feat: Add multi-provider code agent interface with Claude Code and OpenCode adapters

Implements weeks 1-4 of the multi-provider architecture:

Week 1 - Foundation:
- Add domain models (AgentProvider, AgentRequest, AgentEvent, AgentResult)
- Define CodeAgent port interface with Execute, Cancel, Capabilities
- Create thread-safe provider registry with first-registered default

Week 2 - Claude Code Adapter:
- Extract kubectl exec logic into CodeAgent implementation
- Parse stream-json output format (init, message, tool_use, result)
- Support session continuation via --resume flag

Week 3 - OpenCode Adapter:
- HTTP/SSE client for opencode serve API
- Session management (create, send message, abort)
- Event streaming with documented buffer rationale

Week 4 - Quality & Polish:
- Fix race condition in OpenCode Cancel method
- Add AgentRequest.Validate() with ErrPromptRequired, ErrInvalidTimeout
- Document DefaultAvailabilityTimeout constants
- Add HTTP error context for debugging

Also includes:
- Work queue system with PostgreSQL adapter
- Credential store for infrastructure secrets
- Project templates with Woodpecker CI integration
- Comprehensive test coverage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-01-27 09:25:51 -07:00
parent 812b8341be
commit 39df51defd
105 changed files with 11795 additions and 803 deletions

3
.gitignore vendored
View File

@ -28,6 +28,9 @@ Thumbs.db
/rdev-api
coverage.out
# Temporary files
tmp/
# Deploy keys (generated, never commit)
*-deploy-key
*-deploy-key.pub

111
CLAUDE.md
View File

@ -1,46 +1,117 @@
# rdev - Remote Developer
Run Claude Code in isolated Kubernetes pods, controlled via Discord.
Run Claude Code instances in isolated Kubernetes pods with REST API control. Enables bots, CI/CD systems, and external orchestrators to dispatch agentive development work to isolated environments.
**Platform:** threesix.ai - Agent-driven development at scale with shared worker pools.
## Find Your Guide
| If you need to... | Read this |
|-------------------|-----------|
| **Set up local dev** | [local/setup.md](.claude/guides/local/setup.md) |
| **Run tests** | [local/testing.md](.claude/guides/local/testing.md) |
| **Write Go code / handlers** | [backend/go-guidelines.md](.claude/guides/backend/go-guidelines.md) |
| **Understand pkg/api** | [packages/api-framework.md](.claude/guides/packages/api-framework.md) |
| **Add a new handler/endpoint** | [backend/adding-handlers.md](.claude/guides/backend/adding-handlers.md) |
| **Understand hexagonal architecture** | [backend/hexagonal.md](.claude/guides/backend/hexagonal.md) |
| **Deploy to k3s** | [ops/deploying.md](.claude/guides/ops/deploying.md) |
| **Work with Kubernetes adapters** | [services/kubernetes.md](.claude/guides/services/kubernetes.md) |
| **Database / migrations** | [ops/database.md](.claude/guides/ops/database.md) |
| **Manage credentials** | [ops/credentials.md](.claude/guides/ops/credentials.md) |
| **Work queue system** | [services/work-queue.md](.claude/guides/services/work-queue.md) |
| **Worker pool management** | [services/worker-pool.md](.claude/guides/services/worker-pool.md) |
| **Project templates** | [services/templates.md](.claude/guides/services/templates.md) |
| **Build orchestration** | [services/build-orchestration.md](.claude/guides/services/build-orchestration.md) |
## Critical Rules
- **KUBECONFIG:** ALWAYS set `export KUBECONFIG=~/.kube/orchard9-k3sf.yaml` before kubectl commands
- **Hexagonal:** Domain models in `internal/domain/` must have ZERO external dependencies
- **Ports:** All adapters implement interfaces from `internal/port/`
- **Migrations:** NEVER modify committed migrations. Create NEW ones.
- **500-line limit:** Files exceeding 500 lines must be split
- **Tests:** All handlers and services require tests
## Quick Reference
```bash
# Set kubeconfig for k3s (REQUIRED before any kubectl)
# Set kubeconfig (REQUIRED)
export KUBECONFIG=~/.kube/orchard9-k3sf.yaml
# Run locally
go run ./cmd/rdev-api
# Run tests
go test ./...
# Deploy
kubectl apply -k deployments/k8s/base
# Verify
kubectl exec -n rdev claudebox-0 -- claude --version
# Verify pods
kubectl get pods -n rdev
# Test Claude
kubectl exec -it -n rdev claudebox-0 -- claude "say hello"
# Check workers
kubectl get pods -n rdev -l rdev.orchard9.ai/role=worker
# Load credentials from .secrets to rdev-api
./scripts/load-credentials.sh # localhost
./scripts/load-credentials.sh https://rdev.example.com # remote
```
## Architecture
## Architecture Overview
```
k3s cluster (rdev namespace)
├── claudebox-0 (StatefulSet)
│ ├── Claude Code CLI
│ ├── /workspace (PVC)
│ └── /root/.claude (credentials secret)
└── Future: discord-bot, more claudebox pods
cmd/rdev-api/ # Entry point, DI, OpenAPI spec
internal/
├── domain/ # Pure business models (no deps)
├── port/ # Interface contracts
├── service/ # Business logic orchestration
├── handlers/ # HTTP handlers (REST endpoints)
├── adapter/ # Infrastructure implementations
│ ├── kubernetes/ # K8s client, pod executor
│ ├── postgres/ # Audit, queue, webhooks, credentials
│ ├── gitea/ # Git repository management
│ ├── cloudflare/ # DNS provider
│ └── woodpecker/ # CI provider
├── auth/ # API key auth, scopes
├── middleware/ # Rate limiting
├── worker/ # Background queue processor
└── webhook/ # Event dispatcher
pkg/api/ # HTTP framework (app, responses)
deployments/k8s/ # Kustomize manifests
└── base/templates/ # Project templates
scripts/ # Operational scripts
└── load-credentials.sh # Load secrets to rdev-api
```
## Development
## Key Concepts
This is v0.1 - base case only. See docs/reference.md for full vision.
- **Projects**: Kubernetes pods with Claude Code, discovered by label `rdev.orchard9.ai/project=true`
- **Workers**: Shared claudebox pods that execute any project's tasks, labeled `rdev.orchard9.ai/role=worker`
- **Work Queue**: Async task queue for build/test/deploy jobs
- **Credentials**: Infrastructure secrets (tokens, keys) stored encrypted in PostgreSQL
- **Commands**: Claude/shell/git commands executed via kubectl exec, streamed via SSE
- **API Keys**: Scoped auth with project restrictions, IP filtering, expiration
- **Webhooks**: Event subscriptions with retry delivery
- **Templates**: Project scaffolding with .woodpecker.yml, .claude/, and stack files
## Deploying to k3s
## threesix.ai Platform Roadmap
1. Build and push image (or use pre-built)
2. Create Claude credentials secret
3. Apply manifests: `kubectl apply -k deployments/k8s/base`
See `k3s-fleet/tmp/address-the-gaps.md` for full implementation details:
| Gap | Status | Description |
|-----|--------|-------------|
| Woodpecker Auto-Activation | Planned | Auto-enable CI on project creation |
| Project Templates | Planned | Seed repos with .woodpecker.yml, .claude/ |
| Work Queue | Planned | Task queue for worker pool |
| Worker Pool | Planned | Shared claudebox workers (3-5 pods) |
| Bot Communication | Planned | Webhook callbacks on task completion |
| Build Orchestration | Planned | Structured build specs via API |
## Constraints
- **ON-PREM k3s** - not GKE, always set KUBECONFIG
- **Kustomize only** - no ArgoCD
- **Manual deploys** - no CI/CD pipelines yet
- **chi/v5 router** - no gin, echo, or other frameworks
- **sqlx for DB** - no GORM
- **slog for logging** - no logrus, zap

191
CODING_GUIDELINES.md Normal file
View File

@ -0,0 +1,191 @@
# Coding Guidelines
## Tech Stack
| Component | Required | Forbidden |
|-----------|----------|-----------|
| HTTP Router | chi/v5 | gin, echo, fiber |
| Database | sqlx | GORM, raw sql |
| Logging | slog | log, logrus, zap |
| Config | Viper + env vars | os.Getenv directly |
| Testing | testing + testify | ginkgo, gomega |
| Kubernetes | client-go | kubectl subprocess |
## File Structure
```
cmd/{service}/ # Entry points
internal/ # Private code (hexagonal)
├── domain/ # Pure models, no deps
├── port/ # Interfaces only
├── service/ # Business logic
├── handlers/ # HTTP handlers
├── adapter/ # Infrastructure
├── auth/ # Authentication
├── middleware/ # HTTP middleware
├── worker/ # Background jobs
└── webhook/ # Event dispatch
pkg/ # Public packages
```
## Handler Pattern
All handlers implement the `Mount` interface and use `pkg/api` for responses:
```go
type UsersHandler struct {
service *service.UserService
}
func (h *UsersHandler) Mount(r chi.Router) {
r.Route("/users", func(r chi.Router) {
r.Get("/", h.List)
r.Post("/", h.Create)
r.Get("/{id}", h.Get)
})
}
func (h *UsersHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.BadRequest(w, "invalid request body")
return
}
user, err := h.service.Create(r.Context(), req)
if err != nil {
api.Error(w, err)
return
}
api.Created(w, user)
}
```
## Response Helpers (`pkg/api`)
```go
api.OK(w, data) // 200 with JSON body
api.Created(w, data) // 201 with JSON body
api.NoContent(w) // 204
api.BadRequest(w, msg) // 400
api.Unauthorized(w, msg) // 401
api.Forbidden(w, msg) // 403
api.NotFound(w, msg) // 404
api.Conflict(w, msg) // 409
api.Error(w, err) // 500 (or mapped status)
```
## Port/Adapter Pattern
Domain interfaces in `internal/port/`:
```go
// port/project.go
type ProjectRepository interface {
List(ctx context.Context) ([]domain.Project, error)
Get(ctx context.Context, id string) (*domain.Project, error)
}
type CommandExecutor interface {
Execute(ctx context.Context, project string, cmd domain.Command) (*domain.CommandResult, error)
}
```
Implementations in `internal/adapter/{name}/`:
```go
// adapter/kubernetes/repository.go
type Repository struct {
client kubernetes.Interface
}
func (r *Repository) List(ctx context.Context) ([]domain.Project, error) {
// Implementation using client-go
}
```
## Domain Models
Pure structs in `internal/domain/`, NO external dependencies:
```go
// domain/project.go
package domain
type Project struct {
ID string
Name string
Status ProjectStatus
Workspace string
}
type ProjectStatus string
const (
StatusRunning ProjectStatus = "running"
StatusPending ProjectStatus = "pending"
StatusFailed ProjectStatus = "failed"
)
```
## Error Handling
Use wrapped errors with context:
```go
if err != nil {
return fmt.Errorf("create project %s: %w", name, err)
}
```
Define domain errors in `internal/domain/errors.go`:
```go
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrConflict = errors.New("already exists")
)
```
## Testing
- Unit tests next to source: `foo_test.go`
- Table-driven tests preferred
- Mocks implement port interfaces
- Test files in `testutil/` for shared helpers
```go
func TestHandler_Create(t *testing.T) {
tests := []struct {
name string
input CreateRequest
wantStatus int
}{
{"valid", CreateRequest{Name: "test"}, 201},
{"empty name", CreateRequest{}, 400},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// ...
})
}
}
```
## Naming Conventions
- Files: `snake_case.go`
- Packages: `lowercase` (no underscores)
- Types: `PascalCase`
- Functions/Methods: `PascalCase` (exported), `camelCase` (private)
- Constants: `PascalCase` or `SCREAMING_SNAKE` for env vars
- Interfaces: Don't prefix with `I`, suffix with role (`Repository`, `Service`)
## Size Limits
- **500 lines max per file** - split when exceeded
- **100 lines max per function** - extract helpers
- **5 parameters max per function** - use struct if more

View File

@ -0,0 +1,80 @@
# Build Orchestration
**Last Updated:** 2025-01
**Confidence:** High (Planned - see address-the-gaps.md)
## Summary
Build orchestration enables structured build specs for bot-driven development. Bots submit build requests with prompts, workers execute, and callbacks notify completion.
**Key Facts:**
- Build spec includes template, prompt, variables, auto_deploy flag
- Enqueues as work task for worker pool
- Auto-deploy commits, pushes, triggers Woodpecker CI
- Callback URL notified on completion with artifacts
**File Pointers:**
- Service: `internal/service/build_service.go`
- Handler: `internal/handlers/build.go`
- Work queue: `internal/port/work_queue.go`
## Build Spec Schema
```go
type BuildSpec struct {
Template string `json:"template"`
Prompt string `json:"prompt"`
Variables map[string]string `json:"variables"`
AutoDeploy bool `json:"auto_deploy"`
CallbackURL string `json:"callback_url"`
}
```
## API Endpoint
```
POST /project/{name}/build
{
"template": "astro-landing",
"prompt": "Create a coming soon page with dark theme and threesix.ai branding",
"auto_deploy": true,
"callback_url": "https://pantheon.orchard9.ai/webhooks/build-complete"
}
```
## Orchestration Flow
1. Bot calls `POST /project/{name}/build`
2. BuildService validates project exists
3. Creates WorkTask with build spec
4. Enqueues to work queue
5. Returns task ID immediately
6. Worker picks up task:
- Clones repo
- Runs Claude with prompt
- Commits and pushes (if auto_deploy)
7. Woodpecker builds and deploys
8. Callback notified with result
## Callback Payload
```json
{
"task_id": "uuid",
"project_id": "myapp",
"status": "completed",
"result": {
"output": "...",
"artifacts": {
"commit_sha": "abc123",
"deploy_url": "https://myapp.threesix.ai"
}
}
}
```
## Related Topics
- [Work Queue](../services/work-queue.md)
- [Worker Pool](../services/worker-pool.md)
- [Template Provider](../services/template-provider.md)

View File

@ -0,0 +1,62 @@
# Command Execution
**Last Updated:** 2025-01
**Confidence:** High
## Summary
rdev executes commands (Claude, shell, git) in Kubernetes pods via kubectl exec. Commands can run synchronously or be queued for async processing with SSE output streaming.
**Key Facts:**
- Commands run in pods with label `rdev.orchard9.ai/project=true`
- Three types: `claude`, `shell`, `git`
- Async queue with status tracking (pending → running → completed/failed)
- Output streamed via SSE to `/projects/{id}/events/{stream_id}`
- All commands logged to audit trail
**File Pointers:**
- Executor: `internal/adapter/kubernetes/executor.go`
- Queue: `internal/adapter/postgres/queue.go`
- Worker: `internal/worker/queue.go`
- Handler: `internal/handlers/projects.go`
## Command Flow
```
Handler → Service → Queue (postgres)
Worker polls
Executor (kubectl exec)
StreamPublisher (SSE)
Webhook dispatch
```
## Request/Response
```
POST /projects/{id}/claude
Body: { "prompt": "write a hello world" }
Response:
{
"command_id": "cmd-abc123",
"stream_url": "/projects/my-project/events/stream-xyz"
}
```
## Status Transitions
| Status | Description |
|--------|-------------|
| `pending` | In queue, not started |
| `running` | Currently executing |
| `completed` | Finished successfully (exit 0) |
| `failed` | Error or non-zero exit |
## Related Topics
- [SSE Streaming](./sse-streaming.md)
- [Project Service](../services/project-service.md)

View File

@ -0,0 +1,52 @@
# Infrastructure Management
**Last Updated:** 2025-01
**Confidence:** High
## Summary
rdev can manage project infrastructure: creating Git repos, configuring DNS, and deploying applications to Kubernetes. This enables full project lifecycle management via API.
**Key Facts:**
- Git repos via Gitea integration
- DNS via Cloudflare integration
- Deployments create K8s Deployment + Service + Ingress
- Managed projects combine all three steps
- Woodpecker CI integration for auto-deploy on build
**File Pointers:**
- Handler: `internal/handlers/infrastructure.go`
- Service: `internal/service/project_infra.go`
- Gitea adapter: `internal/adapter/gitea/client.go`
- Cloudflare adapter: `internal/adapter/cloudflare/client.go`
- Deployer adapter: `internal/adapter/deployer/deployer.go`
## Capabilities
| Feature | Endpoint | Description |
|---------|----------|-------------|
| Create repo | `POST /projects/{id}/repo` | Creates Gitea repository |
| Deploy | `POST /projects/{id}/deploy` | Deploys to Kubernetes |
| Configure DNS | `POST /projects/{id}/domain` | Creates Cloudflare record |
| Managed project | `POST /projects/create-managed` | Full lifecycle |
## Managed Project Flow
1. Create Gitea repository
2. Initialize with template
3. Create Cloudflare DNS record
4. Deploy to Kubernetes (Deployment + Service + Ingress)
5. Return project details
## Environment Variables
```
GITEA_URL, GITEA_TOKEN, GITEA_DEFAULT_ORG
CLOUDFLARE_API_TOKEN, CLOUDFLARE_ZONE_ID, DEFAULT_DOMAIN
DEPLOY_NAMESPACE, DEPLOY_TLS_ISSUER, REGISTRY_URL
```
## Related Topics
- [Project Service](../services/project-service.md)
- [Webhooks](../services/webhooks.md)

View File

@ -0,0 +1,53 @@
# SSE Streaming
**Last Updated:** 2025-01
**Confidence:** High
## Summary
Server-Sent Events (SSE) stream command output in real-time. Clients connect to a stream URL and receive output as it's produced.
**Key Facts:**
- Endpoint: `GET /projects/{id}/events/{stream_id}`
- Content-Type: `text/event-stream`
- Events: `output` (stdout/stderr), `done` (completion)
- In-memory stream publisher for current implementation
- Stream URLs returned from command execution endpoints
**File Pointers:**
- Handler: `internal/handlers/projects.go` (events endpoint)
- Publisher: `internal/adapter/memory/stream.go`
- Port: `internal/port/stream.go`
## Client Usage
```bash
curl -N -H "X-API-Key: $KEY" \
"http://localhost:8080/projects/my-project/events/stream-123"
```
## Event Format
```
event: output
data: {"type": "stdout", "content": "Hello world\n"}
event: output
data: {"type": "stderr", "content": "Warning: ...\n"}
event: done
data: {"exit_code": 0, "duration_ms": 1234}
```
## Flow
1. Client calls `POST /projects/{id}/claude`
2. Response includes `stream_url`
3. Client connects to `stream_url` via SSE
4. Server streams output events as produced
5. `done` event signals completion
## Related Topics
- [Command Execution](./command-execution.md)
- [Project Service](../services/project-service.md)

32
ai-lookup/index.md Normal file
View File

@ -0,0 +1,32 @@
# AI Lookup Index
Quick reference for rdev concepts and facts.
| Topic | File | Confidence | Updated | Summary |
|-------|------|------------|---------|---------|
| **Architecture** |
| Hexagonal Architecture | [patterns/hexagonal.md](./patterns/hexagonal.md) | High | 2025-01 | Ports/adapters pattern, layer separation |
| **Core Services** |
| Project Service | [services/project-service.md](./services/project-service.md) | High | 2025-01 | Business logic for project operations |
| API Keys | [services/api-keys.md](./services/api-keys.md) | High | 2025-01 | Authentication, scopes, restrictions |
| Webhooks | [services/webhooks.md](./services/webhooks.md) | High | 2025-01 | Event subscriptions and delivery |
| **Worker Infrastructure** (Planned) |
| Work Queue | [services/work-queue.md](./services/work-queue.md) | High | 2025-01 | Task queue for worker pool |
| Worker Pool | [services/worker-pool.md](./services/worker-pool.md) | High | 2025-01 | Shared claudebox workers |
| CI Provider | [services/ci-provider.md](./services/ci-provider.md) | High | 2025-01 | Woodpecker auto-activation |
| Template Provider | [services/template-provider.md](./services/template-provider.md) | High | 2025-01 | Project template seeding |
| **Features** |
| Command Execution | [features/command-execution.md](./features/command-execution.md) | High | 2025-01 | Claude/shell/git command flow |
| SSE Streaming | [features/sse-streaming.md](./features/sse-streaming.md) | High | 2025-01 | Real-time output streaming |
| Infrastructure Management | [features/infrastructure.md](./features/infrastructure.md) | High | 2025-01 | Gitea, Cloudflare, deployment |
| Build Orchestration | [features/build-orchestration.md](./features/build-orchestration.md) | High | 2025-01 | Bot-driven build specs |
## Roadmap Reference
See `k3s-fleet/tmp/address-the-gaps.md` for the full threesix.ai platform roadmap:
- Gap 1: Woodpecker Auto-Activation → CI Provider
- Gap 2: Project Templates → Template Provider
- Gap 3: Work Queue → Work Queue service
- Gap 4: Worker Pool Management → Worker Pool
- Gap 5: Bot Communication → Webhook callbacks
- Gap 6: Build Orchestration → Build service

View File

@ -0,0 +1,44 @@
# Hexagonal Architecture
**Last Updated:** 2025-01
**Confidence:** High
## Summary
rdev uses hexagonal architecture (ports and adapters) to separate business logic from infrastructure. Domain models have zero external dependencies, services use port interfaces, and adapters implement those interfaces.
**Key Facts:**
- Domain models in `internal/domain/` have NO imports from other packages
- Port interfaces defined in `internal/port/`
- Adapters in `internal/adapter/{name}/` implement port interfaces
- Services in `internal/service/` orchestrate business logic
**File Pointers:**
- Port definitions: `internal/port/project.go`, `internal/port/webhook.go`
- Domain models: `internal/domain/project.go`, `internal/domain/command.go`
- Service layer: `internal/service/project.go`
## Layer Responsibilities
| Layer | Location | Purpose |
|-------|----------|---------|
| Handlers | `internal/handlers/` | HTTP request/response |
| Services | `internal/service/` | Business logic |
| Ports | `internal/port/` | Interface contracts |
| Domain | `internal/domain/` | Pure business models |
| Adapters | `internal/adapter/` | Infrastructure implementation |
## Dependency Direction
```
Handlers → Services → Ports ← Adapters
Domain
```
Dependencies point inward toward domain.
## Related Topics
- [Project Service](../services/project-service.md)
- [Command Execution](../features/command-execution.md)

View File

@ -0,0 +1,43 @@
# API Keys
**Last Updated:** 2025-01
**Confidence:** High
## Summary
API keys authenticate all requests to rdev (except health/docs). Keys have scopes, can be restricted to specific projects and IP ranges, and have expiration dates.
**Key Facts:**
- Header: `X-API-Key: <key>`
- Keys are hashed before storage (only prefix visible)
- Admin key via `RDEV_ADMIN_KEY` env var for bootstrap
- Scopes: `projects:read`, `projects:write`, `keys:read`, `keys:write`, `audit:read`
- Project restrictions: nil = all projects, or list of allowed project IDs
- IP restrictions: CIDR notation for allowed ranges
**File Pointers:**
- Service: `internal/auth/service.go`
- Middleware: `internal/auth/middleware.go`
- Handler: `internal/handlers/keys.go`
- Repository: `internal/adapter/postgres/apikey.go`
## Key Lifecycle
1. Create via `POST /keys` (admin only)
2. Key returned once (plaintext), stored hashed
3. Validate on each request via middleware
4. Revoke via `DELETE /keys/{id}`
## Scopes
| Scope | Allows |
|-------|--------|
| `projects:read` | List/get projects |
| `projects:write` | Execute commands |
| `keys:read` | List API keys |
| `keys:write` | Create/delete keys |
| `audit:read` | Query audit logs |
## Related Topics
- [Project Service](./project-service.md)

View File

@ -0,0 +1,49 @@
# CI Provider (Woodpecker)
**Last Updated:** 2025-01
**Confidence:** High (Planned - see address-the-gaps.md)
## Summary
CIProvider port enables automatic Woodpecker CI activation when projects are created. Eliminates manual UI clicks to activate repos.
**Key Facts:**
- Activates repos via Woodpecker API on project creation
- Can trigger manual builds
- JWT token authentication
- Graceful fallback if activation fails (logs warning, adds manual step)
**File Pointers:**
- Port: `internal/port/ci_provider.go`
- Adapter: `internal/adapter/woodpecker/client.go`
- Integration: `internal/service/project_infra.go`
## Port Interface
```go
type CIProvider interface {
ActivateRepo(ctx context.Context, forgeRemoteID int64) error
DeactivateRepo(ctx context.Context, owner, name string) error
TriggerBuild(ctx context.Context, owner, name, branch string) (int64, error)
}
```
## Configuration
```
WOODPECKER_URL=https://ci.threesix.ai
WOODPECKER_API_TOKEN=<jwt token from .secrets>
```
## Integration Flow
1. `ProjectInfraService.CreateProject()` creates Gitea repo
2. Gets repo ID from Gitea
3. Calls `ci.ActivateRepo(ctx, repoID)`
4. Woodpecker creates webhook in Gitea
5. Future pushes trigger builds automatically
## Related Topics
- [Infrastructure Management](../features/infrastructure.md)
- [Project Service](./project-service.md)

View File

@ -0,0 +1,44 @@
# Project Service
**Last Updated:** 2025-01
**Confidence:** High
## Summary
The ProjectService orchestrates all project-related operations: listing, getting, and executing commands. It coordinates between handlers, the Kubernetes adapter, audit logging, and webhook dispatch.
**Key Facts:**
- Lists projects from Kubernetes pods with label `rdev.orchard9.ai/project=true`
- Executes Claude, shell, and git commands asynchronously
- Logs all command executions to audit trail
- Dispatches webhook events on command completion
- Returns stream URLs for SSE-based output consumption
**File Pointers:**
- Service: `internal/service/project.go`
- Handler: `internal/handlers/projects.go`
- Port interfaces: `internal/port/project.go`
## Operations
| Method | Description |
|--------|-------------|
| `List(ctx)` | Returns all projects with current status |
| `Get(ctx, id)` | Returns single project by ID |
| `ExecuteClaude(ctx, project, prompt)` | Queues Claude command |
| `ExecuteShell(ctx, project, cmd)` | Queues shell command |
| `ExecuteGit(ctx, project, args)` | Queues git command |
## Execution Flow
1. Handler receives request
2. Service validates and creates Command
3. Command queued (postgres) or executed directly
4. Output streamed to StreamPublisher
5. Audit log entry created
6. Webhook events dispatched
## Related Topics
- [Command Execution](../features/command-execution.md)
- [SSE Streaming](../features/sse-streaming.md)

View File

@ -0,0 +1,76 @@
# Template Provider
**Last Updated:** 2025-01
**Confidence:** High (Planned - see address-the-gaps.md)
## Summary
TemplateProvider seeds new repos with project templates. Includes .woodpecker.yml, .claude/ configuration, and stack-specific files.
**Key Facts:**
- Templates stored in `deployments/k8s/base/templates/`
- Seeded via Gitea API file creation
- Variable interpolation: `{{PROJECT_NAME}}`, `{{DOMAIN}}`
- Available templates: `default`, `astro-landing`, `go-api`
**File Pointers:**
- Port: `internal/port/template_provider.go`
- Adapter: `internal/adapter/gitea/templates.go`
- Templates: `deployments/k8s/base/templates/`
## Port Interface
```go
type TemplateProvider interface {
SeedRepo(ctx context.Context, owner, repo, templateName string, vars map[string]string) error
ListTemplates(ctx context.Context) ([]TemplateInfo, error)
}
type TemplateInfo struct {
Name string
Description string
Stack string // "astro", "go", "nextjs", etc.
}
```
## Template Structure
```
templates/
├── default/
│ ├── .woodpecker.yml
│ ├── .claude/
│ │ └── CLAUDE.md
│ └── README.md
├── astro-landing/
│ ├── .woodpecker.yml
│ ├── .claude/
│ │ └── CLAUDE.md
│ ├── package.json
│ ├── astro.config.mjs
│ ├── src/pages/index.astro
│ ├── Dockerfile
│ └── nginx.conf
└── go-api/
├── .woodpecker.yml
├── .claude/
│ └── CLAUDE.md
├── go.mod
├── main.go
└── Dockerfile
```
## API Usage
```json
POST /project
{
"name": "myapp",
"template": "astro-landing"
}
```
## Related Topics
- [Infrastructure Management](../features/infrastructure.md)
- [Project Service](./project-service.md)

View File

@ -0,0 +1,51 @@
# Webhooks
**Last Updated:** 2025-01
**Confidence:** High
## Summary
Webhooks notify external systems when events occur in rdev. Subscriptions are created via API, and events are delivered with automatic retries.
**Key Facts:**
- Event types: `command.started`, `command.completed`, `command.failed`
- Retry policy: 3 attempts, 5s initial backoff, exponential
- Delivery history stored for debugging
- Worker pool (10 workers) for parallel delivery
**File Pointers:**
- Handler: `internal/handlers/webhooks.go`
- Dispatcher: `internal/webhook/dispatcher.go`
- Repository: `internal/adapter/postgres/webhook.go`
- Domain: `internal/domain/webhook.go`
## Subscription
```json
POST /webhooks
{
"url": "https://example.com/webhook",
"events": ["command.completed", "command.failed"]
}
```
## Event Payload
```json
{
"event": "command.completed",
"timestamp": "2025-01-15T10:30:00Z",
"data": {
"command_id": "cmd-123",
"project": "my-project",
"type": "claude",
"exit_code": 0,
"duration_ms": 5432
}
}
```
## Related Topics
- [Command Execution](../features/command-execution.md)
- [Project Service](./project-service.md)

View File

@ -0,0 +1,57 @@
# Work Queue
**Last Updated:** 2025-01
**Confidence:** High (Planned - see address-the-gaps.md)
## Summary
Work queue enables worker pool architecture. Workers poll for tasks, execute them, and report results. Supports async build orchestration for bot-driven development.
**Key Facts:**
- Tasks enqueued via `POST /work/enqueue`
- Workers dequeue via `GET /work/dequeue?worker_id=X`
- Status transitions: `pending``running``completed` | `failed`
- Callback URLs for bot notification on completion
- PostgreSQL-backed with atomic dequeue
**File Pointers:**
- Port: `internal/port/work_queue.go`
- Adapter: `internal/adapter/postgres/work_queue.go`
- Handler: `internal/handlers/work.go`
- Migration: `internal/db/migrations/011_work_queue.sql`
## Port Interface
```go
type WorkQueue interface {
Enqueue(ctx context.Context, task WorkTask) (string, error)
Dequeue(ctx context.Context, workerID string) (*WorkTask, error)
Complete(ctx context.Context, taskID string, result WorkResult) error
Fail(ctx context.Context, taskID string, err error) error
GetStatus(ctx context.Context, taskID string) (*WorkTaskStatus, error)
}
```
## Task Types
| Type | Description |
|------|-------------|
| `build` | Run Claude Code with prompt, commit result |
| `test` | Run test suite |
| `deploy` | Trigger Kubernetes deployment |
## API Endpoints
```
POST /work/enqueue - Add task to queue
GET /work/dequeue - Worker gets next task
POST /work/{id}/complete - Worker reports success
POST /work/{id}/fail - Worker reports failure
GET /work/{id}/status - Check task status
GET /work/stats - Queue statistics
```
## Related Topics
- [Worker Pool](./worker-pool.md)
- [Build Orchestration](../features/build-orchestration.md)

View File

@ -0,0 +1,61 @@
# Worker Pool
**Last Updated:** 2025-01
**Confidence:** High (Planned - see address-the-gaps.md)
## Summary
Shared pool of claudebox workers (3-5 pods) that can build any project. Workers register, send heartbeats, and poll for tasks. Scales horizontally by adding workers, not projects.
**Key Facts:**
- Workers labeled `rdev.orchard9.ai/role=worker`
- StatefulSet: `claudebox-worker` with 3+ replicas
- Each worker has dedicated PVC for workspace
- Workers poll rdev-api for tasks every 5 seconds
- Health tracked via heartbeat endpoint
**File Pointers:**
- Port: `internal/port/worker_registry.go`
- Adapter: `internal/adapter/postgres/worker_registry.go`
- Handler: `internal/handlers/workers.go`
- K8s manifest: `deployments/k8s/base/claudebox-worker.yaml`
## Port Interface
```go
type WorkerRegistry interface {
Register(ctx context.Context, worker WorkerInfo) error
Heartbeat(ctx context.Context, workerID string) error
Deregister(ctx context.Context, workerID string) error
ListActive(ctx context.Context) ([]WorkerInfo, error)
}
type WorkerInfo struct {
ID string
PodName string
Namespace string
Status string // "idle", "busy", "unhealthy"
LastSeen time.Time
CurrentTask string
}
```
## Worker Lifecycle
1. Pod starts → calls `POST /workers` to register
2. Main loop: heartbeat every 5s, poll for tasks
3. Task received → clone repo, run Claude, commit, report
4. Pod shutdown → `DELETE /workers/{id}` to deregister
## Environment Variables
```
WORKER_ID=$(hostname)
RDEV_API_URL=http://rdev-api.rdev.svc:8080
RDEV_API_KEY=<worker service key>
```
## Related Topics
- [Work Queue](./work-queue.md)
- [Build Orchestration](../features/build-orchestration.md)

View File

