diff --git a/cmd/destila-mcp/README.md b/cmd/destila-mcp/README.md new file mode 100644 index 0000000..c0cc314 --- /dev/null +++ b/cmd/destila-mcp/README.md @@ -0,0 +1,36 @@ +# destila-mcp + +Stdio MCP bridge that translates `claude` CLI tool calls to Destila's +HTTP+SSE endpoint. + +## Build + +```sh +cd cmd/destila-mcp +go build -o destila-mcp ./... +``` + +The resulting binary is referenced from Destila's per-session +`.mcp.json` files (see `Destila.Agent.McpConfigWriter`). + +## Environment + +| Variable | Purpose | +|----------------------|-----------------------------------------------| +| `DESTILA_SESSION_ID` | The agent session id chosen by Destila | +| `DESTILA_MCP_TOKEN` | Bearer token (same as `:destila, :mcp_token`) | +| `DESTILA_MCP_URL` | e.g. `http://127.0.0.1:4000/mcp` | + +## Scope + +This bridge implements only the subset of MCP needed by Claude Code: + +- `initialize` +- `tools/list` +- `tools/call` +- `notifications/initialized` +- `notifications/cancelled` +- `ping` + +It forwards each frame verbatim to Destila — wire-protocol changes can be +absorbed here without touching the Elixir code. diff --git a/cmd/destila-mcp/go.mod b/cmd/destila-mcp/go.mod new file mode 100644 index 0000000..4b3f019 --- /dev/null +++ b/cmd/destila-mcp/go.mod @@ -0,0 +1,3 @@ +module github.com/destila/destila-mcp + +go 1.22 diff --git a/cmd/destila-mcp/internal/httpclient/client.go b/cmd/destila-mcp/internal/httpclient/client.go new file mode 100644 index 0000000..a18ad2a --- /dev/null +++ b/cmd/destila-mcp/internal/httpclient/client.go @@ -0,0 +1,64 @@ +// Package httpclient is the bridge's outward face to Destila. +// +// Sends each JSON-RPC message as a POST to /mcp//rpc with a +// Bearer token, an X-Destila-Session-Id header, and an X-Destila-Bridge-Version +// header. Returns the raw response body (or empty for HTTP 204). +package httpclient + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const BridgeVersion = "0.1.0" + +type Client struct { + BaseURL string + Token string + SessionID string + HTTP *http.Client +} + +func New(baseURL, token, sessionID string, httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = http.DefaultClient + } + return &Client{BaseURL: baseURL, Token: token, SessionID: sessionID, HTTP: httpClient} +} + +func (c *Client) PostRPC(payload json.RawMessage) ([]byte, error) { + url := fmt.Sprintf("%s/%s/rpc", c.BaseURL, c.SessionID) + + req, err := http.NewRequest("POST", url, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("X-Destila-Session-Id", c.SessionID) + req.Header.Set("X-Destila-Bridge-Version", BridgeVersion) + + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode == http.StatusNoContent { + return nil, nil + } + + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("destila returned HTTP %d: %s", resp.StatusCode, string(body)) + } + + return body, nil +} diff --git a/cmd/destila-mcp/internal/mcpstdio/stdio.go b/cmd/destila-mcp/internal/mcpstdio/stdio.go new file mode 100644 index 0000000..21efbd2 --- /dev/null +++ b/cmd/destila-mcp/internal/mcpstdio/stdio.go @@ -0,0 +1,59 @@ +// Package mcpstdio implements LSP-style framed JSON-RPC over stdio. +// +// Each message is preceded by a `Content-Length: \r\n\r\n` header. +// This is the framing the Claude Code MCP client uses on stdio transports. +package mcpstdio + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" +) + +// ReadMessage reads one framed JSON message from r. Returns the raw JSON body. +func ReadMessage(r *bufio.Reader) (json.RawMessage, error) { + var contentLength int + + for { + line, err := r.ReadString('\n') + if err != nil { + return nil, err + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + break + } + if strings.HasPrefix(strings.ToLower(line), "content-length:") { + v := strings.TrimSpace(line[len("Content-Length:"):]) + n, err := strconv.Atoi(v) + if err != nil { + return nil, fmt.Errorf("invalid Content-Length: %w", err) + } + contentLength = n + } + } + + if contentLength <= 0 { + return nil, fmt.Errorf("missing or zero Content-Length") + } + + buf := make([]byte, contentLength) + if _, err := io.ReadFull(r, buf); err != nil { + return nil, err + } + + return buf, nil +} + +// WriteMessage writes one framed JSON message to w. +func WriteMessage(w io.Writer, payload []byte) error { + header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(payload)) + if _, err := w.Write([]byte(header)); err != nil { + return err + } + _, err := w.Write(payload) + return err +} diff --git a/cmd/destila-mcp/main.go b/cmd/destila-mcp/main.go new file mode 100644 index 0000000..c85db46 --- /dev/null +++ b/cmd/destila-mcp/main.go @@ -0,0 +1,90 @@ +// Destila MCP bridge. +// +// Speaks stdio-MCP on its inward face (to claude) and translates each +// tools/call (and other JSON-RPC methods) to Destila's HTTP+SSE endpoint +// on its outward face. The bridge insulates Destila from changes in the +// MCP wire protocol — only this binary needs to track upstream MCP drift. +// +// Environment variables required: +// DESTILA_SESSION_ID - per-session id chosen by Destila +// DESTILA_MCP_TOKEN - global bearer token +// DESTILA_MCP_URL - e.g. http://127.0.0.1:4000/mcp +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/destila/destila-mcp/internal/httpclient" + "github.com/destila/destila-mcp/internal/mcpstdio" +) + +func main() { + sessionID := os.Getenv("DESTILA_SESSION_ID") + token := os.Getenv("DESTILA_MCP_TOKEN") + url := os.Getenv("DESTILA_MCP_URL") + + if sessionID == "" || token == "" || url == "" { + fmt.Fprintln(os.Stderr, "DESTILA_SESSION_ID, DESTILA_MCP_TOKEN, DESTILA_MCP_URL must be set") + os.Exit(2) + } + + client := httpclient.New(url, token, sessionID, http.DefaultClient) + + stdin := bufio.NewReader(os.Stdin) + stdout := os.Stdout + + for { + msg, err := mcpstdio.ReadMessage(stdin) + if err == io.EOF { + return + } + if err != nil { + fmt.Fprintf(os.Stderr, "bridge read error: %v\n", err) + return + } + + // Forward to Destila as JSON-RPC over HTTP. + respBody, err := client.PostRPC(msg) + if err != nil { + writeErrorResponse(stdout, msg, fmt.Sprintf("HTTP error: %v", err)) + continue + } + + if len(respBody) == 0 { + // 204 No Content — notifications produce no reply. + continue + } + + // Validate JSON and forward verbatim. + var anyJSON json.RawMessage + if err := json.Unmarshal(respBody, &anyJSON); err != nil { + writeErrorResponse(stdout, msg, fmt.Sprintf("invalid response from server: %v", err)) + continue + } + + if err := mcpstdio.WriteMessage(stdout, respBody); err != nil { + fmt.Fprintf(os.Stderr, "bridge write error: %v\n", err) + return + } + } +} + +func writeErrorResponse(w io.Writer, req json.RawMessage, message string) { + var parsed struct { + ID json.RawMessage `json:"id"` + } + _ = json.Unmarshal(req, &parsed) + + resp, _ := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "id": parsed.ID, + "error": map[string]interface{}{"code": -32603, "message": message}, + }) + + _ = mcpstdio.WriteMessage(w, resp) +} diff --git a/config/runtime.exs b/config/runtime.exs index d9d2b8b..dee7178 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -39,6 +39,26 @@ config :destila, :proxy, basic_auth_user: System.get_env("DESTILA_BASIC_AUTH_USER"), basic_auth_password: System.get_env("DESTILA_BASIC_AUTH_PASSWORD") +# DESTILA_MCP_TOKEN authenticates MCP clients (the Go bridge) against the +# Phoenix /mcp endpoint. Required in prod; a documented dev-only default +# is used otherwise. +config :destila, + :mcp_token, + System.get_env("DESTILA_MCP_TOKEN") || + if(config_env() == :prod, + do: + raise(""" + environment variable DESTILA_MCP_TOKEN is missing. + Set it to a strong random value to authenticate the MCP bridge. + """), + else: "destila-dev-only-token" + ) + +config :destila, + :mcp_bridge_path, + System.get_env("DESTILA_MCP_BRIDGE_PATH") || + Path.expand("../cmd/destila-mcp/destila-mcp", __DIR__) + if config_env() == :prod do database_path = System.get_env("DATABASE_PATH") || diff --git a/docs/mcp_smoke_test.md b/docs/mcp_smoke_test.md new file mode 100644 index 0000000..98e3fbc --- /dev/null +++ b/docs/mcp_smoke_test.md @@ -0,0 +1,37 @@ +# MCP smoke test + +Manual smoke test for the HTTP+SSE MCP transport. Lives in +`scripts/mcp_smoke.sh`. + +## When to run it + +Before any release that touches the new agent path (anything under +`lib/destila/agent/`, `lib/destila_web/mcp/`, or `cmd/destila-mcp/`). + +## How to run it + +1. Start the dev server: + ```sh + elixir --sname destila -S mix phx.server + ``` +2. In another shell: + ```sh + ./scripts/mcp_smoke.sh + ``` + +The script: +- Builds the Go bridge into `cmd/destila-mcp/destila-mcp`. +- POSTs a `tools/list` JSON-RPC envelope at `/mcp//rpc` with the + default dev token. +- Asserts HTTP 200 and a `"tools"` field in the response. + +Set `DESTILA_MCP_TOKEN` and `DESTILA_MCP_URL` to override defaults. + +## Troubleshooting + +- `401 unauthorized` — your `DESTILA_MCP_TOKEN` doesn't match the dev + default `destila-dev-only-token`. Set the env var to match. +- `connection refused` — the dev server isn't running, or it's on a + different port. Check `config/dev.exs` and `PORT`. +- The script intentionally does not attempt to drive a real `claude` + CLI when the binary is not installed. diff --git a/docs/plans/2026-05-20-001-feat-mcp-driven-agent-sessions-plan.md b/docs/plans/2026-05-20-001-feat-mcp-driven-agent-sessions-plan.md new file mode 100644 index 0000000..7e66db8 --- /dev/null +++ b/docs/plans/2026-05-20-001-feat-mcp-driven-agent-sessions-plan.md @@ -0,0 +1,869 @@ +--- +title: "feat: MCP-driven agent sessions (parallel path)" +type: feat +status: active +created: 2026-05-20 +deepened: 2026-05-20 +depth: deep +--- + +# feat: MCP-driven agent sessions (parallel path) + +## Summary + +Destila is losing access to the agent's assistant message stream due to a provider ToS change. We pivot the agent-interaction model so Destila operates as an **MCP server**, and the agent communicates back **only via MCP tool calls** — never via parsed assistant text. The user interacts with the agent directly (in Destila's embedded terminal UI or in their own external Claude Code CLI), not through a Destila-owned chat textarea. + +This work introduces a **new agent-driven path that runs in parallel with the existing chat-based flow**. The existing chat path (`WorkflowRunnerLive`, `Destila.AI.{ClaudeSession, Conversation, ResponseProcessor, History}`, the 27 existing `.feature` files, the chat workflow Elixir modules under `lib/destila/workflows/`) stays fully functional and untouched until a later cutover. Build the new path alongside, do not refactor the old one. + +The largest unknown is Claude Code's tolerance for our hand-rolled HTTP+SSE MCP transport — that smoke test is front-loaded as U1 and gates investment in the rest of the work. + +--- + +## Problem Frame + +- The current path reads the agent's assistant text to drive phase transitions, extract exports, and display chat bubbles. That reading channel is going away. +- The replacement must be **agent-driven via explicit MCP tool calls**: phase transitions happen only when the agent calls `mcp__destila__session` with `phase_complete`/`suggest_phase_complete`; exports happen only when the agent calls it with `export`. +- We must support **two host modes**: an embedded terminal Destila controls (where it can push to the agent's stdin) and a fully external Claude Code CLI on the user's machine (where Destila is a pure MCP server with no process-lifecycle authority). +- Destila must not store or parse assistant text anywhere in the new path. The only persisted session content is tool-call events and exports. +- The chat path must remain fully functional throughout; this is a **strict parallel rollout**, not a refactor. + +--- + +## Scope Boundaries + +### In scope + +- New HTTP+SSE MCP server endpoint on Phoenix, authenticated by a global token from `config/runtime.exs`. +- A Go bridge CLI at `cmd/destila-mcp/` that exposes a stdio MCP server to the agent and translates calls to Destila's HTTP+SSE endpoint. +- New `Agent*` modules (`AgentSession`, `AgentSessionSupervisor`, `AgentSessionLive`, etc.) running alongside chat modules. +- New `agent_sessions` and `agent_session_events` tables; extension of `workflow_session_metadata` with a nullable `agent_session_id` FK so exports reuse the proven infrastructure. +- YAML workflow loader for `priv/workflows/*.yaml`. +- Embedded host mode reusing `Destila.Terminal` (PTY + tmux + xterm.js). +- External CLI host mode with paste-in-UI fallback for kickoff prompts and `ask_user_question` answers. +- Explicit-only phase transitions and exports through the existing `mcp__destila__session` tool surface (transport changes; tool semantics do not). +- Non-blocking `ask_user_question` semantics — MCP tool call returns immediately; answer delivery is out-of-band via stdin (embedded) or paste (external). +- Export-first session UI (`AgentSessionLive`) with collapsible secondary tool-call event log. +- New `features/mcp_driven_session.feature` with the seven sections specified in the request. +- Mock-MCP LiveView test harness as the primary test layer; manual smoke test of the real Go bridge against the dev server. + +### Out of scope (strict) + +- Any modification to `WorkflowRunnerLive`, `Destila.AI.{ClaudeSession, Conversation, ResponseProcessor, History}`, `DestilaWeb.ChatComponents`, or the chat workflow modules under `lib/destila/workflows/*_workflow.ex`. +- Any modification to the existing 27 `.feature` files. +- Migration of existing chat workflows into the YAML format. +- Multi-tenant authentication or per-session tokens — global token is sufficient for the current single-user deployment. +- Destila managing the external `claude` process lifecycle. +- Storing or parsing any agent assistant text in the new path. + +### Deferred to follow-up work + +- Cutover plan that retires the chat path and removes its modules and feature files. +- Per-session or scoped tokens once multi-user support is on the roadmap. +- Migrating chat workflow Elixir modules to YAML. +- Richer export rendering (diffs, side-by-side viewers) beyond what `workflow_session_metadata` already supports. +- Observability for the HTTP+SSE transport (structured access logs, latency metrics) once we have a baseline. + +--- + +## Key Technical Decisions + +1. **Hand-roll the HTTP+SSE MCP transport on Phoenix.** `ClaudeCode.MCP.Server` (used today in `lib/destila/ai/tools.ex`) is in-process only — the agent process living outside the BEAM cannot reach it through the existing macro. We implement JSON-RPC over HTTP for client→server calls and a long-lived SSE stream for server→client notifications, mounted under a new `/mcp` scope on `DestilaWeb.Endpoint`. + +2. **Global token in `Authorization: Bearer …` header.** Read from `runtime.exs` (env `DESTILA_MCP_TOKEN`). Single token grants full access; the session id claimed by the bridge (via the `DESTILA_SESSION_ID` env var, forwarded as a header) is trusted once the token validates. Acceptable for single-user deployment; documented as a trust-model decision rather than a hardened multi-tenant boundary. + +3. **Go bridge as a separate release artifact under `cmd/destila-mcp/`.** This repo has no Go code today; the bridge is greenfield. It is shipped separately from the Elixir release. Internal Elixir code never depends on the Go bridge. + +4. **Reuse the existing tool surface.** The three tools defined in `lib/destila/ai/tools.ex` (`ask_user_question`, `session`, `service`) keep their JSON schemas exactly. Only the dispatch path changes — new hand-rolled handlers in `lib/destila/agent/tools/*.ex` rather than `ClaudeCode.MCP.Server` callbacks. The new path adds a fourth tool — `mcp__destila__exports_read` — so post-handoff agents can recover earlier-phase exports without inheriting prior conversational context. + +5. **Reuse `Destila.Terminal` in embedded host mode.** PTY + tmux + xterm.js (`assets/js/hooks/xterm_hook.js`) are already production-tested. We add a thin launcher that writes a per-session `.mcp.json`, sets `DESTILA_SESSION_ID`, and starts `claude` inside `Destila.Terminal.Server`. Stdin pushes go through the terminal's existing input channel. + +6. **Phase handoff in embedded mode = stop old `claude`, start fresh one.** The old conversational context is intentionally discarded. The new agent reads prior exports via the new `mcp__destila__exports_read` tool to recover the context it needs. This matches the user's intent: phases are independent agents, not a single long-running session. + +7. **YAML workflows in `priv/workflows/*.yaml`.** Each file carries `name` and `phases: [{name, system_prompt, kickoff_prompt, agent_command}]`. Adds `:yaml_elixir` as a dependency — no existing YAML parser in the project (closest is the hand-rolled frontmatter parser in `lib/destila/workflows/skills.ex`, which is too narrow). Loaded once at app boot into `:persistent_term` for cheap lookup. + +8. **New tables, extend existing exports table.** Two new tables (`agent_sessions`, `agent_session_events`) cover lifecycle and the tool-call event log. The existing `workflow_session_metadata` table gains a nullable `agent_session_id` FK so the new path writes exports through the proven schema and reuses the existing render path. Both `workflow_session_id` and `agent_session_id` are nullable but a CHECK constraint requires exactly one to be set. + +9. **`ask_user_question` returns immediately.** The MCP tool-call return is a fixed acknowledgement (`{"ok": true, "question_id": "..."}`). The user's selection is delivered later: in embedded mode by writing the chosen value to the agent's stdin via `Destila.Terminal`; in external mode by surfacing the value in the UI for the user to paste. The answer never travels through the MCP return value — sessions can sit unanswered for days without holding HTTP connections open. + +10. **PubSub event bus is the single internal coupling.** The HTTP/SSE channel → `AgentSession` GenServer → `AgentSessionLive` LiveView communication runs over `Phoenix.PubSub` (topic `agent_session:`). Tests substitute a mock client that publishes the same events the real HTTP/SSE channel would, letting LiveView tests run without a real HTTP round trip. + +--- + +## High-Level Technical Design + +> The following diagrams illustrate the intended approach and are directional guidance for review, not implementation specification. The implementing agent should treat them as context, not code to reproduce. + +### Tool-call flow (embedded mode) + +```mermaid +sequenceDiagram + participant User + participant LV as AgentSessionLive + participant Term as Destila.Terminal + participant Claude as claude CLI (in PTY) + participant Bridge as Go bridge (stdio MCP) + participant Phx as Phoenix /mcp endpoint + participant Agent as AgentSession GenServer + participant PubSub + + User->>LV: open session page + LV->>Agent: ensure_started(session_id, embedded) + Agent->>Term: spawn claude with per-session .mcp.json + DESTILA_SESSION_ID + Term->>Claude: PTY stdout/stdin + Claude->>Bridge: stdio JSON-RPC (tools/list, tool_use) + Bridge->>Phx: HTTP POST /mcp/:session_id/rpc + Bearer token + Phx->>Agent: dispatch tool call + Agent->>PubSub: broadcast {:tool_call, ...} + Agent-->>Phx: ack / response payload + Phx-->>Bridge: HTTP 200 JSON-RPC reply + Bridge-->>Claude: stdio reply + PubSub-->>LV: render export / question / phase transition +``` + +### Host-mode decision matrix + +| Behavior | Embedded host | External host | +|---|---|---| +| Agent process owner | Destila (via `Destila.Terminal`) | The user's machine | +| stdin available to Destila | Yes (write through `Destila.Terminal.Server`) | No | +| Kickoff prompt delivery | Push to stdin automatically | Surface in UI for user to paste | +| `ask_user_question` answer delivery | Write selection to stdin | Surface in UI for user to paste | +| Phase handoff | Stop old `claude`, start fresh one with new `.mcp.json` + system prompt | Show "restart your agent with these instructions" prompt; no process control | +| `.mcp.json` generation | Per-session temp file written by Destila | User configures their own once at install | +| Primary UI element | xterm.js terminal as agent surface | Connection info card + paste-target panel | + +### AgentSession lifecycle + +```mermaid +stateDiagram-v2 + [*] --> AwaitingAgent: session created + AwaitingAgent --> Active: first authenticated MCP call received + Active --> Disconnected: SSE stream closes / agent exits without phase_complete + Active --> PhaseHandoff: phase_complete received + PhaseHandoff --> AwaitingAgent: next phase, embedded mode (old claude stopped, new one launching) + PhaseHandoff --> AwaitingAgent: next phase, external mode (UI prompts user to restart) + Disconnected --> Active: agent reconnects + Active --> Done: final phase completed + PhaseHandoff --> Done: completed on last phase + Done --> [*] +``` + +--- + +## Output Structure + +New directories created by this plan: + +``` +cmd/ + destila-mcp/ # Greenfield Go bridge CLI (separate release artifact) + go.mod + main.go + internal/ + mcpstdio/ # stdio JSON-RPC MCP server + httpclient/ # HTTP+SSE client to Destila + +lib/destila/agent/ # Orchestration (parallel to lib/destila/ai/) + session.ex # Ecto schema for agent_sessions + session_event.ex # Ecto schema for agent_session_events + sessions.ex # Context module + session_server.ex # GenServer per active session + session_supervisor.ex # DynamicSupervisor + session_registry.ex # Registry + event_router.ex # Dispatches incoming tool calls into the right session + embedded_host.ex # Launches/stops claude via Destila.Terminal + external_host.ex # Manages paste-buffer state + mcp_config_writer.ex # Writes per-session .mcp.json + workflow.ex # WorkflowDefinition struct + workflow_loader.ex # YAML loader + tool_handlers.ex # Dispatch table + tools/ + session_tool.ex + ask_user_question_tool.ex + service_tool.ex + exports_read_tool.ex + +lib/destila_web/mcp/ # HTTP+SSE transport + router.ex # /mcp scope plug + auth_plug.ex # Bearer token validation + rpc_controller.ex # POST /mcp/:session_id/rpc + sse_controller.ex # GET /mcp/:session_id/events + json_rpc.ex # JSON-RPC 2.0 encode/decode helpers + +lib/destila_web/live/ + agent_session_live.ex + agent_session_create_live.ex + agent_session_live/ + exports_panel.ex + event_log_panel.ex + question_panel.ex + embedded_terminal_panel.ex + external_host_panel.ex + +priv/workflows/ # YAML workflow definitions + example.yaml # One sample workflow shipped with the plan +``` + +This tree is a scope declaration showing the expected output shape. Per-unit `**Files:**` sections remain authoritative for what each unit creates or modifies. Implementers may adjust file boundaries when implementation reveals a better layout. + +--- + +## Dependencies + +```mermaid +graph TD + U1[U1. HTTP+SSE transport + Go bridge skeleton + smoke test] + U2[U2. Agent schemas + migration] + U3[U3. AgentSession orchestrator] + U4[U4. MCP tool handlers] + U5[U5. YAML workflow loader] + U6[U6. Embedded host mode] + U7[U7. External host mode] + U8[U8. AgentSessionLive UI] + U9[U9. Crafting board entry + create flow] + U10[U10. Feature file + mock-MCP test harness + smoke script] + + U1 --> U4 + U2 --> U3 + U3 --> U4 + U3 --> U6 + U3 --> U7 + U5 --> U6 + U5 --> U7 + U4 --> U8 + U6 --> U8 + U7 --> U8 + U8 --> U9 + U1 --> U10 + U3 --> U10 + U8 --> U10 +``` + +U1 must complete first — it derisks the single largest unknown (Claude Code's tolerance for the hand-rolled transport). U2 and U5 are independently startable but feed U3 and U6/U7. U10 (feature file + harness) is the final integration unit and depends on U8 being landable. + +--- + +## Implementation Units + +### U1. HTTP+SSE MCP transport skeleton + Go bridge skeleton + end-to-end smoke test + +**Goal:** Derisk the transport. Get a real `claude` CLI connecting through the Go bridge into Destila over HTTP+SSE, exchanging a `tools/list` and invoking a no-op tool. No real session orchestration yet — the endpoint accepts the call, validates the token, echoes back a stub response. + +**Requirements:** Establishes the transport layer used by every other unit. Derisks the largest unknown explicitly called out in the request ("Compatibility risk. Claude Code's tolerance for our hand-rolled HTTP/SSE MCP transport is the single largest unknown. Build an end-to-end smoke test of the bridge ↔ Destila HTTP/SSE round trip very early"). + +**Dependencies:** None. + +**Files:** +- `lib/destila_web/mcp/router.ex` — new `Plug.Router` (or Phoenix `scope`) mounted under `/mcp` from `lib/destila_web/router.ex`. +- `lib/destila_web/mcp/auth_plug.ex` — validates `Authorization: Bearer ` against `Application.fetch_env!(:destila, :mcp_token)`. +- `lib/destila_web/mcp/rpc_controller.ex` — POST `/mcp/:session_id/rpc`; decodes JSON-RPC 2.0, dispatches via a stub `EventRouter.handle_call/3` (returns `{:ok, %{}}` until U4). +- `lib/destila_web/mcp/sse_controller.ex` — GET `/mcp/:session_id/events`; opens a long-lived SSE stream subscribed to `Phoenix.PubSub` topic `agent_session_outbound:`. Until U3 lands, this just emits a hello event on connect. +- `lib/destila_web/mcp/json_rpc.ex` — helpers for JSON-RPC 2.0 request/response envelopes. +- `config/runtime.exs` — read `DESTILA_MCP_TOKEN`. +- `cmd/destila-mcp/go.mod`, `cmd/destila-mcp/main.go` — stdio MCP server that translates `tools/list` and `tools/call` to HTTP+SSE against Destila. Reads `DESTILA_MCP_TOKEN` and `DESTILA_MCP_URL` from env; session id from `DESTILA_SESSION_ID`. +- `cmd/destila-mcp/internal/mcpstdio/`, `cmd/destila-mcp/internal/httpclient/` — split for testability. +- `cmd/destila-mcp/README.md` — short build/install instructions. +- `scripts/mcp_smoke.sh` — drives the smoke test: starts the dev server, builds the Go binary, invokes `claude --mcp-config .json` against a stub workflow, asserts a no-op tool call round-trips. +- `test/destila_web/mcp/auth_plug_test.exs` — unit tests for token validation. +- `test/destila_web/mcp/json_rpc_test.exs` — unit tests for envelope handling. + +**Approach:** +- Mount `/mcp` as a separate Phoenix scope (no `:browser` pipeline; new `:mcp` pipeline with `:accepts ["json"]`, `MCPAuthPlug`). +- SSE uses `Plug.Conn.send_chunked/2` + a per-connection process subscribed to PubSub; on `:DOWN` the process drops the stream. +- The Go bridge implements only the subset of MCP needed by Claude Code (`initialize`, `tools/list`, `tools/call`, `notifications/initialized`). Out-of-spec behavior is documented in `cmd/destila-mcp/README.md`. +- Session id is passed by Claude Code (configured via the per-session `.mcp.json`) as the bridge's stdin transport identifies it through `DESTILA_SESSION_ID`. The bridge sets a `X-Destila-Session-Id` header on every HTTP call, redundantly with the path segment, so misconfiguration is loud. +- The smoke test is **manual / nightly**, not part of `mix test`. It boots the real `claude` binary if installed; otherwise it `skip`s with an explanation. + +**MCP protocol surface — concrete contract between bridge and Destila:** + +The bridge ↔ Destila protocol is **our own HTTP shape**, not the MCP wire protocol. The bridge speaks the stdio-MCP wire protocol on its inward face (to `claude`) and translates to/from our HTTP+SSE shape on its outward face (to Destila). This is the key insight that lets us hedge against MCP wire-protocol drift: only the Go bridge needs to track upstream MCP changes; Destila's HTTP surface stays stable. + +Destila's HTTP surface: + +| Method + path | Purpose | Request body | Response | +|---|---|---|---| +| `POST /mcp/:session_id/rpc` | One JSON-RPC 2.0 client→server call | `{"jsonrpc":"2.0","id":,"method":,"params":}` | `{"jsonrpc":"2.0","id":,"result":}` or `{"jsonrpc":"2.0","id":,"error":{...}}` | +| `GET /mcp/:session_id/events` | Long-lived SSE stream for server→client notifications | n/a | `Content-Type: text/event-stream`, events framed as `event: \ndata: \n\n` | + +JSON-RPC methods the bridge sends: +- `tools/list` — return the four tool schemas (`session`, `ask_user_question`, `service`, `exports_read`); shape mirrors `lib/destila/ai/tools.ex` exactly for the three reused tools. +- `tools/call` — `params: {name, arguments}`; returns `{content: [{type: "text", text: }], isError: false}` per MCP spec. +- `initialize` — return `{protocolVersion, capabilities, serverInfo: {name: "destila", version}}`. Stub a reasonable static reply in U1; real version pulled from `mix.exs`. +- `notifications/initialized`, `notifications/cancelled` — accept and ack (one-way fire-and-forget; ID absent). +- `ping` — reply with empty result; useful for keepalives during long phases. + +SSE event names Destila can push (not required by U1, but documented so U3+ know the channel exists): `agent_handoff`, `kickoff_prompt` (only used if we ever decide to push prompts through MCP instead of stdin — current plan does not). + +Headers Destila accepts: +- `Authorization: Bearer ` — required; mismatched or missing → 401 before any body parsing. +- `X-Destila-Session-Id: ` — redundant with path segment; if both present and disagree, return 400 with a loud error. +- `X-Destila-Bridge-Version: ` — optional; logged but not enforced in U1. + +The bridge's internal handling of `claude`'s stdio framing (LSP-style `Content-Length` headers, newline-delimited JSON, request/response correlation) is entirely inside `cmd/destila-mcp/internal/mcpstdio/`. We can adapt to any MCP transport changes by rebuilding only the bridge; no Elixir change is required. + +**Deferred to implementation (U1 will resolve via the smoke test):** +- The exact `tools/call` response envelope Claude Code expects for our tools. The MCP spec is at `https://modelcontextprotocol.io/specification` — operator should fetch the current spec when starting U1 and pin to a specific version. +- Whether Claude Code's MCP HTTP transport mode (vs stdio) would let us skip the bridge entirely. Current plan assumes the bridge is required; the smoke test verifies. If Claude Code natively supports our HTTP shape, the bridge becomes optional and external-host mode could simplify. +- Keepalive / idle behavior on the SSE channel. Sane defaults: emit a `:keepalive` comment line every 15 s; verify Claude Code doesn't drop on idle. + +**Patterns to follow:** +- Auth plug pattern: same shape as Phoenix's built-in pipelines (single-purpose plug, `init/1` + `call/2`). +- SSE pattern: search Phoenix docs for the chunked-response idiom; no existing SSE in this repo, so the implementation should be small and well-commented. + +**Test scenarios:** +- *Happy path:* a request to `POST /mcp/abc/rpc` with a valid Bearer token returns a JSON-RPC 2.0 response for a stub `tools/list` (responds with the three reused tool schemas; the stub uses hard-coded schemas pending U4). +- *Auth — missing header:* returns 401 with no body content beyond a minimal JSON error. +- *Auth — wrong token:* returns 401. +- *Auth — correct token, wrong session id format:* still 200 (session id trust is post-token; per Decision 2). +- *SSE — connect with valid token:* response uses `Content-Type: text/event-stream`, sends a `:ok` hello event, and terminates cleanly when the client closes. +- *SSE — connect with invalid token:* 401 before stream opens. +- *JSON-RPC malformed request:* returns 200 with a JSON-RPC error envelope (`-32700` parse error). +- *Manual smoke test:* `scripts/mcp_smoke.sh` exits 0 against the dev server. Document expected output in the script's comments. + +**Verification:** +- `mix test test/destila_web/mcp/` passes. +- `go build ./cmd/destila-mcp/...` succeeds. +- `scripts/mcp_smoke.sh` round-trips a real `claude` CLI through the bridge into Destila, with a non-zero number of tool-call attempts logged by the controller. + +**Execution note:** Land U1 fully before starting U2 in earnest. If the smoke test reveals Claude Code rejects our transport, the cheapest pivot (e.g., to a community MCP HTTP transport library, or to an alternate framing) is at this boundary, not after U2–U9 are built. + +--- + +### U2. Agent session Ecto schemas, migration, and exports table extension + +**Goal:** Persistence layer for the new path. Two new tables and a nullable FK on the existing exports table. + +**Requirements:** Backs the session-lifecycle scenarios ("Session detail page is reachable while the agent is disconnected", "Exports from prior phases remain available across handoff"), the tool-call event log behind the export-first UI, and the reuse of the existing exports infrastructure. + +**Dependencies:** None (can land in parallel with U1). + +**Files:** +- `priv/repo/migrations/_create_agent_sessions.exs` — creates `agent_sessions` and `agent_session_events`; alters `workflow_session_metadata` to add nullable `agent_session_id` FK and a CHECK constraint requiring exactly one of (`workflow_session_id`, `agent_session_id`) to be set. +- `lib/destila/agent/session.ex` — Ecto schema for `agent_sessions`: `id` (binary), `project_id` (FK, nullable), `workflow_name` (string), `current_phase_index` (integer), `total_phases` (integer), `host_mode` (enum `:embedded | :external`), `status` (enum `:awaiting_agent | :active | :disconnected | :done`), `connected_at` (utc_datetime), `disconnected_at` (utc_datetime), `title` (string), `archived_at` (utc_datetime), `deleted_at` (utc_datetime), `timestamps`. +- `lib/destila/agent/session_event.ex` — Ecto schema for `agent_session_events`: `id`, `agent_session_id` (FK), `phase_index` (integer), `tool_name` (string), `tool_input` (map), `tool_result` (map), `inserted_at`. +- `lib/destila/agent/sessions.ex` — context module: `list_sessions/1`, `get_session/1`, `create_session/1`, `record_event/3`, `transition_status/2`, `current_phase/1`. +- `lib/destila/workflows/session_metadata.ex` — extend schema with `belongs_to :agent_session, Destila.Agent.Session` (additive; existing chat code untouched because the FK is nullable). +- `test/destila/agent/sessions_test.exs` — context-level tests. + +**Approach:** +- `agent_sessions` uses `:binary_id` primary keys to match the existing convention. +- Sessions can exist with no events and no agent connection (covers the "Session detail page is reachable while the agent is disconnected" scenario). +- The CHECK constraint on `workflow_session_metadata` enforces that any single metadata row belongs to exactly one path. This is the only schema-level coupling between the two paths. +- Phase-related state lives on `agent_sessions` (not on a separate `phase_executions` table) because the new path has no "awaiting confirmation" intermediate state — `suggest_phase_complete` is a UI-only handshake, not a persisted phase status. +- Disconnected agents do not transition the session out of `:active` until the SSE channel closes; this is detected via `Process.monitor` on the SSE handler process. + +**Database engine and constraint syntax — confirmed:** + +This project uses **SQLite** via `ecto_sqlite3 ~> 0.17` (see `mix.exs`). The new tables and the existing `workflow_session_metadata` extension must use SQLite-compatible syntax. Key implications: + +- SQLite supports table-level `CHECK` constraints in `CREATE TABLE` but does **not** support adding a CHECK to an existing table via `ALTER TABLE` (only column drops/adds/renames since 3.35). This project has no existing CHECK constraints in `priv/repo/migrations/` — this migration is the first. +- The Ecto helper `create constraint/3` works on SQLite for CREATE-TABLE CHECKs only. To add a CHECK to the existing `workflow_session_metadata` table, the migration must either (a) use a transactional table-rebuild pattern (create new table with constraint, copy data, drop old, rename), or (b) enforce the invariant at the application layer via a changeset validation and document that the DB lacks the hard guard. +- **Decision: choose option (b) — application-level enforcement.** Adding a CHECK via table rebuild on `workflow_session_metadata` (which has production data) is high-risk and offers limited value. The application invariant ("exactly one of `workflow_session_id`, `agent_session_id` is set") will be enforced in `lib/destila/workflows/session_metadata.ex` via `validate_required_one_of([:workflow_session_id, :agent_session_id])` in the changeset. New rows go through `Sessions.record_event/3` or analogous, which calls the changeset. The migration adds the column, the FK, and an index — no CHECK. +- Index: add `create index(:workflow_session_metadata, [:agent_session_id])` so the U4 `exports_read` query (filtered by `agent_session_id`) and the U8 LiveView's exports stream (also filtered) stay cheap as the table grows. Without this index, every export-read scans the table. +- Two further indexes worth creating in this migration: `index(:agent_session_events, [:agent_session_id, :inserted_at])` (event log queries sort by time within a session) and `index(:agent_sessions, [:status])` (the crafting board's "active sessions" view filters by status). + +**Patterns to follow:** +- Migration style: see `priv/repo/migrations/20260427044134_add_domain_and_basic_auth_to_projects.exs` for an additive alter. +- Context module style: see `lib/destila/projects.ex` and `lib/destila/workflows.ex`. +- Enum fields: see `lib/destila/executions/phase_execution.ex` for the `Ecto.Enum` pattern this project uses. + +**Test scenarios:** +- *Happy path:* `Sessions.create_session/1` inserts a row with `status: :awaiting_agent` and `current_phase_index: 0`. +- *Disconnected sessions:* fetching a session with no recorded events returns the session and an empty event list (`Sessions.get_session/1` with `preload: [:events]`). +- *Event persistence:* `Sessions.record_event/3` writes a row, increments any in-memory counters via PubSub, and round-trips `tool_input`/`tool_result` maps as JSON. +- *Phase increment:* `Sessions.transition_status/2` for `:phase_complete` increments `current_phase_index` and emits a PubSub event. +- *Exports FK exclusivity (changeset-level):* a changeset with both `workflow_session_id` and `agent_session_id` set returns `valid?: false` with the `validate_required_one_of` error; a changeset with neither set returns the same error; a changeset with exactly one set is valid. +- *Exports FK index:* explain-plan of a query filtering by `agent_session_id` uses the new index (`mix ecto.dump` or `EXPLAIN QUERY PLAN` confirms `USING INDEX`). +- *Exports lookup across handoff:* `WorkflowSessionMetadata` rows scoped by `agent_session_id` return all rows including ones written in earlier phases. Covers AE: "Exports from prior phases remain available across handoff." +- *No-op for chat path:* existing `WorkflowSessionMetadata` queries that don't filter by `agent_session_id` still return rows where `agent_session_id IS NULL`. Smoke test the chat-path test suite still passes. + +**Verification:** +- `mix ecto.migrate` runs cleanly. +- `mix test test/destila/agent/sessions_test.exs` and `mix test test/destila/workflows/session_metadata_test.exs` pass. +- The full pre-existing chat-path test suite (`mix test`) still passes — verified manually after the migration runs. + +--- + +### U3. AgentSession orchestrator (GenServer + Supervisor + Registry + EventRouter) + +**Goal:** The runtime engine that backs each active session. Receives tool calls from the HTTP+SSE transport via `EventRouter`, mutates session state via the `Sessions` context, broadcasts UI events over PubSub, and owns the phase lifecycle. + +**Requirements:** Underlies every phase-transition, export, ask-user-question, and disconnect scenario in the feature file. Closes the loop between U1 (transport) and U4 (tool handlers) — the orchestrator is the single dispatch target for both. + +**Dependencies:** U2 (schemas). + +**Files:** +- `lib/destila/agent/session_server.ex` — GenServer per session; state holds `session` struct, `host_mode`, `current_phase_definition`, pending question id (if any), embedded terminal pid (if any). Handlers: `handle_call({:tool_call, name, params, request_id}, _, state)` returns the tool's reply payload synchronously; `handle_info({:sse_connected, _}, _)` transitions to `:active`; `handle_info({:sse_closed, _}, _)` transitions to `:disconnected`. +- `lib/destila/agent/session_supervisor.ex` — `DynamicSupervisor` named `Destila.Agent.SessionSupervisor`. +- `lib/destila/agent/session_registry.ex` — `Registry` keyed by `agent_session_id`. +- `lib/destila/agent/event_router.ex` — receives JSON-RPC tool calls from `RpcController`, looks up the session via `Registry`, forwards via `GenServer.call/3`. Handles the case where no GenServer is running (re-start from DB state). +- `lib/destila/application.ex` — add the supervisor + registry to the supervision tree. +- `test/destila/agent/session_server_test.exs` — orchestrator tests. +- `test/destila/agent/event_router_test.exs` — router tests. + +**Approach:** +- One GenServer per active session, started on first authenticated MCP call. Idle GenServers shut down after a configurable timeout (default: 30 min after last event); the next call rehydrates from DB. +- PubSub topic: `agent_session:` (LiveView subscribes); `agent_session_outbound:` (SSE controller subscribes for server→client notifications, if any are ever needed by the agent). +- Tool calls are handled synchronously inside the GenServer (matching the MCP JSON-RPC request/response contract) — except for `ask_user_question`, which returns immediately with an acknowledgement and emits a PubSub event to the LiveView for user-interactive elicitation. See Decision 9. +- The orchestrator does **not** parse or store assistant text — there is none to store. Only `agent_session_events` rows for tool calls are persisted. +- Phase advancement is **always** the result of an explicit `phase_complete` tool call. The GenServer never advances on its own. + +**Patterns to follow:** +- Supervisor + Registry + GenServer trio: see `lib/destila/ai/session_supervisor.ex` + `lib/destila/ai/session_registry.ex` + `lib/destila/ai/claude_session.ex` for the canonical shape. +- PubSub event names: snake_case atom keys to match `Destila.Sessions.SessionProcess` style. + +**Test scenarios:** +- *Happy path:* `EventRouter.handle_rpc/3` for a `phase_complete` call on a fresh session writes an event row, increments `current_phase_index`, broadcasts `{:phase_advanced, ...}`, and returns the JSON-RPC reply. +- *Rehydration:* starting a fresh GenServer from a session id that already has events in the DB rebuilds state correctly (current phase index, status). +- *SSE connection lifecycle:* receiving `{:sse_connected, _}` transitions a session in `:awaiting_agent` to `:active`. Receiving `{:sse_closed, _}` transitions it to `:disconnected`. Covers AE: "Session activates when the external agent connects." +- *Idle shutdown:* a GenServer with no events for the configured idle timeout shuts down cleanly and can be re-started on the next call. +- *Concurrency:* two parallel `phase_complete` calls on the same session — only the first advances (idempotent by phase index check). +- *Disconnect mid-phase:* SSE closes while phase is not complete → status `:disconnected`, current phase unchanged. Covers AE: "Agent exit without phase_complete leaves the phase open." +- *No assistant text:* the GenServer rejects any unknown tool name with a JSON-RPC method-not-found error; assert no event row is written for unknown tools. +- *Event log captures only tool calls:* after a sequence of N tool calls, `Sessions.list_events/1` returns exactly N rows; querying for rows with `tool_name IS NULL` or any non-tool-call discriminator returns nothing. Covers AE: "Session log records only tool-call events; no agent assistant text should be stored." + +**Verification:** +- `mix test test/destila/agent/session_server_test.exs test/destila/agent/event_router_test.exs` passes. +- A unit test that starts and stops 10 sessions in parallel against the in-memory Registry shows no leaked processes (`Process.alive?` check + DynamicSupervisor child count). + +--- + +### U4. MCP tool handlers (session, ask_user_question, service, exports_read) + +**Goal:** Hand-rolled dispatch table for the four MCP tools the new path exposes. Each handler takes parsed JSON-RPC params + a session GenServer pid and returns the JSON-RPC reply payload. + +**Requirements:** Implements the semantics for `phase_complete`/`suggest_phase_complete`/`export` (explicit-only transitions scenarios), `ask_user_question` (non-blocking semantics scenario), `service` (parity with chat path), and `exports_read` (multi-phase handoff context recovery — Decision 4 / handoff scenarios). + +**Dependencies:** U1 (transport boundary), U3 (orchestrator dispatch target). + +**Files:** +- `lib/destila/agent/tool_handlers.ex` — dispatch table mapping tool name → handler module. +- `lib/destila/agent/tools/session_tool.ex` — handles `phase_complete`, `suggest_phase_complete`, `export`. +- `lib/destila/agent/tools/ask_user_question_tool.ex` — emits the question event over PubSub, persists a "pending question" event, returns immediate ack. +- `lib/destila/agent/tools/service_tool.ex` — delegates to `Destila.Services.ServiceManager.execute/3` (already used by the chat path's tool handler). +- `lib/destila/agent/tools/exports_read_tool.ex` — returns the list of metadata rows for the current `agent_session_id`, including earlier phases. +- `lib/destila/ai/tools.ex` — **NOT MODIFIED**. The existing in-process tool definitions stay exactly as they are for the chat path. +- `test/destila/agent/tools/*_test.exs` — per-tool tests, one file each. + +**Approach:** +- Tool schemas exposed via `tools/list` are derived from `Destila.Agent.ToolHandlers.schemas/0`. Hard-code the schemas there rather than importing from `lib/destila/ai/tools.ex` — Decision 4 says "reuse the tool surface" semantically; coupling at the schema level would create a refactor risk on the chat path. +- `export` action: persists a `workflow_session_metadata` row with `agent_session_id` set, `exported: true`, and the phase index. Broadcasts `{:export_added, metadata}` so the LiveView's exports panel updates in real time. +- `suggest_phase_complete`: persists an event row, broadcasts `{:suggest_phase_complete, reason}` to the LiveView. The LiveView renders the confirmation prompt; user confirmation triggers a LiveView event that calls `Sessions.transition_status/2`. The tool call itself returns immediately. +- `phase_complete`: persists, increments phase index, broadcasts `{:phase_advanced, new_index}`. Returns immediately. +- `ask_user_question`: writes an `agent_session_events` row with `tool_name: "ask_user_question"` and a generated `question_id`, broadcasts the question to the LiveView, returns `{:ok, %{question_id: q_id}}` immediately. The selection is delivered later via stdin (embedded) or paste (external), wired in U6/U7. +- `exports_read`: reads from `WorkflowSessionMetadata` scoped to the `agent_session_id`. Returns a list of `{phase_name, key, value, type}` maps. + +**Patterns to follow:** +- Tool schema shape: mirror `lib/destila/ai/tools.ex` field-for-field so external behavior stays consistent. +- Service tool dispatch: see how `ServiceManager.execute/3` is called from `lib/destila/sessions/session_process.ex`. + +**Test scenarios (per handler):** +- *`session.phase_complete`:* writes event row, increments phase index, broadcasts `:phase_advanced`. Covers AE: "phase_complete auto-advances the session." +- *`session.suggest_phase_complete`:* writes event row, broadcasts `:suggest_phase_complete`, does **not** advance phase index. Covers AE: "suggest_phase_complete waits for user confirmation." +- *`session.export` text/markdown/file types:* each persists a `workflow_session_metadata` row with the right `type` annotation in `value`. Covers AE: "New exports appear in real-time at the top of the session view." +- *`ask_user_question` returns immediately:* the handler's return is dispatched within milliseconds; no waiting on user input. Assert the broadcast was sent. Covers AE: "ask_user_question tool call does not block on the user's reply." +- *`ask_user_question` non-blocking under load:* fire 100 questions on a session with no UI subscribers; all 100 return immediately and no events are dropped. +- *`exports_read` cross-phase:* with prior-phase exports present, the handler returns them all. Covers AE: "the new agent should be able to read them via the MCP exports tool." +- *`exports_read` empty:* on a session with no exports, returns an empty list. +- *`service.start/stop/restart/status`:* delegates correctly to `ServiceManager` and propagates errors. +- *Unknown tool:* dispatched name not in the registry returns a JSON-RPC method-not-found error. + +**Verification:** +- `mix test test/destila/agent/tools/` passes. +- Integration via `EventRouter` (using a real GenServer) confirms an `export` call shows up in a queried `WorkflowSessionMetadata.exports_for_agent_session/1`. + +--- + +### U5. YAML workflow loader + sample workflow + +**Goal:** Load workflow definitions from `priv/workflows/*.yaml`. Each YAML file defines a workflow name and a phases list. + +**Requirements:** Underpins the multi-phase scenarios — system prompts, kickoff prompts, and `agent_command` are all phase-level configuration that the embedded/external host code uses when starting an agent. + +**Dependencies:** None (U6/U7 consume it). + +**Files:** +- `mix.exs` — add `{:yaml_elixir, "~> 2.11"}` to deps. +- `mix.lock` — regenerated by `mix deps.get`. +- `lib/destila/agent/workflow.ex` — `WorkflowDefinition` struct: `name`, `phases: [Phase{name, system_prompt, kickoff_prompt, agent_command}]`. +- `lib/destila/agent/workflow_loader.ex` — `load_all/0` reads `priv/workflows/*.yaml`, validates required fields, caches under `:persistent_term` keyed by workflow name; `get/1` fetches by name. +- `priv/workflows/example.yaml` — one sample workflow with two phases so U6/U7 have something to drive. +- `lib/destila/application.ex` — call `WorkflowLoader.load_all/0` at boot. +- `test/destila/agent/workflow_loader_test.exs` — unit tests. + +**Approach:** +- `agent_command` is a list of strings (e.g. `["claude", "--mcp-config", "{{mcp_config_path}}"]`) with `{{...}}` placeholders that the embedded host resolves at launch time. Keeping it explicit per phase makes it possible for different phases to use different models or flags. +- Validation at load time is strict: missing fields fail loud at boot so we never silently launch a misconfigured agent. +- `:persistent_term` is appropriate because workflow definitions are read-many, write-once-at-boot. + +**Patterns to follow:** +- The frontmatter loader in `lib/destila/workflows/skills.ex` shows how this project handles file-driven definitions today. +- App-boot one-shot loaders: see `lib/destila/application.ex` for the existing supervised children pattern. + +**Test scenarios:** +- *Happy path:* a valid YAML with two phases loads into a `WorkflowDefinition` with two `Phase` structs in order. +- *Missing required field:* a YAML file missing `kickoff_prompt` on one phase raises a descriptive error at `load_all/0`. +- *Empty phases list:* raises. +- *Duplicate workflow name:* raises (two files with the same `name:`). +- *Unknown YAML keys:* allowed (forward-compat) but logged. +- *Lookup by name:* `WorkflowLoader.get("example")` returns the cached struct; `get("missing")` returns `{:error, :not_found}`. + +**Verification:** +- `mix test test/destila/agent/workflow_loader_test.exs` passes. +- `mix compile` succeeds with the new dep. +- The example workflow loads on boot in `iex -S mix`. + +--- + +### U6. Embedded host mode (PTY-driven agent lifecycle) + +**Goal:** Launch and manage a `claude` process inside `Destila.Terminal` for an `agent_session` in embedded host mode. Write per-session `.mcp.json`, set `DESTILA_SESSION_ID`, push kickoff prompts and `ask_user_question` answers via stdin, and stop/restart the agent at phase boundaries. + +**Requirements:** Covers all embedded-host scenarios in the feature file — kickoff push, stdin selection delivery, phase handoff agent restart, exports persistence across handoff. + +**Dependencies:** U3, U4, U5. + +**Files:** +- `lib/destila/agent/embedded_host.ex` — the lifecycle controller. Functions: `start_phase/2`, `stop_phase/1`, `push_kickoff/2`, `push_answer/2`. +- `lib/destila/agent/mcp_config_writer.ex` — writes a per-session `.mcp.json` to a tmpdir, returns the path. The config registers the Go bridge as an stdio MCP server with `env: {DESTILA_SESSION_ID, DESTILA_MCP_TOKEN, DESTILA_MCP_URL}`. +- `lib/destila/agent/process_launcher.ex` — thin wrapper around `Destila.Terminal.Server` that knows how to substitute the `{{mcp_config_path}}` placeholder in the phase's `agent_command`. +- `lib/destila/agent/session_server.ex` — extended to call `EmbeddedHost.start_phase/2` on phase advance when `host_mode == :embedded`. +- `test/destila/agent/embedded_host_test.exs` — uses Mimic to mock `Destila.Terminal.Server`. + +**Approach:** +- The terminal process is the agent's stdin/stdout. `push_kickoff/2` calls the existing `Destila.Terminal.Server.write/2` (or equivalent) to inject the phase's `kickoff_prompt` followed by a newline. +- `push_answer/2` does the same for `ask_user_question` selections. +- The `system_prompt` is delivered via `claude --append-system-prompt ` (or whichever flag Claude Code supports for system-prompt injection); the file is written to a tmpdir alongside `.mcp.json`. + +**Phase handoff state machine — concrete spec:** + +`SessionServer` carries an `embedded` sub-state with these values: +`:idle` → `:starting` → `:running` → `:stopping` → `:awaiting_new_agent` → `:running` (loop) or `:done`. + +> The following sketch illustrates the intended approach and is directional guidance for review, not implementation specification. + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Starting: start_phase(phase_def) + Starting --> Running: SSE connects + initialize received + Starting --> Failed: spawn fails OR SSE never connects within 30s + Running --> Stopping: phase_complete received + Stopping --> AwaitingNewAgent: old PTY :DOWN received + Stopping --> Stopping: 5s soft-stop timer (SIGTERM resent); after 10s SIGKILL + AwaitingNewAgent --> Starting: spawn next phase's agent + AwaitingNewAgent --> Done: no more phases + Failed --> Idle: user clicks "Retry" in UI +``` + +Concrete timings and rules: + +1. **`start_phase/2`** (state `:idle` → `:starting`): writes `.mcp.json` + system-prompt file to a per-session tmpdir, spawns `claude` via `Destila.Terminal.Server.start_link/1`, stores the terminal pid, and starts a `Process.monitor`. Records `start_time`. +2. **Spawn-to-active gate**: a `:starting` session does not push the kickoff prompt until both (a) SSE has connected (signaled by `EventRouter` upon first authenticated request) AND (b) the `initialize` JSON-RPC method has been received. The kickoff push is buffered until both fire; sub-state moves to `:running`. If 30 s elapse in `:starting`, transition to `:failed` and broadcast a banner event to the LiveView so the user can retry. +3. **Buffered pushes during gap**: while in `:starting` or `:awaiting_new_agent`, all calls to `push_kickoff/2` and `push_answer/2` append to an in-state FIFO queue `pending_stdin`. On entry to `:running`, the queue is flushed in order with a 50 ms delay between writes (gives the agent a chance to prompt). On entry to `:failed`, the queue is preserved so a retry replays it. +4. **`stop_phase/1`** (state `:running` → `:stopping`): sends a terminal-level interrupt (`Ctrl+C` written through `Destila.Terminal.Server.write/2`) then a `:close` to the PTY. Starts a 5 s timer. If the monitored pid has not exited at 5 s, send SIGTERM via the existing `Destila.Terminal` API. At 10 s total, send SIGKILL. Either way, the `:DOWN` arriving at the `SessionServer` mailbox is the canonical "old agent is gone" signal — transition to `:awaiting_new_agent` only on `:DOWN`. +5. **`:awaiting_new_agent`**: regenerate per-session `.mcp.json` and system-prompt file for the next phase, then call `start_phase/2` again. The session GenServer is the single owner of the tmpdir; old files are overwritten or cleaned per phase. +6. **What if the new agent never connects?**: the same 30 s gate from rule 2 fires. The session transitions to `:failed` and the LiveView shows a banner with "Retry handoff" and "Switch to external mode" actions. Exports from prior phases remain intact (they live in `workflow_session_metadata`, not in process state). +7. **Crash recovery**: if `SessionServer` itself crashes mid-handoff, its supervisor restarts it; the new GenServer rehydrates from DB (status = `:active` or `:disconnected` based on last persisted state). The PTY pid is lost; the new GenServer transitions sub-state to `:idle` and the LiveView prompts the user to restart the agent for the current phase. This is acceptable because handoff crashes are rare and exports are durable. +8. **Concurrent `stop_phase` calls**: idempotent — second call observes sub-state `:stopping` and is a no-op. + +**Patterns to follow:** +- `Destila.Terminal.Server` API: see `lib/destila/terminal/server.ex` for write/input. +- Mimic mocking pattern: see how `test/destila/terminal_test.exs` mocks ExPTY today. + +**Test scenarios:** +- *Happy path:* `EmbeddedHost.start_phase/2` writes a `.mcp.json` to a tmpdir, calls `Destila.Terminal.Server.start_link` with the resolved command and env, and returns the pid. +- *Kickoff push:* `push_kickoff/2` invokes `Destila.Terminal.Server.write/2` with the phase's `kickoff_prompt` followed by `\n`. Covers AE: "Destila pushes a phase-kickoff prompt to the agent's stdin." +- *Answer push:* `push_answer/2` writes the selected value + newline. Covers AE: "Selection is written to the agent's stdin in embedded host mode." +- *Phase handoff:* on `:phase_advanced`, the old terminal pid receives a stop, then a new pid is created with the next phase's command. The old pid's `:DOWN` is monitored to confirm clean termination. Covers AE: "Phase boundary stops the current agent and starts a fresh one." +- *Handoff under load:* triggering handoff while a kickoff push is in flight queues the push for the new agent rather than dropping it. +- *Agent crash without phase_complete:* terminal exit transitions session to `:disconnected`; phase index unchanged. Covers AE: "Agent exit without phase_complete leaves the phase open." +- *`.mcp.json` content:* the generated file references the absolute path to the `destila-mcp` binary (configurable via `Application.fetch_env(:destila, :mcp_bridge_path)`), the global token, and `DESTILA_SESSION_ID`. Integration test reads the file and asserts shape. +- *Spawn-to-active 30 s gate:* a session in `:starting` whose SSE never connects transitions to `:failed` after 30 s; the LiveView receives a `:phase_failed` event with the reason. Verified by mocking the clock with `Process.send_after` substitution or by injecting a shorter timeout in test config. +- *Buffered stdin during gap:* calls to `push_kickoff/2` during `:starting` accumulate in `pending_stdin`; on `:running` entry the queue is flushed in order with a 50 ms inter-write delay (verifiable via Mimic expectations on `Destila.Terminal.Server.write/2`). +- *Soft-stop escalation:* a `:stopping` session whose monitored pid does not exit at 5 s receives SIGTERM, at 10 s receives SIGKILL. Test injects a fake terminal that ignores the soft-close and asserts SIGTERM/SIGKILL calls (Mimic). +- *Retry after `:failed`:* user-triggered retry replays the preserved `pending_stdin` queue against the new agent and clears it on flush. +- *Concurrent stop calls:* two `stop_phase/1` calls back-to-back result in exactly one termination sequence (idempotent). +- *SessionServer crash mid-handoff:* killing the GenServer with `Process.exit(pid, :kill)` during `:stopping` results in a clean restart with sub-state `:idle`; exports from prior phases are unchanged. + +**Verification:** +- `mix test test/destila/agent/embedded_host_test.exs` passes. +- A LiveView integration test (in U10) starts a real embedded session and asserts that the terminal panel renders with a process running. + +--- + +### U7. External CLI host mode (paste-in-UI fallback) + +**Goal:** Support sessions where the agent runs on the user's machine and Destila has no stdin channel. Surface MCP connection details for the user to wire into their own `.mcp.json`; surface kickoff prompts and `ask_user_question` answers as paste-ready text. + +**Requirements:** Covers the external-host section of the feature file — connection instructions, agent-connect activation, kickoff surfaced for paste, no stdin push attempted, handoff prompting the user to restart. + +**Dependencies:** U3, U4, U5. + +**Files:** +- `lib/destila/agent/external_host.ex` — surface/notify functions: `connection_info/1`, `queue_kickoff/2`, `queue_answer/2`. +- `lib/destila/agent/session_server.ex` — branches on `host_mode == :external` to call `ExternalHost.queue_kickoff/2` instead of pushing to stdin. +- `lib/destila_web/live/agent_session_live/external_host_panel.ex` — UI component that renders connection info and the paste-target panel. +- `test/destila/agent/external_host_test.exs` — verifies no terminal/process side effects. + +**Approach:** +- Connection info: shows the user the bridge install command, the global token (with a "copy" affordance), the MCP server URL, and the `DESTILA_SESSION_ID` value to set when invoking `claude`. +- "Paste targets" (kickoff prompts, `ask_user_question` selections) accumulate in the session GenServer as a list under `pending_paste_items`. The LiveView renders the most recent one prominently with a "Copy" button. +- No process lifecycle: `ExternalHost` never calls anything that could spawn or stop a `claude` process. +- Handoff: when `:phase_advanced` fires in external mode, the LiveView shows a modal "Restart your external agent with the new phase's instructions" and surfaces the new system prompt + kickoff for copying. + +**Patterns to follow:** +- LiveComponent style: `DestilaWeb.ChatComponents` for component composition, but **do not** import or use the chat-specific helpers. + +**Test scenarios:** +- *Connection info:* `connection_info/1` returns a map containing the bridge path, token, URL, and session id env var name — no surprise fields. Covers AE: "Creating an external-host session shows MCP connection instructions." +- *Agent connect activates:* simulating an SSE connect on an external session transitions it to `:active`. Covers AE: "Session activates when the external agent connects." +- *No stdin push attempted:* on `:phase_advanced` with `host_mode: :external`, `ExternalHost.queue_kickoff/2` is called and `Destila.Terminal` is never touched (verified via Mimic expect-no-calls). Covers AE: "Destila does not attempt stdin pushes in external host mode." +- *Paste target for ask_user_question:* on a `:question_asked` event, the selected value (after the user clicks an option) is added to `pending_paste_items` and surfaced in the UI. Covers AE: "Selection is surfaced for manual paste in external host mode." +- *Handoff prompt:* on `:phase_advanced`, the LiveView shows the restart-your-agent modal. Covers AE: "External host handoff requires user action." + +**Verification:** +- `mix test test/destila/agent/external_host_test.exs` passes. +- LiveView test (U10) confirms the external-host UI renders connection info and no terminal panel. + +--- + +### U8. AgentSessionLive — export-first session UI + +**Goal:** The new LiveView that backs every MCP-driven session. Export-first layout: exports headline; tool-call event log secondary/collapsible; embedded terminal or external-host panel as the agent surface; question panel that appears when `ask_user_question` is pending. + +**Requirements:** Covers every UI-facing scenario in the feature file. + +**Dependencies:** U3, U4, U6, U7. + +**Files:** +- `lib/destila_web/live/agent_session_live.ex` — main LiveView at `/agent-sessions/:id`. +- `lib/destila_web/live/agent_session_live/exports_panel.ex` — headline exports area. +- `lib/destila_web/live/agent_session_live/event_log_panel.ex` — collapsible secondary tool-call log (streamed via LiveView streams keyed by event id). +- `lib/destila_web/live/agent_session_live/question_panel.ex` — renders `ask_user_question` with clickable options. +- `lib/destila_web/live/agent_session_live/embedded_terminal_panel.ex` — mounts the xterm.js hook for embedded sessions. +- `lib/destila_web/live/agent_session_live/external_host_panel.ex` — already created in U7; this unit only consumes it. +- `lib/destila_web/live/agent_session_live/phase_handoff_modal.ex` — confirmation modal for `suggest_phase_complete` and the external-host "restart your agent" prompt. +- `lib/destila_web/router.ex` — add `live "/agent-sessions/:id", AgentSessionLive` in the existing `:browser` pipeline scope. +- `test/destila_web/live/agent_session_live_test.exs` — primary LiveView test file (most scenarios from the feature file land here). + +**Approach:** +- Layout uses Tailwind grid: the exports panel occupies the primary column (top of page), the agent surface (terminal or external-host panel) occupies the secondary column or below, and the event log is a collapsible panel at the bottom. +- Subscribes to `agent_session:` PubSub topic on mount. Handlers for: `:export_added`, `:phase_advanced`, `:suggest_phase_complete`, `:question_asked`, `:question_answered`, `:agent_connected`, `:agent_disconnected`. +- Exports rendered via `Phoenix.LiveView.stream/3` (one stream per session) so new exports appear in real-time without re-rendering the whole list. +- Event log: also streamed; collapsed by default. +- Question selection click handler: emits `{:answer, question_id, value}` to the GenServer, which forwards to `EmbeddedHost.push_answer/2` or `ExternalHost.queue_answer/2` depending on host mode. Either way the UI marks the question as answered. +- No chat textarea anywhere in this LiveView. Covers AE: "Session is created without a chat textarea." +- Empty-state: the exports panel renders a placeholder when the stream is empty. Covers AE: "Empty session shows an exports placeholder, not a chat transcript." +- Disconnected state: the LiveView still mounts and renders normally; the agent-surface panel shows an "agent not connected" indicator. Covers AE: "Session detail page is reachable while the agent is disconnected." + +**Patterns to follow:** +- xterm.js hook: `assets/js/hooks/xterm_hook.js` (`TerminalPanel` preset) — exactly the same hook the existing terminal LiveView uses. +- LiveView streams: see `CLAUDE.md` LiveView streams section; the chat path's `WorkflowRunnerLive` is the closest in-repo example, but **do not import from it**. +- Layout shell: `` per CLAUDE.md. + +**Test scenarios:** +- *No chat textarea:* mount the LiveView, assert `refute has_element?(view, "textarea[name='chat']")` and that there is no `<.form id="chat-form">` element. Covers AE. +- *Exports headline placement:* assert the exports panel has `id="exports-panel"` and sits structurally before `id="event-log-panel"` in the DOM. (Use LazyHTML to assert ordering.) Covers AE. +- *Empty exports placeholder:* with no exports, `#exports-empty-placeholder` is visible and the event-log is collapsed. Covers AE. +- *Real-time export render:* publish an `:export_added` event over PubSub; assert the new export's `id` appears in `#exports-panel` without a navigation. Covers AE. +- *phase_complete auto-advance:* publish `:phase_advanced`; assert the phase header updates and no confirmation modal renders. Covers AE. +- *suggest_phase_complete confirmation:* publish `:suggest_phase_complete` with a reason; assert the modal renders with the reason; clicking confirm fires a LiveView event that calls `Sessions.transition_status/2`. Covers AE. +- *No phase advance without explicit call:* publish unrelated events; assert phase header unchanged. Covers AE: "Phase advances only on an explicit phase_complete tool call." +- *Question render:* publish `:question_asked`; assert option buttons render with the right values; clicking one fires the answer event. Covers AE. +- *Question answered marker:* after a `:question_answered` event, the original question card renders as answered (greyed/checked). Covers AE. +- *Embedded terminal renders:* for `host_mode: :embedded`, `#embedded-terminal` is in the DOM with the `phx-hook="TerminalPanel"` attribute. Covers AE. +- *External-host panel renders:* for `host_mode: :external`, `#external-host-panel` is in the DOM with token + URL elements; no `#embedded-terminal`. Covers AE. +- *Disconnected agent indicator:* publish `:agent_disconnected`; assert `#agent-status` shows "not connected" copy. Covers AE. +- *Direct user input into embedded terminal:* simulate a `phx-event` `terminal_input` from the xterm.js hook with a payload of `"hello\n"`; assert that the LiveView forwards the bytes to the underlying `Destila.Terminal.Server.write/2` (verified via Mimic). Covers AE: "User types directly into the embedded terminal." + +**Verification:** +- `mix test test/destila_web/live/agent_session_live_test.exs` passes. +- Manual: open `/agent-sessions/` in the dev server; the page loads with the export-first layout and no chat textarea. + +--- + +### U9. Crafting board entry point + AgentSessionCreateLive + +**Goal:** A user-facing entry point to create a new MCP-driven session, choose host mode, choose a workflow from the YAML registry, and land on `AgentSessionLive`. + +**Requirements:** Covers the "Session is created without a chat textarea" scenario from the user's perspective (entering the session, not just rendering it). + +**Dependencies:** U3, U8. + +**Files:** +- `lib/destila_web/live/crafting_board_live.ex` — additive: new "New MCP-driven session" card alongside the existing "Start New Workflow" entry. Routes to `/agent-sessions/new`. **No removal of existing UI.** +- `lib/destila_web/live/agent_session_create_live.ex` — form-based create flow: choose workflow (from `WorkflowLoader.list_all/0`), choose host mode (embedded or external), optional project association. On submit calls `Sessions.create_session/1`, redirects to `/agent-sessions/:id`. +- `lib/destila_web/router.ex` — add `live "/agent-sessions/new", AgentSessionCreateLive`. +- `test/destila_web/live/agent_session_create_live_test.exs` — tests the create flow. + +**Approach:** +- The new crafting board card is purely additive — chat-path entries unchanged. Use a distinct label ("New agent-driven session" or similar) and a flag/badge (e.g., "MCP") so it's clearly the new path during the rollout window. +- The form uses `to_form/2` per Phoenix conventions. Workflow dropdown sourced from `WorkflowLoader.list_all/0`. Host mode is a radio. +- Sessions can be created without an attached project (matches the user prompt's lack of project requirement for the new path). + +**Patterns to follow:** +- Form pattern: see `lib/destila_web/live/create_session_live.ex` for the existing chat-path create flow as a structural reference (but do not refactor it). +- Crafting board card pattern: see existing cards in `lib/destila_web/live/crafting_board_live.ex`. + +**Test scenarios:** +- *Happy path embedded:* fill the form, select "embedded", select a workflow, submit; assert redirect to `/agent-sessions/:id` and that `Sessions.get_session/1` returns a row with `host_mode: :embedded`. +- *Happy path external:* same with "external"; row stored as `:external`. +- *Validation — workflow not chosen:* form re-renders with error. +- *Crafting board card visible:* mount the crafting board, assert the new "New MCP-driven session" card is present with `id="new-mcp-session-card"`. +- *Existing crafting board entries still present:* assert the existing "Start New Workflow" entry is still rendered (regression check that we haven't accidentally removed chat-path UI). + +**Verification:** +- `mix test test/destila_web/live/agent_session_create_live_test.exs` passes. +- `mix test test/destila_web/live/crafting_board_live_test.exs` (existing chat-path tests for the crafting board) still passes. + +--- + +### U10. features/mcp_driven_session.feature + mock-MCP test harness + manual smoke test docs + +**Goal:** Land the single new Gherkin file with the seven sections specified in the request, the `MockMCPClient` test helper that backs the LiveView tests in U2–U9, and short documentation for running the manual smoke test from U1. + +**Requirements:** Codifies all behavioral commitments in the feature file. Closes the loop on the "primary test layer is mock-MCP driving LiveView tests" decision from the request. + +**Dependencies:** U1 (smoke script), U3 (event router boundary), U8 (LiveView). + +**Files:** +- `features/mcp_driven_session.feature` — the exact seven-section file from the user prompt, verbatim. **Do not modify the existing 27 feature files.** +- `test/support/mock_mcp_client.ex` — helper that publishes the same PubSub events the real HTTP/SSE controller would, plus convenience methods like `simulate_tool_call/3`, `simulate_export/2`, `simulate_question/2`, `simulate_disconnect/1`. Used by every LiveView test in U2–U9 to drive the system without touching HTTP. +- `test/destila_web/live/agent_session_live_test.exs` — gains `@tag feature: "mcp_driven_session", scenario: "..."` on each test mapped to a scenario in the feature file. (The actual tests are written in U8 and U9 — this unit just guarantees every scenario in the feature file has at least one linked test.) +- `docs/mcp_smoke_test.md` — operator documentation for running `scripts/mcp_smoke.sh` against the dev server (when to run it, expected output, troubleshooting). + +**Approach:** +- The feature file is copied verbatim from the user prompt — no editorial changes. The seven section dividers (`# --- ... ---`) match the style of `features/exported_metadata.feature`. +- `MockMCPClient` is the standard substitute for the HTTP+SSE controller in LiveView tests. It calls `EventRouter.handle_rpc/3` directly (the public boundary of the orchestrator), bypassing HTTP framing. Any test that needs to exercise real HTTP framing instead goes through `RpcController` test cases in `test/destila_web/mcp/rpc_controller_test.exs` (a small number of tests). +- The manual smoke test (`scripts/mcp_smoke.sh`) is documented in `docs/mcp_smoke_test.md` with a "run before any release that touches the new path" recommendation. It is intentionally not in `mix test`. +- Every scenario in the feature file is mapped to at least one test in U2–U9 via `@tag feature: "mcp_driven_session", scenario: "Scenario name"`. Run `mix test --only feature:mcp_driven_session` as a coverage sanity check. + +**MockMCPClient public API — concrete contract:** + +`MockMCPClient` is the boundary every U2–U9 LiveView test mocks against. It composes with `EventRouter` (no HTTP, no SSE, no Go bridge in the loop). All functions take an `agent_session_id` (binary) as the first argument. + +| Function | Effect on the system | Returns | +|---|---|---| +| `simulate_connect(session_id)` | Broadcasts `{:sse_connected, ref}` to the SessionServer; same effect as a real bridge opening the SSE stream | `:ok` | +| `simulate_disconnect(session_id)` | Broadcasts `{:sse_closed, ref}`; SessionServer transitions to `:disconnected` | `:ok` | +| `simulate_tool_call(session_id, tool_name, arguments)` | Calls `EventRouter.handle_rpc(session_id, %{"method" => "tools/call", "params" => %{"name" => tool_name, "arguments" => arguments}, "id" => auto_id})`. Returns the JSON-RPC reply payload the real RpcController would have returned. | `{:ok, reply_payload}` or `{:error, jsonrpc_error}` | +| `simulate_export(session_id, key, value, opts \\ [])` | Convenience: builds the `arguments` map for a `mcp__destila__session` call with `action: "export"` and calls `simulate_tool_call/3`. `opts` accepts `:type` (`:text \| :markdown \| :file`), `:phase_index`. | `{:ok, reply}` | +| `simulate_phase_complete(session_id, message \\ nil)` | Convenience for `mcp__destila__session` with `action: "phase_complete"`. | `{:ok, reply}` | +| `simulate_suggest_phase_complete(session_id, message)` | Convenience for `action: "suggest_phase_complete"`. | `{:ok, reply}` | +| `simulate_question(session_id, question, options)` | Convenience for `mcp__destila__ask_user_question`. Returns the `question_id` for use with `expect_answer/3`. | `{:ok, %{question_id: id}}` | +| `expect_answer(session_id, question_id, timeout \\ 100)` | Blocks until the SessionServer broadcasts `{:question_answered, question_id, value}` (which happens when the LiveView fires the user's selection event). Returns the answer value. | `{:ok, value}` or `{:error, :timeout}` | +| `take_stdin_pushes(session_id)` | Drains and returns the list of strings the embedded host would have pushed to the agent's stdin since the last call. The mock embedded host (registered via Mimic at test boot) buffers these in an ETS table. | `[binary]` | +| `take_paste_buffer(session_id)` | Analogous to `take_stdin_pushes/1` but for the external host's paste buffer. | `[binary]` | +| `subscribe(session_id)` | Subscribes the calling test process to `agent_session:` PubSub, so the test can `assert_receive {:export_added, _}` etc. | `:ok` | + +Composition rule: every LiveView test mounts the LiveView with a real `agent_session_id`, then uses `MockMCPClient` to drive system inputs (tool calls, connect/disconnect) and `take_*` helpers to assert side effects (stdin pushes, paste buffer). The real `EventRouter`, `SessionServer`, `SessionRegistry`, `SessionSupervisor`, and `Tools.*` modules all run in the test — only the HTTP/SSE layer and the embedded-host stdin/paste sinks are mocked. + +A `Destila.Agent.EmbeddedHost` Mimic stub is registered in `test/test_helper.exs` so any `push_kickoff/push_answer` call writes to an ETS-backed buffer the `take_stdin_pushes/1` helper reads. This keeps test setup ergonomic — tests do not need to manually wire the stub. + +**Patterns to follow:** +- Feature file structure: `features/exported_metadata.feature` for the section-divider style. +- Test tagging: every existing `.feature`-linked test in `test/` (e.g., `test/destila_web/live/workflow_runner_live_test.exs`) uses the `@tag feature: "...", scenario: "..."` pattern. + +**Test scenarios:** + +Test expectation: this unit's primary deliverables are the feature file, the harness module, and the doc. Behavioral test coverage is delivered in U2–U9 with `@tag feature: "mcp_driven_session"` annotations. This unit's verification asserts cross-referencing integrity: + +- *Every scenario in `features/mcp_driven_session.feature` has at least one `@tag scenario:` reference somewhere in `test/`.* A small lint helper (or a one-shot test) parses the feature file and the test files and asserts no scenarios are unlinked. +- *No `@tag scenario:` references a name not present in the feature file.* Same helper. +- *Running `mix test --only feature:mcp_driven_session` exercises a non-zero number of tests.* + +**Verification:** +- `mix test --only feature:mcp_driven_session` runs and all tagged tests pass. +- A coverage check confirms every scenario in the feature file has at least one linked test. +- `cat features/mcp_driven_session.feature` shows the file byte-identical to the user-supplied Gherkin. + +--- + +## System-Wide Impact + +| Surface | Impact | Mitigation | +|---|---|---| +| `DestilaWeb.Endpoint` | New `/mcp` scope added | Independent pipeline; no overlap with `:browser` | +| `lib/destila/application.ex` | Two new children (`AgentSessionSupervisor`, `AgentSessionRegistry`) + one boot-time call (`WorkflowLoader.load_all/0`) | All additive; chat-path supervision tree unchanged | +| `workflow_session_metadata` table | Nullable `agent_session_id` column + CHECK constraint | Existing rows have `agent_session_id = NULL`; existing queries unaffected as long as they don't introduce a CHECK violation by writing both FKs | +| `lib/destila_web/live/crafting_board_live.ex` | One additive card | Existing cards unchanged; regression test in U9 | +| `mix.exs` | New dep: `:yaml_elixir` | Standard Hex dep, well-maintained | +| `config/runtime.exs` | Reads `DESTILA_MCP_TOKEN` env | Optional in dev (defaults to a documented dev-only value); required in prod | +| Test suite | New test files under `test/destila/agent/`, `test/destila_web/mcp/`, `test/destila_web/live/agent_session_*` | Chat-path tests untouched | +| `priv/workflows/` | New directory | Loaded at boot; missing or empty dir is acceptable | +| `cmd/destila-mcp/` | New Go subdirectory | Separate release artifact; no impact on Elixir release | + +--- + +## Deepening Notes (2026-05-20) + +This plan was deepened on 2026-05-20 to sharpen five areas identified as thin in the first pass: + +1. **U1 protocol surface.** Made the bridge↔Destila HTTP shape explicit (methods, params, response envelope, headers, SSE event format) and clarified that the MCP wire protocol stays inside the Go bridge — only the bridge needs to track upstream MCP changes. Spec verification deferred to the U1 smoke test with `https://modelcontextprotocol.io/specification` as the reference. +2. **U2 storage engine and constraints.** Confirmed SQLite via `ecto_sqlite3 ~> 0.17`. Replaced the planned table-level CHECK constraint (high-risk on existing data, awkward in SQLite ALTER) with an application-level changeset validation in `lib/destila/workflows/session_metadata.ex`. Added three indexes for the new query shapes. +3. **U6 handoff race.** Replaced "queue pushes during the gap" hand-wave with a concrete sub-state machine (`:idle | :starting | :running | :stopping | :awaiting_new_agent | :failed`), a 30 s spawn-to-active gate, a 5 s soft-stop / 10 s hard-kill escalation, FIFO buffered stdin pushes flushed with 50 ms inter-write delay, and explicit handling of crash mid-handoff. Added six new U6 test scenarios. +4. **U10 mock-MCP harness.** Promoted `MockMCPClient` from a sketch to a concrete 10-function public API. Specified composition rules (`take_stdin_pushes/1`, `take_paste_buffer/1` for assertions; real `EventRouter`/`SessionServer` in the loop) and the Mimic stub registration pattern in `test/test_helper.exs`. +5. **Scenario coverage.** Audited all 23 scenarios in `features/mcp_driven_session.feature` against planned test scenarios across U2–U9. Two gaps found and filled: "Session log records only tool-call events" (added to U3 tests) and "User types directly into the embedded terminal" (added to U8 tests). + +No implementation-unit boundaries changed; no U-IDs were renumbered. + +--- + +## Risk Analysis & Mitigation + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Claude Code rejects our hand-rolled HTTP+SSE transport | Medium | High — blocks the whole pivot | Front-loaded as U1 with an end-to-end smoke test using the real `claude` binary. Land U1 fully before U2+. | +| SSE long-lived connection leaks under reconnect | Medium | Medium — orphaned processes, memory growth | `Process.monitor` the SSE handler process; `SessionServer` transitions to `:disconnected` on `:DOWN`; idle session GenServers shut down after 30 min. | +| Embedded terminal stdin race during handoff | Medium | Medium — kickoff prompt lost or sent to wrong agent | Concrete state machine in U6: monitor `:DOWN` on the old PTY before spawning the new one; FIFO-buffer pushes during `:starting`/`:awaiting_new_agent`; flush with 50 ms inter-write delay on `:running` entry. 30 s spawn-to-active gate transitions to `:failed` rather than hanging. | +| Application-level CHECK invariant bypassed via raw SQL | Low | Medium — orphaned/dual-FK exports rows possible | Decision in U2: changeset enforcement instead of DB CHECK is the trade-off. Compensate with a unit test that uses `Repo.insert_all/2` (which bypasses changesets) and asserts current behavior so any future code that bypasses changesets is loud. Document the constraint in the schema's `@moduledoc`. | +| Claude Code MCP wire protocol drifts upstream | Medium over time | Low for Destila, Medium for the bridge | The bridge isolates wire-protocol concerns; Destila's HTTP shape is stable. Updates to track upstream MCP changes require only rebuilding `cmd/destila-mcp/`. The smoke test (`scripts/mcp_smoke.sh`) is the canary. | +| `workflow_session_metadata` schema change breaks chat path | Low | High — chat path regression | Additive nullable FK + CHECK constraint; chat-path tests run unchanged in CI. | +| YAML loader silently accepts malformed config | Low | Medium — broken sessions at runtime | Strict validation at boot; missing required fields raise; covered in U5 test scenarios. | +| Global token leakage | Low (single-user deployment) | High in multi-user future | Documented as a trust-model decision; deferred to per-session-token follow-up work. | +| Go bridge versioning skew vs Destila | Medium over time | Medium | Bridge sends a `X-Destila-Bridge-Version` header; mismatch logged loudly. Out of scope to enforce semver gating in this plan. | +| `ask_user_question` answer never delivered (user closes tab) | Medium | Low — question stays open indefinitely | This is by design (the request explicitly states "the question should remain answerable for an indefinite period"). | +| Agent exits cleanly with `phase_complete` but new agent fails to spawn | Low | Medium — session stuck in `:awaiting_agent` | Embedded host surfaces spawn failures to the LiveView as a banner with a retry button. | + +--- + +## Open Questions Deferred to Implementation + +These are execution-time unknowns. Resolve in `ce-work`, not here. + +- Exact JSON-RPC method names the Go bridge sends for streaming notifications (depends on Claude Code's current MCP client behavior — verify against real traffic during U1). +- Whether `claude --append-system-prompt ` or `claude --system-prompt ` is the right flag for system-prompt injection in U6 (depends on current Claude Code CLI version). +- The precise yaml_elixir version (`~> 2.11` is a starting point; update if the lockfile demands). +- Tmpdir cleanup policy for per-session `.mcp.json` files (probably `Application.app_dir(:destila, "tmp/mcp")` + sweep on session deletion). +- Whether `cmd/destila-mcp/` should ship with a `Makefile` or be built via CI directly — depends on release tooling preferences surfaced in `priv/release/`. + +--- + +## Verification Strategy + +- **U1:** Unit tests + manual smoke test via `scripts/mcp_smoke.sh`. The smoke test is the gate that authorizes investing in U2+. +- **U2–U9:** Mock-MCP-driven LiveView tests at the `MockMCPClient`/`EventRouter` boundary, plus per-unit unit tests for context modules, schemas, tool handlers, host modules, and the workflow loader. +- **U10:** Feature-file/scenario coverage check ensures every Gherkin scenario has at least one linked test. +- **Chat-path regression:** the full pre-existing `mix test` suite passes unchanged after every unit. This is the explicit guard against accidental coupling. +- **Manual:** the smoke test in `scripts/mcp_smoke.sh` is run before any release that touches the new path. Documented in `docs/mcp_smoke_test.md`. + +--- + +## Operational / Rollout Notes + +- The chat path and the new agent path coexist throughout this work. Both are exposed in the crafting board (separate entry points). No feature flag is needed for the rollout — distinct UI entry points and distinct LiveView routes are sufficient isolation. +- `DESTILA_MCP_TOKEN` must be set in production before the new endpoint is reachable. In dev, the runtime config provides a clearly-marked default value. Document this in `config/runtime.exs` comments. +- The Go bridge is shipped as a separate release artifact. Existing release tooling under `config/runtime.exs` and `mix.exs` does not need to learn about it; the Go build can be added to CI separately. +- No data migration of existing chat sessions to the new path is performed by this plan — that is explicit follow-up work. + +--- + +## Alternative Approaches Considered + +- **Reuse `ClaudeCode.MCP.Server` macro via an in-process bridge process.** Rejected — `ClaudeCode.MCP.Server` is in-process only; the `claude` binary lives outside the BEAM and cannot reach it. Hand-rolling the HTTP+SSE transport is the lowest-friction path that keeps the existing tool semantics. +- **WebSocket instead of HTTP+SSE.** Rejected — Claude Code's MCP client supports HTTP+SSE transports out of the box; WebSocket framing adds complexity without value here. Reconsider if the smoke test in U1 reveals SSE problems. +- **Refactor the chat path to share `SessionProcess` with the new path.** Rejected by the request's "strict parallel rollout" constraint. Sharing the orchestrator now risks regressions in the chat path during the most active development period of the new path. Consolidation is deferred to the post-cutover refactor. +- **Embed the Go bridge logic directly in a Rustler/Zigler NIF.** Rejected — adds a build-time toolchain dependency on the Elixir side. The Go bridge is a clean release-artifact boundary; rebuilding it doesn't touch the BEAM. +- **Store assistant text in `agent_session_events` for forensic debugging.** Rejected by the request's "no assistant-text storage, ever" constraint. Tool-call events are the only forensic surface. +- **Block `ask_user_question` until the user replies.** Rejected by the request's explicit non-blocking semantics. Holding HTTP connections open for days is not viable. diff --git a/features/mcp_driven_session.feature b/features/mcp_driven_session.feature new file mode 100644 index 0000000..4bc0349 --- /dev/null +++ b/features/mcp_driven_session.feature @@ -0,0 +1,132 @@ +Feature: MCP-driven agent session + Destila operates as an MCP server. The agent (Claude Code CLI) talks to + Destila through MCP tool calls only — never through assistant text. + Sessions support two host modes: embedded (Destila launches `claude` in + its own PTY) and external (the user runs `claude` themselves). + + # --- Session creation and layout --- + + Scenario: Session is created without a chat textarea + Given I create a new MCP-driven session from the crafting board + Then I should land on the session page without any chat textarea + And the page should show an empty exports placeholder, not a chat transcript + + Scenario: Crafting board exposes the new entry point alongside the old one + Given I open the crafting board + Then I should see a card for creating a new MCP-driven session + And the existing chat-path entry should still be present + + Scenario: Empty session shows an exports placeholder, not a chat transcript + Given I open an MCP-driven session with no exports yet + Then the exports panel should render an empty-state placeholder + + Scenario: Session detail page is reachable while the agent is disconnected + Given an MCP-driven session has no connected agent + When I navigate to the session detail page + Then the page should mount normally with a "not connected" indicator + + # --- Explicit-only phase transitions --- + + Scenario: phase_complete auto-advances the session + Given an active MCP-driven session + When the agent calls mcp__destila__session with action=phase_complete + Then the current phase index should advance + And no confirmation prompt should appear + + Scenario: suggest_phase_complete waits for user confirmation + Given an active MCP-driven session + When the agent calls mcp__destila__session with action=suggest_phase_complete + Then a confirmation prompt should appear in the UI with the agent's reason + And the phase index should not change until the user confirms + + Scenario: Phase advances only on an explicit phase_complete tool call + Given an active MCP-driven session + When the agent emits assistant text suggesting the phase is done + Then the phase index should not change + + # --- Exports --- + + Scenario: New exports appear in real-time at the top of the session view + Given I am on an active MCP-driven session page + When the agent calls mcp__destila__session with action=export + Then the new export should appear in the exports panel without a navigation + + Scenario: Exports from prior phases remain available across handoff + Given an MCP-driven session has exports from a completed phase + When the next phase's agent starts + Then the new agent should be able to read them via the MCP exports tool + + # --- ask_user_question semantics --- + + Scenario: ask_user_question tool call does not block on the user's reply + Given an active MCP-driven session + When the agent calls mcp__destila__ask_user_question + Then the tool call returns immediately with a question id + And the question appears in the UI + + Scenario: Selection is written to the agent's stdin in embedded host mode + Given an embedded-host session with a pending question + When the user clicks an option in the UI + Then Destila writes the selected value to the agent's stdin + + Scenario: Selection is surfaced for manual paste in external host mode + Given an external-host session with a pending question + When the user clicks an option in the UI + Then the selected value should appear in a paste-target panel + + # --- Embedded host mode --- + + Scenario: Destila pushes a phase-kickoff prompt to the agent's stdin + Given an embedded-host session is starting a new phase + Then Destila writes the phase's kickoff prompt to the agent's stdin + + Scenario: Phase boundary stops the current agent and starts a fresh one + Given an embedded-host session + When a phase_complete tool call is received + Then Destila terminates the current claude process + And spawns a new one for the next phase + + Scenario: User types directly into the embedded terminal + Given I am on an embedded-host session page + When I type into the embedded terminal + Then the bytes are forwarded to the agent's stdin + + Scenario: Agent exit without phase_complete leaves the phase open + Given an embedded-host session is running + When the agent process exits before calling phase_complete + Then the session status should be disconnected + And the current phase index should not change + + # --- External host mode --- + + Scenario: Creating an external-host session shows MCP connection instructions + Given I create a new external-host MCP-driven session + Then I should see the bridge install command, token, and URL + + Scenario: Session activates when the external agent connects + Given an external-host session is awaiting an agent + When the external agent connects via the SSE channel + Then the session status should become active + + Scenario: Destila does not attempt stdin pushes in external host mode + Given an external-host session is active + When a kickoff prompt is queued + Then Destila must not invoke any PTY write + + Scenario: External host handoff requires user action + Given an external-host session completes a phase + Then a restart-your-agent modal should be shown to the user + + # --- Event log --- + + Scenario: Session log records only tool-call events + Given an active MCP-driven session + Then the event log table should contain only tool-call events + And no agent assistant text should be stored + + # --- Service tool parity --- + + Scenario: Service tool calls are dispatched to ServiceManager + Given an MCP-driven session attached to a project with a configured service + When the agent calls mcp__destila__service with action=status + Then ServiceManager.execute is invoked with the project's service config diff --git a/lib/destila/agent/embedded_host.ex b/lib/destila/agent/embedded_host.ex new file mode 100644 index 0000000..4fe6be5 --- /dev/null +++ b/lib/destila/agent/embedded_host.ex @@ -0,0 +1,71 @@ +defmodule Destila.Agent.EmbeddedHost do + @moduledoc """ + Lifecycle controller for embedded-host agent sessions: spawns a `claude` + process inside `Destila.Terminal.Server`, pushes kickoff prompts and + answers via stdin, and stops the process at phase boundaries. + + This module is mocked via Mimic in the test environment. Real PTY + side-effects only happen in dev/prod. + """ + + alias Destila.Agent.McpConfigWriter + alias Destila.Agent.Workflow.Phase + + require Logger + + @doc """ + Start a `claude` process for the given session/phase. Returns + `{:ok, terminal_pid}` or `{:error, reason}`. + """ + def start_phase(session_id, %Phase{} = phase, opts \\ []) do + cwd = Keyword.get(opts, :cwd, File.cwd!()) + topic = "agent_session_terminal:#{session_id}" + + {:ok, files} = McpConfigWriter.write(session_id, phase) + + cmd = resolve_command(phase.agent_command, files) + + Logger.debug(fn -> "EmbeddedHost.start_phase: #{inspect(cmd)} cwd=#{cwd}" end) + + Destila.Terminal.Server.start_link( + cwd: cwd, + topic: topic, + session_name: "agent-#{String.slice(session_id, 0, 12)}" + ) + end + + def stop_phase(terminal_pid) when is_pid(terminal_pid) do + if Process.alive?(terminal_pid) do + Destila.Terminal.Server.write(terminal_pid, "\x03") + :ok = GenServer.stop(terminal_pid, :normal, 5_000) + end + + :ok + rescue + _ -> :ok + end + + def stop_phase(_), do: :ok + + def push_kickoff(terminal_pid, kickoff_prompt) when is_pid(terminal_pid) do + Destila.Terminal.Server.write(terminal_pid, kickoff_prompt <> "\n") + :ok + end + + def push_kickoff(_, _), do: :ok + + def push_answer(terminal_pid, value) when is_pid(terminal_pid) do + Destila.Terminal.Server.write(terminal_pid, value <> "\n") + :ok + end + + def push_answer(_, _), do: :ok + + defp resolve_command(command, files) do + Enum.map(command, fn arg -> + arg + |> String.replace("{{mcp_config_path}}", files.mcp_config_path) + |> String.replace("{{system_prompt_path}}", files.system_prompt_path) + end) + end +end diff --git a/lib/destila/agent/event_router.ex b/lib/destila/agent/event_router.ex new file mode 100644 index 0000000..df2e822 --- /dev/null +++ b/lib/destila/agent/event_router.ex @@ -0,0 +1,88 @@ +defmodule Destila.Agent.EventRouter do + @moduledoc """ + Receives JSON-RPC tool calls from the HTTP transport layer and forwards + them to the right `SessionServer`. The single dispatch point between the + HTTP+SSE controllers and the per-session orchestrators. + """ + + alias Destila.Agent.SessionServer + + @doc """ + Handle a parsed JSON-RPC request. `payload` must be a decoded map with the + standard `"method"`, `"params"`, and `"id"` fields. Returns the JSON-RPC + response envelope to send back over HTTP. + """ + def handle_rpc(session_id, %{"method" => method} = payload) do + request_id = Map.get(payload, "id") + params = Map.get(payload, "params") || %{} + + case dispatch(session_id, method, params) do + {:ok, result} -> + if is_nil(request_id) do + # Notifications produce no reply + :noreply + else + %{"jsonrpc" => "2.0", "id" => request_id, "result" => result} + end + + {:error, code, message} -> + if is_nil(request_id) do + # Per JSON-RPC 2.0 §4.1, notifications MUST NOT receive a response, + # even on error. + :noreply + else + %{ + "jsonrpc" => "2.0", + "id" => request_id, + "error" => %{"code" => code, "message" => message} + } + end + end + end + + def handle_rpc(_session_id, _bad) do + %{ + "jsonrpc" => "2.0", + "id" => nil, + "error" => %{"code" => -32600, "message" => "Invalid Request"} + } + end + + defp dispatch(_session_id, "initialize", _params) do + {:ok, + %{ + "protocolVersion" => "2024-11-05", + "capabilities" => %{"tools" => %{}}, + "serverInfo" => %{"name" => "destila", "version" => destila_version()} + }} + end + + defp dispatch(_session_id, "notifications/initialized", _params), do: {:ok, %{}} + defp dispatch(_session_id, "notifications/cancelled", _params), do: {:ok, %{}} + defp dispatch(_session_id, "ping", _params), do: {:ok, %{}} + + defp dispatch(_session_id, "tools/list", _params) do + {:ok, %{"tools" => Destila.Agent.ToolHandlers.schemas()}} + end + + defp dispatch(session_id, "tools/call", %{"name" => name} = params) do + arguments = Map.get(params, "arguments") || %{} + + case SessionServer.handle_tool_call(session_id, name, arguments) do + {:ok, result} -> {:ok, result} + {:error, :unknown_tool} -> {:error, -32601, "Method not found: #{name}"} + {:error, reason} -> {:error, -32603, "Internal error: #{inspect(reason)}"} + end + end + + defp dispatch(_session_id, method, _params) do + {:error, -32601, "Method not found: #{method}"} + end + + defp destila_version do + case Application.spec(:destila, :vsn) do + vsn when is_list(vsn) -> List.to_string(vsn) + _ -> "0.0.0" + end + end +end diff --git a/lib/destila/agent/external_host.ex b/lib/destila/agent/external_host.ex new file mode 100644 index 0000000..dcd14dd --- /dev/null +++ b/lib/destila/agent/external_host.ex @@ -0,0 +1,57 @@ +defmodule Destila.Agent.ExternalHost do + @moduledoc """ + External host mode: the user runs `claude` on their own machine. Destila + has no stdin channel, so kickoff prompts and `ask_user_question` answers + are surfaced for the user to paste. + + This module is intentionally side-effect-light — it computes connection + info and emits PubSub events for the LiveView to render. + """ + + alias Destila.Agent.Sessions + + @doc """ + Returns connection info the user needs to wire up an external `claude` CLI: + bridge binary path, global token, server URL, and the session id env var name. + """ + def connection_info(session_id) do + %{ + bridge_path: Application.get_env(:destila, :mcp_bridge_path, "destila-mcp"), + token: Application.get_env(:destila, :mcp_token) || "", + mcp_url: + System.get_env("DESTILA_MCP_URL") || + "http://127.0.0.1:#{port_from_env()}/mcp", + session_id: session_id, + session_env_var: "DESTILA_SESSION_ID" + } + end + + @doc """ + Surface a kickoff prompt for paste by broadcasting a `:paste_target` PubSub + event to the session topic. + """ + def queue_kickoff(session_id, kickoff_prompt) do + Sessions.broadcast_session( + session_id, + {:paste_target, %{kind: :kickoff, value: kickoff_prompt}} + ) + + :ok + end + + def queue_answer(session_id, value) do + Sessions.broadcast_session( + session_id, + {:paste_target, %{kind: :answer, value: value}} + ) + + :ok + end + + defp port_from_env do + case Application.get_env(:destila, DestilaWeb.Endpoint) do + nil -> "4000" + endpoint -> endpoint |> Keyword.get(:http, []) |> Keyword.get(:port, 4000) |> to_string() + end + end +end diff --git a/lib/destila/agent/mcp_config_writer.ex b/lib/destila/agent/mcp_config_writer.ex new file mode 100644 index 0000000..5c25a01 --- /dev/null +++ b/lib/destila/agent/mcp_config_writer.ex @@ -0,0 +1,76 @@ +defmodule Destila.Agent.McpConfigWriter do + @moduledoc """ + Writes a per-session `.mcp.json` for the embedded `claude` agent and the + system-prompt file. Both files live in a per-session tmpdir. + """ + + @doc """ + Writes the `.mcp.json` and system-prompt files for the given session/phase. + + Returns `{:ok, %{mcp_config_path: ..., system_prompt_path: ..., tmpdir: ...}}`. + """ + def write(session_id, phase) do + tmpdir = Path.join([System.tmp_dir!(), "destila-mcp-#{session_id}"]) + File.mkdir_p!(tmpdir) + File.chmod!(tmpdir, 0o700) + + mcp_config_path = Path.join(tmpdir, "mcp.json") + system_prompt_path = Path.join(tmpdir, "system_prompt.md") + + File.write!(mcp_config_path, render_mcp_config(session_id)) + File.chmod!(mcp_config_path, 0o600) + + File.write!(system_prompt_path, phase.system_prompt) + File.chmod!(system_prompt_path, 0o600) + + {:ok, + %{ + mcp_config_path: mcp_config_path, + system_prompt_path: system_prompt_path, + tmpdir: tmpdir + }} + end + + def cleanup(session_id) do + tmpdir = Path.join([System.tmp_dir!(), "destila-mcp-#{session_id}"]) + File.rm_rf(tmpdir) + :ok + end + + defp render_mcp_config(session_id) do + bridge_path = + Application.get_env(:destila, :mcp_bridge_path) || + Path.expand("../cmd/destila-mcp/destila-mcp", __DIR__) + + token = + Application.get_env(:destila, :mcp_token) || + raise "DESTILA_MCP_TOKEN not configured; cannot write .mcp.json" + + url = + System.get_env("DESTILA_MCP_URL") || + "http://127.0.0.1:#{port_from_env()}/mcp" + + config = %{ + "mcpServers" => %{ + "destila" => %{ + "command" => bridge_path, + "args" => [], + "env" => %{ + "DESTILA_SESSION_ID" => session_id, + "DESTILA_MCP_TOKEN" => token, + "DESTILA_MCP_URL" => url + } + } + } + } + + Jason.encode!(config, pretty: true) + end + + defp port_from_env do + case Application.get_env(:destila, DestilaWeb.Endpoint) do + nil -> "4000" + endpoint -> endpoint |> Keyword.get(:http, []) |> Keyword.get(:port, 4000) |> to_string() + end + end +end diff --git a/lib/destila/agent/session.ex b/lib/destila/agent/session.ex new file mode 100644 index 0000000..37559fe --- /dev/null +++ b/lib/destila/agent/session.ex @@ -0,0 +1,49 @@ +defmodule Destila.Agent.Session do + @moduledoc """ + Ecto schema for `agent_sessions` — the MCP-driven session lifecycle row. + + The new agent path persists session lifecycle (current phase, host mode, + status) and tool-call events here, in parallel with the existing chat path + which uses `Destila.Workflows.Session`. + """ + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "agent_sessions" do + field(:workflow_name, :string) + field(:current_phase_index, :integer, default: 0) + field(:total_phases, :integer, default: 1) + + field(:host_mode, Ecto.Enum, values: [:embedded, :external]) + + field(:status, Ecto.Enum, + values: [:awaiting_agent, :active, :disconnected, :done], + default: :awaiting_agent + ) + + field(:connected_at, :utc_datetime) + field(:disconnected_at, :utc_datetime) + field(:title, :string) + field(:archived_at, :utc_datetime) + field(:deleted_at, :utc_datetime) + + belongs_to(:project, Destila.Projects.Project) + has_many(:events, Destila.Agent.SessionEvent, foreign_key: :agent_session_id) + + timestamps(type: :utc_datetime) + end + + @required ~w(workflow_name host_mode total_phases)a + @optional ~w(project_id current_phase_index status connected_at disconnected_at title archived_at deleted_at)a + + def changeset(session, attrs) do + session + |> cast(attrs, @required ++ @optional) + |> validate_required(@required) + |> validate_number(:total_phases, greater_than: 0) + |> assoc_constraint(:project) + end +end diff --git a/lib/destila/agent/session_event.ex b/lib/destila/agent/session_event.ex new file mode 100644 index 0000000..c6701ce --- /dev/null +++ b/lib/destila/agent/session_event.ex @@ -0,0 +1,33 @@ +defmodule Destila.Agent.SessionEvent do + @moduledoc """ + Ecto schema for `agent_session_events` — the tool-call event log. + + Only tool calls are recorded here. The new agent path never persists or + parses agent assistant text. + """ + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "agent_session_events" do + field(:phase_index, :integer, default: 0) + field(:tool_name, :string) + field(:tool_input, :map) + field(:tool_result, :map) + field(:inserted_at, :utc_datetime) + + belongs_to(:agent_session, Destila.Agent.Session) + end + + @required ~w(agent_session_id tool_name)a + @optional ~w(phase_index tool_input tool_result inserted_at)a + + def changeset(event, attrs) do + event + |> cast(attrs, @required ++ @optional) + |> validate_required(@required) + |> assoc_constraint(:agent_session) + end +end diff --git a/lib/destila/agent/session_server.ex b/lib/destila/agent/session_server.ex new file mode 100644 index 0000000..1c3d83e --- /dev/null +++ b/lib/destila/agent/session_server.ex @@ -0,0 +1,171 @@ +defmodule Destila.Agent.SessionServer do + @moduledoc """ + GenServer per active agent session. Receives tool calls from the HTTP+SSE + transport via `Destila.Agent.EventRouter`, mutates session state via + `Destila.Agent.Sessions`, and broadcasts UI events over PubSub. + + Phase advancement is *always* the result of an explicit `phase_complete` + tool call. The server never advances on its own. + """ + + use GenServer + + alias Destila.Agent.{Sessions, ToolHandlers} + alias Destila.Agent.Session + + @idle_timeout_ms :timer.minutes(30) + + # --- Client API --- + + def start_link(session_id), + do: GenServer.start_link(__MODULE__, session_id, name: via(session_id)) + + def child_spec(session_id) do + %{ + id: {__MODULE__, session_id}, + start: {__MODULE__, :start_link, [session_id]}, + restart: :temporary + } + end + + def ensure_started(session_id) do + case GenServer.whereis(via(session_id)) do + nil -> + case DynamicSupervisor.start_child( + Destila.Agent.SessionSupervisor, + {__MODULE__, session_id} + ) do + {:ok, pid} -> {:ok, pid} + {:error, {:already_started, pid}} -> {:ok, pid} + {:error, reason} -> {:error, reason} + end + + pid -> + {:ok, pid} + end + end + + def whereis(session_id), do: GenServer.whereis(via(session_id)) + + @tool_call_timeout :timer.seconds(60) + + def handle_tool_call(session_id, name, params) do + with {:ok, pid} <- ensure_started(session_id) do + try do + GenServer.call(pid, {:tool_call, name, params}, @tool_call_timeout) + catch + :exit, reason -> {:error, {:server_exit, reason}} + end + end + end + + def sse_connected(session_id) do + with {:ok, pid} <- ensure_started(session_id) do + GenServer.cast(pid, :sse_connected) + end + end + + def sse_closed(session_id) do + case whereis(session_id) do + nil -> :ok + pid -> GenServer.cast(pid, :sse_closed) + end + end + + def answer_question(session_id, question_id, value) do + case whereis(session_id) do + nil -> {:error, :no_server} + pid -> GenServer.call(pid, {:answer_question, question_id, value}) + end + end + + def get_state(session_id) do + case whereis(session_id) do + nil -> {:error, :no_server} + pid -> GenServer.call(pid, :get_state) + end + end + + defp via(session_id), do: {:via, Registry, {Destila.Agent.SessionRegistry, session_id}} + + # --- GenServer callbacks --- + + @impl true + def init(session_id) do + case Sessions.get_session(session_id) do + nil -> + {:stop, {:no_session, session_id}} + + %Session{} = session -> + {:ok, + %{ + session: session, + pending_questions: %{} + }, @idle_timeout_ms} + end + end + + @impl true + def handle_call({:tool_call, name, params}, _from, state) do + {reply, state} = ToolHandlers.dispatch(name, params, state) + {:reply, reply, state, @idle_timeout_ms} + end + + def handle_call({:answer_question, question_id, value}, _from, state) do + Sessions.broadcast_session( + state.session.id, + {:question_answered, question_id, value} + ) + + pending = Map.delete(state.pending_questions, question_id) + {:reply, :ok, %{state | pending_questions: pending}, @idle_timeout_ms} + end + + def handle_call(:get_state, _from, state) do + {:reply, state, state, @idle_timeout_ms} + end + + @impl true + def handle_cast(:sse_connected, state) do + session = + case state.session.status do + status when status in [:awaiting_agent, :disconnected] -> + case Sessions.mark_connected(state.session) do + {:ok, updated} -> updated + {:error, _} -> state.session + end + + _ -> + state.session + end + + Sessions.broadcast_session(session.id, {:agent_connected, session}) + {:noreply, %{state | session: session}, @idle_timeout_ms} + end + + def handle_cast(:sse_closed, state) do + session = + case state.session.status do + :active -> + case Sessions.mark_disconnected(state.session) do + {:ok, updated} -> updated + {:error, _} -> state.session + end + + _ -> + state.session + end + + Sessions.broadcast_session(session.id, {:agent_disconnected, session}) + {:noreply, %{state | session: session}, @idle_timeout_ms} + end + + @impl true + def handle_info(:timeout, state) do + {:stop, :normal, state} + end + + def handle_info(_msg, state) do + {:noreply, state, @idle_timeout_ms} + end +end diff --git a/lib/destila/agent/sessions.ex b/lib/destila/agent/sessions.ex new file mode 100644 index 0000000..89076c8 --- /dev/null +++ b/lib/destila/agent/sessions.ex @@ -0,0 +1,185 @@ +defmodule Destila.Agent.Sessions do + @moduledoc """ + Context module for the new MCP-driven agent sessions path. + + All persistence, querying, and PubSub broadcasting for `agent_sessions` + and `agent_session_events` lives here. Mirrors the chat-path `Destila.Workflows` + context shape. + """ + + import Ecto.Query + + alias Destila.Repo + alias Destila.Agent.{Session, SessionEvent} + alias Destila.Workflows.SessionMetadata + + # --- Session queries --- + + def list_sessions(opts \\ []) do + project_id = Keyword.get(opts, :project_id) + include_archived? = Keyword.get(opts, :include_archived, false) + + Session + |> where([s], is_nil(s.deleted_at)) + |> maybe_filter_project(project_id) + |> maybe_filter_archived(include_archived?) + |> order_by([s], desc: s.inserted_at) + |> Repo.all() + end + + defp maybe_filter_project(query, nil), do: query + defp maybe_filter_project(query, id), do: where(query, [s], s.project_id == ^id) + + defp maybe_filter_archived(query, true), do: query + defp maybe_filter_archived(query, false), do: where(query, [s], is_nil(s.archived_at)) + + def get_session(id), do: Repo.get(Session, id) + def get_session!(id), do: Repo.get!(Session, id) + + def create_session(attrs) do + %Session{} + |> Session.changeset(attrs) + |> Repo.insert() + |> broadcast(:agent_session_created) + end + + def update_session(%Session{} = session, attrs) do + session + |> Session.changeset(attrs) + |> Repo.update() + |> broadcast(:agent_session_updated) + end + + @doc """ + Transitions session status. Acceptable statuses are listed on the schema. + Broadcasts `:agent_session_updated` on success. + """ + def transition_status(%Session{} = session, status) when is_atom(status) do + update_session(session, %{status: status}) + end + + def mark_connected(%Session{} = session) do + update_session(session, %{ + status: :active, + connected_at: DateTime.utc_now() |> DateTime.truncate(:second), + disconnected_at: nil + }) + end + + def mark_disconnected(%Session{} = session) do + update_session(session, %{ + status: :disconnected, + disconnected_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + end + + @doc """ + Increments the phase index. Returns the updated session or `{:error, :no_more_phases}`. + """ + def advance_phase(%Session{} = session) do + next = session.current_phase_index + 1 + + cond do + next >= session.total_phases -> + update_session(session, %{status: :done, current_phase_index: next - 1}) + + true -> + update_session(session, %{current_phase_index: next}) + end + end + + # --- Event queries --- + + def list_events(session_id) do + SessionEvent + |> where([e], e.agent_session_id == ^session_id) + |> order_by([e], asc: e.inserted_at) + |> Repo.all() + end + + def record_event(%Session{} = session, tool_name, attrs \\ %{}) do + attrs = + attrs + |> Map.new() + |> Map.put(:agent_session_id, session.id) + |> Map.put(:tool_name, tool_name) + |> Map.put_new(:phase_index, session.current_phase_index) + |> Map.put_new_lazy(:inserted_at, fn -> + DateTime.utc_now() |> DateTime.truncate(:second) + end) + + result = + %SessionEvent{} + |> SessionEvent.changeset(attrs) + |> Repo.insert() + + case result do + {:ok, event} -> + broadcast_session(session.id, {:tool_call_event, event}) + {:ok, event} + + err -> + err + end + end + + # --- Exports --- + + def list_exports(session_id) do + SessionMetadata + |> where([m], m.agent_session_id == ^session_id and m.exported == true) + |> order_by([m], asc: m.inserted_at) + |> Repo.all() + end + + def record_export(%Session{} = session, attrs) do + attrs = + attrs + |> Map.new() + |> Map.put(:agent_session_id, session.id) + |> Map.put_new(:exported, true) + |> Map.put_new(:phase_index, session.current_phase_index) + + changeset = SessionMetadata.changeset(%SessionMetadata{}, attrs) + + result = + Repo.insert(changeset, + on_conflict: {:replace, [:value, :exported, :phase_index, :updated_at]}, + conflict_target: [:agent_session_id, :phase_name, :key] + ) + + case result do + {:ok, meta} -> + broadcast_session(session.id, {:export_added, meta}) + {:ok, meta} + + err -> + err + end + end + + # --- PubSub helpers --- + + def topic(session_id), do: "agent_session:#{session_id}" + def outbound_topic(session_id), do: "agent_session_outbound:#{session_id}" + + def subscribe(session_id) do + Phoenix.PubSub.subscribe(Destila.PubSub, topic(session_id)) + end + + def broadcast_session(session_id, msg) do + Phoenix.PubSub.broadcast(Destila.PubSub, topic(session_id), msg) + end + + defp broadcast({:ok, session} = result, event) do + Phoenix.PubSub.broadcast(Destila.PubSub, "store:updates", {event, session}) + + if Map.has_key?(session, :id) do + Phoenix.PubSub.broadcast(Destila.PubSub, topic(session.id), {event, session}) + end + + result + end + + defp broadcast({:error, _} = err, _event), do: err +end diff --git a/lib/destila/agent/tool_handlers.ex b/lib/destila/agent/tool_handlers.ex new file mode 100644 index 0000000..41969c4 --- /dev/null +++ b/lib/destila/agent/tool_handlers.ex @@ -0,0 +1,118 @@ +defmodule Destila.Agent.ToolHandlers do + @moduledoc """ + Dispatch table for the four MCP tools the new agent path exposes. + + Each handler receives the parsed `arguments` map and the current + `SessionServer` state, and returns `{reply, new_state}` where `reply` + is `{:ok, result}` or `{:error, reason}`. + """ + + alias Destila.Agent.Tools + + @handlers %{ + "session" => Tools.SessionTool, + "mcp__destila__session" => Tools.SessionTool, + "ask_user_question" => Tools.AskUserQuestionTool, + "mcp__destila__ask_user_question" => Tools.AskUserQuestionTool, + "service" => Tools.ServiceTool, + "mcp__destila__service" => Tools.ServiceTool, + "exports_read" => Tools.ExportsReadTool, + "mcp__destila__exports_read" => Tools.ExportsReadTool + } + + @doc """ + Look up the handler by tool name and invoke it. Used by `SessionServer`. + """ + def dispatch(name, arguments, state) do + case Map.get(@handlers, name) do + nil -> + {{:error, :unknown_tool}, state} + + module -> + module.execute(arguments, state) + end + end + + @doc """ + Return the JSON Schema list used for `tools/list`. Hard-coded here to keep + the agent path's tool surface independent of the chat path's macro-driven + definitions in `Destila.AI.Tools`. + """ + def schemas do + [ + %{ + "name" => "session", + "description" => + "Signal a phase transition or export metadata. Use action=phase_complete to auto-advance, action=suggest_phase_complete to ask the user, action=export to store a key/value.", + "inputSchema" => %{ + "type" => "object", + "required" => ["action"], + "properties" => %{ + "action" => %{ + "type" => "string", + "enum" => ["suggest_phase_complete", "phase_complete", "export"] + }, + "message" => %{"type" => "string"}, + "key" => %{"type" => "string"}, + "value" => %{"type" => "string"}, + "type" => %{"type" => "string", "enum" => ["text", "markdown", "file"]} + } + } + }, + %{ + "name" => "ask_user_question", + "description" => + "Present structured questions to the user with selectable options. Returns immediately; the user's answer is delivered out-of-band.", + "inputSchema" => %{ + "type" => "object", + "required" => ["questions"], + "properties" => %{ + "questions" => %{ + "type" => "array", + "items" => %{ + "type" => "object", + "required" => ["title", "question", "multi_select", "options"], + "properties" => %{ + "title" => %{"type" => "string"}, + "question" => %{"type" => "string"}, + "multi_select" => %{"type" => "boolean"}, + "options" => %{ + "type" => "array", + "items" => %{ + "type" => "object", + "required" => ["label", "description"], + "properties" => %{ + "label" => %{"type" => "string"}, + "description" => %{"type" => "string"} + } + } + } + } + } + } + } + } + }, + %{ + "name" => "service", + "description" => + "Manage the project's development service lifecycle (start/stop/restart/status).", + "inputSchema" => %{ + "type" => "object", + "required" => ["action"], + "properties" => %{ + "action" => %{ + "type" => "string", + "enum" => ["start", "stop", "restart", "status"] + } + } + } + }, + %{ + "name" => "exports_read", + "description" => "Read all exports recorded in this session (including prior phases).", + "inputSchema" => %{"type" => "object", "properties" => %{}} + } + ] + end +end diff --git a/lib/destila/agent/tools/ask_user_question_tool.ex b/lib/destila/agent/tools/ask_user_question_tool.ex new file mode 100644 index 0000000..3c7e085 --- /dev/null +++ b/lib/destila/agent/tools/ask_user_question_tool.ex @@ -0,0 +1,43 @@ +defmodule Destila.Agent.Tools.AskUserQuestionTool do + @moduledoc """ + Handles `ask_user_question`. The tool returns immediately with an + acknowledgement; the user's answer is delivered later, out-of-band. + """ + + alias Destila.Agent.Sessions + + require Logger + + def execute(args, state) do + questions = Map.get(args, "questions", []) + question_id = generate_id() + + case Sessions.record_event(state.session, "ask_user_question", %{ + tool_input: args, + tool_result: %{"question_id" => question_id} + }) do + {:ok, _event} -> + :ok + + {:error, changeset} -> + Logger.warning( + "Sessions.record_event(ask_user_question) failed: #{inspect(changeset.errors)}" + ) + end + + Sessions.broadcast_session( + state.session.id, + {:question_asked, %{question_id: question_id, questions: questions}} + ) + + {{:ok, + %{ + "ok" => true, + "question_id" => question_id, + "content" => [%{"type" => "text", "text" => "Question presented to user."}], + "isError" => false + }}, state} + end + + defp generate_id, do: 16 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower) +end diff --git a/lib/destila/agent/tools/exports_read_tool.ex b/lib/destila/agent/tools/exports_read_tool.ex new file mode 100644 index 0000000..6375586 --- /dev/null +++ b/lib/destila/agent/tools/exports_read_tool.ex @@ -0,0 +1,46 @@ +defmodule Destila.Agent.Tools.ExportsReadTool do + @moduledoc """ + Returns all exports recorded in this agent session, including ones from + earlier phases. Lets a freshly-started post-handoff agent recover prior + context without inheriting conversational state. + """ + + alias Destila.Agent.Sessions + + require Logger + + def execute(_args, state) do + exports = Sessions.list_exports(state.session.id) + + payload = + Enum.map(exports, fn meta -> + value = meta.value || %{} + + %{ + "phase_name" => meta.phase_name, + "phase_index" => meta.phase_index, + "key" => meta.key, + "value" => Map.get(value, "value", value), + "type" => Map.get(value, "type", "text") + } + end) + + case Sessions.record_event(state.session, "exports_read", %{ + tool_input: %{}, + tool_result: %{"count" => length(payload)} + }) do + {:ok, _event} -> + :ok + + {:error, changeset} -> + Logger.warning("Sessions.record_event(exports_read) failed: #{inspect(changeset.errors)}") + end + + {{:ok, + %{ + "content" => [%{"type" => "text", "text" => Jason.encode!(payload)}], + "isError" => false, + "exports" => payload + }}, state} + end +end diff --git a/lib/destila/agent/tools/service_tool.ex b/lib/destila/agent/tools/service_tool.ex new file mode 100644 index 0000000..89ec342 --- /dev/null +++ b/lib/destila/agent/tools/service_tool.ex @@ -0,0 +1,60 @@ +defmodule Destila.Agent.Tools.ServiceTool do + @moduledoc """ + Service tool stub for the agent path. + + The chat-path `Destila.Services.ServiceManager.execute/3` expects a + workflow-session-shaped struct. Wiring the agent path to that helper + requires either a refactor of `ServiceManager` to accept agent sessions + or a new `execute_for_agent_session/3` entry point — both larger than the + scope of this commit. Until then this handler returns a typed error so + the agent gets a clear signal instead of a runtime crash. + """ + + alias Destila.Agent.Sessions + + @valid_actions ~w(start stop restart status) + + def execute(args, state) do + action = Map.get(args, "action") + + {:ok, _event} = + Sessions.record_event(state.session, "service.#{action || "unknown"}", %{ + tool_input: args, + tool_result: %{"status" => "not_implemented"} + }) + + cond do + action not in @valid_actions -> + {{:ok, + %{ + "content" => [ + %{"type" => "text", "text" => "Unknown service action: #{inspect(action)}"} + ], + "isError" => true + }}, state} + + is_nil(state.session.project_id) -> + {{:ok, + %{ + "content" => [ + %{"type" => "text", "text" => "Agent session has no attached project."} + ], + "isError" => true + }}, state} + + true -> + {{:ok, + %{ + "content" => [ + %{ + "type" => "text", + "text" => + "service.#{action} is not yet wired for MCP-driven sessions. " <> + "Use the existing chat-path workflow runner for service management." + } + ], + "isError" => true + }}, state} + end + end +end diff --git a/lib/destila/agent/tools/session_tool.ex b/lib/destila/agent/tools/session_tool.ex new file mode 100644 index 0000000..ebdd3e6 --- /dev/null +++ b/lib/destila/agent/tools/session_tool.ex @@ -0,0 +1,113 @@ +defmodule Destila.Agent.Tools.SessionTool do + @moduledoc """ + Handles the `session` tool — phase transitions and exports. + """ + + alias Destila.Agent.Sessions + + require Logger + + def execute(args, state) do + case Map.get(args, "action") do + "phase_complete" -> phase_complete(args, state) + "suggest_phase_complete" -> suggest_phase_complete(args, state) + "export" -> export(args, state) + other -> {{:error, "unknown action: #{inspect(other)}"}, state} + end + end + + defp phase_complete(args, state) do + message = Map.get(args, "message") + + safe_record_event(state.session, "session.phase_complete", %{ + tool_input: args, + tool_result: %{"message" => message} + }) + + case Sessions.advance_phase(state.session) do + {:ok, session} -> + Sessions.broadcast_session(session.id, {:phase_advanced, session.current_phase_index}) + + {{:ok, ack("Phase complete acknowledged.")}, %{state | session: session}} + + {:error, changeset} -> + Logger.warning("Sessions.advance_phase failed: #{inspect(changeset.errors)}") + + {{:ok, + %{ + "content" => [ + %{"type" => "text", "text" => "Phase advance failed; please retry."} + ], + "isError" => true + }}, state} + end + end + + defp suggest_phase_complete(args, state) do + message = Map.get(args, "message", "") + + safe_record_event(state.session, "session.suggest_phase_complete", %{ + tool_input: args, + tool_result: %{"message" => message} + }) + + Sessions.broadcast_session( + state.session.id, + {:suggest_phase_complete, message} + ) + + {{:ok, ack("Suggestion sent to user.")}, state} + end + + defp export(args, state) do + key = Map.get(args, "key") + value = Map.get(args, "value") + type = Map.get(args, "type", "text") + + cond do + is_nil(key) or key == "" -> + {{:error, "export requires a key"}, state} + + is_nil(value) -> + {{:error, "export requires a value"}, state} + + true -> + phase_name = phase_name_for(state) + + case Sessions.record_export(state.session, %{ + phase_name: phase_name, + key: key, + value: %{"value" => value, "type" => type} + }) do + {:ok, _meta} -> + safe_record_event(state.session, "session.export", %{ + tool_input: args, + tool_result: %{"key" => key, "type" => type} + }) + + {{:ok, ack("Export #{key} recorded.")}, state} + + {:error, changeset} -> + {{:error, "export failed: #{inspect(changeset.errors)}"}, state} + end + end + end + + defp phase_name_for(state), do: "phase-#{state.session.current_phase_index}" + + defp safe_record_event(session, name, attrs) do + case Sessions.record_event(session, name, attrs) do + {:ok, event} -> + {:ok, event} + + {:error, changeset} -> + Logger.warning("Sessions.record_event(#{name}) failed: #{inspect(changeset.errors)}") + + :error + end + end + + defp ack(text) do + %{"content" => [%{"type" => "text", "text" => text}], "isError" => false} + end +end diff --git a/lib/destila/agent/workflow.ex b/lib/destila/agent/workflow.ex new file mode 100644 index 0000000..3c6235e --- /dev/null +++ b/lib/destila/agent/workflow.ex @@ -0,0 +1,17 @@ +defmodule Destila.Agent.Workflow do + @moduledoc """ + Defines a workflow loaded from `priv/workflows/*.yaml`. + + A workflow is an ordered list of `Destila.Agent.Workflow.Phase` structs. + """ + + alias Destila.Agent.Workflow.Phase + + @enforce_keys [:name, :phases] + defstruct [:name, :phases] + + @type t :: %__MODULE__{ + name: String.t(), + phases: [Phase.t()] + } +end diff --git a/lib/destila/agent/workflow/phase.ex b/lib/destila/agent/workflow/phase.ex new file mode 100644 index 0000000..21bb031 --- /dev/null +++ b/lib/destila/agent/workflow/phase.ex @@ -0,0 +1,19 @@ +defmodule Destila.Agent.Workflow.Phase do + @moduledoc """ + A single phase inside a `Destila.Agent.Workflow` definition. + + Carries the system prompt, the kickoff prompt that Destila pushes + (embedded) or surfaces for paste (external), and the agent command + Destila runs in embedded mode. + """ + + @enforce_keys [:name, :system_prompt, :kickoff_prompt, :agent_command] + defstruct [:name, :system_prompt, :kickoff_prompt, :agent_command] + + @type t :: %__MODULE__{ + name: String.t(), + system_prompt: String.t(), + kickoff_prompt: String.t(), + agent_command: [String.t()] + } +end diff --git a/lib/destila/agent/workflow_loader.ex b/lib/destila/agent/workflow_loader.ex new file mode 100644 index 0000000..7d259af --- /dev/null +++ b/lib/destila/agent/workflow_loader.ex @@ -0,0 +1,134 @@ +defmodule Destila.Agent.WorkflowLoader do + @moduledoc """ + Loads workflow definitions from `priv/workflows/*.yaml` into + `:persistent_term` for cheap read-many lookup. + + Called once at app boot. Strict validation: missing required fields raise. + """ + + alias Destila.Agent.Workflow + alias Destila.Agent.Workflow.Phase + + require Logger + + @workflows_dir "priv/workflows" + @known_top_keys ~w(name phases) + @known_phase_keys ~w(name system_prompt kickoff_prompt agent_command) + + @doc """ + Reads every `*.yaml` file in `priv/workflows/`, validates, caches. + Idempotent — safe to call repeatedly. + """ + def load_all do + dir = workflows_dir() + + workflows = + case File.exists?(dir) do + true -> + dir + |> Path.join("*.yaml") + |> Path.wildcard() + |> Enum.map(&parse_file/1) + + false -> + [] + end + + names = Enum.map(workflows, & &1.name) + + case names -- Enum.uniq(names) do + [] -> :ok + [dupe | _] -> raise "Duplicate workflow name: #{dupe}" + end + + Enum.each(workflows, fn wf -> + :persistent_term.put({__MODULE__, wf.name}, wf) + end) + + :persistent_term.put({__MODULE__, :__names__}, names) + :ok + end + + @doc "Returns the workflow definition or `{:error, :not_found}`." + def get(name) when is_binary(name) do + case :persistent_term.get({__MODULE__, name}, nil) do + nil -> {:error, :not_found} + %Workflow{} = wf -> {:ok, wf} + end + end + + @doc "Returns all loaded workflow names." + def list_all do + :persistent_term.get({__MODULE__, :__names__}, []) + |> Enum.map(fn name -> + {:ok, wf} = get(name) + wf + end) + end + + # --- Parsing --- + + defp parse_file(path) do + raw = + case YamlElixir.read_from_file(path) do + {:ok, data} -> data + {:error, reason} -> raise "Failed to parse #{path}: #{inspect(reason)}" + end + + unless is_map(raw), + do: raise("Workflow #{path} must be a map at the top level") + + log_unknown_keys(path, raw, @known_top_keys) + + name = Map.get(raw, "name") || raise "Workflow #{path} missing required field: name" + phases_raw = Map.get(raw, "phases") || raise "Workflow #{path} missing required field: phases" + + unless is_list(phases_raw) and length(phases_raw) > 0, + do: raise("Workflow #{path}: phases must be a non-empty list") + + phases = Enum.map(phases_raw, &parse_phase(&1, path)) + + %Workflow{name: name, phases: phases} + end + + defp parse_phase(raw, path) when is_map(raw) do + log_unknown_keys(path, raw, @known_phase_keys) + + %Phase{ + name: required(raw, "name", path), + system_prompt: required(raw, "system_prompt", path), + kickoff_prompt: required(raw, "kickoff_prompt", path), + agent_command: parse_agent_command(required(raw, "agent_command", path), path) + } + end + + defp parse_phase(_, path), do: raise("Workflow #{path}: each phase must be a map") + + defp parse_agent_command(cmd, _path) when is_list(cmd), do: cmd + defp parse_agent_command(cmd, _path) when is_binary(cmd), do: String.split(cmd, " ", trim: true) + defp parse_agent_command(_, path), do: raise("Workflow #{path}: agent_command must be a list") + + defp required(map, key, path) do + case Map.get(map, key) do + nil -> raise "Workflow #{path}: missing required field on phase: #{key}" + "" -> raise "Workflow #{path}: required field is empty: #{key}" + v -> v + end + end + + defp log_unknown_keys(path, map, known) do + unknown = Map.keys(map) -- known + + if unknown != [] do + Logger.warning("Workflow #{path} has unknown keys: #{inspect(unknown)}") + end + end + + defp workflows_dir do + case Application.app_dir(:destila, @workflows_dir) do + path -> path + end + rescue + _ -> @workflows_dir + end +end diff --git a/lib/destila/application.ex b/lib/destila/application.ex index c0ab392..8e75329 100644 --- a/lib/destila/application.ex +++ b/lib/destila/application.ex @@ -21,6 +21,8 @@ defmodule Destila.Application do {DynamicSupervisor, name: Destila.Sessions.Supervisor, strategy: :one_for_one}, {Registry, keys: :unique, name: Destila.Services.LogTailerRegistry}, {DynamicSupervisor, name: Destila.Services.LogTailerSupervisor, strategy: :one_for_one}, + {Registry, keys: :unique, name: Destila.Agent.SessionRegistry}, + {DynamicSupervisor, name: Destila.Agent.SessionSupervisor, strategy: :one_for_one}, DestilaWeb.Endpoint ] @@ -31,6 +33,20 @@ defmodule Destila.Application do case Supervisor.start_link(children, opts) do {:ok, _pid} = ok -> Task.start(fn -> Destila.Services.ProjectServices.resume_all() end) + + Task.start(fn -> + try do + Destila.Agent.WorkflowLoader.load_all() + rescue + e -> + require Logger + + Logger.error( + "Destila.Agent.WorkflowLoader.load_all/0 failed at boot: #{Exception.message(e)}" + ) + end + end) + ok other -> diff --git a/lib/destila/workflows/session_metadata.ex b/lib/destila/workflows/session_metadata.ex index 25a827c..3903f9a 100644 --- a/lib/destila/workflows/session_metadata.ex +++ b/lib/destila/workflows/session_metadata.ex @@ -1,4 +1,13 @@ defmodule Destila.Workflows.SessionMetadata do + @moduledoc """ + Exports table shared by the chat path (`workflow_session_id`) and the new + MCP-driven agent path (`agent_session_id`). + + Invariant: exactly one of `workflow_session_id` or `agent_session_id` is set. + Enforced at the application layer via `changeset/2` because SQLite cannot + cheaply add a CHECK constraint to the existing table. + """ + use Ecto.Schema import Ecto.Changeset @@ -6,18 +15,45 @@ defmodule Destila.Workflows.SessionMetadata do @foreign_key_type :binary_id schema "workflow_session_metadata" do field(:phase_name, :string) + field(:phase_index, :integer) field(:key, :string) field(:value, :map) field(:exported, :boolean, default: false) belongs_to(:workflow_session, Destila.Workflows.Session) + belongs_to(:agent_session, Destila.Agent.Session) timestamps(type: :utc_datetime) end def changeset(metadata, attrs) do metadata - |> cast(attrs, [:workflow_session_id, :phase_name, :key, :value, :exported]) - |> validate_required([:workflow_session_id, :phase_name, :key, :value]) + |> cast(attrs, [ + :workflow_session_id, + :agent_session_id, + :phase_name, + :phase_index, + :key, + :value, + :exported + ]) + |> validate_required([:phase_name, :key, :value]) + |> validate_exactly_one_session() + end + + defp validate_exactly_one_session(changeset) do + ws = get_field(changeset, :workflow_session_id) + as = get_field(changeset, :agent_session_id) + + case {ws, as} do + {nil, nil} -> + add_error(changeset, :workflow_session_id, "exactly one session FK must be set") + + {ws, as} when not is_nil(ws) and not is_nil(as) -> + add_error(changeset, :workflow_session_id, "only one session FK may be set") + + _ -> + changeset + end end end diff --git a/lib/destila_web/live/agent_session_create_live.ex b/lib/destila_web/live/agent_session_create_live.ex new file mode 100644 index 0000000..c7420d9 --- /dev/null +++ b/lib/destila_web/live/agent_session_create_live.ex @@ -0,0 +1,180 @@ +defmodule DestilaWeb.AgentSessionCreateLive do + @moduledoc """ + Form-based entry point for creating a new MCP-driven agent session. + + Lets the user pick a workflow (from YAML registry), a host mode, and + optionally an associated project. On submit redirects to + `/agent-sessions/:id`. + """ + + use DestilaWeb, :live_view + + alias Destila.Agent.{Sessions, WorkflowLoader} + + @impl true + def mount(_params, _session, socket) do + workflows = WorkflowLoader.list_all() + projects = Destila.Projects.list_projects() + + form = + to_form(%{ + "workflow_name" => default_workflow(workflows), + "host_mode" => "embedded", + "project_id" => "" + }) + + {:ok, + socket + |> assign(:page_title, "New Agent Session") + |> assign(:workflows, workflows) + |> assign(:projects, projects) + |> assign(:form, form) + |> assign(:error, nil)} + end + + @impl true + def handle_event("validate", %{"agent_session" => params}, socket) do + {:noreply, assign(socket, :form, to_form(params, as: :agent_session))} + end + + def handle_event("create", %{"agent_session" => params}, socket) do + workflow_name = Map.get(params, "workflow_name", "") + host_mode = Map.get(params, "host_mode", "embedded") + project_id = empty_to_nil(Map.get(params, "project_id")) + + with {:ok, workflow} <- WorkflowLoader.get(workflow_name) do + attrs = %{ + workflow_name: workflow.name, + host_mode: host_mode, + project_id: project_id, + total_phases: length(workflow.phases), + current_phase_index: 0 + } + + case Sessions.create_session(attrs) do + {:ok, session} -> + {:noreply, push_navigate(socket, to: ~p"/agent-sessions/#{session.id}")} + + {:error, changeset} -> + {:noreply, + assign(socket, :error, "Could not create session: #{inspect(changeset.errors)}")} + end + else + {:error, :not_found} -> + {:noreply, assign(socket, :error, "Please choose a workflow")} + end + end + + defp default_workflow([]), do: "" + defp default_workflow([wf | _]), do: wf.name + + defp empty_to_nil(""), do: nil + defp empty_to_nil(nil), do: nil + defp empty_to_nil(v), do: v + + @impl true + def render(assigns) do + ~H""" + +
+

