chore: remove outdated documentation and scripts (#175)
This commit is contained in:
@@ -1,455 +0,0 @@
|
||||
# Getting Started with Kandev Backend Development
|
||||
|
||||
> **Note:** This guide reflects the current unified binary implementation.
|
||||
> The original microservices architecture is documented in IMPLEMENTATION_PLAN.md for future reference.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required Software
|
||||
|
||||
1. **Go 1.21 or higher**
|
||||
```bash
|
||||
# Check version
|
||||
go version
|
||||
|
||||
# Install from https://go.dev/dl/
|
||||
```
|
||||
|
||||
2. **Docker**
|
||||
```bash
|
||||
# Check version
|
||||
docker --version
|
||||
|
||||
# Install from https://docs.docker.com/get-docker/
|
||||
```
|
||||
|
||||
3. **Augment CLI Credentials**
|
||||
```bash
|
||||
# Verify Augment session exists
|
||||
cat ~/.augment/session.json
|
||||
```
|
||||
|
||||
4. **Git with SSH Keys Configured** (for repository access)
|
||||
```bash
|
||||
git --version
|
||||
|
||||
# Verify SSH keys exist
|
||||
ls -la ~/.ssh/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Step 1: Build the Backend
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
|
||||
# Build the binary
|
||||
make build
|
||||
|
||||
# This creates ./bin/kandev
|
||||
```
|
||||
|
||||
### Step 2: Run the Server
|
||||
|
||||
```bash
|
||||
# Run with default settings (creates kandev.db in current directory)
|
||||
./bin/kandev
|
||||
|
||||
# Or specify a custom database path
|
||||
KANDEV_DB_PATH=/path/to/kandev.db ./bin/kandev
|
||||
```
|
||||
|
||||
The server starts on port 8080 by default.
|
||||
|
||||
### Step 3: Verify It's Running
|
||||
|
||||
The backend uses a WebSocket-first architecture. Connect to `ws://localhost:8080/ws` to interact with the API.
|
||||
|
||||
#### Using websocat (Command Line)
|
||||
|
||||
```bash
|
||||
# Install websocat
|
||||
# macOS: brew install websocat
|
||||
# Linux: cargo install websocat
|
||||
|
||||
# Health check
|
||||
echo '{"id":"1","type":"request","action":"health.check","payload":{}}' | websocat ws://localhost:8080/ws
|
||||
|
||||
# Should return a response with status "ok"
|
||||
```
|
||||
|
||||
#### Using JavaScript
|
||||
|
||||
```javascript
|
||||
const ws = new WebSocket('ws://localhost:8080/ws');
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(JSON.stringify({
|
||||
id: '1',
|
||||
type: 'request',
|
||||
action: 'health.check',
|
||||
payload: {}
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
console.log('Health:', msg); // { id: '1', type: 'response', action: 'health.check', payload: { status: 'ok' }, ... }
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Message Protocol
|
||||
|
||||
### Connection
|
||||
|
||||
Connect to: `ws://localhost:8080/ws`
|
||||
|
||||
### Message Envelope
|
||||
|
||||
All messages use this envelope format:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "correlation-uuid",
|
||||
"type": "request|response|notification|error",
|
||||
"action": "action.name",
|
||||
"payload": {},
|
||||
"timestamp": "2026-01-10T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Message Types
|
||||
|
||||
- `request` - Client request to the server
|
||||
- `response` - Server response to a request
|
||||
- `notification` - Server push notification (e.g., agent status updates)
|
||||
- `error` - Error response
|
||||
|
||||
---
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Create a Board
|
||||
|
||||
#### websocat
|
||||
|
||||
```bash
|
||||
echo '{"id":"1","type":"request","action":"board.create","payload":{"name":"My Project","description":"Project tasks"}}' | websocat ws://localhost:8080/ws
|
||||
```
|
||||
|
||||
#### JavaScript
|
||||
|
||||
```javascript
|
||||
ws.send(JSON.stringify({
|
||||
id: '1',
|
||||
type: 'request',
|
||||
action: 'board.create',
|
||||
payload: { name: 'My Project', description: 'Project tasks' }
|
||||
}));
|
||||
```
|
||||
|
||||
### List Boards
|
||||
|
||||
#### websocat
|
||||
|
||||
```bash
|
||||
echo '{"id":"2","type":"request","action":"board.list","payload":{}}' | websocat ws://localhost:8080/ws
|
||||
```
|
||||
|
||||
#### JavaScript
|
||||
|
||||
```javascript
|
||||
ws.send(JSON.stringify({
|
||||
id: '2',
|
||||
type: 'request',
|
||||
action: 'board.list',
|
||||
payload: {}
|
||||
}));
|
||||
```
|
||||
|
||||
### Create a Column
|
||||
|
||||
#### websocat
|
||||
|
||||
```bash
|
||||
echo '{"id":"3","type":"request","action":"column.create","payload":{"board_id":"<board_id>","name":"To Do"}}' | websocat ws://localhost:8080/ws
|
||||
```
|
||||
|
||||
#### JavaScript
|
||||
|
||||
```javascript
|
||||
ws.send(JSON.stringify({
|
||||
id: '3',
|
||||
type: 'request',
|
||||
action: 'column.create',
|
||||
payload: { board_id: '<board_id>', name: 'To Do' }
|
||||
}));
|
||||
```
|
||||
|
||||
### Create a Task
|
||||
|
||||
#### websocat
|
||||
|
||||
```bash
|
||||
echo '{"id":"4","type":"request","action":"task.create","payload":{"board_id":"<board_id>","column_id":"<column_id>","title":"Fix login bug","description":"Users cannot login"}}' | websocat ws://localhost:8080/ws
|
||||
```
|
||||
|
||||
#### JavaScript
|
||||
|
||||
```javascript
|
||||
ws.send(JSON.stringify({
|
||||
id: '4',
|
||||
type: 'request',
|
||||
action: 'task.create',
|
||||
payload: {
|
||||
board_id: '<board_id>',
|
||||
column_id: '<column_id>',
|
||||
title: 'Fix login bug',
|
||||
description: 'Users cannot login'
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
### Launch an Agent for a Task
|
||||
|
||||
#### websocat
|
||||
|
||||
```bash
|
||||
echo '{"id":"5","type":"request","action":"agent.launch","payload":{"task_id":"<task_id>","agent_type":"augment-agent","repository_url":"/path/to/your/project"}}' | websocat ws://localhost:8080/ws
|
||||
```
|
||||
|
||||
#### JavaScript
|
||||
|
||||
```javascript
|
||||
ws.send(JSON.stringify({
|
||||
id: '5',
|
||||
type: 'request',
|
||||
action: 'agent.launch',
|
||||
payload: {
|
||||
task_id: '<task_id>',
|
||||
agent_type: 'augment-agent',
|
||||
repository_url: '/path/to/your/project'
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
### Start Task via Orchestrator
|
||||
|
||||
#### websocat
|
||||
|
||||
```bash
|
||||
echo '{"id":"6","type":"request","action":"orchestrator.start","payload":{"task_id":"<task_id>"}}' | websocat ws://localhost:8080/ws
|
||||
```
|
||||
|
||||
#### JavaScript
|
||||
|
||||
```javascript
|
||||
ws.send(JSON.stringify({
|
||||
id: '6',
|
||||
type: 'request',
|
||||
action: 'orchestrator.start',
|
||||
payload: { task_id: '<task_id>' }
|
||||
}));
|
||||
```
|
||||
|
||||
### Check Agent Status
|
||||
|
||||
#### websocat
|
||||
|
||||
```bash
|
||||
echo '{"id":"7","type":"request","action":"agent.status","payload":{"agent_id":"<agent_id>"}}' | websocat ws://localhost:8080/ws
|
||||
```
|
||||
|
||||
#### JavaScript
|
||||
|
||||
```javascript
|
||||
ws.send(JSON.stringify({
|
||||
id: '7',
|
||||
type: 'request',
|
||||
action: 'agent.status',
|
||||
payload: { agent_id: '<agent_id>' }
|
||||
}));
|
||||
```
|
||||
|
||||
### Interactive Mode
|
||||
|
||||
For interactive testing, connect in interactive mode:
|
||||
|
||||
```bash
|
||||
websocat ws://localhost:8080/ws
|
||||
# Then type messages manually, one per line:
|
||||
{"id":"1","type":"request","action":"health.check","payload":{}}
|
||||
{"id":"2","type":"request","action":"board.list","payload":{}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running the End-to-End Test
|
||||
|
||||
The project includes a comprehensive E2E test script:
|
||||
|
||||
```bash
|
||||
# Build the agent Docker image first
|
||||
cd apps/backend/dockerfiles/augment-agent
|
||||
docker build -t kandev/augment-agent:latest .
|
||||
|
||||
# Run the E2E test
|
||||
cd /path/to/kandev
|
||||
./scripts/e2e-test.sh
|
||||
```
|
||||
|
||||
The E2E test uses WebSocket connections to:
|
||||
1. Start the server with a fresh database
|
||||
2. Connect via WebSocket to `ws://localhost:8080/ws`
|
||||
3. Create a board, column, and task using WebSocket messages
|
||||
4. Start the task via the orchestrator (`orchestrator.start`)
|
||||
5. Verify the agent container starts
|
||||
6. Check that ACP session is created
|
||||
7. Receive real-time agent status notifications
|
||||
8. Wait for agent to complete
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|---------------------|---------|-------------|
|
||||
| `KANDEV_DB_PATH` | `./kandev.db` | SQLite database file path |
|
||||
| `PORT` | `8080` | HTTP server port |
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
make build
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
make test
|
||||
|
||||
# Or with verbose output
|
||||
go test -v ./...
|
||||
```
|
||||
|
||||
### Code Formatting
|
||||
|
||||
```bash
|
||||
go fmt ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
### View Database
|
||||
|
||||
```bash
|
||||
# SQLite CLI
|
||||
sqlite3 apps/backend/kandev.db
|
||||
|
||||
# List tables
|
||||
.tables
|
||||
|
||||
# View boards
|
||||
SELECT * FROM boards;
|
||||
|
||||
# View tasks
|
||||
SELECT * FROM tasks;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
kandev/
|
||||
├── apps/
|
||||
│ ├── backend/ # Go backend services
|
||||
│ │ ├── cmd/kandev/ # Main entry point
|
||||
│ │ │ └── main.go
|
||||
│ │ ├── internal/
|
||||
│ │ │ ├── agent/ # Agent management
|
||||
│ │ │ │ ├── acp/ # ACP session management
|
||||
│ │ │ │ ├── api/ # Agent HTTP handlers
|
||||
│ │ │ │ ├── credentials/ # Credential providers
|
||||
│ │ │ │ ├── docker/ # Docker client
|
||||
│ │ │ │ ├── lifecycle/# Container lifecycle
|
||||
│ │ │ │ └── registry/ # Agent type registry
|
||||
│ │ │ ├── task/ # Task management
|
||||
│ │ │ │ ├── api/ # Task HTTP handlers
|
||||
│ │ │ │ ├── models/ # Data models
|
||||
│ │ │ │ ├── repository/ # SQLite/Memory storage
|
||||
│ │ │ │ └── service/ # Business logic
|
||||
│ │ │ ├── orchestrator/ # Orchestration logic
|
||||
│ │ │ ├── events/ # In-memory event bus
|
||||
│ │ │ └── common/ # Shared utilities
|
||||
│ │ │ └── logger/ # Zap logger
|
||||
│ │ ├── pkg/acp/ # ACP protocol types
|
||||
│ │ │ └── jsonrpc/ # JSON-RPC 2.0 client
|
||||
│ │ ├── dockerfiles/ # Agent Dockerfiles
|
||||
│ │ │ └── augment-agent/
|
||||
│ │ ├── bin/ # Built binaries (gitignored)
|
||||
│ │ └── Makefile
|
||||
│ └── web/ # Frontend application
|
||||
├── scripts/
|
||||
│ └── e2e-test.sh # End-to-end test script
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker Permission Issues
|
||||
|
||||
```bash
|
||||
# Add user to docker group (Linux)
|
||||
sudo usermod -aG docker $USER
|
||||
newgrp docker
|
||||
```
|
||||
|
||||
### Port Already in Use
|
||||
|
||||
```bash
|
||||
# Find process using port 8080
|
||||
lsof -i :8080
|
||||
|
||||
# Kill it
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
### Database Issues
|
||||
|
||||
```bash
|
||||
# Delete and recreate database
|
||||
rm apps/backend/kandev.db
|
||||
./bin/kandev # Will create fresh database
|
||||
```
|
||||
|
||||
### Agent Container Issues
|
||||
|
||||
```bash
|
||||
# List running containers
|
||||
docker ps -a --filter "label=kandev.managed=true"
|
||||
|
||||
# View container logs
|
||||
docker logs <container_id>
|
||||
|
||||
# Clean up old containers
|
||||
docker rm $(docker ps -a -q --filter "label=kandev.managed=true")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
See [NEXT_STEPS.md](../apps/backend/NEXT_STEPS.md) for the development roadmap and immediate next steps.
|
||||
@@ -1,249 +0,0 @@
|
||||
# Agent Orchestration + Session UI Data Flow
|
||||
|
||||
This document describes how a task session is created, how agentctl/agent lifecycle events flow through the backend, and how the session details page consumes those events.
|
||||
|
||||
## Overview (Actors)
|
||||
|
||||
- **Client UI**: Next.js app with session details page and panels (chat, terminal, git, files).
|
||||
- **Backend**: Orchestrator + WebSocket gateway + task/session store.
|
||||
- **agentctl**: Sidecar HTTP/WS server inside agent container (or standalone process) that manages the agent subprocess and exposes ACP + workspace streams.
|
||||
- **Agent**: ACP-speaking process (JSON-RPC over stdin/stdout) that performs work.
|
||||
|
||||
## Orchestration Flow (Task Session Creation)
|
||||
|
||||
```
|
||||
Client UI
|
||||
| HTTP: POST /api/v1/tasks { start_agent: true, agent_profile_id }
|
||||
v
|
||||
Backend Task Service
|
||||
| create task + first session
|
||||
| start agentctl orchestration
|
||||
| create task_session (STARTING)
|
||||
| launch agentctl (container or standalone)
|
||||
| wait for /health
|
||||
| POST /api/v1/start
|
||||
| POST /api/v1/acp/call initialize
|
||||
| POST /api/v1/acp/call session/new
|
||||
| POST /api/v1/acp/call session/prompt
|
||||
v
|
||||
agentctl
|
||||
| spawns agent process
|
||||
| streams ACP updates + workspace streams
|
||||
v
|
||||
Agent
|
||||
| ACP notifications (session/update)
|
||||
v
|
||||
agentctl -> Backend
|
||||
| ACP update stream -> backend
|
||||
| backend updates task_session state + messages
|
||||
| backend publishes WS notifications
|
||||
v
|
||||
Client UI
|
||||
| session.subscribe
|
||||
| receive session-scoped events
|
||||
```
|
||||
|
||||
## Backend Event Routing (Session Scoped)
|
||||
|
||||
Events are now routed primarily by **session_id**. The WS gateway maintains **session subscriber lists**. It broadcasts session events only to clients subscribed to that session.
|
||||
|
||||
```
|
||||
agentctl WS /api/v1/acp/stream
|
||||
-> backend ACP handler
|
||||
-> task_session state update
|
||||
-> message persisted
|
||||
-> WS broadcast: session.channel
|
||||
|
||||
agentctl WS /api/v1/workspace/git-status/stream
|
||||
-> backend git-status handler
|
||||
-> attach to session
|
||||
-> WS broadcast: session.channel
|
||||
|
||||
agentctl WS /api/v1/output/stream
|
||||
-> backend shell handler
|
||||
-> session resolve (session_id -> task_id)
|
||||
-> WS broadcast: session.channel
|
||||
```
|
||||
|
||||
Session notifications:
|
||||
- `session.agentctl_starting`
|
||||
- `session.agentctl_ready`
|
||||
- `session.agentctl_error`
|
||||
- `session.state_changed`
|
||||
- `session.message.added`, `session.message.updated`
|
||||
- `session.shell.output`, `session.shell.status`
|
||||
- `session.git.status`
|
||||
- `session.workspace.file.changes`
|
||||
|
||||
## Frontend Session Page Flow (SSR + WS)
|
||||
|
||||
```
|
||||
Server (SSR)
|
||||
- fetch session by id
|
||||
- fetch parent task + workspace context
|
||||
- hydrate store (tasks, sessions, repos, agent profiles)
|
||||
|
||||
Client (Hydrated)
|
||||
- read sessionId from URL (/s/:sessionId)
|
||||
- set activeSessionId in store
|
||||
- subscribe to session events
|
||||
- child components read from store + hooks
|
||||
```
|
||||
|
||||
### Component Subscriptions
|
||||
|
||||
Each component subscribes to the data it needs via hooks. The **WS client deduplicates subscriptions** so multiple components can safely request the same channel.
|
||||
|
||||
```
|
||||
TaskSessionPage (/s/:sessionId)
|
||||
-> useSession(sessionId)
|
||||
-> subscribe: session.{sessionId}
|
||||
|
||||
TaskChatPanel
|
||||
-> useSessionMessages(sessionId)
|
||||
-> subscribe: session.{sessionId}
|
||||
|
||||
ShellTerminal
|
||||
-> useSessionAgentctl(sessionId)
|
||||
-> subscribe: session.{sessionId}
|
||||
-> useShellOutput(sessionId)
|
||||
-> subscribe: session.{sessionId}
|
||||
|
||||
TaskChangesPanel (Git)
|
||||
-> useSessionGitStatus(sessionId)
|
||||
-> subscribe: session.{sessionId}
|
||||
|
||||
TaskFilesPanel
|
||||
-> useSessionAgentctl(sessionId)
|
||||
-> wait for readiness
|
||||
-> useSessionWorktrees(sessionId)
|
||||
-> subscribe: session.{sessionId}
|
||||
```
|
||||
|
||||
## Event + State Flow Diagram
|
||||
|
||||
```
|
||||
Agent
|
||||
| ACP session/update
|
||||
v
|
||||
agentctl
|
||||
| WS /api/v1/acp/stream
|
||||
v
|
||||
Backend ACP handler
|
||||
| update session state/messages
|
||||
| emit session WS events
|
||||
v
|
||||
WS Gateway (session subscribers)
|
||||
| broadcast to session.{sessionId}
|
||||
v
|
||||
WebSocket Client (browser)
|
||||
| dedupe subscription
|
||||
| route to store slice
|
||||
v
|
||||
Zustand Store
|
||||
| sessions, messages, shell, git, worktrees
|
||||
v
|
||||
Session UI Panels
|
||||
| hook selectors
|
||||
| render
|
||||
```
|
||||
|
||||
## Concrete Example: New Session -> Message Appears In Chat
|
||||
|
||||
Scenario: user starts a task session and the agent replies with a completion message that appears in the chat panel.
|
||||
|
||||
### Sequence (Calls + Events)
|
||||
|
||||
1) Client -> Backend (HTTP request)
|
||||
```
|
||||
POST /api/v1/tasks
|
||||
body:
|
||||
title: "Example task"
|
||||
workspace_id: "workspace-1"
|
||||
repository_id: "repo-9"
|
||||
agent_profile_id: "profile-abc"
|
||||
start_agent: true
|
||||
```
|
||||
|
||||
2) Backend (internal)
|
||||
- creates task
|
||||
- creates first task session
|
||||
- starts agentctl orchestration for the new session
|
||||
- returns session_id in the HTTP response
|
||||
|
||||
3) Backend -> agentctl (HTTP)
|
||||
- `POST /api/v1/start`
|
||||
- `POST /api/v1/acp/call` initialize
|
||||
- `POST /api/v1/acp/call` session/new
|
||||
- `POST /api/v1/acp/call` session/prompt
|
||||
|
||||
4) Backend -> Client (WS notification)
|
||||
```
|
||||
action: session.agentctl_starting
|
||||
payload:
|
||||
task_id: "task-123"
|
||||
session_id: "session-789"
|
||||
```
|
||||
|
||||
5) Client subscribes to session
|
||||
```
|
||||
action: session.subscribe
|
||||
payload:
|
||||
session_id: "session-789"
|
||||
```
|
||||
|
||||
6) agentctl -> Backend (ACP notification)
|
||||
```
|
||||
jsonrpc: "2.0"
|
||||
method: "session/update"
|
||||
params:
|
||||
type: "content"
|
||||
data:
|
||||
text: "Working on your request..."
|
||||
session_id: "session-789"
|
||||
```
|
||||
|
||||
7) Backend -> Client (WS notification)
|
||||
```
|
||||
action: session.message.added
|
||||
payload:
|
||||
session_id: "session-789"
|
||||
message:
|
||||
id: "msg-1"
|
||||
author_type: "agent"
|
||||
content: "Working on your request..."
|
||||
```
|
||||
|
||||
8) Client store update (message handler)
|
||||
```
|
||||
messages.bySession["session-789"] += message
|
||||
```
|
||||
|
||||
9) Chat panel render
|
||||
```
|
||||
TaskChatPanel -> useSessionMessages("session-789") -> messages list -> render
|
||||
```
|
||||
|
||||
### Full Event List (Typical Session Start)
|
||||
|
||||
- HTTP request: `POST /api/v1/tasks` with `start_agent: true`
|
||||
- WS notification: `session.agentctl_starting`
|
||||
- WS request: `session.subscribe`
|
||||
- WS notification: `session.agentctl_ready`
|
||||
- ACP notifications from agent:
|
||||
- `session/update` (content/toolCall/complete/error)
|
||||
- WS notifications:
|
||||
- `session.message.added`
|
||||
- `session.message.updated`
|
||||
- `session.state_changed`
|
||||
- `session.shell.status` / `session.shell.output` (if shell active)
|
||||
- `session.git.status` (if git watcher active)
|
||||
- `session.workspace.file.changes` (if watcher active)
|
||||
|
||||
## Notes / Guardrails
|
||||
|
||||
- Session is the source of truth; task id is derived from session when needed.
|
||||
- No fallback to “active task” or “first message” for session ownership.
|
||||
- Agentctl readiness is explicit; panels that require a live agent should wait for `agentctl_ready`.
|
||||
- Store slices are keyed by session id (messages, shell output, git status, worktrees).
|
||||
- WS subscriptions are **session-scoped** and **deduplicated** at the client level.
|
||||
@@ -1,104 +0,0 @@
|
||||
# Agent Permission Requests
|
||||
|
||||
This document describes how permission requests flow between the agent, backend, and frontend.
|
||||
|
||||
## Overview
|
||||
Some agents (e.g., Auggie) require user approval for sensitive tool actions (run command, write file, etc.). The agentctl sidecar streams permission requests to the backend, which then broadcasts them to the UI. The user responds via the UI, and the backend forwards the response back to agentctl.
|
||||
|
||||
## High-Level Flow
|
||||
```
|
||||
Agent (ACP/CLI)
|
||||
|
|
||||
| permission_request (tool call needs approval)
|
||||
v
|
||||
agentctl (permission stream)
|
||||
|
|
||||
| WS /api/v1/acp/permissions/stream
|
||||
v
|
||||
Backend (lifecycle manager)
|
||||
|
|
||||
| events.ACPMessage (type=permission_request)
|
||||
v
|
||||
Backend (WS gateway)
|
||||
|
|
||||
| session.permission.requested (WS notification)
|
||||
v
|
||||
Frontend (CommandApprovalDialog / tool-call UI)
|
||||
|
|
||||
| permission.respond (WS request)
|
||||
v
|
||||
Backend (orchestrator)
|
||||
|
|
||||
| RespondToPermissionBySessionID
|
||||
v
|
||||
agentctl (POST /api/v1/acp/permissions/respond)
|
||||
|
|
||||
v
|
||||
Agent continues (approved/rejected)
|
||||
```
|
||||
|
||||
## Backend Components
|
||||
- **agentctl permission stream**
|
||||
- agentctl exposes `GET /api/v1/acp/permissions/stream`.
|
||||
- Streams `PermissionNotification` from the agent process.
|
||||
- **lifecycle StreamManager**
|
||||
- Connects to the permission stream.
|
||||
- Publishes `events.ACPMessage` with `type=permission_request`.
|
||||
- **orchestrator watcher**
|
||||
- Parses ACP messages and forwards permission requests to WS clients.
|
||||
- **gateway websocket**
|
||||
- Broadcasts `session.permission.requested` to session subscribers.
|
||||
- **orchestrator handlers**
|
||||
- Handles `permission.respond` and forwards to agentctl.
|
||||
|
||||
## Frontend Components
|
||||
- **CommandApprovalDialog**
|
||||
- Shows permission requests not tied to a visible tool call.
|
||||
- **ToolCallMessage inline actions**
|
||||
- Shows approve/reject inline for tool call messages.
|
||||
- **WebSocket client**
|
||||
- Subscribes to `session.permission.requested`.
|
||||
- Sends `permission.respond` with `{ session_id, pending_id, option_id, cancelled }`.
|
||||
|
||||
## Message Shapes
|
||||
|
||||
### Permission Requested (WS notification)
|
||||
```json
|
||||
{
|
||||
"type": "notification",
|
||||
"action": "session.permission.requested",
|
||||
"payload": {
|
||||
"pending_id": "perm-123",
|
||||
"session_id": "session-abc",
|
||||
"task_id": "task-xyz",
|
||||
"tool_call_id": "tool-456",
|
||||
"title": "Command Execution Approval",
|
||||
"options": [
|
||||
{ "option_id": "allow_once", "name": "Allow once", "kind": "allow_once" },
|
||||
{ "option_id": "reject_once", "name": "Reject", "kind": "reject_once" }
|
||||
],
|
||||
"action_type": "command",
|
||||
"action_details": { "command": "rm -rf .", "cwd": "/workspace" },
|
||||
"created_at": "2026-01-18T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Permission Respond (WS request)
|
||||
```json
|
||||
{
|
||||
"type": "request",
|
||||
"action": "permission.respond",
|
||||
"payload": {
|
||||
"session_id": "session-abc",
|
||||
"pending_id": "perm-123",
|
||||
"option_id": "allow_once",
|
||||
"cancelled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
- Permissions are scoped to a **session**, not a task.
|
||||
- If the session has no active UI, the pending permission is still stored on the backend and sent when the client subscribes.
|
||||
- The agent can define different tools that require permission via its registry config.
|
||||
@@ -1,127 +0,0 @@
|
||||
# Agentctl session lifecycle (Kandev)
|
||||
|
||||
This document explains how **agentctl** fits into the Kandev execution flow, what “ready” means, and which parts of the system depend on agentctl being up.
|
||||
|
||||
## What agentctl is
|
||||
|
||||
`agentctl` is a sidecar process that runs inside each agent container (or standalone runtime). It provides:
|
||||
|
||||
- **HTTP API** for lifecycle operations (`/health`, `/api/v1/start`, `/api/v1/stop`, etc.)
|
||||
- **ACP bridge** (stdin/stdout JSON‑RPC) to the agent process (Auggie/Codex/etc.)
|
||||
- **WebSockets** for streaming ACP updates and agent output
|
||||
- **Shell access** to the workspace (terminal output + input)
|
||||
|
||||
It is the backend’s *control plane* for an agent execution.
|
||||
|
||||
## High‑level flow (runtime‑agnostic)
|
||||
|
||||
```
|
||||
User action (start task)
|
||||
|
|
||||
v
|
||||
Kandev orchestrator
|
||||
|
|
||||
| 1) Create task session in DB
|
||||
| 2) Launch agent runtime (Docker or standalone)
|
||||
v
|
||||
agentctl process starts
|
||||
|
|
||||
| 3) HTTP server comes up (health + control APIs)
|
||||
v
|
||||
Backend waits for agentctl readiness
|
||||
|
|
||||
| 4) agentctl ready => shell/workspace access available
|
||||
v
|
||||
ACP session initialization
|
||||
|
|
||||
| 5) initialize -> session/new or session/load
|
||||
| 6) prompt is sent
|
||||
v
|
||||
Agent runs, ACP updates stream
|
||||
|
|
||||
| 7) session/update notifications => WS to client
|
||||
v
|
||||
Task session state transitions
|
||||
```
|
||||
|
||||
## “Ready” concepts (important distinction)
|
||||
|
||||
There are **two different “ready” moments**:
|
||||
|
||||
1) **agentctl ready**
|
||||
- Meaning: agentctl HTTP server is healthy and can accept requests.
|
||||
- Practical impact: shell/workspace endpoints become usable.
|
||||
- Source: `lifecycle.Manager.waitForAgentctlReady`.
|
||||
- Today: only logged; **not emitted as a WS event**.
|
||||
|
||||
2) **agent ready** (prompt completed)
|
||||
- Meaning: agent finished the current prompt and is idle/ready for follow‑up.
|
||||
- Source: `events.AgentReady` (after prompt completion).
|
||||
- Today: internal event for orchestrator state transitions.
|
||||
|
||||
The frontend should not rely on “agent ready” when it needs shell access; shell access depends on agentctl readiness instead.
|
||||
|
||||
## Current backend signals
|
||||
|
||||
- **agentctl readiness**
|
||||
- `agentctl is ready` (agentctl client log)
|
||||
- `agentctl ready - shell/workspace access available` (lifecycle manager log)
|
||||
|
||||
- **agent ready** (post‑prompt)
|
||||
- `events.AgentReady` internal event (used by orchestrator state transitions)
|
||||
|
||||
There is **no WS event** that tells the client “agentctl is ready.”
|
||||
|
||||
## Where shell streaming depends on agentctl
|
||||
|
||||
- Client calls `shell.subscribe` (WS request)
|
||||
- Backend checks:
|
||||
- agent execution exists for the task
|
||||
- agentctl is connected
|
||||
- Backend starts a streaming loop that relays agentctl shell output as `shell.output` WS notifications
|
||||
|
||||
If agentctl isn’t ready yet, `shell.subscribe` fails and the frontend currently retries.
|
||||
|
||||
## Suggested improvement (optional)
|
||||
|
||||
Introduce a WS notification like:
|
||||
|
||||
```
|
||||
{ "action": "task.session.agentctl_ready", "payload": { "task_id": "...", "task_session_id": "..." } }
|
||||
```
|
||||
|
||||
So the client can subscribe once and only request shell after it receives readiness.
|
||||
|
||||
## End‑to‑end diagram (detailed)
|
||||
|
||||
```
|
||||
Client Backend (Kandev) agentctl Agent
|
||||
| | | |
|
||||
| orchestrator.start | | |
|
||||
|------------------------------->| | |
|
||||
| | create task session | |
|
||||
| | launch runtime | |
|
||||
| |---------------------------->| process starts |
|
||||
| | | HTTP server up |
|
||||
| | waitForAgentctlReady | |
|
||||
| |---------------------------->| /health |
|
||||
| |<----------------------------| 200 OK |
|
||||
| | (agentctl ready) | |
|
||||
| | ACP initialize | |
|
||||
| |---------------------------->| initialize |
|
||||
| | ACP session/new | |
|
||||
| |---------------------------->| session/new |
|
||||
| | ACP prompt | |
|
||||
| |---------------------------->| session/prompt |
|
||||
| | |--------------------->|
|
||||
| | | agent runs |
|
||||
| | WS stream | |
|
||||
|<-------------------------------| (session/update notifications) |
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- `apps/backend/internal/agent/lifecycle/manager.go` (waitForAgentctlReady)
|
||||
- `apps/backend/internal/agentctl/client/client.go` (WaitForReady)
|
||||
- `apps/backend/internal/agent/handlers/shell_handlers.go` (shell.subscribe)
|
||||
- `apps/backend/internal/events/types.go` (`AgentReady`)
|
||||
@@ -1,326 +0,0 @@
|
||||
# MCP Server Migration: Backend → agentctl
|
||||
|
||||
This document describes the architectural change to move the MCP server from the Kandev backend to agentctl, enabling remote agents to access MCP tools.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Currently, the MCP server runs embedded in the Kandev backend on the user's machine. When agents run remotely (Docker containers, VPS, Kubernetes), they cannot access the MCP server because it's not reachable from their network location.
|
||||
|
||||
## Solution
|
||||
|
||||
Move the MCP server into agentctl, which always runs co-located with the agent. API calls from MCP tools are tunneled back to the Kandev backend through the existing WebSocket connection.
|
||||
|
||||
---
|
||||
|
||||
## Current Architecture (MCP Server with Backend)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph UserMachine["👤 USER'S MACHINE"]
|
||||
subgraph Backend["KANDEV BACKEND"]
|
||||
Orchestrator["Orchestrator"]
|
||||
Lifecycle["Lifecycle Manager"]
|
||||
Runtime["Runtime<br/>(Docker/Standalone)"]
|
||||
|
||||
subgraph MCPServer["EMBEDDED MCP SERVER :9090"]
|
||||
SSE["/sse endpoint"]
|
||||
MCP["/mcp endpoint"]
|
||||
Tools["Tools:<br/>list_workspaces<br/>list_tasks<br/>create_task<br/>update_task"]
|
||||
end
|
||||
|
||||
API["REST API :8080<br/>/api/v1/tasks<br/>/api/v1/boards"]
|
||||
|
||||
Orchestrator --> Lifecycle
|
||||
Lifecycle --> Runtime
|
||||
MCPServer -->|"HTTP calls"| API
|
||||
end
|
||||
end
|
||||
|
||||
subgraph Remote["🖥️ REMOTE SERVER / CONTAINER"]
|
||||
subgraph Agentctl["AGENTCTL"]
|
||||
InstanceMgr["Instance Manager<br/>Ports: 10000+"]
|
||||
Agent["Agent Process<br/>(Claude, Codex, etc)<br/>stdin/stdout ACP"]
|
||||
|
||||
InstanceMgr --> Agent
|
||||
end
|
||||
|
||||
NoMCP["🚫 NO MCP ACCESS<br/>Port 9090 unreachable"]
|
||||
Agent -.->|"❌ CANNOT CONNECT"| NoMCP
|
||||
end
|
||||
|
||||
Runtime -->|"WS connection"| Agentctl
|
||||
NoMCP -.->|"❌ Network barrier"| MCPServer
|
||||
|
||||
style NoMCP fill:#ffcccc,stroke:#cc0000
|
||||
style MCPServer fill:#ffffcc,stroke:#cccc00
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Proposed Architecture (MCP Server in agentctl)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph UserMachine["👤 USER'S MACHINE"]
|
||||
subgraph Backend["KANDEV BACKEND"]
|
||||
Orchestrator["Orchestrator"]
|
||||
Lifecycle["Lifecycle Manager"]
|
||||
Runtime["Runtime<br/>(Docker/Standalone)"]
|
||||
|
||||
subgraph WSGateway["WS Gateway :8080"]
|
||||
Dispatcher["Dispatcher"]
|
||||
MCPHandlers["MCP Handlers"]
|
||||
end
|
||||
|
||||
Services["Service Layer<br/>(Task, Board, Workspace)"]
|
||||
|
||||
Orchestrator --> Lifecycle
|
||||
Lifecycle --> Runtime
|
||||
Dispatcher --> MCPHandlers
|
||||
MCPHandlers --> Services
|
||||
end
|
||||
end
|
||||
|
||||
subgraph Remote["🖥️ REMOTE SERVER / CONTAINER"]
|
||||
subgraph Agentctl["AGENTCTL"]
|
||||
InstanceMgr["Instance Manager<br/>Ports: 10000+"]
|
||||
|
||||
subgraph MCPServer["EMBEDDED MCP SERVER"]
|
||||
SSE["/sse endpoint"]
|
||||
MCP["/mcp endpoint"]
|
||||
Tools["Tools:<br/>list_workspaces<br/>list_tasks<br/>create_task<br/>update_task"]
|
||||
end
|
||||
|
||||
WSTunnel["WS Client<br/>(connects to backend)"]
|
||||
|
||||
Agent["Agent Process<br/>(Claude, Codex, etc)<br/>stdin/stdout ACP"]
|
||||
|
||||
InstanceMgr --> Agent
|
||||
MCPServer -->|"forward requests"| WSTunnel
|
||||
Agent -->|"✅ localhost"| MCPServer
|
||||
end
|
||||
end
|
||||
|
||||
Runtime <-->|"WS connection"| Agentctl
|
||||
WSTunnel <-->|"mcp.* actions"| Dispatcher
|
||||
|
||||
style MCPServer fill:#ccffcc,stroke:#00cc00
|
||||
style WSTunnel fill:#cce5ff,stroke:#0066cc
|
||||
style MCPHandlers fill:#ccffcc,stroke:#00cc00
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sequence Diagram: Agent Using MCP (Proposed)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Agent as Agent Process
|
||||
participant MCP as MCP Server<br/>(in agentctl)
|
||||
participant Tunnel as WS Tunnel<br/>(in agentctl)
|
||||
participant Dispatcher as WS Dispatcher<br/>(in Backend)
|
||||
participant Handler as MCP Handler<br/>(in Backend)
|
||||
participant Service as Service Layer<br/>(in Backend)
|
||||
participant DB as Database
|
||||
|
||||
Note over Agent,DB: Agent wants to create a task
|
||||
|
||||
Agent->>MCP: POST /mcp<br/>tool: create_task
|
||||
MCP->>MCP: Parse MCP request
|
||||
|
||||
MCP->>Tunnel: Forward request
|
||||
Tunnel->>Dispatcher: WS message<br/>{action: "mcp.create_task", ...}
|
||||
Dispatcher->>Handler: Dispatch to registered handler
|
||||
Handler->>Service: taskService.Create(...)
|
||||
Service->>DB: Insert task
|
||||
DB-->>Service: Task created
|
||||
Service-->>Handler: Task entity
|
||||
Handler-->>Dispatcher: Response message
|
||||
Dispatcher-->>Tunnel: WS message<br/>{action: "mcp.create_task", result: {...}}
|
||||
Tunnel-->>MCP: Response
|
||||
|
||||
MCP-->>Agent: MCP tool result<br/>{task_id: "..."}
|
||||
|
||||
Note over Agent,DB: ✅ Uses existing WS Dispatcher pattern
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Comparison
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Current["❌ CURRENT"]
|
||||
direction TB
|
||||
B1["Backend"]
|
||||
M1["MCP Server"]
|
||||
A1["agentctl"]
|
||||
AG1["Agent"]
|
||||
|
||||
B1 --- M1
|
||||
B1 -.->|"WS"| A1
|
||||
A1 --- AG1
|
||||
AG1 -.->|"❌ unreachable"| M1
|
||||
end
|
||||
|
||||
subgraph Proposed["✅ PROPOSED"]
|
||||
direction TB
|
||||
B2["Backend"]
|
||||
A2["agentctl"]
|
||||
M2["MCP Server"]
|
||||
AG2["Agent"]
|
||||
|
||||
B2 <-->|"WS + Tunnel"| A2
|
||||
A2 --- M2
|
||||
A2 --- AG2
|
||||
AG2 -->|"✅ localhost"| M2
|
||||
end
|
||||
|
||||
Current -->|"Migration"| Proposed
|
||||
|
||||
style Current fill:#ffeeee
|
||||
style Proposed fill:#eeffee
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Aspect | Current | Proposed |
|
||||
|--------|---------|----------|
|
||||
| **MCP Location** | Backend (user's machine) | agentctl (co-located with agent) |
|
||||
| **Remote Agent Access** | ❌ Cannot reach MCP | ✅ Always localhost |
|
||||
| **Network Dependency** | Requires direct network path | Works over existing WS tunnel |
|
||||
| **Deployment Flexibility** | Limited to local agents | Works anywhere (Docker, VPS, K8s) |
|
||||
| **API Communication** | MCP → localhost API | MCP → WS Tunnel → Backend API |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Changes Required
|
||||
|
||||
**agentctl:**
|
||||
1. Add embedded MCP server (adapt `internal/mcpserver` package)
|
||||
2. Add WS client that connects back to Kandev backend
|
||||
3. MCP tool handlers forward requests via WS client instead of HTTP
|
||||
|
||||
**Backend:**
|
||||
1. Add MCP handlers package (`internal/mcp/handlers/`)
|
||||
2. Register `mcp.*` actions with WS Dispatcher
|
||||
3. Handlers call existing services (TaskService, BoardService, etc.)
|
||||
4. Remove embedded MCP server from `cmd/kandev`
|
||||
|
||||
**Lifecycle Manager:**
|
||||
1. Pass backend WS URL to agentctl during instance creation
|
||||
2. agentctl connects back to backend on startup
|
||||
|
||||
### Backend Internal Architecture
|
||||
|
||||
The backend uses the existing **WS Dispatcher pattern** (not the event bus) for MCP requests. This is consistent with how other WS handlers work (tasks, messages, workflows, etc.):
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Backend["KANDEV BACKEND"]
|
||||
subgraph Gateway["WS Gateway"]
|
||||
Hub["Hub<br/>(client connections)"]
|
||||
Dispatcher["Dispatcher<br/>(action routing)"]
|
||||
end
|
||||
|
||||
subgraph Handlers["Registered Handlers"]
|
||||
TaskH["Task Handlers<br/>task.list, task.create..."]
|
||||
MsgH["Message Handlers<br/>message.add, message.list..."]
|
||||
MCPH["MCP Handlers ⭐ NEW<br/>mcp.create_task, mcp.list_tasks..."]
|
||||
end
|
||||
|
||||
subgraph Services["Service Layer"]
|
||||
TaskSvc["TaskService"]
|
||||
BoardSvc["BoardService"]
|
||||
WorkspaceSvc["WorkspaceService"]
|
||||
end
|
||||
|
||||
Hub --> Dispatcher
|
||||
Dispatcher --> TaskH
|
||||
Dispatcher --> MsgH
|
||||
Dispatcher --> MCPH
|
||||
|
||||
TaskH --> TaskSvc
|
||||
MsgH --> TaskSvc
|
||||
MCPH --> TaskSvc
|
||||
MCPH --> BoardSvc
|
||||
MCPH --> WorkspaceSvc
|
||||
end
|
||||
|
||||
style MCPH fill:#ccffcc,stroke:#00cc00
|
||||
```
|
||||
|
||||
**Why WS Dispatcher instead of Event Bus?**
|
||||
|
||||
| Aspect | WS Dispatcher | Event Bus |
|
||||
|--------|---------------|-----------|
|
||||
| **Pattern** | Request/Response | Pub/Sub (fire-and-forget) |
|
||||
| **Use case** | Synchronous operations | Async notifications |
|
||||
| **Existing usage** | All WS handlers (tasks, messages, etc.) | Agent lifecycle events, notifications |
|
||||
| **MCP fit** | ✅ MCP tools need responses | ❌ Would need request-reply wrapper |
|
||||
|
||||
### WS Message Format
|
||||
|
||||
MCP requests use the standard Kandev WS message format with `mcp.*` action prefix:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "req-123",
|
||||
"action": "mcp.create_task",
|
||||
"payload": {
|
||||
"workspace_id": "ws-1",
|
||||
"board_id": "board-1",
|
||||
"title": "New task"
|
||||
},
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "req-123",
|
||||
"action": "mcp.create_task",
|
||||
"payload": {
|
||||
"task_id": "task-456",
|
||||
"title": "New task"
|
||||
},
|
||||
"timestamp": "2024-01-15T10:30:01Z"
|
||||
}
|
||||
```
|
||||
|
||||
Error response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "req-123",
|
||||
"action": "mcp.create_task",
|
||||
"error": {
|
||||
"code": "NOT_FOUND",
|
||||
"message": "Board not found"
|
||||
},
|
||||
"timestamp": "2024-01-15T10:30:01Z"
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Configuration for Agents
|
||||
|
||||
When configuring agents that support MCP, agentctl will provide the local MCP endpoint:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"kandev": {
|
||||
"url": "http://localhost:10001/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Where `10001` is the instance's allocated port.
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
# Task-Specific Executor Selection Architecture Refactoring
|
||||
|
||||
> **Status**: Proposal
|
||||
> **Author**: Generated for architectural review
|
||||
> **Date**: 2026-01-25
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document proposes refactoring the executor selection architecture from a global runtime configuration to a per-task executor selection model. The goal is to enable multiple runtimes (Docker and Standalone) to coexist, with each task independently selecting its execution environment.
|
||||
|
||||
---
|
||||
|
||||
## 1. Current Architecture
|
||||
|
||||
### 1.1 Global Runtime Configuration
|
||||
|
||||
The backend currently uses a **global runtime configuration** that determines how ALL agents are launched:
|
||||
|
||||
```go
|
||||
// apps/backend/internal/common/config/config.go
|
||||
type AgentConfig struct {
|
||||
Runtime string // "docker" or "standalone" - GLOBAL for all tasks
|
||||
StandaloneHost string
|
||||
StandalonePort int
|
||||
}
|
||||
```
|
||||
|
||||
The `provideLifecycleManager()` function in `apps/backend/cmd/kandev/agents.go` creates a **single runtime** based on this global config:
|
||||
|
||||
```go
|
||||
func provideLifecycleManager(...) (*lifecycle.Manager, *registry.Registry, error) {
|
||||
var agentRuntime lifecycle.Runtime
|
||||
switch cfg.Agent.Runtime {
|
||||
case "standalone":
|
||||
agentRuntime = lifecycle.NewStandaloneRuntime(...) // ALL tasks use this
|
||||
default:
|
||||
agentRuntime = lifecycle.NewDockerRuntime(...) // OR ALL tasks use this
|
||||
}
|
||||
// Only ONE runtime is created and passed to the Manager
|
||||
lifecycleMgr := lifecycle.NewManager(..., agentRuntime, ...)
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 Why This Is Limiting
|
||||
|
||||
1. **All-or-nothing approach**: Every task uses either Docker OR Standalone, never both
|
||||
2. **No runtime flexibility**: Can't run lightweight tasks on host while running isolated tasks in Docker
|
||||
3. **Configuration at startup**: Runtime is fixed when the backend starts; changing requires restart
|
||||
4. **Underutilized Executor model**: The database has `Executor` with `Type` field (`local_pc`, `local_docker`) but it's not used for runtime selection
|
||||
|
||||
### 1.3 Current Data Flow
|
||||
|
||||
```
|
||||
Task Creation
|
||||
│
|
||||
├── TaskSession.ExecutorID is set (from workspace default)
|
||||
│ But it's only used for metadata (MCP policy), not runtime selection
|
||||
│
|
||||
└── executor.ExecuteWithProfile()
|
||||
│
|
||||
└── LaunchAgent() → lifecycle.Manager.Launch()
|
||||
│
|
||||
└── m.runtime.CreateInstance() ← Uses GLOBAL runtime
|
||||
```
|
||||
|
||||
### 1.4 Existing Executor Infrastructure
|
||||
|
||||
The database already has:
|
||||
- `executors` table with `type` field (local_pc, local_docker, remote_docker, etc.)
|
||||
- `task_sessions.executor_id` foreign key
|
||||
- `executors_running.runtime` field (currently underutilized)
|
||||
- Default executors seeded: "Local PC" (exec-local-pc) and "Local Docker" (exec-local-docker)
|
||||
|
||||
---
|
||||
|
||||
## 2. Proposed Architecture
|
||||
|
||||
### 2.1 Core Change: Multi-Runtime Manager
|
||||
|
||||
Replace the single-runtime lifecycle manager with a **runtime registry** that can dispatch to multiple runtimes based on executor type:
|
||||
|
||||
```go
|
||||
// NEW: RuntimeRegistry manages multiple runtimes
|
||||
type RuntimeRegistry struct {
|
||||
runtimes map[runtime.Name]Runtime // docker, standalone, etc.
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
func (r *RuntimeRegistry) GetRuntime(name runtime.Name) (Runtime, error) {
|
||||
rt, ok := r.runtimes[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("runtime %q not available", name)
|
||||
}
|
||||
return rt, nil
|
||||
}
|
||||
|
||||
// Lifecycle Manager now uses RuntimeRegistry instead of single Runtime
|
||||
type Manager struct {
|
||||
runtimeRegistry *RuntimeRegistry // Replaces: runtime Runtime
|
||||
// ... rest unchanged
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Executor Type → Runtime Mapping
|
||||
|
||||
```go
|
||||
// ExecutorType to Runtime mapping
|
||||
func ExecutorTypeToRuntime(execType models.ExecutorType) runtime.Name {
|
||||
switch execType {
|
||||
case models.ExecutorTypeLocalPC:
|
||||
return runtime.NameStandalone
|
||||
case models.ExecutorTypeLocalDocker:
|
||||
return runtime.NameDocker
|
||||
case models.ExecutorTypeRemoteDocker:
|
||||
return runtime.NameDocker // Future: separate RemoteDockerRuntime
|
||||
default:
|
||||
return runtime.NameStandalone // Fallback
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 New Data Flow
|
||||
|
||||
```
|
||||
Task Creation (with executor_id)
|
||||
│
|
||||
├── TaskSession.ExecutorID → Lookup Executor → Get ExecutorType
|
||||
│
|
||||
└── executor.ExecuteWithProfile()
|
||||
│
|
||||
└── LaunchAgentRequest now includes ExecutorType
|
||||
│
|
||||
└── lifecycle.Manager.Launch()
|
||||
│
|
||||
├── Resolve runtime: ExecutorTypeToRuntime(req.ExecutorType)
|
||||
│
|
||||
└── runtimeRegistry.GetRuntime(runtimeName).CreateInstance()
|
||||
```
|
||||
|
||||
### 2.4 Configuration Changes
|
||||
|
||||
**Remove** the global `cfg.Agent.Runtime` setting. Instead:
|
||||
|
||||
```yaml
|
||||
# BEFORE: Global runtime selection (to be removed)
|
||||
agent:
|
||||
runtime: "standalone" # DELETE THIS
|
||||
|
||||
# AFTER: Runtime availability configuration
|
||||
agent:
|
||||
docker:
|
||||
enabled: true
|
||||
# Docker-specific config (image, network, etc.)
|
||||
standalone:
|
||||
host: "localhost"
|
||||
port: 9999
|
||||
# NOTE: No "enabled" flag - agentctl always runs as a core service
|
||||
```
|
||||
|
||||
**Important**: The `standalone` section configures connection settings for the agentctl process, but there's no `enabled` flag. The agentctl launcher always starts as a core service - it's the control plane for standalone execution and is always available.
|
||||
|
||||
---
|
||||
|
||||
## 3. Files Requiring Modification
|
||||
|
||||
### 3.1 Configuration Layer
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `internal/common/config/config.go` | Remove `Runtime` field; add `Docker.Enabled` (standalone is always on) |
|
||||
| `cmd/kandev/agents.go` | Refactor `provideLifecycleManager()` to create RuntimeRegistry with both runtimes |
|
||||
| `cmd/kandev/agentctl.go` | **Always start agentctl launcher** (remove conditional check) |
|
||||
|
||||
### 3.2 Lifecycle Manager
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `internal/agent/lifecycle/manager.go` | Replace `runtime Runtime` with `runtimeRegistry *RuntimeRegistry`; update `Launch()` to select runtime via registry |
|
||||
| `internal/agent/lifecycle/runtime_registry.go` | **NEW FILE**: RuntimeRegistry implementation |
|
||||
| `internal/agent/lifecycle/runtime.go` | Add `ExecutorTypeToRuntime()` mapping function |
|
||||
|
||||
> **Design Principle**: Keep `manager.go` free of runtime-specific code (no Docker imports, no standalone-specific logic). All runtime-specific behavior should be encapsulated in the `Runtime` interface implementations (`runtime_docker.go`, `runtime_standalone.go`) and dispatched through the `RuntimeRegistry`. The manager should only interact with the abstract `Runtime` interface.
|
||||
|
||||
### 3.3 Orchestrator/Executor
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `internal/orchestrator/executor/executor.go` | Pass `ExecutorType` in `LaunchAgentRequest`; resolve executor before launch |
|
||||
| `internal/agent/lifecycle/types.go` | Add `ExecutorType` field to `LaunchRequest` |
|
||||
| `cmd/kandev/adapters.go` | Pass `ExecutorType` from executor request to lifecycle request |
|
||||
|
||||
### 3.4 Data Layer
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `internal/task/models/models.go` | Add helper: `Executor.GetRuntimeName()` |
|
||||
| `internal/agent/runtime/runtime.go` | Add `ExecutorTypeToRuntime()` function |
|
||||
|
||||
### 3.5 Recovery/Resumption
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `internal/orchestrator/service.go` | `resumeExecutorsOnStartup()` must iterate all available runtimes |
|
||||
| `internal/agent/lifecycle/manager.go` | `RecoverExecutions()` must query each runtime in registry |
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Plan
|
||||
|
||||
### Phase 1: Infrastructure Setup (Non-Breaking)
|
||||
|
||||
1. **Create RuntimeRegistry** (`runtime_registry.go`)
|
||||
- Implement registry with `Register()`, `GetRuntime()`, `List()`, `HealthCheckAll()`
|
||||
- Add `ExecutorTypeToRuntime()` mapping function
|
||||
|
||||
2. **Update Configuration**
|
||||
- Add `Docker.Enabled` and `Standalone.Enabled` fields
|
||||
- Keep `Runtime` field temporarily for backward compatibility
|
||||
- Add deprecation warning if `Runtime` is used
|
||||
|
||||
3. **Refactor `provideLifecycleManager()`**
|
||||
- Create both runtimes if enabled
|
||||
- Build RuntimeRegistry with available runtimes
|
||||
- Pass registry to Manager (but Manager still uses single runtime internally)
|
||||
|
||||
### Phase 2: Lifecycle Manager Refactoring
|
||||
|
||||
4. **Update Manager to use RuntimeRegistry**
|
||||
- Change `runtime Runtime` to `runtimeRegistry *RuntimeRegistry`
|
||||
- Add `ExecutorType` to `LaunchRequest`
|
||||
- Update `Launch()` to:
|
||||
```go
|
||||
runtimeName := ExecutorTypeToRuntime(req.ExecutorType)
|
||||
rt, err := m.runtimeRegistry.GetRuntime(runtimeName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("runtime not available: %w", err)
|
||||
}
|
||||
runtimeInstance, err := rt.CreateInstance(ctx, runtimeReq)
|
||||
```
|
||||
|
||||
5. **Update RecoverExecutions()**
|
||||
- Iterate all registered runtimes
|
||||
- Merge recovered instances from each runtime
|
||||
|
||||
### Phase 3: Executor Integration
|
||||
|
||||
6. **Update `LaunchAgentRequest`**
|
||||
- Add `ExecutorType models.ExecutorType` field
|
||||
|
||||
7. **Update `executor.ExecuteWithProfile()`**
|
||||
- Resolve executor before launch
|
||||
- Pass executor type in request
|
||||
|
||||
8. **Update `lifecycleAdapter.LaunchAgent()`**
|
||||
- Map executor type to lifecycle request
|
||||
|
||||
### Phase 4: Cleanup
|
||||
|
||||
9. **Remove global `cfg.Agent.Runtime`**
|
||||
- Delete from config struct
|
||||
- Update flag parsing
|
||||
- Update documentation
|
||||
|
||||
10. **Update agentctl launcher logic**
|
||||
- **Always start** the agentctl launcher (remove all conditional checks)
|
||||
- agentctl is a core service, not dependent on runtime configuration
|
||||
|
||||
---
|
||||
|
||||
## 5. Edge Cases and Challenges
|
||||
|
||||
### 5.1 Session Resumption Across Runtimes
|
||||
|
||||
**Challenge**: A session started on Docker cannot be resumed on Standalone (different environment).
|
||||
|
||||
**Solution**: Store runtime name in `executors_running.runtime` and validate on resume:
|
||||
```go
|
||||
func (e *Executor) ResumeSession(...) {
|
||||
running, _ := e.repo.GetExecutorRunningBySessionID(session.ID)
|
||||
if running != nil && running.Runtime != "" {
|
||||
// Use the same runtime that was used originally
|
||||
req.ExecutorType = RuntimeToExecutorType(running.Runtime)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Runtime Availability Changes
|
||||
|
||||
**Challenge**: Task was created with Docker executor, but Docker is disabled on restart.
|
||||
|
||||
**Solution**:
|
||||
- Check runtime availability before launch
|
||||
- Return clear error: "Runtime 'docker' is not available. Enable it in configuration or change the task's executor."
|
||||
- Frontend can show unavailable executors as disabled
|
||||
|
||||
### 5.3 Fallback Behavior
|
||||
|
||||
**Challenge**: Executor not set on task/session.
|
||||
|
||||
**Solution**: Define precedence:
|
||||
1. Session's executor (if resuming)
|
||||
2. Workspace default executor
|
||||
3. System default ("Local PC" → Standalone)
|
||||
|
||||
### 5.4 agentctl Architecture
|
||||
|
||||
**Clarification**: agentctl runs in **both** modes:
|
||||
- **Docker**: agentctl runs inside each container (started by entrypoint)
|
||||
- **Standalone**: agentctl runs as a subprocess on the host, managing multiple agent instances
|
||||
|
||||
**Current**: agentctl launcher only started if `cfg.Agent.Runtime == "standalone"`
|
||||
|
||||
**New**: agentctl launcher should **always start** on the host. It serves as the control plane for standalone execution. The launcher startup should not depend on any runtime configuration - it's a core service that's always available.
|
||||
|
||||
### 5.5 Container Recovery
|
||||
|
||||
**Challenge**: Docker runtime recovers containers on startup; Standalone doesn't persist instances.
|
||||
|
||||
**Solution**:
|
||||
- Docker: `RecoverInstances()` finds containers by label
|
||||
- Standalone: Returns empty (sessions recreated on resume via `LaunchAgent()`)
|
||||
- RuntimeRegistry: Iterates all runtimes, merges results
|
||||
|
||||
---
|
||||
|
||||
## 6. Database Schema Changes
|
||||
|
||||
No schema changes required. Existing fields are sufficient:
|
||||
- `task_sessions.executor_id` - Links to selected executor
|
||||
- `executors.type` - Determines runtime (local_pc → standalone, local_docker → docker)
|
||||
- `executors_running.runtime` - Records which runtime was used
|
||||
|
||||
---
|
||||
|
||||
## 7. API Changes
|
||||
|
||||
### 7.1 Task Creation (No Change Needed)
|
||||
|
||||
The `executor_id` can already be passed via:
|
||||
- Workspace default (`workspace.default_executor_id`)
|
||||
- Potentially extended to task creation payload in future
|
||||
|
||||
### 7.2 New: Runtime Status Endpoint
|
||||
|
||||
```json
|
||||
GET /api/v1/runtimes/status
|
||||
{
|
||||
"runtimes": [
|
||||
{ "name": "standalone", "available": true, "healthy": true },
|
||||
{ "name": "docker", "available": true, "healthy": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This allows the frontend to:
|
||||
- Show which executors are available
|
||||
- Disable executor selection for unavailable runtimes
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing Strategy
|
||||
|
||||
1. **Unit Tests**
|
||||
- `RuntimeRegistry.GetRuntime()` for registered/unregistered runtimes
|
||||
- `ExecutorTypeToRuntime()` mapping
|
||||
- `Launch()` with different executor types
|
||||
|
||||
2. **Integration Tests**
|
||||
- Start task with `local_pc` executor → uses Standalone runtime
|
||||
- Start task with `local_docker` executor → uses Docker runtime
|
||||
- Resume session maintains original runtime
|
||||
- Backend restart with Docker disabled → sessions on Docker can't resume
|
||||
|
||||
3. **E2E Tests**
|
||||
- Create workspace with Docker as default executor
|
||||
- Start task → runs in Docker container
|
||||
- Change workspace default to Local PC
|
||||
- Start new task → runs via Standalone
|
||||
- Both tasks run concurrently with different runtimes
|
||||
|
||||
---
|
||||
|
||||
## 9. Code Examples
|
||||
|
||||
### 9.1 RuntimeRegistry Implementation
|
||||
|
||||
```go
|
||||
// internal/agent/lifecycle/runtime_registry.go
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/runtime"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
)
|
||||
|
||||
type RuntimeRegistry struct {
|
||||
runtimes map[runtime.Name]Runtime
|
||||
mu sync.RWMutex
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
func NewRuntimeRegistry(log *logger.Logger) *RuntimeRegistry {
|
||||
return &RuntimeRegistry{
|
||||
runtimes: make(map[runtime.Name]Runtime),
|
||||
logger: log,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RuntimeRegistry) Register(rt Runtime) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.runtimes[rt.Name()] = rt
|
||||
r.logger.Info("registered runtime", zap.String("name", string(rt.Name())))
|
||||
}
|
||||
|
||||
func (r *RuntimeRegistry) GetRuntime(name runtime.Name) (Runtime, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
rt, ok := r.runtimes[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("runtime %q not registered", name)
|
||||
}
|
||||
return rt, nil
|
||||
}
|
||||
|
||||
func (r *RuntimeRegistry) List() []runtime.Name {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
names := make([]runtime.Name, 0, len(r.runtimes))
|
||||
for name := range r.runtimes {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (r *RuntimeRegistry) HealthCheckAll(ctx context.Context) map[runtime.Name]error {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
results := make(map[runtime.Name]error)
|
||||
for name, rt := range r.runtimes {
|
||||
results[name] = rt.HealthCheck(ctx)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func (r *RuntimeRegistry) RecoverAll(ctx context.Context) ([]*RuntimeInstance, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
var allRecovered []*RuntimeInstance
|
||||
for _, rt := range r.runtimes {
|
||||
recovered, err := rt.RecoverInstances(ctx)
|
||||
if err != nil {
|
||||
r.logger.Warn("failed to recover instances from runtime",
|
||||
zap.String("runtime", string(rt.Name())),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
allRecovered = append(allRecovered, recovered...)
|
||||
}
|
||||
return allRecovered, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Updated provideLifecycleManager
|
||||
|
||||
```go
|
||||
// apps/backend/cmd/kandev/agents.go
|
||||
func provideLifecycleManager(
|
||||
ctx context.Context,
|
||||
cfg *config.Config,
|
||||
log *logger.Logger,
|
||||
eventBus bus.EventBus,
|
||||
dockerClient *docker.Client,
|
||||
agentSettingsRepo settingsstore.Repository,
|
||||
) (*lifecycle.Manager, *registry.Registry, error) {
|
||||
|
||||
// Create runtime registry
|
||||
runtimeRegistry := lifecycle.NewRuntimeRegistry(log)
|
||||
|
||||
// Standalone runtime is always available (agentctl is a core service)
|
||||
controlClient := agentctl.NewControlClient(
|
||||
cfg.Agent.Standalone.Host,
|
||||
cfg.Agent.Standalone.Port,
|
||||
log,
|
||||
)
|
||||
standaloneRuntime := lifecycle.NewStandaloneRuntime(
|
||||
controlClient,
|
||||
cfg.Agent.Standalone.Host,
|
||||
cfg.Agent.Standalone.Port,
|
||||
log,
|
||||
)
|
||||
runtimeRegistry.Register(standaloneRuntime)
|
||||
log.Info("Standalone runtime registered")
|
||||
|
||||
// Register Docker runtime if enabled and Docker client is available
|
||||
if cfg.Agent.Docker.Enabled && dockerClient != nil {
|
||||
dockerRuntime := lifecycle.NewDockerRuntime(dockerClient, log)
|
||||
runtimeRegistry.Register(dockerRuntime)
|
||||
log.Info("Docker runtime registered")
|
||||
}
|
||||
|
||||
// ... rest of initialization using runtimeRegistry
|
||||
// NOTE: Manager receives the registry, not individual runtimes
|
||||
// This keeps manager.go free of runtime-specific code
|
||||
lifecycleMgr := lifecycle.NewManager(
|
||||
agentRegistry, eventBus, runtimeRegistry, /* ... */
|
||||
)
|
||||
return lifecycleMgr, agentRegistry, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 Updated Launch Method
|
||||
|
||||
```go
|
||||
// internal/agent/lifecycle/manager.go
|
||||
func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecution, error) {
|
||||
// ... existing validation and setup ...
|
||||
|
||||
// Determine which runtime to use based on executor type
|
||||
runtimeName := runtime.ExecutorTypeToRuntime(req.ExecutorType)
|
||||
if runtimeName == runtime.NameUnknown {
|
||||
runtimeName = runtime.NameStandalone // Default fallback
|
||||
}
|
||||
|
||||
rt, err := m.runtimeRegistry.GetRuntime(runtimeName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("runtime %q not available for executor type %q: %w",
|
||||
runtimeName, req.ExecutorType, err)
|
||||
}
|
||||
|
||||
// Create runtime instance using the selected runtime
|
||||
runtimeInstance, err := rt.CreateInstance(ctx, runtimeReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create instance: %w", err)
|
||||
}
|
||||
|
||||
// Store runtime name for session resumption
|
||||
execution := runtimeInstance.ToAgentExecution(runtimeReq)
|
||||
execution.RuntimeName = runtimeName // NEW FIELD
|
||||
|
||||
// ... rest unchanged ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Migration Path
|
||||
|
||||
Since breaking changes are acceptable, the migration is straightforward:
|
||||
|
||||
1. **Deploy new backend** with both runtimes enabled
|
||||
2. **Running sessions continue** (Docker containers persist; Standalone sessions will need resume)
|
||||
3. **New tasks** use executor-specific runtime selection
|
||||
4. **Remove old config** after verifying stability
|
||||
|
||||
No database migration needed - schema already supports this model.
|
||||
@@ -1,409 +0,0 @@
|
||||
asyncapi: 2.6.0
|
||||
info:
|
||||
title: Kandev WebSocket API
|
||||
description: |
|
||||
Kandev is a Kanban board system with AI agent orchestration capabilities.
|
||||
|
||||
## Overview
|
||||
- **Task Service**: Manages tasks, boards, and columns
|
||||
- **Agent Manager**: Manages Docker container lifecycle for AI agents
|
||||
- **Orchestrator**: Coordinates agent execution
|
||||
|
||||
## WebSocket Protocol
|
||||
All communication happens over a single WebSocket connection.
|
||||
Messages use a JSON envelope format with request/response correlation.
|
||||
|
||||
## Agent Communication Protocol (ACP)
|
||||
Agents communicate via JSON messages on stdout. Real-time updates are
|
||||
pushed to clients via WebSocket notifications.
|
||||
|
||||
⚠️ **This documentation must be updated when API changes.**
|
||||
version: 2.0.0
|
||||
contact:
|
||||
name: Kandev Team
|
||||
license:
|
||||
name: MIT
|
||||
|
||||
servers:
|
||||
development:
|
||||
url: ws://localhost:8080/ws
|
||||
protocol: ws
|
||||
description: Local development WebSocket server
|
||||
|
||||
defaultContentType: application/json
|
||||
|
||||
channels:
|
||||
/:
|
||||
publish:
|
||||
summary: Send messages to the server
|
||||
operationId: sendMessage
|
||||
message:
|
||||
oneOf:
|
||||
- $ref: '#/components/messages/Request'
|
||||
subscribe:
|
||||
summary: Receive messages from the server
|
||||
operationId: receiveMessage
|
||||
message:
|
||||
oneOf:
|
||||
- $ref: '#/components/messages/Response'
|
||||
- $ref: '#/components/messages/Notification'
|
||||
- $ref: '#/components/messages/Error'
|
||||
|
||||
components:
|
||||
messages:
|
||||
Request:
|
||||
name: Request
|
||||
title: Client Request
|
||||
summary: Request message from client to server
|
||||
contentType: application/json
|
||||
payload:
|
||||
$ref: '#/components/schemas/RequestEnvelope'
|
||||
|
||||
Response:
|
||||
name: Response
|
||||
title: Server Response
|
||||
summary: Response message from server to client
|
||||
contentType: application/json
|
||||
payload:
|
||||
$ref: '#/components/schemas/ResponseEnvelope'
|
||||
|
||||
Notification:
|
||||
name: Notification
|
||||
title: Server Notification
|
||||
summary: Push notification from server to client
|
||||
contentType: application/json
|
||||
payload:
|
||||
$ref: '#/components/schemas/NotificationEnvelope'
|
||||
|
||||
Error:
|
||||
name: Error
|
||||
title: Error Response
|
||||
summary: Error response from server
|
||||
contentType: application/json
|
||||
payload:
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
|
||||
schemas:
|
||||
# Message Envelopes
|
||||
RequestEnvelope:
|
||||
type: object
|
||||
required: [id, type, action, payload]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Correlation ID for request/response matching
|
||||
type:
|
||||
type: string
|
||||
enum: [request]
|
||||
action:
|
||||
type: string
|
||||
description: Action to perform
|
||||
enum:
|
||||
- health.check
|
||||
- board.list
|
||||
- board.create
|
||||
- board.get
|
||||
- board.update
|
||||
- board.delete
|
||||
- column.list
|
||||
- column.create
|
||||
- column.get
|
||||
- task.list
|
||||
- task.create
|
||||
- task.get
|
||||
- task.update
|
||||
- task.delete
|
||||
- task.move
|
||||
- task.state
|
||||
- task.subscribe
|
||||
- task.unsubscribe
|
||||
- agent.list
|
||||
- agent.launch
|
||||
- agent.status
|
||||
- agent.logs
|
||||
- agent.stop
|
||||
- agent.prompt
|
||||
- agent.cancel
|
||||
- agent.session
|
||||
- agent.types
|
||||
- orchestrator.status
|
||||
- orchestrator.queue
|
||||
- orchestrator.trigger
|
||||
- orchestrator.start
|
||||
- orchestrator.stop
|
||||
- orchestrator.prompt
|
||||
- orchestrator.complete
|
||||
payload:
|
||||
type: object
|
||||
description: Action-specific payload
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
ResponseEnvelope:
|
||||
type: object
|
||||
required: [id, type, action, payload]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Correlation ID matching the request
|
||||
type:
|
||||
type: string
|
||||
enum: [response]
|
||||
action:
|
||||
type: string
|
||||
description: Action that was performed
|
||||
payload:
|
||||
type: object
|
||||
description: Response data
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
NotificationEnvelope:
|
||||
type: object
|
||||
required: [type, action, payload]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [notification]
|
||||
action:
|
||||
type: string
|
||||
description: Notification type
|
||||
enum:
|
||||
- acp.progress
|
||||
- acp.log
|
||||
- acp.result
|
||||
- acp.error
|
||||
- acp.status
|
||||
- acp.heartbeat
|
||||
- task.updated
|
||||
- agent.updated
|
||||
- agent.available.updated
|
||||
payload:
|
||||
type: object
|
||||
description: Notification data
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
ErrorEnvelope:
|
||||
type: object
|
||||
required: [id, type, action, payload]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Correlation ID matching the request
|
||||
type:
|
||||
type: string
|
||||
enum: [error]
|
||||
action:
|
||||
type: string
|
||||
description: Action that failed
|
||||
payload:
|
||||
$ref: '#/components/schemas/ErrorPayload'
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
ErrorPayload:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: Error code
|
||||
message:
|
||||
type: string
|
||||
description: Human readable message
|
||||
details:
|
||||
type: object
|
||||
description: Additional error details
|
||||
|
||||
# Domain Models (keep from openapi.yaml)
|
||||
Board:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [id, name, created_at, updated_at]
|
||||
|
||||
Column:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
board_id:
|
||||
type: string
|
||||
format: uuid
|
||||
name:
|
||||
type: string
|
||||
position:
|
||||
type: integer
|
||||
state:
|
||||
type: string
|
||||
enum: [TODO, IN_PROGRESS, DONE]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
required: [id, board_id, name, position, created_at]
|
||||
|
||||
Task:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
board_id:
|
||||
type: string
|
||||
format: uuid
|
||||
column_id:
|
||||
type: string
|
||||
format: uuid
|
||||
title:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
state:
|
||||
type: string
|
||||
enum: [TODO, IN_PROGRESS, BLOCKED, COMPLETED, FAILED, CANCELLED]
|
||||
priority:
|
||||
type: integer
|
||||
agent_type:
|
||||
type: string
|
||||
position:
|
||||
type: integer
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
required: [id, board_id, column_id, title, state, created_at, updated_at]
|
||||
|
||||
AgentInstance:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
task_id:
|
||||
type: string
|
||||
format: uuid
|
||||
agent_type:
|
||||
type: string
|
||||
container_id:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [starting, running, completed, failed, stopped, READY]
|
||||
progress:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
started_at:
|
||||
type: string
|
||||
format: date-time
|
||||
finished_at:
|
||||
type: string
|
||||
format: date-time
|
||||
exit_code:
|
||||
type: integer
|
||||
error_message:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
required: [id, task_id, agent_type, status, progress, started_at]
|
||||
|
||||
AgentType:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
image:
|
||||
type: string
|
||||
capabilities:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
required: [id, name, image, enabled]
|
||||
|
||||
# ACP Notification Payloads
|
||||
ACPProgress:
|
||||
type: object
|
||||
properties:
|
||||
agent_id:
|
||||
type: string
|
||||
task_id:
|
||||
type: string
|
||||
progress:
|
||||
type: integer
|
||||
message:
|
||||
type: string
|
||||
current_file:
|
||||
type: string
|
||||
files_processed:
|
||||
type: integer
|
||||
total_files:
|
||||
type: integer
|
||||
|
||||
ACPLog:
|
||||
type: object
|
||||
properties:
|
||||
agent_id:
|
||||
type: string
|
||||
task_id:
|
||||
type: string
|
||||
level:
|
||||
type: string
|
||||
enum: [debug, info, warn, error]
|
||||
message:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
|
||||
ACPResult:
|
||||
type: object
|
||||
properties:
|
||||
agent_id:
|
||||
type: string
|
||||
task_id:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
artifacts:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
@@ -1,478 +0,0 @@
|
||||
# Dynamic Model Switching Feature Analysis
|
||||
|
||||
> **Status**: Design Document
|
||||
> **Created**: 2026-01-21
|
||||
> **Scope**: Mid-session model switching within the Kandev application
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document analyzes the implementation of dynamic model switching, allowing users to change the AI model mid-session without starting a new session. The feature requires UI changes to expose model selection, backend logic to detect model changes and restart agents, and careful handling of session continuity.
|
||||
|
||||
## Current Architecture Overview
|
||||
|
||||
### Session Lifecycle (Current Flow)
|
||||
|
||||
```
|
||||
User clicks "Start Session"
|
||||
→ Frontend sends orchestrator.start with agentProfileId
|
||||
→ Orchestrator.StartTask → Executor.ExecuteWithProfile
|
||||
→ Lifecycle Manager resolves profile → gets model from AgentProfileInfo
|
||||
→ CommandBuilder.BuildCommand adds --model flag
|
||||
→ Agent process started with fixed model
|
||||
→ ACP session initialized
|
||||
→ User sends prompts via session/prompt
|
||||
```
|
||||
|
||||
**Key Insight**: Model is determined at session start via `AgentProfileInfo.Model` and baked into the agent command. There's no current mechanism to change the model mid-session.
|
||||
|
||||
### Relevant Components
|
||||
|
||||
| Component | Path | Role |
|
||||
|-----------|------|------|
|
||||
| TaskChatPanel | `apps/web/components/task/task-chat-panel.tsx` | Chat UI, displays current model |
|
||||
| SessionsDropdown | `apps/web/components/task/sessions-dropdown.tsx` | Session switcher |
|
||||
| Orchestrator Service | `apps/backend/internal/orchestrator/service.go` | PromptTask, StartTask, ResumeTaskSession |
|
||||
| Executor | `apps/backend/internal/orchestrator/executor/executor.go` | Agent launch, prompt forwarding |
|
||||
| Lifecycle Manager | `apps/backend/internal/agent/lifecycle/manager.go` | Agent process management |
|
||||
| CommandBuilder | `apps/backend/internal/agent/lifecycle/command.go` | Builds CLI args including --model |
|
||||
| ProfileResolver | `apps/backend/internal/agent/lifecycle/profile_resolver.go` | Resolves profile → model |
|
||||
| SessionManager | `apps/backend/internal/agent/lifecycle/session.go` | ACP session init/prompt |
|
||||
| TaskSession Model | `apps/backend/internal/task/models/models.go` | Session persistence |
|
||||
| Message Service | `apps/backend/internal/task/service/service.go` | CreateMessage |
|
||||
|
||||
---
|
||||
|
||||
## Requirements Breakdown
|
||||
|
||||
### UI Requirements
|
||||
|
||||
1. **Model Selector in Session View**: Replace existing agent selector with model dropdown
|
||||
2. **Deferred Application**: Selection does NOT take effect immediately
|
||||
3. **Apply on Prompt**: Selected model applied when user sends next prompt
|
||||
4. **Persistent State**: All subsequent prompts use new model until changed again
|
||||
|
||||
### Backend Requirements
|
||||
|
||||
1. **Model Change Detection**: Compare current model vs requested model on prompt
|
||||
2. **System Message Insertion**: "Model switching to [model name]" message
|
||||
3. **Agent Restart**: Stop current agent, restart with new `--model` flag
|
||||
4. **Session Resumption**: Attempt to resume ACP session after restart (if supported)
|
||||
|
||||
---
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Phase 1: Frontend Model Selector
|
||||
|
||||
#### 1.1 Store State for Pending Model
|
||||
|
||||
Add to the Zustand store a per-session "pending model" that tracks what model the user wants to switch to.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/lib/state/store.ts` - Add `pendingModelBySessionId: Record<string, string>`
|
||||
- `apps/web/lib/types/http.ts` - Add model-related types if needed
|
||||
|
||||
#### 1.2 Model Selector Component
|
||||
|
||||
Create a model dropdown in the chat panel that:
|
||||
- Shows available models for the current agent type
|
||||
- Shows current model (from session's agent profile)
|
||||
- Allows selection of a different model
|
||||
- Stores selection in `pendingModelBySessionId[sessionId]`
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/components/task/task-chat-panel.tsx` - Add ModelSelector component
|
||||
- May create new component: `apps/web/components/task/model-selector.tsx`
|
||||
|
||||
**Data source for available models:**
|
||||
- From `AvailableAgent.model_config.available_models` in the agent discovery store
|
||||
- Need to resolve which agent the session is using via `session.agent_profile_id`
|
||||
|
||||
#### 1.3 Prompt Submission with Model Override
|
||||
|
||||
When user submits a prompt, check if `pendingModelBySessionId[sessionId]` differs from current model:
|
||||
- If different: include `model` field in WebSocket prompt payload
|
||||
- Clear pending model after submission (backend will handle the switch)
|
||||
|
||||
**WebSocket payload change:**
|
||||
|
||||
```typescript
|
||||
// Current
|
||||
{ action: "orchestrator.prompt", payload: { taskId, sessionId, content } }
|
||||
|
||||
// Proposed
|
||||
{ action: "orchestrator.prompt", payload: { taskId, sessionId, content, model?: string } }
|
||||
```
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/web/lib/ws/actions.ts` - Add `model` to prompt action payload
|
||||
- `apps/web/components/task/task-chat-panel.tsx` - Include model in prompt submission
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Backend Model Change Detection
|
||||
|
||||
#### 2.1 Extend Prompt Handling
|
||||
|
||||
Modify the orchestrator's `PromptTask` to accept an optional model parameter.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/backend/internal/orchestrator/service.go` - `PromptTask` method
|
||||
- `apps/backend/internal/ws/handlers/orchestrator.go` - Parse model from WS payload
|
||||
|
||||
**Logic flow:**
|
||||
```
|
||||
PromptTask(taskId, sessionId, content, model)
|
||||
→ If model != currentExecution.Model:
|
||||
→ Insert system message "Switching to {model}..."
|
||||
→ Stop current agent (graceful shutdown)
|
||||
→ Update session's effective model
|
||||
→ Restart agent with new --model flag
|
||||
→ Resume or reinitialize ACP session
|
||||
→ Forward prompt to agent
|
||||
```
|
||||
|
||||
#### 2.2 Executor Model Switch
|
||||
|
||||
The executor needs a new method to handle model switching:
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/backend/internal/orchestrator/executor/executor.go`
|
||||
- Add `SwitchModel(sessionId, newModel)` method
|
||||
- Coordinates stop → rebuild → restart → resume
|
||||
|
||||
**Implementation sketch:**
|
||||
```go
|
||||
func (e *Executor) SwitchModel(ctx context.Context, sessionId, newModel string) error {
|
||||
exec, ok := e.running[sessionId]
|
||||
if !ok {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
// 1. Stop current agent
|
||||
e.lifecycleManager.StopAgent(ctx, exec.AgentID)
|
||||
|
||||
// 2. Update profile with new model
|
||||
newProfileInfo := exec.ProfileInfo
|
||||
newProfileInfo.Model = newModel
|
||||
|
||||
// 3. Restart agent with new command
|
||||
newAgentID, err := e.lifecycleManager.Launch(ctx, LaunchOpts{
|
||||
Model: newModel,
|
||||
// ... other opts from exec
|
||||
})
|
||||
|
||||
// 4. Attempt session resume
|
||||
e.lifecycleManager.InitializeSession(ctx, newAgentID, InitOpts{
|
||||
IsResume: true,
|
||||
ResumeToken: exec.ResumeToken,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.3 System Message for Model Switch
|
||||
|
||||
Insert a visible message in the chat indicating the model change.
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/backend/internal/task/service/service.go` - Use `CreateMessage` with type `status`
|
||||
|
||||
**Message format:**
|
||||
```json
|
||||
{
|
||||
"type": "status",
|
||||
"content": "Switching model to Claude Opus 4.5...",
|
||||
"metadata": {
|
||||
"status_type": "model_switch",
|
||||
"from_model": "sonnet4.5",
|
||||
"to_model": "opus4.5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Session Resumption After Restart
|
||||
|
||||
#### 3.1 Agent-Specific Resumption
|
||||
|
||||
Different agents handle resumption differently (from `agents.json`):
|
||||
|
||||
| Agent | `resume_via_acp` | `resume_flag` | `can_recover` |
|
||||
|-------|------------------|---------------|---------------|
|
||||
| auggie-agent | false | `--resume` | false |
|
||||
| codex-agent | true | (none) | true |
|
||||
|
||||
**For Codex-style agents (resume_via_acp: true):**
|
||||
- Use ACP `session/load` with the stored `ResumeToken`
|
||||
- Session history is preserved by the agent
|
||||
|
||||
**For Auggie-style agents (resume_via_acp: false, can_recover: false):**
|
||||
- Cannot truly resume; must reinitialize with fresh ACP session
|
||||
- **Decision**: Accept context loss, display system message: "Model switched. A new session was started."
|
||||
|
||||
#### 3.2 Handling Resume Failures
|
||||
|
||||
**Files to modify:**
|
||||
- `apps/backend/internal/agent/lifecycle/session.go` - `InitializeSession` error handling
|
||||
|
||||
**Fallback strategy:**
|
||||
1. Attempt resume with stored token
|
||||
2. If resume fails → start new session
|
||||
3. Emit warning message to user: "Session could not be fully resumed after model switch"
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
### WebSocket Integration
|
||||
|
||||
| Layer | Change Required |
|
||||
|-------|-----------------|
|
||||
| Client (`apps/web/lib/ws/client.ts`) | No changes |
|
||||
| Actions (`apps/web/lib/ws/actions.ts`) | Add `model` field to prompt action |
|
||||
| Handler (`apps/backend/internal/ws/handlers/orchestrator.go`) | Parse `model` from payload |
|
||||
|
||||
**Wire format example:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"type": "request",
|
||||
"action": "orchestrator.prompt",
|
||||
"payload": {
|
||||
"task_id": "task-uuid",
|
||||
"session_id": "session-uuid",
|
||||
"content": "User message",
|
||||
"model": "opus4.5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Store Integration
|
||||
|
||||
**New store slice additions:**
|
||||
|
||||
```typescript
|
||||
// In store.ts
|
||||
pendingModel: {
|
||||
bySessionId: Record<string, string>, // sessionId → pending model id
|
||||
setPendingModel: (sessionId: string, modelId: string) => void,
|
||||
clearPendingModel: (sessionId: string) => void,
|
||||
getPendingModel: (sessionId: string) => string | null,
|
||||
}
|
||||
```
|
||||
|
||||
**Store → Component data flow:**
|
||||
```
|
||||
ModelSelector reads: settingsAgents (for available models), session.agent_profile_id
|
||||
ModelSelector writes: pendingModel.bySessionId[sessionId]
|
||||
TaskChatPanel reads: pendingModel.bySessionId[sessionId] on prompt submit
|
||||
```
|
||||
|
||||
### Database Integration
|
||||
|
||||
**Decision: No schema change (runtime-only)**
|
||||
- Model switch is runtime-only; session's `agent_profile_id` remains unchanged
|
||||
- On backend restart, session resumes with original profile model
|
||||
- No migration required
|
||||
|
||||
---
|
||||
|
||||
## Challenges and Edge Cases
|
||||
|
||||
### 1. Agent Busy During Model Switch Request
|
||||
|
||||
**Scenario**: User selects new model while agent is processing a previous prompt.
|
||||
|
||||
**Solution options:**
|
||||
- **Queue the switch**: Store pending model, apply after current response completes
|
||||
- **Block selection**: Disable model selector while agent is busy
|
||||
- **Immediate abort**: Cancel current response, switch model (destructive)
|
||||
|
||||
**Recommendation**: Queue the switch; apply on next user-initiated prompt.
|
||||
|
||||
### 2. Rapid Model Changes
|
||||
|
||||
**Scenario**: User changes model multiple times before sending a prompt.
|
||||
|
||||
**Solution**: Only the final selection matters; each change overwrites `pendingModelBySessionId[sessionId]`.
|
||||
|
||||
### 3. Resume Failure After Switch
|
||||
|
||||
**Scenario**: Agent restarts with new model but ACP session/load fails.
|
||||
|
||||
**Handling:**
|
||||
1. Log error for debugging
|
||||
2. Fall back to new session initialization
|
||||
3. Show user message: "Model switched successfully but session context could not be fully restored"
|
||||
|
||||
### 4. Different Agent Types
|
||||
|
||||
**Scenario**: User tries to switch to a model not supported by current agent type.
|
||||
|
||||
**Prevention**: Model selector only shows models from `agent.model_config.available_models` for the session's agent type.
|
||||
|
||||
### 5. Session State Persistence
|
||||
|
||||
**Scenario**: Backend restarts mid-session; what model should resume use?
|
||||
|
||||
**Behavior**: Uses `agent_profile_snapshot.model` from session creation (original profile model). Runtime model switches are not persisted.
|
||||
|
||||
### 6. Model Availability Changes
|
||||
|
||||
**Scenario**: A model is removed from agent configuration while sessions are active.
|
||||
|
||||
**Handling**: Validate model against current `available_models` before sending to backend. If invalid, fall back to session's original model.
|
||||
|
||||
---
|
||||
|
||||
## Alternative Approaches
|
||||
|
||||
### Alternative A: Runtime Model Switching via ACP
|
||||
|
||||
**Concept**: Send a hypothetical `session/switch_model` ACP call without restarting agent.
|
||||
|
||||
**Pros:**
|
||||
- No process restart; faster switch
|
||||
- Seamless context preservation
|
||||
|
||||
**Cons:**
|
||||
- Not supported by current agent implementations
|
||||
- Would require agent CLI changes upstream
|
||||
- Different agents would need different implementations
|
||||
|
||||
**Verdict**: Not viable without significant upstream work.
|
||||
|
||||
### Alternative B: Model as Separate Session Concept
|
||||
|
||||
**Concept**: Treat each model switch as a "sub-session" within the same UI session.
|
||||
|
||||
**Pros:**
|
||||
- Clean separation of concerns
|
||||
- Easy to track model history
|
||||
|
||||
**Cons:**
|
||||
- Complex UI implications
|
||||
- Breaks current session model significantly
|
||||
- Overhead for simple model changes
|
||||
|
||||
**Verdict**: Over-engineering for the requirement.
|
||||
|
||||
### Alternative C: Pre-select Model Before Session Start Only
|
||||
|
||||
**Concept**: Allow model selection only at session creation, not mid-session.
|
||||
|
||||
**Pros:**
|
||||
- Simplest implementation
|
||||
- No restart complexity
|
||||
|
||||
**Cons:**
|
||||
- Doesn't meet the stated requirement
|
||||
- Poor UX for iterative work
|
||||
|
||||
**Verdict**: Does not satisfy user requirements.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Frontend Model Selector (Est. 2-3 days)
|
||||
|
||||
**Goal**: Model dropdown appears in UI; selection stored locally.
|
||||
|
||||
**Files to modify:**
|
||||
1. `apps/web/lib/state/store.ts` - Add pendingModel slice
|
||||
2. `apps/web/components/task/task-chat-panel.tsx` - Add ModelSelector
|
||||
3. Create `apps/web/components/task/model-selector.tsx` - New component
|
||||
|
||||
**Deliverable**: User can select a model, but it has no effect yet.
|
||||
|
||||
### Phase 2: Backend Prompt Extension (Est. 2-3 days)
|
||||
|
||||
**Goal**: Prompts can include model; backend detects change.
|
||||
|
||||
**Files to modify:**
|
||||
1. `apps/web/lib/ws/actions.ts` - Add model to prompt payload
|
||||
2. `apps/backend/internal/ws/handlers/orchestrator.go` - Parse model
|
||||
3. `apps/backend/internal/orchestrator/service.go` - PromptTask accepts model
|
||||
|
||||
**Deliverable**: Model parameter flows from UI to orchestrator.
|
||||
|
||||
### Phase 3: Agent Restart Logic (Est. 3-4 days)
|
||||
|
||||
**Goal**: Backend restarts agent with new model when detected.
|
||||
|
||||
**Files to modify:**
|
||||
1. `apps/backend/internal/orchestrator/executor/executor.go` - SwitchModel method
|
||||
2. `apps/backend/internal/agent/lifecycle/manager.go` - Ensure clean restart
|
||||
3. `apps/backend/internal/task/service/service.go` - Insert status message
|
||||
|
||||
**Deliverable**: Model switch triggers agent restart; status message appears.
|
||||
|
||||
### Phase 4: Session Resumption (Est. 2-3 days)
|
||||
|
||||
**Goal**: Resume ACP session after agent restart (where supported).
|
||||
|
||||
**Files to modify:**
|
||||
1. `apps/backend/internal/agent/lifecycle/session.go` - Resume logic
|
||||
2. `apps/backend/internal/orchestrator/executor/executor.go` - Handle resume failures
|
||||
|
||||
**Deliverable**: Codex-style agents resume context; Auggie-style gracefully degrade.
|
||||
|
||||
### Phase 5: Testing & Edge Cases (Est. 2-3 days)
|
||||
|
||||
**Goal**: Verify all scenarios work correctly.
|
||||
|
||||
**Test scenarios:**
|
||||
- Model switch while agent idle
|
||||
- Model switch while agent busy (queued)
|
||||
- Rapid model changes
|
||||
- Resume failure fallback
|
||||
- Invalid model selection
|
||||
- Backend restart mid-session
|
||||
|
||||
---
|
||||
|
||||
## Summary of Files to Modify
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `apps/web/lib/state/store.ts` | Add pendingModel slice |
|
||||
| `apps/web/components/task/task-chat-panel.tsx` | Integrate ModelSelector, pass model on prompt |
|
||||
| `apps/web/components/task/model-selector.tsx` | New component |
|
||||
| `apps/web/lib/ws/actions.ts` | Add model field to prompt action |
|
||||
| `apps/backend/internal/ws/handlers/orchestrator.go` | Parse model from WS payload |
|
||||
| `apps/backend/internal/orchestrator/service.go` | Extend PromptTask signature |
|
||||
| `apps/backend/internal/orchestrator/executor/executor.go` | Add SwitchModel method |
|
||||
| `apps/backend/internal/agent/lifecycle/manager.go` | Ensure clean restart support |
|
||||
| `apps/backend/internal/agent/lifecycle/session.go` | Handle resume after restart |
|
||||
| `apps/backend/internal/task/service/service.go` | Create model switch status message |
|
||||
|
||||
---
|
||||
|
||||
## Estimated Total Effort
|
||||
|
||||
| Phase | Estimate |
|
||||
|-------|----------|
|
||||
| Phase 1: Frontend Model Selector | 2-3 days |
|
||||
| Phase 2: Backend Prompt Extension | 2-3 days |
|
||||
| Phase 3: Agent Restart Logic | 3-4 days |
|
||||
| Phase 4: Session Resumption | 2-3 days |
|
||||
| Phase 5: Testing & Edge Cases | 2-3 days |
|
||||
| **Total** | **11-16 days** |
|
||||
|
||||
---
|
||||
|
||||
## Resolved Decisions
|
||||
|
||||
| Decision | Resolution |
|
||||
|----------|------------|
|
||||
| Database schema | Runtime-only, no schema changes |
|
||||
| Non-recoverable agents (Auggie) | Accept context loss, show "new session started" message |
|
||||
| Agent busy during switch | Not an issue - model applied on next user prompt |
|
||||
| UI placement | Replace existing agent selector with model selector |
|
||||
| Confirmation dialog | None required - seamless switch |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,124 +0,0 @@
|
||||
# Relationship Events LLD
|
||||
|
||||
This document defines backend-driven state propagation and how WS notifications mirror those changes to the UI. Clients do **not** emit these events.
|
||||
|
||||
## Core Rules
|
||||
|
||||
1) **Session state changes drive task state changes**.
|
||||
2) **Agentctl lifecycle changes drive session state changes**, which then drive task state changes.
|
||||
3) **Task state changes may move columns**, but only between **In Progress** and **Review** for now.
|
||||
|
||||
All state propagation happens **server-side** on internal events. WebSocket events are outbound mirrors for UI synchronization.
|
||||
|
||||
---
|
||||
|
||||
## 1) Session State -> Task State
|
||||
|
||||
**Trigger (backend only)**
|
||||
- `events.TaskSessionStateChanged` (internal event bus)
|
||||
|
||||
**Flow**
|
||||
1. Resolve `task_id` from `session_id`.
|
||||
2. Map `TaskSessionState -> TaskState` (table below).
|
||||
3. If task state changes, call `taskSvc.UpdateTaskState(taskID, newState)`.
|
||||
4. Emit `events.TaskStateChanged` and WS `task.state_changed`.
|
||||
5. Emit WS `session.state_changed` for clients.
|
||||
|
||||
**Mapping Table**
|
||||
|
||||
| TaskSessionState | TaskState | Board Column |
|
||||
|-------------------|---------------|-----------------------|
|
||||
| CREATED | SCHEDULING | In Progress (if any) |
|
||||
| STARTING | SCHEDULING | In Progress (if any) |
|
||||
| RUNNING | IN_PROGRESS | In Progress |
|
||||
| WAITING_FOR_INPUT | REVIEW | Review |
|
||||
| COMPLETED | REVIEW | Review |
|
||||
| ERROR | ERROR | (no auto-move) |
|
||||
| CANCELLED | CANCELLED | (no auto-move) |
|
||||
|
||||
Notes:
|
||||
- `WAITING_FOR_INPUT -> REVIEW` and `COMPLETED -> REVIEW` are the default paths. If a board has no Review column, keep task in its current column.
|
||||
- If the mapped task state equals current task state, do nothing.
|
||||
|
||||
---
|
||||
|
||||
## 2) Agentctl State -> Session State -> Task State
|
||||
|
||||
**Triggers (backend only)**
|
||||
- `events.AgentctlStarting`
|
||||
- `events.AgentctlReady`
|
||||
- `events.AgentctlError`
|
||||
|
||||
**Agentctl -> Session Mapping**
|
||||
|
||||
| Agentctl Event | Session State |
|
||||
|--------------------------|---------------|
|
||||
| AgentctlStarting | STARTING |
|
||||
| AgentctlReady | RUNNING |
|
||||
| AgentctlError | FAILED |
|
||||
|
||||
**Flow**
|
||||
1. On Agentctl event, update session state in DB.
|
||||
2. Emit `events.TaskSessionStateChanged` (which triggers session->task mapping).
|
||||
3. Emit WS `session.agentctl_*` and `session.state_changed`.
|
||||
|
||||
---
|
||||
|
||||
## 3) Task State -> Column Move
|
||||
|
||||
**Trigger (backend only)**
|
||||
- `events.TaskStateChanged`
|
||||
|
||||
**Policy (for now)**
|
||||
- Only move tasks **between In Progress and Review**.
|
||||
- Do not auto-move for other states.
|
||||
|
||||
**Flow**
|
||||
1. When task state changes:
|
||||
- If new state is `IN_PROGRESS`, move to column mapped to In Progress (if exists).
|
||||
- If new state is `REVIEW`, move to column mapped to Review (if exists).
|
||||
2. If column lookup fails or column already matches, do nothing.
|
||||
3. Emit `events.TaskUpdated` and WS `task.updated` with new `column_id`.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Notifications (Outbound Only)
|
||||
|
||||
- `session.state_changed`
|
||||
- `session.agentctl_starting`
|
||||
- `session.agentctl_ready`
|
||||
- `session.agentctl_error`
|
||||
- `task.state_changed`
|
||||
- `task.updated`
|
||||
|
||||
Clients should treat these as **read-only state mirrors**.
|
||||
|
||||
---
|
||||
|
||||
## E2E Flow (Client-Initiated)
|
||||
|
||||
| Edge Trigger (Client) | Backend Action | Internal Event(s) | WS Notification(s) |
|
||||
|----------------------------------------------|---------------------------------------------------|--------------------------------------|-------------------------------------------------|
|
||||
| `POST /api/v1/tasks` (start_agent: true) | Create task + create session + start agentctl | task.created, task_session.created | task.created, session.agentctl_starting |
|
||||
| `POST /api/v1/tasks` (start_agent: false) | Create task + create session (idle) | task.created, task_session.created | task.created |
|
||||
| `message.add` (session message) | Persist message + prompt agent via ACP | message.added | session.message.added |
|
||||
| `task.state` (manual update) | Update task state | task.state_changed | task.state_changed, task.updated (column move) |
|
||||
| `task.session.resume` | Resume session + start agentctl | task_session.state_changed | session.agentctl_starting, session.state_changed |
|
||||
|
||||
Notes:
|
||||
- Column move only occurs for `IN_PROGRESS` / `REVIEW` transitions.
|
||||
|
||||
---
|
||||
|
||||
## E2E Flow (Agent / agentctl-Initiated)
|
||||
|
||||
| Edge Trigger (Agent/agentctl) | Backend Action | Internal Event(s) | WS Notification(s) |
|
||||
|----------------------------------------------|---------------------------------------------------|--------------------------------------|-------------------------------------------------|
|
||||
| Agentctl starting | Update session state → STARTING | task_session.state_changed | session.agentctl_starting, session.state_changed |
|
||||
| Agentctl ready | Update session state → RUNNING | task_session.state_changed | session.agentctl_ready, session.state_changed |
|
||||
| Agentctl error | Update session state → ERROR | task_session.state_changed | session.agentctl_error, session.state_changed |
|
||||
| ACP content/progress | Create/append message chunks | message.added / message.updated | session.message.added / session.message.updated |
|
||||
| ACP input_required | Create message (requests_input=true) | message.added | session.message.added + session.waiting_for_input |
|
||||
| Git status stream update | Update session git status | git.status.updated | session.git.status |
|
||||
| Workspace file change stream | Update session file activity | workspace.file.changed | session.workspace.file.changes |
|
||||
| Shell output stream | Append shell output | shell.output | session.shell.output |
|
||||
@@ -1,67 +0,0 @@
|
||||
# Releases
|
||||
|
||||
This repo ships two things:
|
||||
- **Kandev runtime bundles** (backend + web) published to GitHub Releases.
|
||||
- **NPX launcher** (`kandev` on npm) that downloads and runs those bundles.
|
||||
|
||||
## Packaging the web bundle
|
||||
|
||||
The release workflow calls:
|
||||
|
||||
```bash
|
||||
scripts/release/package-web.sh
|
||||
```
|
||||
|
||||
It packages the Next.js standalone output into `dist/web/`, which is then bundled into each platform ZIP.
|
||||
|
||||
## Release flow (runtime bundles)
|
||||
|
||||
1) Pick a version tag:
|
||||
|
||||
```bash
|
||||
git tag v0.1.0
|
||||
git push origin v0.1.0
|
||||
```
|
||||
|
||||
Or use the helper script (creates and pushes the tag):
|
||||
|
||||
```bash
|
||||
scripts/release/publish-release.sh 0.1.0
|
||||
```
|
||||
|
||||
2) GitHub Actions will:
|
||||
- Build the web app in standalone mode.
|
||||
- Build `kandev` + `agentctl` binaries for each platform.
|
||||
- Package and upload `kandev-<platform>.zip` and `.sha256` to the release.
|
||||
|
||||
## Publish the NPX launcher
|
||||
|
||||
The launcher package lives at `apps/cli` and is published as `kandev`.
|
||||
|
||||
```bash
|
||||
cd apps/cli
|
||||
pnpm build
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
Or use the helper script:
|
||||
|
||||
```bash
|
||||
scripts/release/publish-launcher.sh 0.1.0
|
||||
```
|
||||
|
||||
## Releasing a new Kandev version
|
||||
|
||||
1) Update versions:
|
||||
- Bump `apps/cli/package.json` (npm package version).
|
||||
- The `publish-release.sh` script will error if this doesn't match the tag.
|
||||
2) Tag and push the release (`vX.Y.Z`).
|
||||
3) Publish the launcher to npm (same version or higher).
|
||||
4) Verify:
|
||||
- `npx kandev@latest` downloads the new release bundle.
|
||||
- `npx kandev` prompts to update if an older launcher is installed.
|
||||
|
||||
## Notes
|
||||
|
||||
- Release assets are downloaded to `~/.kandev/bin/<version>/<platform>/`.
|
||||
- Local data is stored under `~/.kandev/data/`.
|
||||
@@ -1,166 +0,0 @@
|
||||
# Task Session Turns: Table + Flow
|
||||
|
||||
This document describes how to add a turn table that groups all messages from a single agent turn, how messages link to a turn, and how turn updates propagate through the backend → event bus → WebSocket → frontend store.
|
||||
|
||||
## Goals
|
||||
- Add a `task_session_turns` table with at least `id`, `started_at`, `completed_at`.
|
||||
- Ensure every `task_session_message` points to the turn it belongs to.
|
||||
- Turn creation/completion follows existing app flow and broadcasts to subscribed clients.
|
||||
|
||||
## Definitions
|
||||
- **Turn**: A single prompt/response cycle for a task session, starting when a prompt is sent and ending when the agent completes/cancels/errors.
|
||||
- **Message**: A persisted item in `task_session_messages` (user prompt, agent text, tool call, status, error).
|
||||
|
||||
## Schema Changes (SQLite)
|
||||
Add a new table and link messages to it.
|
||||
|
||||
### New Table: `task_session_turns`
|
||||
Suggested columns (minimum + useful metadata):
|
||||
- `id` TEXT PRIMARY KEY (UUID).
|
||||
- `task_session_id` TEXT NOT NULL (FK → `task_sessions.id`).
|
||||
- `task_id` TEXT NOT NULL (denormalized for query fanout).
|
||||
- `started_at` DATETIME NOT NULL.
|
||||
- `completed_at` DATETIME (nullable).
|
||||
- `created_at` DATETIME NOT NULL.
|
||||
- `updated_at` DATETIME NOT NULL.
|
||||
- `metadata` TEXT DEFAULT '{}' (optional, for provider-specific data like Codex `turn_id`).
|
||||
|
||||
Indexes:
|
||||
- `idx_turns_session_id` on `(task_session_id)`.
|
||||
- `idx_turns_session_started` on `(task_session_id, started_at)`.
|
||||
- `idx_turns_task_id` on `(task_id)`.
|
||||
- Optional: partial index for active turn lookup `(task_session_id) WHERE completed_at IS NULL`.
|
||||
|
||||
### Link Messages to Turns
|
||||
Add a `turn_id` column to `task_session_messages`:
|
||||
- `turn_id` TEXT (FK → `task_session_turns.id`).
|
||||
- Index on `(turn_id)`.
|
||||
- Keep nullable for legacy messages; new writes should set it.
|
||||
|
||||
Files to update:
|
||||
- `apps/backend/internal/task/repository/sqlite.go` (schema + migration via `ensureColumn`).
|
||||
- `apps/backend/internal/task/models/models.go` (add `Turn` model, add `TurnID` to `Message`).
|
||||
- `apps/backend/pkg/api/v1/task.go` and `apps/web/lib/types/http.ts` (API types include `turn_id`).
|
||||
|
||||
## Backend Flow (Turn Lifecycle)
|
||||
Turns should align with the prompt lifecycle already handled in the orchestrator and message services.
|
||||
|
||||
### Create Turn
|
||||
Create a turn when a prompt begins:
|
||||
- **Entry point**: `apps/backend/internal/orchestrator/service.go` in `PromptTask` before calling `executor.Prompt`.
|
||||
- Create `task_session_turns` row with `started_at = now`.
|
||||
- Persist the new `turn_id` in memory for the rest of the prompt lifecycle.
|
||||
- Suggested: update `task_sessions.metadata.active_turn_id` and/or hold in the `Executor`/`AgentExecution` context.
|
||||
|
||||
### Attach Messages to Turn
|
||||
Ensure all messages created while a prompt is in-flight use the active `turn_id`.
|
||||
|
||||
Where messages are created today:
|
||||
- User prompt: `apps/backend/internal/task/handlers/message_handlers.go` → `MessageController.CreateMessage`.
|
||||
- Agent output + tool events: `apps/backend/internal/orchestrator/service.go` (`saveAgentTextIfPresent`, `handleToolCallEvent`, `handleToolUpdateEvent`, error/status creation).
|
||||
|
||||
Expected behavior:
|
||||
- The user prompt message should reference the newly created `turn_id`.
|
||||
- Subsequent agent/tool/status messages should use the same `turn_id`.
|
||||
- If the session is resumed and no active turn exists, create a new turn before message creation.
|
||||
|
||||
Implementation sketch:
|
||||
- Add `TurnID` to `CreateMessageRequest` (service/controller layers).
|
||||
- Teach `messageCreator.CreateAgentMessage/CreateToolCallMessage/CreateSessionMessage` to accept a `turn_id` or resolve the current active turn by `session_id`.
|
||||
- When the orchestrator receives agent stream events, it should pass `turn_id` to message creation calls.
|
||||
|
||||
### Complete Turn
|
||||
Close the turn when the agent finishes or the turn is cancelled:
|
||||
- **Completion**: `apps/backend/internal/orchestrator/service.go` in `handleAgentStreamEvent` when `eventType == "complete"`.
|
||||
- **Cancellation**: `apps/backend/internal/orchestrator/service.go` in `CancelAgent`.
|
||||
- **Errors**: Treat `eventType == "error"` as terminal if it ends the prompt; set `completed_at`.
|
||||
|
||||
Action:
|
||||
- Update `task_session_turns.completed_at = now`, `updated_at = now`.
|
||||
- Clear `task_sessions.metadata.active_turn_id` (if used).
|
||||
|
||||
## Event Bus and WebSocket Broadcast
|
||||
Turn updates should follow the same path as existing message/session events.
|
||||
|
||||
### Event Types
|
||||
Add new event types in `apps/backend/internal/events/types.go`:
|
||||
- `TurnStarted = "turn.started"` (or `task_session.turn.started`).
|
||||
- `TurnCompleted = "turn.completed"`.
|
||||
- Optional `TurnUpdated = "turn.updated"` for partial updates.
|
||||
|
||||
### Publish Events
|
||||
Publish turn events when you create/complete a turn:
|
||||
- Location: task service or orchestrator service (same layer that writes to DB).
|
||||
- Payload fields (minimum):
|
||||
- `turn_id`
|
||||
- `session_id`
|
||||
- `task_id`
|
||||
- `started_at`
|
||||
- `completed_at`
|
||||
|
||||
### Broadcast to WebSocket Subscribers
|
||||
Wire up in `apps/backend/internal/gateway/websocket/task_notifications.go`:
|
||||
- Subscribe to the new event types.
|
||||
- Broadcast to the session channel (same as `session.message.added`).
|
||||
|
||||
Add action constants in `apps/backend/pkg/websocket/actions.go`:
|
||||
- `ActionSessionTurnStarted = "session.turn.started"`
|
||||
- `ActionSessionTurnCompleted = "session.turn.completed"`
|
||||
- Optional `ActionSessionTurnUpdated = "session.turn.updated"`
|
||||
|
||||
## Frontend Flow
|
||||
Frontend store and handlers should mirror the current message flow.
|
||||
|
||||
### WebSocket Handlers
|
||||
Add a new WS handler file (or extend existing):
|
||||
- `apps/web/lib/ws/handlers/turns.ts` to handle:
|
||||
- `session.turn.started`
|
||||
- `session.turn.completed`
|
||||
- Store updates should be keyed by `session_id`.
|
||||
|
||||
### Store Shape
|
||||
Add a `turns` slice in `apps/web/lib/state/store.ts`:
|
||||
- `turns.bySession[sessionId] = Turn[]`
|
||||
- `turns.activeBySession[sessionId] = Turn | null` (optional for UI)
|
||||
|
||||
### Message Rendering
|
||||
Messages already render from `messages.bySession[sessionId]`. With `turn_id`:
|
||||
- Allow grouping in UI by turn.
|
||||
- Allow filtering by active turn.
|
||||
- Show turn duration in the UI (e.g., "running for 42s") by comparing `started_at` to now for active turns.
|
||||
|
||||
### SSR Data
|
||||
If needed for initial render:
|
||||
- Add an HTTP endpoint to list turns for a session.
|
||||
- SSR fetch in `apps/web/app/task/[id]/page-client.tsx` or equivalent (see `docs/session-page-data-flow.md`).
|
||||
|
||||
## Migration / Backfill
|
||||
Existing messages will not have `turn_id`:
|
||||
- Keep `turn_id` nullable.
|
||||
- Optional backfill: create one turn per session using min/max message timestamps and assign messages with NULL `turn_id` to that turn.
|
||||
|
||||
## Edge Cases
|
||||
- **Multiple prompts queued**: Ensure only one active turn per session. If prompts can overlap, add a `state` column or queue ID.
|
||||
- **Agent error before messages**: Start turn on prompt, complete on error even if no agent messages.
|
||||
- **Permission requests**: If the agent pauses for permission, the turn remains open; completion happens after prompt completion or cancellation.
|
||||
- **Concurrency**: Multiple sessions can run in parallel without conflict as long as turns and broadcasts stay scoped by `session_id` (and `active_turn_id` is per session).
|
||||
|
||||
## Touchpoints Summary
|
||||
Backend:
|
||||
- `apps/backend/internal/task/models/models.go` (Turn model, Message.TurnID)
|
||||
- `apps/backend/internal/task/repository/sqlite.go` (schema + queries)
|
||||
- `apps/backend/internal/task/repository/interface.go` (turn methods)
|
||||
- `apps/backend/internal/task/service/service.go` (turn create/complete, message association)
|
||||
- `apps/backend/internal/orchestrator/service.go` (prompt start/complete)
|
||||
- `apps/backend/internal/events/types.go` (turn events)
|
||||
- `apps/backend/internal/gateway/websocket/task_notifications.go` (broadcast)
|
||||
- `apps/backend/pkg/websocket/actions.go` (actions)
|
||||
|
||||
Frontend:
|
||||
- `apps/web/lib/ws/handlers/turns.ts` (WS handlers)
|
||||
- `apps/web/lib/state/store.ts` (turns slice)
|
||||
- `apps/web/lib/types/http.ts` (Turn + Message.turn_id)
|
||||
- `apps/web/components/task/chat/*` (optional turn grouping UI)
|
||||
|
||||
Docs:
|
||||
- `docs/WEBSOCKET_API.md` (document new turn actions and payloads).
|
||||
@@ -1,773 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# End-to-End Test for Kandev - Comment System with Types
|
||||
#
|
||||
# This script tests the unified comment system that handles all task-related
|
||||
# communication including agent tool calls, content, progress, errors, and status.
|
||||
# It demonstrates the new comment type system:
|
||||
# - message: Regular user/agent messages
|
||||
# - content: Agent response content
|
||||
# - tool_call: When agent uses a tool (file operations, commands)
|
||||
# - progress: Progress updates
|
||||
# - error: Error messages
|
||||
# - status: Status changes (started, completed, failed)
|
||||
#
|
||||
# The test creates a Python calculator task that forces the agent to use
|
||||
# tools (file creation, editing) so we can see different comment types in action.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Docker running with kandev/augment-agent:latest image built
|
||||
# - Augment session credentials in ~/.augment/session.json
|
||||
# - Port 8080 available
|
||||
# - websocat installed (cargo install websocat or brew install websocat)
|
||||
#
|
||||
# Usage: ./scripts/e2e-test.sh [--build]
|
||||
# --build Build the Docker image before running tests
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
BACKEND_DIR="$PROJECT_ROOT/apps/backend"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
SERVER_PORT=8080
|
||||
WS_URL="ws://localhost:${SERVER_PORT}/ws"
|
||||
AGENTCTL_PORT=9999
|
||||
WAIT_FOR_AGENT_SECONDS=45
|
||||
SERVER_PID=""
|
||||
BUILD_IMAGE=false
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--build)
|
||||
BUILD_IMAGE=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
echo -e "\n${YELLOW}Cleaning up...${NC}"
|
||||
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
# Stop any running agent containers
|
||||
docker ps --filter "name=kandev-agent" -q | xargs -r docker stop 2>/dev/null || true
|
||||
docker ps --filter "name=kandev-agent" -aq | xargs -r docker rm 2>/dev/null || true
|
||||
echo -e "${GREEN}Cleanup complete${NC}"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
# Helper functions
|
||||
log_step() {
|
||||
echo -e "\n${BLUE}=== $1 ===${NC}"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}✓ $1${NC}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}✗ $1${NC}"
|
||||
}
|
||||
|
||||
log_info() {
|
||||
echo -e "${YELLOW}→ $1${NC}"
|
||||
}
|
||||
|
||||
# Color codes for different comment types
|
||||
MAGENTA='\033[0;35m'
|
||||
WHITE='\033[1;37m'
|
||||
|
||||
log_comment() {
|
||||
local author_type=$1
|
||||
local content=$2
|
||||
local requests_input=$3
|
||||
local comment_type=$4
|
||||
local has_metadata=$5
|
||||
|
||||
# Build type badge
|
||||
local type_badge=""
|
||||
case "$comment_type" in
|
||||
"message") type_badge="${WHITE}[msg]${NC}" ;;
|
||||
"content") type_badge="${CYAN}[content]${NC}" ;;
|
||||
"tool_call") type_badge="${MAGENTA}[tool]${NC}" ;;
|
||||
"progress") type_badge="${YELLOW}[progress]${NC}" ;;
|
||||
"error") type_badge="${RED}[error]${NC}" ;;
|
||||
"status") type_badge="${BLUE}[status]${NC}" ;;
|
||||
*) type_badge="${WHITE}[${comment_type:-msg}]${NC}" ;;
|
||||
esac
|
||||
|
||||
# Build metadata indicator
|
||||
local meta_indicator=""
|
||||
if [ "$has_metadata" == "true" ]; then
|
||||
meta_indicator=" ${YELLOW}📎${NC}"
|
||||
fi
|
||||
|
||||
# Build input indicator
|
||||
local input_indicator=""
|
||||
if [ "$requests_input" == "true" ]; then
|
||||
input_indicator=" ${GREEN}⏳${NC}"
|
||||
fi
|
||||
|
||||
if [ "$author_type" == "user" ]; then
|
||||
echo -e " ${CYAN}👤 USER${NC} $type_badge$input_indicator$meta_indicator $content"
|
||||
else
|
||||
echo -e " ${GREEN}🤖 AGENT${NC} $type_badge$input_indicator$meta_indicator $content"
|
||||
fi
|
||||
}
|
||||
|
||||
# WebSocket helper function
|
||||
# Sends a request via WebSocket and returns the response
|
||||
ws_request() {
|
||||
local action=$1
|
||||
local payload=$2
|
||||
local id=$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "req-$$-$RANDOM")
|
||||
|
||||
# Compact the payload to single line (remove newlines and extra spaces)
|
||||
local compact_payload=$(echo "$payload" | jq -c '.')
|
||||
local message="{\"id\":\"${id}\",\"type\":\"request\",\"action\":\"${action}\",\"payload\":${compact_payload}}"
|
||||
|
||||
# Send request and get response (with timeout)
|
||||
echo "$message" | timeout 10 websocat -n1 "${WS_URL}" 2>/dev/null
|
||||
}
|
||||
|
||||
wait_for_server() {
|
||||
log_info "Waiting for server to be ready..."
|
||||
for i in {1..20}; do
|
||||
HEALTH=$(ws_request "health.check" '{}' 2>/dev/null)
|
||||
if echo "$HEALTH" | jq -e '.payload.status == "ok"' > /dev/null 2>&1; then
|
||||
log_success "Server is ready"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
log_error "Server failed to start"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Display all comments for a task with full details
|
||||
display_comments() {
|
||||
local task_id=$1
|
||||
local label=$2
|
||||
|
||||
COMMENTS_RESPONSE=$(ws_request "comment.list" "{\"task_id\": \"${task_id}\"}" 2>/dev/null) || COMMENTS_RESPONSE='{}'
|
||||
local first_response=$(echo "$COMMENTS_RESPONSE" | head -1)
|
||||
COMMENTS_COUNT=$(echo "$first_response" | jq 'if .payload | type == "array" then .payload | length else 0 end' 2>/dev/null || echo "0")
|
||||
# Ensure COMMENTS_COUNT is a valid number
|
||||
if ! [[ "$COMMENTS_COUNT" =~ ^[0-9]+$ ]]; then
|
||||
COMMENTS_COUNT=0
|
||||
fi
|
||||
|
||||
echo -e "\n${CYAN}╔══════════════════════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${CYAN}║ $label ($COMMENTS_COUNT comments)${NC}"
|
||||
echo -e "${CYAN}╠══════════════════════════════════════════════════════════════════════════════╣${NC}"
|
||||
|
||||
if [ "$COMMENTS_COUNT" -gt 0 ]; then
|
||||
# Count by type (use first_response to ensure we have valid JSON)
|
||||
local msg_count=$(echo "$first_response" | jq '[.payload[] | select(.type == "message" or .type == null or .type == "")] | length' 2>/dev/null || echo "0")
|
||||
local content_count=$(echo "$first_response" | jq '[.payload[] | select(.type == "content")] | length' 2>/dev/null || echo "0")
|
||||
local tool_count=$(echo "$first_response" | jq '[.payload[] | select(.type == "tool_call")] | length' 2>/dev/null || echo "0")
|
||||
local progress_count=$(echo "$first_response" | jq '[.payload[] | select(.type == "progress")] | length' 2>/dev/null || echo "0")
|
||||
local error_count=$(echo "$first_response" | jq '[.payload[] | select(.type == "error")] | length' 2>/dev/null || echo "0")
|
||||
local status_count=$(echo "$first_response" | jq '[.payload[] | select(.type == "status")] | length' 2>/dev/null || echo "0")
|
||||
|
||||
echo -e "${CYAN}║${NC} Types: ${WHITE}msg:$msg_count${NC} ${CYAN}content:$content_count${NC} ${MAGENTA}tool:$tool_count${NC} ${YELLOW}progress:$progress_count${NC} ${RED}error:$error_count${NC} ${BLUE}status:$status_count${NC}"
|
||||
echo -e "${CYAN}╟──────────────────────────────────────────────────────────────────────────────╢${NC}"
|
||||
|
||||
# Process each comment - use head -1 to ensure we only get the first response line
|
||||
local first_response=$(echo "$COMMENTS_RESPONSE" | head -1)
|
||||
local comment_count=$(echo "$first_response" | jq '.payload | length' 2>/dev/null || echo "0")
|
||||
# Ensure comment_count is a valid number
|
||||
if ! [[ "$comment_count" =~ ^[0-9]+$ ]]; then
|
||||
comment_count=0
|
||||
fi
|
||||
if [ "$comment_count" -gt 0 ]; then
|
||||
for i in $(seq 0 $((comment_count - 1))); do
|
||||
local author_type=$(echo "$first_response" | jq -r ".payload[$i].author_type // \"user\"" 2>/dev/null) || author_type="user"
|
||||
local requests_input=$(echo "$first_response" | jq -r ".payload[$i].requests_input // false" 2>/dev/null) || requests_input="false"
|
||||
local comment_type=$(echo "$first_response" | jq -r ".payload[$i].type // \"message\"" 2>/dev/null) || comment_type="message"
|
||||
local has_metadata=$(echo "$first_response" | jq -r "if .payload[$i].metadata then \"true\" else \"false\" end" 2>/dev/null) || has_metadata="false"
|
||||
local content=$(echo "$first_response" | jq -r ".payload[$i].content // \"\"" 2>/dev/null) || content=""
|
||||
|
||||
# Remove newlines from content
|
||||
content=$(echo "$content" | tr '\n' ' ' | tr '\r' ' ')
|
||||
# Truncate long content for display
|
||||
if [ ${#content} -gt 100 ]; then
|
||||
content="${content:0:100}..."
|
||||
fi
|
||||
# Show placeholder if content is empty
|
||||
if [ -z "$content" ]; then
|
||||
content="(empty)"
|
||||
fi
|
||||
log_comment "$author_type" "$content" "$requests_input" "$comment_type" "$has_metadata"
|
||||
done
|
||||
fi
|
||||
else
|
||||
echo -e " ${YELLOW}(no comments yet)${NC}"
|
||||
fi
|
||||
echo -e "${CYAN}╚══════════════════════════════════════════════════════════════════════════════╝${NC}"
|
||||
}
|
||||
|
||||
# Display detailed metadata for tool_call comments
|
||||
display_tool_calls() {
|
||||
local task_id=$1
|
||||
|
||||
COMMENTS_RESPONSE=$(ws_request "comment.list" "{\"task_id\": \"${task_id}\"}" 2>/dev/null) || COMMENTS_RESPONSE='{}'
|
||||
local first_response=$(echo "$COMMENTS_RESPONSE" | head -1)
|
||||
TOOL_CALLS=$(echo "$first_response" | jq '[.payload[] | select(.type == "tool_call")]' 2>/dev/null) || TOOL_CALLS='[]'
|
||||
TOOL_COUNT=$(echo "$TOOL_CALLS" | jq 'length' 2>/dev/null || echo "0")
|
||||
# Ensure TOOL_COUNT is a valid number
|
||||
if ! [[ "$TOOL_COUNT" =~ ^[0-9]+$ ]]; then
|
||||
TOOL_COUNT=0
|
||||
fi
|
||||
|
||||
if [ "$TOOL_COUNT" -gt 0 ]; then
|
||||
echo -e "\n${MAGENTA}╔══════════════════════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${MAGENTA}║ Tool Calls Detail ($TOOL_COUNT tools used)${NC}"
|
||||
echo -e "${MAGENTA}╠══════════════════════════════════════════════════════════════════════════════╣${NC}"
|
||||
|
||||
echo "$TOOL_CALLS" | jq -r '.[] | "TOOL: \(.metadata.tool_name // .metadata.name // "unknown") | \(.content[:80] // "no content")"' 2>/dev/null | while read -r line; do
|
||||
echo -e "${MAGENTA}║${NC} $line"
|
||||
done || true
|
||||
|
||||
echo -e "${MAGENTA}╚══════════════════════════════════════════════════════════════════════════════╝${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Check prerequisites
|
||||
log_step "Checking Prerequisites"
|
||||
|
||||
if ! command -v websocat &> /dev/null; then
|
||||
log_error "websocat is not installed"
|
||||
log_info "Install with: cargo install websocat"
|
||||
log_info "Or on macOS: brew install websocat"
|
||||
exit 1
|
||||
fi
|
||||
log_success "websocat is available"
|
||||
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "Docker is not installed"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Docker is available"
|
||||
|
||||
if ! docker info &> /dev/null; then
|
||||
log_error "Docker daemon is not running"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Docker daemon is running"
|
||||
|
||||
if [ ! -f "$HOME/.augment/session.json" ]; then
|
||||
log_error "Augment session not found at ~/.augment/session.json"
|
||||
log_info "Please log in with: auggie login"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Augment session credentials found"
|
||||
|
||||
# Build Docker image if requested or if it doesn't exist
|
||||
if [ "$BUILD_IMAGE" = true ]; then
|
||||
log_step "Building Docker Image"
|
||||
log_info "Building kandev/augment-agent:latest with agentctl..."
|
||||
cd "$BACKEND_DIR"
|
||||
docker build -t kandev/augment-agent:latest -f dockerfiles/augment-agent/Dockerfile .
|
||||
log_success "Docker image built"
|
||||
elif ! docker image inspect kandev/augment-agent:latest &> /dev/null; then
|
||||
log_error "Docker image kandev/augment-agent:latest not found"
|
||||
log_info "Build it with: ./scripts/e2e-test.sh --build"
|
||||
log_info "Or manually: cd apps/backend && docker build -t kandev/augment-agent:latest -f dockerfiles/augment-agent/Dockerfile ."
|
||||
exit 1
|
||||
else
|
||||
log_success "Agent Docker image exists"
|
||||
fi
|
||||
|
||||
# Kill any existing server on port 8080
|
||||
if lsof -i :${SERVER_PORT} &> /dev/null; then
|
||||
log_info "Killing existing process on port ${SERVER_PORT}"
|
||||
pkill -9 -f "kandev" 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# Build and start the server
|
||||
log_step "Building and Starting Server"
|
||||
|
||||
cd "$BACKEND_DIR"
|
||||
#rm -f kandev.db kandev.db-shm kandev.db-wal
|
||||
|
||||
log_info "Building kandev..."
|
||||
go build -o kandev ./cmd/kandev
|
||||
|
||||
log_info "Starting server..."
|
||||
./kandev > /tmp/kandev-e2e.log 2>&1 &
|
||||
SERVER_PID=$!
|
||||
log_info "Server PID: $SERVER_PID"
|
||||
|
||||
wait_for_server
|
||||
|
||||
# Run the E2E test
|
||||
log_step "Step 1: Create Workspace"
|
||||
WORKSPACE_RESPONSE=$(ws_request "workspace.create" '{"name": "E2E Test Workspace", "description": "Automated end-to-end test workspace"}')
|
||||
WORKSPACE_ID=$(echo "$WORKSPACE_RESPONSE" | head -1 | jq -r '.payload.id // empty' 2>/dev/null)
|
||||
if [ -z "$WORKSPACE_ID" ]; then
|
||||
log_error "Failed to create workspace"
|
||||
echo "$WORKSPACE_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Created workspace: $WORKSPACE_ID"
|
||||
|
||||
log_step "Step 2: Create Board"
|
||||
BOARD_RESPONSE=$(ws_request "board.create" "{\"workspace_id\": \"${WORKSPACE_ID}\", \"name\": \"E2E Test Board\", \"description\": \"Automated end-to-end test\"}")
|
||||
BOARD_ID=$(echo "$BOARD_RESPONSE" | head -1 | jq -r '.payload.id // empty' 2>/dev/null)
|
||||
if [ -z "$BOARD_ID" ]; then
|
||||
log_error "Failed to create board"
|
||||
echo "$BOARD_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Created board: $BOARD_ID"
|
||||
|
||||
log_step "Step 3: Create Column"
|
||||
COLUMN_RESPONSE=$(ws_request "column.create" "{\"board_id\": \"${BOARD_ID}\", \"name\": \"To Do\", \"position\": 0}")
|
||||
COLUMN_ID=$(echo "$COLUMN_RESPONSE" | head -1 | jq -r '.payload.id // empty' 2>/dev/null)
|
||||
if [ -z "$COLUMN_ID" ]; then
|
||||
log_error "Failed to create column"
|
||||
echo "$COLUMN_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Created column: $COLUMN_ID"
|
||||
|
||||
log_step "Step 4: Create Task for Comment System Test"
|
||||
# Create a temp directory for the test workspace
|
||||
TEST_WORKSPACE_DIR=$(mktemp -d)
|
||||
log_info "Test workspace: $TEST_WORKSPACE_DIR"
|
||||
|
||||
# Initialize a git repo in the temp directory so the agent can work with it
|
||||
cd "$TEST_WORKSPACE_DIR"
|
||||
git init -q
|
||||
echo "# E2E Test Workspace" > README.md
|
||||
git add README.md
|
||||
git commit -q -m "Initial commit"
|
||||
cd "$SCRIPT_DIR/.."
|
||||
|
||||
# Create task that will force agent to use tools (file operations, code writing)
|
||||
TASK_PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"title": "E2E Test: Create a Python Calculator Module",
|
||||
"description": "Create a simple Python calculator module with the following requirements:\n\n1. Create a file called 'calculator.py' with functions: add, subtract, multiply, divide\n2. Each function should take two numbers and return the result\n3. The divide function should handle division by zero\n4. Create a 'test_calculator.py' file with basic tests for each function\n\nPlease start by creating the calculator.py file first, then wait for my feedback before creating the tests.",
|
||||
"workspace_id": "${WORKSPACE_ID}",
|
||||
"board_id": "${BOARD_ID}",
|
||||
"column_id": "${COLUMN_ID}",
|
||||
"repository_url": "${TEST_WORKSPACE_DIR}",
|
||||
"agent_type": "augment-agent"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
TASK_RESPONSE=$(ws_request "task.create" "$TASK_PAYLOAD")
|
||||
# Extract only the first valid JSON line and parse the id
|
||||
TASK_ID=$(echo "$TASK_RESPONSE" | head -1 | jq -r '.payload.id // empty' 2>/dev/null)
|
||||
if [ -z "$TASK_ID" ]; then
|
||||
log_error "Failed to create task"
|
||||
echo "$TASK_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Created task: $TASK_ID"
|
||||
|
||||
# Test adding a user comment before agent starts
|
||||
log_step "Step 5: Test comment.add Before Agent Start"
|
||||
PRE_COMMENT_PAYLOAD=$(cat <<EOF
|
||||
{
|
||||
"task_id": "${TASK_ID}",
|
||||
"content": "Note: Use simple, clear code. No external dependencies needed.",
|
||||
"author_id": "e2e-test-user"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
PRE_COMMENT_RESPONSE=$(ws_request "comment.add" "$PRE_COMMENT_PAYLOAD")
|
||||
PRE_COMMENT_ID=$(echo "$PRE_COMMENT_RESPONSE" | jq -r '.payload.id // empty')
|
||||
if [ -n "$PRE_COMMENT_ID" ]; then
|
||||
log_success "Created pre-start comment: $PRE_COMMENT_ID"
|
||||
else
|
||||
log_info "Note: comment.add returned: $(echo "$PRE_COMMENT_RESPONSE" | jq -c '.payload')"
|
||||
fi
|
||||
|
||||
# Verify comment.list works
|
||||
INITIAL_COMMENTS=$(ws_request "comment.list" "{\"task_id\": \"${TASK_ID}\"}")
|
||||
INITIAL_COUNT=$(echo "$INITIAL_COMMENTS" | jq 'if .payload | type == "array" then .payload | length else 0 end' 2>/dev/null || echo "0")
|
||||
log_info "Comments before agent start: $INITIAL_COUNT"
|
||||
|
||||
log_step "Step 6: Start Task via Orchestrator"
|
||||
START_RESPONSE=$(ws_request "orchestrator.start" "{\"task_id\": \"${TASK_ID}\"}")
|
||||
AGENT_ID=$(echo "$START_RESPONSE" | head -1 | jq -r '.payload.agent_instance_id // empty' 2>/dev/null)
|
||||
if [ -z "$AGENT_ID" ]; then
|
||||
log_error "Failed to start task"
|
||||
echo "$START_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
log_success "Task started, agent ID: $AGENT_ID"
|
||||
|
||||
# Brief wait for container startup
|
||||
sleep 2
|
||||
CONTAINER_ID=$(docker ps --filter "name=kandev-agent" --format "{{.ID}}" | head -1)
|
||||
if [ -n "$CONTAINER_ID" ]; then
|
||||
log_success "Agent container running: $CONTAINER_ID"
|
||||
fi
|
||||
|
||||
log_step "Step 7: Wait for Initial Agent Response"
|
||||
log_info "Waiting up to ${WAIT_FOR_AGENT_SECONDS}s for agent..."
|
||||
AGENT_READY=false
|
||||
for i in $(seq 1 $WAIT_FOR_AGENT_SECONDS); do
|
||||
AGENT_STATUS_RESPONSE=$(ws_request "agent.status" "{\"agent_id\": \"${AGENT_ID}\"}" 2>/dev/null || echo '{}')
|
||||
AGENT_STATUS=$(echo "$AGENT_STATUS_RESPONSE" | jq -r '.payload.status // "UNKNOWN"')
|
||||
|
||||
if [ "$AGENT_STATUS" == "READY" ]; then
|
||||
log_success "Agent READY after $i seconds"
|
||||
AGENT_READY=true
|
||||
break
|
||||
elif [ "$AGENT_STATUS" == "FAILED" ]; then
|
||||
log_error "Agent failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ $((i % 10)) -eq 0 ]; then
|
||||
log_info "Waiting... ($i seconds, status: $AGENT_STATUS)"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$AGENT_READY" != "true" ]; then
|
||||
log_error "Agent did not become READY (last: $AGENT_STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Display comments after first agent response (should include tool_call for creating calculator.py)
|
||||
display_comments "$TASK_ID" "After Initial Agent Response (should have tool calls)"
|
||||
display_tool_calls "$TASK_ID"
|
||||
|
||||
# Check if calculator.py was created
|
||||
if [ -f "$TEST_WORKSPACE_DIR/calculator.py" ]; then
|
||||
log_success "calculator.py was created!"
|
||||
log_info "File contents:"
|
||||
echo -e "${CYAN}---${NC}"
|
||||
head -20 "$TEST_WORKSPACE_DIR/calculator.py"
|
||||
echo -e "${CYAN}---${NC}"
|
||||
else
|
||||
log_info "calculator.py not created yet (agent may still be working)"
|
||||
fi
|
||||
|
||||
log_step "Step 8: Multi-Turn Conversation - Turn 1 (Request changes)"
|
||||
# User Comment Turn 1: Request a modification
|
||||
log_info "Sending user comment via comment.add..."
|
||||
TURN1_PAYLOAD="{\"task_id\": \"${TASK_ID}\", \"content\": \"Great work on the calculator! Please also add a power function that calculates x raised to the power of y. Then show me the updated file.\", \"author_id\": \"e2e-test-user\"}"
|
||||
TURN1_RESPONSE=$(ws_request "comment.add" "$TURN1_PAYLOAD" 2>/dev/null) || TURN1_RESPONSE='{}'
|
||||
TURN1_ID=$(echo "$TURN1_RESPONSE" | head -1 | jq -r '.payload.id // empty' 2>/dev/null) || TURN1_ID=""
|
||||
if [ -n "$TURN1_ID" ]; then
|
||||
log_success "User comment added: $TURN1_ID"
|
||||
else
|
||||
log_info "comment.add response: $(echo "$TURN1_RESPONSE" | head -1 | jq -c '.payload' 2>/dev/null || echo 'parse error')"
|
||||
fi
|
||||
|
||||
# Wait for agent to process and respond
|
||||
log_info "Waiting for agent to respond to comment..."
|
||||
for i in $(seq 1 $WAIT_FOR_AGENT_SECONDS); do
|
||||
AGENT_STATUS_RESPONSE=$(ws_request "agent.status" "{\"agent_id\": \"${AGENT_ID}\"}" 2>/dev/null) || AGENT_STATUS_RESPONSE='{}'
|
||||
AGENT_STATUS=$(echo "$AGENT_STATUS_RESPONSE" | head -1 | jq -r '.payload.status // "UNKNOWN"' 2>/dev/null) || AGENT_STATUS="UNKNOWN"
|
||||
if [ "$AGENT_STATUS" == "READY" ]; then
|
||||
log_success "Agent READY after Turn 1 ($i seconds)"
|
||||
break
|
||||
fi
|
||||
if [ $((i % 10)) -eq 0 ]; then
|
||||
log_info "Waiting... ($i seconds, status: $AGENT_STATUS)"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
display_comments "$TASK_ID" "After Turn 1 (power function request)"
|
||||
display_tool_calls "$TASK_ID"
|
||||
|
||||
log_step "Step 9: Multi-Turn Conversation - Turn 2 (Request tests)"
|
||||
# User Comment Turn 2: Request test file
|
||||
TURN2_PAYLOAD="{\"task_id\": \"${TASK_ID}\", \"content\": \"Now please create the test_calculator.py file with tests for all functions including the power function. Use Python unittest module.\", \"author_id\": \"e2e-test-user\"}"
|
||||
TURN2_RESPONSE=$(ws_request "comment.add" "$TURN2_PAYLOAD" 2>/dev/null) || TURN2_RESPONSE='{}'
|
||||
TURN2_ID=$(echo "$TURN2_RESPONSE" | head -1 | jq -r '.payload.id // empty' 2>/dev/null) || TURN2_ID=""
|
||||
if [ -n "$TURN2_ID" ]; then
|
||||
log_success "User comment (Turn 2) added: $TURN2_ID"
|
||||
fi
|
||||
|
||||
# Wait for agent response
|
||||
log_info "Waiting for agent to respond..."
|
||||
for i in $(seq 1 $WAIT_FOR_AGENT_SECONDS); do
|
||||
AGENT_STATUS_RESPONSE=$(ws_request "agent.status" "{\"agent_id\": \"${AGENT_ID}\"}" 2>/dev/null) || AGENT_STATUS_RESPONSE='{}'
|
||||
AGENT_STATUS=$(echo "$AGENT_STATUS_RESPONSE" | head -1 | jq -r '.payload.status // "UNKNOWN"' 2>/dev/null) || AGENT_STATUS="UNKNOWN"
|
||||
if [ "$AGENT_STATUS" == "READY" ]; then
|
||||
log_success "Agent READY after Turn 2 ($i seconds)"
|
||||
break
|
||||
fi
|
||||
if [ $((i % 10)) -eq 0 ]; then
|
||||
log_info "Waiting... ($i seconds, status: $AGENT_STATUS)"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
display_comments "$TASK_ID" "After Turn 2 (test file request)"
|
||||
display_tool_calls "$TASK_ID"
|
||||
|
||||
# Check if test file was created
|
||||
if [ -f "$TEST_WORKSPACE_DIR/test_calculator.py" ]; then
|
||||
log_success "test_calculator.py was created!"
|
||||
else
|
||||
log_info "test_calculator.py not created yet"
|
||||
fi
|
||||
|
||||
log_step "Step 10: Multi-Turn Conversation - Turn 3 (Run tests)"
|
||||
# User Comment Turn 3: Run the tests
|
||||
TURN3_PAYLOAD="{\"task_id\": \"${TASK_ID}\", \"content\": \"Please run the tests using python -m pytest test_calculator.py -v or python -m unittest test_calculator -v and show me the results.\", \"author_id\": \"e2e-test-user\"}"
|
||||
TURN3_RESPONSE=$(ws_request "comment.add" "$TURN3_PAYLOAD" 2>/dev/null) || TURN3_RESPONSE='{}'
|
||||
TURN3_ID=$(echo "$TURN3_RESPONSE" | head -1 | jq -r '.payload.id // empty' 2>/dev/null) || TURN3_ID=""
|
||||
if [ -n "$TURN3_ID" ]; then
|
||||
log_success "User comment (Turn 3) added: $TURN3_ID"
|
||||
fi
|
||||
|
||||
# Wait for final agent response
|
||||
log_info "Waiting for agent to complete..."
|
||||
for i in $(seq 1 $WAIT_FOR_AGENT_SECONDS); do
|
||||
AGENT_STATUS_RESPONSE=$(ws_request "agent.status" "{\"agent_id\": \"${AGENT_ID}\"}" 2>/dev/null) || AGENT_STATUS_RESPONSE='{}'
|
||||
AGENT_STATUS=$(echo "$AGENT_STATUS_RESPONSE" | head -1 | jq -r '.payload.status // "UNKNOWN"' 2>/dev/null) || AGENT_STATUS="UNKNOWN"
|
||||
if [ "$AGENT_STATUS" == "READY" ]; then
|
||||
log_success "Agent READY after Turn 3 ($i seconds)"
|
||||
break
|
||||
fi
|
||||
if [ $((i % 10)) -eq 0 ]; then
|
||||
log_info "Waiting... ($i seconds, status: $AGENT_STATUS)"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
display_comments "$TASK_ID" "After Turn 3 (test execution)"
|
||||
display_tool_calls "$TASK_ID"
|
||||
|
||||
log_step "Step 11: Verify Comment Types and Metadata"
|
||||
# Get all comments and verify metadata
|
||||
log_info "Querying comments for task: $TASK_ID"
|
||||
FINAL_COMMENTS=$(ws_request "comment.list" "{\"task_id\": \"${TASK_ID}\"}" 2>/dev/null) || FINAL_COMMENTS='{}'
|
||||
FINAL_COMMENTS_LINE=$(echo "$FINAL_COMMENTS" | head -1)
|
||||
log_info "DEBUG: Raw response length: ${#FINAL_COMMENTS_LINE} chars"
|
||||
log_info "DEBUG: Response preview: ${FINAL_COMMENTS_LINE:0:200}"
|
||||
COMMENT_COUNT=$(echo "$FINAL_COMMENTS_LINE" | jq 'if .payload | type == "array" then .payload | length else 0 end' 2>/dev/null || echo "0")
|
||||
log_info "Total comments in conversation: $COMMENT_COUNT"
|
||||
|
||||
# Count by author type
|
||||
USER_COMMENTS=$(echo "$FINAL_COMMENTS_LINE" | jq '[.payload[] | select(.author_type == "user")] | length' 2>/dev/null || echo "0")
|
||||
AGENT_COMMENTS=$(echo "$FINAL_COMMENTS_LINE" | jq '[.payload[] | select(.author_type == "agent")] | length' 2>/dev/null || echo "0")
|
||||
log_info "User comments: $USER_COMMENTS, Agent comments: $AGENT_COMMENTS"
|
||||
|
||||
# Count by comment type
|
||||
MSG_COUNT=$(echo "$FINAL_COMMENTS_LINE" | jq '[.payload[] | select(.type == "message" or .type == null or .type == "")] | length' 2>/dev/null || echo "0")
|
||||
CONTENT_COUNT=$(echo "$FINAL_COMMENTS_LINE" | jq '[.payload[] | select(.type == "content")] | length' 2>/dev/null || echo "0")
|
||||
TOOL_CALL_COUNT=$(echo "$FINAL_COMMENTS_LINE" | jq '[.payload[] | select(.type == "tool_call")] | length' 2>/dev/null || echo "0")
|
||||
PROGRESS_COUNT=$(echo "$FINAL_COMMENTS_LINE" | jq '[.payload[] | select(.type == "progress")] | length' 2>/dev/null || echo "0")
|
||||
ERROR_COUNT=$(echo "$FINAL_COMMENTS_LINE" | jq '[.payload[] | select(.type == "error")] | length' 2>/dev/null || echo "0")
|
||||
STATUS_COUNT=$(echo "$FINAL_COMMENTS_LINE" | jq '[.payload[] | select(.type == "status")] | length' 2>/dev/null || echo "0")
|
||||
|
||||
log_info "Comment types breakdown:"
|
||||
log_info " - message: $MSG_COUNT"
|
||||
log_info " - content: $CONTENT_COUNT"
|
||||
log_info " - tool_call: $TOOL_CALL_COUNT"
|
||||
log_info " - progress: $PROGRESS_COUNT"
|
||||
log_info " - error: $ERROR_COUNT"
|
||||
log_info " - status: $STATUS_COUNT"
|
||||
|
||||
# Count comments with metadata
|
||||
METADATA_COUNT=$(echo "$FINAL_COMMENTS_LINE" | jq '[.payload[] | select(.metadata != null)] | length' 2>/dev/null || echo "0")
|
||||
if [[ "$METADATA_COUNT" =~ ^[0-9]+$ ]] && [ "$METADATA_COUNT" -gt 0 ]; then
|
||||
log_success "Found $METADATA_COUNT comment(s) with metadata"
|
||||
else
|
||||
log_info "No comments have metadata attached"
|
||||
fi
|
||||
|
||||
# Check for requests_input flag
|
||||
REQUESTS_INPUT_COUNT=$(echo "$FINAL_COMMENTS_LINE" | jq '[.payload[] | select(.requests_input == true)] | length' 2>/dev/null || echo "0")
|
||||
if [[ "$REQUESTS_INPUT_COUNT" =~ ^[0-9]+$ ]] && [ "$REQUESTS_INPUT_COUNT" -gt 0 ]; then
|
||||
log_success "Found $REQUESTS_INPUT_COUNT comment(s) with requests_input=true"
|
||||
else
|
||||
log_info "No comments with requests_input=true (agent didn't explicitly request input)"
|
||||
fi
|
||||
|
||||
# Verify timestamps are present
|
||||
HAS_TIMESTAMPS=$(echo "$FINAL_COMMENTS_LINE" | jq 'if .payload | type == "array" and (.payload | length) > 0 then (.payload[0].created_at != null) else false end' 2>/dev/null || echo "false")
|
||||
if [ "$HAS_TIMESTAMPS" == "true" ]; then
|
||||
log_success "Comments have created_at timestamps"
|
||||
fi
|
||||
|
||||
# Check for files created
|
||||
log_step "Step 11b: Verify Files Created"
|
||||
if [ -f "$TEST_WORKSPACE_DIR/calculator.py" ]; then
|
||||
log_success "calculator.py exists"
|
||||
CALC_LINES=$(wc -l < "$TEST_WORKSPACE_DIR/calculator.py") || CALC_LINES=0
|
||||
log_info " Lines: $CALC_LINES"
|
||||
else
|
||||
log_info "calculator.py not found"
|
||||
fi
|
||||
|
||||
if [ -f "$TEST_WORKSPACE_DIR/test_calculator.py" ]; then
|
||||
log_success "test_calculator.py exists"
|
||||
TEST_LINES=$(wc -l < "$TEST_WORKSPACE_DIR/test_calculator.py") || TEST_LINES=0
|
||||
log_info " Lines: $TEST_LINES"
|
||||
else
|
||||
log_info "test_calculator.py not found"
|
||||
fi
|
||||
|
||||
log_step "Step 12: Complete Task and Verify Final State"
|
||||
COMPLETE_RESPONSE=$(ws_request "orchestrator.complete" "{\"task_id\": \"${TASK_ID}\"}" 2>/dev/null) || COMPLETE_RESPONSE='{}'
|
||||
COMPLETE_SUCCESS=$(echo "$COMPLETE_RESPONSE" | head -1 | jq -r '.payload.success // false' 2>/dev/null) || COMPLETE_SUCCESS="false"
|
||||
if [ "$COMPLETE_SUCCESS" == "true" ]; then
|
||||
log_success "Task completed successfully"
|
||||
else
|
||||
log_info "Complete response: $(echo "$COMPLETE_RESPONSE" | head -1 | jq -c '.payload' 2>/dev/null || echo 'parse error')"
|
||||
fi
|
||||
|
||||
# Verify task state
|
||||
FINAL_TASK=$(ws_request "task.get" "{\"id\": \"${TASK_ID}\"}" 2>/dev/null) || FINAL_TASK='{}'
|
||||
FINAL_TASK_LINE=$(echo "$FINAL_TASK" | head -1)
|
||||
TASK_STATE=$(echo "$FINAL_TASK_LINE" | jq -r '.payload.state' 2>/dev/null) || TASK_STATE="UNKNOWN"
|
||||
log_info "Final task state: $TASK_STATE"
|
||||
|
||||
# Check for session ID in metadata
|
||||
SESSION_ID=$(echo "$FINAL_TASK_LINE" | jq -r '.payload.metadata.auggie_session_id // empty' 2>/dev/null) || SESSION_ID=""
|
||||
if [ -n "$SESSION_ID" ]; then
|
||||
log_success "Session ID stored: ${SESSION_ID:0:20}..."
|
||||
fi
|
||||
|
||||
# Summary
|
||||
log_step "Test Summary"
|
||||
|
||||
# Debug output for variables
|
||||
log_info "DEBUG: TASK_ID=$TASK_ID"
|
||||
log_info "DEBUG: TURN1_ID=$TURN1_ID, TURN2_ID=$TURN2_ID, TURN3_ID=$TURN3_ID"
|
||||
log_info "DEBUG: COMMENT_COUNT=$COMMENT_COUNT, USER_COMMENTS=$USER_COMMENTS"
|
||||
log_info "DEBUG: MSG_COUNT=$MSG_COUNT, CONTENT_COUNT=$CONTENT_COUNT, TOOL_CALL_COUNT=$TOOL_CALL_COUNT"
|
||||
log_info "DEBUG: TEST_WORKSPACE_DIR=$TEST_WORKSPACE_DIR"
|
||||
log_info "DEBUG: Files exist check: calculator.py=$([ -f "$TEST_WORKSPACE_DIR/calculator.py" ] && echo YES || echo NO)"
|
||||
|
||||
# Determine test results
|
||||
COMMENT_ADD_PASSED=false
|
||||
COMMENT_LIST_PASSED=false
|
||||
MULTI_TURN_PASSED=false
|
||||
METADATA_PASSED=false
|
||||
TOOL_CALLS_PASSED=false
|
||||
FILES_CREATED_PASSED=false
|
||||
|
||||
# Check for files BEFORE cleanup
|
||||
[ -f "$TEST_WORKSPACE_DIR/calculator.py" ] && FILES_CREATED_PASSED=true
|
||||
|
||||
# Safely check conditions with defaults for empty values
|
||||
[[ -n "$TURN1_ID" && -n "$TURN2_ID" && -n "$TURN3_ID" ]] && COMMENT_ADD_PASSED=true
|
||||
[[ "$COMMENT_COUNT" =~ ^[0-9]+$ ]] && [ "$COMMENT_COUNT" -gt 0 ] && COMMENT_LIST_PASSED=true
|
||||
[[ "$USER_COMMENTS" =~ ^[0-9]+$ ]] && [ "$USER_COMMENTS" -ge 3 ] && MULTI_TURN_PASSED=true
|
||||
[ "$HAS_TIMESTAMPS" == "true" ] && METADATA_PASSED=true
|
||||
[[ "$TOOL_CALL_COUNT" =~ ^[0-9]+$ ]] && [ "$TOOL_CALL_COUNT" -gt 0 ] && TOOL_CALLS_PASSED=true
|
||||
|
||||
echo -e "\n${GREEN}╔════════════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ Comment System E2E Test Results ║${NC}"
|
||||
echo -e "${GREEN}╠════════════════════════════════════════════════════════════════════╣${NC}"
|
||||
|
||||
# Comment Types Section
|
||||
echo -e "${GREEN}║ ${WHITE}COMMENT TYPES${NC} ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}╟────────────────────────────────────────────────────────────────────╢${NC}"
|
||||
printf "${GREEN}║${NC} message: %-5s content: %-5s tool_call: %-5s ${GREEN}║${NC}\n" "$MSG_COUNT" "$CONTENT_COUNT" "$TOOL_CALL_COUNT"
|
||||
printf "${GREEN}║${NC} progress: %-5s error: %-5s status: %-5s ${GREEN}║${NC}\n" "$PROGRESS_COUNT" "$ERROR_COUNT" "$STATUS_COUNT"
|
||||
echo -e "${GREEN}╟────────────────────────────────────────────────────────────────────╢${NC}"
|
||||
|
||||
# Core functionality
|
||||
echo -e "${GREEN}║ ${WHITE}CORE FUNCTIONALITY${NC} ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}╟────────────────────────────────────────────────────────────────────╢${NC}"
|
||||
|
||||
if [ "$COMMENT_ADD_PASSED" == "true" ]; then
|
||||
echo -e "${GREEN}║${NC} comment.add: ${GREEN}✓${NC} (3 user comments) ${GREEN}║${NC}"
|
||||
else
|
||||
echo -e "${GREEN}║${NC} comment.add: ${RED}✗${NC} ${GREEN}║${NC}"
|
||||
fi
|
||||
|
||||
if [ "$COMMENT_LIST_PASSED" == "true" ]; then
|
||||
printf "${GREEN}║${NC} comment.list: ${GREEN}✓${NC} (%-3s total comments) ${GREEN}║${NC}\n" "$COMMENT_COUNT"
|
||||
else
|
||||
echo -e "${GREEN}║${NC} comment.list: ${RED}✗${NC} ${GREEN}║${NC}"
|
||||
fi
|
||||
|
||||
if [ "$TOOL_CALLS_PASSED" == "true" ]; then
|
||||
printf "${GREEN}║${NC} Tool calls: ${GREEN}✓${NC} (%-3s tool_call comments) ${GREEN}║${NC}\n" "$TOOL_CALL_COUNT"
|
||||
else
|
||||
echo -e "${GREEN}║${NC} Tool calls: ${YELLOW}○${NC} (no tool_call comments) ${GREEN}║${NC}"
|
||||
fi
|
||||
|
||||
if [ "$MULTI_TURN_PASSED" == "true" ]; then
|
||||
echo -e "${GREEN}║${NC} Multi-turn conversation: ${GREEN}✓${NC} (3 turns completed) ${GREEN}║${NC}"
|
||||
else
|
||||
echo -e "${GREEN}║${NC} Multi-turn conversation: ${YELLOW}○${NC} (partial) ${GREEN}║${NC}"
|
||||
fi
|
||||
|
||||
if [ "$AGENT_COMMENTS" -gt 0 ]; then
|
||||
printf "${GREEN}║${NC} Agent responses: ${GREEN}✓${NC} (%-3s agent comments) ${GREEN}║${NC}\n" "$AGENT_COMMENTS"
|
||||
else
|
||||
echo -e "${GREEN}║${NC} Agent responses: ${YELLOW}○${NC} (pending) ${GREEN}║${NC}"
|
||||
fi
|
||||
|
||||
if [ "$METADATA_COUNT" -gt 0 ]; then
|
||||
printf "${GREEN}║${NC} Comments with metadata: ${GREEN}✓${NC} (%-3s comments) ${GREEN}║${NC}\n" "$METADATA_COUNT"
|
||||
else
|
||||
echo -e "${GREEN}║${NC} Comments with metadata: ${YELLOW}○${NC} (none) ${GREEN}║${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}╟────────────────────────────────────────────────────────────────────╢${NC}"
|
||||
echo -e "${GREEN}║ ${WHITE}FILE OPERATIONS${NC} ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}╟────────────────────────────────────────────────────────────────────╢${NC}"
|
||||
|
||||
if [ "$FILES_CREATED_PASSED" == "true" ]; then
|
||||
echo -e "${GREEN}║${NC} calculator.py: ${GREEN}✓${NC} (created by agent) ${GREEN}║${NC}"
|
||||
else
|
||||
echo -e "${GREEN}║${NC} calculator.py: ${YELLOW}○${NC} (not created) ${GREEN}║${NC}"
|
||||
fi
|
||||
|
||||
if [ -f "$TEST_WORKSPACE_DIR/test_calculator.py" ]; then
|
||||
echo -e "${GREEN}║${NC} test_calculator.py: ${GREEN}✓${NC} (created by agent) ${GREEN}║${NC}"
|
||||
else
|
||||
echo -e "${GREEN}║${NC} test_calculator.py: ${YELLOW}○${NC} (not created) ${GREEN}║${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}╟────────────────────────────────────────────────────────────────────╢${NC}"
|
||||
echo -e "${GREEN}║ ${WHITE}INFRASTRUCTURE${NC} ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}╟────────────────────────────────────────────────────────────────────╢${NC}"
|
||||
echo -e "${GREEN}║${NC} Workspace/Board/Column: ${GREEN}✓${NC} ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}║${NC} Task creation: ${GREEN}✓${NC} ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}║${NC} Agent execution: ${GREEN}✓${NC} ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}║${NC} Task completion: ${GREEN}✓${NC} ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}╚════════════════════════════════════════════════════════════════════╝${NC}"
|
||||
|
||||
# Clean up workspace directory (after all checks)
|
||||
rm -rf "$TEST_WORKSPACE_DIR" 2>/dev/null || true
|
||||
|
||||
# Overall result
|
||||
if [ "$COMMENT_ADD_PASSED" == "true" ] && [ "$COMMENT_LIST_PASSED" == "true" ]; then
|
||||
echo -e "\n${GREEN}✓ Comment System E2E Test PASSED${NC}"
|
||||
echo -e "${GREEN} Total comments: $COMMENT_COUNT (user: $USER_COMMENTS, agent: $AGENT_COMMENTS)${NC}"
|
||||
echo -e "${GREEN} Tool calls recorded: $TOOL_CALL_COUNT${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "\n${RED}✗ Comment System E2E Test FAILED${NC}"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user