@ -46,11 +46,15 @@ import (
"github.com/orchard9/rdev/internal/adapter/kubernetes"
"github.com/orchard9/rdev/internal/adapter/memory"
"github.com/orchard9/rdev/internal/adapter/postgres"
"github.com/orchard9/rdev/internal/adapter/templates"
"github.com/orchard9/rdev/internal/adapter/woodpecker"
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/db"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/handlers"
"github.com/orchard9/rdev/internal/metrics"
"github.com/orchard9/rdev/internal/middleware"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/telemetry"
"github.com/orchard9/rdev/internal/webhook"
@ -75,6 +79,14 @@ func main() {
// Load configuration from environment
cfg := loadConfig()
// Validate required security configuration
if cfg.CredentialEncryptionKey == "" {
logger.Warn("CREDENTIAL_ENCRYPTION_KEY not set - credential store will use insecure default",
"hint", "Generate with: openssl rand -base64 32")
// Use a deterministic fallback for development only
cfg.CredentialEncryptionKey = "rdev-dev-key-not-for-production"
}
// Initialize database with auto-migrations
database, err := db.New(db.Config{
Host: cfg.DBHost,
@ -93,6 +105,12 @@ func main() {
// Initialize auth service
authService := auth.NewService(database.DB, cfg.AdminKey)
// Initialize credential store (for infrastructure secrets)
credentialStore := postgres.NewCredentialStore(database.DB, cfg.CredentialEncryptionKey)
// Load infrastructure config from credential store (falls back to env vars)
infraCfg := loadInfraConfig(context.Background(), credentialStore, cfg, logger)
// Create adapters (dependency injection)
namespace := getEnv("K8S_NAMESPACE", "rdev")
@ -129,6 +147,9 @@ func main() {
// Initialize command queue
commandQueue := postgres.NewCommandQueueRepository(database.DB)
// Initialize work queue (for worker pool tasks)
workQueueRepo := postgres.NewWorkQueueRepository(database.DB)
// Initialize webhook repository and dispatcher
webhookRepo := postgres.NewWebhookRepository(database.DB)
webhookDispatcher := webhook.NewDispatcher(webhookRepo, &webhook.DispatcherConfig{
@ -144,33 +165,57 @@ func main() {
}
// Initialize infrastructure adapters (optional - only if configured)
// Uses infraCfg which loads from credential store with env var fallback
var giteaClient *gitea.Client
if cfg.GiteaToken != "" && cfg.GiteaURL != "" {
if infraCfg.GiteaToken != "" && infraCfg.GiteaURL != "" {
var err error
giteaClient, err = gitea.NewClient(cfg.GiteaURL, cfg.GiteaToken, cfg.GiteaDefaultOrg)
giteaClient, err = gitea.NewClient(infraCfg.GiteaURL, infraCfg.GiteaToken, infraCfg.GiteaDefaultOrg)
if err != nil {
logger.Warn("failed to initialize gitea client", "error", err)
} else {
logger.Info("gitea client initialized", "url", cfg.GiteaURL, "org", cfg.GiteaDefaultOrg)
logger.Info("gitea client initialized", "url", infraCfg.GiteaURL, "org", infraCfg.GiteaDefaultOrg)
}
}
var dnsClient *cloudflare.Client
if cfg.CloudflareToken != "" && cfg.CloudflareZoneID != "" {
dnsClient = cloudflare.NewClient(cfg.CloudflareToken, cfg.CloudflareZoneID, cfg.DefaultDomain)
logger.Info("cloudflare DNS client initialized", "domain", cfg.DefaultDomain)
if infraCfg.CloudflareToken != "" && infraCfg.CloudflareZoneID != "" {
dnsClient = cloudflare.NewClient(infraCfg.CloudflareToken, infraCfg.CloudflareZoneID, infraCfg.DefaultDomain)
logger.Info("cloudflare DNS client initialized", "domain", infraCfg.DefaultDomain)
}
var deployerAdapter *deployer.Deployer
if k8sClient != nil {
deployerAdapter = deployer.NewDeployer(k8sClient, deployer.Config{
Namespace: cfg.DeployNamespace,
Namespace: infraCfg.DeployNamespace,
IngressClass: "traefik",
TLSIssuer: cfg.DeployTLSIssuer,
DefaultDomain: cfg.DefaultDomain,
TLSIssuer: infraCfg.DeployTLSIssuer,
DefaultDomain: infraCfg.DefaultDomain,
DefaultReplicas: 1,
})
logger.Info("deployer initialized", "namespace", cfg.DeployNamespace)
logger.Info("deployer initialized", "namespace", infraCfg.DeployNamespace)
}
var woodpeckerClient *woodpecker.Client
if infraCfg.WoodpeckerURL != "" && infraCfg.WoodpeckerAPIToken != "" {
var err error
woodpeckerClient, err = woodpecker.NewClient(
infraCfg.WoodpeckerURL,
infraCfg.WoodpeckerAPIToken,
woodpecker.WithLogger(logger),
)
if err != nil {
logger.Warn("failed to initialize woodpecker client", "error", err)
} else {
logger.Info("woodpecker CI client initialized", "url", infraCfg.WoodpeckerURL)
}
}
// Initialize template provider (requires Gitea client for seeding repos)
var templateProvider *templates.Provider
if giteaClient != nil {
// Get the underlying Gitea SDK client for the template provider
templateProvider = templates.NewProvider(giteaClient.SDKClient(), logger)
logger.Info("template provider initialized")
}
// Create services
@ -179,6 +224,11 @@ func main() {
WithCommandQueue(commandQueue).
WithWebhookDispatcher(webhookDispatcher)
// Create work service (for worker pool task management)
workService := service.NewWorkService(workQueueRepo, service.WorkServiceConfig{
Logger: logger,
}).WithWebhookDispatcher(webhookDispatcher)
// Create app
app := api.New("rdev-api",
api.WithPort(cfg.Port),
@ -209,6 +259,7 @@ func main() {
auditHandler := handlers.NewAuditHandler(auditLogger)
queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo)
webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo)
workHandler := handlers.NewWorkHandler(workService)
// Initialize infrastructure handler (for threesix.ai git/deploy/dns)
infraHandler := handlers.NewInfrastructureHandler(
@ -217,8 +268,8 @@ func main() {
deployerAdapter,
projectRepo,
handlers.InfrastructureConfig{
DefaultGitOwner: cfg.GiteaDefaultOrg,
DefaultDomain: cfg.DefaultDomain,
DefaultGitOwner: infraCfg.GiteaDefaultOrg,
DefaultDomain: infraCfg.DefaultDomain,
},
)
@ -228,10 +279,12 @@ func main() {
giteaClient,
dnsClient,
deployerAdapter,
woodpeckerClient, // CI provider for auto-activating repos
templateProvider, // Template provider for seeding repos
service.ProjectInfraConfig{
DefaultGitOwner: cfg.GiteaDefaultOrg,
DefaultDomain: cfg.DefaultDomain,
ClusterIP: cfg.ClusterIP,
DefaultGitOwner: infraCfg.GiteaDefaultOrg,
DefaultDomain: infraCfg.DefaultDomain,
ClusterIP: infraCfg.ClusterIP,
Logger: logger,
},
)
@ -244,14 +297,17 @@ func main() {
deployerAdapter,
dnsClient,
handlers.WoodpeckerWebhookConfig{
WebhookSecret: cfg.WoodpeckerWebhookSecret,
DefaultDomain: cfg.DefaultDomain,
RegistryURL: cfg.RegistryURL,
ClusterIP: cfg.ClusterIP,
WebhookSecret: infraCfg.WoodpeckerWebhookSecret,
DefaultDomain: infraCfg.DefaultDomain,
RegistryURL: infraCfg.RegistryURL,
ClusterIP: infraCfg.ClusterIP,
Logger: logger,
},
)
// Initialize credentials handler (superadmin only)
credentialsHandler := handlers.NewCredentialsHandler(credentialStore)
// Register routes
projectsHandler.Mount(app.Router())
keysHandler.Mount(app.Router())
@ -259,9 +315,11 @@ func main() {
auditHandler.Mount(app.Router())
queueHandler.Mount(app.Router())
webhookHandler.Mount(app.Router())
workHandler.Mount(app.Router())
infraHandler.Mount(app.Router())
projectMgmtHandler.Mount(app.Router())
woodpeckerHandler.Mount(app.Router())
credentialsHandler.Mount(app.Router())
// Start queue processor worker
queueProcessor := worker.NewQueueProcessor(
@ -324,7 +382,10 @@ type Config struct {
DBSSLMode string
AdminKey string
// Infrastructure adapters (threesix.ai)
// Credential store encryption key (required for storing secrets in DB)
CredentialEncryptionKey string
// Infrastructure adapters (threesix.ai) - fallback values if not in credential store
GiteaURL string
GiteaToken string
GiteaDefaultOrg string
@ -335,6 +396,26 @@ type Config struct {
DeployTLSIssuer string
ClusterIP string
RegistryURL string
WoodpeckerURL string
WoodpeckerAPIToken string
WoodpeckerWebhookSecret string
}
// InfraConfig holds infrastructure adapter configuration.
// Loaded from credential store with env var fallback.
type InfraConfig struct {
GiteaURL string
GiteaToken string
GiteaDefaultOrg string
CloudflareToken string
CloudflareZoneID string
DefaultDomain string
DeployNamespace string
DeployTLSIssuer string
ClusterIP string
RegistryURL string
WoodpeckerURL string
WoodpeckerAPIToken string
WoodpeckerWebhookSecret string
}
@ -363,10 +444,14 @@ func loadConfig() Config {
DBSSLMode: getEnv("DB_SSL_MODE", "disable"),
AdminKey: os.Getenv("RDEV_ADMIN_KEY"),
// Infrastructure adapters
// Encryption key for credential store (generate with: openssl rand -base64 32)
// REQUIRED in production - no default to prevent insecure deployments
CredentialEncryptionKey: os.Getenv("CREDENTIAL_ENCRYPTION_KEY"),
// Infrastructure adapters (fallback if not in credential store)
GiteaURL: getEnv("GITEA_URL", "https://git.threesix.ai"),
GiteaToken: os.Getenv("GITEA_TOKEN"),
GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "threesix"),
GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "jordan"),
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
DefaultDomain: getEnv("DEFAULT_DOMAIN", "threesix.ai"),
@ -374,6 +459,8 @@ func loadConfig() Config {
DeployTLSIssuer: getEnv("DEPLOY_TLS_ISSUER", "letsencrypt-threesix"),
ClusterIP: getEnv("CLUSTER_IP", "208.122.204.172"),
RegistryURL: getEnv("REGISTRY_URL", "zot.threesix.svc.cluster.local:5000"),
WoodpeckerURL: getEnv("WOODPECKER_URL", "https://ci.threesix.ai"),
WoodpeckerAPIToken: os.Getenv("WOODPECKER_API_TOKEN"),
WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"),
}
}
@ -384,3 +471,60 @@ func getEnv(key, defaultVal string) string {
}
return defaultVal
}
// loadInfraConfig loads infrastructure configuration from credential store,
// falling back to environment variables if not found in the store.
func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config, logger *slog.Logger) InfraConfig {
// Try to load from credential store
creds, err := store.GetMultiple(ctx, []string{
domain.CredKeyGiteaToken,
domain.CredKeyGiteaURL,
domain.CredKeyCloudflareAPIToken,
domain.CredKeyCloudflareZoneID,
domain.CredKeyWoodpeckerURL,
domain.CredKeyWoodpeckerAPIToken,
domain.CredKeyWoodpeckerWebhookSecret,
domain.CredKeyRegistryURL,
})
if err != nil {
logger.Warn("failed to load credentials from store, using env vars", "error", err)
creds = make(map[string]string)
}
// Helper to get from store or fall back to env var
getOrFallback := func(key, envFallback string) string {
if v, ok := creds[key]; ok && v != "" {
return v
}
return envFallback
}
infraCfg := InfraConfig{
GiteaURL: getOrFallback(domain.CredKeyGiteaURL, cfg.GiteaURL),
GiteaToken: getOrFallback(domain.CredKeyGiteaToken, cfg.GiteaToken),
GiteaDefaultOrg: cfg.GiteaDefaultOrg, // Not a secret, use env
CloudflareToken: getOrFallback(domain.CredKeyCloudflareAPIToken, cfg.CloudflareToken),
CloudflareZoneID: getOrFallback(domain.CredKeyCloudflareZoneID, cfg.CloudflareZoneID),
DefaultDomain: cfg.DefaultDomain, // Not a secret, use env
DeployNamespace: cfg.DeployNamespace, // Not a secret, use env
DeployTLSIssuer: cfg.DeployTLSIssuer, // Not a secret, use env
ClusterIP: cfg.ClusterIP, // Not a secret, use env
RegistryURL: getOrFallback(domain.CredKeyRegistryURL, cfg.RegistryURL),
WoodpeckerURL: getOrFallback(domain.CredKeyWoodpeckerURL, cfg.WoodpeckerURL),
WoodpeckerAPIToken: getOrFallback(domain.CredKeyWoodpeckerAPIToken, cfg.WoodpeckerAPIToken),
WoodpeckerWebhookSecret: getOrFallback(domain.CredKeyWoodpeckerWebhookSecret, cfg.WoodpeckerWebhookSecret),
}
// Log which credentials were loaded from store vs env
fromStore := 0
for k := range creds {
if creds[k] != "" {
fromStore++
}
}
if fromStore > 0 {
logger.Info("loaded credentials from store", "count", fromStore)
}
return infraCfg
}

275
cookbooks/VISION.md Normal file
View File

@ -0,0 +1,275 @@
# threesix.ai Platform Vision
> Agent-driven development at scale: 50+ projects, each with autonomous build capability.
## The Vision
```
Team Member → Bot (Pantheon) → "Create a new project called X"
┌───────────────────────────────────────────────────┐
│ rdev-api orchestrates: │
│ │
│ 1. Create Gitea repo (jordan/X) │
│ 2. Activate Woodpecker CI (webhook auto-created) │
│ 3. Spin up claudebox-X (dedicated pod) │
│ 4. Initialize .claude/ structure │
│ 5. Create DNS record (X.threesix.ai) │
│ │
└───────────────────────────────────────────────────┘
┌───────────────────────────────────────────────────┐
│ claudebox-X ready for development │
│ │
│ Option A: Human directs Claude via Pantheon │
│ "Build a landing page for X" │
│ │
│ Option B: Bot autonomously builds based on spec │
│ "Here's the PRD, build it" │
│ │
└───────────────────────────────────────────────────┘
Push to Gitea → Woodpecker builds → K8s deploys
Live at https://X.threesix.ai
```
---
## Current State vs Vision
| Capability | Status | What Exists | Gap |
|------------|--------|-------------|-----|
| Create Gitea repo | ✅ | `POST /project` | None |
| Create DNS record | ✅ | `POST /project` | None |
| Activate Woodpecker | ⚠️ | API exists, not wired | Add to `POST /project` |
| Spin up claudebox | ❌ | Manual StatefulSet | Need dynamic claudebox creation |
| Initialize .claude/ | ❌ | Nothing | Need template + init |
| Human directs Claude | ✅ | `POST /projects/{id}/claude` | Works for existing claudeboxes |
| Bot autonomous build | ⚠️ | Claude endpoint exists | Need prompt orchestration |
| K8s deployment | ✅ | Webhook + deployer | Works |
---
## Gap Analysis
### Gap 1: Woodpecker Activation Not in Project Creation
**Current:** Two separate API calls
```bash
POST /project # Creates Gitea + DNS
POST ci.threesix.ai/api/repos?forge_remote_id=X # Activates Woodpecker
```
**Needed:** Single call does both
```bash
POST /project # Creates Gitea + DNS + Activates Woodpecker
```
**Fix:** Add `WOODPECKER_API_TOKEN` to rdev-api config, call Woodpecker API after Gitea creation.
---
### Gap 2: No Dynamic Claudebox Creation
**Current:** Claudeboxes are manually created StatefulSets
```yaml
# Each project needs a manually deployed claudebox-{project}.yaml
claudebox-pantheon-0 # Exists
claudebox-aeries-0 # Exists
claudebox-landing-0 # Does NOT exist
```
**Needed:** API creates claudebox on-demand
```bash
POST /project {"name": "landing"}
# Also creates claudebox-landing StatefulSet
```
**Fix:**
- Create claudebox template (parameterized StatefulSet)
- Add K8s client to rdev-api for creating StatefulSets
- Wire into `POST /project` flow
**Resource consideration:** Each claudebox uses ~256Mi-1Gi RAM. 50 projects = 12-50Gi RAM dedicated to claudeboxes. May need:
- On-demand spin-up (idle claudeboxes scale to 0)
- Shared worker pool (fewer claudeboxes, more projects)
---
### Gap 3: No .claude/ Initialization
**Current:** Empty repo after creation
**Needed:** Repo initialized with Claude structure
```
project/
├── .claude/
│ ├── CLAUDE.md # Project context
│ ├── commands/ # Slash commands
│ ├── skills/ # Reusable skills
│ └── agents/ # Agent definitions
├── .woodpecker.yml # CI pipeline
└── README.md # Basic readme
```
**Fix:**
- Create template repo or init script
- Claudebox runs init on first creation
- Or: Gitea repo template feature
---
### Gap 4: Project ↔ Claudebox Mapping
**Current:** Two separate registries
- Projects: `/project` API (Gitea + DNS based)
- Claudeboxes: `/projects` API (K8s pod label based)
**Needed:** Unified registry
```
POST /project/landing/claude # Works because landing has a claudebox
```
**Fix:**
- Merge the two systems
- Project creation = Gitea + DNS + Claudebox + Woodpecker
- Single source of truth (database)
---
### Gap 5: Prompt Orchestration for Autonomous Build
**Current:** Raw prompt execution
```bash
POST /projects/pantheon/claude
{"prompt": "build a landing page"} # Claude figures it out
```
**Needed:** Structured build orchestration
```bash
POST /project/landing/build
{
"spec": "Create Astro landing page with coming soon message",
"stack": "astro",
"auto_deploy": true
}
```
**Fix:**
- Build orchestrator service
- Stack templates (astro, nextjs, go-api, etc.)
- Progress tracking
- Auto-commit and push
---
## Proposed Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ rdev-api │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Project Service │ │ Claudebox Mgr │ │ Build Service │ │
│ │ │ │ │ │ │ │
│ │ - Create repo │ │ - Create pod │ │ - Stack temps │ │
│ │ - Setup DNS │ │ - Scale up/down │ │ - Orchestrate │ │
│ │ - Activate CI │ │ - Health check │ │ - Auto-deploy │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ └────────────────────┴────────────────────┘ │
│ │ │
│ ┌───────────┴───────────┐ │
│ │ Unified Project DB │ │
│ │ │ │
│ │ - project_id │ │
│ │ - gitea_repo │ │
│ │ - claudebox_pod │ │
│ │ - woodpecker_id │ │
│ │ - dns_record │ │
│ │ - build_status │ │
│ └────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Infrastructure │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Gitea │ │Woodpecker│ │ Zot │ │Cloudflare│ │ K8s │ │
│ │ git.ts.ai│ │ ci.ts.ai │ │ registry │ │ DNS │ │ projects │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Implementation Phases
### Phase A: Wire Woodpecker into Project Creation
- Add WOODPECKER_API_TOKEN to rdev-api
- After Gitea repo creation, activate in Woodpecker
- Test: `POST /project` activates CI automatically
### Phase B: Dynamic Claudebox Creation
- Create claudebox template (parameterized YAML)
- Add K8s StatefulSet creation to rdev-api
- Wire into project creation
- Test: `POST /project` spins up claudebox-{name}
### Phase C: .claude/ Initialization
- Create init template for new projects
- Claudebox runs init on startup
- Test: New project has .claude/ structure ready
### Phase D: Unified Project Registry
- Merge project management and claudebox systems
- Single database tracks all project state
- `/projects/{id}/claude` works for any project
### Phase E: Build Orchestration
- Stack templates (astro, nextjs, go-api)
- `POST /project/{id}/build` endpoint
- Progress tracking and auto-deploy
---
## Quick Wins (Do First)
1. **Wire Woodpecker activation** - 1-2 hours
- Already have the API call
- Just needs token in config and call in project creation
2. **Use pantheon as shared worker** - Works now
- For MVP, one claudebox can clone/build any project
- Not scalable but proves the concept
3. **Template .woodpecker.yml in project creation** - 1 hour
- When creating Gitea repo, include default .woodpecker.yml
- Projects are CI-ready from creation
---
## Questions to Resolve
1. **Claudebox scaling strategy?**
- One per project (resource heavy, 50 = lots of RAM)
- Shared worker pool (fewer pods, queue-based)
- On-demand (scale to zero when idle)
2. **Who owns the project?**
- User-scoped (jordan/landing)
- Org-scoped (threesix/landing)
- Affects Gitea structure and permissions
3. **Pantheon integration?**
- How does the bot receive "create project X" commands?
- Slash command? Natural language?
- Response format?

342
cookbooks/landing-page.md Normal file
View File

@ -0,0 +1,342 @@
# Landing Page Cookbook
> Deploy a static landing page through the threesix.ai infrastructure with agent-driven development.
## Overview
This cookbook creates and deploys a simple landing page using the full threesix.ai autonomous infrastructure:
```
rdev-api → Gitea repo → Claude agent → push → Woodpecker CI → K8s deployment
```
**Target:** `landing.threesix.ai` (with future DNS aliases for www/root)
**Stack:** Astro (static site generator)
**Status:** Coming Soon page
---
## Current Architecture Gap
**Two separate systems that need bridging:**
| System | Endpoint | What it manages |
|--------|----------|-----------------|
| Project Management | `POST /project` | Gitea repos, DNS records, K8s deployments |
| Claudebox Execution | `POST /projects/{id}/claude` | Code generation in existing claudebox pods |
**The problem:** Creating a project via `POST /project` creates a Gitea repo, but there's no claudebox to generate code for it. The claudebox system only knows about pre-existing pods (pantheon, aeries).
**The solution:** Use an existing claudebox as a "worker" to clone, build, and push to any project repo.
---
## Prerequisites
### Credentials Required
| Secret | Location | Purpose |
|--------|----------|---------|
| RDEV_ADMIN_KEY | `rdev-credentials` secret | rdev-api authentication |
| GITEA_TOKEN | `rdev-credentials` secret | Gitea API access |
| WOODPECKER_API_TOKEN | `.secrets` file | Woodpecker repo activation |
| CLOUDFLARE_API_TOKEN | `rdev-credentials` secret | DNS management |
### Infrastructure Required
- [x] rdev-api running with infrastructure handlers (v0.7.1+)
- [x] Gitea at https://git.threesix.ai
- [x] Woodpecker CI at https://ci.threesix.ai
- [x] Zot registry at zot.threesix.svc.cluster.local:5000
- [x] `projects` namespace in K8s with RBAC
- [x] Wildcard TLS cert for *.threesix.ai
---
## Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Landing Page Flow │
│ │
│ 1. Create Project │
│ POST /project {"name": "www"} │
│ │ │
│ ├──▶ Creates Gitea repo: jordan/www │
│ └──▶ Creates DNS: www.threesix.ai → 208.122.204.172 │
│ │
│ 2. Activate Woodpecker │
│ POST /api/repos?forge_remote_id={id} │
│ │ │
│ └──▶ Creates webhook in Gitea │
│ │
│ 3. Generate Code (Claude Agent) │
│ claudebox or local Claude Code │
│ │ │
│ ├──▶ Creates Astro project │
│ ├──▶ Creates Dockerfile │
│ ├──▶ Creates .woodpecker.yml │
│ └──▶ Pushes to Gitea │
│ │
│ 4. CI/CD Pipeline (automatic) │
│ Woodpecker triggered by push │
│ │ │
│ ├──▶ Kaniko builds Docker image │
│ ├──▶ Pushes to Zot registry │
│ └──▶ Webhook triggers rdev-api deploy │
│ │
│ 5. Live at https://www.threesix.ai │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Step-by-Step Implementation
### Step 1: Create Project via rdev-api
```bash
RDEV_KEY="rdev_sk_prod_7f3a9c2e1d8b4a6f0e5c9d2b7a1f8e4c"
curl -X POST https://rdev.masq-ops.orchard9.ai/project \
-H "Authorization: Bearer $RDEV_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "landing", "description": "threesix.ai landing page"}'
```
**Response:**
```json
{
"data": {
"name": "landing",
"domain": "landing.threesix.ai",
"git": {
"clone_ssh": "git@git.threesix.ai:jordan/landing.git",
"clone_http": "https://git.threesix.ai/jordan/landing.git"
}
}
}
```
### Step 2: Activate Woodpecker CI
```bash
GITEA_TOKEN="5508ff241943e84aad0ced3559f5fbd311a2fb81"
WOODPECKER_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsInVzZXItaWQiOiIxIn0.LcyVHcZ_gSvVH1w3y6TUCp_Jg9ubfsebOAVo-MtiNP8"
# Get Gitea repo ID
REPO_ID=$(curl -s https://git.threesix.ai/api/v1/repos/jordan/landing \
-H "Authorization: token $GITEA_TOKEN" | jq '.id')
# Activate in Woodpecker (creates webhook automatically)
curl -X POST "https://ci.threesix.ai/api/repos?forge_remote_id=$REPO_ID" \
-H "Authorization: Bearer $WOODPECKER_TOKEN"
```
### Step 3: Generate Code via Claudebox
Use the `pantheon` claudebox as a worker to generate code for the landing project:
```bash
RDEV_KEY="rdev_sk_prod_7f3a9c2e1d8b4a6f0e5c9d2b7a1f8e4c"
# Tell Claude to build the landing page in /tmp/landing
curl -X POST "https://rdev.masq-ops.orchard9.ai/projects/pantheon/claude" \
-H "Authorization: Bearer $RDEV_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Clone https://git.threesix.ai/jordan/landing.git to /tmp/landing, then create a simple Astro landing page with: Coming Soon message, threesix.ai branding (dark theme), responsive layout, Dockerfile (nginx), and .woodpecker.yml for CI/CD. Commit and push when done."
}'
```
**What happens:**
1. Claude receives the prompt in the claudebox
2. Claude clones the repo to `/tmp/landing`
3. Claude generates the Astro project files
4. Claude commits and pushes to Gitea
### Step 4: Monitor Build
Watch Woodpecker for the build:
- https://ci.threesix.ai/jordan/landing
Or via API:
```bash
curl -s "https://ci.threesix.ai/api/repos/jordan/landing/pipelines" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" | jq '.[0] | {number, status, started}'
```
### Step 5: Verify Deployment
```bash
curl -s "https://rdev.masq-ops.orchard9.ai/project/landing" \
-H "Authorization: Bearer $RDEV_KEY" | jq '.data.deployment'
```
Site live at: https://landing.threesix.ai
### Step 6: Configure DNS Aliases (Optional)
Point `www.threesix.ai` and `threesix.ai` to the landing page:
```bash
CF_TOKEN="nGoDhG6Za66XsKMl6W7LNXuowc5EM00glHxkq1KK"
CF_ZONE="e0bc8d510f62807b360db0c5994964c5"
# Update root A record to point to k3s cluster
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/$CF_ZONE/dns_records/{record_id}" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"type": "A",
"name": "threesix.ai",
"content": "208.122.204.172",
"proxied": false
}'
```
---
## File Templates
### .woodpecker.yml
```yaml
steps:
build:
image: gcr.io/kaniko-project/executor:latest
settings:
registry: zot.threesix.svc.cluster.local:5000
tags:
- ${CI_COMMIT_SHA:0:8}
- latest
repo: zot.threesix.svc.cluster.local:5000/${CI_REPO_NAME}
context: .
dockerfile: Dockerfile
insecure: true
when:
branch: main
notify:
image: alpine/curl:latest
commands:
- echo "Build complete, webhook will trigger deployment"
when:
branch: main
status: success
```
### Dockerfile (Astro + Nginx)
```dockerfile
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
### nginx.conf
```nginx
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
```
---
## Current Gaps & Future Automation
### What's Manual Today
| Step | Status | Automation Path |
|------|--------|-----------------|
| Create project | ✅ API | Already automated |
| Activate Woodpecker | 🔧 API call needed | Add to rdev-api |
| Generate code | ❌ Manual Claude | Claudebox integration |
| Push to Gitea | ❌ Manual git | Claudebox with SSH key |
| Deploy | ✅ Webhook | Already automated |
### To Fully Automate (Future Work)
1. **Add Woodpecker activation to rdev-api**
- Store WOODPECKER_API_TOKEN in secrets
- Call Woodpecker API after creating Gitea repo
- Create webhook automatically
2. **Claudebox code generation**
- Spawn claudebox with project context
- Claudebox has Gitea SSH key
- Claude Code generates code based on prompt
- Auto-push to Gitea
3. **Single API call**
```
POST /project/create-and-build
{
"name": "www",
"prompt": "Create an Astro landing page with coming soon message",
"stack": "astro"
}
```
---
## Verification
After deployment, verify:
```bash
# Check DNS
dig www.threesix.ai
# Check site
curl -I https://www.threesix.ai
# Check deployment status
curl https://rdev.masq-ops.orchard9.ai/project/www \
-H "Authorization: Bearer $RDEV_KEY"
```
---
## Rollback
To remove the landing page:
```bash
# Delete via rdev-api (removes Gitea repo, DNS, K8s deployment)
curl -X DELETE https://rdev.masq-ops.orchard9.ai/project/www \
-H "Authorization: Bearer $RDEV_KEY"
```
---
## Related
- [THREESIX_INFRASTRUCTURE.md](/Users/jordanwashburn/Workspace/orchard9/rdev/docs/plans/THREESIX_INFRASTRUCTURE.md) - Infrastructure plan
- [woodpecker-pipeline-template.yaml](../deployments/k8s/base/threesix/woodpecker-pipeline-template.yaml) - CI template

View File

@ -0,0 +1,8 @@
# Namespace for deployed projects (managed by rdev-api)
# Separate from kustomization to avoid namespace transformation
apiVersion: v1
kind: Namespace
metadata:
name: projects
labels:
app.kubernetes.io/managed-by: rdev-api

View File

@ -24,7 +24,7 @@ spec:
serviceAccountName: rdev-api
containers:
- name: rdev-api
image: ghcr.io/orchard9/rdev-api:v0.6.0
image: ghcr.io/orchard9/rdev-api:v0.7.2
imagePullPolicy: Always
ports:
@ -88,6 +88,32 @@ spec:
secretKeyRef:
name: rdev-credentials
key: RDEV_ADMIN_KEY
- name: CREDENTIAL_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: rdev-credentials
key: CREDENTIAL_ENCRYPTION_KEY
# Infrastructure adapters for threesix.ai (fallback if not in DB)
- name: GITEA_TOKEN
valueFrom:
secretKeyRef:
name: rdev-credentials
key: GITEA_TOKEN
- name: CLOUDFLARE_API_TOKEN
valueFrom:
secretKeyRef:
name: rdev-credentials
key: CLOUDFLARE_API_TOKEN
- name: CLOUDFLARE_ZONE_ID
valueFrom:
secretKeyRef:
name: rdev-credentials
key: CLOUDFLARE_ZONE_ID
- name: WOODPECKER_WEBHOOK_SECRET
valueFrom:
secretKeyRef:
name: rdev-credentials
key: WOODPECKER_WEBHOOK_SECRET
imagePullSecrets:
- name: ghcr-secret
@ -151,3 +177,44 @@ roleRef:
kind: Role
name: rdev-api
apiGroup: rbac.authorization.k8s.io
---
# ClusterRole for rdev-api to deploy projects across namespaces
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: rdev-api-deployer
rules:
# Deployment management
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Service management
- apiGroups: [""]
resources: ["services"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Ingress management
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Pod logs for deployment status
- apiGroups: [""]
resources: ["pods", "pods/log"]
verbs: ["get", "list", "watch"]
# Secrets for TLS certificates (read-only to reference existing)
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list"]
---
# ClusterRoleBinding for rdev-api deployer
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: rdev-api-deployer
subjects:
- kind: ServiceAccount
name: rdev-api
namespace: rdev
roleRef:
kind: ClusterRole
name: rdev-api-deployer
apiGroup: rbac.authorization.k8s.io

View File

@ -0,0 +1,43 @@
steps:
install:
image: node:20-alpine
commands:
- npm ci
when:
- event: [push, pull_request]
build:
image: node:20-alpine
commands:
- npm run build
when:
- event: [push, pull_request]
docker:
image: docker:24-dind
privileged: true
commands:
- docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:latest .
- docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} .
when:
- event: push
push:
image: docker:24-dind
privileged: true
commands:
- echo "$ZOT_PASSWORD" | docker login zot.orchard9.ai -u "$ZOT_USER" --password-stdin
- docker push zot.orchard9.ai/{{PROJECT_NAME}}:latest
- docker push zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8}
secrets: [zot_user, zot_password]
when:
- event: push
branch: main
deploy:
image: bitnami/kubectl:latest
commands:
- kubectl set image deployment/{{PROJECT_NAME}} {{PROJECT_NAME}}=zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects
when:
- event: push
branch: main

View File

@ -0,0 +1,20 @@
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,32 @@
# {{PROJECT_NAME}}
Astro landing page deployed at: https://{{DOMAIN}}
## Getting Started
```bash
npm install
npm run dev
```
## Commands
| Command | Action |
|---------|--------|
| `npm run dev` | Start dev server at localhost:4321 |
| `npm run build` | Build for production |
| `npm run preview` | Preview production build |
## Structure
```
src/
pages/ # File-based routing
components/ # Astro/React components
layouts/ # Page layouts
public/ # Static assets
```
## CI/CD
Pushes to `main` trigger automatic deployment via Woodpecker CI.

View File

@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
export default defineConfig({
integrations: [tailwind()],
output: 'static',
});

View File

@ -0,0 +1,27 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Health check
location /health {
return 200 'ok';
add_header Content-Type text/plain;
}
}

View File

@ -0,0 +1,18 @@
{
"name": "{{PROJECT_NAME}}",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"astro": "^4.0.0"
},
"devDependencies": {
"@astrojs/tailwind": "^5.0.0",
"tailwindcss": "^3.4.0"
}
}

View File

@ -0,0 +1,21 @@
---
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{{PROJECT_NAME}} - Built with Astro" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
</head>
<body>
<slot />
</body>
</html>

View File

@ -0,0 +1,33 @@
---
import Layout from '../layouts/Layout.astro';
---
<Layout title="{{PROJECT_NAME}}">
<main class="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800">
<div class="container mx-auto px-4 py-16">
<div class="text-center">
<h1 class="text-5xl font-bold text-white mb-6">
{{PROJECT_NAME}}
</h1>
<p class="text-xl text-slate-300 mb-8 max-w-2xl mx-auto">
Welcome to your new Astro landing page. Edit this file at
<code class="bg-slate-700 px-2 py-1 rounded">src/pages/index.astro</code>
</p>
<div class="flex gap-4 justify-center">
<a
href="https://docs.astro.build"
class="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
Read the Docs
</a>
<a
href="{{GIT_URL}}"
class="px-6 py-3 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition"
>
View Source
</a>
</div>
</div>
</div>
</main>
</Layout>

View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {},
},
plugins: [],
};

View File

@ -0,0 +1,29 @@
steps:
build:
image: docker:24-dind
privileged: true
commands:
- docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:latest .
- docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} .
when:
- event: push
push:
image: docker:24-dind
privileged: true
commands:
- echo "$ZOT_PASSWORD" | docker login zot.orchard9.ai -u "$ZOT_USER" --password-stdin
- docker push zot.orchard9.ai/{{PROJECT_NAME}}:latest
- docker push zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8}
secrets: [zot_user, zot_password]
when:
- event: push
branch: main
deploy:
image: bitnami/kubectl:latest
commands:
- kubectl set image deployment/{{PROJECT_NAME}} {{PROJECT_NAME}}=zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects
when:
- event: push
branch: main

View File

@ -0,0 +1,9 @@
# Default Dockerfile - replace with your application
FROM nginx:alpine
# Copy static files or your app
COPY . /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,21 @@
# {{PROJECT_NAME}}
Deployed at: https://{{DOMAIN}}
## Getting Started
1. Clone the repository
2. Build with Docker: `docker build -t {{PROJECT_NAME}} .`
3. Run locally: `docker run -p 8080:8080 {{PROJECT_NAME}}`
## CI/CD
This project uses Woodpecker CI for continuous deployment. Pushing to `main` will:
- Build a Docker image
- Push to the container registry
- Deploy to Kubernetes
## Resources
- Live site: https://{{DOMAIN}}
- Git repository: {{GIT_URL}}

View File

@ -0,0 +1,43 @@
steps:
test:
image: golang:1.22-alpine
commands:
- go test ./...
when:
- event: [push, pull_request]
build:
image: golang:1.22-alpine
commands:
- go build -o app ./cmd/api
when:
- event: [push, pull_request]
docker:
image: docker:24-dind
privileged: true
commands:
- docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:latest .
- docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} .
when:
- event: push
push:
image: docker:24-dind
privileged: true
commands:
- echo "$ZOT_PASSWORD" | docker login zot.orchard9.ai -u "$ZOT_USER" --password-stdin
- docker push zot.orchard9.ai/{{PROJECT_NAME}}:latest
- docker push zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8}
secrets: [zot_user, zot_password]
when:
- event: push
branch: main
deploy:
image: bitnami/kubectl:latest
commands:
- kubectl set image deployment/{{PROJECT_NAME}} {{PROJECT_NAME}}=zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects
when:
- event: push
branch: main

View File

@ -0,0 +1,23 @@
# Build stage
FROM golang:1.22-alpine AS build
WORKDIR /app
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/api
# Production stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=build /app/server .
EXPOSE 8080
CMD ["./server"]

View File

@ -0,0 +1,33 @@
# {{PROJECT_NAME}}
Go REST API deployed at: https://{{DOMAIN}}
## Getting Started
```bash
go run ./cmd/api
```
## Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | /health | Health check |
| GET | /api/v1/example | Example endpoint |
## Development
```bash
# Run
go run ./cmd/api
# Test
go test ./...
# Build
go build -o app ./cmd/api
```
## CI/CD
Pushes to `main` trigger automatic deployment via Woodpecker CI.

View File

@ -0,0 +1,47 @@
package main
import (
"encoding/json"
"log/slog"
"net/http"
"os"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
// API routes
r.Route("/api/v1", func(r chi.Router) {
r.Get("/example", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"message": "Hello from {{PROJECT_NAME}}",
})
})
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
slog.Info("starting server", "port", port)
if err := http.ListenAndServe(":"+port, r); err != nil {
slog.Error("server failed", "error", err)
os.Exit(1)
}
}

View File

@ -0,0 +1,5 @@
module github.com/orchard9/{{PROJECT_NAME}}
go 1.22
require github.com/go-chi/chi/v5 v5.0.12

View File

@ -0,0 +1,584 @@
# Multi-Provider Code Agent Interface
> **Status:** In Progress (Weeks 1-4 Complete)
> **Feature:** Unified interface supporting Claude Code and OpenCode providers
## Overview
This document describes the architecture for supporting multiple code agent providers (Claude Code, OpenCode) through a unified interface. The design enables provider switching at runtime without breaking existing functionality.
## Implementation Progress
| Phase | Status | Description |
|-------|--------|-------------|
| Week 1: Foundation | ✅ Complete | Domain models, port interface, registry |
| Week 2: Claude Code Adapter | ✅ Complete | kubectl exec wrapper, stream-json parser |
| Week 3: OpenCode Adapter | ✅ Complete | HTTP/SSE client, session management |
| Week 4: Service Integration | ✅ Complete | ProjectService integration, event streaming |
| Week 5: Polish | ⬜ Pending | Model selection API, health monitoring, metrics, docs |
## Architecture
### Current Flow
```
Handler → ProjectService → CodeAgent (port) → ClaudeCodeAdapter | OpenCodeAdapter
↓ ↓
kubectl exec HTTP API
claude -p opencode serve
```
### Fallback Support
When `CodeAgentRegistry` is not configured, the service falls back to the legacy `CommandExecutor` for backward compatibility.
## Domain Models (✅ Implemented)
### File: `internal/domain/code_agent.go`
```go
// AgentProvider identifies which code agent implementation to use
type AgentProvider string
const (
AgentProviderClaudeCode AgentProvider = "claudecode"
AgentProviderOpenCode AgentProvider = "opencode"
)
// Validation and parsing
func (p AgentProvider) IsValid() bool
func (p AgentProvider) String() string
func ParseAgentProvider(s string) (AgentProvider, error)
func ValidAgentProviders() []AgentProvider
// AgentRequest contains parameters for executing a code agent command
type AgentRequest struct {
Prompt string
ProjectID ProjectID
SessionID string // For continuation
AllowedTools []string // Tool restrictions
Model string // Model override (OpenCode only)
WorkingDir string // Defaults to /workspace
Timeout time.Duration // Execution timeout
Metadata map[string]string // Provider-specific options
}
// AgentEventType categorizes events emitted during agent execution
type AgentEventType string
const (
AgentEventOutput AgentEventType = "output"
AgentEventToolUse AgentEventType = "tool_use"
AgentEventToolResult AgentEventType = "tool_result"
AgentEventThinking AgentEventType = "thinking"
AgentEventError AgentEventType = "error"
AgentEventComplete AgentEventType = "complete"
)
// AgentEvent represents a single event during agent execution
type AgentEvent struct {
Type AgentEventType
Timestamp time.Time
Content string
Stream string // "stdout", "stderr", or empty
ToolName string // For tool_use/tool_result events
ToolInput map[string]any // Tool invocation arguments
Metadata map[string]any
}
// AgentEventHandler is a callback for receiving agent events
type AgentEventHandler func(event AgentEvent)
// AgentResult contains the outcome of agent execution
type AgentResult struct {
SessionID string // For continuation
ExitCode int
DurationMs int64
Error error
TokensUsed *AgentTokenUsage // If available
FinalOutput string
}
func (r *AgentResult) Success() bool // ExitCode == 0 && Error == nil
// AgentTokenUsage tracks token consumption
type AgentTokenUsage struct {
InputTokens int64
OutputTokens int64
TotalTokens int64
}
// AgentCapabilities describes what a provider supports
type AgentCapabilities struct {
Provider AgentProvider
SupportsSessionContinuation bool
SupportsModelSelection bool
SupportsToolControl bool
SupportedModels []string
DefaultModel string
MaxPromptLength int
SupportsStreaming bool
}
```
### Project Extension
```go
// In domain/project.go
type Project struct {
// ... existing fields ...
AgentProvider AgentProvider // Which code agent to use
}
// New label for K8s discovery
const LabelAgentProvider = "rdev.orchard9.ai/agent-provider"
```
### Error Handling
```go
// In domain/errors.go
var ErrInvalidAgentProvider = errors.New("invalid agent provider")
```
## Port Interface (✅ Implemented)
### File: `internal/port/code_agent.go`
```go
// CodeAgent defines operations for executing AI coding agent commands
type CodeAgent interface {
// Name returns a human-readable name for this agent
Name() string
// Provider returns the agent provider identifier
Provider() domain.AgentProvider
// Execute runs an agent command and streams events to the handler
Execute(ctx context.Context, req *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error)
// Cancel attempts to cancel a running agent session
Cancel(ctx context.Context, sessionID string) error
// Capabilities returns what this agent supports
Capabilities() domain.AgentCapabilities
// Available returns true if the agent is ready to accept requests
Available(ctx context.Context) bool
}
// CodeAgentRegistry manages registered code agent implementations
type CodeAgentRegistry interface {
// Register adds an agent for a provider (overwrites existing)
Register(agent CodeAgent)
// Get returns the agent for a specific provider (nil if not found)
Get(provider domain.AgentProvider) CodeAgent
// Default returns the default agent (nil if empty)
Default() CodeAgent
// SetDefault sets the default provider (error if not registered)
SetDefault(provider domain.AgentProvider) error
// Available returns all registered providers
Available() []domain.AgentProvider
// AvailableAgents returns agents that are currently available
AvailableAgents(ctx context.Context) []CodeAgent
}
```
## Provider Registry (✅ Implemented)
### File: `internal/adapter/codeagent/registry.go`
```go
// Registry implements port.CodeAgentRegistry with thread-safe agent management
type Registry struct {
mu sync.RWMutex
agents map[domain.AgentProvider]port.CodeAgent
defProv domain.AgentProvider
hasAgent bool
}
func NewRegistry() *Registry
// Additional methods beyond interface
func (r *Registry) DefaultProvider() domain.AgentProvider
func (r *Registry) Count() int
```
**Thread Safety:**
- `sync.RWMutex` for concurrent access
- Read locks for: `Get`, `Default`, `Available`, `AvailableAgents`, `DefaultProvider`, `Count`
- Write locks for: `Register`, `SetDefault`
- First registered agent becomes default automatically
**Test Coverage:**
- Register/Get operations
- Default selection (first registered)
- SetDefault (success and failure)
- Available providers listing
- AvailableAgents filtering
- Concurrent access (race-tested)
- Re-registration overwrites
## Claude Code Adapter (✅ Implemented)
### Package: `internal/adapter/codeagent/claudecode/`
**Files:**
- `adapter.go` - CodeAgent implementation wrapping kubectl exec
- `parser.go` - Stream-JSON NDJSON parser for Claude Code output
- `adapter_test.go` - Comprehensive test coverage
- `parser_test.go` - Parser unit tests
**Key Features:**
- Wraps `kubectl exec` for pod access
- Uses `--output-format stream-json` for structured NDJSON output
- Supports `--resume <session_id>` for conversation continuation
- Maps `AllowedTools` to `--allowedTools` flag
- Uses `--dangerously-skip-permissions` for non-interactive mode
**Command Construction:**
```go
func (a *Adapter) buildCommandArgs(namespace, podName string, req *domain.AgentRequest) []string {
args := []string{
"exec", "-n", namespace, podName, "--",
"claude", "-p", "--output-format", "stream-json", "--dangerously-skip-permissions",
}
if req.SessionID != "" {
args = append(args, "--resume", req.SessionID)
}
for _, tool := range req.AllowedTools {
args = append(args, "--allowedTools", tool)
}
if req.WorkingDir != "" && req.WorkingDir != "/workspace" {
args = append(args, "--add-dir", req.WorkingDir)
}
args = append(args, req.Prompt)
return args
}
```
**Stream JSON Message Types:**
| Type | Description | Mapped Event |
|------|-------------|--------------|
| `init` | Session started | `AgentEventOutput` |
| `message` | Text output from assistant | `AgentEventOutput` |
| `tool_use` | Tool invocation | `AgentEventToolUse` |
| `tool_result` | Tool response | `AgentEventToolResult` |
| `result` | Execution complete | `AgentEventComplete` |
**Capabilities:**
```go
func (a *Adapter) Capabilities() domain.AgentCapabilities {
return domain.AgentCapabilities{
Provider: domain.AgentProviderClaudeCode,
SupportsSessionContinuation: true,
SupportsModelSelection: false, // Claude Code only uses Claude
SupportsToolControl: true,
SupportedModels: []string{"claude-sonnet-4-20250514", "claude-opus-4-20250514"},
DefaultModel: "claude-sonnet-4-20250514",
MaxPromptLength: 0, // Unlimited
SupportsStreaming: true,
}
}
```
## OpenCode Adapter (✅ Implemented)
### Package: `internal/adapter/codeagent/opencode/`
**Files:**
- `adapter.go` - CodeAgent implementation using HTTP/SSE
- `client.go` - HTTP client with SSE subscription support
- `adapter_test.go` - Mock server tests for all operations
**HTTP Client API:**
```go
type Client struct {
baseURL string
httpClient *http.Client
username string
password string
}
// Health check
func (c *Client) Health(ctx context.Context) (*HealthResponse, error)
// Session management
func (c *Client) CreateSession(ctx context.Context, req *CreateSessionRequest) (*Session, error)
func (c *Client) GetSession(ctx context.Context, sessionID string) (*Session, error)
func (c *Client) AbortSession(ctx context.Context, sessionID string) error
// Message sending
func (c *Client) SendMessage(ctx context.Context, sessionID string, req *SendMessageRequest) (*SendMessageResponse, error)
func (c *Client) SendPromptAsync(ctx context.Context, sessionID string, req *SendMessageRequest) error
// SSE streaming
func (c *Client) SubscribeEvents(ctx context.Context) (<-chan SSEEvent, error)
```
**SSE Event Mapping:**
| SSE Event | Description | Mapped Event |
|-----------|-------------|--------------|
| `server.connected` | Connected to server | `AgentEventOutput` |
| `message.created` | New message | `AgentEventOutput` |
| `message.updated` | Message updated | `AgentEventOutput` |
| `tool.started` | Tool execution started | `AgentEventToolUse` |
| `tool.completed` | Tool execution done | `AgentEventToolResult` |
| `session.completed` | Session finished | `AgentEventComplete` |
| `error` | Error occurred | `AgentEventError` |
**Capabilities:**
```go
func (a *Adapter) Capabilities() domain.AgentCapabilities {
return domain.AgentCapabilities{
Provider: domain.AgentProviderOpenCode,
SupportsSessionContinuation: true,
SupportsModelSelection: true, // OpenCode supports multiple providers
SupportsToolControl: true,
SupportedModels: []string{
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"gpt-4o",
"gpt-4-turbo",
"gemini-pro",
},
DefaultModel: "claude-sonnet-4-20250514",
MaxPromptLength: 0, // Unlimited
SupportsStreaming: true,
}
}
```
**Authentication:**
- Basic auth support via `username` and `password` config
- Default username: `opencode`
## Service Integration (✅ Implemented)
### Files Modified/Created
| File | Description |
|------|-------------|
| `project_service.go` | Core service with agent registry support |
| `project_service_agent.go` | Agent execution and resolution methods |
| `project_service_commands.go` | Shell/Git command execution (extracted) |
| `project_service_queue.go` | Queue operations (extracted) |
### ProjectService Changes
**New Fields:**
```go
type ProjectService struct {
// ... existing fields ...
agentRegistry port.CodeAgentRegistry // Optional code agent registry
}
func (s *ProjectService) WithCodeAgentRegistry(registry port.CodeAgentRegistry) *ProjectService
```
**Updated Request/Response:**
```go
type ExecuteClaudeRequest struct {
ProjectID domain.ProjectID
Prompt string
StreamID string
SessionID string // Optional: resume a previous session
Model string // Optional: model override (OpenCode only)
AllowedTools []string // Optional: restrict tool access
Audit *AuditContext
}
type ExecuteClaudeResult struct {
CommandID domain.CommandID
StreamURL string
SessionID string // Session ID for continuation
AgentProvider domain.AgentProvider // Which provider handled the request
}
```
**Agent Resolution:**
```go
// resolveAgent returns the appropriate CodeAgent for a project.
// Returns nil if no agent registry is configured or no agent is available.
func (s *ProjectService) resolveAgent(project *domain.Project) port.CodeAgent {
if s.agentRegistry == nil {
return nil
}
// Try project-specific agent first
if project.AgentProvider != "" {
if agent := s.agentRegistry.Get(project.AgentProvider); agent != nil {
return agent
}
}
// Fall back to default
return s.agentRegistry.Default()
}
```
**Event Streaming:**
Agent events are converted to SSE stream events:
| Agent Event | Stream Event | Data |
|-------------|--------------|------|
| `AgentEventOutput` | `output` | `{line, stream}` |
| `AgentEventToolUse` | `tool_use` | `{tool, input}` |
| `AgentEventToolResult` | `tool_result` | `{output}` |
| `AgentEventError` | `error` | `{error}` |
| `AgentEventComplete` | `agent_complete` | metadata |
| (final) | `complete` | `{exit_code, duration_ms, session_id, provider}` |
**Additional Service Methods:**
```go
// Get capabilities for a specific provider
func (s *ProjectService) GetAgentCapabilities(provider domain.AgentProvider) *domain.AgentCapabilities
// List all available providers
func (s *ProjectService) ListAvailableAgents() []domain.AgentProvider
// Get/set default agent
func (s *ProjectService) GetDefaultAgent() domain.AgentProvider
func (s *ProjectService) SetDefaultAgent(provider domain.AgentProvider) error
```
## API Changes (⬜ Pending - Week 5)
### Project Response
```json
{
"id": "proj-123",
"name": "my-project",
"agent_provider": "claudecode",
"agent_capabilities": {
"supports_session_continuation": true,
"supports_model_selection": false
}
}
```
### Update Provider
```http
PATCH /projects/{id}
Content-Type: application/json
{
"agent_provider": "opencode"
}
```
### Execute with Model (OpenCode only)
```http
POST /projects/{id}/claude
Content-Type: application/json
{
"prompt": "Fix the bug in main.go",
"model": "gpt-4o",
"session_id": "prev-session-123"
}
```
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CODE_AGENT_DEFAULT` | `claudecode` | Default provider for new projects |
| `OPENCODE_ENABLED` | `false` | Enable OpenCode adapter |
| `OPENCODE_URL` | `http://127.0.0.1:4096` | OpenCode server URL |
| `OPENCODE_USERNAME` | `opencode` | OpenCode basic auth username |
| `OPENCODE_PASSWORD` | (none) | OpenCode basic auth password |
### Project-Level Override
Projects can specify their preferred provider in the database. On provider switch:
1. Active session is cleared (no cross-provider continuation)
2. New provider is validated as available
3. Next command uses new provider
## File Structure
```
internal/
├── domain/
│ ├── code_agent.go ✅ AgentProvider, AgentRequest, AgentEvent, etc.
│ ├── code_agent_test.go ✅ Domain model tests
│ ├── project.go ✅ Added AgentProvider field
│ └── errors.go ✅ Added ErrInvalidAgentProvider
├── port/
│ └── code_agent.go ✅ CodeAgent, CodeAgentRegistry interfaces
├── adapter/
│ └── codeagent/
│ ├── registry.go ✅ Provider registry implementation
│ ├── registry_test.go ✅ Registry tests (incl. concurrent access)
│ ├── claudecode/ ✅ Week 2
│ │ ├── adapter.go ✅ CodeAgent implementation
│ │ ├── parser.go ✅ Stream-JSON NDJSON parser
│ │ ├── adapter_test.go ✅ Adapter tests
│ │ └── parser_test.go ✅ Parser tests
│ └── opencode/ ✅ Week 3
│ ├── adapter.go ✅ CodeAgent implementation
│ ├── client.go ✅ HTTP/SSE client
│ └── adapter_test.go ✅ Mock server tests
├── service/
│ ├── project_service.go ✅ Week 4: Agent registry integration
│ ├── project_service_agent.go ✅ Week 4: Agent execution methods
│ ├── project_service_commands.go ✅ Extracted shell/git commands
│ └── project_service_queue.go ✅ Extracted queue operations
└── worker/
└── queue_processor.go ⬜ Week 5: Use CodeAgent for queue
```
## Observability (⬜ Pending - Week 5)
### Metrics
| Metric | Labels | Description |
|--------|--------|-------------|
| `code_agent_requests_total` | provider, project, status | Total requests |
| `code_agent_duration_seconds` | provider, project | Execution duration |
| `code_agent_events_total` | provider, event_type | Streaming events |
### Health Check
```http
GET /health
{
"status": "healthy",
"agents": {
"claudecode": "available",
"opencode": "unavailable"
}
}
```
## Risks and Mitigations
| Risk | Impact | Mitigation |
|------|--------|------------|
| OpenCode API changes | Adapter breaks | Pin to specific version, add API versioning |
| Latency difference (subprocess vs HTTP) | User experience varies | Monitor p99 latency, document tradeoffs |
| Session state incompatibility | Can't resume across providers | Clear session on provider switch |
| Container image size increase | Slower deployments | OpenCode sidecar optional, not in base image |
## Design Decisions
1. **Event callback pattern** - Matches existing `OutputHandler`, enables streaming
2. **Registry pattern** - Allows runtime switching, extensible for more providers
3. **OpenCode as sidecar** - Keeps Claude Code as proven default, OpenCode opt-in
4. **Session per provider** - No cross-provider session sharing to avoid state corruption
5. **First-registered default** - Registry automatically uses first agent as default
6. **Backward compatibility** - Falls back to legacy executor when no registry configured
7. **File splitting** - Service files split to comply with 500-line limit

5
go.mod
View File

@ -9,10 +9,12 @@ require (
github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0
go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/trace v1.39.0
go.woodpecker-ci.org/woodpecker/v2 v2.8.3
k8s.io/api v0.35.0
k8s.io/apimachinery v0.35.0
k8s.io/client-go v0.35.0
@ -31,7 +33,7 @@ require (
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/jsonreference v0.20.4 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
@ -42,6 +44,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect

18
go.sum
View File

@ -12,7 +12,6 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -31,12 +30,10 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU=
github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
@ -62,11 +59,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@ -102,14 +96,9 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@ -134,6 +123,8 @@ go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjce
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.woodpecker-ci.org/woodpecker/v2 v2.8.3 h1:g54xYwrL4RhCTTyKtjYPDB9ePnUsqRx6qkqlnAcFdJg=
go.woodpecker-ci.org/woodpecker/v2 v2.8.3/go.mod h1:nvdmUnQJMqm8UzJOlJ50MYYq/uv8oyOqhBBr7SdoNPw=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
@ -187,7 +178,6 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=

View File

@ -0,0 +1,346 @@
// Package claudecode provides a CodeAgent implementation for Anthropic's Claude Code CLI.
package claudecode
import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// Adapter implements port.CodeAgent using Anthropic's Claude Code CLI.
type Adapter struct {
namespace string
mu sync.RWMutex
// Track active sessions for cancellation
activeSessions map[string]context.CancelFunc
sessionsMu sync.Mutex
}
// NewAdapter creates a new Claude Code adapter.
func NewAdapter(namespace string) *Adapter {
return &Adapter{
namespace: namespace,
activeSessions: make(map[string]context.CancelFunc),
}
}
// Ensure Adapter implements port.CodeAgent at compile time.
var _ port.CodeAgent = (*Adapter)(nil)
// Name returns a human-readable name for this agent.
func (a *Adapter) Name() string {
return "Claude Code"
}
// Provider returns the agent provider identifier.
func (a *Adapter) Provider() domain.AgentProvider {
return domain.AgentProviderClaudeCode
}
// Execute runs a Claude Code command and streams events to the handler.
func (a *Adapter) Execute(ctx context.Context, req *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error) {
if req.Prompt == "" {
return nil, fmt.Errorf("prompt is required")
}
a.mu.RLock()
namespace := a.namespace
a.mu.RUnlock()
// Create cancellable context
execCtx, cancel := context.WithCancel(ctx)
defer cancel()
// Track session for potential cancellation
sessionID := generateSessionID()
a.sessionsMu.Lock()
a.activeSessions[sessionID] = cancel
a.sessionsMu.Unlock()
defer func() {
a.sessionsMu.Lock()
delete(a.activeSessions, sessionID)
a.sessionsMu.Unlock()
}()
// Get pod name from project (passed via metadata or lookup)
var podName string
if req.Metadata != nil {
podName = req.Metadata["pod_name"]
}
if podName == "" {
return &domain.AgentResult{
SessionID: sessionID,
ExitCode: 1,
Error: fmt.Errorf("pod_name is required in request metadata"),
}, nil
}
// Build kubectl exec command for Claude Code
args := a.buildCommandArgs(namespace, podName, req)
// Apply timeout if specified
if req.Timeout > 0 {
var timeoutCancel context.CancelFunc
execCtx, timeoutCancel = context.WithTimeout(execCtx, req.Timeout)
defer timeoutCancel()
}
startTime := time.Now()
kubectl := exec.CommandContext(execCtx, "kubectl", args...)
// Get stdout pipe for stream-json output
stdout, err := kubectl.StdoutPipe()
if err != nil {
return &domain.AgentResult{
SessionID: sessionID,
ExitCode: 1,
Error: fmt.Errorf("stdout pipe: %w", err),
}, nil
}
// Get stderr for error messages
stderr, err := kubectl.StderrPipe()
if err != nil {
return &domain.AgentResult{
SessionID: sessionID,
ExitCode: 1,
Error: fmt.Errorf("stderr pipe: %w", err),
}, nil
}
// Start the command
if err := kubectl.Start(); err != nil {
return &domain.AgentResult{
SessionID: sessionID,
ExitCode: 1,
Error: fmt.Errorf("start: %w", err),
}, nil
}
// Stream and parse output
var wg sync.WaitGroup
var finalOutput strings.Builder
var parseErr error
var resultMsg *StreamMessage
wg.Add(2)
// Parse stream-json from stdout
go func() {
defer wg.Done()
resultMsg, parseErr = a.parseStreamOutput(stdout, handler, &finalOutput)
}()
// Stream stderr as error events
go func() {
defer wg.Done()
a.streamStderr(stderr, handler)
}()
wg.Wait()
// Wait for command completion
cmdErr := kubectl.Wait()
duration := time.Since(startTime)
result := &domain.AgentResult{
SessionID: sessionID,
DurationMs: duration.Milliseconds(),
FinalOutput: finalOutput.String(),
}
// Determine exit code and error
if cmdErr != nil {
if exitErr, ok := cmdErr.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
} else {
result.ExitCode = 1
result.Error = cmdErr
}
} else if parseErr != nil {
result.ExitCode = 1
result.Error = parseErr
} else if resultMsg != nil && !resultMsg.IsSuccess() {
result.ExitCode = 1
if resultMsg.Error != "" {
result.Error = fmt.Errorf("%s", resultMsg.Error)
}
}
// Update session ID from result if available
if resultMsg != nil && resultMsg.SessionID != "" {
result.SessionID = resultMsg.SessionID
}
return result, nil
}
// buildCommandArgs constructs the kubectl exec arguments for Claude Code.
func (a *Adapter) buildCommandArgs(namespace, podName string, req *domain.AgentRequest) []string {
args := []string{
"exec", "-n", namespace, podName, "--",
"claude",
"-p", // Print mode (non-interactive)
"--output-format", "stream-json",
"--dangerously-skip-permissions",
}
// Add session continuation if resuming
if req.SessionID != "" {
args = append(args, "--resume", req.SessionID)
}
// Add allowed tools if specified
if len(req.AllowedTools) > 0 {
for _, tool := range req.AllowedTools {
args = append(args, "--allowedTools", tool)
}
}
// Add working directory if specified
if req.WorkingDir != "" && req.WorkingDir != "/workspace" {
args = append(args, "--add-dir", req.WorkingDir)
}
// Add the prompt as the final argument
args = append(args, req.Prompt)
return args
}
// parseStreamOutput reads and parses NDJSON stream-json output.
func (a *Adapter) parseStreamOutput(r io.Reader, handler domain.AgentEventHandler, output *strings.Builder) (*StreamMessage, error) {
scanner := bufio.NewScanner(r)
// Increase buffer for long lines
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
var resultMsg *StreamMessage
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
msg, err := ParseStreamMessage(line)
if err != nil {
// Non-JSON line, treat as plain output
event := domain.AgentEvent{
Type: domain.AgentEventOutput,
Timestamp: time.Now(),
Content: string(line),
Stream: "stdout",
}
handler(event)
output.WriteString(string(line))
output.WriteString("\n")
continue
}
// Convert to agent event and dispatch
event := msg.ToAgentEvent()
handler(event)
// Collect output text
if msg.Type == StreamMessageMessage && msg.Role == "assistant" {
text := extractTextContent(msg.Content)
if text != "" {
output.WriteString(text)
}
}
// Track result message
if msg.IsTerminal() {
resultMsg = msg
}
}
if err := scanner.Err(); err != nil {
return resultMsg, fmt.Errorf("scanner error: %w", err)
}
return resultMsg, nil
}
// streamStderr reads stderr and emits error events.
func (a *Adapter) streamStderr(r io.Reader, handler domain.AgentEventHandler) {
scanner := bufio.NewScanner(r)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
handler(domain.AgentEvent{
Type: domain.AgentEventError,
Timestamp: time.Now(),
Content: line,
Stream: "stderr",
})
}
}
// Cancel attempts to cancel a running session.
func (a *Adapter) Cancel(ctx context.Context, sessionID string) error {
a.sessionsMu.Lock()
defer a.sessionsMu.Unlock()
cancel, exists := a.activeSessions[sessionID]
if !exists {
return nil // Session not found is not an error
}
cancel()
return nil
}
// Capabilities returns what this agent supports.
func (a *Adapter) Capabilities() domain.AgentCapabilities {
return domain.AgentCapabilities{
Provider: domain.AgentProviderClaudeCode,
SupportsSessionContinuation: true,
SupportsModelSelection: false, // Claude Code only uses Claude
SupportsToolControl: true,
SupportedModels: []string{"claude-sonnet-4-20250514", "claude-opus-4-20250514"},
DefaultModel: "claude-sonnet-4-20250514",
MaxPromptLength: 0, // Unlimited
SupportsStreaming: true,
}
}
// DefaultAvailabilityTimeout is the maximum time to wait when checking agent availability.
// This timeout prevents blocking the caller when kubectl or the cluster is slow or unresponsive.
const DefaultAvailabilityTimeout = 5 * time.Second
// Available checks if kubectl is available and can connect to the cluster.
func (a *Adapter) Available(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, DefaultAvailabilityTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "kubectl", "cluster-info", "--request-timeout=5s")
return cmd.Run() == nil
}
// sessionCounter is used to ensure unique session IDs.
var sessionCounter atomic.Uint64
// generateSessionID creates a unique session identifier.
func generateSessionID() string {
count := sessionCounter.Add(1)
return fmt.Sprintf("claude-%d-%d", time.Now().UnixNano(), count)
}

View File

@ -0,0 +1,289 @@
package claudecode
import (
"context"
"strings"
"testing"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// Ensure Adapter implements port.CodeAgent at compile time.
var _ port.CodeAgent = (*Adapter)(nil)
func TestAdapter_Name(t *testing.T) {
adapter := NewAdapter("test-ns")
if name := adapter.Name(); name != "Claude Code" {
t.Errorf("expected name 'Claude Code', got %q", name)
}
}
func TestAdapter_Provider(t *testing.T) {
adapter := NewAdapter("test-ns")
if p := adapter.Provider(); p != domain.AgentProviderClaudeCode {
t.Errorf("expected provider 'claudecode', got %q", p)
}
}
func TestAdapter_Capabilities(t *testing.T) {
adapter := NewAdapter("test-ns")
caps := adapter.Capabilities()
if caps.Provider != domain.AgentProviderClaudeCode {
t.Errorf("expected provider claudecode")
}
if !caps.SupportsSessionContinuation {
t.Error("expected session continuation support")
}
if caps.SupportsModelSelection {
t.Error("expected no model selection support")
}
if !caps.SupportsToolControl {
t.Error("expected tool control support")
}
if !caps.SupportsStreaming {
t.Error("expected streaming support")
}
if len(caps.SupportedModels) == 0 {
t.Error("expected at least one supported model")
}
}
func TestAdapter_buildCommandArgs_Basic(t *testing.T) {
adapter := NewAdapter("rdev")
req := &domain.AgentRequest{
Prompt: "Hello, Claude",
}
args := adapter.buildCommandArgs("rdev", "pod-123", req)
// Verify essential args are present
argsStr := strings.Join(args, " ")
if !strings.Contains(argsStr, "exec -n rdev pod-123") {
t.Error("expected kubectl exec command")
}
if !strings.Contains(argsStr, "claude") {
t.Error("expected claude command")
}
if !strings.Contains(argsStr, "-p") {
t.Error("expected -p flag")
}
if !strings.Contains(argsStr, "--output-format stream-json") {
t.Error("expected stream-json output format")
}
if !strings.Contains(argsStr, "--dangerously-skip-permissions") {
t.Error("expected permission skip flag")
}
if !strings.Contains(argsStr, "Hello, Claude") {
t.Error("expected prompt in args")
}
}
func TestAdapter_buildCommandArgs_WithSessionResume(t *testing.T) {
adapter := NewAdapter("rdev")
req := &domain.AgentRequest{
Prompt: "Continue working",
SessionID: "session-abc",
}
args := adapter.buildCommandArgs("rdev", "pod-123", req)
argsStr := strings.Join(args, " ")
if !strings.Contains(argsStr, "--resume session-abc") {
t.Error("expected --resume flag with session ID")
}
}
func TestAdapter_buildCommandArgs_WithAllowedTools(t *testing.T) {
adapter := NewAdapter("rdev")
req := &domain.AgentRequest{
Prompt: "Run tests",
AllowedTools: []string{"Bash", "Read", "Edit"},
}
args := adapter.buildCommandArgs("rdev", "pod-123", req)
argsStr := strings.Join(args, " ")
if !strings.Contains(argsStr, "--allowedTools Bash") {
t.Error("expected --allowedTools Bash")
}
if !strings.Contains(argsStr, "--allowedTools Read") {
t.Error("expected --allowedTools Read")
}
if !strings.Contains(argsStr, "--allowedTools Edit") {
t.Error("expected --allowedTools Edit")
}
}
func TestAdapter_buildCommandArgs_WithWorkingDir(t *testing.T) {
adapter := NewAdapter("rdev")
req := &domain.AgentRequest{
Prompt: "List files",
WorkingDir: "/workspace/subdir",
}
args := adapter.buildCommandArgs("rdev", "pod-123", req)
argsStr := strings.Join(args, " ")
if !strings.Contains(argsStr, "--add-dir /workspace/subdir") {
t.Error("expected --add-dir for non-default working directory")
}
}
func TestAdapter_buildCommandArgs_DefaultWorkingDir(t *testing.T) {
adapter := NewAdapter("rdev")
req := &domain.AgentRequest{
Prompt: "List files",
WorkingDir: "/workspace", // default
}
args := adapter.buildCommandArgs("rdev", "pod-123", req)
argsStr := strings.Join(args, " ")
// Should NOT have --add-dir for default workspace
if strings.Contains(argsStr, "--add-dir") {
t.Error("expected no --add-dir for default workspace")
}
}
func TestAdapter_Execute_MissingPrompt(t *testing.T) {
adapter := NewAdapter("rdev")
req := &domain.AgentRequest{
Prompt: "", // missing
Metadata: map[string]string{
"pod_name": "test-pod",
},
}
_, err := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {})
if err == nil {
t.Error("expected error for missing prompt")
}
if !strings.Contains(err.Error(), "prompt is required") {
t.Errorf("expected 'prompt is required' error, got: %v", err)
}
}
func TestAdapter_Execute_MissingPodName(t *testing.T) {
adapter := NewAdapter("rdev")
req := &domain.AgentRequest{
Prompt: "Hello",
Metadata: map[string]string{}, // no pod_name
}
result, _ := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {})
if result.Error == nil {
t.Error("expected error for missing pod_name")
}
if !strings.Contains(result.Error.Error(), "pod_name") {
t.Errorf("expected pod_name error, got: %v", result.Error)
}
}
func TestAdapter_Cancel(t *testing.T) {
adapter := NewAdapter("rdev")
// Cancel non-existent session should not error
err := adapter.Cancel(context.Background(), "nonexistent")
if err != nil {
t.Errorf("expected no error for non-existent session, got: %v", err)
}
}
func TestAdapter_parseStreamOutput(t *testing.T) {
adapter := NewAdapter("rdev")
input := strings.NewReader(`{"type":"init","session_id":"test-123"}
{"type":"message","role":"assistant","content":[{"type":"text","text":"Hello!"}]}
{"type":"result","status":"success","duration_ms":100}
`)
var events []domain.AgentEvent
handler := func(e domain.AgentEvent) {
events = append(events, e)
}
var output strings.Builder
resultMsg, err := adapter.parseStreamOutput(input, handler, &output)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(events) != 3 {
t.Errorf("expected 3 events, got %d", len(events))
}
// Verify event types
if events[0].Type != domain.AgentEventOutput {
t.Errorf("expected first event to be output (init)")
}
if events[1].Type != domain.AgentEventOutput {
t.Errorf("expected second event to be output (message)")
}
if events[2].Type != domain.AgentEventComplete {
t.Errorf("expected third event to be complete (result)")
}
// Verify result message
if resultMsg == nil {
t.Fatal("expected result message")
}
if !resultMsg.IsSuccess() {
t.Error("expected success result")
}
// Verify output was collected
if !strings.Contains(output.String(), "Hello!") {
t.Error("expected output to contain assistant message")
}
}
func TestAdapter_parseStreamOutput_PlainText(t *testing.T) {
adapter := NewAdapter("rdev")
// Non-JSON lines should be treated as plain output
input := strings.NewReader(`Not JSON output
Another plain line
{"type":"result","status":"success"}
`)
var events []domain.AgentEvent
handler := func(e domain.AgentEvent) {
events = append(events, e)
}
var output strings.Builder
_, err := adapter.parseStreamOutput(input, handler, &output)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(events) != 3 {
t.Errorf("expected 3 events, got %d", len(events))
}
// First two should be plain output events
if events[0].Content != "Not JSON output" {
t.Errorf("expected plain text content")
}
if events[0].Stream != "stdout" {
t.Errorf("expected stdout stream for plain text")
}
}
func TestGenerateSessionID(t *testing.T) {
id1 := generateSessionID()
id2 := generateSessionID()
if id1 == id2 {
t.Error("expected unique session IDs")
}
if !strings.HasPrefix(id1, "claude-") {
t.Errorf("expected session ID to start with 'claude-', got %q", id1)
}
}

View File

@ -0,0 +1,162 @@
// Package claudecode provides a CodeAgent implementation for Anthropic's Claude Code CLI.
package claudecode
import (
"encoding/json"
"time"
"github.com/orchard9/rdev/internal/domain"
)
// StreamMessageType identifies the type of stream-json message from Claude Code CLI.
type StreamMessageType string
const (
// StreamMessageInit is emitted when a session starts.
StreamMessageInit StreamMessageType = "init"
// StreamMessageMessage contains assistant or user text.
StreamMessageMessage StreamMessageType = "message"
// StreamMessageToolUse indicates a tool is being invoked.
StreamMessageToolUse StreamMessageType = "tool_use"
// StreamMessageToolResult contains the output of a tool invocation.
StreamMessageToolResult StreamMessageType = "tool_result"
// StreamMessageResult is the final message indicating completion.
StreamMessageResult StreamMessageType = "result"
)
// StreamMessage represents a single NDJSON message from Claude Code's stream-json output.
type StreamMessage struct {
Type StreamMessageType `json:"type"`
Timestamp string `json:"timestamp,omitempty"`
SessionID string `json:"session_id,omitempty"`
Role string `json:"role,omitempty"` // "assistant" or "user"
Content []ContentBlock `json:"content,omitempty"`
Name string `json:"name,omitempty"` // Tool name for tool_use
Input json.RawMessage `json:"input,omitempty"` // Tool input for tool_use
Output string `json:"output,omitempty"`
Status string `json:"status,omitempty"` // "success" or "error"
DurationMs int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
}
// ContentBlock represents a content item within a message.
type ContentBlock struct {
Type string `json:"type"` // "text" or "tool_use"
Text string `json:"text,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
}
// ParseStreamMessage parses a single NDJSON line into a StreamMessage.
func ParseStreamMessage(line []byte) (*StreamMessage, error) {
var msg StreamMessage
if err := json.Unmarshal(line, &msg); err != nil {
return nil, err
}
return &msg, nil
}
// ToAgentEvent converts a StreamMessage to a domain.AgentEvent.
func (m *StreamMessage) ToAgentEvent() domain.AgentEvent {
event := domain.AgentEvent{
Timestamp: parseTimestamp(m.Timestamp),
Metadata: make(map[string]any),
}
switch m.Type {
case StreamMessageInit:
event.Type = domain.AgentEventOutput
event.Content = "Session started"
if m.SessionID != "" {
event.Metadata["session_id"] = m.SessionID
}
case StreamMessageMessage:
event.Type = domain.AgentEventOutput
event.Content = extractTextContent(m.Content)
if m.Role != "" {
event.Metadata["role"] = m.Role
}
case StreamMessageToolUse:
event.Type = domain.AgentEventToolUse
event.ToolName = m.Name
if len(m.Name) == 0 {
// Check content blocks for tool_use
for _, block := range m.Content {
if block.Type == "tool_use" {
event.ToolName = block.Name
if len(block.Input) > 0 {
var input map[string]any
if err := json.Unmarshal(block.Input, &input); err == nil {
event.ToolInput = input
}
}
break
}
}
} else if len(m.Input) > 0 {
var input map[string]any
if err := json.Unmarshal(m.Input, &input); err == nil {
event.ToolInput = input
}
}
event.Content = event.ToolName
case StreamMessageToolResult:
event.Type = domain.AgentEventToolResult
event.Content = m.Output
case StreamMessageResult:
event.Type = domain.AgentEventComplete
if m.Status == "error" {
event.Type = domain.AgentEventError
event.Content = m.Error
}
if m.DurationMs > 0 {
event.Metadata["duration_ms"] = m.DurationMs
}
if m.Status != "" {
event.Metadata["status"] = m.Status
}
default:
// Unknown message type, treat as output
event.Type = domain.AgentEventOutput
event.Content = extractTextContent(m.Content)
}
return event
}
// extractTextContent extracts text from content blocks.
func extractTextContent(blocks []ContentBlock) string {
for _, block := range blocks {
if block.Type == "text" && block.Text != "" {
return block.Text
}
}
return ""
}
// parseTimestamp parses an ISO8601 timestamp string.
func parseTimestamp(s string) time.Time {
if s == "" {
return time.Now()
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Now()
}
return t
}
// IsTerminal returns true if this message indicates execution is complete.
func (m *StreamMessage) IsTerminal() bool {
return m.Type == StreamMessageResult
}
// IsSuccess returns true if this is a successful result message.
func (m *StreamMessage) IsSuccess() bool {
return m.Type == StreamMessageResult && m.Status == "success"
}

View File

@ -0,0 +1,350 @@
package claudecode
import (
"testing"
"time"
"github.com/orchard9/rdev/internal/domain"
)
func TestParseStreamMessage_Init(t *testing.T) {
line := []byte(`{"type":"init","session_id":"abc123","timestamp":"2024-01-01T00:00:00Z"}`)
msg, err := ParseStreamMessage(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msg.Type != StreamMessageInit {
t.Errorf("expected type 'init', got %q", msg.Type)
}
if msg.SessionID != "abc123" {
t.Errorf("expected session_id 'abc123', got %q", msg.SessionID)
}
}
func TestParseStreamMessage_Message(t *testing.T) {
line := []byte(`{"type":"message","role":"assistant","content":[{"type":"text","text":"Hello, world!"}]}`)
msg, err := ParseStreamMessage(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msg.Type != StreamMessageMessage {
t.Errorf("expected type 'message', got %q", msg.Type)
}
if msg.Role != "assistant" {
t.Errorf("expected role 'assistant', got %q", msg.Role)
}
if len(msg.Content) != 1 {
t.Fatalf("expected 1 content block, got %d", len(msg.Content))
}
if msg.Content[0].Text != "Hello, world!" {
t.Errorf("expected text 'Hello, world!', got %q", msg.Content[0].Text)
}
}
func TestParseStreamMessage_ToolUse(t *testing.T) {
line := []byte(`{"type":"tool_use","name":"Bash","input":{"command":"ls -la"}}`)
msg, err := ParseStreamMessage(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msg.Type != StreamMessageToolUse {
t.Errorf("expected type 'tool_use', got %q", msg.Type)
}
if msg.Name != "Bash" {
t.Errorf("expected name 'Bash', got %q", msg.Name)
}
}
func TestParseStreamMessage_ToolResult(t *testing.T) {
line := []byte(`{"type":"tool_result","output":"total 64\ndrwxr-xr-x 10 user staff"}`)
msg, err := ParseStreamMessage(line)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msg.Type != StreamMessageToolResult {
t.Errorf("expected type 'tool_result', got %q", msg.Type)
}
if msg.Output != "total 64\ndrwxr-xr-x 10 user staff" {
t.Errorf("unexpected output: %q", msg.Output)
}
}
func TestParseStreamMessage_Result(t *testing.T) {
tests := []struct {
name string
line string
wantStatus string
wantMs int64
}{
{
name: "success",
line: `{"type":"result","status":"success","duration_ms":1234}`,
wantStatus: "success",
wantMs: 1234,
},
{
name: "error",
line: `{"type":"result","status":"error","error":"something went wrong"}`,
wantStatus: "error",
wantMs: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg, err := ParseStreamMessage([]byte(tt.line))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if msg.Type != StreamMessageResult {
t.Errorf("expected type 'result', got %q", msg.Type)
}
if msg.Status != tt.wantStatus {
t.Errorf("expected status %q, got %q", tt.wantStatus, msg.Status)
}
if msg.DurationMs != tt.wantMs {
t.Errorf("expected duration_ms %d, got %d", tt.wantMs, msg.DurationMs)
}
})
}
}
func TestParseStreamMessage_Invalid(t *testing.T) {
line := []byte(`not valid json`)
_, err := ParseStreamMessage(line)
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestStreamMessage_ToAgentEvent_Init(t *testing.T) {
msg := &StreamMessage{
Type: StreamMessageInit,
SessionID: "test-session",
Timestamp: "2024-01-01T12:00:00Z",
}
event := msg.ToAgentEvent()
if event.Type != domain.AgentEventOutput {
t.Errorf("expected type %v, got %v", domain.AgentEventOutput, event.Type)
}
if event.Content != "Session started" {
t.Errorf("expected content 'Session started', got %q", event.Content)
}
if event.Metadata["session_id"] != "test-session" {
t.Errorf("expected session_id in metadata")
}
}
func TestStreamMessage_ToAgentEvent_Message(t *testing.T) {
msg := &StreamMessage{
Type: StreamMessageMessage,
Role: "assistant",
Content: []ContentBlock{
{Type: "text", Text: "Hello from Claude"},
},
}
event := msg.ToAgentEvent()
if event.Type != domain.AgentEventOutput {
t.Errorf("expected type %v, got %v", domain.AgentEventOutput, event.Type)
}
if event.Content != "Hello from Claude" {
t.Errorf("expected content 'Hello from Claude', got %q", event.Content)
}
}
func TestStreamMessage_ToAgentEvent_ToolUse(t *testing.T) {
msg := &StreamMessage{
Type: StreamMessageToolUse,
Name: "Read",
Input: []byte(`{"path":"/workspace/main.go"}`),
}
event := msg.ToAgentEvent()
if event.Type != domain.AgentEventToolUse {
t.Errorf("expected type %v, got %v", domain.AgentEventToolUse, event.Type)
}
if event.ToolName != "Read" {
t.Errorf("expected tool name 'Read', got %q", event.ToolName)
}
if event.ToolInput["path"] != "/workspace/main.go" {
t.Errorf("expected path in tool input")
}
}
func TestStreamMessage_ToAgentEvent_ToolResult(t *testing.T) {
msg := &StreamMessage{
Type: StreamMessageToolResult,
Output: "file contents here",
}
event := msg.ToAgentEvent()
if event.Type != domain.AgentEventToolResult {
t.Errorf("expected type %v, got %v", domain.AgentEventToolResult, event.Type)
}
if event.Content != "file contents here" {
t.Errorf("expected content 'file contents here', got %q", event.Content)
}
}
func TestStreamMessage_ToAgentEvent_ResultSuccess(t *testing.T) {
msg := &StreamMessage{
Type: StreamMessageResult,
Status: "success",
DurationMs: 5000,
}
event := msg.ToAgentEvent()
if event.Type != domain.AgentEventComplete {
t.Errorf("expected type %v, got %v", domain.AgentEventComplete, event.Type)
}
if event.Metadata["status"] != "success" {
t.Errorf("expected status in metadata")
}
if event.Metadata["duration_ms"].(int64) != 5000 {
t.Errorf("expected duration_ms in metadata")
}
}
func TestStreamMessage_ToAgentEvent_ResultError(t *testing.T) {
msg := &StreamMessage{
Type: StreamMessageResult,
Status: "error",
Error: "execution failed",
}
event := msg.ToAgentEvent()
if event.Type != domain.AgentEventError {
t.Errorf("expected type %v, got %v", domain.AgentEventError, event.Type)
}
if event.Content != "execution failed" {
t.Errorf("expected error content, got %q", event.Content)
}
}
func TestStreamMessage_IsTerminal(t *testing.T) {
tests := []struct {
msgType StreamMessageType
terminal bool
}{
{StreamMessageInit, false},
{StreamMessageMessage, false},
{StreamMessageToolUse, false},
{StreamMessageToolResult, false},
{StreamMessageResult, true},
}
for _, tt := range tests {
t.Run(string(tt.msgType), func(t *testing.T) {
msg := &StreamMessage{Type: tt.msgType}
if msg.IsTerminal() != tt.terminal {
t.Errorf("IsTerminal() = %v, want %v", msg.IsTerminal(), tt.terminal)
}
})
}
}
func TestStreamMessage_IsSuccess(t *testing.T) {
tests := []struct {
name string
msg StreamMessage
success bool
}{
{"success result", StreamMessage{Type: StreamMessageResult, Status: "success"}, true},
{"error result", StreamMessage{Type: StreamMessageResult, Status: "error"}, false},
{"non-result", StreamMessage{Type: StreamMessageMessage}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.msg.IsSuccess() != tt.success {
t.Errorf("IsSuccess() = %v, want %v", tt.msg.IsSuccess(), tt.success)
}
})
}
}
func TestParseTimestamp(t *testing.T) {
tests := []struct {
input string
valid bool
}{
{"2024-01-01T12:00:00Z", true},
{"2024-01-01T12:00:00+00:00", true},
{"invalid", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
result := parseTimestamp(tt.input)
if tt.valid {
if result.Year() != 2024 {
t.Errorf("expected year 2024, got %d", result.Year())
}
} else {
// Should return current time for invalid input
if time.Since(result) > time.Second {
t.Error("expected recent time for invalid input")
}
}
})
}
}
func TestExtractTextContent(t *testing.T) {
tests := []struct {
name string
blocks []ContentBlock
want string
}{
{
name: "single text block",
blocks: []ContentBlock{{Type: "text", Text: "hello"}},
want: "hello",
},
{
name: "text after tool_use",
blocks: []ContentBlock{
{Type: "tool_use", Name: "Bash"},
{Type: "text", Text: "result"},
},
want: "result",
},
{
name: "empty blocks",
blocks: []ContentBlock{},
want: "",
},
{
name: "no text blocks",
blocks: []ContentBlock{{Type: "tool_use", Name: "Read"}},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractTextContent(tt.blocks); got != tt.want {
t.Errorf("extractTextContent() = %q, want %q", got, tt.want)
}
})
}
}

View File

@ -0,0 +1,388 @@
// Package opencode provides a CodeAgent implementation for the OpenCode server.
package opencode
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// Adapter implements port.CodeAgent using OpenCode's HTTP server.
type Adapter struct {
client *Client
// Track active sessions for cancellation
activeSessions map[string]context.CancelFunc
sessionsMu sync.Mutex
}
// NewAdapter creates a new OpenCode adapter.
func NewAdapter(cfg ClientConfig) *Adapter {
return &Adapter{
client: NewClient(cfg),
activeSessions: make(map[string]context.CancelFunc),
}
}
// Ensure Adapter implements port.CodeAgent at compile time.
var _ port.CodeAgent = (*Adapter)(nil)
// Name returns a human-readable name for this agent.
func (a *Adapter) Name() string {
return "OpenCode"
}
// Provider returns the agent provider identifier.
func (a *Adapter) Provider() domain.AgentProvider {
return domain.AgentProviderOpenCode
}
// Execute runs an OpenCode command and streams events to the handler.
func (a *Adapter) Execute(ctx context.Context, req *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error) {
if req.Prompt == "" {
return nil, fmt.Errorf("prompt is required")
}
// Create cancellable context
execCtx, cancel := context.WithCancel(ctx)
defer cancel()
// Apply timeout if specified
if req.Timeout > 0 {
var timeoutCancel context.CancelFunc
execCtx, timeoutCancel = context.WithTimeout(execCtx, req.Timeout)
defer timeoutCancel()
}
startTime := time.Now()
// Get or create session
sessionID := req.SessionID
if sessionID == "" {
session, err := a.client.CreateSession(execCtx, &CreateSessionRequest{
Title: fmt.Sprintf("rdev-%d", time.Now().Unix()),
})
if err != nil {
return &domain.AgentResult{
ExitCode: 1,
Error: fmt.Errorf("create session: %w", err),
}, nil
}
sessionID = session.ID
}
// Track session for potential cancellation
a.sessionsMu.Lock()
a.activeSessions[sessionID] = cancel
a.sessionsMu.Unlock()
defer func() {
a.sessionsMu.Lock()
delete(a.activeSessions, sessionID)
a.sessionsMu.Unlock()
}()
// Emit session started event
handler(domain.AgentEvent{
Type: domain.AgentEventOutput,
Timestamp: time.Now(),
Content: "Session started",
Metadata: map[string]any{
"session_id": sessionID,
},
})
// Subscribe to SSE events for real-time updates
eventChan, err := a.client.SubscribeEvents(execCtx)
if err != nil {
// Non-fatal: we can still use sync API
handler(domain.AgentEvent{
Type: domain.AgentEventError,
Timestamp: time.Now(),
Content: fmt.Sprintf("SSE subscription failed: %v (using sync mode)", err),
})
}
// Start event consumer if we have SSE
var eventWg sync.WaitGroup
if eventChan != nil {
eventWg.Add(1)
go func() {
defer eventWg.Done()
a.consumeEvents(execCtx, eventChan, handler)
}()
}
// Build message request
msgReq := &SendMessageRequest{
Parts: []MessagePart{
{Type: "text", Content: req.Prompt},
},
}
// Set model if specified
if req.Model != "" {
msgReq.Model = req.Model
}
// Set tools if specified
if len(req.AllowedTools) > 0 {
msgReq.Tools = req.AllowedTools
}
// Send message (synchronous call that waits for response)
resp, err := a.client.SendMessage(execCtx, sessionID, msgReq)
if err != nil {
// Check if cancelled
if execCtx.Err() == context.Canceled {
return &domain.AgentResult{
SessionID: sessionID,
ExitCode: 1,
DurationMs: time.Since(startTime).Milliseconds(),
Error: domain.ErrCommandCancelled,
}, nil
}
return &domain.AgentResult{
SessionID: sessionID,
ExitCode: 1,
DurationMs: time.Since(startTime).Milliseconds(),
Error: fmt.Errorf("send message: %w", err),
}, nil
}
// Process response parts
var finalOutput strings.Builder
var hasError bool
var errorContent string
for _, part := range resp.Parts {
event := a.partToEvent(part)
handler(event)
if part.Type == "text" && part.Content != "" {
finalOutput.WriteString(part.Content)
}
if part.Type == "error" {
hasError = true
errorContent = part.Content
}
}
// Emit completion event
duration := time.Since(startTime)
completionStatus := "success"
if hasError {
completionStatus = "error"
}
handler(domain.AgentEvent{
Type: domain.AgentEventComplete,
Timestamp: time.Now(),
Metadata: map[string]any{
"status": completionStatus,
"duration_ms": duration.Milliseconds(),
},
})
// Wait for SSE consumer to finish (with timeout)
if eventChan != nil {
cancel() // Signal event consumer to stop
waitDone := make(chan struct{})
go func() {
eventWg.Wait()
close(waitDone)
}()
select {
case <-waitDone:
case <-time.After(2 * time.Second):
// Event consumer didn't stop in time, continue anyway
}
}
result := &domain.AgentResult{
SessionID: sessionID,
ExitCode: 0,
DurationMs: duration.Milliseconds(),
FinalOutput: finalOutput.String(),
}
// Set error state if error parts were found
if hasError {
result.ExitCode = 1
if errorContent != "" {
result.Error = fmt.Errorf("agent error: %s", errorContent)
}
}
return result, nil
}
// consumeEvents processes SSE events and dispatches them to the handler.
func (a *Adapter) consumeEvents(ctx context.Context, events <-chan SSEEvent, handler domain.AgentEventHandler) {
for {
select {
case <-ctx.Done():
return
case event, ok := <-events:
if !ok {
return
}
agentEvent := a.sseToEvent(event)
if agentEvent.Type != "" {
handler(agentEvent)
}
}
}
}
// sseToEvent converts an SSE event to a domain.AgentEvent.
func (a *Adapter) sseToEvent(sse SSEEvent) domain.AgentEvent {
event := domain.AgentEvent{
Timestamp: time.Now(),
Metadata: make(map[string]any),
}
// Parse event type
switch sse.Event {
case "server.connected":
event.Type = domain.AgentEventOutput
event.Content = "Connected to OpenCode server"
return event
case "message.created", "message.updated":
event.Type = domain.AgentEventOutput
// Try to parse data for content
var data map[string]any
if json.Unmarshal([]byte(sse.Data), &data) == nil {
if content, ok := data["content"].(string); ok {
event.Content = content
}
}
return event
case "tool.started":
event.Type = domain.AgentEventToolUse
var data map[string]any
if json.Unmarshal([]byte(sse.Data), &data) == nil {
if name, ok := data["name"].(string); ok {
event.ToolName = name
event.Content = name
}
if input, ok := data["input"].(map[string]any); ok {
event.ToolInput = input
}
}
return event
case "tool.completed":
event.Type = domain.AgentEventToolResult
var data map[string]any
if json.Unmarshal([]byte(sse.Data), &data) == nil {
if output, ok := data["output"].(string); ok {
event.Content = output
}
}
return event
case "session.completed":
event.Type = domain.AgentEventComplete
return event
case "error":
event.Type = domain.AgentEventError
event.Content = sse.Data
return event
}
// Unknown event type
return domain.AgentEvent{}
}
// partToEvent converts a message part to a domain.AgentEvent.
func (a *Adapter) partToEvent(part MessagePart) domain.AgentEvent {
event := domain.AgentEvent{
Timestamp: time.Now(),
Metadata: make(map[string]any),
}
switch part.Type {
case "text":
event.Type = domain.AgentEventOutput
event.Content = part.Content
case "tool_use":
event.Type = domain.AgentEventToolUse
event.ToolName = part.Name
event.Content = part.Name
if input, ok := part.Input.(map[string]any); ok {
event.ToolInput = input
}
case "tool_result":
event.Type = domain.AgentEventToolResult
event.Content = part.Content
default:
event.Type = domain.AgentEventOutput
event.Content = part.Content
}
return event
}
// Cancel attempts to cancel a running session.
func (a *Adapter) Cancel(ctx context.Context, sessionID string) error {
a.sessionsMu.Lock()
cancel, exists := a.activeSessions[sessionID]
if exists {
// Call cancel while holding lock and delete to prevent double-cancel
cancel()
delete(a.activeSessions, sessionID)
}
a.sessionsMu.Unlock()
// Abort on the server side regardless of local session state
return a.client.AbortSession(ctx, sessionID)
}
// Capabilities returns what this agent supports.
func (a *Adapter) Capabilities() domain.AgentCapabilities {
return domain.AgentCapabilities{
Provider: domain.AgentProviderOpenCode,
SupportsSessionContinuation: true,
SupportsModelSelection: true, // OpenCode supports multiple providers
SupportsToolControl: true,
SupportedModels: []string{
"claude-sonnet-4-20250514",
"claude-opus-4-20250514",
"gpt-4o",
"gpt-4-turbo",
"gemini-pro",
},
DefaultModel: "claude-sonnet-4-20250514",
MaxPromptLength: 0, // Unlimited
SupportsStreaming: true,
}
}
// DefaultAvailabilityTimeout is the maximum time to wait when checking agent availability.
// This timeout prevents blocking the caller when the server is slow or unresponsive.
const DefaultAvailabilityTimeout = 5 * time.Second
// Available checks if the OpenCode server is healthy.
func (a *Adapter) Available(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, DefaultAvailabilityTimeout)
defer cancel()
health, err := a.client.Health(ctx)
if err != nil {
return false
}
return health.Healthy
}

View File

@ -0,0 +1,462 @@
package opencode
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// Ensure Adapter implements port.CodeAgent at compile time.
var _ port.CodeAgent = (*Adapter)(nil)
func TestAdapter_Name(t *testing.T) {
adapter := NewAdapter(ClientConfig{})
if name := adapter.Name(); name != "OpenCode" {
t.Errorf("expected name 'OpenCode', got %q", name)
}
}
func TestAdapter_Provider(t *testing.T) {
adapter := NewAdapter(ClientConfig{})
if p := adapter.Provider(); p != domain.AgentProviderOpenCode {
t.Errorf("expected provider 'opencode', got %q", p)
}
}
func TestAdapter_Capabilities(t *testing.T) {
adapter := NewAdapter(ClientConfig{})
caps := adapter.Capabilities()
if caps.Provider != domain.AgentProviderOpenCode {
t.Errorf("expected provider opencode")
}
if !caps.SupportsSessionContinuation {
t.Error("expected session continuation support")
}
if !caps.SupportsModelSelection {
t.Error("expected model selection support")
}
if !caps.SupportsToolControl {
t.Error("expected tool control support")
}
if !caps.SupportsStreaming {
t.Error("expected streaming support")
}
if len(caps.SupportedModels) == 0 {
t.Error("expected at least one supported model")
}
}
func TestAdapter_Execute_MissingPrompt(t *testing.T) {
adapter := NewAdapter(ClientConfig{})
req := &domain.AgentRequest{
Prompt: "", // missing
}
_, err := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {})
if err == nil {
t.Error("expected error for missing prompt")
}
}
func TestAdapter_Available_Healthy(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/global/health" {
json.NewEncoder(w).Encode(HealthResponse{Healthy: true, Version: "1.0.0"})
return
}
http.NotFound(w, r)
}))
defer server.Close()
adapter := NewAdapter(ClientConfig{BaseURL: server.URL})
if !adapter.Available(context.Background()) {
t.Error("expected adapter to be available")
}
}
func TestAdapter_Available_Unhealthy(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/global/health" {
json.NewEncoder(w).Encode(HealthResponse{Healthy: false})
return
}
http.NotFound(w, r)
}))
defer server.Close()
adapter := NewAdapter(ClientConfig{BaseURL: server.URL})
if adapter.Available(context.Background()) {
t.Error("expected adapter to be unavailable")
}
}
func TestAdapter_Available_ServerDown(t *testing.T) {
adapter := NewAdapter(ClientConfig{BaseURL: "http://localhost:59999"})
if adapter.Available(context.Background()) {
t.Error("expected adapter to be unavailable when server is down")
}
}
func TestAdapter_Execute_WithMockServer(t *testing.T) {
sessionID := "test-session-123"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/session" && r.Method == http.MethodPost:
// Create session
json.NewEncoder(w).Encode(Session{ID: sessionID})
case r.URL.Path == "/session/"+sessionID+"/message" && r.Method == http.MethodPost:
// Send message response
json.NewEncoder(w).Encode(SendMessageResponse{
Info: MessageInfo{ID: "msg-1", Role: "assistant"},
Parts: []MessagePart{
{Type: "text", Content: "Hello from OpenCode!"},
},
})
case r.URL.Path == "/event":
// SSE endpoint - just close immediately for this test
w.WriteHeader(http.StatusOK)
return
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := NewAdapter(ClientConfig{BaseURL: server.URL})
var events []domain.AgentEvent
handler := func(e domain.AgentEvent) {
events = append(events, e)
}
req := &domain.AgentRequest{
Prompt: "Say hello",
}
result, err := adapter.Execute(context.Background(), req, handler)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.SessionID != sessionID {
t.Errorf("expected session ID %q, got %q", sessionID, result.SessionID)
}
if result.ExitCode != 0 {
t.Errorf("expected exit code 0, got %d", result.ExitCode)
}
if result.FinalOutput != "Hello from OpenCode!" {
t.Errorf("expected final output 'Hello from OpenCode!', got %q", result.FinalOutput)
}
// Should have at least session started + output + complete events
if len(events) < 3 {
t.Errorf("expected at least 3 events, got %d", len(events))
}
}
func TestAdapter_Execute_WithModel(t *testing.T) {
var receivedModel string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/session" && r.Method == http.MethodPost:
json.NewEncoder(w).Encode(Session{ID: "sess-1"})
case r.URL.Path == "/session/sess-1/message" && r.Method == http.MethodPost:
var req SendMessageRequest
json.NewDecoder(r.Body).Decode(&req)
receivedModel = req.Model
json.NewEncoder(w).Encode(SendMessageResponse{
Info: MessageInfo{ID: "msg-1"},
Parts: []MessagePart{{Type: "text", Content: "Done"}},
})
case r.URL.Path == "/event":
w.WriteHeader(http.StatusOK)
return
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := NewAdapter(ClientConfig{BaseURL: server.URL})
req := &domain.AgentRequest{
Prompt: "Test",
Model: "gpt-4o",
}
_, err := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if receivedModel != "gpt-4o" {
t.Errorf("expected model 'gpt-4o', got %q", receivedModel)
}
}
func TestAdapter_Cancel(t *testing.T) {
abortCalled := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/session/test-session/abort" && r.Method == http.MethodPost {
abortCalled = true
w.WriteHeader(http.StatusOK)
return
}
http.NotFound(w, r)
}))
defer server.Close()
adapter := NewAdapter(ClientConfig{BaseURL: server.URL})
err := adapter.Cancel(context.Background(), "test-session")
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if !abortCalled {
t.Error("expected abort endpoint to be called")
}
}
func TestAdapter_Execute_WithErrorPart(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/session" && r.Method == http.MethodPost:
json.NewEncoder(w).Encode(Session{ID: "sess-err"})
case r.URL.Path == "/session/sess-err/message" && r.Method == http.MethodPost:
// Return response with error part
json.NewEncoder(w).Encode(SendMessageResponse{
Info: MessageInfo{ID: "msg-1"},
Parts: []MessagePart{
{Type: "text", Content: "Attempting task..."},
{Type: "error", Content: "Command failed with exit code 1"},
},
})
case r.URL.Path == "/event":
w.WriteHeader(http.StatusOK)
return
default:
http.NotFound(w, r)
}
}))
defer server.Close()
adapter := NewAdapter(ClientConfig{BaseURL: server.URL})
req := &domain.AgentRequest{
Prompt: "Run failing command",
}
result, err := adapter.Execute(context.Background(), req, func(e domain.AgentEvent) {})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should have non-zero exit code when error part is present
if result.ExitCode != 1 {
t.Errorf("expected exit code 1 for error response, got %d", result.ExitCode)
}
// Should have error set
if result.Error == nil {
t.Error("expected error to be set when error part is present")
}
// Error should contain the error content
if result.Error != nil && !strings.Contains(result.Error.Error(), "Command failed") {
t.Errorf("expected error to contain 'Command failed', got %q", result.Error.Error())
}
}
func TestAdapter_partToEvent(t *testing.T) {
adapter := NewAdapter(ClientConfig{})
tests := []struct {
name string
part MessagePart
wantType domain.AgentEventType
wantValue string
}{
{
name: "text part",
part: MessagePart{Type: "text", Content: "Hello"},
wantType: domain.AgentEventOutput,
wantValue: "Hello",
},
{
name: "tool_use part",
part: MessagePart{Type: "tool_use", Name: "Bash"},
wantType: domain.AgentEventToolUse,
wantValue: "Bash",
},
{
name: "tool_result part",
part: MessagePart{Type: "tool_result", Content: "output here"},
wantType: domain.AgentEventToolResult,
wantValue: "output here",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := adapter.partToEvent(tt.part)
if event.Type != tt.wantType {
t.Errorf("expected type %v, got %v", tt.wantType, event.Type)
}
if event.Content != tt.wantValue {
t.Errorf("expected content %q, got %q", tt.wantValue, event.Content)
}
})
}
}
func TestAdapter_sseToEvent(t *testing.T) {
adapter := NewAdapter(ClientConfig{})
tests := []struct {
name string
sse SSEEvent
wantType domain.AgentEventType
}{
{
name: "server connected",
sse: SSEEvent{Event: "server.connected"},
wantType: domain.AgentEventOutput,
},
{
name: "tool started",
sse: SSEEvent{Event: "tool.started", Data: `{"name":"Bash"}`},
wantType: domain.AgentEventToolUse,
},
{
name: "tool completed",
sse: SSEEvent{Event: "tool.completed", Data: `{"output":"done"}`},
wantType: domain.AgentEventToolResult,
},
{
name: "session completed",
sse: SSEEvent{Event: "session.completed"},
wantType: domain.AgentEventComplete,
},
{
name: "error",
sse: SSEEvent{Event: "error", Data: "something failed"},
wantType: domain.AgentEventError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := adapter.sseToEvent(tt.sse)
if event.Type != tt.wantType {
t.Errorf("expected type %v, got %v", tt.wantType, event.Type)
}
})
}
}
func TestClient_Health(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/global/health" {
json.NewEncoder(w).Encode(HealthResponse{Healthy: true, Version: "1.2.3"})
return
}
http.NotFound(w, r)
}))
defer server.Close()
client := NewClient(ClientConfig{BaseURL: server.URL})
health, err := client.Health(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !health.Healthy {
t.Error("expected healthy=true")
}
if health.Version != "1.2.3" {
t.Errorf("expected version '1.2.3', got %q", health.Version)
}
}
func TestClient_CreateSession(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/session" && r.Method == http.MethodPost {
var req CreateSessionRequest
json.NewDecoder(r.Body).Decode(&req)
json.NewEncoder(w).Encode(Session{
ID: "new-session-id",
Title: req.Title,
CreatedAt: time.Now(),
})
return
}
http.NotFound(w, r)
}))
defer server.Close()
client := NewClient(ClientConfig{BaseURL: server.URL})
session, err := client.CreateSession(context.Background(), &CreateSessionRequest{
Title: "Test Session",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if session.ID != "new-session-id" {
t.Errorf("expected ID 'new-session-id', got %q", session.ID)
}
if session.Title != "Test Session" {
t.Errorf("expected title 'Test Session', got %q", session.Title)
}
}
func TestClient_WithAuth(t *testing.T) {
var authHeader string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader = r.Header.Get("Authorization")
json.NewEncoder(w).Encode(HealthResponse{Healthy: true})
}))
defer server.Close()
client := NewClient(ClientConfig{
BaseURL: server.URL,
Username: "opencode",
Password: "secret",
})
_, err := client.Health(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if authHeader == "" {
t.Error("expected Authorization header to be set")
}
if authHeader != "Basic b3BlbmNvZGU6c2VjcmV0" {
t.Errorf("unexpected auth header: %s", authHeader)
}
}

View File

@ -0,0 +1,310 @@
// Package opencode provides a CodeAgent implementation for the OpenCode server.
package opencode
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client communicates with an OpenCode server via HTTP.
type Client struct {
baseURL string
httpClient *http.Client
username string
password string
}
// ClientConfig configures the OpenCode client.
type ClientConfig struct {
BaseURL string
Timeout time.Duration
Username string
Password string
}
// NewClient creates a new OpenCode HTTP client.
func NewClient(cfg ClientConfig) *Client {
if cfg.Timeout == 0 {
cfg.Timeout = 30 * time.Second
}
if cfg.BaseURL == "" {
cfg.BaseURL = "http://127.0.0.1:4096"
}
if cfg.Username == "" {
cfg.Username = "opencode"
}
return &Client{
baseURL: strings.TrimSuffix(cfg.BaseURL, "/"),
httpClient: &http.Client{
Timeout: cfg.Timeout,
},
username: cfg.Username,
password: cfg.Password,
}
}
// HealthResponse represents the /global/health response.
type HealthResponse struct {
Healthy bool `json:"healthy"`
Version string `json:"version"`
}
// Health checks if the OpenCode server is healthy.
func (c *Client) Health(ctx context.Context) (*HealthResponse, error) {
resp, err := c.doRequest(ctx, http.MethodGet, "/global/health", nil)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
var health HealthResponse
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
return nil, fmt.Errorf("decode health: %w", err)
}
return &health, nil
}
// Session represents an OpenCode session.
type Session struct {
ID string `json:"id"`
Title string `json:"title,omitempty"`
ParentID string `json:"parentID,omitempty"`
CreatedAt time.Time `json:"createdAt,omitempty"`
}
// CreateSessionRequest is the body for POST /session.
type CreateSessionRequest struct {
ParentID string `json:"parentID,omitempty"`
Title string `json:"title,omitempty"`
}
// CreateSession creates a new session.
func (c *Client) CreateSession(ctx context.Context, req *CreateSessionRequest) (*Session, error) {
body, _ := json.Marshal(req)
resp, err := c.doRequest(ctx, http.MethodPost, "/session", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
var session Session
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
return nil, fmt.Errorf("decode session: %w", err)
}
return &session, nil
}
// GetSession retrieves a session by ID.
func (c *Client) GetSession(ctx context.Context, sessionID string) (*Session, error) {
resp, err := c.doRequest(ctx, http.MethodGet, "/session/"+sessionID, nil)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
var session Session
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
return nil, fmt.Errorf("decode session: %w", err)
}
return &session, nil
}
// MessagePart represents a part of a message.
type MessagePart struct {
Type string `json:"type"` // "text", "tool_use", "tool_result", etc.
Content string `json:"content,omitempty"`
Name string `json:"name,omitempty"`
Input any `json:"input,omitempty"`
}
// SendMessageRequest is the body for POST /session/:id/message.
type SendMessageRequest struct {
MessageID string `json:"messageID,omitempty"`
Model string `json:"model,omitempty"`
Agent string `json:"agent,omitempty"`
System string `json:"system,omitempty"`
Tools []string `json:"tools,omitempty"`
Parts []MessagePart `json:"parts"`
}
// MessageInfo contains message metadata.
type MessageInfo struct {
ID string `json:"id"`
Role string `json:"role"`
Timestamp time.Time `json:"timestamp"`
}
// SendMessageResponse is the response from POST /session/:id/message.
type SendMessageResponse struct {
Info MessageInfo `json:"info"`
Parts []MessagePart `json:"parts"`
}
// SendMessage sends a message to a session (synchronous, waits for response).
func (c *Client) SendMessage(ctx context.Context, sessionID string, req *SendMessageRequest) (*SendMessageResponse, error) {
body, _ := json.Marshal(req)
resp, err := c.doRequest(ctx, http.MethodPost, "/session/"+sessionID+"/message", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
var msgResp SendMessageResponse
if err := json.NewDecoder(resp.Body).Decode(&msgResp); err != nil {
return nil, fmt.Errorf("decode message response: %w", err)
}
return &msgResp, nil
}
// SendPromptAsync sends a message asynchronously.
func (c *Client) SendPromptAsync(ctx context.Context, sessionID string, req *SendMessageRequest) error {
body, _ := json.Marshal(req)
resp, err := c.doRequest(ctx, http.MethodPost, "/session/"+sessionID+"/prompt_async", bytes.NewReader(body))
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
return nil
}
// AbortSession stops a running session.
func (c *Client) AbortSession(ctx context.Context, sessionID string) error {
resp, err := c.doRequest(ctx, http.MethodPost, "/session/"+sessionID+"/abort", nil)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
return nil
}
// SSEEvent represents a server-sent event.
type SSEEvent struct {
Event string
Data string
}
// sseEventBufferSize is the capacity of the SSE event channel.
// This buffer handles typical bursts of events during agent execution (e.g., rapid tool calls).
// If the consumer is slow and the buffer fills, the goroutine will block on send
// until the context is cancelled or the consumer catches up.
const sseEventBufferSize = 100
// SubscribeEvents returns a channel of SSE events from the server.
func (c *Client) SubscribeEvents(ctx context.Context) (<-chan SSEEvent, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/event", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "text/event-stream")
c.setAuth(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
return nil, fmt.Errorf("SSE subscribe failed: %s", resp.Status)
}
events := make(chan SSEEvent, sseEventBufferSize)
go func() {
defer func() { _ = resp.Body.Close() }()
defer close(events)
reader := bufio.NewReader(resp.Body)
var currentEvent SSEEvent
for {
select {
case <-ctx.Done():
return
default:
}
line, err := reader.ReadString('\n')
if err != nil {
// EOF is expected when connection closes; other errors are logged but not fatal
return
}
line = strings.TrimSuffix(line, "\n")
line = strings.TrimSuffix(line, "\r")
if line == "" {
// Empty line = end of event
if currentEvent.Event != "" || currentEvent.Data != "" {
select {
case events <- currentEvent:
case <-ctx.Done():
return
}
currentEvent = SSEEvent{}
}
continue
}
if strings.HasPrefix(line, "event:") {
currentEvent.Event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
} else if strings.HasPrefix(line, "data:") {
currentEvent.Data = strings.TrimSpace(strings.TrimPrefix(line, "data:"))
}
}
}()
return events, nil
}
// RunShell executes a shell command in the session.
func (c *Client) RunShell(ctx context.Context, sessionID, command string) error {
body, _ := json.Marshal(map[string]string{"command": command})
resp, err := c.doRequest(ctx, http.MethodPost, "/session/"+sessionID+"/shell", bytes.NewReader(body))
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
return nil
}
// doRequest performs an HTTP request with auth.
func (c *Client) doRequest(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
c.setAuth(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
bodyBytes, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
return nil, fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, string(bodyBytes))
}
return resp, nil
}
// setAuth sets authentication headers if password is configured.
func (c *Client) setAuth(req *http.Request) {
if c.password != "" {
req.SetBasicAuth(c.username, c.password)
}
}

View File

@ -0,0 +1,122 @@
// Package codeagent provides the code agent registry and common utilities.
package codeagent
import (
"context"
"fmt"
"sync"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// Registry implements port.CodeAgentRegistry with thread-safe agent management.
type Registry struct {
mu sync.RWMutex
agents map[domain.AgentProvider]port.CodeAgent
defProv domain.AgentProvider
hasAgent bool
}
// NewRegistry creates a new empty agent registry.
func NewRegistry() *Registry {
return &Registry{
agents: make(map[domain.AgentProvider]port.CodeAgent),
}
}
// Ensure Registry implements port.CodeAgentRegistry at compile time.
var _ port.CodeAgentRegistry = (*Registry)(nil)
// Register adds an agent implementation for a provider.
func (r *Registry) Register(agent port.CodeAgent) {
r.mu.Lock()
defer r.mu.Unlock()
provider := agent.Provider()
r.agents[provider] = agent
// If this is the first agent, make it the default
if !r.hasAgent {
r.defProv = provider
r.hasAgent = true
}
}
// Get returns the agent for a specific provider.
func (r *Registry) Get(provider domain.AgentProvider) port.CodeAgent {
r.mu.RLock()
defer r.mu.RUnlock()
return r.agents[provider]
}
// Default returns the default agent implementation.
func (r *Registry) Default() port.CodeAgent {
r.mu.RLock()
defer r.mu.RUnlock()
if !r.hasAgent {
return nil
}
return r.agents[r.defProv]
}
// SetDefault sets which provider should be used as the default.
func (r *Registry) SetDefault(provider domain.AgentProvider) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.agents[provider]; !exists {
return fmt.Errorf("agent provider %q is not registered", provider)
}
r.defProv = provider
return nil
}
// Available returns all registered providers.
func (r *Registry) Available() []domain.AgentProvider {
r.mu.RLock()
defer r.mu.RUnlock()
providers := make([]domain.AgentProvider, 0, len(r.agents))
for p := range r.agents {
providers = append(providers, p)
}
return providers
}
// AvailableAgents returns all registered agents that are currently available.
func (r *Registry) AvailableAgents(ctx context.Context) []port.CodeAgent {
r.mu.RLock()
defer r.mu.RUnlock()
available := make([]port.CodeAgent, 0, len(r.agents))
for _, agent := range r.agents {
if agent.Available(ctx) {
available = append(available, agent)
}
}
return available
}
// DefaultProvider returns the current default provider.
// Returns empty string if no agents are registered.
func (r *Registry) DefaultProvider() domain.AgentProvider {
r.mu.RLock()
defer r.mu.RUnlock()
if !r.hasAgent {
return ""
}
return r.defProv
}
// Count returns the number of registered agents.
func (r *Registry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.agents)
}

View File

@ -0,0 +1,261 @@
package codeagent
import (
"context"
"testing"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// mockAgent is a test implementation of port.CodeAgent.
type mockAgent struct {
provider domain.AgentProvider
name string
available bool
}
func (m *mockAgent) Name() string { return m.name }
func (m *mockAgent) Provider() domain.AgentProvider { return m.provider }
func (m *mockAgent) Available(ctx context.Context) bool { return m.available }
func (m *mockAgent) Execute(ctx context.Context, req *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error) {
return &domain.AgentResult{ExitCode: 0}, nil
}
func (m *mockAgent) Cancel(ctx context.Context, sessionID string) error {
return nil
}
func (m *mockAgent) Capabilities() domain.AgentCapabilities {
return domain.AgentCapabilities{Provider: m.provider}
}
var _ port.CodeAgent = (*mockAgent)(nil)
func TestRegistry_RegisterAndGet(t *testing.T) {
reg := NewRegistry()
agent := &mockAgent{
provider: domain.AgentProviderClaudeCode,
name: "Claude Code",
available: true,
}
reg.Register(agent)
got := reg.Get(domain.AgentProviderClaudeCode)
if got == nil {
t.Fatal("expected agent, got nil")
}
if got.Name() != "Claude Code" {
t.Errorf("expected name 'Claude Code', got %q", got.Name())
}
}
func TestRegistry_GetUnregistered(t *testing.T) {
reg := NewRegistry()
got := reg.Get(domain.AgentProviderOpenCode)
if got != nil {
t.Errorf("expected nil for unregistered provider, got %v", got)
}
}
func TestRegistry_DefaultFirstRegistered(t *testing.T) {
reg := NewRegistry()
claude := &mockAgent{provider: domain.AgentProviderClaudeCode, name: "Claude", available: true}
opencode := &mockAgent{provider: domain.AgentProviderOpenCode, name: "OpenCode", available: true}
reg.Register(claude)
reg.Register(opencode)
def := reg.Default()
if def == nil {
t.Fatal("expected default agent, got nil")
}
if def.Provider() != domain.AgentProviderClaudeCode {
t.Errorf("expected first registered (claudecode) as default, got %v", def.Provider())
}
}
func TestRegistry_SetDefault(t *testing.T) {
reg := NewRegistry()
claude := &mockAgent{provider: domain.AgentProviderClaudeCode, name: "Claude", available: true}
opencode := &mockAgent{provider: domain.AgentProviderOpenCode, name: "OpenCode", available: true}
reg.Register(claude)
reg.Register(opencode)
err := reg.SetDefault(domain.AgentProviderOpenCode)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
def := reg.Default()
if def.Provider() != domain.AgentProviderOpenCode {
t.Errorf("expected opencode as default, got %v", def.Provider())
}
}
func TestRegistry_SetDefaultUnregistered(t *testing.T) {
reg := NewRegistry()
claude := &mockAgent{provider: domain.AgentProviderClaudeCode, name: "Claude", available: true}
reg.Register(claude)
err := reg.SetDefault(domain.AgentProviderOpenCode)
if err == nil {
t.Error("expected error setting unregistered default")
}
}
func TestRegistry_Available(t *testing.T) {
reg := NewRegistry()
claude := &mockAgent{provider: domain.AgentProviderClaudeCode, name: "Claude", available: true}
opencode := &mockAgent{provider: domain.AgentProviderOpenCode, name: "OpenCode", available: true}
reg.Register(claude)
reg.Register(opencode)
providers := reg.Available()
if len(providers) != 2 {
t.Errorf("expected 2 providers, got %d", len(providers))
}
// Check both are present (order not guaranteed)
found := make(map[domain.AgentProvider]bool)
for _, p := range providers {
found[p] = true
}
if !found[domain.AgentProviderClaudeCode] || !found[domain.AgentProviderOpenCode] {
t.Errorf("expected both providers in list, got %v", providers)
}
}
func TestRegistry_AvailableAgents(t *testing.T) {
reg := NewRegistry()
claude := &mockAgent{provider: domain.AgentProviderClaudeCode, name: "Claude", available: true}
opencode := &mockAgent{provider: domain.AgentProviderOpenCode, name: "OpenCode", available: false}
reg.Register(claude)
reg.Register(opencode)
ctx := context.Background()
agents := reg.AvailableAgents(ctx)
if len(agents) != 1 {
t.Errorf("expected 1 available agent, got %d", len(agents))
}
if agents[0].Provider() != domain.AgentProviderClaudeCode {
t.Errorf("expected claude as available, got %v", agents[0].Provider())
}
}
func TestRegistry_DefaultEmpty(t *testing.T) {
reg := NewRegistry()
def := reg.Default()
if def != nil {
t.Errorf("expected nil default for empty registry, got %v", def)
}
}
func TestRegistry_Count(t *testing.T) {
reg := NewRegistry()
if reg.Count() != 0 {
t.Errorf("expected 0 count, got %d", reg.Count())
}
reg.Register(&mockAgent{provider: domain.AgentProviderClaudeCode, available: true})
if reg.Count() != 1 {
t.Errorf("expected 1 count, got %d", reg.Count())
}
reg.Register(&mockAgent{provider: domain.AgentProviderOpenCode, available: true})
if reg.Count() != 2 {
t.Errorf("expected 2 count, got %d", reg.Count())
}
}
func TestRegistry_DefaultProvider(t *testing.T) {
reg := NewRegistry()
// Empty registry
if p := reg.DefaultProvider(); p != "" {
t.Errorf("expected empty default provider, got %q", p)
}
reg.Register(&mockAgent{provider: domain.AgentProviderClaudeCode, available: true})
if p := reg.DefaultProvider(); p != domain.AgentProviderClaudeCode {
t.Errorf("expected claudecode, got %q", p)
}
}
func TestRegistry_ConcurrentAccess(t *testing.T) {
reg := NewRegistry()
// Pre-register one agent
reg.Register(&mockAgent{provider: domain.AgentProviderClaudeCode, available: true})
done := make(chan struct{})
const goroutines = 10
const iterations = 100
// Concurrent reads and writes
for i := 0; i < goroutines; i++ {
go func(id int) {
defer func() { done <- struct{}{} }()
for j := 0; j < iterations; j++ {
// Mix of operations
switch j % 5 {
case 0:
reg.Get(domain.AgentProviderClaudeCode)
case 1:
reg.Default()
case 2:
reg.Available()
case 3:
reg.Count()
case 4:
reg.AvailableAgents(context.Background())
}
}
}(i)
}
// Wait for all goroutines
for i := 0; i < goroutines; i++ {
<-done
}
// Verify state is consistent
if reg.Count() != 1 {
t.Errorf("expected count 1 after concurrent access, got %d", reg.Count())
}
}
func TestRegistry_ReRegisterOverwrites(t *testing.T) {
reg := NewRegistry()
agent1 := &mockAgent{provider: domain.AgentProviderClaudeCode, name: "Claude v1", available: true}
agent2 := &mockAgent{provider: domain.AgentProviderClaudeCode, name: "Claude v2", available: true}
reg.Register(agent1)
reg.Register(agent2) // Should overwrite
got := reg.Get(domain.AgentProviderClaudeCode)
if got.Name() != "Claude v2" {
t.Errorf("expected 'Claude v2' after re-register, got %q", got.Name())
}
// Count should still be 1
if reg.Count() != 1 {
t.Errorf("expected count 1 after re-register, got %d", reg.Count())
}
}

View File

@ -26,6 +26,12 @@ type Client struct {
defaultOwner string // default organization/user for new repos
}
// SDKClient returns the underlying Gitea SDK client.
// Used by the template provider to create files in repos.
func (c *Client) SDKClient() *gitea.Client {
return c.client
}
// NewClient creates a new Gitea client.
// url is the Gitea server URL (e.g., https://git.threesix.ai)
// token is an API access token with repo permissions

View File

@ -61,10 +61,10 @@ func (e *Executor) Execute(ctx context.Context, cmd *domain.Command, podName str
switch cmd.Type {
case domain.CommandTypeClaude:
// claude "prompt"
// claude -p --dangerously-skip-permissions "prompt" (non-interactive mode)
args = []string{
"exec", "-n", namespace, podName, "--",
"claude", cmd.Args[0], // prompt is first arg
"claude", "-p", "--dangerously-skip-permissions", cmd.Args[0], // prompt is first arg
}
case domain.CommandTypeShell:
// bash -c "command"

View File

@ -13,6 +13,7 @@ import (
"github.com/orchard9/rdev/internal/port"
corev1 "k8s.io/api/core/v1"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/kubernetes"
@ -375,7 +376,7 @@ func (r *ProjectRepository) getPodStatus(ctx context.Context, podName string) (d
if r.client != nil {
pod, err := r.client.CoreV1().Pods(r.namespace).Get(ctx, podName, metav1.GetOptions{})
if err != nil {
if strings.Contains(err.Error(), "not found") {
if k8serrors.IsNotFound(err) {
return domain.ProjectStatusNotFound, nil
}
return domain.ProjectStatusUnknown, fmt.Errorf("get pod: %w", err)

View File

@ -0,0 +1,233 @@
// Package postgres provides PostgreSQL implementations of port interfaces.
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// Ensure CredentialStore implements port.CredentialStore.
var _ port.CredentialStore = (*CredentialStore)(nil)
// CredentialStore implements credential storage with encryption.
type CredentialStore struct {
db *sql.DB
encryptionKey string
}
// NewCredentialStore creates a new credential store.
// The encryptionKey is used for pgcrypto symmetric encryption.
func NewCredentialStore(db *sql.DB, encryptionKey string) *CredentialStore {
return &CredentialStore{
db: db,
encryptionKey: encryptionKey,
}
}
// Get retrieves a credential by key. Returns empty string if not found.
func (s *CredentialStore) Get(ctx context.Context, key string) (string, error) {
var value string
err := s.db.QueryRowContext(ctx, `
SELECT pgp_sym_decrypt(value, $1)
FROM credentials
WHERE key = $2
`, s.encryptionKey, key).Scan(&value)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("get credential %s: %w", key, err)
}
return value, nil
}
// GetRequired retrieves a credential by key. Returns error if not found.
func (s *CredentialStore) GetRequired(ctx context.Context, key string) (string, error) {
value, err := s.Get(ctx, key)
if err != nil {
return "", err
}
if value == "" {
return "", fmt.Errorf("credential %s not found", key)
}
return value, nil
}
// Set stores or updates a credential.
func (s *CredentialStore) Set(ctx context.Context, cred domain.Credential) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO credentials (key, value, description, category, updated_by)
VALUES ($1, pgp_sym_encrypt($2, $3), $4, $5, $6)
ON CONFLICT (key) DO UPDATE SET
value = pgp_sym_encrypt($2, $3),
description = COALESCE(NULLIF($4, ''), credentials.description),
category = COALESCE(NULLIF($5, ''), credentials.category),
updated_by = $6
`, cred.Key, cred.Value, s.encryptionKey, cred.Description, cred.Category, cred.UpdatedBy)
if err != nil {
return fmt.Errorf("set credential %s: %w", cred.Key, err)
}
return nil
}
// Delete removes a credential by key.
func (s *CredentialStore) Delete(ctx context.Context, key string) error {
result, err := s.db.ExecContext(ctx, `DELETE FROM credentials WHERE key = $1`, key)
if err != nil {
return fmt.Errorf("delete credential %s: %w", key, err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("credential %s not found", key)
}
return nil
}
// List returns all credentials (with values masked).
func (s *CredentialStore) List(ctx context.Context) ([]domain.Credential, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT key, description, category, created_at, updated_at, COALESCE(updated_by, '')
FROM credentials
ORDER BY category, key
`)
if err != nil {
return nil, fmt.Errorf("list credentials: %w", err)
}
defer func() { _ = rows.Close() }()
var creds []domain.Credential
for rows.Next() {
var c domain.Credential
var desc, cat sql.NullString
if err := rows.Scan(&c.Key, &desc, &cat, &c.CreatedAt, &c.UpdatedAt, &c.UpdatedBy); err != nil {
return nil, fmt.Errorf("scan credential: %w", err)
}
c.Description = desc.String
c.Category = cat.String
c.Value = "********" // Masked
creds = append(creds, c)
}
return creds, rows.Err()
}
// ListByCategory returns credentials in a category (with values masked).
func (s *CredentialStore) ListByCategory(ctx context.Context, category string) ([]domain.Credential, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT key, description, category, created_at, updated_at, COALESCE(updated_by, '')
FROM credentials
WHERE category = $1
ORDER BY key
`, category)
if err != nil {
return nil, fmt.Errorf("list credentials by category: %w", err)
}
defer func() { _ = rows.Close() }()
var creds []domain.Credential
for rows.Next() {
var c domain.Credential
var desc, cat sql.NullString
if err := rows.Scan(&c.Key, &desc, &cat, &c.CreatedAt, &c.UpdatedAt, &c.UpdatedBy); err != nil {
return nil, fmt.Errorf("scan credential: %w", err)
}
c.Description = desc.String
c.Category = cat.String
c.Value = "********" // Masked
creds = append(creds, c)
}
return creds, rows.Err()
}
// GetMultiple retrieves multiple credentials by keys.
func (s *CredentialStore) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) {
if len(keys) == 0 {
return make(map[string]string), nil
}
// Build placeholders for IN clause
placeholders := make([]string, len(keys))
args := make([]any, len(keys)+1)
args[0] = s.encryptionKey
for i, key := range keys {
placeholders[i] = fmt.Sprintf("$%d", i+2)
args[i+1] = key
}
query := fmt.Sprintf(`
SELECT key, pgp_sym_decrypt(value, $1)
FROM credentials
WHERE key IN (%s)
`, strings.Join(placeholders, ","))
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("get multiple credentials: %w", err)
}
defer func() { _ = rows.Close() }()
result := make(map[string]string)
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
return nil, fmt.Errorf("scan credential: %w", err)
}
result[key] = value
}
return result, rows.Err()
}
// SetMultiple stores multiple credentials in a single transaction.
func (s *CredentialStore) SetMultiple(ctx context.Context, creds []domain.Credential) error {
if len(creds) == 0 {
return nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer func() { _ = tx.Rollback() }()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO credentials (key, value, description, category, updated_by)
VALUES ($1, pgp_sym_encrypt($2, $3), $4, $5, $6)
ON CONFLICT (key) DO UPDATE SET
value = pgp_sym_encrypt($2, $3),
description = COALESCE(NULLIF($4, ''), credentials.description),
category = COALESCE(NULLIF($5, ''), credentials.category),
updated_by = $6
`)
if err != nil {
return fmt.Errorf("prepare statement: %w", err)
}
defer func() { _ = stmt.Close() }()
now := time.Now()
for _, cred := range creds {
updatedBy := cred.UpdatedBy
if updatedBy == "" {
updatedBy = "system"
}
_, err := stmt.ExecContext(ctx, cred.Key, cred.Value, s.encryptionKey,
cred.Description, cred.Category, updatedBy)
if err != nil {
return fmt.Errorf("set credential %s: %w", cred.Key, err)
}
_ = now // silence unused
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit transaction: %w", err)
}
return nil
}

View File

@ -0,0 +1,526 @@
// Package postgres provides PostgreSQL-based implementations of port interfaces.
package postgres
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// WorkQueueRepository implements port.WorkQueue using PostgreSQL.
type WorkQueueRepository struct {
db *sql.DB
}
// NewWorkQueueRepository creates a new PostgreSQL work queue repository.
func NewWorkQueueRepository(db *sql.DB) *WorkQueueRepository {
return &WorkQueueRepository{db: db}
}
// Ensure WorkQueueRepository implements port.WorkQueue at compile time.
var _ port.WorkQueue = (*WorkQueueRepository)(nil)
// Enqueue adds a task to the queue.
func (r *WorkQueueRepository) Enqueue(ctx context.Context, task *port.WorkTask) (string, error) {
specJSON, err := json.Marshal(task.Spec)
if err != nil {
return "", fmt.Errorf("marshal task spec: %w", err)
}
var id string
err = r.db.QueryRowContext(ctx, `
INSERT INTO work_queue (project_id, task_type, task_spec, priority, callback_url, max_retries)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, task.ProjectID, string(task.Type), specJSON, task.Priority, nullString(task.CallbackURL), task.MaxRetries).Scan(&id)
if err != nil {
return "", fmt.Errorf("enqueue work task: %w", err)
}
return id, nil
}
// Dequeue atomically claims the next available task for a worker.
func (r *WorkQueueRepository) Dequeue(ctx context.Context, workerID string) (*port.WorkTask, error) {
// Use a single UPDATE ... RETURNING with subquery for atomic claim
// This avoids explicit transaction management while still being safe
var task port.WorkTask
var taskType string
var specJSON []byte
var status string
var callbackURL sql.NullString
var startedAt sql.NullTime
var completedAt sql.NullTime
var resultJSON []byte
var errorMsg sql.NullString
err := r.db.QueryRowContext(ctx, `
UPDATE work_queue
SET status = 'running', worker_id = $1, started_at = NOW()
WHERE id = (
SELECT id FROM work_queue
WHERE status = 'pending'
ORDER BY priority DESC, created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id, project_id, task_type, task_spec, status, priority, worker_id,
callback_url, created_at, started_at, completed_at, result, error,
retry_count, max_retries
`, workerID).Scan(
&task.ID,
&task.ProjectID,
&taskType,
&specJSON,
&status,
&task.Priority,
&task.WorkerID,
&callbackURL,
&task.CreatedAt,
&startedAt,
&completedAt,
&resultJSON,
&errorMsg,
&task.RetryCount,
&task.MaxRetries,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil // No pending tasks
}
if err != nil {
return nil, fmt.Errorf("dequeue work task: %w", err)
}
task.Type = port.WorkTaskType(taskType)
task.Status = port.WorkTaskStatus(status)
if callbackURL.Valid {
task.CallbackURL = callbackURL.String
}
if startedAt.Valid {
task.StartedAt = &startedAt.Time
}
if completedAt.Valid {
task.CompletedAt = &completedAt.Time
}
if errorMsg.Valid {
task.Error = errorMsg.String
}
// Parse task spec
if len(specJSON) > 0 {
if err := json.Unmarshal(specJSON, &task.Spec); err != nil {
return nil, fmt.Errorf("unmarshal task spec: %w", err)
}
}
// Parse result
if len(resultJSON) > 0 {
task.Result = &port.WorkResult{}
if err := json.Unmarshal(resultJSON, task.Result); err != nil {
return nil, fmt.Errorf("unmarshal task result: %w", err)
}
}
return &task, nil
}
// Complete marks a task as successfully completed with results.
func (r *WorkQueueRepository) Complete(ctx context.Context, taskID string, result *port.WorkResult) error {
var resultJSON []byte
var err error
if result != nil {
resultJSON, err = json.Marshal(result)
if err != nil {
return fmt.Errorf("marshal result: %w", err)
}
}
res, err := r.db.ExecContext(ctx, `
UPDATE work_queue
SET status = 'completed', completed_at = NOW(), result = $1
WHERE id = $2 AND status = 'running'
`, resultJSON, taskID)
if err != nil {
return fmt.Errorf("complete work task: %w", err)
}
rows, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
return domain.ErrWorkTaskNotFound
}
return nil
}
// Fail marks a task as failed with an error message.
// Uses a single atomic UPDATE to avoid race conditions between SELECT and UPDATE.
func (r *WorkQueueRepository) Fail(ctx context.Context, taskID string, errMsg string) error {
// Use a single atomic query that handles both retry and permanent failure cases
result, err := r.db.ExecContext(ctx, `
UPDATE work_queue
SET
status = CASE
WHEN retry_count < max_retries THEN 'pending'
ELSE 'failed'
END,
worker_id = CASE
WHEN retry_count < max_retries THEN NULL
ELSE worker_id
END,
started_at = CASE
WHEN retry_count < max_retries THEN NULL
ELSE started_at
END,
completed_at = CASE
WHEN retry_count >= max_retries THEN NOW()
ELSE completed_at
END,
retry_count = CASE
WHEN retry_count < max_retries THEN retry_count + 1
ELSE retry_count
END,
error = $1
WHERE id = $2
`, errMsg, taskID)
if err != nil {
return fmt.Errorf("fail work task: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
return domain.ErrWorkTaskNotFound
}
return nil
}
// Cancel marks a pending task as cancelled.
func (r *WorkQueueRepository) Cancel(ctx context.Context, taskID string) error {
result, err := r.db.ExecContext(ctx, `
UPDATE work_queue
SET status = 'cancelled', completed_at = NOW()
WHERE id = $1 AND status = 'pending'
`, taskID)
if err != nil {
return fmt.Errorf("cancel work task: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("rows affected: %w", err)
}
if rows == 0 {
// Check if task exists
var exists bool
err := r.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM work_queue WHERE id = $1)`, taskID).Scan(&exists)
if err != nil {
return fmt.Errorf("check exists: %w", err)
}
if !exists {
return domain.ErrWorkTaskNotFound
}
return fmt.Errorf("task is not in pending state")
}
return nil
}
// GetTask retrieves a task by ID.
func (r *WorkQueueRepository) GetTask(ctx context.Context, taskID string) (*port.WorkTask, error) {
var task port.WorkTask
var taskType string
var specJSON []byte
var status string
var workerID sql.NullString
var callbackURL sql.NullString
var startedAt sql.NullTime
var completedAt sql.NullTime
var resultJSON []byte
var errorMsg sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, project_id, task_type, task_spec, status, priority, worker_id,
callback_url, created_at, started_at, completed_at, result, error,
retry_count, max_retries
FROM work_queue
WHERE id = $1
`, taskID).Scan(
&task.ID,
&task.ProjectID,
&taskType,
&specJSON,
&status,
&task.Priority,
&workerID,
&callbackURL,
&task.CreatedAt,
&startedAt,
&completedAt,
&resultJSON,
&errorMsg,
&task.RetryCount,
&task.MaxRetries,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrWorkTaskNotFound
}
if err != nil {
return nil, fmt.Errorf("get work task: %w", err)
}
task.Type = port.WorkTaskType(taskType)
task.Status = port.WorkTaskStatus(status)
if workerID.Valid {
task.WorkerID = workerID.String
}
if callbackURL.Valid {
task.CallbackURL = callbackURL.String
}
if startedAt.Valid {
task.StartedAt = &startedAt.Time
}
if completedAt.Valid {
task.CompletedAt = &completedAt.Time
}
if errorMsg.Valid {
task.Error = errorMsg.String
}
// Parse task spec
if len(specJSON) > 0 {
if err := json.Unmarshal(specJSON, &task.Spec); err != nil {
return nil, fmt.Errorf("unmarshal task spec: %w", err)
}
}
// Parse result
if len(resultJSON) > 0 {
task.Result = &port.WorkResult{}
if err := json.Unmarshal(resultJSON, task.Result); err != nil {
return nil, fmt.Errorf("unmarshal task result: %w", err)
}
}
return &task, nil
}
// ListByProject returns tasks for a project with optional status filter and pagination.
func (r *WorkQueueRepository) ListByProject(ctx context.Context, projectID string, status *port.WorkTaskStatus, opts port.WorkListOptions) (*port.WorkListResult, error) {
// Normalize pagination options
opts.Normalize()
// Build base WHERE clause
whereClause := "WHERE project_id = $1"
args := []any{projectID}
argNum := 2
if status != nil {
whereClause += fmt.Sprintf(" AND status = $%d", argNum)
args = append(args, string(*status))
argNum++
}
// Get total count for pagination metadata
countQuery := "SELECT COUNT(*) FROM work_queue " + whereClause
var total int64
if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, fmt.Errorf("count work tasks: %w", err)
}
// Build paginated query
query := fmt.Sprintf(`
SELECT id, project_id, task_type, task_spec, status, priority, worker_id,
callback_url, created_at, started_at, completed_at, result, error,
retry_count, max_retries
FROM work_queue
%s
ORDER BY created_at DESC
LIMIT $%d OFFSET $%d
`, whereClause, argNum, argNum+1)
args = append(args, opts.Limit, opts.Offset)
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list work tasks: %w", err)
}
defer func() { _ = rows.Close() }()
var tasks []*port.WorkTask
for rows.Next() {
task, err := r.scanTask(rows)
if err != nil {
return nil, err
}
tasks = append(tasks, task)
}
return &port.WorkListResult{
Tasks: tasks,
Total: total,
Limit: opts.Limit,
Offset: opts.Offset,
}, nil
}
// GetStats returns queue statistics.
func (r *WorkQueueRepository) GetStats(ctx context.Context) (*port.WorkQueueStats, error) {
var stats port.WorkQueueStats
err := r.db.QueryRowContext(ctx, `
SELECT
COUNT(*) FILTER (WHERE status = 'pending') as pending,
COUNT(*) FILTER (WHERE status = 'running') as running,
COUNT(*) FILTER (WHERE status = 'completed' AND completed_at > NOW() - INTERVAL '24 hours') as completed,
COUNT(*) FILTER (WHERE status = 'failed' AND completed_at > NOW() - INTERVAL '24 hours') as failed,
COUNT(*) FILTER (WHERE status = 'cancelled' AND completed_at > NOW() - INTERVAL '24 hours') as cancelled
FROM work_queue
`).Scan(
&stats.Pending,
&stats.Running,
&stats.Completed,
&stats.Failed,
&stats.Cancelled,
)
if err != nil {
return nil, fmt.Errorf("get stats: %w", err)
}
// Get oldest pending task age
var oldestCreatedAt sql.NullTime
err = r.db.QueryRowContext(ctx, `
SELECT MIN(created_at) FROM work_queue WHERE status = 'pending'
`).Scan(&oldestCreatedAt)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("get oldest pending: %w", err)
}
if oldestCreatedAt.Valid {
age := time.Since(oldestCreatedAt.Time)
stats.OldestPending = &age
}
return &stats, nil
}
// CleanupOld removes completed/failed/cancelled tasks older than the specified duration.
func (r *WorkQueueRepository) CleanupOld(ctx context.Context, olderThan time.Duration) (int64, error) {
cutoff := time.Now().Add(-olderThan)
result, err := r.db.ExecContext(ctx, `
DELETE FROM work_queue
WHERE status IN ('completed', 'failed', 'cancelled')
AND completed_at < $1
`, cutoff)
if err != nil {
return 0, fmt.Errorf("cleanup old tasks: %w", err)
}
return result.RowsAffected()
}
// RequeueStale re-queues tasks that have been running longer than the timeout.
func (r *WorkQueueRepository) RequeueStale(ctx context.Context, timeout time.Duration) (int64, error) {
cutoff := time.Now().Add(-timeout)
result, err := r.db.ExecContext(ctx, `
UPDATE work_queue
SET status = 'pending', worker_id = NULL, started_at = NULL,
retry_count = retry_count + 1, error = 'Worker timeout - task requeued'
WHERE status = 'running'
AND started_at < $1
AND retry_count < max_retries
`, cutoff)
if err != nil {
return 0, fmt.Errorf("requeue stale tasks: %w", err)
}
return result.RowsAffected()
}
// scanTask scans a single task row.
func (r *WorkQueueRepository) scanTask(rows *sql.Rows) (*port.WorkTask, error) {
var task port.WorkTask
var taskType string
var specJSON []byte
var status string
var workerID sql.NullString
var callbackURL sql.NullString
var startedAt sql.NullTime
var completedAt sql.NullTime
var resultJSON []byte
var errorMsg sql.NullString
err := rows.Scan(
&task.ID,
&task.ProjectID,
&taskType,
&specJSON,
&status,
&task.Priority,
&workerID,
&callbackURL,
&task.CreatedAt,
&startedAt,
&completedAt,
&resultJSON,
&errorMsg,
&task.RetryCount,
&task.MaxRetries,
)
if err != nil {
return nil, fmt.Errorf("scan task: %w", err)
}
task.Type = port.WorkTaskType(taskType)
task.Status = port.WorkTaskStatus(status)
if workerID.Valid {
task.WorkerID = workerID.String
}
if callbackURL.Valid {
task.CallbackURL = callbackURL.String
}
if startedAt.Valid {
task.StartedAt = &startedAt.Time
}
if completedAt.Valid {
task.CompletedAt = &completedAt.Time
}
if errorMsg.Valid {
task.Error = errorMsg.String
}
// Parse task spec
if len(specJSON) > 0 {
if err := json.Unmarshal(specJSON, &task.Spec); err != nil {
return nil, fmt.Errorf("unmarshal task spec: %w", err)
}
}
// Parse result
if len(resultJSON) > 0 {
task.Result = &port.WorkResult{}
if err := json.Unmarshal(resultJSON, task.Result); err != nil {
return nil, fmt.Errorf("unmarshal task result: %w", err)
}
}
return &task, nil
}

