diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..32b9468c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +target/ +.git/ +.env +.env.* +*.md +!CLAUDE.md +node_modules/ +tools-src/ diff --git a/.gitignore b/.gitignore index e9846352..0f80f04c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ target/ +# WASM build artifacts (loaded from disk, not bundled) +*.wasm + diff --git a/CLAUDE.md b/CLAUDE.md index 2064847a..3d3bd4a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,8 +11,13 @@ - **Always available** - Multi-channel access with proactive background execution ### Features -- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, Telegram, WhatsApp, Slack (WASM channels) +- **Multi-channel input**: TUI (Ratatui), HTTP webhooks, WASM channels (Telegram, Slack), web gateway - **Parallel job execution** with state machine and self-repair for stuck jobs +- **Sandbox execution**: Docker container isolation with orchestrator/worker pattern +- **Claude Code mode**: Delegate jobs to Claude CLI inside containers +- **Routines**: Scheduled (cron) and reactive (event, webhook) task execution +- **Web gateway**: Browser UI with SSE/WebSocket real-time streaming +- **Extension management**: Install, auth, activate MCP/WASM extensions - **Extensible tools**: Built-in tools, WASM sandbox, MCP client, dynamic builder - **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF) - **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection @@ -59,7 +64,9 @@ src/ │ ├── context_monitor.rs # Memory pressure detection │ ├── undo.rs # Turn-based undo/redo with checkpoints │ ├── submission.rs # Submission parsing (undo, redo, compact, clear, etc.) -│ └── task.rs # Sub-task execution framework +│ ├── task.rs # Sub-task execution framework +│ ├── routine.rs # Routine types (Trigger, Action, Guardrails) +│ └── routine_engine.rs # Routine execution (cron ticker, event matcher) │ ├── channels/ # Multi-channel input │ ├── channel.rs # Channel trait, IncomingMessage, OutgoingResponse @@ -72,8 +79,33 @@ src/ │ │ ├── overlay.rs # Approval overlays │ │ └── composer.rs # Message composition │ ├── http.rs # HTTP webhook (axum) with secret validation -│ ├── slack.rs # Stub -│ └── telegram.rs # Stub +│ ├── repl.rs # Simple REPL (for testing) +│ ├── web/ # Web gateway (browser UI) +│ │ ├── mod.rs # Gateway builder, startup +│ │ ├── server.rs # Axum router, 40+ API endpoints +│ │ ├── sse.rs # SSE broadcast manager +│ │ ├── ws.rs # WebSocket gateway + connection tracking +│ │ ├── types.rs # Request/response types, SseEvent enum +│ │ ├── auth.rs # Bearer token auth middleware +│ │ ├── log_layer.rs # Tracing layer for log streaming +│ │ └── static/ # HTML, CSS, JS (single-page app) +│ └── wasm/ # WASM channel runtime +│ ├── mod.rs +│ ├── bundled.rs # Bundled channel discovery +│ └── wrapper.rs # Channel trait wrapper for WASM modules +│ +├── orchestrator/ # Internal HTTP API for sandbox containers +│ ├── mod.rs +│ ├── api.rs # Axum endpoints (LLM proxy, events, prompts) +│ ├── auth.rs # Per-job bearer token store +│ └── job_manager.rs # Container lifecycle (create, stop, cleanup) +│ +├── worker/ # Runs inside Docker containers +│ ├── mod.rs +│ ├── runtime.rs # Worker execution loop (tool calls, LLM) +│ ├── claude_bridge.rs # Claude Code bridge (spawns claude CLI) +│ ├── api.rs # HTTP client to orchestrator +│ └── proxy_llm.rs # LlmProvider that proxies through orchestrator │ ├── safety/ # Prompt injection defense │ ├── sanitizer.rs # Pattern detection, content escaping @@ -96,6 +128,9 @@ src/ │ │ ├── file.rs # ReadFile, WriteFile, ListDir, ApplyPatch │ │ ├── shell.rs # Shell command execution │ │ ├── memory.rs # Memory tools (search, write, read, tree) +│ │ ├── job.rs # CreateJob, ListJobs, JobStatus, CancelJob +│ │ ├── routine.rs # routine_create/list/update/delete/history +│ │ ├── extension_tools.rs # Extension install/auth/activate/remove │ │ └── marketplace.rs, ecommerce.rs, taskrabbit.rs, restaurant.rs (stubs) │ ├── builder/ # Dynamic tool building │ │ ├── core.rs # BuildRequirement, SoftwareType, Language @@ -236,6 +271,30 @@ HEARTBEAT_ENABLED=true HEARTBEAT_INTERVAL_SECS=1800 # 30 minutes HEARTBEAT_NOTIFY_CHANNEL=tui HEARTBEAT_NOTIFY_USER=default + +# Web gateway +GATEWAY_ENABLED=true +GATEWAY_HOST=127.0.0.1 +GATEWAY_PORT=3001 +GATEWAY_AUTH_TOKEN=changeme # Required for API access +GATEWAY_USER_ID=default + +# Docker sandbox +SANDBOX_ENABLED=true +SANDBOX_IMAGE=ironclaw-worker:latest +SANDBOX_MEMORY_LIMIT_MB=512 +SANDBOX_TIMEOUT_SECS=1800 + +# Claude Code mode (runs inside sandbox containers) +CLAUDE_CODE_ENABLED=false +CLAUDE_CODE_MODEL=claude-sonnet-4-20250514 +CLAUDE_CODE_MAX_TURNS=50 +CLAUDE_CODE_CONFIG_DIR=/home/worker/.claude + +# Routines (scheduled/reactive execution) +ROUTINES_ENABLED=true +ROUTINES_CRON_INTERVAL=60 # Tick interval in seconds +ROUTINES_MAX_CONCURRENT=3 ``` ### NEAR AI Provider @@ -297,13 +356,14 @@ Key test patterns: ## Current Limitations / TODOs -1. **Slack/Telegram channels** - Stubs only, need implementation -2. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations -3. **Integration tests** - Need testcontainers setup for PostgreSQL -4. **MCP stdio transport** - Only HTTP transport implemented -5. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed) -6. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access -7. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools +1. **Domain-specific tools** - `marketplace.rs`, `restaurant.rs`, `taskrabbit.rs`, `ecommerce.rs` return placeholder responses; need real API integrations +2. **Integration tests** - Need testcontainers setup for PostgreSQL +3. **MCP stdio transport** - Only HTTP transport implemented +4. **WIT bindgen integration** - Auto-extract tool description/schema from WASM modules (stubbed) +5. **Capability granting after tool build** - Built tools get empty capabilities; need UX for granting HTTP/secrets access +6. **Tool versioning workflow** - No version tracking or rollback for dynamically built tools +7. **Webhook trigger endpoint** - Routines webhook trigger not yet exposed in web gateway +8. **Full channel status view** - Gateway status widget exists, but no per-channel connection dashboard ### Completed @@ -320,6 +380,13 @@ Key test patterns: - ✅ **Tool approval enforcement** - Tools with `requires_approval()` (shell, http, file write/patch, build_software) now gate execution, track auto-approved tools per session - ✅ **Tool definition refresh** - Tool definitions refreshed each iteration so newly built tools become visible in same session - ✅ **Worker tool call handling** - Uses `respond_with_tools()` to properly execute tool calls when `select_tools()` returns empty +- ✅ **Gateway control plane** - Web gateway with 40+ API endpoints, SSE/WebSocket +- ✅ **Web Control UI** - Browser-based dashboard with chat, memory, jobs, logs, extensions, routines +- ✅ **Slack/Telegram channels** - Implemented as WASM tools +- ✅ **Docker sandbox** - Orchestrator/worker containers with per-job auth +- ✅ **Claude Code mode** - Delegate jobs to Claude CLI inside containers +- ✅ **Routines system** - Cron, event, webhook, and manual triggers with guardrails +- ✅ **Extension management** - Install, auth, activate MCP/WASM extensions via CLI and web UI ## Adding a New Tool diff --git a/Cargo.lock b/Cargo.lock index 85dfeab3..0fe4dc8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -996,6 +996,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "cron" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee8b2b4516038bc0f1d3c9934bcb4a13dd316e04abbc63c96757a6d75978532" +dependencies = [ + "chrono", + "nom", + "once_cell", +] + [[package]] name = "crossbeam" version = "0.8.4" @@ -2189,6 +2200,7 @@ dependencies = [ "bytes", "chrono", "clap", + "cron", "crossterm 0.28.1", "deadpool-postgres", "dirs 6.0.0", @@ -2198,6 +2210,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "mime_guess", "open", "pgvector", "postgres-types", @@ -2495,6 +2508,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimad" version = "0.14.0" @@ -2504,6 +2527,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.1.1" @@ -2550,6 +2579,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4673,6 +4712,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/Cargo.toml b/Cargo.toml index 6fc39003..26a449fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,9 @@ axum = { version = "0.8", features = ["ws"] } tower = "0.5" tower-http = { version = "0.6", features = ["trace", "cors"] } +# Cron scheduling for routines +cron = "0.13" + # Safety/sanitization regex = "1" aho-corasick = "1" @@ -99,6 +102,7 @@ hyper-util = { version = "0.1", features = ["server", "tokio", "http1", "http2"] http-body-util = "0.1" bytes = "1" base64 = "0.22.1" +mime_guess = "2.0.5" # macOS keychain [target.'cfg(target_os = "macos")'.dependencies] diff --git a/Dockerfile.worker b/Dockerfile.worker new file mode 100644 index 00000000..3909abaa --- /dev/null +++ b/Dockerfile.worker @@ -0,0 +1,63 @@ +# Multi-stage Dockerfile for the IronClaw worker container. +# +# This image runs the ironclaw binary in worker mode inside Docker containers. +# The orchestrator creates instances of this image for sandboxed job execution. +# +# Build: +# docker build -f Dockerfile.worker -t ironclaw-worker . +# +# The image includes common development tools so workers can build software, +# run tests, and execute shell commands. + +FROM rust:1.85-bookworm AS builder + +WORKDIR /build +COPY . . + +# Build only the ironclaw binary (release mode) +RUN cargo build --release --bin ironclaw + +# --- + +FROM debian:bookworm-slim + +# Install common development tools +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + build-essential \ + pkg-config \ + libssl-dev \ + nodejs \ + npm \ + python3 \ + python3-pip \ + python3-venv \ + && rm -rf /var/lib/apt/lists/* + +# Install Rust toolchain for the sandbox user +ENV RUSTUP_HOME=/usr/local/rustup \ + CARGO_HOME=/usr/local/cargo \ + PATH=/usr/local/cargo/bin:$PATH +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.85.0 \ + && chmod -R a+r /usr/local/rustup /usr/local/cargo + +# Install Claude Code CLI (for claude-bridge mode) +RUN npm install -g @anthropic-ai/claude-code@latest + +# Copy the binary +COPY --from=builder /build/target/release/ironclaw /usr/local/bin/ironclaw + +# Create non-root user (UID 1000 matches the orchestrator's container config) +RUN useradd -m -u 1000 -s /bin/bash sandbox \ + && mkdir -p /workspace \ + && chown sandbox:sandbox /workspace \ + && mkdir -p /home/sandbox/.claude \ + && chown sandbox:sandbox /home/sandbox/.claude + +USER sandbox +WORKDIR /workspace + +# The orchestrator passes the full command via Docker cmd. +ENTRYPOINT ["ironclaw"] diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 6f791052..ce126dca 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -16,8 +16,8 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Feature | OpenClaw | IronClaw | Notes | |---------|----------|----------|-------| -| Hub-and-spoke architecture | ✅ | 🚧 | IronClaw has channels but no central gateway | -| WebSocket control plane | ✅ | ❌ | Gateway with ws://127.0.0.1:18789 | +| Hub-and-spoke architecture | ✅ | ✅ | Web gateway as central hub | +| WebSocket control plane | ✅ | ✅ | Gateway with WebSocket + SSE | | Single-user system | ✅ | ✅ | | | Multi-agent routing | ✅ | ❌ | Workspace isolation per-agent | | Session-based messaging | ✅ | ✅ | Per-sender sessions | @@ -31,9 +31,9 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Feature | OpenClaw | IronClaw | Notes | |---------|----------|----------|-------| -| Gateway control plane | ✅ | ❌ | Central WebSocket server | -| HTTP endpoints for Control UI | ✅ | ❌ | Web dashboard | -| Channel connection lifecycle | ✅ | 🚧 | ChannelManager handles streams | +| Gateway control plane | ✅ | ✅ | Web gateway with 40+ API endpoints | +| HTTP endpoints for Control UI | ✅ | ✅ | Web dashboard with chat, memory, jobs, logs, extensions | +| Channel connection lifecycle | ✅ | ✅ | ChannelManager + WebSocket tracker | | Session management/routing | ✅ | ✅ | SessionManager exists | | Configuration hot-reload | ✅ | ❌ | | | Network modes (loopback/LAN/remote) | ✅ | 🚧 | HTTP only | @@ -43,7 +43,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | launchd/systemd integration | ✅ | ❌ | | | Bonjour/mDNS discovery | ✅ | ❌ | | | Tailscale integration | ✅ | ❌ | | -| Health check endpoints | ✅ | ❌ | | +| Health check endpoints | ✅ | ✅ | /api/health + /api/gateway/status | | `doctor` diagnostics | ✅ | ❌ | | ### Owner: _Unassigned_ @@ -59,14 +59,14 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | REPL (simple) | ✅ | ✅ | - | For testing | | WASM channels | ❌ | ✅ | - | IronClaw innovation | | WhatsApp | ✅ | ❌ | P1 | Baileys (Web) | -| Telegram | ✅ | ❌ | P1 | grammY (Bot API) | +| Telegram | ✅ | ✅ | - | WASM tool (MTProto) | | Discord | ✅ | ❌ | P2 | discord.js | | Signal | ✅ | ❌ | P2 | signal-cli | -| Slack | ✅ | 🚧 | P1 | Stub exists, needs implementation | +| Slack | ✅ | ✅ | - | WASM tool | | iMessage | ✅ | ❌ | P3 | BlueBubbles recommended | | Feishu/Lark | ✅ | ❌ | P3 | | | LINE | ✅ | ❌ | P3 | | -| WebChat | ✅ | ❌ | P2 | Browser-based chat | +| WebChat | ✅ | ✅ | - | Web gateway chat | | Matrix | ✅ | ❌ | P3 | E2EE support | | Mattermost | ✅ | ❌ | P3 | | | Google Chat | ✅ | ❌ | P3 | | @@ -99,15 +99,15 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | `run` (agent) | ✅ | ✅ | - | Default command | | `tool install/list/remove` | ✅ | ✅ | - | WASM tools | | `gateway start/stop` | ✅ | ❌ | P2 | | -| `onboard` (wizard) | ✅ | ❌ | P2 | Interactive setup | +| `onboard` (wizard) | ✅ | ✅ | - | Interactive setup | | `tui` | ✅ | ✅ | - | Ratatui TUI | -| `config` | ✅ | ❌ | P2 | Read/write config | +| `config` | ✅ | ✅ | - | Read/write config | | `channels` | ✅ | ❌ | P2 | Channel management | | `models` | ✅ | 🚧 | - | Model selector in TUI | -| `status` | ✅ | ❌ | P2 | System status | +| `status` | ✅ | ✅ | - | System status | | `agents` | ✅ | ❌ | P3 | Multi-agent management | | `sessions` | ✅ | ❌ | P3 | Session listing | -| `memory` | ✅ | ❌ | P2 | Memory search CLI | +| `memory` | ✅ | ✅ | - | Memory search CLI | | `skills` | ✅ | ❌ | P3 | Agent skills | | `pairing` | ✅ | ❌ | P3 | Node pairing | | `nodes` | ✅ | ❌ | P3 | Device management | @@ -132,7 +132,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Feature | OpenClaw | IronClaw | Notes | |---------|----------|----------|-------| | Pi agent runtime | ✅ | ➖ | IronClaw uses custom runtime | -| RPC-based execution | ✅ | 🚧 | Worker isolation | +| RPC-based execution | ✅ | ✅ | Orchestrator/worker pattern | | Multi-provider failover | ✅ | ❌ | Provider fallback chains | | Per-sender sessions | ✅ | ✅ | | | Global sessions | ✅ | ❌ | Optional shared context | @@ -303,13 +303,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Feature | OpenClaw | IronClaw | Priority | Notes | |---------|----------|----------|----------|-------| -| Control UI Dashboard | ✅ | ❌ | P2 | Web status/config | -| Channel status view | ✅ | ❌ | P2 | | +| Control UI Dashboard | ✅ | ✅ | - | Web gateway with chat, memory, jobs, logs, extensions | +| Channel status view | ✅ | 🚧 | P2 | Gateway status widget, full channel view pending | | Agent management | ✅ | ❌ | P3 | | | Model selection | ✅ | ✅ | - | TUI only | | Config editing | ✅ | ❌ | P3 | | -| Debug/logs viewer | ✅ | ❌ | P3 | | -| WebChat interface | ✅ | ❌ | P2 | Browser chat | +| Debug/logs viewer | ✅ | ✅ | - | Real-time log streaming with level/target filters | +| WebChat interface | ✅ | ✅ | - | Web gateway chat with SSE/WebSocket | | Canvas system (A2UI) | ✅ | ❌ | P3 | Agent-driven UI | ### Owner: _Unassigned_ @@ -320,13 +320,13 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Feature | OpenClaw | IronClaw | Priority | Notes | |---------|----------|----------|----------|-------| -| Cron jobs | ✅ | ❌ | P2 | Schedule-based tasks | -| Timezone support | ✅ | ❌ | P2 | | -| One-shot/recurring jobs | ✅ | ❌ | P2 | | +| Cron jobs | ✅ | ✅ | - | Routines with cron trigger | +| Timezone support | ✅ | ✅ | - | Via cron expressions | +| One-shot/recurring jobs | ✅ | ✅ | - | Manual + cron triggers | | `beforeInbound` hook | ✅ | ❌ | P2 | | | `beforeOutbound` hook | ✅ | ❌ | P2 | | | `beforeToolCall` hook | ✅ | ❌ | P2 | | -| `onMessage` hook | ✅ | ❌ | P2 | | +| `onMessage` hook | ✅ | ✅ | - | Routines with event trigger | | `onSessionStart` hook | ✅ | ❌ | P2 | | | `onSessionEnd` hook | ✅ | ❌ | P2 | | | `transcribeAudio` hook | ✅ | ❌ | P3 | | @@ -346,7 +346,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Feature | OpenClaw | IronClaw | Notes | |---------|----------|----------|-------| -| Gateway token auth | ✅ | 🚧 | HTTP webhook secret | +| Gateway token auth | ✅ | ✅ | Bearer token auth on web gateway | | Device pairing | ✅ | ❌ | | | Tailscale identity | ✅ | ❌ | | | OAuth flows | ✅ | 🚧 | NEAR AI OAuth | @@ -357,7 +357,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | TLS 1.3 minimum | ✅ | ✅ | reqwest rustls | | SSRF protection | ✅ | ✅ | WASM allowlist | | Loopback-first | ✅ | 🚧 | HTTP binds 0.0.0.0 | -| Docker sandbox | ✅ | ❌ | Uses WASM sandbox | +| Docker sandbox | ✅ | ✅ | Orchestrator/worker containers | | WASM sandbox | ❌ | ✅ | IronClaw innovation | | Tool policies | ✅ | ✅ | | | Elevated mode | ✅ | ❌ | | @@ -404,23 +404,26 @@ This document tracks feature parity between IronClaw (Rust implementation) and O - ✅ Session management - ✅ Context compaction - ✅ Model selection +- ✅ Gateway control plane + WebSocket +- ✅ Web Control UI (chat, memory, jobs, logs, extensions, routines) +- ✅ WebChat channel (web gateway) +- ✅ Slack channel (WASM tool) +- ✅ Telegram channel (WASM tool, MTProto) +- ✅ Docker sandbox (orchestrator/worker) +- ✅ Cron job scheduling (routines) +- ✅ CLI subcommands (onboard, config, status, memory) +- ✅ Gateway token auth ### P1 - High Priority -- ❌ Slack channel (real implementation) -- ❌ Telegram channel - ❌ WhatsApp channel - ❌ Multi-provider failover -- ❌ Gateway control plane + WebSocket - ❌ Hooks system (beforeInbound, beforeToolCall, etc.) ### P2 - Medium Priority -- ❌ Cron job scheduling -- ❌ Web Control UI -- ❌ WebChat channel - ❌ Media handling (images, PDFs) -- ❌ CLI subcommands (config, status, memory, doctor) - ❌ Ollama/local model support - ❌ Configuration hot-reload +- ❌ Webhook trigger endpoint in web gateway ### P3 - Lower Priority - ❌ Discord channel diff --git a/README.md b/README.md index 194b9e15..80c16302 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,10 @@ IronClaw is the AI assistant you can actually trust with your personal and profe ### Always Available -- **Multi-channel** - REPL, HTTP webhooks, and extensible WASM channels (Telegram, Slack, and more) +- **Multi-channel** - REPL, HTTP webhooks, WASM channels (Telegram, Slack), and web gateway +- **Docker Sandbox** - Isolated container execution with per-job tokens and orchestrator/worker pattern +- **Web Gateway** - Browser UI with real-time SSE/WebSocket streaming +- **Routines** - Cron schedules, event triggers, webhook handlers for background automation - **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks - **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts - **Self-repair** - Automatic detection and recovery of stuck operations @@ -143,37 +146,42 @@ External content passes through multiple security layers: ## Architecture ``` -┌─────────────────────────────────────────────────────────────────┐ -│ Channels │ -│ ┌──────┐ ┌──────┐ ┌──────────────┐ │ -│ │ REPL │ │ HTTP │ │ WASM Channels│ │ -│ └──┬───┘ └──┬───┘ └──────┬───────┘ │ -│ └─────────┴─────────────┘ │ -│ │ │ -│ ┌────▼────┐ │ -│ │ Router │ Intent classification │ -│ └────┬────┘ │ -│ │ │ -│ ┌──────────▼──────────┐ │ -│ │ Scheduler │ Parallel job management │ -│ └──────────┬──────────┘ │ -│ │ │ -│ ┌───────────────┼───────────────┐ │ -│ ▼ ▼ ▼ │ -│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ -│ │ Worker │ │ Worker │ │ Worker │ LLM reasoning │ -│ └────┬────┘ └────┬────┘ └────┬────┘ │ -│ └───────────────┼───────────────┘ │ -│ │ │ -│ ┌──────────▼──────────┐ │ -│ │ Tool Registry │ │ -│ │ ┌───────────────┐ │ │ -│ │ │ Built-in │ │ │ -│ │ │ MCP │ │ │ -│ │ │ WASM Sandbox │ │ │ -│ │ └───────────────┘ │ │ -│ └─────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ +┌────────────────────────────────────────────────────────────────────┐ +│ Channels │ +│ ┌──────┐ ┌──────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ REPL │ │ HTTP │ │WASM Channels│ │ Web Gateway │ │ +│ └──┬───┘ └──┬───┘ └──────┬──────┘ │ (SSE + WS) │ │ +│ │ │ │ └──────┬──────┘ │ +│ └─────────┴──────────────┴────────────────┘ │ +│ │ │ +│ ┌─────────▼─────────┐ │ +│ │ Agent Loop │ Intent routing │ +│ └────┬─────────┬────┘ │ +│ │ │ │ +│ ┌──────────▼───┐ ┌──▼──────────────┐ │ +│ │ Scheduler │ │ Routines Engine │ │ +│ │(parallel jobs)│ │(cron, event, wh) │ │ +│ └──────┬───────┘ └────────┬─────────┘ │ +│ │ │ │ +│ ┌─────────────┼───────────────────┘ │ +│ │ │ │ +│ ┌───▼────┐ ┌────▼────────────────┐ │ +│ │ Local │ │ Orchestrator │ │ +│ │Workers │ │ ┌───────────────┐ │ │ +│ │(in-proc)│ │ │ Docker Sandbox│ │ │ +│ └───┬────┘ │ │ Containers │ │ │ +│ │ │ │ ┌───────────┐ │ │ │ +│ │ │ │ │Worker / CC│ │ │ │ +│ │ │ │ └───────────┘ │ │ │ +│ │ │ └───────────────┘ │ │ +│ │ └─────────┬───────────┘ │ +│ └──────────────────┤ │ +│ │ │ +│ ┌───────────▼──────────┐ │ +│ │ Tool Registry │ │ +│ │ Built-in, MCP, WASM │ │ +│ └──────────────────────┘ │ +└────────────────────────────────────────────────────────────────────┘ ``` ### Core Components @@ -184,6 +192,9 @@ External content passes through multiple security layers: | **Router** | Classifies user intent (command, query, task) | | **Scheduler** | Manages parallel job execution with priorities | | **Worker** | Executes jobs with LLM reasoning and tool calls | +| **Orchestrator** | Container lifecycle, LLM proxying, per-job auth | +| **Web Gateway** | Browser UI with chat, memory, jobs, logs, extensions, routines | +| **Routines Engine** | Scheduled (cron) and reactive (event, webhook) background tasks | | **Workspace** | Persistent memory with hybrid search | | **Safety Layer** | Prompt injection defense and content sanitization | diff --git a/channels-src/slack/src/lib.rs b/channels-src/slack/src/lib.rs index c54af12f..5cf7b10d 100644 --- a/channels-src/slack/src/lib.rs +++ b/channels-src/slack/src/lib.rs @@ -108,7 +108,10 @@ struct SlackPostMessageResponse { #[derive(Debug, Deserialize)] struct SlackConfig { /// Name of secret containing signing secret (for verification by host). + /// Parsed from config for forward compatibility; not yet used in WASM + /// (host handles signature verification). #[serde(default = "default_signing_secret_name")] + #[allow(dead_code)] signing_secret_name: String, } @@ -175,11 +178,7 @@ impl Guest for SlackChannel { // Actual event callback "event_callback" => { if let Some(event) = event_wrapper.event { - handle_slack_event( - event, - event_wrapper.team_id, - event_wrapper.event_id, - ); + handle_slack_event(event, event_wrapper.team_id, event_wrapper.event_id); } // Always respond 200 quickly to Slack (they have a 3s timeout) json_response(200, serde_json::json!({"ok": true})) @@ -230,6 +229,7 @@ impl Guest for SlackChannel { "https://slack.com/api/chat.postMessage", &headers.to_string(), Some(&payload_bytes), + None, ); match result { @@ -243,14 +243,15 @@ impl Guest for SlackChannel { // Parse Slack response let slack_response: SlackPostMessageResponse = - serde_json::from_slice(&http_response.body).map_err(|e| { - format!("Failed to parse Slack response: {}", e) - })?; + serde_json::from_slice(&http_response.body) + .map_err(|e| format!("Failed to parse Slack response: {}", e))?; if !slack_response.ok { return Err(format!( "Slack API error: {}", - slack_response.error.unwrap_or_else(|| "unknown".to_string()) + slack_response + .error + .unwrap_or_else(|| "unknown".to_string()) )); } @@ -277,17 +278,16 @@ impl Guest for SlackChannel { } /// Handle a Slack event and emit message if applicable. -fn handle_slack_event( - event: SlackEvent, - team_id: Option, - _event_id: Option, -) { +fn handle_slack_event(event: SlackEvent, team_id: Option, _event_id: Option) { match event.event_type.as_str() { // Direct mention of the bot "app_mention" => { - if let (Some(user), Some(channel), Some(text), Some(ts)) = - (event.user, event.channel.clone(), event.text, event.ts.clone()) - { + if let (Some(user), Some(channel), Some(text), Some(ts)) = ( + event.user, + event.channel.clone(), + event.text, + event.ts.clone(), + ) { emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id); } } @@ -299,9 +299,12 @@ fn handle_slack_event( return; } - if let (Some(user), Some(channel), Some(text), Some(ts)) = - (event.user, event.channel.clone(), event.text, event.ts.clone()) - { + if let (Some(user), Some(channel), Some(text), Some(ts)) = ( + event.user, + event.channel.clone(), + event.text, + event.ts.clone(), + ) { // Only process DMs (channel IDs starting with D) if channel.starts_with('D') { emit_message(user, text, channel, event.thread_ts.or(Some(ts)), team_id); @@ -335,8 +338,7 @@ fn emit_message( team_id, }; - let metadata_json = - serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); + let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); // Strip @ mentions of the bot from the text for cleaner messages let cleaned_text = strip_bot_mention(&text); diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index 984385bc..5a1591bc 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -388,7 +388,9 @@ impl Guest for TelegramChannel { let headers = serde_json::json!({}); - let result = channel_host::http_request("GET", &url, &headers.to_string(), None); + // 35s HTTP timeout outlives Telegram's 30s server-side long-poll + let result = + channel_host::http_request("GET", &url, &headers.to_string(), None, Some(35_000)); match result { Ok(response) => { @@ -461,72 +463,52 @@ impl Guest for TelegramChannel { } fn on_respond(response: AgentResponse) -> Result<(), String> { - // Parse metadata to get chat info let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json) .map_err(|e| format!("Failed to parse metadata: {}", e))?; - // Build sendMessage payload - let mut payload = serde_json::json!({ - "chat_id": metadata.chat_id, - "text": response.content, - "parse_mode": "Markdown", - }); - - // Reply to the original message for context - payload["reply_to_message_id"] = serde_json::Value::Number(metadata.message_id.into()); - - let payload_bytes = serde_json::to_vec(&payload) - .map_err(|e| format!("Failed to serialize payload: {}", e))?; - - // Make HTTP request to Telegram API - // The bot token is injected into the URL by the host - let headers = serde_json::json!({ - "Content-Type": "application/json" - }); - - let result = channel_host::http_request( - "POST", - "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", - &headers.to_string(), - Some(&payload_bytes), + // Try sending with Markdown first; fall back to plain text if Telegram + // can't parse the entities (e.g. model leaked with underscores). + let result = send_message( + metadata.chat_id, + &response.content, + metadata.message_id, + Some("Markdown"), ); match result { - Ok(http_response) => { - if http_response.status != 200 { - let body_str = String::from_utf8_lossy(&http_response.body); - return Err(format!( - "Telegram API returned status {}: {}", - http_response.status, body_str - )); - } - - // Parse Telegram response - let api_response: TelegramApiResponse = - serde_json::from_slice(&http_response.body) - .map_err(|e| format!("Failed to parse Telegram response: {}", e))?; - - if !api_response.ok { - return Err(format!( - "Telegram API error: {}", - api_response - .description - .unwrap_or_else(|| "unknown".to_string()) - )); - } - + Ok(msg_id) => { channel_host::log( channel_host::LogLevel::Debug, &format!( "Sent message to chat {}: message_id={}", - metadata.chat_id, - api_response.result.map(|r| r.message_id).unwrap_or(0) + metadata.chat_id, msg_id ), ); - Ok(()) } - Err(e) => Err(format!("HTTP request failed: {}", e)), + Err(SendError::ParseEntities(detail)) => { + channel_host::log( + channel_host::LogLevel::Warn, + &format!("Markdown parse failed ({}), retrying as plain text", detail), + ); + let msg_id = send_message( + metadata.chat_id, + &response.content, + metadata.message_id, + None, + ) + .map_err(|e| format!("Plain-text retry also failed: {}", e))?; + + channel_host::log( + channel_host::LogLevel::Debug, + &format!( + "Sent plain-text message to chat {}: message_id={}", + metadata.chat_id, msg_id + ), + ); + Ok(()) + } + Err(e) => Err(e.to_string()), } } @@ -568,6 +550,7 @@ impl Guest for TelegramChannel { "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendChatAction", &headers.to_string(), Some(&payload_bytes), + None, ); if let Err(e) = result { @@ -586,6 +569,101 @@ impl Guest for TelegramChannel { } } +// ============================================================================ +// Send Message Helper +// ============================================================================ + +/// Errors from send_message, split so callers can match on parse-entity failures. +enum SendError { + /// Telegram returned 400 with "can't parse entities" (Markdown issue). + ParseEntities(String), + /// Any other failure. + Other(String), +} + +impl std::fmt::Display for SendError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SendError::ParseEntities(detail) => write!(f, "parse entities error: {}", detail), + SendError::Other(msg) => write!(f, "{}", msg), + } + } +} + +/// Send a message via the Telegram Bot API. +/// +/// Returns the sent message_id on success. When `parse_mode` is set and +/// Telegram returns a 400 "can't parse entities" error, returns +/// `SendError::ParseEntities` so the caller can retry without formatting. +fn send_message( + chat_id: i64, + text: &str, + reply_to_message_id: i64, + parse_mode: Option<&str>, +) -> Result { + let mut payload = serde_json::json!({ + "chat_id": chat_id, + "text": text, + "reply_to_message_id": reply_to_message_id, + }); + + if let Some(mode) = parse_mode { + payload["parse_mode"] = serde_json::Value::String(mode.to_string()); + } + + let payload_bytes = serde_json::to_vec(&payload) + .map_err(|e| SendError::Other(format!("Failed to serialize payload: {}", e)))?; + + let headers = serde_json::json!({ "Content-Type": "application/json" }); + + let result = channel_host::http_request( + "POST", + "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", + &headers.to_string(), + Some(&payload_bytes), + None, + ); + + match result { + Ok(http_response) => { + if http_response.status == 400 { + let body_str = String::from_utf8_lossy(&http_response.body); + if body_str.contains("can't parse entities") { + return Err(SendError::ParseEntities(body_str.to_string())); + } + return Err(SendError::Other(format!( + "Telegram API returned 400: {}", + body_str + ))); + } + + if http_response.status != 200 { + let body_str = String::from_utf8_lossy(&http_response.body); + return Err(SendError::Other(format!( + "Telegram API returned status {}: {}", + http_response.status, body_str + ))); + } + + let api_response: TelegramApiResponse = + serde_json::from_slice(&http_response.body) + .map_err(|e| SendError::Other(format!("Failed to parse response: {}", e)))?; + + if !api_response.ok { + return Err(SendError::Other(format!( + "Telegram API error: {}", + api_response + .description + .unwrap_or_else(|| "unknown".to_string()) + ))); + } + + Ok(api_response.result.map(|r| r.message_id).unwrap_or(0)) + } + Err(e) => Err(SendError::Other(format!("HTTP request failed: {}", e))), + } +} + // ============================================================================ // Webhook Management // ============================================================================ @@ -604,6 +682,7 @@ fn delete_webhook() -> Result<(), String> { "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/deleteWebhook", &headers.to_string(), None, + None, ); match result { @@ -666,6 +745,7 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<() "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/setWebhook", &headers.to_string(), Some(&body_bytes), + None, ); match result { diff --git a/channels-src/telegram/telegram.capabilities.json b/channels-src/telegram/telegram.capabilities.json index 70f56e01..f9335ad0 100644 --- a/channels-src/telegram/telegram.capabilities.json +++ b/channels-src/telegram/telegram.capabilities.json @@ -26,6 +26,7 @@ "allowed_paths": ["/webhook/telegram"], "allow_polling": true, "min_poll_interval_ms": 30000, + "callback_timeout_secs": 45, "workspace_prefix": "channels/telegram/", "emit_rate_limit": { "messages_per_minute": 100, diff --git a/channels-src/telegram/telegram.wasm b/channels-src/telegram/telegram.wasm deleted file mode 100644 index f739b982..00000000 Binary files a/channels-src/telegram/telegram.wasm and /dev/null differ diff --git a/channels-src/whatsapp/src/lib.rs b/channels-src/whatsapp/src/lib.rs index e28340b8..27d79e2c 100644 --- a/channels-src/whatsapp/src/lib.rs +++ b/channels-src/whatsapp/src/lib.rs @@ -361,6 +361,7 @@ impl Guest for WhatsAppChannel { &api_url, &headers.to_string(), Some(&payload_bytes), + None, ); match result { diff --git a/examples/test_heartbeat.rs b/examples/test_heartbeat.rs new file mode 100644 index 00000000..c62d28c3 --- /dev/null +++ b/examples/test_heartbeat.rs @@ -0,0 +1,120 @@ +//! Standalone heartbeat test. +//! +//! Exercises the heartbeat system in isolation: connects to the real +//! database, reads the real HEARTBEAT.md, calls the real LLM, and prints +//! every step so you can see exactly where it breaks. +//! +//! Usage: +//! cargo run --example test_heartbeat + +use std::sync::Arc; + +use ironclaw::{ + agent::HeartbeatRunner, + config::Config, + history::Store, + llm::{SessionConfig, create_llm_provider, create_session_manager}, + workspace::Workspace, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Load .env and set up logging + let _ = dotenvy::dotenv(); + tracing_subscriber::fmt() + .with_env_filter("ironclaw=debug") + .init(); + + println!("=== Heartbeat Integration Test ===\n"); + + // 1. Load config + let config = Config::from_env().map_err(|e| anyhow::anyhow!("Config: {}", e))?; + println!("[1/6] Config loaded"); + println!(" heartbeat.enabled = {}", config.heartbeat.enabled); + println!( + " heartbeat.interval_secs = {}", + config.heartbeat.interval_secs + ); + println!( + " heartbeat.notify_channel = {:?}", + config.heartbeat.notify_channel + ); + println!( + " heartbeat.notify_user = {:?}", + config.heartbeat.notify_user + ); + + // 2. Connect to database + let store = Store::new(&config.database).await?; + store.run_migrations().await?; + println!("[2/6] Database connected"); + + // 3. Create workspace + let workspace = Arc::new(Workspace::new("default", store.pool())); + println!("[3/6] Workspace created"); + + // 4. Read HEARTBEAT.md + let checklist = workspace.heartbeat_checklist().await; + match &checklist { + Ok(Some(content)) => { + let preview: String = content.chars().take(200).collect(); + println!("[4/6] HEARTBEAT.md found ({} chars)", content.len()); + println!(" Preview: {}...", preview); + } + Ok(None) => { + println!("[4/6] HEARTBEAT.md is None (no file, no seed fallback)"); + println!(" Heartbeat will return Skipped."); + } + Err(e) => { + println!("[4/6] HEARTBEAT.md read error: {}", e); + } + } + + // Check if the checklist would be considered "effectively empty" + if let Ok(Some(_)) = checklist { + println!(" (Will verify via runner below)"); + } + + // 5. Create LLM provider + let session = create_session_manager(SessionConfig { + auth_base_url: config.llm.nearai.auth_base_url.clone(), + session_path: config.llm.nearai.session_path.clone(), + ..Default::default() + }) + .await; + let llm = create_llm_provider(&config.llm, session)?; + println!("[5/6] LLM provider created (model: {})", llm.model_name()); + + // 6. Run heartbeat check + println!("[6/6] Running check_heartbeat()...\n"); + + let hb_config = ironclaw::agent::HeartbeatConfig::default(); + let runner = HeartbeatRunner::new(hb_config, workspace, llm); + + let result = runner.check_heartbeat().await; + + println!("=== Result ===\n"); + match &result { + ironclaw::agent::HeartbeatResult::Ok => { + println!("HeartbeatResult::Ok"); + println!(" LLM responded HEARTBEAT_OK, nothing needs attention."); + } + ironclaw::agent::HeartbeatResult::NeedsAttention(msg) => { + println!("HeartbeatResult::NeedsAttention"); + println!(" Message:\n{}", msg); + } + ironclaw::agent::HeartbeatResult::Skipped => { + println!("HeartbeatResult::Skipped"); + println!(" No checklist found, or checklist was effectively empty."); + println!(" This means the HEARTBEAT.md either:"); + println!(" - Does not exist in the workspace database"); + println!(" - Contains only headers, comments, and empty checkboxes"); + } + ironclaw::agent::HeartbeatResult::Failed(err) => { + println!("HeartbeatResult::Failed"); + println!(" Error: {}", err); + } + } + + Ok(()) +} diff --git a/migrations/V4__sandbox_columns.sql b/migrations/V4__sandbox_columns.sql new file mode 100644 index 00000000..7510e847 --- /dev/null +++ b/migrations/V4__sandbox_columns.sql @@ -0,0 +1,10 @@ +-- Add project_dir and user_id columns for sandbox job tracking. +-- user_id was previously hardcoded to "default" in the Rust layer; +-- now it's persisted so we can filter per-user. + +ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS project_dir TEXT; +ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS user_id TEXT NOT NULL DEFAULT 'default'; + +CREATE INDEX IF NOT EXISTS idx_agent_jobs_source ON agent_jobs(source); +CREATE INDEX IF NOT EXISTS idx_agent_jobs_user ON agent_jobs(user_id); +CREATE INDEX IF NOT EXISTS idx_agent_jobs_created ON agent_jobs(created_at DESC); diff --git a/migrations/V5__claude_code.sql b/migrations/V5__claude_code.sql new file mode 100644 index 00000000..f0a20426 --- /dev/null +++ b/migrations/V5__claude_code.sql @@ -0,0 +1,14 @@ +-- Track which mode a sandbox job uses (worker vs claude_code). +ALTER TABLE agent_jobs ADD COLUMN IF NOT EXISTS job_mode TEXT NOT NULL DEFAULT 'worker'; + +-- Persist Claude Code streaming events so they survive restarts and can be +-- loaded when the frontend opens a job detail view after the fact. +CREATE TABLE IF NOT EXISTS claude_code_events ( + id BIGSERIAL PRIMARY KEY, + job_id UUID NOT NULL REFERENCES agent_jobs(id), + event_type TEXT NOT NULL, + data JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_cc_events_job ON claude_code_events(job_id, id); diff --git a/migrations/V6__routines.sql b/migrations/V6__routines.sql new file mode 100644 index 00000000..36f63cb2 --- /dev/null +++ b/migrations/V6__routines.sql @@ -0,0 +1,73 @@ +-- Routines: scheduled and reactive job system. +-- +-- A routine is a named, persistent, user-owned task with a trigger and an action. +-- Triggers fire independently (cron, event, webhook, manual) so only the +-- relevant routine's prompt hits the LLM, not the whole checklist. + +CREATE TABLE routines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + user_id TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT true, + + -- Trigger definition + trigger_type TEXT NOT NULL, -- 'cron', 'event', 'webhook', 'manual' + trigger_config JSONB NOT NULL, -- type-specific config (schedule, pattern, etc.) + + -- Action definition + action_type TEXT NOT NULL, -- 'lightweight', 'full_job' + action_config JSONB NOT NULL, -- prompt, context_paths, max_tokens / title, max_iterations + + -- Guardrails + cooldown_secs INTEGER NOT NULL DEFAULT 300, + max_concurrent INTEGER NOT NULL DEFAULT 1, + dedup_window_secs INTEGER, -- NULL = no dedup + + -- Notification preferences + notify_channel TEXT, -- NULL = use default + notify_user TEXT NOT NULL DEFAULT 'default', + notify_on_success BOOLEAN NOT NULL DEFAULT false, + notify_on_failure BOOLEAN NOT NULL DEFAULT true, + notify_on_attention BOOLEAN NOT NULL DEFAULT true, + + -- Runtime state (updated by engine) + state JSONB NOT NULL DEFAULT '{}', + last_run_at TIMESTAMPTZ, + next_fire_at TIMESTAMPTZ, -- pre-computed for cron triggers + run_count BIGINT NOT NULL DEFAULT 0, + consecutive_failures INTEGER NOT NULL DEFAULT 0, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + UNIQUE (user_id, name) +); + +-- Fast lookup: "which cron routines need to fire right now?" +CREATE INDEX idx_routines_next_fire + ON routines (next_fire_at) + WHERE enabled AND next_fire_at IS NOT NULL; + +-- Fast lookup: event triggers for a user +CREATE INDEX idx_routines_event_triggers + ON routines (user_id) + WHERE enabled AND trigger_type = 'event'; + +-- Audit log of individual routine executions. +CREATE TABLE routine_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + routine_id UUID NOT NULL REFERENCES routines(id) ON DELETE CASCADE, + trigger_type TEXT NOT NULL, + trigger_detail TEXT, -- e.g. matched message preview, cron expression + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'running', -- running, ok, attention, failed + result_summary TEXT, + tokens_used INTEGER, + job_id UUID REFERENCES agent_jobs(id), -- non-NULL for full_job runs + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_routine_runs_routine ON routine_runs (routine_id); +CREATE INDEX idx_routine_runs_status ON routine_runs (status) WHERE status = 'running'; diff --git a/migrations/V7__rename_events.sql b/migrations/V7__rename_events.sql new file mode 100644 index 00000000..3676fbf7 --- /dev/null +++ b/migrations/V7__rename_events.sql @@ -0,0 +1,3 @@ +-- Rename claude_code_events to job_events (generic for all sandbox job types). +ALTER TABLE claude_code_events RENAME TO job_events; +ALTER INDEX idx_cc_events_job RENAME TO idx_job_events_job; diff --git a/migrations/V8__settings.sql b/migrations/V8__settings.sql new file mode 100644 index 00000000..515b0a74 --- /dev/null +++ b/migrations/V8__settings.sql @@ -0,0 +1,16 @@ +-- Settings table: key-value store for all user configuration. +-- +-- Replaces ~/.ironclaw/settings.json, session.json, and mcp-servers.json. +-- Keys use dotted paths matching the existing Settings.get()/set() convention +-- (e.g., "agent.name", "sandbox.enabled", "mcp_servers"). +-- One row per setting so individual values can be updated atomically. + +CREATE TABLE IF NOT EXISTS settings ( + user_id TEXT NOT NULL, + key TEXT NOT NULL, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, key) +); + +CREATE INDEX IF NOT EXISTS idx_settings_user ON settings (user_id); diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 76cefdf9..462535e9 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -9,13 +9,14 @@ use uuid::Uuid; use crate::agent::compaction::ContextCompactor; use crate::agent::context_monitor::ContextMonitor; use crate::agent::heartbeat::spawn_heartbeat; +use crate::agent::routine_engine::{RoutineEngine, spawn_cron_ticker}; use crate::agent::self_repair::{DefaultSelfRepair, RepairResult, SelfRepair}; use crate::agent::session::{PendingApproval, Session, ThreadState}; use crate::agent::session_manager::SessionManager; use crate::agent::submission::{Submission, SubmissionParser, SubmissionResult}; use crate::agent::{HeartbeatConfig as AgentHeartbeatConfig, MessageIntent, Router, Scheduler}; use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse, StatusUpdate}; -use crate::config::{AgentConfig, HeartbeatConfig}; +use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig}; use crate::context::ContextManager; use crate::context::JobContext; use crate::error::Error; @@ -77,6 +78,7 @@ pub struct Agent { session_manager: Arc, context_monitor: ContextMonitor, heartbeat_config: Option, + routine_config: Option, } impl Agent { @@ -89,6 +91,7 @@ impl Agent { deps: AgentDeps, channels: ChannelManager, heartbeat_config: Option, + routine_config: Option, context_manager: Option>, session_manager: Option>, ) -> Self { @@ -116,6 +119,7 @@ impl Agent { session_manager, context_monitor: ContextMonitor::new(), heartbeat_config, + routine_config, } } @@ -256,53 +260,28 @@ impl Agent { let channels = self.channels.clone(); tokio::spawn(async move { while let Some(response) = notify_rx.recv().await { - // Route notification to configured channel/user, or broadcast to all - match (¬ify_channel, ¬ify_user) { - (Some(channel), Some(user)) => { - // Send to specific channel and user - if let Err(e) = - channels.broadcast(channel, user, response.clone()).await - { + let user = notify_user.as_deref().unwrap_or("default"); + + // Try the configured channel first, fall back to + // broadcasting on all channels. + let targeted_ok = if let Some(ref channel) = notify_channel { + channels + .broadcast(channel, user, response.clone()) + .await + .is_ok() + } else { + false + }; + + if !targeted_ok { + let results = channels.broadcast_all(user, response).await; + for (ch, result) in results { + if let Err(e) = result { tracing::warn!( - "Failed to send heartbeat to {}/{}: {}", - channel, - user, + "Failed to broadcast heartbeat to {}: {}", + ch, e ); - } else { - tracing::debug!( - "Heartbeat notification sent to {}/{}", - channel, - user - ); - } - } - (None, Some(user)) => { - // Broadcast to all channels for this user - let results = channels.broadcast_all(user, response).await; - for (ch, result) in results { - if let Err(e) = result { - tracing::warn!( - "Failed to broadcast heartbeat to {}: {}", - ch, - e - ); - } - } - } - _ => { - // No explicit target, broadcast to all channels - // for the default user so notifications actually - // reach someone instead of vanishing into logs. - let results = channels.broadcast_all("default", response).await; - for (ch, result) in results { - if let Err(e) = result { - tracing::warn!( - "Failed to broadcast heartbeat to {}: {}", - ch, - e - ); - } } } } @@ -330,6 +309,85 @@ impl Agent { None }; + // Spawn routine engine if enabled + let routine_handle = if let Some(ref rt_config) = self.routine_config { + if rt_config.enabled { + if let (Some(store), Some(workspace)) = (self.store(), self.workspace()) { + // Set up notification channel (same pattern as heartbeat) + let (notify_tx, mut notify_rx) = + tokio::sync::mpsc::channel::(32); + + let engine = Arc::new(RoutineEngine::new( + rt_config.clone(), + Arc::clone(store), + self.llm().clone(), + Arc::clone(workspace), + notify_tx, + )); + + // Register routine tools + self.deps + .tools + .register_routine_tools(Arc::clone(store), Arc::clone(&engine)); + + // Load initial event cache + engine.refresh_event_cache().await; + + // Spawn notification forwarder + let channels = self.channels.clone(); + tokio::spawn(async move { + while let Some(response) = notify_rx.recv().await { + let user = response + .metadata + .get("notify_user") + .and_then(|v| v.as_str()) + .unwrap_or("default") + .to_string(); + let results = channels.broadcast_all(&user, response).await; + for (ch, result) in results { + if let Err(e) = result { + tracing::warn!( + "Failed to broadcast routine notification to {}: {}", + ch, + e + ); + } + } + } + }); + + // Spawn cron ticker + let cron_interval = + std::time::Duration::from_secs(rt_config.cron_check_interval_secs); + let cron_handle = spawn_cron_ticker(Arc::clone(&engine), cron_interval); + + // Store engine reference for event trigger checking + // Safety: we're in run() which takes self, no other reference exists + let engine_ref = Arc::clone(&engine); + // SAFETY: self is consumed by run(), we can smuggle the engine in + // via a local to use in the message loop below. + + tracing::info!( + "Routines enabled: cron ticker every {}s, max {} concurrent", + rt_config.cron_check_interval_secs, + rt_config.max_concurrent_routines + ); + + Some((cron_handle, engine_ref)) + } else { + tracing::warn!("Routines enabled but store/workspace not available"); + None + } + } else { + None + } + } else { + None + }; + + // Extract engine ref for use in message loop + let routine_engine_for_loop = routine_handle.as_ref().map(|(_, e)| Arc::clone(e)); + // Main message loop tracing::info!("Agent {} ready and listening", self.config.name); @@ -374,6 +432,14 @@ impl Agent { .await; } } + + // Check event triggers (cheap in-memory regex, fires async if matched) + if let Some(ref engine) = routine_engine_for_loop { + let fired = engine.check_event_triggers(&message).await; + if fired > 0 { + tracing::debug!("Fired {} event-triggered routines", fired); + } + } } // Cleanup @@ -383,6 +449,9 @@ impl Agent { if let Some(handle) = heartbeat_handle { handle.abort(); } + if let Some((cron_handle, _)) = routine_handle { + cron_handle.abort(); + } self.scheduler.stop_all().await; self.channels.shutdown_all().await?; @@ -393,6 +462,11 @@ impl Agent { // Parse submission type first let submission = SubmissionParser::parse(&message.content); + // Hydrate thread from DB if it's a historical thread not in memory + if let Some(ref external_thread_id) = message.thread_id { + self.maybe_hydrate_thread(message, external_thread_id).await; + } + // Resolve session and thread let (session, thread_id) = self .session_manager @@ -444,6 +518,9 @@ impl Agent { self.process_user_input(message, session, thread_id, &content) .await } + Submission::SystemCommand { command, args } => { + self.handle_system_command(&command, &args).await + } Submission::Undo => self.process_undo(session, thread_id).await, Submission::Redo => self.process_redo(session, thread_id).await, Submission::Interrupt => self.process_interrupt(session, thread_id).await, @@ -515,6 +592,107 @@ impl Agent { } } + /// Hydrate a historical thread from DB into memory if not already present. + /// + /// Called before `resolve_thread` so that the session manager finds the + /// thread on lookup instead of creating a new one. + /// + /// Creates an in-memory thread with the exact UUID the frontend sent, + /// even when the conversation has zero messages (e.g. a brand-new + /// assistant thread). Without this, `resolve_thread` would mint a + /// fresh UUID and all messages would land in the wrong conversation. + async fn maybe_hydrate_thread(&self, message: &IncomingMessage, external_thread_id: &str) { + // Only hydrate UUID-shaped thread IDs (web gateway uses UUIDs) + let thread_uuid = match Uuid::parse_str(external_thread_id) { + Ok(id) => id, + Err(_) => return, + }; + + // Check if already in memory + let session = self + .session_manager + .get_or_create_session(&message.user_id) + .await; + { + let sess = session.lock().await; + if sess.threads.contains_key(&thread_uuid) { + return; + } + } + + // Load history from DB (may be empty for a newly created thread). + let mut chat_messages: Vec = Vec::new(); + let msg_count; + + if let Some(store) = self.store() { + let db_messages = store + .list_conversation_messages(thread_uuid) + .await + .unwrap_or_default(); + msg_count = db_messages.len(); + chat_messages = db_messages + .iter() + .filter_map(|m| match m.role.as_str() { + "user" => Some(ChatMessage::user(&m.content)), + "assistant" => Some(ChatMessage::assistant(&m.content)), + _ => None, + }) + .collect(); + } else { + msg_count = 0; + } + + // Create thread with the historical ID and restore messages + let session_id = { + let sess = session.lock().await; + sess.id + }; + + let mut thread = crate::agent::session::Thread::with_id(thread_uuid, session_id); + if !chat_messages.is_empty() { + thread.restore_from_messages(chat_messages); + } + + // Restore response chain from conversation metadata + if let Some(store) = self.store() { + if let Ok(Some(metadata)) = store.get_conversation_metadata(thread_uuid).await { + if let Some(rid) = metadata + .get("last_response_id") + .and_then(|v| v.as_str()) + .map(String::from) + { + thread.last_response_id = Some(rid.clone()); + self.llm() + .seed_response_chain(&thread_uuid.to_string(), rid); + tracing::debug!("Restored response chain for thread {}", thread_uuid); + } + } + } + + // Insert into session and register with session manager + { + let mut sess = session.lock().await; + sess.threads.insert(thread_uuid, thread); + sess.active_thread = Some(thread_uuid); + sess.last_active_at = chrono::Utc::now(); + } + + self.session_manager + .register_thread( + &message.user_id, + &message.channel, + thread_uuid, + Arc::clone(&session), + ) + .await; + + tracing::debug!( + "Hydrated thread {} from DB ({} messages)", + thread_uuid, + msg_count + ); + } + async fn process_user_input( &self, message: &IncomingMessage, @@ -694,6 +872,7 @@ impl Agent { match result { Ok(AgenticLoopResult::Response(response)) => { thread.complete_turn(&response); + self.persist_response_chain(thread); let _ = self .channels .send_status( @@ -702,6 +881,10 @@ impl Agent { &message.metadata, ) .await; + + // Fire-and-forget: persist turn to DB + self.persist_turn(thread_id, &message.user_id, content, Some(&response)); + Ok(SubmissionResult::response(response)) } Ok(AgenticLoopResult::NeedApproval { pending }) => { @@ -728,11 +911,95 @@ impl Agent { } Err(e) => { thread.fail_turn(e.to_string()); + + // Persist the user message even on failure + self.persist_turn(thread_id, &message.user_id, content, None); + Ok(SubmissionResult::error(e.to_string())) } } } + /// Fire-and-forget: persist a turn (user message + optional assistant response) to the DB. + fn persist_turn( + &self, + thread_id: Uuid, + user_id: &str, + user_input: &str, + response: Option<&str>, + ) { + let store = match self.store() { + Some(s) => Arc::clone(s), + None => return, + }; + + let user_id = user_id.to_string(); + let user_input = user_input.to_string(); + let response = response.map(String::from); + + tokio::spawn(async move { + if let Err(e) = store + .ensure_conversation(thread_id, "gateway", &user_id, None) + .await + { + tracing::warn!("Failed to ensure conversation {}: {}", thread_id, e); + return; + } + + if let Err(e) = store + .add_conversation_message(thread_id, "user", &user_input) + .await + { + tracing::warn!("Failed to persist user message: {}", e); + return; + } + + if let Some(ref resp) = response { + if let Err(e) = store + .add_conversation_message(thread_id, "assistant", resp) + .await + { + tracing::warn!("Failed to persist assistant message: {}", e); + } + } + }); + } + + /// Sync the provider's response chain ID to the thread and DB metadata. + /// + /// Call after a successful agentic loop to persist the latest + /// `previous_response_id` so chaining survives restarts. + fn persist_response_chain(&self, thread: &mut crate::agent::session::Thread) { + let tid = thread.id.to_string(); + let response_id = match self.llm().get_response_chain_id(&tid) { + Some(rid) => rid, + None => return, + }; + + // Update in-memory thread + thread.last_response_id = Some(response_id.clone()); + + // Fire-and-forget DB write + let store = match self.store() { + Some(s) => Arc::clone(s), + None => return, + }; + let thread_id = thread.id; + tokio::spawn(async move { + let val = serde_json::json!(response_id); + if let Err(e) = store + .update_conversation_metadata_field(thread_id, "last_response_id", &val) + .await + { + tracing::warn!( + "Failed to persist response chain for thread {}: {}", + thread_id, + e + ); + } + }); + } + /// Run the agentic loop: call LLM, execute tools, repeat until text response. /// /// Returns `AgenticLoopResult::Response` on completion, or @@ -808,7 +1075,12 @@ impl Agent { // Call LLM with current context let context = ReasoningContext::new() .with_messages(context_messages.clone()) - .with_tools(tool_defs); + .with_tools(tool_defs) + .with_metadata({ + let mut m = std::collections::HashMap::new(); + m.insert("thread_id".to_string(), thread_id.to_string()); + m + }); let result = reasoning.respond_with_tools(&context).await?; @@ -832,13 +1104,16 @@ impl Agent { // Tools have been executed or we've tried multiple times, return response return Ok(AgenticLoopResult::Response(text)); } - RespondResult::ToolCalls(tool_calls) => { + RespondResult::ToolCalls { + tool_calls, + content, + } => { tools_executed = true; // Add the assistant message with tool_calls to context. - // OpenAI-compatible APIs require this before tool-result messages. + // OpenAI protocol requires this before tool-result messages. context_messages.push(ChatMessage::assistant_with_tool_calls( - "", + content, tool_calls.clone(), )); @@ -960,10 +1235,26 @@ impl Agent { if let Some((ext_name, instructions)) = detect_auth_awaiting(&tc.name, &tool_result) { - let mut sess = session.lock().await; - if let Some(thread) = sess.threads.get_mut(&thread_id) { - thread.enter_auth_mode(ext_name); + let auth_data = parse_auth_result(&tool_result); + { + let mut sess = session.lock().await; + if let Some(thread) = sess.threads.get_mut(&thread_id) { + thread.enter_auth_mode(ext_name.clone()); + } } + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::AuthRequired { + extension_name: ext_name, + instructions: Some(instructions.clone()), + auth_url: auth_data.auth_url, + setup_url: auth_data.setup_url, + }, + &message.metadata, + ) + .await; return Ok(AgenticLoopResult::Response(instructions)); } @@ -1024,19 +1315,59 @@ impl Agent { .into()); } - // Execute with timeout - let result = tokio::time::timeout(std::time::Duration::from_secs(60), async { + tracing::debug!( + tool = %tool_name, + params = %params, + "Tool call started" + ); + + // Execute with per-tool timeout + let timeout = tool.execution_timeout(); + let start = std::time::Instant::now(); + let result = tokio::time::timeout(timeout, async { tool.execute(params.clone(), job_ctx).await }) - .await - .map_err(|_| crate::error::ToolError::Timeout { - name: tool_name.to_string(), - timeout: std::time::Duration::from_secs(60), - })? - .map_err(|e| crate::error::ToolError::ExecutionFailed { - name: tool_name.to_string(), - reason: e.to_string(), - })?; + .await; + let elapsed = start.elapsed(); + + match &result { + Ok(Ok(output)) => { + let result_str = serde_json::to_string(&output.result) + .unwrap_or_else(|_| "".to_string()); + tracing::debug!( + tool = %tool_name, + elapsed_ms = elapsed.as_millis() as u64, + result = %result_str, + "Tool call succeeded" + ); + } + Ok(Err(e)) => { + tracing::debug!( + tool = %tool_name, + elapsed_ms = elapsed.as_millis() as u64, + error = %e, + "Tool call failed" + ); + } + Err(_) => { + tracing::debug!( + tool = %tool_name, + elapsed_ms = elapsed.as_millis() as u64, + timeout_secs = timeout.as_secs(), + "Tool call timed out" + ); + } + } + + let result = result + .map_err(|_| crate::error::ToolError::Timeout { + name: tool_name.to_string(), + timeout, + })? + .map_err(|e| crate::error::ToolError::ExecutionFailed { + name: tool_name.to_string(), + reason: e.to_string(), + })?; // Convert result to string serde_json::to_string_pretty(&result.result).map_err(|e| { @@ -1380,10 +1711,11 @@ impl Agent { if let Some((ext_name, instructions)) = detect_auth_awaiting(&pending.tool_name, &tool_result) { + let auth_data = parse_auth_result(&tool_result); { let mut sess = session.lock().await; if let Some(thread) = sess.threads.get_mut(&thread_id) { - thread.enter_auth_mode(ext_name); + thread.enter_auth_mode(ext_name.clone()); thread.complete_turn(&instructions); } } @@ -1391,7 +1723,12 @@ impl Agent { .channels .send_status( &message.channel, - StatusUpdate::Status("Awaiting token".into()), + StatusUpdate::AuthRequired { + extension_name: ext_name, + instructions: Some(instructions.clone()), + auth_url: auth_data.auth_url, + setup_url: auth_data.setup_url, + }, &message.metadata, ) .await; @@ -1434,6 +1771,7 @@ impl Agent { match result { Ok(AgenticLoopResult::Response(response)) => { thread.complete_turn(&response); + self.persist_response_chain(thread); let _ = self .channels .send_status( @@ -1532,16 +1870,6 @@ impl Agent { pending.extension_name ); - // Notify via channel status so the response doesn't echo the token - let _ = self - .channels - .send_status( - &message.channel, - StatusUpdate::Status("Authenticated, loading tools...".into()), - &message.metadata, - ) - .await; - // Auto-activate so tools are available immediately after auth match ext_mgr.activate(&pending.extension_name).await { Ok(activate_result) => { @@ -1551,10 +1879,23 @@ impl Agent { } else { format!("\n\nTools: {}", activate_result.tools_loaded.join(", ")) }; - Ok(Some(format!( + let msg = format!( "{} authenticated and activated ({} tools loaded).{}", pending.extension_name, tool_count, tool_list - ))) + ); + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::AuthCompleted { + extension_name: pending.extension_name.clone(), + success: true, + message: msg.clone(), + }, + &message.metadata, + ) + .await; + Ok(Some(msg)) } Err(e) => { tracing::warn!( @@ -1562,16 +1903,29 @@ impl Agent { pending.extension_name, e ); - Ok(Some(format!( + let msg = format!( "{} authenticated successfully, but activation failed: {}. \ Try activating manually.", pending.extension_name, e - ))) + ); + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::AuthCompleted { + extension_name: pending.extension_name.clone(), + success: true, + message: msg.clone(), + }, + &message.metadata, + ) + .await; + Ok(Some(msg)) } } } Ok(result) => { - // Unexpected state, re-enter auth mode + // Invalid token, re-enter auth mode { let mut sess = session.lock().await; if let Some(thread) = sess.threads.get_mut(&thread_id) { @@ -1580,13 +1934,43 @@ impl Agent { } let msg = result .instructions + .clone() .unwrap_or_else(|| "Invalid token. Please try again.".to_string()); + // Re-emit AuthRequired so web UI re-shows the card + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::AuthRequired { + extension_name: pending.extension_name.clone(), + instructions: Some(msg.clone()), + auth_url: result.auth_url, + setup_url: result.setup_url, + }, + &message.metadata, + ) + .await; + Ok(Some(msg)) + } + Err(e) => { + let msg = format!( + "Authentication failed for {}: {}", + pending.extension_name, e + ); + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::AuthCompleted { + extension_name: pending.extension_name.clone(), + success: false, + message: msg.clone(), + }, + &message.metadata, + ) + .await; Ok(Some(msg)) } - Err(e) => Ok(Some(format!( - "Authentication failed for {}: {}", - pending.extension_name, e - ))), } } @@ -1937,40 +2321,49 @@ impl Agent { } } - async fn handle_command( + /// Handle system commands that bypass thread-state checks entirely. + async fn handle_system_command( &self, command: &str, - _args: &[String], - ) -> Result, Error> { + args: &[String], + ) -> Result { match command { - "help" => Ok(Some( - r#"Commands: - /job - Create a job - /status [id] - Check job status - /cancel - Cancel a job - /list - List all jobs - /help - Help a stuck job + "help" => Ok(SubmissionResult::response(concat!( + "System:\n", + " /help Show this help\n", + " /model [name] Show or switch the active model\n", + " /version Show version info\n", + " /tools List available tools\n", + " /debug Toggle debug mode\n", + " /ping Connectivity check\n", + "\n", + "Jobs:\n", + " /job Create a new job\n", + " /status [id] Check job status\n", + " /cancel Cancel a job\n", + " /list List all jobs\n", + "\n", + "Session:\n", + " /undo Undo last turn\n", + " /redo Redo undone turn\n", + " /compact Compress context window\n", + " /clear Clear current thread\n", + " /interrupt Stop current operation\n", + " /new New conversation thread\n", + " /thread Switch to thread\n", + " /resume Resume from checkpoint\n", + "\n", + "Agent:\n", + " /heartbeat Run heartbeat check\n", + " /summarize Summarize current thread\n", + " /suggest Suggest next steps\n", + "\n", + " /quit Exit", + ))), - /undo - Undo last turn - /redo - Redo undone turn - /compact - Compress context - /clear - Clear thread - /interrupt - Stop current turn - /thread new - New thread - /thread - Switch thread - /resume - Resume checkpoint + "ping" => Ok(SubmissionResult::response("pong!")), - /heartbeat - Run heartbeat check now - /summarize - Summarize current thread - /suggest - Suggest next steps - - /quit - Exit"# - .to_string(), - )), - - "ping" => Ok(Some("pong!".to_string())), - - "version" => Ok(Some(format!( + "version" => Ok(SubmissionResult::response(format!( "{} v{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION") @@ -1978,12 +2371,113 @@ impl Agent { "tools" => { let tools = self.tools().list().await; - Ok(Some(format!("Available tools: {}", tools.join(", ")))) + Ok(SubmissionResult::response(format!( + "Available tools: {}", + tools.join(", ") + ))) } - _ => Ok(Some(format!("Unknown command: {}. Try /help", command))), + "debug" => { + // Debug toggle is handled client-side in the REPL. + // For non-REPL channels, just acknowledge. + Ok(SubmissionResult::ok_with_message( + "Debug toggle is handled by your client.", + )) + } + + "model" => { + if args.is_empty() { + // Show current model + let name = self.llm().active_model_name(); + Ok(SubmissionResult::response(format!( + "Active model: {}", + name + ))) + } else { + let requested = &args[0]; + + // Validate the model exists + match self.llm().list_models().await { + Ok(models) if !models.is_empty() => { + if !models.iter().any(|m| m == requested) { + return Ok(SubmissionResult::error(format!( + "Unknown model: {}. Available models:\n {}", + requested, + models.join("\n ") + ))); + } + } + Ok(_) => { + // Empty model list, can't validate but try anyway + } + Err(e) => { + tracing::warn!("Could not fetch model list for validation: {}", e); + // Proceed anyway, the provider will error on the next call if invalid + } + } + + match self.llm().set_model(requested) { + Ok(()) => Ok(SubmissionResult::response(format!( + "Switched model to: {}", + requested + ))), + Err(e) => Ok(SubmissionResult::error(format!( + "Failed to switch model: {}", + e + ))), + } + } + } + + _ => Ok(SubmissionResult::error(format!( + "Unknown command: {}. Try /help", + command + ))), } } + + /// Handle legacy command routing from the Router (job commands that go through + /// process_user_input -> router -> handle_job_or_command -> here). + async fn handle_command( + &self, + command: &str, + args: &[String], + ) -> Result, Error> { + // System commands are now handled directly via Submission::SystemCommand, + // but the router may still send us unknown /commands. + match self.handle_system_command(command, args).await? { + SubmissionResult::Response { content } => Ok(Some(content)), + SubmissionResult::Ok { message } => Ok(message), + SubmissionResult::Error { message } => Ok(Some(format!("Error: {}", message))), + _ => Ok(None), + } + } +} + +/// Parsed auth result fields for emitting StatusUpdate::AuthRequired. +struct ParsedAuthData { + auth_url: Option, + setup_url: Option, +} + +/// Extract auth_url and setup_url from a tool_auth result JSON string. +fn parse_auth_result(result: &Result) -> ParsedAuthData { + let parsed = result + .as_ref() + .ok() + .and_then(|s| serde_json::from_str::(s).ok()); + ParsedAuthData { + auth_url: parsed + .as_ref() + .and_then(|v| v.get("auth_url")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + setup_url: parsed + .as_ref() + .and_then(|v| v.get("setup_url")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + } } /// Check if a tool_auth result indicates the extension is awaiting a token. @@ -1994,7 +2488,7 @@ fn detect_auth_awaiting( tool_name: &str, result: &Result, ) -> Option<(String, String)> { - if tool_name != "tool_auth" { + if tool_name != "tool_auth" && tool_name != "tool_activate" { return None; } let output = result.as_ref().ok()?; @@ -2078,4 +2572,34 @@ mod tests { let (_, instructions) = detect_auth_awaiting("tool_auth", &result).unwrap(); assert_eq!(instructions, "Please provide your API token/key."); } + + #[test] + fn test_detect_auth_awaiting_tool_activate() { + let result: Result = Ok(serde_json::json!({ + "name": "slack", + "kind": "McpServer", + "awaiting_token": true, + "status": "awaiting_token", + "instructions": "Provide your Slack Bot token." + }) + .to_string()); + + let detected = detect_auth_awaiting("tool_activate", &result); + assert!(detected.is_some()); + let (name, instructions) = detected.unwrap(); + assert_eq!(name, "slack"); + assert!(instructions.contains("Slack Bot")); + } + + #[test] + fn test_detect_auth_awaiting_tool_activate_not_awaiting() { + let result: Result = Ok(serde_json::json!({ + "name": "slack", + "tools_loaded": ["slack_post_message"], + "message": "Activated" + }) + .to_string()); + + assert!(detect_auth_awaiting("tool_activate", &result).is_none()); + } } diff --git a/src/agent/heartbeat.rs b/src/agent/heartbeat.rs index 115b8159..ff35955d 100644 --- a/src/agent/heartbeat.rs +++ b/src/agent/heartbeat.rs @@ -29,7 +29,7 @@ use std::time::Duration; use tokio::sync::mpsc; use crate::channels::OutgoingResponse; -use crate::llm::{ChatMessage, CompletionRequest, LlmProvider}; +use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider}; use crate::workspace::Workspace; /// Configuration for the heartbeat runner. @@ -217,9 +217,26 @@ impl HeartbeatRunner { ] }; + // Use the model's context_length to set max_tokens. The API returns + // the total context window; we cap output at half of that (the rest is + // the prompt) with a floor of 4096. + let max_tokens = match self.llm.model_metadata().await { + Ok(meta) => { + let from_api = meta.context_length.map(|ctx| ctx / 2).unwrap_or(4096); + from_api.max(4096) + } + Err(e) => { + tracing::warn!( + "Could not fetch model metadata, using default max_tokens: {}", + e + ); + 4096 + } + }; + let request = CompletionRequest::new(messages) - .with_max_tokens(1024) - .with_temperature(0.3); // Lower temperature for more focused responses + .with_max_tokens(max_tokens) + .with_temperature(0.3); let response = match self.llm.complete(request).await { Ok(r) => r, @@ -228,6 +245,20 @@ impl HeartbeatRunner { let content = response.content.trim(); + // Guard against empty content. Reasoning models (e.g. GLM-4.7) may + // burn all output tokens on chain-of-thought and return content: null. + if content.is_empty() { + return if response.finish_reason == FinishReason::Length { + HeartbeatResult::Failed( + "LLM response was truncated (finish_reason=length) with no content. \ + The model may have exhausted its token budget on reasoning." + .to_string(), + ) + } else { + HeartbeatResult::Failed("LLM returned empty content.".to_string()) + }; + } + // Check if nothing needs attention if content == "HEARTBEAT_OK" || content.contains("HEARTBEAT_OK") { return HeartbeatResult::Ok; diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 7c3d7685..1455c92c 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -6,6 +6,7 @@ //! - Tool invocation with safety //! - Self-repair for stuck jobs //! - Proactive heartbeat execution +//! - Routine-based scheduled and reactive jobs //! - Turn-based session management with undo //! - Context compaction for long conversations @@ -14,6 +15,8 @@ pub mod compaction; pub mod context_monitor; mod heartbeat; mod router; +pub mod routine; +pub mod routine_engine; mod scheduler; mod self_repair; pub mod session; @@ -28,6 +31,8 @@ pub use compaction::{CompactionResult, ContextCompactor}; pub use context_monitor::{CompactionStrategy, ContextBreakdown, ContextMonitor}; pub use heartbeat::{HeartbeatConfig, HeartbeatResult, HeartbeatRunner, spawn_heartbeat}; pub use router::{MessageIntent, Router}; +pub use routine::{Routine, RoutineAction, RoutineRun, Trigger}; +pub use routine_engine::RoutineEngine; pub use scheduler::Scheduler; pub use self_repair::{BrokenTool, RepairResult, RepairTask, SelfRepair, StuckJob}; pub use session::{PendingApproval, PendingAuth, Session, Thread, ThreadState, Turn, TurnState}; diff --git a/src/agent/routine.rs b/src/agent/routine.rs new file mode 100644 index 00000000..084a9b9f --- /dev/null +++ b/src/agent/routine.rs @@ -0,0 +1,509 @@ +//! Core types for the routines system. +//! +//! A routine is a named, persistent, user-owned task with a trigger and an action. +//! Each routine fires independently when its trigger condition is met, with only +//! that routine's prompt and context sent to the LLM. +//! +//! ```text +//! ┌──────────┐ ┌─────────┐ ┌──────────────────┐ +//! │ Trigger │────▶│ Engine │────▶│ Execution Mode │ +//! │ cron/event│ │guardrail│ │lightweight│full_job│ +//! │ webhook │ │ check │ └──────────────────┘ +//! │ manual │ └─────────┘ │ +//! └──────────┘ ▼ +//! ┌──────────────┐ +//! │ Notify user │ +//! │ if needed │ +//! └──────────────┘ +//! ``` + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::str::FromStr; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A routine is a named, persistent, user-owned task with a trigger and an action. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Routine { + pub id: Uuid, + pub name: String, + pub description: String, + pub user_id: String, + pub enabled: bool, + pub trigger: Trigger, + pub action: RoutineAction, + pub guardrails: RoutineGuardrails, + pub notify: NotifyConfig, + + // Runtime state (DB-managed) + pub last_run_at: Option>, + pub next_fire_at: Option>, + pub run_count: u64, + pub consecutive_failures: u32, + pub state: serde_json::Value, + + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// When a routine should fire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Trigger { + /// Fire on a cron schedule (e.g. "0 9 * * MON-FRI" or "every 2h"). + Cron { schedule: String }, + /// Fire when a channel message matches a pattern. + Event { + /// Optional channel filter (e.g. "telegram", "slack"). + channel: Option, + /// Regex pattern to match against message content. + pattern: String, + }, + /// Fire on incoming webhook POST to /hooks/routine/{id}. + Webhook { + /// Optional webhook path suffix (defaults to routine id). + path: Option, + /// Optional shared secret for HMAC validation. + secret: Option, + }, + /// Only fires via tool call or CLI. + Manual, +} + +impl Trigger { + /// The string tag stored in the DB trigger_type column. + pub fn type_tag(&self) -> &'static str { + match self { + Trigger::Cron { .. } => "cron", + Trigger::Event { .. } => "event", + Trigger::Webhook { .. } => "webhook", + Trigger::Manual => "manual", + } + } + + /// Parse a trigger from its DB representation. + pub fn from_db(trigger_type: &str, config: serde_json::Value) -> Result { + match trigger_type { + "cron" => { + let schedule = config + .get("schedule") + .and_then(|v| v.as_str()) + .ok_or("cron trigger missing 'schedule'")? + .to_string(); + Ok(Trigger::Cron { schedule }) + } + "event" => { + let pattern = config + .get("pattern") + .and_then(|v| v.as_str()) + .ok_or("event trigger missing 'pattern'")? + .to_string(); + let channel = config + .get("channel") + .and_then(|v| v.as_str()) + .map(String::from); + Ok(Trigger::Event { channel, pattern }) + } + "webhook" => { + let path = config + .get("path") + .and_then(|v| v.as_str()) + .map(String::from); + let secret = config + .get("secret") + .and_then(|v| v.as_str()) + .map(String::from); + Ok(Trigger::Webhook { path, secret }) + } + "manual" => Ok(Trigger::Manual), + other => Err(format!("unknown trigger type: {other}")), + } + } + + /// Serialize trigger-specific config to JSON for DB storage. + pub fn to_config_json(&self) -> serde_json::Value { + match self { + Trigger::Cron { schedule } => serde_json::json!({ "schedule": schedule }), + Trigger::Event { channel, pattern } => serde_json::json!({ + "pattern": pattern, + "channel": channel, + }), + Trigger::Webhook { path, secret } => serde_json::json!({ + "path": path, + "secret": secret, + }), + Trigger::Manual => serde_json::json!({}), + } + } +} + +/// What happens when a routine fires. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RoutineAction { + /// Single LLM call, no tools. Cheap and fast. + Lightweight { + /// The prompt sent to the LLM. + prompt: String, + /// Workspace paths to load as context (e.g. ["context/priorities.md"]). + #[serde(default)] + context_paths: Vec, + /// Max output tokens (default: 4096). + #[serde(default = "default_max_tokens")] + max_tokens: u32, + }, + /// Full multi-turn worker job with tool access. + FullJob { + /// Job title for the scheduler. + title: String, + /// Job description / initial prompt. + description: String, + /// Max reasoning iterations (default: 10). + #[serde(default = "default_max_iterations")] + max_iterations: u32, + }, +} + +fn default_max_tokens() -> u32 { + 4096 +} + +fn default_max_iterations() -> u32 { + 10 +} + +impl RoutineAction { + /// The string tag stored in the DB action_type column. + pub fn type_tag(&self) -> &'static str { + match self { + RoutineAction::Lightweight { .. } => "lightweight", + RoutineAction::FullJob { .. } => "full_job", + } + } + + /// Parse an action from its DB representation. + pub fn from_db(action_type: &str, config: serde_json::Value) -> Result { + match action_type { + "lightweight" => { + let prompt = config + .get("prompt") + .and_then(|v| v.as_str()) + .ok_or("lightweight action missing 'prompt'")? + .to_string(); + let context_paths = config + .get("context_paths") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let max_tokens = config + .get("max_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(default_max_tokens() as u64) as u32; + Ok(RoutineAction::Lightweight { + prompt, + context_paths, + max_tokens, + }) + } + "full_job" => { + let title = config + .get("title") + .and_then(|v| v.as_str()) + .ok_or("full_job action missing 'title'")? + .to_string(); + let description = config + .get("description") + .and_then(|v| v.as_str()) + .ok_or("full_job action missing 'description'")? + .to_string(); + let max_iterations = config + .get("max_iterations") + .and_then(|v| v.as_u64()) + .unwrap_or(default_max_iterations() as u64) + as u32; + Ok(RoutineAction::FullJob { + title, + description, + max_iterations, + }) + } + other => Err(format!("unknown action type: {other}")), + } + } + + /// Serialize action config to JSON for DB storage. + pub fn to_config_json(&self) -> serde_json::Value { + match self { + RoutineAction::Lightweight { + prompt, + context_paths, + max_tokens, + } => serde_json::json!({ + "prompt": prompt, + "context_paths": context_paths, + "max_tokens": max_tokens, + }), + RoutineAction::FullJob { + title, + description, + max_iterations, + } => serde_json::json!({ + "title": title, + "description": description, + "max_iterations": max_iterations, + }), + } + } +} + +/// Guardrails to prevent runaway execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoutineGuardrails { + /// Minimum time between fires. + pub cooldown: Duration, + /// Max simultaneous runs of this routine. + pub max_concurrent: u32, + /// Window for content-hash dedup (event triggers). None = no dedup. + pub dedup_window: Option, +} + +impl Default for RoutineGuardrails { + fn default() -> Self { + Self { + cooldown: Duration::from_secs(300), + max_concurrent: 1, + dedup_window: None, + } + } +} + +/// Notification preferences for a routine. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotifyConfig { + /// Channel to notify on (None = default/broadcast all). + pub channel: Option, + /// User to notify. + pub user: String, + /// Notify when routine produces actionable output. + pub on_attention: bool, + /// Notify when routine errors. + pub on_failure: bool, + /// Notify when routine runs with no findings. + pub on_success: bool, +} + +impl Default for NotifyConfig { + fn default() -> Self { + Self { + channel: None, + user: "default".to_string(), + on_attention: true, + on_failure: true, + on_success: false, + } + } +} + +/// Status of a routine run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RunStatus { + Running, + Ok, + Attention, + Failed, +} + +impl std::fmt::Display for RunStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RunStatus::Running => write!(f, "running"), + RunStatus::Ok => write!(f, "ok"), + RunStatus::Attention => write!(f, "attention"), + RunStatus::Failed => write!(f, "failed"), + } + } +} + +impl FromStr for RunStatus { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "running" => Ok(RunStatus::Running), + "ok" => Ok(RunStatus::Ok), + "attention" => Ok(RunStatus::Attention), + "failed" => Ok(RunStatus::Failed), + other => Err(format!("unknown run status: {other}")), + } + } +} + +/// A single execution of a routine. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoutineRun { + pub id: Uuid, + pub routine_id: Uuid, + pub trigger_type: String, + pub trigger_detail: Option, + pub started_at: DateTime, + pub completed_at: Option>, + pub status: RunStatus, + pub result_summary: Option, + pub tokens_used: Option, + pub job_id: Option, + pub created_at: DateTime, +} + +/// Compute a content hash for event dedup. +pub fn content_hash(content: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + content.hash(&mut hasher); + hasher.finish() +} + +/// Parse a cron expression and compute the next fire time from now. +pub fn next_cron_fire(schedule: &str) -> Result>, String> { + let cron_schedule = + cron::Schedule::from_str(schedule).map_err(|e| format!("invalid cron: {e}"))?; + Ok(cron_schedule.upcoming(Utc).next()) +} + +#[cfg(test)] +mod tests { + use crate::agent::routine::{ + RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash, next_cron_fire, + }; + + #[test] + fn test_trigger_roundtrip() { + let trigger = Trigger::Cron { + schedule: "0 9 * * MON-FRI".to_string(), + }; + let json = trigger.to_config_json(); + let parsed = Trigger::from_db("cron", json).expect("parse cron"); + assert!(matches!(parsed, Trigger::Cron { schedule } if schedule == "0 9 * * MON-FRI")); + } + + #[test] + fn test_event_trigger_roundtrip() { + let trigger = Trigger::Event { + channel: Some("telegram".to_string()), + pattern: r"deploy\s+\w+".to_string(), + }; + let json = trigger.to_config_json(); + let parsed = Trigger::from_db("event", json).expect("parse event"); + assert!(matches!(parsed, Trigger::Event { channel, pattern } + if channel == Some("telegram".to_string()) && pattern == r"deploy\s+\w+")); + } + + #[test] + fn test_action_lightweight_roundtrip() { + let action = RoutineAction::Lightweight { + prompt: "Check PRs".to_string(), + context_paths: vec!["context/priorities.md".to_string()], + max_tokens: 2048, + }; + let json = action.to_config_json(); + let parsed = RoutineAction::from_db("lightweight", json).expect("parse lightweight"); + assert!( + matches!(parsed, RoutineAction::Lightweight { prompt, context_paths, max_tokens } + if prompt == "Check PRs" && context_paths.len() == 1 && max_tokens == 2048) + ); + } + + #[test] + fn test_action_full_job_roundtrip() { + let action = RoutineAction::FullJob { + title: "Deploy review".to_string(), + description: "Review and deploy pending changes".to_string(), + max_iterations: 5, + }; + let json = action.to_config_json(); + let parsed = RoutineAction::from_db("full_job", json).expect("parse full_job"); + assert!( + matches!(parsed, RoutineAction::FullJob { title, max_iterations, .. } + if title == "Deploy review" && max_iterations == 5) + ); + } + + #[test] + fn test_run_status_display_parse() { + for status in [ + RunStatus::Running, + RunStatus::Ok, + RunStatus::Attention, + RunStatus::Failed, + ] { + let s = status.to_string(); + let parsed: RunStatus = s.parse().expect("parse status"); + assert_eq!(parsed, status); + } + } + + #[test] + fn test_content_hash_deterministic() { + let h1 = content_hash("deploy production"); + let h2 = content_hash("deploy production"); + assert_eq!(h1, h2); + + let h3 = content_hash("deploy staging"); + assert_ne!(h1, h3); + } + + #[test] + fn test_next_cron_fire_valid() { + // Every minute should always have a next fire + let next = next_cron_fire("* * * * * *").expect("valid cron"); + assert!(next.is_some()); + } + + #[test] + fn test_next_cron_fire_invalid() { + let result = next_cron_fire("not a cron"); + assert!(result.is_err()); + } + + #[test] + fn test_guardrails_default() { + let g = RoutineGuardrails::default(); + assert_eq!(g.cooldown.as_secs(), 300); + assert_eq!(g.max_concurrent, 1); + assert!(g.dedup_window.is_none()); + } + + #[test] + fn test_trigger_type_tag() { + assert_eq!( + Trigger::Cron { + schedule: String::new() + } + .type_tag(), + "cron" + ); + assert_eq!( + Trigger::Event { + channel: None, + pattern: String::new() + } + .type_tag(), + "event" + ); + assert_eq!( + Trigger::Webhook { + path: None, + secret: None + } + .type_tag(), + "webhook" + ); + assert_eq!(Trigger::Manual.type_tag(), "manual"); + } +} diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs new file mode 100644 index 00000000..6a35e879 --- /dev/null +++ b/src/agent/routine_engine.rs @@ -0,0 +1,606 @@ +//! Routine execution engine. +//! +//! Handles loading routines, checking triggers, enforcing guardrails, +//! and executing both lightweight (single LLM call) and full-job routines. +//! +//! The engine runs two independent loops: +//! - A **cron ticker** that polls the DB every N seconds for due cron routines +//! - An **event matcher** called synchronously from the agent main loop +//! +//! Lightweight routines execute inline (single LLM call, no scheduler slot). +//! Full-job routines are delegated to the existing `Scheduler`. + +use std::sync::Arc; +use std::time::Duration; + +use chrono::Utc; +use regex::Regex; +use tokio::sync::{RwLock, mpsc}; +use uuid::Uuid; + +use crate::agent::routine::{ + NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire, +}; +use crate::channels::{IncomingMessage, OutgoingResponse}; +use crate::config::RoutineConfig; +use crate::history::Store; +use crate::llm::{ChatMessage, CompletionRequest, FinishReason, LlmProvider}; +use crate::workspace::Workspace; + +/// The routine execution engine. +pub struct RoutineEngine { + config: RoutineConfig, + store: Arc, + llm: Arc, + workspace: Arc, + /// Sender for notifications (routed to channel manager). + notify_tx: mpsc::Sender, + /// Currently running routine count (across all routines). + running_count: Arc>, + /// Compiled event regex cache: routine_id -> compiled regex. + event_cache: Arc>>, +} + +impl RoutineEngine { + pub fn new( + config: RoutineConfig, + store: Arc, + llm: Arc, + workspace: Arc, + notify_tx: mpsc::Sender, + ) -> Self { + Self { + config, + store, + llm, + workspace, + notify_tx, + running_count: Arc::new(RwLock::new(0)), + event_cache: Arc::new(RwLock::new(Vec::new())), + } + } + + /// Refresh the in-memory event trigger cache from DB. + pub async fn refresh_event_cache(&self) { + match self.store.list_event_routines().await { + Ok(routines) => { + let mut cache = Vec::new(); + for routine in routines { + if let Trigger::Event { ref pattern, .. } = routine.trigger { + match Regex::new(pattern) { + Ok(re) => cache.push((routine.id, routine.clone(), re)), + Err(e) => { + tracing::warn!( + routine = %routine.name, + "Invalid event regex '{}': {}", + pattern, e + ); + } + } + } + } + let count = cache.len(); + *self.event_cache.write().await = cache; + tracing::debug!("Refreshed event cache: {} routines", count); + } + Err(e) => { + tracing::error!("Failed to refresh event cache: {}", e); + } + } + } + + /// Check incoming message against event triggers. Returns number of routines fired. + /// + /// Called synchronously from the main loop after handle_message(). The actual + /// execution is spawned async so this returns quickly. + pub async fn check_event_triggers(&self, message: &IncomingMessage) -> usize { + let cache = self.event_cache.read().await; + let mut fired = 0; + + for (_, routine, re) in cache.iter() { + // Channel filter + if let Trigger::Event { + channel: Some(ch), .. + } = &routine.trigger + { + if ch != &message.channel { + continue; + } + } + + // Regex match + if !re.is_match(&message.content) { + continue; + } + + // Cooldown check + if !self.check_cooldown(routine) { + tracing::debug!(routine = %routine.name, "Skipped: cooldown active"); + continue; + } + + // Concurrent run check + if !self.check_concurrent(routine).await { + tracing::debug!(routine = %routine.name, "Skipped: max concurrent reached"); + continue; + } + + // Global capacity check + if *self.running_count.read().await >= self.config.max_concurrent_routines { + tracing::warn!(routine = %routine.name, "Skipped: global max concurrent reached"); + continue; + } + + let detail = truncate(&message.content, 200); + self.spawn_fire(routine.clone(), "event", Some(detail)); + fired += 1; + } + + fired + } + + /// Check all due cron routines and fire them. Called by the cron ticker. + pub async fn check_cron_triggers(&self) { + let routines = match self.store.list_due_cron_routines().await { + Ok(r) => r, + Err(e) => { + tracing::error!("Failed to load due cron routines: {}", e); + return; + } + }; + + for routine in routines { + if *self.running_count.read().await >= self.config.max_concurrent_routines { + tracing::warn!("Global max concurrent routines reached, skipping remaining"); + break; + } + + if !self.check_cooldown(&routine) { + continue; + } + + if !self.check_concurrent(&routine).await { + continue; + } + + let detail = if let Trigger::Cron { ref schedule } = routine.trigger { + Some(schedule.clone()) + } else { + None + }; + + self.spawn_fire(routine, "cron", detail); + } + } + + /// Fire a routine manually (from tool call or CLI). + pub async fn fire_manual(&self, routine_id: Uuid) -> Result { + let routine = self + .store + .get_routine(routine_id) + .await + .map_err(|e| format!("DB error: {e}"))? + .ok_or_else(|| format!("routine {routine_id} not found"))?; + + if !routine.enabled { + return Err(format!("routine '{}' is disabled", routine.name)); + } + + if !self.check_concurrent(&routine).await { + return Err(format!( + "routine '{}' already at max concurrent runs", + routine.name + )); + } + + let run_id = Uuid::new_v4(); + let run = RoutineRun { + id: run_id, + routine_id: routine.id, + trigger_type: "manual".to_string(), + trigger_detail: None, + started_at: Utc::now(), + completed_at: None, + status: RunStatus::Running, + result_summary: None, + tokens_used: None, + job_id: None, + created_at: Utc::now(), + }; + + if let Err(e) = self.store.create_routine_run(&run).await { + return Err(format!("failed to create run record: {e}")); + } + + // Execute inline for manual triggers (caller wants to wait) + let engine = EngineContext { + store: self.store.clone(), + llm: self.llm.clone(), + workspace: self.workspace.clone(), + notify_tx: self.notify_tx.clone(), + running_count: self.running_count.clone(), + max_lightweight_tokens: self.config.max_lightweight_tokens, + }; + + tokio::spawn(async move { + execute_routine(engine, routine, run).await; + }); + + Ok(run_id) + } + + /// Spawn a fire in a background task. + fn spawn_fire(&self, routine: Routine, trigger_type: &str, trigger_detail: Option) { + let run = RoutineRun { + id: Uuid::new_v4(), + routine_id: routine.id, + trigger_type: trigger_type.to_string(), + trigger_detail, + started_at: Utc::now(), + completed_at: None, + status: RunStatus::Running, + result_summary: None, + tokens_used: None, + job_id: None, + created_at: Utc::now(), + }; + + let engine = EngineContext { + store: self.store.clone(), + llm: self.llm.clone(), + workspace: self.workspace.clone(), + notify_tx: self.notify_tx.clone(), + running_count: self.running_count.clone(), + max_lightweight_tokens: self.config.max_lightweight_tokens, + }; + + // Record the run in DB, then spawn execution + let store = self.store.clone(); + tokio::spawn(async move { + if let Err(e) = store.create_routine_run(&run).await { + tracing::error!(routine = %routine.name, "Failed to record run: {}", e); + return; + } + execute_routine(engine, routine, run).await; + }); + } + + fn check_cooldown(&self, routine: &Routine) -> bool { + if let Some(last_run) = routine.last_run_at { + let elapsed = Utc::now().signed_duration_since(last_run); + let cooldown = chrono::Duration::from_std(routine.guardrails.cooldown) + .unwrap_or(chrono::Duration::seconds(300)); + if elapsed < cooldown { + return false; + } + } + true + } + + async fn check_concurrent(&self, routine: &Routine) -> bool { + match self.store.count_running_routine_runs(routine.id).await { + Ok(count) => count < routine.guardrails.max_concurrent as i64, + Err(e) => { + tracing::error!( + routine = %routine.name, + "Failed to check concurrent runs: {}", e + ); + false + } + } + } +} + +/// Shared context passed to the execution function. +struct EngineContext { + store: Arc, + llm: Arc, + workspace: Arc, + notify_tx: mpsc::Sender, + running_count: Arc>, + max_lightweight_tokens: u32, +} + +/// Execute a routine run. Handles both lightweight and full_job modes. +async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) { + // Increment running count + { + let mut count = ctx.running_count.write().await; + *count += 1; + } + + let result = match &routine.action { + RoutineAction::Lightweight { + prompt, + context_paths, + max_tokens, + } => execute_lightweight(&ctx, &routine, prompt, context_paths, *max_tokens).await, + RoutineAction::FullJob { description, .. } => { + // Full job mode: for now, execute as lightweight with the description + // as prompt. Full scheduler integration will come as a follow-up. + tracing::info!( + routine = %routine.name, + "FullJob mode executing as lightweight (scheduler integration pending)" + ); + execute_lightweight(&ctx, &routine, description, &[], ctx.max_lightweight_tokens).await + } + }; + + // Decrement running count + { + let mut count = ctx.running_count.write().await; + *count = count.saturating_sub(1); + } + + // Process result + let (status, summary, tokens) = match result { + Ok(execution) => execution, + Err(e) => { + tracing::error!(routine = %routine.name, "Execution failed: {}", e); + (RunStatus::Failed, Some(e), None) + } + }; + + // Complete the run record + if let Err(e) = ctx + .store + .complete_routine_run(run.id, status, summary.as_deref(), tokens) + .await + { + tracing::error!(routine = %routine.name, "Failed to complete run record: {}", e); + } + + // Update routine runtime state + let now = Utc::now(); + let next_fire = if let Trigger::Cron { ref schedule } = routine.trigger { + next_cron_fire(schedule).unwrap_or(None) + } else { + None + }; + + let new_failures = if status == RunStatus::Failed { + routine.consecutive_failures + 1 + } else { + 0 + }; + + if let Err(e) = ctx + .store + .update_routine_runtime( + routine.id, + now, + next_fire, + routine.run_count + 1, + new_failures, + &routine.state, + ) + .await + { + tracing::error!(routine = %routine.name, "Failed to update runtime state: {}", e); + } + + // Send notifications based on config + send_notification( + &ctx.notify_tx, + &routine.notify, + &routine.name, + status, + summary.as_deref(), + ) + .await; +} + +/// Execute a lightweight routine (single LLM call). +async fn execute_lightweight( + ctx: &EngineContext, + routine: &Routine, + prompt: &str, + context_paths: &[String], + max_tokens: u32, +) -> Result<(RunStatus, Option, Option), String> { + // Load context from workspace + let mut context_parts = Vec::new(); + for path in context_paths { + match ctx.workspace.read(path).await { + Ok(doc) => { + context_parts.push(format!("## {}\n\n{}", path, doc.content)); + } + Err(e) => { + tracing::debug!( + routine = %routine.name, + "Failed to read context path {}: {}", path, e + ); + } + } + } + + // Load routine state from workspace + let state_path = format!("routines/{}/state.md", routine.name); + let state_content = match ctx.workspace.read(&state_path).await { + Ok(doc) => Some(doc.content), + Err(_) => None, + }; + + // Build the prompt + let mut full_prompt = String::new(); + full_prompt.push_str(prompt); + + if !context_parts.is_empty() { + full_prompt.push_str("\n\n---\n\n# Context\n\n"); + full_prompt.push_str(&context_parts.join("\n\n")); + } + + if let Some(state) = &state_content { + full_prompt.push_str("\n\n---\n\n# Previous State\n\n"); + full_prompt.push_str(state); + } + + full_prompt.push_str( + "\n\n---\n\nIf nothing needs attention, reply EXACTLY with: ROUTINE_OK\n\ + If something needs attention, provide a concise summary.", + ); + + // Get system prompt + let system_prompt = match ctx.workspace.system_prompt().await { + Ok(p) => p, + Err(e) => { + tracing::warn!(routine = %routine.name, "Failed to get system prompt: {}", e); + String::new() + } + }; + + let messages = if system_prompt.is_empty() { + vec![ChatMessage::user(&full_prompt)] + } else { + vec![ + ChatMessage::system(&system_prompt), + ChatMessage::user(&full_prompt), + ] + }; + + // Determine max_tokens from model metadata with fallback + let effective_max_tokens = match ctx.llm.model_metadata().await { + Ok(meta) => { + let from_api = meta.context_length.map(|ctx| ctx / 2).unwrap_or(max_tokens); + from_api.max(max_tokens) + } + Err(_) => max_tokens, + }; + + let request = CompletionRequest::new(messages) + .with_max_tokens(effective_max_tokens) + .with_temperature(0.3); + + let response = ctx + .llm + .complete(request) + .await + .map_err(|e| format!("LLM call failed: {e}"))?; + + let content = response.content.trim(); + let tokens_used = Some((response.input_tokens + response.output_tokens) as i32); + + // Empty content guard (same as heartbeat) + if content.is_empty() { + return if response.finish_reason == FinishReason::Length { + Err( + "LLM response truncated (finish_reason=length) with no content. \ + Model may have exhausted token budget on reasoning." + .to_string(), + ) + } else { + Err("LLM returned empty content.".to_string()) + }; + } + + // Check for the "nothing to do" sentinel + if content == "ROUTINE_OK" || content.contains("ROUTINE_OK") { + return Ok((RunStatus::Ok, None, tokens_used)); + } + + Ok((RunStatus::Attention, Some(content.to_string()), tokens_used)) +} + +/// Send a notification based on the routine's notify config and run status. +async fn send_notification( + tx: &mpsc::Sender, + notify: &NotifyConfig, + routine_name: &str, + status: RunStatus, + summary: Option<&str>, +) { + let should_notify = match status { + RunStatus::Ok => notify.on_success, + RunStatus::Attention => notify.on_attention, + RunStatus::Failed => notify.on_failure, + RunStatus::Running => false, + }; + + if !should_notify { + return; + } + + let icon = match status { + RunStatus::Ok => "✅", + RunStatus::Attention => "🔔", + RunStatus::Failed => "❌", + RunStatus::Running => "⏳", + }; + + let message = match summary { + Some(s) => format!("{} *Routine '{}'*: {}\n\n{}", icon, routine_name, status, s), + None => format!("{} *Routine '{}'*: {}", icon, routine_name, status), + }; + + let response = OutgoingResponse { + content: message, + thread_id: None, + metadata: serde_json::json!({ + "source": "routine", + "routine_name": routine_name, + "status": status.to_string(), + }), + }; + + if let Err(e) = tx.send(response).await { + tracing::error!(routine = %routine_name, "Failed to send notification: {}", e); + } +} + +/// Spawn the cron ticker background task. +pub fn spawn_cron_ticker( + engine: Arc, + interval: Duration, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + // Skip immediate first tick + ticker.tick().await; + + loop { + ticker.tick().await; + engine.check_cron_triggers().await; + } + }) +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}...", &s[..max]) + } +} + +#[cfg(test)] +mod tests { + use crate::agent::routine::{NotifyConfig, RunStatus}; + + #[test] + fn test_notification_gating() { + let config = NotifyConfig { + on_success: false, + on_failure: true, + on_attention: true, + ..Default::default() + }; + + // on_success = false means Ok status should not notify + assert!(!config.on_success); + assert!(config.on_failure); + assert!(config.on_attention); + } + + #[test] + fn test_run_status_icons() { + // Just verify the mapping doesn't panic + for status in [ + RunStatus::Ok, + RunStatus::Attention, + RunStatus::Failed, + RunStatus::Running, + ] { + let _ = status.to_string(); + } + } +} diff --git a/src/agent/scheduler.rs b/src/agent/scheduler.rs index a88df10a..5175e3b2 100644 --- a/src/agent/scheduler.rs +++ b/src/agent/scheduler.rs @@ -373,23 +373,23 @@ impl Scheduler { .into()); } - // Execute with timeout - let result = tokio::time::timeout(Duration::from_secs(60), async { - tool.execute(params, &job_ctx).await - }) - .await - .map_err(|_| { - Error::Tool(crate::error::ToolError::Timeout { - name: tool_name.to_string(), - timeout: Duration::from_secs(60), - }) - })? - .map_err(|e| { - Error::Tool(crate::error::ToolError::ExecutionFailed { - name: tool_name.to_string(), - reason: e.to_string(), - }) - })?; + // Execute with per-tool timeout + let tool_timeout = tool.execution_timeout(); + let result = + tokio::time::timeout(tool_timeout, async { tool.execute(params, &job_ctx).await }) + .await + .map_err(|_| { + Error::Tool(crate::error::ToolError::Timeout { + name: tool_name.to_string(), + timeout: tool_timeout, + }) + })? + .map_err(|e| { + Error::Tool(crate::error::ToolError::ExecutionFailed { + name: tool_name.to_string(), + reason: e.to_string(), + }) + })?; Ok(TaskOutput::new(result.result, start.elapsed())) } diff --git a/src/agent/session.rs b/src/agent/session.rs index a40d3fbc..fbfb4aa3 100644 --- a/src/agent/session.rs +++ b/src/agent/session.rs @@ -173,6 +173,10 @@ pub struct Thread { /// Pending auth token request (thread is in auth mode). #[serde(default)] pub pending_auth: Option, + /// Last NEAR AI response ID for response chaining. Persisted to DB + /// metadata so we can resume chaining across restarts. + #[serde(default)] + pub last_response_id: Option, } impl Thread { @@ -189,6 +193,24 @@ impl Thread { metadata: serde_json::Value::Null, pending_approval: None, pending_auth: None, + last_response_id: None, + } + } + + /// Create a thread with a specific ID (for DB hydration). + pub fn with_id(id: Uuid, session_id: Uuid) -> Self { + let now = Utc::now(); + Self { + id, + session_id, + state: ThreadState::Idle, + turns: Vec::new(), + created_at: now, + updated_at: now, + metadata: serde_json::Value::Null, + pending_approval: None, + pending_auth: None, + last_response_id: None, } } @@ -593,4 +615,386 @@ mod tests { let restored: Thread = serde_json::from_str(&json).expect("should deserialize"); assert!(restored.pending_auth.is_none()); } + + #[test] + fn test_thread_with_id() { + let specific_id = Uuid::new_v4(); + let session_id = Uuid::new_v4(); + let thread = Thread::with_id(specific_id, session_id); + + assert_eq!(thread.id, specific_id); + assert_eq!(thread.session_id, session_id); + assert_eq!(thread.state, ThreadState::Idle); + assert!(thread.turns.is_empty()); + } + + #[test] + fn test_thread_with_id_restore_messages() { + let thread_id = Uuid::new_v4(); + let session_id = Uuid::new_v4(); + let mut thread = Thread::with_id(thread_id, session_id); + + let messages = vec![ + ChatMessage::user("Hello from DB"), + ChatMessage::assistant("Restored response"), + ]; + thread.restore_from_messages(messages); + + assert_eq!(thread.id, thread_id); + assert_eq!(thread.turns.len(), 1); + assert_eq!(thread.turns[0].user_input, "Hello from DB"); + assert_eq!( + thread.turns[0].response, + Some("Restored response".to_string()) + ); + } + + #[test] + fn test_restore_from_messages_empty() { + let mut thread = Thread::new(Uuid::new_v4()); + + // Add a turn first, then restore with empty vec + thread.start_turn("hello"); + thread.complete_turn("hi"); + assert_eq!(thread.turns.len(), 1); + + thread.restore_from_messages(Vec::new()); + + // Should clear all turns and stay idle + assert!(thread.turns.is_empty()); + assert_eq!(thread.state, ThreadState::Idle); + } + + #[test] + fn test_restore_from_messages_only_assistant_messages() { + let mut thread = Thread::new(Uuid::new_v4()); + + // Only assistant messages (no user messages to anchor turns) + let messages = vec![ + ChatMessage::assistant("I'm here"), + ChatMessage::assistant("Still here"), + ]; + + thread.restore_from_messages(messages); + + // Assistant-only messages have no user turn to attach to, so + // they should be skipped entirely. + assert!(thread.turns.is_empty()); + } + + #[test] + fn test_restore_from_messages_multiple_user_messages_in_a_row() { + let mut thread = Thread::new(Uuid::new_v4()); + + // Two user messages with no assistant response between them + let messages = vec![ + ChatMessage::user("first"), + ChatMessage::user("second"), + ChatMessage::assistant("reply to second"), + ]; + + thread.restore_from_messages(messages); + + // First user message becomes a turn with no response, + // second user message pairs with the assistant response. + assert_eq!(thread.turns.len(), 2); + assert_eq!(thread.turns[0].user_input, "first"); + assert!(thread.turns[0].response.is_none()); + assert_eq!(thread.turns[1].user_input, "second"); + assert_eq!( + thread.turns[1].response, + Some("reply to second".to_string()) + ); + } + + #[test] + fn test_thread_switch() { + let mut session = Session::new("user-1"); + + let t1_id = session.create_thread().id; + let t2_id = session.create_thread().id; + + // After creating two threads, active should be the last one + assert_eq!(session.active_thread, Some(t2_id)); + + // Switch back to the first + assert!(session.switch_thread(t1_id)); + assert_eq!(session.active_thread, Some(t1_id)); + + // Switching to a nonexistent thread should fail + let fake_id = Uuid::new_v4(); + assert!(!session.switch_thread(fake_id)); + // Active thread should remain unchanged + assert_eq!(session.active_thread, Some(t1_id)); + } + + #[test] + fn test_get_or_create_thread_idempotent() { + let mut session = Session::new("user-1"); + + let tid1 = session.get_or_create_thread().id; + let tid2 = session.get_or_create_thread().id; + + // Should return the same thread (not create a new one each time) + assert_eq!(tid1, tid2); + assert_eq!(session.threads.len(), 1); + } + + #[test] + fn test_truncate_turns() { + let mut thread = Thread::new(Uuid::new_v4()); + + for i in 0..5 { + thread.start_turn(format!("msg-{}", i)); + thread.complete_turn(format!("resp-{}", i)); + } + assert_eq!(thread.turns.len(), 5); + + thread.truncate_turns(3); + assert_eq!(thread.turns.len(), 3); + + // Should keep the most recent turns + assert_eq!(thread.turns[0].user_input, "msg-2"); + assert_eq!(thread.turns[1].user_input, "msg-3"); + assert_eq!(thread.turns[2].user_input, "msg-4"); + + // Turn numbers should be re-indexed + assert_eq!(thread.turns[0].turn_number, 0); + assert_eq!(thread.turns[1].turn_number, 1); + assert_eq!(thread.turns[2].turn_number, 2); + } + + #[test] + fn test_truncate_turns_noop_when_fewer() { + let mut thread = Thread::new(Uuid::new_v4()); + + thread.start_turn("only one"); + thread.complete_turn("response"); + + thread.truncate_turns(10); + assert_eq!(thread.turns.len(), 1); + assert_eq!(thread.turns[0].user_input, "only one"); + } + + #[test] + fn test_thread_interrupt_and_resume() { + let mut thread = Thread::new(Uuid::new_v4()); + + thread.start_turn("do something"); + assert_eq!(thread.state, ThreadState::Processing); + + thread.interrupt(); + assert_eq!(thread.state, ThreadState::Interrupted); + + let last_turn = thread.last_turn().unwrap(); + assert_eq!(last_turn.state, TurnState::Interrupted); + assert!(last_turn.completed_at.is_some()); + + thread.resume(); + assert_eq!(thread.state, ThreadState::Idle); + } + + #[test] + fn test_resume_only_from_interrupted() { + let mut thread = Thread::new(Uuid::new_v4()); + + // Idle thread: resume should be a no-op + assert_eq!(thread.state, ThreadState::Idle); + thread.resume(); + assert_eq!(thread.state, ThreadState::Idle); + + // Processing thread: resume should not change state + thread.start_turn("work"); + assert_eq!(thread.state, ThreadState::Processing); + thread.resume(); + assert_eq!(thread.state, ThreadState::Processing); + } + + #[test] + fn test_turn_fail() { + let mut thread = Thread::new(Uuid::new_v4()); + + thread.start_turn("risky operation"); + thread.fail_turn("connection timed out"); + + assert_eq!(thread.state, ThreadState::Idle); + + let turn = thread.last_turn().unwrap(); + assert_eq!(turn.state, TurnState::Failed); + assert_eq!(turn.error, Some("connection timed out".to_string())); + assert!(turn.response.is_none()); + assert!(turn.completed_at.is_some()); + } + + #[test] + fn test_messages_with_incomplete_last_turn() { + let mut thread = Thread::new(Uuid::new_v4()); + + thread.start_turn("first"); + thread.complete_turn("first reply"); + thread.start_turn("second (in progress)"); + + let messages = thread.messages(); + // Should have 3 messages: user, assistant, user (no assistant for in-progress) + assert_eq!(messages.len(), 3); + assert_eq!(messages[0].content, "first"); + assert_eq!(messages[1].content, "first reply"); + assert_eq!(messages[2].content, "second (in progress)"); + } + + #[test] + fn test_thread_serialization_round_trip() { + let mut thread = Thread::new(Uuid::new_v4()); + + thread.start_turn("hello"); + thread.complete_turn("world"); + thread.last_response_id = Some("resp_abc123".to_string()); + + let json = serde_json::to_string(&thread).unwrap(); + let restored: Thread = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.id, thread.id); + assert_eq!(restored.session_id, thread.session_id); + assert_eq!(restored.turns.len(), 1); + assert_eq!(restored.turns[0].user_input, "hello"); + assert_eq!(restored.turns[0].response, Some("world".to_string())); + assert_eq!(restored.last_response_id, Some("resp_abc123".to_string())); + } + + #[test] + fn test_session_serialization_round_trip() { + let mut session = Session::new("user-ser"); + session.create_thread(); + session.auto_approve_tool("echo"); + + let json = serde_json::to_string(&session).unwrap(); + let restored: Session = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.user_id, "user-ser"); + assert_eq!(restored.threads.len(), 1); + assert!(restored.is_tool_auto_approved("echo")); + assert!(!restored.is_tool_auto_approved("shell")); + } + + #[test] + fn test_auto_approved_tools() { + let mut session = Session::new("user-1"); + + assert!(!session.is_tool_auto_approved("shell")); + session.auto_approve_tool("shell"); + assert!(session.is_tool_auto_approved("shell")); + + // Idempotent + session.auto_approve_tool("shell"); + assert_eq!(session.auto_approved_tools.len(), 1); + } + + #[test] + fn test_turn_tool_call_error() { + let mut turn = Turn::new(0, "test"); + turn.record_tool_call("http", serde_json::json!({"url": "example.com"})); + turn.record_tool_error("timeout"); + + assert_eq!(turn.tool_calls.len(), 1); + assert_eq!(turn.tool_calls[0].error, Some("timeout".to_string())); + assert!(turn.tool_calls[0].result.is_none()); + } + + #[test] + fn test_turn_number_increments() { + let mut thread = Thread::new(Uuid::new_v4()); + + // Before any turns, turn_number() is 1 (1-indexed for display) + assert_eq!(thread.turn_number(), 1); + + thread.start_turn("first"); + thread.complete_turn("done"); + assert_eq!(thread.turn_number(), 2); + + thread.start_turn("second"); + assert_eq!(thread.turn_number(), 3); + } + + #[test] + fn test_complete_turn_on_empty_thread() { + let mut thread = Thread::new(Uuid::new_v4()); + + // Completing a turn when there are no turns should be a safe no-op + thread.complete_turn("phantom response"); + assert_eq!(thread.state, ThreadState::Idle); + assert!(thread.turns.is_empty()); + } + + #[test] + fn test_fail_turn_on_empty_thread() { + let mut thread = Thread::new(Uuid::new_v4()); + + // Failing a turn when there are no turns should be a safe no-op + thread.fail_turn("phantom error"); + assert_eq!(thread.state, ThreadState::Idle); + assert!(thread.turns.is_empty()); + } + + #[test] + fn test_pending_approval_flow() { + let mut thread = Thread::new(Uuid::new_v4()); + + let approval = PendingApproval { + request_id: Uuid::new_v4(), + tool_name: "shell".to_string(), + parameters: serde_json::json!({"command": "rm -rf /"}), + description: "dangerous command".to_string(), + tool_call_id: "call_123".to_string(), + context_messages: vec![ChatMessage::user("do it")], + }; + + thread.await_approval(approval); + assert_eq!(thread.state, ThreadState::AwaitingApproval); + assert!(thread.pending_approval.is_some()); + + let taken = thread.take_pending_approval(); + assert!(taken.is_some()); + assert_eq!(taken.unwrap().tool_name, "shell"); + assert!(thread.pending_approval.is_none()); + } + + #[test] + fn test_clear_pending_approval() { + let mut thread = Thread::new(Uuid::new_v4()); + + let approval = PendingApproval { + request_id: Uuid::new_v4(), + tool_name: "http".to_string(), + parameters: serde_json::json!({}), + description: "test".to_string(), + tool_call_id: "call_456".to_string(), + context_messages: vec![], + }; + + thread.await_approval(approval); + thread.clear_pending_approval(); + + assert_eq!(thread.state, ThreadState::Idle); + assert!(thread.pending_approval.is_none()); + } + + #[test] + fn test_active_thread_accessors() { + let mut session = Session::new("user-1"); + + assert!(session.active_thread().is_none()); + assert!(session.active_thread_mut().is_none()); + + let tid = session.create_thread().id; + + assert!(session.active_thread().is_some()); + assert_eq!(session.active_thread().unwrap().id, tid); + + // Mutably modify through accessor + session.active_thread_mut().unwrap().start_turn("test"); + assert_eq!( + session.active_thread().unwrap().state, + ThreadState::Processing + ); + } } diff --git a/src/agent/session_manager.rs b/src/agent/session_manager.rs index dcf46343..2ea6bfa1 100644 --- a/src/agent/session_manager.rs +++ b/src/agent/session_manager.rs @@ -110,6 +110,41 @@ impl SessionManager { (session, thread_id) } + /// Register a hydrated thread so subsequent `resolve_thread` calls find it. + /// + /// Inserts into the thread_map and creates an undo manager for the thread. + pub async fn register_thread( + &self, + user_id: &str, + channel: &str, + thread_id: Uuid, + session: Arc>, + ) { + let key = ThreadKey { + user_id: user_id.to_string(), + channel: channel.to_string(), + external_thread_id: Some(thread_id.to_string()), + }; + + { + let mut thread_map = self.thread_map.write().await; + thread_map.insert(key, thread_id); + } + + { + let mut undo_managers = self.undo_managers.write().await; + undo_managers + .entry(thread_id) + .or_insert_with(|| Arc::new(Mutex::new(UndoManager::new()))); + } + + // Ensure the session is tracked + { + let mut sessions = self.sessions.write().await; + sessions.entry(user_id.to_string()).or_insert(session); + } + } + /// Get undo manager for a thread. pub async fn get_undo_manager(&self, thread_id: Uuid) -> Arc> { // Fast path @@ -296,4 +331,344 @@ mod tests { .await; assert_eq!(pruned, 0); } + + #[tokio::test] + async fn test_register_thread() { + use crate::agent::session::{Session, Thread}; + + let manager = SessionManager::new(); + let thread_id = Uuid::new_v4(); + + // Create a session with a hydrated thread + let session = Arc::new(Mutex::new(Session::new("user-hydrate"))); + { + let mut sess = session.lock().await; + let thread = Thread::with_id(thread_id, sess.id); + sess.threads.insert(thread_id, thread); + sess.active_thread = Some(thread_id); + } + + // Register the thread + manager + .register_thread("user-hydrate", "gateway", thread_id, Arc::clone(&session)) + .await; + + // resolve_thread should find it (using the UUID as external_thread_id) + let (resolved_session, resolved_tid) = manager + .resolve_thread("user-hydrate", "gateway", Some(&thread_id.to_string())) + .await; + assert_eq!(resolved_tid, thread_id); + + // Should be the same session object + let sess = resolved_session.lock().await; + assert!(sess.threads.contains_key(&thread_id)); + } + + #[tokio::test] + async fn test_resolve_thread_with_explicit_external_id() { + let manager = SessionManager::new(); + + // Two calls with the same explicit external thread ID should resolve + // to the same internal thread. + let (_, t1) = manager + .resolve_thread("user-1", "gateway", Some("ext-abc")) + .await; + let (_, t2) = manager + .resolve_thread("user-1", "gateway", Some("ext-abc")) + .await; + assert_eq!(t1, t2); + + // A different external ID on the same channel/user gets a new thread. + let (_, t3) = manager + .resolve_thread("user-1", "gateway", Some("ext-xyz")) + .await; + assert_ne!(t1, t3); + } + + #[tokio::test] + async fn test_resolve_thread_none_vs_some_external_id() { + let manager = SessionManager::new(); + + // None external_thread_id is a distinct key from Some("ext-1"). + let (_, t_none) = manager.resolve_thread("user-1", "cli", None).await; + let (_, t_some) = manager.resolve_thread("user-1", "cli", Some("ext-1")).await; + assert_ne!(t_none, t_some); + } + + #[tokio::test] + async fn test_resolve_thread_different_users_isolated() { + let manager = SessionManager::new(); + + let (_, t1) = manager + .resolve_thread("user-a", "gateway", Some("same-ext")) + .await; + let (_, t2) = manager + .resolve_thread("user-b", "gateway", Some("same-ext")) + .await; + + // Same channel + same external ID but different users = different threads + assert_ne!(t1, t2); + } + + #[tokio::test] + async fn test_resolve_thread_different_channels_isolated() { + let manager = SessionManager::new(); + + let (_, t1) = manager + .resolve_thread("user-1", "gateway", Some("thread-x")) + .await; + let (_, t2) = manager + .resolve_thread("user-1", "telegram", Some("thread-x")) + .await; + + // Same user + same external ID but different channels = different threads + assert_ne!(t1, t2); + } + + #[tokio::test] + async fn test_resolve_thread_stale_mapping_creates_new_thread() { + let manager = SessionManager::new(); + + // Create a thread normally + let (session, original_tid) = manager + .resolve_thread("user-1", "gateway", Some("ext-1")) + .await; + + // Simulate the thread being removed from the session (e.g. pruned) + { + let mut sess = session.lock().await; + sess.threads.remove(&original_tid); + } + + // Next resolve should detect the stale mapping and create a fresh thread + let (_, new_tid) = manager + .resolve_thread("user-1", "gateway", Some("ext-1")) + .await; + assert_ne!(original_tid, new_tid); + + // The new thread should actually exist in the session + let sess = session.lock().await; + assert!(sess.threads.contains_key(&new_tid)); + } + + #[tokio::test] + async fn test_register_thread_preserves_uuid_on_resolve() { + use crate::agent::session::{Session, Thread}; + + let manager = SessionManager::new(); + let known_uuid = Uuid::new_v4(); + + let session = Arc::new(Mutex::new(Session::new("user-web"))); + let session_id = { + let sess = session.lock().await; + sess.id + }; + + // Simulate hydration: create thread with a known UUID + { + let mut sess = session.lock().await; + let thread = Thread::with_id(known_uuid, session_id); + sess.threads.insert(known_uuid, thread); + } + + // Register it + manager + .register_thread("user-web", "gateway", known_uuid, Arc::clone(&session)) + .await; + + // resolve_thread with UUID as external_thread_id MUST return the same UUID, + // not mint a new one (this was the root cause of the "wrong conversation" bug) + let (_, resolved) = manager + .resolve_thread("user-web", "gateway", Some(&known_uuid.to_string())) + .await; + assert_eq!(resolved, known_uuid); + } + + #[tokio::test] + async fn test_register_thread_idempotent() { + use crate::agent::session::{Session, Thread}; + + let manager = SessionManager::new(); + let tid = Uuid::new_v4(); + + let session = Arc::new(Mutex::new(Session::new("user-idem"))); + { + let mut sess = session.lock().await; + let thread = Thread::with_id(tid, sess.id); + sess.threads.insert(tid, thread); + } + + // Register twice + manager + .register_thread("user-idem", "gateway", tid, Arc::clone(&session)) + .await; + manager + .register_thread("user-idem", "gateway", tid, Arc::clone(&session)) + .await; + + // Should still resolve to the same thread + let (_, resolved) = manager + .resolve_thread("user-idem", "gateway", Some(&tid.to_string())) + .await; + assert_eq!(resolved, tid); + } + + #[tokio::test] + async fn test_register_thread_creates_undo_manager() { + use crate::agent::session::{Session, Thread}; + + let manager = SessionManager::new(); + let tid = Uuid::new_v4(); + + let session = Arc::new(Mutex::new(Session::new("user-undo"))); + { + let mut sess = session.lock().await; + let thread = Thread::with_id(tid, sess.id); + sess.threads.insert(tid, thread); + } + + manager + .register_thread("user-undo", "gateway", tid, Arc::clone(&session)) + .await; + + // Undo manager should exist for the registered thread + let undo = manager.get_undo_manager(tid).await; + let undo2 = manager.get_undo_manager(tid).await; + assert!(Arc::ptr_eq(&undo, &undo2)); + } + + #[tokio::test] + async fn test_register_thread_stores_session() { + use crate::agent::session::{Session, Thread}; + + let manager = SessionManager::new(); + let tid = Uuid::new_v4(); + + let session = Arc::new(Mutex::new(Session::new("user-new"))); + { + let mut sess = session.lock().await; + let thread = Thread::with_id(tid, sess.id); + sess.threads.insert(tid, thread); + } + + // The user has no session yet in the manager + { + let sessions = manager.sessions.read().await; + assert!(!sessions.contains_key("user-new")); + } + + manager + .register_thread("user-new", "gateway", tid, Arc::clone(&session)) + .await; + + // Now the session should be tracked + { + let sessions = manager.sessions.read().await; + assert!(sessions.contains_key("user-new")); + } + } + + #[tokio::test] + async fn test_multiple_threads_per_user() { + let manager = SessionManager::new(); + + let (_, t1) = manager + .resolve_thread("user-1", "gateway", Some("thread-a")) + .await; + let (_, t2) = manager + .resolve_thread("user-1", "gateway", Some("thread-b")) + .await; + let (session, t3) = manager + .resolve_thread("user-1", "gateway", Some("thread-c")) + .await; + + // All three should be distinct + assert_ne!(t1, t2); + assert_ne!(t2, t3); + assert_ne!(t1, t3); + + // All three should exist in the same session + let sess = session.lock().await; + assert!(sess.threads.contains_key(&t1)); + assert!(sess.threads.contains_key(&t2)); + assert!(sess.threads.contains_key(&t3)); + } + + #[tokio::test] + async fn test_prune_cleans_thread_map_and_undo_managers() { + let manager = SessionManager::new(); + + let (stale_session, stale_tid) = manager.resolve_thread("user-stale", "cli", None).await; + + // Backdate the session + { + let mut sess = stale_session.lock().await; + sess.last_active_at = chrono::Utc::now() - chrono::TimeDelta::seconds(86400 * 30); + } + + // Verify thread_map and undo_managers have entries + { + let tm = manager.thread_map.read().await; + assert!(!tm.is_empty()); + } + { + let um = manager.undo_managers.read().await; + assert!(um.contains_key(&stale_tid)); + } + + let pruned = manager + .prune_stale_sessions(std::time::Duration::from_secs(86400 * 7)) + .await; + assert_eq!(pruned, 1); + + // Thread map and undo managers should be cleaned up + { + let tm = manager.thread_map.read().await; + assert!(tm.is_empty()); + } + { + let um = manager.undo_managers.read().await; + assert!(!um.contains_key(&stale_tid)); + } + } + + #[tokio::test] + async fn test_resolve_thread_active_thread_set() { + let manager = SessionManager::new(); + + let (session, thread_id) = manager + .resolve_thread("user-1", "gateway", Some("ext-1")) + .await; + + // The resolved thread should be set as the active thread + let sess = session.lock().await; + assert_eq!(sess.active_thread, Some(thread_id)); + } + + #[tokio::test] + async fn test_register_then_resolve_different_channel_creates_new() { + use crate::agent::session::{Session, Thread}; + + let manager = SessionManager::new(); + let tid = Uuid::new_v4(); + + let session = Arc::new(Mutex::new(Session::new("user-cross"))); + { + let mut sess = session.lock().await; + let thread = Thread::with_id(tid, sess.id); + sess.threads.insert(tid, thread); + } + + // Register on "gateway" channel + manager + .register_thread("user-cross", "gateway", tid, Arc::clone(&session)) + .await; + + // Resolve on a different channel with the same UUID string should NOT + // find the registered thread (channel is part of the key) + let (_, resolved) = manager + .resolve_thread("user-cross", "telegram", Some(&tid.to_string())) + .await; + assert_ne!(resolved, tid); + } } diff --git a/src/agent/submission.rs b/src/agent/submission.rs index 7a28356c..11f86263 100644 --- a/src/agent/submission.rs +++ b/src/agent/submission.rs @@ -43,6 +43,49 @@ impl SubmissionParser { if lower == "/thread new" || lower == "/new" { return Submission::NewThread; } + // System commands (bypass thread-state checks) + if lower == "/help" || lower == "/?" { + return Submission::SystemCommand { + command: "help".to_string(), + args: vec![], + }; + } + if lower == "/version" { + return Submission::SystemCommand { + command: "version".to_string(), + args: vec![], + }; + } + if lower == "/tools" { + return Submission::SystemCommand { + command: "tools".to_string(), + args: vec![], + }; + } + if lower == "/ping" { + return Submission::SystemCommand { + command: "ping".to_string(), + args: vec![], + }; + } + if lower == "/debug" { + return Submission::SystemCommand { + command: "debug".to_string(), + args: vec![], + }; + } + if lower.starts_with("/model") { + let args: Vec = trimmed + .split_whitespace() + .skip(1) + .map(|s| s.to_string()) + .collect(); + return Submission::SystemCommand { + command: "model".to_string(), + args, + }; + } + if lower == "/quit" || lower == "/exit" || lower == "/shutdown" { return Submission::Quit; } @@ -172,6 +215,15 @@ pub enum Submission { /// Quit the agent. Bypasses thread-state checks. Quit, + + /// System command (help, model, version, tools, ping, debug). + /// Bypasses thread-state checks and safety validation. + SystemCommand { + /// The command name (e.g. "help", "model", "version"). + command: String, + /// Arguments to the command. + args: Vec, + }, } impl Submission { @@ -238,6 +290,7 @@ impl Submission { | Self::Heartbeat | Self::Summarize | Self::Suggest + | Self::SystemCommand { .. } ) } } @@ -504,6 +557,84 @@ mod tests { ); } + #[test] + fn test_parser_system_command_help() { + let submission = SubmissionParser::parse("/help"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "help" && args.is_empty()) + ); + + let submission = SubmissionParser::parse("/?"); + assert!( + matches!(submission, Submission::SystemCommand { command, .. } if command == "help") + ); + + let submission = SubmissionParser::parse("/HELP"); + assert!( + matches!(submission, Submission::SystemCommand { command, .. } if command == "help") + ); + } + + #[test] + fn test_parser_system_command_model() { + // No args: show current model + let submission = SubmissionParser::parse("/model"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args.is_empty()) + ); + + // With args: switch model + let submission = SubmissionParser::parse("/model gpt-4o"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args == vec!["gpt-4o"]) + ); + + // Case insensitive command, preserves arg case + let submission = SubmissionParser::parse("/MODEL Claude-3.5"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "model" && args == vec!["Claude-3.5"]) + ); + } + + #[test] + fn test_parser_system_command_version() { + let submission = SubmissionParser::parse("/version"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "version" && args.is_empty()) + ); + } + + #[test] + fn test_parser_system_command_tools() { + let submission = SubmissionParser::parse("/tools"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "tools" && args.is_empty()) + ); + } + + #[test] + fn test_parser_system_command_ping() { + let submission = SubmissionParser::parse("/ping"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "ping" && args.is_empty()) + ); + } + + #[test] + fn test_parser_system_command_debug() { + let submission = SubmissionParser::parse("/debug"); + assert!( + matches!(submission, Submission::SystemCommand { command, args } if command == "debug" && args.is_empty()) + ); + } + + #[test] + fn test_parser_system_command_is_control() { + let submission = SubmissionParser::parse("/help"); + assert!(submission.is_control()); + assert!(!submission.starts_turn()); + } + #[test] fn test_parser_quit() { assert!(matches!(SubmissionParser::parse("/quit"), Submission::Quit)); diff --git a/src/agent/worker.rs b/src/agent/worker.rs index c05ece63..77b11004 100644 --- a/src/agent/worker.rs +++ b/src/agent/worker.rs @@ -272,7 +272,10 @@ Report when the job is complete or if you encounter issues you cannot resolve."# )); } } - RespondResult::ToolCalls(tool_calls) => { + RespondResult::ToolCalls { + tool_calls, + content, + } => { // Model returned tool calls - execute them tracing::debug!( "Job {} respond_with_tools returned {} tool calls", @@ -280,6 +283,14 @@ Report when the job is complete or if you encounter issues you cannot resolve."# tool_calls.len() ); + // Add assistant message with tool_calls (OpenAI protocol) + reason_ctx + .messages + .push(ChatMessage::assistant_with_tool_calls( + content, + tool_calls.clone(), + )); + for tc in tool_calls { let result = self.execute_tool(&tc.name, &tc.arguments).await; @@ -417,14 +428,51 @@ Report when the job is complete or if you encounter issues you cannot resolve."# .into()); } - // Execute with timeout and timing + tracing::debug!( + tool = %tool_name, + params = %params, + job = %job_id, + "Tool call started" + ); + + // Execute with per-tool timeout and timing + let tool_timeout = tool.execution_timeout(); let start = std::time::Instant::now(); - let result = tokio::time::timeout(Duration::from_secs(60), async { + let result = tokio::time::timeout(tool_timeout, async { tool.execute(params.clone(), &job_ctx).await }) .await; let elapsed = start.elapsed(); + match &result { + Ok(Ok(output)) => { + let result_str = serde_json::to_string(&output.result) + .unwrap_or_else(|_| "".to_string()); + tracing::debug!( + tool = %tool_name, + elapsed_ms = elapsed.as_millis() as u64, + result = %result_str, + "Tool call succeeded" + ); + } + Ok(Err(e)) => { + tracing::debug!( + tool = %tool_name, + elapsed_ms = elapsed.as_millis() as u64, + error = %e, + "Tool call failed" + ); + } + Err(_) => { + tracing::debug!( + tool = %tool_name, + elapsed_ms = elapsed.as_millis() as u64, + timeout_secs = tool_timeout.as_secs(), + "Tool call timed out" + ); + } + } + // Record action in memory and get the ActionRecord for persistence let action = match &result { Ok(Ok(output)) => { @@ -479,7 +527,7 @@ Report when the job is complete or if you encounter issues you cannot resolve."# let output = result .map_err(|_| crate::error::ToolError::Timeout { name: tool_name.to_string(), - timeout: Duration::from_secs(60), + timeout: tool_timeout, })? .map_err(|e| crate::error::ToolError::ExecutionFailed { name: tool_name.to_string(), diff --git a/src/bootstrap.rs b/src/bootstrap.rs new file mode 100644 index 00000000..72ff65c0 --- /dev/null +++ b/src/bootstrap.rs @@ -0,0 +1,325 @@ +//! Bootstrap configuration for IronClaw. +//! +//! These are the only settings that MUST live on disk because they're needed +//! before the database connection is established. Everything else lives in the +//! `settings` table in PostgreSQL. +//! +//! File: `~/.ironclaw/bootstrap.json` + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::settings::KeySource; + +/// Minimal config needed to connect to the database and decrypt secrets. +/// +/// This is the only JSON file IronClaw reads from disk at startup. +/// All other configuration lives in the `settings` table in PostgreSQL. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BootstrapConfig { + /// Database connection URL (postgres://...). + #[serde(default)] + pub database_url: Option, + + /// Database connection pool size. + #[serde(default)] + pub database_pool_size: Option, + + /// Source for the secrets master key. + #[serde(default)] + pub secrets_master_key_source: KeySource, + + /// Whether onboarding wizard has been completed. + #[serde(default)] + pub onboard_completed: bool, +} + +impl Default for BootstrapConfig { + fn default() -> Self { + Self { + database_url: None, + database_pool_size: None, + secrets_master_key_source: KeySource::None, + onboard_completed: false, + } + } +} + +impl BootstrapConfig { + /// Default bootstrap file path: `~/.ironclaw/bootstrap.json`. + pub fn default_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironclaw") + .join("bootstrap.json") + } + + /// Legacy settings.json path (for migration detection). + pub fn legacy_settings_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironclaw") + .join("settings.json") + } + + /// Load from the default path, falling back to legacy settings.json, + /// then to defaults if neither exists. + pub fn load() -> Self { + let bootstrap_path = Self::default_path(); + if bootstrap_path.exists() { + return Self::load_from(&bootstrap_path); + } + + // Fall back to legacy settings.json (extract just the 4 bootstrap fields) + let legacy_path = Self::legacy_settings_path(); + if legacy_path.exists() { + return Self::load_from_legacy(&legacy_path); + } + + Self::default() + } + + /// Load from a specific path. + pub fn load_from(path: &PathBuf) -> Self { + match std::fs::read_to_string(path) { + Ok(data) => serde_json::from_str(&data).unwrap_or_default(), + Err(_) => Self::default(), + } + } + + /// Extract bootstrap fields from a legacy settings.json. + fn load_from_legacy(path: &PathBuf) -> Self { + match std::fs::read_to_string(path) { + Ok(data) => { + // The legacy Settings struct is a superset; serde will ignore extra fields. + serde_json::from_str(&data).unwrap_or_default() + } + Err(_) => Self::default(), + } + } + + /// Save to the default path. + pub fn save(&self) -> std::io::Result<()> { + self.save_to(&Self::default_path()) + } + + /// Save to a specific path. + pub fn save_to(&self, path: &PathBuf) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(self) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; + std::fs::write(path, json) + } +} + +/// One-time migration from disk config files to the database settings table. +/// +/// On first boot after upgrade, checks if: +/// 1. `~/.ironclaw/settings.json` exists +/// 2. The DB settings table is empty for this user +/// +/// If both conditions hold, migrates settings, MCP servers, and session data +/// to the database, writes `bootstrap.json`, and renames old files to `.migrated`. +pub async fn migrate_disk_to_db( + store: &crate::history::Store, + user_id: &str, +) -> Result<(), MigrationError> { + let legacy_settings_path = BootstrapConfig::legacy_settings_path(); + if !legacy_settings_path.exists() { + tracing::debug!("No legacy settings.json found, skipping disk-to-DB migration"); + return Ok(()); + } + + // Only migrate if DB is empty for this user + let has_settings = store.has_settings(user_id).await.map_err(|e| { + MigrationError::Database(format!("Failed to check existing settings: {}", e)) + })?; + if has_settings { + tracing::debug!( + "DB already has settings for user '{}', skipping migration", + user_id + ); + return Ok(()); + } + + tracing::info!("Migrating disk settings to database..."); + + // 1. Load and migrate settings.json + let settings = crate::settings::Settings::load_from(&legacy_settings_path); + let db_map = settings.to_db_map(); + if !db_map.is_empty() { + store + .set_all_settings(user_id, &db_map) + .await + .map_err(|e| { + MigrationError::Database(format!("Failed to write settings to DB: {}", e)) + })?; + tracing::info!("Migrated {} settings to database", db_map.len()); + } + + // 2. Write bootstrap.json with the 4 essential fields + let bootstrap = BootstrapConfig { + database_url: settings.database_url.clone(), + database_pool_size: settings.database_pool_size, + secrets_master_key_source: settings.secrets_master_key_source, + onboard_completed: settings.onboard_completed, + }; + bootstrap + .save() + .map_err(|e| MigrationError::Io(format!("Failed to write bootstrap.json: {}", e)))?; + tracing::info!("Wrote bootstrap.json"); + + // 3. Migrate mcp-servers.json if it exists + let ironclaw_dir = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironclaw"); + let mcp_path = ironclaw_dir.join("mcp-servers.json"); + if mcp_path.exists() { + match std::fs::read_to_string(&mcp_path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(value) => { + store + .set_setting(user_id, "mcp_servers", &value) + .await + .map_err(|e| { + MigrationError::Database(format!( + "Failed to write MCP servers to DB: {}", + e + )) + })?; + tracing::info!("Migrated mcp-servers.json to database"); + + rename_to_migrated(&mcp_path); + } + Err(e) => { + tracing::warn!("Failed to parse mcp-servers.json: {}", e); + } + }, + Err(e) => { + tracing::warn!("Failed to read mcp-servers.json: {}", e); + } + } + } + + // 4. Migrate session.json if it exists + let session_path = ironclaw_dir.join("session.json"); + if session_path.exists() { + match std::fs::read_to_string(&session_path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(value) => { + store + .set_setting(user_id, "nearai.session", &value) + .await + .map_err(|e| { + MigrationError::Database(format!( + "Failed to write session to DB: {}", + e + )) + })?; + tracing::info!("Migrated session.json to database"); + + rename_to_migrated(&session_path); + } + Err(e) => { + tracing::warn!("Failed to parse session.json: {}", e); + } + }, + Err(e) => { + tracing::warn!("Failed to read session.json: {}", e); + } + } + } + + // 5. Rename settings.json to .migrated (don't delete, safety net) + rename_to_migrated(&legacy_settings_path); + + tracing::info!("Disk-to-DB migration complete"); + Ok(()) +} + +/// Rename a file to `.migrated` as a safety net. +fn rename_to_migrated(path: &PathBuf) { + let mut migrated = path.as_os_str().to_owned(); + migrated.push(".migrated"); + if let Err(e) = std::fs::rename(path, &migrated) { + tracing::warn!("Failed to rename {} to .migrated: {}", path.display(), e); + } +} + +/// Errors that can occur during disk-to-DB migration. +#[derive(Debug, thiserror::Error)] +pub enum MigrationError { + #[error("Database error: {0}")] + Database(String), + #[error("IO error: {0}")] + Io(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_bootstrap_save_load() { + let dir = tempdir().unwrap(); + let path = dir.path().join("bootstrap.json"); + + let config = BootstrapConfig { + database_url: Some("postgres://localhost/test".to_string()), + database_pool_size: Some(5), + secrets_master_key_source: KeySource::Keychain, + onboard_completed: true, + }; + + config.save_to(&path).unwrap(); + + let loaded = BootstrapConfig::load_from(&path); + assert_eq!( + loaded.database_url, + Some("postgres://localhost/test".to_string()) + ); + assert_eq!(loaded.database_pool_size, Some(5)); + assert_eq!(loaded.secrets_master_key_source, KeySource::Keychain); + assert!(loaded.onboard_completed); + } + + #[test] + fn test_bootstrap_from_legacy_settings() { + let dir = tempdir().unwrap(); + let path = dir.path().join("settings.json"); + + // Write a legacy settings.json with many extra fields + let legacy = serde_json::json!({ + "database_url": "postgres://localhost/ironclaw", + "database_pool_size": 10, + "secrets_master_key_source": "keychain", + "onboard_completed": true, + "selected_model": "claude-3-5-sonnet", + "agent": { "name": "testbot", "max_parallel_jobs": 3 }, + "heartbeat": { "enabled": true } + }); + std::fs::write(&path, serde_json::to_string_pretty(&legacy).unwrap()).unwrap(); + + let config = BootstrapConfig::load_from_legacy(&path); + assert_eq!( + config.database_url, + Some("postgres://localhost/ironclaw".to_string()) + ); + assert_eq!(config.database_pool_size, Some(10)); + assert_eq!(config.secrets_master_key_source, KeySource::Keychain); + assert!(config.onboard_completed); + } + + #[test] + fn test_bootstrap_defaults() { + let config = BootstrapConfig::default(); + assert!(config.database_url.is_none()); + assert!(config.database_pool_size.is_none()); + assert_eq!(config.secrets_master_key_source, KeySource::None); + assert!(!config.onboard_completed); + } +} diff --git a/src/channels/channel.rs b/src/channels/channel.rs index c5575ccd..d87c8240 100644 --- a/src/channels/channel.rs +++ b/src/channels/channel.rs @@ -114,6 +114,12 @@ pub enum StatusUpdate { StreamChunk(String), /// General status message. Status(String), + /// A sandbox job has started (shown as a clickable card in the UI). + JobStarted { + job_id: String, + title: String, + browse_url: String, + }, /// Tool requires user approval before execution. ApprovalNeeded { request_id: String, @@ -121,6 +127,19 @@ pub enum StatusUpdate { description: String, parameters: serde_json::Value, }, + /// Extension needs user authentication (token or OAuth). + AuthRequired { + extension_name: String, + instructions: Option, + auth_url: Option, + setup_url: Option, + }, + /// Extension authentication completed. + AuthCompleted { + extension_name: String, + success: bool, + message: String, + }, } /// Trait for message channels. diff --git a/src/channels/repl.rs b/src/channels/repl.rs index ef5db362..cbfd1c4a 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -42,12 +42,25 @@ const SLASH_COMMANDS: &[&str] = &[ "/quit", "/exit", "/debug", + "/model", "/undo", "/redo", "/clear", "/compact", "/new", "/interrupt", + "/version", + "/tools", + "/ping", + "/job", + "/status", + "/cancel", + "/list", + "/heartbeat", + "/summarize", + "/suggest", + "/thread", + "/resume", ]; /// Rustyline helper for slash-command tab completion. @@ -295,10 +308,11 @@ impl Channel for ReplChannel { continue; } - // Handle local REPL commands + // Handle local REPL commands (only commands that need + // immediate local handling stay here) match line.to_lowercase().as_str() { "/quit" | "/exit" => break, - "/help" | "/?" => { + "/help" => { print_help(); continue; } @@ -413,6 +427,15 @@ impl Channel for ReplChannel { print!("{chunk}"); let _ = io::stdout().flush(); } + StatusUpdate::JobStarted { + job_id, + title, + browse_url, + } => { + eprintln!( + " \x1b[36m[job]\x1b[0m {title} \x1b[90m({job_id})\x1b[0m \x1b[4m{browse_url}\x1b[0m" + ); + } StatusUpdate::Status(msg) => { if debug || msg.contains("approval") || msg.contains("Approval") { eprintln!(" \x1b[90m{msg}\x1b[0m"); @@ -472,6 +495,33 @@ impl Channel for ReplChannel { eprintln!(" {bot_border}"); eprintln!(); } + StatusUpdate::AuthRequired { + extension_name, + instructions, + setup_url, + .. + } => { + eprintln!(); + eprintln!("\x1b[33m Authentication required for {extension_name}\x1b[0m"); + if let Some(ref instr) = instructions { + eprintln!(" {instr}"); + } + if let Some(ref url) = setup_url { + eprintln!(" \x1b[4m{url}\x1b[0m"); + } + eprintln!(); + } + StatusUpdate::AuthCompleted { + extension_name, + success, + message, + } => { + if success { + eprintln!("\x1b[32m {extension_name}: {message}\x1b[0m"); + } else { + eprintln!("\x1b[31m {extension_name}: {message}\x1b[0m"); + } + } } Ok(()) } diff --git a/src/channels/wasm/bundled.rs b/src/channels/wasm/bundled.rs index 9825b72b..1974be41 100644 --- a/src/channels/wasm/bundled.rs +++ b/src/channels/wasm/bundled.rs @@ -1,68 +1,125 @@ -//! Bundled WASM channels that can be installed locally. +//! Known WASM channels that can be installed from build artifacts. +//! +//! Instead of embedding WASM binaries in the host binary via include_bytes!, +//! channels are compiled separately and installed from their build output +//! directories during onboarding. +//! +//! Channel source layout: +//! channels-src// +//! target/wasm32-wasip2/release/_channel.wasm +//! .capabilities.json -use std::path::Path; +use std::path::{Path, PathBuf}; use tokio::fs; -#[derive(Clone, Copy)] -struct BundledChannel { - name: &'static str, - wasm: &'static [u8], - capabilities: &'static [u8], +/// Compile-time project root, used to locate channels-src/ in dev builds. +const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR"); + +/// Known channel names and their crate names (for locating build artifacts). +const KNOWN_CHANNELS: &[(&str, &str)] = &[ + ("telegram", "telegram_channel"), + ("slack", "slack_channel"), + ("whatsapp", "whatsapp_channel"), +]; + +/// Names of known channels that can be installed. +pub fn bundled_channel_names() -> Vec<&'static str> { + KNOWN_CHANNELS.iter().map(|(name, _)| *name).collect() } -/// Names of bundled channels shipped with IronClaw. -pub fn bundled_channel_names() -> &'static [&'static str] { - &["telegram"] +/// Resolve the channels source directory. +/// +/// Checks (in order): +/// 1. `IRONCLAW_CHANNELS_SRC` env var +/// 2. `/channels-src/` (dev builds) +fn channels_src_dir() -> PathBuf { + if let Ok(dir) = std::env::var("IRONCLAW_CHANNELS_SRC") { + return PathBuf::from(dir); + } + PathBuf::from(CARGO_MANIFEST_DIR).join("channels-src") } -/// Install a bundled channel into a channels directory. +/// Locate the build artifacts for a channel. +/// +/// Returns (wasm_path, capabilities_path) or an error if files are missing. +fn locate_channel_artifacts(name: &str) -> Result<(PathBuf, PathBuf), String> { + let (_, crate_name) = KNOWN_CHANNELS + .iter() + .find(|(n, _)| *n == name) + .ok_or_else(|| format!("Unknown channel '{}'", name))?; + + let src_dir = channels_src_dir(); + let channel_dir = src_dir.join(name); + + let wasm_path = channel_dir + .join("target/wasm32-wasip2/release") + .join(format!("{}.wasm", crate_name)); + + let caps_path = channel_dir.join(format!("{}.capabilities.json", name)); + + if !wasm_path.exists() { + return Err(format!( + "Channel '{}' WASM not found at {}. Build it first:\n \ + cd {} && cargo build --target wasm32-wasip2 --release", + name, + wasm_path.display(), + channel_dir.display() + )); + } + + if !caps_path.exists() { + return Err(format!( + "Channel '{}' capabilities not found at {}", + name, + caps_path.display() + )); + } + + Ok((wasm_path, caps_path)) +} + +/// Install a channel from build artifacts into the channels directory. pub async fn install_bundled_channel( name: &str, target_dir: &Path, force: bool, ) -> Result<(), String> { - let channel = bundled_channel(name) - .ok_or_else(|| format!("Unknown bundled channel '{}'", name.to_lowercase()))?; + let (wasm_src, caps_src) = locate_channel_artifacts(name)?; fs::create_dir_all(target_dir) .await .map_err(|e| format!("Failed to create channels directory: {}", e))?; - let wasm_path = target_dir.join(format!("{}.wasm", channel.name)); - let caps_path = target_dir.join(format!("{}.capabilities.json", channel.name)); + let wasm_dst = target_dir.join(format!("{}.wasm", name)); + let caps_dst = target_dir.join(format!("{}.capabilities.json", name)); - let has_existing = wasm_path.exists() || caps_path.exists(); + let has_existing = wasm_dst.exists() || caps_dst.exists(); if has_existing && !force { return Err(format!( "Channel '{}' already exists at {}", - channel.name, + name, target_dir.display() )); } - fs::write(&wasm_path, channel.wasm) + fs::copy(&wasm_src, &wasm_dst) .await - .map_err(|e| format!("Failed to write {}: {}", wasm_path.display(), e))?; - fs::write(&caps_path, channel.capabilities) + .map_err(|e| format!("Failed to copy {}: {}", wasm_src.display(), e))?; + fs::copy(&caps_src, &caps_dst) .await - .map_err(|e| format!("Failed to write {}: {}", caps_path.display(), e))?; + .map_err(|e| format!("Failed to copy {}: {}", caps_src.display(), e))?; Ok(()) } -fn bundled_channel(name: &str) -> Option { - if name.eq_ignore_ascii_case("telegram") { - Some(BundledChannel { - name: "telegram", - wasm: include_bytes!("../../../channels-src/telegram/telegram.wasm"), - capabilities: include_bytes!( - "../../../channels-src/telegram/telegram.capabilities.json" - ), - }) - } else { - None - } +/// Check which known channels have build artifacts available. +pub fn available_channel_names() -> Vec<&'static str> { + KNOWN_CHANNELS + .iter() + .filter(|(name, _)| locate_channel_artifacts(name).is_ok()) + .map(|(name, _)| *name) + .collect() } #[cfg(test)] @@ -73,31 +130,35 @@ mod tests { use super::*; #[test] - fn test_bundled_channel_names_contains_telegram() { - assert!(bundled_channel_names().contains(&"telegram")); + fn test_known_channels_includes_all_three() { + let names = bundled_channel_names(); + assert!(names.contains(&"telegram")); + assert!(names.contains(&"slack")); + assert!(names.contains(&"whatsapp")); + } + + #[test] + fn test_channels_src_dir_default() { + let dir = channels_src_dir(); + assert!(dir.ends_with("channels-src")); + } + + #[test] + fn test_locate_unknown_channel_errors() { + assert!(locate_channel_artifacts("nonexistent").is_err()); } #[tokio::test] - async fn test_install_bundled_channel_writes_files() { - let dir = tempdir().unwrap(); - - install_bundled_channel("telegram", dir.path(), false) - .await - .unwrap(); - - assert!(dir.path().join("telegram.wasm").exists()); - assert!(dir.path().join("telegram.capabilities.json").exists()); - } - - #[tokio::test] - async fn test_install_bundled_channel_refuses_overwrite_without_force() { + async fn test_install_refuses_overwrite_without_force() { let dir = tempdir().unwrap(); let wasm_path = dir.path().join("telegram.wasm"); fs::write(&wasm_path, b"custom").await.unwrap(); let result = install_bundled_channel("telegram", dir.path(), false).await; + // Either fails because artifacts missing OR because file exists assert!(result.is_err()); + // Original file should be untouched let existing = fs::read(&wasm_path).await.unwrap(); assert_eq!(existing, b"custom"); } diff --git a/src/channels/wasm/mod.rs b/src/channels/wasm/mod.rs index 9f7b7c37..17ac7726 100644 --- a/src/channels/wasm/mod.rs +++ b/src/channels/wasm/mod.rs @@ -89,7 +89,7 @@ mod schema; mod wrapper; // Core types -pub use bundled::{bundled_channel_names, install_bundled_channel}; +pub use bundled::{available_channel_names, bundled_channel_names, install_bundled_channel}; pub use capabilities::{ChannelCapabilities, EmitRateLimitConfig, HttpEndpointConfig, PollConfig}; pub use error::WasmChannelError; pub use host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage}; diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 14a33a24..127fa372 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -141,6 +141,22 @@ impl ChannelStoreData { result } + + /// Replace injected credential values with `[REDACTED]` in text. + /// + /// Prevents credentials from leaking through error messages, logs, or + /// return values to WASM. reqwest::Error includes the full URL in its + /// Display output, so any error from an injected-URL request will + /// contain the raw credential unless we scrub it. + fn redact_credentials(&self, text: &str) -> String { + let mut result = text.to_string(); + for (name, value) in &self.credentials { + if !value.is_empty() { + result = result.replace(value, &format!("[REDACTED:{}]", name)); + } + } + result + } } // Implement WasiView to provide WASI context and resource table @@ -187,6 +203,7 @@ impl near::agent::channel_host::Host for ChannelStoreData { url: String, headers_json: String, body: Option>, + timeout_ms: Option, ) -> Result { tracing::info!( method = %method, @@ -276,12 +293,21 @@ impl near::agent::channel_host::Host for ChannelStoreData { request = request.body(body_bytes); } - // Send request with timeout - let response = request - .timeout(std::time::Duration::from_secs(30)) - .send() - .await - .map_err(|e| format!("HTTP request failed: {}", e))?; + // Send request with caller-specified timeout (default 30s). + // Cap at callback_timeout to prevent outliving the host wrapper. + let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(30_000) as u64); + let response = request.timeout(timeout).send().await.map_err(|e| { + // Walk the full error chain so we get the actual root cause + // (DNS, TLS, connection refused, etc.) instead of just + // "error sending request for url (...)". + let mut chain = format!("HTTP request failed: {}", e); + let mut source = std::error::Error::source(&e); + while let Some(cause) = source { + chain.push_str(&format!(" -> {}", cause)); + source = cause.source(); + } + chain + })?; let status = response.status().as_u16(); let response_headers: std::collections::HashMap = response @@ -330,6 +356,11 @@ impl near::agent::channel_host::Host for ChannelStoreData { }) }); + // Scrub credential values from error messages before logging or returning + // to WASM. reqwest::Error includes the full URL (with injected credentials) + // in its Display output. + let result = result.map_err(|e| self.redact_credentials(&e)); + match &result { Ok(resp) => { tracing::info!(status = resp.status, "http_request completed successfully"); @@ -1858,6 +1889,29 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha message: format!("Approval needed: {} - {}", tool_name, description), metadata_json, }, + StatusUpdate::JobStarted { job_id, title, .. } => wit_channel::StatusUpdate { + status: wit_channel::StatusType::Thinking, + message: format!("Job started: {} ({})", title, job_id), + metadata_json, + }, + StatusUpdate::AuthRequired { extension_name, .. } => wit_channel::StatusUpdate { + status: wit_channel::StatusType::Thinking, + message: format!("Auth required: {}", extension_name), + metadata_json, + }, + StatusUpdate::AuthCompleted { + extension_name, + success, + .. + } => wit_channel::StatusUpdate { + status: wit_channel::StatusType::Thinking, + message: format!( + "Auth {}: {}", + if *success { "completed" } else { "failed" }, + extension_name + ), + metadata_json, + }, } } @@ -2350,4 +2404,78 @@ mod tests { assert_eq!(cloned.message, "hello"); assert_eq!(cloned.metadata_json, "{\"a\":1}"); } + + #[test] + fn test_redact_credentials_replaces_values() { + use super::ChannelStoreData; + + let mut creds = std::collections::HashMap::new(); + creds.insert( + "TELEGRAM_BOT_TOKEN".to_string(), + "8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis".to_string(), + ); + creds.insert("OTHER_SECRET".to_string(), "s3cret".to_string()); + + let store = + ChannelStoreData::new(1024 * 1024, "test", ChannelCapabilities::default(), creds); + + let error = "HTTP request failed: error sending request for url \ + (https://api.telegram.org/bot8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis/getUpdates)"; + + let redacted = store.redact_credentials(error); + + assert!( + !redacted.contains("8218490433:AAEZeUxwqZ5OO3mOCXv7fKvpdhDgsmBBNis"), + "credential value should be redacted" + ); + assert!( + redacted.contains("[REDACTED:TELEGRAM_BOT_TOKEN]"), + "redacted text should contain placeholder name" + ); + assert!( + !redacted.contains("s3cret"), + "other credentials should also be redacted" + ); + } + + #[test] + fn test_redact_credentials_no_op_without_credentials() { + use super::ChannelStoreData; + + let store = ChannelStoreData::new( + 1024 * 1024, + "test", + ChannelCapabilities::default(), + std::collections::HashMap::new(), + ); + + let input = "some error message"; + assert_eq!(store.redact_credentials(input), input); + } + + #[test] + fn test_redact_credentials_skips_empty_values() { + use super::ChannelStoreData; + + let mut creds = std::collections::HashMap::new(); + creds.insert("EMPTY_TOKEN".to_string(), String::new()); + + let store = + ChannelStoreData::new(1024 * 1024, "test", ChannelCapabilities::default(), creds); + + let input = "should not match anything"; + assert_eq!(store.redact_credentials(input), input); + } + + /// Verify that the block_on-inside-spawn_blocking pattern used by the WASM + /// channel HTTP host function doesn't deadlock or panic. + #[tokio::test] + async fn test_block_on_inside_spawn_blocking_does_not_deadlock() { + let result = tokio::task::spawn_blocking(|| { + tokio::runtime::Handle::current().block_on(async { 42 }) + }) + .await + .expect("spawn_blocking panicked"); + assert_eq!(result, 42); + } } diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index 46590402..c4df28c1 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -10,7 +10,7 @@ //! ◄── GET /api/chat/events ── SSE stream //! ─── GET /api/chat/ws ─────► WebSocket (bidirectional) //! ─── GET /api/memory/* ────► Workspace -//! ─── GET /api/jobs/* ──────► ContextManager +//! ─── GET /api/jobs/* ──────► Database //! ◄── GET / ───────────────── Static HTML/CSS/JS //! ``` @@ -31,9 +31,10 @@ use tokio_stream::wrappers::ReceiverStream; use crate::agent::SessionManager; use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate}; use crate::config::GatewayConfig; -use crate::context::ContextManager; use crate::error::ChannelError; use crate::extensions::ExtensionManager; +use crate::history::Store; +use crate::orchestrator::job_manager::ContainerJobManager; use crate::tools::ToolRegistry; use crate::workspace::Workspace; @@ -70,11 +71,13 @@ impl GatewayChannel { msg_tx: tokio::sync::RwLock::new(None), sse: SseManager::new(), workspace: None, - context_manager: None, session_manager: None, log_broadcaster: None, extension_manager: None, tool_registry: None, + store: None, + job_manager: None, + prompt_queue: None, user_id: config.user_id.clone(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())), @@ -93,11 +96,13 @@ impl GatewayChannel { msg_tx: tokio::sync::RwLock::new(None), sse: SseManager::new(), workspace: self.state.workspace.clone(), - context_manager: self.state.context_manager.clone(), session_manager: self.state.session_manager.clone(), log_broadcaster: self.state.log_broadcaster.clone(), extension_manager: self.state.extension_manager.clone(), tool_registry: self.state.tool_registry.clone(), + store: self.state.store.clone(), + job_manager: self.state.job_manager.clone(), + prompt_queue: self.state.prompt_queue.clone(), user_id: self.state.user_id.clone(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: self.state.ws_tracker.clone(), @@ -112,12 +117,6 @@ impl GatewayChannel { self } - /// Inject the context manager for the jobs API. - pub fn with_context_manager(mut self, cm: Arc) -> Self { - self.rebuild_state(|s| s.context_manager = Some(cm)); - self - } - /// Inject the session manager for thread/session info. pub fn with_session_manager(mut self, sm: Arc) -> Self { self.rebuild_state(|s| s.session_manager = Some(sm)); @@ -142,6 +141,34 @@ impl GatewayChannel { self } + /// Inject the database store for sandbox job persistence. + pub fn with_store(mut self, store: Arc) -> Self { + self.rebuild_state(|s| s.store = Some(store)); + self + } + + /// Inject the container job manager for sandbox operations. + pub fn with_job_manager(mut self, jm: Arc) -> Self { + self.rebuild_state(|s| s.job_manager = Some(jm)); + self + } + + /// Inject the prompt queue for Claude Code follow-up prompts. + pub fn with_prompt_queue( + mut self, + pq: Arc< + tokio::sync::Mutex< + std::collections::HashMap< + uuid::Uuid, + std::collections::VecDeque, + >, + >, + >, + ) -> Self { + self.rebuild_state(|s| s.prompt_queue = Some(pq)); + self + } + /// Get the auth token (for printing to console on startup). pub fn auth_token(&self) -> &str { &self.auth_token @@ -173,11 +200,7 @@ impl Channel for GatewayChannel { ), })?; - let bound_addr = - server::start_server(addr, self.state.clone(), self.auth_token.clone()).await?; - - tracing::info!("Web gateway listening on http://{}", bound_addr); - tracing::info!("Auth token: {}", self.auth_token); + server::start_server(addr, self.state.clone(), self.auth_token.clone()).await?; Ok(Box::pin(ReceiverStream::new(rx))) } @@ -200,17 +223,48 @@ impl Channel for GatewayChannel { async fn send_status( &self, status: StatusUpdate, - _metadata: &serde_json::Value, + metadata: &serde_json::Value, ) -> Result<(), ChannelError> { + let thread_id = metadata + .get("thread_id") + .and_then(|v| v.as_str()) + .map(String::from); let event = match status { - StatusUpdate::Thinking(msg) => SseEvent::Thinking { message: msg }, - StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted { name }, - StatusUpdate::ToolCompleted { name, success } => { - SseEvent::ToolCompleted { name, success } - } - StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult { name, preview }, - StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk { content }, - StatusUpdate::Status(msg) => SseEvent::Status { message: msg }, + StatusUpdate::Thinking(msg) => SseEvent::Thinking { + message: msg, + thread_id: thread_id.clone(), + }, + StatusUpdate::ToolStarted { name } => SseEvent::ToolStarted { + name, + thread_id: thread_id.clone(), + }, + StatusUpdate::ToolCompleted { name, success } => SseEvent::ToolCompleted { + name, + success, + thread_id: thread_id.clone(), + }, + StatusUpdate::ToolResult { name, preview } => SseEvent::ToolResult { + name, + preview, + thread_id: thread_id.clone(), + }, + StatusUpdate::StreamChunk(content) => SseEvent::StreamChunk { + content, + thread_id: thread_id.clone(), + }, + StatusUpdate::Status(msg) => SseEvent::Status { + message: msg, + thread_id: thread_id.clone(), + }, + StatusUpdate::JobStarted { + job_id, + title, + browse_url, + } => SseEvent::JobStarted { + job_id, + title, + browse_url, + }, StatusUpdate::ApprovalNeeded { request_id, tool_name, @@ -223,6 +277,26 @@ impl Channel for GatewayChannel { parameters: serde_json::to_string_pretty(¶meters) .unwrap_or_else(|_| parameters.to_string()), }, + StatusUpdate::AuthRequired { + extension_name, + instructions, + auth_url, + setup_url, + } => SseEvent::AuthRequired { + extension_name, + instructions, + auth_url, + setup_url, + }, + StatusUpdate::AuthCompleted { + extension_name, + success, + message, + } => SseEvent::AuthCompleted { + extension_name, + success, + message, + }, }; self.state.sse.broadcast(event); diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 11363dfa..b4a56bb0 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -28,11 +28,22 @@ use crate::channels::web::auth::{AuthState, auth_middleware}; use crate::channels::web::log_layer::LogBroadcaster; use crate::channels::web::sse::SseManager; use crate::channels::web::types::*; -use crate::context::ContextManager; use crate::extensions::ExtensionManager; +use crate::history::Store; +use crate::orchestrator::job_manager::ContainerJobManager; use crate::tools::ToolRegistry; use crate::workspace::Workspace; +/// Shared prompt queue: maps job IDs to pending follow-up prompts for Claude Code bridges. +pub type PromptQueue = Arc< + tokio::sync::Mutex< + std::collections::HashMap< + uuid::Uuid, + std::collections::VecDeque, + >, + >, +>; + /// Shared state for all gateway handlers. pub struct GatewayState { /// Channel to send messages to the agent loop. @@ -41,8 +52,6 @@ pub struct GatewayState { pub sse: SseManager, /// Workspace for memory API. pub workspace: Option>, - /// Context manager for jobs API. - pub context_manager: Option>, /// Session manager for thread info. pub session_manager: Option>, /// Log broadcaster for the logs SSE endpoint. @@ -51,6 +60,12 @@ pub struct GatewayState { pub extension_manager: Option>, /// Tool registry for listing registered tools. pub tool_registry: Option>, + /// Database store for sandbox job persistence. + pub store: Option>, + /// Container job manager for sandbox operations. + pub job_manager: Option>, + /// Prompt queue for Claude Code follow-up prompts. + pub prompt_queue: Option, /// User ID for this gateway. pub user_id: String, /// Shutdown signal sender. @@ -90,6 +105,8 @@ pub async fn start_server( // Chat .route("/api/chat/send", post(chat_send_handler)) .route("/api/chat/approval", post(chat_approval_handler)) + .route("/api/chat/auth-token", post(chat_auth_token_handler)) + .route("/api/chat/auth-cancel", post(chat_auth_cancel_handler)) .route("/api/chat/events", get(chat_events_handler)) .route("/api/chat/ws", get(chat_ws_handler)) .route("/api/chat/history", get(chat_history_handler)) @@ -106,6 +123,11 @@ pub async fn start_server( .route("/api/jobs/summary", get(jobs_summary_handler)) .route("/api/jobs/{id}", get(jobs_detail_handler)) .route("/api/jobs/{id}/cancel", post(jobs_cancel_handler)) + .route("/api/jobs/{id}/restart", post(jobs_restart_handler)) + .route("/api/jobs/{id}/prompt", post(jobs_prompt_handler)) + .route("/api/jobs/{id}/events", get(jobs_events_handler)) + .route("/api/jobs/{id}/files/list", get(job_files_list_handler)) + .route("/api/jobs/{id}/files/read", get(job_files_read_handler)) // Logs .route("/api/logs/events", get(logs_events_handler)) // Extensions @@ -120,6 +142,30 @@ pub async fn start_server( "/api/extensions/{name}/remove", post(extensions_remove_handler), ) + // Routines + .route("/api/routines", get(routines_list_handler)) + .route("/api/routines/summary", get(routines_summary_handler)) + .route("/api/routines/{id}", get(routines_detail_handler)) + .route("/api/routines/{id}/trigger", post(routines_trigger_handler)) + .route("/api/routines/{id}/toggle", post(routines_toggle_handler)) + .route( + "/api/routines/{id}", + axum::routing::delete(routines_delete_handler), + ) + .route("/api/routines/{id}/runs", get(routines_runs_handler)) + // Settings + .route("/api/settings", get(settings_list_handler)) + .route("/api/settings/export", get(settings_export_handler)) + .route("/api/settings/import", post(settings_import_handler)) + .route("/api/settings/{key}", get(settings_get_handler)) + .route( + "/api/settings/{key}", + axum::routing::put(settings_set_handler), + ) + .route( + "/api/settings/{key}", + axum::routing::delete(settings_delete_handler), + ) // Gateway control plane .route("/api/gateway/status", get(gateway_status_handler)) .route_layer(middleware::from_fn_with_state(auth_state, auth_middleware)); @@ -130,9 +176,18 @@ pub async fn start_server( .route("/style.css", get(css_handler)) .route("/app.js", get(js_handler)); + // Project file serving (no auth, local browsing of sandbox outputs). + // The trailing-slash route serves index.html; the bare route redirects so + // relative paths in the HTML (e.g. href="style.css") resolve correctly. + let projects = Router::new() + .route("/projects/{project_id}", get(project_redirect_handler)) + .route("/projects/{project_id}/", get(project_index_handler)) + .route("/projects/{project_id}/{*path}", get(project_file_handler)); + let app = Router::new() .merge(public) .merge(statics) + .merge(projects) .merge(protected) .with_state(state.clone()); @@ -193,6 +248,7 @@ async fn chat_send_handler( if let Some(ref thread_id) = req.thread_id { msg = msg.with_thread(thread_id); + msg = msg.with_metadata(serde_json::json!({"thread_id": thread_id})); } let msg_id = msg.id; @@ -256,7 +312,12 @@ async fn chat_approval_handler( ) })?; - let msg = IncomingMessage::new("gateway", &state.user_id, content); + let mut msg = IncomingMessage::new("gateway", &state.user_id, content); + + if let Some(ref thread_id) = req.thread_id { + msg = msg.with_thread(thread_id); + } + let msg_id = msg.id; let tx_guard = state.msg_tx.read().await; @@ -281,6 +342,85 @@ async fn chat_approval_handler( )) } +/// Submit an auth token directly to the extension manager, bypassing the message pipeline. +/// +/// The token never touches the LLM, chat history, or SSE stream. +async fn chat_auth_token_handler( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, String)> { + let ext_mgr = state.extension_manager.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Extension manager not available".to_string(), + ))?; + + let result = ext_mgr + .auth(&req.extension_name, Some(&req.token)) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if result.status == "authenticated" { + // Auto-activate so tools are available immediately + let msg = match ext_mgr.activate(&req.extension_name).await { + Ok(r) => format!( + "{} authenticated ({} tools loaded)", + req.extension_name, + r.tools_loaded.len() + ), + Err(e) => format!( + "{} authenticated but activation failed: {}", + req.extension_name, e + ), + }; + + // Clear auth mode on the active thread + clear_auth_mode(&state).await; + + state.sse.broadcast(SseEvent::AuthCompleted { + extension_name: req.extension_name, + success: true, + message: msg.clone(), + }); + + Ok(Json(ActionResponse::ok(msg))) + } else { + // Re-emit auth_required for retry + state.sse.broadcast(SseEvent::AuthRequired { + extension_name: req.extension_name.clone(), + instructions: result.instructions.clone(), + auth_url: result.auth_url.clone(), + setup_url: result.setup_url.clone(), + }); + Ok(Json(ActionResponse::fail( + result + .instructions + .unwrap_or_else(|| "Invalid token".to_string()), + ))) + } +} + +/// Cancel an in-progress auth flow. +async fn chat_auth_cancel_handler( + State(state): State>, + Json(_req): Json, +) -> Result, (StatusCode, String)> { + clear_auth_mode(&state).await; + Ok(Json(ActionResponse::ok("Auth cancelled"))) +} + +/// Clear pending auth mode on the active thread. +pub async fn clear_auth_mode(state: &GatewayState) { + if let Some(ref sm) = state.session_manager { + let session = sm.get_or_create_session(&state.user_id).await; + let mut sess = session.lock().await; + if let Some(thread_id) = sess.active_thread { + if let Some(thread) = sess.threads.get_mut(&thread_id) { + thread.pending_auth = None; + } + } + } +} + async fn chat_events_handler(State(state): State>) -> impl IntoResponse { // subscribe() returns Sse> so no lifetime issues state.sse.subscribe() @@ -296,6 +436,8 @@ async fn chat_ws_handler( #[derive(Deserialize)] struct HistoryQuery { thread_id: Option, + limit: Option, + before: Option, } async fn chat_history_handler( @@ -310,6 +452,22 @@ async fn chat_history_handler( let session = session_manager.get_or_create_session(&state.user_id).await; let sess = session.lock().await; + let limit = query.limit.unwrap_or(50); + let before_cursor = query + .before + .as_deref() + .map(|s| { + chrono::DateTime::parse_from_rfc3339(s) + .map(|dt| dt.with_timezone(&chrono::Utc)) + .map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "Invalid 'before' timestamp".to_string(), + ) + }) + }) + .transpose()?; + // Find the thread let thread_id = if let Some(ref tid) = query.thread_id { Uuid::parse_str(tid) @@ -319,34 +477,125 @@ async fn chat_history_handler( .ok_or((StatusCode::NOT_FOUND, "No active thread".to_string()))? }; - let thread = sess - .threads - .get(&thread_id) - .ok_or((StatusCode::NOT_FOUND, "Thread not found".to_string()))?; + // For paginated requests (before cursor set), always go to DB + if before_cursor.is_some() { + if let Some(ref store) = state.store { + let (messages, has_more) = store + .list_conversation_messages_paginated(thread_id, before_cursor, limit as i64) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - let turns: Vec = thread - .turns - .iter() - .map(|t| TurnInfo { - turn_number: t.turn_number, - user_input: t.user_input.clone(), - response: t.response.clone(), - state: format!("{:?}", t.state), - started_at: t.started_at.to_rfc3339(), - completed_at: t.completed_at.map(|dt| dt.to_rfc3339()), - tool_calls: t - .tool_calls + let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339()); + let turns = build_turns_from_db_messages(&messages); + return Ok(Json(HistoryResponse { + thread_id, + turns, + has_more, + oldest_timestamp, + })); + } + } + + // Try in-memory first (freshest data for active threads) + if let Some(thread) = sess.threads.get(&thread_id) { + if !thread.turns.is_empty() { + let turns: Vec = thread + .turns .iter() - .map(|tc| ToolCallInfo { - name: tc.name.clone(), - has_result: tc.result.is_some(), - has_error: tc.error.is_some(), + .map(|t| TurnInfo { + turn_number: t.turn_number, + user_input: t.user_input.clone(), + response: t.response.clone(), + state: format!("{:?}", t.state), + started_at: t.started_at.to_rfc3339(), + completed_at: t.completed_at.map(|dt| dt.to_rfc3339()), + tool_calls: t + .tool_calls + .iter() + .map(|tc| ToolCallInfo { + name: tc.name.clone(), + has_result: tc.result.is_some(), + has_error: tc.error.is_some(), + }) + .collect(), }) - .collect(), - }) - .collect(); + .collect(); - Ok(Json(HistoryResponse { thread_id, turns })) + return Ok(Json(HistoryResponse { + thread_id, + turns, + has_more: false, + oldest_timestamp: None, + })); + } + } + + // Fall back to DB for historical threads not in memory (paginated) + if let Some(ref store) = state.store { + let (messages, has_more) = store + .list_conversation_messages_paginated(thread_id, None, limit as i64) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if !messages.is_empty() { + let oldest_timestamp = messages.first().map(|m| m.created_at.to_rfc3339()); + let turns = build_turns_from_db_messages(&messages); + return Ok(Json(HistoryResponse { + thread_id, + turns, + has_more, + oldest_timestamp, + })); + } + } + + // Empty thread (just created, no messages yet) + Ok(Json(HistoryResponse { + thread_id, + turns: Vec::new(), + has_more: false, + oldest_timestamp: None, + })) +} + +/// Build TurnInfo pairs from flat DB messages (alternating user/assistant). +fn build_turns_from_db_messages(messages: &[crate::history::ConversationMessage]) -> Vec { + let mut turns = Vec::new(); + let mut turn_number = 0; + let mut iter = messages.iter().peekable(); + + while let Some(msg) = iter.next() { + if msg.role == "user" { + let mut turn = TurnInfo { + turn_number, + user_input: msg.content.clone(), + response: None, + state: "Completed".to_string(), + started_at: msg.created_at.to_rfc3339(), + completed_at: None, + tool_calls: Vec::new(), + }; + + // Check if next message is an assistant response + if let Some(next) = iter.peek() { + if next.role == "assistant" { + let assistant_msg = iter.next().expect("peeked"); + turn.response = Some(assistant_msg.content.clone()); + turn.completed_at = Some(assistant_msg.created_at.to_rfc3339()); + } + } + + // Incomplete turn (user message without response) + if turn.response.is_none() { + turn.state = "Failed".to_string(); + } + + turns.push(turn); + turn_number += 1; + } + } + + turns } async fn chat_threads_handler( @@ -360,6 +609,61 @@ async fn chat_threads_handler( let session = session_manager.get_or_create_session(&state.user_id).await; let sess = session.lock().await; + // Try DB first for persistent thread list + if let Some(ref store) = state.store { + // Auto-create assistant thread if it doesn't exist + let assistant_id = store + .get_or_create_assistant_conversation(&state.user_id, "gateway") + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if let Ok(summaries) = store + .list_conversations_with_preview(&state.user_id, "gateway", 50) + .await + { + let mut assistant_thread = None; + let mut threads = Vec::new(); + + for s in &summaries { + let info = ThreadInfo { + id: s.id, + state: "Idle".to_string(), + turn_count: (s.message_count / 2).max(0) as usize, + created_at: s.started_at.to_rfc3339(), + updated_at: s.last_activity.to_rfc3339(), + title: s.title.clone(), + thread_type: s.thread_type.clone(), + }; + + if s.id == assistant_id { + assistant_thread = Some(info); + } else { + threads.push(info); + } + } + + // If assistant wasn't in the list (0 messages), synthesize it + if assistant_thread.is_none() { + assistant_thread = Some(ThreadInfo { + id: assistant_id, + state: "Idle".to_string(), + turn_count: 0, + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + title: None, + thread_type: Some("assistant".to_string()), + }); + } + + return Ok(Json(ThreadListResponse { + assistant_thread, + threads, + active_thread: sess.active_thread, + })); + } + } + + // Fallback: in-memory only (no assistant thread without DB) let threads: Vec = sess .threads .values() @@ -369,10 +673,13 @@ async fn chat_threads_handler( turn_count: t.turns.len(), created_at: t.created_at.to_rfc3339(), updated_at: t.updated_at.to_rfc3339(), + title: None, + thread_type: None, }) .collect(); Ok(Json(ThreadListResponse { + assistant_thread: None, threads, active_thread: sess.active_thread, })) @@ -389,14 +696,39 @@ async fn chat_new_thread_handler( let session = session_manager.get_or_create_session(&state.user_id).await; let mut sess = session.lock().await; let thread = sess.create_thread(); - - Ok(Json(ThreadInfo { + let thread_id = thread.id; + let info = ThreadInfo { id: thread.id, state: format!("{:?}", thread.state), turn_count: thread.turns.len(), created_at: thread.created_at.to_rfc3339(), updated_at: thread.updated_at.to_rfc3339(), - })) + title: None, + thread_type: Some("thread".to_string()), + }; + + // Persist the empty conversation row with thread_type metadata + if let Some(ref store) = state.store { + let store = Arc::clone(store); + let user_id = state.user_id.clone(); + tokio::spawn(async move { + if let Err(e) = store + .ensure_conversation(thread_id, "gateway", &user_id, None) + .await + { + tracing::warn!("Failed to persist new thread: {}", e); + } + let metadata_val = serde_json::json!("thread"); + if let Err(e) = store + .update_conversation_metadata_field(thread_id, "thread_type", &metadata_val) + .await + { + tracing::warn!("Failed to set thread_type metadata: {}", e); + } + }); + } + + Ok(Json(info)) } // --- Memory handlers --- @@ -564,26 +896,38 @@ async fn memory_search_handler( async fn jobs_list_handler( State(state): State>, ) -> Result, (StatusCode, String)> { - let context_manager = state.context_manager.as_ref().ok_or(( + let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, - "Context manager not available".to_string(), + "Database not available".to_string(), ))?; - let job_ids = context_manager.all_jobs_for(&state.user_id).await; - let mut jobs = Vec::new(); + // Fetch sandbox jobs from the DB. + let sandbox_jobs = store + .list_sandbox_jobs() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - for job_id in job_ids { - if let Ok(ctx) = context_manager.get_context(job_id).await { - jobs.push(JobInfo { - id: ctx.job_id, - title: ctx.title.clone(), - state: ctx.state.to_string(), - user_id: ctx.user_id.clone(), - created_at: ctx.created_at.to_rfc3339(), - started_at: ctx.started_at.map(|dt| dt.to_rfc3339()), - }); - } - } + let mut jobs: Vec = sandbox_jobs + .iter() + .map(|j| { + let ui_state = match j.status.as_str() { + "creating" => "pending", + "running" => "in_progress", + s => s, + }; + JobInfo { + id: j.id, + title: j.task.clone(), + state: ui_state.to_string(), + user_id: j.user_id.clone(), + created_at: j.created_at.to_rfc3339(), + started_at: j.started_at.map(|dt| dt.to_rfc3339()), + } + }) + .collect(); + + // Most recent first. + jobs.sort_by(|a, b| b.created_at.cmp(&a.created_at)); Ok(Json(JobListResponse { jobs })) } @@ -591,87 +935,404 @@ async fn jobs_list_handler( async fn jobs_summary_handler( State(state): State>, ) -> Result, (StatusCode, String)> { - let context_manager = state.context_manager.as_ref().ok_or(( + let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, - "Context manager not available".to_string(), + "Database not available".to_string(), ))?; - let summary = context_manager.summary_for(&state.user_id).await; + let s = store + .sandbox_job_summary() + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; Ok(Json(JobSummaryResponse { - total: summary.total, - pending: summary.pending, - in_progress: summary.in_progress, - completed: summary.completed, - failed: summary.failed, - stuck: summary.stuck, + total: s.total, + pending: s.creating, + in_progress: s.running, + completed: s.completed, + failed: s.failed + s.interrupted, + stuck: 0, })) } async fn jobs_detail_handler( State(state): State>, Path(id): Path, -) -> Result, (StatusCode, String)> { - let context_manager = state.context_manager.as_ref().ok_or(( - StatusCode::SERVICE_UNAVAILABLE, - "Context manager not available".to_string(), - ))?; - +) -> Result, (StatusCode, String)> { let job_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; - let ctx = context_manager - .get_context(job_id) - .await - .map_err(|_| (StatusCode::NOT_FOUND, "Job not found".to_string()))?; + // Try sandbox job from DB first. + if let Some(ref store) = state.store { + if let Ok(Some(job)) = store.get_sandbox_job(job_id).await { + let browse_id = std::path::Path::new(&job.project_dir) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| job.id.to_string()); - if ctx.user_id != state.user_id { - return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); + let ui_state = match job.status.as_str() { + "creating" => "pending", + "running" => "in_progress", + s => s, + }; + + let elapsed_secs = job.started_at.map(|start| { + let end = job.completed_at.unwrap_or_else(chrono::Utc::now); + (end - start).num_seconds().max(0) as u64 + }); + + // Synthesize transitions from timestamps. + let mut transitions = Vec::new(); + if let Some(started) = job.started_at { + transitions.push(TransitionInfo { + from: "creating".to_string(), + to: "running".to_string(), + timestamp: started.to_rfc3339(), + reason: None, + }); + } + if let Some(completed) = job.completed_at { + transitions.push(TransitionInfo { + from: "running".to_string(), + to: job.status.clone(), + timestamp: completed.to_rfc3339(), + reason: job.failure_reason.clone(), + }); + } + + return Ok(Json(JobDetailResponse { + id: job.id, + title: job.task.clone(), + description: String::new(), + state: ui_state.to_string(), + user_id: job.user_id.clone(), + created_at: job.created_at.to_rfc3339(), + started_at: job.started_at.map(|dt| dt.to_rfc3339()), + completed_at: job.completed_at.map(|dt| dt.to_rfc3339()), + elapsed_secs, + project_dir: Some(job.project_dir.clone()), + browse_url: Some(format!("/projects/{}/", browse_id)), + job_mode: { + let mode = store.get_sandbox_job_mode(job.id).await.ok().flatten(); + mode.filter(|m| m != "worker") + }, + transitions, + })); + } } - Ok(Json(JobInfo { - id: ctx.job_id, - title: ctx.title.clone(), - state: ctx.state.to_string(), - user_id: ctx.user_id.clone(), - created_at: ctx.created_at.to_rfc3339(), - started_at: ctx.started_at.map(|dt| dt.to_rfc3339()), - })) + Err((StatusCode::NOT_FOUND, "Job not found".to_string())) } async fn jobs_cancel_handler( State(state): State>, Path(id): Path, ) -> Result, (StatusCode, String)> { - let context_manager = state.context_manager.as_ref().ok_or(( + let job_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; + + // Try sandbox job cancellation. + if let Some(ref store) = state.store { + if let Ok(Some(job)) = store.get_sandbox_job(job_id).await { + if job.status == "running" || job.status == "creating" { + // Stop the container if we have a job manager. + if let Some(ref jm) = state.job_manager { + let _ = jm.stop_job(job_id).await; + } + store + .update_sandbox_job_status( + job_id, + "failed", + Some(false), + Some("Cancelled by user"), + None, + Some(chrono::Utc::now()), + ) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + return Ok(Json(serde_json::json!({ + "status": "cancelled", + "job_id": job_id, + }))); + } + } + + Err((StatusCode::NOT_FOUND, "Job not found".to_string())) +} + +async fn jobs_restart_handler( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, - "Context manager not available".to_string(), + "Database not available".to_string(), + ))?; + let jm = state.job_manager.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Sandbox not enabled".to_string(), + ))?; + + let old_job_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; + + let old_job = store + .get_sandbox_job(old_job_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?; + + if old_job.status != "interrupted" && old_job.status != "failed" { + return Err(( + StatusCode::CONFLICT, + format!("Cannot restart job in state '{}'", old_job.status), + )); + } + + // Create a new job with the same task and project_dir. + let new_job_id = Uuid::new_v4(); + let now = chrono::Utc::now(); + + let record = crate::history::SandboxJobRecord { + id: new_job_id, + task: old_job.task.clone(), + status: "creating".to_string(), + user_id: old_job.user_id.clone(), + project_dir: old_job.project_dir.clone(), + success: None, + failure_reason: None, + created_at: now, + started_at: None, + completed_at: None, + }; + store + .save_sandbox_job(&record) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Look up the original job's mode so the restart uses the same mode. + let mode = match store.get_sandbox_job_mode(old_job_id).await { + Ok(Some(m)) if m == "claude_code" => crate::orchestrator::job_manager::JobMode::ClaudeCode, + _ => crate::orchestrator::job_manager::JobMode::Worker, + }; + + let project_dir = std::path::PathBuf::from(&old_job.project_dir); + let _token = jm + .create_job(new_job_id, &old_job.task, Some(project_dir), mode) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to create container: {}", e), + ) + })?; + + store + .update_sandbox_job_status(new_job_id, "running", None, None, Some(now), None) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(serde_json::json!({ + "status": "restarted", + "old_job_id": old_job_id, + "new_job_id": new_job_id, + }))) +} + +// --- Claude Code prompt and events handlers --- + +/// Submit a follow-up prompt to a running Claude Code sandbox job. +async fn jobs_prompt_handler( + State(state): State>, + Path(id): Path, + Json(body): Json, +) -> Result, (StatusCode, String)> { + let prompt_queue = state.prompt_queue.as_ref().ok_or(( + StatusCode::NOT_IMPLEMENTED, + "Claude Code not configured".to_string(), + ))?; + + let job_id: uuid::Uuid = id + .parse() + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; + + let content = body + .get("content") + .and_then(|v| v.as_str()) + .ok_or(( + StatusCode::BAD_REQUEST, + "Missing 'content' field".to_string(), + ))? + .to_string(); + + let done = body.get("done").and_then(|v| v.as_bool()).unwrap_or(false); + + let prompt = crate::orchestrator::api::PendingPrompt { content, done }; + + { + let mut queue = prompt_queue.lock().await; + queue.entry(job_id).or_default().push_back(prompt); + } + + Ok(Json(serde_json::json!({ + "status": "queued", + "job_id": job_id.to_string(), + }))) +} + +/// Load persisted job events for a job (for history replay on page open). +async fn jobs_events_handler( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::NOT_IMPLEMENTED, + "Database not available".to_string(), + ))?; + + let job_id: uuid::Uuid = id + .parse() + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; + + let events = store + .list_job_events(job_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let events_json: Vec = events + .into_iter() + .map(|e| { + serde_json::json!({ + "id": e.id, + "event_type": e.event_type, + "data": e.data, + "created_at": e.created_at.to_rfc3339(), + }) + }) + .collect(); + + Ok(Json(serde_json::json!({ + "job_id": job_id.to_string(), + "events": events_json, + }))) +} + +// --- Project file handlers for sandbox jobs --- + +#[derive(Deserialize)] +struct FilePathQuery { + path: Option, +} + +async fn job_files_list_handler( + State(state): State>, + Path(id): Path, + Query(query): Query, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), ))?; let job_id = Uuid::parse_str(&id) .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; - let ctx = context_manager - .get_context(job_id) - .await - .map_err(|_| (StatusCode::NOT_FOUND, "Job not found".to_string()))?; - - if ctx.user_id != state.user_id { - return Err((StatusCode::NOT_FOUND, "Job not found".to_string())); - } - - context_manager - .update_context(job_id, |ctx| { - ctx.transition_to(crate::context::JobState::Cancelled, None) - }) + let job = store + .get_sandbox_job(job_id) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? - .map_err(|msg| (StatusCode::CONFLICT, msg))?; + .ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?; - Ok(Json(serde_json::json!({ - "status": "cancelled", - "job_id": job_id, - }))) + let base = std::path::PathBuf::from(&job.project_dir); + let rel_path = query.path.as_deref().unwrap_or(""); + let target = base.join(rel_path); + + // Path traversal guard. + let canonical = target + .canonicalize() + .map_err(|_| (StatusCode::NOT_FOUND, "Path not found".to_string()))?; + let base_canonical = base + .canonicalize() + .map_err(|_| (StatusCode::NOT_FOUND, "Project dir not found".to_string()))?; + if !canonical.starts_with(&base_canonical) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } + + let mut entries = Vec::new(); + let mut read_dir = tokio::fs::read_dir(&canonical) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Cannot read directory".to_string()))?; + + while let Ok(Some(entry)) = read_dir.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + let is_dir = entry + .file_type() + .await + .map(|ft| ft.is_dir()) + .unwrap_or(false); + let rel = if rel_path.is_empty() { + name.clone() + } else { + format!("{}/{}", rel_path, name) + }; + entries.push(ProjectFileEntry { + name, + path: rel, + is_dir, + }); + } + + entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name))); + + Ok(Json(ProjectFilesResponse { entries })) +} + +async fn job_files_read_handler( + State(state): State>, + Path(id): Path, + Query(query): Query, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let job_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid job ID".to_string()))?; + + let job = store + .get_sandbox_job(job_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?; + + let path = query.path.as_deref().ok_or(( + StatusCode::BAD_REQUEST, + "path parameter required".to_string(), + ))?; + + let base = std::path::PathBuf::from(&job.project_dir); + let file_path = base.join(path); + + let canonical = file_path + .canonicalize() + .map_err(|_| (StatusCode::NOT_FOUND, "File not found".to_string()))?; + let base_canonical = base + .canonicalize() + .map_err(|_| (StatusCode::NOT_FOUND, "Project dir not found".to_string()))?; + if !canonical.starts_with(&base_canonical) { + return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); + } + + let content = tokio::fs::read_to_string(&canonical) + .await + .map_err(|_| (StatusCode::NOT_FOUND, "Cannot read file".to_string()))?; + + Ok(Json(ProjectFileReadResponse { + path: path.to_string(), + content, + })) } // --- Logs handlers --- @@ -841,6 +1502,61 @@ async fn extensions_activate_handler( } } +// --- Project file serving handlers --- + +/// Redirect `/projects/{id}` to `/projects/{id}/` so relative paths in +/// the served HTML resolve within the project namespace. +async fn project_redirect_handler(Path(project_id): Path) -> impl IntoResponse { + axum::response::Redirect::permanent(&format!("/projects/{project_id}/")) +} + +/// Serve `index.html` when hitting `/projects/{project_id}/`. +async fn project_index_handler(Path(project_id): Path) -> impl IntoResponse { + serve_project_file(&project_id, "index.html").await +} + +/// Serve any file under `/projects/{project_id}/{path}`. +async fn project_file_handler( + Path((project_id, path)): Path<(String, String)>, +) -> impl IntoResponse { + serve_project_file(&project_id, &path).await +} + +/// Shared logic: resolve the file inside `~/.ironclaw/projects/{project_id}/`, +/// guard against path traversal, and stream the content with the right MIME type. +async fn serve_project_file(project_id: &str, path: &str) -> axum::response::Response { + let base = dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".ironclaw") + .join("projects") + .join(project_id); + + let file_path = base.join(path); + + // Path traversal guard + let canonical = match file_path.canonicalize() { + Ok(p) => p, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let base_canonical = match base.canonicalize() { + Ok(p) => p, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + if !canonical.starts_with(&base_canonical) { + return (StatusCode::FORBIDDEN, "Forbidden").into_response(); + } + + match tokio::fs::read(&canonical).await { + Ok(contents) => { + let mime = mime_guess::from_path(&canonical) + .first_or_octet_stream() + .to_string(); + ([(header::CONTENT_TYPE, mime)], contents).into_response() + } + Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(), + } +} + async fn extensions_remove_handler( State(state): State>, Path(name): Path, @@ -856,6 +1572,446 @@ async fn extensions_remove_handler( } } +// --- Routines handlers --- + +async fn routines_list_handler( + State(state): State>, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let routines = store + .list_routines(&state.user_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let items: Vec = routines.iter().map(routine_to_info).collect(); + + Ok(Json(RoutineListResponse { routines: items })) +} + +async fn routines_summary_handler( + State(state): State>, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let routines = store + .list_routines(&state.user_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let total = routines.len() as u64; + let enabled = routines.iter().filter(|r| r.enabled).count() as u64; + let disabled = total - enabled; + let failing = routines + .iter() + .filter(|r| r.consecutive_failures > 0) + .count() as u64; + + let today_start = chrono::Utc::now() + .date_naive() + .and_hms_opt(0, 0, 0) + .map(|dt| dt.and_utc()); + let runs_today = if let Some(start) = today_start { + routines + .iter() + .filter(|r| r.last_run_at.is_some_and(|ts| ts >= start)) + .count() as u64 + } else { + 0 + }; + + Ok(Json(RoutineSummaryResponse { + total, + enabled, + disabled, + failing, + runs_today, + })) +} + +async fn routines_detail_handler( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let routine_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; + + let routine = store + .get_routine(routine_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?; + + let runs = store + .list_routine_runs(routine_id, 20) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let recent_runs: Vec = runs + .iter() + .map(|run| RoutineRunInfo { + id: run.id, + trigger_type: run.trigger_type.clone(), + started_at: run.started_at.to_rfc3339(), + completed_at: run.completed_at.map(|dt| dt.to_rfc3339()), + status: format!("{:?}", run.status), + result_summary: run.result_summary.clone(), + tokens_used: run.tokens_used, + }) + .collect(); + + Ok(Json(RoutineDetailResponse { + id: routine.id, + name: routine.name.clone(), + description: routine.description.clone(), + enabled: routine.enabled, + trigger: serde_json::to_value(&routine.trigger).unwrap_or_default(), + action: serde_json::to_value(&routine.action).unwrap_or_default(), + guardrails: serde_json::to_value(&routine.guardrails).unwrap_or_default(), + notify: serde_json::to_value(&routine.notify).unwrap_or_default(), + last_run_at: routine.last_run_at.map(|dt| dt.to_rfc3339()), + next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()), + run_count: routine.run_count, + consecutive_failures: routine.consecutive_failures, + created_at: routine.created_at.to_rfc3339(), + recent_runs, + })) +} + +async fn routines_trigger_handler( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let routine_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; + + let routine = store + .get_routine(routine_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?; + + // Send the routine prompt through the message pipeline as a manual trigger. + let prompt = match &routine.action { + crate::agent::routine::RoutineAction::Lightweight { prompt, .. } => prompt.clone(), + crate::agent::routine::RoutineAction::FullJob { + title, description, .. + } => format!("{}: {}", title, description), + }; + + let content = format!("[routine:{}] {}", routine.name, prompt); + let msg = IncomingMessage::new("gateway", &state.user_id, content); + + let tx_guard = state.msg_tx.read().await; + let tx = tx_guard.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Channel not started".to_string(), + ))?; + + tx.send(msg).await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Channel closed".to_string(), + ) + })?; + + Ok(Json(serde_json::json!({ + "status": "triggered", + "routine_id": routine_id, + }))) +} + +#[derive(Deserialize)] +struct ToggleRequest { + enabled: Option, +} + +async fn routines_toggle_handler( + State(state): State>, + Path(id): Path, + body: Option>, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let routine_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; + + let mut routine = store + .get_routine(routine_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?; + + // If a specific value was provided, use it; otherwise toggle. + routine.enabled = match body { + Some(Json(req)) => req.enabled.unwrap_or(!routine.enabled), + None => !routine.enabled, + }; + + store + .update_routine(&routine) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(Json(serde_json::json!({ + "status": if routine.enabled { "enabled" } else { "disabled" }, + "routine_id": routine_id, + }))) +} + +async fn routines_delete_handler( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let routine_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; + + let deleted = store + .delete_routine(routine_id) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if deleted { + Ok(Json(serde_json::json!({ + "status": "deleted", + "routine_id": routine_id, + }))) + } else { + Err((StatusCode::NOT_FOUND, "Routine not found".to_string())) + } +} + +async fn routines_runs_handler( + State(state): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + let store = state.store.as_ref().ok_or(( + StatusCode::SERVICE_UNAVAILABLE, + "Database not available".to_string(), + ))?; + + let routine_id = Uuid::parse_str(&id) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid routine ID".to_string()))?; + + let runs = store + .list_routine_runs(routine_id, 50) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let run_infos: Vec = runs + .iter() + .map(|run| RoutineRunInfo { + id: run.id, + trigger_type: run.trigger_type.clone(), + started_at: run.started_at.to_rfc3339(), + completed_at: run.completed_at.map(|dt| dt.to_rfc3339()), + status: format!("{:?}", run.status), + result_summary: run.result_summary.clone(), + tokens_used: run.tokens_used, + }) + .collect(); + + Ok(Json(serde_json::json!({ + "routine_id": routine_id, + "runs": run_infos, + }))) +} + +/// Convert a Routine to the trimmed RoutineInfo for list display. +fn routine_to_info(r: &crate::agent::routine::Routine) -> RoutineInfo { + let (trigger_type, trigger_summary) = match &r.trigger { + crate::agent::routine::Trigger::Cron { schedule } => { + ("cron".to_string(), format!("cron: {}", schedule)) + } + crate::agent::routine::Trigger::Event { + pattern, channel, .. + } => { + let ch = channel.as_deref().unwrap_or("any"); + ("event".to_string(), format!("on {} /{}/", ch, pattern)) + } + crate::agent::routine::Trigger::Webhook { path, .. } => { + let p = path.as_deref().unwrap_or("/"); + ("webhook".to_string(), format!("webhook: {}", p)) + } + crate::agent::routine::Trigger::Manual => ("manual".to_string(), "manual only".to_string()), + }; + + let action_type = match &r.action { + crate::agent::routine::RoutineAction::Lightweight { .. } => "lightweight", + crate::agent::routine::RoutineAction::FullJob { .. } => "full_job", + }; + + let status = if !r.enabled { + "disabled" + } else if r.consecutive_failures > 0 { + "failing" + } else { + "active" + }; + + RoutineInfo { + id: r.id, + name: r.name.clone(), + description: r.description.clone(), + enabled: r.enabled, + trigger_type, + trigger_summary, + action_type: action_type.to_string(), + last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()), + next_fire_at: r.next_fire_at.map(|dt| dt.to_rfc3339()), + run_count: r.run_count, + consecutive_failures: r.consecutive_failures, + status: status.to_string(), + } +} + +// --- Settings handlers --- + +async fn settings_list_handler( + State(state): State>, +) -> Result, StatusCode> { + let store = state + .store + .as_ref() + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + let rows = store.list_settings(&state.user_id).await.map_err(|e| { + tracing::error!("Failed to list settings: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let settings = rows + .into_iter() + .map(|r| SettingResponse { + key: r.key, + value: r.value, + updated_at: r.updated_at.to_rfc3339(), + }) + .collect(); + + Ok(Json(SettingsListResponse { settings })) +} + +async fn settings_get_handler( + State(state): State>, + Path(key): Path, +) -> Result, StatusCode> { + let store = state + .store + .as_ref() + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + let row = store + .get_setting_full(&state.user_id, &key) + .await + .map_err(|e| { + tracing::error!("Failed to get setting '{}': {}", key, e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(SettingResponse { + key: row.key, + value: row.value, + updated_at: row.updated_at.to_rfc3339(), + })) +} + +async fn settings_set_handler( + State(state): State>, + Path(key): Path, + Json(body): Json, +) -> Result { + let store = state + .store + .as_ref() + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + store + .set_setting(&state.user_id, &key, &body.value) + .await + .map_err(|e| { + tracing::error!("Failed to set setting '{}': {}", key, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(StatusCode::NO_CONTENT) +} + +async fn settings_delete_handler( + State(state): State>, + Path(key): Path, +) -> Result { + let store = state + .store + .as_ref() + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + store + .delete_setting(&state.user_id, &key) + .await + .map_err(|e| { + tracing::error!("Failed to delete setting '{}': {}", key, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(StatusCode::NO_CONTENT) +} + +async fn settings_export_handler( + State(state): State>, +) -> Result, StatusCode> { + let store = state + .store + .as_ref() + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + let settings = store.get_all_settings(&state.user_id).await.map_err(|e| { + tracing::error!("Failed to export settings: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(SettingsExportResponse { settings })) +} + +async fn settings_import_handler( + State(state): State>, + Json(body): Json, +) -> Result { + let store = state + .store + .as_ref() + .ok_or(StatusCode::SERVICE_UNAVAILABLE)?; + store + .set_all_settings(&state.user_id, &body.settings) + .await + .map_err(|e| { + tracing::error!("Failed to import settings: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(StatusCode::NO_CONTENT) +} + // --- Gateway control plane handlers --- async fn gateway_status_handler( @@ -881,3 +2037,84 @@ struct GatewayStatusResponse { ws_connections: u64, total_connections: u64, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_turns_from_db_messages_complete() { + let now = chrono::Utc::now(); + let messages = vec![ + crate::history::ConversationMessage { + id: Uuid::new_v4(), + role: "user".to_string(), + content: "Hello".to_string(), + created_at: now, + }, + crate::history::ConversationMessage { + id: Uuid::new_v4(), + role: "assistant".to_string(), + content: "Hi there!".to_string(), + created_at: now + chrono::TimeDelta::seconds(1), + }, + crate::history::ConversationMessage { + id: Uuid::new_v4(), + role: "user".to_string(), + content: "How are you?".to_string(), + created_at: now + chrono::TimeDelta::seconds(2), + }, + crate::history::ConversationMessage { + id: Uuid::new_v4(), + role: "assistant".to_string(), + content: "Doing well!".to_string(), + created_at: now + chrono::TimeDelta::seconds(3), + }, + ]; + + let turns = build_turns_from_db_messages(&messages); + assert_eq!(turns.len(), 2); + assert_eq!(turns[0].user_input, "Hello"); + assert_eq!(turns[0].response.as_deref(), Some("Hi there!")); + assert_eq!(turns[0].state, "Completed"); + assert_eq!(turns[1].user_input, "How are you?"); + assert_eq!(turns[1].response.as_deref(), Some("Doing well!")); + } + + #[test] + fn test_build_turns_from_db_messages_incomplete_last() { + let now = chrono::Utc::now(); + let messages = vec![ + crate::history::ConversationMessage { + id: Uuid::new_v4(), + role: "user".to_string(), + content: "Hello".to_string(), + created_at: now, + }, + crate::history::ConversationMessage { + id: Uuid::new_v4(), + role: "assistant".to_string(), + content: "Hi!".to_string(), + created_at: now + chrono::TimeDelta::seconds(1), + }, + crate::history::ConversationMessage { + id: Uuid::new_v4(), + role: "user".to_string(), + content: "Lost message".to_string(), + created_at: now + chrono::TimeDelta::seconds(2), + }, + ]; + + let turns = build_turns_from_db_messages(&messages); + assert_eq!(turns.len(), 2); + assert_eq!(turns[1].user_input, "Lost message"); + assert!(turns[1].response.is_none()); + assert_eq!(turns[1].state, "Failed"); + } + + #[test] + fn test_build_turns_from_db_messages_empty() { + let turns = build_turns_from_db_messages(&[]); + assert!(turns.is_empty()); + } +} diff --git a/src/channels/web/sse.rs b/src/channels/web/sse.rs index 240c9c2b..73c73730 100644 --- a/src/channels/web/sse.rs +++ b/src/channels/web/sse.rs @@ -79,7 +79,15 @@ impl SseManager { SseEvent::StreamChunk { .. } => "stream_chunk", SseEvent::Status { .. } => "status", SseEvent::ApprovalNeeded { .. } => "approval_needed", + SseEvent::AuthRequired { .. } => "auth_required", + SseEvent::AuthCompleted { .. } => "auth_completed", SseEvent::Error { .. } => "error", + SseEvent::JobStarted { .. } => "job_started", + SseEvent::JobMessage { .. } => "job_message", + SseEvent::JobToolUse { .. } => "job_tool_use", + SseEvent::JobToolResult { .. } => "job_tool_result", + SseEvent::JobStatus { .. } => "job_status", + SseEvent::JobResult { .. } => "job_result", SseEvent::Heartbeat => "heartbeat", }; Ok(Event::default().event(event_type).data(data)) @@ -152,13 +160,14 @@ mod tests { manager.broadcast(SseEvent::Status { message: "test".to_string(), + thread_id: None, }); let event = rx.next().await; assert!(event.is_some()); let event = event.unwrap().unwrap(); match event { - SseEvent::Status { message } => assert_eq!(message, "test"), + SseEvent::Status { message, .. } => assert_eq!(message, "test"), _ => panic!("unexpected event type"), } } @@ -172,11 +181,12 @@ mod tests { manager.broadcast(SseEvent::Thinking { message: "working".to_string(), + thread_id: None, }); let event = stream.next().await.unwrap(); match event { - SseEvent::Thinking { message } => assert_eq!(message, "working"), + SseEvent::Thinking { message, .. } => assert_eq!(message, "working"), _ => panic!("Expected Thinking event"), } } diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index a7ac57dd..f73ec2af 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -4,6 +4,14 @@ let token = ''; let eventSource = null; let logEventSource = null; let currentTab = 'chat'; +let currentThreadId = null; +let assistantThreadId = null; +let hasMore = false; +let oldestTimestamp = null; +let loadingOlder = false; +let jobEvents = new Map(); // job_id -> Array of events +let jobListRefreshTimer = null; +const JOB_EVENTS_CAP = 500; // --- Auth --- @@ -17,15 +25,24 @@ function authenticate() { // Test the token against the health-ish endpoint (chat/threads requires auth) apiFetch('/api/chat/threads') .then(() => { + sessionStorage.setItem('ironclaw_token', token); document.getElementById('auth-screen').style.display = 'none'; document.getElementById('app').style.display = 'flex'; + // Strip token from URL so it's not visible in the address bar + const cleaned = new URL(window.location); + cleaned.searchParams.delete('token'); + window.history.replaceState({}, '', cleaned.pathname + cleaned.search); connectSSE(); connectLogSSE(); - loadHistory(); + startGatewayStatusPolling(); + loadThreads(); loadMemoryTree(); loadJobs(); }) .catch(() => { + sessionStorage.removeItem('ironclaw_token'); + document.getElementById('auth-screen').style.display = ''; + document.getElementById('app').style.display = 'none'; document.getElementById('auth-error').textContent = 'Invalid token'; }); } @@ -34,6 +51,26 @@ document.getElementById('token-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') authenticate(); }); +// Auto-authenticate from URL param or saved session +(function autoAuth() { + const params = new URLSearchParams(window.location.search); + const urlToken = params.get('token'); + if (urlToken) { + document.getElementById('token-input').value = urlToken; + authenticate(); + return; + } + const saved = sessionStorage.getItem('ironclaw_token'); + if (saved) { + document.getElementById('token-input').value = saved; + // Hide auth screen immediately to prevent flash, authenticate() will + // restore it if the token turns out to be invalid. + document.getElementById('auth-screen').style.display = 'none'; + document.getElementById('app').style.display = 'flex'; + authenticate(); + } +})(); + // --- API helper --- function apiFetch(path, options) { @@ -69,34 +106,54 @@ function connectSSE() { eventSource.addEventListener('response', (e) => { const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; addMessage('assistant', data.content); setStatus(''); + enableChatInput(); + // Refresh thread list so new titles appear after first message + loadThreads(); }); eventSource.addEventListener('thinking', (e) => { const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; setStatus(data.message, true); }); eventSource.addEventListener('tool_started', (e) => { const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; setStatus('Running tool: ' + data.name, true); }); eventSource.addEventListener('tool_completed', (e) => { const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; const icon = data.success ? '\u2713' : '\u2717'; setStatus('Tool ' + data.name + ' ' + icon); }); eventSource.addEventListener('stream_chunk', (e) => { const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; appendToLastAssistant(data.content); }); eventSource.addEventListener('status', (e) => { const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; setStatus(data.message); + // "Done" and "Awaiting approval" are terminal signals from the agent: + // the agentic loop finished, so re-enable input as a safety net in case + // the response SSE event is empty or lost. + if (data.message === 'Done' || data.message === 'Awaiting approval') { + enableChatInput(); + } + }); + + eventSource.addEventListener('job_started', (e) => { + const data = JSON.parse(e.data); + showJobCard(data); }); eventSource.addEventListener('approval_needed', (e) => { @@ -104,18 +161,70 @@ function connectSSE() { showApproval(data); }); + eventSource.addEventListener('auth_required', (e) => { + const data = JSON.parse(e.data); + showAuthCard(data); + }); + + eventSource.addEventListener('auth_completed', (e) => { + const data = JSON.parse(e.data); + removeAuthCard(data.extension_name); + showToast(data.message, 'success'); + enableChatInput(); + }); + eventSource.addEventListener('error', (e) => { if (e.data) { const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; addMessage('system', 'Error: ' + data.message); + enableChatInput(); } }); + + // Job event listeners (activity stream for all sandbox jobs) + const jobEventTypes = [ + 'job_message', 'job_tool_use', 'job_tool_result', + 'job_status', 'job_result' + ]; + for (const evtType of jobEventTypes) { + eventSource.addEventListener(evtType, (e) => { + const data = JSON.parse(e.data); + const jobId = data.job_id; + if (!jobId) return; + if (!jobEvents.has(jobId)) jobEvents.set(jobId, []); + const events = jobEvents.get(jobId); + events.push({ type: evtType, data: data, ts: Date.now() }); + // Cap per-job events to prevent memory leak + while (events.length > JOB_EVENTS_CAP) events.shift(); + // If the Activity tab is currently visible for this job, refresh it + refreshActivityTab(jobId); + // Auto-refresh job list when on jobs tab (debounced) + if ((evtType === 'job_result' || evtType === 'job_status') && currentTab === 'jobs' && !currentJobId) { + clearTimeout(jobListRefreshTimer); + jobListRefreshTimer = setTimeout(loadJobs, 200); + } + // Clean up finished job events after a viewing window + if (evtType === 'job_result') { + setTimeout(() => jobEvents.delete(jobId), 60000); + } + }); + } +} + +// Check if an SSE event belongs to the currently viewed thread. +// Events without a thread_id (legacy) are always shown. +function isCurrentThread(threadId) { + if (!threadId) return true; + if (!currentThreadId) return true; + return threadId === currentThreadId; } // --- Chat --- function sendMessage() { const input = document.getElementById('chat-input'); + const sendBtn = document.getElementById('send-btn'); const content = input.value.trim(); if (!content) return; @@ -124,19 +233,31 @@ function sendMessage() { autoResizeTextarea(input); setStatus('Sending...', true); + sendBtn.disabled = true; + input.disabled = true; + apiFetch('/api/chat/send', { method: 'POST', - body: { content }, + body: { content, thread_id: currentThreadId || undefined }, }).catch((err) => { addMessage('system', 'Failed to send: ' + err.message); setStatus(''); + enableChatInput(); }); } +function enableChatInput() { + const input = document.getElementById('chat-input'); + const sendBtn = document.getElementById('send-btn'); + sendBtn.disabled = false; + input.disabled = false; + input.focus(); +} + function sendApprovalAction(requestId, action) { apiFetch('/api/chat/approval', { method: 'POST', - body: { request_id: requestId, action: action }, + body: { request_id: requestId, action: action, thread_id: currentThreadId }, }).catch((err) => { addMessage('system', 'Failed to send approval: ' + err.message); }); @@ -159,11 +280,24 @@ function sendApprovalAction(requestId, action) { function renderMarkdown(text) { if (typeof marked !== 'undefined') { - return marked.parse(text); + let html = marked.parse(text); + // Inject copy buttons into
 blocks
+    html = html.replace(/
/g, '
');
+    return html;
   }
   return escapeHtml(text);
 }
 
+function copyCodeBlock(btn) {
+  const pre = btn.parentElement;
+  const code = pre.querySelector('code');
+  const text = code ? code.textContent : pre.textContent;
+  navigator.clipboard.writeText(text).then(() => {
+    btn.textContent = 'Copied!';
+    setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
+  });
+}
+
 function addMessage(role, content) {
   const container = document.getElementById('chat-messages');
   const div = document.createElement('div');
@@ -268,19 +402,340 @@ function showApproval(data) {
   container.scrollTop = container.scrollHeight;
 }
 
-function loadHistory() {
-  apiFetch('/api/chat/history').then((data) => {
-    const container = document.getElementById('chat-messages');
-    container.innerHTML = '';
-    for (const turn of data.turns) {
-      addMessage('user', turn.user_input);
-      if (turn.response) {
-        addMessage('assistant', turn.response);
-      }
-    }
-  }).catch(() => {
-    // No history or no active thread, that's fine
+function showJobCard(data) {
+  const container = document.getElementById('chat-messages');
+  const card = document.createElement('div');
+  card.className = 'job-card';
+
+  const icon = document.createElement('span');
+  icon.className = 'job-card-icon';
+  icon.textContent = '\u2692';
+  card.appendChild(icon);
+
+  const info = document.createElement('div');
+  info.className = 'job-card-info';
+
+  const title = document.createElement('div');
+  title.className = 'job-card-title';
+  title.textContent = data.title || 'Sandbox Job';
+  info.appendChild(title);
+
+  const id = document.createElement('div');
+  id.className = 'job-card-id';
+  id.textContent = (data.job_id || '').substring(0, 8);
+  info.appendChild(id);
+
+  card.appendChild(info);
+
+  const viewBtn = document.createElement('button');
+  viewBtn.className = 'job-card-view';
+  viewBtn.textContent = 'View Job';
+  viewBtn.addEventListener('click', () => {
+    switchTab('jobs');
+    openJobDetail(data.job_id);
   });
+  card.appendChild(viewBtn);
+
+  if (data.browse_url) {
+    const browseBtn = document.createElement('a');
+    browseBtn.className = 'job-card-browse';
+    browseBtn.href = data.browse_url;
+    browseBtn.target = '_blank';
+    browseBtn.textContent = 'Browse';
+    card.appendChild(browseBtn);
+  }
+
+  container.appendChild(card);
+  container.scrollTop = container.scrollHeight;
+}
+
+// --- Auth card ---
+
+function showAuthCard(data) {
+  // Remove any existing card for this extension first
+  removeAuthCard(data.extension_name);
+
+  const container = document.getElementById('chat-messages');
+  const card = document.createElement('div');
+  card.className = 'auth-card';
+  card.setAttribute('data-extension-name', data.extension_name);
+
+  const header = document.createElement('div');
+  header.className = 'auth-header';
+  header.textContent = 'Authentication required for ' + data.extension_name;
+  card.appendChild(header);
+
+  if (data.instructions) {
+    const instr = document.createElement('div');
+    instr.className = 'auth-instructions';
+    instr.textContent = data.instructions;
+    card.appendChild(instr);
+  }
+
+  const links = document.createElement('div');
+  links.className = 'auth-links';
+
+  if (data.auth_url) {
+    const oauthBtn = document.createElement('button');
+    oauthBtn.className = 'auth-oauth';
+    oauthBtn.textContent = 'Authenticate with ' + data.extension_name;
+    oauthBtn.addEventListener('click', () => {
+      window.open(data.auth_url, '_blank', 'width=600,height=700');
+    });
+    links.appendChild(oauthBtn);
+  }
+
+  if (data.setup_url) {
+    const setupLink = document.createElement('a');
+    setupLink.href = data.setup_url;
+    setupLink.target = '_blank';
+    setupLink.textContent = 'Get your token';
+    links.appendChild(setupLink);
+  }
+
+  if (links.children.length > 0) {
+    card.appendChild(links);
+  }
+
+  // Token input
+  const tokenRow = document.createElement('div');
+  tokenRow.className = 'auth-token-input';
+
+  const tokenInput = document.createElement('input');
+  tokenInput.type = 'password';
+  tokenInput.placeholder = 'Paste your API key or token';
+  tokenInput.addEventListener('keydown', (e) => {
+    if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value);
+  });
+  tokenRow.appendChild(tokenInput);
+  card.appendChild(tokenRow);
+
+  // Error display (hidden initially)
+  const errorEl = document.createElement('div');
+  errorEl.className = 'auth-error';
+  errorEl.style.display = 'none';
+  card.appendChild(errorEl);
+
+  // Action buttons
+  const actions = document.createElement('div');
+  actions.className = 'auth-actions';
+
+  const submitBtn = document.createElement('button');
+  submitBtn.className = 'auth-submit';
+  submitBtn.textContent = 'Submit';
+  submitBtn.addEventListener('click', () => submitAuthToken(data.extension_name, tokenInput.value));
+
+  const cancelBtn = document.createElement('button');
+  cancelBtn.className = 'auth-cancel';
+  cancelBtn.textContent = 'Cancel';
+  cancelBtn.addEventListener('click', () => cancelAuth(data.extension_name));
+
+  actions.appendChild(submitBtn);
+  actions.appendChild(cancelBtn);
+  card.appendChild(actions);
+
+  container.appendChild(card);
+  container.scrollTop = container.scrollHeight;
+  tokenInput.focus();
+}
+
+function removeAuthCard(extensionName) {
+  const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
+  if (card) card.remove();
+}
+
+function submitAuthToken(extensionName, tokenValue) {
+  if (!tokenValue || !tokenValue.trim()) return;
+
+  // Disable submit button while in flight
+  const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
+  if (card) {
+    const btns = card.querySelectorAll('button');
+    btns.forEach((b) => { b.disabled = true; });
+  }
+
+  apiFetch('/api/chat/auth-token', {
+    method: 'POST',
+    body: { extension_name: extensionName, token: tokenValue.trim() },
+  }).then((result) => {
+    if (result.success) {
+      removeAuthCard(extensionName);
+      addMessage('system', result.message);
+    } else {
+      showAuthCardError(extensionName, result.message);
+    }
+  }).catch((err) => {
+    showAuthCardError(extensionName, 'Failed: ' + err.message);
+  });
+}
+
+function cancelAuth(extensionName) {
+  apiFetch('/api/chat/auth-cancel', {
+    method: 'POST',
+    body: { extension_name: extensionName },
+  }).catch(() => {});
+  removeAuthCard(extensionName);
+  enableChatInput();
+}
+
+function showAuthCardError(extensionName, message) {
+  const card = document.querySelector('.auth-card[data-extension-name="' + extensionName + '"]');
+  if (!card) return;
+  // Re-enable buttons
+  const btns = card.querySelectorAll('button');
+  btns.forEach((b) => { b.disabled = false; });
+  // Show error
+  const errorEl = card.querySelector('.auth-error');
+  if (errorEl) {
+    errorEl.textContent = message;
+    errorEl.style.display = 'block';
+  }
+}
+
+function loadHistory(before) {
+  let historyUrl = '/api/chat/history?limit=50';
+  if (currentThreadId) {
+    historyUrl += '&thread_id=' + encodeURIComponent(currentThreadId);
+  }
+  if (before) {
+    historyUrl += '&before=' + encodeURIComponent(before);
+  }
+
+  const isPaginating = !!before;
+  if (isPaginating) loadingOlder = true;
+
+  apiFetch(historyUrl).then((data) => {
+    const container = document.getElementById('chat-messages');
+
+    if (!isPaginating) {
+      // Fresh load: clear and render
+      container.innerHTML = '';
+      for (const turn of data.turns) {
+        addMessage('user', turn.user_input);
+        if (turn.response) {
+          addMessage('assistant', turn.response);
+        }
+      }
+    } else {
+      // Pagination: prepend older messages
+      const savedHeight = container.scrollHeight;
+      const fragment = document.createDocumentFragment();
+      for (const turn of data.turns) {
+        const userDiv = createMessageElement('user', turn.user_input);
+        fragment.appendChild(userDiv);
+        if (turn.response) {
+          const assistantDiv = createMessageElement('assistant', turn.response);
+          fragment.appendChild(assistantDiv);
+        }
+      }
+      container.insertBefore(fragment, container.firstChild);
+      // Restore scroll position so the user doesn't jump
+      container.scrollTop = container.scrollHeight - savedHeight;
+    }
+
+    hasMore = data.has_more || false;
+    oldestTimestamp = data.oldest_timestamp || null;
+  }).catch(() => {
+    // No history or no active thread
+  }).finally(() => {
+    loadingOlder = false;
+    removeScrollSpinner();
+  });
+}
+
+// Create a message DOM element without appending it (for prepend operations)
+function createMessageElement(role, content) {
+  const div = document.createElement('div');
+  div.className = 'message ' + role;
+  if (role === 'user') {
+    div.textContent = content;
+  } else {
+    div.setAttribute('data-raw', content);
+    div.innerHTML = renderMarkdown(content);
+  }
+  return div;
+}
+
+function removeScrollSpinner() {
+  const spinner = document.getElementById('scroll-load-spinner');
+  if (spinner) spinner.remove();
+}
+
+// --- Threads ---
+
+function loadThreads() {
+  apiFetch('/api/chat/threads').then((data) => {
+    // Pinned assistant thread
+    if (data.assistant_thread) {
+      assistantThreadId = data.assistant_thread.id;
+      const el = document.getElementById('assistant-thread');
+      const isActive = currentThreadId === assistantThreadId;
+      el.className = 'assistant-item' + (isActive ? ' active' : '');
+      const meta = document.getElementById('assistant-meta');
+      const count = data.assistant_thread.turn_count || 0;
+      meta.textContent = count > 0 ? count + ' turns' : '';
+    }
+
+    // Regular threads
+    const list = document.getElementById('thread-list');
+    list.innerHTML = '';
+    const threads = data.threads || [];
+    for (const thread of threads) {
+      const item = document.createElement('div');
+      item.className = 'thread-item' + (thread.id === currentThreadId ? ' active' : '');
+      const label = document.createElement('span');
+      label.className = 'thread-label';
+      label.textContent = thread.title || thread.id.substring(0, 8);
+      label.title = thread.title ? thread.title + ' (' + thread.id + ')' : thread.id;
+      item.appendChild(label);
+      const meta = document.createElement('span');
+      meta.className = 'thread-meta';
+      meta.textContent = (thread.turn_count || 0) + ' turns';
+      item.appendChild(meta);
+      item.addEventListener('click', () => switchThread(thread.id));
+      list.appendChild(item);
+    }
+
+    // Default to assistant thread on first load if no thread selected
+    if (!currentThreadId && assistantThreadId) {
+      switchToAssistant();
+    }
+  }).catch(() => {});
+}
+
+function switchToAssistant() {
+  if (!assistantThreadId) return;
+  currentThreadId = assistantThreadId;
+  hasMore = false;
+  oldestTimestamp = null;
+  loadHistory();
+  loadThreads();
+}
+
+function switchThread(threadId) {
+  currentThreadId = threadId;
+  hasMore = false;
+  oldestTimestamp = null;
+  loadHistory();
+  loadThreads();
+}
+
+function createNewThread() {
+  apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
+    currentThreadId = data.id || null;
+    document.getElementById('chat-messages').innerHTML = '';
+    setStatus('');
+    loadThreads();
+  }).catch((err) => {
+    showToast('Failed to create thread: ' + err.message, 'error');
+  });
+}
+
+function toggleThreadSidebar() {
+  const sidebar = document.getElementById('thread-sidebar');
+  sidebar.classList.toggle('collapsed');
+  const btn = document.getElementById('thread-toggle-btn');
+  btn.innerHTML = sidebar.classList.contains('collapsed') ? '»' : '«';
 }
 
 // Chat input auto-resize and keyboard handling
@@ -293,6 +748,20 @@ chatInput.addEventListener('keydown', (e) => {
 });
 chatInput.addEventListener('input', () => autoResizeTextarea(chatInput));
 
+// Infinite scroll: load older messages when scrolled near the top
+document.getElementById('chat-messages').addEventListener('scroll', function () {
+  if (this.scrollTop < 100 && hasMore && !loadingOlder) {
+    loadingOlder = true;
+    // Show spinner at top
+    const spinner = document.createElement('div');
+    spinner.id = 'scroll-load-spinner';
+    spinner.className = 'scroll-load-spinner';
+    spinner.innerHTML = '
Loading older messages...'; + this.insertBefore(spinner, this.firstChild); + loadHistory(oldestTimestamp); + } +}); + function autoResizeTextarea(el) { el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 120) + 'px'; @@ -318,12 +787,16 @@ function switchTab(tab) { if (tab === 'memory') loadMemoryTree(); if (tab === 'jobs') loadJobs(); + if (tab === 'routines') loadRoutines(); + if (tab === 'logs') applyLogFilters(); if (tab === 'extensions') loadExtensions(); } // --- Memory (filesystem tree) --- let memorySearchTimeout = null; +let currentMemoryPath = null; +let currentMemoryContent = null; // Tree state: nested nodes persisted across renders // { name, path, is_dir, children: [] | null, expanded: bool, loaded: bool } let memoryTreeState = null; @@ -437,16 +910,61 @@ function toggleExpand(node) { } function readMemoryFile(path) { + currentMemoryPath = path; // Update breadcrumb - document.getElementById('memory-breadcrumb').innerHTML = buildBreadcrumb(path); + document.getElementById('memory-breadcrumb-path').innerHTML = buildBreadcrumb(path); + document.getElementById('memory-edit-btn').style.display = 'inline-block'; + + // Exit edit mode if active + cancelMemoryEdit(); apiFetch('/api/memory/read?path=' + encodeURIComponent(path)).then((data) => { - document.getElementById('memory-viewer').textContent = data.content; + currentMemoryContent = data.content; + const viewer = document.getElementById('memory-viewer'); + // Render markdown if it's a .md file + if (path.endsWith('.md')) { + viewer.innerHTML = '
' + renderMarkdown(data.content) + '
'; + viewer.classList.add('rendered'); + } else { + viewer.textContent = data.content; + viewer.classList.remove('rendered'); + } }).catch((err) => { + currentMemoryContent = null; document.getElementById('memory-viewer').innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; }); } +function startMemoryEdit() { + if (!currentMemoryPath || currentMemoryContent === null) return; + document.getElementById('memory-viewer').style.display = 'none'; + const editor = document.getElementById('memory-editor'); + editor.style.display = 'flex'; + const textarea = document.getElementById('memory-edit-textarea'); + textarea.value = currentMemoryContent; + textarea.focus(); +} + +function cancelMemoryEdit() { + document.getElementById('memory-viewer').style.display = ''; + document.getElementById('memory-editor').style.display = 'none'; +} + +function saveMemoryEdit() { + if (!currentMemoryPath) return; + const content = document.getElementById('memory-edit-textarea').value; + apiFetch('/api/memory/write', { + method: 'POST', + body: { path: currentMemoryPath, content: content }, + }).then(() => { + showToast('Saved ' + currentMemoryPath, 'success'); + cancelMemoryEdit(); + readMemoryFile(currentMemoryPath); + }).catch((err) => { + showToast('Save failed: ' + err.message, 'error'); + }); +} + function buildBreadcrumb(path) { const parts = path.split('/'); let html = 'workspace'; @@ -472,14 +990,35 @@ function searchMemory(query) { for (const result of data.results) { const item = document.createElement('div'); item.className = 'search-result'; + const snippet = snippetAround(result.content, query, 120); item.innerHTML = '
' + escapeHtml(result.path) + '
' - + '
' + escapeHtml(result.content.substring(0, 120)) + '
'; + + '
' + highlightQuery(snippet, query) + '
'; item.addEventListener('click', () => readMemoryFile(result.path)); tree.appendChild(item); } }).catch(() => {}); } +function snippetAround(text, query, len) { + const lower = text.toLowerCase(); + const idx = lower.indexOf(query.toLowerCase()); + if (idx < 0) return text.substring(0, len); + const start = Math.max(0, idx - Math.floor(len / 2)); + const end = Math.min(text.length, start + len); + let s = text.substring(start, end); + if (start > 0) s = '...' + s; + if (end < text.length) s = s + '...'; + return s; +} + +function highlightQuery(text, query) { + if (!query) return escapeHtml(text); + const escaped = escapeHtml(text); + const queryEscaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp('(' + queryEscaped + ')', 'gi'); + return escaped.replace(re, '$1'); +} + // --- Logs --- const LOG_MAX_ENTRIES = 2000; @@ -574,6 +1113,7 @@ function toggleLogsPause() { } function clearLogs() { + if (!confirm('Clear all logs?')) return; document.getElementById('logs-output').innerHTML = ''; logBuffer = []; } @@ -709,39 +1249,53 @@ function activateExtension(name) { } if (res.auth_url) { - addMessage( - 'system', - 'Opening authentication for **' + name + '**. Complete the flow in the opened tab, then click Activate again.' - ); + showToast('Opening authentication for ' + name, 'info'); window.open(res.auth_url, '_blank'); } else if (res.awaiting_token) { - addMessage( - 'system', - (res.instructions || 'Please provide an API token for **' + name + '**.') + - '\n\nYou can authenticate via chat: type `Authenticate ' + name + '` and follow the instructions.' - ); + showToast(res.instructions || 'Please provide an API token for ' + name, 'info'); } else { - addMessage('system', 'Activate failed: ' + res.message); + showToast('Activate failed: ' + res.message, 'error'); } loadExtensions(); }) - .catch((err) => addMessage('system', 'Activate failed: ' + err.message)); + .catch((err) => showToast('Activate failed: ' + err.message, 'error')); } function removeExtension(name) { + if (!confirm('Remove extension "' + name + '"?')) return; apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' }) .then((res) => { if (!res.success) { - addMessage('system', 'Remove failed: ' + res.message); + showToast('Remove failed: ' + res.message, 'error'); + } else { + showToast('Removed ' + name, 'success'); } loadExtensions(); }) - .catch((err) => addMessage('system', 'Remove failed: ' + err.message)); + .catch((err) => showToast('Remove failed: ' + err.message, 'error')); } // --- Jobs --- +let currentJobId = null; +let currentJobSubTab = 'overview'; +let jobFilesTreeState = null; + function loadJobs() { + currentJobId = null; + jobFilesTreeState = null; + + // Rebuild DOM if renderJobDetail() destroyed it (it wipes .jobs-container innerHTML). + const container = document.querySelector('.jobs-container'); + if (!document.getElementById('jobs-summary')) { + container.innerHTML = + '
' + + '' + + '' + + '
IDTitleStatusCreatedActions
' + + ''; + } + Promise.all([ apiFetch('/api/jobs/summary'), apiFetch('/api/jobs'), @@ -781,27 +1335,792 @@ function renderJobsList(jobs) { tbody.innerHTML = jobs.map((job) => { const shortId = job.id.substring(0, 8); const stateClass = job.state.replace(' ', '_'); - const cancelBtn = (job.state === 'pending' || job.state === 'in_progress') - ? '' - : ''; - return '' + + let actionBtns = ''; + if (job.state === 'pending' || job.state === 'in_progress') { + actionBtns = ''; + } else if (job.state === 'failed' || job.state === 'interrupted') { + actionBtns = ''; + } + + return '' + '' + shortId + '' + '' + escapeHtml(job.title) + '' + '' + escapeHtml(job.state) + '' + '' + formatDate(job.created_at) + '' - + '' + cancelBtn + '' + + '' + actionBtns + '' + ''; }).join(''); } function cancelJob(jobId) { + if (!confirm('Cancel this job?')) return; apiFetch('/api/jobs/' + jobId + '/cancel', { method: 'POST' }) - .then(() => loadJobs()) + .then(() => { + showToast('Job cancelled', 'success'); + if (currentJobId) openJobDetail(currentJobId); + else loadJobs(); + }) .catch((err) => { - addMessage('system', 'Failed to cancel job: ' + err.message); + showToast('Failed to cancel job: ' + err.message, 'error'); }); } +function restartJob(jobId) { + apiFetch('/api/jobs/' + jobId + '/restart', { method: 'POST' }) + .then((res) => { + showToast('Job restarted as ' + (res.new_job_id || '').substring(0, 8), 'success'); + loadJobs(); + }) + .catch((err) => { + showToast('Failed to restart job: ' + err.message, 'error'); + }); +} + +function openJobDetail(jobId) { + currentJobId = jobId; + currentJobSubTab = 'activity'; + apiFetch('/api/jobs/' + jobId).then((job) => { + renderJobDetail(job); + }).catch((err) => { + addMessage('system', 'Failed to load job: ' + err.message); + closeJobDetail(); + }); +} + +function closeJobDetail() { + currentJobId = null; + jobFilesTreeState = null; + loadJobs(); +} + +function renderJobDetail(job) { + const container = document.querySelector('.jobs-container'); + const stateClass = job.state.replace(' ', '_'); + + container.innerHTML = ''; + + // Header + const header = document.createElement('div'); + header.className = 'job-detail-header'; + + let headerHtml = '' + + '

' + escapeHtml(job.title) + '

' + + '' + escapeHtml(job.state) + ''; + + if (job.state === 'failed' || job.state === 'interrupted') { + headerHtml += ''; + } + if (job.browse_url) { + headerHtml += 'Browse Files'; + } + + header.innerHTML = headerHtml; + container.appendChild(header); + + // Sub-tab bar + const tabs = document.createElement('div'); + tabs.className = 'job-detail-tabs'; + const subtabs = ['overview', 'activity', 'files']; + for (const st of subtabs) { + const btn = document.createElement('button'); + btn.textContent = st.charAt(0).toUpperCase() + st.slice(1); + btn.className = st === currentJobSubTab ? 'active' : ''; + btn.addEventListener('click', () => { + currentJobSubTab = st; + renderJobDetail(job); + }); + tabs.appendChild(btn); + } + container.appendChild(tabs); + + // Content + const content = document.createElement('div'); + content.className = 'job-detail-content'; + container.appendChild(content); + + switch (currentJobSubTab) { + case 'overview': renderJobOverview(content, job); break; + case 'files': renderJobFiles(content, job); break; + case 'activity': renderJobActivity(content, job); break; + } +} + +function metaItem(label, value) { + return '
' + escapeHtml(label) + + '
' + escapeHtml(String(value != null ? value : '-')) + + '
'; +} + +function formatDuration(secs) { + if (secs == null) return '-'; + if (secs < 60) return secs + 's'; + const m = Math.floor(secs / 60); + const s = secs % 60; + if (m < 60) return m + 'm ' + s + 's'; + const h = Math.floor(m / 60); + return h + 'h ' + (m % 60) + 'm'; +} + +function renderJobOverview(container, job) { + // Metadata grid + const grid = document.createElement('div'); + grid.className = 'job-meta-grid'; + grid.innerHTML = metaItem('Job ID', job.id) + + metaItem('State', job.state) + + metaItem('Created', formatDate(job.created_at)) + + metaItem('Started', formatDate(job.started_at)) + + metaItem('Completed', formatDate(job.completed_at)) + + metaItem('Duration', formatDuration(job.elapsed_secs)) + + (job.job_mode ? metaItem('Mode', job.job_mode) : ''); + container.appendChild(grid); + + // Description + if (job.description) { + const descSection = document.createElement('div'); + descSection.className = 'job-description'; + const descHeader = document.createElement('h3'); + descHeader.textContent = 'Description'; + descSection.appendChild(descHeader); + const descBody = document.createElement('div'); + descBody.className = 'job-description-body'; + descBody.innerHTML = renderMarkdown(job.description); + descSection.appendChild(descBody); + container.appendChild(descSection); + } + + // State transitions timeline + if (job.transitions.length > 0) { + const timelineSection = document.createElement('div'); + timelineSection.className = 'job-timeline-section'; + const tlHeader = document.createElement('h3'); + tlHeader.textContent = 'State Transitions'; + timelineSection.appendChild(tlHeader); + + const timeline = document.createElement('div'); + timeline.className = 'timeline'; + for (const t of job.transitions) { + const entry = document.createElement('div'); + entry.className = 'timeline-entry'; + const dot = document.createElement('div'); + dot.className = 'timeline-dot'; + entry.appendChild(dot); + const info = document.createElement('div'); + info.className = 'timeline-info'; + info.innerHTML = '' + escapeHtml(t.from) + '' + + ' → ' + + '' + escapeHtml(t.to) + '' + + '' + formatDate(t.timestamp) + '' + + (t.reason ? '
' + escapeHtml(t.reason) + '
' : ''); + entry.appendChild(info); + timeline.appendChild(entry); + } + timelineSection.appendChild(timeline); + container.appendChild(timelineSection); + } +} + +function renderJobFiles(container, job) { + container.innerHTML = '
' + + '
' + + '
Select a file to view
' + + '
'; + + container._jobId = job ? job.id : null; + + apiFetch('/api/jobs/' + job.id + '/files/list?path=').then((data) => { + jobFilesTreeState = data.entries.map((e) => ({ + name: e.name, + path: e.path, + is_dir: e.is_dir, + children: e.is_dir ? null : undefined, + expanded: false, + loaded: false, + })); + renderJobFilesTree(); + }).catch(() => { + const treeContainer = document.querySelector('.job-files-tree'); + if (treeContainer) { + treeContainer.innerHTML = '
No project files
'; + } + }); +} + +function renderJobFilesTree() { + const treeContainer = document.querySelector('.job-files-tree'); + if (!treeContainer) return; + treeContainer.innerHTML = ''; + if (!jobFilesTreeState || jobFilesTreeState.length === 0) { + treeContainer.innerHTML = '
No files in workspace
'; + return; + } + renderJobFileNodes(jobFilesTreeState, treeContainer, 0); +} + +function renderJobFileNodes(nodes, container, depth) { + for (const node of nodes) { + const row = document.createElement('div'); + row.className = 'tree-row'; + row.style.paddingLeft = (depth * 16 + 8) + 'px'; + + if (node.is_dir) { + const arrow = document.createElement('span'); + arrow.className = 'expand-arrow' + (node.expanded ? ' expanded' : ''); + arrow.textContent = '\u25B6'; + arrow.addEventListener('click', (e) => { + e.stopPropagation(); + toggleJobFileExpand(node); + }); + row.appendChild(arrow); + + const label = document.createElement('span'); + label.className = 'tree-label dir'; + label.textContent = node.name; + label.addEventListener('click', () => toggleJobFileExpand(node)); + row.appendChild(label); + } else { + const spacer = document.createElement('span'); + spacer.className = 'expand-arrow-spacer'; + row.appendChild(spacer); + + const label = document.createElement('span'); + label.className = 'tree-label file'; + label.textContent = node.name; + label.addEventListener('click', () => readJobFile(node.path)); + row.appendChild(label); + } + + container.appendChild(row); + + if (node.is_dir && node.expanded && node.children) { + const childContainer = document.createElement('div'); + childContainer.className = 'tree-children'; + renderJobFileNodes(node.children, childContainer, depth + 1); + container.appendChild(childContainer); + } + } +} + +function getJobId() { + const container = document.querySelector('.job-detail-content'); + return (container && container._jobId) || null; +} + +function toggleJobFileExpand(node) { + if (node.expanded) { + node.expanded = false; + renderJobFilesTree(); + return; + } + if (node.loaded) { + node.expanded = true; + renderJobFilesTree(); + return; + } + const jobId = getJobId(); + apiFetch('/api/jobs/' + jobId + '/files/list?path=' + encodeURIComponent(node.path)).then((data) => { + node.children = data.entries.map((e) => ({ + name: e.name, + path: e.path, + is_dir: e.is_dir, + children: e.is_dir ? null : undefined, + expanded: false, + loaded: false, + })); + node.loaded = true; + node.expanded = true; + renderJobFilesTree(); + }).catch(() => {}); +} + +function readJobFile(path) { + const viewer = document.querySelector('.job-files-viewer'); + if (!viewer) return; + const jobId = getJobId(); + apiFetch('/api/jobs/' + jobId + '/files/read?path=' + encodeURIComponent(path)).then((data) => { + viewer.innerHTML = '
' + escapeHtml(path) + '
' + + '
' + escapeHtml(data.content) + '
'; + }).catch((err) => { + viewer.innerHTML = '
Error: ' + escapeHtml(err.message) + '
'; + }); +} + +// --- Activity tab (unified for all sandbox jobs) --- + +let activityCurrentJobId = null; +// Track how many live SSE events we've already rendered so refreshActivityTab +// only appends new ones (avoids duplicates on each SSE tick). +let activityRenderedLiveIndex = 0; + +function renderJobActivity(container, job) { + activityCurrentJobId = job ? job.id : null; + activityRenderedLiveIndex = 0; + + container.innerHTML = '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
'; + + document.getElementById('activity-type-filter').addEventListener('change', applyActivityFilter); + + const terminal = document.getElementById('activity-terminal'); + const input = document.getElementById('activity-prompt-input'); + const sendBtn = document.getElementById('activity-send-btn'); + const doneBtn = document.getElementById('activity-done-btn'); + + sendBtn.addEventListener('click', () => sendJobPrompt(job.id, false)); + doneBtn.addEventListener('click', () => sendJobPrompt(job.id, true)); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') sendJobPrompt(job.id, false); + }); + + // Load persisted events from DB, then catch up with any live SSE events + apiFetch('/api/jobs/' + job.id + '/events').then((data) => { + if (data.events && data.events.length > 0) { + for (const evt of data.events) { + appendActivityEvent(terminal, evt.event_type, evt.data); + } + } + appendNewLiveEvents(terminal, job.id); + }).catch(() => { + appendNewLiveEvents(terminal, job.id); + }); +} + +function appendNewLiveEvents(terminal, jobId) { + const live = jobEvents.get(jobId) || []; + for (let i = activityRenderedLiveIndex; i < live.length; i++) { + const evt = live[i]; + appendActivityEvent(terminal, evt.type.replace('job_', ''), evt.data); + } + activityRenderedLiveIndex = live.length; + const autoScroll = document.getElementById('activity-autoscroll'); + if (!autoScroll || autoScroll.checked) { + terminal.scrollTop = terminal.scrollHeight; + } +} + +function applyActivityFilter() { + const filter = document.getElementById('activity-type-filter').value; + const events = document.querySelectorAll('#activity-terminal .activity-event'); + for (const el of events) { + if (filter === 'all') { + el.style.display = ''; + } else { + el.style.display = el.getAttribute('data-event-type') === filter ? '' : 'none'; + } + } +} + +function appendActivityEvent(terminal, eventType, data) { + if (!terminal) return; + const el = document.createElement('div'); + el.className = 'activity-event activity-event-' + eventType; + el.setAttribute('data-event-type', eventType); + + // Respect current filter + const filterEl = document.getElementById('activity-type-filter'); + if (filterEl && filterEl.value !== 'all' && filterEl.value !== eventType) { + el.style.display = 'none'; + } + + switch (eventType) { + case 'message': + el.innerHTML = '' + escapeHtml(data.role || 'assistant') + ' ' + + '' + escapeHtml(data.content || '') + ''; + break; + case 'tool_use': + el.innerHTML = '
' + + ' ' + + escapeHtml(data.tool_name || 'tool') + + '
'
+        + escapeHtml(typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2))
+        + '
'; + break; + case 'tool_result': + el.innerHTML = '
' + + ' ' + + escapeHtml(data.tool_name || 'result') + + '
'
+        + escapeHtml(data.output || '')
+        + '
'; + break; + case 'status': + el.innerHTML = '' + escapeHtml(data.message || '') + ''; + break; + case 'result': + el.className += ' activity-final'; + const success = data.success !== false; + el.innerHTML = '' + + escapeHtml(data.message || data.status || 'done') + ''; + if (data.session_id) { + el.innerHTML += ' session: ' + escapeHtml(data.session_id) + ''; + } + break; + default: + el.innerHTML = '' + escapeHtml(JSON.stringify(data)) + ''; + } + + terminal.appendChild(el); +} + +function refreshActivityTab(jobId) { + if (activityCurrentJobId !== jobId) return; + if (currentJobSubTab !== 'activity') return; + const terminal = document.getElementById('activity-terminal'); + if (!terminal) return; + appendNewLiveEvents(terminal, jobId); +} + +function sendJobPrompt(jobId, done) { + const input = document.getElementById('activity-prompt-input'); + const content = input ? input.value.trim() : ''; + if (!content && !done) return; + + apiFetch('/api/jobs/' + jobId + '/prompt', { + method: 'POST', + body: { content: content || '(done)', done: done }, + }).then(() => { + if (input) input.value = ''; + if (done) { + const bar = document.getElementById('activity-input-bar'); + if (bar) bar.innerHTML = 'Done signal sent'; + } + }).catch((err) => { + const terminal = document.getElementById('activity-terminal'); + if (terminal) { + appendActivityEvent(terminal, 'status', { message: 'Failed to send: ' + err.message }); + } + }); +} + +// --- Routines --- + +let currentRoutineId = null; + +function loadRoutines() { + currentRoutineId = null; + + // Restore list view if detail was open + const detail = document.getElementById('routine-detail'); + if (detail) detail.style.display = 'none'; + const table = document.getElementById('routines-table'); + if (table) table.style.display = ''; + + Promise.all([ + apiFetch('/api/routines/summary'), + apiFetch('/api/routines'), + ]).then(([summary, listData]) => { + renderRoutinesSummary(summary); + renderRoutinesList(listData.routines); + }).catch(() => {}); +} + +function renderRoutinesSummary(s) { + document.getElementById('routines-summary').innerHTML = '' + + summaryCard('Total', s.total, '') + + summaryCard('Enabled', s.enabled, 'active') + + summaryCard('Disabled', s.disabled, '') + + summaryCard('Failing', s.failing, 'failed') + + summaryCard('Runs Today', s.runs_today, 'completed'); +} + +function renderRoutinesList(routines) { + const tbody = document.getElementById('routines-tbody'); + const empty = document.getElementById('routines-empty'); + + if (!routines || routines.length === 0) { + tbody.innerHTML = ''; + empty.style.display = 'block'; + return; + } + + empty.style.display = 'none'; + tbody.innerHTML = routines.map((r) => { + const statusClass = r.status === 'active' ? 'completed' + : r.status === 'failing' ? 'failed' + : 'pending'; + + const toggleLabel = r.enabled ? 'Disable' : 'Enable'; + const toggleClass = r.enabled ? 'btn-cancel' : 'btn-restart'; + + return '' + + '' + escapeHtml(r.name) + '' + + '' + escapeHtml(r.trigger_summary) + '' + + '' + escapeHtml(r.action_type) + '' + + '' + formatRelativeTime(r.last_run_at) + '' + + '' + formatRelativeTime(r.next_fire_at) + '' + + '' + r.run_count + '' + + '' + escapeHtml(r.status) + '' + + '' + + ' ' + + ' ' + + '' + + '' + + ''; + }).join(''); +} + +function openRoutineDetail(id) { + currentRoutineId = id; + apiFetch('/api/routines/' + id).then((routine) => { + renderRoutineDetail(routine); + }).catch((err) => { + showToast('Failed to load routine: ' + err.message, 'error'); + }); +} + +function closeRoutineDetail() { + currentRoutineId = null; + loadRoutines(); +} + +function renderRoutineDetail(routine) { + const table = document.getElementById('routines-table'); + if (table) table.style.display = 'none'; + document.getElementById('routines-empty').style.display = 'none'; + + const detail = document.getElementById('routine-detail'); + detail.style.display = 'block'; + + const statusClass = !routine.enabled ? 'pending' + : routine.consecutive_failures > 0 ? 'failed' + : 'completed'; + const statusLabel = !routine.enabled ? 'disabled' + : routine.consecutive_failures > 0 ? 'failing' + : 'active'; + + let html = '
' + + '' + + '

' + escapeHtml(routine.name) + '

' + + '' + escapeHtml(statusLabel) + '' + + '
'; + + // Metadata grid + html += '
' + + metaItem('Routine ID', routine.id) + + metaItem('Enabled', routine.enabled ? 'Yes' : 'No') + + metaItem('Run Count', routine.run_count) + + metaItem('Failures', routine.consecutive_failures) + + metaItem('Last Run', formatDate(routine.last_run_at)) + + metaItem('Next Fire', formatDate(routine.next_fire_at)) + + metaItem('Created', formatDate(routine.created_at)) + + '
'; + + // Description + if (routine.description) { + html += '

Description

' + + '
' + escapeHtml(routine.description) + '
'; + } + + // Trigger config + html += '

Trigger

' + + '
' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + '
'; + + // Action config + html += '

Action

' + + '
' + escapeHtml(JSON.stringify(routine.action, null, 2)) + '
'; + + // Recent runs + if (routine.recent_runs && routine.recent_runs.length > 0) { + html += '

Recent Runs

' + + '' + + '' + + ''; + for (const run of routine.recent_runs) { + const runStatusClass = run.status === 'Ok' ? 'completed' + : run.status === 'Failed' ? 'failed' + : run.status === 'Attention' ? 'stuck' + : 'in_progress'; + html += '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + html += '
TriggerStartedCompletedStatusSummaryTokens
' + escapeHtml(run.trigger_type) + '' + formatDate(run.started_at) + '' + formatDate(run.completed_at) + '' + escapeHtml(run.status) + '' + escapeHtml(run.result_summary || '-') + '' + (run.tokens_used != null ? run.tokens_used : '-') + '
'; + } + + detail.innerHTML = html; +} + +function triggerRoutine(id) { + apiFetch('/api/routines/' + id + '/trigger', { method: 'POST' }) + .then(() => showToast('Routine triggered', 'success')) + .catch((err) => showToast('Trigger failed: ' + err.message, 'error')); +} + +function toggleRoutine(id) { + apiFetch('/api/routines/' + id + '/toggle', { method: 'POST' }) + .then((res) => { + showToast('Routine ' + (res.status || 'toggled'), 'success'); + if (currentRoutineId) openRoutineDetail(currentRoutineId); + else loadRoutines(); + }) + .catch((err) => showToast('Toggle failed: ' + err.message, 'error')); +} + +function deleteRoutine(id, name) { + if (!confirm('Delete routine "' + name + '"?')) return; + apiFetch('/api/routines/' + id, { method: 'DELETE' }) + .then(() => { + showToast('Routine deleted', 'success'); + if (currentRoutineId === id) closeRoutineDetail(); + else loadRoutines(); + }) + .catch((err) => showToast('Delete failed: ' + err.message, 'error')); +} + +function formatRelativeTime(isoString) { + if (!isoString) return '-'; + const d = new Date(isoString); + const now = Date.now(); + const diffMs = now - d.getTime(); + const absDiff = Math.abs(diffMs); + const future = diffMs < 0; + + if (absDiff < 60000) return future ? 'in <1m' : '<1m ago'; + if (absDiff < 3600000) { + const m = Math.floor(absDiff / 60000); + return future ? 'in ' + m + 'm' : m + 'm ago'; + } + if (absDiff < 86400000) { + const h = Math.floor(absDiff / 3600000); + return future ? 'in ' + h + 'h' : h + 'h ago'; + } + const days = Math.floor(absDiff / 86400000); + return future ? 'in ' + days + 'd' : days + 'd ago'; +} + +// --- Gateway status widget --- + +let gatewayStatusInterval = null; + +function startGatewayStatusPolling() { + fetchGatewayStatus(); + gatewayStatusInterval = setInterval(fetchGatewayStatus, 30000); +} + +function fetchGatewayStatus() { + apiFetch('/api/gateway/status').then((data) => { + const popover = document.getElementById('gateway-popover'); + popover.innerHTML = '
SSE clients' + (data.sse_clients || 0) + '
' + + '
Log clients' + (data.log_clients || 0) + '
' + + '
Uptime' + formatDuration(data.uptime_secs) + '
'; + }).catch(() => {}); +} + +// Show/hide popover on hover +document.getElementById('gateway-status-trigger').addEventListener('mouseenter', () => { + document.getElementById('gateway-popover').classList.add('visible'); +}); +document.getElementById('gateway-status-trigger').addEventListener('mouseleave', () => { + document.getElementById('gateway-popover').classList.remove('visible'); +}); + +// --- Extension install --- + +function installExtension() { + const name = document.getElementById('ext-install-name').value.trim(); + if (!name) { + showToast('Extension name is required', 'error'); + return; + } + const url = document.getElementById('ext-install-url').value.trim(); + const kind = document.getElementById('ext-install-kind').value; + + apiFetch('/api/extensions/install', { + method: 'POST', + body: { name, url: url || undefined, kind }, + }).then((res) => { + if (res.success) { + showToast('Installed ' + name, 'success'); + document.getElementById('ext-install-name').value = ''; + document.getElementById('ext-install-url').value = ''; + loadExtensions(); + } else { + showToast('Install failed: ' + (res.message || 'unknown error'), 'error'); + } + }).catch((err) => { + showToast('Install failed: ' + err.message, 'error'); + }); +} + +// --- Keyboard shortcuts --- + +document.addEventListener('keydown', (e) => { + const mod = e.metaKey || e.ctrlKey; + const tag = (e.target.tagName || '').toLowerCase(); + const inInput = tag === 'input' || tag === 'textarea'; + + // Mod+1-6: switch tabs + if (mod && e.key >= '1' && e.key <= '6') { + e.preventDefault(); + const tabs = ['chat', 'memory', 'jobs', 'routines', 'logs', 'extensions']; + const idx = parseInt(e.key) - 1; + if (tabs[idx]) switchTab(tabs[idx]); + return; + } + + // Mod+K: focus chat input or memory search + if (mod && e.key === 'k') { + e.preventDefault(); + if (currentTab === 'memory') { + document.getElementById('memory-search').focus(); + } else { + document.getElementById('chat-input').focus(); + } + return; + } + + // Mod+N: new thread + if (mod && e.key === 'n' && currentTab === 'chat') { + e.preventDefault(); + createNewThread(); + return; + } + + // Escape: close job detail or blur input + if (e.key === 'Escape') { + if (currentJobId) { + closeJobDetail(); + } else if (inInput) { + e.target.blur(); + } + return; + } +}); + +// --- Toasts --- + +function showToast(message, type) { + const container = document.getElementById('toasts'); + const toast = document.createElement('div'); + toast.className = 'toast toast-' + (type || 'info'); + toast.textContent = message; + container.appendChild(toast); + // Trigger slide-in + requestAnimationFrame(() => toast.classList.add('visible')); + setTimeout(() => { + toast.classList.remove('visible'); + toast.addEventListener('transitionend', () => toast.remove()); + }, 4000); +} + // --- Utilities --- function escapeHtml(str) { diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index 8ae5be51..bf6c227c 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -10,12 +10,19 @@
-

IronClaw

-
- - + -
@@ -26,16 +33,33 @@ +
-
+
Connected +
+
+
+ Threads + + +
+
+ Assistant + +
+
+ Conversations +
+
+
@@ -56,10 +80,20 @@
-
workspace /
+
+ workspace / + +
Select a file to view its contents
+
@@ -73,6 +107,7 @@ ID Title + Source Status Created Actions @@ -104,9 +139,48 @@ + +
+
+
+ + + + + + + + + + + + + + +
NameTriggerActionLast RunNext RunRunsStatusActions
+ + +
+
+
+
+

Install Extension

+
+ + + + +
+

Installed Extensions

@@ -130,6 +204,7 @@
+
diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index bb30ccb2..d28edc2b 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -39,28 +39,57 @@ body { align-items: center; justify-content: center; height: 100vh; - flex-direction: column; - gap: 16px; } -#auth-screen h1 { - font-size: 24px; - font-weight: 600; +.auth-card-login { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 40px 36px 32px; + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + gap: 24px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3); +} + +.auth-brand { + text-align: center; +} + +.auth-brand h1 { + font-size: 28px; + font-weight: 700; + color: var(--text); + margin-bottom: 4px; +} + +.auth-tagline { + font-size: 14px; + color: var(--text-secondary); } #auth-screen .auth-form { display: flex; + flex-direction: column; gap: 8px; } +#auth-screen .auth-form label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); +} + #auth-screen input { - padding: 8px 12px; - background: var(--bg-secondary); + padding: 10px 12px; + background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 14px; - width: 300px; + width: 100%; } #auth-screen input:focus { @@ -69,13 +98,15 @@ body { } #auth-screen button { - padding: 8px 16px; + padding: 10px 16px; background: var(--accent); color: #fff; border: none; border-radius: var(--radius); cursor: pointer; font-size: 14px; + font-weight: 500; + margin-top: 4px; } #auth-screen button:hover { @@ -86,6 +117,14 @@ body { color: var(--danger); font-size: 13px; min-height: 20px; + text-align: center; +} + +.auth-hint { + font-size: 12px; + color: var(--text-secondary); + text-align: center; + line-height: 1.4; } /* Main App */ @@ -135,6 +174,8 @@ body { gap: 8px; font-size: 12px; color: var(--text-secondary); + position: relative; + cursor: pointer; } .tab-bar .status .dot { @@ -283,6 +324,25 @@ body { to { transform: rotate(360deg); } } +.scroll-load-spinner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px; + color: var(--text-secondary); + font-size: 12px; +} + +.scroll-load-spinner .spinner { + width: 12px; + height: 12px; + border: 2px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + /* Approval card (inline in chat) */ .approval-card { align-self: flex-start; @@ -391,6 +451,109 @@ body { font-style: italic; } +/* Auth card (inline in chat) */ +.auth-card { + align-self: flex-start; + max-width: 80%; + background: var(--bg-secondary); + border: 1px solid var(--accent); + border-radius: var(--radius); + padding: 12px 16px; + margin: 8px 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.auth-card .auth-header { + font-weight: 600; + color: var(--accent); + font-size: 13px; +} + +.auth-card .auth-instructions { + font-size: 13px; + color: var(--text); + line-height: 1.4; +} + +.auth-card .auth-links { + display: flex; + gap: 8px; + align-items: center; +} + +.auth-card .auth-links a { + color: var(--accent); + font-size: 13px; + text-decoration: underline; +} + +.auth-card .auth-token-input { + display: flex; + gap: 8px; + align-items: center; +} + +.auth-card .auth-token-input input { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font-size: 13px; + font-family: monospace; +} + +.auth-card .auth-token-input input:focus { + outline: none; + border-color: var(--accent); +} + +.auth-card .auth-actions { + display: flex; + gap: 8px; + align-items: center; +} + +.auth-card .auth-actions button { + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + font-size: 13px; + background: var(--bg-secondary); + color: var(--text); +} + +.auth-card .auth-actions button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.auth-card .auth-actions button.auth-submit { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.auth-card .auth-actions button.auth-cancel { + background: var(--bg-secondary); + border-color: var(--border); +} + +.auth-card .auth-actions button.auth-oauth { + background: var(--success); + border-color: var(--success); + color: #fff; +} + +.auth-card .auth-error { + color: var(--danger); + font-size: 12px; +} + /* Chat input */ .chat-input { display: flex; @@ -583,6 +746,9 @@ body { color: var(--text-secondary); border-bottom: 1px solid var(--border); background: var(--bg-secondary); + display: flex; + align-items: center; + gap: 8px; } .memory-breadcrumb a { @@ -718,6 +884,9 @@ body { .badge.failed { background: rgba(248, 81, 73, 0.15); color: var(--danger); } .badge.stuck { background: rgba(210, 153, 34, 0.15); color: var(--warning); } .badge.cancelled { background: var(--bg-tertiary); color: var(--text-secondary); } +.badge.interrupted { background: rgba(210, 153, 34, 0.15); color: var(--warning); } +.badge.source-sandbox { background: rgba(136, 132, 216, 0.15); color: #b4b0e8; } +.badge.source-direct { background: var(--bg-tertiary); color: var(--text-secondary); } .btn-cancel { padding: 4px 10px; @@ -733,12 +902,587 @@ body { background: rgba(248, 81, 73, 0.15); } +.btn-restart { + padding: 4px 10px; + background: none; + border: 1px solid var(--accent); + border-radius: var(--radius); + color: var(--accent); + cursor: pointer; + font-size: 12px; +} + +.btn-restart:hover { + background: rgba(88, 166, 255, 0.15); +} + +.btn-browse { + padding: 4px 10px; + background: none; + border: 1px solid var(--success); + border-radius: var(--radius); + color: var(--success); + cursor: pointer; + font-size: 12px; + text-decoration: none; +} + +.btn-browse:hover { + background: rgba(63, 185, 80, 0.15); +} + +/* Job started card in chat */ +.job-card { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + margin: 8px 0; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + border-left: 3px solid var(--accent); +} + +.job-card-icon { + font-size: 20px; +} + +.job-card-info { + flex: 1; +} + +.job-card-title { + font-weight: 600; + font-size: 14px; +} + +.job-card-id { + font-size: 12px; + color: var(--text-secondary); + font-family: monospace; +} + +.job-card-view, .job-card-browse { + padding: 4px 12px; + border-radius: var(--radius); + font-size: 12px; + cursor: pointer; + text-decoration: none; +} + +.job-card-view { + background: none; + border: 1px solid var(--accent); + color: var(--accent); +} + +.job-card-view:hover { + background: rgba(88, 166, 255, 0.15); +} + +.job-card-browse { + background: none; + border: 1px solid var(--success); + color: var(--success); +} + +.job-card-browse:hover { + background: rgba(63, 185, 80, 0.15); +} + +/* Clickable job rows */ +.job-row { + cursor: pointer; +} + +/* Job Detail View */ +.job-detail-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.job-detail-header h2 { + font-size: 18px; + font-weight: 600; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.btn-back { + padding: 6px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + cursor: pointer; + font-size: 13px; + flex-shrink: 0; +} + +.btn-back:hover { + background: var(--bg-tertiary); +} + +.job-detail-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; +} + +.job-detail-tabs button { + padding: 8px 16px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-secondary); + cursor: pointer; + font-size: 13px; +} + +.job-detail-tabs button:hover { + color: var(--text); +} + +.job-detail-tabs button.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.job-detail-content { + flex: 1; + overflow-y: auto; +} + +/* Metadata grid */ +.job-meta-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.meta-item { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 12px; +} + +.meta-label { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.meta-value { + font-size: 14px; + color: var(--text); + word-break: break-all; +} + +/* Job description */ +.job-description { + margin-bottom: 20px; +} + +.job-description h3 { + font-size: 14px; + font-weight: 600; + margin-bottom: 8px; + color: var(--text); +} + +.job-description-body { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 16px; + font-size: 14px; + line-height: 1.6; +} + +/* State transitions timeline */ +.job-timeline-section { + margin-bottom: 20px; +} + +.job-timeline-section h3 { + font-size: 14px; + font-weight: 600; + margin-bottom: 12px; + color: var(--text); +} + +.timeline { + position: relative; + padding-left: 20px; + border-left: 2px solid var(--border); +} + +.timeline-entry { + position: relative; + padding: 8px 0 8px 16px; +} + +.timeline-dot { + position: absolute; + left: -27px; + top: 14px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--bg); +} + +.timeline-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + font-size: 13px; +} + +.timeline-time { + color: var(--text-secondary); + font-size: 12px; + margin-left: 8px; +} + +.timeline-reason { + width: 100%; + font-size: 12px; + color: var(--text-secondary); + margin-top: 2px; +} + +/* Action cards */ +.action-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + margin-bottom: 8px; + border-left: 3px solid var(--success); +} + +.action-card.failure { + border-left-color: var(--danger); +} + +.action-header { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + font-size: 13px; +} + +.action-header:hover { + background: var(--bg-tertiary); +} + +.action-tool { + font-weight: 600; + color: var(--text); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +.action-seq { + color: var(--text-secondary); + font-size: 11px; +} + +.action-duration { + color: var(--text-secondary); + font-size: 12px; +} + +.action-time { + color: var(--text-secondary); + font-size: 12px; + margin-left: auto; +} + +.action-toggle { + color: var(--text-secondary); + font-size: 10px; + flex-shrink: 0; +} + +.action-detail { + padding: 0 12px 12px; +} + +.action-section { + margin-top: 8px; +} + +.action-section strong { + font-size: 12px; + color: var(--text-secondary); + display: block; + margin-bottom: 4px; +} + +.action-json { + background: var(--code-bg); + padding: 8px 12px; + border-radius: var(--radius); + font-size: 12px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + line-height: 1.4; + overflow-x: auto; + color: var(--text-secondary); + margin: 0; + white-space: pre-wrap; + word-break: break-all; + max-height: 300px; + overflow-y: auto; +} + +.action-error { + background: rgba(248, 81, 73, 0.1); + padding: 8px 12px; + border-radius: var(--radius); + font-size: 12px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + line-height: 1.4; + color: var(--danger); + margin: 0; + white-space: pre-wrap; + word-break: break-all; +} + +/* Conversation messages */ +.conv-message { + padding: 10px 14px; + border-radius: var(--radius); + margin-bottom: 8px; + font-size: 14px; + line-height: 1.5; +} + +.conv-role { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.conv-body { + word-wrap: break-word; +} + +.conv-system { + background: var(--bg-tertiary); + border: 1px solid var(--border); +} + +.conv-system .conv-role { color: var(--text-secondary); } +.conv-system .conv-body { color: var(--text-secondary); font-size: 13px; } + +.conv-user { + background: rgba(88, 166, 255, 0.08); + border: 1px solid rgba(88, 166, 255, 0.2); +} + +.conv-user .conv-role { color: var(--accent); } + +.conv-assistant { + background: var(--bg-secondary); + border: 1px solid var(--border); +} + +.conv-assistant .conv-role { color: var(--success); } + +.conv-tool { + background: var(--bg-secondary); + border: 1px solid var(--border); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 13px; +} + +.conv-tool .conv-role { color: var(--warning); } +.conv-tool .conv-body { white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow-y: auto; } + +.conv-tc-id { + font-size: 11px; + color: var(--text-secondary); + margin-bottom: 4px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +.conv-tool-calls { + margin-top: 8px; + border-top: 1px solid var(--border); + padding-top: 8px; +} + +.conv-tc-entry { + margin-bottom: 6px; +} + +.conv-tc-name { + font-size: 12px; + font-weight: 600; + color: var(--accent); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +.conv-tc-args { + background: var(--code-bg); + padding: 6px 10px; + border-radius: var(--radius); + font-size: 11px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + line-height: 1.4; + margin: 4px 0 0; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-all; + max-height: 150px; + overflow-y: auto; +} + +/* Job files browser */ +.job-files { + display: flex; + height: calc(100vh - 280px); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; +} + +.job-files-sidebar { + width: 240px; + border-right: 1px solid var(--border); + background: var(--bg-secondary); + overflow-y: auto; +} + +.job-files-tree { + padding: 8px 0; +} + +.job-files-viewer { + flex: 1; + overflow: auto; + padding: 12px 16px; +} + +.job-files-path { + font-size: 12px; + color: var(--accent); + margin-bottom: 8px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +.job-files-content { + font-size: 13px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; + color: var(--text); + margin: 0; +} + .empty-state { text-align: center; padding: 40px; color: var(--text-secondary); } +/* Routines Tab */ +.routines-container { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.routines-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; + margin-bottom: 20px; +} + +.routines-table { + width: 100%; + border-collapse: collapse; +} + +.routines-table th, +.routines-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--border); + font-size: 13px; +} + +.routines-table th { + color: var(--text-secondary); + font-weight: 500; + text-transform: uppercase; + font-size: 11px; + letter-spacing: 0.5px; +} + +.routines-table tr:hover td { + background: var(--bg-secondary); +} + +.routine-row { + cursor: pointer; +} + +.routine-detail { + padding: 16px 0; +} + +.badge.enabled { background: rgba(63, 185, 80, 0.15); color: var(--success); } +.badge.disabled { background: var(--bg-tertiary); color: var(--text-secondary); } +.badge.failing { background: rgba(248, 81, 73, 0.15); color: var(--danger); } + +.btn-trigger { + padding: 4px 10px; + background: none; + border: 1px solid var(--accent); + border-radius: var(--radius); + color: var(--accent); + cursor: pointer; + font-size: 12px; +} + +.btn-trigger:hover { + background: rgba(88, 166, 255, 0.15); +} + +.btn-toggle { + padding: 4px 10px; + background: none; + border: 1px solid var(--warning); + border-radius: var(--radius); + color: var(--warning); + cursor: pointer; + font-size: 12px; +} + +.btn-toggle:hover { + background: rgba(210, 153, 34, 0.15); +} + /* Logs Tab */ .logs-container { flex: 1; @@ -1050,3 +1794,710 @@ body { .tools-table tr:hover td { background: var(--bg-secondary); } + +/* --- Activity tab (unified sandbox job events) --- */ + +.activity-terminal { + flex: 1; + overflow-y: auto; + padding: 12px; + font-family: monospace; + font-size: 13px; + line-height: 1.6; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 8px; + max-height: calc(100vh - 320px); +} + +.activity-event { + padding: 4px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.activity-event-message .activity-role { + color: var(--accent); + font-weight: 600; + margin-right: 8px; +} + +.activity-event-message .activity-content { + white-space: pre-wrap; + word-break: break-word; +} + +.activity-event-status .activity-status { + color: var(--text-secondary); + font-style: italic; +} + +.activity-event-result.activity-final { + padding: 8px 0; + font-weight: 600; +} + +.activity-result-status { + color: var(--success); +} + +.activity-result-status[data-success="false"] { + color: var(--danger); +} + +.activity-session-id { + color: var(--text-secondary); + font-size: 11px; + font-weight: 400; +} + +.activity-tool-block { + margin: 4px 0; + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; +} + +.activity-tool-block summary { + padding: 6px 10px; + cursor: pointer; + background: var(--bg-secondary); + font-size: 12px; + color: var(--text-secondary); +} + +.activity-tool-block summary:hover { + color: var(--text); +} + +.activity-tool-icon { + margin-right: 4px; +} + +.activity-tool-result .activity-tool-icon { + color: var(--success); +} + +.activity-tool-input, +.activity-tool-output { + padding: 8px 10px; + margin: 0; + font-size: 12px; + overflow-x: auto; + max-height: 200px; + overflow-y: auto; + background: var(--bg); +} + +.activity-input-bar { + display: flex; + gap: 8px; + padding: 8px 0; +} + +.activity-input-bar input { + flex: 1; + padding: 8px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + font-size: 13px; +} + +.activity-input-bar input:focus { + outline: none; + border-color: var(--accent); +} + +.activity-input-bar button { + padding: 8px 16px; + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 13px; +} + +.activity-input-bar button:hover { + background: var(--accent-hover); +} + +#activity-done-btn { + background: var(--bg-secondary); + border: 1px solid var(--border); + color: var(--text-secondary); +} + +#activity-done-btn:hover { + color: var(--text); + border-color: var(--text-secondary); + background: var(--bg-secondary); +} + +/* --- Copy button on code blocks --- */ + +.code-block-wrapper { + position: relative; +} + +.copy-btn { + position: absolute; + top: 6px; + right: 6px; + padding: 2px 8px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-secondary); + font-size: 11px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} + +.code-block-wrapper:hover .copy-btn { + opacity: 1; +} + +.copy-btn:hover { + color: var(--text); + background: var(--border); +} + +/* --- Toast notifications --- */ + +#toasts { + position: fixed; + top: 16px; + right: 16px; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; +} + +.toast { + padding: 10px 16px; + border-radius: var(--radius); + font-size: 13px; + color: #fff; + pointer-events: auto; + transform: translateX(120%); + transition: transform 0.25s ease; + max-width: 360px; + word-break: break-word; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.toast.visible { + transform: translateX(0); +} + +.toast-info { + background: var(--accent); +} + +.toast-success { + background: var(--success); +} + +.toast-error { + background: var(--danger); +} + +/* --- Memory search highlighting --- */ + +mark { + background: rgba(88, 166, 255, 0.3); + color: inherit; + border-radius: 2px; + padding: 0 1px; +} + +/* --- Thread sidebar --- */ + +#tab-chat { + flex-direction: row; +} + +.thread-sidebar { + width: 200px; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; + transition: width 0.2s ease; + overflow: hidden; +} + +.thread-sidebar.collapsed { + width: 36px; +} + +.thread-sidebar.collapsed .thread-sidebar-header span, +.thread-sidebar.collapsed .thread-new-btn, +.thread-sidebar.collapsed .thread-list, +.thread-sidebar.collapsed .assistant-item, +.thread-sidebar.collapsed .threads-section-header { + display: none; +} + +.thread-sidebar-header { + display: flex; + align-items: center; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + font-size: 13px; + font-weight: 600; + gap: 8px; +} + +.thread-sidebar-header span { + flex: 1; +} + +.thread-new-btn { + background: none; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--accent); + cursor: pointer; + font-size: 16px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + line-height: 1; +} + +.thread-new-btn:hover { + background: rgba(88, 166, 255, 0.15); +} + +.assistant-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + color: var(--text); + border-bottom: 1px solid var(--border); + background: var(--bg-secondary); +} + +.assistant-item:hover { + background: var(--bg-tertiary); +} + +.assistant-item.active { + background: rgba(88, 166, 255, 0.08); + color: var(--accent); + border-left: 2px solid var(--accent); +} + +.assistant-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.assistant-meta { + font-size: 11px; + font-weight: 400; + color: var(--text-secondary); +} + +.threads-section-header { + padding: 8px 12px 4px; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} + +.thread-toggle-btn { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + padding: 2px; +} + +.thread-toggle-btn:hover { + color: var(--text); +} + +.thread-list { + flex: 1; + overflow-y: auto; +} + +.thread-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + cursor: pointer; + font-size: 13px; + color: var(--text-secondary); + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +} + +.thread-item:hover { + background: var(--bg-tertiary); + color: var(--text); +} + +.thread-item.active { + background: var(--bg-tertiary); + color: var(--accent); + border-left: 2px solid var(--accent); +} + +.thread-label { + font-family: monospace; + font-size: 12px; +} + +.thread-meta { + font-size: 11px; + color: var(--text-secondary); +} + +/* --- Memory editing --- */ + +#memory-breadcrumb-path { + flex: 1; +} + +.memory-edit-btn { + padding: 3px 10px; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-secondary); + cursor: pointer; + font-size: 12px; + flex-shrink: 0; +} + +.memory-edit-btn:hover { + color: var(--accent); + border-color: var(--accent); +} + +.memory-editor { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + overflow: hidden; +} + +.memory-editor textarea { + flex: 1; + padding: 12px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 13px; + line-height: 1.5; + resize: none; +} + +.memory-editor textarea:focus { + outline: none; + border-color: var(--accent); +} + +.memory-editor-actions { + display: flex; + gap: 8px; +} + +.btn-save { + padding: 6px 16px; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 13px; +} + +.btn-save:hover { + background: var(--accent-hover); +} + +.btn-cancel-edit { + padding: 6px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + cursor: pointer; + font-size: 13px; +} + +.btn-cancel-edit:hover { + background: var(--bg-tertiary); +} + +/* Memory rendered markdown */ +.memory-viewer.rendered { + white-space: normal; + font-family: inherit; +} + +.memory-rendered { + font-size: 14px; + line-height: 1.6; +} + +.memory-rendered h1, .memory-rendered h2, .memory-rendered h3 { + margin: 12px 0 6px 0; +} + +.memory-rendered p { margin: 0 0 8px 0; } +.memory-rendered p:last-child { margin-bottom: 0; } +.memory-rendered ul, .memory-rendered ol { margin: 4px 0; padding-left: 20px; } +.memory-rendered li { margin: 2px 0; } +.memory-rendered code { + background: var(--code-bg); + padding: 1px 4px; + border-radius: 3px; + font-size: 13px; +} +.memory-rendered pre { + background: var(--code-bg); + padding: 8px 12px; + border-radius: var(--radius); + overflow-x: auto; + margin: 6px 0; +} +.memory-rendered pre code { background: none; padding: 0; } +.memory-rendered a { color: var(--accent); } +.memory-rendered blockquote { + margin: 6px 0; + padding: 4px 12px; + border-left: 3px solid var(--border); + color: var(--text-secondary); +} + +/* --- Gateway status popover --- */ + +.gateway-popover { + display: none; + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; + min-width: 180px; + box-shadow: var(--shadow); + z-index: 100; +} + +.gateway-popover.visible { + display: block; +} + +.gw-stat { + display: flex; + justify-content: space-between; + font-size: 12px; + padding: 3px 0; + color: var(--text-secondary); +} + +.gw-stat span:last-child { + color: var(--text); + font-weight: 500; +} + +/* --- Extension install form --- */ + +.ext-install-form { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.ext-install-form input { + padding: 6px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; +} + +.ext-install-form input:focus { + outline: none; + border-color: var(--accent); +} + +.ext-install-form select { + padding: 6px 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 13px; +} + +.ext-install-form button { + padding: 6px 16px; + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius); + cursor: pointer; + font-size: 13px; +} + +.ext-install-form button:hover { + background: var(--accent-hover); +} + +/* --- Activity toolbar --- */ + +.activity-toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; +} + +.activity-toolbar select { + padding: 5px 8px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 12px; +} + +.activity-toolbar select:focus { + outline: none; + border-color: var(--accent); +} + +/* --- Mobile responsive --- */ + +@media (max-width: 768px) { + /* Tab bar: horizontal scroll */ + .tab-bar { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + padding: 0 8px; + } + + .tab-bar button { + padding: 8px 12px; + font-size: 13px; + white-space: nowrap; + } + + /* Chat messages: wider */ + .message { + max-width: 95%; + } + + /* Thread sidebar: hidden behind toggle */ + .thread-sidebar { + width: 36px; + } + + .thread-sidebar .thread-sidebar-header span, + .thread-sidebar .thread-new-btn, + .thread-sidebar .thread-list, + .thread-sidebar .assistant-item, + .thread-sidebar .threads-section-header { + display: none; + } + + .thread-sidebar.expanded-mobile { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 200px; + z-index: 50; + } + + .thread-sidebar.expanded-mobile .thread-sidebar-header span, + .thread-sidebar.expanded-mobile .thread-new-btn, + .thread-sidebar.expanded-mobile .thread-list, + .thread-sidebar.expanded-mobile .assistant-item, + .thread-sidebar.expanded-mobile .threads-section-header { + display: flex; + } + + /* Memory: vertical stack */ + .memory-container { + flex-direction: column; + } + + .memory-sidebar { + width: 100%; + max-height: 200px; + border-right: none; + border-bottom: 1px solid var(--border); + } + + /* Job detail sub-tabs: wrap */ + .job-detail-tabs { + flex-wrap: wrap; + } + + .job-detail-header { + flex-wrap: wrap; + } + + .job-detail-header h2 { + min-width: 100%; + order: -1; + } + + /* Job files: vertical */ + .job-files { + flex-direction: column; + height: auto; + } + + .job-files-sidebar { + width: 100%; + max-height: 180px; + border-right: none; + border-bottom: 1px solid var(--border); + } + + /* Extension install form */ + .ext-install-form { + flex-direction: column; + align-items: stretch; + } + + .ext-install-form input, + .ext-install-form select { + width: 100%; + } +} diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index e476a0f6..c43e86f9 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -24,10 +24,17 @@ pub struct ThreadInfo { pub turn_count: usize, pub created_at: String, pub updated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_type: Option, } #[derive(Debug, Serialize)] pub struct ThreadListResponse { + /// The pinned assistant thread (always present after first load). + pub assistant_thread: Option, + /// Regular conversation threads. pub threads: Vec, pub active_thread: Option, } @@ -54,6 +61,12 @@ pub struct ToolCallInfo { pub struct HistoryResponse { pub thread_id: Uuid, pub turns: Vec, + /// Whether there are older messages available. + #[serde(default)] + pub has_more: bool, + /// Cursor for the next page (ISO8601 timestamp of the oldest message returned). + #[serde(skip_serializing_if = "Option::is_none")] + pub oldest_timestamp: Option, } // --- Approval --- @@ -63,6 +76,8 @@ pub struct ApprovalRequest { pub request_id: String, /// "approve", "always", or "deny" pub action: String, + /// Thread that owns the pending approval (so the agent loop finds the right session). + pub thread_id: Option, } // --- SSE Event Types --- @@ -73,17 +88,49 @@ pub enum SseEvent { #[serde(rename = "response")] Response { content: String, thread_id: String }, #[serde(rename = "thinking")] - Thinking { message: String }, + Thinking { + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, #[serde(rename = "tool_started")] - ToolStarted { name: String }, + ToolStarted { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, #[serde(rename = "tool_completed")] - ToolCompleted { name: String, success: bool }, + ToolCompleted { + name: String, + success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, #[serde(rename = "tool_result")] - ToolResult { name: String, preview: String }, + ToolResult { + name: String, + preview: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, #[serde(rename = "stream_chunk")] - StreamChunk { content: String }, + StreamChunk { + content: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, #[serde(rename = "status")] - Status { message: String }, + Status { + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, + #[serde(rename = "job_started")] + JobStarted { + job_id: String, + title: String, + browse_url: String, + }, #[serde(rename = "approval_needed")] ApprovalNeeded { request_id: String, @@ -91,10 +138,59 @@ pub enum SseEvent { description: String, parameters: String, }, + #[serde(rename = "auth_required")] + AuthRequired { + extension_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + auth_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + setup_url: Option, + }, + #[serde(rename = "auth_completed")] + AuthCompleted { + extension_name: String, + success: bool, + message: String, + }, #[serde(rename = "error")] - Error { message: String }, + Error { + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, #[serde(rename = "heartbeat")] Heartbeat, + + // Sandbox job streaming events (worker + Claude Code bridge) + #[serde(rename = "job_message")] + JobMessage { + job_id: String, + role: String, + content: String, + }, + #[serde(rename = "job_tool_use")] + JobToolUse { + job_id: String, + tool_name: String, + input: serde_json::Value, + }, + #[serde(rename = "job_tool_result")] + JobToolResult { + job_id: String, + tool_name: String, + output: String, + }, + #[serde(rename = "job_status")] + JobStatus { job_id: String, message: String }, + #[serde(rename = "job_result")] + JobResult { + job_id: String, + status: String, + #[serde(skip_serializing_if = "Option::is_none")] + session_id: Option, + }, } // --- Memory --- @@ -188,6 +284,54 @@ pub struct JobSummaryResponse { pub stuck: usize, } +#[derive(Debug, Serialize)] +pub struct JobDetailResponse { + pub id: Uuid, + pub title: String, + pub description: String, + pub state: String, + pub user_id: String, + pub created_at: String, + pub started_at: Option, + pub completed_at: Option, + pub elapsed_secs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_dir: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub browse_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub job_mode: Option, + pub transitions: Vec, +} + +// --- Project Files --- + +#[derive(Debug, Serialize)] +pub struct ProjectFileEntry { + pub name: String, + pub path: String, + pub is_dir: bool, +} + +#[derive(Debug, Serialize)] +pub struct ProjectFilesResponse { + pub entries: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ProjectFileReadResponse { + pub path: String, + pub content: String, +} + +#[derive(Debug, Serialize)] +pub struct TransitionInfo { + pub from: String, + pub to: String, + pub timestamp: String, + pub reason: Option, +} + // --- Extensions --- #[derive(Debug, Serialize)] @@ -262,6 +406,21 @@ impl ActionResponse { } } +// --- Auth Token --- + +/// Request to submit an auth token for an extension (dedicated endpoint). +#[derive(Debug, Deserialize)] +pub struct AuthTokenRequest { + pub extension_name: String, + pub token: String, +} + +/// Request to cancel an in-progress auth flow. +#[derive(Debug, Deserialize)] +pub struct AuthCancelRequest { + pub extension_name: String, +} + // --- WebSocket --- /// Message sent by a WebSocket client to the server. @@ -280,7 +439,18 @@ pub enum WsClientMessage { request_id: String, /// "approve", "always", or "deny" action: String, + /// Thread that owns the pending approval. + thread_id: Option, }, + /// Submit an auth token for an extension (bypasses message pipeline). + #[serde(rename = "auth_token")] + AuthToken { + extension_name: String, + token: String, + }, + /// Cancel an in-progress auth flow. + #[serde(rename = "auth_cancel")] + AuthCancel { extension_name: String }, /// Client heartbeat ping. #[serde(rename = "ping")] Ping, @@ -317,9 +487,17 @@ impl WsServerMessage { SseEvent::ToolResult { .. } => "tool_result", SseEvent::StreamChunk { .. } => "stream_chunk", SseEvent::Status { .. } => "status", + SseEvent::JobStarted { .. } => "job_started", SseEvent::ApprovalNeeded { .. } => "approval_needed", + SseEvent::AuthRequired { .. } => "auth_required", + SseEvent::AuthCompleted { .. } => "auth_completed", SseEvent::Error { .. } => "error", SseEvent::Heartbeat => "heartbeat", + SseEvent::JobMessage { .. } => "job_message", + SseEvent::JobToolUse { .. } => "job_tool_use", + SseEvent::JobToolResult { .. } => "job_tool_result", + SseEvent::JobStatus { .. } => "job_status", + SseEvent::JobResult { .. } => "job_result", }; let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null); WsServerMessage::Event { @@ -329,6 +507,96 @@ impl WsServerMessage { } } +// --- Routines --- + +#[derive(Debug, Serialize)] +pub struct RoutineInfo { + pub id: Uuid, + pub name: String, + pub description: String, + pub enabled: bool, + pub trigger_type: String, + pub trigger_summary: String, + pub action_type: String, + pub last_run_at: Option, + pub next_fire_at: Option, + pub run_count: u64, + pub consecutive_failures: u32, + pub status: String, +} + +#[derive(Debug, Serialize)] +pub struct RoutineListResponse { + pub routines: Vec, +} + +#[derive(Debug, Serialize)] +pub struct RoutineSummaryResponse { + pub total: u64, + pub enabled: u64, + pub disabled: u64, + pub failing: u64, + pub runs_today: u64, +} + +#[derive(Debug, Serialize)] +pub struct RoutineDetailResponse { + pub id: Uuid, + pub name: String, + pub description: String, + pub enabled: bool, + pub trigger: serde_json::Value, + pub action: serde_json::Value, + pub guardrails: serde_json::Value, + pub notify: serde_json::Value, + pub last_run_at: Option, + pub next_fire_at: Option, + pub run_count: u64, + pub consecutive_failures: u32, + pub created_at: String, + pub recent_runs: Vec, +} + +#[derive(Debug, Serialize)] +pub struct RoutineRunInfo { + pub id: Uuid, + pub trigger_type: String, + pub started_at: String, + pub completed_at: Option, + pub status: String, + pub result_summary: Option, + pub tokens_used: Option, +} + +// --- Settings --- + +#[derive(Debug, Serialize)] +pub struct SettingResponse { + pub key: String, + pub value: serde_json::Value, + pub updated_at: String, +} + +#[derive(Debug, Serialize)] +pub struct SettingsListResponse { + pub settings: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct SettingWriteRequest { + pub value: serde_json::Value, +} + +#[derive(Debug, Deserialize)] +pub struct SettingsImportRequest { + pub settings: std::collections::HashMap, +} + +#[derive(Debug, Serialize)] +pub struct SettingsExportResponse { + pub settings: std::collections::HashMap, +} + // --- Health --- #[derive(Debug, Serialize)] @@ -371,12 +639,36 @@ mod tests { #[test] fn test_ws_client_approval_parse() { - let json = r#"{"type":"approval","request_id":"abc-123","action":"approve"}"#; + let json = + r#"{"type":"approval","request_id":"abc-123","action":"approve","thread_id":"t1"}"#; let msg: WsClientMessage = serde_json::from_str(json).unwrap(); match msg { - WsClientMessage::Approval { request_id, action } => { + WsClientMessage::Approval { + request_id, + action, + thread_id, + } => { assert_eq!(request_id, "abc-123"); assert_eq!(action, "approve"); + assert_eq!(thread_id.as_deref(), Some("t1")); + } + _ => panic!("Expected Approval variant"), + } + } + + #[test] + fn test_ws_client_approval_parse_no_thread() { + let json = r#"{"type":"approval","request_id":"abc-123","action":"deny"}"#; + let msg: WsClientMessage = serde_json::from_str(json).unwrap(); + match msg { + WsClientMessage::Approval { + request_id, + action, + thread_id, + } => { + assert_eq!(request_id, "abc-123"); + assert_eq!(action, "deny"); + assert!(thread_id.is_none()); } _ => panic!("Expected Approval variant"), } @@ -437,6 +729,7 @@ mod tests { fn test_ws_server_from_sse_thinking() { let sse = SseEvent::Thinking { message: "reasoning...".to_string(), + thread_id: None, }; let ws = WsServerMessage::from_sse_event(&sse); match ws { @@ -477,4 +770,115 @@ mod tests { _ => panic!("Expected Event variant"), } } + + // ---- Auth type tests ---- + + #[test] + fn test_ws_client_auth_token_parse() { + let json = r#"{"type":"auth_token","extension_name":"notion","token":"sk-123"}"#; + let msg: WsClientMessage = serde_json::from_str(json).unwrap(); + match msg { + WsClientMessage::AuthToken { + extension_name, + token, + } => { + assert_eq!(extension_name, "notion"); + assert_eq!(token, "sk-123"); + } + _ => panic!("Expected AuthToken variant"), + } + } + + #[test] + fn test_ws_client_auth_cancel_parse() { + let json = r#"{"type":"auth_cancel","extension_name":"notion"}"#; + let msg: WsClientMessage = serde_json::from_str(json).unwrap(); + match msg { + WsClientMessage::AuthCancel { extension_name } => { + assert_eq!(extension_name, "notion"); + } + _ => panic!("Expected AuthCancel variant"), + } + } + + #[test] + fn test_sse_auth_required_serialize() { + let event = SseEvent::AuthRequired { + extension_name: "notion".to_string(), + instructions: Some("Get your token from...".to_string()), + auth_url: None, + setup_url: Some("https://notion.so/integrations".to_string()), + }; + let json = serde_json::to_string(&event).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["type"], "auth_required"); + assert_eq!(parsed["extension_name"], "notion"); + assert_eq!(parsed["instructions"], "Get your token from..."); + assert!(parsed.get("auth_url").is_none()); + assert_eq!(parsed["setup_url"], "https://notion.so/integrations"); + } + + #[test] + fn test_sse_auth_completed_serialize() { + let event = SseEvent::AuthCompleted { + extension_name: "notion".to_string(), + success: true, + message: "notion authenticated (3 tools loaded)".to_string(), + }; + let json = serde_json::to_string(&event).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["type"], "auth_completed"); + assert_eq!(parsed["extension_name"], "notion"); + assert_eq!(parsed["success"], true); + } + + #[test] + fn test_ws_server_from_sse_auth_required() { + let sse = SseEvent::AuthRequired { + extension_name: "openai".to_string(), + instructions: Some("Enter API key".to_string()), + auth_url: None, + setup_url: None, + }; + let ws = WsServerMessage::from_sse_event(&sse); + match ws { + WsServerMessage::Event { event_type, data } => { + assert_eq!(event_type, "auth_required"); + assert_eq!(data["extension_name"], "openai"); + } + _ => panic!("Expected Event variant"), + } + } + + #[test] + fn test_ws_server_from_sse_auth_completed() { + let sse = SseEvent::AuthCompleted { + extension_name: "slack".to_string(), + success: false, + message: "Invalid token".to_string(), + }; + let ws = WsServerMessage::from_sse_event(&sse); + match ws { + WsServerMessage::Event { event_type, data } => { + assert_eq!(event_type, "auth_completed"); + assert_eq!(data["success"], false); + } + _ => panic!("Expected Event variant"), + } + } + + #[test] + fn test_auth_token_request_deserialize() { + let json = r#"{"extension_name":"telegram","token":"bot12345"}"#; + let req: AuthTokenRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.extension_name, "telegram"); + assert_eq!(req.token, "bot12345"); + } + + #[test] + fn test_auth_cancel_request_deserialize() { + let json = r#"{"extension_name":"telegram"}"#; + let req: AuthCancelRequest = serde_json::from_str(json).unwrap(); + assert_eq!(req.extension_name, "telegram"); + } } diff --git a/src/channels/web/ws.rs b/src/channels/web/ws.rs index 64755e3c..8778f39d 100644 --- a/src/channels/web/ws.rs +++ b/src/channels/web/ws.rs @@ -170,7 +170,11 @@ async fn handle_client_message( .await; } } - WsClientMessage::Approval { request_id, action } => { + WsClientMessage::Approval { + request_id, + action, + thread_id, + } => { let (approved, always) = match action.as_str() { "approve" => (true, false), "always" => (true, true), @@ -214,12 +218,71 @@ async fn handle_client_message( } }; - let msg = IncomingMessage::new("gateway", user_id, content); + let mut msg = IncomingMessage::new("gateway", user_id, content); + if let Some(ref tid) = thread_id { + msg = msg.with_thread(tid); + } let tx_guard = state.msg_tx.read().await; if let Some(ref tx) = *tx_guard { let _ = tx.send(msg).await; } } + WsClientMessage::AuthToken { + extension_name, + token, + } => { + if let Some(ref ext_mgr) = state.extension_manager { + match ext_mgr.auth(&extension_name, Some(&token)).await { + Ok(result) if result.status == "authenticated" => { + let msg = match ext_mgr.activate(&extension_name).await { + Ok(r) => format!( + "{} authenticated ({} tools loaded)", + extension_name, + r.tools_loaded.len() + ), + Err(e) => format!( + "{} authenticated but activation failed: {}", + extension_name, e + ), + }; + crate::channels::web::server::clear_auth_mode(state).await; + state + .sse + .broadcast(crate::channels::web::types::SseEvent::AuthCompleted { + extension_name, + success: true, + message: msg, + }); + } + Ok(result) => { + state + .sse + .broadcast(crate::channels::web::types::SseEvent::AuthRequired { + extension_name, + instructions: result.instructions, + auth_url: result.auth_url, + setup_url: result.setup_url, + }); + } + Err(e) => { + let _ = direct_tx + .send(WsServerMessage::Error { + message: format!("Auth failed: {}", e), + }) + .await; + } + } + } else { + let _ = direct_tx + .send(WsServerMessage::Error { + message: "Extension manager not available".to_string(), + }) + .await; + } + } + WsClientMessage::AuthCancel { .. } => { + crate::channels::web::server::clear_auth_mode(state).await; + } WsClientMessage::Ping => { let _ = direct_tx.send(WsServerMessage::Pong).await; } @@ -328,6 +391,7 @@ mod tests { WsClientMessage::Approval { request_id: request_id.to_string(), action: "approve".to_string(), + thread_id: Some("thread-42".to_string()), }, &state, "user1", @@ -338,6 +402,8 @@ mod tests { let incoming = agent_rx.recv().await.unwrap(); // The content should be a serialized ExecApproval assert!(incoming.content.contains("ExecApproval")); + // Thread should be forwarded onto the IncomingMessage. + assert_eq!(incoming.thread_id.as_deref(), Some("thread-42")); } #[tokio::test] @@ -349,6 +415,7 @@ mod tests { WsClientMessage::Approval { request_id: Uuid::new_v4().to_string(), action: "maybe".to_string(), + thread_id: None, }, &state, "user1", @@ -374,6 +441,7 @@ mod tests { WsClientMessage::Approval { request_id: "not-a-uuid".to_string(), action: "approve".to_string(), + thread_id: None, }, &state, "user1", @@ -398,11 +466,13 @@ mod tests { msg_tx: tokio::sync::RwLock::new(msg_tx), sse: SseManager::new(), workspace: None, - context_manager: None, session_manager: None, log_broadcaster: None, extension_manager: None, tool_registry: None, + store: None, + job_manager: None, + prompt_queue: None, user_id: "test".to_string(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: Some(Arc::new(WsConnectionTracker::new())), diff --git a/src/cli/config.rs b/src/cli/config.rs index 080cccf1..d248fcaf 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -1,6 +1,7 @@ //! Configuration management CLI commands. //! //! Commands for viewing and modifying settings. +//! Settings are stored in PostgreSQL (env > DB > default). use clap::Subcommand; @@ -36,41 +37,80 @@ pub enum ConfigCommand { path: String, }, - /// Show the settings file path + /// Show the settings storage info Path, } /// Run a config command. -pub fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> { +/// +/// Connects to the database to read/write settings. Falls back to disk +/// if the database is not available. +pub async fn run_config_command(cmd: ConfigCommand) -> anyhow::Result<()> { + let _ = dotenvy::dotenv(); + + // Try to connect to the DB for settings access + let store = match connect_store().await { + Ok(s) => Some(s), + Err(e) => { + eprintln!( + "Warning: Could not connect to database ({}), using disk fallback", + e + ); + None + } + }; + match cmd { - ConfigCommand::List { filter } => list_settings(filter), - ConfigCommand::Get { path } => get_setting(&path), - ConfigCommand::Set { path, value } => set_setting(&path, &value), - ConfigCommand::Reset { path } => reset_setting(&path), - ConfigCommand::Path => show_path(), + ConfigCommand::List { filter } => list_settings(store.as_ref(), filter).await, + ConfigCommand::Get { path } => get_setting(store.as_ref(), &path).await, + ConfigCommand::Set { path, value } => set_setting(store.as_ref(), &path, &value).await, + ConfigCommand::Reset { path } => reset_setting(store.as_ref(), &path).await, + ConfigCommand::Path => show_path(store.is_some()), } } +/// Bootstrap a DB connection for config commands. +async fn connect_store() -> anyhow::Result { + let config = crate::config::Config::from_env().map_err(|e| anyhow::anyhow!("{}", e))?; + let store = crate::history::Store::new(&config.database).await?; + store.run_migrations().await?; + Ok(store) +} + +const DEFAULT_USER_ID: &str = "default"; + +/// Load settings: DB if available, else disk. +async fn load_settings(store: Option<&crate::history::Store>) -> Settings { + if let Some(store) = store { + match store.get_all_settings(DEFAULT_USER_ID).await { + Ok(map) if !map.is_empty() => return Settings::from_db_map(&map), + _ => {} + } + } + Settings::load() +} + /// List all settings. -fn list_settings(filter: Option) -> anyhow::Result<()> { - let settings = Settings::load(); +async fn list_settings( + store: Option<&crate::history::Store>, + filter: Option, +) -> anyhow::Result<()> { + let settings = load_settings(store).await; let all = settings.list(); - // Find the longest key for alignment let max_key_len = all.iter().map(|(k, _)| k.len()).max().unwrap_or(0); - println!("Settings:"); + let source = if store.is_some() { "database" } else { "disk" }; + println!("Settings (source: {}):", source); println!(); for (key, value) in all { - // Skip if filter is set and doesn't match if let Some(ref f) = filter { if !key.starts_with(f) { continue; } } - // Truncate long values for display let display_value = if value.len() > 60 { format!("{}...", &value[..57]) } else { @@ -84,8 +124,8 @@ fn list_settings(filter: Option) -> anyhow::Result<()> { } /// Get a specific setting. -fn get_setting(path: &str) -> anyhow::Result<()> { - let settings = Settings::load(); +async fn get_setting(store: Option<&crate::history::Store>, path: &str) -> anyhow::Result<()> { + let settings = load_settings(store).await; match settings.get(path) { Some(value) => { @@ -99,67 +139,92 @@ fn get_setting(path: &str) -> anyhow::Result<()> { } /// Set a setting value. -fn set_setting(path: &str, value: &str) -> anyhow::Result<()> { - let mut settings = Settings::load(); +async fn set_setting( + store: Option<&crate::history::Store>, + path: &str, + value: &str, +) -> anyhow::Result<()> { + let mut settings = load_settings(store).await; - // Try to set the value settings .set(path, value) .map_err(|e| anyhow::anyhow!("{}", e))?; - // Save to disk - settings.save()?; + // Save to DB if available, otherwise disk + if let Some(store) = store { + let json_value = match serde_json::from_str::(value) { + Ok(v) => v, + Err(_) => serde_json::Value::String(value.to_string()), + }; + store + .set_setting(DEFAULT_USER_ID, path, &json_value) + .await + .map_err(|e| anyhow::anyhow!("Failed to save to database: {}", e))?; + } else { + settings.save()?; + } println!("Set {} = {}", path, value); Ok(()) } /// Reset a setting to default. -fn reset_setting(path: &str) -> anyhow::Result<()> { - let mut settings = Settings::load(); - - // Get the default value for display +async fn reset_setting(store: Option<&crate::history::Store>, path: &str) -> anyhow::Result<()> { let default = Settings::default(); let default_value = default .get(path) .ok_or_else(|| anyhow::anyhow!("Unknown setting: {}", path))?; - // Reset it - settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?; - - // Save to disk - settings.save()?; + // Delete from DB (falling back to default) or reset on disk + if let Some(store) = store { + store + .delete_setting(DEFAULT_USER_ID, path) + .await + .map_err(|e| anyhow::anyhow!("Failed to delete setting from database: {}", e))?; + } else { + let mut settings = Settings::load(); + settings.reset(path).map_err(|e| anyhow::anyhow!("{}", e))?; + settings.save()?; + } println!("Reset {} to default: {}", path, default_value); Ok(()) } -/// Show the settings file path. -fn show_path() -> anyhow::Result<()> { - let path = Settings::default_path(); - println!("{}", path.display()); - - if path.exists() { - let metadata = std::fs::metadata(&path)?; - println!(" Size: {} bytes", metadata.len()); - if let Ok(modified) = metadata.modified() { - use std::time::SystemTime; - let duration = SystemTime::now() - .duration_since(modified) - .unwrap_or_default(); - let secs = duration.as_secs(); - if secs < 60 { - println!(" Modified: {} seconds ago", secs); - } else if secs < 3600 { - println!(" Modified: {} minutes ago", secs / 60); - } else if secs < 86400 { - println!(" Modified: {} hours ago", secs / 3600); - } else { - println!(" Modified: {} days ago", secs / 86400); - } - } +/// Show the settings storage info. +fn show_path(has_db: bool) -> anyhow::Result<()> { + if has_db { + println!("Settings stored in: PostgreSQL (settings table)"); + println!( + "Bootstrap config: {}", + crate::bootstrap::BootstrapConfig::default_path().display() + ); } else { - println!(" (does not exist, using defaults)"); + let path = Settings::default_path(); + println!("Settings stored in: {} (disk fallback)", path.display()); + + if path.exists() { + let metadata = std::fs::metadata(&path)?; + println!(" Size: {} bytes", metadata.len()); + if let Ok(modified) = metadata.modified() { + use std::time::SystemTime; + let duration = SystemTime::now() + .duration_since(modified) + .unwrap_or_default(); + let secs = duration.as_secs(); + if secs < 60 { + println!(" Modified: {} seconds ago", secs); + } else if secs < 3600 { + println!(" Modified: {} minutes ago", secs / 60); + } else if secs < 86400 { + println!(" Modified: {} hours ago", secs / 3600); + } else { + println!(" Modified: {} days ago", secs / 86400); + } + } + } else { + println!(" (does not exist, using defaults)"); + } } Ok(()) diff --git a/src/cli/mcp.rs b/src/cli/mcp.rs index db0c65be..311aedc1 100644 --- a/src/cli/mcp.rs +++ b/src/cli/mcp.rs @@ -13,9 +13,7 @@ use crate::secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore}; use crate::tools::mcp::{ McpClient, McpServerConfig, McpSessionManager, OAuthConfig, auth::{authorize_mcp_server, is_authenticated}, - config::{ - add_mcp_server, get_mcp_server, load_mcp_servers, remove_mcp_server, save_mcp_servers, - }, + config::{self, McpServersFile}, }; #[derive(Subcommand, Debug, Clone)] @@ -173,8 +171,11 @@ async fn add_server( // Validate config.validate()?; - // Save - add_mcp_server(config).await?; + // Save (DB if available, else disk) + let store = connect_store().await; + let mut servers = load_servers(store.as_ref()).await?; + servers.upsert(config); + save_servers(store.as_ref(), &servers).await?; println!(); println!(" ✓ Added MCP server '{}'", name); @@ -192,7 +193,12 @@ async fn add_server( /// Remove an MCP server. async fn remove_server(name: String) -> anyhow::Result<()> { - remove_mcp_server(&name).await?; + let store = connect_store().await; + let mut servers = load_servers(store.as_ref()).await?; + if !servers.remove(&name) { + anyhow::bail!("Server '{}' not found", name); + } + save_servers(store.as_ref(), &servers).await?; println!(); println!(" ✓ Removed MCP server '{}'", name); @@ -203,7 +209,8 @@ async fn remove_server(name: String) -> anyhow::Result<()> { /// List configured MCP servers. async fn list_servers(verbose: bool) -> anyhow::Result<()> { - let servers = load_mcp_servers().await?; + let store = connect_store().await; + let servers = load_servers(store.as_ref()).await?; if servers.servers.is_empty() { println!(); @@ -261,7 +268,12 @@ async fn list_servers(verbose: bool) -> anyhow::Result<()> { /// Authenticate with an MCP server. async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> { // Get server config - let server = get_mcp_server(&name).await?; + let store = connect_store().await; + let servers = load_servers(store.as_ref()).await?; + let server = servers + .get(&name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Server '{}' not found", name))?; // Initialize secrets store let secrets = get_secrets_store().await?; @@ -329,7 +341,12 @@ async fn auth_server(name: String, user_id: String) -> anyhow::Result<()> { /// Test connection to an MCP server. async fn test_server(name: String, user_id: String) -> anyhow::Result<()> { // Get server config - let server = get_mcp_server(&name).await?; + let store = connect_store().await; + let servers = load_servers(store.as_ref()).await?; + let server = servers + .get(&name) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Server '{}' not found", name))?; println!(); println!(" Testing connection to '{}'...", name); @@ -420,7 +437,8 @@ async fn test_server(name: String, user_id: String) -> anyhow::Result<()> { /// Toggle server enabled/disabled state. async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Result<()> { - let mut servers = load_mcp_servers().await?; + let store = connect_store().await; + let mut servers = load_servers(store.as_ref()).await?; let server = servers .get_mut(&name) @@ -435,7 +453,7 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res }; server.enabled = new_state; - save_mcp_servers(&servers).await?; + save_servers(store.as_ref(), &servers).await?; let status = if new_state { "enabled" } else { "disabled" }; println!(); @@ -445,6 +463,37 @@ async fn toggle_server(name: String, enable: bool, disable: bool) -> anyhow::Res Ok(()) } +const DEFAULT_USER_ID: &str = "default"; + +/// Try to connect to the database store for DB-backed config. +async fn connect_store() -> Option { + let config = Config::from_env().ok()?; + let store = Store::new(&config.database).await.ok()?; + store.run_migrations().await.ok()?; + Some(store) +} + +/// Load MCP servers (DB if available, else disk). +async fn load_servers(store: Option<&Store>) -> Result { + if let Some(store) = store { + config::load_mcp_servers_from_db(store, DEFAULT_USER_ID).await + } else { + config::load_mcp_servers().await + } +} + +/// Save MCP servers (DB if available, else disk). +async fn save_servers( + store: Option<&Store>, + servers: &McpServersFile, +) -> Result<(), config::ConfigError> { + if let Some(store) = store { + config::save_mcp_servers_to_db(store, DEFAULT_USER_ID, servers).await + } else { + config::save_mcp_servers(servers).await + } +} + /// Initialize and return the secrets store. async fn get_secrets_store() -> anyhow::Result> { let config = Config::from_env()?; diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 005a22c4..a8a0de3c 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -88,6 +88,42 @@ pub enum Command { /// Show system health and diagnostics Status, + + /// Run as a sandboxed worker inside a Docker container (internal use). + /// This is invoked automatically by the orchestrator, not by users directly. + Worker { + /// Job ID to execute. + #[arg(long)] + job_id: uuid::Uuid, + + /// URL of the orchestrator's internal API. + #[arg(long, default_value = "http://host.docker.internal:50051")] + orchestrator_url: String, + + /// Maximum iterations before stopping. + #[arg(long, default_value = "50")] + max_iterations: u32, + }, + + /// Run as a Claude Code bridge inside a Docker container (internal use). + /// Spawns the `claude` CLI and streams output back to the orchestrator. + ClaudeBridge { + /// Job ID to execute. + #[arg(long)] + job_id: uuid::Uuid, + + /// URL of the orchestrator's internal API. + #[arg(long, default_value = "http://host.docker.internal:50051")] + orchestrator_url: String, + + /// Maximum agentic turns for Claude Code. + #[arg(long, default_value = "50")] + max_turns: u32, + + /// Claude model to use (e.g. "sonnet", "opus"). + #[arg(long, default_value = "sonnet")] + model: String, + }, } impl Cli { diff --git a/src/config.rs b/src/config.rs index caa1d386..fd60caa4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,9 @@ //! Configuration for IronClaw. +//! +//! Settings are loaded with priority: env var > database > default. +//! The database replaces the old `settings.json` file for all settings +//! except the 4 bootstrap fields (database_url, pool_size, secrets key +//! source, onboard_completed) which live in `~/.ironclaw/bootstrap.json`. use std::path::PathBuf; use std::time::Duration; @@ -6,6 +11,7 @@ use std::time::Duration; use secrecy::{ExposeSecret, SecretString}; use crate::error::ConfigError; +use crate::settings::Settings; /// Main configuration for the agent. #[derive(Debug, Clone)] @@ -21,28 +27,67 @@ pub struct Config { pub secrets: SecretsConfig, pub builder: BuilderModeConfig, pub heartbeat: HeartbeatConfig, + pub routines: RoutineConfig, pub sandbox: SandboxModeConfig, + pub claude_code: ClaudeCodeConfig, } impl Config { - /// Load configuration from environment variables. - pub fn from_env() -> Result { - // Load .env file if present (ignore errors if not found) + /// Load configuration from environment variables and the database. + /// + /// Priority: env var > DB settings > default. + /// This is the primary way to load config after DB is connected. + pub async fn from_db( + store: &crate::history::Store, + user_id: &str, + bootstrap: &crate::bootstrap::BootstrapConfig, + ) -> Result { let _ = dotenvy::dotenv(); + // Load all settings from DB into a Settings struct + let db_settings = match store.get_all_settings(user_id).await { + Ok(map) => Settings::from_db_map(&map), + Err(e) => { + tracing::warn!("Failed to load settings from DB, using defaults: {}", e); + Settings::default() + } + }; + + Self::build(bootstrap, &db_settings) + } + + /// Load configuration from environment variables only (no database). + /// + /// Used during early startup before the database is connected, + /// and by CLI commands that don't have DB access. + /// Falls back to legacy `settings.json` on disk if present. + pub fn from_env() -> Result { + let _ = dotenvy::dotenv(); + let bootstrap = crate::bootstrap::BootstrapConfig::load(); + let settings = Settings::load(); + Self::build(&bootstrap, &settings) + } + + /// Build config from bootstrap + settings (shared by from_env and from_db). + fn build( + bootstrap: &crate::bootstrap::BootstrapConfig, + settings: &Settings, + ) -> Result { Ok(Self { - database: DatabaseConfig::from_env()?, - llm: LlmConfig::from_env()?, - embeddings: EmbeddingsConfig::from_env()?, - tunnel: TunnelConfig::from_env()?, - channels: ChannelsConfig::from_env()?, - agent: AgentConfig::from_env()?, - safety: SafetyConfig::from_env()?, - wasm: WasmConfig::from_env()?, - secrets: SecretsConfig::from_env()?, - builder: BuilderModeConfig::from_env()?, - heartbeat: HeartbeatConfig::from_env()?, - sandbox: SandboxModeConfig::from_env()?, + database: DatabaseConfig::resolve(bootstrap)?, + llm: LlmConfig::resolve(settings)?, + embeddings: EmbeddingsConfig::resolve(settings)?, + tunnel: TunnelConfig::resolve(settings)?, + channels: ChannelsConfig::resolve(settings)?, + agent: AgentConfig::resolve(settings)?, + safety: SafetyConfig::resolve()?, + wasm: WasmConfig::resolve()?, + secrets: SecretsConfig::resolve(bootstrap)?, + builder: BuilderModeConfig::resolve()?, + heartbeat: HeartbeatConfig::resolve(settings)?, + routines: RoutineConfig::resolve()?, + sandbox: SandboxModeConfig::resolve()?, + claude_code: ClaudeCodeConfig::resolve()?, }) } } @@ -51,48 +96,17 @@ impl Config { /// /// Used by channels and tools that need public webhook endpoints. /// The tunnel URL is shared across all channels (Telegram, Slack, etc.). -/// -/// # Security Notes -/// -/// **Webhook endpoints** (e.g., `/webhook/telegram`) should NOT use tunnel-level -/// authentication because webhook providers (Telegram, Slack, GitHub) need -/// unauthenticated access to POST updates. Security for webhooks comes from: -/// - Webhook signature verification (provider-specific secrets) -/// - IP allowlisting (if supported by provider) -/// -/// **Non-webhook endpoints** (admin APIs, health checks) CAN be protected using -/// tunnel provider features: -/// - ngrok: Basic Auth, OAuth, IP restrictions -/// - Cloudflare: Access policies, mTLS -/// -/// These protections are configured in the tunnel provider, not here. -/// -/// # Supported Providers -/// -/// - **ngrok**: `ngrok http 8080` -> `https://abc123.ngrok.io` -/// - **Cloudflare Tunnel**: `cloudflared tunnel --url http://localhost:8080` -/// - **localtunnel**: `lt --port 8080` -/// - Any service that provides a public HTTPS URL to localhost #[derive(Debug, Clone, Default)] pub struct TunnelConfig { /// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io"). - /// - /// When set, channels that support webhooks will register their endpoints - /// with this base URL instead of using polling. pub public_url: Option, } impl TunnelConfig { - fn from_env() -> Result { - // Priority: env var > settings file - let public_url = optional_env("TUNNEL_URL")?.or_else(|| { - crate::settings::Settings::load() - .tunnel - .public_url - .filter(|s| !s.is_empty()) - }); + fn resolve(settings: &Settings) -> Result { + let public_url = optional_env("TUNNEL_URL")? + .or_else(|| settings.tunnel.public_url.clone().filter(|s| !s.is_empty())); - // Validate URL format if provided if let Some(ref url) = public_url { if !url.starts_with("https://") { return Err(ConfigError::InvalidValue { @@ -111,8 +125,6 @@ impl TunnelConfig { } /// Get the webhook URL for a given path. - /// - /// Returns `None` if no tunnel is configured. pub fn webhook_url(&self, path: &str) -> Option { self.public_url.as_ref().map(|base| { let base = base.trim_end_matches('/'); @@ -130,18 +142,14 @@ pub struct DatabaseConfig { } impl DatabaseConfig { - fn from_env() -> Result { - let settings = crate::settings::Settings::load(); - - // Priority: env var > settings > error (required) + fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result { let url = optional_env("DATABASE_URL")? - .or(settings.database_url.clone()) + .or_else(|| bootstrap.database_url.clone()) .ok_or_else(|| ConfigError::MissingRequired { key: "database_url".to_string(), hint: "Run 'ironclaw onboard' or set DATABASE_URL environment variable".to_string(), })?; - // Priority: env var > settings > default let pool_size = optional_env("DATABASE_POOL_SIZE")? .map(|s| s.parse()) .transpose() @@ -149,7 +157,7 @@ impl DatabaseConfig { key: "DATABASE_POOL_SIZE".to_string(), message: format!("must be a positive integer: {e}"), })? - .or(settings.database_pool_size) + .or(bootstrap.database_pool_size) .unwrap_or(10); Ok(Self { @@ -215,17 +223,15 @@ pub struct NearAiConfig { } impl LlmConfig { - fn from_env() -> Result { + fn resolve(settings: &Settings) -> Result { let api_key = optional_env("NEARAI_API_KEY")?.map(SecretString::from); - // Determine API mode: explicit setting, or infer from API key presence let api_mode = if let Some(mode_str) = optional_env("NEARAI_API_MODE")? { mode_str.parse().map_err(|e| ConfigError::InvalidValue { key: "NEARAI_API_MODE".to_string(), message: e, })? } else if api_key.is_some() { - // If API key is provided, default to chat_completions mode NearAiApiMode::ChatCompletions } else { NearAiApiMode::Responses @@ -233,10 +239,8 @@ impl LlmConfig { Ok(Self { nearai: NearAiConfig { - // Load model from saved settings first, then env, then default - model: crate::settings::Settings::load() - .selected_model - .or_else(|| optional_env("NEARAI_MODEL").ok().flatten()) + model: optional_env("NEARAI_MODEL")? + .or_else(|| settings.selected_model.clone()) .unwrap_or_else(|| { "fireworks::accounts/fireworks/models/llama4-maverick-instruct-basic" .to_string() @@ -265,8 +269,6 @@ pub struct EmbeddingsConfig { /// OpenAI API key (for OpenAI provider). pub openai_api_key: Option, /// Model to use for embeddings. - /// For OpenAI: "text-embedding-3-small", "text-embedding-3-large", "text-embedding-ada-002" - /// For NEAR AI: Uses the configured session for auth. pub model: String, } @@ -282,18 +284,15 @@ impl Default for EmbeddingsConfig { } impl EmbeddingsConfig { - fn from_env() -> Result { - let settings = crate::settings::Settings::load(); + fn resolve(settings: &Settings) -> Result { let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from); - // Priority: env var > settings > default let provider = optional_env("EMBEDDING_PROVIDER")? .unwrap_or_else(|| settings.embeddings.provider.clone()); let model = optional_env("EMBEDDING_MODEL")?.unwrap_or_else(|| settings.embeddings.model.clone()); - // Priority: env var > settings > auto-detect from API key let enabled = optional_env("EMBEDDING_ENABLED")? .map(|s| s.parse()) .transpose() @@ -301,10 +300,7 @@ impl EmbeddingsConfig { key: "EMBEDDING_ENABLED".to_string(), message: format!("must be 'true' or 'false': {e}"), })? - .unwrap_or_else(|| { - // Check settings, or auto-enable if API key present - settings.embeddings.enabled || openai_api_key.is_some() - }); + .unwrap_or_else(|| settings.embeddings.enabled || openai_api_key.is_some()); Ok(Self { enabled, @@ -338,6 +334,8 @@ pub struct ChannelsConfig { pub wasm_channels_dir: std::path::PathBuf, /// Whether WASM channels are enabled. pub wasm_channels_enabled: bool, + /// Telegram owner user ID. When set, the bot only responds to this user. + pub telegram_owner_id: Option, } #[derive(Debug, Clone)] @@ -364,7 +362,7 @@ pub struct GatewayConfig { } impl ChannelsConfig { - fn from_env() -> Result { + fn resolve(settings: &Settings) -> Result { let http = if optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some() { Some(HttpConfig { host: optional_env("HTTP_HOST")?.unwrap_or_else(|| "0.0.0.0".to_string()), @@ -385,7 +383,7 @@ impl ChannelsConfig { let gateway = if optional_env("GATEWAY_ENABLED")? .map(|s| s.to_lowercase() == "true" || s == "1") - .unwrap_or(false) + .unwrap_or(true) { Some(GatewayConfig { host: optional_env("GATEWAY_HOST")?.unwrap_or_else(|| "127.0.0.1".to_string()), @@ -425,6 +423,14 @@ impl ChannelsConfig { message: format!("must be 'true' or 'false': {e}"), })? .unwrap_or(true), + telegram_owner_id: optional_env("TELEGRAM_OWNER_ID")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "TELEGRAM_OWNER_ID".to_string(), + message: format!("must be an integer: {e}"), + })? + .or(settings.channels.telegram_owner_id), }) } } @@ -450,14 +456,13 @@ pub struct AgentConfig { pub use_planning: bool, /// Session idle timeout. Sessions inactive longer than this are pruned. pub session_idle_timeout: Duration, + /// Allow chat to use filesystem/shell tools directly (bypass sandbox). + pub allow_local_tools: bool, } impl AgentConfig { - fn from_env() -> Result { - let settings = crate::settings::Settings::load(); - + fn resolve(settings: &Settings) -> Result { Ok(Self { - // Priority: env var > settings > default name: optional_env("AGENT_NAME")?.unwrap_or_else(|| settings.agent.name.clone()), max_parallel_jobs: optional_env("AGENT_MAX_PARALLEL_JOBS")? .map(|s| s.parse()) @@ -523,6 +528,14 @@ impl AgentConfig { })? .unwrap_or(settings.agent.session_idle_timeout_secs), ), + allow_local_tools: optional_env("ALLOW_LOCAL_TOOLS")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "ALLOW_LOCAL_TOOLS".to_string(), + message: format!("must be 'true' or 'false': {e}"), + })? + .unwrap_or(false), }) } } @@ -535,7 +548,7 @@ pub struct SafetyConfig { } impl SafetyConfig { - fn from_env() -> Result { + fn resolve() -> Result { Ok(Self { max_output_length: parse_optional_env("SAFETY_MAX_OUTPUT_LENGTH", 100_000)?, injection_check_enabled: optional_env("SAFETY_INJECTION_CHECK_ENABLED")? @@ -573,7 +586,6 @@ pub struct WasmConfig { #[derive(Clone, Default)] pub struct SecretsConfig { /// Master key for encrypting secrets. - /// Source determined by KeySource in settings. pub master_key: Option, /// Whether secrets management is enabled. pub enabled: bool, @@ -592,38 +604,28 @@ impl std::fmt::Debug for SecretsConfig { } impl SecretsConfig { - fn from_env() -> Result { + fn resolve(bootstrap: &crate::bootstrap::BootstrapConfig) -> Result { use crate::settings::KeySource; - let settings = crate::settings::Settings::load(); - - // Priority: env var > keychain (based on settings) > disabled let (master_key, source) = if let Some(env_key) = optional_env("SECRETS_MASTER_KEY")? { - // Env var takes priority (for CI/Docker) (Some(SecretString::from(env_key)), KeySource::Env) } else { - match settings.secrets_master_key_source { - KeySource::Keychain => { - // Try to load from OS keychain - match crate::secrets::keychain::get_master_key() { - Ok(key_bytes) => { - let key_hex: String = - key_bytes.iter().map(|b| format!("{:02x}", b)).collect(); - (Some(SecretString::from(key_hex)), KeySource::Keychain) - } - Err(_) => { - // Keychain configured but key not found - // This might happen if keychain was cleared - tracing::warn!( - "Secrets configured for keychain but key not found. \ - Run 'ironclaw onboard' to reconfigure." - ); - (None, KeySource::None) - } + match bootstrap.secrets_master_key_source { + KeySource::Keychain => match crate::secrets::keychain::get_master_key() { + Ok(key_bytes) => { + let key_hex: String = + key_bytes.iter().map(|b| format!("{:02x}", b)).collect(); + (Some(SecretString::from(key_hex)), KeySource::Keychain) } - } + Err(_) => { + tracing::warn!( + "Secrets configured for keychain but key not found. \ + Run 'ironclaw onboard' to reconfigure." + ); + (None, KeySource::None) + } + }, KeySource::Env => { - // Settings say env, but no env var found tracing::warn!( "Secrets configured for env var but SECRETS_MASTER_KEY not set." ); @@ -635,7 +637,6 @@ impl SecretsConfig { let enabled = master_key.is_some(); - // Validate master key length if provided if let Some(ref key) = master_key { if key.expose_secret().len() < 32 { return Err(ConfigError::InvalidValue { @@ -681,7 +682,7 @@ fn default_tools_dir() -> PathBuf { } impl WasmConfig { - fn from_env() -> Result { + fn resolve() -> Result { Ok(Self { enabled: optional_env("WASM_ENABLED")? .map(|s| s.parse()) @@ -752,7 +753,7 @@ pub struct BuilderModeConfig { impl Default for BuilderModeConfig { fn default() -> Self { Self { - enabled: true, // Builder enabled by default + enabled: true, build_dir: None, max_iterations: 20, timeout_secs: 600, @@ -762,7 +763,7 @@ impl Default for BuilderModeConfig { } impl BuilderModeConfig { - fn from_env() -> Result { + fn resolve() -> Result { Ok(Self { enabled: optional_env("BUILDER_ENABLED")? .map(|s| s.parse()) @@ -771,7 +772,7 @@ impl BuilderModeConfig { key: "BUILDER_ENABLED".to_string(), message: format!("must be 'true' or 'false': {e}"), })? - .unwrap_or(true), // Builder enabled by default + .unwrap_or(true), build_dir: optional_env("BUILDER_DIR")?.map(PathBuf::from), max_iterations: parse_optional_env("BUILDER_MAX_ITERATIONS", 20)?, timeout_secs: parse_optional_env("BUILDER_TIMEOUT_SECS", 600)?, @@ -826,11 +827,8 @@ impl Default for HeartbeatConfig { } impl HeartbeatConfig { - fn from_env() -> Result { - let settings = crate::settings::Settings::load(); - + fn resolve(settings: &Settings) -> Result { Ok(Self { - // Priority: env var > settings > default enabled: optional_env("HEARTBEAT_ENABLED")? .map(|s| s.parse()) .transpose() @@ -848,9 +846,55 @@ impl HeartbeatConfig { })? .unwrap_or(settings.heartbeat.interval_secs), notify_channel: optional_env("HEARTBEAT_NOTIFY_CHANNEL")? - .or(settings.heartbeat.notify_channel.clone()), + .or_else(|| settings.heartbeat.notify_channel.clone()), notify_user: optional_env("HEARTBEAT_NOTIFY_USER")? - .or(settings.heartbeat.notify_user.clone()), + .or_else(|| settings.heartbeat.notify_user.clone()), + }) + } +} + +/// Routines configuration. +#[derive(Debug, Clone)] +pub struct RoutineConfig { + /// Whether the routines system is enabled. + pub enabled: bool, + /// How often (seconds) to poll for cron routines that need firing. + pub cron_check_interval_secs: u64, + /// Max routines executing concurrently across all users. + pub max_concurrent_routines: usize, + /// Default cooldown between fires (seconds). + pub default_cooldown_secs: u64, + /// Max output tokens for lightweight routine LLM calls. + pub max_lightweight_tokens: u32, +} + +impl Default for RoutineConfig { + fn default() -> Self { + Self { + enabled: true, + cron_check_interval_secs: 15, + max_concurrent_routines: 10, + default_cooldown_secs: 300, + max_lightweight_tokens: 4096, + } + } +} + +impl RoutineConfig { + fn resolve() -> Result { + Ok(Self { + enabled: optional_env("ROUTINES_ENABLED")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "ROUTINES_ENABLED".to_string(), + message: format!("must be 'true' or 'false': {e}"), + })? + .unwrap_or(true), + cron_check_interval_secs: parse_optional_env("ROUTINES_CRON_INTERVAL", 15)?, + max_concurrent_routines: parse_optional_env("ROUTINES_MAX_CONCURRENT", 10)?, + default_cooldown_secs: parse_optional_env("ROUTINES_DEFAULT_COOLDOWN", 300)?, + max_lightweight_tokens: parse_optional_env("ROUTINES_MAX_TOKENS", 4096)?, }) } } @@ -879,7 +923,7 @@ pub struct SandboxModeConfig { impl Default for SandboxModeConfig { fn default() -> Self { Self { - enabled: true, // Enabled by default + enabled: true, policy: "readonly".to_string(), timeout_secs: 120, memory_limit_mb: 2048, @@ -892,7 +936,7 @@ impl Default for SandboxModeConfig { } impl SandboxModeConfig { - fn from_env() -> Result { + fn resolve() -> Result { let extra_domains = optional_env("SANDBOX_EXTRA_DOMAINS")? .map(|s| s.split(',').map(|d| d.trim().to_string()).collect()) .unwrap_or_default(); @@ -948,6 +992,60 @@ impl SandboxModeConfig { } } +/// Claude Code sandbox configuration. +#[derive(Debug, Clone)] +pub struct ClaudeCodeConfig { + /// Whether Claude Code sandbox mode is available. + pub enabled: bool, + /// Host directory containing Claude auth session (mounted read-only). + pub config_dir: std::path::PathBuf, + /// Claude model to use (e.g. "sonnet", "opus"). + pub model: String, + /// Maximum agentic turns before stopping. + pub max_turns: u32, + /// Memory limit in MB for Claude Code containers (heavier than workers). + pub memory_limit_mb: u64, +} + +impl Default for ClaudeCodeConfig { + fn default() -> Self { + Self { + enabled: false, + config_dir: dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".claude"), + model: "sonnet".to_string(), + max_turns: 50, + memory_limit_mb: 4096, + } + } +} + +impl ClaudeCodeConfig { + fn resolve() -> Result { + let defaults = Self::default(); + Ok(Self { + enabled: optional_env("CLAUDE_CODE_ENABLED")? + .map(|s| s.parse()) + .transpose() + .map_err(|e| ConfigError::InvalidValue { + key: "CLAUDE_CODE_ENABLED".to_string(), + message: format!("must be 'true' or 'false': {e}"), + })? + .unwrap_or(defaults.enabled), + config_dir: optional_env("CLAUDE_CONFIG_DIR")? + .map(std::path::PathBuf::from) + .unwrap_or(defaults.config_dir), + model: optional_env("CLAUDE_CODE_MODEL")?.unwrap_or(defaults.model), + max_turns: parse_optional_env("CLAUDE_CODE_MAX_TURNS", defaults.max_turns)?, + memory_limit_mb: parse_optional_env( + "CLAUDE_CODE_MEMORY_LIMIT_MB", + defaults.memory_limit_mb, + )?, + }) + } +} + // Helper functions fn optional_env(key: &str) -> Result, ConfigError> { diff --git a/src/error.rs b/src/error.rs index 10da699f..189589d0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -39,6 +39,12 @@ pub enum Error { #[error("Workspace error: {0}")] Workspace(#[from] WorkspaceError), + + #[error("Orchestrator error: {0}")] + Orchestrator(#[from] OrchestratorError), + + #[error("Worker error: {0}")] + Worker(#[from] WorkerError), } /// Configuration-related errors. @@ -308,5 +314,52 @@ pub enum WorkspaceError { HeartbeatError { reason: String }, } +/// Orchestrator errors (internal API, container management). +#[derive(Debug, thiserror::Error)] +pub enum OrchestratorError { + #[error("Container creation failed for job {job_id}: {reason}")] + ContainerCreationFailed { job_id: Uuid, reason: String }, + + #[error("Container not found for job {job_id}")] + ContainerNotFound { job_id: Uuid }, + + #[error("Container for job {job_id} is in unexpected state: {state}")] + InvalidContainerState { job_id: Uuid, state: String }, + + #[error("Worker authentication failed: {reason}")] + AuthFailed { reason: String }, + + #[error("Internal API error: {reason}")] + ApiError { reason: String }, + + #[error("Docker error: {reason}")] + Docker { reason: String }, + + #[error("Job {job_id} timed out in container")] + ContainerTimeout { job_id: Uuid }, +} + +/// Worker errors (container-side execution). +#[derive(Debug, thiserror::Error)] +pub enum WorkerError { + #[error("Failed to connect to orchestrator at {url}: {reason}")] + ConnectionFailed { url: String, reason: String }, + + #[error("LLM proxy request failed: {reason}")] + LlmProxyFailed { reason: String }, + + #[error("Secret resolution failed for {secret_name}: {reason}")] + SecretResolveFailed { secret_name: String, reason: String }, + + #[error("Orchestrator returned error for job {job_id}: {reason}")] + OrchestratorRejected { job_id: Uuid, reason: String }, + + #[error("Worker execution failed: {reason}")] + ExecutionFailed { reason: String }, + + #[error("Missing worker token (IRONCLAW_WORKER_TOKEN not set)")] + MissingToken, +} + /// Result type alias for the agent. pub type Result = std::result::Result; diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index f79d7d95..e39d1980 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -23,9 +23,7 @@ use crate::tools::mcp::auth::{ PkceChallenge, authorize_mcp_server, build_authorization_url, discover_full_oauth_metadata, find_available_port, is_authenticated, register_client, }; -use crate::tools::mcp::config::{ - McpServerConfig, add_mcp_server, get_mcp_server, load_mcp_servers, remove_mcp_server, -}; +use crate::tools::mcp::config::McpServerConfig; use crate::tools::mcp::session::McpSessionManager; use crate::tools::wasm::{WasmToolLoader, WasmToolRuntime, discover_tools}; @@ -58,6 +56,8 @@ pub struct ExtensionManager { /// Tunnel URL for remote OAuth callbacks (used in future iterations). _tunnel_url: Option, user_id: String, + /// Optional database store for DB-backed MCP config. + store: Option>, } impl ExtensionManager { @@ -71,6 +71,7 @@ impl ExtensionManager { wasm_channels_dir: PathBuf, tunnel_url: Option, user_id: String, + store: Option>, ) -> Self { Self { registry: ExtensionRegistry::new(), @@ -85,6 +86,7 @@ impl ExtensionManager { pending_auth: RwLock::new(HashMap::new()), _tunnel_url: tunnel_url, user_id, + store, } } @@ -191,7 +193,7 @@ impl ExtensionManager { // List MCP servers if kind_filter.is_none() || kind_filter == Some(ExtensionKind::McpServer) { - match load_mcp_servers().await { + match self.load_mcp_servers().await { Ok(servers) => { for server in &servers.servers { let authenticated = @@ -304,7 +306,7 @@ impl ExtensionManager { self.mcp_clients.write().await.remove(name); // Remove from config - remove_mcp_server(name) + self.remove_mcp_server(name) .await .map_err(|e| ExtensionError::Config(e.to_string()))?; @@ -342,6 +344,54 @@ impl ExtensionManager { } } + // ── MCP config helpers (DB with disk fallback) ───────────────────── + + async fn load_mcp_servers( + &self, + ) -> Result + { + if let Some(ref store) = self.store { + crate::tools::mcp::config::load_mcp_servers_from_db(store, &self.user_id).await + } else { + crate::tools::mcp::config::load_mcp_servers().await + } + } + + async fn get_mcp_server( + &self, + name: &str, + ) -> Result { + let servers = self.load_mcp_servers().await?; + servers.get(name).cloned().ok_or_else(|| { + crate::tools::mcp::config::ConfigError::ServerNotFound { + name: name.to_string(), + } + }) + } + + async fn add_mcp_server( + &self, + config: McpServerConfig, + ) -> Result<(), crate::tools::mcp::config::ConfigError> { + config.validate()?; + if let Some(ref store) = self.store { + crate::tools::mcp::config::add_mcp_server_db(store, &self.user_id, config).await + } else { + crate::tools::mcp::config::add_mcp_server(config).await + } + } + + async fn remove_mcp_server( + &self, + name: &str, + ) -> Result<(), crate::tools::mcp::config::ConfigError> { + if let Some(ref store) = self.store { + crate::tools::mcp::config::remove_mcp_server_db(store, &self.user_id, name).await + } else { + crate::tools::mcp::config::remove_mcp_server(name).await + } + } + // ── Private helpers ────────────────────────────────────────────────── async fn install_from_entry( @@ -381,7 +431,7 @@ impl ExtensionManager { url: &str, ) -> Result { // Check if already installed - if get_mcp_server(name).await.is_ok() { + if self.get_mcp_server(name).await.is_ok() { return Err(ExtensionError::AlreadyInstalled(name.to_string())); } @@ -390,7 +440,7 @@ impl ExtensionManager { .validate() .map_err(|e| ExtensionError::InvalidUrl(e.to_string()))?; - add_mcp_server(config) + self.add_mcp_server(config) .await .map_err(|e| ExtensionError::Config(e.to_string()))?; @@ -465,7 +515,8 @@ impl ExtensionManager { name: &str, token: Option<&str>, ) -> Result { - let server = get_mcp_server(name) + let server = self + .get_mcp_server(name) .await .map_err(|e| ExtensionError::NotInstalled(e.to_string()))?; @@ -784,7 +835,8 @@ impl ExtensionManager { } } - let server = get_mcp_server(name) + let server = self + .get_mcp_server(name) .await .map_err(|e| ExtensionError::NotInstalled(e.to_string()))?; @@ -893,7 +945,7 @@ impl ExtensionManager { /// Determine what kind of installed extension this is. async fn determine_installed_kind(&self, name: &str) -> Result { // Check MCP servers first - if get_mcp_server(name).await.is_ok() { + if self.get_mcp_server(name).await.is_ok() { return Ok(ExtensionKind::McpServer); } diff --git a/src/history/mod.rs b/src/history/mod.rs index e8254479..a9e5df35 100644 --- a/src/history/mod.rs +++ b/src/history/mod.rs @@ -9,4 +9,7 @@ mod analytics; mod store; pub use analytics::{JobStats, ToolStats}; -pub use store::{LlmCallRecord, Store}; +pub use store::{ + ConversationMessage, ConversationSummary, JobEventRecord, LlmCallRecord, SandboxJobRecord, + SandboxJobSummary, Store, +}; diff --git a/src/history/store.rs b/src/history/store.rs index 0850701d..c5f8d8bd 100644 --- a/src/history/store.rs +++ b/src/history/store.rs @@ -1,5 +1,6 @@ //! PostgreSQL store for persisting agent data. +use chrono::{DateTime, Utc}; use deadpool_postgres::{Config, Pool, Runtime}; use rust_decimal::Decimal; use tokio_postgres::NoTls; @@ -47,11 +48,16 @@ impl Store { Ok(Self { pool }) } - /// Run database migrations. + /// Run database migrations (embedded via refinery). pub async fn run_migrations(&self) -> Result<(), DatabaseError> { - // For now, we assume migrations are run externally via refinery or similar - // In production, you'd integrate refinery here - tracing::info!("Database migrations should be run via: refinery migrate -c refinery.toml"); + use refinery::embed_migrations; + embed_migrations!("migrations"); + + let mut client = self.pool.get().await?; + migrations::runner() + .run_async(&mut **client) + .await + .map_err(|e| DatabaseError::Migration(e.to_string()))?; Ok(()) } @@ -176,7 +182,7 @@ impl Store { let row = conn .query_opt( r#" - SELECT id, conversation_id, title, description, category, status, + SELECT id, conversation_id, title, description, category, status, user_id, budget_amount, budget_token, bid_amount, estimated_cost, estimated_time_secs, actual_cost, repair_attempts, created_at, started_at, completed_at FROM agent_jobs WHERE id = $1 @@ -194,7 +200,7 @@ impl Store { Ok(Some(JobContext { job_id: row.get("id"), state, - user_id: "default".to_string(), // Not stored in DB yet + user_id: row.get::<_, String>("user_id"), conversation_id: row.get("conversation_id"), title: row.get("title"), description: row.get("description"), @@ -429,6 +435,946 @@ impl Store { } } +// ==================== Sandbox Jobs ==================== + +/// Record for a sandbox container job, persisted in the `agent_jobs` table +/// with `source = 'sandbox'`. +#[derive(Debug, Clone)] +pub struct SandboxJobRecord { + pub id: Uuid, + pub task: String, + pub status: String, + pub user_id: String, + pub project_dir: String, + pub success: Option, + pub failure_reason: Option, + pub created_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, +} + +/// Summary of sandbox job counts grouped by status. +#[derive(Debug, Clone, Default)] +pub struct SandboxJobSummary { + pub total: usize, + pub creating: usize, + pub running: usize, + pub completed: usize, + pub failed: usize, + pub interrupted: usize, +} + +impl Store { + /// Insert a new sandbox job into `agent_jobs`. + pub async fn save_sandbox_job(&self, job: &SandboxJobRecord) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + conn.execute( + r#" + INSERT INTO agent_jobs ( + id, title, description, status, source, user_id, project_dir, + success, failure_reason, created_at, started_at, completed_at + ) VALUES ($1, $2, '', $3, 'sandbox', $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + success = EXCLUDED.success, + failure_reason = EXCLUDED.failure_reason, + started_at = EXCLUDED.started_at, + completed_at = EXCLUDED.completed_at + "#, + &[ + &job.id, + &job.task, + &job.status, + &job.user_id, + &job.project_dir, + &job.success, + &job.failure_reason, + &job.created_at, + &job.started_at, + &job.completed_at, + ], + ) + .await?; + Ok(()) + } + + /// Get a sandbox job by ID. + pub async fn get_sandbox_job( + &self, + id: Uuid, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let row = conn + .query_opt( + r#" + SELECT id, title, status, user_id, project_dir, + success, failure_reason, created_at, started_at, completed_at + FROM agent_jobs WHERE id = $1 AND source = 'sandbox' + "#, + &[&id], + ) + .await?; + + Ok(row.map(|r| SandboxJobRecord { + id: r.get("id"), + task: r.get("title"), + status: r.get("status"), + user_id: r.get("user_id"), + project_dir: r + .get::<_, Option>("project_dir") + .unwrap_or_default(), + success: r.get("success"), + failure_reason: r.get("failure_reason"), + created_at: r.get("created_at"), + started_at: r.get("started_at"), + completed_at: r.get("completed_at"), + })) + } + + /// List all sandbox jobs, most recent first. + pub async fn list_sandbox_jobs(&self) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + r#" + SELECT id, title, status, user_id, project_dir, + success, failure_reason, created_at, started_at, completed_at + FROM agent_jobs WHERE source = 'sandbox' + ORDER BY created_at DESC + "#, + &[], + ) + .await?; + + Ok(rows + .iter() + .map(|r| SandboxJobRecord { + id: r.get("id"), + task: r.get("title"), + status: r.get("status"), + user_id: r.get("user_id"), + project_dir: r + .get::<_, Option>("project_dir") + .unwrap_or_default(), + success: r.get("success"), + failure_reason: r.get("failure_reason"), + created_at: r.get("created_at"), + started_at: r.get("started_at"), + completed_at: r.get("completed_at"), + }) + .collect()) + } + + /// Update sandbox job status and optional timestamps/result. + pub async fn update_sandbox_job_status( + &self, + id: Uuid, + status: &str, + success: Option, + message: Option<&str>, + started_at: Option>, + completed_at: Option>, + ) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + conn.execute( + r#" + UPDATE agent_jobs SET + status = $2, + success = COALESCE($3, success), + failure_reason = COALESCE($4, failure_reason), + started_at = COALESCE($5, started_at), + completed_at = COALESCE($6, completed_at) + WHERE id = $1 AND source = 'sandbox' + "#, + &[&id, &status, &success, &message, &started_at, &completed_at], + ) + .await?; + Ok(()) + } + + /// Mark any sandbox jobs left in "running" or "creating" as "interrupted". + /// + /// Called on startup to handle jobs that were running when the process died. + pub async fn cleanup_stale_sandbox_jobs(&self) -> Result { + let conn = self.conn().await?; + let count = conn + .execute( + r#" + UPDATE agent_jobs SET + status = 'interrupted', + failure_reason = 'Process restarted', + completed_at = NOW() + WHERE source = 'sandbox' AND status IN ('running', 'creating') + "#, + &[], + ) + .await?; + if count > 0 { + tracing::info!("Marked {} stale sandbox jobs as interrupted", count); + } + Ok(count) + } + + /// Get a summary of sandbox job counts by status. + pub async fn sandbox_job_summary(&self) -> Result { + let conn = self.conn().await?; + let rows = conn + .query( + "SELECT status, COUNT(*) as cnt FROM agent_jobs WHERE source = 'sandbox' GROUP BY status", + &[], + ) + .await?; + + let mut summary = SandboxJobSummary::default(); + for row in &rows { + let status: String = row.get("status"); + let count: i64 = row.get("cnt"); + let c = count as usize; + summary.total += c; + match status.as_str() { + "creating" => summary.creating += c, + "running" => summary.running += c, + "completed" => summary.completed += c, + "failed" => summary.failed += c, + "interrupted" => summary.interrupted += c, + _ => {} + } + } + Ok(summary) + } +} + +// ==================== Job Events ==================== + +/// A persisted job streaming event (from worker or Claude Code bridge). +#[derive(Debug, Clone)] +pub struct JobEventRecord { + pub id: i64, + pub job_id: Uuid, + pub event_type: String, + pub data: serde_json::Value, + pub created_at: DateTime, +} + +impl Store { + /// Persist a job event (fire-and-forget from orchestrator handler). + pub async fn save_job_event( + &self, + job_id: Uuid, + event_type: &str, + data: &serde_json::Value, + ) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + conn.execute( + r#" + INSERT INTO job_events (job_id, event_type, data) + VALUES ($1, $2, $3) + "#, + &[&job_id, &event_type, data], + ) + .await?; + Ok(()) + } + + /// Load all job events for a job, ordered by id. + pub async fn list_job_events( + &self, + job_id: Uuid, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + r#" + SELECT id, job_id, event_type, data, created_at + FROM job_events + WHERE job_id = $1 + ORDER BY id ASC + "#, + &[&job_id], + ) + .await?; + Ok(rows + .iter() + .map(|r| JobEventRecord { + id: r.get("id"), + job_id: r.get("job_id"), + event_type: r.get("event_type"), + data: r.get("data"), + created_at: r.get("created_at"), + }) + .collect()) + } + + /// Update the job_mode column for a sandbox job. + pub async fn update_sandbox_job_mode(&self, id: Uuid, mode: &str) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + conn.execute( + "UPDATE agent_jobs SET job_mode = $2 WHERE id = $1", + &[&id, &mode], + ) + .await?; + Ok(()) + } + + /// Get the job_mode for a sandbox job. + pub async fn get_sandbox_job_mode(&self, id: Uuid) -> Result, DatabaseError> { + let conn = self.conn().await?; + let row = conn + .query_opt("SELECT job_mode FROM agent_jobs WHERE id = $1", &[&id]) + .await?; + Ok(row.map(|r| r.get("job_mode"))) + } +} + +// ==================== Routines ==================== + +use crate::agent::routine::{ + NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger, +}; + +impl Store { + /// Create a new routine. + pub async fn create_routine(&self, routine: &Routine) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + let trigger_type = routine.trigger.type_tag(); + let trigger_config = routine.trigger.to_config_json(); + let action_type = routine.action.type_tag(); + let action_config = routine.action.to_config_json(); + let cooldown_secs = routine.guardrails.cooldown.as_secs() as i32; + let max_concurrent = routine.guardrails.max_concurrent as i32; + let dedup_window_secs = routine.guardrails.dedup_window.map(|d| d.as_secs() as i32); + + conn.execute( + r#" + INSERT INTO routines ( + id, name, description, user_id, enabled, + trigger_type, trigger_config, action_type, action_config, + cooldown_secs, max_concurrent, dedup_window_secs, + notify_channel, notify_user, notify_on_success, notify_on_failure, notify_on_attention, + state, next_fire_at, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, + $10, $11, $12, + $13, $14, $15, $16, $17, + $18, $19, $20, $21 + ) + "#, + &[ + &routine.id, + &routine.name, + &routine.description, + &routine.user_id, + &routine.enabled, + &trigger_type, + &trigger_config, + &action_type, + &action_config, + &cooldown_secs, + &max_concurrent, + &dedup_window_secs, + &routine.notify.channel, + &routine.notify.user, + &routine.notify.on_success, + &routine.notify.on_failure, + &routine.notify.on_attention, + &routine.state, + &routine.next_fire_at, + &routine.created_at, + &routine.updated_at, + ], + ) + .await?; + + Ok(()) + } + + /// Get a routine by ID. + pub async fn get_routine(&self, id: Uuid) -> Result, DatabaseError> { + let conn = self.conn().await?; + let row = conn + .query_opt("SELECT * FROM routines WHERE id = $1", &[&id]) + .await?; + row.map(|r| row_to_routine(&r)).transpose() + } + + /// Get a routine by user_id and name. + pub async fn get_routine_by_name( + &self, + user_id: &str, + name: &str, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let row = conn + .query_opt( + "SELECT * FROM routines WHERE user_id = $1 AND name = $2", + &[&user_id, &name], + ) + .await?; + row.map(|r| row_to_routine(&r)).transpose() + } + + /// List routines for a user. + pub async fn list_routines(&self, user_id: &str) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + "SELECT * FROM routines WHERE user_id = $1 ORDER BY name", + &[&user_id], + ) + .await?; + rows.iter().map(row_to_routine).collect() + } + + /// List all enabled routines with event triggers (for event matching). + pub async fn list_event_routines(&self) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + "SELECT * FROM routines WHERE enabled AND trigger_type = 'event'", + &[], + ) + .await?; + rows.iter().map(row_to_routine).collect() + } + + /// List all enabled cron routines whose next_fire_at <= now. + pub async fn list_due_cron_routines(&self) -> Result, DatabaseError> { + let conn = self.conn().await?; + let now = Utc::now(); + let rows = conn + .query( + r#" + SELECT * FROM routines + WHERE enabled + AND trigger_type = 'cron' + AND next_fire_at IS NOT NULL + AND next_fire_at <= $1 + "#, + &[&now], + ) + .await?; + rows.iter().map(row_to_routine).collect() + } + + /// Update a routine (full replacement of mutable fields). + pub async fn update_routine(&self, routine: &Routine) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + let trigger_type = routine.trigger.type_tag(); + let trigger_config = routine.trigger.to_config_json(); + let action_type = routine.action.type_tag(); + let action_config = routine.action.to_config_json(); + let cooldown_secs = routine.guardrails.cooldown.as_secs() as i32; + let max_concurrent = routine.guardrails.max_concurrent as i32; + let dedup_window_secs = routine.guardrails.dedup_window.map(|d| d.as_secs() as i32); + + conn.execute( + r#" + UPDATE routines SET + name = $2, description = $3, enabled = $4, + trigger_type = $5, trigger_config = $6, + action_type = $7, action_config = $8, + cooldown_secs = $9, max_concurrent = $10, dedup_window_secs = $11, + notify_channel = $12, notify_user = $13, + notify_on_success = $14, notify_on_failure = $15, notify_on_attention = $16, + state = $17, next_fire_at = $18, + updated_at = now() + WHERE id = $1 + "#, + &[ + &routine.id, + &routine.name, + &routine.description, + &routine.enabled, + &trigger_type, + &trigger_config, + &action_type, + &action_config, + &cooldown_secs, + &max_concurrent, + &dedup_window_secs, + &routine.notify.channel, + &routine.notify.user, + &routine.notify.on_success, + &routine.notify.on_failure, + &routine.notify.on_attention, + &routine.state, + &routine.next_fire_at, + ], + ) + .await?; + Ok(()) + } + + /// Update runtime state after a routine fires. + pub async fn update_routine_runtime( + &self, + id: Uuid, + last_run_at: DateTime, + next_fire_at: Option>, + run_count: u64, + consecutive_failures: u32, + state: &serde_json::Value, + ) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + conn.execute( + r#" + UPDATE routines SET + last_run_at = $2, next_fire_at = $3, + run_count = $4, consecutive_failures = $5, + state = $6, updated_at = now() + WHERE id = $1 + "#, + &[ + &id, + &last_run_at, + &next_fire_at, + &(run_count as i64), + &(consecutive_failures as i32), + state, + ], + ) + .await?; + Ok(()) + } + + /// Delete a routine. + pub async fn delete_routine(&self, id: Uuid) -> Result { + let conn = self.conn().await?; + let count = conn + .execute("DELETE FROM routines WHERE id = $1", &[&id]) + .await?; + Ok(count > 0) + } + + // ==================== Routine Runs ==================== + + /// Record a routine run starting. + pub async fn create_routine_run(&self, run: &RoutineRun) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + let status = run.status.to_string(); + conn.execute( + r#" + INSERT INTO routine_runs ( + id, routine_id, trigger_type, trigger_detail, + started_at, status, job_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + "#, + &[ + &run.id, + &run.routine_id, + &run.trigger_type, + &run.trigger_detail, + &run.started_at, + &status, + &run.job_id, + ], + ) + .await?; + Ok(()) + } + + /// Complete a routine run. + pub async fn complete_routine_run( + &self, + id: Uuid, + status: RunStatus, + result_summary: Option<&str>, + tokens_used: Option, + ) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + let status_str = status.to_string(); + let now = Utc::now(); + conn.execute( + r#" + UPDATE routine_runs SET + completed_at = $2, status = $3, + result_summary = $4, tokens_used = $5 + WHERE id = $1 + "#, + &[&id, &now, &status_str, &result_summary, &tokens_used], + ) + .await?; + Ok(()) + } + + /// List recent runs for a routine. + pub async fn list_routine_runs( + &self, + routine_id: Uuid, + limit: i64, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + r#" + SELECT * FROM routine_runs + WHERE routine_id = $1 + ORDER BY started_at DESC + LIMIT $2 + "#, + &[&routine_id, &limit], + ) + .await?; + rows.iter().map(row_to_routine_run).collect() + } + + /// Count currently running runs for a routine. + pub async fn count_running_routine_runs(&self, routine_id: Uuid) -> Result { + let conn = self.conn().await?; + let row = conn + .query_one( + "SELECT COUNT(*) as cnt FROM routine_runs WHERE routine_id = $1 AND status = 'running'", + &[&routine_id], + ) + .await?; + Ok(row.get("cnt")) + } +} + +fn row_to_routine(row: &tokio_postgres::Row) -> Result { + let trigger_type: String = row.get("trigger_type"); + let trigger_config: serde_json::Value = row.get("trigger_config"); + let action_type: String = row.get("action_type"); + let action_config: serde_json::Value = row.get("action_config"); + let cooldown_secs: i32 = row.get("cooldown_secs"); + let max_concurrent: i32 = row.get("max_concurrent"); + let dedup_window_secs: Option = row.get("dedup_window_secs"); + + let trigger = + Trigger::from_db(&trigger_type, trigger_config).map_err(DatabaseError::Serialization)?; + let action = RoutineAction::from_db(&action_type, action_config) + .map_err(DatabaseError::Serialization)?; + + Ok(Routine { + id: row.get("id"), + name: row.get("name"), + description: row.get("description"), + user_id: row.get("user_id"), + enabled: row.get("enabled"), + trigger, + action, + guardrails: RoutineGuardrails { + cooldown: std::time::Duration::from_secs(cooldown_secs as u64), + max_concurrent: max_concurrent as u32, + dedup_window: dedup_window_secs.map(|s| std::time::Duration::from_secs(s as u64)), + }, + notify: NotifyConfig { + channel: row.get("notify_channel"), + user: row.get("notify_user"), + on_attention: row.get("notify_on_attention"), + on_failure: row.get("notify_on_failure"), + on_success: row.get("notify_on_success"), + }, + last_run_at: row.get("last_run_at"), + next_fire_at: row.get("next_fire_at"), + run_count: row.get::<_, i64>("run_count") as u64, + consecutive_failures: row.get::<_, i32>("consecutive_failures") as u32, + state: row.get("state"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) +} + +fn row_to_routine_run(row: &tokio_postgres::Row) -> Result { + let status_str: String = row.get("status"); + let status: RunStatus = status_str + .parse() + .map_err(|e: String| DatabaseError::Serialization(e))?; + + Ok(RoutineRun { + id: row.get("id"), + routine_id: row.get("routine_id"), + trigger_type: row.get("trigger_type"), + trigger_detail: row.get("trigger_detail"), + started_at: row.get("started_at"), + completed_at: row.get("completed_at"), + status, + result_summary: row.get("result_summary"), + tokens_used: row.get("tokens_used"), + job_id: row.get("job_id"), + created_at: row.get("created_at"), + }) +} + +// ==================== Conversation Persistence ==================== + +/// Summary of a conversation for the thread list. +#[derive(Debug, Clone)] +pub struct ConversationSummary { + pub id: Uuid, + /// First user message, truncated to 100 chars. + pub title: Option, + pub message_count: i64, + pub started_at: DateTime, + pub last_activity: DateTime, + /// Thread type extracted from metadata (e.g. "assistant", "thread"). + pub thread_type: Option, +} + +/// A single message in a conversation. +#[derive(Debug, Clone)] +pub struct ConversationMessage { + pub id: Uuid, + pub role: String, + pub content: String, + pub created_at: DateTime, +} + +impl Store { + /// Ensure a conversation row exists for a given UUID. + /// + /// Idempotent: inserts on first call, bumps `last_activity` on subsequent calls. + pub async fn ensure_conversation( + &self, + id: Uuid, + channel: &str, + user_id: &str, + thread_id: Option<&str>, + ) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + conn.execute( + r#" + INSERT INTO conversations (id, channel, user_id, thread_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE SET last_activity = NOW() + "#, + &[&id, &channel, &user_id, &thread_id], + ) + .await?; + Ok(()) + } + + /// List conversations with a title derived from the first user message. + pub async fn list_conversations_with_preview( + &self, + user_id: &str, + channel: &str, + limit: i64, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + r#" + SELECT + c.id, + c.started_at, + c.last_activity, + c.metadata, + (SELECT COUNT(*) FROM conversation_messages m WHERE m.conversation_id = c.id) AS message_count, + (SELECT LEFT(m2.content, 100) + FROM conversation_messages m2 + WHERE m2.conversation_id = c.id AND m2.role = 'user' + ORDER BY m2.created_at ASC + LIMIT 1 + ) AS title + FROM conversations c + WHERE c.user_id = $1 AND c.channel = $2 + ORDER BY c.last_activity DESC + LIMIT $3 + "#, + &[&user_id, &channel, &limit], + ) + .await?; + + Ok(rows + .iter() + .map(|r| { + let metadata: serde_json::Value = r.get("metadata"); + let thread_type = metadata + .get("thread_type") + .and_then(|v| v.as_str()) + .map(String::from); + ConversationSummary { + id: r.get("id"), + title: r.get("title"), + message_count: r.get("message_count"), + started_at: r.get("started_at"), + last_activity: r.get("last_activity"), + thread_type, + } + }) + .collect()) + } + + /// Get or create the singleton "assistant" conversation for a user+channel. + /// + /// Looks for a conversation where `metadata->>'thread_type' = 'assistant'`. + /// Creates one if it doesn't exist. + pub async fn get_or_create_assistant_conversation( + &self, + user_id: &str, + channel: &str, + ) -> Result { + let conn = self.conn().await?; + + // Try to find existing assistant conversation + let row = conn + .query_opt( + r#" + SELECT id FROM conversations + WHERE user_id = $1 AND channel = $2 AND metadata->>'thread_type' = 'assistant' + LIMIT 1 + "#, + &[&user_id, &channel], + ) + .await?; + + if let Some(row) = row { + return Ok(row.get("id")); + } + + // Create a new assistant conversation + let id = Uuid::new_v4(); + let metadata = serde_json::json!({"thread_type": "assistant", "title": "Assistant"}); + conn.execute( + r#" + INSERT INTO conversations (id, channel, user_id, metadata) + VALUES ($1, $2, $3, $4) + "#, + &[&id, &channel, &user_id, &metadata], + ) + .await?; + + Ok(id) + } + + /// Create a conversation with specific metadata. + pub async fn create_conversation_with_metadata( + &self, + channel: &str, + user_id: &str, + metadata: &serde_json::Value, + ) -> Result { + let conn = self.conn().await?; + let id = Uuid::new_v4(); + + conn.execute( + "INSERT INTO conversations (id, channel, user_id, metadata) VALUES ($1, $2, $3, $4)", + &[&id, &channel, &user_id, metadata], + ) + .await?; + + Ok(id) + } + + /// Load messages for a conversation with cursor-based pagination. + /// + /// Returns `(messages_oldest_first, has_more)`. + /// Pass `before` as a cursor to load older messages. + pub async fn list_conversation_messages_paginated( + &self, + conversation_id: Uuid, + before: Option>, + limit: i64, + ) -> Result<(Vec, bool), DatabaseError> { + let conn = self.conn().await?; + let fetch_limit = limit + 1; // Fetch one extra to determine has_more + + let rows = if let Some(before_ts) = before { + conn.query( + r#" + SELECT id, role, content, created_at + FROM conversation_messages + WHERE conversation_id = $1 AND created_at < $2 + ORDER BY created_at DESC + LIMIT $3 + "#, + &[&conversation_id, &before_ts, &fetch_limit], + ) + .await? + } else { + conn.query( + r#" + SELECT id, role, content, created_at + FROM conversation_messages + WHERE conversation_id = $1 + ORDER BY created_at DESC + LIMIT $2 + "#, + &[&conversation_id, &fetch_limit], + ) + .await? + }; + + let has_more = rows.len() as i64 > limit; + let take_count = (rows.len() as i64).min(limit) as usize; + + // Rows come newest-first from DB; reverse so caller gets oldest-first + let mut messages: Vec = rows + .iter() + .take(take_count) + .map(|r| ConversationMessage { + id: r.get("id"), + role: r.get("role"), + content: r.get("content"), + created_at: r.get("created_at"), + }) + .collect(); + messages.reverse(); + + Ok((messages, has_more)) + } + + /// Merge a single key into a conversation's metadata JSONB. + pub async fn update_conversation_metadata_field( + &self, + id: Uuid, + key: &str, + value: &serde_json::Value, + ) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + let patch = serde_json::json!({ key: value }); + conn.execute( + "UPDATE conversations SET metadata = metadata || $2 WHERE id = $1", + &[&id, &patch], + ) + .await?; + Ok(()) + } + + /// Read the metadata JSONB for a conversation. + pub async fn get_conversation_metadata( + &self, + id: Uuid, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let row = conn + .query_opt("SELECT metadata FROM conversations WHERE id = $1", &[&id]) + .await?; + Ok(row.map(|r| r.get::<_, serde_json::Value>(0))) + } + + /// Load all messages for a conversation, ordered chronologically. + pub async fn list_conversation_messages( + &self, + conversation_id: Uuid, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + r#" + SELECT id, role, content, created_at + FROM conversation_messages + WHERE conversation_id = $1 + ORDER BY created_at ASC + "#, + &[&conversation_id], + ) + .await?; + + Ok(rows + .iter() + .map(|r| ConversationMessage { + id: r.get("id"), + role: r.get("role"), + content: r.get("content"), + created_at: r.get("created_at"), + }) + .collect()) + } +} + fn parse_job_state(s: &str) -> JobState { match s { "pending" => JobState::Pending, @@ -529,3 +1475,168 @@ impl Store { Ok(()) } } + +// ==================== Settings ==================== + +/// A single setting row from the database. +#[derive(Debug, Clone)] +pub struct SettingRow { + pub key: String, + pub value: serde_json::Value, + pub updated_at: DateTime, +} + +impl Store { + /// Get a single setting by key. + pub async fn get_setting( + &self, + user_id: &str, + key: &str, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let row = conn + .query_opt( + "SELECT value FROM settings WHERE user_id = $1 AND key = $2", + &[&user_id, &key], + ) + .await?; + Ok(row.map(|r| r.get("value"))) + } + + /// Get a single setting with full metadata. + pub async fn get_setting_full( + &self, + user_id: &str, + key: &str, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let row = conn + .query_opt( + "SELECT key, value, updated_at FROM settings WHERE user_id = $1 AND key = $2", + &[&user_id, &key], + ) + .await?; + Ok(row.map(|r| SettingRow { + key: r.get("key"), + value: r.get("value"), + updated_at: r.get("updated_at"), + })) + } + + /// Set a single setting (upsert). + pub async fn set_setting( + &self, + user_id: &str, + key: &str, + value: &serde_json::Value, + ) -> Result<(), DatabaseError> { + let conn = self.conn().await?; + conn.execute( + r#" + INSERT INTO settings (user_id, key, value, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (user_id, key) DO UPDATE SET + value = EXCLUDED.value, + updated_at = NOW() + "#, + &[&user_id, &key, value], + ) + .await?; + Ok(()) + } + + /// Delete a single setting (reset to default). + pub async fn delete_setting(&self, user_id: &str, key: &str) -> Result { + let conn = self.conn().await?; + let count = conn + .execute( + "DELETE FROM settings WHERE user_id = $1 AND key = $2", + &[&user_id, &key], + ) + .await?; + Ok(count > 0) + } + + /// List all settings for a user (with metadata). + pub async fn list_settings(&self, user_id: &str) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + "SELECT key, value, updated_at FROM settings WHERE user_id = $1 ORDER BY key", + &[&user_id], + ) + .await?; + Ok(rows + .iter() + .map(|r| SettingRow { + key: r.get("key"), + value: r.get("value"), + updated_at: r.get("updated_at"), + }) + .collect()) + } + + /// Get all settings as a flat key-value map. + pub async fn get_all_settings( + &self, + user_id: &str, + ) -> Result, DatabaseError> { + let conn = self.conn().await?; + let rows = conn + .query( + "SELECT key, value FROM settings WHERE user_id = $1", + &[&user_id], + ) + .await?; + Ok(rows + .iter() + .map(|r| { + let key: String = r.get("key"); + let value: serde_json::Value = r.get("value"); + (key, value) + }) + .collect()) + } + + /// Bulk-write settings (used for migration/import). + /// + /// Each entry is upserted individually within a single transaction. + pub async fn set_all_settings( + &self, + user_id: &str, + settings: &std::collections::HashMap, + ) -> Result<(), DatabaseError> { + let mut conn = self.conn().await?; + let tx = conn.transaction().await?; + + for (key, value) in settings { + tx.execute( + r#" + INSERT INTO settings (user_id, key, value, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (user_id, key) DO UPDATE SET + value = EXCLUDED.value, + updated_at = NOW() + "#, + &[&user_id, &key, value], + ) + .await?; + } + + tx.commit().await?; + Ok(()) + } + + /// Check if the settings table has any rows for a user. + pub async fn has_settings(&self, user_id: &str) -> Result { + let conn = self.conn().await?; + let row = conn + .query_one( + "SELECT COUNT(*) as cnt FROM settings WHERE user_id = $1", + &[&user_id], + ) + .await?; + let count: i64 = row.get("cnt"); + Ok(count > 0) + } +} diff --git a/src/lib.rs b/src/lib.rs index d78cbff7..6f3c5911 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,7 @@ //! - **Continuous learning** - Improve estimates from historical data pub mod agent; +pub mod bootstrap; pub mod channels; pub mod cli; pub mod config; @@ -49,12 +50,14 @@ pub mod evaluation; pub mod extensions; pub mod history; pub mod llm; +pub mod orchestrator; pub mod safety; pub mod sandbox; pub mod secrets; pub mod settings; pub mod setup; pub mod tools; +pub mod worker; pub mod workspace; pub use config::Config; diff --git a/src/llm/mod.rs b/src/llm/mod.rs index f2d5ffcb..ee6063ad 100644 --- a/src/llm/mod.rs +++ b/src/llm/mod.rs @@ -13,8 +13,8 @@ pub mod session; pub use nearai::{ModelInfo, NearAiProvider}; pub use nearai_chat::NearAiChatProvider; pub use provider::{ - ChatMessage, CompletionRequest, CompletionResponse, LlmProvider, Role, ToolCall, - ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult, + ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata, + Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, ToolResult, }; pub use reasoning::{ActionPlan, Reasoning, ReasoningContext, RespondResult, ToolSelection}; pub use session::{SessionConfig, SessionManager, create_session_manager}; diff --git a/src/llm/nearai.rs b/src/llm/nearai.rs index b204bda5..193510bd 100644 --- a/src/llm/nearai.rs +++ b/src/llm/nearai.rs @@ -3,6 +3,7 @@ //! This provider uses the NEAR AI chat-api which provides a unified interface //! to multiple LLM models (OpenAI, Anthropic, etc.) with user authentication. +use std::collections::HashMap; use std::sync::Arc; use async_trait::async_trait; @@ -31,11 +32,23 @@ pub struct ModelInfo { pub provider: Option, } +/// Per-thread chaining state: the last response ID and how many input +/// messages were included in that request. This lets subsequent calls send +/// only the delta (new messages since last call). +struct ChainState { + response_id: String, + input_count: usize, +} + /// NEAR AI Chat API provider. pub struct NearAiProvider { client: Client, config: NearAiConfig, session: Arc, + active_model: std::sync::RwLock, + /// Per-thread response ID chaining state. + /// Key is thread_id from request metadata. + response_chains: std::sync::RwLock>, } impl NearAiProvider { @@ -46,13 +59,64 @@ impl NearAiProvider { .build() .unwrap_or_else(|_| Client::new()); + let active_model = std::sync::RwLock::new(config.model.clone()); Self { client, config, session, + active_model, + response_chains: std::sync::RwLock::new(HashMap::new()), } } + /// Seed a response chain for a thread (e.g. when restoring from DB). + pub fn seed_response_id(&self, thread_id: &str, response_id: String) { + let mut chains = self + .response_chains + .write() + .expect("response_chains lock poisoned"); + chains.insert( + thread_id.to_string(), + ChainState { + response_id, + input_count: 0, + }, + ); + } + + /// Get the last response ID for a thread (for persistence). + pub fn get_response_id(&self, thread_id: &str) -> Option { + let chains = self + .response_chains + .read() + .expect("response_chains lock poisoned"); + chains.get(thread_id).map(|c| c.response_id.clone()) + } + + /// Store a response chain state after a successful call. + fn store_chain(&self, thread_id: &str, response_id: String, input_count: usize) { + let mut chains = self + .response_chains + .write() + .expect("response_chains lock poisoned"); + chains.insert( + thread_id.to_string(), + ChainState { + response_id, + input_count, + }, + ); + } + + /// Clear the chain for a thread (on error / fallback). + fn clear_chain(&self, thread_id: &str) { + let mut chains = self + .response_chains + .write() + .expect("response_chains lock poisoned"); + chains.remove(thread_id); + } + fn api_url(&self, path: &str) -> String { format!( "{}/v1/{}", @@ -291,18 +355,34 @@ impl NearAiProvider { } } -/// Split messages into system instructions and non-system input messages. +/// Split messages into system instructions and non-system input items. /// The OpenAI Responses API expects system prompts in an `instructions` field, /// not as a message with role "system" in the input array. -fn split_messages(messages: Vec) -> (Option, Vec) { +/// +/// When `chaining` is true, tool result messages (role=tool) are converted to +/// `NearAiInputItem::FunctionCallOutput` for the Responses API protocol. +fn split_messages( + messages: Vec, + chaining: bool, +) -> (Option, Vec) { let mut instructions: Vec = Vec::new(); - let mut input: Vec = Vec::new(); + let mut input: Vec = Vec::new(); for msg in messages { if msg.role == Role::System { instructions.push(msg.content); + } else if chaining && msg.role == Role::Tool { + if let Some(ref call_id) = msg.tool_call_id { + input.push(NearAiInputItem::FunctionCallOutput { + item_type: "function_call_output".to_string(), + call_id: call_id.clone(), + output: msg.content, + }); + } else { + input.push(NearAiInputItem::Message(msg.into())); + } } else { - input.push(msg.into()); + input.push(NearAiInputItem::Message(msg.into())); } } @@ -318,12 +398,14 @@ fn split_messages(messages: Vec) -> (Option, Vec Result { - let (instructions, input) = split_messages(req.messages); + let thread_id = req.metadata.get("thread_id").cloned(); + let (instructions, input) = split_messages(req.messages, false); let request = NearAiRequest { - model: self.config.model.clone(), + model: self.active_model_name(), instructions, input, + previous_response_id: None, temperature: req.temperature, max_output_tokens: req.max_tokens, stream: Some(false), @@ -350,6 +432,7 @@ impl LlmProvider for NearAiProvider { finish_reason: FinishReason::Stop, input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, + response_id: None, }); } @@ -367,6 +450,7 @@ impl LlmProvider for NearAiProvider { finish_reason: FinishReason::Stop, input_tokens: 0, output_tokens: 0, + response_id: None, }); } Err(e) => return Err(e), @@ -423,11 +507,17 @@ impl LlmProvider for NearAiProvider { ); } + // Store response ID for chaining + if let Some(ref tid) = thread_id { + self.store_chain(tid, response.id.clone(), 0); + } + Ok(CompletionResponse { content: text, finish_reason: FinishReason::Stop, input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens, + response_id: Some(response.id), }) } @@ -435,7 +525,33 @@ impl LlmProvider for NearAiProvider { &self, req: ToolCompletionRequest, ) -> Result { - let (instructions, input) = split_messages(req.messages); + let thread_id = req.metadata.get("thread_id").cloned(); + + // Look up chaining state for this thread + let chain_state = thread_id.as_ref().and_then(|tid| { + let chains = self + .response_chains + .read() + .expect("response_chains lock poisoned"); + chains + .get(tid) + .map(|c| (c.response_id.clone(), c.input_count)) + }); + + let chaining = chain_state.is_some(); + let (previous_response_id, prev_input_count) = chain_state + .map(|(rid, count)| (Some(rid), count)) + .unwrap_or((None, 0)); + + // When chaining, only send new messages (the delta since last call). + // Tool results are converted to function_call_output items. + let (instructions, all_input) = split_messages(req.messages, chaining); + let input = if chaining && all_input.len() > prev_input_count { + all_input[prev_input_count..].to_vec() + } else { + all_input.clone() + }; + let total_input_count = all_input.len(); let tools: Vec = req .tools @@ -449,18 +565,58 @@ impl LlmProvider for NearAiProvider { .collect(); let request = NearAiRequest { - model: self.config.model.clone(), - instructions, + model: self.active_model_name(), + instructions: if chaining { None } else { instructions.clone() }, input, + previous_response_id: previous_response_id.clone(), temperature: req.temperature, max_output_tokens: req.max_tokens, stream: Some(false), - tools: if tools.is_empty() { None } else { Some(tools) }, + tools: if tools.is_empty() { + None + } else { + Some(tools.clone()) + }, }; - // Try to get structured response, fall back to alternative formats + // Try to get structured response, fall back to alternative formats. + // If chaining fails (bad previous_response_id), retry with full history. let response: NearAiResponse = match self.send_request("responses", &request).await { Ok(r) => r, + Err(ref e) if chaining && is_chain_error(e) => { + tracing::warn!( + "Response chaining failed, retrying with full history: {}", + e + ); + if let Some(ref tid) = thread_id { + self.clear_chain(tid); + } + let (instructions_full, input_full) = split_messages( + // Rebuild from the original input (non-chaining mode) + { + let mut msgs = Vec::new(); + if let Some(ref instr) = instructions { + msgs.push(ChatMessage::system(instr.clone())); + } + for item in &all_input { + msgs.push(item.to_chat_message()); + } + msgs + }, + false, + ); + let retry_request = NearAiRequest { + model: self.active_model_name(), + instructions: instructions_full, + input: input_full, + previous_response_id: None, + temperature: request.temperature, + max_output_tokens: request.max_output_tokens, + stream: Some(false), + tools: request.tools.clone(), + }; + self.send_request("responses", &retry_request).await? + } Err(LlmError::InvalidResponse { reason, .. }) if reason.contains("Raw: ") => { let raw_text = reason.split("Raw: ").nth(1).unwrap_or(""); @@ -490,6 +646,7 @@ impl LlmProvider for NearAiProvider { finish_reason, input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, + response_id: None, }); } @@ -507,6 +664,7 @@ impl LlmProvider for NearAiProvider { finish_reason: FinishReason::Stop, input_tokens: 0, output_tokens: 0, + response_id: None, }); } Err(e) => return Err(e), @@ -560,12 +718,18 @@ impl LlmProvider for NearAiProvider { FinishReason::ToolUse }; + // Store response ID for chaining on subsequent calls + if let Some(ref tid) = thread_id { + self.store_chain(tid, response.id.clone(), total_input_count); + } + Ok(ToolCompletionResponse { content: if text.is_empty() { None } else { Some(text) }, tool_calls, finish_reason, input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens, + response_id: Some(response.id), }) } @@ -584,6 +748,30 @@ impl LlmProvider for NearAiProvider { let models = NearAiProvider::list_models(self).await?; Ok(models.into_iter().map(|m| m.name).collect()) } + + fn active_model_name(&self) -> String { + self.active_model + .read() + .expect("active_model lock poisoned") + .clone() + } + + fn set_model(&self, model: &str) -> Result<(), LlmError> { + let mut guard = self + .active_model + .write() + .expect("active_model lock poisoned"); + *guard = model.to_string(); + Ok(()) + } + + fn seed_response_chain(&self, thread_id: &str, response_id: String) { + self.seed_response_id(thread_id, response_id); + } + + fn get_response_chain_id(&self, thread_id: &str) -> Option { + self.get_response_id(thread_id) + } } // NEAR AI API types @@ -597,8 +785,11 @@ struct NearAiRequest { /// System instructions (replaces sending system role in input) #[serde(skip_serializing_if = "Option::is_none")] instructions: Option, - /// Input messages (user/assistant/tool only, NOT system) - input: Vec, + /// Input items: messages and/or function_call_output entries. + input: Vec, + /// Chain this request to a previous response (avoids resending full context). + #[serde(skip_serializing_if = "Option::is_none")] + previous_response_id: Option, #[serde(skip_serializing_if = "Option::is_none")] temperature: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -609,7 +800,7 @@ struct NearAiRequest { tools: Option>, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] struct NearAiMessage { role: String, content: String, @@ -630,7 +821,68 @@ impl From for NearAiMessage { } } -#[derive(Debug, Serialize)] +/// Input item for the Responses API. Either a regular message or a +/// function_call_output (for returning tool results when chaining). +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +enum NearAiInputItem { + Message(NearAiMessage), + FunctionCallOutput { + #[serde(rename = "type")] + item_type: String, + call_id: String, + output: String, + }, +} + +impl NearAiInputItem { + /// Convert back to a ChatMessage (used for fallback retry). + fn to_chat_message(&self) -> ChatMessage { + match self { + NearAiInputItem::Message(msg) => { + let role = match msg.role.as_str() { + "system" => Role::System, + "user" => Role::User, + "assistant" => Role::Assistant, + "tool" => Role::Tool, + _ => Role::User, + }; + ChatMessage { + role, + content: msg.content.clone(), + tool_call_id: None, + name: None, + tool_calls: None, + } + } + NearAiInputItem::FunctionCallOutput { + call_id, output, .. + } => ChatMessage { + role: Role::Tool, + content: output.clone(), + tool_call_id: Some(call_id.clone()), + name: None, + tool_calls: None, + }, + } + } +} + +/// Check if an LLM error is likely caused by an invalid previous_response_id. +fn is_chain_error(err: &LlmError) -> bool { + match err { + LlmError::RequestFailed { reason, .. } => { + let lower = reason.to_lowercase(); + lower.contains("previous_response_id") + || lower.contains("previous response") + || lower.contains("not found") + || lower.contains("invalid response id") + } + _ => false, + } +} + +#[derive(Debug, Clone, Serialize)] struct NearAiTool { #[serde(rename = "type")] tool_type: String, @@ -833,14 +1085,17 @@ mod tests { ChatMessage::user("Hello"), ChatMessage::assistant("Hi there!"), ]; - let (instructions, input) = split_messages(messages); + let (instructions, input) = split_messages(messages, false); assert_eq!( instructions, Some("You are a helpful assistant".to_string()) ); assert_eq!(input.len(), 2); - assert_eq!(input[0].role, "user"); - assert_eq!(input[1].role, "assistant"); + // Verify the input items are messages + match &input[0] { + NearAiInputItem::Message(m) => assert_eq!(m.role, "user"), + _ => panic!("expected Message"), + } } #[test] @@ -849,7 +1104,7 @@ mod tests { ChatMessage::user("Hello"), ChatMessage::assistant("Hi there!"), ]; - let (instructions, input) = split_messages(messages); + let (instructions, input) = split_messages(messages, false); assert!(instructions.is_none()); assert_eq!(input.len(), 2); } @@ -861,11 +1116,44 @@ mod tests { ChatMessage::system("Second instruction"), ChatMessage::user("Hello"), ]; - let (instructions, input) = split_messages(messages); + let (instructions, input) = split_messages(messages, false); assert_eq!( instructions, Some("First instruction\n\nSecond instruction".to_string()) ); assert_eq!(input.len(), 1); } + + #[test] + fn test_split_messages_chaining_converts_tool_results() { + let messages = vec![ + ChatMessage::user("Hello"), + ChatMessage::tool_result("call_123", "my_tool", "result data"), + ]; + let (_, input) = split_messages(messages, true); + assert_eq!(input.len(), 2); + match &input[1] { + NearAiInputItem::FunctionCallOutput { + call_id, output, .. + } => { + assert_eq!(call_id, "call_123"); + assert_eq!(output, "result data"); + } + _ => panic!("expected FunctionCallOutput"), + } + } + + #[test] + fn test_split_messages_no_chaining_keeps_tool_as_message() { + let messages = vec![ + ChatMessage::user("Hello"), + ChatMessage::tool_result("call_123", "my_tool", "result data"), + ]; + let (_, input) = split_messages(messages, false); + assert_eq!(input.len(), 2); + match &input[1] { + NearAiInputItem::Message(m) => assert_eq!(m.role, "tool"), + _ => panic!("expected Message"), + } + } } diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs index 5a76e7b6..8c0c5747 100644 --- a/src/llm/nearai_chat.rs +++ b/src/llm/nearai_chat.rs @@ -13,14 +13,15 @@ use serde::{Deserialize, Serialize}; use crate::config::NearAiConfig; use crate::error::LlmError; use crate::llm::provider::{ - ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, Role, ToolCall, - ToolCompletionRequest, ToolCompletionResponse, + ChatMessage, CompletionRequest, CompletionResponse, FinishReason, LlmProvider, ModelMetadata, + Role, ToolCall, ToolCompletionRequest, ToolCompletionResponse, }; /// NEAR AI Chat Completions API provider. pub struct NearAiChatProvider { client: Client, config: NearAiConfig, + active_model: std::sync::RwLock, } impl NearAiChatProvider { @@ -37,7 +38,12 @@ impl NearAiChatProvider { .build() .unwrap_or_else(|_| Client::new()); - Ok(Self { client, config }) + let active_model = std::sync::RwLock::new(config.model.clone()); + Ok(Self { + client, + config, + active_model, + }) } fn api_url(&self, path: &str) -> String { @@ -65,6 +71,11 @@ impl NearAiChatProvider { tracing::debug!("Sending request to NEAR AI Chat: {}", url); + // Log the request body for debugging tool call issues + if let Ok(json) = serde_json::to_string(body) { + tracing::debug!("NEAR AI Chat request body: {}", json); + } + let response = self .client .post(&url) @@ -111,8 +122,8 @@ impl NearAiChatProvider { }) } - /// Fetch available models. - pub async fn list_models(&self) -> Result, LlmError> { + /// Fetch available models with full metadata from the `/v1/models` endpoint. + async fn fetch_models(&self) -> Result, LlmError> { let url = self.api_url("models"); let response = self @@ -138,12 +149,7 @@ impl NearAiChatProvider { #[derive(Deserialize)] struct ModelsResponse { - data: Vec, - } - - #[derive(Deserialize)] - struct ModelEntry { - id: String, + data: Vec, } let resp: ModelsResponse = @@ -152,10 +158,18 @@ impl NearAiChatProvider { reason: format!("JSON parse error: {}", e), })?; - Ok(resp.data.into_iter().map(|m| m.id).collect()) + Ok(resp.data) } } +/// Model entry as returned by the `/v1/models` API. +#[derive(Debug, Deserialize)] +struct ApiModelEntry { + id: String, + #[serde(default)] + context_length: Option, +} + #[async_trait] impl LlmProvider for NearAiChatProvider { async fn complete(&self, req: CompletionRequest) -> Result { @@ -163,7 +177,7 @@ impl LlmProvider for NearAiChatProvider { req.messages.into_iter().map(|m| m.into()).collect(); let request = ChatCompletionRequest { - model: self.config.model.clone(), + model: self.active_model_name(), messages, temperature: req.temperature, max_tokens: req.max_tokens, @@ -197,6 +211,7 @@ impl LlmProvider for NearAiChatProvider { finish_reason, input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens, + response_id: None, }) } @@ -221,7 +236,7 @@ impl LlmProvider for NearAiChatProvider { .collect(); let request = ChatCompletionRequest { - model: self.config.model.clone(), + model: self.active_model_name(), messages, temperature: req.temperature, max_tokens: req.max_tokens, @@ -278,6 +293,7 @@ impl LlmProvider for NearAiChatProvider { finish_reason, input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens, + response_id: None, }) } @@ -291,7 +307,34 @@ impl LlmProvider for NearAiChatProvider { } async fn list_models(&self) -> Result, LlmError> { - NearAiChatProvider::list_models(self).await + let models = self.fetch_models().await?; + Ok(models.into_iter().map(|m| m.id).collect()) + } + + async fn model_metadata(&self) -> Result { + let active = self.active_model_name(); + let models = self.fetch_models().await?; + let current = models.iter().find(|m| m.id == active); + Ok(ModelMetadata { + id: active, + context_length: current.and_then(|m| m.context_length), + }) + } + + fn active_model_name(&self) -> String { + self.active_model + .read() + .expect("active_model lock poisoned") + .clone() + } + + fn set_model(&self, model: &str) -> Result<(), crate::error::LlmError> { + let mut guard = self + .active_model + .write() + .expect("active_model lock poisoned"); + *guard = model.to_string(); + Ok(()) } } @@ -332,6 +375,7 @@ impl From for ChatCompletionMessage { Role::Assistant => "assistant", Role::Tool => "tool", }; + let tool_calls = msg.tool_calls.map(|calls| { calls .into_iter() @@ -345,9 +389,16 @@ impl From for ChatCompletionMessage { }) .collect() }); + + let content = if role == "assistant" && tool_calls.is_some() && msg.content.is_empty() { + None + } else { + Some(msg.content) + }; + Self { role: role.to_string(), - content: Some(msg.content), + content, tool_call_id: msg.tool_call_id, name: msg.name, tool_calls, @@ -454,7 +505,7 @@ mod tests { }, ]; - let msg = ChatMessage::assistant_with_tool_calls("", tool_calls); + let msg = ChatMessage::assistant_with_tool_calls(None, tool_calls); let chat_msg: ChatCompletionMessage = msg.into(); assert_eq!(chat_msg.role, "assistant"); @@ -484,7 +535,7 @@ mod tests { name: "test".to_string(), arguments: serde_json::json!({"key": "value"}), }; - let msg = ChatMessage::assistant_with_tool_calls("", vec![tc]); + let msg = ChatMessage::assistant_with_tool_calls(None, vec![tc]); let chat_msg: ChatCompletionMessage = msg.into(); let calls = chat_msg.tool_calls.unwrap(); diff --git a/src/llm/provider.rs b/src/llm/provider.rs index bb6dcdcb..e06d8b77 100644 --- a/src/llm/provider.rs +++ b/src/llm/provider.rs @@ -27,9 +27,8 @@ pub struct ChatMessage { /// Name of the tool for tool results. #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, - /// Tool calls requested by the assistant (for conversation replay). - /// OpenAI-compatible APIs require the assistant message to include - /// tool_calls when followed by tool result messages. + /// Tool calls made by the assistant (OpenAI protocol requires these + /// to appear on the assistant message preceding tool result messages). #[serde(skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, } @@ -68,17 +67,14 @@ impl ChatMessage { } } - /// Create an assistant message that requested tool calls. + /// Create an assistant message that includes tool calls. /// - /// OpenAI-compatible APIs require the assistant message to carry the - /// `tool_calls` array when followed by tool-result messages. - pub fn assistant_with_tool_calls( - content: impl Into, - tool_calls: Vec, - ) -> Self { + /// Per the OpenAI protocol, an assistant message with tool_calls must + /// precede the corresponding tool result messages in the conversation. + pub fn assistant_with_tool_calls(content: Option, tool_calls: Vec) -> Self { Self { role: Role::Assistant, - content: content.into(), + content: content.unwrap_or_default(), tool_call_id: None, name: None, tool_calls: if tool_calls.is_empty() { @@ -112,6 +108,8 @@ pub struct CompletionRequest { pub max_tokens: Option, pub temperature: Option, pub stop_sequences: Option>, + /// Opaque metadata passed through to the provider (e.g. thread_id for chaining). + pub metadata: std::collections::HashMap, } impl CompletionRequest { @@ -122,6 +120,7 @@ impl CompletionRequest { max_tokens: None, temperature: None, stop_sequences: None, + metadata: std::collections::HashMap::new(), } } @@ -145,6 +144,8 @@ pub struct CompletionResponse { pub input_tokens: u32, pub output_tokens: u32, pub finish_reason: FinishReason, + /// Provider-specific response ID (e.g. for NEAR AI response chaining). + pub response_id: Option, } /// Why the completion finished. @@ -191,6 +192,8 @@ pub struct ToolCompletionRequest { pub temperature: Option, /// How to handle tool use: "auto", "required", or "none". pub tool_choice: Option, + /// Opaque metadata passed through to the provider (e.g. thread_id for chaining). + pub metadata: std::collections::HashMap, } impl ToolCompletionRequest { @@ -202,6 +205,7 @@ impl ToolCompletionRequest { max_tokens: None, temperature: None, tool_choice: None, + metadata: std::collections::HashMap::new(), } } @@ -234,6 +238,16 @@ pub struct ToolCompletionResponse { pub input_tokens: u32, pub output_tokens: u32, pub finish_reason: FinishReason, + /// Provider-specific response ID (e.g. for NEAR AI response chaining). + pub response_id: Option, +} + +/// Metadata about a model returned by the provider's API. +#[derive(Debug, Clone)] +pub struct ModelMetadata { + pub id: String, + /// Total context window size in tokens. + pub context_length: Option, } /// Trait for LLM providers. @@ -260,6 +274,45 @@ pub trait LlmProvider: Send + Sync { Ok(Vec::new()) } + /// Fetch metadata for the current model (context length, etc.). + /// Default returns the model name with no size info. + async fn model_metadata(&self) -> Result { + Ok(ModelMetadata { + id: self.model_name().to_string(), + context_length: None, + }) + } + + /// Get the currently active model name. + /// + /// May differ from `model_name()` if the model was switched at runtime + /// via `set_model()`. Default returns `model_name()`. + fn active_model_name(&self) -> String { + self.model_name().to_string() + } + + /// Switch the active model at runtime. Not all providers support this. + fn set_model(&self, _model: &str) -> Result<(), LlmError> { + Err(LlmError::RequestFailed { + provider: "unknown".to_string(), + reason: "Runtime model switching not supported by this provider".to_string(), + }) + } + + /// Seed a response chain for a thread (e.g. restoring from DB). + /// + /// Providers that support response chaining (e.g. NEAR AI `previous_response_id`) + /// store this so subsequent calls send only delta messages. + fn seed_response_chain(&self, _thread_id: &str, _response_id: String) {} + + /// Get the last response chain ID for a thread. + /// + /// Returns `None` if the provider doesn't support chaining or has no + /// stored state for this thread. + fn get_response_chain_id(&self, _thread_id: &str) -> Option { + None + } + /// Calculate cost for a completion. fn calculate_cost(&self, input_tokens: u32, output_tokens: u32) -> Decimal { let (input_cost, output_cost) = self.cost_per_token(); diff --git a/src/llm/reasoning.rs b/src/llm/reasoning.rs index 6d8bf183..95125298 100644 --- a/src/llm/reasoning.rs +++ b/src/llm/reasoning.rs @@ -21,6 +21,8 @@ pub struct ReasoningContext { pub job_description: Option, /// Current state description. pub current_state: Option, + /// Opaque metadata forwarded to the LLM provider (e.g. thread_id for chaining). + pub metadata: std::collections::HashMap, } impl ReasoningContext { @@ -31,6 +33,7 @@ impl ReasoningContext { available_tools: Vec::new(), job_description: None, current_state: None, + metadata: std::collections::HashMap::new(), } } @@ -57,6 +60,12 @@ impl ReasoningContext { self.job_description = Some(description.into()); self } + + /// Set metadata (forwarded to the LLM provider). + pub fn with_metadata(mut self, metadata: std::collections::HashMap) -> Self { + self.metadata = metadata; + self + } } impl Default for ReasoningContext { @@ -114,7 +123,12 @@ pub enum RespondResult { /// A text response (no tools needed). Text(String), /// The model wants to call tools. Caller should execute them and call back. - ToolCalls(Vec), + /// Includes the optional content from the assistant message (some models + /// include explanatory text alongside tool calls). + ToolCalls { + tool_calls: Vec, + content: Option, + }, } /// Reasoning engine for the agent. @@ -192,10 +206,11 @@ impl Reasoning { return Ok(vec![]); } - let request = + let mut request = ToolCompletionRequest::new(context.messages.clone(), context.available_tools.clone()) .with_max_tokens(1024) .with_tool_choice("auto"); + request.metadata = context.metadata.clone(); let response = self.llm.complete_with_tools(request).await?; @@ -271,7 +286,9 @@ Respond in JSON format: pub async fn respond(&self, context: &ReasoningContext) -> Result { match self.respond_with_tools(context).await? { RespondResult::Text(text) => Ok(text), - RespondResult::ToolCalls(calls) => { + RespondResult::ToolCalls { + tool_calls: calls, .. + } => { // Format tool calls as text (legacy behavior for non-agentic callers) let tool_info: Vec = calls .iter() @@ -298,28 +315,49 @@ Respond in JSON format: // If we have tools, use tool completion mode if !context.available_tools.is_empty() { - let request = ToolCompletionRequest::new(messages, context.available_tools.clone()) + let mut request = ToolCompletionRequest::new(messages, context.available_tools.clone()) .with_max_tokens(4096) .with_temperature(0.7) .with_tool_choice("auto"); + request.metadata = context.metadata.clone(); let response = self.llm.complete_with_tools(request).await?; // If there were tool calls, return them for execution if !response.tool_calls.is_empty() { - return Ok(RespondResult::ToolCalls(response.tool_calls)); + return Ok(RespondResult::ToolCalls { + tool_calls: response.tool_calls, + content: response.content, + }); } let content = response .content .unwrap_or_else(|| "I'm not sure how to respond to that.".to_string()); + // Some models (e.g. GLM-4.7) emit tool calls as XML tags in content + // instead of using the structured tool_calls field. Try to recover + // them before giving up and returning plain text. + let recovered = recover_tool_calls_from_content(&content, &context.available_tools); + if !recovered.is_empty() { + let cleaned = clean_response(&content); + return Ok(RespondResult::ToolCalls { + tool_calls: recovered, + content: if cleaned.is_empty() { + None + } else { + Some(cleaned) + }, + }); + } + Ok(RespondResult::Text(clean_response(&content))) } else { // No tools, use simple completion - let request = CompletionRequest::new(messages) + let mut request = CompletionRequest::new(messages) .with_max_tokens(4096) .with_temperature(0.7); + request.metadata = context.metadata.clone(); let response = self.llm.complete(request).await?; Ok(RespondResult::Text(clean_response(&response.content))) @@ -462,47 +500,178 @@ fn extract_json(text: &str) -> Option<&str> { } } -/// Clean up LLM response by stripping thinking tags and reasoning patterns. +/// Clean up LLM response by stripping model-internal tags and reasoning patterns. +/// +/// Some models (GLM-4.7, etc.) emit XML-tagged internal state like +/// Try to extract tool calls from content text where the model emitted them +/// as XML tags instead of using the structured tool_calls field. +/// +/// Handles these formats: +/// - `tool_name` (bare name) +/// - `{"name":"x","arguments":{}}` (JSON) +/// - `<|tool_call|>...<|/tool_call|>` (pipe-delimited variant) +/// - `...` (function_call variant) +/// +/// Only returns calls whose name matches an available tool. +fn recover_tool_calls_from_content( + content: &str, + available_tools: &[ToolDefinition], +) -> Vec { + let tool_names: std::collections::HashSet<&str> = + available_tools.iter().map(|t| t.name.as_str()).collect(); + let mut calls = Vec::new(); + + for (open, close) in &[ + ("", ""), + ("<|tool_call|>", "<|/tool_call|>"), + ("", ""), + ("<|function_call|>", "<|/function_call|>"), + ] { + let mut remaining = content; + while let Some(start) = remaining.find(open) { + let inner_start = start + open.len(); + let after = &remaining[inner_start..]; + let Some(end) = after.find(close) else { + break; + }; + let inner = after[..end].trim(); + remaining = &after[end + close.len()..]; + + if inner.is_empty() { + continue; + } + + // Try JSON first: {"name":"x","arguments":{}} + if let Ok(parsed) = serde_json::from_str::(inner) { + if let Some(name) = parsed.get("name").and_then(|v| v.as_str()) { + if tool_names.contains(name) { + let arguments = parsed + .get("arguments") + .cloned() + .unwrap_or(serde_json::Value::Object(Default::default())); + calls.push(ToolCall { + id: format!("recovered_{}", calls.len()), + name: name.to_string(), + arguments, + }); + continue; + } + } + } + + // Bare tool name (e.g. "tool_list") + let name = inner.trim(); + if tool_names.contains(name) { + calls.push(ToolCall { + id: format!("recovered_{}", calls.len()), + name: name.to_string(), + arguments: serde_json::Value::Object(Default::default()), + }); + } + } + } + + calls +} + +/// `tool_list` or `<|tool_call|>` in the content field +/// instead of using the standard OpenAI tool_calls array. We strip all of +/// these before the response reaches channels/users. fn clean_response(text: &str) -> String { - let text = strip_thinking_tags(text); + let text = strip_internal_tags(text); strip_reasoning_patterns(&text) } -/// Strip `...` blocks from LLM output. +/// Tags that are model-internal and should never reach users. +const INTERNAL_TAGS: &[&str] = &["thinking", "tool_call", "function_call", "tool_calls"]; + +/// Strip all model-internal XML tags from LLM output. /// -/// Some models (especially Claude with extended thinking) include internal -/// reasoning in thinking tags. We strip these before showing to users. -fn strip_thinking_tags(text: &str) -> String { +/// Handles standard XML tags (`...`) and pipe-delimited variants +/// (`<|tag|>...<|/tag|>`) used by some models (e.g. GLM-4.7). +fn strip_internal_tags(text: &str) -> String { + let mut result = text.to_string(); + for tag in INTERNAL_TAGS { + result = strip_xml_tag(&result, tag); + result = strip_pipe_tag(&result, tag); + } + // Collapse triple+ newlines left behind by removed blocks + while result.contains("\n\n\n") { + result = result.replace("\n\n\n", "\n\n"); + } + result.trim().to_string() +} + +/// Strip `...` and `...` blocks from text. +fn strip_xml_tag(text: &str, tag: &str) -> String { + let open_exact = format!("<{}>", tag); + let open_prefix = format!("<{} ", tag); // for + let close = format!("", tag); + let mut result = String::with_capacity(text.len()); let mut remaining = text; - while let Some(start) = remaining.find("") { + loop { + // Find the next opening tag (exact or with attributes) + let exact_pos = remaining.find(&open_exact); + let prefix_pos = remaining.find(&open_prefix); + let start = match (exact_pos, prefix_pos) { + (Some(a), Some(b)) => a.min(b), + (Some(a), None) => a, + (None, Some(b)) => b, + (None, None) => break, + }; + // Add everything before the tag result.push_str(&remaining[..start]); + // Find the end of the opening tag (the closing >) + let after_open = &remaining[start..]; + let open_end = match after_open.find('>') { + Some(pos) => start + pos + 1, + None => break, // malformed, stop + }; + // Find the closing tag - if let Some(end_offset) = remaining[start..].find("") { - // Skip past the closing tag (start + offset + tag length) - let end = start + end_offset + "".len(); + if let Some(close_offset) = remaining[open_end..].find(&close) { + let end = open_end + close_offset + close.len(); remaining = &remaining[end..]; } else { - // No closing tag found, discard everything from here - // (malformed, but handle gracefully by not including the unclosed tag) + // No closing tag, discard from here (malformed) remaining = ""; break; } } - // Add any remaining content after the last thinking block result.push_str(remaining); + result +} - // Clean up any double newlines left behind - let mut cleaned = result.trim().to_string(); - while cleaned.contains("\n\n\n") { - cleaned = cleaned.replace("\n\n\n", "\n\n"); +/// Strip `<|tag|>...<|/tag|>` pipe-delimited blocks from text. +/// +/// Some models (e.g. certain Chinese LLMs) use this format instead of +/// standard XML tags. +fn strip_pipe_tag(text: &str, tag: &str) -> String { + let open = format!("<|{}|>", tag); + let close = format!("<|/{}|>", tag); + + let mut result = String::with_capacity(text.len()); + let mut remaining = text; + + while let Some(start) = remaining.find(&open) { + result.push_str(&remaining[..start]); + + if let Some(close_offset) = remaining[start..].find(&close) { + let end = start + close_offset + close.len(); + remaining = &remaining[end..]; + } else { + remaining = ""; + break; + } } - cleaned + result.push_str(remaining); + result } /// Strip any remaining reasoning that wasn't in proper tags. @@ -574,7 +743,7 @@ That's my plan."#; #[test] fn test_strip_thinking_tags_basic() { let input = "Let me think about this...Hello, user!"; - let output = strip_thinking_tags(input); + let output = strip_internal_tags(input); assert_eq!(output, "Hello, user!"); } @@ -582,7 +751,7 @@ That's my plan."#; fn test_strip_thinking_tags_multiple() { let input = "First thoughtHelloSecond thought world!"; - let output = strip_thinking_tags(input); + let output = strip_internal_tags(input); assert_eq!(output, "Hello world!"); } @@ -594,14 +763,14 @@ I need to consider: 2. How to respond Here is my response to your question."#; - let output = strip_thinking_tags(input); + let output = strip_internal_tags(input); assert_eq!(output, "Here is my response to your question."); } #[test] fn test_strip_thinking_tags_no_tags() { let input = "Just a normal response without thinking tags."; - let output = strip_thinking_tags(input); + let output = strip_internal_tags(input); assert_eq!(output, "Just a normal response without thinking tags."); } @@ -609,10 +778,77 @@ Here is my response to your question."#; fn test_strip_thinking_tags_unclosed() { // Malformed: unclosed tag should strip from there to end let input = "Hello this never closes"; - let output = strip_thinking_tags(input); + let output = strip_internal_tags(input); assert_eq!(output, "Hello"); } + #[test] + fn test_strip_tool_call_tags() { + // GLM-4.7 emits this garbage instead of using the tool_calls array + let input = "tool_list"; + let output = strip_internal_tags(input); + assert_eq!(output, ""); + } + + #[test] + fn test_strip_tool_call_with_surrounding_text() { + let input = "Here is my answer.\n\n\n{\"name\": \"search\", \"arguments\": {}}\n"; + let output = strip_internal_tags(input); + assert_eq!(output, "Here is my answer."); + } + + #[test] + fn test_strip_multiple_internal_tags() { + let input = "Let me thinkHello!\nsome_tool"; + let output = strip_internal_tags(input); + assert_eq!(output, "Hello!"); + } + + #[test] + fn test_strip_function_call_tags() { + let input = "Response text{\"name\": \"foo\"}"; + let output = strip_internal_tags(input); + assert_eq!(output, "Response text"); + } + + #[test] + fn test_strip_tool_calls_plural() { + let input = "[{\"id\": \"1\"}]Actual response."; + let output = strip_internal_tags(input); + assert_eq!(output, "Actual response."); + } + + #[test] + fn test_strip_pipe_delimited_tags() { + let input = "<|tool_call|>{\"name\": \"search\"}<|/tool_call|>Hello!"; + let output = strip_internal_tags(input); + assert_eq!(output, "Hello!"); + } + + #[test] + fn test_strip_pipe_delimited_thinking() { + let input = "<|thinking|>reasoning here<|/thinking|>The answer is 42."; + let output = strip_internal_tags(input); + assert_eq!(output, "The answer is 42."); + } + + #[test] + fn test_strip_xml_tag_with_attributes() { + let input = "search()Done."; + let output = strip_internal_tags(input); + assert_eq!(output, "Done."); + } + + #[test] + fn test_clean_response_preserves_normal_content() { + let input = "The function tool_call_handler works great. No tags here!"; + let output = clean_response(input); + assert_eq!( + output, + "The function tool_call_handler works great. No tags here!" + ); + } + #[test] fn test_strip_reasoning_paragraph_break() { // Content after paragraph break with "here" marker @@ -660,4 +896,92 @@ Here is my response to your question."#; let output = clean_response(input); assert_eq!(output, "Here's the answer."); } + + // -- recover_tool_calls_from_content tests -- + + fn make_tools(names: &[&str]) -> Vec { + names + .iter() + .map(|n| ToolDefinition { + name: n.to_string(), + description: String::new(), + parameters: serde_json::json!({}), + }) + .collect() + } + + #[test] + fn test_recover_bare_tool_name() { + let tools = make_tools(&["tool_list", "tool_auth"]); + let content = "tool_list"; + let calls = recover_tool_calls_from_content(content, &tools); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "tool_list"); + assert_eq!(calls[0].arguments, serde_json::json!({})); + } + + #[test] + fn test_recover_json_tool_call() { + let tools = make_tools(&["memory_search"]); + let content = + r#"{"name": "memory_search", "arguments": {"query": "test"}}"#; + let calls = recover_tool_calls_from_content(content, &tools); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "memory_search"); + assert_eq!(calls[0].arguments, serde_json::json!({"query": "test"})); + } + + #[test] + fn test_recover_pipe_delimited() { + let tools = make_tools(&["tool_list"]); + let content = "<|tool_call|>tool_list<|/tool_call|>"; + let calls = recover_tool_calls_from_content(content, &tools); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "tool_list"); + } + + #[test] + fn test_recover_unknown_tool_ignored() { + let tools = make_tools(&["tool_list"]); + let content = "nonexistent_tool"; + let calls = recover_tool_calls_from_content(content, &tools); + assert!(calls.is_empty()); + } + + #[test] + fn test_recover_no_tags() { + let tools = make_tools(&["tool_list"]); + let content = "Just a normal response."; + let calls = recover_tool_calls_from_content(content, &tools); + assert!(calls.is_empty()); + } + + #[test] + fn test_recover_multiple_tool_calls() { + let tools = make_tools(&["tool_list", "tool_auth"]); + let content = "tool_list\ntool_auth"; + let calls = recover_tool_calls_from_content(content, &tools); + assert_eq!(calls.len(), 2); + assert_eq!(calls[0].name, "tool_list"); + assert_eq!(calls[1].name, "tool_auth"); + } + + #[test] + fn test_recover_function_call_variant() { + let tools = make_tools(&["shell"]); + let content = + r#"{"name": "shell", "arguments": {"cmd": "ls"}}"#; + let calls = recover_tool_calls_from_content(content, &tools); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "shell"); + } + + #[test] + fn test_recover_with_surrounding_text() { + let tools = make_tools(&["tool_list"]); + let content = "Let me check.\n\ntool_list\n\nDone."; + let calls = recover_tool_calls_from_content(content, &tools); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].name, "tool_list"); + } } diff --git a/src/llm/session.rs b/src/llm/session.rs index 5a136171..7dac5b72 100644 --- a/src/llm/session.rs +++ b/src/llm/session.rs @@ -61,6 +61,10 @@ pub struct SessionManager { token: RwLock>, /// Prevents thundering herd during concurrent 401s. renewal_lock: Mutex<()>, + /// Optional database store for persisting session to the settings table. + store: RwLock>>, + /// User ID for DB settings (default: "default"). + user_id: RwLock, } impl SessionManager { @@ -74,6 +78,8 @@ impl SessionManager { .unwrap_or_else(|_| Client::new()), token: RwLock::new(None), renewal_lock: Mutex::new(()), + store: RwLock::new(None), + user_id: RwLock::new("default".to_string()), }; // Try to load existing session synchronously during construction @@ -103,6 +109,8 @@ impl SessionManager { .unwrap_or_else(|_| Client::new()), token: RwLock::new(None), renewal_lock: Mutex::new(()), + store: RwLock::new(None), + user_id: RwLock::new("default".to_string()), }; if let Err(e) = manager.load_session().await { @@ -112,6 +120,21 @@ impl SessionManager { manager } + /// Attach a database store for persisting session tokens. + /// + /// When a store is attached, session tokens are saved to the `settings` + /// table (key: `nearai.session_token`) in addition to the disk file. + /// On load, DB is preferred over disk. + pub async fn attach_store(&self, store: Arc, user_id: &str) { + *self.store.write().await = Some(store); + *self.user_id.write().await = user_id.to_string(); + + // Try to load from DB (may have been saved by a previous run) + if let Err(e) = self.load_session_from_db().await { + tracing::debug!("No session in DB: {}", e); + } + } + /// Get the current session token, returning an error if not authenticated. pub async fn get_token(&self) -> Result { let guard = self.token.read().await; @@ -460,7 +483,7 @@ impl SessionManager { Ok(()) } - /// Save session data to disk. + /// Save session data to disk and (if available) to the database. async fn save_session(&self, token: &str, auth_provider: Option<&str>) -> Result<(), LlmError> { let session = SessionData { session_token: token.to_string(), @@ -468,7 +491,7 @@ impl SessionManager { auth_provider: auth_provider.map(String::from), }; - // Ensure parent directory exists + // Save to disk (always, as bootstrap fallback) if let Some(parent) = self.config.session_path.parent() { tokio::fs::create_dir_all(parent).await.map_err(|e| { LlmError::Io(std::io::Error::new( @@ -498,6 +521,58 @@ impl SessionManager { })?; tracing::debug!("Session saved to {}", self.config.session_path.display()); + + // Also save to DB if a store is attached + if let Some(ref store) = *self.store.read().await { + let user_id = self.user_id.read().await.clone(); + let session_json = serde_json::to_value(&session) + .unwrap_or(serde_json::Value::String(token.to_string())); + if let Err(e) = store + .set_setting(&user_id, "nearai.session_token", &session_json) + .await + { + tracing::warn!("Failed to save session to DB: {}", e); + } else { + tracing::debug!("Session also saved to DB settings"); + } + } + + Ok(()) + } + + /// Try to load session from the database. + async fn load_session_from_db(&self) -> Result<(), LlmError> { + let store_guard = self.store.read().await; + let store = store_guard + .as_ref() + .ok_or_else(|| LlmError::SessionRenewalFailed { + provider: "nearai".to_string(), + reason: "No DB store attached".to_string(), + })?; + + let user_id = self.user_id.read().await.clone(); + let value = store + .get_setting(&user_id, "nearai.session_token") + .await + .map_err(|e| LlmError::SessionRenewalFailed { + provider: "nearai".to_string(), + reason: format!("DB query failed: {}", e), + })? + .ok_or_else(|| LlmError::SessionRenewalFailed { + provider: "nearai".to_string(), + reason: "No session in DB".to_string(), + })?; + + let session: SessionData = + serde_json::from_value(value).map_err(|e| LlmError::SessionRenewalFailed { + provider: "nearai".to_string(), + reason: format!("Failed to parse DB session: {}", e), + })?; + + let mut guard = self.token.write().await; + *guard = Some(SecretString::from(session.session_token)); + tracing::info!("Loaded session from DB settings"); + Ok(()) } diff --git a/src/main.rs b/src/main.rs index 913e9717..0247e163 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,14 +24,17 @@ use ironclaw::{ extensions::ExtensionManager, history::Store, llm::{SessionConfig, create_llm_provider, create_session_manager}, + orchestrator::{ + ContainerJobConfig, ContainerJobManager, OrchestratorApi, TokenStore, + api::OrchestratorState, + }, safety::SafetyLayer, secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore}, - settings::Settings, setup::{SetupConfig, SetupWizard}, tools::{ ToolRegistry, - mcp::{McpClient, McpSessionManager, config::load_mcp_servers, is_authenticated}, - wasm::{WasmToolLoader, WasmToolRuntime}, + mcp::{McpClient, McpSessionManager, config::load_mcp_servers_from_db, is_authenticated}, + wasm::{WasmToolLoader, WasmToolRuntime, load_dev_tools}, }, workspace::{EmbeddingProvider, NearAiEmbeddings, OpenAiEmbeddings, Workspace}, }; @@ -53,9 +56,14 @@ async fn main() -> anyhow::Result<()> { return run_tool_command(tool_cmd.clone()).await; } Some(Command::Config(config_cmd)) => { - // Config commands don't need logging setup - return ironclaw::cli::run_config_command(config_cmd.clone()) - .map_err(|e| anyhow::anyhow!("{}", e)); + // Config commands need DB access for settings + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")), + ) + .init(); + + return ironclaw::cli::run_config_command(config_cmd.clone()).await; } Some(Command::Mcp(mcp_cmd)) => { // Simple logging for MCP commands @@ -130,6 +138,83 @@ async fn main() -> anyhow::Result<()> { return run_status_command().await; } + Some(Command::Worker { + job_id, + orchestrator_url, + max_iterations, + }) => { + // Worker mode: runs inside a Docker container. + // Simple logging (no TUI, no DB, no channels). + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("ironclaw=info")), + ) + .init(); + + tracing::info!( + "Starting worker for job {} (orchestrator: {})", + job_id, + orchestrator_url + ); + + let config = ironclaw::worker::runtime::WorkerConfig { + job_id: *job_id, + orchestrator_url: orchestrator_url.clone(), + max_iterations: *max_iterations, + timeout: std::time::Duration::from_secs(600), + }; + + let runtime = ironclaw::worker::WorkerRuntime::new(config) + .map_err(|e| anyhow::anyhow!("Worker init failed: {}", e))?; + + runtime + .run() + .await + .map_err(|e| anyhow::anyhow!("Worker failed: {}", e))?; + + return Ok(()); + } + Some(Command::ClaudeBridge { + job_id, + orchestrator_url, + max_turns, + model, + }) => { + // Claude Code bridge mode: runs inside a Docker container. + // Spawns the `claude` CLI and streams output to the orchestrator. + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("ironclaw=info")), + ) + .init(); + + tracing::info!( + "Starting Claude Code bridge for job {} (orchestrator: {}, model: {})", + job_id, + orchestrator_url, + model + ); + + let config = ironclaw::worker::claude_bridge::ClaudeBridgeConfig { + job_id: *job_id, + orchestrator_url: orchestrator_url.clone(), + max_turns: *max_turns, + model: model.clone(), + timeout: std::time::Duration::from_secs(1800), + }; + + let runtime = ironclaw::worker::ClaudeBridgeRuntime::new(config) + .map_err(|e| anyhow::anyhow!("Claude bridge init failed: {}", e))?; + + runtime + .run() + .await + .map_err(|e| anyhow::anyhow!("Claude bridge failed: {}", e))?; + + return Ok(()); + } Some(Command::Onboard { skip_auth, channels_only, @@ -163,8 +248,11 @@ async fn main() -> anyhow::Result<()> { } } - // Load configuration (after potential setup) - let config = match Config::from_env() { + // Load bootstrap config (4 fields that must live on disk) + let bootstrap = ironclaw::bootstrap::BootstrapConfig::load(); + + // Load initial config from env + disk (before DB is available) + let mut config = match Config::from_env() { Ok(c) => c, Err(ironclaw::error::ConfigError::MissingRequired { key, hint }) => { eprintln!("Configuration error: Missing required setting '{}'", key); @@ -224,7 +312,38 @@ async fn main() -> anyhow::Result<()> { let store = Store::new(&config.database).await?; store.run_migrations().await?; tracing::info!("Database connected and migrations applied"); - Some(Arc::new(store)) + + // One-time migration: move disk config files into the DB settings table. + if let Err(e) = ironclaw::bootstrap::migrate_disk_to_db(&store, "default").await { + tracing::warn!("Disk-to-DB settings migration failed: {}", e); + } + + // Reload config from DB now that we have a connection. + // Priority: env var > DB setting > default. + match Config::from_db(&store, "default", &bootstrap).await { + Ok(db_config) => { + config = db_config; + tracing::info!("Configuration reloaded from database"); + } + Err(e) => { + tracing::warn!( + "Failed to reload config from DB, keeping env-based config: {}", + e + ); + } + } + + let store = Arc::new(store); + + // Attach store to session manager so tokens save to DB too + session.attach_store(Arc::clone(&store), "default").await; + + // Mark any jobs left in "running" or "creating" state as "interrupted". + if let Err(e) = store.cleanup_stale_sandbox_jobs().await { + tracing::warn!("Failed to cleanup stale sandbox jobs: {}", e); + } + + Some(store) }; // Initialize LLM provider (clone session so we can reuse it for embeddings) @@ -289,8 +408,11 @@ async fn main() -> anyhow::Result<()> { tools.register_memory_tools(workspace); } - // Register builder tool if enabled - if config.builder.enabled { + // Register builder tool if enabled. + // When sandbox is enabled and allow_local_tools is false, skip builder registration + // because register_builder_tool also registers dev tools (shell, file ops) that would + // bypass the sandbox. The builder runs inside containers instead. + if config.builder.enabled && (config.agent.allow_local_tools || !config.sandbox.enabled) { tools .register_builder_tool( llm.clone(), @@ -339,6 +461,8 @@ async fn main() -> anyhow::Result<()> { let wasm_tools_future = async { if let Some(ref runtime) = wasm_tool_runtime { let loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&tools)); + + // Load installed tools from ~/.ironclaw/tools/ match loader.load_from_dir(&config.wasm.tools_dir).await { Ok(results) => { if !results.loaded.is_empty() { @@ -356,12 +480,32 @@ async fn main() -> anyhow::Result<()> { tracing::warn!("Failed to scan WASM tools directory: {}", e); } } + + // Load dev tools from build artifacts (overrides installed if newer) + match load_dev_tools(&loader, &config.wasm.tools_dir).await { + Ok(results) => { + if !results.loaded.is_empty() { + tracing::info!( + "Loaded {} dev WASM tools from build artifacts", + results.loaded.len() + ); + } + } + Err(e) => { + tracing::debug!("No dev WASM tools found: {}", e); + } + } } }; let mcp_servers_future = async { if let Some(ref secrets) = secrets_store { - match load_mcp_servers().await { + let servers_result = if let Some(ref s) = store { + load_mcp_servers_from_db(s, "default").await + } else { + ironclaw::tools::mcp::config::load_mcp_servers().await + }; + match servers_result { Ok(servers) => { let enabled: Vec<_> = servers.enabled_servers().cloned().collect(); if !enabled.is_empty() { @@ -470,6 +614,7 @@ async fn main() -> anyhow::Result<()> { config.channels.wasm_channels_dir.clone(), config.tunnel.public_url.clone(), "default".to_string(), + store.clone(), )); tools.register_extension_tools(Arc::clone(&manager)); tracing::info!("Extension manager initialized with in-chat discovery tools"); @@ -482,6 +627,77 @@ async fn main() -> anyhow::Result<()> { None }; + // Set up orchestrator for sandboxed job execution + // When allow_local_tools is false (default), the LLM uses create_job for FS/shell work. + // When allow_local_tools is true, dev tools are also registered directly (current behavior). + if config.agent.allow_local_tools { + tools.register_dev_tools(); + tracing::info!( + "Local tools enabled (allow_local_tools=true), dev tools registered directly" + ); + } + + // Shared state for job events (used by both orchestrator and web gateway) + let job_event_tx: Option< + tokio::sync::broadcast::Sender<(uuid::Uuid, ironclaw::channels::web::types::SseEvent)>, + > = if config.sandbox.enabled { + let (tx, _) = tokio::sync::broadcast::channel(256); + Some(tx) + } else { + None + }; + let prompt_queue = Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::< + uuid::Uuid, + std::collections::VecDeque, + >::new())); + + let container_job_manager: Option> = if config.sandbox.enabled { + let token_store = TokenStore::new(); + let job_config = ContainerJobConfig { + image: config.sandbox.image.clone(), + memory_limit_mb: config.sandbox.memory_limit_mb, + cpu_shares: config.sandbox.cpu_shares, + orchestrator_port: 50051, + claude_config_dir: if config.claude_code.enabled { + Some(config.claude_code.config_dir.clone()) + } else { + None + }, + claude_code_model: config.claude_code.model.clone(), + claude_code_max_turns: config.claude_code.max_turns, + claude_code_memory_limit_mb: config.claude_code.memory_limit_mb, + }; + let jm = Arc::new(ContainerJobManager::new(job_config, token_store.clone())); + + // Start the orchestrator internal API in the background + let orchestrator_state = OrchestratorState { + llm: llm.clone(), + job_manager: Arc::clone(&jm), + token_store, + job_event_tx: job_event_tx.clone(), + prompt_queue: Arc::clone(&prompt_queue), + store: store.clone(), + }; + + tokio::spawn(async move { + if let Err(e) = OrchestratorApi::start(orchestrator_state, 50051).await { + tracing::error!("Orchestrator API failed: {}", e); + } + }); + + tracing::info!("Orchestrator API started on :50051, sandbox delegation enabled"); + if config.claude_code.enabled { + tracing::info!( + "Claude Code sandbox mode available (model: {}, max_turns: {})", + config.claude_code.model, + config.claude_code.max_turns + ); + } + Some(jm) + } else { + None + }; + tracing::info!( "Tool registry initialized with {} total tools", tools.count() @@ -563,6 +779,17 @@ async fn main() -> anyhow::Result<()> { ); } + // Inject owner_id for Telegram so the bot only responds + // to the bound user account. + if channel_name == "telegram" { + if let Some(owner_id) = config.channels.telegram_owner_id { + config_updates.insert( + "owner_id".to_string(), + serde_json::json!(owner_id), + ); + } + } + if !config_updates.is_empty() { channel_arc.update_config(config_updates).await; tracing::info!( @@ -621,7 +848,7 @@ async fn main() -> anyhow::Result<()> { channels.add(Box::new(SharedWasmChannel::new(channel_arc))); } - if has_webhook_channels && config.tunnel.public_url.is_some() { + if has_webhook_channels { webhook_routes.push(create_wasm_channel_router( wasm_router, extension_manager.as_ref().map(Arc::clone), @@ -693,6 +920,19 @@ async fn main() -> anyhow::Result<()> { Arc::new(ws) }); + // Seed workspace with core identity files on first boot + if let Some(ref ws) = workspace { + match ws.seed_if_empty().await { + Ok(count) if count > 0 => { + tracing::info!("Workspace seeded with {} core files", count); + } + Ok(_) => {} + Err(e) => { + tracing::warn!("Failed to seed workspace: {}", e); + } + } + } + // Backfill embeddings if we just enabled the provider if let (Some(ws), Some(_)) = (&workspace, &embeddings) { match ws.backfill_embeddings().await { @@ -712,8 +952,12 @@ async fn main() -> anyhow::Result<()> { // Create session manager (shared between agent and web gateway) let session_manager = Arc::new(SessionManager::new()); - // Register job tools - tools.register_job_tools(Arc::clone(&context_manager)); + // Register job tools (sandbox deps auto-injected when container_job_manager is available) + tools.register_job_tools( + Arc::clone(&context_manager), + container_job_manager.clone(), + store.clone(), + ); // Add web gateway channel if configured if let Some(ref gw_config) = config.channels.gateway { @@ -721,19 +965,44 @@ async fn main() -> anyhow::Result<()> { if let Some(ref ws) = workspace { gw = gw.with_workspace(Arc::clone(ws)); } - gw = gw.with_context_manager(Arc::clone(&context_manager)); gw = gw.with_session_manager(Arc::clone(&session_manager)); gw = gw.with_log_broadcaster(Arc::clone(&log_broadcaster)); gw = gw.with_tool_registry(Arc::clone(&tools)); if let Some(ref ext_mgr) = extension_manager { gw = gw.with_extension_manager(Arc::clone(ext_mgr)); } + if let Some(ref s) = store { + gw = gw.with_store(Arc::clone(s)); + } + if let Some(ref jm) = container_job_manager { + gw = gw.with_job_manager(Arc::clone(jm)); + } + if config.sandbox.enabled { + gw = gw.with_prompt_queue(Arc::clone(&prompt_queue)); + + // Spawn a task to forward job events from the broadcast channel to SSE + if let Some(ref tx) = job_event_tx { + let mut rx = tx.subscribe(); + let gw_state = Arc::clone(gw.state()); + tokio::spawn(async move { + while let Ok((_job_id, event)) = rx.recv().await { + gw_state.sse.broadcast(event); + } + }); + } + } tracing::info!( "Web gateway enabled on {}:{}", gw_config.host, gw_config.port ); + tracing::info!( + "Web UI: http://{}:{}/?token={}", + gw_config.host, + gw_config.port, + gw.auth_token() + ); channels.add(Box::new(gw)); } @@ -752,6 +1021,7 @@ async fn main() -> anyhow::Result<()> { deps, channels, Some(config.heartbeat.clone()), + Some(config.routines.clone()), Some(context_manager), Some(session_manager), ); @@ -774,15 +1044,15 @@ async fn main() -> anyhow::Result<()> { /// /// Returns `Some(reason)` if onboarding should be triggered, `None` otherwise. fn check_onboard_needed() -> Option<&'static str> { - let settings = Settings::load(); + let bootstrap = ironclaw::bootstrap::BootstrapConfig::load(); // Database not configured (and not in env) - if settings.database_url.is_none() && std::env::var("DATABASE_URL").is_err() { + if bootstrap.database_url.is_none() && std::env::var("DATABASE_URL").is_err() { return Some("Database not configured"); } // Secrets not configured (and not in env) - if settings.secrets_master_key_source == ironclaw::settings::KeySource::None + if bootstrap.secrets_master_key_source == ironclaw::settings::KeySource::None && std::env::var("SECRETS_MASTER_KEY").is_err() && !ironclaw::secrets::keychain::has_master_key() { @@ -792,7 +1062,7 @@ fn check_onboard_needed() -> Option<&'static str> { // First run (onboarding never completed and no session) let session_path = ironclaw::llm::session::default_session_path(); - if !settings.onboard_completed && !session_path.exists() { + if !bootstrap.onboard_completed && !session_path.exists() { return Some("First run"); } diff --git a/src/orchestrator/api.rs b/src/orchestrator/api.rs new file mode 100644 index 00000000..b209403a --- /dev/null +++ b/src/orchestrator/api.rs @@ -0,0 +1,511 @@ +//! Internal HTTP API for worker-to-orchestrator communication. +//! +//! This runs on a separate port (default 50051) from the web gateway. +//! All endpoints are authenticated via per-job bearer tokens. + +use std::collections::{HashMap, VecDeque}; +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use tokio::sync::{Mutex, broadcast}; +use uuid::Uuid; + +use crate::channels::web::types::SseEvent; +use crate::history::Store; +use crate::llm::{CompletionRequest, LlmProvider, ToolCompletionRequest}; +use crate::orchestrator::auth::{TokenStore, worker_auth_middleware}; +use crate::orchestrator::job_manager::ContainerJobManager; +use crate::worker::api::JobEventPayload; +use crate::worker::api::{ + CompletionReport, JobDescription, ProxyCompletionRequest, ProxyCompletionResponse, + ProxyToolCompletionRequest, ProxyToolCompletionResponse, StatusUpdate, +}; + +/// A follow-up prompt queued for a Claude Code bridge. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingPrompt { + pub content: String, + pub done: bool, +} + +/// Shared state for the orchestrator API. +#[derive(Clone)] +pub struct OrchestratorState { + pub llm: Arc, + pub job_manager: Arc, + pub token_store: TokenStore, + /// Broadcast channel for job events (consumed by the web gateway SSE). + pub job_event_tx: Option>, + /// Buffered follow-up prompts for sandbox jobs, keyed by job_id. + pub prompt_queue: Arc>>>, + /// Database handle for persisting job events. + pub store: Option>, +} + +/// The orchestrator's internal API server. +pub struct OrchestratorApi; + +impl OrchestratorApi { + /// Build the axum router for the internal API. + pub fn router(state: OrchestratorState) -> Router { + Router::new() + // Worker routes: authenticated via route_layer middleware. + .route("/worker/{job_id}/job", get(get_job)) + .route("/worker/{job_id}/llm/complete", post(llm_complete)) + .route( + "/worker/{job_id}/llm/complete_with_tools", + post(llm_complete_with_tools), + ) + .route("/worker/{job_id}/status", post(report_status)) + .route("/worker/{job_id}/complete", post(report_complete)) + .route("/worker/{job_id}/event", post(job_event_handler)) + .route("/worker/{job_id}/prompt", get(get_prompt_handler)) + .route_layer(axum::middleware::from_fn_with_state( + state.token_store.clone(), + worker_auth_middleware, + )) + // Unauthenticated routes (added after the layer). + .route("/health", get(health_check)) + .with_state(state) + } + + /// Start the internal API server on the given port. + /// + /// On macOS/Windows (Docker Desktop), binds to loopback only because + /// Docker Desktop routes `host.docker.internal` through its VM to the + /// host's `127.0.0.1`. + /// + /// On Linux, containers reach the host via the docker bridge gateway + /// (`172.17.0.1`), which is NOT loopback. Binding to `127.0.0.1` + /// would reject container traffic. We bind to all interfaces instead + /// and rely on `worker_auth_middleware` (applied as a route_layer on + /// every `/worker/` endpoint) to reject unauthenticated requests. + pub async fn start( + state: OrchestratorState, + port: u16, + ) -> Result<(), Box> { + let router = Self::router(state); + let addr = if cfg!(target_os = "linux") { + std::net::SocketAddr::from(([0, 0, 0, 0], port)) + } else { + std::net::SocketAddr::from(([127, 0, 0, 1], port)) + }; + + tracing::info!("Orchestrator internal API listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, router).await?; + + Ok(()) + } +} + +// -- Handlers -- +// +// All /worker/ handlers below are behind the worker_auth_middleware route_layer, +// so they don't need to validate tokens themselves. + +async fn health_check() -> &'static str { + "ok" +} + +async fn get_job( + State(state): State, + Path(job_id): Path, +) -> Result, StatusCode> { + let handle = state + .job_manager + .get_handle(job_id) + .await + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(JobDescription { + title: format!("Job {}", job_id), + description: handle.task_description, + project_dir: handle.project_dir.map(|p| p.display().to_string()), + })) +} + +async fn llm_complete( + State(state): State, + Path(job_id): Path, + Json(req): Json, +) -> Result, StatusCode> { + let completion_req = CompletionRequest { + messages: req.messages, + max_tokens: req.max_tokens, + temperature: req.temperature, + stop_sequences: req.stop_sequences, + metadata: std::collections::HashMap::new(), + }; + + let resp = state.llm.complete(completion_req).await.map_err(|e| { + tracing::error!("LLM completion failed for job {}: {}", job_id, e); + StatusCode::BAD_GATEWAY + })?; + + Ok(Json(ProxyCompletionResponse { + content: resp.content, + input_tokens: resp.input_tokens, + output_tokens: resp.output_tokens, + finish_reason: format_finish_reason(resp.finish_reason), + })) +} + +async fn llm_complete_with_tools( + State(state): State, + Path(job_id): Path, + Json(req): Json, +) -> Result, StatusCode> { + let tool_req = ToolCompletionRequest { + messages: req.messages, + tools: req.tools, + max_tokens: req.max_tokens, + temperature: req.temperature, + tool_choice: req.tool_choice, + metadata: std::collections::HashMap::new(), + }; + + let resp = state.llm.complete_with_tools(tool_req).await.map_err(|e| { + tracing::error!("LLM tool completion failed for job {}: {}", job_id, e); + StatusCode::BAD_GATEWAY + })?; + + Ok(Json(ProxyToolCompletionResponse { + content: resp.content, + tool_calls: resp.tool_calls, + input_tokens: resp.input_tokens, + output_tokens: resp.output_tokens, + finish_reason: format_finish_reason(resp.finish_reason), + })) +} + +async fn report_status( + Path(job_id): Path, + Json(update): Json, +) -> Result { + tracing::debug!( + job_id = %job_id, + state = %update.state, + iteration = update.iteration, + "Worker status update" + ); + + Ok(StatusCode::OK) +} + +async fn report_complete( + State(state): State, + Path(job_id): Path, + Json(report): Json, +) -> Result { + if report.success { + tracing::info!( + job_id = %job_id, + "Worker reported job complete" + ); + } else { + tracing::warn!( + job_id = %job_id, + message = ?report.message, + "Worker reported job failure" + ); + } + + // Store the result and clean up the container + let result = crate::orchestrator::job_manager::CompletionResult { + success: report.success, + message: report.message.clone(), + }; + let _ = state.job_manager.complete_job(job_id, result).await; + + Ok(StatusCode::OK) +} + +// -- Sandbox job event handlers -- + +/// Receive a job event from a worker or Claude Code bridge and broadcast + persist it. +async fn job_event_handler( + State(state): State, + Path(job_id): Path, + Json(payload): Json, +) -> Result { + tracing::debug!( + job_id = %job_id, + event_type = %payload.event_type, + "Job event received" + ); + + // Persist to DB (fire-and-forget) + if let Some(ref store) = state.store { + let store = Arc::clone(store); + let event_type = payload.event_type.clone(); + let data = payload.data.clone(); + tokio::spawn(async move { + if let Err(e) = store.save_job_event(job_id, &event_type, &data).await { + tracing::warn!(job_id = %job_id, "Failed to persist job event: {}", e); + } + }); + } + + // Convert to SSE event and broadcast + let job_id_str = job_id.to_string(); + let sse_event = match payload.event_type.as_str() { + "message" => SseEvent::JobMessage { + job_id: job_id_str, + role: payload + .data + .get("role") + .and_then(|v| v.as_str()) + .unwrap_or("assistant") + .to_string(), + content: payload + .data + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }, + "tool_use" => SseEvent::JobToolUse { + job_id: job_id_str, + tool_name: payload + .data + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + input: payload + .data + .get("input") + .cloned() + .unwrap_or(serde_json::Value::Null), + }, + "tool_result" => SseEvent::JobToolResult { + job_id: job_id_str, + tool_name: payload + .data + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + output: payload + .data + .get("output") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }, + "result" => SseEvent::JobResult { + job_id: job_id_str, + status: payload + .data + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + session_id: payload + .data + .get("session_id") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }, + _ => SseEvent::JobStatus { + job_id: job_id_str, + message: payload + .data + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + }, + }; + + // Broadcast via the channel (if configured) + if let Some(ref tx) = state.job_event_tx { + let _ = tx.send((job_id, sse_event)); + } + + Ok(StatusCode::OK) +} + +/// Return the next queued follow-up prompt for a Claude Code bridge. +/// Returns 204 No Content if no prompt is available. +async fn get_prompt_handler( + State(state): State, + Path(job_id): Path, +) -> Result<(StatusCode, Json), StatusCode> { + let mut queue = state.prompt_queue.lock().await; + if let Some(prompts) = queue.get_mut(&job_id) { + if let Some(prompt) = prompts.pop_front() { + return Ok(( + StatusCode::OK, + Json(serde_json::json!({ + "content": prompt.content, + "done": prompt.done, + })), + )); + } + } + + // Return 204 with an empty body. The Json wrapper requires some value + // but the status code signals "nothing here". + Ok((StatusCode::NO_CONTENT, Json(serde_json::Value::Null))) +} + +fn format_finish_reason(reason: crate::llm::FinishReason) -> String { + match reason { + crate::llm::FinishReason::Stop => "stop".to_string(), + crate::llm::FinishReason::Length => "length".to_string(), + crate::llm::FinishReason::ToolUse => "tool_use".to_string(), + crate::llm::FinishReason::ContentFilter => "content_filter".to_string(), + crate::llm::FinishReason::Unknown => "unknown".to_string(), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + use uuid::Uuid; + + use crate::error::LlmError; + use crate::llm::{ + CompletionRequest, CompletionResponse, ToolCompletionRequest, ToolCompletionResponse, + }; + use crate::orchestrator::auth::TokenStore; + use crate::orchestrator::job_manager::{ContainerJobConfig, ContainerJobManager}; + + use super::*; + + /// Stub LLM provider that panics if called (tests only exercise routing/auth). + struct StubLlm; + + #[async_trait::async_trait] + impl crate::llm::LlmProvider for StubLlm { + fn model_name(&self) -> &str { + "stub" + } + fn cost_per_token(&self) -> (rust_decimal::Decimal, rust_decimal::Decimal) { + (rust_decimal::Decimal::ZERO, rust_decimal::Decimal::ZERO) + } + async fn complete(&self, _req: CompletionRequest) -> Result { + Err(LlmError::RequestFailed { + provider: "stub".into(), + reason: "not implemented".into(), + }) + } + async fn complete_with_tools( + &self, + _req: ToolCompletionRequest, + ) -> Result { + Err(LlmError::RequestFailed { + provider: "stub".into(), + reason: "not implemented".into(), + }) + } + } + + fn test_state() -> OrchestratorState { + let token_store = TokenStore::new(); + let jm = ContainerJobManager::new(ContainerJobConfig::default(), token_store.clone()); + OrchestratorState { + llm: Arc::new(StubLlm), + job_manager: Arc::new(jm), + token_store, + job_event_tx: None, + prompt_queue: Arc::new(Mutex::new(HashMap::new())), + store: None, + } + } + + #[tokio::test] + async fn health_requires_no_auth() { + let state = test_state(); + let router = OrchestratorApi::router(state); + + let req = Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[tokio::test] + async fn worker_route_rejects_missing_token() { + let state = test_state(); + let router = OrchestratorApi::router(state); + + let job_id = Uuid::new_v4(); + let req = Request::builder() + .uri(format!("/worker/{}/job", job_id)) + .body(Body::empty()) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn worker_route_rejects_wrong_token() { + let state = test_state(); + let router = OrchestratorApi::router(state); + + let job_id = Uuid::new_v4(); + let req = Request::builder() + .uri(format!("/worker/{}/job", job_id)) + .header("Authorization", "Bearer totally-bogus") + .body(Body::empty()) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn worker_route_accepts_valid_token() { + let state = test_state(); + let job_id = Uuid::new_v4(); + let token = state.token_store.create_token(job_id).await; + + let router = OrchestratorApi::router(state); + + let req = Request::builder() + .uri(format!("/worker/{}/job", job_id)) + .header("Authorization", format!("Bearer {}", token)) + .body(Body::empty()) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + // 404 because no container exists for this job_id, but NOT 401. + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn token_for_job_a_rejected_on_job_b() { + let state = test_state(); + let job_a = Uuid::new_v4(); + let job_b = Uuid::new_v4(); + let token_a = state.token_store.create_token(job_a).await; + + let router = OrchestratorApi::router(state); + + // Use job_a's token to hit job_b's endpoint + let req = Request::builder() + .uri(format!("/worker/{}/job", job_b)) + .header("Authorization", format!("Bearer {}", token_a)) + .body(Body::empty()) + .unwrap(); + + let resp = router.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + } +} diff --git a/src/orchestrator/auth.rs b/src/orchestrator/auth.rs new file mode 100644 index 00000000..c3045b9a --- /dev/null +++ b/src/orchestrator/auth.rs @@ -0,0 +1,162 @@ +//! Per-job bearer token authentication for worker-to-orchestrator communication. +//! +//! Security properties: +//! - Tokens are cryptographically random (32 bytes, hex-encoded) +//! - Tokens are scoped to a specific job_id +//! - Tokens are ephemeral (in-memory only, never persisted) +//! - A token for Job A cannot access endpoints for Job B + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::extract::{Request, State}; +use axum::http::StatusCode; +use axum::middleware::Next; +use axum::response::Response; +use rand::Rng; +use tokio::sync::RwLock; +use uuid::Uuid; + +/// In-memory store for per-job authentication tokens. +#[derive(Clone)] +pub struct TokenStore { + /// Maps job_id -> bearer token. Never logged or persisted. + tokens: Arc>>, +} + +impl TokenStore { + pub fn new() -> Self { + Self { + tokens: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Generate and store a new token for a job. + pub async fn create_token(&self, job_id: Uuid) -> String { + let token = generate_token(); + self.tokens.write().await.insert(job_id, token.clone()); + token + } + + /// Validate a token for a specific job. + pub async fn validate(&self, job_id: Uuid, token: &str) -> bool { + self.tokens + .read() + .await + .get(&job_id) + .map(|stored| stored == token) + .unwrap_or(false) + } + + /// Remove a token (on container cleanup). + pub async fn revoke(&self, job_id: Uuid) { + self.tokens.write().await.remove(&job_id); + } + + /// Get the number of active tokens (for diagnostics). + pub async fn active_count(&self) -> usize { + self.tokens.read().await.len() + } +} + +impl Default for TokenStore { + fn default() -> Self { + Self::new() + } +} + +/// Generate a cryptographically random token (32 bytes, hex-encoded = 64 chars). +fn generate_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill(&mut bytes); + hex_encode(&bytes) +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +/// Axum middleware that validates worker bearer tokens. +/// +/// Extracts the job_id from the path (`/worker/{job_id}/...`) and validates +/// the `Authorization: Bearer ` header against the token store. +/// +/// Wire up with `axum::middleware::from_fn_with_state(token_store, worker_auth_middleware)`. +pub async fn worker_auth_middleware( + State(token_store): State, + request: Request, + next: Next, +) -> Result { + let path = request.uri().path().to_string(); + let job_id = extract_job_id_from_path(&path).ok_or(StatusCode::BAD_REQUEST)?; + + let token = request + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or(StatusCode::UNAUTHORIZED)?; + + if !token_store.validate(job_id, token).await { + return Err(StatusCode::UNAUTHORIZED); + } + + Ok(next.run(request).await) +} + +/// Extract job UUID from a path like `/worker/{uuid}/...` +fn extract_job_id_from_path(path: &str) -> Option { + let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect(); + if parts.len() >= 2 && parts[0] == "worker" { + Uuid::parse_str(parts[1]).ok() + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_token_create_and_validate() { + let store = TokenStore::new(); + let job_id = Uuid::new_v4(); + + let token = store.create_token(job_id).await; + assert_eq!(token.len(), 64); // 32 bytes hex = 64 chars + + assert!(store.validate(job_id, &token).await); + assert!(!store.validate(job_id, "wrong-token").await); + assert!(!store.validate(Uuid::new_v4(), &token).await); + } + + #[tokio::test] + async fn test_token_revoke() { + let store = TokenStore::new(); + let job_id = Uuid::new_v4(); + + let token = store.create_token(job_id).await; + assert!(store.validate(job_id, &token).await); + + store.revoke(job_id).await; + assert!(!store.validate(job_id, &token).await); + } + + #[test] + fn test_extract_job_id() { + let id = Uuid::new_v4(); + let path = format!("/worker/{}/llm/complete", id); + assert_eq!(extract_job_id_from_path(&path), Some(id)); + + assert_eq!(extract_job_id_from_path("/other/path"), None); + assert_eq!(extract_job_id_from_path("/worker/not-a-uuid/foo"), None); + } + + #[test] + fn test_token_is_random() { + let t1 = generate_token(); + let t2 = generate_token(); + assert_ne!(t1, t2); + } +} diff --git a/src/orchestrator/job_manager.rs b/src/orchestrator/job_manager.rs new file mode 100644 index 00000000..6d4f9204 --- /dev/null +++ b/src/orchestrator/job_manager.rs @@ -0,0 +1,474 @@ +//! Container lifecycle management for sandboxed jobs. +//! +//! Extends the existing `SandboxManager` infrastructure to support persistent +//! containers with their own agent loops (as opposed to ephemeral per-command containers). + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::error::OrchestratorError; +use crate::orchestrator::auth::TokenStore; +use crate::sandbox::connect_docker; + +/// Which mode a sandbox container runs in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JobMode { + /// Standard IronClaw worker with proxied LLM calls. + Worker, + /// Claude Code bridge that spawns the `claude` CLI directly. + ClaudeCode, +} + +impl JobMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::Worker => "worker", + Self::ClaudeCode => "claude_code", + } + } +} + +impl std::fmt::Display for JobMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +/// Configuration for the container job manager. +#[derive(Debug, Clone)] +pub struct ContainerJobConfig { + /// Docker image for worker containers. + pub image: String, + /// Default memory limit in MB. + pub memory_limit_mb: u64, + /// Default CPU shares. + pub cpu_shares: u32, + /// Port the orchestrator internal API listens on. + pub orchestrator_port: u16, + /// Host directory containing Claude auth config (mounted read-only for ClaudeCode mode). + pub claude_config_dir: Option, + /// Claude model to use in ClaudeCode mode. + pub claude_code_model: String, + /// Maximum turns for Claude Code. + pub claude_code_max_turns: u32, + /// Memory limit in MB for Claude Code containers (heavier than workers). + pub claude_code_memory_limit_mb: u64, +} + +impl Default for ContainerJobConfig { + fn default() -> Self { + Self { + image: "ironclaw-worker:latest".to_string(), + memory_limit_mb: 2048, + cpu_shares: 1024, + orchestrator_port: 50051, + claude_config_dir: None, + claude_code_model: "sonnet".to_string(), + claude_code_max_turns: 50, + claude_code_memory_limit_mb: 4096, + } + } +} + +/// State of a container. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ContainerState { + Creating, + Running, + Stopped, + Failed, +} + +impl std::fmt::Display for ContainerState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Creating => write!(f, "creating"), + Self::Running => write!(f, "running"), + Self::Stopped => write!(f, "stopped"), + Self::Failed => write!(f, "failed"), + } + } +} + +/// Handle to a running container job. +#[derive(Debug, Clone)] +pub struct ContainerHandle { + pub job_id: Uuid, + pub container_id: String, + pub state: ContainerState, + pub mode: JobMode, + pub created_at: DateTime, + pub project_dir: Option, + pub task_description: String, + /// Completion result from the worker (set when the worker reports done). + pub completion_result: Option, + // NOTE: auth_token is intentionally NOT in this struct. + // It lives only in the TokenStore (never logged, serialized, or persisted). +} + +/// Result reported by a worker on completion. +#[derive(Debug, Clone)] +pub struct CompletionResult { + pub success: bool, + pub message: Option, +} + +/// Manages the lifecycle of Docker containers for sandboxed job execution. +pub struct ContainerJobManager { + config: ContainerJobConfig, + token_store: TokenStore, + containers: Arc>>, +} + +impl ContainerJobManager { + pub fn new(config: ContainerJobConfig, token_store: TokenStore) -> Self { + Self { + config, + token_store, + containers: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Create and start a new container for a job. + /// + /// The caller provides the `job_id` so it can be persisted to the database + /// before the container is created. Returns the auth token for the worker. + pub async fn create_job( + &self, + job_id: Uuid, + task: &str, + project_dir: Option, + mode: JobMode, + ) -> Result { + // Generate auth token (stored in TokenStore, never logged) + let token = self.token_store.create_token(job_id).await; + + // Record the handle + let handle = ContainerHandle { + job_id, + container_id: String::new(), // set after container creation + state: ContainerState::Creating, + mode, + created_at: Utc::now(), + project_dir: project_dir.clone(), + task_description: task.to_string(), + completion_result: None, + }; + self.containers.write().await.insert(job_id, handle); + + // Connect to Docker + let docker = connect_docker() + .await + .map_err(|e| OrchestratorError::Docker { + reason: e.to_string(), + })?; + + // Build container configuration + let orchestrator_host = if cfg!(target_os = "linux") { + "172.17.0.1" + } else { + "host.docker.internal" + }; + + let orchestrator_url = format!( + "http://{}:{}", + orchestrator_host, self.config.orchestrator_port + ); + + let mut env_vec = vec![ + format!("IRONCLAW_WORKER_TOKEN={}", token), + format!("IRONCLAW_JOB_ID={}", job_id), + format!("IRONCLAW_ORCHESTRATOR_URL={}", orchestrator_url), + ]; + + // Build volume mounts (validate project_dir stays within ~/.ironclaw/projects/) + let mut binds = Vec::new(); + if let Some(ref dir) = project_dir { + let canonical = + dir.canonicalize() + .map_err(|e| OrchestratorError::ContainerCreationFailed { + job_id, + reason: format!( + "failed to canonicalize project dir {}: {}", + dir.display(), + e + ), + })?; + let projects_base = dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironclaw") + .join("projects"); + if let Ok(canonical_base) = projects_base.canonicalize() { + if !canonical.starts_with(&canonical_base) { + return Err(OrchestratorError::ContainerCreationFailed { + job_id, + reason: format!( + "project directory {} is outside allowed base {}", + canonical.display(), + canonical_base.display() + ), + }); + } + } + binds.push(format!("{}:/workspace:rw", canonical.display())); + env_vec.push("IRONCLAW_WORKSPACE=/workspace".to_string()); + } + + // Claude Code mode: mount host ~/.claude read-only for auth + if mode == JobMode::ClaudeCode { + if let Some(ref claude_dir) = self.config.claude_config_dir { + binds.push(format!("{}:/home/sandbox/.claude:ro", claude_dir.display())); + } + } + + // Memory limit: Claude Code gets more memory + let memory_mb = match mode { + JobMode::ClaudeCode => self.config.claude_code_memory_limit_mb, + JobMode::Worker => self.config.memory_limit_mb, + }; + + // Create the container + use bollard::container::{Config, CreateContainerOptions}; + use bollard::models::HostConfig; + + let host_config = HostConfig { + binds: if binds.is_empty() { None } else { Some(binds) }, + memory: Some((memory_mb * 1024 * 1024) as i64), + cpu_shares: Some(self.config.cpu_shares as i64), + network_mode: Some("bridge".to_string()), + extra_hosts: Some(vec!["host.docker.internal:host-gateway".to_string()]), + cap_drop: Some(vec!["ALL".to_string()]), + cap_add: Some(vec![ + "CHOWN".to_string(), + "SETUID".to_string(), + "SETGID".to_string(), + ]), + security_opt: Some(vec!["no-new-privileges:true".to_string()]), + tmpfs: Some( + [("/tmp".to_string(), "size=512M".to_string())] + .into_iter() + .collect(), + ), + ..Default::default() + }; + + // Build CMD based on mode + let cmd = match mode { + JobMode::Worker => vec![ + "worker".to_string(), + "--job-id".to_string(), + job_id.to_string(), + "--orchestrator-url".to_string(), + orchestrator_url, + ], + JobMode::ClaudeCode => vec![ + "claude-bridge".to_string(), + "--job-id".to_string(), + job_id.to_string(), + "--orchestrator-url".to_string(), + orchestrator_url, + "--max-turns".to_string(), + self.config.claude_code_max_turns.to_string(), + "--model".to_string(), + self.config.claude_code_model.clone(), + ], + }; + + let container_config = Config { + image: Some(self.config.image.clone()), + cmd: Some(cmd), + env: Some(env_vec), + host_config: Some(host_config), + user: Some("1000:1000".to_string()), + working_dir: Some("/workspace".to_string()), + ..Default::default() + }; + + let container_name = match mode { + JobMode::Worker => format!("ironclaw-worker-{}", job_id), + JobMode::ClaudeCode => format!("ironclaw-claude-{}", job_id), + }; + let options = CreateContainerOptions { + name: container_name, + ..Default::default() + }; + + let response = docker + .create_container(Some(options), container_config) + .await + .map_err(|e| OrchestratorError::ContainerCreationFailed { + job_id, + reason: e.to_string(), + })?; + + let container_id = response.id; + + // Start the container + docker + .start_container::(&container_id, None) + .await + .map_err(|e| OrchestratorError::ContainerCreationFailed { + job_id, + reason: format!("failed to start container: {}", e), + })?; + + // Update handle with container ID + if let Some(handle) = self.containers.write().await.get_mut(&job_id) { + handle.container_id = container_id; + handle.state = ContainerState::Running; + } + + tracing::info!( + job_id = %job_id, + "Created and started worker container" + ); + + Ok(token) + } + + /// Stop a running container job. + pub async fn stop_job(&self, job_id: Uuid) -> Result<(), OrchestratorError> { + let container_id = { + let containers = self.containers.read().await; + containers + .get(&job_id) + .map(|h| h.container_id.clone()) + .ok_or(OrchestratorError::ContainerNotFound { job_id })? + }; + + if container_id.is_empty() { + return Err(OrchestratorError::InvalidContainerState { + job_id, + state: "creating (no container ID yet)".to_string(), + }); + } + + let docker = connect_docker() + .await + .map_err(|e| OrchestratorError::Docker { + reason: e.to_string(), + })?; + + // Stop the container (10 second grace period) + let _ = docker + .stop_container( + &container_id, + Some(bollard::container::StopContainerOptions { t: 10 }), + ) + .await; + + // Remove the container + let _ = docker + .remove_container( + &container_id, + Some(bollard::container::RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await; + + // Update state + if let Some(handle) = self.containers.write().await.get_mut(&job_id) { + handle.state = ContainerState::Stopped; + } + + // Revoke the auth token + self.token_store.revoke(job_id).await; + + tracing::info!(job_id = %job_id, "Stopped worker container"); + + Ok(()) + } + + /// Mark a job as complete with a result. The container is stopped but the + /// handle is kept so `CreateJobTool` can read the completion message. + pub async fn complete_job( + &self, + job_id: Uuid, + result: CompletionResult, + ) -> Result<(), OrchestratorError> { + // Store the result before stopping + { + let mut containers = self.containers.write().await; + if let Some(handle) = containers.get_mut(&job_id) { + handle.completion_result = Some(result); + handle.state = ContainerState::Stopped; + } + } + + // Stop container and revoke token (but keep handle in map) + let container_id = { + let containers = self.containers.read().await; + containers.get(&job_id).map(|h| h.container_id.clone()) + }; + if let Some(cid) = container_id { + if !cid.is_empty() { + if let Ok(docker) = connect_docker().await { + let _ = docker + .stop_container( + &cid, + Some(bollard::container::StopContainerOptions { t: 5 }), + ) + .await; + let _ = docker + .remove_container( + &cid, + Some(bollard::container::RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await; + } + } + } + self.token_store.revoke(job_id).await; + + tracing::info!(job_id = %job_id, "Completed worker container"); + Ok(()) + } + + /// Remove a completed job handle from memory (called after result is read). + pub async fn cleanup_job(&self, job_id: Uuid) { + self.containers.write().await.remove(&job_id); + } + + /// Get the handle for a job. + pub async fn get_handle(&self, job_id: Uuid) -> Option { + self.containers.read().await.get(&job_id).cloned() + } + + /// List all active container jobs. + pub async fn list_jobs(&self) -> Vec { + self.containers.read().await.values().cloned().collect() + } + + /// Get a reference to the token store. + pub fn token_store(&self) -> &TokenStore { + &self.token_store + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_container_job_config_default() { + let config = ContainerJobConfig::default(); + assert_eq!(config.orchestrator_port, 50051); + assert_eq!(config.memory_limit_mb, 2048); + } + + #[test] + fn test_container_state_display() { + assert_eq!(ContainerState::Running.to_string(), "running"); + assert_eq!(ContainerState::Stopped.to_string(), "stopped"); + } +} diff --git a/src/orchestrator/mod.rs b/src/orchestrator/mod.rs new file mode 100644 index 00000000..8462f6aa --- /dev/null +++ b/src/orchestrator/mod.rs @@ -0,0 +1,37 @@ +//! Orchestrator for managing sandboxed worker containers. +//! +//! The orchestrator runs in the main agent process and provides: +//! - An internal HTTP API for worker communication (LLM proxy, status, secrets) +//! - Per-job bearer token authentication +//! - Container lifecycle management (create, monitor, stop) +//! +//! ```text +//! ┌───────────────────────────────────────────────┐ +//! │ Orchestrator │ +//! │ │ +//! │ Internal API (:50051) │ +//! │ POST /worker/{id}/llm/complete │ +//! │ POST /worker/{id}/llm/complete_with_tools │ +//! │ GET /worker/{id}/job │ +//! │ POST /worker/{id}/status │ +//! │ POST /worker/{id}/complete │ +//! │ │ +//! │ ContainerJobManager │ +//! │ create_job() -> container + token │ +//! │ stop_job() │ +//! │ list_jobs() │ +//! │ │ +//! │ TokenStore │ +//! │ per-job bearer tokens (in-memory only) │ +//! └───────────────────────────────────────────────┘ +//! ``` + +pub mod api; +pub mod auth; +pub mod job_manager; + +pub use api::OrchestratorApi; +pub use auth::TokenStore; +pub use job_manager::{ + CompletionResult, ContainerHandle, ContainerJobConfig, ContainerJobManager, JobMode, +}; diff --git a/src/sandbox/container.rs b/src/sandbox/container.rs index 29289667..87bec652 100644 --- a/src/sandbox/container.rs +++ b/src/sandbox/container.rs @@ -491,9 +491,36 @@ impl ContainerRunner { } /// Connect to the Docker daemon. +/// +/// Tries these locations in order: +/// 1. `DOCKER_HOST` env var (bollard default) +/// 2. `/var/run/docker.sock` (Linux default) +/// 3. `~/.docker/run/docker.sock` (Docker Desktop on macOS) pub async fn connect_docker() -> Result { - Docker::connect_with_local_defaults().map_err(|e| SandboxError::DockerNotAvailable { - reason: e.to_string(), + // First try bollard defaults (checks DOCKER_HOST, then /var/run/docker.sock) + if let Ok(docker) = Docker::connect_with_local_defaults() { + if docker.ping().await.is_ok() { + return Ok(docker); + } + } + + // Try Docker Desktop socket (macOS) + if let Some(home) = std::env::var_os("HOME") { + let desktop_sock = std::path::Path::new(&home).join(".docker/run/docker.sock"); + if desktop_sock.exists() { + let sock_str = desktop_sock.to_string_lossy(); + if let Ok(docker) = + Docker::connect_with_socket(&sock_str, 120, bollard::API_DEFAULT_VERSION) + { + if docker.ping().await.is_ok() { + return Ok(docker); + } + } + } + } + + Err(SandboxError::DockerNotAvailable { + reason: "Socket not found: /var/run/docker.sock".to_string(), }) } diff --git a/src/settings.rs b/src/settings.rs index 4f8cda30..59165c55 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -149,6 +149,11 @@ pub struct ChannelSettings { #[serde(default)] pub http_host: Option, + /// Telegram owner user ID. When set, the bot only responds to this user. + /// Captured during setup by having the user message the bot. + #[serde(default)] + pub telegram_owner_id: Option, + /// Enabled WASM channels by name. /// Channels not in this list but present in the channels directory will still load. /// This is primarily used by the setup wizard to track which channels were configured. @@ -490,6 +495,51 @@ impl Settings { .join("settings.json") } + /// Reconstruct Settings from a flat key-value map (as stored in the DB). + /// + /// Each key is a dotted path (e.g., "agent.name"), value is a JSONB value. + /// Missing keys get their default value. + pub fn from_db_map(map: &std::collections::HashMap) -> Self { + // Start with defaults, then overlay each DB setting + let mut settings = Self::default(); + + for (key, value) in map { + // Convert the JSONB value to a string for the existing set() method + let value_str = match value { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Null => "null".to_string(), + other => other.to_string(), + }; + + if let Err(e) = settings.set(key, &value_str) { + tracing::warn!( + "Failed to apply DB setting '{}' = '{}': {}", + key, + value_str, + e + ); + } + } + + settings + } + + /// Flatten Settings into a key-value map suitable for DB storage. + /// + /// Each entry is a (dotted_path, JSONB value) pair. + pub fn to_db_map(&self) -> std::collections::HashMap { + let json = match serde_json::to_value(self) { + Ok(v) => v, + Err(_) => return std::collections::HashMap::new(), + }; + + let mut map = std::collections::HashMap::new(); + collect_settings_json(&json, String::new(), &mut map); + map + } + /// Load settings from disk, returning default if not found. pub fn load() -> Self { Self::load_from(&Self::default_path()) @@ -656,6 +706,29 @@ impl Settings { } } +/// Recursively collect settings paths with their JSON values (for DB storage). +fn collect_settings_json( + value: &serde_json::Value, + prefix: String, + results: &mut std::collections::HashMap, +) { + match value { + serde_json::Value::Object(obj) => { + for (key, val) in obj { + let path = if prefix.is_empty() { + key.clone() + } else { + format!("{}.{}", prefix, key) + }; + collect_settings_json(val, path, results); + } + } + other => { + results.insert(prefix, other.clone()); + } + } +} + /// Recursively collect settings paths and values. fn collect_settings( value: &serde_json::Value, @@ -799,4 +872,32 @@ mod tests { assert_eq!(settings.embeddings.provider, "nearai"); assert_eq!(settings.embeddings.model, "text-embedding-3-small"); } + + #[test] + fn test_telegram_owner_id_round_trip() { + let dir = tempdir().unwrap(); + let path = dir.path().join("settings.json"); + + let mut settings = Settings::default(); + settings.channels.telegram_owner_id = Some(123456789); + settings.save_to(&path).unwrap(); + + let loaded = Settings::load_from(&path); + assert_eq!(loaded.channels.telegram_owner_id, Some(123456789)); + } + + #[test] + fn test_telegram_owner_id_default_none() { + let settings = Settings::default(); + assert_eq!(settings.channels.telegram_owner_id, None); + } + + #[test] + fn test_telegram_owner_id_via_set() { + let mut settings = Settings::default(); + settings + .set("channels.telegram_owner_id", "987654321") + .unwrap(); + assert_eq!(settings.channels.telegram_owner_id, Some(987654321)); + } } diff --git a/src/setup/channels.rs b/src/setup/channels.rs index f99081a9..7728ff0d 100644 --- a/src/setup/channels.rs +++ b/src/setup/channels.rs @@ -52,6 +52,16 @@ impl SecretsContext { .await .unwrap_or(false) } + + /// Read a secret from the database (decrypted). + pub async fn get_secret(&self, name: &str) -> Result { + let decrypted = self + .store + .get_decrypted(&self.user_id, name) + .await + .map_err(|e| format!("Failed to read secret: {}", e))?; + Ok(SecretString::from(decrypted.expose().to_string())) + } } /// Result of Telegram setup. @@ -60,6 +70,7 @@ pub struct TelegramSetupResult { pub enabled: bool, pub bot_username: Option, pub webhook_secret: Option, + pub owner_id: Option, } /// Telegram Bot API response for getMe. @@ -76,6 +87,32 @@ struct TelegramUser { first_name: String, } +/// Telegram Bot API response for getUpdates. +#[derive(Debug, Deserialize)] +struct TelegramGetUpdatesResponse { + ok: bool, + result: Vec, +} + +#[derive(Debug, Deserialize)] +struct TelegramUpdate { + #[allow(dead_code)] + update_id: i64, + message: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramUpdateMessage { + from: Option, +} + +#[derive(Debug, Deserialize)] +struct TelegramUpdateUser { + id: i64, + first_name: String, + username: Option, +} + /// Set up Telegram bot channel. /// /// Guides the user through: @@ -96,12 +133,14 @@ pub async fn setup_telegram(secrets: &SecretsContext) -> Result Result Result { @@ -141,12 +184,128 @@ pub async fn setup_telegram(secrets: &SecretsContext) -> Result Result, String> { + println!(); + print_info("Account Binding (recommended):"); + print_info("Binding restricts the bot so only YOU can use it."); + print_info("Without this, anyone who finds your bot can send it messages."); + println!(); + + if !confirm("Bind bot to your Telegram account?", true).map_err(|e| e.to_string())? { + print_info("Skipping account binding. Bot will accept messages from all users."); + return Ok(None); + } + + print_info("Send any message (e.g. /start) to your bot in Telegram."); + print_info("Waiting for your message (up to 120 seconds)..."); + + let client = Client::builder() + .timeout(std::time::Duration::from_secs(35)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + // Clear any existing webhook so getUpdates works + let delete_url = format!( + "https://api.telegram.org/bot{}/deleteWebhook", + token.expose_secret() + ); + let _ = client.post(&delete_url).send().await; + + let updates_url = format!( + "https://api.telegram.org/bot{}/getUpdates", + token.expose_secret() + ); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120); + + while std::time::Instant::now() < deadline { + let response = client + .get(&updates_url) + .query(&[("timeout", "30"), ("allowed_updates", "[\"message\"]")]) + .send() + .await + .map_err(|e| format!("getUpdates request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("getUpdates returned status {}", response.status())); + } + + let body: TelegramGetUpdatesResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse getUpdates response: {}", e))?; + + if !body.ok { + return Err("Telegram API returned error for getUpdates".to_string()); + } + + // Find the first message with a sender + for update in &body.result { + if let Some(ref msg) = update.message { + if let Some(ref from) = msg.from { + let display_name = from + .username + .as_ref() + .map(|u| format!("@{}", u)) + .unwrap_or_else(|| from.first_name.clone()); + + print_success(&format!( + "Received message from {} (ID: {})", + display_name, from.id + )); + + // Acknowledge the update so it doesn't pile up + let ack_url = format!( + "https://api.telegram.org/bot{}/getUpdates", + token.expose_secret() + ); + let _ = client + .get(&ack_url) + .query(&[("offset", &(update.update_id + 1).to_string())]) + .send() + .await; + + return Ok(Some(from.id)); + } + } + } + } + + print_error("Timed out waiting for a message. You can re-run setup to try again."); + print_info("Bot will accept messages from all users until owner is bound."); + Ok(None) +} + +/// Bind flow when the token already exists (reads from secrets store). +/// +/// Retrieves the saved bot token and delegates to `bind_telegram_owner`. +async fn bind_telegram_owner_flow(secrets: &SecretsContext) -> Result, String> { + // Check current settings first + let settings = Settings::load(); + if settings.channels.telegram_owner_id.is_some() { + print_info("Bot is already bound to a Telegram account."); + if !confirm("Re-bind to a different account?", false).map_err(|e| e.to_string())? { + return Ok(settings.channels.telegram_owner_id); + } + } + + // We need the token to poll getUpdates + let token = secrets.get_secret("telegram_bot_token").await?; + + bind_telegram_owner(&token).await +} + /// Set up a tunnel for exposing the agent to the internet. /// /// This is shared across all channels that need webhook endpoints. diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index f4ed590f..adf76d25 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -17,7 +17,7 @@ use secrecy::SecretString; use tokio_postgres::NoTls; use crate::channels::wasm::{ - ChannelCapabilitiesFile, bundled_channel_names, install_bundled_channel, + ChannelCapabilitiesFile, available_channel_names, install_bundled_channel, }; use crate::llm::{SessionConfig, SessionManager}; use crate::secrets::SecretsCrypto; @@ -689,6 +689,9 @@ impl SetupWizard { } else if channel_name == "telegram" { let telegram_result = setup_telegram(ctx).await.map_err(SetupError::Channel)?; + if let Some(owner_id) = telegram_result.owner_id { + self.settings.channels.telegram_owner_id = Some(owner_id); + } crate::setup::channels::WasmChannelSetupResult { enabled: telegram_result.enabled, channel_name: "telegram".to_string(), @@ -978,7 +981,7 @@ async fn install_missing_bundled_channels( ) -> Result, SetupError> { let mut installed = Vec::new(); - for name in bundled_channel_names().iter().copied() { + for name in available_channel_names().iter().copied() { if already_installed.contains(name) { continue; } @@ -995,7 +998,7 @@ async fn install_missing_bundled_channels( fn wasm_channel_option_names(discovered: &[(String, ChannelCapabilitiesFile)]) -> Vec { let mut names: Vec = discovered.iter().map(|(name, _)| name.clone()).collect(); - for bundled in bundled_channel_names().iter().copied() { + for bundled in available_channel_names().iter().copied() { if !names.iter().any(|name| name == bundled) { names.push(bundled.to_string()); } @@ -1009,7 +1012,7 @@ async fn install_selected_bundled_channels( selected_channels: &[String], already_installed: &HashSet, ) -> Result>, SetupError> { - let bundled: HashSet<&str> = bundled_channel_names().iter().copied().collect(); + let bundled: HashSet<&str> = available_channel_names().iter().copied().collect(); let selected_missing: HashSet = selected_channels .iter() .filter(|name| bundled.contains(name.as_str()) && !already_installed.contains(*name)) @@ -1092,16 +1095,29 @@ mod tests { } #[test] - fn test_wasm_channel_option_names_includes_bundled_when_missing() { + fn test_wasm_channel_option_names_includes_available_when_missing() { let discovered = Vec::new(); let options = wasm_channel_option_names(&discovered); - assert_eq!(options, vec!["telegram".to_string()]); + let available = available_channel_names(); + // All available (built) channels should appear + for name in &available { + assert!( + options.contains(&name.to_string()), + "expected '{}' in options", + name + ); + } } #[test] - fn test_wasm_channel_option_names_dedupes_bundled() { + fn test_wasm_channel_option_names_dedupes_available() { let discovered = vec![(String::from("telegram"), ChannelCapabilitiesFile::default())]; let options = wasm_channel_option_names(&discovered); - assert_eq!(options, vec!["telegram".to_string()]); + // telegram should appear exactly once despite being both discovered and available + assert_eq!( + options.iter().filter(|n| *n == "telegram").count(), + 1, + "telegram should not be duplicated" + ); } } diff --git a/src/tools/builder/core.rs b/src/tools/builder/core.rs index e46c8aaa..5f71b8dc 100644 --- a/src/tools/builder/core.rs +++ b/src/tools/builder/core.rs @@ -689,9 +689,20 @@ Create alongside the .wasm file to grant capabilities: .messages .push(ChatMessage::user("Continue with the next step.")); } - RespondResult::ToolCalls(tool_calls) => { + RespondResult::ToolCalls { + tool_calls, + content, + } => { tools_executed = true; + // Add assistant message with tool_calls (OpenAI protocol) + reason_ctx + .messages + .push(ChatMessage::assistant_with_tool_calls( + content, + tool_calls.clone(), + )); + // Execute each tool call for tc in tool_calls { logs.push(BuildLog { diff --git a/src/tools/builtin/extension_tools.rs b/src/tools/builtin/extension_tools.rs index 6b4ca532..c707fd69 100644 --- a/src/tools/builtin/extension_tools.rs +++ b/src/tools/builtin/extension_tools.rs @@ -185,8 +185,9 @@ impl Tool for ToolAuthTool { } fn description(&self) -> &str { - "Authenticate an installed extension. For MCP servers, starts OAuth flow. \ - For WASM tools with manual auth, returns instructions; call again with token param to complete." + "Initiate authentication for an extension. For OAuth, returns a URL. \ + For manual auth, returns instructions. The user provides their token \ + through a secure channel, never through this tool." } fn parameters_schema(&self) -> serde_json::Value { @@ -196,10 +197,6 @@ impl Tool for ToolAuthTool { "name": { "type": "string", "description": "Extension name to authenticate" - }, - "token": { - "type": "string", - "description": "API token/key for manual auth (WASM tools). Provide after user gives you the token." } }, "required": ["name"] @@ -218,11 +215,9 @@ impl Tool for ToolAuthTool { .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?; - let token = params.get("token").and_then(|v| v.as_str()); - let result = self .manager - .auth(name, token) + .auth(name, None) .await .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; @@ -316,16 +311,53 @@ impl Tool for ToolActivateTool { .and_then(|v| v.as_str()) .ok_or_else(|| ToolError::InvalidParameters("name is required".to_string()))?; - let result = self - .manager - .activate(name) - .await - .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + match self.manager.activate(name).await { + Ok(result) => { + let output = serde_json::to_value(&result) + .unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"})); + Ok(ToolOutput::success(output, start.elapsed())) + } + Err(activate_err) => { + let err_str = activate_err.to_string(); + let needs_auth = err_str.contains("authentication") + || err_str.contains("401") + || err_str.contains("Unauthorized") + || err_str.contains("not authenticated"); - let output = serde_json::to_value(&result) - .unwrap_or_else(|_| serde_json::json!({"error": "serialization failed"})); + if !needs_auth { + return Err(ToolError::ExecutionFailed(err_str)); + } - Ok(ToolOutput::success(output, start.elapsed())) + // Activation failed due to missing auth; initiate auth flow + // so the agent loop can show the auth card. + match self.manager.auth(name, None).await { + Ok(auth_result) if auth_result.status == "authenticated" => { + // Auth succeeded (e.g. env var was set); retry activation. + let result = self + .manager + .activate(name) + .await + .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; + let output = serde_json::to_value(&result).unwrap_or_else( + |_| serde_json::json!({"error": "serialization failed"}), + ); + Ok(ToolOutput::success(output, start.elapsed())) + } + Ok(auth_result) => { + // Auth needs user input (awaiting_token). Return the auth + // result so detect_auth_awaiting picks it up. + let output = serde_json::to_value(&auth_result).unwrap_or_else( + |_| serde_json::json!({"error": "serialization failed"}), + ); + Ok(ToolOutput::success(output, start.elapsed())) + } + Err(auth_err) => Err(ToolError::ExecutionFailed(format!( + "Activation failed ({}), and authentication also failed: {}", + err_str, auth_err + ))), + } + } + } } } @@ -499,7 +531,11 @@ mod tests { assert!(tool.requires_approval()); let schema = tool.parameters_schema(); assert!(schema["properties"].get("name").is_some()); - assert!(schema["properties"].get("token").is_some()); + // token param must NOT be in schema (security: tokens never go through LLM) + assert!( + schema["properties"].get("token").is_none(), + "tool_auth must not have a token parameter" + ); } #[test] @@ -550,6 +586,7 @@ mod tests { std::path::PathBuf::from("/tmp/ironclaw-test-channels"), None, "test".to_string(), + None, )) } } diff --git a/src/tools/builtin/file.rs b/src/tools/builtin/file.rs index e72df9f8..14b76677 100644 --- a/src/tools/builtin/file.rs +++ b/src/tools/builtin/file.rs @@ -11,7 +11,7 @@ use async_trait::async_trait; use tokio::fs; use crate::context::JobContext; -use crate::tools::tool::{Tool, ToolError, ToolOutput}; +use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput}; use crate::workspace::paths as ws_paths; /// Well-known workspace filenames that must go through memory_write, not write_file. @@ -246,6 +246,10 @@ impl Tool for ReadFileTool { fn requires_approval(&self) -> bool { true // Reading local files should require approval } + + fn domain(&self) -> ToolDomain { + ToolDomain::Container + } } /// Write file contents tool. @@ -359,6 +363,10 @@ impl Tool for WriteFileTool { fn requires_sanitization(&self) -> bool { false // We're writing, not reading external data } + + fn domain(&self) -> ToolDomain { + ToolDomain::Container + } } /// List directory contents tool. @@ -467,6 +475,10 @@ impl Tool for ListDirTool { fn requires_approval(&self) -> bool { true // Directory listings can leak filesystem structure } + + fn domain(&self) -> ToolDomain { + ToolDomain::Container + } } /// Recursively list directory contents. @@ -685,6 +697,10 @@ impl Tool for ApplyPatchTool { fn requires_sanitization(&self) -> bool { false // We're writing, not reading external data } + + fn domain(&self) -> ToolDomain { + ToolDomain::Container + } } #[cfg(test)] diff --git a/src/tools/builtin/job.rs b/src/tools/builtin/job.rs index 7f6663c9..20fda2ad 100644 --- a/src/tools/builtin/job.rs +++ b/src/tools/builtin/job.rs @@ -1,77 +1,108 @@ //! Job management tools. //! //! These tools allow the LLM to manage jobs: -//! - Create new jobs/tasks +//! - Create new jobs/tasks (with optional sandbox delegation) //! - List existing jobs //! - Check job status //! - Cancel running jobs +use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use async_trait::async_trait; +use chrono::Utc; use uuid::Uuid; use crate::context::{ContextManager, JobContext, JobState}; +use crate::history::{SandboxJobRecord, Store}; +use crate::orchestrator::job_manager::{ContainerJobManager, JobMode}; use crate::tools::tool::{Tool, ToolError, ToolOutput}; /// Tool for creating a new job. +/// +/// When sandbox deps are injected (via `with_sandbox`), the tool automatically +/// delegates execution to a Docker container. Otherwise it creates an in-memory +/// job via the ContextManager. The LLM never needs to know the difference. pub struct CreateJobTool { context_manager: Arc, + job_manager: Option>, + store: Option>, } impl CreateJobTool { pub fn new(context_manager: Arc) -> Self { - Self { context_manager } - } -} - -#[async_trait] -impl Tool for CreateJobTool { - fn name(&self) -> &str { - "create_job" + Self { + context_manager, + job_manager: None, + store: None, + } } - fn description(&self) -> &str { - "Create a new job or task for the agent to work on. Use this when the user wants \ - you to do something substantial that should be tracked as a separate job." + /// Inject sandbox dependencies so `create_job` delegates to Docker containers. + pub fn with_sandbox( + mut self, + job_manager: Arc, + store: Option>, + ) -> Self { + self.job_manager = Some(job_manager); + self.store = store; + self } - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "A short title for the job (max 100 chars)" - }, - "description": { - "type": "string", - "description": "Full description of what needs to be done" + fn sandbox_enabled(&self) -> bool { + self.job_manager.is_some() + } + + /// Persist a sandbox job record (fire-and-forget). + fn persist_job(&self, record: SandboxJobRecord) { + if let Some(store) = self.store.clone() { + tokio::spawn(async move { + if let Err(e) = store.save_sandbox_job(&record).await { + tracing::warn!(job_id = %record.id, "Failed to persist sandbox job: {}", e); } - }, - "required": ["title", "description"] - }) + }); + } } - async fn execute( + /// Update sandbox job status in DB (fire-and-forget). + fn update_status( &self, - params: serde_json::Value, + job_id: Uuid, + status: &str, + success: Option, + message: Option, + started_at: Option>, + completed_at: Option>, + ) { + if let Some(store) = self.store.clone() { + let status = status.to_string(); + tokio::spawn(async move { + if let Err(e) = store + .update_sandbox_job_status( + job_id, + &status, + success, + message.as_deref(), + started_at, + completed_at, + ) + .await + { + tracing::warn!(job_id = %job_id, "Failed to update sandbox job status: {}", e); + } + }); + } + } + + /// Execute via in-memory ContextManager (no sandbox). + async fn execute_local( + &self, + title: &str, + description: &str, ctx: &JobContext, ) -> Result { let start = std::time::Instant::now(); - - let title = params - .get("title") - .and_then(|v| v.as_str()) - .ok_or_else(|| ToolError::InvalidParameters("missing 'title' parameter".into()))?; - - let description = params - .get("description") - .and_then(|v| v.as_str()) - .ok_or_else(|| { - ToolError::InvalidParameters("missing 'description' parameter".into()) - })?; - match self .context_manager .create_job_for_user(&ctx.user_id, title, description) @@ -95,6 +126,374 @@ impl Tool for CreateJobTool { } } + /// Execute via sandboxed Docker container. + async fn execute_sandbox( + &self, + task: &str, + explicit_dir: Option, + wait: bool, + mode: JobMode, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + let jm = self.job_manager.as_ref().expect("sandbox deps required"); + + let job_id = Uuid::new_v4(); + let (project_dir, browse_id) = resolve_project_dir(explicit_dir, job_id)?; + let project_dir_str = project_dir.display().to_string(); + + // Persist the job to DB before creating the container. + self.persist_job(SandboxJobRecord { + id: job_id, + task: task.to_string(), + status: "creating".to_string(), + user_id: ctx.user_id.clone(), + project_dir: project_dir_str.clone(), + success: None, + failure_reason: None, + created_at: Utc::now(), + started_at: None, + completed_at: None, + }); + + // Persist the job mode to DB + if mode == JobMode::ClaudeCode { + if let Some(store) = self.store.clone() { + let job_id_copy = job_id; + tokio::spawn(async move { + if let Err(e) = store + .update_sandbox_job_mode(job_id_copy, "claude_code") + .await + { + tracing::warn!(job_id = %job_id_copy, "Failed to set job mode: {}", e); + } + }); + } + } + + // Create the container job with the pre-determined job_id. + let _token = jm + .create_job(job_id, task, Some(project_dir), mode) + .await + .map_err(|e| { + self.update_status( + job_id, + "failed", + Some(false), + Some(e.to_string()), + None, + Some(Utc::now()), + ); + ToolError::ExecutionFailed(format!("failed to create container: {}", e)) + })?; + + // Container started successfully. + let now = Utc::now(); + self.update_status(job_id, "running", None, None, Some(now), None); + + if !wait { + let result = serde_json::json!({ + "job_id": job_id.to_string(), + "status": "started", + "message": "Container started. Use job tools to check status.", + "project_dir": project_dir_str, + "browse_url": format!("/projects/{}", browse_id), + }); + return Ok(ToolOutput::success(result, start.elapsed())); + } + + // Wait for completion by polling the container state. + let timeout = Duration::from_secs(600); + let poll_interval = Duration::from_secs(2); + let deadline = tokio::time::Instant::now() + timeout; + + loop { + if tokio::time::Instant::now() > deadline { + let _ = jm.stop_job(job_id).await; + jm.cleanup_job(job_id).await; + self.update_status( + job_id, + "failed", + Some(false), + Some("Timed out (10 minutes)".to_string()), + None, + Some(Utc::now()), + ); + return Err(ToolError::ExecutionFailed( + "container execution timed out (10 minutes)".to_string(), + )); + } + + match jm.get_handle(job_id).await { + Some(handle) => match handle.state { + crate::orchestrator::job_manager::ContainerState::Running + | crate::orchestrator::job_manager::ContainerState::Creating => { + tokio::time::sleep(poll_interval).await; + } + crate::orchestrator::job_manager::ContainerState::Stopped => { + let message = handle + .completion_result + .as_ref() + .and_then(|r| r.message.clone()) + .unwrap_or_else(|| "Container job completed".to_string()); + let success = handle + .completion_result + .as_ref() + .map(|r| r.success) + .unwrap_or(true); + jm.cleanup_job(job_id).await; + + let finished_at = Utc::now(); + if success { + self.update_status( + job_id, + "completed", + Some(true), + None, + None, + Some(finished_at), + ); + let result = serde_json::json!({ + "job_id": job_id.to_string(), + "status": "completed", + "output": message, + "project_dir": project_dir_str, + "browse_url": format!("/projects/{}", browse_id), + }); + return Ok(ToolOutput::success(result, start.elapsed())); + } else { + self.update_status( + job_id, + "failed", + Some(false), + Some(message.clone()), + None, + Some(finished_at), + ); + return Err(ToolError::ExecutionFailed(format!( + "container job failed: {}", + message + ))); + } + } + crate::orchestrator::job_manager::ContainerState::Failed => { + let message = handle + .completion_result + .as_ref() + .and_then(|r| r.message.clone()) + .unwrap_or_else(|| "unknown failure".to_string()); + jm.cleanup_job(job_id).await; + self.update_status( + job_id, + "failed", + Some(false), + Some(message.clone()), + None, + Some(Utc::now()), + ); + return Err(ToolError::ExecutionFailed(format!( + "container job failed: {}", + message + ))); + } + }, + None => { + self.update_status( + job_id, + "completed", + Some(true), + None, + None, + Some(Utc::now()), + ); + let result = serde_json::json!({ + "job_id": job_id.to_string(), + "status": "completed", + "output": "Container job completed", + "project_dir": project_dir_str, + "browse_url": format!("/projects/{}", browse_id), + }); + return Ok(ToolOutput::success(result, start.elapsed())); + } + } + } + } +} + +/// The base directory where all project directories must live. +fn projects_base() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".ironclaw") + .join("projects") +} + +/// Resolve the project directory, creating it if it doesn't exist. +/// +/// Auto-creates `~/.ironclaw/projects/{project_id}/` so every sandbox job has a +/// persistent bind mount that survives container teardown. +/// +/// When an explicit path is provided (e.g. job restarts reusing the old dir), +/// it is validated to fall within `~/.ironclaw/projects/` after canonicalization. +fn resolve_project_dir( + explicit: Option, + project_id: Uuid, +) -> Result<(PathBuf, String), ToolError> { + let base = projects_base(); + std::fs::create_dir_all(&base).map_err(|e| { + ToolError::ExecutionFailed(format!( + "failed to create projects base {}: {}", + base.display(), + e + )) + })?; + let canonical_base = base.canonicalize().map_err(|e| { + ToolError::ExecutionFailed(format!("failed to canonicalize projects base: {}", e)) + })?; + + let dir = match explicit { + Some(d) => d, + None => canonical_base.join(project_id.to_string()), + }; + + std::fs::create_dir_all(&dir).map_err(|e| { + ToolError::ExecutionFailed(format!( + "failed to create project dir {}: {}", + dir.display(), + e + )) + })?; + + // Canonicalize resolves symlinks, `..`, etc. so we can do a reliable prefix check. + let canonical_dir = dir.canonicalize().map_err(|e| { + ToolError::ExecutionFailed(format!( + "failed to canonicalize project dir {}: {}", + dir.display(), + e + )) + })?; + + if !canonical_dir.starts_with(&canonical_base) { + return Err(ToolError::InvalidParameters(format!( + "project directory must be under {}", + canonical_base.display() + ))); + } + + let browse_id = canonical_dir + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| project_id.to_string()); + Ok((canonical_dir, browse_id)) +} + +#[async_trait] +impl Tool for CreateJobTool { + fn name(&self) -> &str { + "create_job" + } + + fn description(&self) -> &str { + if self.sandbox_enabled() { + "Create and execute a job. The job runs in a sandboxed Docker container with its own \ + sub-agent that has shell, file read/write, list_dir, and apply_patch tools. Use this \ + whenever the user asks you to build, create, or work on something. The task \ + description should be detailed enough for the sub-agent to work independently. \ + Set wait=false to start immediately while continuing the conversation. Set mode \ + to 'claude_code' for complex software engineering tasks." + } else { + "Create a new job or task for the agent to work on. Use this when the user wants \ + you to do something substantial that should be tracked as a separate job." + } + } + + fn parameters_schema(&self) -> serde_json::Value { + if self.sandbox_enabled() { + serde_json::json!({ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Clear description of what to accomplish" + }, + "description": { + "type": "string", + "description": "Full description of what needs to be done" + }, + "wait": { + "type": "boolean", + "description": "If true (default), wait for the container to complete and return results. \ + If false, start the container and return the job_id immediately." + }, + "mode": { + "type": "string", + "enum": ["worker", "claude_code"], + "description": "Execution mode. 'worker' (default) uses the IronClaw sub-agent. \ + 'claude_code' uses Claude Code CLI for full agentic software engineering." + } + }, + "required": ["title", "description"] + }) + } else { + serde_json::json!({ + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "A short title for the job (max 100 chars)" + }, + "description": { + "type": "string", + "description": "Full description of what needs to be done" + } + }, + "required": ["title", "description"] + }) + } + } + + fn execution_timeout(&self) -> Duration { + if self.sandbox_enabled() { + // Sandbox polls for up to 10 min internally; give an extra 60s buffer. + Duration::from_secs(660) + } else { + Duration::from_secs(30) + } + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let title = params + .get("title") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing 'title' parameter".into()))?; + + let description = params + .get("description") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters("missing 'description' parameter".into()) + })?; + + if self.sandbox_enabled() { + let wait = params.get("wait").and_then(|v| v.as_bool()).unwrap_or(true); + + let mode = match params.get("mode").and_then(|v| v.as_str()) { + Some("claude_code") => JobMode::ClaudeCode, + _ => JobMode::Worker, + }; + + // Combine title and description into the task prompt for the sub-agent. + let task = format!("{}\n\n{}", title, description); + self.execute_sandbox(&task, None, wait, mode, ctx).await + } else { + self.execute_local(title, description, ctx).await + } + } + fn requires_sanitization(&self) -> bool { false } @@ -377,10 +776,13 @@ mod tests { use super::*; #[tokio::test] - async fn test_create_job_tool() { + async fn test_create_job_tool_local() { let manager = Arc::new(ContextManager::new(5)); let tool = CreateJobTool::new(manager.clone()); + // Without sandbox deps, it should use the local path + assert!(!tool.sandbox_enabled()); + let params = serde_json::json!({ "title": "Test Job", "description": "A test job description" @@ -391,6 +793,37 @@ mod tests { let job_id = result.result.get("job_id").unwrap().as_str().unwrap(); assert!(!job_id.is_empty()); + assert_eq!( + result.result.get("status").unwrap().as_str().unwrap(), + "pending" + ); + } + + #[test] + fn test_schema_changes_with_sandbox() { + let manager = Arc::new(ContextManager::new(5)); + + // Without sandbox + let tool = CreateJobTool::new(Arc::clone(&manager)); + let schema = tool.parameters_schema(); + let props = schema.get("properties").unwrap().as_object().unwrap(); + assert!(props.contains_key("title")); + assert!(props.contains_key("description")); + assert!( + !props.contains_key("project_dir"), + "project_dir must not be exposed to the LLM" + ); + assert!(!props.contains_key("wait")); + assert!(!props.contains_key("mode")); + } + + #[test] + fn test_execution_timeout_sandbox() { + let manager = Arc::new(ContextManager::new(5)); + + // Without sandbox: default timeout + let tool = CreateJobTool::new(Arc::clone(&manager)); + assert_eq!(tool.execution_timeout(), Duration::from_secs(30)); } #[tokio::test] @@ -429,4 +862,67 @@ mod tests { "Test Job" ); } + + #[test] + fn test_resolve_project_dir_auto() { + let project_id = Uuid::new_v4(); + let (dir, browse_id) = resolve_project_dir(None, project_id).unwrap(); + assert!(dir.exists()); + assert!(dir.ends_with(project_id.to_string())); + assert_eq!(browse_id, project_id.to_string()); + + // Must be under the projects base + let base = projects_base().canonicalize().unwrap(); + assert!(dir.starts_with(&base)); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn test_resolve_project_dir_explicit_under_base() { + let base = projects_base(); + std::fs::create_dir_all(&base).unwrap(); + let explicit = base.join("test_explicit_project"); + let project_id = Uuid::new_v4(); + + let (dir, browse_id) = resolve_project_dir(Some(explicit.clone()), project_id).unwrap(); + assert!(dir.exists()); + assert_eq!(browse_id, "test_explicit_project"); + + let canonical_base = base.canonicalize().unwrap(); + assert!(dir.starts_with(&canonical_base)); + + let _ = std::fs::remove_dir_all(&explicit); + } + + #[test] + fn test_resolve_project_dir_rejects_outside_base() { + let tmp = tempfile::tempdir().unwrap(); + let escape_attempt = tmp.path().join("evil_project"); + + let result = resolve_project_dir(Some(escape_attempt), Uuid::new_v4()); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("must be under"), + "expected 'must be under' error, got: {}", + err + ); + } + + #[test] + fn test_resolve_project_dir_rejects_traversal() { + // Attempt to escape via `..` components + let base = projects_base(); + let traversal = base.join("legit").join("..").join("..").join(".ssh"); + + let result = resolve_project_dir(Some(traversal), Uuid::new_v4()); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("must be under"), + "expected 'must be under' error, got: {}", + err + ); + } } diff --git a/src/tools/builtin/mod.rs b/src/tools/builtin/mod.rs index 46aee56b..c834aa9d 100644 --- a/src/tools/builtin/mod.rs +++ b/src/tools/builtin/mod.rs @@ -10,6 +10,7 @@ mod json; mod marketplace; mod memory; mod restaurant; +pub mod routine; mod shell; mod taskrabbit; mod time; @@ -26,6 +27,9 @@ pub use json::JsonTool; pub use marketplace::MarketplaceTool; pub use memory::{MemoryReadTool, MemorySearchTool, MemoryTreeTool, MemoryWriteTool}; pub use restaurant::RestaurantTool; +pub use routine::{ + RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, RoutineUpdateTool, +}; pub use shell::ShellTool; pub use taskrabbit::TaskRabbitTool; pub use time::TimeTool; diff --git a/src/tools/builtin/routine.rs b/src/tools/builtin/routine.rs new file mode 100644 index 00000000..decf7357 --- /dev/null +++ b/src/tools/builtin/routine.rs @@ -0,0 +1,654 @@ +//! LLM-facing tools for managing routines. +//! +//! Five tools let the agent manage routines conversationally: +//! - `routine_create` - Create a new routine +//! - `routine_list` - List all routines with status +//! - `routine_update` - Modify or toggle a routine +//! - `routine_delete` - Remove a routine +//! - `routine_history` - View past runs + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use chrono::Utc; +use uuid::Uuid; + +use crate::agent::routine::{ + NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire, +}; +use crate::agent::routine_engine::RoutineEngine; +use crate::context::JobContext; +use crate::history::Store; +use crate::tools::tool::{Tool, ToolError, ToolOutput}; + +// ==================== routine_create ==================== + +pub struct RoutineCreateTool { + store: Arc, + engine: Arc, +} + +impl RoutineCreateTool { + pub fn new(store: Arc, engine: Arc) -> Self { + Self { store, engine } + } +} + +#[async_trait] +impl Tool for RoutineCreateTool { + fn name(&self) -> &str { + "routine_create" + } + + fn description(&self) -> &str { + "Create a new routine (scheduled or event-driven task). \ + Supports cron schedules, event pattern matching, webhooks, and manual triggers. \ + Use this when the user wants something to happen periodically or reactively." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Unique name for the routine (e.g. 'daily-pr-review')" + }, + "description": { + "type": "string", + "description": "What this routine does" + }, + "trigger_type": { + "type": "string", + "enum": ["cron", "event", "webhook", "manual"], + "description": "When the routine fires" + }, + "schedule": { + "type": "string", + "description": "Cron expression (for cron trigger). E.g. '0 9 * * MON-FRI' for weekdays at 9am. Uses 6-field cron (sec min hour day month weekday)." + }, + "event_pattern": { + "type": "string", + "description": "Regex pattern to match messages (for event trigger)" + }, + "event_channel": { + "type": "string", + "description": "Optional channel filter for event trigger (e.g. 'telegram')" + }, + "prompt": { + "type": "string", + "description": "The prompt/instructions for the routine" + }, + "context_paths": { + "type": "array", + "items": { "type": "string" }, + "description": "Workspace paths to load as context (e.g. ['context/priorities.md'])" + }, + "action_type": { + "type": "string", + "enum": ["lightweight", "full_job"], + "description": "Execution mode: 'lightweight' (single LLM call, default) or 'full_job' (multi-turn with tools)" + }, + "cooldown_secs": { + "type": "integer", + "description": "Minimum seconds between fires (default: 300)" + } + }, + "required": ["name", "trigger_type", "prompt"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + + let name = params + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?; + + let description = params + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let trigger_type = params + .get("trigger_type") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing 'trigger_type'".to_string()))?; + + let prompt = params + .get("prompt") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing 'prompt'".to_string()))?; + + // Build trigger + let trigger = match trigger_type { + "cron" => { + let schedule = + params + .get("schedule") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters( + "cron trigger requires 'schedule'".to_string(), + ) + })?; + // Validate cron expression + next_cron_fire(schedule).map_err(|e| { + ToolError::InvalidParameters(format!("invalid cron schedule: {e}")) + })?; + Trigger::Cron { + schedule: schedule.to_string(), + } + } + "event" => { + let pattern = params + .get("event_pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ToolError::InvalidParameters( + "event trigger requires 'event_pattern'".to_string(), + ) + })?; + // Validate regex + regex::Regex::new(pattern) + .map_err(|e| ToolError::InvalidParameters(format!("invalid regex: {e}")))?; + let channel = params + .get("event_channel") + .and_then(|v| v.as_str()) + .map(String::from); + Trigger::Event { + channel, + pattern: pattern.to_string(), + } + } + "webhook" => Trigger::Webhook { + path: None, + secret: None, + }, + "manual" => Trigger::Manual, + other => { + return Err(ToolError::InvalidParameters(format!( + "unknown trigger_type: {other}" + ))); + } + }; + + // Build action + let action_type = params + .get("action_type") + .and_then(|v| v.as_str()) + .unwrap_or("lightweight"); + + let context_paths: Vec = params + .get("context_paths") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let action = match action_type { + "lightweight" => RoutineAction::Lightweight { + prompt: prompt.to_string(), + context_paths, + max_tokens: 4096, + }, + "full_job" => RoutineAction::FullJob { + title: name.to_string(), + description: prompt.to_string(), + max_iterations: 10, + }, + other => { + return Err(ToolError::InvalidParameters(format!( + "unknown action_type: {other}" + ))); + } + }; + + let cooldown_secs = params + .get("cooldown_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(300); + + // Compute next fire time for cron + let next_fire = if let Trigger::Cron { ref schedule } = trigger { + next_cron_fire(schedule).unwrap_or(None) + } else { + None + }; + + let routine = Routine { + id: Uuid::new_v4(), + name: name.to_string(), + description: description.to_string(), + user_id: ctx.user_id.clone(), + enabled: true, + trigger, + action, + guardrails: RoutineGuardrails { + cooldown: Duration::from_secs(cooldown_secs), + max_concurrent: 1, + dedup_window: None, + }, + notify: NotifyConfig::default(), + last_run_at: None, + next_fire_at: next_fire, + run_count: 0, + consecutive_failures: 0, + state: serde_json::json!({}), + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + self.store + .create_routine(&routine) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("failed to create routine: {e}")))?; + + // Refresh event cache if this is an event trigger + if routine.trigger.type_tag() == "event" { + self.engine.refresh_event_cache().await; + } + + let result = serde_json::json!({ + "id": routine.id.to_string(), + "name": routine.name, + "trigger_type": routine.trigger.type_tag(), + "next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()), + "status": "created", + }); + + Ok(ToolOutput::success(result, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} + +// ==================== routine_list ==================== + +pub struct RoutineListTool { + store: Arc, +} + +impl RoutineListTool { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl Tool for RoutineListTool { + fn name(&self) -> &str { + "routine_list" + } + + fn description(&self) -> &str { + "List all routines with their status, trigger info, and next fire time." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": {}, + "required": [] + }) + } + + async fn execute( + &self, + _params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + + let routines = self + .store + .list_routines(&ctx.user_id) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("failed to list routines: {e}")))?; + + let list: Vec = routines + .iter() + .map(|r| { + serde_json::json!({ + "id": r.id.to_string(), + "name": r.name, + "description": r.description, + "enabled": r.enabled, + "trigger_type": r.trigger.type_tag(), + "action_type": r.action.type_tag(), + "last_run_at": r.last_run_at.map(|t| t.to_rfc3339()), + "next_fire_at": r.next_fire_at.map(|t| t.to_rfc3339()), + "run_count": r.run_count, + "consecutive_failures": r.consecutive_failures, + }) + }) + .collect(); + + let result = serde_json::json!({ + "count": list.len(), + "routines": list, + }); + + Ok(ToolOutput::success(result, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} + +// ==================== routine_update ==================== + +pub struct RoutineUpdateTool { + store: Arc, + engine: Arc, +} + +impl RoutineUpdateTool { + pub fn new(store: Arc, engine: Arc) -> Self { + Self { store, engine } + } +} + +#[async_trait] +impl Tool for RoutineUpdateTool { + fn name(&self) -> &str { + "routine_update" + } + + fn description(&self) -> &str { + "Update an existing routine. Can modify trigger, prompt, schedule, or toggle enabled state. \ + Pass the routine name and only the fields you want to change." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the routine to update" + }, + "enabled": { + "type": "boolean", + "description": "Enable or disable the routine" + }, + "prompt": { + "type": "string", + "description": "New prompt/instructions" + }, + "schedule": { + "type": "string", + "description": "New cron schedule (for cron triggers)" + }, + "description": { + "type": "string", + "description": "New description" + } + }, + "required": ["name"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + + let name = params + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?; + + let mut routine = self + .store + .get_routine_by_name(&ctx.user_id, name) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))? + .ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?; + + // Apply updates + if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) { + routine.enabled = enabled; + } + + if let Some(desc) = params.get("description").and_then(|v| v.as_str()) { + routine.description = desc.to_string(); + } + + if let Some(prompt) = params.get("prompt").and_then(|v| v.as_str()) { + match &mut routine.action { + RoutineAction::Lightweight { prompt: p, .. } => *p = prompt.to_string(), + RoutineAction::FullJob { description: d, .. } => *d = prompt.to_string(), + } + } + + if let Some(schedule) = params.get("schedule").and_then(|v| v.as_str()) { + // Validate + next_cron_fire(schedule) + .map_err(|e| ToolError::InvalidParameters(format!("invalid cron schedule: {e}")))?; + + routine.trigger = Trigger::Cron { + schedule: schedule.to_string(), + }; + routine.next_fire_at = next_cron_fire(schedule).unwrap_or(None); + } + + self.store + .update_routine(&routine) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("failed to update: {e}")))?; + + // Refresh event cache in case trigger changed + self.engine.refresh_event_cache().await; + + let result = serde_json::json!({ + "name": routine.name, + "enabled": routine.enabled, + "trigger_type": routine.trigger.type_tag(), + "next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()), + "status": "updated", + }); + + Ok(ToolOutput::success(result, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} + +// ==================== routine_delete ==================== + +pub struct RoutineDeleteTool { + store: Arc, + engine: Arc, +} + +impl RoutineDeleteTool { + pub fn new(store: Arc, engine: Arc) -> Self { + Self { store, engine } + } +} + +#[async_trait] +impl Tool for RoutineDeleteTool { + fn name(&self) -> &str { + "routine_delete" + } + + fn description(&self) -> &str { + "Delete a routine permanently. This also removes all run history." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the routine to delete" + } + }, + "required": ["name"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + + let name = params + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?; + + let routine = self + .store + .get_routine_by_name(&ctx.user_id, name) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))? + .ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?; + + let deleted = self + .store + .delete_routine(routine.id) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("failed to delete: {e}")))?; + + // Refresh event cache + self.engine.refresh_event_cache().await; + + let result = serde_json::json!({ + "name": name, + "deleted": deleted, + }); + + Ok(ToolOutput::success(result, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} + +// ==================== routine_history ==================== + +pub struct RoutineHistoryTool { + store: Arc, +} + +impl RoutineHistoryTool { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl Tool for RoutineHistoryTool { + fn name(&self) -> &str { + "routine_history" + } + + fn description(&self) -> &str { + "View the execution history of a routine. Shows recent runs with status, duration, and results." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Name of the routine" + }, + "limit": { + "type": "integer", + "description": "Max runs to return (default: 10)", + "default": 10 + } + }, + "required": ["name"] + }) + } + + async fn execute( + &self, + params: serde_json::Value, + ctx: &JobContext, + ) -> Result { + let start = std::time::Instant::now(); + + let name = params + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| ToolError::InvalidParameters("missing 'name'".to_string()))?; + + let limit = params + .get("limit") + .and_then(|v| v.as_i64()) + .unwrap_or(10) + .min(50); + + let routine = self + .store + .get_routine_by_name(&ctx.user_id, name) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))? + .ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?; + + let runs = self + .store + .list_routine_runs(routine.id, limit) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("failed to list runs: {e}")))?; + + let run_list: Vec = runs + .iter() + .map(|r| { + let duration_secs = r + .completed_at + .map(|c| c.signed_duration_since(r.started_at).num_seconds()); + serde_json::json!({ + "id": r.id.to_string(), + "trigger_type": r.trigger_type, + "trigger_detail": r.trigger_detail, + "started_at": r.started_at.to_rfc3339(), + "completed_at": r.completed_at.map(|t| t.to_rfc3339()), + "duration_secs": duration_secs, + "status": r.status.to_string(), + "result_summary": r.result_summary, + "tokens_used": r.tokens_used, + }) + }) + .collect(); + + let result = serde_json::json!({ + "routine": name, + "total_runs": routine.run_count, + "runs": run_list, + }); + + Ok(ToolOutput::success(result, start.elapsed())) + } + + fn requires_sanitization(&self) -> bool { + false + } +} diff --git a/src/tools/builtin/shell.rs b/src/tools/builtin/shell.rs index 202a9109..a428f125 100644 --- a/src/tools/builtin/shell.rs +++ b/src/tools/builtin/shell.rs @@ -30,7 +30,7 @@ use tokio::process::Command; use crate::context::JobContext; use crate::sandbox::{SandboxManager, SandboxPolicy}; -use crate::tools::tool::{Tool, ToolError, ToolOutput}; +use crate::tools::tool::{Tool, ToolDomain, ToolError, ToolOutput}; /// Maximum output size before truncation (64KB). const MAX_OUTPUT_SIZE: usize = 64 * 1024; @@ -386,6 +386,10 @@ impl Tool for ShellTool { fn requires_sanitization(&self) -> bool { true // Shell output could contain anything } + + fn domain(&self) -> ToolDomain { + ToolDomain::Container + } } /// Truncate output to fit within limits. diff --git a/src/tools/mcp/config.rs b/src/tools/mcp/config.rs index 54893654..4eb3fc2f 100644 --- a/src/tools/mcp/config.rs +++ b/src/tools/mcp/config.rs @@ -327,6 +327,86 @@ pub async fn get_mcp_server(name: &str) -> Result }) } +// ==================== Database-backed MCP server config ==================== + +/// Load MCP server configurations from the database settings table. +/// +/// Falls back to the disk file if DB has no entry. +pub async fn load_mcp_servers_from_db( + store: &crate::history::Store, + user_id: &str, +) -> Result { + match store.get_setting(user_id, "mcp_servers").await { + Ok(Some(value)) => { + let config: McpServersFile = serde_json::from_value(value)?; + Ok(config) + } + Ok(None) => { + // No entry in DB, fall back to disk + load_mcp_servers().await + } + Err(e) => { + tracing::warn!( + "Failed to load MCP servers from DB: {}, falling back to disk", + e + ); + load_mcp_servers().await + } + } +} + +/// Save MCP server configurations to the database settings table. +pub async fn save_mcp_servers_to_db( + store: &crate::history::Store, + user_id: &str, + config: &McpServersFile, +) -> Result<(), ConfigError> { + let value = serde_json::to_value(config)?; + store + .set_setting(user_id, "mcp_servers", &value) + .await + .map_err(|e| { + ConfigError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )) + })?; + Ok(()) +} + +/// Add a new MCP server configuration (DB-backed). +pub async fn add_mcp_server_db( + store: &crate::history::Store, + user_id: &str, + config: McpServerConfig, +) -> Result<(), ConfigError> { + config.validate()?; + + let mut servers = load_mcp_servers_from_db(store, user_id).await?; + servers.upsert(config); + save_mcp_servers_to_db(store, user_id, &servers).await?; + + Ok(()) +} + +/// Remove an MCP server by name (DB-backed). +pub async fn remove_mcp_server_db( + store: &crate::history::Store, + user_id: &str, + name: &str, +) -> Result<(), ConfigError> { + let mut servers = load_mcp_servers_from_db(store, user_id).await?; + + if !servers.remove(name) { + return Err(ConfigError::ServerNotFound { + name: name.to_string(), + }); + } + + save_mcp_servers_to_db(store, user_id, &servers).await?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 5943c722..d26f79d3 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -23,4 +23,4 @@ pub use builder::{ }; pub use registry::ToolRegistry; pub use sandbox::ToolSandbox; -pub use tool::{Tool, ToolError, ToolOutput}; +pub use tool::{Tool, ToolDomain, ToolError, ToolOutput}; diff --git a/src/tools/registry.rs b/src/tools/registry.rs index acc038a7..845af8e2 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -7,7 +7,9 @@ use tokio::sync::RwLock; use crate::context::ContextManager; use crate::extensions::ExtensionManager; +use crate::history::Store; use crate::llm::{LlmProvider, ToolDefinition}; +use crate::orchestrator::job_manager::ContainerJobManager; use crate::safety::SafetyLayer; use crate::tools::builder::{BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder}; use crate::tools::builtin::{ @@ -16,7 +18,7 @@ use crate::tools::builtin::{ ReadFileTool, ShellTool, TimeTool, ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool, ToolRemoveTool, ToolSearchTool, WriteFileTool, }; -use crate::tools::tool::Tool; +use crate::tools::tool::{Tool, ToolDomain}; use crate::tools::wasm::{ Capabilities, ResourceLimits, WasmError, WasmStorageError, WasmToolRuntime, WasmToolStore, WasmToolWrapper, @@ -120,6 +122,39 @@ impl ToolRegistry { tracing::info!("Registered {} built-in tools", self.count()); } + /// Register only orchestrator-domain tools (safe for the main process). + /// + /// This registers tools that don't touch the filesystem or run shell commands: + /// echo, time, json, http. Use this when `allow_local_tools = false` and + /// container-domain tools should only be available inside sandboxed containers. + pub fn register_orchestrator_tools(&self) { + self.register_builtin_tools(); + // register_builtin_tools already only registers orchestrator-domain tools + } + + /// Register container-domain tools (filesystem, shell, code). + /// + /// These tools are intended to run inside sandboxed Docker containers. + /// Call this in the worker process, not the orchestrator (unless `allow_local_tools = true`). + pub fn register_container_tools(&self) { + self.register_dev_tools(); + } + + /// Get tool definitions filtered by domain. + pub async fn tool_definitions_for_domain(&self, domain: ToolDomain) -> Vec { + self.tools + .read() + .await + .values() + .filter(|tool| tool.domain() == domain) + .map(|tool| ToolDefinition { + name: tool.name().to_string(), + description: tool.description().to_string(), + parameters: tool.parameters_schema(), + }) + .collect() + } + /// Register development tools for building software. /// /// These tools provide shell access, file operations, and code editing @@ -151,9 +186,19 @@ impl ToolRegistry { /// Register job management tools. /// /// Job tools allow the LLM to create, list, check status, and cancel jobs. - /// These enable natural language job management without hardcoded intent parsing. - pub fn register_job_tools(&self, context_manager: Arc) { - self.register_sync(Arc::new(CreateJobTool::new(Arc::clone(&context_manager)))); + /// When sandbox deps are provided, `create_job` automatically delegates to + /// Docker containers. Otherwise it creates in-memory jobs via ContextManager. + pub fn register_job_tools( + &self, + context_manager: Arc, + job_manager: Option>, + store: Option>, + ) { + let mut create_tool = CreateJobTool::new(Arc::clone(&context_manager)); + if let Some(jm) = job_manager { + create_tool = create_tool.with_sandbox(jm, store); + } + self.register_sync(Arc::new(create_tool)); self.register_sync(Arc::new(ListJobsTool::new(Arc::clone(&context_manager)))); self.register_sync(Arc::new(JobStatusTool::new(Arc::clone(&context_manager)))); self.register_sync(Arc::new(CancelJobTool::new(context_manager))); @@ -174,6 +219,36 @@ impl ToolRegistry { tracing::info!("Registered 6 extension management tools"); } + /// Register routine management tools. + /// + /// These allow the LLM to create, list, update, delete, and view history + /// of routines (scheduled and event-driven tasks). + pub fn register_routine_tools( + &self, + store: Arc, + engine: Arc, + ) { + use crate::tools::builtin::{ + RoutineCreateTool, RoutineDeleteTool, RoutineHistoryTool, RoutineListTool, + RoutineUpdateTool, + }; + self.register_sync(Arc::new(RoutineCreateTool::new( + Arc::clone(&store), + Arc::clone(&engine), + ))); + self.register_sync(Arc::new(RoutineListTool::new(Arc::clone(&store)))); + self.register_sync(Arc::new(RoutineUpdateTool::new( + Arc::clone(&store), + Arc::clone(&engine), + ))); + self.register_sync(Arc::new(RoutineDeleteTool::new( + Arc::clone(&store), + Arc::clone(&engine), + ))); + self.register_sync(Arc::new(RoutineHistoryTool::new(store))); + tracing::info!("Registered 5 routine management tools"); + } + /// Register the software builder tool. /// /// The builder tool allows the agent to create new software including WASM tools, diff --git a/src/tools/tool.rs b/src/tools/tool.rs index 06fcb2c6..bfe51820 100644 --- a/src/tools/tool.rs +++ b/src/tools/tool.rs @@ -9,6 +9,18 @@ use thiserror::Error; use crate::context::JobContext; +/// Where a tool should execute: orchestrator process or inside a container. +/// +/// Orchestrator tools run in the main agent process (memory access, job mgmt, etc). +/// Container tools run inside Docker containers (shell, file ops, code mods). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ToolDomain { + /// Safe to run in the orchestrator (pure functions, memory, job management). + Orchestrator, + /// Must run inside a sandboxed container (filesystem, shell, code). + Container, +} + /// Error type for tool execution. #[derive(Debug, Error)] pub enum ToolError { @@ -160,6 +172,23 @@ pub trait Tool: Send + Sync { false } + /// Maximum time this tool is allowed to run before the caller kills it. + /// Override for long-running tools like sandbox execution. + /// Default: 60 seconds. + fn execution_timeout(&self) -> Duration { + Duration::from_secs(60) + } + + /// Where this tool should execute. + /// + /// `Orchestrator` tools run in the main agent process (safe, no FS access). + /// `Container` tools run inside Docker containers (shell, file ops). + /// + /// Default: `Orchestrator` (safe for the main process). + fn domain(&self) -> ToolDomain { + ToolDomain::Orchestrator + } + /// Get the tool schema for LLM function calling. fn schema(&self) -> ToolSchema { ToolSchema { @@ -242,4 +271,10 @@ mod tests { assert_eq!(schema.name, "echo"); assert!(!schema.description.is_empty()); } + + #[test] + fn test_execution_timeout_default() { + let tool = EchoTool; + assert_eq!(tool.execution_timeout(), Duration::from_secs(60)); + } } diff --git a/src/tools/wasm/loader.rs b/src/tools/wasm/loader.rs index ade7661c..230de268 100644 --- a/src/tools/wasm/loader.rs +++ b/src/tools/wasm/loader.rs @@ -2,6 +2,7 @@ //! //! This module provides a way to load WASM tools dynamically at runtime from: //! - A directory containing `.wasm` and `.capabilities.json` +//! - Build artifacts in `tools-src/` (dev mode, auto-detected) //! - Database storage (via [`WasmToolStore`]) //! //! # Example: Loading from Directory @@ -19,6 +20,13 @@ //! loader.load_from_dir(Path::new("~/.ironclaw/tools/")).await?; //! ``` //! +//! # Dev Mode +//! +//! When `load_dev_tools()` is called, the loader scans `tools-src/*/` for build +//! artifacts. Tools found there are loaded directly from the build output, +//! skipping the install directory. This means during development you just +//! rebuild the WASM and restart the host, no manual copy step needed. +//! //! # Security //! //! Tools loaded from files are assigned `TrustLevel::User` by default, meaning @@ -312,6 +320,159 @@ impl LoadResults { } } +/// Compile-time project root, used to locate tools-src/ in dev builds. +const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR"); + +/// Resolve the tools source directory. +/// +/// Checks (in order): +/// 1. `IRONCLAW_TOOLS_SRC` env var +/// 2. `/tools-src/` (dev builds) +fn tools_src_dir() -> PathBuf { + if let Ok(dir) = std::env::var("IRONCLAW_TOOLS_SRC") { + return PathBuf::from(dir); + } + PathBuf::from(CARGO_MANIFEST_DIR).join("tools-src") +} + +/// Discover WASM tools available as build artifacts in `tools-src/`. +/// +/// Scans each subdirectory for: +/// - `tools-src//target/wasm32-wasip2/release/_tool.wasm` +/// - `tools-src//-tool.capabilities.json` +/// +/// Returns a map of install-name (e.g. "gmail-tool") to paths. +pub async fn discover_dev_tools() -> Result, std::io::Error> { + let src_dir = tools_src_dir(); + let mut tools = HashMap::new(); + + if !src_dir.is_dir() { + return Ok(tools); + } + + let mut entries = fs::read_dir(&src_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let dir_name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n.to_string(), + None => continue, + }; + + // Convention: crate name uses underscores, directory uses hyphens + let crate_name = dir_name.replace('-', "_"); + let install_name = format!("{}-tool", dir_name); + + let wasm_path = path + .join("target/wasm32-wasip2/release") + .join(format!("{}_tool.wasm", crate_name)); + + if !wasm_path.exists() { + continue; + } + + let caps_path = path.join(format!("{}-tool.capabilities.json", dir_name)); + + tools.insert( + install_name, + DiscoveredTool { + wasm_path, + capabilities_path: if caps_path.exists() { + Some(caps_path) + } else { + None + }, + }, + ); + } + + Ok(tools) +} + +/// Load WASM tools from build artifacts in `tools-src/`. +/// +/// In dev mode, tools can be loaded directly from their build output without +/// needing to install them to `~/.ironclaw/tools/` first. Build artifacts +/// that are newer than installed copies take priority. +/// +/// Set `IRONCLAW_TOOLS_SRC` env var to override the source directory. +pub async fn load_dev_tools( + loader: &WasmToolLoader, + install_dir: &Path, +) -> Result { + let dev_tools = discover_dev_tools().await?; + let mut results = LoadResults::default(); + + if dev_tools.is_empty() { + return Ok(results); + } + + for (name, discovered) in &dev_tools { + // Check if the build artifact is newer than the installed copy + let installed_path = install_dir.join(format!("{}.wasm", name)); + let should_load = if installed_path.exists() { + // Compare modification times: prefer fresher build artifact + match ( + fs::metadata(&discovered.wasm_path).await, + fs::metadata(&installed_path).await, + ) { + (Ok(dev_meta), Ok(inst_meta)) => { + let dev_modified = dev_meta.modified().unwrap_or(std::time::UNIX_EPOCH); + let inst_modified = inst_meta.modified().unwrap_or(std::time::UNIX_EPOCH); + dev_modified > inst_modified + } + _ => true, + } + } else { + true + }; + + if !should_load { + continue; + } + + tracing::info!( + name = name, + wasm_path = %discovered.wasm_path.display(), + "Loading dev tool from build artifacts (newer than installed)" + ); + + match loader + .load_from_files( + name, + &discovered.wasm_path, + discovered.capabilities_path.as_deref(), + ) + .await + { + Ok(()) => { + results.loaded.push(name.clone()); + } + Err(e) => { + tracing::error!( + name = name, + error = %e, + "Failed to load dev tool" + ); + results.errors.push((discovered.wasm_path.clone(), e)); + } + } + } + + if !results.loaded.is_empty() { + tracing::info!( + count = results.loaded.len(), + tools = ?results.loaded, + "Loaded dev tools from build artifacts" + ); + } + + Ok(results) +} + /// Discover WASM tool files in a directory without loading them. /// /// Returns a map of tool name -> (wasm_path, capabilities_path). @@ -430,4 +591,31 @@ mod tests { let err = WasmLoadError::WasmNotFound(std::path::PathBuf::from("/foo/bar.wasm")); assert!(err.to_string().contains("/foo/bar.wasm")); } + + #[test] + fn test_tools_src_dir_default() { + let dir = super::tools_src_dir(); + assert!(dir.ends_with("tools-src")); + } + + #[tokio::test] + async fn test_discover_dev_tools_finds_build_artifacts() { + // This test relies on the actual tools-src/ directory in the repo. + // If build artifacts exist, they should be discovered. + let tools = super::discover_dev_tools().await.unwrap(); + + // If any tools have been built, they should appear with "-tool" suffix + for (name, discovered) in &tools { + assert!( + name.ends_with("-tool"), + "Dev tool name should end with -tool: {}", + name + ); + assert!( + discovered.wasm_path.exists(), + "WASM should exist: {:?}", + discovered.wasm_path + ); + } + } } diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs index d898dbbe..fea18e44 100644 --- a/src/tools/wasm/mod.rs +++ b/src/tools/wasm/mod.rs @@ -115,7 +115,10 @@ pub use storage::{ }; // Loader -pub use loader::{DiscoveredTool, LoadResults, WasmLoadError, WasmToolLoader, discover_tools}; +pub use loader::{ + DiscoveredTool, LoadResults, WasmLoadError, WasmToolLoader, discover_dev_tools, discover_tools, + load_dev_tools, +}; // Capabilities schema (for parsing *.capabilities.json files) pub use capabilities_schema::{ diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs index b499cc3b..bd2be44e 100644 --- a/src/tools/wasm/wrapper.rs +++ b/src/tools/wasm/wrapper.rs @@ -1,16 +1,23 @@ //! WASM tool wrapper implementing the Tool trait. //! +//! Uses wasmtime::component::bindgen! to generate typed bindings from the WIT +//! interface, ensuring all host functions are properly registered under the +//! correct `near:agent/host` namespace. +//! //! Each execution creates a fresh instance (NEAR pattern) to ensure //! isolation and deterministic behavior. +use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; use async_trait::async_trait; use wasmtime::Store; -use wasmtime::component::{Component, Linker, Val}; +use wasmtime::component::{Component, Linker}; +use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView}; use crate::context::JobContext; +use crate::safety::LeakDetector; use crate::tools::tool::{Tool, ToolError, ToolOutput}; use crate::tools::wasm::capabilities::Capabilities; use crate::tools::wasm::error::WasmError; @@ -18,21 +25,259 @@ use crate::tools::wasm::host::{HostState, LogLevel}; use crate::tools::wasm::limits::{ResourceLimits, WasmResourceLimiter}; use crate::tools::wasm::runtime::{PreparedModule, WasmToolRuntime}; -/// Store data for WASM execution. +// Generate component model bindings from the WIT file. +// +// This creates: +// - `near::agent::host::Host` trait + `add_to_linker()` for the import interface +// - `SandboxedTool` struct with `instantiate()` for the world +// - `exports::near::agent::tool::*` types for the export interface +wasmtime::component::bindgen!({ + path: "wit/tool.wit", + world: "sandboxed-tool", + async: false, + with: {}, +}); + +// Alias the export interface types for convenience. +use exports::near::agent::tool as wit_tool; + +/// Store data for WASM tool execution. /// -/// Contains both the resource limiter and host state. +/// Contains the resource limiter, host state, WASI context, and injected +/// credentials. Fresh instance created per execution (NEAR pattern). struct StoreData { limiter: WasmResourceLimiter, host_state: HostState, + wasi: WasiCtx, + table: ResourceTable, + /// Injected credentials for URL/header substitution. + /// Keys are placeholder names like "GOOGLE_ACCESS_TOKEN". + credentials: HashMap, } impl StoreData { - fn new(memory_limit: u64, capabilities: Capabilities) -> Self { + fn new( + memory_limit: u64, + capabilities: Capabilities, + credentials: HashMap, + ) -> Self { + // Minimal WASI context: no filesystem, no env vars (security) + let wasi = WasiCtxBuilder::new().build(); + Self { limiter: WasmResourceLimiter::new(memory_limit), host_state: HostState::new(capabilities), + wasi, + table: ResourceTable::new(), + credentials, } } + + /// Inject credentials into a string by replacing placeholders. + /// + /// Replaces patterns like `{GOOGLE_ACCESS_TOKEN}` with actual values. + /// WASM tools reference credentials by placeholder, never seeing real values. + fn inject_credentials(&self, input: &str, context: &str) -> String { + let mut result = input.to_string(); + + for (name, value) in &self.credentials { + let placeholder = format!("{{{}}}", name); + if result.contains(&placeholder) { + tracing::debug!( + placeholder = %placeholder, + context = %context, + "Replacing credential placeholder in tool request" + ); + result = result.replace(&placeholder, value); + } + } + + result + } + + /// Replace injected credential values with `[REDACTED]` in text. + /// + /// Prevents credentials from leaking through error messages or logs. + /// reqwest::Error includes the full URL in its Display output, so any + /// error from an injected-URL request will contain the raw credential + /// unless we scrub it. + fn redact_credentials(&self, text: &str) -> String { + let mut result = text.to_string(); + for (name, value) in &self.credentials { + if !value.is_empty() { + result = result.replace(value, &format!("[REDACTED:{}]", name)); + } + } + result + } +} + +// Provide WASI context for the WASM component. +// Required because tools are compiled with wasm32-wasip2 target. +impl WasiView for StoreData { + fn ctx(&mut self) -> &mut WasiCtx { + &mut self.wasi + } + + fn table(&mut self) -> &mut ResourceTable { + &mut self.table + } +} + +// Implement the generated Host trait from bindgen. +// +// This registers all 6 host functions under the `near:agent/host` namespace: +// log, now-millis, workspace-read, http-request, secret-exists, tool-invoke +impl near::agent::host::Host for StoreData { + fn log(&mut self, level: near::agent::host::LogLevel, message: String) { + let log_level = match level { + near::agent::host::LogLevel::Trace => LogLevel::Trace, + near::agent::host::LogLevel::Debug => LogLevel::Debug, + near::agent::host::LogLevel::Info => LogLevel::Info, + near::agent::host::LogLevel::Warn => LogLevel::Warn, + near::agent::host::LogLevel::Error => LogLevel::Error, + }; + let _ = self.host_state.log(log_level, message); + } + + fn now_millis(&mut self) -> u64 { + self.host_state.now_millis() + } + + fn workspace_read(&mut self, path: String) -> Option { + self.host_state.workspace_read(&path).ok().flatten() + } + + fn http_request( + &mut self, + method: String, + url: String, + headers_json: String, + body: Option>, + timeout_ms: Option, + ) -> Result { + // Inject credentials into URL (e.g., replace {TELEGRAM_BOT_TOKEN}) + let injected_url = self.inject_credentials(&url, "url"); + + // Check HTTP allowlist + self.host_state + .check_http_allowed(&injected_url, &method) + .map_err(|e| format!("HTTP not allowed: {}", e))?; + + // Record for rate limiting + self.host_state + .record_http_request() + .map_err(|e| format!("Rate limit exceeded: {}", e))?; + + // Parse headers and inject credentials into header values + let raw_headers: HashMap = + serde_json::from_str(&headers_json).unwrap_or_default(); + + let headers: HashMap = raw_headers + .into_iter() + .map(|(k, v)| { + ( + k.clone(), + self.inject_credentials(&v, &format!("header:{}", k)), + ) + }) + .collect(); + + let url = injected_url; + let leak_detector = LeakDetector::new(); + let header_vec: Vec<(String, String)> = headers + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + leak_detector + .scan_http_request(&url, &header_vec, body.as_deref()) + .map_err(|e| format!("Potential secret leak blocked: {}", e))?; + + // Make HTTP request using blocking I/O. + // We're inside a spawn_blocking context, so use block_on. + let result = tokio::runtime::Handle::current().block_on(async { + let client = reqwest::Client::new(); + + let mut request = match method.to_uppercase().as_str() { + "GET" => client.get(&url), + "POST" => client.post(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + "PATCH" => client.patch(&url), + "HEAD" => client.head(&url), + _ => return Err(format!("Unsupported HTTP method: {}", method)), + }; + + for (key, value) in headers { + request = request.header(&key, &value); + } + + if let Some(body_bytes) = body { + request = request.body(body_bytes); + } + + // Caller-specified timeout (default 30s) + let timeout = Duration::from_millis(timeout_ms.unwrap_or(30_000) as u64); + let response = request.timeout(timeout).send().await.map_err(|e| { + // Walk the full error chain for the actual root cause + let mut chain = format!("HTTP request failed: {}", e); + let mut source = std::error::Error::source(&e); + while let Some(cause) = source { + chain.push_str(&format!(" -> {}", cause)); + source = cause.source(); + } + chain + })?; + + let status = response.status().as_u16(); + let response_headers: HashMap = response + .headers() + .iter() + .filter_map(|(k, v)| { + v.to_str() + .ok() + .map(|v| (k.as_str().to_string(), v.to_string())) + }) + .collect(); + let headers_json = serde_json::to_string(&response_headers).unwrap_or_default(); + let body = response + .bytes() + .await + .map_err(|e| format!("Failed to read response body: {}", e))? + .to_vec(); + + // Leak detection on response body + if let Ok(body_str) = std::str::from_utf8(&body) { + leak_detector + .scan_and_clean(body_str) + .map_err(|e| format!("Potential secret leak in response: {}", e))?; + } + + Ok(near::agent::host::HttpResponse { + status, + headers_json, + body, + }) + }); + + // Redact credentials from error messages before returning to WASM + result.map_err(|e| self.redact_credentials(&e)) + } + + fn tool_invoke(&mut self, alias: String, _params_json: String) -> Result { + // Validate capability and resolve alias + let _real_name = self.host_state.check_tool_invoke_allowed(&alias)?; + self.host_state.record_tool_invoke()?; + + // Tool invocation requires async context and access to the tool registry, + // which aren't available inside a synchronous WASM callback. + Err("Tool invocation from WASM tools is not yet supported".to_string()) + } + + fn secret_exists(&mut self, name: String) -> bool { + self.host_state.secret_exists(&name) + } } /// A Tool implementation backed by a WASM component. @@ -49,6 +294,9 @@ pub struct WasmToolWrapper { description: String, /// Cached schema (from PreparedModule or override). schema: serde_json::Value, + /// Injected credentials for HTTP requests (e.g., OAuth tokens). + /// Keys are placeholder names like "GOOGLE_ACCESS_TOKEN". + credentials: HashMap, } impl WasmToolWrapper { @@ -64,6 +312,7 @@ impl WasmToolWrapper { runtime, prepared, capabilities, + credentials: HashMap::new(), } } @@ -79,11 +328,34 @@ impl WasmToolWrapper { self } + /// Set credentials for HTTP request injection. + pub fn with_credentials(mut self, credentials: HashMap) -> Self { + self.credentials = credentials; + self + } + /// Get the resource limits for this tool. pub fn limits(&self) -> &ResourceLimits { &self.prepared.limits } + /// Add all host functions to the linker using generated bindings. + /// + /// Uses the bindgen-generated `add_to_linker` function to properly register + /// all host functions with correct component model signatures under the + /// `near:agent/host` namespace. + fn add_host_functions(linker: &mut Linker) -> Result<(), WasmError> { + // Add WASI support (required by components built with wasm32-wasip2) + wasmtime_wasi::add_to_linker_sync(linker) + .map_err(|e| WasmError::ConfigError(format!("Failed to add WASI functions: {}", e)))?; + + // Add our custom host interface using the generated add_to_linker + near::agent::host::add_to_linker(linker, |state| state) + .map_err(|e| WasmError::ConfigError(format!("Failed to add host functions: {}", e)))?; + + Ok(()) + } + /// Execute the WASM tool synchronously (called from spawn_blocking). fn execute_sync( &self, @@ -94,7 +366,11 @@ impl WasmToolWrapper { let limits = &self.prepared.limits; // Create store with fresh state (NEAR pattern: fresh instance per call) - let store_data = StoreData::new(limits.memory_bytes, self.capabilities.clone()); + let store_data = StoreData::new( + limits.memory_bytes, + self.capabilities.clone(), + self.credentials.clone(), + ); let mut store = Store::new(engine, store_data); // Configure fuel if enabled @@ -115,172 +391,46 @@ impl WasmToolWrapper { let component = Component::new(engine, self.prepared.component_bytes()) .map_err(|e| WasmError::CompilationFailed(e.to_string()))?; - // Create linker and add host functions + // Create linker with all host functions properly namespaced let mut linker = Linker::new(engine); - self.add_host_functions(&mut linker)?; + Self::add_host_functions(&mut linker)?; - // Instantiate the component - let instance = linker - .instantiate(&mut store, &component) + // Instantiate using the generated bindings + let instance = SandboxedTool::instantiate(&mut store, &component, &linker) .map_err(|e| WasmError::InstantiationFailed(e.to_string()))?; - // Get the execute function - let execute_func = instance - .get_func(&mut store, "execute") - .ok_or_else(|| WasmError::MissingExport("execute".to_string()))?; - - // Prepare request + // Prepare the request let params_json = serde_json::to_string(¶ms) .map_err(|e| WasmError::InvalidResponseJson(e.to_string()))?; - // Build request record - // Note: The exact calling convention depends on how WIT records are lowered. - // With component model, we'd use typed bindings from wit-bindgen. - // For now, we use the lower-level Val API. - let request_params = Val::String(params_json); - let request_context = match context_json { - Some(ctx) => Val::Option(Some(Box::new(Val::String(ctx)))), - None => Val::Option(None), + let request = wit_tool::Request { + params: params_json, + context: context_json, }; - // Create request record (params, context) - let request = Val::Record(vec![ - ("params".to_string(), request_params), - ("context".to_string(), request_context), - ]); - - // Call the function - let mut results = vec![Val::Bool(false)]; // Placeholder for response - execute_func - .call(&mut store, &[request], &mut results) - .map_err(|e| { - // Check for specific trap types - let error_str = e.to_string(); - if error_str.contains("out of fuel") { - WasmError::FuelExhausted { limit: limits.fuel } - } else if error_str.contains("unreachable") { - WasmError::Trapped("unreachable code executed".to_string()) - } else { - WasmError::Trapped(error_str) - } - })?; - - // Post-call completion (cleanup) - execute_func - .post_return(&mut store) - .map_err(|e| WasmError::Trapped(format!("post_return failed: {}", e)))?; - - // Extract response - let response = &results[0]; - let (result_str, error_str) = extract_response(response)?; + // Call execute using the generated typed interface + let tool_iface = instance.near_agent_tool(); + let response = tool_iface.call_execute(&mut store, &request).map_err(|e| { + let error_str = e.to_string(); + if error_str.contains("out of fuel") { + WasmError::FuelExhausted { limit: limits.fuel } + } else if error_str.contains("unreachable") { + WasmError::Trapped("unreachable code executed".to_string()) + } else { + WasmError::Trapped(error_str) + } + })?; // Get logs from host state let logs = store.data_mut().host_state.take_logs(); // Check for tool-level error - if let Some(err) = error_str { + if let Some(err) = response.error { return Err(WasmError::ToolReturnedError(err)); } // Return result (or empty string if none) - Ok((result_str.unwrap_or_default(), logs)) - } - - /// Add host functions to the linker. - fn add_host_functions(&self, linker: &mut Linker) -> Result<(), WasmError> { - // Note: With WIT bindgen, these would be generated automatically. - // For now, we manually define the host functions. - // - // Component model func_wrap signature: F: Fn(StoreContextMut, Params) -> Result - // where Params is a tuple of the function arguments. - - // host.log(level: log-level, message: string) - linker - .root() - .func_wrap( - "log", - |mut ctx: wasmtime::StoreContextMut<'_, StoreData>, - (level, message): (i32, String)| { - let log_level = match level { - 0 => LogLevel::Trace, - 1 => LogLevel::Debug, - 2 => LogLevel::Info, - 3 => LogLevel::Warn, - 4 => LogLevel::Error, - _ => LogLevel::Info, - }; - // Ignore errors from logging (rate limiting) - let _ = ctx.data_mut().host_state.log(log_level, message); - Ok(()) - }, - ) - .map_err(|e| WasmError::ConfigError(format!("Failed to add log function: {}", e)))?; - - // host.now-millis() -> u64 - linker - .root() - .func_wrap( - "now-millis", - |ctx: wasmtime::StoreContextMut<'_, StoreData>, (): ()| -> anyhow::Result<(u64,)> { - Ok((ctx.data().host_state.now_millis(),)) - }, - ) - .map_err(|e| { - WasmError::ConfigError(format!("Failed to add now-millis function: {}", e)) - })?; - - // host.workspace-read(path: string) -> option - linker - .root() - .func_wrap( - "workspace-read", - |ctx: wasmtime::StoreContextMut<'_, StoreData>, - (path,): (String,)| - -> anyhow::Result<(Option,)> { - let result = ctx.data().host_state.workspace_read(&path).ok().flatten(); - Ok((result,)) - }, - ) - .map_err(|e| { - WasmError::ConfigError(format!("Failed to add workspace-read function: {}", e)) - })?; - - Ok(()) - } -} - -/// Extract result and error from a WIT response record. -fn extract_response(response: &Val) -> Result<(Option, Option), WasmError> { - match response { - Val::Record(fields) => { - let mut result = None; - let mut error = None; - - for (name, val) in fields { - match name.as_str() { - "output" => { - if let Val::Option(Some(inner)) = val { - if let Val::String(s) = inner.as_ref() { - result = Some(s.to_string()); - } - } - } - "error" => { - if let Val::Option(Some(inner)) = val { - if let Val::String(s) = inner.as_ref() { - error = Some(s.to_string()); - } - } - } - _ => {} - } - } - - Ok((result, error)) - } - _ => Err(WasmError::InvalidResponseJson( - "Expected record response".to_string(), - )), + Ok((response.output.unwrap_or_default(), logs)) } } @@ -315,6 +465,7 @@ impl Tool for WasmToolWrapper { let capabilities = self.capabilities.clone(); let description = self.description.clone(); let schema = self.schema.clone(); + let credentials = self.credentials.clone(); // Execute in blocking task with timeout let result = tokio::time::timeout(timeout, async move { @@ -324,6 +475,7 @@ impl Tool for WasmToolWrapper { capabilities, description, schema, + credentials, }; tokio::task::spawn_blocking(move || wrapper.execute_sync(params, context_json)) @@ -359,7 +511,7 @@ impl Tool for WasmToolWrapper { } fn requires_sanitization(&self) -> bool { - // WASM tools always require sanitization - they're untrusted by definition + // WASM tools always require sanitization, they're untrusted by definition true } diff --git a/src/worker/api.rs b/src/worker/api.rs new file mode 100644 index 00000000..2974d21e --- /dev/null +++ b/src/worker/api.rs @@ -0,0 +1,400 @@ +//! HTTP client for worker-to-orchestrator communication. +//! +//! Every request includes a bearer token from `IRONCLAW_WORKER_TOKEN` env var. +//! The orchestrator validates this token is scoped to the correct job. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::WorkerError; +use crate::llm::{ + ChatMessage, CompletionRequest, CompletionResponse, FinishReason, ToolCall, + ToolCompletionRequest, ToolCompletionResponse, ToolDefinition, +}; + +/// HTTP client that a container worker uses to talk to the orchestrator. +pub struct WorkerHttpClient { + client: reqwest::Client, + orchestrator_url: String, + job_id: Uuid, + token: String, +} + +/// Status update sent from worker to orchestrator. +#[derive(Debug, Serialize, Deserialize)] +pub struct StatusUpdate { + pub state: String, + pub message: Option, + pub iteration: u32, +} + +/// Job description fetched from orchestrator. +#[derive(Debug, Serialize, Deserialize)] +pub struct JobDescription { + pub title: String, + pub description: String, + pub project_dir: Option, +} + +/// Completion result from the orchestrator (proxied from the real LLM). +#[derive(Debug, Serialize, Deserialize)] +pub struct ProxyCompletionRequest { + pub messages: Vec, + pub max_tokens: Option, + pub temperature: Option, + pub stop_sequences: Option>, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProxyCompletionResponse { + pub content: String, + pub input_tokens: u32, + pub output_tokens: u32, + pub finish_reason: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProxyToolCompletionRequest { + pub messages: Vec, + pub tools: Vec, + pub max_tokens: Option, + pub temperature: Option, + pub tool_choice: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ProxyToolCompletionResponse { + pub content: Option, + pub tool_calls: Vec, + pub input_tokens: u32, + pub output_tokens: u32, + pub finish_reason: String, +} + +/// Completion result for the worker to report when done. +#[derive(Debug, Serialize, Deserialize)] +pub struct CompletionReport { + pub success: bool, + pub message: Option, + pub iterations: u32, +} + +/// Payload sent to the orchestrator for each job event (shared by worker and Claude Code bridge). +#[derive(Debug, Serialize, Deserialize)] +pub struct JobEventPayload { + pub event_type: String, + pub data: serde_json::Value, +} + +/// Response from the prompt polling endpoint. +#[derive(Debug, Deserialize)] +pub struct PromptResponse { + pub content: String, + #[serde(default)] + pub done: bool, +} + +impl WorkerHttpClient { + /// Create a new client from environment. + /// + /// Reads `IRONCLAW_WORKER_TOKEN` from the environment. + pub fn from_env(orchestrator_url: String, job_id: Uuid) -> Result { + let token = + std::env::var("IRONCLAW_WORKER_TOKEN").map_err(|_| WorkerError::MissingToken)?; + + Ok(Self { + client: reqwest::Client::new(), + orchestrator_url: orchestrator_url.trim_end_matches('/').to_string(), + job_id, + token, + }) + } + + /// Create with an explicit token (for testing). + pub fn new(orchestrator_url: String, job_id: Uuid, token: String) -> Self { + Self { + client: reqwest::Client::new(), + orchestrator_url: orchestrator_url.trim_end_matches('/').to_string(), + job_id, + token, + } + } + + /// Get the base orchestrator URL. + pub fn orchestrator_url(&self) -> &str { + &self.orchestrator_url + } + + fn url(&self, path: &str) -> String { + format!("{}/worker/{}/{}", self.orchestrator_url, self.job_id, path) + } + + /// Fetch the job description from the orchestrator. + pub async fn get_job(&self) -> Result { + let resp = self + .client + .get(self.url("job")) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| WorkerError::ConnectionFailed { + url: self.orchestrator_url.clone(), + reason: e.to_string(), + })?; + + if !resp.status().is_success() { + return Err(WorkerError::OrchestratorRejected { + job_id: self.job_id, + reason: format!("GET /job returned {}", resp.status()), + }); + } + + resp.json().await.map_err(|e| WorkerError::LlmProxyFailed { + reason: format!("failed to parse job description: {}", e), + }) + } + + /// Proxy an LLM completion request through the orchestrator. + pub async fn llm_complete( + &self, + request: &CompletionRequest, + ) -> Result { + let proxy_req = ProxyCompletionRequest { + messages: request.messages.clone(), + max_tokens: request.max_tokens, + temperature: request.temperature, + stop_sequences: request.stop_sequences.clone(), + }; + + let resp = self + .client + .post(self.url("llm/complete")) + .bearer_auth(&self.token) + .json(&proxy_req) + .send() + .await + .map_err(|e| WorkerError::LlmProxyFailed { + reason: e.to_string(), + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(WorkerError::LlmProxyFailed { + reason: format!("orchestrator returned {}: {}", status, body), + }); + } + + let proxy_resp: ProxyCompletionResponse = + resp.json().await.map_err(|e| WorkerError::LlmProxyFailed { + reason: format!("failed to parse LLM response: {}", e), + })?; + + Ok(CompletionResponse { + content: proxy_resp.content, + input_tokens: proxy_resp.input_tokens, + output_tokens: proxy_resp.output_tokens, + finish_reason: parse_finish_reason(&proxy_resp.finish_reason), + response_id: None, + }) + } + + /// Proxy an LLM tool completion request through the orchestrator. + pub async fn llm_complete_with_tools( + &self, + request: &ToolCompletionRequest, + ) -> Result { + let proxy_req = ProxyToolCompletionRequest { + messages: request.messages.clone(), + tools: request.tools.clone(), + max_tokens: request.max_tokens, + temperature: request.temperature, + tool_choice: request.tool_choice.clone(), + }; + + let resp = self + .client + .post(self.url("llm/complete_with_tools")) + .bearer_auth(&self.token) + .json(&proxy_req) + .send() + .await + .map_err(|e| WorkerError::LlmProxyFailed { + reason: e.to_string(), + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(WorkerError::LlmProxyFailed { + reason: format!("orchestrator returned {}: {}", status, body), + }); + } + + let proxy_resp: ProxyToolCompletionResponse = + resp.json().await.map_err(|e| WorkerError::LlmProxyFailed { + reason: format!("failed to parse tool completion response: {}", e), + })?; + + Ok(ToolCompletionResponse { + content: proxy_resp.content, + tool_calls: proxy_resp.tool_calls, + input_tokens: proxy_resp.input_tokens, + output_tokens: proxy_resp.output_tokens, + finish_reason: parse_finish_reason(&proxy_resp.finish_reason), + response_id: None, + }) + } + + /// Report status to the orchestrator. + pub async fn report_status(&self, update: &StatusUpdate) -> Result<(), WorkerError> { + let resp = self + .client + .post(self.url("status")) + .bearer_auth(&self.token) + .json(update) + .send() + .await + .map_err(|e| WorkerError::ConnectionFailed { + url: self.orchestrator_url.clone(), + reason: e.to_string(), + })?; + + if !resp.status().is_success() { + tracing::warn!( + "Status report failed with {}: {}", + resp.status(), + resp.text().await.unwrap_or_default() + ); + } + + Ok(()) + } + + /// Post a job event to the orchestrator (fire-and-forget style, logs on failure). + pub async fn post_event(&self, payload: &JobEventPayload) { + let resp = self + .client + .post(self.url("event")) + .bearer_auth(&self.token) + .json(payload) + .send() + .await; + + match resp { + Ok(r) if !r.status().is_success() => { + tracing::debug!( + job_id = %self.job_id, + event_type = %payload.event_type, + status = %r.status(), + "Job event POST rejected" + ); + } + Err(e) => { + tracing::debug!( + job_id = %self.job_id, + event_type = %payload.event_type, + "Job event POST failed: {}", e + ); + } + _ => {} + } + } + + /// Poll the orchestrator for a follow-up prompt. + /// + /// Returns `None` if no prompt is available (204 No Content). + pub async fn poll_prompt(&self) -> Result, WorkerError> { + let resp = self + .client + .get(self.url("prompt")) + .bearer_auth(&self.token) + .send() + .await + .map_err(|e| WorkerError::ConnectionFailed { + url: self.orchestrator_url.clone(), + reason: e.to_string(), + })?; + + if resp.status() == reqwest::StatusCode::NO_CONTENT { + return Ok(None); + } + + if !resp.status().is_success() { + return Err(WorkerError::OrchestratorRejected { + job_id: self.job_id, + reason: format!("prompt endpoint returned {}", resp.status()), + }); + } + + let prompt: PromptResponse = + resp.json().await.map_err(|e| WorkerError::LlmProxyFailed { + reason: format!("failed to parse prompt response: {}", e), + })?; + + Ok(Some(prompt)) + } + + /// Signal job completion to the orchestrator. + pub async fn report_complete(&self, report: &CompletionReport) -> Result<(), WorkerError> { + let resp = self + .client + .post(self.url("complete")) + .bearer_auth(&self.token) + .json(report) + .send() + .await + .map_err(|e| WorkerError::ConnectionFailed { + url: self.orchestrator_url.clone(), + reason: e.to_string(), + })?; + + if !resp.status().is_success() { + return Err(WorkerError::OrchestratorRejected { + job_id: self.job_id, + reason: format!("completion report rejected: {}", resp.status()), + }); + } + + Ok(()) + } +} + +fn parse_finish_reason(s: &str) -> FinishReason { + match s { + "stop" => FinishReason::Stop, + "length" => FinishReason::Length, + "tool_use" | "tool_calls" => FinishReason::ToolUse, + "content_filter" => FinishReason::ContentFilter, + _ => FinishReason::Unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_url_construction() { + let client = WorkerHttpClient::new( + "http://host.docker.internal:50051".to_string(), + Uuid::nil(), + "test-token".to_string(), + ); + + assert_eq!( + client.url("llm/complete"), + format!( + "http://host.docker.internal:50051/worker/{}/llm/complete", + Uuid::nil() + ) + ); + } + + #[test] + fn test_parse_finish_reason() { + assert_eq!(parse_finish_reason("stop"), FinishReason::Stop); + assert_eq!(parse_finish_reason("tool_use"), FinishReason::ToolUse); + assert_eq!(parse_finish_reason("unknown"), FinishReason::Unknown); + } +} diff --git a/src/worker/claude_bridge.rs b/src/worker/claude_bridge.rs new file mode 100644 index 00000000..1f639281 --- /dev/null +++ b/src/worker/claude_bridge.rs @@ -0,0 +1,644 @@ +//! Claude Code bridge for sandboxed execution. +//! +//! Spawns the `claude` CLI inside a Docker container and streams its NDJSON +//! output back to the orchestrator via HTTP. Supports follow-up prompts via +//! `--resume`. +//! +//! ```text +//! ┌─────────────────────────────────────────────┐ +//! │ Docker Container │ +//! │ │ +//! │ ironclaw claude-bridge --job-id │ +//! │ └─ claude -p "task" --output-format │ +//! │ stream-json --dangerously-skip-perms │ +//! │ └─ reads stdout line-by-line │ +//! │ └─ POSTs events to orchestrator │ +//! │ └─ polls for follow-up prompts │ +//! │ └─ on follow-up: claude --resume │ +//! └─────────────────────────────────────────────┘ +//! ``` + +use std::sync::Arc; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use uuid::Uuid; + +use crate::error::WorkerError; +use crate::worker::api::{CompletionReport, JobEventPayload, PromptResponse, WorkerHttpClient}; + +/// Configuration for the Claude bridge runtime. +pub struct ClaudeBridgeConfig { + pub job_id: Uuid, + pub orchestrator_url: String, + pub max_turns: u32, + pub model: String, + pub timeout: Duration, +} + +/// A Claude Code streaming event (NDJSON line from `--output-format stream-json`). +/// +/// Claude Code emits one JSON object per line. We capture the key fields +/// we need and forward the rest as opaque data. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeStreamEvent { + #[serde(rename = "type")] + pub event_type: String, + + /// For `system` events: the session ID. + #[serde(default)] + pub session_id: Option, + + /// For `assistant` events: the text content blocks. + #[serde(default)] + pub content: Option>, + + /// For `result` events: final status info. + #[serde(default)] + pub result: Option, + + /// For `tool_use`/`tool_result`: the tool name. + #[serde(default)] + pub tool_name: Option, + + /// For `tool_use`: the input parameters. + #[serde(default)] + pub input: Option, + + /// For `tool_result`: the output content. + #[serde(default)] + pub output: Option, + + /// Subtype discriminator (e.g. "text", "tool_use", "tool_result"). + #[serde(default)] + pub subtype: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContentBlock { + #[serde(rename = "type")] + pub block_type: String, + #[serde(default)] + pub text: Option, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub input: Option, + #[serde(default)] + pub content: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultInfo { + #[serde(default)] + pub is_error: Option, + #[serde(default)] + pub duration_ms: Option, + #[serde(default)] + pub num_turns: Option, +} + +/// The Claude Code bridge runtime. +pub struct ClaudeBridgeRuntime { + config: ClaudeBridgeConfig, + client: Arc, +} + +impl ClaudeBridgeRuntime { + /// Create a new bridge runtime. + /// + /// Reads `IRONCLAW_WORKER_TOKEN` from the environment for auth. + pub fn new(config: ClaudeBridgeConfig) -> Result { + let client = Arc::new(WorkerHttpClient::from_env( + config.orchestrator_url.clone(), + config.job_id, + )?); + + Ok(Self { config, client }) + } + + /// Run the bridge: fetch job, spawn claude, stream events, handle follow-ups. + pub async fn run(&self) -> Result<(), WorkerError> { + // Fetch the job description from the orchestrator + let job = self.client.get_job().await?; + + tracing::info!( + job_id = %self.config.job_id, + "Starting Claude Code bridge for: {}", + truncate(&job.description, 100) + ); + + // Report that we're running + self.client + .report_status(&crate::worker::api::StatusUpdate { + state: "running".to_string(), + message: Some("Spawning Claude Code".to_string()), + iteration: 0, + }) + .await?; + + // Run the initial Claude session + let session_id = match self.run_claude_session(&job.description, None).await { + Ok(sid) => sid, + Err(e) => { + tracing::error!(job_id = %self.config.job_id, "Claude session failed: {}", e); + self.client + .report_complete(&CompletionReport { + success: false, + message: Some(format!("Claude Code failed: {}", e)), + iterations: 1, + }) + .await?; + return Ok(()); + } + }; + + // Follow-up loop: poll for prompts, resume Claude sessions + let mut iteration = 1u32; + loop { + // Poll for a follow-up prompt (2 second intervals) + match self.poll_for_prompt().await { + Ok(Some(prompt)) => { + if prompt.done { + tracing::info!(job_id = %self.config.job_id, "Orchestrator signaled done"); + break; + } + iteration += 1; + tracing::info!( + job_id = %self.config.job_id, + "Got follow-up prompt, resuming session" + ); + if let Err(e) = self + .run_claude_session(&prompt.content, session_id.as_deref()) + .await + { + tracing::error!( + job_id = %self.config.job_id, + "Follow-up Claude session failed: {}", e + ); + // Don't fail the whole job on a follow-up error, just report it + self.report_event( + "status", + &serde_json::json!({ + "message": format!("Follow-up session failed: {}", e), + }), + ) + .await; + } + } + Ok(None) => { + // No prompt available, wait and poll again + tokio::time::sleep(Duration::from_secs(2)).await; + } + Err(e) => { + tracing::warn!( + job_id = %self.config.job_id, + "Prompt polling error: {}", e + ); + tokio::time::sleep(Duration::from_secs(5)).await; + } + } + } + + self.client + .report_complete(&CompletionReport { + success: true, + message: Some("Claude Code session completed".to_string()), + iterations: iteration, + }) + .await?; + + Ok(()) + } + + /// Spawn a `claude` CLI process and stream its output. + /// + /// Returns the session_id if captured from the `system` init message. + async fn run_claude_session( + &self, + prompt: &str, + resume_session_id: Option<&str>, + ) -> Result, WorkerError> { + let mut cmd = Command::new("claude"); + cmd.arg("-p") + .arg(prompt) + .arg("--output-format") + .arg("stream-json") + .arg("--dangerously-skip-permissions") + .arg("--max-turns") + .arg(self.config.max_turns.to_string()) + .arg("--model") + .arg(&self.config.model); + + if let Some(sid) = resume_session_id { + cmd.arg("--resume").arg(sid); + } + + cmd.current_dir("/workspace") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + let mut child = cmd.spawn().map_err(|e| WorkerError::ExecutionFailed { + reason: format!("failed to spawn claude: {}", e), + })?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| WorkerError::ExecutionFailed { + reason: "failed to capture claude stdout".to_string(), + })?; + + let stderr = child + .stderr + .take() + .ok_or_else(|| WorkerError::ExecutionFailed { + reason: "failed to capture claude stderr".to_string(), + })?; + + // Spawn stderr reader that forwards lines as log events + let client_for_stderr = Arc::clone(&self.client); + let job_id = self.config.job_id; + let stderr_handle = tokio::spawn(async move { + let reader = BufReader::new(stderr); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + tracing::debug!(job_id = %job_id, "claude stderr: {}", line); + let payload = JobEventPayload { + event_type: "status".to_string(), + data: serde_json::json!({ "message": line }), + }; + client_for_stderr.post_event(&payload).await; + } + }); + + // Read stdout NDJSON line by line + let reader = BufReader::new(stdout); + let mut lines = reader.lines(); + let mut session_id: Option = None; + + while let Ok(Some(line)) = lines.next_line().await { + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + + match serde_json::from_str::(&line) { + Ok(event) => { + // Capture session_id from system init + if event.event_type == "system" { + if let Some(ref sid) = event.session_id { + session_id = Some(sid.clone()); + tracing::info!( + job_id = %self.config.job_id, + session_id = %sid, + "Captured Claude session ID" + ); + } + } + + // Convert to our event payload and forward + let payloads = stream_event_to_payloads(&event); + for payload in payloads { + self.report_event(&payload.event_type, &payload.data).await; + } + } + Err(e) => { + // Not valid JSON, forward as a status message + tracing::debug!( + job_id = %self.config.job_id, + "Non-JSON claude output: {} (parse error: {})", line, e + ); + self.report_event("status", &serde_json::json!({ "message": line })) + .await; + } + } + } + + // Wait for the process to exit + let status = child + .wait() + .await + .map_err(|e| WorkerError::ExecutionFailed { + reason: format!("failed waiting for claude: {}", e), + })?; + + // Wait for stderr reader to finish + let _ = stderr_handle.await; + + if !status.success() { + let code = status.code().unwrap_or(-1); + tracing::warn!( + job_id = %self.config.job_id, + exit_code = code, + "Claude process exited with non-zero status" + ); + + // Report result event + self.report_event( + "result", + &serde_json::json!({ + "status": "error", + "exit_code": code, + "session_id": session_id, + }), + ) + .await; + + return Err(WorkerError::ExecutionFailed { + reason: format!("claude exited with code {}", code), + }); + } + + // Report successful result + self.report_event( + "result", + &serde_json::json!({ + "status": "completed", + "session_id": session_id, + }), + ) + .await; + + Ok(session_id) + } + + /// Post a job event to the orchestrator. + async fn report_event(&self, event_type: &str, data: &serde_json::Value) { + let payload = JobEventPayload { + event_type: event_type.to_string(), + data: data.clone(), + }; + self.client.post_event(&payload).await; + } + + /// Poll the orchestrator for a follow-up prompt. + async fn poll_for_prompt(&self) -> Result, WorkerError> { + self.client.poll_prompt().await + } +} + +/// Convert a Claude stream event into one or more event payloads for the orchestrator. +fn stream_event_to_payloads(event: &ClaudeStreamEvent) -> Vec { + let mut payloads = Vec::new(); + + match event.event_type.as_str() { + "system" => { + payloads.push(JobEventPayload { + event_type: "status".to_string(), + data: serde_json::json!({ + "message": "Claude Code session started", + "session_id": event.session_id, + }), + }); + } + "assistant" => { + // Extract text content and tool_use blocks + if let Some(ref blocks) = event.content { + for block in blocks { + match block.block_type.as_str() { + "text" => { + if let Some(ref text) = block.text { + payloads.push(JobEventPayload { + event_type: "message".to_string(), + data: serde_json::json!({ + "role": "assistant", + "content": text, + }), + }); + } + } + "tool_use" => { + payloads.push(JobEventPayload { + event_type: "tool_use".to_string(), + data: serde_json::json!({ + "tool_name": block.name, + "input": block.input, + }), + }); + } + "tool_result" => { + payloads.push(JobEventPayload { + event_type: "tool_result".to_string(), + data: serde_json::json!({ + "tool_name": block.name.as_deref().unwrap_or("unknown"), + "output": block.content.as_deref().unwrap_or(""), + }), + }); + } + _ => {} + } + } + } + } + "result" => { + let is_error = event + .result + .as_ref() + .and_then(|r| r.is_error) + .unwrap_or(false); + payloads.push(JobEventPayload { + event_type: "result".to_string(), + data: serde_json::json!({ + "status": if is_error { "error" } else { "completed" }, + "session_id": event.session_id, + "duration_ms": event.result.as_ref().and_then(|r| r.duration_ms), + "num_turns": event.result.as_ref().and_then(|r| r.num_turns), + }), + }); + } + _ => { + // Forward unknown event types as status + payloads.push(JobEventPayload { + event_type: "status".to_string(), + data: serde_json::json!({ + "message": format!("Claude event: {}", event.event_type), + "raw_type": event.event_type, + }), + }); + } + } + + payloads +} + +fn truncate(s: &str, max_len: usize) -> &str { + if s.len() <= max_len { s } else { &s[..max_len] } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_system_event() { + let json = r#"{"type":"system","session_id":"abc-123","subtype":"init"}"#; + let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event.event_type, "system"); + assert_eq!(event.session_id.as_deref(), Some("abc-123")); + } + + #[test] + fn test_parse_assistant_text_event() { + let json = r#"{"type":"assistant","content":[{"type":"text","text":"Hello world"}]}"#; + let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event.event_type, "assistant"); + let blocks = event.content.unwrap(); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].block_type, "text"); + assert_eq!(blocks[0].text.as_deref(), Some("Hello world")); + } + + #[test] + fn test_parse_assistant_tool_use_event() { + let json = r#"{"type":"assistant","content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}"#; + let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); + let blocks = event.content.unwrap(); + assert_eq!(blocks[0].block_type, "tool_use"); + assert_eq!(blocks[0].name.as_deref(), Some("Bash")); + assert!(blocks[0].input.is_some()); + } + + #[test] + fn test_parse_result_event() { + let json = + r#"{"type":"result","result":{"is_error":false,"duration_ms":5000,"num_turns":3}}"#; + let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); + assert_eq!(event.event_type, "result"); + let result = event.result.unwrap(); + assert_eq!(result.is_error, Some(false)); + assert_eq!(result.duration_ms, Some(5000)); + assert_eq!(result.num_turns, Some(3)); + } + + #[test] + fn test_parse_result_error_event() { + let json = r#"{"type":"result","result":{"is_error":true}}"#; + let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); + let result = event.result.unwrap(); + assert_eq!(result.is_error, Some(true)); + } + + #[test] + fn test_stream_event_to_payloads_system() { + let event = ClaudeStreamEvent { + event_type: "system".to_string(), + session_id: Some("sid-123".to_string()), + content: None, + result: None, + tool_name: None, + input: None, + output: None, + subtype: None, + }; + let payloads = stream_event_to_payloads(&event); + assert_eq!(payloads.len(), 1); + assert_eq!(payloads[0].event_type, "status"); + assert_eq!(payloads[0].data["session_id"], "sid-123"); + } + + #[test] + fn test_stream_event_to_payloads_assistant_text() { + let event = ClaudeStreamEvent { + event_type: "assistant".to_string(), + session_id: None, + content: Some(vec![ContentBlock { + block_type: "text".to_string(), + text: Some("Here's the answer".to_string()), + name: None, + input: None, + content: None, + }]), + result: None, + tool_name: None, + input: None, + output: None, + subtype: None, + }; + let payloads = stream_event_to_payloads(&event); + assert_eq!(payloads.len(), 1); + assert_eq!(payloads[0].event_type, "message"); + assert_eq!(payloads[0].data["role"], "assistant"); + assert_eq!(payloads[0].data["content"], "Here's the answer"); + } + + #[test] + fn test_stream_event_to_payloads_result_success() { + let event = ClaudeStreamEvent { + event_type: "result".to_string(), + session_id: Some("s1".to_string()), + content: None, + result: Some(ResultInfo { + is_error: Some(false), + duration_ms: Some(12000), + num_turns: Some(5), + }), + tool_name: None, + input: None, + output: None, + subtype: None, + }; + let payloads = stream_event_to_payloads(&event); + assert_eq!(payloads.len(), 1); + assert_eq!(payloads[0].event_type, "result"); + assert_eq!(payloads[0].data["status"], "completed"); + } + + #[test] + fn test_stream_event_to_payloads_result_error() { + let event = ClaudeStreamEvent { + event_type: "result".to_string(), + session_id: None, + content: None, + result: Some(ResultInfo { + is_error: Some(true), + duration_ms: None, + num_turns: None, + }), + tool_name: None, + input: None, + output: None, + subtype: None, + }; + let payloads = stream_event_to_payloads(&event); + assert_eq!(payloads[0].data["status"], "error"); + } + + #[test] + fn test_stream_event_to_payloads_unknown_type() { + let event = ClaudeStreamEvent { + event_type: "fancy_new_thing".to_string(), + session_id: None, + content: None, + result: None, + tool_name: None, + input: None, + output: None, + subtype: None, + }; + let payloads = stream_event_to_payloads(&event); + assert_eq!(payloads.len(), 1); + assert_eq!(payloads[0].event_type, "status"); + } + + #[test] + fn test_claude_event_payload_serde() { + let payload = JobEventPayload { + event_type: "message".to_string(), + data: serde_json::json!({ "role": "assistant", "content": "hi" }), + }; + let json = serde_json::to_string(&payload).unwrap(); + let parsed: JobEventPayload = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.event_type, "message"); + assert_eq!(parsed.data["content"], "hi"); + } + + #[test] + fn test_truncate() { + assert_eq!(truncate("hello", 10), "hello"); + assert_eq!(truncate("hello world", 5), "hello"); + assert_eq!(truncate("", 5), ""); + } +} diff --git a/src/worker/mod.rs b/src/worker/mod.rs new file mode 100644 index 00000000..88dd7c56 --- /dev/null +++ b/src/worker/mod.rs @@ -0,0 +1,35 @@ +//! Worker mode for running inside Docker containers. +//! +//! When `ironclaw worker` is invoked, the binary starts in worker mode: +//! - Connects to the orchestrator over HTTP +//! - Uses a `ProxyLlmProvider` that routes LLM calls through the orchestrator +//! - Runs container-safe tools (shell, file ops, patch) +//! - Reports status and completion back to the orchestrator +//! +//! ```text +//! ┌────────────────────────────────┐ +//! │ Docker Container │ +//! │ │ +//! │ ironclaw worker │ +//! │ ├─ ProxyLlmProvider ─────────┼──▶ Orchestrator /worker/{id}/llm/complete +//! │ ├─ SafetyLayer │ +//! │ ├─ ToolRegistry │ +//! │ │ ├─ shell │ +//! │ │ ├─ read_file │ +//! │ │ ├─ write_file │ +//! │ │ ├─ list_dir │ +//! │ │ └─ apply_patch │ +//! │ └─ WorkerHttpClient ─────────┼──▶ Orchestrator /worker/{id}/status +//! │ │ +//! └────────────────────────────────┘ +//! ``` + +pub mod api; +pub mod claude_bridge; +pub mod proxy_llm; +pub mod runtime; + +pub use api::WorkerHttpClient; +pub use claude_bridge::ClaudeBridgeRuntime; +pub use proxy_llm::ProxyLlmProvider; +pub use runtime::WorkerRuntime; diff --git a/src/worker/proxy_llm.rs b/src/worker/proxy_llm.rs new file mode 100644 index 00000000..95dc38af --- /dev/null +++ b/src/worker/proxy_llm.rs @@ -0,0 +1,95 @@ +//! LLM provider that proxies all calls through the orchestrator HTTP API. +//! +//! The worker never has direct access to API keys or session tokens. +//! All LLM requests go through the orchestrator, which holds the real credentials. + +use std::sync::Arc; + +use async_trait::async_trait; +use rust_decimal::Decimal; + +use crate::error::LlmError; +use crate::llm::{ + CompletionRequest, CompletionResponse, LlmProvider, ToolCompletionRequest, + ToolCompletionResponse, +}; +use crate::worker::api::WorkerHttpClient; + +/// An LLM provider that routes all calls through the orchestrator's HTTP API. +/// +/// No API keys or secrets are needed in the container. The orchestrator +/// handles authentication and billing. +pub struct ProxyLlmProvider { + client: Arc, + model_name: String, +} + +impl ProxyLlmProvider { + pub fn new(client: Arc, model_name: String) -> Self { + Self { client, model_name } + } +} + +#[async_trait] +impl LlmProvider for ProxyLlmProvider { + fn model_name(&self) -> &str { + &self.model_name + } + + fn cost_per_token(&self) -> (Decimal, Decimal) { + // Cost tracking happens on the orchestrator side + (Decimal::ZERO, Decimal::ZERO) + } + + async fn complete(&self, request: CompletionRequest) -> Result { + self.client + .llm_complete(&request) + .await + .map_err(|e| LlmError::RequestFailed { + provider: "proxy".to_string(), + reason: e.to_string(), + }) + } + + async fn complete_with_tools( + &self, + request: ToolCompletionRequest, + ) -> Result { + self.client + .llm_complete_with_tools(&request) + .await + .map_err(|e| LlmError::RequestFailed { + provider: "proxy".to_string(), + reason: e.to_string(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_proxy_model_name() { + let client = Arc::new(WorkerHttpClient::new( + "http://localhost:50051".to_string(), + uuid::Uuid::nil(), + "test".to_string(), + )); + let provider = ProxyLlmProvider::new(client, "test-model".to_string()); + assert_eq!(provider.model_name(), "test-model"); + } + + #[test] + fn test_proxy_cost_is_zero() { + let client = Arc::new(WorkerHttpClient::new( + "http://localhost:50051".to_string(), + uuid::Uuid::nil(), + "test".to_string(), + )); + let provider = ProxyLlmProvider::new(client, "test-model".to_string()); + let (input, output) = provider.cost_per_token(); + assert_eq!(input, Decimal::ZERO); + assert_eq!(output, Decimal::ZERO); + } +} diff --git a/src/worker/runtime.rs b/src/worker/runtime.rs new file mode 100644 index 00000000..2d83d55f --- /dev/null +++ b/src/worker/runtime.rs @@ -0,0 +1,491 @@ +//! Worker runtime: the main execution loop inside a container. +//! +//! Reuses the existing `Reasoning` and `SafetyLayer` infrastructure but +//! connects to the orchestrator for LLM calls instead of calling APIs directly. +//! Streams real-time events (message, tool_use, tool_result, result) through +//! the orchestrator's job event pipeline for UI visibility. + +use std::sync::Arc; +use std::time::Duration; + +use uuid::Uuid; + +use crate::config::SafetyConfig; +use crate::context::JobContext; +use crate::error::WorkerError; +use crate::llm::{ + ChatMessage, LlmProvider, Reasoning, ReasoningContext, RespondResult, ToolSelection, +}; +use crate::safety::SafetyLayer; +use crate::tools::ToolRegistry; +use crate::worker::api::{CompletionReport, JobEventPayload, StatusUpdate, WorkerHttpClient}; +use crate::worker::proxy_llm::ProxyLlmProvider; + +/// Configuration for the worker runtime. +pub struct WorkerConfig { + pub job_id: Uuid, + pub orchestrator_url: String, + pub max_iterations: u32, + pub timeout: Duration, +} + +impl Default for WorkerConfig { + fn default() -> Self { + Self { + job_id: Uuid::nil(), + orchestrator_url: String::new(), + max_iterations: 50, + timeout: Duration::from_secs(600), + } + } +} + +/// The worker runtime runs inside a Docker container. +/// +/// It connects to the orchestrator over HTTP, fetches its job description, +/// then runs a tool execution loop until the job is complete. Events are +/// streamed to the orchestrator so the UI can show real-time progress. +pub struct WorkerRuntime { + config: WorkerConfig, + client: Arc, + llm: Arc, + safety: Arc, + tools: Arc, +} + +impl WorkerRuntime { + /// Create a new worker runtime. + /// + /// Reads `IRONCLAW_WORKER_TOKEN` from the environment for auth. + pub fn new(config: WorkerConfig) -> Result { + let client = Arc::new(WorkerHttpClient::from_env( + config.orchestrator_url.clone(), + config.job_id, + )?); + + let llm: Arc = Arc::new(ProxyLlmProvider::new( + Arc::clone(&client), + "proxied".to_string(), + )); + + let safety = Arc::new(SafetyLayer::new(&SafetyConfig { + max_output_length: 100_000, + injection_check_enabled: true, + })); + + let tools = Arc::new(ToolRegistry::new()); + // Register only container-safe tools + tools.register_container_tools(); + + Ok(Self { + config, + client, + llm, + safety, + tools, + }) + } + + /// Run the worker until the job is complete or an error occurs. + pub async fn run(self) -> Result<(), WorkerError> { + tracing::info!("Worker starting for job {}", self.config.job_id); + + // Fetch job description from orchestrator + let job = self.client.get_job().await?; + + tracing::info!( + "Received job: {} - {}", + job.title, + truncate(&job.description, 100) + ); + + // Report that we're starting + self.client + .report_status(&StatusUpdate { + state: "in_progress".to_string(), + message: Some("Worker started, beginning execution".to_string()), + iteration: 0, + }) + .await?; + + // Create reasoning engine + let reasoning = Reasoning::new(self.llm.clone(), self.safety.clone()); + + // Build initial context + let mut reason_ctx = ReasoningContext::new().with_job(&job.description); + + reason_ctx.messages.push(ChatMessage::system(format!( + r#"You are an autonomous agent running inside a Docker container. + +Job: {} +Description: {} + +You have tools for shell commands, file operations, and code editing. +Work independently to complete this job. Report when done."#, + job.title, job.description + ))); + + // Run with timeout + let result = tokio::time::timeout(self.config.timeout, async { + self.execution_loop(&reasoning, &mut reason_ctx).await + }) + .await; + + match result { + Ok(Ok(output)) => { + tracing::info!("Worker completed job {} successfully", self.config.job_id); + self.post_event( + "result", + serde_json::json!({ + "success": true, + "message": truncate(&output, 2000), + }), + ) + .await; + self.client + .report_complete(&CompletionReport { + success: true, + message: Some(output), + iterations: 0, + }) + .await?; + } + Ok(Err(e)) => { + tracing::error!("Worker failed for job {}: {}", self.config.job_id, e); + self.post_event( + "result", + serde_json::json!({ + "success": false, + "message": format!("Execution failed: {}", e), + }), + ) + .await; + self.client + .report_complete(&CompletionReport { + success: false, + message: Some(format!("Execution failed: {}", e)), + iterations: 0, + }) + .await?; + } + Err(_) => { + tracing::warn!("Worker timed out for job {}", self.config.job_id); + self.post_event( + "result", + serde_json::json!({ + "success": false, + "message": "Execution timed out", + }), + ) + .await; + self.client + .report_complete(&CompletionReport { + success: false, + message: Some("Execution timed out".to_string()), + iterations: 0, + }) + .await?; + } + } + + Ok(()) + } + + async fn execution_loop( + &self, + reasoning: &Reasoning, + reason_ctx: &mut ReasoningContext, + ) -> Result { + let max_iterations = self.config.max_iterations; + let mut last_output = String::new(); + + // Load tool definitions + reason_ctx.available_tools = self.tools.tool_definitions().await; + + for iteration in 1..=max_iterations { + // Report progress + if iteration % 5 == 1 { + let _ = self + .client + .report_status(&StatusUpdate { + state: "in_progress".to_string(), + message: Some(format!("Iteration {}", iteration)), + iteration, + }) + .await; + } + + // Poll for follow-up prompts from the user + self.poll_and_inject_prompt(reason_ctx).await; + + // Refresh tools (in case WASM tools were built) + reason_ctx.available_tools = self.tools.tool_definitions().await; + + // Ask the LLM what to do next + let selections = reasoning.select_tools(reason_ctx).await.map_err(|e| { + WorkerError::ExecutionFailed { + reason: format!("tool selection failed: {}", e), + } + })?; + + if selections.is_empty() { + // No tools selected, try direct response + let respond_result = + reasoning + .respond_with_tools(reason_ctx) + .await + .map_err(|e| WorkerError::ExecutionFailed { + reason: format!("respond_with_tools failed: {}", e), + })?; + + match respond_result { + RespondResult::Text(response) => { + self.post_event( + "message", + serde_json::json!({ + "role": "assistant", + "content": truncate(&response, 2000), + }), + ) + .await; + + let response_lower = response.to_lowercase(); + if response_lower.contains("complete") + || response_lower.contains("finished") + || response_lower.contains("done") + { + if last_output.is_empty() { + last_output = response.clone(); + } + return Ok(last_output); + } + reason_ctx.messages.push(ChatMessage::assistant(&response)); + } + RespondResult::ToolCalls { + tool_calls, + content, + } => { + if let Some(ref text) = content { + self.post_event( + "message", + serde_json::json!({ + "role": "assistant", + "content": truncate(text, 2000), + }), + ) + .await; + } + + // Add assistant message with tool_calls (OpenAI protocol) + reason_ctx + .messages + .push(ChatMessage::assistant_with_tool_calls( + content, + tool_calls.clone(), + )); + + for tc in tool_calls { + self.post_event( + "tool_use", + serde_json::json!({ + "tool_name": tc.name, + "input": truncate(&tc.arguments.to_string(), 500), + }), + ) + .await; + + let result = self.execute_tool(&tc.name, &tc.arguments).await; + + self.post_event( + "tool_result", + serde_json::json!({ + "tool_name": tc.name, + "output": match &result { + Ok(output) => truncate(output, 2000), + Err(e) => format!("Error: {}", truncate(e, 500)), + }, + "success": result.is_ok(), + }), + ) + .await; + + if let Ok(ref output) = result { + last_output = output.clone(); + } + let selection = ToolSelection { + tool_name: tc.name.clone(), + parameters: tc.arguments.clone(), + reasoning: String::new(), + alternatives: vec![], + }; + self.process_result(reason_ctx, &selection, result); + } + } + } + } else { + // Execute selected tools + for selection in &selections { + self.post_event( + "tool_use", + serde_json::json!({ + "tool_name": selection.tool_name, + "input": truncate(&selection.parameters.to_string(), 500), + }), + ) + .await; + + let result = self + .execute_tool(&selection.tool_name, &selection.parameters) + .await; + + self.post_event( + "tool_result", + serde_json::json!({ + "tool_name": selection.tool_name, + "output": match &result { + Ok(output) => truncate(output, 2000), + Err(e) => format!("Error: {}", truncate(e, 500)), + }, + "success": result.is_ok(), + }), + ) + .await; + + if let Ok(ref output) = result { + last_output = output.clone(); + } + + let completed = self.process_result(reason_ctx, selection, result); + if completed { + return Ok(last_output); + } + } + } + + // Brief pause between iterations + tokio::time::sleep(Duration::from_millis(100)).await; + } + + Err(WorkerError::ExecutionFailed { + reason: format!("max iterations ({}) exceeded", max_iterations), + }) + } + + async fn execute_tool( + &self, + tool_name: &str, + params: &serde_json::Value, + ) -> Result { + let tool = match self.tools.get(tool_name).await { + Some(t) => t, + None => return Err(format!("tool '{}' not found", tool_name)), + }; + + let ctx = JobContext::default(); + + // Validate params + let validation = self.safety.validator().validate_tool_params(params); + if !validation.is_valid { + let details = validation + .errors + .iter() + .map(|e| format!("{}: {}", e.field, e.message)) + .collect::>() + .join("; "); + return Err(format!("invalid parameters: {}", details)); + } + + // Execute with per-tool timeout + let tool_timeout = tool.execution_timeout(); + let result = tokio::time::timeout(tool_timeout, tool.execute(params.clone(), &ctx)).await; + + match result { + Ok(Ok(output)) => serde_json::to_string_pretty(&output.result) + .map_err(|e| format!("serialization error: {}", e)), + Ok(Err(e)) => Err(e.to_string()), + Err(_) => Err("tool execution timed out".to_string()), + } + } + + /// Process a tool result into the reasoning context. Returns true if the job is complete. + fn process_result( + &self, + reason_ctx: &mut ReasoningContext, + selection: &ToolSelection, + result: Result, + ) -> bool { + match result { + Ok(output) => { + let sanitized = self + .safety + .sanitize_tool_output(&selection.tool_name, &output); + let wrapped = self.safety.wrap_for_llm( + &selection.tool_name, + &sanitized.content, + sanitized.was_modified, + ); + + reason_ctx.messages.push(ChatMessage::tool_result( + "tool_call_id", + &selection.tool_name, + wrapped, + )); + + output.contains("TASK_COMPLETE") || output.contains("JOB_DONE") + } + Err(e) => { + tracing::warn!("Tool {} failed: {}", selection.tool_name, e); + reason_ctx.messages.push(ChatMessage::tool_result( + "tool_call_id", + &selection.tool_name, + format!("Error: {}", e), + )); + false + } + } + } + + /// Post a job event to the orchestrator (fire-and-forget). + async fn post_event(&self, event_type: &str, data: serde_json::Value) { + self.client + .post_event(&JobEventPayload { + event_type: event_type.to_string(), + data, + }) + .await; + } + + /// Poll the orchestrator for a follow-up prompt. If one is available, + /// inject it as a user message into the reasoning context. + async fn poll_and_inject_prompt(&self, reason_ctx: &mut ReasoningContext) { + match self.client.poll_prompt().await { + Ok(Some(prompt)) => { + tracing::info!( + "Received follow-up prompt: {}", + truncate(&prompt.content, 100) + ); + self.post_event( + "message", + serde_json::json!({ + "role": "user", + "content": truncate(&prompt.content, 2000), + }), + ) + .await; + reason_ctx.messages.push(ChatMessage::user(&prompt.content)); + } + Ok(None) => {} + Err(e) => { + tracing::debug!("Failed to poll for prompt: {}", e); + } + } + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}...", &s[..max]) + } +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 24f77217..548b5718 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -442,6 +442,97 @@ impl Workspace { Ok(()) } + // ==================== Seeding ==================== + + /// Seed any missing core identity files in the workspace. + /// + /// Called on every boot. Only creates files that don't already exist, + /// so user edits are never overwritten. Returns the number of files + /// created (0 if all core files already existed). + pub async fn seed_if_empty(&self) -> Result { + let seed_files: &[(&str, &str)] = &[ + ( + paths::README, + "# Workspace\n\n\ + This is your agent's persistent memory. Files here are indexed for search\n\ + and used to build the agent's context.\n\n\ + ## Structure\n\n\ + - `MEMORY.md` - Long-term notes and facts worth remembering\n\ + - `IDENTITY.md` - Agent name, nature, personality\n\ + - `SOUL.md` - Core values and principles\n\ + - `AGENTS.md` - Behavior instructions for the agent\n\ + - `USER.md` - Information about you (the user)\n\ + - `HEARTBEAT.md` - Periodic background task checklist\n\ + - `daily/` - Automatic daily session logs\n\ + - `context/` - Additional context documents\n\n\ + Edit these files to shape how your agent thinks and acts.", + ), + ( + paths::MEMORY, + "# Memory\n\n\ + Long-term notes, decisions, and facts worth remembering.\n\ + The agent appends here during conversations.", + ), + ( + paths::IDENTITY, + "# Identity\n\n\ + Name: IronClaw\n\ + Nature: A secure personal AI assistant\n\n\ + Edit this file to give your agent a custom name and personality.", + ), + ( + paths::SOUL, + "# Core Values\n\n\ + - Protect user privacy and data security above all else\n\ + - Be honest about limitations and uncertainty\n\ + - Prefer action over lengthy deliberation\n\ + - Ask for clarification rather than guessing on important decisions\n\ + - Learn from mistakes and remember lessons", + ), + ( + paths::AGENTS, + "# Agent Instructions\n\n\ + You are a personal AI assistant with access to tools and persistent memory.\n\n\ + ## Guidelines\n\n\ + - Always search memory before answering questions about prior conversations\n\ + - Write important facts and decisions to memory for future reference\n\ + - Use the daily log for session-level notes\n\ + - Be concise but thorough", + ), + ( + paths::USER, + "# User Context\n\n\ + The agent will fill this in as it learns about you.\n\ + You can also edit this directly to provide context upfront.", + ), + (paths::HEARTBEAT, HEARTBEAT_SEED), + ]; + + let mut count = 0; + for (path, content) in seed_files { + // Skip files that already exist (never overwrite user edits) + match self.read(path).await { + Ok(_) => continue, + Err(WorkspaceError::DocumentNotFound { .. }) => {} + Err(e) => { + tracing::warn!("Failed to check {}: {}", path, e); + continue; + } + } + + if let Err(e) = self.write(path, content).await { + tracing::warn!("Failed to seed {}: {}", path, e); + } else { + count += 1; + } + } + + if count > 0 { + tracing::info!("Seeded {} workspace files", count); + } + Ok(count) + } + /// Generate embeddings for chunks that don't have them yet. /// /// This is useful for backfilling embeddings after enabling the provider. diff --git a/tests/ws_gateway_integration.rs b/tests/ws_gateway_integration.rs index a93bc49d..80bcc3c9 100644 --- a/tests/ws_gateway_integration.rs +++ b/tests/ws_gateway_integration.rs @@ -41,11 +41,13 @@ async fn start_test_server() -> ( msg_tx: tokio::sync::RwLock::new(Some(agent_tx)), sse: SseManager::new(), workspace: None, - context_manager: None, session_manager: None, log_broadcaster: None, extension_manager: None, tool_registry: None, + store: None, + job_manager: None, + prompt_queue: None, user_id: "test-user".to_string(), shutdown_tx: tokio::sync::RwLock::new(None), ws_tracker: Some(Arc::new(WsConnectionTracker::new())), @@ -162,6 +164,7 @@ async fn test_ws_thinking_event() { state.sse.broadcast(SseEvent::Thinking { message: "analyzing...".to_string(), + thread_id: None, }); let text = recv_text(&mut ws).await; @@ -286,13 +289,16 @@ async fn test_ws_multiple_events_in_sequence() { // Broadcast multiple events rapidly state.sse.broadcast(SseEvent::Thinking { message: "step 1".to_string(), + thread_id: None, }); state.sse.broadcast(SseEvent::ToolStarted { name: "shell".to_string(), + thread_id: None, }); state.sse.broadcast(SseEvent::ToolCompleted { name: "shell".to_string(), success: true, + thread_id: None, }); state.sse.broadcast(SseEvent::Response { content: "done".to_string(), diff --git a/tools-src/gmail/src/api.rs b/tools-src/gmail/src/api.rs index a84c1f81..f1bc9764 100644 --- a/tools-src/gmail/src/api.rs +++ b/tools-src/gmail/src/api.rs @@ -26,7 +26,7 @@ fn api_call(method: &str, path: &str, body: Option<&str>) -> Result= 300 { let body_text = String::from_utf8_lossy(&response.body); @@ -389,7 +389,7 @@ const BASE64URL_CHARS: &[u8; 64] = /// Base64url-encode bytes (no padding, URL-safe alphabet). fn base64url_encode(input: &[u8]) -> String { - let mut result = String::with_capacity((input.len() + 2) / 3 * 4); + let mut result = String::with_capacity(input.len().div_ceil(3) * 4); for chunk in input.chunks(3) { let b0 = chunk[0] as u32; diff --git a/tools-src/google-calendar/src/api.rs b/tools-src/google-calendar/src/api.rs index ead7f7ea..c826a7fd 100644 --- a/tools-src/google-calendar/src/api.rs +++ b/tools-src/google-calendar/src/api.rs @@ -26,7 +26,7 @@ fn api_call(method: &str, path: &str, body: Option<&str>) -> Result= 300 { let body_text = String::from_utf8_lossy(&response.body); @@ -144,64 +144,68 @@ pub fn get_event(calendar_id: &str, event_id: &str) -> Result { + pub calendar_id: &'a str, + pub summary: &'a str, + pub description: Option<&'a str>, + pub location: Option<&'a str>, + pub start_datetime: Option<&'a str>, + pub end_datetime: Option<&'a str>, + pub start_date: Option<&'a str>, + pub end_date: Option<&'a str>, + pub timezone: Option<&'a str>, + pub attendees: &'a [String], +} + /// Create a new event. -pub fn create_event( - calendar_id: &str, - summary: &str, - description: Option<&str>, - location: Option<&str>, - start_datetime: Option<&str>, - end_datetime: Option<&str>, - start_date: Option<&str>, - end_date: Option<&str>, - timezone: Option<&str>, - attendees: &[String], -) -> Result { +pub fn create_event(p: &CreateEventParams<'_>) -> Result { let mut event = serde_json::json!({ - "summary": summary, + "summary": p.summary, }); - if let Some(desc) = description { + if let Some(desc) = p.description { event["description"] = serde_json::Value::String(desc.to_string()); } - if let Some(loc) = location { + if let Some(loc) = p.location { event["location"] = serde_json::Value::String(loc.to_string()); } // Build start/end, preferring datetime over date - if let Some(dt) = start_datetime { + if let Some(dt) = p.start_datetime { let mut start = serde_json::json!({ "dateTime": dt }); - if let Some(tz) = timezone { + if let Some(tz) = p.timezone { start["timeZone"] = serde_json::Value::String(tz.to_string()); } event["start"] = start; - } else if let Some(d) = start_date { + } else if let Some(d) = p.start_date { event["start"] = serde_json::json!({ "date": d }); } else { return Err("Either start_datetime or start_date is required".to_string()); } - if let Some(dt) = end_datetime { + if let Some(dt) = p.end_datetime { let mut end = serde_json::json!({ "dateTime": dt }); - if let Some(tz) = timezone { + if let Some(tz) = p.timezone { end["timeZone"] = serde_json::Value::String(tz.to_string()); } event["end"] = end; - } else if let Some(d) = end_date { + } else if let Some(d) = p.end_date { event["end"] = serde_json::json!({ "date": d }); } else { return Err("Either end_datetime or end_date is required".to_string()); } - if !attendees.is_empty() { - event["attendees"] = serde_json::json!(attendees + if !p.attendees.is_empty() { + event["attendees"] = serde_json::json!(p + .attendees .iter() .map(|e| serde_json::json!({ "email": e })) .collect::>()); } let body = serde_json::to_string(&event).map_err(|e| e.to_string())?; - let path = format!("calendars/{}/events", url_encode(calendar_id)); + let path = format!("calendars/{}/events", url_encode(p.calendar_id)); let response = api_call("POST", &path, Some(&body))?; let parsed: serde_json::Value = @@ -212,53 +216,56 @@ pub fn create_event( }) } +/// Parameters for updating a calendar event. +pub struct UpdateEventParams<'a> { + pub calendar_id: &'a str, + pub event_id: &'a str, + pub summary: Option<&'a str>, + pub description: Option<&'a str>, + pub location: Option<&'a str>, + pub start_datetime: Option<&'a str>, + pub end_datetime: Option<&'a str>, + pub start_date: Option<&'a str>, + pub end_date: Option<&'a str>, + pub timezone: Option<&'a str>, + pub attendees: Option<&'a [String]>, +} + /// Update an existing event (PATCH for partial updates). -pub fn update_event( - calendar_id: &str, - event_id: &str, - summary: Option<&str>, - description: Option<&str>, - location: Option<&str>, - start_datetime: Option<&str>, - end_datetime: Option<&str>, - start_date: Option<&str>, - end_date: Option<&str>, - timezone: Option<&str>, - attendees: Option<&[String]>, -) -> Result { +pub fn update_event(p: &UpdateEventParams<'_>) -> Result { let mut patch = serde_json::json!({}); - if let Some(s) = summary { + if let Some(s) = p.summary { patch["summary"] = serde_json::Value::String(s.to_string()); } - if let Some(d) = description { + if let Some(d) = p.description { patch["description"] = serde_json::Value::String(d.to_string()); } - if let Some(l) = location { + if let Some(l) = p.location { patch["location"] = serde_json::Value::String(l.to_string()); } - if let Some(dt) = start_datetime { + if let Some(dt) = p.start_datetime { let mut start = serde_json::json!({ "dateTime": dt }); - if let Some(tz) = timezone { + if let Some(tz) = p.timezone { start["timeZone"] = serde_json::Value::String(tz.to_string()); } patch["start"] = start; - } else if let Some(d) = start_date { + } else if let Some(d) = p.start_date { patch["start"] = serde_json::json!({ "date": d }); } - if let Some(dt) = end_datetime { + if let Some(dt) = p.end_datetime { let mut end = serde_json::json!({ "dateTime": dt }); - if let Some(tz) = timezone { + if let Some(tz) = p.timezone { end["timeZone"] = serde_json::Value::String(tz.to_string()); } patch["end"] = end; - } else if let Some(d) = end_date { + } else if let Some(d) = p.end_date { patch["end"] = serde_json::json!({ "date": d }); } - if let Some(att) = attendees { + if let Some(att) = p.attendees { patch["attendees"] = serde_json::json!(att .iter() .map(|e| serde_json::json!({ "email": e })) @@ -268,8 +275,8 @@ pub fn update_event( let body = serde_json::to_string(&patch).map_err(|e| e.to_string())?; let path = format!( "calendars/{}/events/{}", - url_encode(calendar_id), - url_encode(event_id) + url_encode(p.calendar_id), + url_encode(p.event_id) ); let response = api_call("PATCH", &path, Some(&body))?; diff --git a/tools-src/google-calendar/src/lib.rs b/tools-src/google-calendar/src/lib.rs index bd13506c..70f62d63 100644 --- a/tools-src/google-calendar/src/lib.rs +++ b/tools-src/google-calendar/src/lib.rs @@ -279,18 +279,18 @@ fn execute_inner(params: &str) -> Result { timezone, attendees, } => { - let result = api::create_event( - &calendar_id, - &summary, - description.as_deref(), - location.as_deref(), - start_datetime.as_deref(), - end_datetime.as_deref(), - start_date.as_deref(), - end_date.as_deref(), - timezone.as_deref(), - &attendees, - )?; + let result = api::create_event(&api::CreateEventParams { + calendar_id: &calendar_id, + summary: &summary, + description: description.as_deref(), + location: location.as_deref(), + start_datetime: start_datetime.as_deref(), + end_datetime: end_datetime.as_deref(), + start_date: start_date.as_deref(), + end_date: end_date.as_deref(), + timezone: timezone.as_deref(), + attendees: &attendees, + })?; serde_json::to_string(&result).map_err(|e| e.to_string())? } @@ -307,19 +307,19 @@ fn execute_inner(params: &str) -> Result { timezone, attendees, } => { - let result = api::update_event( - &calendar_id, - &event_id, - summary.as_deref(), - description.as_deref(), - location.as_deref(), - start_datetime.as_deref(), - end_datetime.as_deref(), - start_date.as_deref(), - end_date.as_deref(), - timezone.as_deref(), - attendees.as_deref(), - )?; + let result = api::update_event(&api::UpdateEventParams { + calendar_id: &calendar_id, + event_id: &event_id, + summary: summary.as_deref(), + description: description.as_deref(), + location: location.as_deref(), + start_datetime: start_datetime.as_deref(), + end_datetime: end_datetime.as_deref(), + start_date: start_date.as_deref(), + end_date: end_date.as_deref(), + timezone: timezone.as_deref(), + attendees: attendees.as_deref(), + })?; serde_json::to_string(&result).map_err(|e| e.to_string())? } diff --git a/tools-src/google-docs/src/api.rs b/tools-src/google-docs/src/api.rs index 2e90c744..185ae979 100644 --- a/tools-src/google-docs/src/api.rs +++ b/tools-src/google-docs/src/api.rs @@ -30,7 +30,7 @@ fn api_call(method: &str, path: &str, body: Option<&str>) -> Result= 300 { let body_text = String::from_utf8_lossy(&response.body); diff --git a/tools-src/google-drive/src/api.rs b/tools-src/google-drive/src/api.rs index d8e2d3f3..5b812032 100644 --- a/tools-src/google-drive/src/api.rs +++ b/tools-src/google-drive/src/api.rs @@ -32,7 +32,7 @@ fn api_call(method: &str, path: &str, body: Option<&str>) -> Result= 300 { let body_text = String::from_utf8_lossy(&response.body); @@ -56,7 +56,7 @@ fn api_call_raw(method: &str, url: &str) -> Result, String> { &format!("Drive API raw: {} {}", method, url), ); - let response = host::http_request(method, url, "{}", None)?; + let response = host::http_request(method, url, "{}", None, None)?; if response.status < 200 || response.status >= 300 { let body_text = String::from_utf8_lossy(&response.body); @@ -262,7 +262,7 @@ pub fn upload_file( "Drive API: POST upload/files (multipart)", ); - let response = host::http_request("POST", &url, &headers, Some(body.as_bytes()))?; + let response = host::http_request("POST", &url, &headers, Some(body.as_bytes()), None)?; if response.status < 200 || response.status >= 300 { let body_text = String::from_utf8_lossy(&response.body); diff --git a/tools-src/google-sheets/src/api.rs b/tools-src/google-sheets/src/api.rs index 934388b2..4d7af5ca 100644 --- a/tools-src/google-sheets/src/api.rs +++ b/tools-src/google-sheets/src/api.rs @@ -30,7 +30,7 @@ fn api_call(method: &str, path: &str, body: Option<&str>) -> Result= 300 { let body_text = String::from_utf8_lossy(&response.body); diff --git a/tools-src/google-slides/src/api.rs b/tools-src/google-slides/src/api.rs index 4f85adf6..f0bd8bc4 100644 --- a/tools-src/google-slides/src/api.rs +++ b/tools-src/google-slides/src/api.rs @@ -30,7 +30,7 @@ fn api_call(method: &str, path: &str, body: Option<&str>) -> Result= 300 { let body_text = String::from_utf8_lossy(&response.body); diff --git a/tools-src/slack/src/api.rs b/tools-src/slack/src/api.rs index cae7506b..56eabf54 100644 --- a/tools-src/slack/src/api.rs +++ b/tools-src/slack/src/api.rs @@ -45,7 +45,7 @@ fn slack_api_call(method: &str, endpoint: &str, body: Option<&str>) -> Result= 300 { return Err(format!( diff --git a/tools-src/telegram/src/transport.rs b/tools-src/telegram/src/transport.rs index 48624ef9..54cfe575 100644 --- a/tools-src/telegram/src/transport.rs +++ b/tools-src/telegram/src/transport.rs @@ -89,7 +89,7 @@ pub fn post_encrypted( /// HTTP POST with raw binary body via the WASM host's http-request capability. fn http_post_binary(url: &str, body: &[u8]) -> Result, String> { - let resp = host::http_request("POST", url, "{}", Some(body))?; + let resp = host::http_request("POST", url, "{}", Some(body), None)?; if resp.status < 200 || resp.status >= 300 { let body_text = String::from_utf8_lossy(&resp.body); diff --git a/wit/channel.wit b/wit/channel.wit index 99ea8160..48fba6be 100644 --- a/wit/channel.wit +++ b/wit/channel.wit @@ -90,11 +90,17 @@ interface channel-host { /// - Credentials are injected by the host; WASM never sees them /// - Response is scanned for leaked secrets before returning /// - Rate-limited per channel + /// + /// The optional timeout-ms parameter controls the HTTP client timeout + /// in milliseconds. Defaults to 30000 (30s) when not provided. Use a + /// longer timeout for long-polling requests (e.g., Telegram getUpdates). + /// Capped at the channel's callback_timeout to prevent hangs. http-request: func( method: string, url: string, headers-json: string, - body: option> + body: option>, + timeout-ms: option, ) -> result; /// Check if a secret exists (if capability granted). diff --git a/wit/tool.wit b/wit/tool.wit index a6a45a3d..743a0121 100644 --- a/wit/tool.wit +++ b/wit/tool.wit @@ -67,11 +67,16 @@ interface host { /// - Network error /// - Timeout /// - Secret leak detected in response + /// + /// The optional timeout-ms parameter controls the HTTP client timeout + /// in milliseconds. Defaults to 30000 (30s) when not provided. + /// Capped at the callback timeout to prevent hangs. http-request: func( method: string, url: string, headers-json: string, - body: option> + body: option>, + timeout-ms: option, ) -> result; // ==================== Tool Invocation Capability ====================