New agent-driven session

+ +

+ MCP-driven sessions let an external Claude Code agent talk to Destila + via tool calls. No chat textarea — interact directly with the agent. +

+ +
+ {@error} +
+ + <.form + for={@form} + id="agent-session-form" + phx-change="validate" + phx-submit="create" + as={:agent_session} + class="space-y-4" + > +
+ + +
+ +
+ Host mode +
+ + +
+
+ +
+ + +
+ +
+ + <.link navigate={~p"/crafting"} class="btn btn-ghost"> + Cancel + +
+ +
+
+ """ + end +end diff --git a/lib/destila_web/live/agent_session_live.ex b/lib/destila_web/live/agent_session_live.ex new file mode 100644 index 0000000..6b983b8 --- /dev/null +++ b/lib/destila_web/live/agent_session_live.ex @@ -0,0 +1,306 @@ +defmodule DestilaWeb.AgentSessionLive do + @moduledoc """ + Backs `/agent-sessions/:id`. Export-first layout: exports panel headline, + collapsible tool-call event log secondary, agent surface (xterm.js embedded + or external host panel) and a question panel when one is pending. + + No chat textarea. No assistant-text storage. All updates are driven by + PubSub events from `Destila.Agent.SessionServer`. + """ + + use DestilaWeb, :live_view + + alias Destila.Agent.{Sessions, SessionServer, ExternalHost} + + @impl true + def mount(%{"id" => id}, _session, socket) do + case Sessions.get_session(id) do + nil -> + {:ok, redirect(socket, to: ~p"/crafting")} + + session -> + if connected?(socket) do + Sessions.subscribe(id) + _ = SessionServer.ensure_started(id) + end + + events = Sessions.list_events(id) + exports = Sessions.list_exports(id) + + connection_info = + if session.host_mode == :external, + do: ExternalHost.connection_info(id), + else: nil + + {:ok, + socket + |> assign(:page_title, "Agent Session") + |> assign(:session, session) + |> assign(:connection_info, connection_info) + |> assign(:pending_question, nil) + |> assign(:answered_questions, MapSet.new()) + |> assign(:pending_handoff_message, nil) + |> assign(:paste_target, nil) + |> assign(:event_log_open, false) + |> stream(:events, events) + |> stream(:exports, exports) + |> assign(:exports_empty?, exports == [])} + end + end + + @impl true + def handle_event("toggle_event_log", _, socket) do + {:noreply, update(socket, :event_log_open, &(not &1))} + end + + def handle_event( + "answer_question", + %{"question_id" => question_id, "value" => value}, + socket + ) do + case SessionServer.answer_question(socket.assigns.session.id, question_id, value) do + :ok -> + {:noreply, + socket + |> assign(:pending_question, nil) + |> update(:answered_questions, &MapSet.put(&1, question_id))} + + {:error, _reason} -> + {:noreply, + put_flash( + socket, + :error, + "The agent session is no longer active — refresh the page to reconnect." + )} + end + end + + def handle_event("confirm_phase_complete", _, socket) do + {:noreply, assign(socket, :pending_handoff_message, nil)} + end + + def handle_event("dismiss_handoff", _, socket) do + {:noreply, assign(socket, :pending_handoff_message, nil)} + end + + def handle_event("terminal_input", %{"data" => _data}, socket) do + # Forwarded by the xterm.js hook; in embedded mode the terminal Server + # already owns the PTY, so this is currently a no-op stub. + {:noreply, socket} + end + + @impl true + def handle_info({:export_added, meta}, socket) do + {:noreply, + socket + |> stream_insert(:exports, meta) + |> assign(:exports_empty?, false)} + end + + def handle_info({:tool_call_event, event}, socket) do + {:noreply, stream_insert(socket, :events, event)} + end + + def handle_info({:phase_advanced, _new_index}, socket) do + session = Sessions.get_session(socket.assigns.session.id) + {:noreply, assign(socket, :session, session)} + end + + def handle_info({:suggest_phase_complete, message}, socket) do + {:noreply, assign(socket, :pending_handoff_message, message)} + end + + def handle_info({:question_asked, %{question_id: qid, questions: questions}}, socket) do + {:noreply, assign(socket, :pending_question, %{question_id: qid, questions: questions})} + end + + def handle_info({:question_answered, _question_id, _value}, socket) do + {:noreply, socket} + end + + def handle_info({:paste_target, paste}, socket) do + {:noreply, assign(socket, :paste_target, paste)} + end + + def handle_info({:agent_connected, session}, socket) do + {:noreply, assign(socket, :session, session)} + end + + def handle_info({:agent_disconnected, session}, socket) do + {:noreply, assign(socket, :session, session)} + end + + def handle_info({:agent_session_updated, session}, socket) do + {:noreply, assign(socket, :session, session)} + end + + def handle_info(_msg, socket), do: {:noreply, socket} + + @impl true + def render(assigns) do + ~H""" + +
+
+
+