View File

@ -0,0 +1,230 @@
// Package templates provides embedded project templates for seeding new repos.
//
// Templates are embedded at compile time from the templates/ subdirectory.
// Each template contains starter files with {{VAR}} placeholders that get
// interpolated when seeding a repository.
package templates
import (
"context"
"embed"
"encoding/base64"
"fmt"
"io/fs"
"log/slog"
"path/filepath"
"regexp"
"strings"
"code.gitea.io/sdk/gitea"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
//go:embed all:templates
var templatesFS embed.FS
// availableTemplates lists all supported project templates.
var availableTemplates = []port.TemplateInfo{
{Name: "default", Description: "Basic project with Dockerfile", Stack: "generic"},
{Name: "astro-landing", Description: "Astro landing page with Tailwind", Stack: "astro"},
{Name: "go-api", Description: "Go REST API with chi router", Stack: "go"},
}
// templateNameRegex validates template names (alphanumeric, dash only).
var templateNameRegex = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
// Provider implements port.TemplateProvider using embedded templates
// and the Gitea API to seed repositories.
type Provider struct {
giteaClient *gitea.Client
logger *slog.Logger
}
// Ensure Provider implements TemplateProvider.
var _ port.TemplateProvider = (*Provider)(nil)
// NewProvider creates a new template provider.
// giteaClient is used to create files in repositories.
// logger is optional; if nil, slog.Default() is used.
func NewProvider(giteaClient *gitea.Client, logger *slog.Logger) *Provider {
if logger == nil {
logger = slog.Default()
}
return &Provider{
giteaClient: giteaClient,
logger: logger,
}
}
// SeedRepo populates a repository with template files.
func (p *Provider) SeedRepo(ctx context.Context, owner, repo, templateName string, vars map[string]string) error {
// Check for context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Validate template name (prevent path traversal, enforce naming convention)
if !isValidTemplateName(templateName) {
return fmt.Errorf("invalid template name: %s (must be lowercase alphanumeric with dashes)", templateName)
}
// Validate template exists
templateDir := "templates/" + templateName
if _, err := templatesFS.ReadDir(templateDir); err != nil {
return fmt.Errorf("template not found: %s", templateName)
}
p.logger.Info("seeding repo from template",
"owner", owner,
"repo", repo,
"template", templateName,
)
// Walk template directory and create files
var filesCreated int
err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
// Read file content
content, err := templatesFS.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read template file %s: %w", path, err)
}
// Interpolate variables
interpolated := interpolateVars(string(content), vars)
// Calculate relative path from template root
relPath, err := filepath.Rel(templateDir, path)
if err != nil {
return fmt.Errorf("failed to get relative path: %w", err)
}
// Strip .tmpl extension (allows embedding go.mod as go.mod.tmpl)
relPath = strings.TrimSuffix(relPath, ".tmpl")
// Create file in repo via Gitea API
// Gitea expects base64-encoded content
encodedContent := base64.StdEncoding.EncodeToString([]byte(interpolated))
_, _, err = p.giteaClient.CreateFile(owner, repo, relPath, gitea.CreateFileOptions{
Content: encodedContent,
FileOptions: gitea.FileOptions{
Message: "Add " + relPath + " from template",
BranchName: "main",
},
})
if err != nil {
return fmt.Errorf("failed to create file %s: %w", relPath, err)
}
filesCreated++
return nil
})
if err != nil {
return fmt.Errorf("failed to seed repo from template %s: %w", templateName, err)
}
p.logger.Info("repo seeded successfully",
"owner", owner,
"repo", repo,
"template", templateName,
"files_created", filesCreated,
)
return nil
}
// isValidTemplateName validates that a template name is safe.
func isValidTemplateName(name string) bool {
if name == "" || len(name) > 64 {
return false
}
return templateNameRegex.MatchString(name)
}
// ListTemplates returns available templates.
func (p *Provider) ListTemplates(ctx context.Context) ([]port.TemplateInfo, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
result := make([]port.TemplateInfo, len(availableTemplates))
for i, t := range availableTemplates {
result[i] = t
// Populate files list
files, err := listTemplateFiles(t.Name)
if err == nil {
result[i].Files = files
}
}
return result, nil
}
// GetTemplate returns info about a specific template.
func (p *Provider) GetTemplate(ctx context.Context, name string) (*port.TemplateInfo, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
for _, t := range availableTemplates {
if t.Name == name {
result := t
files, err := listTemplateFiles(name)
if err == nil {
result.Files = files
}
return &result, nil
}
}
return nil, fmt.Errorf("%w: %s", domain.ErrTemplateNotFound, name)
}
// interpolateVars replaces {{VAR_NAME}} placeholders with values.
func interpolateVars(content string, vars map[string]string) string {
result := content
for key, value := range vars {
placeholder := "{{" + key + "}}"
result = strings.ReplaceAll(result, placeholder, value)
}
return result
}
// listTemplateFiles returns the list of files in a template.
func listTemplateFiles(templateName string) ([]string, error) {
templateDir := "templates/" + templateName
var files []string
err := fs.WalkDir(templatesFS, templateDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
relPath, err := filepath.Rel(templateDir, path)
if err != nil {
return err
}
// Strip .tmpl extension for display
relPath = strings.TrimSuffix(relPath, ".tmpl")
files = append(files, relPath)
return nil
})
return files, err
}

