Files
correx/docs/epics/epic-13.md
T

327 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Epic 13 — Interfaces (TUI + CLI + API)
**status:** planned
**depends on:** epics 012
**goal:** user interaction layer — server owns the orchestrator, TUI and CLI are clients
---
## architecture
```
apps/server ← owns orchestrator, event store, all infrastructure. Ktor.
apps/tui ← Mosaic-based interactive terminal. websocket client only.
apps/cli ← Clikt-based non-interactive entrypoint. http/websocket client.
```
all three are clients of `core:kernel` + `infrastructure` through `apps/server`.
TUI and CLI never instantiate orchestrator directly — they talk to the server.
---
## task 1: websocket protocol definition
**this must be done first.** everything else depends on it.
define the protocol as a sealed class hierarchy, serialized via kotlinx.serialization:
### server → client (push):
```kotlin
sealed class ServerMessage {
// session lifecycle
data class SessionStarted(val sessionId: SessionId, val workflowId: String) : ServerMessage()
data class SessionPaused(val sessionId: SessionId, val reason: PauseReason) : ServerMessage()
data class SessionCompleted(val sessionId: SessionId) : ServerMessage()
data class SessionFailed(val sessionId: SessionId, val reason: String) : ServerMessage()
// stage execution
data class StageStarted(val sessionId: SessionId, val stageId: StageId) : ServerMessage()
data class StageCompleted(val sessionId: SessionId, val stageId: StageId) : ServerMessage()
data class StageFailed(val sessionId: SessionId, val stageId: StageId, val reason: String) : ServerMessage()
// inference
data class InferenceStarted(val sessionId: SessionId, val stageId: StageId) : ServerMessage()
data class InferenceCompleted(val sessionId: SessionId, val stageId: StageId, val outputSummary: String) : ServerMessage()
data class InferenceTimedOut(val sessionId: SessionId, val stageId: StageId, val elapsedMs: Long) : ServerMessage()
// tool execution
data class ToolStarted(val sessionId: SessionId, val toolName: String, val tier: Tier) : ServerMessage()
data class ToolCompleted(val sessionId: SessionId, val toolName: String, val outputSummary: String) : ServerMessage()
data class ToolFailed(val sessionId: SessionId, val toolName: String, val reason: String) : ServerMessage()
data class ToolRejected(val sessionId: SessionId, val toolName: String, val reason: String) : ServerMessage()
// approval
data class ApprovalRequired(
val sessionId: SessionId,
val requestId: ApprovalRequestId,
val tier: Tier,
val riskSummary: RiskSummaryDto,
val toolName: String?,
val preview: String?,
) : ServerMessage()
// provider
data class ProviderStatusChanged(val providerId: String, val status: ProviderHealthDto) : ServerMessage()
// error
data class ProtocolError(val message: String) : ServerMessage()
}
```
### client → server (commands):
```kotlin
sealed class ClientMessage {
data class StartSession(val workflowId: String, val config: SessionConfigDto?) : ClientMessage()
data class ResumeSession(val sessionId: SessionId) : ClientMessage()
data class CancelSession(val sessionId: SessionId) : ClientMessage()
data class ApprovalResponse(
val requestId: ApprovalRequestId,
val decision: ApprovalDecision, // APPROVE, REJECT, STEER
val steeringNote: String?,
) : ClientMessage()
data class Ping(val timestamp: Long) : ClientMessage()
}
```
**files:**
- `apps/server/src/main/kotlin/.../protocol/ServerMessage.kt`
- `apps/server/src/main/kotlin/.../protocol/ClientMessage.kt`
- `apps/server/src/main/kotlin/.../protocol/ProtocolSerializer.kt` — register both hierarchies
**acceptance criteria:**
- all messages serialize/deserialize correctly
- unknown message type produces `ProtocolError`, not crash
- protocol types are DTOs — no domain types leak into protocol layer
---
## task 2: apps/server — Ktor server + websocket
**dependencies:** task 1
**endpoints:**
```
GET /health → server + provider status
GET /sessions → list recent sessions (last 20)
GET /sessions/{id} → session detail + current state
POST /sessions → start new session (body: StartSessionRequest)
POST /sessions/{id}/cancel → cancel session
GET /sessions/{id}/events → full event log (paginated)
WS /sessions/{id}/stream → live event stream for session
WS /stream → global event stream (all sessions)
GET /workflows → list available workflow definitions
GET /providers → provider registry + health
```
**websocket behavior:**
- on connect: send snapshot of current session state, then stream live events
- on disconnect: clean up subscription, no state lost
- on reconnect: client sends last received event id, server replays missed events from that point
- heartbeat: server sends `Ping` every 30s, client must respond within 10s or connection dropped
**files:**
- `apps/server/src/main/kotlin/.../Application.kt`
- `apps/server/src/main/kotlin/.../routes/SessionRoutes.kt`
- `apps/server/src/main/kotlin/.../routes/WorkflowRoutes.kt`
- `apps/server/src/main/kotlin/.../routes/ProviderRoutes.kt`
- `apps/server/src/main/kotlin/.../ws/SessionStreamHandler.kt`
- `apps/server/src/main/kotlin/.../ws/GlobalStreamHandler.kt`
- `apps/server/src/main/kotlin/.../ServerModule.kt` — wires InfrastructureModule + Ktor
**acceptance criteria:**
- `/health` returns 200 with provider status
- websocket stream delivers events within 100ms of emission
- reconnect with last event id replays missed events correctly
- unknown WS message type → `ProtocolError` sent, connection kept open
---
## task 3: apps/server — approval interaction
**dependencies:** task 2
approval flow:
1. orchestrator emits `ApprovalRequiredEvent``OrchestrationPausedEvent`
2. server catches pause, sends `ApprovalRequired` message to all connected clients for that session
3. client sends `ApprovalResponse`
4. server calls `approvalEngine.grant()` or `approvalEngine.deny()` accordingly
5. orchestrator resumes
**steering:** `ApprovalDecision.STEER` + `steeringNote` injects a steering event into the session event stream before resuming. the note becomes L0 context for the next stage.
**timeout:** if no response within `ApprovalConfig.timeoutMs`, server auto-denies and emits `ApprovalTimedOutEvent`. configurable, default 5 minutes.
**files:**
- `apps/server/src/main/kotlin/.../approval/ApprovalCoordinator.kt`
- `apps/server/src/main/kotlin/.../routes/ApprovalRoutes.kt` — REST fallback for non-WS clients
**acceptance criteria:**
- approval required → session pauses → client receives message
- approve → session resumes
- reject → session fails with reason
- steer → steering note injected → session resumes with note in context
- timeout → auto-deny, event emitted
---
## task 4: apps/tui — Mosaic dashboard
**dependencies:** task 2, task 3
**layout:**
```
┌─ status bar ──────────────────────────────────────────────────────┐
│ server: ● connected │ provider: gemma-4 (loaded) │ gpu: 74% │ cwd │
├─ session list ────────────────────────────────────────────────────┤
│ ▶ [abc123] "fix auth bug" ACTIVE stage 3/5 2m ago │
│ [def456] "refactor context" PAUSED awaiting approval │
│ [ghi789] "add tool registry" DONE 5m ago │
│ [jkl012] "write tests for..." FAILED 12m ago │
│ [mno345] "epic 11 wiring" DONE 1h ago │
├─ active session ──────────────────────────────────────────────────┤
│ stage: execute_plan (3/5) │
│ last output: "FileEditTool applied patch to src/main/..." │
│ tool: FileEditTool ████████░░ T2 │
├─ approval prompt (when pending) ──────────────────────────────────┤
│ ⚠ APPROVAL REQUIRED — Tier T3 │
│ tool: ShellTool argv: ["rm", "-rf", "build/"] │
│ risk: HIGH — DestructiveOperation, RepeatedFailure(3/3) │
│ [A] approve [R] reject [S] steer [D] details │
├─ input ────────────────────────────────────────────────────────────┤
│ > _ │
├─ keybinds ────────────────────────────────────────────────────────┤
│ n new r resume c cancel a approve q quit ? help , config │
└───────────────────────────────────────────────────────────────────┘
```
**keybinds:**
- `n` — new session (prompts for workflow selection)
- `r` — resume selected session
- `c` — cancel selected session
- `a/r/s` — approve/reject/steer (only active when approval pending)
- `↑↓` — navigate session list
- `enter` — select/expand session
- `q` — quit
- `,` — open config
- `?` — help overlay
**state management:** Mosaic reactive state. server events update state via websocket listener coroutine. no polling.
**files:**
- `apps/tui/src/main/kotlin/.../TuiApp.kt` — Mosaic entry point
- `apps/tui/src/main/kotlin/.../components/StatusBar.kt`
- `apps/tui/src/main/kotlin/.../components/SessionList.kt`
- `apps/tui/src/main/kotlin/.../components/ActiveSession.kt`
- `apps/tui/src/main/kotlin/.../components/ApprovalPrompt.kt`
- `apps/tui/src/main/kotlin/.../components/InputBar.kt`
- `apps/tui/src/main/kotlin/.../ws/TuiWsClient.kt` — websocket client
- `apps/tui/src/main/kotlin/.../state/TuiState.kt`
**acceptance criteria:**
- status bar updates within 1s of provider state change
- session list shows last 5 sessions with correct status
- approval prompt appears immediately on `ApprovalRequired` message
- approve/reject/steer sends correct `ApprovalResponse` to server
- disconnect from server → status bar shows reconnecting state, no crash
---
## task 5: apps/cli — Clikt entrypoint
**dependencies:** task 2, task 3
**commands:**
```
correx run --workflow <path> [--session <id>] [--auto-approve]
correx session list [--limit 20]
correx session resume <id>
correx session cancel <id>
correx session events <id> [--from <eventId>]
correx approve <session-id> --decision <approve|reject|steer> [--note "..."]
correx status
correx provider list
correx config [--edit]
```
**output format:**
- default: human-readable, colored
- `--json` flag: machine-readable JSON on stdout for all commands
- `--quiet` flag: minimal output, only errors
**`correx run` behavior:**
- starts session, subscribes to websocket stream
- prints stage transitions and tool calls to stdout
- on approval required: prompts interactively if TTY, auto-denies if `--auto-approve` not set and no TTY
- exits 0 on session complete, 1 on failure, 2 on approval denied
**files:**
- `apps/cli/src/main/kotlin/.../CorrexCli.kt` — root Clikt command
- `apps/cli/src/main/kotlin/.../commands/RunCommand.kt`
- `apps/cli/src/main/kotlin/.../commands/SessionCommand.kt`
- `apps/cli/src/main/kotlin/.../commands/ApproveCommand.kt`
- `apps/cli/src/main/kotlin/.../commands/StatusCommand.kt`
- `apps/cli/src/main/kotlin/.../commands/ProviderCommand.kt`
- `apps/cli/src/main/kotlin/.../ws/CliWsClient.kt`
**acceptance criteria:**
- `correx run` exits with correct codes
- `--json` produces valid parseable JSON for all commands
- approval prompt works in TTY, auto-denies without TTY when flag absent
- `correx session list` output matches server `/sessions` response
---
## task 6: configuration
**shared config file:** `~/.config/correx/config.toml` (or `$CORREX_CONFIG`)
```toml
[server]
host = "localhost"
port = 8080
[tui]
theme = "dark"
session_list_limit = 5
[cli]
default_output = "human"
[approval]
timeout_ms = 300000 # 5 minutes
```
config accessible from both TUI (`,` keybind) and CLI (`correx config --edit`).
**files:**
- `apps/shared/src/main/kotlin/.../config/CorrexConfig.kt`
- `apps/shared/src/main/kotlin/.../config/ConfigLoader.kt`
---
## sequencing
```
task 1 (protocol)
task 2 (server + websocket)
task 3 (approval interaction) ← depends on task 2
task 4 (TUI) task 5 (CLI) ← both depend on tasks 2+3, parallel
task 6 (config) ← shared, can be done alongside 4+5
```
---
## deferred to epic 14+
- router layer (L2/L3 memory bridge)
- workflow definition editor in TUI
- session diff viewer
- replay viewer (epic 15)
- GPU memory pressure indicator beyond simple % usage
- multi-server TUI (connecting to remote correx instances)