From ba1df2086d38b5888fef8b332c95ea7030ba126a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 24 Feb 2026 10:40:39 +0000 Subject: [PATCH] feat: remove unnecessary files --- apps/cli/package.json | 2 +- docs/plans/01-secrets-infrastructure.md | 915 ------- .../02-executor-profiles-infrastructure.md | 2191 ----------------- docs/plans/03-sprites-dev-support.md | 1336 ---------- docs/plans/04-executors.md | 63 - github-plan.md | 744 ------ scripts/release/publish-release.sh | 21 +- 7 files changed, 8 insertions(+), 5264 deletions(-) delete mode 100644 docs/plans/01-secrets-infrastructure.md delete mode 100644 docs/plans/02-executor-profiles-infrastructure.md delete mode 100644 docs/plans/03-sprites-dev-support.md delete mode 100644 docs/plans/04-executors.md delete mode 100644 github-plan.md diff --git a/apps/cli/package.json b/apps/cli/package.json index 3f71435481..03189b75c1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -3,7 +3,7 @@ "version": "0.1.5", "private": false, "description": "NPX launcher for Kandev", - "license": "UNLICENSED", + "license": "AGPL-3.0-only", "type": "commonjs", "bin": { "kandev": "bin/cli.js" diff --git a/docs/plans/01-secrets-infrastructure.md b/docs/plans/01-secrets-infrastructure.md deleted file mode 100644 index d34c6abc44..0000000000 --- a/docs/plans/01-secrets-infrastructure.md +++ /dev/null @@ -1,915 +0,0 @@ -# Plan 01: Secrets Infrastructure - -> Encrypted secrets store for API keys, tokens, and sensitive configuration. -> Foundation for Sprites.dev API key, future SSH keys, and third-party service tokens. - ---- - -## Table of Contents - -1. [Overview](#overview) -2. [Architecture](#architecture) -3. [Master Key Management](#master-key-management) -4. [Encryption Scheme](#encryption-scheme) -5. [Backend Implementation](#backend-implementation) -6. [API Endpoints](#api-endpoints) -7. [Frontend Implementation](#frontend-implementation) -8. [Integration with Credential Manager](#integration-with-credential-manager) -9. [Migration & Rollout](#migration--rollout) -10. [Security Considerations](#security-considerations) - ---- - -## Overview - -Kandev needs a way to store user secrets (API keys, tokens) encrypted at rest. Currently, credentials are sourced from environment variables (`EnvProvider`) or a JSON file (`FileProvider`) — neither supports encrypted storage or user-managed secrets via the UI. - -### Goals - -- Store secrets encrypted at rest in SQLite using AES-256-GCM -- Master key file at `~/.kandev/master.key` (auto-generated, 0600 permissions) -- `SecretStore` interface abstractable to OS keyring in the future -- Integrate as a `CredentialProvider` in the existing credential manager chain -- API endpoints for CRUD (never return plaintext in list operations) -- Frontend settings page for managing secrets - -### Non-Goals (This Phase) - -- OS keyring integration (future) -- Secret rotation policies -- Multi-user / team secret sharing -- Hardware security module (HSM) support - ---- - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Frontend (Settings) │ -│ │ -│ ┌──────────────┐ ┌───────────────┐ ┌──────────────────────────┐ │ -│ │ Secrets Page │ │ Secret Form │ │ Secret List (masked) │ │ -│ └──────┬───────┘ └───────┬───────┘ └──────────┬───────────────┘ │ -│ │ │ │ │ -└─────────┼──────────────────┼──────────────────────┼─────────────────┘ - │ HTTP/WS │ │ - ▼ ▼ ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ Backend API Layer │ -│ │ -│ ┌────────────────────────────────────────────────────────────────┐ │ -│ │ secrets/handlers/handlers.go │ │ -│ │ POST /api/v1/secrets → Create secret │ │ -│ │ GET /api/v1/secrets → List secrets (names only) │ │ -│ │ GET /api/v1/secrets/:id → Get secret metadata │ │ -│ │ PUT /api/v1/secrets/:id → Update secret value │ │ -│ │ DELETE /api/v1/secrets/:id → Delete secret │ │ -│ │ POST /api/v1/secrets/:id/reveal → Get plaintext (auth req) │ │ -│ └──────────────────────┬─────────────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────▼─────────────────────────────────────────┐ │ -│ │ secrets/service.go │ │ -│ │ SecretService — business logic, validation, categorization │ │ -│ └──────────────────────┬─────────────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────▼─────────────────────────────────────────┐ │ -│ │ secrets/store.go (SecretStore interface) │ │ -│ │ │ │ -│ │ ┌─────────────────────────────────────────────────────────┐ │ │ -│ │ │ SQLiteSecretStore (default) │ │ │ -│ │ │ - Encrypts with AES-256-GCM before writing to DB │ │ │ -│ │ │ - Decrypts on read │ │ │ -│ │ │ - Uses master key from MasterKeyProvider │ │ │ -│ │ └─────────────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌─────────────────────────────────────────────────────────┐ │ │ -│ │ │ KeyringSecretStore (future) │ │ │ -│ │ │ - OS keyring via go-keyring │ │ │ -│ │ └─────────────────────────────────────────────────────────┘ │ │ -│ └──────────────────────┬─────────────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────▼─────────────────────────────────────────┐ │ -│ │ secrets/crypto.go │ │ -│ │ MasterKeyProvider — loads/generates ~/.kandev/master.key │ │ -│ │ Encrypt(plaintext, key) → (ciphertext, nonce) │ │ -│ │ Decrypt(ciphertext, nonce, key) → plaintext │ │ -│ └───────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌──────────────────────▼─────────────────────────────────────────┐ │ -│ │ credentials/secret_store_provider.go │ │ -│ │ SecretStoreProvider implements CredentialProvider │ │ -│ │ - Bridges SecretStore → credential manager chain │ │ -│ └───────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ SQLite Database │ -│ │ -│ ┌───────────────────────────────────────────────────────────────┐ │ -│ │ secrets table │ │ -│ │ ┌──────────┬────────┬───────────────┬───────┬─────────────┐ │ │ -│ │ │ id (PK) │ name │ encrypted_val │ nonce │ category │ │ │ -│ │ │ TEXT │ TEXT │ BLOB │ BLOB │ TEXT │ │ │ -│ │ ├──────────┼────────┼───────────────┼───────┼─────────────┤ │ │ -│ │ │ metadata │ env_key│ created_at │ │ updated_at │ │ │ -│ │ │ TEXT/JSON│ TEXT │ TIMESTAMP │ │ TIMESTAMP │ │ │ -│ │ └──────────┴────────┴───────────────┴───────┴─────────────┘ │ │ -│ └───────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -### Credential Resolution Chain - -``` -GetCredential("SPRITES_API_TOKEN") - │ - ▼ -┌────────────────────────────┐ -│ 1. Check cache │ ─── hit ──→ return cached -│ (in-memory) │ -└────────────┬───────────────┘ - │ miss - ▼ -┌────────────────────────────┐ -│ 2. SecretStoreProvider │ ─── found ──→ cache + return -│ (encrypted DB secrets) │ -└────────────┬───────────────┘ - │ not found - ▼ -┌────────────────────────────┐ -│ 3. EnvProvider │ ─── found ──→ cache + return -│ (environment variables) │ -└────────────┬───────────────┘ - │ not found - ▼ -┌────────────────────────────┐ -│ 4. FileProvider │ ─── found ──→ cache + return -│ (JSON credentials file) │ -└────────────┬───────────────┘ - │ not found - ▼ - return error -``` - ---- - -## Master Key Management - -### Key Generation & Storage - -``` -~/.kandev/ -├── master.key # 32-byte random key, 0600 permissions -├── data/ # SQLite database (existing) -└── ... -``` - -### Implementation: `secrets/crypto.go` - -```go -package secrets - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "fmt" - "io" - "os" - "path/filepath" -) - -const ( - MasterKeyFile = "master.key" - MasterKeySize = 32 // AES-256 -) - -// MasterKeyProvider manages the master encryption key. -type MasterKeyProvider struct { - keyPath string - key []byte -} - -// NewMasterKeyProvider creates a provider that loads or generates -// the master key from the given kandev config directory. -func NewMasterKeyProvider(kandevDir string) (*MasterKeyProvider, error) { - keyPath := filepath.Join(kandevDir, MasterKeyFile) - provider := &MasterKeyProvider{keyPath: keyPath} - - if err := provider.loadOrGenerate(); err != nil { - return nil, fmt.Errorf("master key init: %w", err) - } - return provider, nil -} - -func (p *MasterKeyProvider) loadOrGenerate() error { - // Try to load existing key - data, err := os.ReadFile(p.keyPath) - if err == nil && len(data) == MasterKeySize { - p.key = data - return nil - } - - // Generate new key - key := make([]byte, MasterKeySize) - if _, err := io.ReadFull(rand.Reader, key); err != nil { - return fmt.Errorf("generate key: %w", err) - } - - // Ensure directory exists - if err := os.MkdirAll(filepath.Dir(p.keyPath), 0700); err != nil { - return fmt.Errorf("create key dir: %w", err) - } - - // Write with restrictive permissions - if err := os.WriteFile(p.keyPath, key, 0600); err != nil { - return fmt.Errorf("write key: %w", err) - } - - p.key = key - return nil -} - -// Key returns the master key bytes. Never log this. -func (p *MasterKeyProvider) Key() []byte { - return p.key -} -``` - -### Encryption Functions - -```go -// Encrypt encrypts plaintext using AES-256-GCM with a random nonce. -// Returns (ciphertext, nonce, error). -func Encrypt(plaintext []byte, key []byte) ([]byte, []byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, nil, fmt.Errorf("create cipher: %w", err) - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, nil, fmt.Errorf("create GCM: %w", err) - } - - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return nil, nil, fmt.Errorf("generate nonce: %w", err) - } - - ciphertext := gcm.Seal(nil, nonce, plaintext, nil) - return ciphertext, nonce, nil -} - -// Decrypt decrypts ciphertext using AES-256-GCM. -func Decrypt(ciphertext, nonce, key []byte) ([]byte, error) { - block, err := aes.NewCipher(key) - if err != nil { - return nil, fmt.Errorf("create cipher: %w", err) - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, fmt.Errorf("create GCM: %w", err) - } - - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) - if err != nil { - return nil, fmt.Errorf("decrypt: %w", err) - } - - return plaintext, nil -} -``` - ---- - -## Encryption Scheme - -### AES-256-GCM Details - -``` -┌──────────────────────────────────────────────────────────────┐ -│ Encryption Process │ -│ │ -│ plaintext: "sk-ant-api03-abc..." │ -│ master_key: [32 bytes from ~/.kandev/master.key] │ -│ │ -│ 1. Generate random nonce (12 bytes) │ -│ 2. AES-256-GCM encrypt: │ -│ ciphertext = GCM.Seal(nonce, plaintext, master_key) │ -│ 3. Store: (ciphertext, nonce) in DB │ -│ │ -│ Decryption: │ -│ 1. Read (ciphertext, nonce) from DB │ -│ 2. plaintext = GCM.Open(nonce, ciphertext, master_key) │ -│ │ -│ Properties: │ -│ - Authenticated encryption (integrity + confidentiality) │ -│ - Per-secret random nonce prevents ciphertext correlation │ -│ - 128-bit authentication tag │ -│ - Master key never leaves memory / ~/.kandev/master.key │ -└──────────────────────────────────────────────────────────────┘ -``` - ---- - -## Backend Implementation - -### Package Structure - -``` -apps/backend/internal/secrets/ -├── crypto.go # MasterKeyProvider, Encrypt(), Decrypt() -├── models.go # Secret model, categories, DTOs -├── store.go # SecretStore interface -├── sqlite_store.go # SQLite implementation -├── service.go # Business logic, validation -├── handlers.go # HTTP/WS handlers -└── provider.go # CredentialProvider bridge (SecretStoreProvider) -``` - -### Models: `secrets/models.go` - -```go -package secrets - -import "time" - -// SecretCategory groups secrets by purpose. -type SecretCategory string - -const ( - CategoryAPIKey SecretCategory = "api_key" // LLM provider keys - CategoryServiceToken SecretCategory = "service_token" // Sprites, exe.dev, etc. - CategorySSHKey SecretCategory = "ssh_key" // SSH private keys - CategoryCustom SecretCategory = "custom" // User-defined -) - -// Secret represents a stored secret (decrypted in-memory representation). -type Secret struct { - ID string `json:"id"` - Name string `json:"name"` // Human-readable name - EnvKey string `json:"env_key"` // Env var name (e.g., SPRITES_API_TOKEN) - Category SecretCategory `json:"category"` - Metadata map[string]string `json:"metadata,omitempty"` // e.g., {"service": "sprites.dev"} - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - // Value is NEVER included in list/get responses. Only via Reveal(). -} - -// SecretWithValue is only used internally and for create/update operations. -type SecretWithValue struct { - Secret - Value string `json:"value,omitempty"` -} - -// SecretListItem is returned by List() — never contains the value. -type SecretListItem struct { - ID string `json:"id"` - Name string `json:"name"` - EnvKey string `json:"env_key"` - Category SecretCategory `json:"category"` - Metadata map[string]string `json:"metadata,omitempty"` - HasValue bool `json:"has_value"` // true if a value is stored - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} -``` - -### Store Interface: `secrets/store.go` - -```go -package secrets - -import "context" - -// SecretStore abstracts secret storage. Implementations handle -// encryption/decryption internally. -type SecretStore interface { - // Create stores a new secret (encrypts the value). - Create(ctx context.Context, secret *SecretWithValue) error - - // Get retrieves secret metadata (without value). - Get(ctx context.Context, id string) (*Secret, error) - - // GetByEnvKey retrieves secret metadata by env key name. - GetByEnvKey(ctx context.Context, envKey string) (*Secret, error) - - // Reveal retrieves the decrypted value of a secret. - Reveal(ctx context.Context, id string) (string, error) - - // RevealByEnvKey retrieves the decrypted value by env key name. - RevealByEnvKey(ctx context.Context, envKey string) (string, error) - - // Update updates a secret's value and/or metadata. - Update(ctx context.Context, id string, secret *SecretWithValue) error - - // Delete permanently removes a secret. - Delete(ctx context.Context, id string) error - - // List returns all secrets without values. - List(ctx context.Context) ([]*SecretListItem, error) - - // ListByCategory returns secrets filtered by category. - ListByCategory(ctx context.Context, category SecretCategory) ([]*SecretListItem, error) - - // Close releases resources. - Close() error -} -``` - -### SQLite Implementation: `secrets/sqlite_store.go` - -```go -// SQLiteSecretStore implements SecretStore with AES-256-GCM encryption. -type SQLiteSecretStore struct { - db *sqlx.DB // writer - ro *sqlx.DB // reader - crypto *MasterKeyProvider - logger *logger.Logger -} -``` - -**Database Schema:** - -```sql -CREATE TABLE IF NOT EXISTS secrets ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - env_key TEXT NOT NULL UNIQUE, - encrypted_value BLOB NOT NULL, - nonce BLOB NOT NULL, - category TEXT NOT NULL DEFAULT 'custom', - metadata TEXT DEFAULT '{}', - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_secrets_env_key ON secrets(env_key); -CREATE INDEX IF NOT EXISTS idx_secrets_category ON secrets(category); -``` - -**Key operations:** - -```go -func (s *SQLiteSecretStore) Create(ctx context.Context, secret *SecretWithValue) error { - // 1. Validate: name and env_key not empty, env_key unique - // 2. Encrypt: ciphertext, nonce, err := Encrypt([]byte(secret.Value), s.crypto.Key()) - // 3. INSERT INTO secrets (id, name, env_key, encrypted_value, nonce, category, metadata, ...) -} - -func (s *SQLiteSecretStore) Reveal(ctx context.Context, id string) (string, error) { - // 1. SELECT encrypted_value, nonce FROM secrets WHERE id = ? - // 2. plaintext, err := Decrypt(ciphertext, nonce, s.crypto.Key()) - // 3. return string(plaintext), nil -} - -func (s *SQLiteSecretStore) RevealByEnvKey(ctx context.Context, envKey string) (string, error) { - // 1. SELECT encrypted_value, nonce FROM secrets WHERE env_key = ? - // 2. Decrypt and return -} -``` - -### Credential Provider Bridge: `secrets/provider.go` - -```go -package secrets - -import ( - "context" - "github.com/kandev/kandev/internal/agent/credentials" -) - -// SecretStoreProvider bridges SecretStore into the credential provider chain. -// It implements credentials.CredentialProvider. -type SecretStoreProvider struct { - store SecretStore -} - -func NewSecretStoreProvider(store SecretStore) *SecretStoreProvider { - return &SecretStoreProvider{store: store} -} - -func (p *SecretStoreProvider) Name() string { - return "secret_store" -} - -func (p *SecretStoreProvider) GetCredential(ctx context.Context, key string) (*credentials.Credential, error) { - value, err := p.store.RevealByEnvKey(ctx, key) - if err != nil { - return nil, err - } - return &credentials.Credential{ - Key: key, - Value: value, - Source: "secret_store", - }, nil -} - -func (p *SecretStoreProvider) ListAvailable(ctx context.Context) ([]string, error) { - items, err := p.store.List(ctx) - if err != nil { - return nil, err - } - keys := make([]string, len(items)) - for i, item := range items { - keys[i] = item.EnvKey - } - return keys, nil -} -``` - -### Service: `secrets/service.go` - -```go -type Service struct { - store SecretStore - logger *logger.Logger -} - -// Validation rules: -// - Name: 1-100 chars, unique -// - EnvKey: uppercase, underscores, unique (e.g., SPRITES_API_TOKEN) -// - Value: 1-10000 chars -// - Category: must be valid SecretCategory -``` - -### Handlers: `secrets/handlers.go` - -```go -type Handler struct { - service *Service - logger *logger.Logger -} - -func (h *Handler) RegisterRoutes(r chi.Router) { - r.Route("/api/v1/secrets", func(r chi.Router) { - r.Post("/", h.CreateSecret) - r.Get("/", h.ListSecrets) - r.Get("/{id}", h.GetSecret) - r.Put("/{id}", h.UpdateSecret) - r.Delete("/{id}", h.DeleteSecret) - r.Post("/{id}/reveal", h.RevealSecret) // POST to avoid caching - }) -} - -func (h *Handler) RegisterWSActions(dispatcher ws.Dispatcher) { - dispatcher.Register("secrets.list", h.WSListSecrets) - dispatcher.Register("secrets.create", h.WSCreateSecret) - dispatcher.Register("secrets.update", h.WSUpdateSecret) - dispatcher.Register("secrets.delete", h.WSDeleteSecret) - dispatcher.Register("secrets.reveal", h.WSRevealSecret) -} -``` - -### Startup Wiring: `cmd/kandev/storage.go` additions - -```go -// In provideRepositories(): - -// Initialize master key provider -kandevDir := filepath.Join(os.Getenv("HOME"), ".kandev") -masterKeyProvider, err := secrets.NewMasterKeyProvider(kandevDir) -if err != nil { - return nil, fmt.Errorf("master key: %w", err) -} - -// Create secret store -secretStore, cleanupSecrets, err := secrets.ProvideSQLiteStore(writer, reader, masterKeyProvider, log) -if err != nil { - return nil, fmt.Errorf("secret store: %w", err) -} - -// In provideLifecycleManager() — register as credential provider: -secretProvider := secrets.NewSecretStoreProvider(secretStore) -credsMgr.AddProvider(secretProvider) // First in chain (highest priority) -credsMgr.AddProvider(credentials.NewEnvProvider("KANDEV_")) -// ... rest of providers -``` - ---- - -## API Endpoints - -### HTTP Endpoints - -| Method | Path | Request Body | Response | Description | -|--------|------|-------------|----------|-------------| -| `POST` | `/api/v1/secrets` | `{name, env_key, value, category?, metadata?}` | `{id, name, env_key, category, metadata, created_at}` | Create secret | -| `GET` | `/api/v1/secrets` | — | `[{id, name, env_key, category, has_value, ...}]` | List (no values) | -| `GET` | `/api/v1/secrets/:id` | — | `{id, name, env_key, category, metadata, ...}` | Get metadata | -| `PUT` | `/api/v1/secrets/:id` | `{name?, value?, category?, metadata?}` | `{id, name, env_key, ...}` | Update | -| `DELETE` | `/api/v1/secrets/:id` | — | `204 No Content` | Delete | -| `POST` | `/api/v1/secrets/:id/reveal` | — | `{value}` | Get plaintext | - -### WebSocket Actions - -| Action | Payload | Response | Description | -|--------|---------|----------|-------------| -| `secrets.list` | `{}` | `[SecretListItem]` | List secrets | -| `secrets.create` | `{name, env_key, value, category?}` | `SecretListItem` | Create | -| `secrets.update` | `{id, name?, value?, category?}` | `SecretListItem` | Update | -| `secrets.delete` | `{id}` | `{success: true}` | Delete | -| `secrets.reveal` | `{id}` | `{value}` | Get plaintext | - -### Request/Response DTOs - -```go -// Create -type CreateSecretRequest struct { - Name string `json:"name" validate:"required,min=1,max=100"` - EnvKey string `json:"env_key" validate:"required,env_key_format"` - Value string `json:"value" validate:"required,min=1,max=10000"` - Category SecretCategory `json:"category,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` -} - -type CreateSecretResponse struct { - ID string `json:"id"` - Name string `json:"name"` - EnvKey string `json:"env_key"` - Category SecretCategory `json:"category"` - Metadata map[string]string `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// Reveal (POST to avoid URL caching of sensitive data) -type RevealSecretResponse struct { - Value string `json:"value"` -} -``` - ---- - -## Frontend Implementation - -### New Files - -``` -apps/web/ -├── app/settings/secrets/ -│ └── page.tsx # Secrets settings page route -├── components/settings/ -│ ├── secrets-page.tsx # Secrets management page component -│ └── secret-form-dialog.tsx # Create/edit secret dialog -├── lib/api/domains/ -│ └── secrets-api.ts # API client for secrets endpoints -├── lib/types/ -│ └── http-secrets.ts # TypeScript types for secrets -├── lib/ws/handlers/ -│ └── secrets.ts # WS event handlers -└── hooks/domains/settings/ - └── use-secrets.ts # Secrets data hook -``` - -### TypeScript Types: `lib/types/http-secrets.ts` - -```typescript -export type SecretCategory = 'api_key' | 'service_token' | 'ssh_key' | 'custom'; - -export interface SecretListItem { - id: string; - name: string; - env_key: string; - category: SecretCategory; - metadata?: Record; - has_value: boolean; - created_at: string; - updated_at: string; -} - -export interface CreateSecretRequest { - name: string; - env_key: string; - value: string; - category?: SecretCategory; - metadata?: Record; -} - -export interface UpdateSecretRequest { - name?: string; - value?: string; - category?: SecretCategory; - metadata?: Record; -} -``` - -### API Client: `lib/api/domains/secrets-api.ts` - -```typescript -export const secretsApi = { - list: () => fetchApi('/api/v1/secrets'), - create: (req: CreateSecretRequest) => fetchApi('/api/v1/secrets', { method: 'POST', body: req }), - update: (id: string, req: UpdateSecretRequest) => fetchApi(`/api/v1/secrets/${id}`, { method: 'PUT', body: req }), - delete: (id: string) => fetchApi(`/api/v1/secrets/${id}`, { method: 'DELETE' }), - reveal: (id: string) => fetchApi<{ value: string }>(`/api/v1/secrets/${id}/reveal`, { method: 'POST' }), -}; -``` - -### Store Slice Addition: `lib/state/slices/settings/settings-slice.ts` - -```typescript -// Add to SettingsSlice state: -secrets: { - items: SecretListItem[]; - loading: boolean; - loaded: boolean; -} - -// Add actions: -setSecrets: (items: SecretListItem[]) => void; -addSecret: (item: SecretListItem) => void; -updateSecret: (id: string, item: Partial) => void; -removeSecret: (id: string) => void; -``` - -### Secrets Page Component: `components/settings/secrets-page.tsx` - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Secrets [+ Add] │ -│─────────────────────────────────────────────────────────────│ -│ │ -│ ┌───────────────────────────────────────────────────────┐ │ -│ │ 🔑 Anthropic API Key │ │ -│ │ ANTHROPIC_API_KEY • api_key • ●●●●●●●●●●●● │ │ -│ │ [Reveal] [Delete] │ │ -│ └───────────────────────────────────────────────────────┘ │ -│ │ -│ ┌───────────────────────────────────────────────────────┐ │ -│ │ 🔑 Sprites API Token │ │ -│ │ SPRITES_API_TOKEN • service_token • ●●●●●●●● │ │ -│ │ [Reveal] [Delete] │ │ -│ └───────────────────────────────────────────────────────┘ │ -│ │ -│ ┌───────────────────────────────────────────────────────┐ │ -│ │ 🔑 GitHub Token │ │ -│ │ GITHUB_TOKEN • service_token • ●●●●●●●●●●●● │ │ -│ │ [Reveal] [Delete] │ │ -│ └───────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - -Components: -- Uses `@kandev/ui/card`, `@kandev/ui/button`, `@kandev/ui/dialog`, `@kandev/ui/badge` -- Secret values shown as masked dots, with a "Reveal" button that calls the reveal endpoint -- Create/Edit dialog with form validation (name, env_key format, value textarea) -- Delete with confirmation dialog -- Category badges with color coding - -### Settings Sidebar Addition - -Add "Secrets" item to the settings sidebar navigation, under a new "Security" section: - -```typescript -// In settings-app-sidebar.tsx -{ - title: "Security", - items: [ - { title: "Secrets", url: "/settings/secrets", icon: IconKey } - ] -} -``` - ---- - -## Integration with Credential Manager - -### Provider Registration Order - -```go -// In provideLifecycleManager() or startup wiring: -credsMgr := credentials.NewManager(log) - -// 1. Secret store (highest priority — user-managed secrets) -credsMgr.AddProvider(secrets.NewSecretStoreProvider(secretStore)) - -// 2. Environment variables (existing behavior) -credsMgr.AddProvider(credentials.NewEnvProvider("KANDEV_")) - -// 3. Augment session provider (existing) -credsMgr.AddProvider(credentials.NewAugmentSessionProvider()) - -// 4. File provider (existing, lowest priority) -if credsFile := os.Getenv("KANDEV_CREDENTIALS_FILE"); credsFile != "" { - credsMgr.AddProvider(credentials.NewFileProvider(credsFile)) -} -``` - -### Usage Example: Sprites API Token - -```go -// When creating a Sprites executor instance: -token, err := credsMgr.GetCredentialValue(ctx, "SPRITES_API_TOKEN") -if err != nil { - return fmt.Errorf("sprites API token not configured: %w", err) -} -client := sprites.New(token) -``` - ---- - -## Migration & Rollout - -### Database Migration - -The `secrets` table is created via `initSchema()` in the SQLite secret store (same pattern as other repos). No migration files needed — the table is created if it doesn't exist. - -### Master Key Bootstrapping - -``` -First startup after secrets feature: -1. Check ~/.kandev/master.key exists -2. If not, generate 32 random bytes, write with 0600 perms -3. Log: "Master key generated at ~/.kandev/master.key" -4. Warning: "Back up your master key — losing it means losing all stored secrets" - -Subsequent startups: -1. Load ~/.kandev/master.key -2. Validate 32 bytes -3. Initialize SQLiteSecretStore with loaded key -``` - -### Backward Compatibility - -- Existing `EnvProvider` and `FileProvider` continue to work unchanged -- `SecretStoreProvider` is additive — no breaking changes to credential resolution -- Users can gradually migrate from env vars to stored secrets - ---- - -## Security Considerations - -### Threat Model - -| Threat | Mitigation | -|--------|-----------| -| DB file stolen | Secrets encrypted with AES-256-GCM | -| Master key stolen | File permissions 0600; future: OS keyring | -| Memory dump | Secrets in memory only during active use; cache cleared on shutdown | -| API response leak | List endpoints never return values; reveal is POST | -| Log injection | Secret values never logged (zap fields skip value) | -| SQL injection | Parameterized queries via sqlx | - -### Future: OS Keyring Migration - -```go -// KeyringSecretStore implements SecretStore using the OS keyring. -// When implemented, secrets can be migrated: -// 1. Read all secrets from SQLiteSecretStore (decrypt with master key) -// 2. Write each to KeyringSecretStore -// 3. Delete from SQLite -// 4. Update config to use keyring backend -``` - -### Master Key Backup Warning - -On first generation, the backend logs a prominent warning and the frontend shows a one-time notification: - -``` -⚠️ Master encryption key generated at ~/.kandev/master.key - Back up this file — losing it means losing access to all stored secrets. - The key is NOT recoverable. -``` - ---- - -## Files Changed Summary - -### New Files -| File | Description | -|------|-------------| -| `apps/backend/internal/secrets/crypto.go` | MasterKeyProvider, Encrypt/Decrypt | -| `apps/backend/internal/secrets/models.go` | Secret, SecretListItem, categories | -| `apps/backend/internal/secrets/store.go` | SecretStore interface | -| `apps/backend/internal/secrets/sqlite_store.go` | SQLite + encryption implementation | -| `apps/backend/internal/secrets/service.go` | Validation, business logic | -| `apps/backend/internal/secrets/handlers.go` | HTTP + WS handlers | -| `apps/backend/internal/secrets/provider.go` | SecretStoreProvider (CredentialProvider bridge) | -| `apps/web/app/settings/secrets/page.tsx` | Route page | -| `apps/web/components/settings/secrets-page.tsx` | Secrets management UI | -| `apps/web/components/settings/secret-form-dialog.tsx` | Create/edit dialog | -| `apps/web/lib/api/domains/secrets-api.ts` | API client | -| `apps/web/lib/types/http-secrets.ts` | TypeScript types | -| `apps/web/lib/ws/handlers/secrets.ts` | WS event handlers | -| `apps/web/hooks/domains/settings/use-secrets.ts` | Data hook | - -### Modified Files -| File | Change | -|------|--------| -| `apps/backend/cmd/kandev/storage.go` | Init MasterKeyProvider + SecretStore | -| `apps/backend/cmd/kandev/agents.go` | Register SecretStoreProvider in credential chain | -| `apps/backend/cmd/kandev/routes.go` | Register secrets HTTP routes | -| `apps/backend/cmd/kandev/websocket.go` | Register secrets WS actions | -| `apps/web/lib/state/slices/settings/settings-slice.ts` | Add secrets state | -| `apps/web/components/settings/settings-app-sidebar.tsx` | Add Secrets nav item | - ---- - -*Last updated: 2026-02-20* diff --git a/docs/plans/02-executor-profiles-infrastructure.md b/docs/plans/02-executor-profiles-infrastructure.md deleted file mode 100644 index a628498044..0000000000 --- a/docs/plans/02-executor-profiles-infrastructure.md +++ /dev/null @@ -1,2191 +0,0 @@ -# Plan 02: Executor Profiles Infrastructure - -> Unified executor abstraction with profiles. Full rename of `runtime` to `executor`. -> Merge `environments` into executor profiles. Foundation for SSH, K8s, Sprites, and more. - ---- - -## Table of Contents - -1. [Overview](#overview) -2. [Architecture](#architecture) -3. [Rename Strategy: Runtime → Executor](#rename-strategy-runtime--executor) -4. [Executor & Profile Models](#executor--profile-models) -5. [Backend Implementation](#backend-implementation) -6. [Environment Preparation Abstraction](#environment-preparation-abstraction) -7. [Health Check System](#health-check-system) -8. [API Endpoints](#api-endpoints) -9. [Frontend Implementation](#frontend-implementation) -10. [Tracing](#tracing) -11. [Database Schema (New Service)](#database-schema-new-service) -12. [Backward Compatibility](#backward-compatibility) - ---- - -## Overview - -Kandev currently has two separate concepts that overlap: - -1. **Executors** — where to run (local, worktree, local_docker, remote_docker) -2. **Environments** — how to configure the runtime (local_pc, docker_image with image tag/dockerfile) - -Additionally, the backend uses "Runtime" as the internal interface name while "Executor" is the user-facing concept. This creates confusion. - -### Goals - -- **Full rename**: `runtime.Name` → `executor.Name`, `Runtime` interface → `Executor` interface, `RuntimeRegistry` → `ExecutorRegistry`, all through the Go codebase -- **Merge environments into executor profiles**: Docker profiles hold image config, local profiles hold worktree root, SSH profiles hold host/key, etc. -- **Add executor types**: `ssh`, `k8s`, `sprites` (in addition to existing `local`, `worktree`, `local_docker`, `remote_docker`) -- **Profile system**: Each executor can have multiple named profiles with type-specific configuration -- **Health check system**: Periodic health checks for remote executors with status on session cards -- **Environment preparation**: Abstraction for setting up executor environments before agent launch - -### Non-Goals (This Phase) - -- Implementing SSH, K8s, or Sprites executors (separate plans) -- Remote preparer implementations (SSH/Sprites/K8s — only the interface + local/worktree/docker preparers) -- Health check UI beyond session card indicator - ---- - -## Architecture - -### Current vs. New Model - -``` -CURRENT MODEL: -┌────────────────────┐ ┌─────────────────────┐ -│ Executor │ │ Environment │ -│ (where to run) │ │ (how to configure) │ -│ │ │ │ -│ - local │ │ - local_pc │ -│ - worktree │ │ worktree_root │ -│ - local_docker │ │ - docker_image │ -│ - remote_docker │ │ image_tag │ -│ │ │ dockerfile │ -│ config: map[str]str│ │ build_config │ -└────────────────────┘ └─────────────────────┘ - ↓ ↓ - chosen per session chosen per session - (executor_id) (environment_id) - - -NEW MODEL: -┌──────────────────────────────────────────────────────────┐ -│ Executor │ -│ (unified: where + how to run) │ -│ │ -│ Types: local, worktree, docker, ssh, k8s, sprites │ -│ │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ Executor Profiles │ │ -│ │ │ │ -│ │ Docker Executor: │ │ -│ │ ├── "local" profile: │ │ -│ │ │ host: /var/run/docker.sock │ │ -│ │ │ image: kandev-agent:latest │ │ -│ │ │ dockerfile: ./Dockerfile.agent │ │ -│ │ └── "remote-server" profile: │ │ -│ │ host: tcp://192.168.1.100:2376 │ │ -│ │ image: kandev-agent:latest │ │ -│ │ │ │ -│ │ SSH Executor: │ │ -│ │ ├── "dev-server" profile: │ │ -│ │ │ host: dev.example.com │ │ -│ │ │ user: deploy │ │ -│ │ │ key_secret_id: │ │ -│ │ └── "staging" profile: │ │ -│ │ host: staging.example.com │ │ -│ │ user: deploy │ │ -│ │ │ │ -│ │ Sprites Executor: │ │ -│ │ └── "default" profile: │ │ -│ │ api_token_secret_id: │ │ -│ │ network_policies: [...] │ │ -│ │ │ │ -│ │ Local Executor: │ │ -│ │ └── "default" profile: │ │ -│ │ (no additional config) │ │ -│ │ │ │ -│ │ Worktree Executor: │ │ -│ │ └── "default" profile: │ │ -│ │ worktree_root: ~/kandev │ │ -│ │ │ │ -│ └────────────────────────────────────────────────────┘ │ -│ │ -└──────────────────────────────────────────────────────────┘ - ↓ - chosen per session: - (executor_id + profile_id) -``` - -### Execution Flow (Updated) - -``` -Client (WS) Orchestrator Lifecycle Manager ExecutorImpl agentctl - │ │ │ │ │ - │ task.start │ │ │ │ - │ executor_id │ │ │ │ - │ profile_id │ │ │ │ - │───────────────>│ LaunchAgent() │ │ │ - │ │────────────────────>│ │ │ - │ │ │ ResolveExecutor()│ │ - │ │ │ (load profile │ │ - │ │ │ merge config) │ │ - │ │ │ │ │ - │ │ │ PrepareEnvironment() │ - │ │ │────────────────>│ │ - │ │ │ │ (install deps, │ - │ │ │ │ copy configs) │ - │ │ │ │<───────────────│ - │ │ │ │ │ - │ │ │ CreateInstance() │ │ - │ │ │────────────────>│ │ - │ │ │ │ (start env + │ - │ │ │ │ agentctl) │ - │ │ │ │───────────────>│ - │ │ │ │ │ - │ │ ConfigureAgent() │ │ │ - │ │────────────────────>│─────────────────────────────────>│ - │ │ Start() │ │ │ - │ │────────────────────>│─────────────────────────────────>│ - │ │ │ │ │ - │<── WS events ──│<── stream updates ──│<─────────────────────────────────│ - │ │ │ │ │ - │ │ ┌──────┴──────┐ │ │ - │ │ │ Health Loop │ │ │ - │ │ │ (remote │ │ │ - │ │ │ executors) │ │ │ - │ │ └─────────────┘ │ │ -``` - -### Session Resume Flow (Remote Executors) - -``` -Backend Restart - │ - ▼ -┌─────────────────────────────────┐ -│ RecoverInstances() │ -│ for each registered executor │ -│ │ -│ Local/Worktree: │ -│ Check PID still alive │ -│ Reconnect agentctl │ -│ │ -│ Docker: │ -│ List kandev.managed │ -│ containers, reconnect │ -│ │ -│ Remote (SSH/Sprites/K8s): │ -│ Load executors_running │ -│ rows from DB │ -│ HealthCheck each │ -│ ├── healthy → reconnect │ -│ └── unhealthy → mark dead, │ -│ emit session.error │ -└─────────────────────────────────┘ -``` - ---- - -## Rename Strategy: Runtime → Executor - -### Scope of Rename - -The rename affects Go packages, interfaces, types, and variables. Frontend already uses "executor" terminology. - -``` -GO PACKAGE RENAME: - internal/agent/runtime/runtime.go - → internal/agent/executor/executor.go - - runtime.Name → executor.Name - runtime.NameDocker → executor.NameDocker - runtime.NameStandalone → executor.NameStandalone - ... - -INTERFACE RENAME (in lifecycle package): - Runtime → ExecutorBackend - RuntimeRegistry → ExecutorRegistry - RuntimeCreateRequest → ExecutorCreateRequest - RuntimeInstance → ExecutorInstance - RuntimeFallbackPolicy → ExecutorFallbackPolicy - - NewRuntimeRegistry → NewExecutorRegistry - NewDockerRuntime → NewDockerExecutor - NewStandaloneRuntime → NewStandaloneExecutor - NewRemoteDockerRuntime → NewRemoteDockerExecutor - -FILE RENAMES: - lifecycle/runtime.go → lifecycle/executor_backend.go - lifecycle/runtime_registry.go → lifecycle/executor_registry.go - lifecycle/runtime_registry_test.go → lifecycle/executor_registry_test.go - lifecycle/runtime_docker.go → lifecycle/executor_docker.go - lifecycle/runtime_standalone.go → lifecycle/executor_standalone.go - lifecycle/runtime_remote_docker.go → lifecycle/executor_remote_docker.go - -DB COLUMN (executors_running table): - "runtime" column → keep as "runtime" for DB compat, but add alias - OR rename to "executor_backend" with migration - -LOGGING: - zap.String("runtime", ...) → zap.String("executor", ...) -``` - -### Rename Procedure - -1. **Create new package**: `internal/agent/executor/` with updated names -2. **Update lifecycle package**: Rename interfaces and types -3. **Rename implementation files**: Docker, Standalone, RemoteDocker -4. **Update all imports**: `cmd/kandev/`, orchestrator, handlers -5. **DB migration**: Add `executor_backend` column, copy from `runtime`, drop `runtime` (or keep both during transition) -6. **Update tests** -7. **Delete old `internal/agent/runtime/` package** - -### Detailed File Changes - -``` -DELETED: - internal/agent/runtime/runtime.go - -CREATED: - internal/agent/executor/executor.go - -RENAMED: - lifecycle/runtime.go → lifecycle/executor_backend.go - lifecycle/runtime_registry.go → lifecycle/executor_registry.go - lifecycle/runtime_registry_test.go → lifecycle/executor_registry_test.go - lifecycle/runtime_docker.go → lifecycle/executor_docker.go - lifecycle/runtime_standalone.go → lifecycle/executor_standalone.go - lifecycle/runtime_remote_docker.go → lifecycle/executor_remote_docker.go - lifecycle/manager_runtime.go → lifecycle/manager_executor.go - -UPDATED (imports + references): - cmd/kandev/agents.go - cmd/kandev/main.go - internal/agent/lifecycle/manager.go - internal/agent/lifecycle/execution_store.go - internal/agent/lifecycle/session.go - internal/agent/lifecycle/streams.go - internal/agent/lifecycle/events.go - internal/agent/lifecycle/container.go - internal/agent/lifecycle/process_runner.go - internal/orchestrator/executor/executor.go - internal/orchestrator/handlers/*.go - internal/task/models/models.go - internal/task/repository/sqlite/executor.go -``` - ---- - -## Executor & Profile Models - -### Updated Go Models: `task/models/models.go` - -```go -// ExecutorType represents the executor type. -type ExecutorType string - -const ( - ExecutorTypeLocal ExecutorType = "local" - ExecutorTypeWorktree ExecutorType = "worktree" - ExecutorTypeDocker ExecutorType = "docker" // was "local_docker" - ExecutorTypeSSH ExecutorType = "ssh" // new - ExecutorTypeK8s ExecutorType = "k8s" // new - ExecutorTypeSprites ExecutorType = "sprites" // new - // Deprecated aliases for backward compat during migration - ExecutorTypeLocalDocker ExecutorType = "local_docker" // → docker - ExecutorTypeRemoteDocker ExecutorType = "remote_docker" // → docker + remote profile -) - -// Executor represents an execution target with profiles. -type Executor struct { - ID string `json:"id"` - Name string `json:"name"` - Type ExecutorType `json:"type"` - Status ExecutorStatus `json:"status"` - IsSystem bool `json:"is_system"` - Resumable bool `json:"resumable"` - Config map[string]string `json:"config,omitempty"` // executor-level defaults - Profiles []*ExecutorProfile `json:"profiles,omitempty"` // loaded via join - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` -} - -// ExecutorProfile represents a named configuration variant for an executor. -type ExecutorProfile struct { - ID string `json:"id"` - ExecutorID string `json:"executor_id"` - Name string `json:"name"` - IsDefault bool `json:"is_default"` - Config map[string]interface{} `json:"config"` // type-specific config (see below) - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` -} - -// ExecutorRunning tracks an active executor instance for a session. -type ExecutorRunning struct { - ID string `json:"id"` - SessionID string `json:"session_id"` - TaskID string `json:"task_id"` - ExecutorID string `json:"executor_id"` - ProfileID string `json:"profile_id,omitempty"` - ExecutorBackend string `json:"executor_backend,omitempty"` // was "runtime" - Status string `json:"status"` - Resumable bool `json:"resumable"` - ResumeToken string `json:"resume_token,omitempty"` - AgentExecutionID string `json:"agent_execution_id,omitempty"` - ContainerID string `json:"container_id,omitempty"` - RemoteID string `json:"remote_id,omitempty"` // NEW: sprite name, SSH session, pod name - AgentctlURL string `json:"agentctl_url,omitempty"` - AgentctlPort int `json:"agentctl_port,omitempty"` - PID int `json:"pid,omitempty"` - WorktreeID string `json:"worktree_id,omitempty"` - WorktreePath string `json:"worktree_path,omitempty"` - WorktreeBranch string `json:"worktree_branch,omitempty"` - HealthStatus string `json:"health_status,omitempty"` // NEW: "healthy", "unhealthy", "unknown" - HealthCheckedAt *time.Time `json:"health_checked_at,omitempty"` // NEW - ErrorMessage string `json:"error_message,omitempty"` - LastSeenAt *time.Time `json:"last_seen_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} -``` - -### Profile Config Schemas (by executor type) - -Each executor type has a specific set of config keys stored in the profile's `config` JSON: - -```go -// DockerProfileConfig — stored in executor_profiles.config for type="docker" -// { -// "host": "/var/run/docker.sock" | "tcp://remote:2376", -// "image_tag": "kandev-agent:latest", -// "dockerfile": "FROM ubuntu:22.04\nRUN ...", -// "build_config": {"build_arg": "value"}, -// "network": "bridge", -// "resource_limits": {"memory": "4g", "cpus": "2"} -// } - -// SSHProfileConfig — stored in executor_profiles.config for type="ssh" -// { -// "host": "dev.example.com", -// "port": "22", -// "user": "deploy", -// "key_secret_id": "secret-uuid-ref", // references secrets store -// "key_path": "~/.ssh/id_ed25519", // OR direct path -// "install_deps": true, -// "workspace_path": "/home/deploy/kandev" -// } - -// K8sProfileConfig — stored in executor_profiles.config for type="k8s" -// { -// "context": "my-cluster", -// "namespace": "kandev", -// "image": "kandev-agent:latest", -// "service_account": "kandev-agent", -// "resource_limits": {"memory": "4Gi", "cpu": "2"}, -// "storage_class": "standard", -// "kubeconfig_secret_id": "secret-uuid-ref" -// } - -// SpritesProfileConfig — stored in executor_profiles.config for type="sprites" -// { -// "api_token_secret_id": "secret-uuid-ref", -// "base_image": "ubuntu:22.04", -// "network_policies": ["*.npm.org", "*.github.com"], -// "copy_gh_auth": true, -// "copy_git_config": true, -// "copy_agent_config": true -// } - -// LocalProfileConfig — stored in executor_profiles.config for type="local" -// { -// (empty — no additional config needed) -// } - -// WorktreeProfileConfig — stored in executor_profiles.config for type="worktree" -// { -// "worktree_root": "~/kandev" -// } -``` - ---- - -## Backend Implementation - -### New/Renamed Package: `internal/agent/executor/` - -```go -// Package executor defines the executor backend types shared across lifecycle and policy logic. -package executor - -import "github.com/kandev/kandev/internal/task/models" - -// Name identifies the execution backend. -type Name string - -const ( - NameUnknown Name = "" - NameDocker Name = "docker" - NameStandalone Name = "standalone" - NameLocal Name = "local" - NameRemoteDocker Name = "remote_docker" // deprecated, use NameDocker + remote profile - NameSSH Name = "ssh" - NameK8s Name = "k8s" - NameSprites Name = "sprites" -) - -// ExecutorTypeToBackend maps an ExecutorType to its corresponding backend Name. -func ExecutorTypeToBackend(execType models.ExecutorType) Name { - switch execType { - case models.ExecutorTypeLocal: - return NameStandalone - case models.ExecutorTypeWorktree: - return NameStandalone - case models.ExecutorTypeDocker, models.ExecutorTypeLocalDocker: - return NameDocker - case models.ExecutorTypeRemoteDocker: - return NameRemoteDocker - case models.ExecutorTypeSSH: - return NameSSH - case models.ExecutorTypeK8s: - return NameK8s - case models.ExecutorTypeSprites: - return NameSprites - default: - return NameStandalone - } -} -``` - -### Renamed Interface: `lifecycle/executor_backend.go` - -```go -// ExecutorBackend abstracts the agent execution environment (Docker, Standalone, K8s, SSH, etc.) -// Each backend is responsible for creating and managing agentctl instances. -type ExecutorBackend interface { - // Name returns the backend identifier (e.g., "docker", "standalone", "k8s") - Name() executor.Name - - // HealthCheck verifies the backend is available and operational - HealthCheck(ctx context.Context) error - - // CreateInstance creates a new agentctl instance for a task. - CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) - - // StopInstance stops an agentctl instance. - StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error - - // RecoverInstances discovers and recovers instances that were running before a restart. - RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) - - // GetInteractiveRunner returns the interactive runner for passthrough mode. - GetInteractiveRunner() *process.InteractiveRunner - - // IsRemote returns true if this backend manages remote environments. - // Remote backends get periodic health checks and special recovery logic. - IsRemote() bool -} - -// ExecutorCreateRequest contains parameters for creating an agentctl instance. -type ExecutorCreateRequest struct { - InstanceID string - TaskID string - SessionID string - AgentProfileID string - ExecutorID string // NEW: executor DB ID - ProfileID string // NEW: profile DB ID - ProfileConfig map[string]interface{} // NEW: merged profile config - WorkspacePath string - Protocol string - Env map[string]string - Metadata map[string]interface{} - McpServers []McpServerConfig - AgentConfig agents.Agent -} - -// ExecutorInstance represents an agentctl instance created by a backend. -type ExecutorInstance struct { - // Core identifiers - InstanceID string - TaskID string - SessionID string - - // Backend name (e.g., "docker", "standalone") - set by the backend that created this instance - BackendName string - - // Agentctl client for communicating with this instance - Client *agentctl.Client - - // Backend-specific identifiers (only one set is populated) - ContainerID string // Docker - ContainerIP string // Docker - StandaloneInstanceID string // Standalone - StandalonePort int // Standalone - RemoteID string // NEW: Sprites sprite name, SSH session, K8s pod name - - // Common fields - WorkspacePath string - Metadata map[string]interface{} -} -``` - -### Executor Registry: `lifecycle/executor_registry.go` - -```go -// ExecutorRegistry manages multiple ExecutorBackend implementations. -type ExecutorRegistry struct { - backends map[executor.Name]ExecutorBackend - mu sync.RWMutex - logger *logger.Logger -} - -func NewExecutorRegistry(log *logger.Logger) *ExecutorRegistry { ... } -func (r *ExecutorRegistry) Register(backend ExecutorBackend) { ... } -func (r *ExecutorRegistry) GetBackend(name executor.Name) (ExecutorBackend, error) { ... } -func (r *ExecutorRegistry) List() []executor.Name { ... } -func (r *ExecutorRegistry) HealthCheckAll(ctx context.Context) map[executor.Name]error { ... } -func (r *ExecutorRegistry) RecoverAll(ctx context.Context) ([]*ExecutorInstance, error) { ... } -``` - -### Profile Resolution: `lifecycle/profile_resolver.go` additions - -```go -// ResolveExecutorProfile loads the executor and profile, merging configs. -// Returns the merged config that includes executor-level defaults + profile overrides. -func (r *StoreProfileResolver) ResolveExecutorProfile( - ctx context.Context, - executorID string, - profileID string, -) (*models.Executor, *models.ExecutorProfile, map[string]interface{}, error) { - // 1. Load executor from DB - // 2. Load profile from DB (or default profile if profileID empty) - // 3. Merge: executor.Config (base) + profile.Config (override) - // 4. Return merged config -} -``` - -### Database Changes - -#### New Table: `executor_profiles` - -```sql -CREATE TABLE IF NOT EXISTS executor_profiles ( - id TEXT PRIMARY KEY, - executor_id TEXT NOT NULL REFERENCES executors(id) ON DELETE CASCADE, - name TEXT NOT NULL, - is_default INTEGER NOT NULL DEFAULT 0, - config TEXT NOT NULL DEFAULT '{}', -- JSON: type-specific config - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP DEFAULT NULL, - UNIQUE(executor_id, name) -); - -CREATE INDEX IF NOT EXISTS idx_executor_profiles_executor_id - ON executor_profiles(executor_id); -``` - -#### Column Changes: `executors_running` - -```sql --- Add new columns -ALTER TABLE executors_running ADD COLUMN profile_id TEXT DEFAULT ''; -ALTER TABLE executors_running ADD COLUMN executor_backend TEXT DEFAULT ''; -ALTER TABLE executors_running ADD COLUMN remote_id TEXT DEFAULT ''; -ALTER TABLE executors_running ADD COLUMN health_status TEXT DEFAULT 'unknown'; -ALTER TABLE executors_running ADD COLUMN health_checked_at TIMESTAMP DEFAULT NULL; - --- Migrate runtime → executor_backend -UPDATE executors_running SET executor_backend = runtime WHERE executor_backend = ''; -``` - -#### Executor Type Migration - -```sql --- Migrate old executor types -UPDATE executors SET type = 'docker' WHERE type = 'local_docker'; --- remote_docker executors get a "remote" profile instead -``` - -### Profile CRUD Repository: `repository/sqlite/executor_profile.go` - -```go -func (r *Repository) CreateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error -func (r *Repository) GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error) -func (r *Repository) UpdateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error -func (r *Repository) DeleteExecutorProfile(ctx context.Context, id string) error -func (r *Repository) ListExecutorProfiles(ctx context.Context, executorID string) ([]*models.ExecutorProfile, error) -func (r *Repository) GetDefaultExecutorProfile(ctx context.Context, executorID string) (*models.ExecutorProfile, error) -``` - -### Repository Interface Addition: `repository/interface.go` - -```go -// Add to Repository interface: -CreateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error -GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error) -UpdateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error -DeleteExecutorProfile(ctx context.Context, id string) error -ListExecutorProfiles(ctx context.Context, executorID string) ([]*models.ExecutorProfile, error) -GetDefaultExecutorProfile(ctx context.Context, executorID string) (*models.ExecutorProfile, error) -``` - -### Default Executors & Profiles: `repository/sqlite/defaults.go` - -```go -func ensureDefaultExecutorsAndProfiles(ctx context.Context, db *sqlx.DB) error { - // exec-local (Local) + "default" profile (empty config) - // exec-worktree (Worktree) + "default" profile (worktree_root: ~/kandev) - // exec-docker (Docker) + "local" profile (host: /var/run/docker.sock) - // - // If old exec-local-docker exists, migrate to exec-docker - // If old environments exist, migrate config into profiles -} -``` - ---- - -## Environment Preparation Abstraction - -The environment preparation layer runs **before** agent process launch and is responsible for making the workspace ready. For local executors this means git pull + worktree creation; for Docker it means image builds; for remote executors it means installing dependencies and copying configs. - -Each preparation step emits real-time progress events to the frontend so the user can see exactly what's happening (commands being run, stdout/stderr, exit codes). - -### Interface: `lifecycle/env_preparer.go` - -```go -// EnvironmentPreparer handles setting up an executor environment -// before agent process launch. This includes git operations, workspace -// setup, dependency installation, and configuration. -type EnvironmentPreparer interface { - // Prepare sets up the environment for agent execution. - // Called before CreateInstance for local/worktree (workspace must exist first), - // or after CreateInstance for Docker/remote (container must exist to run commands in). - // Emits PrepareProgress events via the provided callback. - Prepare(ctx context.Context, req *EnvPrepareRequest) (*EnvPrepareResult, error) - - // Cleanup tears down any environment-specific resources. - Cleanup(ctx context.Context, instanceID string) error -} - -// EnvPrepareRequest contains the info needed to prepare an environment. -type EnvPrepareRequest struct { - TaskID string - SessionID string - ExecutionID string // For event correlation - - // Workspace - RepositoryPath string // Main repo path on host (local/worktree) - RepositoryURL string // HTTPS clone URL (remote executors — no local filesystem) - RepositoryID string - BaseBranch string - WorkspacePath string // Resolved workspace path (may be overridden by result) - - // Worktree config (local/worktree executors) - UseWorktree bool - TaskTitle string - WorktreeBranchPrefix string - PullBeforeWorktree bool - WorktreeID string // For session resumption - - // Instance (set for Docker/remote — nil for local/worktree pre-instance prep) - Instance *ExecutorInstance - - // Agent & profile - AgentConfig agents.Agent - ProfileConfig map[string]interface{} - - // Remote preparation flags (from profile config) - CopyGHAuth bool - CopyGitConfig bool - CopyAgentConfig bool - InstallDeps []string - - // Progress callback — preparer calls this for each step - OnProgress func(event *PrepareProgressEvent) -} - -// EnvPrepareResult contains the outcome of environment preparation. -type EnvPrepareResult struct { - WorkspacePath string // Final workspace path (may differ if worktree created) - WorktreeID string // Worktree ID if created - WorktreePath string // Worktree filesystem path - WorktreeBranch string // Worktree branch name -} - -// PrepareProgressEvent represents a single preparation step update. -// Streamed to the frontend in real-time. -type PrepareProgressEvent struct { - TaskID string `json:"task_id"` - SessionID string `json:"session_id"` - ExecutionID string `json:"execution_id"` - - // Phase & step identification - Phase string `json:"phase"` // "deps" or "repo" - Step string `json:"step"` // e.g., "git_fetch", "git_pull", "git_clone", "worktree_create", "setup_script", "install_agentctl", "copy_gh_auth" - StepIndex int `json:"step_index"` // 0-based index within the phase - TotalSteps int `json:"total_steps"` // Total expected steps in this phase - - // Status - Status string `json:"status"` // "running", "completed", "failed", "skipped" - - // Command execution details (shown to user) - Command string `json:"command,omitempty"` // e.g., "git fetch origin main" - Stdout string `json:"stdout,omitempty"` // Captured stdout - Stderr string `json:"stderr,omitempty"` // Captured stderr - ExitCode *int `json:"exit_code,omitempty"` // Process exit code (nil while running) - - // Human-readable - Message string `json:"message,omitempty"` // e.g., "Fetching latest changes from origin..." - Error string `json:"error,omitempty"` // Error message if failed - - Timestamp string `json:"timestamp"` -} -``` - -### Preparer Registry - -```go -// PreparerRegistry maps executor types to their preparers. -type PreparerRegistry struct { - preparers map[executor.Name]EnvironmentPreparer -} - -func (r *PreparerRegistry) Register(name executor.Name, preparer EnvironmentPreparer) -func (r *PreparerRegistry) Get(name executor.Name) (EnvironmentPreparer, bool) -``` - -### Preparation Phases - -Every preparer runs two phases in order. Either phase may be a no-op depending on executor type. - -``` -Phase 1: DEPS — install tools, copy configs & credentials -Phase 2: REPO — git clone/pull, worktree creation, repository setup script -``` - -Why this order: credentials and tools (e.g., git config, gh auth, SSH keys) must be available before git clone/pull can succeed on private repos. - -### Built-in Preparers - -``` -┌────────────────────────────────────────────────────────────────────────────┐ -│ EnvironmentPreparer │ -│ (interface) │ -├─────────────────────┬────────────────────┬────────────────────────────────┤ -│ LocalPreparer │ DockerPreparer │ RemotePreparer │ -│ (local executor) │ (local docker) │ (SSH, Sprites, K8s) │ -│ │ │ │ -│ DEPS: no-op │ DEPS: build image │ DEPS: install agentctl, │ -│ REPO: git pull │ if needed │ install agent CLI, │ -│ + setup script│ REPO: git pull │ copy gh auth/git config,│ -│ │ (inside │ copy agent config │ -│ WorktreePreparer │ container, │ REPO: git clone , │ -│ (worktree executor) │ repo is │ checkout branch, │ -│ │ mounted) │ run setup script │ -│ DEPS: no-op │ │ │ -│ REPO: git pull + │ │ │ -│ worktree add +│ │ │ -│ setup script │ │ │ -└─────────────────────┴────────────────────┴────────────────────────────────┘ -``` - -```go -// LocalPreparer — for local executor: pull repo + run setup script -type LocalPreparer struct { - repoProvider worktree.RepositoryProvider - logger *logger.Logger -} -func (p *LocalPreparer) Prepare(ctx context.Context, req *EnvPrepareRequest) (*EnvPrepareResult, error) { - result := &EnvPrepareResult{WorkspacePath: req.RepositoryPath} - - // === Phase 1: DEPS — no-op for local (tools already on host) === - - // === Phase 2: REPO — git pull + setup script === - - if !req.PullBeforeWorktree { - return result, nil - } - - // Step 1: git fetch origin - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "git_fetch", StepIndex: 0, TotalSteps: 3, - Status: "running", Command: fmt.Sprintf("git fetch origin %s", req.BaseBranch), - Message: "Fetching latest changes...", - }) - stdout, stderr, exitCode, err := runGitCommand(ctx, req.RepositoryPath, - "git", "fetch", "origin", req.BaseBranch) - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "git_fetch", StepIndex: 0, TotalSteps: 3, - Status: statusFromExit(exitCode, err), - Command: fmt.Sprintf("git fetch origin %s", req.BaseBranch), - Stdout: stdout, Stderr: stderr, ExitCode: &exitCode, - }) - - // Step 2: git pull --ff-only (if on the base branch) - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "git_pull", StepIndex: 1, TotalSteps: 3, - Status: "running", Command: fmt.Sprintf("git pull --ff-only origin %s", req.BaseBranch), - Message: "Pulling latest changes...", - }) - stdout, stderr, exitCode, err = runGitCommand(ctx, req.RepositoryPath, - "git", "pull", "--ff-only", "origin", req.BaseBranch) - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "git_pull", StepIndex: 1, TotalSteps: 3, - Status: statusFromExit(exitCode, err), - Command: fmt.Sprintf("git pull --ff-only origin %s", req.BaseBranch), - Stdout: stdout, Stderr: stderr, ExitCode: &exitCode, - }) - // Pull failures are non-fatal — continue with current HEAD - - // Step 3: Run repository setup script (if exists) - p.runSetupScript(ctx, req, result, 2, 3) - - return result, nil -} - -// WorktreePreparer — for worktree executor: pull + worktree create + setup script -type WorktreePreparer struct { - worktreeMgr *worktree.Manager - repoProvider worktree.RepositoryProvider - logger *logger.Logger -} -func (p *WorktreePreparer) Prepare(ctx context.Context, req *EnvPrepareRequest) (*EnvPrepareResult, error) { - totalSteps := 3 // fetch + worktree create + setup script - if req.PullBeforeWorktree { - totalSteps = 4 // fetch + pull + worktree create + setup script - } - stepIdx := 0 - - // === Phase 1: DEPS — no-op for local worktree (tools already on host) === - - // === Phase 2: REPO — git pull + worktree + setup script === - - // Step 1: git fetch - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "git_fetch", StepIndex: stepIdx, TotalSteps: totalSteps, - Status: "running", Command: fmt.Sprintf("git fetch origin %s", req.BaseBranch), - Message: "Fetching latest changes...", - }) - // ... run git fetch, emit completion event ... - stepIdx++ - - // Step 2: git pull --ff-only (if configured) - if req.PullBeforeWorktree { - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "git_pull", StepIndex: stepIdx, TotalSteps: totalSteps, - Status: "running", Message: "Pulling latest changes...", - }) - // ... run git pull, emit completion event (non-fatal) ... - stepIdx++ - } - - // Step 3: Create git worktree - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "worktree_create", StepIndex: stepIdx, TotalSteps: totalSteps, - Status: "running", - Command: fmt.Sprintf("git worktree add -b %s", req.BaseBranch), - Message: "Creating isolated worktree...", - }) - wt, err := p.worktreeMgr.Create(ctx, &worktree.CreateRequest{ - TaskID: req.TaskID, - SessionID: req.SessionID, - TaskTitle: req.TaskTitle, - RepositoryID: req.RepositoryID, - RepositoryPath: req.RepositoryPath, - BaseBranch: req.BaseBranch, - WorktreeBranchPrefix: req.WorktreeBranchPrefix, - PullBeforeWorktree: false, // Already pulled above - WorktreeID: req.WorktreeID, - }) - if err != nil { - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "worktree_create", StepIndex: stepIdx, TotalSteps: totalSteps, - Status: "failed", Error: err.Error(), - }) - return nil, fmt.Errorf("create worktree: %w", err) - } - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "worktree_create", StepIndex: stepIdx, TotalSteps: totalSteps, - Status: "completed", - Message: fmt.Sprintf("Created worktree at %s (branch: %s)", wt.Path, wt.Branch), - }) - stepIdx++ - - // Step 4: Run repository setup script (if exists) - result := &EnvPrepareResult{ - WorkspacePath: wt.Path, - WorktreeID: wt.ID, - WorktreePath: wt.Path, - WorktreeBranch: wt.Branch, - } - repo, err := p.repoProvider.GetRepository(ctx, req.RepositoryID) - if err == nil && repo != nil && strings.TrimSpace(repo.SetupScript) != "" { - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "setup_script", StepIndex: stepIdx, TotalSteps: totalSteps, - Status: "running", - Command: repo.SetupScript, - Message: "Running repository setup script...", - }) - stdout, stderr, exitCode, err := runScript(ctx, wt.Path, repo.SetupScript) - if err != nil { - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "setup_script", StepIndex: stepIdx, TotalSteps: totalSteps, - Status: "failed", - Command: repo.SetupScript, - Stdout: stdout, Stderr: stderr, ExitCode: &exitCode, - Error: err.Error(), - }) - // Cleanup worktree on setup script failure (mirrors existing behavior) - p.worktreeMgr.Delete(ctx, wt.ID) - return nil, fmt.Errorf("setup script failed: %w", err) - } - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "setup_script", StepIndex: stepIdx, TotalSteps: totalSteps, - Status: "completed", - Command: repo.SetupScript, - Stdout: stdout, Stderr: stderr, ExitCode: &exitCode, - Message: "Setup script completed", - }) - } else { - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "setup_script", StepIndex: stepIdx, TotalSteps: totalSteps, - Status: "skipped", Message: "No setup script configured", - }) - } - - return result, nil -} - -// DockerPreparer — builds Docker image if Dockerfile provided in profile -type DockerPreparer struct { - docker *docker.Client - repoProvider worktree.RepositoryProvider - logger *logger.Logger -} -func (p *DockerPreparer) Prepare(ctx context.Context, req *EnvPrepareRequest) (*EnvPrepareResult, error) { - result := &EnvPrepareResult{WorkspacePath: req.WorkspacePath} - - // === Phase 1: DEPS — Docker image build === - // 1. Check if image exists → emit progress - // 2. If Dockerfile in profile config, build image → emit progress with build output - // 3. Tag with profile-specific tag → emit progress - - // === Phase 2: REPO — repo is bind-mounted from host, pull inside container === - // Repo is already on the host filesystem and mounted into the container. - // If pull_before requested, run git pull inside the container via agentctl shell. - // Run setup script inside container via agentctl shell if configured. - - return result, nil -} - -// RemotePreparer — base for SSH/Sprites/K8s (run commands on remote instance) -type RemotePreparer struct { - repoProvider worktree.RepositoryProvider - logger *logger.Logger -} -func (p *RemotePreparer) Prepare(ctx context.Context, req *EnvPrepareRequest) (*EnvPrepareResult, error) { - result := &EnvPrepareResult{WorkspacePath: req.WorkspacePath} - - // === Phase 1: DEPS — install tools, copy configs & credentials === - // All steps run commands on the remote instance via agentctl shell. - // Credentials must be set up BEFORE git clone (private repos need auth). - - // Step 1: Install agentctl (if not present) - req.OnProgress(&PrepareProgressEvent{ - Phase: "deps", Step: "install_agentctl", StepIndex: 0, TotalSteps: depsTotal, - Status: "running", Message: "Checking agentctl on remote host...", - }) - // ... run via instance shell, emit completion ... - - // Step 2: Install agent CLI - req.OnProgress(&PrepareProgressEvent{ - Phase: "deps", Step: "install_agent_cli", StepIndex: 1, TotalSteps: depsTotal, - Status: "running", - Command: "npm install -g @anthropics/claude-code", - Message: "Installing agent CLI...", - }) - // ... run via instance shell, emit completion ... - - // Step 3: Copy git config (if configured) - if req.CopyGitConfig { - req.OnProgress(&PrepareProgressEvent{ - Phase: "deps", Step: "copy_git_config", Status: "running", - Message: "Copying git configuration...", - }) - // ... scp or write ~/.gitconfig on remote, emit completion ... - } - - // Step 4: Copy GH auth token (if configured) - if req.CopyGHAuth { - req.OnProgress(&PrepareProgressEvent{ - Phase: "deps", Step: "copy_gh_auth", Status: "running", - Message: "Copying GitHub CLI authentication...", - }) - // ... scp or write gh auth config on remote, emit completion ... - } - - // Step 5: Copy agent config (if configured) - if req.CopyAgentConfig { - // ... copy agent-specific config files ... - } - - // Step 6: Install additional dependencies (if configured) - for _, dep := range req.InstallDeps { - req.OnProgress(&PrepareProgressEvent{ - Phase: "deps", Step: "install_dep", Status: "running", - Command: fmt.Sprintf("apt-get install -y %s", dep), - Message: fmt.Sprintf("Installing %s...", dep), - }) - // ... run on remote, emit completion ... - } - - // === Phase 2: REPO — git clone + checkout + setup script === - // Remote host has no local filesystem access — must clone the repo. - - // Step 1: git clone - workspacePath := getRemoteWorkspacePath(req.ProfileConfig) - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "git_clone", Status: "running", - Command: fmt.Sprintf("git clone %s %s", req.RepositoryURL, workspacePath), - Message: "Cloning repository on remote host...", - }) - stdout, stderr, exitCode, err := runRemoteCommand(ctx, req.Instance, - "git", "clone", req.RepositoryURL, workspacePath) - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "git_clone", - Status: statusFromExit(exitCode, err), - Command: fmt.Sprintf("git clone %s %s", req.RepositoryURL, workspacePath), - Stdout: stdout, Stderr: stderr, ExitCode: &exitCode, - }) - if err != nil { - return nil, fmt.Errorf("git clone failed: %w", err) - } - result.WorkspacePath = workspacePath - - // Step 2: git checkout (if specific branch requested) - if req.BaseBranch != "" { - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "git_checkout", Status: "running", - Command: fmt.Sprintf("git checkout %s", req.BaseBranch), - Message: fmt.Sprintf("Checking out branch %s...", req.BaseBranch), - }) - stdout, stderr, exitCode, err = runRemoteCommand(ctx, req.Instance, - "git", "-C", workspacePath, "checkout", req.BaseBranch) - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "git_checkout", - Status: statusFromExit(exitCode, err), - Stdout: stdout, Stderr: stderr, ExitCode: &exitCode, - }) - // Checkout failure is non-fatal (may already be on the branch) - } - - // Step 3: Run repository setup script (if exists) - repo, err := p.repoProvider.GetRepository(ctx, req.RepositoryID) - if err == nil && repo != nil && strings.TrimSpace(repo.SetupScript) != "" { - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "setup_script", Status: "running", - Command: repo.SetupScript, - Message: "Running repository setup script...", - }) - stdout, stderr, exitCode, err = runRemoteCommand(ctx, req.Instance, - "sh", "-c", fmt.Sprintf("cd %s && %s", workspacePath, repo.SetupScript)) - status := statusFromExit(exitCode, err) - req.OnProgress(&PrepareProgressEvent{ - Phase: "repo", Step: "setup_script", Status: status, - Command: repo.SetupScript, - Stdout: stdout, Stderr: stderr, ExitCode: &exitCode, - }) - if err != nil { - return nil, fmt.Errorf("setup script failed on remote: %w", err) - } - } - - return result, nil -} -``` - -### Integration Point - -```go -// In lifecycle manager LaunchAgent(): -func (m *Manager) LaunchAgent(ctx context.Context, req LaunchRequest) error { - // 1. Resolve executor + profile - exec, profile, mergedConfig := m.profileResolver.ResolveExecutorProfile(...) - - // 2. Get executor backend - backendName := executor.ExecutorTypeToBackend(exec.Type) - backend, err := m.executorRegistry.GetBackend(backendName) - - // 3. Build progress callback that publishes events to frontend - onProgress := func(event *PrepareProgressEvent) { - event.TaskID = req.TaskID - event.SessionID = req.SessionID - event.ExecutionID = req.ExecutionID - event.Timestamp = time.Now().UTC().Format(time.RFC3339Nano) - m.eventPublisher.PublishPrepareProgress(event) - } - - // 4. Prepare environment (NEW — runs BEFORE CreateInstance for local/worktree) - preparer, _ := m.preparerRegistry.Get(backendName) - if preparer != nil { - m.eventPublisher.PublishPrepareStarted(req.TaskID, req.SessionID) - - result, err := preparer.Prepare(ctx, &EnvPrepareRequest{ - TaskID: req.TaskID, - SessionID: req.SessionID, - RepositoryPath: req.RepositoryPath, - RepositoryID: req.RepositoryID, - BaseBranch: req.BaseBranch, - UseWorktree: req.UseWorktree, - TaskTitle: req.TaskTitle, - WorktreeBranchPrefix: req.WorktreeBranchPrefix, - PullBeforeWorktree: req.PullBeforeWorktree, - WorktreeID: req.WorktreeID, - ProfileConfig: mergedConfig, - OnProgress: onProgress, - }) - if err != nil { - m.eventPublisher.PublishPrepareFailed(req.TaskID, req.SessionID, err) - return fmt.Errorf("prepare environment: %w", err) - } - - // Apply result (workspace path may have changed due to worktree) - if result.WorkspacePath != "" { - req.WorkspacePath = result.WorkspacePath - } - // Store worktree metadata for later use - if result.WorktreeID != "" { - req.Metadata[MetadataKeyWorktreeID] = result.WorktreeID - req.Metadata[MetadataKeyWorktreeBranch] = result.WorktreeBranch - } - - m.eventPublisher.PublishPrepareCompleted(req.TaskID, req.SessionID) - } - - // 5. Create instance (container or standalone process) - instance, err := backend.CreateInstance(ctx, &ExecutorCreateRequest{ - ProfileConfig: mergedConfig, - ... - }) - - // 6. For Docker/remote: run post-instance preparation (image build, deps install) - // (uses instance.Client to run commands inside container) - - // 7. Configure and start agent (existing flow) - // ... -} -``` - -### Execution Flow by Executor Type - -``` -LOCAL EXECUTOR: - Prepare (pre-instance): - DEPS: (no-op — tools already on host) - REPO: - 1. git fetch origin → progress event - 2. git pull --ff-only origin → progress event (non-fatal) - 3. Run repository setup script → progress event (if configured) - CreateInstance: - 4. Start standalone agentctl process - Start: - 5. Configure + start agent subprocess - -WORKTREE EXECUTOR: - Prepare (pre-instance): - DEPS: (no-op — tools already on host) - REPO: - 1. git fetch origin → progress event - 2. git pull --ff-only origin → progress event (non-fatal) - 3. git worktree add -b → progress event - 4. Run repository setup script → progress event (if configured, fatal on failure) - CreateInstance: - 5. Start standalone agentctl process (in worktree dir) - Start: - 6. Configure + start agent subprocess - -DOCKER EXECUTOR: - Prepare (pre-instance — image build on host): - DEPS: - 1. Check/build Docker image → progress event - CreateInstance: - 2. Create + start Docker container (repo mounted from host) - Prepare (post-instance — inside container): - REPO: - 3. git pull (if configured) → progress event (via agentctl shell) - 4. Run repository setup script → progress event (if configured) - Start: - 5. Configure + start agent subprocess - -REMOTE EXECUTOR (SSH/Sprites/K8s): - CreateInstance: - 1. Create remote instance (VM, pod, sprite) - Prepare (post-instance — on remote): - DEPS: - 2. Install agentctl binary → progress event - 3. Install agent CLI → progress event - 4. Copy git config → progress event (if configured) - 5. Copy GH auth / SSH keys → progress event (if configured) - 6. Copy agent config → progress event (if configured) - 7. Install additional packages → progress event (if configured) - REPO: - 8. git clone → progress event (FATAL on failure) - 9. git checkout → progress event (non-fatal) - 10. Run repository setup script → progress event (if configured, fatal) - Start: - 11. Configure + start agent subprocess -``` - ---- - -## Health Check System - -### Architecture - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ Health Check Loop │ -│ │ -│ lifecycle.Manager.healthCheckLoop() (goroutine) │ -│ │ -│ Every 30s: │ -│ for each running execution: │ -│ if execution.backend.IsRemote(): │ -│ err := execution.agentctl.HealthCheck(ctx) │ -│ if err: │ -│ execution.healthStatus = "unhealthy" │ -│ execution.healthFailCount++ │ -│ if healthFailCount >= 3: │ -│ emit SessionHealthDegraded event │ -│ else: │ -│ execution.healthStatus = "healthy" │ -│ execution.healthFailCount = 0 │ -│ execution.healthCheckedAt = now() │ -│ upsert executors_running (health_status, health_checked_at)│ -│ emit ExecutorHealthUpdate event │ -│ │ -│ Events: │ -│ executor.health_update → {session_id, status, checked_at} │ -│ session.health_degraded → {session_id, error} │ -└──────────────────────────────────────────────────────────────────┘ -``` - -### Implementation - -```go -// In lifecycle manager: -const ( - healthCheckInterval = 30 * time.Second - healthCheckTimeout = 10 * time.Second - healthFailThreshold = 3 -) - -func (m *Manager) healthCheckLoop(ctx context.Context) { - ticker := time.NewTicker(healthCheckInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - m.runHealthChecks(ctx) - } - } -} - -func (m *Manager) runHealthChecks(ctx context.Context) { - executions := m.executionStore.ListActive() - for _, exec := range executions { - // Only health-check remote executors - backend, err := m.executorRegistry.GetBackend(executor.Name(exec.BackendName)) - if err != nil || !backend.IsRemote() { - continue - } - - checkCtx, cancel := context.WithTimeout(ctx, healthCheckTimeout) - err = exec.agentctl.HealthCheck(checkCtx) - cancel() - - if err != nil { - exec.healthFailCount++ - m.updateHealthStatus(exec, "unhealthy") - if exec.healthFailCount >= healthFailThreshold { - m.eventPublisher.PublishSessionHealthDegraded(exec.SessionID, err) - } - } else { - exec.healthFailCount = 0 - m.updateHealthStatus(exec, "healthy") - } - } -} -``` - -### Health Status Events (WS) - -```json -{ - "type": "executor.health_update", - "payload": { - "session_id": "sess-123", - "health_status": "healthy", // "healthy" | "unhealthy" | "unknown" - "health_checked_at": "2026-02-20T10:30:00Z" - } -} -``` - ---- - -## API Endpoints - -### Executor Profile Endpoints - -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/api/v1/executors/:id/profiles` | List profiles for executor | -| `POST` | `/api/v1/executors/:id/profiles` | Create profile | -| `GET` | `/api/v1/executors/:id/profiles/:profileId` | Get profile | -| `PATCH` | `/api/v1/executors/:id/profiles/:profileId` | Update profile | -| `DELETE` | `/api/v1/executors/:id/profiles/:profileId` | Delete profile | - -### Updated Executor Endpoints - -The existing executor endpoints remain but responses now include profiles: - -```json -// GET /api/v1/executors/:id — now includes profiles -{ - "id": "exec-docker", - "name": "Docker", - "type": "docker", - "status": "active", - "is_system": false, - "resumable": true, - "config": {}, - "profiles": [ - { - "id": "prof-local", - "name": "local", - "is_default": true, - "config": { - "host": "/var/run/docker.sock", - "image_tag": "kandev-agent:latest" - } - }, - { - "id": "prof-remote", - "name": "remote-server", - "is_default": false, - "config": { - "host": "tcp://192.168.1.100:2376", - "image_tag": "kandev-agent:latest" - } - } - ] -} -``` - -### Session Start Request Update - -```json -// task.start payload — now includes profile_id -{ - "task_id": "task-123", - "executor_id": "exec-docker", - "profile_id": "prof-remote", // NEW: optional, defaults to is_default profile - "agent_profile_id": "agent-prof-123", - "workspace_id": "ws-123" -} -``` - -### Health Status in Session Response - -```json -// GET /api/v1/sessions/:id — now includes executor health -{ - "id": "sess-123", - "executor_id": "exec-docker", - "profile_id": "prof-remote", - "executor_health": { - "status": "healthy", - "checked_at": "2026-02-20T10:30:00Z" - } -} -``` - -### WebSocket Actions - -| Action | Payload | Description | -|--------|---------|-------------| -| `executor.profile.list` | `{executor_id}` | List profiles | -| `executor.profile.create` | `{executor_id, name, config, is_default?}` | Create profile | -| `executor.profile.update` | `{id, name?, config?, is_default?}` | Update profile | -| `executor.profile.delete` | `{id}` | Delete profile | - -### Preparation Progress Events (WS Notifications) - -These are server-push events sent during environment preparation. The frontend subscribes to these to show real-time progress. - -```json -// executor.prepare.started — preparation phase begins -{ - "type": "executor.prepare.started", - "payload": { - "task_id": "task-123", - "session_id": "sess-456", - "timestamp": "2026-02-20T10:30:00.000Z" - } -} - -// executor.prepare.progress — individual step update (sent multiple times) -{ - "type": "executor.prepare.progress", - "payload": { - "task_id": "task-123", - "session_id": "sess-456", - "execution_id": "exec-789", - "phase": "repo", // "deps" | "repo" - "step": "git_fetch", - "step_index": 0, - "total_steps": 4, - "status": "completed", // "running" | "completed" | "failed" | "skipped" - "command": "git fetch origin main", - "stdout": "From github.com:org/repo\n * branch main -> FETCH_HEAD\n", - "stderr": "", - "exit_code": 0, - "message": "Fetched latest changes from origin", - "timestamp": "2026-02-20T10:30:01.234Z" - } -} - -// executor.prepare.completed — preparation phase done -{ - "type": "executor.prepare.completed", - "payload": { - "task_id": "task-123", - "session_id": "sess-456", - "worktree_id": "wt-abc", - "worktree_path": "/home/user/kandev/worktrees/fix-login-bug_a1b2c3d4", - "worktree_branch": "feature/fix-login-bug-xyz", - "timestamp": "2026-02-20T10:30:03.000Z" - } -} - -// executor.prepare.failed — preparation failed -{ - "type": "executor.prepare.failed", - "payload": { - "task_id": "task-123", - "session_id": "sess-456", - "error": "create worktree: git worktree add failed: exit code 128", - "step": "worktree_create", - "timestamp": "2026-02-20T10:30:02.000Z" - } -} -``` - ---- - -## Frontend Implementation - -### Type Updates: `lib/types/http.ts` - -```typescript -export type ExecutorType = - | 'local' - | 'worktree' - | 'docker' // replaces 'local_docker' - | 'ssh' - | 'k8s' - | 'sprites' - | 'local_docker' // deprecated alias - | 'remote_docker'; // deprecated alias - -export interface ExecutorProfile { - id: string; - executor_id: string; - name: string; - is_default: boolean; - config: Record; - created_at: string; - updated_at: string; -} - -export interface Executor { - id: string; - name: string; - type: ExecutorType; - status: 'active' | 'disabled'; - is_system: boolean; - resumable: boolean; - config?: Record; - profiles?: ExecutorProfile[]; - created_at: string; - updated_at: string; -} - -export interface ExecutorHealth { - status: 'healthy' | 'unhealthy' | 'unknown'; - checked_at?: string; -} -``` - -### Executor Icons Update: `lib/executor-icons.ts` - -```typescript -// Add new executor type icons: -const executorIcons: Record = { - local: IconFolder, - worktree: IconFolders, - docker: IconBrandDocker, // was local_docker - local_docker: IconBrandDocker, // deprecated alias - remote_docker: IconCloud, // deprecated alias - ssh: IconTerminal2, // NEW - k8s: IconBrandKubernetes, // NEW (or IconHexagons) - sprites: IconSparkles, // NEW -}; - -const executorLabels: Record = { - local: 'Local', - worktree: 'Worktree', - docker: 'Docker', - local_docker: 'Docker', // deprecated alias - remote_docker: 'Remote Docker', // deprecated alias - ssh: 'SSH', - k8s: 'Kubernetes', - sprites: 'Sprites', -}; -``` - -### Settings Store Update: `lib/state/slices/settings/settings-slice.ts` - -```typescript -// Remove environments state, add profiles to executors: -executors: { - items: Executor[]; // now includes profiles[] - loading: boolean; - loaded: boolean; -} - -// Remove: -// environments: { items: Environment[] } -``` - -### Executor Settings Page: `components/settings/executor-page.tsx` - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ Docker Executor [Active ●] │ -│──────────────────────────────────────────────────────────────────│ -│ │ -│ Type: Docker │ -│ System: No Resumable: Yes │ -│ │ -│ ┌─── Profiles ───────────────────────────────────────────────┐ │ -│ │ [+ Add]│ │ -│ │ ┌─────────────────────────────────────────────────────┐ │ │ -│ │ │ ★ local (default) │ │ │ -│ │ │ Host: /var/run/docker.sock │ │ │ -│ │ │ Image: kandev-agent:latest │ │ │ -│ │ │ [Edit] [Set Default] │ │ │ -│ │ └─────────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌─────────────────────────────────────────────────────┐ │ │ -│ │ │ remote-server │ │ │ -│ │ │ Host: tcp://192.168.1.100:2376 │ │ │ -│ │ │ Image: kandev-agent:latest │ │ │ -│ │ │ [Edit] [Set Default] [Delete]│ │ │ -│ │ └─────────────────────────────────────────────────────┘ │ │ -│ └────────────────────────────────────────────────────────────┘ │ -│ │ -│ [Delete Executor] │ -└──────────────────────────────────────────────────────────────────┘ -``` - -### Profile Edit Dialog - -``` -┌──────────────────────────────────────────────┐ -│ Edit Profile: local │ -│──────────────────────────────────────────────│ -│ │ -│ Name: [local_______________] │ -│ │ -│ Docker Host: │ -│ [/var/run/docker.sock_______] │ -│ │ -│ Image Tag: │ -│ [kandev-agent:latest________] │ -│ │ -│ Dockerfile: (optional) │ -│ ┌──────────────────────────────────┐ │ -│ │ FROM ubuntu:22.04 │ │ -│ │ RUN apt-get update && ... │ │ -│ └──────────────────────────────────┘ │ -│ │ -│ ☐ Set as default profile │ -│ │ -│ [Cancel] [Save] │ -└──────────────────────────────────────────────┘ -``` - -The dialog renders different fields based on executor type using a config schema per type. - -### Health Indicator on Session Cards - -``` -┌──────────────────────────────────────┐ -│ Task: Fix login bug │ -│ Session #1 │ -│ │ -│ Agent: Claude Code • auto-approve │ -│ Executor: Docker • remote-server │ -│ Health: ● healthy │ ← green dot for healthy -│ │ ← red dot for unhealthy -│ Status: Running │ ← gray dot for unknown -│ ... │ -└──────────────────────────────────────┘ -``` - -Implementation in session card component: - -```typescript -function ExecutorHealthDot({ health }: { health?: ExecutorHealth }) { - if (!health) return null; - - const colors = { - healthy: 'bg-green-500', - unhealthy: 'bg-red-500', - unknown: 'bg-gray-400', - }; - - return ( - - ); -} -``` - -### WS Handler for Health Updates - -```typescript -// lib/ws/handlers/executor-health.ts -export function registerExecutorHealthHandlers(dispatcher: WSDispatcher) { - dispatcher.on('executor.health_update', (payload) => { - const { session_id, health_status, health_checked_at } = payload; - useAppStore.getState().updateSessionExecutorHealth(session_id, { - status: health_status, - checked_at: health_checked_at, - }); - }); -} -``` - -### Preparation Progress State & Components - -#### Types: `lib/types/backend.ts` - -```typescript -export interface PrepareProgressEvent { - task_id: string; - session_id: string; - execution_id: string; - phase: 'deps' | 'repo'; - step: string; // "git_fetch" | "git_pull" | "git_clone" | "git_checkout" | "worktree_create" | "setup_script" | "install_agentctl" | "install_agent_cli" | "copy_gh_auth" | "copy_git_config" | ... - step_index: number; - total_steps: number; - status: 'running' | 'completed' | 'failed' | 'skipped'; - command?: string; - stdout?: string; - stderr?: string; - exit_code?: number; - message?: string; - error?: string; - timestamp: string; -} - -export type PrepareStatus = 'idle' | 'preparing' | 'completed' | 'failed'; - -export interface SessionPrepareState { - status: PrepareStatus; - steps: PrepareProgressEvent[]; - error?: string; -} -``` - -#### State: `lib/state/slices/session-runtime/types.ts` - -```typescript -// Add to SessionRuntimeSlice: -prepare: { - bySessionId: Record; -}; - -// Actions: -setPrepareStarted: (sessionId: string) => void; -addPrepareStep: (sessionId: string, step: PrepareProgressEvent) => void; -setPrepareCompleted: (sessionId: string) => void; -setPrepareFailed: (sessionId: string, error: string) => void; -clearPrepareState: (sessionId: string) => void; -``` - -#### WS Handler: `lib/ws/handlers/executor-prepare.ts` - -```typescript -export function registerExecutorPrepareHandlers(store: StoreApi): WsHandlers { - return { - 'executor.prepare.started': (message) => { - const { session_id } = message.payload; - store.getState().setPrepareStarted(session_id); - }, - 'executor.prepare.progress': (message) => { - const payload = message.payload as PrepareProgressEvent; - store.getState().addPrepareStep(payload.session_id, payload); - }, - 'executor.prepare.completed': (message) => { - const { session_id } = message.payload; - store.getState().setPrepareCompleted(session_id); - }, - 'executor.prepare.failed': (message) => { - const { session_id, error } = message.payload; - store.getState().setPrepareFailed(session_id, error); - }, - }; -} -``` - -#### Component: `components/session/prepare-progress.tsx` - -Shows real-time preparation progress in the session panel (above the chat). - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ ⚙ Preparing environment... [2/4] │ -│──────────────────────────────────────────────────────────────────│ -│ │ -│ ✓ git fetch origin main 0.8s │ -│ From github.com:org/repo │ -│ * branch main -> FETCH_HEAD │ -│ │ -│ ✓ git pull --ff-only origin main 0.3s │ -│ Already up to date. │ -│ │ -│ ◐ Creating isolated worktree... │ -│ git worktree add -b feature/fix-login-bug-xyz │ -│ /home/user/kandev/worktrees/fix-login-bug_a1b2c3d4 │ -│ origin/main │ -│ │ -│ ○ Run setup script (pending) │ -│ │ -└──────────────────────────────────────────────────────────────────┘ -``` - -After completion, collapses to a summary line: - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ ✓ Environment ready • worktree: feature/fix-login-bug-xyz ▸ │ -└──────────────────────────────────────────────────────────────────┘ -``` - -On failure: - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ ✗ Environment preparation failed │ -│──────────────────────────────────────────────────────────────────│ -│ │ -│ ✓ git fetch origin main 0.8s │ -│ ✓ git pull --ff-only origin main 0.3s │ -│ ✗ git worktree add ... exit: 128 │ -│ stderr: fatal: 'feature/fix-login-bug' already exists │ -│ │ -│ [Retry] │ -└──────────────────────────────────────────────────────────────────┘ -``` - -```typescript -function PrepareProgress({ sessionId }: { sessionId: string }) { - const prepareState = useAppStore( - (s) => s.prepare.bySessionId[sessionId] - ); - - if (!prepareState || prepareState.status === 'idle') return null; - - const isActive = prepareState.status === 'preparing'; - const isFailed = prepareState.status === 'failed'; - const isCompleted = prepareState.status === 'completed'; - - // Collapsed summary after completion - if (isCompleted && !expanded) { - return ; - } - - return ( -
- -
- {prepareState.steps.map((step, i) => ( - - ))} -
- {isFailed && } -
- ); -} - -function PrepareStepItem({ step }: { step: PrepareProgressEvent }) { - const statusIcon = { - running: , - completed: , - failed: , - skipped: , - }[step.status]; - - return ( -
-
- {statusIcon} - {step.command || step.message} - {step.exit_code != null && ( - - exit: {step.exit_code} - - )} -
- {step.stdout && ( -
-                    {step.stdout}
-                
- )} - {step.stderr && ( -
-                    {step.stderr}
-                
- )} -
- ); -} -``` - ---- - -## Tracing - -All executor lifecycle and preparation events are instrumented with OpenTelemetry spans, following the existing `kandev-transport` tracer pattern in `internal/agentctl/tracing/`. - -### New Trace Functions: `tracing/executor.go` - -```go -const executorTracerName = "kandev-executor" - -func executorTracer() trace.Tracer { - return Tracer(executorTracerName) -} - -// TraceExecutorPrepare creates a span covering the entire preparation phase. -// The span contains child spans for each preparation step. -func TraceExecutorPrepare(ctx context.Context, taskID, sessionID, executorType string) (context.Context, trace.Span) { - ctx, span := executorTracer().Start(ctx, "executor.prepare", - trace.WithSpanKind(trace.SpanKindInternal), - ) - span.SetAttributes( - attribute.String("task_id", taskID), - attribute.String("session_id", sessionID), - attribute.String("executor_type", executorType), - ) - return ctx, span -} - -// TraceExecutorPrepareStep creates a child span for a single preparation step. -func TraceExecutorPrepareStep(ctx context.Context, step string, command string) (context.Context, trace.Span) { - ctx, span := executorTracer().Start(ctx, "executor.prepare."+step, - trace.WithSpanKind(trace.SpanKindInternal), - ) - span.SetAttributes( - attribute.String("step", step), - attribute.String("command", command), - ) - return ctx, span -} - -// TraceExecutorPrepareStepResult records the outcome of a preparation step. -func TraceExecutorPrepareStepResult(span trace.Span, exitCode int, stdout, stderr string, err error) { - span.SetAttributes( - attribute.Int("exit_code", exitCode), - attribute.String("stdout", truncate(stdout, 4096)), - attribute.String("stderr", truncate(stderr, 4096)), - ) - if err != nil { - span.SetStatus(codes.Error, err.Error()) - span.RecordError(err) - } - span.End() -} - -// TraceExecutorCreateInstance creates a span for executor instance creation. -func TraceExecutorCreateInstance(ctx context.Context, taskID, sessionID, backendName string) (context.Context, trace.Span) { - ctx, span := executorTracer().Start(ctx, "executor.create_instance", - trace.WithSpanKind(trace.SpanKindInternal), - ) - span.SetAttributes( - attribute.String("task_id", taskID), - attribute.String("session_id", sessionID), - attribute.String("backend", backendName), - ) - return ctx, span -} - -// TraceExecutorHealthCheck creates a span for a health check. -func TraceExecutorHealthCheck(ctx context.Context, sessionID, backendName string) (context.Context, trace.Span) { - ctx, span := executorTracer().Start(ctx, "executor.health_check", - trace.WithSpanKind(trace.SpanKindInternal), - ) - span.SetAttributes( - attribute.String("session_id", sessionID), - attribute.String("backend", backendName), - ) - return ctx, span -} - -// TraceExecutorStop creates a span for executor instance teardown. -func TraceExecutorStop(ctx context.Context, sessionID, executionID string, force bool) (context.Context, trace.Span) { - ctx, span := executorTracer().Start(ctx, "executor.stop", - trace.WithSpanKind(trace.SpanKindInternal), - ) - span.SetAttributes( - attribute.String("session_id", sessionID), - attribute.String("execution_id", executionID), - attribute.Bool("force", force), - ) - return ctx, span -} -``` - -### Trace Integration in Preparers - -```go -// In WorktreePreparer.Prepare(): -func (p *WorktreePreparer) Prepare(ctx context.Context, req *EnvPrepareRequest) (*EnvPrepareResult, error) { - // Parent span for entire preparation - ctx, prepSpan := tracing.TraceExecutorPrepare(ctx, req.TaskID, req.SessionID, "worktree") - defer prepSpan.End() - - // Step 1: git fetch - stepCtx, fetchSpan := tracing.TraceExecutorPrepareStep(ctx, "git_fetch", cmd) - stdout, stderr, exitCode, err := runGitCommand(stepCtx, ...) - tracing.TraceExecutorPrepareStepResult(fetchSpan, exitCode, stdout, stderr, err) - - // Step 2: git pull - stepCtx, pullSpan := tracing.TraceExecutorPrepareStep(ctx, "git_pull", cmd) - stdout, stderr, exitCode, err = runGitCommand(stepCtx, ...) - tracing.TraceExecutorPrepareStepResult(pullSpan, exitCode, stdout, stderr, err) - - // Step 3: worktree create - stepCtx, wtSpan := tracing.TraceExecutorPrepareStep(ctx, "worktree_create", cmd) - wt, err := p.worktreeMgr.Create(stepCtx, ...) - if err != nil { - wtSpan.SetStatus(codes.Error, err.Error()) - wtSpan.End() - prepSpan.SetStatus(codes.Error, "worktree creation failed") - return nil, err - } - wtSpan.SetAttributes( - attribute.String("worktree_id", wt.ID), - attribute.String("worktree_branch", wt.Branch), - attribute.String("worktree_path", wt.Path), - ) - wtSpan.End() - - // ... -} -``` - -### Trace Hierarchy - -``` -session (long-lived root span) - │ - ├─ executor.prepare (duration of entire preparation) - │ │ - │ ├─ executor.prepare.deps (phase 1 — tools & credentials) - │ │ ├─ executor.prepare.install_agentctl (remote only) - │ │ ├─ executor.prepare.install_agent_cli (remote only) - │ │ ├─ executor.prepare.copy_git_config (remote only, if configured) - │ │ ├─ executor.prepare.copy_gh_auth (remote only, if configured) - │ │ └─ executor.prepare.docker_build (docker only, if Dockerfile) - │ │ - │ └─ executor.prepare.repo (phase 2 — repository setup) - │ ├─ executor.prepare.git_clone (remote only) - │ │ attrs: url, workspace_path, exit_code, stdout, stderr - │ ├─ executor.prepare.git_fetch (local/worktree) - │ │ attrs: command, exit_code, stdout, stderr - │ ├─ executor.prepare.git_pull (local/worktree) - │ │ attrs: command, exit_code, stdout, stderr - │ ├─ executor.prepare.git_checkout (remote only) - │ │ attrs: branch, exit_code - │ ├─ executor.prepare.worktree_create (worktree only) - │ │ attrs: worktree_id, worktree_branch, worktree_path - │ └─ executor.prepare.setup_script (all types, if configured) - │ attrs: command, exit_code, stdout, stderr - │ - ├─ executor.create_instance - │ attrs: backend, container_id (if docker) - │ - ├─ session.init (existing span) - │ ├─ HTTP: POST /api/v1/configure - │ ├─ HTTP: POST /api/v1/start - │ └─ WS: session/new - │ - └─ executor.health_check (periodic, remote only) - attrs: session_id, backend, status -``` - ---- - -## Database Schema (New Service) - -This is a new service — no migrations from existing tables are needed. All tables are created fresh in `initSchema()`. - -### Tables - -```sql --- Executor profiles table (new) -CREATE TABLE IF NOT EXISTS executor_profiles ( - id TEXT PRIMARY KEY, - executor_id TEXT NOT NULL REFERENCES executors(id) ON DELETE CASCADE, - name TEXT NOT NULL, - is_default INTEGER NOT NULL DEFAULT 0, - config TEXT NOT NULL DEFAULT '{}', -- JSON: type-specific config - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP DEFAULT NULL, - UNIQUE(executor_id, name) -); - -CREATE INDEX IF NOT EXISTS idx_executor_profiles_executor_id - ON executor_profiles(executor_id); -``` - -### Column Additions: `executors_running` - -These columns are added to the existing `executors_running` table: - -```sql -ALTER TABLE executors_running ADD COLUMN profile_id TEXT DEFAULT ''; -ALTER TABLE executors_running ADD COLUMN executor_backend TEXT DEFAULT ''; -ALTER TABLE executors_running ADD COLUMN remote_id TEXT DEFAULT ''; -ALTER TABLE executors_running ADD COLUMN health_status TEXT DEFAULT 'unknown'; -ALTER TABLE executors_running ADD COLUMN health_checked_at TIMESTAMP DEFAULT NULL; -``` - -### Default Profiles - -Created in `ensureDefaultExecutorsAndProfiles()`: - -```go -func ensureDefaultExecutorsAndProfiles(ctx context.Context, db *sqlx.DB) error { - // exec-local (Local) + "default" profile (empty config) - // exec-worktree (Worktree) + "default" profile (worktree_root config) - // exec-docker (Docker) + "local" profile (host: /var/run/docker.sock) -} -``` - ---- - -## Backward Compatibility - -### API Compatibility - -- `local_docker` and `remote_docker` executor types are accepted as aliases -- API responses include both old and new field names during transition - -### Frontend Compatibility - -- Old executor type strings mapped to new types in `executor-icons.ts` -- Settings sidebar removes environments section (replaced by executor profiles) - -### Timeline - -1. **Phase 1**: Add executor_profiles table, profile CRUD, runtime→executor rename, preparation abstraction -2. **Phase 2**: Frontend preparation progress UI, tracing instrumentation -3. **Phase 3**: Remove deprecated type aliases, remove old environment code - ---- - -## Files Changed Summary - -### New Files -| File | Description | -|------|-------------| -| `apps/backend/internal/agent/executor/executor.go` | New package replacing `runtime` | -| `apps/backend/internal/agent/lifecycle/executor_backend.go` | Renamed from `runtime.go` | -| `apps/backend/internal/agent/lifecycle/executor_registry.go` | Renamed from `runtime_registry.go` | -| `apps/backend/internal/agent/lifecycle/executor_docker.go` | Renamed from `runtime_docker.go` | -| `apps/backend/internal/agent/lifecycle/executor_standalone.go` | Renamed from `runtime_standalone.go` | -| `apps/backend/internal/agent/lifecycle/executor_remote_docker.go` | Renamed from `runtime_remote_docker.go` | -| `apps/backend/internal/agent/lifecycle/env_preparer.go` | EnvironmentPreparer interface + registry + types | -| `apps/backend/internal/agent/lifecycle/env_preparer_local.go` | Local preparer (git pull) | -| `apps/backend/internal/agent/lifecycle/env_preparer_worktree.go` | Worktree preparer (git pull + worktree create + setup script) | -| `apps/backend/internal/agent/lifecycle/env_preparer_docker.go` | Docker image build preparer | -| `apps/backend/internal/agent/lifecycle/env_preparer_remote.go` | Remote preparer (SSH/Sprites/K8s) | -| `apps/backend/internal/agentctl/tracing/executor.go` | OTel trace functions for executor lifecycle | -| `apps/backend/internal/task/repository/sqlite/executor_profile.go` | Profile CRUD | -| `apps/backend/internal/task/handlers/executor_profile_handlers.go` | Profile API handlers | -| `apps/web/components/session/prepare-progress.tsx` | Preparation progress UI component | -| `apps/web/components/settings/executor-profile-dialog.tsx` | Profile create/edit dialog | -| `apps/web/lib/ws/handlers/executor-health.ts` | Health update WS handler | -| `apps/web/lib/ws/handlers/executor-prepare.ts` | Preparation progress WS handler | - -### Deleted Files -| File | Description | -|------|-------------| -| `apps/backend/internal/agent/runtime/runtime.go` | Replaced by `executor/executor.go` | -| `apps/backend/internal/agent/lifecycle/runtime.go` | Renamed | -| `apps/backend/internal/agent/lifecycle/runtime_registry.go` | Renamed | -| `apps/backend/internal/agent/lifecycle/runtime_docker.go` | Renamed | -| `apps/backend/internal/agent/lifecycle/runtime_standalone.go` | Renamed | -| `apps/backend/internal/agent/lifecycle/runtime_remote_docker.go` | Renamed | - -### Modified Files -| File | Change | -|------|--------| -| `apps/backend/internal/task/models/models.go` | Add ExecutorProfile, new types, ExecutorRunning health fields | -| `apps/backend/internal/task/repository/interface.go` | Add profile methods | -| `apps/backend/internal/task/repository/sqlite/base.go` | Create executor_profiles table | -| `apps/backend/internal/task/repository/sqlite/defaults.go` | Default profiles for system executors | -| `apps/backend/internal/task/repository/sqlite/executor.go` | Load profiles with executor | -| `apps/backend/internal/task/handlers/executor_handlers.go` | Include profiles in responses | -| `apps/backend/internal/agent/lifecycle/manager.go` | Use ExecutorRegistry, add health loop, integrate preparers | -| `apps/backend/internal/agent/lifecycle/manager_launch.go` | Call preparer before CreateInstance (local/worktree) | -| `apps/backend/internal/agent/lifecycle/events.go` | Add PublishPrepareProgress/Started/Completed/Failed | -| `apps/backend/internal/agent/lifecycle/event_types.go` | Add PrepareProgressEvent, PrepareStarted/Completed/Failed payloads | -| `apps/backend/internal/agent/lifecycle/execution_store.go` | Track health status | -| `apps/backend/internal/events/types.go` | Add executor.prepare.* event types + subject builders | -| `apps/backend/internal/gateway/websocket/session_notifications.go` | Subscribe to prepare events, broadcast to frontend | -| `apps/backend/cmd/kandev/agents.go` | Register with new names | -| `apps/backend/cmd/kandev/routes.go` | Profile routes | -| All files importing `agent/runtime` | Update imports to `agent/executor` | -| `apps/web/lib/types/http.ts` | ExecutorProfile type, health types | -| `apps/web/lib/types/backend.ts` | PrepareProgressEvent, SessionPrepareState types | -| `apps/web/lib/executor-icons.ts` | New executor type icons | -| `apps/web/lib/state/slices/session-runtime/types.ts` | Add prepare state + actions | -| `apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts` | Implement prepare state reducers | -| `apps/web/lib/state/slices/settings/settings-slice.ts` | Remove environments, add profile state | -| `apps/web/lib/api/domains/settings-api.ts` | Profile API client methods | -| `apps/web/lib/ws/router.ts` | Register executor-prepare + executor-health handlers | -| `apps/web/components/settings/settings-app-sidebar.tsx` | Remove environments section | -| `apps/web/components/settings/executor-page.tsx` | Show profiles | -| Session card components | Add health indicator | -| Session panel components | Add PrepareProgress component above chat | - ---- - -*Last updated: 2026-02-20* diff --git a/docs/plans/03-sprites-dev-support.md b/docs/plans/03-sprites-dev-support.md deleted file mode 100644 index 1e446d628c..0000000000 --- a/docs/plans/03-sprites-dev-support.md +++ /dev/null @@ -1,1336 +0,0 @@ -# Plan 03: Sprites.dev Support - -> First remote sandbox executor using the Sprites.dev service. -> Depends on: Plan 01 (Secrets Infrastructure), Plan 02 (Executor Profiles Infrastructure). - ---- - -## Table of Contents - -1. [Overview](#overview) -2. [Architecture](#architecture) -3. [Sprites.dev Concepts](#spritesdev-concepts) -4. [Backend Implementation](#backend-implementation) -5. [Environment Preparation](#environment-preparation) -6. [agentctl Deployment & Connection](#agentctl-deployment--connection) -7. [Session Resume & Recovery](#session-resume--recovery) -8. [Settings Page](#settings-page) -9. [Debug & Test Harness](#debug--test-harness) -10. [API Endpoints](#api-endpoints) -11. [Frontend Implementation](#frontend-implementation) -12. [Error Handling & Edge Cases](#error-handling--edge-cases) - ---- - -## Overview - -Sprites.dev is a paid service that provides ephemeral sandbox environments (sprites). Users provide their API key, and Kandev creates sprites on their behalf to run agents in isolated environments. - -### Goals - -- Implement `SpritesExecutor` backend (implements `ExecutorBackend` interface from Plan 02) -- Deploy and run agentctl inside sprites via the sprites-go SDK -- Establish port-forwarded connections between kandev backend and agentctl in sprite -- Settings page for Sprites account setup (API key, network policies, running sprites) -- Environment preparation: install agent CLI, node.js, copy auth files -- Session resume with health checking -- Comprehensive debug logging and test harness - -### Prerequisites - -- Plan 01 (Secrets) must be implemented — API key stored in secrets store -- Plan 02 (Executor Profiles) must be implemented — executor/profile model in place - -### Key SDK - -``` -github.com/superfly/sprites-go -``` - -The SDK provides: -- `sprites.New(token)` — create client with API token -- `client.Sprite(name)` — get sprite handle -- `sprite.Command(cmd, args...)` — run commands (mirrors `exec.Cmd`) -- `sprite.CommandContext(ctx, cmd, args...)` — with context -- `sprite.ProxyPort(ctx, localPort, remotePort)` — TCP port forwarding -- `sprite.ProxyPorts(ctx, []PortMapping{...})` — multiple port forwarding -- `cmd.StdinPipe()`, `cmd.StdoutPipe()` — pipe access -- `cmd.SetTTY(true)` — TTY mode - -Sprites API: -- `POST /v1/sprites` — create sprite -- `DELETE /v1/sprites/{name}` — destroy sprite -- `GET /v1/sprites` — list sprites -- `GET /v1/sprites/{name}` — get sprite status -- `WSS /v1/sprites/{name}/exec` — execute commands -- `POST /v1/sprites/{name}/services` — create background service -- `GET/POST /v1/sprites/{name}/network` — network policies -- `WSS proxy` — TCP port tunneling - ---- - -## Architecture - -### High-Level Flow - -``` -┌───────────────────────────────────────────────────────────────────────────┐ -│ Kandev Backend │ -│ │ -│ ┌─────────────┐ ┌─────────────────┐ ┌──────────────────────────┐ │ -│ │ Orchestrator │───>│ Lifecycle Mgr │───>│ SpritesExecutor │ │ -│ │ │ │ │ │ (ExecutorBackend impl) │ │ -│ │ task.start │ │ LaunchAgent() │ │ │ │ -│ │ executor= │ │ profile= │ │ 1. Create sprite │ │ -│ │ sprites │ │ sprites/def │ │ 2. Prepare environment │ │ -│ │ │ │ │ │ 3. Deploy agentctl │ │ -│ │ │ │ │ │ 4. Start agentctl │ │ -│ │ │ │ │ │ 5. Port forward │ │ -│ │ │ │ │ │ 6. Return client │ │ -│ └─────────────┘ └─────────────────┘ └──────────┬───────────────┘ │ -│ │ │ -│ ┌──────────────────────────┘ │ -│ │ │ -│ ▼ │ -│ ┌───────────────────────────────────────────────────────────────────┐ │ -│ │ Port Forwarding (sprites-go SDK) │ │ -│ │ │ │ -│ │ localhost:$LOCAL_PORT ←──WSS tunnel──→ sprite:$AGENTCTL_PORT │ │ -│ │ │ │ -│ │ agentctl.Client connects to localhost:$LOCAL_PORT │ │ -│ │ which tunnels to agentctl running inside the sprite │ │ -│ └───────────────────────────────────────────────────────────────────┘ │ -│ │ -└───────────────────────────────────────────────────────────────────────────┘ - │ - │ WSS tunnel (sprites-go SDK manages) - ▼ -┌───────────────────────────────────────────────────────────────────────────┐ -│ Sprites.dev Cloud │ -│ │ -│ ┌─────────────────────────────────────────────────────────────────────┐ │ -│ │ Sprite: kandev-sess-abc123 │ │ -│ │ │ │ -│ │ ┌───────────────────────────────────────────────────────────────┐ │ │ -│ │ │ agentctl process (port 8765) │ │ │ -│ │ │ │ │ │ -│ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ -│ │ │ │ Agent subprocess (claude-code, codex, etc.) │ │ │ │ -│ │ │ │ stdin/stdout ←→ ACP protocol ←→ agentctl │ │ │ │ -│ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ -│ │ │ │ │ │ -│ │ │ /workspace/ ← git clone of repo │ │ │ -│ │ │ ~/.config/ ← copied agent configs │ │ │ -│ │ │ ~/.gitconfig ← copied git config │ │ │ -│ │ │ Auth tokens for gh, git, etc. │ │ │ -│ │ └───────────────────────────────────────────────────────────────┘ │ │ -│ └─────────────────────────────────────────────────────────────────────┘ │ -│ │ -└───────────────────────────────────────────────────────────────────────────┘ -``` - -### Detailed Sequence Diagram - -``` -Orchestrator LifecycleMgr SpritesExecutor SpritesPreparer Sprites API Sprite - │ │ │ │ │ │ - │ LaunchAgent() │ │ │ │ │ - │───────────────>│ │ │ │ │ - │ │ resolve profile│ │ │ │ - │ │ (api_token from│ │ │ │ - │ │ secrets store)│ │ │ │ - │ │ │ │ │ │ - │ │ CreateInstance()│ │ │ │ - │ │───────────────>│ │ │ │ - │ │ │ POST /v1/sprites │ │ - │ │ │───────────────────────────────>│ │ - │ │ │ │ sprite created │ - │ │ │<───────────────────────────────│ │ - │ │ │ │ │ │ - │ │ Prepare() │ │ │ │ - │ │───────────────────────────────>│ │ │ - │ │ │ │ install node │ │ - │ │ │ │───────────────────────────────>│ - │ │ │ │ install agent │ │ - │ │ │ │───────────────────────────────>│ - │ │ │ │ copy configs │ │ - │ │ │ │───────────────────────────────>│ - │ │ │ │ clone repo │ │ - │ │ │ │───────────────────────────────>│ - │ │ │ │<──────────────────────────────│ - │ │<──────────────────────────────│ │ │ - │ │ │ │ │ │ - │ │ │ upload agentctl│ │ │ - │ │ │ binary │ │ │ - │ │ │───────────────────────────────────────────────>│ - │ │ │ │ │ │ - │ │ │ start agentctl │ │ │ - │ │ │ as service │ │ │ - │ │ │───────────────────────────────>│ │ - │ │ │ │ POST /services│ │ - │ │ │<───────────────────────────────│ │ - │ │ │ │ │ │ - │ │ │ ProxyPort() │ │ │ - │ │ │ local:random ←→ sprite:8765 │ │ - │ │ │───────────────────────────────>│ │ - │ │ │ ← WSS tunnel established ──── │ │ - │ │ │ │ │ │ - │ │ │ return ExecutorInstance │ │ - │ │ │ (client=localhost:random) │ │ - │ │<───────────────│ │ │ │ - │ │ │ │ │ │ - │ │ ConfigureAgent()│ │ │ │ - │ │ via agentctl │ │ │ │ - │ │ client (tunnel)│ │ │ │ - │ │═══════════════════════════════════════════════════════════════>│ - │ │ │ │ │ │ - │ │ Start() agent │ │ │ │ - │ │═══════════════════════════════════════════════════════════════>│ - │ │ │ │ │ │ - │<═══════════════│ stream updates │ │ │ │ - │ via WS │ via tunnel │ │ │ │ -``` - ---- - -## Sprites.dev Concepts - -### Sprite Lifecycle - -``` -┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ -│ Creating │────>│ Ready │────>│ Running │────>│ Stopped │ -│ │ │ │ │ (services│ │ │ -│ │ │ │ │ active) │ │ (destroy)│ -└──────────┘ └──────────┘ └──────────┘ └──────────┘ - │ │ - │ ┌─────────┘ - ▼ ▼ - ┌──────────────┐ - │ Checkpointed │ (optional, save state) - └──────────────┘ -``` - -### Naming Convention - -Sprites are named: `kandev-{session_id_short}` (e.g., `kandev-abc12345`) - -This allows: -- Easy identification of Kandev-managed sprites -- Recovery by listing sprites with `kandev-` prefix -- Cleanup of orphaned sprites - -### Network Policies - -Sprites can have DNS-based outbound network policies. Users configure these per-profile: - -```json -{ - "network_policies": [ - "*.npm.org", - "*.github.com", - "*.githubusercontent.com", - "registry.npmjs.org", - "pypi.org", - "*.anthropic.com" - ] -} -``` - -These are applied via the Sprites API `POST /v1/sprites/{name}/network`. - ---- - -## Backend Implementation - -### Package Structure - -``` -apps/backend/internal/agent/lifecycle/ -├── executor_sprites.go # SpritesExecutor (ExecutorBackend impl) -├── env_preparer_sprites.go # SpritesEnvironmentPreparer -└── sprites_client.go # Sprites SDK wrapper/helpers - -apps/backend/internal/sprites/ -├── settings.go # Sprites settings service -└── handlers.go # Sprites-specific API handlers (list sprites, network policies) -``` - -### SpritesExecutor: `lifecycle/executor_sprites.go` - -```go -package lifecycle - -import ( - "context" - "fmt" - "net" - - sprites "github.com/superfly/sprites-go" - "go.uber.org/zap" - - "github.com/kandev/kandev/internal/agent/executor" - agentctl "github.com/kandev/kandev/internal/agentctl/client" - "github.com/kandev/kandev/internal/agentctl/server/process" - "github.com/kandev/kandev/internal/common/logger" -) - -const ( - spritesAgentctlPort = 8765 - spritesNamePrefix = "kandev-" -) - -// SpritesExecutor implements ExecutorBackend for Sprites.dev sandbox environments. -type SpritesExecutor struct { - secretsProvider SecretsProvider // to fetch API token - logger *logger.Logger - - // Active proxy sessions keyed by instance ID - proxies map[string]*SpritesProxy -} - -// SpritesProxy holds the tunnel and sprite handle for an active instance. -type SpritesProxy struct { - client *sprites.Client - sprite *sprites.Sprite - spriteName string - localPort int - cancelFn context.CancelFunc -} - -func NewSpritesExecutor(secretsProvider SecretsProvider, log *logger.Logger) *SpritesExecutor { - return &SpritesExecutor{ - secretsProvider: secretsProvider, - logger: log.WithFields(zap.String("executor", "sprites")), - proxies: make(map[string]*SpritesProxy), - } -} - -func (e *SpritesExecutor) Name() executor.Name { - return executor.NameSprites -} - -func (e *SpritesExecutor) IsRemote() bool { - return true -} - -func (e *SpritesExecutor) HealthCheck(ctx context.Context) error { - // Try to get API token from secrets - token, err := e.secretsProvider.GetSecret(ctx, "SPRITES_API_TOKEN") - if err != nil { - return fmt.Errorf("sprites API token not configured: %w", err) - } - - // Try listing sprites to verify token works - client := sprites.New(token) - sprite := client.Sprite("kandev-healthcheck") - // Simple command to verify API connectivity - cmd := sprite.CommandContext(ctx, "echo", "health") - // If the sprite doesn't exist, the API will return an error - // which is fine — we just want to verify the token is valid - _ = cmd // We don't actually run this — just verify client creation works - return nil -} - -func (e *SpritesExecutor) CreateInstance( - ctx context.Context, - req *ExecutorCreateRequest, -) (*ExecutorInstance, error) { - // 1. Get API token from profile config → secrets store - tokenSecretID, _ := req.ProfileConfig["api_token_secret_id"].(string) - token, err := e.secretsProvider.GetSecret(ctx, tokenSecretID) - if err != nil { - return nil, fmt.Errorf("sprites API token: %w", err) - } - - // 2. Create sprites client - client := sprites.New(token) - - // 3. Create sprite with unique name - spriteName := fmt.Sprintf("%s%s", spritesNamePrefix, req.SessionID[:8]) - sprite := client.Sprite(spriteName) - - e.logger.Info("creating sprite", - zap.String("name", spriteName), - zap.String("session_id", req.SessionID)) - - // 4. Verify sprite is accessible by running a simple command - cmd := sprite.CommandContext(ctx, "echo", "kandev-ready") - output, err := cmd.Output() - if err != nil { - return nil, fmt.Errorf("sprite creation/access failed: %w", err) - } - e.logger.Debug("sprite ready", zap.String("output", string(output))) - - // 5. Set up port forwarding - localPort, err := getFreePort() - if err != nil { - return nil, fmt.Errorf("get free port: %w", err) - } - - proxyCtx, proxyCancel := context.WithCancel(context.Background()) - go func() { - if proxyErr := sprite.ProxyPort(proxyCtx, localPort, spritesAgentctlPort); proxyErr != nil { - e.logger.Error("sprite port proxy failed", - zap.String("sprite", spriteName), - zap.Error(proxyErr)) - } - }() - - // 6. Store proxy info for cleanup - e.proxies[req.InstanceID] = &SpritesProxy{ - client: &client, // Note: need to check SDK type - sprite: &sprite, - spriteName: spriteName, - localPort: localPort, - cancelFn: proxyCancel, - } - - // 7. Create agentctl client pointing to the local end of the tunnel - agentctlClient := agentctl.NewClient("127.0.0.1", localPort, e.logger) - - e.logger.Info("sprite instance created", - zap.String("sprite", spriteName), - zap.String("instance_id", req.InstanceID), - zap.Int("local_port", localPort)) - - return &ExecutorInstance{ - InstanceID: req.InstanceID, - TaskID: req.TaskID, - SessionID: req.SessionID, - BackendName: string(e.Name()), - Client: agentctlClient, - RemoteID: spriteName, - Metadata: map[string]interface{}{ - "sprite_name": spriteName, - "local_port": localPort, - }, - }, nil -} - -func (e *SpritesExecutor) StopInstance( - ctx context.Context, - instance *ExecutorInstance, - force bool, -) error { - proxy, ok := e.proxies[instance.InstanceID] - if !ok { - return nil - } - - // Cancel port forwarding - proxy.cancelFn() - - // Destroy sprite - spriteName := instance.RemoteID - if spriteName != "" { - sprite := proxy.client.Sprite(spriteName) - // Run a cleanup command or just let it be destroyed - cmd := sprite.CommandContext(ctx, "kill", "-TERM", "1") // send signal to init - _ = cmd.Run() - } - - delete(e.proxies, instance.InstanceID) - - e.logger.Info("sprite instance stopped", - zap.String("sprite", spriteName), - zap.String("instance_id", instance.InstanceID)) - - return nil -} - -func (e *SpritesExecutor) RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) { - // Recovery strategy: - // 1. Load all executors_running with executor_backend="sprites" - // 2. For each, try to reconnect: - // a. Get API token from secrets - // b. Check sprite still exists (sprite.Command("echo", "alive")) - // c. Check agentctl is still running (health check via new proxy) - // d. If alive: set up port forwarding, return recovered instance - // e. If dead: return nil (lifecycle manager will clean up) - - e.logger.Info("recovering sprites instances") - - // Note: actual recovery requires DB access which is done by the lifecycle manager. - // This method handles re-establishing connections to known-alive sprites. - // The lifecycle manager calls this after loading ExecutorRunning records. - - return nil, nil // Sprites are ephemeral — recovery is best-effort -} - -func (e *SpritesExecutor) GetInteractiveRunner() *process.InteractiveRunner { - return nil // No passthrough mode for remote sprites -} - -// getFreePort returns an available TCP port on localhost. -func getFreePort() (int, error) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return 0, err - } - port := listener.Addr().(*net.TCPAddr).Port - _ = listener.Close() - return port, nil -} -``` - -### Sprites Environment Preparer: `lifecycle/env_preparer_sprites.go` - -```go -package lifecycle - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - - sprites "github.com/superfly/sprites-go" - "go.uber.org/zap" - - "github.com/kandev/kandev/internal/common/logger" -) - -// SpritesEnvironmentPreparer sets up the sprite environment for agent execution. -type SpritesEnvironmentPreparer struct { - agentctlBinaryPath string // path to local agentctl binary to upload - logger *logger.Logger -} - -func NewSpritesEnvironmentPreparer( - agentctlBinaryPath string, - log *logger.Logger, -) *SpritesEnvironmentPreparer { - return &SpritesEnvironmentPreparer{ - agentctlBinaryPath: agentctlBinaryPath, - logger: log.WithFields(zap.String("preparer", "sprites")), - } -} - -func (p *SpritesEnvironmentPreparer) Prepare( - ctx context.Context, - req *EnvPrepareRequest, -) error { - spriteName := req.Instance.RemoteID - proxy := req.Instance.Metadata["_sprites_proxy"].(*SpritesProxy) - sprite := proxy.client.Sprite(spriteName) - - steps := []struct { - name string - fn func() error - }{ - {"install-system-deps", func() error { return p.installSystemDeps(ctx, sprite) }}, - {"install-node", func() error { return p.installNode(ctx, sprite) }}, - {"install-agent", func() error { return p.installAgent(ctx, sprite, req.AgentConfig) }}, - {"upload-agentctl", func() error { return p.uploadAgentctl(ctx, sprite) }}, - {"setup-workspace", func() error { return p.setupWorkspace(ctx, sprite, req.WorkspacePath) }}, - {"copy-configs", func() error { return p.copyConfigs(ctx, sprite, req) }}, - {"start-agentctl", func() error { return p.startAgentctl(ctx, sprite, req) }}, - } - - for _, step := range steps { - p.logger.Info("sprite prep step", - zap.String("step", step.name), - zap.String("sprite", spriteName)) - - if err := step.fn(); err != nil { - return fmt.Errorf("sprite prep %s: %w", step.name, err) - } - - p.logger.Info("sprite prep step complete", - zap.String("step", step.name), - zap.String("sprite", spriteName)) - } - - return nil -} - -func (p *SpritesEnvironmentPreparer) installSystemDeps( - ctx context.Context, - sprite sprites.Sprite, -) error { - cmd := sprite.CommandContext(ctx, "sh", "-c", - "apt-get update -qq && apt-get install -y -qq git curl ca-certificates") - output, err := cmd.CombinedOutput() - if err != nil { - p.logger.Error("install system deps failed", zap.String("output", string(output))) - return fmt.Errorf("install deps: %w", err) - } - return nil -} - -func (p *SpritesEnvironmentPreparer) installNode( - ctx context.Context, - sprite sprites.Sprite, -) error { - // Install Node.js via NodeSource or fnm - script := ` - if command -v node >/dev/null 2>&1; then - echo "node already installed: $(node --version)" - exit 0 - fi - curl -fsSL https://deb.nodesource.com/setup_22.x | bash - - apt-get install -y -qq nodejs - echo "node installed: $(node --version)" - ` - cmd := sprite.CommandContext(ctx, "sh", "-c", script) - output, err := cmd.CombinedOutput() - if err != nil { - p.logger.Error("install node failed", zap.String("output", string(output))) - return fmt.Errorf("install node: %w", err) - } - p.logger.Debug("node installation", zap.String("output", string(output))) - return nil -} - -func (p *SpritesEnvironmentPreparer) installAgent( - ctx context.Context, - sprite sprites.Sprite, - agentConfig agents.Agent, -) error { - // Install the agent CLI based on agent type - // This uses the agent's InstallCommand from the registry - installCmd := agentConfig.InstallCommand - if installCmd == "" { - p.logger.Info("no install command for agent, skipping", - zap.String("agent", agentConfig.Name)) - return nil - } - - cmd := sprite.CommandContext(ctx, "sh", "-c", installCmd) - output, err := cmd.CombinedOutput() - if err != nil { - p.logger.Error("install agent failed", - zap.String("agent", agentConfig.Name), - zap.String("output", string(output))) - return fmt.Errorf("install agent %s: %w", agentConfig.Name, err) - } - return nil -} - -func (p *SpritesEnvironmentPreparer) uploadAgentctl( - ctx context.Context, - sprite sprites.Sprite, -) error { - // Upload the agentctl binary to the sprite - // Strategy: pipe binary via stdin to a cat > /usr/local/bin/agentctl command - binaryData, err := os.ReadFile(p.agentctlBinaryPath) - if err != nil { - return fmt.Errorf("read agentctl binary: %w", err) - } - - cmd := sprite.CommandContext(ctx, "sh", "-c", - "cat > /usr/local/bin/agentctl && chmod +x /usr/local/bin/agentctl") - stdin, err := cmd.StdinPipe() - if err != nil { - return fmt.Errorf("stdin pipe: %w", err) - } - if err := cmd.Start(); err != nil { - return fmt.Errorf("start upload: %w", err) - } - if _, err := stdin.Write(binaryData); err != nil { - return fmt.Errorf("write binary: %w", err) - } - if err := stdin.Close(); err != nil { - return fmt.Errorf("close stdin: %w", err) - } - if err := cmd.Wait(); err != nil { - return fmt.Errorf("upload agentctl: %w", err) - } - - // Verify - verifyCmd := sprite.CommandContext(ctx, "agentctl", "--version") - output, err := verifyCmd.Output() - if err != nil { - return fmt.Errorf("verify agentctl: %w", err) - } - p.logger.Info("agentctl uploaded", zap.String("version", strings.TrimSpace(string(output)))) - return nil -} - -func (p *SpritesEnvironmentPreparer) setupWorkspace( - ctx context.Context, - sprite sprites.Sprite, - workspacePath string, -) error { - // Clone the repository into /workspace - // The repo URL and credentials come from the workspace config - script := fmt.Sprintf(` - mkdir -p /workspace - if [ -d /workspace/.git ]; then - echo "workspace already initialized" - exit 0 - fi - cd /workspace - git init - echo "workspace initialized at /workspace" - `) - cmd := sprite.CommandContext(ctx, "sh", "-c", script) - output, err := cmd.CombinedOutput() - if err != nil { - p.logger.Error("setup workspace failed", zap.String("output", string(output))) - return fmt.Errorf("setup workspace: %w", err) - } - return nil -} - -func (p *SpritesEnvironmentPreparer) copyConfigs( - ctx context.Context, - sprite sprites.Sprite, - req *EnvPrepareRequest, -) error { - // Copy configuration files from local machine to sprite - - if req.CopyGitConfig { - if err := p.copyFileToSprite(ctx, sprite, - filepath.Join(os.Getenv("HOME"), ".gitconfig"), - "/root/.gitconfig"); err != nil { - p.logger.Warn("copy .gitconfig failed", zap.Error(err)) - // Non-fatal - } - } - - if req.CopyGHAuth { - // Copy gh CLI auth - ghConfigDir := filepath.Join(os.Getenv("HOME"), ".config", "gh") - if err := p.copyFileToSprite(ctx, sprite, - filepath.Join(ghConfigDir, "hosts.yml"), - "/root/.config/gh/hosts.yml"); err != nil { - p.logger.Warn("copy gh auth failed", zap.Error(err)) - } - } - - if req.CopyAgentConfig { - // Copy agent-specific config (e.g., ~/.claude for Claude Code) - // This depends on the agent type - agentConfigPaths := req.AgentConfig.ConfigPaths - for _, path := range agentConfigPaths { - localPath := filepath.Join(os.Getenv("HOME"), path) - remotePath := filepath.Join("/root", path) - if err := p.copyFileToSprite(ctx, sprite, localPath, remotePath); err != nil { - p.logger.Warn("copy agent config failed", - zap.String("path", path), zap.Error(err)) - } - } - } - - return nil -} - -func (p *SpritesEnvironmentPreparer) copyFileToSprite( - ctx context.Context, - sprite sprites.Sprite, - localPath, remotePath string, -) error { - data, err := os.ReadFile(localPath) - if err != nil { - return fmt.Errorf("read %s: %w", localPath, err) - } - - // Create parent directory - dir := filepath.Dir(remotePath) - mkdirCmd := sprite.CommandContext(ctx, "mkdir", "-p", dir) - if err := mkdirCmd.Run(); err != nil { - return fmt.Errorf("mkdir %s: %w", dir, err) - } - - // Write file via stdin pipe - cmd := sprite.CommandContext(ctx, "sh", "-c", fmt.Sprintf("cat > %s", remotePath)) - stdin, err := cmd.StdinPipe() - if err != nil { - return err - } - if err := cmd.Start(); err != nil { - return err - } - if _, err := stdin.Write(data); err != nil { - return err - } - _ = stdin.Close() - return cmd.Wait() -} - -func (p *SpritesEnvironmentPreparer) startAgentctl( - ctx context.Context, - sprite sprites.Sprite, - req *EnvPrepareRequest, -) error { - // Start agentctl as a background service in the sprite - // Using the Sprites services API for long-running processes - // - // Note: We could also use sprite.Command with Start() (no Wait()), - // but using the services API gives us better lifecycle management. - - envVars := make([]string, 0) - for k, v := range req.Instance.Metadata { - if strings.HasPrefix(k, "env_") { - envVars = append(envVars, fmt.Sprintf("%s=%s", strings.TrimPrefix(k, "env_"), v)) - } - } - - cmd := sprite.CommandContext(ctx, "agentctl", - "--port", fmt.Sprintf("%d", spritesAgentctlPort), - "--workspace", "/workspace", - ) - cmd.Env = envVars - - if err := cmd.Start(); err != nil { - return fmt.Errorf("start agentctl: %w", err) - } - - // Don't Wait() — agentctl runs as a long-lived process. - // Port forwarding will connect to it. - - // Give agentctl a moment to start listening - // Then verify with a health check - // (the actual health check happens via the port-forwarded connection - // after CreateInstance returns) - - return nil -} - -func (p *SpritesEnvironmentPreparer) Cleanup( - ctx context.Context, - instanceID string, -) error { - // Cleanup is handled by SpritesExecutor.StopInstance which destroys the sprite - return nil -} -``` - ---- - -## agentctl Deployment & Connection - -### Binary Deployment Strategy - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ agentctl Binary Deployment │ -│ │ -│ Option A: Upload local binary (primary) │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ 1. Read agentctl binary from known path │ │ -│ │ (compiled for linux/amd64, same as backend binary) │ │ -│ │ 2. Pipe to sprite via stdin: cat > /usr/local/bin/... │ │ -│ │ 3. chmod +x │ │ -│ │ 4. Verify: agentctl --version │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ -│ Option B: Download from release (fallback) │ -│ ┌──────────────────────────────────────────────────────────┐ │ -│ │ 1. curl agentctl binary from release URL │ │ -│ │ 2. Install to /usr/local/bin/agentctl │ │ -│ │ 3. Verify version │ │ -│ └──────────────────────────────────────────────────────────┘ │ -│ │ -│ Config: agentctl binary path set in backend config │ -│ e.g., KANDEV_AGENTCTL_BINARY=/usr/local/bin/agentctl │ -│ or automatically detected relative to kandev binary │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### Port Forwarding Architecture - -``` -┌──────────────────────┐ ┌──────────────────────┐ -│ Kandev Backend │ │ Sprite (remote) │ -│ │ │ │ -│ agentctl.Client │ │ agentctl process │ -│ → 127.0.0.1:49152 │ │ listening :8765 │ -│ │ │ │ ▲ │ -│ ▼ │ │ │ │ -│ ┌──────────────┐ │ │ ┌────────────┐ │ -│ │ Local TCP │ │ WSS │ │ TCP │ │ -│ │ Socket │◄───┼──tunnel──┼───►│ Socket │ │ -│ │ :49152 │ │ (sprites │ │ :8765 │ │ -│ └──────────────┘ │ SDK) │ └────────────┘ │ -│ │ │ │ -└──────────────────────┘ └──────────────────────┘ - -sprites.ProxyPort(ctx, 49152, 8765) - - Establishes WebSocket tunnel to sprites API - - All TCP traffic on localhost:49152 is tunneled to sprite:8765 - - Transparent to agentctl.Client — just connects to localhost port -``` - -### Connection Lifecycle - -``` -1. CreateInstance: - - Create sprite - - Start agentctl inside sprite on port 8765 - - Find free local port (e.g., 49152) - - Start ProxyPort() goroutine (long-lived) - - Create agentctl.Client pointing to 127.0.0.1:49152 - - Return ExecutorInstance with client - -2. During session: - - agentctl.Client sends HTTP/WS requests to 127.0.0.1:49152 - - Sprites SDK tunnels these to sprite:8765 - - agentctl process handles requests, manages agent subprocess - -3. StopInstance: - - Cancel ProxyPort context (closes tunnel) - - Destroy sprite via API - - Clean up proxy state - -4. Error handling: - - If tunnel drops: health check detects, marks unhealthy - - If sprite dies: health check detects, lifecycle manager handles cleanup - - If backend restarts: recovery attempts to re-establish tunnels -``` - ---- - -## Session Resume & Recovery - -### Recovery Flow - -``` -Backend Restart - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ SpritesExecutor.RecoverInstances() │ -│ │ -│ 1. Read SPRITES_API_TOKEN from secrets │ -│ 2. List sprites with prefix "kandev-" │ -│ GET /v1/sprites (filter by name prefix) │ -│ │ -│ 3. For each sprite: │ -│ a. Match to executors_running record by remote_id │ -│ b. Check sprite health: sprite.Command("echo", "alive") │ -│ c. Check agentctl health: sprite.Command("curl", │ -│ "http://localhost:8765/health") │ -│ d. If both healthy: │ -│ - Find free local port │ -│ - Start ProxyPort() │ -│ - Create agentctl.Client │ -│ - Return recovered ExecutorInstance │ -│ e. If unhealthy: │ -│ - Log details │ -│ - Skip (lifecycle manager marks session as failed) │ -│ - Optionally destroy dead sprite │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Health Check for Sprites - -```go -func (e *SpritesExecutor) healthCheckSprite(ctx context.Context, proxy *SpritesProxy) error { - // Two-level health check: - - // Level 1: Can we reach the sprite? - cmd := proxy.sprite.CommandContext(ctx, "echo", "alive") - if _, err := cmd.Output(); err != nil { - return fmt.Errorf("sprite unreachable: %w", err) - } - - // Level 2: Is agentctl responding? - cmd = proxy.sprite.CommandContext(ctx, "curl", "-sf", - fmt.Sprintf("http://localhost:%d/health", spritesAgentctlPort)) - if _, err := cmd.Output(); err != nil { - return fmt.Errorf("agentctl not responding: %w", err) - } - - return nil -} -``` - ---- - -## Settings Page - -### Sprites Settings UI - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ Sprites.dev Integration │ -│──────────────────────────────────────────────────────────────────│ -│ │ -│ ┌─── Connection ──────────────────────────────────────────────┐ │ -│ │ │ │ -│ │ API Token: ●●●●●●●●●●●●●●●●●●●●●● [Reveal] [Change] │ │ -│ │ Status: ● Connected │ │ -│ │ │ │ -│ │ [Test Connection] │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─── Default Network Policies ────────────────────────────────┐ │ -│ │ │ │ -│ │ Allowed domains (one per line): │ │ -│ │ ┌────────────────────────────────────────────────────┐ │ │ -│ │ │ *.npm.org │ │ │ -│ │ │ *.github.com │ │ │ -│ │ │ *.githubusercontent.com │ │ │ -│ │ │ registry.npmjs.org │ │ │ -│ │ │ pypi.org │ │ │ -│ │ │ *.anthropic.com │ │ │ -│ │ └────────────────────────────────────────────────────┘ │ │ -│ │ [Save] │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─── Environment Setup ───────────────────────────────────────┐ │ -│ │ │ │ -│ │ ☑ Copy agent config/auth files from local │ │ -│ │ ☑ Copy gh CLI auth token for git operations │ │ -│ │ ☑ Copy .gitconfig │ │ -│ │ ☐ Custom setup script (advanced) │ │ -│ │ │ │ -│ │ [Save Defaults] │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─── Running Sprites ─────────────────────────────────────────┐ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────────────┐ │ │ -│ │ │ kandev-abc12345 │ │ │ -│ │ │ Session: Fix login bug • Running 2h 15m │ │ │ -│ │ │ Health: ● healthy [Destroy] │ │ │ -│ │ └──────────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ ┌──────────────────────────────────────────────────────┐ │ │ -│ │ │ kandev-def67890 │ │ │ -│ │ │ Session: Refactor auth • Running 45m │ │ │ -│ │ │ Health: ● healthy [Destroy] │ │ │ -│ │ └──────────────────────────────────────────────────────┘ │ │ -│ │ │ │ -│ │ Total: 2 sprites running [Destroy All] │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -│ │ -└──────────────────────────────────────────────────────────────────┘ -``` - -### Settings Data Model - -```go -// SpritesSettings stored in the executor profile config -type SpritesProfileConfig struct { - APITokenSecretID string `json:"api_token_secret_id"` - NetworkPolicies []string `json:"network_policies"` - CopyGHAuth bool `json:"copy_gh_auth"` - CopyGitConfig bool `json:"copy_git_config"` - CopyAgentConfig bool `json:"copy_agent_config"` - CustomSetupScript string `json:"custom_setup_script,omitempty"` -} -``` - ---- - -## Debug & Test Harness - -### Structured Logging - -All sprites operations use structured logging with consistent fields: - -```go -// Logger fields for all sprites operations -zap.String("executor", "sprites") -zap.String("sprite", spriteName) -zap.String("session_id", sessionID) -zap.String("step", stepName) // prep step name -zap.Duration("elapsed", duration) // step duration - -// Example log output: -// INFO sprites executor: creating sprite sprite=kandev-abc12345 session_id=sess-abc -// INFO sprites prep: install-system-deps sprite=kandev-abc12345 elapsed=12.3s -// INFO sprites prep: install-node sprite=kandev-abc12345 elapsed=8.1s -// INFO sprites prep: install-agent sprite=kandev-abc12345 elapsed=15.7s -// INFO sprites prep: upload-agentctl sprite=kandev-abc12345 elapsed=2.1s -// INFO sprites prep: setup-workspace sprite=kandev-abc12345 elapsed=0.3s -// INFO sprites prep: copy-configs sprite=kandev-abc12345 elapsed=1.2s -// INFO sprites prep: start-agentctl sprite=kandev-abc12345 elapsed=0.5s -// INFO sprites executor: instance created sprite=kandev-abc12345 local_port=49152 total=40.2s -``` - -### Test Harness: CLI Command - -```go -// cmd/kandev/sprites_test_harness.go (or a separate CLI command) -// Usage: kandev sprites-test --token --agent claude-code - -// Test harness steps: -// 1. Create a sprite -// 2. Run environment preparation (all steps) -// 3. Start agentctl -// 4. Establish port forwarding -// 5. Send health check via agentctl client -// 6. Send a simple prompt (e.g., "echo hello") -// 7. Wait for response -// 8. Destroy sprite -// 9. Report timing for each step - -func runSpritesTestHarness(cfg SpritesTestConfig) error { - log := logger.New(logger.Debug) // verbose logging - - fmt.Println("=== Sprites.dev Test Harness ===") - fmt.Println() - - // Step 1: Create sprite - fmt.Print("1. Creating sprite... ") - start := time.Now() - client := sprites.New(cfg.Token) - spriteName := fmt.Sprintf("kandev-test-%d", time.Now().Unix()) - sprite := client.Sprite(spriteName) - cmd := sprite.Command("echo", "ready") - if _, err := cmd.Output(); err != nil { - return fmt.Errorf("create sprite: %w", err) - } - fmt.Printf("OK (%s)\n", time.Since(start)) - - // Step 2-6: ... (similar pattern) - - // Step 9: Report - fmt.Println() - fmt.Println("=== Results ===") - fmt.Printf("Total time: %s\n", totalDuration) - fmt.Printf("Sprite name: %s\n", spriteName) - fmt.Println("All steps passed!") - - return nil -} -``` - -### Test Harness API Endpoint - -``` -POST /api/v1/sprites/test -Request: { "token": "optional-override" } // uses stored token if not provided -Response: { - "success": true, - "steps": [ - { "name": "create_sprite", "duration_ms": 2340, "success": true }, - { "name": "install_deps", "duration_ms": 12300, "success": true }, - { "name": "upload_agentctl", "duration_ms": 2100, "success": true }, - { "name": "health_check", "duration_ms": 150, "success": true }, - { "name": "cleanup", "duration_ms": 800, "success": true } - ], - "total_duration_ms": 17690, - "sprite_name": "kandev-test-1708444800" -} -``` - ---- - -## API Endpoints - -### Sprites-Specific Endpoints - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/api/v1/sprites/test` | Run test harness | -| `GET` | `/api/v1/sprites/instances` | List running sprites | -| `DELETE` | `/api/v1/sprites/instances/:name` | Destroy a sprite | -| `DELETE` | `/api/v1/sprites/instances` | Destroy all sprites | -| `GET` | `/api/v1/sprites/status` | Check API connection status | - -### WebSocket Actions - -| Action | Payload | Description | -|--------|---------|-------------| -| `sprites.test` | `{}` | Run test harness | -| `sprites.instances.list` | `{}` | List running sprites | -| `sprites.instances.destroy` | `{name}` | Destroy sprite | -| `sprites.instances.destroy_all` | `{}` | Destroy all | -| `sprites.status` | `{}` | Connection status | - ---- - -## Frontend Implementation - -### New Files - -``` -apps/web/ -├── app/settings/sprites/ -│ └── page.tsx # Sprites settings route -├── components/settings/ -│ └── sprites-settings-page.tsx # Sprites settings page -├── lib/api/domains/ -│ └── sprites-api.ts # Sprites API client -├── lib/types/ -│ └── http-sprites.ts # Sprites types -└── hooks/domains/settings/ - └── use-sprites.ts # Sprites data hooks -``` - -### TypeScript Types: `lib/types/http-sprites.ts` - -```typescript -export interface SpritesInstance { - name: string; - session_id?: string; - task_title?: string; - health_status: 'healthy' | 'unhealthy' | 'unknown'; - created_at: string; - uptime_seconds: number; -} - -export interface SpritesStatus { - connected: boolean; - token_configured: boolean; - instance_count: number; -} - -export interface SpritesTestResult { - success: boolean; - steps: SpritesTestStep[]; - total_duration_ms: number; - sprite_name: string; - error?: string; -} - -export interface SpritesTestStep { - name: string; - duration_ms: number; - success: boolean; - error?: string; - output?: string; -} -``` - -### Settings Sidebar Addition - -```typescript -// In settings-app-sidebar.tsx, under "Integrations" section: -{ - title: "Integrations", - items: [ - { title: "Sprites.dev", url: "/settings/sprites", icon: IconSparkles }, - // future: { title: "exe.dev", url: "/settings/exedev", icon: ... }, - ] -} -``` - ---- - -## Error Handling & Edge Cases - -### Common Failure Modes - -``` -┌────────────────────────────────────┬─────────────────────────────────────┐ -│ Failure │ Handling │ -├────────────────────────────────────┼─────────────────────────────────────┤ -│ Invalid API token │ Error at HealthCheck, clear message │ -│ │ "Sprites API token invalid or │ -│ │ expired. Check Settings > Sprites." │ -├────────────────────────────────────┼─────────────────────────────────────┤ -│ Sprite creation fails │ Retry once, then fail with │ -│ (API rate limit, quota) │ actionable error message │ -├────────────────────────────────────┼─────────────────────────────────────┤ -│ Node.js install fails │ Log full output, fail prep step │ -│ │ with details for debugging │ -├────────────────────────────────────┼─────────────────────────────────────┤ -│ Agent CLI install fails │ Log output, suggest checking │ -│ │ agent availability / network policy │ -├────────────────────────────────────┼─────────────────────────────────────┤ -│ agentctl upload fails │ Check binary path config, │ -│ (binary not found) │ suggest rebuilding agentctl │ -├────────────────────────────────────┼─────────────────────────────────────┤ -│ Port forwarding drops │ Health check detects, marks │ -│ │ unhealthy, attempts reconnect │ -├────────────────────────────────────┼─────────────────────────────────────┤ -│ Sprite dies unexpectedly │ Health check detects, session │ -│ │ marked as failed, cleanup │ -├────────────────────────────────────┼─────────────────────────────────────┤ -│ Network policy blocks agent │ Agent fails to call API, │ -│ API calls │ suggest adding domain to policies │ -├────────────────────────────────────┼─────────────────────────────────────┤ -│ Backend restart with running │ Recovery flow: list sprites, │ -│ sprites │ health check, reconnect or cleanup │ -├────────────────────────────────────┼─────────────────────────────────────┤ -│ Concurrent sprite limit │ Show error with current count and │ -│ (user's account quota) │ link to running sprites list │ -└────────────────────────────────────┴─────────────────────────────────────┘ -``` - -### Timeout Configuration - -```go -const ( - spriteCreateTimeout = 60 * time.Second - spriteStepTimeout = 120 * time.Second // per prep step - spriteTotalPrepTimeout = 10 * time.Minute // total prep time - spriteHealthTimeout = 10 * time.Second - spriteDestroyTimeout = 30 * time.Second -) -``` - -### Cleanup on Failure - -```go -// If any step fails during CreateInstance or Prepare: -// 1. Log the failure with full context -// 2. Attempt to destroy the sprite (best effort) -// 3. Clean up local port forwarding -// 4. Return error to lifecycle manager -// 5. Lifecycle manager emits session error event - -func (e *SpritesExecutor) cleanupOnFailure(ctx context.Context, spriteName string, instanceID string) { - // Best-effort cleanup — don't fail if cleanup itself fails - if proxy, ok := e.proxies[instanceID]; ok { - proxy.cancelFn() - delete(e.proxies, instanceID) - } - - // Try to destroy the sprite - // (sprite may not exist if creation itself failed) - e.logger.Info("cleaning up failed sprite", - zap.String("sprite", spriteName)) -} -``` - ---- - -## Files Changed Summary - -### New Files -| File | Description | -|------|-------------| -| `apps/backend/internal/agent/lifecycle/executor_sprites.go` | SpritesExecutor backend | -| `apps/backend/internal/agent/lifecycle/env_preparer_sprites.go` | Sprites environment preparer | -| `apps/backend/internal/sprites/settings.go` | Sprites settings service | -| `apps/backend/internal/sprites/handlers.go` | Sprites API handlers | -| `apps/web/app/settings/sprites/page.tsx` | Route page | -| `apps/web/components/settings/sprites-settings-page.tsx` | Settings page component | -| `apps/web/lib/api/domains/sprites-api.ts` | API client | -| `apps/web/lib/types/http-sprites.ts` | TypeScript types | -| `apps/web/hooks/domains/settings/use-sprites.ts` | Data hooks | - -### Modified Files -| File | Change | -|------|--------| -| `apps/backend/cmd/kandev/agents.go` | Register SpritesExecutor + preparer | -| `apps/backend/cmd/kandev/routes.go` | Register sprites API routes | -| `apps/backend/go.mod` | Add `github.com/superfly/sprites-go` dependency | -| `apps/backend/internal/agent/executor/executor.go` | Add `NameSprites` constant | -| `apps/backend/internal/task/models/models.go` | Add `ExecutorTypeSprites` constant | -| `apps/backend/internal/task/repository/sqlite/defaults.go` | Default sprites executor + profile | -| `apps/web/lib/executor-icons.ts` | Add sprites icon | -| `apps/web/components/settings/settings-app-sidebar.tsx` | Add Sprites nav item | - -### Dependencies -| Dependency | Purpose | -|------------|---------| -| `github.com/superfly/sprites-go` | Sprites.dev Go SDK | - ---- - -## Implementation Order - -1. **Add sprites-go dependency** — `go get github.com/superfly/sprites-go` -2. **SpritesExecutor skeleton** — implement interface with stub methods -3. **Environment preparer** — step-by-step sprite setup -4. **agentctl deployment** — binary upload + launch -5. **Port forwarding** — ProxyPort integration -6. **Recovery** — list + reconnect sprites on restart -7. **Settings page** — frontend for API key, policies, running sprites -8. **Test harness** — CLI + API endpoint for end-to-end testing -9. **Health checks** — integrate with health loop from Plan 02 -10. **Polish** — error messages, timeouts, cleanup - ---- - -*Last updated: 2026-02-20* diff --git a/docs/plans/04-executors.md b/docs/plans/04-executors.md deleted file mode 100644 index ca12947139..0000000000 --- a/docs/plans/04-executors.md +++ /dev/null @@ -1,63 +0,0 @@ -also i think you're miss understanding a bit the sprites.dev executor: - - the user should not be able to change its name, an Executor can't be changed by the user, he can only change the profile he created - - connection and "Configure a SPRITES_API_TOKEN environment variable in the executor profile, referencing a secret with your Sprites.dev API token." is wrong, so the env variable is wrong, the secrets are now key value only, and - the connection and running sprites that are in the page settings/executor/exec-sprites are wrong because they should belong to a sprites profile, not the executor - - the executor page should only have the option for the user to create a new profile and list the existing profiles - each executor profile is specific to that executor, it can't be "generic" or shared. - - when creating an executor profile, lets say sprites.dev, the should needs to specify the api key (we should display a selector for him to choose from the secrets) - - e.g for the sprites.dev profile the user should provide: - - api key (select from secrets) - - test connection check - - env variables list (that the user can specity or use values from secrets), let the user know these variables will be inject for the runtime and setup script - - prepare env script - - cleanup env script - actually i think its a good idea for the prepare env to be a bash script, with the steps we need (e.g install deps, git clone, setup permissions, etc) but using placeholders (templating) that we'll parse and run - so the user can see clearly what will run and change it, our default scripts should have good comments to guide the user, and on the interface use the monaco editor with syntax highlighting/good editing capabilities - (when opening a sprits.dev profile, we should list the running sprites and existing network policies and let the user stop/remove them) - - - so this prepare env script would have several placeholders like (examples, you need to adapt): - #!/bin/bash - # install deps - apt-install node npm ca-certificates - - # setup git - git clone {repository.ssh_url} - cd {repository.name} - # run repository setup script if configured - {repository.setup.script} - - {kandev.agentctl.install} # pulls from github or copies on make dev? - {kandev.agentctl.start} # runs our agentctl process with our flags - - each executor should have a default prepare script, that is filled when the user creates a new profile, he then can change it - - lets go for each executor: - - Local - we should create a Local profile by default, its profile should only have a prepare setup script that runs git pull / fetch? - (we should provide info to the user to let him know this will run on the chosen repo) - - - Worktree - we should create a Worktree profile by default, its profile should only have a prepare setup script that runs git pull / fetch? - - - Docker - we should create a "Local Docker" profile by default, its profile should have: - - docker host value - - type: local, remote (local for now) - - base image - - build custom image + editor (have a default dockerfile contents with at least node and git, and the user can change it) - - build args - - volumes - should be a list, with our added volumes (like the repo, the agentctl binary, etc disabled) and the user can add more if he wants - - env variables - - (when opening a docker profile, we should list the running containers and let the user stop/remove them) - - so we can use this docker executor as well to let the user a remote docker profile, he would need to provide a host and ssh config? or only a remote docker host? but what about authentication? - anything the local and remote docker profiles should be here - - so we can remove the concept of an environment entirely and the dedicated settings page for it, each executor profile will have its "environment" - in the create task dialog, we list the existing executor profiles to run (not the executors type name, only the existing profiles) diff --git a/github-plan.md b/github-plan.md deleted file mode 100644 index 7115bdaf08..0000000000 --- a/github-plan.md +++ /dev/null @@ -1,744 +0,0 @@ -# GitHub Integration Plan - -## Context - -Kandev needs GitHub integration to support two automation workflows: - -1. **PR Monitor** - When an agent creates a PR during a session, poll GitHub for CI failures and review comments. The user manually reviews this data in the session UI and can choose to send specific feedback to the agent for fixing. -2. **PR Review Queue** - A background service that periodically polls GitHub for PRs awaiting the user's review, creates tasks in a workflow for each, and optionally auto-starts an agent to review them. - -**Additional requirements**: -- PRs must be associated with tasks in the DB (show PR icon on task list) -- Session detail UI: show PR, comments, CI checks, approval status, pending review count, time-pending-review -- PR analytics/stats: PRs created, reviewed, comments, CI results - -**Design decisions** (confirmed with user): -- Auth: `gh` CLI primary (existing `gh auth login`), PAT in secrets as fallback -- PR detection: Auto-detect from git push events (no manual workflow config) -- PR monitor: User manually reviews feedback, NOT auto-sent to agent -- Review queue: Standalone background service with a manual "pull now" topbar button -- Scope: Full stack (backend + frontend) - ---- - -## Phase 1: GitHub Client Package - -**New package**: `apps/backend/internal/github/` - -### 1.1 `models.go` - Data models - -```go -// PR represents a GitHub Pull Request -type PR struct { - Number int - Title string - URL string - HTMLURL string - State string // open, closed, merged - HeadBranch string - BaseBranch string - AuthorLogin string - RepoOwner string - RepoName string - Draft bool - Mergeable bool - Additions int - Deletions int - CreatedAt time.Time - UpdatedAt time.Time - MergedAt *time.Time - ClosedAt *time.Time -} - -// PRReview represents a review on a PR -type PRReview struct { - ID int64 - Author string - State string // APPROVED, CHANGES_REQUESTED, COMMENTED, PENDING, DISMISSED - Body string - CreatedAt time.Time -} - -// PRComment represents a review comment on specific code -type PRComment struct { - ID int64 - Author string - Body string - Path string - Line int - Side string // LEFT, RIGHT - CreatedAt time.Time - UpdatedAt time.Time - InReplyTo *int64 -} - -// CheckRun represents a CI check result -type CheckRun struct { - Name string - Status string // queued, in_progress, completed - Conclusion string // success, failure, neutral, cancelled, timed_out, action_required, skipped - HTMLURL string - Output string // summary text - StartedAt *time.Time - CompletedAt *time.Time -} - -// PRFeedback aggregates all feedback for a PR (fetched live) -type PRFeedback struct { - PR *PR - Reviews []PRReview - Comments []PRComment - Checks []CheckRun - HasIssues bool // true if failing checks or changes_requested -} - -// PRWatch tracks active PR monitoring (session → PR) -type PRWatch struct { - ID string - SessionID string - TaskID string - Owner string - Repo string - PRNumber int - Branch string - LastCheckedAt *time.Time - LastCommentAt *time.Time - LastCheckStatus string - CreatedAt time.Time - UpdatedAt time.Time -} - -// ReviewWatch configures periodic polling for PRs needing user's review -type ReviewWatch struct { - ID string - WorkspaceID string - WorkflowID string - WorkflowStepID string - RepoOwner string - RepoName string - AgentProfileID string - ExecutorProfileID string - Prompt string - AutoStart bool - Enabled bool - PollIntervalSeconds int - LastPolledAt *time.Time - CreatedAt time.Time - UpdatedAt time.Time -} - -// GitHubStatus represents GitHub connection status -type GitHubStatus struct { - Authenticated bool - Username string - AuthMethod string // "gh_cli", "pat", "none" -} -``` - -### 1.2 `client.go` - Interface - -```go -type Client interface { - // Auth - IsAuthenticated(ctx context.Context) (bool, error) - GetAuthenticatedUser(ctx context.Context) (string, error) - - // PR queries - GetPR(ctx context.Context, owner, repo string, number int) (*PR, error) - FindPRByBranch(ctx context.Context, owner, repo, branch string) (*PR, error) - ListAuthoredPRs(ctx context.Context, owner, repo string) ([]*PR, error) - ListReviewRequestedPRs(ctx context.Context, username string) ([]*PR, error) - - // PR details - ListPRReviews(ctx context.Context, owner, repo string, number int) ([]PRReview, error) - ListPRComments(ctx context.Context, owner, repo string, number int, since *time.Time) ([]PRComment, error) - ListCheckRuns(ctx context.Context, owner, repo, ref string) ([]CheckRun, error) - - // Aggregated - GetPRFeedback(ctx context.Context, owner, repo string, number int) (*PRFeedback, error) -} -``` - -### 1.3 `gh_client.go` - Primary (gh CLI) - -Executes `gh` commands: -- `gh pr list --json number,title,url,headRefName,baseRefName,author,state,createdAt,updatedAt,additions,deletions,isDraft,mergeable` -- `gh pr view --json reviews,comments` -- `gh api repos/{owner}/{repo}/commits/{ref}/check-runs` -- `gh api search/issues?q=type:pr+review-requested:@me+state:open` - -Detect availability via `gh --version`. Parse JSON stdout. - -### 1.4 `pat_client.go` - Fallback (PAT via net/http) - -Uses `GITHUB_TOKEN` secret from secrets store. `Authorization: token `. Rate limit header awareness. - -### 1.5 `factory.go` - Client factory - -Try `gh` CLI first, fall back to PAT from secrets store. - -### 1.6 `provider.go` - DI provider - -Standard `Provide(cfg, log, secretsSvc, db) (*Service, cleanup, error)` pattern. - ---- - -## Phase 2: Service + Persistence - -### 2.1 `service.go` - Business logic - -```go -type Service struct { - client Client - store *Store - eventBus bus.EventBus - logger *logger.Logger -} - -// PR Watch operations (for PR monitoring) -func (s *Service) CreatePRWatch(ctx, sessionID, taskID, owner, repo string, prNumber int, branch string) (*PRWatch, error) -func (s *Service) DeletePRWatch(ctx, id string) error -func (s *Service) GetPRWatchBySession(ctx, sessionID string) (*PRWatch, error) -func (s *Service) ListActivePRWatches(ctx) ([]*PRWatch, error) - -// Task-PR association -func (s *Service) AssociatePRWithTask(ctx, taskID, owner, repo string, prNumber int, prURL, prTitle, authorLogin string) (*TaskPR, error) -func (s *Service) GetTaskPR(ctx, taskID string) (*TaskPR, error) -func (s *Service) ListTaskPRs(ctx, taskIDs []string) (map[string]*TaskPR, error) -func (s *Service) UpdateTaskPR(ctx, id string, state, mergeState string, ...) error - -// PR feedback (fetched live, not stored) -func (s *Service) GetPRFeedback(ctx, owner, repo string, number int) (*PRFeedback, error) - -// Review Watch operations (for review queue) -func (s *Service) CreateReviewWatch(ctx, req *CreateReviewWatchRequest) (*ReviewWatch, error) -func (s *Service) UpdateReviewWatch(ctx, id string, req *UpdateReviewWatchRequest) error -func (s *Service) DeleteReviewWatch(ctx, id string) error -func (s *Service) ListReviewWatches(ctx, workspaceID string) ([]*ReviewWatch, error) - -// Polling -func (s *Service) CheckPRWatch(ctx, watch *PRWatch) (*PRFeedback, bool, error) // returns hasNewFeedback -func (s *Service) CheckReviewWatch(ctx, watch *ReviewWatch) ([]*PR, error) // returns new PRs - -// Manual fetch -func (s *Service) TriggerReviewCheck(ctx, watchID string) ([]*PR, error) -func (s *Service) TriggerAllReviewChecks(ctx, workspaceID string) (int, error) // returns count of new PRs found - -// Status -func (s *Service) GetStatus(ctx) (*GitHubStatus, error) -``` - -### 2.2 `store.go` - SQLite persistence - -Four tables (owned by github package): - -```sql --- Tracks active PR monitoring (session → PR) -CREATE TABLE IF NOT EXISTS github_pr_watches ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL UNIQUE, - task_id TEXT NOT NULL, - owner TEXT NOT NULL, - repo TEXT NOT NULL, - pr_number INTEGER NOT NULL, - branch TEXT NOT NULL, - last_checked_at DATETIME, - last_comment_at DATETIME, - last_check_status TEXT DEFAULT '', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL -); - --- Associates a PR with a task (1:1 for now, could be 1:many later) --- Used for: PR icon on task list, session detail PR view, stats -CREATE TABLE IF NOT EXISTS github_task_prs ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - owner TEXT NOT NULL, - repo TEXT NOT NULL, - pr_number INTEGER NOT NULL, - pr_url TEXT NOT NULL, - pr_title TEXT NOT NULL, - head_branch TEXT NOT NULL, - base_branch TEXT NOT NULL, - author_login TEXT NOT NULL, - state TEXT NOT NULL DEFAULT 'open', -- open, closed, merged - review_state TEXT NOT NULL DEFAULT '', -- approved, changes_requested, pending, '' - checks_state TEXT NOT NULL DEFAULT '', -- success, failure, pending, '' - review_count INTEGER DEFAULT 0, - pending_review_count INTEGER DEFAULT 0, - comment_count INTEGER DEFAULT 0, - additions INTEGER DEFAULT 0, - deletions INTEGER DEFAULT 0, - created_at DATETIME NOT NULL, -- PR creation time on GitHub - merged_at DATETIME, - closed_at DATETIME, - last_synced_at DATETIME, - updated_at DATETIME NOT NULL, - UNIQUE(task_id, pr_number) -); - --- Review watch configurations (workspace → repo polling) -CREATE TABLE IF NOT EXISTS github_review_watches ( - id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL, - workflow_id TEXT NOT NULL, - workflow_step_id TEXT NOT NULL, - repo_owner TEXT NOT NULL, - repo_name TEXT NOT NULL, - agent_profile_id TEXT NOT NULL, - executor_profile_id TEXT NOT NULL, - prompt TEXT DEFAULT '', - auto_start BOOLEAN DEFAULT 0, - enabled BOOLEAN DEFAULT 1, - poll_interval_seconds INTEGER DEFAULT 300, - last_polled_at DATETIME, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL -); - --- Deduplication: which PRs we already created tasks for -CREATE TABLE IF NOT EXISTS github_review_pr_tasks ( - id TEXT PRIMARY KEY, - review_watch_id TEXT NOT NULL, - pr_number INTEGER NOT NULL, - pr_url TEXT NOT NULL, - task_id TEXT NOT NULL, - created_at DATETIME NOT NULL, - UNIQUE(review_watch_id, pr_number) -); -``` - ---- - -## Phase 3: Background Poller - -### 3.1 `poller.go` - -Two goroutine loops: - -**PR Monitor Loop** (every 2 min): -1. List all active `github_pr_watches` -2. For each watch, fetch PR feedback (comments + checks) via client -3. Compare with last known state (last_comment_at, last_check_status) -4. If new comments or check state changes: - - Update the `github_task_prs` record (review_state, checks_state, comment_count, etc.) - - Publish `github.pr_feedback` event to event bus (for UI notification, NOT auto-prompt) - - Update watch timestamps -5. Also sync PR state (merged, closed) to `github_task_prs` - -**Review Queue Loop** (every 5 min, per-watch configurable): -1. List all enabled `github_review_watches` -2. For each watch, call `ListReviewRequestedPRs` filtered to the watch's repo -3. For each PR not already in `github_review_pr_tasks`: - - Publish `github.new_pr_to_review` event - - Orchestrator handler creates the task + associates the PR - -Methods: `Start(ctx)`, `Stop()`, `TriggerReviewCheck(ctx, watchID)`, `TriggerAllReviewChecks(ctx, workspaceID)`. - ---- - -## Phase 4: Event Bus + Orchestrator Integration - -### 4.1 New event types - -**File**: `apps/backend/internal/events/types.go` - -```go -// Event types for GitHub integration -const ( - GitHubPRFeedback = "github.pr_feedback" // PR has new feedback (UI notification only) - GitHubPRStateChanged = "github.pr_state_changed" // PR state changed (merged, closed, etc.) - GitHubNewReviewPR = "github.new_pr_to_review" // New PR found needing review - GitHubWatchEvent = "github.watch.event" // Watch created/deleted - GitHubTaskPRUpdated = "github.task_pr.updated" // TaskPR record updated (for UI refresh) -) -``` - -### 4.2 PR auto-detection from git events - -**File**: `apps/backend/internal/orchestrator/event_handlers_git.go` - -In `handleGitStatusUpdate()`, detect push completion: -- Track per-session state: when `Ahead` goes from `>0` to `0` with a `RemoteBranch` set, a push happened -- After detecting push, call `githubService.Client().FindPRByBranch()` to check if a PR exists -- If PR found and no watch exists for this session: - 1. Auto-create `PRWatch` via `githubService.CreatePRWatch()` - 2. Auto-create `TaskPR` association via `githubService.AssociatePRWithTask()` - 3. Publish `github.task_pr.updated` event (so task list shows PR icon) - -**Lookup chain**: -- `repoStore.GetTaskSession(sessionID)` → `RepositoryID` -- `repoStore.GetRepository(repositoryID)` → `ProviderOwner`, `ProviderName` -- `FindPRByBranch(providerOwner, providerName, branch)` - -### 4.3 Orchestrator GitHub event handlers - -**New file**: `apps/backend/internal/orchestrator/event_handlers_github.go` - -```go -// handlePRFeedback forwards PR feedback events to the frontend via WS. -// The user manually decides whether to send feedback to the agent. -// This does NOT auto-prompt the agent. -func (s *Service) handlePRFeedback(ctx context.Context, event *bus.Event) { - // Extract sessionID, feedback summary from event - // Forward to WS for session UI notification - // Frontend shows updated PR data in session detail panel -} - -// handleNewReviewPR creates a task for a PR needing review. -func (s *Service) handleNewReviewPR(ctx context.Context, event *bus.Event) { - // Extract PR data, ReviewWatch config - // Create task in the watch's workflow at configured step - // Associate PR with task via githubService.AssociatePRWithTask() - // Record in github_review_pr_tasks for dedup - // If auto_start, enqueue the task for agent execution -} -``` - -### 4.4 "Send to Agent" action - -The session detail UI will have a "Send to Agent" button that lets the user compose a message to the agent including selected PR comments and/or CI failures. This uses the existing `PromptTask()` flow — the frontend sends a user message with the selected feedback context embedded. - -### 4.5 Orchestrator wiring - -**File**: `apps/backend/internal/orchestrator/service.go` -- Add `githubService` field + `SetGitHubService()` setter -- Add `pushTracker sync.Map` for per-session ahead-count tracking -- Subscribe to `github.pr_feedback` and `github.new_pr_to_review` events in `Start()` - -**File**: `apps/backend/cmd/kandev/services.go` -- Add GitHub service to `Services` struct, initialize via `github.Provide()` -- Wire into orchestrator, start poller - -**File**: `apps/backend/cmd/kandev/main.go` -- Register GitHub HTTP routes - ---- - -## Phase 5: HTTP API - -### 5.1 `controller.go` - -**Route group**: `/api/v1/github/` - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/status` | GET | Auth status (username, auth method) | -| `/prs/authored` | GET | User's open PRs (query: owner, repo) | -| `/prs/review-requested` | GET | PRs awaiting user's review | -| `/prs/{owner}/{repo}/{number}` | GET | PR details with full feedback | -| `/prs/{owner}/{repo}/{number}/checks` | GET | CI check status only | -| `/prs/{owner}/{repo}/{number}/comments` | GET | PR comments only | -| `/task-prs` | GET | List task-PR associations (query: task_ids) | -| `/task-prs/{taskId}` | GET | Get PR association for a task | -| `/watches/pr` | GET | List active PR watches | -| `/watches/pr/{id}` | DELETE | Remove a PR watch | -| `/watches/review` | GET | List review watches (query: workspace_id) | -| `/watches/review` | POST | Create review watch | -| `/watches/review/{id}` | PUT | Update review watch | -| `/watches/review/{id}` | DELETE | Delete review watch | -| `/watches/review/{id}/trigger` | POST | Manual "pull now" for one watch | -| `/watches/review/trigger-all` | POST | Trigger all for workspace | -| `/stats` | GET | PR stats (query: workspace_id, date range) | - -### 5.2 `handlers.go` - WS handlers - -| Action | Description | -|--------|-------------| -| `github.status` | Auth status | -| `github.task_prs.list` | Get task-PR associations (batch) | -| `github.pr_feedback.get` | Get live PR feedback for session | -| `github.review_watches.list` | List review watches | -| `github.review_watches.create` | Create review watch | -| `github.review_watches.update` | Update review watch | -| `github.review_watches.delete` | Delete review watch | -| `github.review_watches.trigger` | Trigger single check | -| `github.review_watches.trigger_all` | Trigger all for workspace | - ---- - -## Phase 6: Frontend - -### 6.1 Types: `apps/web/lib/types/github.ts` - -```typescript -interface GitHubStatus { - authenticated: boolean; - username: string; - authMethod: 'gh_cli' | 'pat' | 'none'; -} - -interface GitHubPR { - number: number; title: string; url: string; htmlUrl: string; - state: 'open' | 'closed' | 'merged'; - headBranch: string; baseBranch: string; - authorLogin: string; repoOwner: string; repoName: string; - draft: boolean; additions: number; deletions: number; - createdAt: string; updatedAt: string; - mergedAt: string | null; closedAt: string | null; -} - -interface TaskPR { - id: string; taskId: string; - owner: string; repo: string; prNumber: number; - prUrl: string; prTitle: string; - headBranch: string; baseBranch: string; authorLogin: string; - state: 'open' | 'closed' | 'merged'; - reviewState: 'approved' | 'changes_requested' | 'pending' | ''; - checksState: 'success' | 'failure' | 'pending' | ''; - reviewCount: number; pendingReviewCount: number; commentCount: number; - additions: number; deletions: number; - createdAt: string; mergedAt: string | null; closedAt: string | null; - lastSyncedAt: string | null; -} - -interface PRReview { id: number; author: string; state: string; body: string; createdAt: string; } -interface PRComment { id: number; author: string; body: string; path: string; line: number; side: string; createdAt: string; inReplyTo: number | null; } -interface CheckRun { name: string; status: string; conclusion: string; htmlUrl: string; output: string; } -interface PRFeedback { pr: GitHubPR; reviews: PRReview[]; comments: PRComment[]; checks: CheckRun[]; hasIssues: boolean; } - -interface PRWatch { id: string; sessionId: string; taskId: string; owner: string; repo: string; prNumber: number; branch: string; lastCheckedAt: string | null; } -interface ReviewWatch { id: string; workspaceId: string; workflowId: string; workflowStepId: string; repoOwner: string; repoName: string; agentProfileId: string; executorProfileId: string; prompt: string; autoStart: boolean; enabled: boolean; pollIntervalSeconds: number; lastPolledAt: string | null; } -``` - -### 6.2 API Client: `apps/web/lib/api/domains/github-api.ts` - -Methods mirroring all HTTP endpoints. - -### 6.3 Store: `apps/web/lib/state/slices/github/github-slice.ts` - -Zustand slice: -- `github.status` - auth status -- `github.taskPRs.byTaskId` - PR associations per task (for task list PR icon) -- `github.prWatches.bySessionId` - active PR watches per session -- `github.reviewWatches.byWorkspaceId` - review watch configs - -### 6.4 Hooks: `apps/web/hooks/domains/github/` - -- `use-github-status.ts` - auth status -- `use-task-pr.ts` - PR association for a task (or batch for task list) -- `use-pr-feedback.ts` - live PR feedback for session detail -- `use-review-watches.ts` - CRUD for review watches -- `use-pr-watch.ts` - PR monitoring status per session - -### 6.5 Task List: PR Icon - -**File**: Modify task card/row components to show a PR icon (e.g., git-pull-request icon from lucide) when `taskPR` exists for that task. Color-coded: green (approved/passing), red (changes requested/failing), yellow (pending). - -### 6.6 Settings Page: `apps/web/app/settings/general/github/page.tsx` - -- **Connection Status card**: Auth method, username, `gh` CLI availability -- **Review Watches table**: List watches with repo, workflow, status, last polled -- **Create/Edit Review Watch dialog**: Repo selector (from workspace repos with `provider: "github"`), workflow+step selector, agent profile, executor profile, custom prompt, auto-start toggle, poll interval, enable/disable - -### 6.7 Session Detail: PR Info in Topbar + PR Panel - -**Session topbar** (`apps/web/components/github/pr-session-topbar.tsx`): -In the session detail topbar (not the kanban topbar), show a compact PR summary when a PR is associated: -- PR number + link (e.g., `#42`) -- State badge (open/merged/closed) -- CI status indicator (green check / red X / yellow spinner) -- Review status (e.g., "2/3 approved", "changes requested") -- Comment count badge -- Clicking opens the full PR detail panel - -**Full PR detail panel** (`apps/web/components/github/pr-detail-panel.tsx`): -Expandable/drawer panel accessible from the session topbar: -- **PR header**: Title, number, link, state badge, draft indicator -- **Stats bar**: +additions/-deletions, time since created, time pending review -- **Reviews section**: List of reviews with state (approved/changes_requested), author, body -- **Pending reviewers**: Who hasn't reviewed yet, how long pending -- **CI Checks section**: List of check runs with status badge (pass/fail/pending), link to details -- **Comments section**: Threaded review comments with file path, line number, author -- **"Send to Agent" button**: Opens a dialog to compose a message to the agent, with checkboxes to include specific comments/CI failures as context. Sends via normal `PromptTask` flow. - -### 6.8 Kanban Homepage Topbar: Manual Pull Button - -**Component**: `apps/web/components/github/refresh-reviews-button.tsx` - -Button in the **kanban/homepage topbar** (NOT the session detail topbar): -- Triggers `POST /watches/review/trigger-all` for active workspace -- Shows toast with results ("Found 3 new PRs to review") -- Only visible when review watches exist for the workspace - -### 6.9 Stats - -**Route**: `/api/v1/github/stats` (backend) + stats section in settings or dedicated page - -**Backend** (`internal/github/stats.go`): - -```go -type PRStats struct { - // Counts - TotalPRsCreated int // PRs authored by user (from github_task_prs) - TotalPRsReviewed int // PRs reviewed (tasks from review watches) - TotalComments int // Total review comments across all PRs - TotalChecksRun int // Total CI check runs tracked - - // Rates - CIPassRate float64 // % of PRs where all checks passed - ApprovalRate float64 // % of PRs approved on first review cycle - - // Timing (averages) - AvgTimeToFirstReview time.Duration // Time from PR creation to first review - AvgTimeToMerge time.Duration // Time from PR creation to merge - AvgTimePendingReview time.Duration // Time PRs spent waiting for review - - // By period (for charts) - PRsByDay []DailyCount // PRs created per day - ReviewsByDay []DailyCount // PRs reviewed per day - CommentsByDay []DailyCount // Comments per day -} - -type DailyCount struct { - Date string `json:"date"` // "2026-02-22" - Count int `json:"count"` -} -``` - -Data sourced from `github_task_prs` table aggregations with date range filtering. - -**Frontend** (`apps/web/components/github/pr-stats.tsx`): -- Stats cards: PRs created, reviewed, avg time to merge, CI pass rate -- Line charts: PRs over time, comments over time -- Uses `@kandev/ui` Card, Badge for layout -- Could be embedded in GitHub settings page or as a section in a workspace dashboard - ---- - -## Files to Create - -``` -apps/backend/internal/github/ - models.go # Data models (PR, Review, Comment, Check, Watch, TaskPR) - client.go # Client interface - gh_client.go # gh CLI implementation - pat_client.go # PAT HTTP implementation - factory.go # Client factory (gh CLI → PAT fallback) - store.go # SQLite persistence (watches, task_prs, dedup) - service.go # Business logic - poller.go # Background polling loops - controller.go # HTTP endpoints - handlers.go # WS handlers - provider.go # DI provider - stats.go # PR analytics queries - -apps/web/ - lib/types/github.ts - lib/api/domains/github-api.ts - lib/state/slices/github/ - index.ts - github-slice.ts - hooks/domains/github/ - use-github-status.ts - use-task-pr.ts - use-pr-feedback.ts - use-review-watches.ts - use-pr-watch.ts - app/settings/general/github/ - page.tsx - components/github/ - github-status.tsx - review-watch-table.tsx - review-watch-dialog.tsx - pr-detail-panel.tsx # Session detail PR view (expandable panel) - pr-session-topbar.tsx # Compact PR info in session topbar - pr-task-icon.tsx # PR icon for task list - refresh-reviews-button.tsx # Topbar manual pull button - send-to-agent-dialog.tsx # Compose feedback to send to agent - pr-stats.tsx # PR analytics cards + charts -``` - -## Files to Modify - -``` -apps/backend/internal/events/types.go # Add GitHub event constants -apps/backend/internal/orchestrator/service.go # Add githubService field + setter + push tracker -apps/backend/internal/orchestrator/event_handlers_git.go # PR auto-detection on push -apps/backend/cmd/kandev/services.go # Wire GitHub service -apps/backend/cmd/kandev/types.go # Add GitHub to Services struct -apps/backend/cmd/kandev/main.go # Register GitHub routes - -apps/web/lib/state/store.ts # Add github slice -apps/web/lib/state/hydration/merge-strategies.ts # Add github hydration -apps/web/app/settings/general/ (layout or nav) # Add GitHub settings link -apps/web/components/ (task card/row) # Add PR icon to task list -apps/web/components/ (session detail panel) # Add PR detail panel -``` - -## New file: `apps/backend/internal/orchestrator/event_handlers_github.go` - -Orchestrator event handlers for GitHub events (handlePRFeedback, handleNewReviewPR). - -## Key Patterns to Reuse - -- **Provider pattern**: `Provide(cfg, log, ...) (*Service, cleanup, error)` — see `apps/backend/internal/secrets/provider.go` -- **Event bus**: `bus.EventBus` publish/subscribe — see `apps/backend/internal/events/types.go` -- **Setter injection**: `SetGitHubService()` — see `orchestrator/service.go` `SetWorkflowStepGetter()` -- **Repository sub-interfaces**: Split interfaces in `apps/backend/internal/task/repository/interface.go` -- **Task service Repos struct**: `taskservice.Repos{}` — see `apps/backend/cmd/kandev/services.go:38` -- **Git event handling**: `handleGitStatusUpdate()` in `apps/backend/internal/orchestrator/event_handlers_git.go` -- **Repository model provider fields**: `Provider`, `ProviderOwner`, `ProviderName` in `apps/backend/internal/task/models/models.go:285` -- **Secrets store for PAT**: `apps/backend/internal/secrets/service.go` (Reveal method) -- **Credential env provider**: `GITHUB_TOKEN` already listed in `apps/backend/internal/agent/credentials/env_provider.go:25` -- **WS handler pattern**: See existing handlers in `apps/backend/internal/task/handlers/` -- **Frontend store slices**: Domain pattern in `apps/web/lib/state/slices/` -- **Frontend hooks**: Domain pattern in `apps/web/hooks/domains/` -- **Settings page layout**: Existing pages at `apps/web/app/settings/general/{editors,secrets,notifications}/` -- **Shadcn components**: Import from `@kandev/ui` — Table, Dialog, Button, Badge, etc. - -## Implementation Order - -### Milestone 1: GitHub Client (Backend) -1. `models.go` - data models -2. `client.go` - interface -3. `gh_client.go` - gh CLI implementation -4. `pat_client.go` - PAT fallback -5. `factory.go` - client factory -6. Unit tests for client JSON parsing - -### Milestone 2: Persistence + Service (Backend) -7. `store.go` - SQLite tables + CRUD -8. `service.go` - business logic -9. `provider.go` - DI provider -10. Wire into `cmd/kandev/services.go` - -### Milestone 3: API + Handlers (Backend) -11. `controller.go` - HTTP endpoints -12. `handlers.go` - WS handlers -13. Add event types to `events/types.go` -14. Wire routes in `cmd/kandev/main.go` - -### Milestone 4: Poller + Orchestrator (Backend) -15. `poller.go` - background polling -16. Push detection in `event_handlers_git.go` -17. `event_handlers_github.go` - orchestrator GitHub event handlers -18. Wire GitHub service into orchestrator -19. Integration tests - -### Milestone 5: Frontend Core -20. TypeScript types -21. API client -22. Store slice -23. Domain hooks - -### Milestone 6: Frontend UI -24. Settings page (GitHub config + review watches) -25. Task list PR icon -26. Session detail PR panel (PR, comments, CI, approvals, "Send to Agent") -27. Topbar refresh button - -### Milestone 7: Stats -28. `stats.go` - PR analytics queries (aggregations on `github_task_prs`) -29. Stats HTTP endpoint (`/api/v1/github/stats`) -30. Frontend stats component (`pr-stats.tsx`) with cards + charts -31. Integrate stats into GitHub settings page or dashboard - -## Verification - -1. **Backend unit tests**: Client JSON parsing, store CRUD, poller logic -2. **Integration**: `make -C apps/backend test` -3. **Lint/typecheck**: `make -C apps/backend lint` + `cd apps && pnpm --filter @kandev/web typecheck` -4. **Manual**: Check `/api/v1/github/status`, create review watch, trigger manual pull -5. **E2E**: Agent pushes → PR auto-detected → PR watch created → task shows PR icon → session detail shows PR panel → user reviews and sends feedback to agent diff --git a/scripts/release/publish-release.sh b/scripts/release/publish-release.sh index 687b819c68..1eba653147 100755 --- a/scripts/release/publish-release.sh +++ b/scripts/release/publish-release.sh @@ -5,28 +5,21 @@ set -euo pipefail if [ $# -lt 1 ]; then echo "Usage: $0 " - echo "Example: $0 0.1.2" + echo "Example: $0 v0.1.2" exit 1 fi VERSION="$1" -LAUNCHER_VERSION="$(node -p "require('./apps/cli/package.json').version")" -if [ "$LAUNCHER_VERSION" != "$VERSION" ]; then - echo "Launcher version mismatch: apps/cli/package.json is $LAUNCHER_VERSION" - echo "Run scripts/release/publish-launcher.sh $VERSION first." +if git rev-parse "$VERSION" >/dev/null 2>&1; then + echo "Tag $VERSION already exists." exit 1 fi -if git rev-parse "v$VERSION" >/dev/null 2>&1; then - echo "Tag v$VERSION already exists." - exit 1 -fi +echo "Creating tag $VERSION..." +git tag "$VERSION" -echo "Creating tag v$VERSION..." -git tag "v$VERSION" - -echo "Pushing tag v$VERSION..." -git push origin "v$VERSION" +echo "Pushing tag $VERSION..." +git push origin "$VERSION" echo "Done. GitHub Actions will build and upload runtime bundles."