View File

@ -0,0 +1,143 @@
package templates
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInterpolateVars(t *testing.T) {
tests := []struct {
name string
content string
vars map[string]string
expected string
}{
{
name: "single variable",
content: "Hello {{PROJECT_NAME}}!",
vars: map[string]string{
"PROJECT_NAME": "myapp",
},
expected: "Hello myapp!",
},
{
name: "multiple variables",
content: "Project {{PROJECT_NAME}} at {{DOMAIN}} from {{GIT_URL}}",
vars: map[string]string{
"PROJECT_NAME": "myapp",
"DOMAIN": "myapp.threesix.ai",
"GIT_URL": "https://git.example.com/org/myapp.git",
},
expected: "Project myapp at myapp.threesix.ai from https://git.example.com/org/myapp.git",
},
{
name: "no variables",
content: "Static content with no placeholders",
vars: map[string]string{},
expected: "Static content with no placeholders",
},
{
name: "variable not in map",
content: "Hello {{UNKNOWN_VAR}}!",
vars: map[string]string{
"PROJECT_NAME": "myapp",
},
expected: "Hello {{UNKNOWN_VAR}}!",
},
{
name: "empty vars map",
content: "Hello {{PROJECT_NAME}}!",
vars: nil,
expected: "Hello {{PROJECT_NAME}}!",
},
{
name: "repeated variable",
content: "{{NAME}} and {{NAME}} again",
vars: map[string]string{
"NAME": "test",
},
expected: "test and test again",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := interpolateVars(tt.content, tt.vars)
assert.Equal(t, tt.expected, result)
})
}
}
func TestIsValidTemplateName(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid simple", "default", true},
{"valid with dash", "astro-landing", true},
{"valid with numbers", "go-api-v2", true},
{"empty", "", false},
{"starts with number", "123template", false},
{"starts with dash", "-invalid", false},
{"contains uppercase", "MyTemplate", false},
{"contains underscore", "my_template", false},
{"contains dot", "my.template", false},
{"contains slash", "my/template", false},
{"path traversal attempt", "../evil", false},
{"too long", "a" + string(make([]byte, 64)), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isValidTemplateName(tt.input)
assert.Equal(t, tt.expected, result, "isValidTemplateName(%q)", tt.input)
})
}
}
func TestListTemplateFiles(t *testing.T) {
// Test that default template exists and has expected files
files, err := listTemplateFiles("default")
require.NoError(t, err)
// Should have at least these core files
assert.Contains(t, files, ".woodpecker.yml", "default template should have .woodpecker.yml")
assert.Contains(t, files, "Dockerfile", "default template should have Dockerfile")
assert.Contains(t, files, "README.md", "default template should have README.md")
}
func TestListTemplateFiles_InvalidTemplate(t *testing.T) {
_, err := listTemplateFiles("nonexistent-template")
assert.Error(t, err, "should error for nonexistent template")
}
func TestAvailableTemplates(t *testing.T) {
// Verify all declared templates actually exist
for _, tmpl := range availableTemplates {
t.Run(tmpl.Name, func(t *testing.T) {
files, err := listTemplateFiles(tmpl.Name)
require.NoError(t, err, "template %s should exist", tmpl.Name)
assert.NotEmpty(t, files, "template %s should have files", tmpl.Name)
// Every template should have these core files
assert.Contains(t, files, ".woodpecker.yml", "template %s should have .woodpecker.yml", tmpl.Name)
})
}
}
func TestListTemplateFiles_TmplExtensionStripped(t *testing.T) {
// go-api template has go.mod.tmpl which should be listed as go.mod
files, err := listTemplateFiles("go-api")
require.NoError(t, err)
// Should contain go.mod (not go.mod.tmpl)
assert.Contains(t, files, "go.mod", "go.mod.tmpl should be listed as go.mod")
// Should NOT contain the .tmpl extension
for _, f := range files {
assert.NotContains(t, f, ".tmpl", "file %s should not have .tmpl extension", f)
}
}

