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

13 KiB
Raw Blame History

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):

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):

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 ApprovalRequiredEventOrchestrationPausedEvent
  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)

[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)