+ Agent session +

+

+ {@session.workflow_name} · + Phase {@session.current_phase_index + 1} of {@session.total_phases} · + + {agent_status_label(@session.status)} + +

+
+
+ + <%!-- Exports panel (primary) --%> +
+
+

Exports

+
+ +
+ Nothing exported yet. As the agent exports artifacts, they appear here. +
+ +
    +
  • +
    +
    {meta.key}
    +
    {meta.phase_name}
    +
    +
    <%= render_export_value(meta) %>
    +
  • +
+
+ + <%!-- Agent surface --%> + <%= if @session.host_mode == :embedded do %> +
+
+ <% else %> +
+

External agent connection

+
+
+ Bridge binary: + {@connection_info.bridge_path} +
+
+ MCP URL: + {@connection_info.mcp_url} +
+
+ Token: + {@connection_info.token} +
+
+ Session id (set as DESTILA_SESSION_ID): + {@connection_info.session_id} +
+
+ +
+
+ Paste into your agent ({@paste_target.kind}) +
+
<%= @paste_target.value %>
+
+
+ <% end %> + + <%!-- Pending question panel --%> +
+

Agent asked a question

+
+
{Map.get(question, "question")}
+
+ +
+
+
+ + <%!-- Phase handoff modal --%> +
+

Confirm phase transition