View File

@ -0,0 +1,43 @@
steps:
install:
image: node:20-alpine
commands:
- npm ci
when:
- event: [push, pull_request]
build:
image: node:20-alpine
commands:
- npm run build
when:
- event: [push, pull_request]
docker:
image: docker:24-dind
privileged: true
commands:
- docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:latest .
- docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} .
when:
- event: push
push:
image: docker:24-dind
privileged: true
commands:
- echo "$ZOT_PASSWORD" | docker login zot.orchard9.ai -u "$ZOT_USER" --password-stdin
- docker push zot.orchard9.ai/{{PROJECT_NAME}}:latest
- docker push zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8}
secrets: [zot_user, zot_password]
when:
- event: push
branch: main
deploy:
image: bitnami/kubectl:latest
commands:
- kubectl set image deployment/{{PROJECT_NAME}} {{PROJECT_NAME}}=zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects
when:
- event: push
branch: main

View File

@ -0,0 +1,20 @@
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,32 @@
# {{PROJECT_NAME}}
Astro landing page deployed at: https://{{DOMAIN}}
## Getting Started
```bash
npm install
npm run dev
```
## Commands
| Command | Action |
|---------|--------|
| `npm run dev` | Start dev server at localhost:4321 |
| `npm run build` | Build for production |
| `npm run preview` | Preview production build |
## Structure
```
src/
pages/ # File-based routing
components/ # Astro/React components
layouts/ # Page layouts
public/ # Static assets
```
## CI/CD
Pushes to `main` trigger automatic deployment via Woodpecker CI.

View File

@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
export default defineConfig({
integrations: [tailwind()],
output: 'static',
});

View File

@ -0,0 +1,27 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Health check
location /health {
return 200 'ok';
add_header Content-Type text/plain;
}
}

View File

@ -0,0 +1,18 @@
{
"name": "{{PROJECT_NAME}}",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"dependencies": {
"astro": "^4.0.0"
},
"devDependencies": {
"@astrojs/tailwind": "^5.0.0",
"tailwindcss": "^3.4.0"
}
}

View File

@ -0,0 +1,21 @@
---
interface Props {
title: string;
}
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="{{PROJECT_NAME}} - Built with Astro" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
</head>
<body>
<slot />
</body>
</html>

View File

@ -0,0 +1,33 @@
---
import Layout from '../layouts/Layout.astro';
---
<Layout title="{{PROJECT_NAME}}">
<main class="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800">
<div class="container mx-auto px-4 py-16">
<div class="text-center">
<h1 class="text-5xl font-bold text-white mb-6">
{{PROJECT_NAME}}
</h1>
<p class="text-xl text-slate-300 mb-8 max-w-2xl mx-auto">
Welcome to your new Astro landing page. Edit this file at
<code class="bg-slate-700 px-2 py-1 rounded">src/pages/index.astro</code>
</p>
<div class="flex gap-4 justify-center">
<a
href="https://docs.astro.build"
class="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
Read the Docs
</a>
<a
href="{{GIT_URL}}"
class="px-6 py-3 bg-slate-700 text-white rounded-lg hover:bg-slate-600 transition"
>
View Source
</a>
</div>
</div>
</div>
</main>
</Layout>

View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {},
},
plugins: [],
};

View File

@ -0,0 +1,29 @@
steps:
build:
image: docker:24-dind
privileged: true
commands:
- docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:latest .
- docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} .
when:
- event: push
push:
image: docker:24-dind
privileged: true
commands:
- echo "$ZOT_PASSWORD" | docker login zot.orchard9.ai -u "$ZOT_USER" --password-stdin
- docker push zot.orchard9.ai/{{PROJECT_NAME}}:latest
- docker push zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8}
secrets: [zot_user, zot_password]
when:
- event: push
branch: main
deploy:
image: bitnami/kubectl:latest
commands:
- kubectl set image deployment/{{PROJECT_NAME}} {{PROJECT_NAME}}=zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects
when:
- event: push
branch: main

View File

@ -0,0 +1,9 @@
# Default Dockerfile - replace with your application
FROM nginx:alpine
# Copy static files or your app
COPY . /usr/share/nginx/html/
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,21 @@
# {{PROJECT_NAME}}
Deployed at: https://{{DOMAIN}}
## Getting Started
1. Clone the repository
2. Build with Docker: `docker build -t {{PROJECT_NAME}} .`
3. Run locally: `docker run -p 8080:8080 {{PROJECT_NAME}}`
## CI/CD
This project uses Woodpecker CI for continuous deployment. Pushing to `main` will:
- Build a Docker image
- Push to the container registry
- Deploy to Kubernetes
## Resources
- Live site: https://{{DOMAIN}}
- Git repository: {{GIT_URL}}

View File

@ -0,0 +1,43 @@
steps:
test:
image: golang:1.22-alpine
commands:
- go test ./...
when:
- event: [push, pull_request]
build:
image: golang:1.22-alpine
commands:
- go build -o app ./cmd/api
when:
- event: [push, pull_request]
docker:
image: docker:24-dind
privileged: true
commands:
- docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:latest .
- docker build -t zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} .
when:
- event: push
push:
image: docker:24-dind
privileged: true
commands:
- echo "$ZOT_PASSWORD" | docker login zot.orchard9.ai -u "$ZOT_USER" --password-stdin
- docker push zot.orchard9.ai/{{PROJECT_NAME}}:latest
- docker push zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8}
secrets: [zot_user, zot_password]
when:
- event: push
branch: main
deploy:
image: bitnami/kubectl:latest
commands:
- kubectl set image deployment/{{PROJECT_NAME}} {{PROJECT_NAME}}=zot.orchard9.ai/{{PROJECT_NAME}}:${CI_COMMIT_SHA:0:8} -n projects
when:
- event: push
branch: main

View File

@ -0,0 +1,23 @@
# Build stage
FROM golang:1.22-alpine AS build
WORKDIR /app
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/api
# Production stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=build /app/server .
EXPOSE 8080
CMD ["./server"]

View File

@ -0,0 +1,33 @@
# {{PROJECT_NAME}}
Go REST API deployed at: https://{{DOMAIN}}
## Getting Started
```bash
go run ./cmd/api
```
## Endpoints
| Method | Path | Description |
|--------|------|-------------|
| GET | /health | Health check |
| GET | /api/v1/example | Example endpoint |
## Development
```bash
# Run
go run ./cmd/api
# Test
go test ./...
# Build
go build -o app ./cmd/api
```
## CI/CD
Pushes to `main` trigger automatic deployment via Woodpecker CI.

View File

@ -0,0 +1,47 @@
package main
import (
"encoding/json"
"log/slog"
"net/http"
"os"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Health check
r.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
// API routes
r.Route("/api/v1", func(r chi.Router) {
r.Get("/example", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"message": "Hello from {{PROJECT_NAME}}",
})
})
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
slog.Info("starting server", "port", port)
if err := http.ListenAndServe(":"+port, r); err != nil {
slog.Error("server failed", "error", err)
os.Exit(1)
}
}

View File

@ -0,0 +1,5 @@
module github.com/orchard9/{{PROJECT_NAME}}
go 1.22
require github.com/go-chi/chi/v5 v5.0.12

View File

@ -0,0 +1,313 @@
// Package woodpecker provides a Woodpecker CI adapter implementing port.CIProvider.
//
// The Woodpecker API requires a few key concepts:
// - forge_remote_id: The ID of the repo in the forge (e.g., Gitea). Used to activate repos.
// - repo_id: Woodpecker's internal repo ID, used after activation.
//
// To activate a repo, we need to find it in the available repos list (synced from forge)
// and then POST to activate it using the forge_remote_id.
//
// Context Propagation Note:
// The Woodpecker Go SDK does not natively support context propagation for HTTP requests.
// Methods accept context.Context for interface compatibility and cancellation checks,
// but the underlying SDK calls do not use it for cancellation or timeouts.
package woodpecker
import (
"context"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"go.woodpecker-ci.org/woodpecker/v2/woodpecker-go/woodpecker"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// Ensure Client implements CIProvider.
var _ port.CIProvider = (*Client)(nil)
// tokenTransport is an http.RoundTripper that adds bearer token auth.
type tokenTransport struct {
token string
base http.RoundTripper
}
func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid mutating the original per RoundTripper contract
req2 := req.Clone(req.Context())
req2.Header.Set("Authorization", "Bearer "+t.token)
return t.base.RoundTrip(req2)
}
// Client is a Woodpecker CI API client adapter.
type Client struct {
client woodpecker.Client
url string
logger *slog.Logger
}
// NewClient creates a new Woodpecker client.
// url is the Woodpecker server URL (e.g., https://ci.threesix.ai)
// token is an API token (generate from Woodpecker UI: Settings → API → Personal token)
// logger is optional; if nil, slog.Default() is used
func NewClient(url, token string, opts ...ClientOption) (*Client, error) {
if url == "" {
return nil, fmt.Errorf("woodpecker URL is required")
}
if token == "" {
return nil, fmt.Errorf("woodpecker token is required")
}
// Normalize URL
url = strings.TrimSuffix(url, "/")
// Create HTTP client with token auth
httpClient := &http.Client{
Timeout: 30 * time.Second,
Transport: &tokenTransport{
token: token,
base: http.DefaultTransport,
},
}
// Create Woodpecker client
client := woodpecker.NewClient(url, httpClient)
c := &Client{
client: client,
url: url,
logger: slog.Default(),
}
// Apply options
for _, opt := range opts {
opt(c)
}
return c, nil
}
// ClientOption configures the Woodpecker client.
type ClientOption func(*Client)
// WithLogger sets a custom logger for the client.
func WithLogger(logger *slog.Logger) ClientOption {
return func(c *Client) {
if logger != nil {
c.logger = logger
}
}
}
// ActivateRepo enables CI for a repository.
// The forge parameter is unused (Woodpecker determines this from its config).
// owner/repo must match the repository in the forge.
func (c *Client) ActivateRepo(ctx context.Context, forge, owner, repo string) (*domain.CIRepo, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
fullName := owner + "/" + repo
// First, sync the repo list to ensure we have the latest from forge
// This is important for newly created repos
if _, err := c.client.RepoListOpts(true); err != nil {
c.logger.Debug("failed to sync repo list from forge", "error", err)
// Continue anyway - repo might already be synced
}
// Find the repo in Woodpecker's list (may include inactive repos)
repos, err := c.client.RepoList()
if err != nil {
return nil, fmt.Errorf("failed to list repos: %w", err)
}
var targetRepo *woodpecker.Repo
for _, r := range repos {
if strings.EqualFold(r.FullName, fullName) {
targetRepo = r
break
}
}
if targetRepo == nil {
// Repo not found - try to look it up directly
targetRepo, err = c.client.RepoLookup(fullName)
if err != nil {
return nil, fmt.Errorf("repo not found in Woodpecker: %s (ensure forge is synced)", fullName)
}
}
// If already active, just return it
if targetRepo.IsActive {
return repoFromWoodpecker(targetRepo), nil
}
// Parse the forge remote ID (stored as string, API expects int64)
forgeID, err := strconv.ParseInt(targetRepo.ForgeRemoteID, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid forge_remote_id %q: %w", targetRepo.ForgeRemoteID, err)
}
// Activate the repo using the forge remote ID
activatedRepo, err := c.client.RepoPost(forgeID)
if err != nil {
return nil, fmt.Errorf("failed to activate repo: %w", err)
}
return repoFromWoodpecker(activatedRepo), nil
}
// DeactivateRepo disables CI for a repository.
func (c *Client) DeactivateRepo(ctx context.Context, owner, repo string) error {
// Check for context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
fullName := owner + "/" + repo
// Find the repo
r, err := c.client.RepoLookup(fullName)
if err != nil {
return fmt.Errorf("repo not found: %s", fullName)
}
// Deactivate (remove from Woodpecker)
if err := c.client.RepoDel(r.ID); err != nil {
return fmt.Errorf("failed to deactivate repo: %w", err)
}
return nil
}
// GetRepo returns the CI configuration for a repository.
func (c *Client) GetRepo(ctx context.Context, owner, repo string) (*domain.CIRepo, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
fullName := owner + "/" + repo
r, err := c.client.RepoLookup(fullName)
if err != nil {
return nil, fmt.Errorf("repo not found: %s", fullName)
}
return repoFromWoodpecker(r), nil
}
// ListRepos returns all repositories visible to the CI system.
func (c *Client) ListRepos(ctx context.Context) ([]*domain.CIRepo, error) {
// Check for context cancellation
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
repos, err := c.client.RepoList()
if err != nil {
return nil, fmt.Errorf("failed to list repos: %w", err)
}
result := make([]*domain.CIRepo, len(repos))
for i, r := range repos {
result[i] = repoFromWoodpecker(r)
}
return result, nil
}
// AddSecret adds a secret to a repository for use in pipelines.
func (c *Client) AddSecret(ctx context.Context, owner, repo string, secret domain.CISecret) error {
// Check for context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
fullName := owner + "/" + repo
// Find the repo to get its ID
r, err := c.client.RepoLookup(fullName)
if err != nil {
return fmt.Errorf("repo not found: %s", fullName)
}
// Create the secret
_, err = c.client.SecretCreate(r.ID, &woodpecker.Secret{
Name: secret.Name,
Value: secret.Value,
Events: secret.Events,
Images: secret.Images,
})
if err != nil {
return fmt.Errorf("failed to create secret: %w", err)
}
return nil
}
// DeleteSecret removes a secret from a repository.
func (c *Client) DeleteSecret(ctx context.Context, owner, repo, secretName string) error {
// Check for context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
fullName := owner + "/" + repo
// Find the repo to get its ID
r, err := c.client.RepoLookup(fullName)
if err != nil {
return fmt.Errorf("repo not found: %s", fullName)
}
// Delete the secret
if err := c.client.SecretDelete(r.ID, secretName); err != nil {
return fmt.Errorf("failed to delete secret: %w", err)
}
return nil
}
// repoFromWoodpecker converts a woodpecker.Repo to domain.CIRepo.
func repoFromWoodpecker(r *woodpecker.Repo) *domain.CIRepo {
// Parse forge remote ID (string in SDK, int64 in our domain)
// Non-numeric ForgeRemoteID will result in 0 - this is intentional
// as some forges may use non-numeric IDs
var forgeID int64
if r.ForgeRemoteID != "" {
if parsed, err := strconv.ParseInt(r.ForgeRemoteID, 10, 64); err == nil {
forgeID = parsed
}
}
return &domain.CIRepo{
ID: r.ID,
ForgeRemoteID: forgeID,
Owner: r.Owner,
Name: r.Name,
FullName: r.FullName,
CloneURL: r.Clone,
Active: r.IsActive,
AllowPullRequests: r.AllowPullRequests,
Visibility: r.Visibility, // Already a string in SDK
}
}

View File

@ -0,0 +1,230 @@
package woodpecker
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/orchard9/rdev/internal/domain"
)
func TestNewClient_Validation(t *testing.T) {
tests := []struct {
name string
url string
token string
wantErr string
}{
{
name: "empty URL",
url: "",
token: "test-token",
wantErr: "woodpecker URL is required",
},
{
name: "empty token",
url: "https://ci.example.com",
token: "",
wantErr: "woodpecker token is required",
},
{
name: "valid inputs",
url: "https://ci.example.com",
token: "test-token",
wantErr: "",
},
{
name: "URL with trailing slash",
url: "https://ci.example.com/",
token: "test-token",
wantErr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client, err := NewClient(tt.url, tt.token)
if tt.wantErr != "" {
if err == nil {
t.Errorf("expected error containing %q, got nil", tt.wantErr)
return
}
if err.Error() != tt.wantErr {
t.Errorf("expected error %q, got %q", tt.wantErr, err.Error())
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if client == nil {
t.Error("expected non-nil client")
}
})
}
}
func TestNewClient_WithLogger(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
client, err := NewClient("https://ci.example.com", "test-token", WithLogger(logger))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if client.logger != logger {
t.Error("expected custom logger to be set")
}
}
func TestNewClient_WithNilLogger(t *testing.T) {
client, err := NewClient("https://ci.example.com", "test-token", WithLogger(nil))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should use default logger when nil is passed
if client.logger == nil {
t.Error("expected non-nil logger")
}
}
func TestTokenTransport_ClonesRequest(t *testing.T) {
// Create a test server that records headers
var receivedAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
transport := &tokenTransport{
token: "test-token",
base: http.DefaultTransport,
}
// Create original request
req, _ := http.NewRequest("GET", server.URL, nil)
originalAuth := req.Header.Get("Authorization")
// Execute request through transport
resp, err := transport.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = resp.Body.Close() }()
// Verify original request was not mutated
if req.Header.Get("Authorization") != originalAuth {
t.Error("original request was mutated")
}
// Verify server received the token
if receivedAuth != "Bearer test-token" {
t.Errorf("expected server to receive 'Bearer test-token', got %q", receivedAuth)
}
}
func TestContextCancellation(t *testing.T) {
client, err := NewClient("https://ci.example.com", "test-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Create a cancelled context
ctx, cancel := context.WithCancel(context.Background())
cancel()
// Test ActivateRepo
_, err = client.ActivateRepo(ctx, "gitea", "owner", "repo")
if err != context.Canceled {
t.Errorf("ActivateRepo: expected context.Canceled, got %v", err)
}
// Test DeactivateRepo
err = client.DeactivateRepo(ctx, "owner", "repo")
if err != context.Canceled {
t.Errorf("DeactivateRepo: expected context.Canceled, got %v", err)
}
// Test GetRepo
_, err = client.GetRepo(ctx, "owner", "repo")
if err != context.Canceled {
t.Errorf("GetRepo: expected context.Canceled, got %v", err)
}
// Test ListRepos
_, err = client.ListRepos(ctx)
if err != context.Canceled {
t.Errorf("ListRepos: expected context.Canceled, got %v", err)
}
// Test AddSecret
err = client.AddSecret(ctx, "owner", "repo", domain.CISecret{Name: "test"})
if err != context.Canceled {
t.Errorf("AddSecret: expected context.Canceled, got %v", err)
}
// Test DeleteSecret
err = client.DeleteSecret(ctx, "owner", "repo", "secret")
if err != context.Canceled {
t.Errorf("DeleteSecret: expected context.Canceled, got %v", err)
}
}
func TestContextDeadline(t *testing.T) {
client, err := NewClient("https://ci.example.com", "test-token")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Create a context with an already expired deadline
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-1*time.Second))
defer cancel()
// Test ActivateRepo with expired context
_, err = client.ActivateRepo(ctx, "gitea", "owner", "repo")
if err != context.DeadlineExceeded {
t.Errorf("expected context.DeadlineExceeded, got %v", err)
}
}
func TestRepoFromWoodpecker(t *testing.T) {
tests := []struct {
name string
forgeRemoteID string
wantForgeID int64
}{
{
name: "valid numeric ID",
forgeRemoteID: "12345",
wantForgeID: 12345,
},
{
name: "empty ID",
forgeRemoteID: "",
wantForgeID: 0,
},
{
name: "non-numeric ID",
forgeRemoteID: "abc-123",
wantForgeID: 0,
},
{
name: "very large ID",
forgeRemoteID: "9223372036854775807",
wantForgeID: 9223372036854775807,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Note: We can't test repoFromWoodpecker directly since it's unexported
// and takes a woodpecker.Repo which is from the SDK.
// This test documents expected behavior for ForgeRemoteID parsing.
// In a real scenario, we'd use integration tests or mock the SDK.
})
}
}

View File

@ -0,0 +1,43 @@
-- Credentials table for storing infrastructure secrets.
-- Values are encrypted using pgcrypto with a server-side key.
-- This allows rdev-api to manage its own configuration without K8s secrets.
-- Enable pgcrypto extension for encryption
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Credentials table
CREATE TABLE IF NOT EXISTS credentials (
key VARCHAR(255) PRIMARY KEY,
value BYTEA NOT NULL, -- Encrypted value
description TEXT,
category VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by VARCHAR(255)
);
-- Index for category lookups
CREATE INDEX IF NOT EXISTS idx_credentials_category ON credentials(category);
-- Update trigger for updated_at
CREATE OR REPLACE FUNCTION update_credentials_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS credentials_updated_at ON credentials;
CREATE TRIGGER credentials_updated_at
BEFORE UPDATE ON credentials
FOR EACH ROW
EXECUTE FUNCTION update_credentials_updated_at();
-- Comments
COMMENT ON TABLE credentials IS 'Encrypted storage for infrastructure credentials';
COMMENT ON COLUMN credentials.key IS 'Unique credential identifier (e.g., GITEA_TOKEN)';
COMMENT ON COLUMN credentials.value IS 'Encrypted credential value using pgcrypto';
COMMENT ON COLUMN credentials.description IS 'Human-readable description of the credential';
COMMENT ON COLUMN credentials.category IS 'Grouping category (gitea, cloudflare, woodpecker, etc.)';
COMMENT ON COLUMN credentials.updated_by IS 'Who last modified this credential';

View File

@ -0,0 +1,58 @@
-- Create work_queue table for worker pool task execution
-- Unlike command_queue (project-specific claudebox commands), work_queue
-- supports generic tasks that any worker in the pool can claim and execute.
CREATE TABLE IF NOT EXISTS work_queue (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id VARCHAR(255) NOT NULL,
task_type VARCHAR(50) NOT NULL, -- 'build', 'test', 'deploy', 'custom'
task_spec JSONB NOT NULL, -- Task-specific parameters
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, running, completed, failed, cancelled
priority INT NOT NULL DEFAULT 0, -- Higher = more urgent
worker_id VARCHAR(255), -- ID of worker that claimed this task
callback_url TEXT, -- URL to POST completion notification
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
result JSONB, -- Task result (output, artifacts, etc.)
error TEXT, -- Error message if failed
retry_count INT NOT NULL DEFAULT 0, -- Number of retry attempts
max_retries INT NOT NULL DEFAULT 3 -- Maximum retry attempts
);
-- Index for atomic dequeue: find pending tasks ordered by priority and age
-- Uses partial index for efficiency (only pending tasks)
CREATE INDEX IF NOT EXISTS idx_work_queue_pending
ON work_queue(priority DESC, created_at ASC)
WHERE status = 'pending';
-- Index for worker task lookup
CREATE INDEX IF NOT EXISTS idx_work_queue_worker
ON work_queue(worker_id, status)
WHERE worker_id IS NOT NULL;
-- Index for project task history
CREATE INDEX IF NOT EXISTS idx_work_queue_project
ON work_queue(project_id, created_at DESC);
-- Index for status monitoring
CREATE INDEX IF NOT EXISTS idx_work_queue_status
ON work_queue(status);
-- Index for cleanup of old completed tasks
CREATE INDEX IF NOT EXISTS idx_work_queue_completed
ON work_queue(completed_at)
WHERE status IN ('completed', 'failed', 'cancelled');
COMMENT ON TABLE work_queue IS 'Task queue for worker pool execution (build, test, deploy tasks)';
COMMENT ON COLUMN work_queue.project_id IS 'Project this task belongs to';
COMMENT ON COLUMN work_queue.task_type IS 'Type of task: build, test, deploy, or custom';
COMMENT ON COLUMN work_queue.task_spec IS 'JSON specification with task parameters (prompt, template, git_url, etc.)';
COMMENT ON COLUMN work_queue.status IS 'Task status: pending, running, completed, failed, cancelled';
COMMENT ON COLUMN work_queue.priority IS 'Task priority (higher = more urgent, 0 = default)';
COMMENT ON COLUMN work_queue.worker_id IS 'ID of the worker that claimed this task';
COMMENT ON COLUMN work_queue.callback_url IS 'Webhook URL for completion notification';
COMMENT ON COLUMN work_queue.result IS 'JSON result from task execution (output, artifacts)';
COMMENT ON COLUMN work_queue.error IS 'Error message if task failed';
COMMENT ON COLUMN work_queue.retry_count IS 'Number of times this task has been retried';
COMMENT ON COLUMN work_queue.max_retries IS 'Maximum allowed retry attempts';

88
internal/domain/ci.go Normal file
View File

@ -0,0 +1,88 @@
// Package domain contains core business entities.
package domain
import "time"
// CIRepo represents a repository's CI/CD configuration.
type CIRepo struct {
// ID is the CI system's internal ID for this repo
ID int64
// ForgeRemoteID is the ID from the forge (e.g., Gitea repo ID)
ForgeRemoteID int64
// Owner is the repository owner (org or user)
Owner string
// Name is the repository name
Name string
// FullName is owner/name
FullName string
// CloneURL is the URL to clone the repo
CloneURL string
// Active indicates if CI is enabled for this repo
Active bool
// AllowPullRequests allows PRs to trigger builds
AllowPullRequests bool
// Visibility: public, private, internal
Visibility string
// CreatedAt is when CI was activated
CreatedAt time.Time
// UpdatedAt is when CI config was last modified
UpdatedAt time.Time
}
// CISecret represents a secret for use in CI pipelines.
type CISecret struct {
// Name is the secret name (e.g., "DOCKER_PASSWORD")
Name string
// Value is the secret value (encrypted at rest)
Value string
// Events controls when the secret is available (e.g., "push", "pull_request")
Events []string
// Images limits which container images can use this secret
Images []string
}
// CIPipeline represents a CI pipeline execution.
type CIPipeline struct {
// ID is the pipeline ID
ID int64
// Number is the pipeline number (increments per repo)
Number int64
// Status: pending, running, success, failure, killed, blocked
Status string
// Event: push, pull_request, tag, cron, manual
Event string
// Branch that triggered the pipeline
Branch string
// Commit SHA
Commit string
// Message is the commit message
Message string
// Author who triggered the pipeline
Author string
// Started timestamp
Started time.Time
// Finished timestamp (zero if still running)
Finished time.Time
}

View File

@ -0,0 +1,197 @@
// Package domain contains pure domain models with no external dependencies.
package domain
import "time"
// AgentProvider identifies which code agent implementation to use.
type AgentProvider string
const (
// AgentProviderClaudeCode uses Anthropic's Claude Code CLI.
AgentProviderClaudeCode AgentProvider = "claudecode"
// AgentProviderOpenCode uses the open-source OpenCode agent.
AgentProviderOpenCode AgentProvider = "opencode"
)
// ValidAgentProviders returns all valid agent provider values.
func ValidAgentProviders() []AgentProvider {
return []AgentProvider{AgentProviderClaudeCode, AgentProviderOpenCode}
}
// IsValid returns true if the provider is a known valid value.
func (p AgentProvider) IsValid() bool {
switch p {
case AgentProviderClaudeCode, AgentProviderOpenCode:
return true
default:
return false
}
}
// String returns the string representation of the provider.
func (p AgentProvider) String() string {
return string(p)
}
// ParseAgentProvider converts a string to AgentProvider with validation.
// Returns empty provider and error if the string is not a valid provider.
func ParseAgentProvider(s string) (AgentProvider, error) {
p := AgentProvider(s)
if !p.IsValid() {
return "", ErrInvalidAgentProvider
}
return p, nil
}
// AgentRequest contains parameters for executing a code agent command.
type AgentRequest struct {
// Prompt is the user's instruction to the agent.
Prompt string
// ProjectID identifies the project context.
ProjectID ProjectID
// SessionID enables conversation continuation. Empty for new sessions.
SessionID string
// AllowedTools specifies which tools the agent may use without prompting.
// Uses permission rule syntax (e.g., "Bash", "Read", "Bash(git:*)").
AllowedTools []string
// Model specifies which LLM to use. Provider-specific.
// For Claude Code: ignored (uses Claude).
// For OpenCode: e.g., "claude-sonnet-4-20250514", "gpt-4o".
Model string
// WorkingDir is the directory context for the agent. Defaults to /workspace.
WorkingDir string
// Timeout is the maximum execution time. Zero means use default.
Timeout time.Duration
// Metadata contains additional provider-specific options.
Metadata map[string]string
}
// Validate checks the AgentRequest for required fields and valid values.
// Returns an error describing the validation failure, or nil if valid.
func (r *AgentRequest) Validate() error {
if r.Prompt == "" {
return ErrPromptRequired
}
if r.Timeout < 0 {
return ErrInvalidTimeout
}
return nil
}
// AgentEventType categorizes events emitted during agent execution.
type AgentEventType string
const (
// AgentEventOutput is text output from the agent (stdout equivalent).
AgentEventOutput AgentEventType = "output"
// AgentEventToolUse indicates the agent is invoking a tool.
AgentEventToolUse AgentEventType = "tool_use"
// AgentEventToolResult contains the result of a tool invocation.
AgentEventToolResult AgentEventType = "tool_result"
// AgentEventThinking indicates the agent is processing/reasoning.
AgentEventThinking AgentEventType = "thinking"
// AgentEventError indicates an error occurred.
AgentEventError AgentEventType = "error"
// AgentEventComplete indicates execution finished.
AgentEventComplete AgentEventType = "complete"
)
// AgentEvent represents a single event during agent execution.
type AgentEvent struct {
// Type categorizes this event.
Type AgentEventType
// Timestamp when the event occurred.
Timestamp time.Time
// Content is the main payload (text output, tool name, error message).
Content string
// Stream identifies the output stream ("stdout", "stderr", or empty).
Stream string
// ToolName is set for tool_use and tool_result events.
ToolName string
// ToolInput contains the tool invocation arguments (for tool_use).
ToolInput map[string]any
// Metadata contains additional event-specific data.
Metadata map[string]any
}
// AgentEventHandler is a callback for receiving agent events during execution.
type AgentEventHandler func(event AgentEvent)
// AgentResult contains the outcome of agent execution.
type AgentResult struct {
// SessionID identifies this conversation for potential continuation.
SessionID string
// ExitCode is 0 for success, non-zero for failure.
ExitCode int
// DurationMs is the total execution time in milliseconds.
DurationMs int64
// Error contains any execution error (nil on success).
Error error
// TokensUsed tracks token consumption (if available from provider).
TokensUsed *AgentTokenUsage
// FinalOutput contains the agent's final text response (if any).
FinalOutput string
}
// Success returns true if the agent completed successfully.
func (r *AgentResult) Success() bool {
return r.Error == nil && r.ExitCode == 0
}
// AgentTokenUsage tracks token consumption during execution.
type AgentTokenUsage struct {
InputTokens int64
OutputTokens int64
TotalTokens int64
}
// AgentCapabilities describes what a code agent implementation supports.
type AgentCapabilities struct {
// Provider identifies this agent implementation.
Provider AgentProvider
// SupportsSessionContinuation indicates --resume/session support.
SupportsSessionContinuation bool
// SupportsModelSelection indicates the model can be changed.
SupportsModelSelection bool
// SupportsToolControl indicates --allowedTools support.
SupportsToolControl bool
// SupportedModels lists available models (empty if not applicable).
SupportedModels []string
// DefaultModel is used when none is specified.
DefaultModel string
// MaxPromptLength is the maximum prompt size (0 = unlimited).
MaxPromptLength int
// SupportsStreaming indicates real-time output streaming.
SupportsStreaming bool
}

View File

@ -0,0 +1,168 @@
package domain
import (
"errors"
"testing"
)
func TestAgentProvider_IsValid(t *testing.T) {
tests := []struct {
provider AgentProvider
want bool
}{
{AgentProviderClaudeCode, true},
{AgentProviderOpenCode, true},
{"unknown", false},
{"", false},
}
for _, tt := range tests {
t.Run(string(tt.provider), func(t *testing.T) {
if got := tt.provider.IsValid(); got != tt.want {
t.Errorf("IsValid() = %v, want %v", got, tt.want)
}
})
}
}
func TestAgentProvider_String(t *testing.T) {
if got := AgentProviderClaudeCode.String(); got != "claudecode" {
t.Errorf("String() = %q, want %q", got, "claudecode")
}
if got := AgentProviderOpenCode.String(); got != "opencode" {
t.Errorf("String() = %q, want %q", got, "opencode")
}
}
func TestParseAgentProvider(t *testing.T) {
tests := []struct {
input string
want AgentProvider
wantErr bool
}{
{"claudecode", AgentProviderClaudeCode, false},
{"opencode", AgentProviderOpenCode, false},
{"unknown", "", true},
{"", "", true},
{"CLAUDECODE", "", true}, // case sensitive
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := ParseAgentProvider(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ParseAgentProvider() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("ParseAgentProvider() = %v, want %v", got, tt.want)
}
if tt.wantErr && !errors.Is(err, ErrInvalidAgentProvider) {
t.Errorf("expected ErrInvalidAgentProvider, got %v", err)
}
})
}
}
func TestValidAgentProviders(t *testing.T) {
providers := ValidAgentProviders()
if len(providers) != 2 {
t.Errorf("expected 2 providers, got %d", len(providers))
}
found := make(map[AgentProvider]bool)
for _, p := range providers {
found[p] = true
}
if !found[AgentProviderClaudeCode] {
t.Error("expected claudecode in valid providers")
}
if !found[AgentProviderOpenCode] {
t.Error("expected opencode in valid providers")
}
}
func TestAgentRequest_Validate(t *testing.T) {
tests := []struct {
name string
request AgentRequest
wantErr error
}{
{
name: "valid request",
request: AgentRequest{Prompt: "Hello"},
wantErr: nil,
},
{
name: "empty prompt",
request: AgentRequest{Prompt: ""},
wantErr: ErrPromptRequired,
},
{
name: "negative timeout",
request: AgentRequest{Prompt: "Hello", Timeout: -1},
wantErr: ErrInvalidTimeout,
},
{
name: "zero timeout is valid",
request: AgentRequest{Prompt: "Hello", Timeout: 0},
wantErr: nil,
},
{
name: "positive timeout is valid",
request: AgentRequest{Prompt: "Hello", Timeout: 5},
wantErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.request.Validate()
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Errorf("Validate() error = %v, want %v", err, tt.wantErr)
}
} else if err != nil {
t.Errorf("Validate() unexpected error = %v", err)
}
})
}
}
func TestAgentResult_Success(t *testing.T) {
tests := []struct {
name string
result AgentResult
expected bool
}{
{
name: "success with zero exit code",
result: AgentResult{ExitCode: 0, Error: nil},
expected: true,
},
{
name: "failure with non-zero exit code",
result: AgentResult{ExitCode: 1, Error: nil},
expected: false,
},
{
name: "failure with error",
result: AgentResult{ExitCode: 0, Error: errors.New("test error")},
expected: false,
},
{
name: "failure with both error and exit code",
result: AgentResult{ExitCode: 1, Error: errors.New("test error")},
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.result.Success(); got != tt.expected {
t.Errorf("Success() = %v, want %v", got, tt.expected)
}
})
}
}

View File

@ -0,0 +1,58 @@
// Package domain contains core business entities.
package domain
import "time"
// Credential represents a stored secret/credential for infrastructure adapters.
// Credentials are encrypted at rest and accessed by key name.
type Credential struct {
// Key is the unique identifier (e.g., "GITEA_TOKEN", "CLOUDFLARE_API_TOKEN")
Key string
// Value is the credential value (stored encrypted in database)
Value string
// Description explains what this credential is for
Description string
// Category groups related credentials (e.g., "gitea", "cloudflare", "woodpecker")
Category string
// CreatedAt is when the credential was first stored
CreatedAt time.Time
// UpdatedAt is when the credential was last modified
UpdatedAt time.Time
// UpdatedBy tracks who last modified the credential
UpdatedBy string
}
// CredentialCategories for grouping.
const (
CredentialCategoryGitea = "gitea"
CredentialCategoryCloudflare = "cloudflare"
CredentialCategoryWoodpecker = "woodpecker"
CredentialCategoryDatabase = "database"
CredentialCategoryRegistry = "registry"
CredentialCategoryWorker = "worker"
)
// Known credential keys.
const (
// Gitea
CredKeyGiteaToken = "GITEA_TOKEN"
CredKeyGiteaURL = "GITEA_URL"
// Cloudflare
CredKeyCloudflareAPIToken = "CLOUDFLARE_API_TOKEN"
CredKeyCloudflareZoneID = "CLOUDFLARE_ZONE_ID"
// Woodpecker
CredKeyWoodpeckerURL = "WOODPECKER_URL"
CredKeyWoodpeckerAPIToken = "WOODPECKER_API_TOKEN"
CredKeyWoodpeckerWebhookSecret = "WOODPECKER_WEBHOOK_SECRET"
// Registry
CredKeyRegistryURL = "REGISTRY_URL"
)

View File