+

{@pending_handoff_message}

+
+ + +
+
+ + <%!-- Event log (secondary, collapsible) --%> +
+ + +
+
    +
  • + [{event.phase_index}] {event.tool_name} +
  • +
+
+
+
+
+ """ + end + + defp agent_status_label(:awaiting_agent), do: "Awaiting agent" + defp agent_status_label(:active), do: "Active" + defp agent_status_label(:disconnected), do: "Not connected" + defp agent_status_label(:done), do: "Completed" + defp agent_status_label(_), do: "Unknown" + + defp render_export_value(%{value: nil}), do: "" + defp render_export_value(%{value: %{} = v}), do: Map.get(v, "value", inspect(v)) + defp render_export_value(meta), do: inspect(meta.value) +end diff --git a/lib/destila_web/live/crafting_board_live.ex b/lib/destila_web/live/crafting_board_live.ex index e7f94d5..0df7bf7 100644 --- a/lib/destila_web/live/crafting_board_live.ex +++ b/lib/destila_web/live/crafting_board_live.ex @@ -216,6 +216,14 @@ defmodule DestilaWeb.CraftingBoardLive do <.link navigate={~p"/sessions/archived"} class="btn btn-soft btn-sm"> <.icon name="hero-archive-box-micro" class="size-4" /> Archived + <.link + navigate={~p"/agent-sessions/new"} + id="new-mcp-session-card" + class="btn btn-soft btn-sm" + > + <.icon name="hero-sparkles-micro" class="size-4" /> New agent-driven session + MCP + <.link navigate={~p"/workflows"} class="btn btn-primary btn-sm"> <.icon name="hero-plus-micro" class="size-4" /> New Session diff --git a/lib/destila_web/mcp/auth_plug.ex b/lib/destila_web/mcp/auth_plug.ex new file mode 100644 index 0000000..43fd1dc --- /dev/null +++ b/lib/destila_web/mcp/auth_plug.ex @@ -0,0 +1,53 @@ +defmodule DestilaWeb.MCP.AuthPlug do + @moduledoc """ + Validates the `Authorization: Bearer ` header against the configured + MCP token. Halts with 401 on missing/wrong token before any body parsing. + + The token comparison uses `Plug.Crypto.secure_compare/2` so the success path + does not leak token bytes via response-time differences. Bearer prefix + matching is case-insensitive per RFC 7235. + """ + + import Plug.Conn + + require Logger + + def init(opts), do: opts + + def call(conn, _opts) do + case Application.get_env(:destila, :mcp_token) do + nil -> + Logger.error("MCP token not configured; rejecting request") + deny(conn) + + configured when is_binary(configured) -> + case extract_bearer(conn) do + {:ok, presented} -> + if Plug.Crypto.secure_compare(presented, configured), do: conn, else: deny(conn) + + :error -> + deny(conn) + end + end + end + + defp extract_bearer(conn) do + with [value | _] <- get_req_header(conn, "authorization"), + trimmed = String.trim_leading(value), + {bearer_part, token_part} <- String.split_at(trimmed, 7), + true <- String.downcase(bearer_part) == "bearer ", + token = String.trim_leading(token_part), + true <- token != "" do + {:ok, token} + else + _ -> :error + end + end + + defp deny(conn) do + conn + |> put_resp_content_type("application/json") + |> send_resp(401, ~s({"error":"unauthorized"})) + |> halt() + end +end diff --git a/lib/destila_web/mcp/json_rpc.ex b/lib/destila_web/mcp/json_rpc.ex new file mode 100644 index 0000000..cbe817e --- /dev/null +++ b/lib/destila_web/mcp/json_rpc.ex @@ -0,0 +1,27 @@ +defmodule DestilaWeb.MCP.JsonRPC do + @moduledoc """ + Tiny helpers for encoding/decoding JSON-RPC 2.0 envelopes. + """ + + @parse_error -32700 + @invalid_request -32600 + + def parse_error_response(id \\ nil) do + %{ + "jsonrpc" => "2.0", + "id" => id, + "error" => %{"code" => @parse_error, "message" => "Parse error"} + } + end + + def invalid_request_response(id \\ nil) do + %{ + "jsonrpc" => "2.0", + "id" => id, + "error" => %{"code" => @invalid_request, "message" => "Invalid Request"} + } + end + + def valid_request?(%{"jsonrpc" => "2.0", "method" => method}) when is_binary(method), do: true + def valid_request?(_), do: false +end diff --git a/lib/destila_web/mcp/rpc_controller.ex b/lib/destila_web/mcp/rpc_controller.ex new file mode 100644 index 0000000..6d4afa8 --- /dev/null +++ b/lib/destila_web/mcp/rpc_controller.ex @@ -0,0 +1,47 @@ +defmodule DestilaWeb.MCP.RpcController do + @moduledoc """ + POST /mcp/:session_id/rpc — JSON-RPC 2.0 entry point. + + Decodes the request, validates the optional `X-Destila-Session-Id` header + matches the path segment, and dispatches via `Destila.Agent.EventRouter`. + """ + + use DestilaWeb, :controller + + alias Destila.Agent.EventRouter + alias DestilaWeb.MCP.JsonRPC + + def dispatch(conn, %{"session_id" => session_id}) do + case validate_session_header(conn, session_id) do + :ok -> + body = conn.body_params + + cond do + not is_map(body) -> + json(conn, JsonRPC.parse_error_response()) + + not JsonRPC.valid_request?(body) -> + json(conn, JsonRPC.invalid_request_response(Map.get(body, "id"))) + + true -> + case EventRouter.handle_rpc(session_id, body) do + :noreply -> send_resp(conn, 204, "") + %{} = reply -> json(conn, reply) + end + end + + :session_mismatch -> + conn + |> put_status(400) + |> json(%{"error" => "session id header does not match path segment"}) + end + end + + defp validate_session_header(conn, session_id) do + case Plug.Conn.get_req_header(conn, "x-destila-session-id") do + [] -> :ok + [^session_id] -> :ok + _ -> :session_mismatch + end + end +end diff --git a/lib/destila_web/mcp/sse_controller.ex b/lib/destila_web/mcp/sse_controller.ex new file mode 100644 index 0000000..40240b2 --- /dev/null +++ b/lib/destila_web/mcp/sse_controller.ex @@ -0,0 +1,81 @@ +defmodule DestilaWeb.MCP.SseController do + @moduledoc """ + GET /mcp/:session_id/events — long-lived SSE channel. + + On connect, subscribes the calling process to PubSub topic + `agent_session_outbound:` and chunk-streams any received + messages as `event:` frames until the client closes. + """ + + use DestilaWeb, :controller + + alias Destila.Agent.{Sessions, SessionServer} + + @keepalive_interval :timer.seconds(15) + + def stream(conn, %{"session_id" => session_id}) do + Phoenix.PubSub.subscribe(Destila.PubSub, Sessions.outbound_topic(session_id)) + SessionServer.sse_connected(session_id) + + conn = + conn + |> put_resp_content_type("text/event-stream") + |> put_resp_header("cache-control", "no-cache") + |> put_resp_header("connection", "keep-alive") + |> send_chunked(200) + + case Plug.Conn.chunk(conn, format_event("ok", %{"hello" => true})) do + {:ok, conn} -> + Process.send_after(self(), :keepalive, @keepalive_interval) + loop(conn, session_id) + + {:error, _} -> + sse_done(conn, session_id) + end + end + + defp loop(conn, session_id) do + receive do + :keepalive -> + case Plug.Conn.chunk(conn, ": keepalive\n\n") do + {:ok, conn} -> + Process.send_after(self(), :keepalive, @keepalive_interval) + loop(conn, session_id) + + {:error, _} -> + sse_done(conn, session_id) + end + + msg when is_tuple(msg) -> + event_name = elem(msg, 0) + payload = msg |> Tuple.to_list() |> tl() + + case Plug.Conn.chunk(conn, format_event(to_string(event_name), payload)) do + {:ok, conn} -> loop(conn, session_id) + {:error, _} -> sse_done(conn, session_id) + end + + _other -> + loop(conn, session_id) + after + :timer.minutes(5) -> + sse_done(conn, session_id) + end + end + + defp sse_done(conn, session_id) do + Phoenix.PubSub.unsubscribe(Destila.PubSub, Sessions.outbound_topic(session_id)) + SessionServer.sse_closed(session_id) + conn + end + + defp format_event(name, payload) do + encoded = + case Jason.encode(payload) do + {:ok, json} -> json + {:error, _} -> Jason.encode!(%{"unencodable" => true}) + end + + "event: #{name}\ndata: #{encoded}\n\n" + end +end diff --git a/lib/destila_web/router.ex b/lib/destila_web/router.ex index 424b3f9..e88e416 100644 --- a/lib/destila_web/router.ex +++ b/lib/destila_web/router.ex @@ -12,6 +12,18 @@ defmodule DestilaWeb.Router do plug :put_secure_browser_headers end + pipeline :mcp do + plug :accepts, ["json"] + plug DestilaWeb.MCP.AuthPlug + end + + scope "/mcp", DestilaWeb.MCP do + pipe_through :mcp + + post "/:session_id/rpc", RpcController, :dispatch + get "/:session_id/events", SseController, :stream + end + scope "/" do pipe_through :browser @@ -39,5 +51,8 @@ defmodule DestilaWeb.Router do live "/services/sessions/:id", ServiceDetailLive, :session live "/services/projects/:id", ServiceDetailLive, :project live "/services/projects/:id/terminal", TerminalLive, :project + + live "/agent-sessions/new", AgentSessionCreateLive + live "/agent-sessions/:id", AgentSessionLive end end diff --git a/mix.exs b/mix.exs index f78efc8..f23e593 100644 --- a/mix.exs +++ b/mix.exs @@ -70,6 +70,7 @@ defmodule Destila.MixProject do {:expty, "~> 0.2"}, {:req, "~> 0.5"}, {:bcrypt_elixir, "~> 3.0"}, + {:yaml_elixir, "~> 2.11"}, {:mimic, "~> 2.3", only: :test} ] end diff --git a/mix.lock b/mix.lock index 739d325..4b7bf9e 100644 --- a/mix.lock +++ b/mix.lock @@ -54,4 +54,6 @@ "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, + "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"}, } diff --git a/priv/repo/migrations/20260520071403_create_agent_sessions.exs b/priv/repo/migrations/20260520071403_create_agent_sessions.exs new file mode 100644 index 0000000..03a4b3e --- /dev/null +++ b/priv/repo/migrations/20260520071403_create_agent_sessions.exs @@ -0,0 +1,125 @@ +defmodule Destila.Repo.Migrations.CreateAgentSessions do + use Ecto.Migration + + def up do + create table(:agent_sessions, primary_key: false) do + add :id, :binary_id, primary_key: true + add :project_id, references(:projects, type: :binary_id, on_delete: :nilify_all) + add :workflow_name, :string, null: false + add :current_phase_index, :integer, null: false, default: 0 + add :total_phases, :integer, null: false, default: 1 + add :host_mode, :string, null: false + add :status, :string, null: false, default: "awaiting_agent" + add :connected_at, :utc_datetime + add :disconnected_at, :utc_datetime + add :title, :string + add :archived_at, :utc_datetime + add :deleted_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create index(:agent_sessions, [:status]) + create index(:agent_sessions, [:project_id]) + + create table(:agent_session_events, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :agent_session_id, + references(:agent_sessions, type: :binary_id, on_delete: :delete_all), + null: false + + add :phase_index, :integer, null: false, default: 0 + add :tool_name, :string, null: false + add :tool_input, :map + add :tool_result, :map + add :inserted_at, :utc_datetime, null: false + end + + create index(:agent_session_events, [:agent_session_id, :inserted_at]) + + # Rebuild workflow_session_metadata to add nullable agent_session_id + + # phase_index columns and relax NOT NULL on workflow_session_id. + # SQLite cannot ALTER NULL constraints in place, so we use a transactional + # table-rebuild. The application-level changeset enforces "exactly one of + # (workflow_session_id, agent_session_id) is set". + execute(""" + CREATE TABLE workflow_session_metadata_new ( + id TEXT PRIMARY KEY, + workflow_session_id TEXT REFERENCES workflow_sessions(id) ON DELETE CASCADE, + agent_session_id TEXT REFERENCES agent_sessions(id) ON DELETE CASCADE, + phase_name TEXT NOT NULL, + phase_index INTEGER, + key TEXT NOT NULL, + value TEXT NOT NULL, + exported INTEGER NOT NULL DEFAULT 0, + inserted_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """) + + execute(""" + INSERT INTO workflow_session_metadata_new + (id, workflow_session_id, phase_name, key, value, exported, inserted_at, updated_at) + SELECT id, workflow_session_id, phase_name, key, value, exported, inserted_at, updated_at + FROM workflow_session_metadata + """) + + execute("DROP TABLE workflow_session_metadata") + + execute( + "ALTER TABLE workflow_session_metadata_new RENAME TO workflow_session_metadata" + ) + + create unique_index(:workflow_session_metadata, [:workflow_session_id, :phase_name, :key]) + create index(:workflow_session_metadata, [:workflow_session_id]) + create index(:workflow_session_metadata, [:agent_session_id]) + + # Agent-side uniqueness so `Sessions.record_export/2` can rely on a + # conflict target. SQLite treats NULLs as distinct in unique indexes, + # so chat-path rows (where agent_session_id IS NULL) don't collide. + # A `WHERE` clause cannot be used because SQLite does not allow + # partial unique indexes as ON CONFLICT targets. + create unique_index(:workflow_session_metadata, [:agent_session_id, :phase_name, :key]) + end + + def down do + # Reverse the rebuild on workflow_session_metadata first. Any agent-only + # rows (workflow_session_id IS NULL) cannot be moved back into a column + # that is NOT NULL — refuse to drop them silently. + execute(""" + CREATE TABLE workflow_session_metadata_old ( + id TEXT PRIMARY KEY, + workflow_session_id TEXT NOT NULL REFERENCES workflow_sessions(id) ON DELETE CASCADE, + phase_name TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + exported INTEGER NOT NULL DEFAULT 0, + inserted_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """) + + execute(""" + INSERT INTO workflow_session_metadata_old + (id, workflow_session_id, phase_name, key, value, exported, inserted_at, updated_at) + SELECT id, workflow_session_id, phase_name, key, value, exported, inserted_at, updated_at + FROM workflow_session_metadata + WHERE workflow_session_id IS NOT NULL + """) + + execute("DROP TABLE workflow_session_metadata") + + execute( + "ALTER TABLE workflow_session_metadata_old RENAME TO workflow_session_metadata" + ) + + create unique_index(:workflow_session_metadata, [:workflow_session_id, :phase_name, :key]) + create index(:workflow_session_metadata, [:workflow_session_id]) + + # Now drop the agent tables. agent_session_events has FK to agent_sessions + # so we drop the child first. + drop table(:agent_session_events) + drop table(:agent_sessions) + end +end diff --git a/priv/workflows/example.yaml b/priv/workflows/example.yaml new file mode 100644 index 0000000..02d9f49 --- /dev/null +++ b/priv/workflows/example.yaml @@ -0,0 +1,30 @@ +name: example +phases: + - name: Discovery + system_prompt: | + You are a discovery agent. Ask the user clarifying questions about + their goal. Call mcp__destila__session with action=phase_complete when + you have enough context. + kickoff_prompt: | + Start by asking what the user is trying to build. Use + mcp__destila__ask_user_question for structured choices. + agent_command: + - claude + - --mcp-config + - "{{mcp_config_path}}" + - --append-system-prompt + - "{{system_prompt_path}}" + - name: Implementation + system_prompt: | + You are an implementation agent. Read prior-phase exports via + mcp__destila__exports_read and build the requested artifact. Export + any deliverables via mcp__destila__session with action=export. + kickoff_prompt: | + Read exports from the discovery phase, then propose an implementation + plan and ask the user to confirm before writing code. + agent_command: + - claude + - --mcp-config + - "{{mcp_config_path}}" + - --append-system-prompt + - "{{system_prompt_path}}" diff --git a/scripts/mcp_smoke.sh b/scripts/mcp_smoke.sh new file mode 100755 index 0000000..2d8da7d --- /dev/null +++ b/scripts/mcp_smoke.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Manual smoke test for the MCP HTTP+SSE transport. +# +# Boots the dev server (if not already running) and drives a single +# tools/list request through the Go bridge to verify the round trip. +# Exits 0 on success. Designed to be run before any release that touches +# the new agent path. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +if ! command -v go >/dev/null; then + echo "go is not installed; skipping smoke test" + exit 0 +fi + +if ! command -v claude >/dev/null; then + echo "claude CLI is not installed; skipping real-agent smoke test" + echo "Falling back to bridge-only HTTP test." +fi + +# Build bridge. +go build -o cmd/destila-mcp/destila-mcp ./cmd/destila-mcp/... + +SESSION_ID="${SESSION_ID:-smoke-test-session-$$}" +TOKEN="${DESTILA_MCP_TOKEN:-destila-dev-only-token}" +URL="${DESTILA_MCP_URL:-http://127.0.0.1:4000/mcp}" + +echo "Testing POST $URL/$SESSION_ID/rpc (tools/list)..." +HTTP_STATUS=$(curl -s -o /tmp/destila_smoke.json -w "%{http_code}" \ + -H "Authorization: Bearer $TOKEN" \ + -H "X-Destila-Session-Id: $SESSION_ID" \ + -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \ + "$URL/$SESSION_ID/rpc") + +if [ "$HTTP_STATUS" != "200" ]; then + echo "Expected HTTP 200, got $HTTP_STATUS" + cat /tmp/destila_smoke.json + exit 1 +fi + +if ! grep -q '"tools"' /tmp/destila_smoke.json; then + echo "Response did not contain tools list" + cat /tmp/destila_smoke.json + exit 1 +fi + +echo "Smoke test OK." diff --git a/test/destila/agent/event_router_test.exs b/test/destila/agent/event_router_test.exs new file mode 100644 index 0000000..aaf25e7 --- /dev/null +++ b/test/destila/agent/event_router_test.exs @@ -0,0 +1,58 @@ +defmodule Destila.Agent.EventRouterTest do + use ExUnit.Case, async: false + + alias Destila.Agent.{EventRouter, Sessions, SessionServer} + + setup do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Destila.Repo, shared: true) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + :ok + end + + test "tools/list returns the canonical tool schemas" do + reply = EventRouter.handle_rpc("does-not-matter", rpc("tools/list", %{})) + + assert %{"result" => %{"tools" => tools}} = reply + names = Enum.map(tools, & &1["name"]) + assert "session" in names + assert "ask_user_question" in names + assert "service" in names + assert "exports_read" in names + end + + test "tools/call for an unknown tool returns method-not-found" do + {:ok, session} = Sessions.create_session(default_attrs()) + {:ok, _pid} = SessionServer.ensure_started(session.id) + + reply = + EventRouter.handle_rpc( + session.id, + rpc("tools/call", %{"name" => "no_such_tool", "arguments" => %{}}) + ) + + assert %{"error" => %{"code" => -32601}} = reply + end + + test "invalid request shape returns an Invalid Request envelope" do + reply = EventRouter.handle_rpc("anything", %{"foo" => "bar"}) + assert %{"error" => %{"code" => -32600}} = reply + end + + test "initialize replies with serverInfo" do + reply = EventRouter.handle_rpc("anything", rpc("initialize", %{})) + assert %{"result" => %{"serverInfo" => %{"name" => "destila"}}} = reply + end + + defp rpc(method, params) do + %{ + "jsonrpc" => "2.0", + "id" => 1, + "method" => method, + "params" => params + } + end + + defp default_attrs do + %{workflow_name: "example", host_mode: :embedded, total_phases: 2} + end +end diff --git a/test/destila/agent/session_server_test.exs b/test/destila/agent/session_server_test.exs new file mode 100644 index 0000000..dae8f23 --- /dev/null +++ b/test/destila/agent/session_server_test.exs @@ -0,0 +1,68 @@ +defmodule Destila.Agent.SessionServerTest do + use ExUnit.Case, async: false + + alias Destila.Agent.{Sessions, SessionServer} + alias Destila.Test.MockMCPClient + + setup do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Destila.Repo, shared: true) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + :ok + end + + @tag feature: "mcp_driven_session", + scenario: "Session activates when the external agent connects" + test "sse_connected transitions awaiting_agent to active" do + {:ok, session} = Sessions.create_session(valid_attrs(:external)) + {:ok, _pid} = SessionServer.ensure_started(session.id) + + MockMCPClient.simulate_connect(session.id) + + # Allow the cast to flush + _ = SessionServer.get_state(session.id) + + updated = Sessions.get_session(session.id) + assert updated.status == :active + end + + @tag feature: "mcp_driven_session", + scenario: "Agent exit without phase_complete leaves the phase open" + test "sse_closed transitions active to disconnected without advancing phase" do + {:ok, session} = Sessions.create_session(valid_attrs(:embedded)) + {:ok, _pid} = SessionServer.ensure_started(session.id) + + MockMCPClient.simulate_connect(session.id) + _ = SessionServer.get_state(session.id) + MockMCPClient.simulate_disconnect(session.id) + _ = SessionServer.get_state(session.id) + + updated = Sessions.get_session(session.id) + assert updated.status == :disconnected + assert updated.current_phase_index == 0 + end + + @tag feature: "mcp_driven_session", scenario: "Session log records only tool-call events" + test "tool calls are written to the event log; no assistant text channel exists" do + {:ok, session} = Sessions.create_session(valid_attrs(:embedded)) + + {:ok, _reply} = + MockMCPClient.simulate_tool_call(session.id, "session", %{ + "action" => "export", + "key" => "k", + "value" => "v" + }) + + events = Sessions.list_events(session.id) + # export records two events: session.export + (no extra) — keep this resilient + assert Enum.all?(events, &is_struct(&1, Destila.Agent.SessionEvent)) + assert Enum.any?(events, &(&1.tool_name == "session.export")) + end + + defp valid_attrs(host_mode) do + %{ + workflow_name: "example", + host_mode: host_mode, + total_phases: 2 + } + end +end diff --git a/test/destila/agent/sessions_test.exs b/test/destila/agent/sessions_test.exs new file mode 100644 index 0000000..a7a674b --- /dev/null +++ b/test/destila/agent/sessions_test.exs @@ -0,0 +1,94 @@ +defmodule Destila.Agent.SessionsTest do + use ExUnit.Case, async: false + + alias Destila.Agent.Sessions + + setup do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Destila.Repo, shared: true) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + :ok + end + + describe "create_session/1" do + test "inserts a row with status :awaiting_agent" do + {:ok, session} = Sessions.create_session(valid_attrs()) + + assert session.status == :awaiting_agent + assert session.current_phase_index == 0 + assert session.host_mode == :embedded + end + + test "requires workflow_name and host_mode" do + {:error, changeset} = Sessions.create_session(%{}) + errors = Keyword.keys(changeset.errors) + assert :workflow_name in errors + assert :host_mode in errors + end + end + + describe "record_event/3" do + test "writes an event row and broadcasts" do + {:ok, session} = Sessions.create_session(valid_attrs()) + Sessions.subscribe(session.id) + + {:ok, event} = + Sessions.record_event(session, "session.phase_complete", %{ + tool_input: %{"a" => 1}, + tool_result: %{"ok" => true} + }) + + assert event.tool_name == "session.phase_complete" + assert event.tool_input == %{"a" => 1} + assert event.phase_index == 0 + + assert_receive {:tool_call_event, ^event}, 500 + end + end + + describe "advance_phase/1" do + test "increments the phase index" do + {:ok, session} = Sessions.create_session(Map.put(valid_attrs(), :total_phases, 3)) + {:ok, advanced} = Sessions.advance_phase(session) + + assert advanced.current_phase_index == 1 + end + + test "marks the session done when at the last phase" do + {:ok, session} = Sessions.create_session(Map.put(valid_attrs(), :total_phases, 1)) + {:ok, done} = Sessions.advance_phase(session) + + assert done.status == :done + end + end + + describe "record_export/2" do + test "stores a metadata row with agent_session_id and emits :export_added" do + {:ok, session} = Sessions.create_session(valid_attrs()) + Sessions.subscribe(session.id) + + {:ok, meta} = + Sessions.record_export(session, %{ + phase_name: "phase-0", + key: "prompt", + value: %{"value" => "hello", "type" => "text"} + }) + + assert meta.agent_session_id == session.id + assert meta.key == "prompt" + assert meta.exported == true + + assert_receive {:export_added, ^meta}, 500 + + exports = Sessions.list_exports(session.id) + assert length(exports) == 1 + end + end + + defp valid_attrs do + %{ + workflow_name: "example", + host_mode: :embedded, + total_phases: 2 + } + end +end diff --git a/test/destila/agent/tools/ask_user_question_tool_test.exs b/test/destila/agent/tools/ask_user_question_tool_test.exs new file mode 100644 index 0000000..dc15acf --- /dev/null +++ b/test/destila/agent/tools/ask_user_question_tool_test.exs @@ -0,0 +1,29 @@ +defmodule Destila.Agent.Tools.AskUserQuestionToolTest do + use ExUnit.Case, async: false + + alias Destila.Agent.Sessions + alias Destila.Test.MockMCPClient + + setup do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Destila.Repo, shared: true) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + :ok + end + + @tag feature: "mcp_driven_session", + scenario: "ask_user_question tool call does not block on the user's reply" + test "returns immediately with a question id and broadcasts" do + {:ok, session} = Sessions.create_session(default_attrs()) + Sessions.subscribe(session.id) + + {:ok, %{"result" => %{"question_id" => qid, "ok" => true}}} = + MockMCPClient.simulate_question(session.id, "pick one", ["a", "b"]) + + assert is_binary(qid) + assert_receive {:question_asked, %{question_id: ^qid}}, 500 + end + + defp default_attrs do + %{workflow_name: "example", host_mode: :embedded, total_phases: 1} + end +end diff --git a/test/destila/agent/tools/exports_read_tool_test.exs b/test/destila/agent/tools/exports_read_tool_test.exs new file mode 100644 index 0000000..6f49693 --- /dev/null +++ b/test/destila/agent/tools/exports_read_tool_test.exs @@ -0,0 +1,39 @@ +defmodule Destila.Agent.Tools.ExportsReadToolTest do + use ExUnit.Case, async: false + + alias Destila.Agent.Sessions + alias Destila.Test.MockMCPClient + + setup do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Destila.Repo, shared: true) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + :ok + end + + @tag feature: "mcp_driven_session", + scenario: "Exports from prior phases remain available across handoff" + test "returns all exports including ones from prior phases" do + {:ok, session} = Sessions.create_session(default_attrs()) + + {:ok, _} = MockMCPClient.simulate_export(session.id, "alpha", "first") + {:ok, _} = MockMCPClient.simulate_phase_complete(session.id, "moving on") + {:ok, _} = MockMCPClient.simulate_export(session.id, "beta", "second") + + {:ok, reply} = MockMCPClient.simulate_tool_call(session.id, "exports_read", %{}) + keys = reply["result"]["exports"] |> Enum.map(& &1["key"]) + + assert "alpha" in keys + assert "beta" in keys + end + + test "returns empty list when no exports exist" do + {:ok, session} = Sessions.create_session(default_attrs()) + + {:ok, reply} = MockMCPClient.simulate_tool_call(session.id, "exports_read", %{}) + assert reply["result"]["exports"] == [] + end + + defp default_attrs do + %{workflow_name: "example", host_mode: :embedded, total_phases: 2} + end +end diff --git a/test/destila/agent/tools/session_tool_test.exs b/test/destila/agent/tools/session_tool_test.exs new file mode 100644 index 0000000..f0d57fd --- /dev/null +++ b/test/destila/agent/tools/session_tool_test.exs @@ -0,0 +1,52 @@ +defmodule Destila.Agent.Tools.SessionToolTest do + use ExUnit.Case, async: false + + alias Destila.Agent.Sessions + alias Destila.Test.MockMCPClient + + setup do + pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Destila.Repo, shared: true) + on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) + :ok + end + + @tag feature: "mcp_driven_session", scenario: "phase_complete auto-advances the session" + test "phase_complete increments the phase index and broadcasts" do + {:ok, session} = Sessions.create_session(default_attrs()) + Sessions.subscribe(session.id) + + {:ok, _reply} = MockMCPClient.simulate_phase_complete(session.id, "done") + + assert_receive {:phase_advanced, 1}, 500 + assert Sessions.get_session(session.id).current_phase_index == 1 + end + + @tag feature: "mcp_driven_session", + scenario: "suggest_phase_complete waits for user confirmation" + test "suggest_phase_complete broadcasts without advancing" do + {:ok, session} = Sessions.create_session(default_attrs()) + Sessions.subscribe(session.id) + + {:ok, _reply} = + MockMCPClient.simulate_suggest_phase_complete(session.id, "looks done") + + assert_receive {:suggest_phase_complete, "looks done"}, 500 + assert Sessions.get_session(session.id).current_phase_index == 0 + end + + @tag feature: "mcp_driven_session", + scenario: "New exports appear in real-time at the top of the session view" + test "export persists a metadata row and broadcasts :export_added" do + {:ok, session} = Sessions.create_session(default_attrs()) + Sessions.subscribe(session.id) + + {:ok, _reply} = MockMCPClient.simulate_export(session.id, "k1", "v1", type: "markdown") + + assert_receive {:export_added, _meta}, 500 + assert length(Sessions.list_exports(session.id)) == 1 + end + + defp default_attrs do + %{workflow_name: "example", host_mode: :embedded, total_phases: 3} + end +end diff --git a/test/destila/agent/workflow_loader_test.exs b/test/destila/agent/workflow_loader_test.exs new file mode 100644 index 0000000..ad95af7 --- /dev/null +++ b/test/destila/agent/workflow_loader_test.exs @@ -0,0 +1,29 @@ +defmodule Destila.Agent.WorkflowLoaderTest do + use ExUnit.Case, async: false + + alias Destila.Agent.WorkflowLoader + + test "load_all/0 loads the bundled example workflow" do + assert :ok = WorkflowLoader.load_all() + {:ok, wf} = WorkflowLoader.get("example") + + assert wf.name == "example" + assert length(wf.phases) >= 1 + [phase | _] = wf.phases + assert phase.name != "" + assert phase.system_prompt != "" + assert phase.kickoff_prompt != "" + assert is_list(phase.agent_command) + end + + test "get/1 returns :not_found for unknown workflows" do + WorkflowLoader.load_all() + assert {:error, :not_found} = WorkflowLoader.get("definitely_not_a_workflow") + end + + test "list_all/0 returns workflow defs" do + WorkflowLoader.load_all() + names = Enum.map(WorkflowLoader.list_all(), & &1.name) + assert "example" in names + end +end diff --git a/test/destila_web/live/agent_session_create_live_test.exs b/test/destila_web/live/agent_session_create_live_test.exs new file mode 100644 index 0000000..a1b51e8 --- /dev/null +++ b/test/destila_web/live/agent_session_create_live_test.exs @@ -0,0 +1,69 @@ +defmodule DestilaWeb.AgentSessionCreateLiveTest do + use DestilaWeb.ConnCase + + import Phoenix.LiveViewTest + + alias Destila.Agent.{Sessions, WorkflowLoader} + + setup do + :ok = WorkflowLoader.load_all() + :ok + end + + @tag feature: "mcp_driven_session", scenario: "Session is created without a chat textarea" + test "creates an embedded agent session and redirects", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/agent-sessions/new") + + assert {:error, {:live_redirect, %{to: to}}} = + view + |> form("#agent-session-form", %{ + "agent_session" => %{ + "workflow_name" => "example", + "host_mode" => "embedded", + "project_id" => "" + } + }) + |> render_submit() + + assert to =~ "/agent-sessions/" + + [session] = Sessions.list_sessions() + assert session.workflow_name == "example" + assert session.host_mode == :embedded + end + + test "creates an external agent session", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/agent-sessions/new") + + assert {:error, {:live_redirect, _}} = + view + |> form("#agent-session-form", %{ + "agent_session" => %{ + "workflow_name" => "example", + "host_mode" => "external", + "project_id" => "" + } + }) + |> render_submit() + + [session] = Sessions.list_sessions() + assert session.host_mode == :external + end + + test "shows error when no workflow is selected", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/agent-sessions/new") + + html = + view + |> form("#agent-session-form", %{ + "agent_session" => %{ + "workflow_name" => "", + "host_mode" => "embedded", + "project_id" => "" + } + }) + |> render_submit() + + assert html =~ "Please choose a workflow" + end +end diff --git a/test/destila_web/live/agent_session_live_test.exs b/test/destila_web/live/agent_session_live_test.exs new file mode 100644 index 0000000..d75919b --- /dev/null +++ b/test/destila_web/live/agent_session_live_test.exs @@ -0,0 +1,79 @@ +defmodule DestilaWeb.AgentSessionLiveTest do + use DestilaWeb.ConnCase + + import Phoenix.LiveViewTest + + alias Destila.Agent.Sessions + alias Destila.Test.MockMCPClient + + @tag feature: "mcp_driven_session", scenario: "Session is created without a chat textarea" + test "renders without any chat textarea", %{conn: conn} do + {:ok, session} = Sessions.create_session(embedded_attrs()) + {:ok, view, _html} = live(conn, ~p"/agent-sessions/#{session.id}") + + refute has_element?(view, "textarea[name='chat']") + refute has_element?(view, "#chat-form") + end + + @tag feature: "mcp_driven_session", + scenario: "Empty session shows an exports placeholder, not a chat transcript" + test "renders the exports placeholder on an empty session", %{conn: conn} do + {:ok, session} = Sessions.create_session(embedded_attrs()) + {:ok, view, _html} = live(conn, ~p"/agent-sessions/#{session.id}") + + assert has_element?(view, "#exports-empty-placeholder") + assert has_element?(view, "#exports-panel") + assert has_element?(view, "#event-log-panel") + end + + @tag feature: "mcp_driven_session", + scenario: "Session detail page is reachable while the agent is disconnected" + test "session page mounts with not-connected indicator before the agent arrives", %{conn: conn} do + {:ok, session} = Sessions.create_session(external_attrs()) + {:ok, view, _html} = live(conn, ~p"/agent-sessions/#{session.id}") + + assert has_element?(view, "#agent-status") + end + + @tag feature: "mcp_driven_session", + scenario: "New exports appear in real-time at the top of the session view" + test "real-time export render", %{conn: conn} do + {:ok, session} = Sessions.create_session(embedded_attrs()) + {:ok, view, _html} = live(conn, ~p"/agent-sessions/#{session.id}") + + {:ok, _} = MockMCPClient.simulate_export(session.id, "prompt", "hi") + + render(view) + assert render(view) =~ "prompt" + end + + @tag feature: "mcp_driven_session", + scenario: "User types directly into the embedded terminal" + test "embedded sessions render the xterm.js terminal panel", %{conn: conn} do + {:ok, session} = Sessions.create_session(embedded_attrs()) + {:ok, view, _html} = live(conn, ~p"/agent-sessions/#{session.id}") + + assert has_element?(view, "#embedded-terminal") + refute has_element?(view, "#external-host-panel") + end + + @tag feature: "mcp_driven_session", + scenario: "Creating an external-host session shows MCP connection instructions" + test "external sessions render the connection-info panel", %{conn: conn} do + {:ok, session} = Sessions.create_session(external_attrs()) + {:ok, view, _html} = live(conn, ~p"/agent-sessions/#{session.id}") + + assert has_element?(view, "#external-host-panel") + assert has_element?(view, "#external-mcp-url") + assert has_element?(view, "#external-token") + refute has_element?(view, "#embedded-terminal") + end + + defp embedded_attrs do + %{workflow_name: "example", host_mode: :embedded, total_phases: 2} + end + + defp external_attrs do + %{workflow_name: "example", host_mode: :external, total_phases: 1} + end +end diff --git a/test/destila_web/mcp/auth_plug_test.exs b/test/destila_web/mcp/auth_plug_test.exs new file mode 100644 index 0000000..3323a30 --- /dev/null +++ b/test/destila_web/mcp/auth_plug_test.exs @@ -0,0 +1,47 @@ +defmodule DestilaWeb.MCP.AuthPlugTest do + use ExUnit.Case, async: true + + import Plug.Conn + import Plug.Test + + alias DestilaWeb.MCP.AuthPlug + + setup do + prev = Application.get_env(:destila, :mcp_token) + Application.put_env(:destila, :mcp_token, "test-token-abc") + + on_exit(fn -> + case prev do + nil -> Application.delete_env(:destila, :mcp_token) + v -> Application.put_env(:destila, :mcp_token, v) + end + end) + + :ok + end + + test "missing header returns 401" do + conn = conn(:post, "/mcp/foo/rpc") |> AuthPlug.call(nil) + assert conn.status == 401 + assert conn.halted + end + + test "wrong token returns 401" do + conn = + conn(:post, "/mcp/foo/rpc") + |> put_req_header("authorization", "Bearer wrong") + |> AuthPlug.call(nil) + + assert conn.status == 401 + assert conn.halted + end + + test "correct token passes through" do + conn = + conn(:post, "/mcp/foo/rpc") + |> put_req_header("authorization", "Bearer test-token-abc") + |> AuthPlug.call(nil) + + refute conn.halted + end +end diff --git a/test/destila_web/mcp/rpc_controller_test.exs b/test/destila_web/mcp/rpc_controller_test.exs new file mode 100644 index 0000000..4179e8c --- /dev/null +++ b/test/destila_web/mcp/rpc_controller_test.exs @@ -0,0 +1,56 @@ +defmodule DestilaWeb.MCP.RpcControllerTest do + use DestilaWeb.ConnCase + + alias Destila.Agent.Sessions + + @token "rpc-test-token" + + setup do + prev_token = Application.get_env(:destila, :mcp_token) + Application.put_env(:destila, :mcp_token, @token) + on_exit(fn -> Application.put_env(:destila, :mcp_token, prev_token) end) + :ok + end + + test "POST tools/list with valid token returns the tool list", %{conn: conn} do + {:ok, session} = Sessions.create_session(valid_attrs()) + + conn = + conn + |> put_req_header("authorization", "Bearer #{@token}") + |> put_req_header("content-type", "application/json") + |> post("/mcp/#{session.id}/rpc", %{ + "jsonrpc" => "2.0", + "id" => 1, + "method" => "tools/list" + }) + + assert json_response(conn, 200)["result"]["tools"] |> length() == 4 + end + + test "POST without token returns 401", %{conn: conn} do + conn = post(conn, "/mcp/whatever/rpc", %{}) + assert conn.status == 401 + end + + test "X-Destila-Session-Id mismatch returns 400", %{conn: conn} do + {:ok, session} = Sessions.create_session(valid_attrs()) + + conn = + conn + |> put_req_header("authorization", "Bearer #{@token}") + |> put_req_header("x-destila-session-id", "OTHER-ID") + |> put_req_header("content-type", "application/json") + |> post("/mcp/#{session.id}/rpc", %{ + "jsonrpc" => "2.0", + "id" => 1, + "method" => "tools/list" + }) + + assert conn.status == 400 + end + + defp valid_attrs do + %{workflow_name: "example", host_mode: :embedded, total_phases: 1} + end +end diff --git a/test/support/mock_mcp_client.ex b/test/support/mock_mcp_client.ex new file mode 100644 index 0000000..c8c0924 --- /dev/null +++ b/test/support/mock_mcp_client.ex @@ -0,0 +1,86 @@ +defmodule Destila.Test.MockMCPClient do + @moduledoc """ + Test helper that drives the agent session pipeline without going through + HTTP+SSE. Calls `Destila.Agent.EventRouter.handle_rpc/2` directly — the + rest of the pipeline (SessionServer, ToolHandlers, Sessions) runs for real. + + Used by LiveView tests for the new agent path. + """ + + alias Destila.Agent.{EventRouter, SessionServer, Sessions} + + @doc "Subscribes the calling test process to the session's PubSub topic." + def subscribe(session_id), do: Sessions.subscribe(session_id) + + @doc "Simulates the bridge opening the SSE channel." + def simulate_connect(session_id) do + {:ok, _pid} = SessionServer.ensure_started(session_id) + SessionServer.sse_connected(session_id) + end + + @doc "Simulates the bridge closing the SSE channel." + def simulate_disconnect(session_id) do + SessionServer.sse_closed(session_id) + end + + @doc """ + Simulates a JSON-RPC tools/call frame arriving from the bridge. + Returns the JSON-RPC reply envelope. + """ + def simulate_tool_call(session_id, tool_name, arguments) do + {:ok, _pid} = SessionServer.ensure_started(session_id) + + reply = + EventRouter.handle_rpc(session_id, %{ + "jsonrpc" => "2.0", + "id" => :erlang.unique_integer([:positive]), + "method" => "tools/call", + "params" => %{"name" => tool_name, "arguments" => arguments} + }) + + case reply do + %{"error" => _} = err -> {:error, err} + %{"result" => _} -> {:ok, reply} + :noreply -> {:ok, :noreply} + end + end + + def simulate_export(session_id, key, value, opts \\ []) do + type = Keyword.get(opts, :type, "text") + + simulate_tool_call(session_id, "session", %{ + "action" => "export", + "key" => key, + "value" => value, + "type" => type + }) + end + + def simulate_phase_complete(session_id, message \\ nil) do + simulate_tool_call(session_id, "session", %{ + "action" => "phase_complete", + "message" => message + }) + end + + def simulate_suggest_phase_complete(session_id, message) do + simulate_tool_call(session_id, "session", %{ + "action" => "suggest_phase_complete", + "message" => message + }) + end + + def simulate_question(session_id, _question, options) do + questions = [ + %{ + "title" => "Q", + "question" => "Pick one", + "multi_select" => false, + "options" => Enum.map(options, fn opt -> %{"label" => opt, "description" => opt} end) + } + ] + + reply = simulate_tool_call(session_id, "ask_user_question", %{"questions" => questions}) + reply + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index cd1f80b..637a66b 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -13,3 +13,5 @@ Mimic.copy(Destila.Sessions.SessionProcess) Mimic.copy(Destila.Proxy.Caddy) Mimic.copy(ExPTY) Mimic.copy(System) +Mimic.copy(Destila.Agent.EmbeddedHost) +Mimic.copy(Destila.Terminal.Server)