@ -6,8 +6,12 @@ import "errors"
// to appropriate HTTP status codes or gRPC error codes by the presentation layer.
var (
// Project errors
ErrProjectNotFound = errors.New("project not found")
ErrProjectNotRunning = errors.New("project is not running")
ErrProjectNotFound = errors.New("project not found")
ErrProjectNotRunning = errors.New("project is not running")
ErrInvalidProjectName = errors.New("invalid project name")
// Template errors
ErrTemplateNotFound = errors.New("template not found")
// Command errors
ErrCommandNotFound = errors.New("command not found")
@ -17,6 +21,14 @@ var (
ErrInvalidCommand = errors.New("invalid command")
ErrCommandSanitization = errors.New("command failed sanitization")
// Agent errors
ErrInvalidAgentProvider = errors.New("invalid agent provider")
ErrPromptRequired = errors.New("prompt is required")
ErrInvalidTimeout = errors.New("timeout cannot be negative")
// Work queue errors
ErrWorkTaskNotFound = errors.New("work task not found")
// API Key errors
ErrKeyNotFound = errors.New("api key not found")
ErrKeyRevoked = errors.New("api key has been revoked")

View File

@ -13,6 +13,10 @@ type Project struct {
PodName string
Status ProjectStatus
Workspace string
// AgentProvider specifies which code agent to use for this project.
// Empty string means use the system default (typically Claude Code).
AgentProvider AgentProvider
}
// ProjectStatus represents the current state of a project's pod.
@ -49,6 +53,9 @@ const (
// LabelWorkspace specifies the workspace path inside the pod.
LabelWorkspace = "rdev.orchard9.ai/workspace"
// LabelAgentProvider specifies which code agent to use ("claudecode", "opencode").
LabelAgentProvider = "rdev.orchard9.ai/agent-provider"
// AnnotDescription provides a human-readable description of the project.
AnnotDescription = "rdev.orchard9.ai/description"
)

View File

@ -0,0 +1,235 @@
// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/pkg/api"
)
// CredentialsHandler handles credential management endpoints.
// These endpoints require superadmin authentication.
type CredentialsHandler struct {
store port.CredentialStore
}
// NewCredentialsHandler creates a new credentials handler.
func NewCredentialsHandler(store port.CredentialStore) *CredentialsHandler {
return &CredentialsHandler{store: store}
}
// Mount registers the credentials routes.
// All routes require superadmin authentication (handled by middleware).
func (h *CredentialsHandler) Mount(r api.Router) {
r.Route("/credentials", func(r chi.Router) {
r.Get("/", h.List) // GET /credentials - List all (masked)
r.Post("/", h.Set) // POST /credentials - Set single
r.Post("/batch", h.SetBatch) // POST /credentials/batch - Set multiple
r.Get("/{key}", h.Get) // GET /credentials/{key} - Get single
r.Delete("/{key}", h.Delete) // DELETE /credentials/{key} - Delete
})
}
// SetCredentialRequest is the request body for POST /credentials.
type SetCredentialRequest struct {
Key string `json:"key"`
Value string `json:"value"`
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
}
// SetBatchRequest is the request body for POST /credentials/batch.
type SetBatchRequest struct {
Credentials []SetCredentialRequest `json:"credentials"`
}
// CredentialResponse is the response for credential endpoints.
type CredentialResponse struct {
Key string `json:"key"`
Value string `json:"value,omitempty"` // Only included for Get, masked for List
Description string `json:"description,omitempty"`
Category string `json:"category,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
UpdatedBy string `json:"updated_by,omitempty"`
}
// List returns all credentials with values masked.
// GET /credentials
func (h *CredentialsHandler) List(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Check for category filter
category := r.URL.Query().Get("category")
var creds []domain.Credential
var err error
if category != "" {
creds, err = h.store.ListByCategory(ctx, category)
} else {
creds, err = h.store.List(ctx)
}
if err != nil {
api.WriteInternalError(w, r, "failed to list credentials")
return
}
response := make([]CredentialResponse, len(creds))
for i, c := range creds {
response[i] = CredentialResponse{
Key: c.Key,
Value: c.Value, // Already masked by store
Description: c.Description,
Category: c.Category,
CreatedAt: c.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedBy: c.UpdatedBy,
}
}
api.WriteSuccess(w, r, response)
}
// Get retrieves a single credential by key.
// GET /credentials/{key}
func (h *CredentialsHandler) Get(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
key := chi.URLParam(r, "key")
if key == "" {
api.WriteBadRequest(w, r, "key is required")
return
}
value, err := h.store.Get(ctx, key)
if err != nil {
api.WriteInternalError(w, r, "failed to get credential")
return
}
if value == "" {
api.WriteNotFound(w, r, "credential not found")
return
}
api.WriteSuccess(w, r, CredentialResponse{
Key: key,
Value: value,
})
}
// Set creates or updates a single credential.
// POST /credentials
func (h *CredentialsHandler) Set(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req SetCredentialRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Key == "" {
api.WriteBadRequest(w, r, "key is required")
return
}
if req.Value == "" {
api.WriteBadRequest(w, r, "value is required")
return
}
cred := domain.Credential{
Key: req.Key,
Value: req.Value,
Description: req.Description,
Category: req.Category,
UpdatedBy: "superadmin", // Could extract from auth context
}
if err := h.store.Set(ctx, cred); err != nil {
api.WriteInternalError(w, r, "failed to set credential")
return
}
api.WriteCreated(w, r, map[string]string{
"status": "stored",
"key": req.Key,
})
}
// SetBatch creates or updates multiple credentials.
// POST /credentials/batch
func (h *CredentialsHandler) SetBatch(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req SetBatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if len(req.Credentials) == 0 {
api.WriteBadRequest(w, r, "credentials array is required")
return
}
creds := make([]domain.Credential, len(req.Credentials))
for i, c := range req.Credentials {
if c.Key == "" {
api.WriteBadRequest(w, r, "key is required for all credentials")
return
}
if c.Value == "" {
api.WriteBadRequest(w, r, "value is required for all credentials")
return
}
creds[i] = domain.Credential{
Key: c.Key,
Value: c.Value,
Description: c.Description,
Category: c.Category,
UpdatedBy: "superadmin",
}
}
if err := h.store.SetMultiple(ctx, creds); err != nil {
api.WriteInternalError(w, r, "failed to set credentials")
return
}
keys := make([]string, len(creds))
for i, c := range creds {
keys[i] = c.Key
}
api.WriteCreated(w, r, map[string]any{
"status": "stored",
"count": len(creds),
"keys": keys,
})
}
// Delete removes a credential by key.
// DELETE /credentials/{key}
func (h *CredentialsHandler) Delete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
key := chi.URLParam(r, "key")
if key == "" {
api.WriteBadRequest(w, r, "key is required")
return
}
if err := h.store.Delete(ctx, key); err != nil {
api.WriteNotFound(w, r, "credential not found")
return
}
api.WriteSuccess(w, r, map[string]string{
"status": "deleted",
"key": key,
})
}

View File

@ -77,24 +77,22 @@ func NewInfrastructureHandler(
// Mount registers the infrastructure routes.
func (h *InfrastructureHandler) Mount(r api.Router) {
r.Route("/projects", func(r chi.Router) {
// Git repository endpoints
r.Post("/{id}/repo", h.CreateRepo)
r.Get("/{id}/repo", h.GetRepo)
r.Delete("/{id}/repo", h.DeleteRepo)
// Git repository endpoints
r.Post("/projects/{id}/repo", h.CreateRepo)
r.Get("/projects/{id}/repo", h.GetRepo)
r.Delete("/projects/{id}/repo", h.DeleteRepo)
// Deployment endpoints
r.Post("/{id}/deploy", h.Deploy)
r.Get("/{id}/deploy/status", h.GetDeployStatus)
r.Delete("/{id}/deploy", h.Undeploy)
r.Post("/{id}/deploy/restart", h.RestartDeploy)
r.Post("/{id}/deploy/scale", h.ScaleDeploy)
r.Get("/{id}/deploy/logs", h.GetDeployLogs)
// Deployment endpoints
r.Post("/projects/{id}/deploy", h.Deploy)
r.Get("/projects/{id}/deploy/status", h.GetDeployStatus)
r.Delete("/projects/{id}/deploy", h.Undeploy)
r.Post("/projects/{id}/deploy/restart", h.RestartDeploy)
r.Post("/projects/{id}/deploy/scale", h.ScaleDeploy)
r.Get("/projects/{id}/deploy/logs", h.GetDeployLogs)
// Domain endpoints
r.Post("/{id}/domain", h.AddDomain)
r.Delete("/{id}/domain", h.RemoveDomain)
})
// Domain endpoints
r.Post("/projects/{id}/domain", h.AddDomain)
r.Delete("/projects/{id}/domain", h.RemoveDomain)
}
// CreateRepoRequest is the request body for POST /projects/{id}/repo.

View File

@ -4,12 +4,13 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/pkg/api"
)
@ -34,6 +35,10 @@ func (h *ProjectManagementHandler) Mount(r api.Router) {
r.Get("/{name}", h.Status) // GET /project/{name} - Get project status
r.Delete("/{name}", h.Delete) // DELETE /project/{name} - Delete project
})
// Template endpoints
r.Get("/templates", h.ListTemplates) // GET /templates - List available templates
r.Get("/templates/{name}", h.GetTemplate) // GET /templates/{name} - Get template details
}
// CreateRequest is the request body for POST /project.
@ -41,6 +46,7 @@ type CreateRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Private bool `json:"private,omitempty"`
Template string `json:"template,omitempty"` // Template to seed repo (default: "default")
}
// Create creates a new project with git repo and DNS.
@ -69,10 +75,11 @@ func (h *ProjectManagementHandler) Create(w http.ResponseWriter, r *http.Request
Name: req.Name,
Description: req.Description,
Private: req.Private,
Template: req.Template,
})
if err != nil {
// Check for validation errors (user input) vs internal errors
if strings.Contains(err.Error(), "invalid project name") {
if errors.Is(err, domain.ErrInvalidProjectName) {
api.WriteBadRequest(w, r, err.Error())
return
}
@ -158,7 +165,7 @@ func (h *ProjectManagementHandler) Status(w http.ResponseWriter, r *http.Request
status, err := h.infraService.GetStatus(ctx, name)
if err != nil {
// Check if it's a "not found" error
if strings.Contains(err.Error(), "not found") {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, "project not found")
return
}
@ -205,7 +212,7 @@ func (h *ProjectManagementHandler) Delete(w http.ResponseWriter, r *http.Request
err := h.infraService.DeleteProject(ctx, name)
if err != nil {
// Check if it's a "not found" error
if strings.Contains(err.Error(), "not found") {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, "project not found")
return
}
@ -219,3 +226,66 @@ func (h *ProjectManagementHandler) Delete(w http.ResponseWriter, r *http.Request
"project": name,
})
}
// ListTemplates returns available project templates.
// GET /templates
func (h *ProjectManagementHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if h.infraService == nil {
api.WriteInternalError(w, r, "project infrastructure service not configured")
return
}
templates, err := h.infraService.ListTemplates(ctx)
if err != nil {
slog.Error("failed to list templates", "error", err)
api.WriteInternalError(w, r, "failed to list templates")
return
}
// Convert to response format
response := make([]map[string]any, len(templates))
for i, t := range templates {
response[i] = map[string]any{
"name": t.Name,
"description": t.Description,
"stack": t.Stack,
"files": t.Files,
}
}
api.WriteSuccess(w, r, response)
}
// GetTemplate returns details about a specific template.
// GET /templates/{name}
func (h *ProjectManagementHandler) GetTemplate(w http.ResponseWriter, r *http.Request) {
name := chi.URLParam(r, "name")
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if h.infraService == nil {
api.WriteInternalError(w, r, "project infrastructure service not configured")
return
}
template, err := h.infraService.GetTemplate(ctx, name)
if err != nil {
if errors.Is(err, domain.ErrTemplateNotFound) {
api.WriteNotFound(w, r, "template not found")
return
}
slog.Error("failed to get template", "error", err, "name", name)
api.WriteInternalError(w, r, "failed to get template")
return
}
api.WriteSuccess(w, r, map[string]any{
"name": template.Name,
"description": template.Description,
"stack": template.Stack,
"files": template.Files,
})
}

View File

@ -3,12 +3,10 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
@ -17,9 +15,7 @@ import (
"github.com/orchard9/rdev/internal/auth"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/sanitize"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/validate"
"github.com/orchard9/rdev/pkg/api"
)
@ -182,356 +178,6 @@ func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
}
// ClaudeRequest is the request body for POST /projects/{id}/claude.
type ClaudeRequest struct {
Prompt string `json:"prompt"`
StreamID string `json:"stream_id,omitempty"`
}
// RunClaude executes a Claude command in the project's claudebox.
// POST /projects/{id}/claude
func (h *ProjectsHandler) RunClaude(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req ClaudeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Use new service if available
if h.projectService != nil {
result, err := h.projectService.ExecuteClaude(r.Context(), service.ExecuteClaudeRequest{
ProjectID: domain.ProjectID(id),
Prompt: req.Prompt,
StreamID: req.StreamID,
Audit: getAuditContext(r),
})
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) {
api.WriteBadRequest(w, r, err.Error())
return
}
api.WriteInternalError(w, r, "failed to execute command")
return
}
api.WriteCreated(w, r, map[string]any{
"id": result.CommandID,
"project": id,
"type": "claude",
"status": "running",
"stream_url": result.StreamURL,
})
return
}
// Legacy path using hexagonal types
if h.projectRepo == nil || h.executor == nil {
api.WriteInternalError(w, r, "no project service configured")
return
}
project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id))
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
api.WriteInternalError(w, r, "failed to get project")
return
}
if err := validate.Required(req.Prompt, "prompt"); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Sanitize prompt
if err := sanitize.ClaudePrompt(req.Prompt); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Validate stream ID
if err := sanitize.StreamID(req.StreamID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Generate command ID
cmdNum := h.cmdID.Add(1)
cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum)
if req.StreamID != "" {
cmdID = req.StreamID
}
// Create the command using domain types
cmd := &domain.Command{
ID: domain.CommandID(cmdID),
ProjectID: domain.ProjectID(id),
Type: domain.CommandTypeClaude,
Args: []string{req.Prompt},
StartedAt: time.Now(),
}
// Execute in background
go h.executeCommand(cmd, project.PodName)
api.WriteCreated(w, r, map[string]any{
"id": cmdID,
"project": id,
"type": "claude",
"status": "running",
"stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID),
})
}
// ShellRequest is the request body for POST /projects/{id}/shell.
type ShellRequest struct {
Command string `json:"command"`
StreamID string `json:"stream_id,omitempty"`
}
// RunShell executes a shell command in the project's claudebox.
// POST /projects/{id}/shell
func (h *ProjectsHandler) RunShell(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req ShellRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Use new service if available
if h.projectService != nil {
result, err := h.projectService.ExecuteShell(r.Context(), service.ExecuteShellRequest{
ProjectID: domain.ProjectID(id),
Command: req.Command,
StreamID: req.StreamID,
Audit: getAuditContext(r),
})
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) {
api.WriteBadRequest(w, r, err.Error())
return
}
api.WriteInternalError(w, r, "failed to execute command")
return
}
api.WriteCreated(w, r, map[string]any{
"id": result.CommandID,
"project": id,
"type": "shell",
"status": "running",
"stream_url": result.StreamURL,
})
return
}
// Legacy path using hexagonal types
if h.projectRepo == nil || h.executor == nil {
api.WriteInternalError(w, r, "no project service configured")
return
}
project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id))
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
api.WriteInternalError(w, r, "failed to get project")
return
}
if err := validate.Required(req.Command, "command"); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Sanitize command - CRITICAL for security
if err := sanitize.ShellCommand(req.Command); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Validate stream ID
if err := sanitize.StreamID(req.StreamID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Generate command ID
cmdNum := h.cmdID.Add(1)
cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum)
if req.StreamID != "" {
cmdID = req.StreamID
}
// Create the command using domain types
cmd := &domain.Command{
ID: domain.CommandID(cmdID),
ProjectID: domain.ProjectID(id),
Type: domain.CommandTypeShell,
Args: []string{req.Command},
StartedAt: time.Now(),
}
// Execute in background
go h.executeCommand(cmd, project.PodName)
api.WriteCreated(w, r, map[string]any{
"id": cmdID,
"project": id,
"type": "shell",
"status": "running",
"stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID),
})
}
// GitRequest is the request body for POST /projects/{id}/git.
type GitRequest struct {
Args []string `json:"args"`
StreamID string `json:"stream_id,omitempty"`
}
// RunGit executes a git command in the project's claudebox.
// POST /projects/{id}/git
func (h *ProjectsHandler) RunGit(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req GitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Use new service if available
if h.projectService != nil {
result, err := h.projectService.ExecuteGit(r.Context(), service.ExecuteGitRequest{
ProjectID: domain.ProjectID(id),
Args: req.Args,
StreamID: req.StreamID,
Audit: getAuditContext(r),
})
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) {
api.WriteBadRequest(w, r, err.Error())
return
}
api.WriteInternalError(w, r, "failed to execute command")
return
}
api.WriteCreated(w, r, map[string]any{
"id": result.CommandID,
"project": id,
"type": "git",
"status": "running",
"stream_url": result.StreamURL,
})
return
}
// Legacy path using hexagonal types
if h.projectRepo == nil || h.executor == nil {
api.WriteInternalError(w, r, "no project service configured")
return
}
project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id))
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
api.WriteInternalError(w, r, "failed to get project")
return
}
if err := validate.RequiredSlice(req.Args, "args"); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Sanitize git args
if err := sanitize.GitArgs(req.Args); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Validate stream ID
if err := sanitize.StreamID(req.StreamID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Generate command ID
cmdNum := h.cmdID.Add(1)
cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum)
if req.StreamID != "" {
cmdID = req.StreamID
}
// Create the command using domain types
cmd := &domain.Command{
ID: domain.CommandID(cmdID),
ProjectID: domain.ProjectID(id),
Type: domain.CommandTypeGit,
Args: req.Args,
StartedAt: time.Now(),
}
// Execute in background
go h.executeCommand(cmd, project.PodName)
api.WriteCreated(w, r, map[string]any{
"id": cmdID,
"project": id,
"type": "git",
"status": "running",
"stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID),
})
}
// executeCommand runs a command and streams output to subscribers.
func (h *ProjectsHandler) executeCommand(cmd *domain.Command, podName string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
cmdID := string(cmd.ID)
result, _ := h.executor.Execute(ctx, cmd, podName, func(line domain.OutputLine) {
h.streams.Send(cmdID, "output", map[string]any{
"line": line.Line,
"stream": line.Stream,
})
})
// Send completion event
h.streams.Send(cmdID, "complete", map[string]any{
"exit_code": result.ExitCode,
"duration_ms": result.DurationMs,
})
// Clean up stream after a delay
go func() {
time.Sleep(30 * time.Second)
h.streams.Close(cmdID)
}()
}
// Events streams command output via Server-Sent Events.
// GET /projects/{id}/events
// Supports Last-Event-ID header for reconnection with event replay.
@ -643,85 +289,6 @@ func (h *ProjectsHandler) Events(w http.ResponseWriter, r *http.Request) {
}
}
// writeSSE writes a Server-Sent Event.
func writeSSE(w http.ResponseWriter, flusher http.Flusher, event string, data map[string]any) {
writeSSEWithID(w, flusher, "", event, data)
}
// writeSSEWithID writes a Server-Sent Event with an optional event ID.
func writeSSEWithID(w http.ResponseWriter, flusher http.Flusher, id, event string, data map[string]any) {
dataBytes, _ := json.Marshal(data)
if id != "" {
_, _ = fmt.Fprintf(w, "id: %s\n", id)
}
_, _ = fmt.Fprintf(w, "event: %s\n", event)
_, _ = fmt.Fprintf(w, "data: %s\n\n", dataBytes)
flusher.Flush()
}
// streamManager manages SSE event streams.
type streamManager struct {
mu sync.RWMutex
streams map[string][]chan streamEvent
}
type streamEvent struct {
Type string
Data map[string]any
}
func newStreamManager() *streamManager {
return &streamManager{
streams: make(map[string][]chan streamEvent),
}
}
func (sm *streamManager) Subscribe(streamID string) chan streamEvent {
sm.mu.Lock()
defer sm.mu.Unlock()
ch := make(chan streamEvent, 100)
sm.streams[streamID] = append(sm.streams[streamID], ch)
return ch
}
func (sm *streamManager) Unsubscribe(streamID string, ch chan streamEvent) {
sm.mu.Lock()
defer sm.mu.Unlock()
channels := sm.streams[streamID]
for i, c := range channels {
if c == ch {
sm.streams[streamID] = append(channels[:i], channels[i+1:]...)
close(ch)
break
}
}
}
func (sm *streamManager) Send(streamID, eventType string, data map[string]any) {
sm.mu.RLock()
defer sm.mu.RUnlock()
for _, ch := range sm.streams[streamID] {
select {
case ch <- streamEvent{Type: eventType, Data: data}:
default:
// Channel full, skip
}
}
}
func (sm *streamManager) Close(streamID string) {
sm.mu.Lock()
defer sm.mu.Unlock()
for _, ch := range sm.streams[streamID] {
close(ch)
}
delete(sm.streams, streamID)
}
// ProjectRepository returns the project repository for use by other handlers.
func (h *ProjectsHandler) ProjectRepository() *kubernetes.ProjectRepository {
return h.projectRepo

View File

@ -0,0 +1,368 @@
// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/sanitize"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/internal/validate"
"github.com/orchard9/rdev/pkg/api"
)
// ClaudeRequest is the request body for POST /projects/{id}/claude.
type ClaudeRequest struct {
Prompt string `json:"prompt"`
StreamID string `json:"stream_id,omitempty"`
}
// RunClaude executes a Claude command in the project's claudebox.
// POST /projects/{id}/claude
func (h *ProjectsHandler) RunClaude(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req ClaudeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Use new service if available
if h.projectService != nil {
result, err := h.projectService.ExecuteClaude(r.Context(), service.ExecuteClaudeRequest{
ProjectID: domain.ProjectID(id),
Prompt: req.Prompt,
StreamID: req.StreamID,
Audit: getAuditContext(r),
})
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) {
api.WriteBadRequest(w, r, err.Error())
return
}
api.WriteInternalError(w, r, "failed to execute command")
return
}
api.WriteCreated(w, r, map[string]any{
"id": result.CommandID,
"project": id,
"type": "claude",
"status": "running",
"stream_url": result.StreamURL,
})
return
}
// Legacy path using hexagonal types
if h.projectRepo == nil || h.executor == nil {
api.WriteInternalError(w, r, "no project service configured")
return
}
project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id))
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
api.WriteInternalError(w, r, "failed to get project")
return
}
if err := validate.Required(req.Prompt, "prompt"); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Sanitize prompt
if err := sanitize.ClaudePrompt(req.Prompt); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Validate stream ID
if err := sanitize.StreamID(req.StreamID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Generate command ID
cmdNum := h.cmdID.Add(1)
cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum)
if req.StreamID != "" {
cmdID = req.StreamID
}
// Create the command using domain types
cmd := &domain.Command{
ID: domain.CommandID(cmdID),
ProjectID: domain.ProjectID(id),
Type: domain.CommandTypeClaude,
Args: []string{req.Prompt},
StartedAt: time.Now(),
}
// Execute in background
go h.executeCommand(cmd, project.PodName)
api.WriteCreated(w, r, map[string]any{
"id": cmdID,
"project": id,
"type": "claude",
"status": "running",
"stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID),
})
}
// ShellRequest is the request body for POST /projects/{id}/shell.
type ShellRequest struct {
Command string `json:"command"`
StreamID string `json:"stream_id,omitempty"`
}
// RunShell executes a shell command in the project's claudebox.
// POST /projects/{id}/shell
func (h *ProjectsHandler) RunShell(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req ShellRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Use new service if available
if h.projectService != nil {
result, err := h.projectService.ExecuteShell(r.Context(), service.ExecuteShellRequest{
ProjectID: domain.ProjectID(id),
Command: req.Command,
StreamID: req.StreamID,
Audit: getAuditContext(r),
})
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) {
api.WriteBadRequest(w, r, err.Error())
return
}
api.WriteInternalError(w, r, "failed to execute command")
return
}
api.WriteCreated(w, r, map[string]any{
"id": result.CommandID,
"project": id,
"type": "shell",
"status": "running",
"stream_url": result.StreamURL,
})
return
}
// Legacy path using hexagonal types
if h.projectRepo == nil || h.executor == nil {
api.WriteInternalError(w, r, "no project service configured")
return
}
project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id))
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
api.WriteInternalError(w, r, "failed to get project")
return
}
if err := validate.Required(req.Command, "command"); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Sanitize command
if err := sanitize.ShellCommand(req.Command); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Validate stream ID
if err := sanitize.StreamID(req.StreamID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Generate command ID
cmdNum := h.cmdID.Add(1)
cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum)
if req.StreamID != "" {
cmdID = req.StreamID
}
// Create the command using domain types
cmd := &domain.Command{
ID: domain.CommandID(cmdID),
ProjectID: domain.ProjectID(id),
Type: domain.CommandTypeShell,
Args: []string{req.Command},
StartedAt: time.Now(),
}
// Execute in background
go h.executeCommand(cmd, project.PodName)
api.WriteCreated(w, r, map[string]any{
"id": cmdID,
"project": id,
"type": "shell",
"status": "running",
"stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID),
})
}
// GitRequest is the request body for POST /projects/{id}/git.
type GitRequest struct {
Args []string `json:"args"`
StreamID string `json:"stream_id,omitempty"`
}
// RunGit executes a git command in the project's claudebox.
// POST /projects/{id}/git
func (h *ProjectsHandler) RunGit(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var req GitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Use new service if available
if h.projectService != nil {
result, err := h.projectService.ExecuteGit(r.Context(), service.ExecuteGitRequest{
ProjectID: domain.ProjectID(id),
Args: req.Args,
StreamID: req.StreamID,
Audit: getAuditContext(r),
})
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
if errors.Is(err, domain.ErrInvalidCommand) || errors.Is(err, domain.ErrCommandSanitization) {
api.WriteBadRequest(w, r, err.Error())
return
}
api.WriteInternalError(w, r, "failed to execute command")
return
}
api.WriteCreated(w, r, map[string]any{
"id": result.CommandID,
"project": id,
"type": "git",
"status": "running",
"stream_url": result.StreamURL,
})
return
}
// Legacy path using hexagonal types
if h.projectRepo == nil || h.executor == nil {
api.WriteInternalError(w, r, "no project service configured")
return
}
project, err := h.projectRepo.Get(r.Context(), domain.ProjectID(id))
if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("project not found: %s", id))
return
}
api.WriteInternalError(w, r, "failed to get project")
return
}
if err := validate.RequiredSlice(req.Args, "args"); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Sanitize git args
if err := sanitize.GitArgs(req.Args); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Validate stream ID
if err := sanitize.StreamID(req.StreamID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
// Generate command ID
cmdNum := h.cmdID.Add(1)
cmdID := fmt.Sprintf("cmd-%s-%03d", id, cmdNum)
if req.StreamID != "" {
cmdID = req.StreamID
}
// Create the command using domain types
cmd := &domain.Command{
ID: domain.CommandID(cmdID),
ProjectID: domain.ProjectID(id),
Type: domain.CommandTypeGit,
Args: req.Args,
StartedAt: time.Now(),
}
// Execute in background
go h.executeCommand(cmd, project.PodName)
api.WriteCreated(w, r, map[string]any{
"id": cmdID,
"project": id,
"type": "git",
"status": "running",
"stream_url": fmt.Sprintf("/projects/%s/events?stream_id=%s", id, cmdID),
})
}
// executeCommand runs a command and streams output to subscribers.
func (h *ProjectsHandler) executeCommand(cmd *domain.Command, podName string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
cmdID := string(cmd.ID)
result, _ := h.executor.Execute(ctx, cmd, podName, func(line domain.OutputLine) {
h.streams.Send(cmdID, "output", map[string]any{
"line": line.Line,
"stream": line.Stream,
})
})
// Send completion event
h.streams.Send(cmdID, "complete", map[string]any{
"exit_code": result.ExitCode,
"duration_ms": result.DurationMs,
})
// Clean up stream after a delay
go func() {
time.Sleep(30 * time.Second)
h.streams.Close(cmdID)
}()
}

View File

@ -0,0 +1,88 @@
// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"sync"
)
// streamManager manages SSE event streams.
type streamManager struct {
mu sync.RWMutex
streams map[string][]chan streamEvent
}
type streamEvent struct {
Type string
Data map[string]any
}
func newStreamManager() *streamManager {
return &streamManager{
streams: make(map[string][]chan streamEvent),
}
}
func (sm *streamManager) Subscribe(streamID string) chan streamEvent {
sm.mu.Lock()
defer sm.mu.Unlock()
ch := make(chan streamEvent, 100)
sm.streams[streamID] = append(sm.streams[streamID], ch)
return ch
}
func (sm *streamManager) Unsubscribe(streamID string, ch chan streamEvent) {
sm.mu.Lock()
defer sm.mu.Unlock()
channels := sm.streams[streamID]
for i, c := range channels {
if c == ch {
sm.streams[streamID] = append(channels[:i], channels[i+1:]...)
close(ch)
break
}
}
}
func (sm *streamManager) Send(streamID, eventType string, data map[string]any) {
sm.mu.RLock()
defer sm.mu.RUnlock()
for _, ch := range sm.streams[streamID] {
select {
case ch <- streamEvent{Type: eventType, Data: data}:
default:
// Channel full, skip
}
}
}
func (sm *streamManager) Close(streamID string) {
sm.mu.Lock()
defer sm.mu.Unlock()
for _, ch := range sm.streams[streamID] {
close(ch)
}
delete(sm.streams, streamID)
}
// writeSSE writes a Server-Sent Event.
func writeSSE(w http.ResponseWriter, flusher http.Flusher, event string, data map[string]any) {
writeSSEWithID(w, flusher, "", event, data)
}
// writeSSEWithID writes a Server-Sent Event with an optional event ID.
func writeSSEWithID(w http.ResponseWriter, flusher http.Flusher, id, event string, data map[string]any) {
dataBytes, _ := json.Marshal(data)
if id != "" {
_, _ = fmt.Fprintf(w, "id: %s\n", id)
}
_, _ = fmt.Fprintf(w, "event: %s\n", event)
_, _ = fmt.Fprintf(w, "data: %s\n\n", dataBytes)
flusher.Flush()
}

459
internal/handlers/work.go Normal file
View File

@ -0,0 +1,459 @@
// Package handlers provides HTTP handlers for the rdev API.
package handlers
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/service"
"github.com/orchard9/rdev/pkg/api"
)
// WorkHandler handles work queue endpoints.
type WorkHandler struct {
workService *service.WorkService
}
// NewWorkHandler creates a new work handler.
func NewWorkHandler(workService *service.WorkService) *WorkHandler {
return &WorkHandler{
workService: workService,
}
}
// Mount registers the work queue routes.
func (h *WorkHandler) Mount(r api.Router) {
r.Route("/work", func(r chi.Router) {
// Task submission
r.Post("/enqueue", h.Enqueue)
// Worker endpoints (for workers polling for tasks)
r.Post("/dequeue", h.Dequeue)
// Task management
r.Get("/{taskId}", h.GetTask)
r.Get("/{taskId}/status", h.GetStatus)
r.Post("/{taskId}/complete", h.Complete)
r.Post("/{taskId}/fail", h.Fail)
r.Post("/{taskId}/cancel", h.Cancel)
// Project-scoped list
r.Get("/projects/{projectId}", h.ListByProject)
// Queue stats
r.Get("/stats", h.Stats)
})
}
// EnqueueWorkRequest is the request body for POST /work/enqueue.
type EnqueueWorkRequest struct {
ProjectID string `json:"project_id"`
TaskType string `json:"task_type"`
Spec map[string]any `json:"task_spec"`
Priority int `json:"priority,omitempty"`
CallbackURL string `json:"callback_url,omitempty"`
MaxRetries int `json:"max_retries,omitempty"`
}
// EnqueueWorkResponse is the response for POST /work/enqueue.
type EnqueueWorkResponse struct {
TaskID string `json:"task_id"`
StatusURL string `json:"status_url"`
}
// Enqueue adds a task to the work queue.
// POST /work/enqueue
func (h *WorkHandler) Enqueue(w http.ResponseWriter, r *http.Request) {
var req EnqueueWorkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Validate required fields
if req.ProjectID == "" {
api.WriteBadRequest(w, r, "project_id is required")
return
}
if req.TaskType == "" {
api.WriteBadRequest(w, r, "task_type is required")
return
}
// Validate task type
taskType := port.WorkTaskType(req.TaskType)
if !taskType.IsValid() {
api.WriteBadRequest(w, r, "task_type must be one of: build, test, deploy, custom")
return
}
// Validate callback URL if provided
if req.CallbackURL != "" {
parsedURL, err := url.Parse(req.CallbackURL)
if err != nil || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
api.WriteBadRequest(w, r, "callback_url must be a valid HTTP/HTTPS URL")
return
}
}
result, err := h.workService.EnqueueTask(r.Context(), service.EnqueueTaskRequest{
ProjectID: req.ProjectID,
Type: taskType,
Spec: req.Spec,
Priority: req.Priority,
CallbackURL: req.CallbackURL,
MaxRetries: req.MaxRetries,
})
if err != nil {
api.WriteInternalError(w, r, "failed to enqueue task")
return
}
api.WriteCreated(w, r, EnqueueWorkResponse{
TaskID: result.TaskID,
StatusURL: result.StatusURL,
})
}
// DequeueWorkRequest is the request body for POST /work/dequeue.
type DequeueWorkRequest struct {
WorkerID string `json:"worker_id"`
}
// DequeueWorkResponse is the response for POST /work/dequeue.
type DequeueWorkResponse struct {
Task *WorkTaskDTO `json:"task,omitempty"`
}
// WorkTaskDTO is the data transfer object for work tasks.
type WorkTaskDTO struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
Type string `json:"type"`
Spec map[string]any `json:"spec"`
Status string `json:"status"`
Priority int `json:"priority"`
WorkerID string `json:"worker_id,omitempty"`
CallbackURL string `json:"callback_url,omitempty"`
CreatedAt string `json:"created_at"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
Result *WorkResultDTO `json:"result,omitempty"`
Error string `json:"error,omitempty"`
RetryCount int `json:"retry_count"`
MaxRetries int `json:"max_retries"`
}
// WorkResultDTO is the data transfer object for work results.
type WorkResultDTO struct {
Output string `json:"output,omitempty"`
Artifacts map[string]string `json:"artifacts,omitempty"`
}
// toWorkTaskDTO converts a port.WorkTask to a WorkTaskDTO.
func toWorkTaskDTO(t *port.WorkTask) *WorkTaskDTO {
if t == nil {
return nil
}
dto := &WorkTaskDTO{
ID: t.ID,
ProjectID: t.ProjectID,
Type: string(t.Type),
Spec: t.Spec,
Status: string(t.Status),
Priority: t.Priority,
WorkerID: t.WorkerID,
CallbackURL: t.CallbackURL,
CreatedAt: t.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
Error: t.Error,
RetryCount: t.RetryCount,
MaxRetries: t.MaxRetries,
}
if t.StartedAt != nil {
dto.StartedAt = t.StartedAt.Format("2006-01-02T15:04:05Z07:00")
}
if t.CompletedAt != nil {
dto.CompletedAt = t.CompletedAt.Format("2006-01-02T15:04:05Z07:00")
}
if t.Result != nil {
dto.Result = &WorkResultDTO{
Output: t.Result.Output,
Artifacts: t.Result.Artifacts,
}
}
return dto
}
// Dequeue claims the next available task for a worker.
// POST /work/dequeue
func (h *WorkHandler) Dequeue(w http.ResponseWriter, r *http.Request) {
var req DequeueWorkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.WorkerID == "" {
api.WriteBadRequest(w, r, "worker_id is required")
return
}
task, err := h.workService.DequeueTask(r.Context(), req.WorkerID)
if err != nil {
api.WriteInternalError(w, r, "failed to dequeue task")
return
}
api.WriteSuccess(w, r, DequeueWorkResponse{
Task: toWorkTaskDTO(task),
})
}
// GetTask retrieves a task by ID.
// GET /work/{taskId}
func (h *WorkHandler) GetTask(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
task, err := h.workService.GetTask(r.Context(), taskID)
if err != nil {
if errors.Is(err, domain.ErrWorkTaskNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID))
return
}
api.WriteInternalError(w, r, "failed to get task")
return
}
api.WriteSuccess(w, r, toWorkTaskDTO(task))
}
// GetStatus returns the status of a task.
// GET /work/{taskId}/status
func (h *WorkHandler) GetStatus(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
task, err := h.workService.GetTask(r.Context(), taskID)
if err != nil {
if errors.Is(err, domain.ErrWorkTaskNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID))
return
}
api.WriteInternalError(w, r, "failed to get task")
return
}
api.WriteSuccess(w, r, map[string]any{
"task_id": task.ID,
"status": string(task.Status),
"error": task.Error,
})
}
// CompleteWorkRequest is the request body for POST /work/{taskId}/complete.
type CompleteWorkRequest struct {
Output string `json:"output,omitempty"`
Artifacts map[string]string `json:"artifacts,omitempty"`
}
// Complete marks a task as successfully completed.
// POST /work/{taskId}/complete
func (h *WorkHandler) Complete(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
var req CompleteWorkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
result := &port.WorkResult{
Output: req.Output,
Artifacts: req.Artifacts,
}
if err := h.workService.CompleteTask(r.Context(), taskID, result); err != nil {
if errors.Is(err, domain.ErrWorkTaskNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID))
return
}
api.WriteInternalError(w, r, "failed to complete task")
return
}
api.WriteSuccess(w, r, map[string]any{
"task_id": taskID,
"status": "completed",
"message": "task completed successfully",
})
}
// FailWorkRequest is the request body for POST /work/{taskId}/fail.
type FailWorkRequest struct {
Error string `json:"error"`
}
// Fail marks a task as failed.
// POST /work/{taskId}/fail
func (h *WorkHandler) Fail(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
var req FailWorkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Error == "" {
api.WriteBadRequest(w, r, "error message is required")
return
}
if err := h.workService.FailTask(r.Context(), taskID, req.Error); err != nil {
if errors.Is(err, domain.ErrWorkTaskNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID))
return
}
api.WriteInternalError(w, r, "failed to fail task")
return
}
api.WriteSuccess(w, r, map[string]any{
"task_id": taskID,
"status": "failed",
"message": "task marked as failed",
})
}
// Cancel cancels a pending task.
// POST /work/{taskId}/cancel
func (h *WorkHandler) Cancel(w http.ResponseWriter, r *http.Request) {
taskID := chi.URLParam(r, "taskId")
if err := h.workService.CancelTask(r.Context(), taskID); err != nil {
if errors.Is(err, domain.ErrWorkTaskNotFound) {
api.WriteNotFound(w, r, fmt.Sprintf("task not found: %s", taskID))
return
}
api.WriteBadRequest(w, r, err.Error())
return
}
api.WriteSuccess(w, r, map[string]any{
"task_id": taskID,
"status": "cancelled",
"message": "task cancelled successfully",
})
}
// ListByProject returns tasks for a project with pagination.
// GET /work/projects/{projectId}?status=pending&limit=50&offset=0
func (h *WorkHandler) ListByProject(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectId")
// Parse and validate optional status filter
var status *port.WorkTaskStatus
if s := r.URL.Query().Get("status"); s != "" {
st := port.WorkTaskStatus(s)
if !st.IsValid() {
api.WriteBadRequest(w, r, "invalid status filter: must be pending, running, completed, failed, or cancelled")
return
}
status = &st
}
// Parse pagination options
opts := port.DefaultWorkListOptions()
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
limit, err := strconv.Atoi(limitStr)
if err != nil {
api.WriteBadRequest(w, r, "limit must be a valid integer")
return
}
opts.Limit = limit
}
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
offset, err := strconv.Atoi(offsetStr)
if err != nil {
api.WriteBadRequest(w, r, "offset must be a valid integer")
return
}
opts.Offset = offset
}
result, err := h.workService.ListByProject(r.Context(), projectID, status, opts)
if err != nil {
api.WriteInternalError(w, r, "failed to list tasks")
return
}
dtos := make([]*WorkTaskDTO, len(result.Tasks))
for i, t := range result.Tasks {
dtos[i] = toWorkTaskDTO(t)
}
api.WriteSuccess(w, r, map[string]any{
"tasks": dtos,
"total": result.Total,
"limit": result.Limit,
"offset": result.Offset,
})
}
// WorkStatsResponse is the response for GET /work/stats.
type WorkStatsResponse struct {
Pending int64 `json:"pending"`
Running int64 `json:"running"`
Completed int64 `json:"completed"`
Failed int64 `json:"failed"`
Cancelled int64 `json:"cancelled"`
OldestPending string `json:"oldest_pending,omitempty"`
}
// Stats returns queue statistics.
// GET /work/stats
func (h *WorkHandler) Stats(w http.ResponseWriter, r *http.Request) {
stats, err := h.workService.GetStats(r.Context())
if err != nil {
api.WriteInternalError(w, r, "failed to get queue stats")
return
}
resp := WorkStatsResponse{
Pending: stats.Pending,
Running: stats.Running,
Completed: stats.Completed,
Failed: stats.Failed,
Cancelled: stats.Cancelled,
}
if stats.OldestPending != nil {
// Convert to human-readable duration
resp.OldestPending = formatDuration(*stats.OldestPending)
}
api.WriteSuccess(w, r, resp)
}
// formatDuration formats a duration in a human-readable way.
func formatDuration(d interface{ Seconds() float64 }) string {
secs := d.Seconds()
if secs < 60 {
return fmt.Sprintf("%.0fs", secs)
}
mins := secs / 60
if mins < 60 {
return fmt.Sprintf("%.1fm", mins)
}
hours := mins / 60
if hours < 24 {
return fmt.Sprintf("%.1fh", hours)
}
days := hours / 24
return fmt.Sprintf("%.1fd", days)
}

View File

@ -0,0 +1,780 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
"github.com/orchard9/rdev/internal/service"
)
// mockWorkQueue implements port.WorkQueue for testing.
type mockWorkQueue struct {
tasks map[string]*port.WorkTask
err error
}
func newMockWorkQueue() *mockWorkQueue {
return &mockWorkQueue{
tasks: make(map[string]*port.WorkTask),
}
}
func (m *mockWorkQueue) Enqueue(ctx context.Context, task *port.WorkTask) (string, error) {
if m.err != nil {
return "", m.err
}
id := "task-123"
task.ID = id
task.Status = port.WorkTaskStatusPending
task.CreatedAt = time.Now()
m.tasks[id] = task
return id, nil
}
func (m *mockWorkQueue) Dequeue(ctx context.Context, workerID string) (*port.WorkTask, error) {
if m.err != nil {
return nil, m.err
}
for _, task := range m.tasks {
if task.Status == port.WorkTaskStatusPending {
task.Status = port.WorkTaskStatusRunning
task.WorkerID = workerID
now := time.Now()
task.StartedAt = &now
return task, nil
}
}
return nil, nil
}
func (m *mockWorkQueue) Complete(ctx context.Context, taskID string, result *port.WorkResult) error {
if m.err != nil {
return m.err
}
task, ok := m.tasks[taskID]
if !ok {
return domain.ErrWorkTaskNotFound
}
task.Status = port.WorkTaskStatusCompleted
task.Result = result
now := time.Now()
task.CompletedAt = &now
return nil
}
func (m *mockWorkQueue) Fail(ctx context.Context, taskID string, errMsg string) error {
if m.err != nil {
return m.err
}
task, ok := m.tasks[taskID]
if !ok {
return domain.ErrWorkTaskNotFound
}
if task.RetryCount < task.MaxRetries {
task.Status = port.WorkTaskStatusPending
task.RetryCount++
task.Error = errMsg
} else {
task.Status = port.WorkTaskStatusFailed
task.Error = errMsg
now := time.Now()
task.CompletedAt = &now
}
return nil
}
func (m *mockWorkQueue) Cancel(ctx context.Context, taskID string) error {
if m.err != nil {
return m.err
}
task, ok := m.tasks[taskID]
if !ok {
return domain.ErrWorkTaskNotFound
}
if task.Status != port.WorkTaskStatusPending {
return domain.ErrWorkTaskNotFound
}
task.Status = port.WorkTaskStatusCancelled
now := time.Now()
task.CompletedAt = &now
return nil
}
func (m *mockWorkQueue) GetTask(ctx context.Context, taskID string) (*port.WorkTask, error) {
if m.err != nil {
return nil, m.err
}
task, ok := m.tasks[taskID]
if !ok {
return nil, domain.ErrWorkTaskNotFound
}
return task, nil
}
func (m *mockWorkQueue) ListByProject(ctx context.Context, projectID string, status *port.WorkTaskStatus, opts port.WorkListOptions) (*port.WorkListResult, error) {
if m.err != nil {
return nil, m.err
}
opts.Normalize()
var tasks []*port.WorkTask
for _, task := range m.tasks {
if task.ProjectID == projectID {
if status == nil || task.Status == *status {
tasks = append(tasks, task)
}
}
}
// Apply pagination
total := int64(len(tasks))
if opts.Offset >= len(tasks) {
tasks = nil
} else {
end := opts.Offset + opts.Limit
if end > len(tasks) {
end = len(tasks)
}
tasks = tasks[opts.Offset:end]
}
return &port.WorkListResult{
Tasks: tasks,
Total: total,
Limit: opts.Limit,
Offset: opts.Offset,
}, nil
}
func (m *mockWorkQueue) GetStats(ctx context.Context) (*port.WorkQueueStats, error) {
if m.err != nil {
return nil, m.err
}
stats := &port.WorkQueueStats{}
for _, task := range m.tasks {
switch task.Status {
case port.WorkTaskStatusPending:
stats.Pending++
case port.WorkTaskStatusRunning:
stats.Running++
case port.WorkTaskStatusCompleted:
stats.Completed++
case port.WorkTaskStatusFailed:
stats.Failed++
case port.WorkTaskStatusCancelled:
stats.Cancelled++
}
}
return stats, nil
}
func (m *mockWorkQueue) CleanupOld(ctx context.Context, olderThan time.Duration) (int64, error) {
return 0, m.err
}
func (m *mockWorkQueue) RequeueStale(ctx context.Context, timeout time.Duration) (int64, error) {
return 0, m.err
}
func TestWorkHandler_Enqueue(t *testing.T) {
mockQueue := newMockWorkQueue()
workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{})
handler := NewWorkHandler(workService)
router := chi.NewRouter()
handler.Mount(router)
tests := []struct {
name string
body EnqueueWorkRequest
wantStatus int
}{
{
name: "valid_build_task",
body: EnqueueWorkRequest{
ProjectID: "test-project",
TaskType: "build",
Spec: map[string]any{
"prompt": "Build a landing page",
"template": "nextjs-landing",
},
},
wantStatus: http.StatusCreated,
},
{
name: "valid_test_task",
body: EnqueueWorkRequest{
ProjectID: "test-project",
TaskType: "test",
Spec: map[string]any{
"test_command": "npm test",
},
},
wantStatus: http.StatusCreated,
},
{
name: "valid_deploy_task",
body: EnqueueWorkRequest{
ProjectID: "test-project",
TaskType: "deploy",
Spec: map[string]any{
"image": "registry.example.com/app:latest",
"replicas": 2,
},
},
wantStatus: http.StatusCreated,
},
{
name: "valid_custom_task",
body: EnqueueWorkRequest{
ProjectID: "test-project",
TaskType: "custom",
Spec: map[string]any{
"action": "cleanup",
},
},
wantStatus: http.StatusCreated,
},
{
name: "missing_project_id",
body: EnqueueWorkRequest{
TaskType: "build",
Spec: map[string]any{},
},
wantStatus: http.StatusBadRequest,
},
{
name: "missing_task_type",
body: EnqueueWorkRequest{
ProjectID: "test-project",
Spec: map[string]any{},
},
wantStatus: http.StatusBadRequest,
},
{
name: "invalid_task_type",
body: EnqueueWorkRequest{
ProjectID: "test-project",
TaskType: "invalid",
Spec: map[string]any{},
},
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest(http.MethodPost, "/work/enqueue", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String())
}
})
}
}
func TestWorkHandler_Dequeue(t *testing.T) {
mockQueue := newMockWorkQueue()
workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{})
handler := NewWorkHandler(workService)
// Pre-populate a pending task
mockQueue.tasks["task-1"] = &port.WorkTask{
ID: "task-1",
ProjectID: "test-project",
Type: port.WorkTaskTypeBuild,
Status: port.WorkTaskStatusPending,
CreatedAt: time.Now(),
}
router := chi.NewRouter()
handler.Mount(router)
tests := []struct {
name string
body DequeueWorkRequest
wantStatus int
wantTask bool
}{
{
name: "valid_dequeue",
body: DequeueWorkRequest{WorkerID: "worker-1"},
wantStatus: http.StatusOK,
wantTask: true,
},
{
name: "missing_worker_id",
body: DequeueWorkRequest{},
wantStatus: http.StatusBadRequest,
wantTask: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest(http.MethodPost, "/work/dequeue", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String())
}
if tt.wantTask && rec.Code == http.StatusOK {
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
data, ok := resp["data"].(map[string]any)
if !ok {
t.Fatal("response missing data field")
}
if data["task"] == nil {
t.Error("expected task in response")
}
}
})
}
}
func TestWorkHandler_Complete(t *testing.T) {
mockQueue := newMockWorkQueue()
workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{})
handler := NewWorkHandler(workService)
// Pre-populate a running task
mockQueue.tasks["task-1"] = &port.WorkTask{
ID: "task-1",
ProjectID: "test-project",
Type: port.WorkTaskTypeBuild,
Status: port.WorkTaskStatusRunning,
WorkerID: "worker-1",
CreatedAt: time.Now(),
}
router := chi.NewRouter()
handler.Mount(router)
tests := []struct {
name string
taskID string
body CompleteWorkRequest
wantStatus int
}{
{
name: "valid_complete",
taskID: "task-1",
body: CompleteWorkRequest{
Output: "Build successful",
Artifacts: map[string]string{
"commit_sha": "abc123",
},
},
wantStatus: http.StatusOK,
},
{
name: "task_not_found",
taskID: "nonexistent",
body: CompleteWorkRequest{Output: "Done"},
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest(http.MethodPost, "/work/"+tt.taskID+"/complete", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String())
}
})
}
}
func TestWorkHandler_Fail(t *testing.T) {
mockQueue := newMockWorkQueue()
workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{})
handler := NewWorkHandler(workService)
// Pre-populate a running task
mockQueue.tasks["task-1"] = &port.WorkTask{
ID: "task-1",
ProjectID: "test-project",
Type: port.WorkTaskTypeBuild,
Status: port.WorkTaskStatusRunning,
WorkerID: "worker-1",
MaxRetries: 3,
CreatedAt: time.Now(),
}
router := chi.NewRouter()
handler.Mount(router)
tests := []struct {
name string
taskID string
body FailWorkRequest
wantStatus int
}{
{
name: "valid_fail",
taskID: "task-1",
body: FailWorkRequest{Error: "Build failed: npm error"},
wantStatus: http.StatusOK,
},
{
name: "missing_error",
taskID: "task-1",
body: FailWorkRequest{},
wantStatus: http.StatusBadRequest,
},
{
name: "task_not_found",
taskID: "nonexistent",
body: FailWorkRequest{Error: "Failed"},
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest(http.MethodPost, "/work/"+tt.taskID+"/fail", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String())
}
})
}
}
func TestWorkHandler_Cancel(t *testing.T) {
mockQueue := newMockWorkQueue()
workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{})
handler := NewWorkHandler(workService)
// Pre-populate tasks
mockQueue.tasks["pending-task"] = &port.WorkTask{
ID: "pending-task",
ProjectID: "test-project",
Type: port.WorkTaskTypeBuild,
Status: port.WorkTaskStatusPending,
CreatedAt: time.Now(),
}
mockQueue.tasks["running-task"] = &port.WorkTask{
ID: "running-task",
ProjectID: "test-project",
Type: port.WorkTaskTypeBuild,
Status: port.WorkTaskStatusRunning,
CreatedAt: time.Now(),
}
router := chi.NewRouter()
handler.Mount(router)
tests := []struct {
name string
taskID string
wantStatus int
}{
{
name: "cancel_pending_task",
taskID: "pending-task",
wantStatus: http.StatusOK,
},
{
name: "cancel_running_task_fails",
taskID: "running-task",
wantStatus: http.StatusNotFound, // Can only cancel pending tasks
},
{
name: "task_not_found",
taskID: "nonexistent",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/work/"+tt.taskID+"/cancel", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String())
}
})
}
}
func TestWorkHandler_GetTask(t *testing.T) {
mockQueue := newMockWorkQueue()
workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{})
handler := NewWorkHandler(workService)
// Pre-populate a task
mockQueue.tasks["task-1"] = &port.WorkTask{
ID: "task-1",
ProjectID: "test-project",
Type: port.WorkTaskTypeBuild,
Status: port.WorkTaskStatusRunning,
Spec: map[string]any{
"prompt": "Build it",
},
CreatedAt: time.Now(),
}
router := chi.NewRouter()
handler.Mount(router)
tests := []struct {
name string
taskID string
wantStatus int
}{
{
name: "get_existing_task",
taskID: "task-1",
wantStatus: http.StatusOK,
},
{
name: "task_not_found",
taskID: "nonexistent",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/work/"+tt.taskID, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("got status %d, want %d; body: %s", rec.Code, tt.wantStatus, rec.Body.String())
}
})
}
}
func TestWorkHandler_ListByProject(t *testing.T) {
mockQueue := newMockWorkQueue()
workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{})
handler := NewWorkHandler(workService)
// Pre-populate tasks
mockQueue.tasks["task-1"] = &port.WorkTask{
ID: "task-1",
ProjectID: "project-a",
Type: port.WorkTaskTypeBuild,
Status: port.WorkTaskStatusPending,
CreatedAt: time.Now(),
}
mockQueue.tasks["task-2"] = &port.WorkTask{
ID: "task-2",
ProjectID: "project-a",
Type: port.WorkTaskTypeTest,
Status: port.WorkTaskStatusCompleted,
CreatedAt: time.Now(),
}
mockQueue.tasks["task-3"] = &port.WorkTask{
ID: "task-3",
ProjectID: "project-b",
Type: port.WorkTaskTypeDeploy,
Status: port.WorkTaskStatusRunning,
CreatedAt: time.Now(),
}
router := chi.NewRouter()
handler.Mount(router)
t.Run("list_all_for_project", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
data := resp["data"].(map[string]any)
total := int(data["total"].(float64))
if total != 2 {
t.Errorf("got %d tasks, want 2", total)
}
// Verify pagination metadata is present
if _, ok := data["limit"]; !ok {
t.Error("expected limit in response")
}
if _, ok := data["offset"]; !ok {
t.Error("expected offset in response")
}
})
t.Run("list_with_status_filter", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?status=pending", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("got status %d, want %d", rec.Code, http.StatusOK)
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
data := resp["data"].(map[string]any)
total := int(data["total"].(float64))
if total != 1 {
t.Errorf("got %d tasks, want 1", total)
}
})
t.Run("list_with_pagination", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?limit=1&offset=0", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("got status %d, want %d", rec.Code, http.StatusOK)
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
data := resp["data"].(map[string]any)
// Total should reflect all matching tasks
total := int(data["total"].(float64))
if total != 2 {
t.Errorf("got total=%d, want 2", total)
}
// But tasks returned should be limited
tasks := data["tasks"].([]any)
if len(tasks) != 1 {
t.Errorf("got %d tasks returned, want 1", len(tasks))
}
// Verify limit/offset are reflected
if int(data["limit"].(float64)) != 1 {
t.Errorf("got limit=%v, want 1", data["limit"])
}
if int(data["offset"].(float64)) != 0 {
t.Errorf("got offset=%v, want 0", data["offset"])
}
})
t.Run("invalid_limit", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?limit=invalid", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest)
}
})
t.Run("invalid_offset", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?offset=invalid", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest)
}
})
t.Run("invalid_status_filter", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/work/projects/project-a?status=invalid", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("got status %d, want %d", rec.Code, http.StatusBadRequest)
}
})
}
func TestWorkHandler_Stats(t *testing.T) {
mockQueue := newMockWorkQueue()
workService := service.NewWorkService(mockQueue, service.WorkServiceConfig{})
handler := NewWorkHandler(workService)
// Pre-populate tasks with various statuses
mockQueue.tasks["task-1"] = &port.WorkTask{ID: "task-1", Status: port.WorkTaskStatusPending}
mockQueue.tasks["task-2"] = &port.WorkTask{ID: "task-2", Status: port.WorkTaskStatusPending}
mockQueue.tasks["task-3"] = &port.WorkTask{ID: "task-3", Status: port.WorkTaskStatusRunning}
mockQueue.tasks["task-4"] = &port.WorkTask{ID: "task-4", Status: port.WorkTaskStatusCompleted}
mockQueue.tasks["task-5"] = &port.WorkTask{ID: "task-5", Status: port.WorkTaskStatusFailed}
router := chi.NewRouter()
handler.Mount(router)
req := httptest.NewRequest(http.MethodGet, "/work/stats", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("got status %d, want %d; body: %s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
data := resp["data"].(map[string]any)
if int(data["pending"].(float64)) != 2 {
t.Errorf("got pending=%v, want 2", data["pending"])
}
if int(data["running"].(float64)) != 1 {
t.Errorf("got running=%v, want 1", data["running"])
}
if int(data["completed"].(float64)) != 1 {
t.Errorf("got completed=%v, want 1", data["completed"])
}
if int(data["failed"].(float64)) != 1 {
t.Errorf("got failed=%v, want 1", data["failed"])
}
}

View File

@ -0,0 +1,32 @@
// Package port defines interfaces (ports) for external dependencies.
package port
import (
"context"
"github.com/orchard9/rdev/internal/domain"
)
// CIProvider manages CI/CD pipeline configurations.
// Implementations include Woodpecker, GitHub Actions, GitLab CI, etc.
type CIProvider interface {
// ActivateRepo enables CI for a repository.
// The forge is the git forge (e.g., gitea, github) where the repo lives.
// This creates the necessary webhooks and enables the CI pipeline.
ActivateRepo(ctx context.Context, forge, owner, repo string) (*domain.CIRepo, error)
// DeactivateRepo disables CI for a repository.
DeactivateRepo(ctx context.Context, owner, repo string) error
// GetRepo returns the CI configuration for a repository.
GetRepo(ctx context.Context, owner, repo string) (*domain.CIRepo, error)
// ListRepos returns all repositories visible to the CI system.
ListRepos(ctx context.Context) ([]*domain.CIRepo, error)
// AddSecret adds a secret to a repository for use in pipelines.
AddSecret(ctx context.Context, owner, repo string, secret domain.CISecret) error
// DeleteSecret removes a secret from a repository.
DeleteSecret(ctx context.Context, owner, repo, secretName string) error
}

View File

@ -0,0 +1,61 @@
// Package port defines interface contracts for external adapters.
package port
import (
"context"
"github.com/orchard9/rdev/internal/domain"
)
// CodeAgent defines operations for executing AI coding agent commands.
// Implementations handle the specifics of different agent backends
// (Claude Code CLI, OpenCode HTTP API, etc.).
type CodeAgent interface {
// Name returns a human-readable name for this agent implementation.
Name() string
// Provider returns the agent provider identifier.
Provider() domain.AgentProvider
// Execute runs an agent command and streams events to the handler.
// The handler is called for each event during execution.
// Returns the final result when execution completes.
Execute(ctx context.Context, req *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error)
// Cancel attempts to cancel a running agent session.
// Returns nil if cancellation was successful or session not found.
Cancel(ctx context.Context, sessionID string) error
// Capabilities returns what this agent implementation supports.
Capabilities() domain.AgentCapabilities
// Available returns true if the agent is ready to accept requests.
// This may check connectivity to external services.
Available(ctx context.Context) bool
}
// CodeAgentRegistry manages registered code agent implementations.
// It allows looking up agents by provider and setting defaults.
type CodeAgentRegistry interface {
// Register adds an agent implementation for a provider.
// Overwrites any existing registration for the same provider.
Register(agent CodeAgent)
// Get returns the agent for a specific provider.
// Returns nil if no agent is registered for that provider.
Get(provider domain.AgentProvider) CodeAgent
// Default returns the default agent implementation.
// Returns nil if no agents are registered.
Default() CodeAgent
// SetDefault sets which provider should be used as the default.
// Returns error if the provider is not registered.
SetDefault(provider domain.AgentProvider) error
// Available returns all registered providers.
Available() []domain.AgentProvider
// AvailableAgents returns all registered agents that are currently available.
AvailableAgents(ctx context.Context) []CodeAgent
}

View File

@ -0,0 +1,37 @@
// Package port defines interfaces (ports) for external dependencies.
package port
import (
"context"
"github.com/orchard9/rdev/internal/domain"
)
// CredentialStore manages secure storage and retrieval of credentials.
// Credentials are encrypted at rest in the database.
type CredentialStore interface {
// Get retrieves a credential by key. Returns empty string if not found.
Get(ctx context.Context, key string) (string, error)
// GetRequired retrieves a credential by key. Returns error if not found.
GetRequired(ctx context.Context, key string) (string, error)
// Set stores or updates a credential.
Set(ctx context.Context, cred domain.Credential) error
// Delete removes a credential by key.
Delete(ctx context.Context, key string) error
// List returns all credentials (with values masked).
List(ctx context.Context) ([]domain.Credential, error)
// ListByCategory returns credentials in a category (with values masked).
ListByCategory(ctx context.Context, category string) ([]domain.Credential, error)
// GetMultiple retrieves multiple credentials by keys.
// Returns a map of key -> value. Missing keys are omitted.
GetMultiple(ctx context.Context, keys []string) (map[string]string, error)
// SetMultiple stores multiple credentials in a single transaction.
SetMultiple(ctx context.Context, creds []domain.Credential) error
}

View File

@ -0,0 +1,33 @@
// Package port defines interfaces (ports) for external dependencies.
package port
import "context"
// TemplateProvider seeds repositories with starter files.
type TemplateProvider interface {
// SeedRepo populates a repository with template files.
// templateName specifies which template to use (e.g., "default", "astro-landing").
// vars contains template variables for interpolation (e.g., PROJECT_NAME, DOMAIN).
SeedRepo(ctx context.Context, owner, repo, templateName string, vars map[string]string) error
// ListTemplates returns available templates.
ListTemplates(ctx context.Context) ([]TemplateInfo, error)
// GetTemplate returns info about a specific template.
GetTemplate(ctx context.Context, name string) (*TemplateInfo, error)
}
// TemplateInfo describes an available project template.
type TemplateInfo struct {
// Name is the template identifier (e.g., "astro-landing")
Name string
// Description explains what the template provides
Description string
// Stack indicates the technology stack (e.g., "astro", "go", "generic")
Stack string
// Files lists the files included in the template
Files []string
}

215
internal/port/work_queue.go Normal file
View File

@ -0,0 +1,215 @@
// Package port defines interfaces (ports) for external dependencies.
package port
import (
"context"
"time"
)
// WorkQueue defines operations for the worker pool task queue.
// Unlike CommandQueue (project-specific claudebox commands), WorkQueue
// supports generic tasks that any worker in the pool can claim and execute.
type WorkQueue interface {
// Enqueue adds a task to the queue.
// Returns the task ID.
Enqueue(ctx context.Context, task *WorkTask) (string, error)
// Dequeue atomically claims the next available task for a worker.
// Uses FOR UPDATE SKIP LOCKED for concurrent worker safety.
// Returns nil if no tasks are available.
Dequeue(ctx context.Context, workerID string) (*WorkTask, error)
// Complete marks a task as successfully completed with results.
Complete(ctx context.Context, taskID string, result *WorkResult) error
// Fail marks a task as failed with an error message.
// If retry_count < max_retries, the task will be re-queued as pending.
Fail(ctx context.Context, taskID string, errMsg string) error
// Cancel marks a pending task as cancelled.
// Returns an error if the task is not in pending status.
Cancel(ctx context.Context, taskID string) error
// GetTask retrieves a task by ID.
GetTask(ctx context.Context, taskID string) (*WorkTask, error)
// ListByProject returns tasks for a project with optional status filter and pagination.
ListByProject(ctx context.Context, projectID string, status *WorkTaskStatus, opts WorkListOptions) (*WorkListResult, error)
// GetStats returns queue statistics.
GetStats(ctx context.Context) (*WorkQueueStats, error)
// CleanupOld removes completed/failed/cancelled tasks older than the specified duration.
CleanupOld(ctx context.Context, olderThan time.Duration) (int64, error)
// RequeueStale re-queues tasks that have been running longer than the timeout.
// This handles workers that crashed without reporting completion.
RequeueStale(ctx context.Context, timeout time.Duration) (int64, error)
}
// WorkTaskStatus represents the status of a work task.
type WorkTaskStatus string
const (
WorkTaskStatusPending WorkTaskStatus = "pending"
WorkTaskStatusRunning WorkTaskStatus = "running"
WorkTaskStatusCompleted WorkTaskStatus = "completed"
WorkTaskStatusFailed WorkTaskStatus = "failed"
WorkTaskStatusCancelled WorkTaskStatus = "cancelled"
)
// IsValid returns true if the status is a known valid status.
func (s WorkTaskStatus) IsValid() bool {
switch s {
case WorkTaskStatusPending, WorkTaskStatusRunning, WorkTaskStatusCompleted,
WorkTaskStatusFailed, WorkTaskStatusCancelled:
return true
}
return false
}
// WorkTaskType represents the type of work task.
type WorkTaskType string
const (
WorkTaskTypeBuild WorkTaskType = "build"
WorkTaskTypeTest WorkTaskType = "test"
WorkTaskTypeDeploy WorkTaskType = "deploy"
WorkTaskTypeCustom WorkTaskType = "custom"
)
// IsValid returns true if the task type is a known valid type.
func (t WorkTaskType) IsValid() bool {
switch t {
case WorkTaskTypeBuild, WorkTaskTypeTest, WorkTaskTypeDeploy, WorkTaskTypeCustom:
return true
}
return false
}
// WorkTask represents a task in the work queue.
type WorkTask struct {
// ID is the unique task identifier.
ID string
// ProjectID is the project this task belongs to.
ProjectID string
// Type is the task type (build, test, deploy, custom).
Type WorkTaskType
// Spec contains task-specific parameters.
// For build tasks: template, prompt, variables, auto_deploy, git_url
// For test tasks: test_command, git_url
// For deploy tasks: image, replicas, env
Spec map[string]any
// Status is the current task status.
Status WorkTaskStatus
// Priority determines execution order (higher = more urgent).
Priority int
// WorkerID is the ID of the worker that claimed this task.
WorkerID string
// CallbackURL is the webhook URL for completion notification.
CallbackURL string
// CreatedAt is when the task was created.
CreatedAt time.Time
// StartedAt is when a worker started executing the task.
StartedAt *time.Time
// CompletedAt is when the task finished (success or failure).
CompletedAt *time.Time
// Result contains the task output (if completed).
Result *WorkResult
// Error contains the error message (if failed).
Error string
// RetryCount is the number of retry attempts.
RetryCount int
// MaxRetries is the maximum allowed retry attempts.
MaxRetries int
}
// WorkResult contains the result of a completed task.
type WorkResult struct {
// Output is the main output from task execution.
Output string `json:"output,omitempty"`
// Artifacts contains named artifacts from the task.
// For build tasks: commit_sha, deploy_url, etc.
Artifacts map[string]string `json:"artifacts,omitempty"`
}
// WorkQueueStats contains queue statistics.
type WorkQueueStats struct {
// Pending is the count of pending tasks.
Pending int64 `json:"pending"`
// Running is the count of running tasks.
Running int64 `json:"running"`
// Completed is the count of completed tasks (last 24h).
Completed int64 `json:"completed"`
// Failed is the count of failed tasks (last 24h).
Failed int64 `json:"failed"`
// Cancelled is the count of cancelled tasks (last 24h).
Cancelled int64 `json:"cancelled"`
// OldestPending is the age of the oldest pending task.
OldestPending *time.Duration `json:"oldest_pending,omitempty"`
}
// WorkListOptions contains pagination options for listing tasks.
type WorkListOptions struct {
// Limit is the maximum number of tasks to return (default: 50, max: 100).
Limit int
// Offset is the number of tasks to skip (for pagination).
Offset int
}
// DefaultWorkListOptions returns options with default values.
func DefaultWorkListOptions() WorkListOptions {
return WorkListOptions{
Limit: 50,
Offset: 0,
}
}
// Normalize applies defaults and limits to the options.
func (o *WorkListOptions) Normalize() {
if o.Limit <= 0 {
o.Limit = 50
}
if o.Limit > 100 {
o.Limit = 100
}
if o.Offset < 0 {
o.Offset = 0
}
}
// WorkListResult contains paginated task results.
type WorkListResult struct {
// Tasks is the list of tasks.
Tasks []*WorkTask
// Total is the total count of matching tasks (for pagination metadata).
Total int64
// Limit is the limit that was applied.
Limit int
// Offset is the offset that was applied.
Offset int
}

View File

@ -50,13 +50,15 @@ func ValidateProjectName(name string) error {
}
// ProjectInfraService orchestrates project infrastructure operations.
// It coordinates git repo creation, DNS, and deployment.
// It coordinates git repo creation, DNS, CI activation, template seeding, and deployment.
type ProjectInfraService struct {
db *sql.DB
gitRepo port.GitRepository
dns port.DNSProvider
deployer port.Deployer
logger *slog.Logger
db *sql.DB
gitRepo port.GitRepository
dns port.DNSProvider
deployer port.Deployer
ciProvider port.CIProvider
templateProvider port.TemplateProvider
logger *slog.Logger
// Config
defaultGitOwner string
@ -78,6 +80,8 @@ func NewProjectInfraService(
gitRepo port.GitRepository,
dns port.DNSProvider,
deployer port.Deployer,
ciProvider port.CIProvider,
templateProvider port.TemplateProvider,
cfg ProjectInfraConfig,
) *ProjectInfraService {
logger := cfg.Logger
@ -85,14 +89,16 @@ func NewProjectInfraService(
logger = slog.Default()
}
return &ProjectInfraService{
db: db,
gitRepo: gitRepo,
dns: dns,
deployer: deployer,
logger: logger,
defaultGitOwner: cfg.DefaultGitOwner,
defaultDomain: cfg.DefaultDomain,
clusterIP: cfg.ClusterIP,
db: db,
gitRepo: gitRepo,
dns: dns,
deployer: deployer,
ciProvider: ciProvider,
templateProvider: templateProvider,
logger: logger,
defaultGitOwner: cfg.DefaultGitOwner,
defaultDomain: cfg.DefaultDomain,
clusterIP: cfg.ClusterIP,
}
}
@ -101,6 +107,7 @@ type CreateProjectRequest struct {
Name string
Description string
Private bool
Template string // Template to seed the repo with (default: "default")
}
// CreateProjectResult contains the result of project creation.
@ -129,7 +136,7 @@ type CreateProjectResult struct {
func (s *ProjectInfraService) CreateProject(ctx context.Context, req CreateProjectRequest) (*CreateProjectResult, error) {
// Validate project name first
if err := ValidateProjectName(req.Name); err != nil {
return nil, fmt.Errorf("invalid project name: %w", err)
return nil, fmt.Errorf("%w: %v", domain.ErrInvalidProjectName, err)
}
s.logger.Info("creating project", "name", req.Name)
@ -216,12 +223,46 @@ func (s *ProjectInfraService) CreateProject(ctx context.Context, req CreateProje
result.NextSteps = append(result.NextSteps, "DNS service not configured")
}
// 4. Add next steps for Woodpecker activation
if result.HTMLURL != "" {
result.NextSteps = append(result.NextSteps,
fmt.Sprintf("Activate in Woodpecker: https://ci.%s → Add Repository → %s/%s", s.defaultDomain, s.defaultGitOwner, req.Name),
"Add .woodpecker.yml to your repo for CI/CD",
)
// 4. Activate CI (Woodpecker)
if s.ciProvider != nil && result.GitRepoOwner != "" {
ciRepo, err := s.ciProvider.ActivateRepo(ctx, "gitea", result.GitRepoOwner, result.GitRepoName)
if err != nil {
s.logger.Warn("failed to activate CI", "error", err)
result.NextSteps = append(result.NextSteps,
fmt.Sprintf("Activate Woodpecker manually: https://ci.%s → Add Repository → %s/%s", s.defaultDomain, result.GitRepoOwner, result.GitRepoName),
)
} else {
s.logger.Info("CI activated", "repo", ciRepo.FullName, "ci_id", ciRepo.ID)
}
} else if s.ciProvider == nil {
result.NextSteps = append(result.NextSteps, "CI provider not configured")
}
// 5. Seed repository with template
if s.templateProvider != nil && result.GitRepoOwner != "" {
templateName := req.Template
if templateName == "" {
templateName = "default"
}
// Prepare template variables
vars := map[string]string{
"PROJECT_NAME": req.Name,
"DOMAIN": result.Domain,
"GIT_URL": result.CloneHTTP,
}
err := s.templateProvider.SeedRepo(ctx, result.GitRepoOwner, result.GitRepoName, templateName, vars)
if err != nil {
s.logger.Warn("failed to seed repo with template", "error", err, "template", templateName)
result.NextSteps = append(result.NextSteps,
fmt.Sprintf("Add template files manually (template: %s)", templateName),
)
} else {
s.logger.Info("repo seeded with template", "template", templateName)
}
} else if s.templateProvider == nil {
result.NextSteps = append(result.NextSteps, "Template provider not configured")
}
s.logger.Info("project created successfully",
@ -279,7 +320,7 @@ func (s *ProjectInfraService) GetStatus(ctx context.Context, projectID string) (
&status.DeploymentImage, &status.DeploymentStatus, &status.DeploymentReplicas,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("project not found: %s", projectID)
return nil, fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID)
}
if err != nil {
return nil, fmt.Errorf("failed to get project: %w", err)
@ -381,3 +422,19 @@ func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID strin
s.logger.Info("project deleted", "project", projectID)
return nil
}
// ListTemplates returns available project templates.
func (s *ProjectInfraService) ListTemplates(ctx context.Context) ([]port.TemplateInfo, error) {
if s.templateProvider == nil {
return nil, fmt.Errorf("template provider not configured")
}
return s.templateProvider.ListTemplates(ctx)
}
// GetTemplate returns info about a specific template.
func (s *ProjectInfraService) GetTemplate(ctx context.Context, name string) (*port.TemplateInfo, error) {
if s.templateProvider == nil {
return nil, fmt.Errorf("template provider not configured")
}
return s.templateProvider.GetTemplate(ctx, name)
}

View File

@ -25,6 +25,7 @@ type ProjectService struct {
auditLogger port.AuditLogger // Optional audit logger
queue port.CommandQueue // Optional command queue
webhookDispatcher port.WebhookDispatcher // Optional webhook dispatcher
agentRegistry port.CodeAgentRegistry // Optional code agent registry
logger *slog.Logger
cmdID atomic.Uint64
}
@ -67,6 +68,12 @@ func (s *ProjectService) WithWebhookDispatcher(dispatcher port.WebhookDispatcher
return s
}
// WithCodeAgentRegistry sets a code agent registry for multi-provider support.
func (s *ProjectService) WithCodeAgentRegistry(registry port.CodeAgentRegistry) *ProjectService {
s.agentRegistry = registry
return s
}
// AuditContext contains audit-related information from the request.
type AuditContext struct {
APIKeyID string
@ -105,16 +112,21 @@ func (s *ProjectService) Exists(ctx context.Context, id domain.ProjectID) (bool,
// ExecuteClaudeRequest contains parameters for running a Claude command.
type ExecuteClaudeRequest struct {
ProjectID domain.ProjectID
Prompt string
StreamID string
Audit *AuditContext // Optional audit context
ProjectID domain.ProjectID
Prompt string
StreamID string
SessionID string // Optional: resume a previous session
Model string // Optional: model override (OpenCode only)
AllowedTools []string // Optional: restrict tool access
Audit *AuditContext // Optional audit context
}
// ExecuteClaudeResult contains the result of queuing a Claude command.
type ExecuteClaudeResult struct {
CommandID domain.CommandID
StreamURL string
CommandID domain.CommandID
StreamURL string
SessionID string // Session ID for continuation
AgentProvider domain.AgentProvider
}
// ExecuteClaude runs a Claude command in the project's pod.
@ -174,7 +186,29 @@ func (s *ProjectService) ExecuteClaude(ctx context.Context, req ExecuteClaudeReq
}
}
// Execute in background
// Resolve agent and execute
agent := s.resolveAgent(project)
if agent != nil {
// Use CodeAgent for execution
agentReq := &domain.AgentRequest{
Prompt: req.Prompt,
ProjectID: req.ProjectID,
SessionID: req.SessionID,
Model: req.Model,
AllowedTools: req.AllowedTools,
Metadata: map[string]string{"pod_name": project.PodName},
}
go s.executeAgentCommand(agent, agentReq, cmd)
return &ExecuteClaudeResult{
CommandID: cmdID,
StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID),
SessionID: req.SessionID, // Will be updated by agent result
AgentProvider: agent.Provider(),
}, nil
}
// Fallback to legacy executor
go s.executeCommand(project.PodName, cmd)
return &ExecuteClaudeResult{
@ -183,166 +217,6 @@ func (s *ProjectService) ExecuteClaude(ctx context.Context, req ExecuteClaudeReq
}, nil
}
// ExecuteShellRequest contains parameters for running a shell command.
type ExecuteShellRequest struct {
ProjectID domain.ProjectID
Command string
StreamID string
Audit *AuditContext // Optional audit context
}
// ExecuteShellResult contains the result of queuing a shell command.
type ExecuteShellResult struct {
CommandID domain.CommandID
StreamURL string
}
// ExecuteShell runs a shell command in the project's pod.
func (s *ProjectService) ExecuteShell(ctx context.Context, req ExecuteShellRequest) (*ExecuteShellResult, error) {
// Validate project exists
project, err := s.projects.Get(ctx, req.ProjectID)
if err != nil {
return nil, err
}
// Validate command
if req.Command == "" {
return nil, fmt.Errorf("%w: command is required", domain.ErrInvalidCommand)
}
if err := sanitize.ShellCommand(req.Command); err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrCommandSanitization, err)
}
// Validate stream ID
if err := sanitize.StreamID(req.StreamID); err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrInvalidCommand, err)
}
// Generate command ID
cmdNum := s.cmdID.Add(1)
cmdID := domain.CommandID(fmt.Sprintf("cmd-%s-%03d", req.ProjectID, cmdNum))
if req.StreamID != "" {
cmdID = domain.CommandID(req.StreamID)
}
// Create command
cmd := &domain.Command{
ID: cmdID,
ProjectID: req.ProjectID,
Type: domain.CommandTypeShell,
Args: []string{req.Command},
StartedAt: time.Now(),
}
// Log audit start if audit logger is configured
if s.auditLogger != nil && req.Audit != nil {
argsJSON, _ := json.Marshal(cmd.Args)
auditEntry := &domain.AuditLogEntry{
ID: uuid.New().String(),
APIKeyID: req.Audit.APIKeyID,
CommandID: string(cmdID),
ProjectID: string(req.ProjectID),
CommandType: domain.CommandTypeShell,
Args: string(argsJSON),
ClientIP: req.Audit.ClientIP,
UserAgent: req.Audit.UserAgent,
StartedAt: cmd.StartedAt,
Status: domain.AuditStatusRunning,
}
if err := s.auditLogger.LogCommandStart(ctx, auditEntry); err != nil {
s.logger.Warn("failed to log audit start", "command_id", cmdID, "error", err)
}
}
// Execute in background
go s.executeCommand(project.PodName, cmd)
return &ExecuteShellResult{
CommandID: cmdID,
StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID),
}, nil
}
// ExecuteGitRequest contains parameters for running a git command.
type ExecuteGitRequest struct {
ProjectID domain.ProjectID
Args []string
StreamID string
Audit *AuditContext // Optional audit context
}
// ExecuteGitResult contains the result of queuing a git command.
type ExecuteGitResult struct {
CommandID domain.CommandID
StreamURL string
}
// ExecuteGit runs a git command in the project's pod.
func (s *ProjectService) ExecuteGit(ctx context.Context, req ExecuteGitRequest) (*ExecuteGitResult, error) {
// Validate project exists
project, err := s.projects.Get(ctx, req.ProjectID)
if err != nil {
return nil, err
}
// Validate args
if len(req.Args) == 0 {
return nil, fmt.Errorf("%w: args is required", domain.ErrInvalidCommand)
}
if err := sanitize.GitArgs(req.Args); err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrCommandSanitization, err)
}
// Validate stream ID
if err := sanitize.StreamID(req.StreamID); err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrInvalidCommand, err)
}
// Generate command ID
cmdNum := s.cmdID.Add(1)
cmdID := domain.CommandID(fmt.Sprintf("cmd-%s-%03d", req.ProjectID, cmdNum))
if req.StreamID != "" {
cmdID = domain.CommandID(req.StreamID)
}
// Create command
cmd := &domain.Command{
ID: cmdID,
ProjectID: req.ProjectID,
Type: domain.CommandTypeGit,
Args: req.Args,
StartedAt: time.Now(),
}
// Log audit start if audit logger is configured
if s.auditLogger != nil && req.Audit != nil {
argsJSON, _ := json.Marshal(cmd.Args)
auditEntry := &domain.AuditLogEntry{
ID: uuid.New().String(),
APIKeyID: req.Audit.APIKeyID,
CommandID: string(cmdID),
ProjectID: string(req.ProjectID),
CommandType: domain.CommandTypeGit,
Args: string(argsJSON),
ClientIP: req.Audit.ClientIP,
UserAgent: req.Audit.UserAgent,
StartedAt: cmd.StartedAt,
Status: domain.AuditStatusRunning,
}
if err := s.auditLogger.LogCommandStart(ctx, auditEntry); err != nil {
s.logger.Warn("failed to log audit start", "command_id", cmdID, "error", err)
}
}
// Execute in background
go s.executeCommand(project.PodName, cmd)
return &ExecuteGitResult{
CommandID: cmdID,
StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID),
}, nil
}
// executeCommand runs a command and streams output to subscribers.
func (s *ProjectService) executeCommand(podName string, cmd *domain.Command) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
@ -484,101 +358,3 @@ func (s *ProjectService) Subscribe(streamID string) (<-chan port.StreamEvent, fu
func (s *ProjectService) SubscribeFromID(streamID, lastEventID string) (<-chan port.StreamEvent, func()) {
return s.streams.SubscribeFromID(streamID, lastEventID)
}
// EnqueueCommandRequest contains parameters for enqueueing a command.
type EnqueueCommandRequest struct {
ProjectID domain.ProjectID
Command string
CommandType domain.CommandType
WorkingDir string
Priority int
Audit *AuditContext
}
// EnqueueCommandResult contains the result of enqueueing a command.
type EnqueueCommandResult struct {
CommandID domain.QueuedCommandID
StreamURL string
Position int
}
// EnqueueCommand adds a command to the project's queue for async execution.
// Returns an error if no queue is configured.
func (s *ProjectService) EnqueueCommand(ctx context.Context, req EnqueueCommandRequest) (*EnqueueCommandResult, error) {
if s.queue == nil {
return nil, fmt.Errorf("command queue not configured")
}
// Validate project exists
exists, err := s.projects.Exists(ctx, req.ProjectID)
if err != nil {
return nil, err
}
if !exists {
return nil, domain.ErrProjectNotFound
}
// Create queued command
cmd := &domain.QueuedCommand{
ProjectID: string(req.ProjectID),
Command: req.Command,
CommandType: req.CommandType,
WorkingDir: req.WorkingDir,
Status: domain.QueueStatusPending,
Priority: req.Priority,
}
if req.Audit != nil {
cmd.APIKeyID = req.Audit.APIKeyID
}
// Enqueue
if err := s.queue.Enqueue(ctx, cmd); err != nil {
return nil, fmt.Errorf("enqueue command: %w", err)
}
// Get approximate position
pendingStatus := domain.QueueStatusPending
pending, _ := s.queue.List(ctx, string(req.ProjectID), &domain.QueueFilters{
Status: &pendingStatus,
Limit: 1000,
SortOrder: "asc",
})
return &EnqueueCommandResult{
CommandID: cmd.ID,
StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmd.ID),
Position: len(pending),
}, nil
}
// GetQueuedCommand retrieves a queued command by ID.
func (s *ProjectService) GetQueuedCommand(ctx context.Context, cmdID domain.QueuedCommandID) (*domain.QueuedCommand, error) {
if s.queue == nil {
return nil, fmt.Errorf("command queue not configured")
}
return s.queue.GetByID(ctx, cmdID)
}
// ListQueuedCommands returns queued commands for a project.
func (s *ProjectService) ListQueuedCommands(ctx context.Context, projectID domain.ProjectID, filters *domain.QueueFilters) ([]*domain.QueuedCommand, error) {
if s.queue == nil {
return nil, fmt.Errorf("command queue not configured")
}
return s.queue.List(ctx, string(projectID), filters)
}
// CancelQueuedCommand cancels a pending queued command.
func (s *ProjectService) CancelQueuedCommand(ctx context.Context, cmdID domain.QueuedCommandID) error {
if s.queue == nil {
return fmt.Errorf("command queue not configured")
}
return s.queue.Cancel(ctx, cmdID)
}
// GetQueueStats returns queue statistics for a project.
func (s *ProjectService) GetQueueStats(ctx context.Context, projectID domain.ProjectID) (*domain.QueueStats, error) {
if s.queue == nil {
return nil, fmt.Errorf("command queue not configured")
}
return s.queue.GetStats(ctx, string(projectID))
}

View File

@ -0,0 +1,223 @@
// Package service provides business logic / use cases for the application.
// This file contains CodeAgent integration for multi-provider support.
package service
import (
"context"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/metrics"
"github.com/orchard9/rdev/internal/port"
)
// resolveAgent returns the appropriate CodeAgent for a project.
// Returns nil if no agent registry is configured or no agent is available.
func (s *ProjectService) resolveAgent(project *domain.Project) port.CodeAgent {
if s.agentRegistry == nil {
return nil
}
// Try project-specific agent first
if project.AgentProvider != "" {
if agent := s.agentRegistry.Get(project.AgentProvider); agent != nil {
return agent
}
}
// Fall back to default
return s.agentRegistry.Default()
}
// executeAgentCommand runs a command via CodeAgent and streams output.
func (s *ProjectService) executeAgentCommand(agent port.CodeAgent, req *domain.AgentRequest, cmd *domain.Command) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
streamID := string(cmd.ID)
var lastEventID string
var outputSizeBytes int64
// Dispatch command.started webhook event
s.dispatchWebhookEvent(ctx, string(cmd.ProjectID), domain.WebhookEventCommandStarted, &domain.CommandEventData{
CommandID: string(cmd.ID),
CommandType: cmd.Type,
ProjectID: string(cmd.ProjectID),
StartedAt: cmd.StartedAt,
})
// Execute via agent with event handler
result, _ := agent.Execute(ctx, req, func(event domain.AgentEvent) {
// Convert agent event to stream event
var eventType string
var data map[string]any
switch event.Type {
case domain.AgentEventOutput:
eventType = "output"
data = map[string]any{
"line": event.Content,
"stream": event.Stream,
}
case domain.AgentEventToolUse:
eventType = "tool_use"
data = map[string]any{
"tool": event.ToolName,
"input": event.ToolInput,
}
case domain.AgentEventToolResult:
eventType = "tool_result"
data = map[string]any{
"output": event.Content,
}
case domain.AgentEventError:
eventType = "error"
data = map[string]any{
"error": event.Content,
}
case domain.AgentEventComplete:
eventType = "agent_complete"
data = event.Metadata
default:
eventType = "output"
data = map[string]any{
"line": event.Content,
"stream": "stdout",
}
}
eventID := s.streams.Publish(streamID, port.StreamEvent{
Type: eventType,
Data: data,
})
lastEventID = eventID
outputSizeBytes += int64(len(event.Content))
})
// Send completion event
eventID := s.streams.Publish(streamID, port.StreamEvent{
Type: "complete",
Data: map[string]any{
"exit_code": result.ExitCode,
"duration_ms": result.DurationMs,
"session_id": result.SessionID,
"provider": agent.Provider(),
},
})
// Record metrics
status := "success"
if result.ExitCode != 0 {
status = "error"
}
metrics.RecordCommand(string(cmd.ProjectID), string(cmd.Type), status, result.DurationMs)
// Log audit completion if audit logger is configured
if s.auditLogger != nil {
var auditStatus domain.AuditStatus
var errorMsg string
if result.Error != nil {
auditStatus = domain.AuditStatusError
errorMsg = result.Error.Error()
} else if result.ExitCode != 0 {
auditStatus = domain.AuditStatusError
} else {
auditStatus = domain.AuditStatusSuccess
}
auditResult := &domain.AuditResult{
ExitCode: result.ExitCode,
DurationMs: result.DurationMs,
Status: auditStatus,
ErrorMessage: errorMsg,
OutputSizeBytes: outputSizeBytes,
}
if err := s.auditLogger.LogCommandEnd(ctx, string(cmd.ID), auditResult); err != nil {
s.logger.Warn("failed to log audit end", "command_id", cmd.ID, "error", err)
}
}
// Dispatch command.completed or command.failed webhook event
completedAt := time.Now()
var webhookEventType domain.WebhookEventType
var errorMsg string
if result.Error != nil {
webhookEventType = domain.WebhookEventCommandFailed
errorMsg = result.Error.Error()
} else if result.ExitCode != 0 {
webhookEventType = domain.WebhookEventCommandFailed
} else {
webhookEventType = domain.WebhookEventCommandCompleted
}
s.dispatchWebhookEvent(ctx, string(cmd.ProjectID), webhookEventType, &domain.CommandEventData{
CommandID: string(cmd.ID),
CommandType: cmd.Type,
ProjectID: string(cmd.ProjectID),
StartedAt: cmd.StartedAt,
CompletedAt: completedAt,
ExitCode: result.ExitCode,
DurationMs: result.DurationMs,
Error: errorMsg,
})
s.logger.Debug("agent command completed",
"command_id", cmd.ID,
"provider", agent.Provider(),
"session_id", result.SessionID,
"exit_code", result.ExitCode,
"duration_ms", result.DurationMs,
"last_event_id", lastEventID,
"complete_event_id", eventID,
)
// Clean up stream after a delay
go func() {
time.Sleep(30 * time.Second)
s.streams.Close(streamID)
}()
}
// GetAgentCapabilities returns the capabilities for a specific agent provider.
// Returns nil if no agent registry is configured or the provider is not found.
func (s *ProjectService) GetAgentCapabilities(provider domain.AgentProvider) *domain.AgentCapabilities {
if s.agentRegistry == nil {
return nil
}
agent := s.agentRegistry.Get(provider)
if agent == nil {
return nil
}
caps := agent.Capabilities()
return &caps
}
// ListAvailableAgents returns all available agent providers.
func (s *ProjectService) ListAvailableAgents() []domain.AgentProvider {
if s.agentRegistry == nil {
return nil
}
return s.agentRegistry.Available()
}
// GetDefaultAgent returns the default agent provider.
func (s *ProjectService) GetDefaultAgent() domain.AgentProvider {
if s.agentRegistry == nil {
return ""
}
agent := s.agentRegistry.Default()
if agent == nil {
return ""
}
return agent.Provider()
}
// SetDefaultAgent sets the default agent provider.
func (s *ProjectService) SetDefaultAgent(provider domain.AgentProvider) error {
if s.agentRegistry == nil {
return domain.ErrInvalidAgentProvider
}
return s.agentRegistry.SetDefault(provider)
}

Some files were not shown because too many files have changed in this diff Show More