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:
parent
812b8341be
commit
39df51defd
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
111
CLAUDE.md
@ -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
191
CODING_GUIDELINES.md
Normal 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
|
||||
80
ai-lookup/features/build-orchestration.md
Normal file
80
ai-lookup/features/build-orchestration.md
Normal 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)
|
||||
62
ai-lookup/features/command-execution.md
Normal file
62
ai-lookup/features/command-execution.md
Normal 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)
|
||||
52
ai-lookup/features/infrastructure.md
Normal file
52
ai-lookup/features/infrastructure.md
Normal 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)
|
||||
53
ai-lookup/features/sse-streaming.md
Normal file
53
ai-lookup/features/sse-streaming.md
Normal 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
32
ai-lookup/index.md
Normal 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
|
||||
44
ai-lookup/patterns/hexagonal.md
Normal file
44
ai-lookup/patterns/hexagonal.md
Normal 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)
|
||||
43
ai-lookup/services/api-keys.md
Normal file
43
ai-lookup/services/api-keys.md
Normal 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)
|
||||
49
ai-lookup/services/ci-provider.md
Normal file
49
ai-lookup/services/ci-provider.md
Normal 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)
|
||||
44
ai-lookup/services/project-service.md
Normal file
44
ai-lookup/services/project-service.md
Normal 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)
|
||||
76
ai-lookup/services/template-provider.md
Normal file
76
ai-lookup/services/template-provider.md
Normal 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)
|
||||
51
ai-lookup/services/webhooks.md
Normal file
51
ai-lookup/services/webhooks.md
Normal 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)
|
||||
57
ai-lookup/services/work-queue.md
Normal file
57
ai-lookup/services/work-queue.md
Normal 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)
|
||||
61
ai-lookup/services/worker-pool.md
Normal file
61
ai-lookup/services/worker-pool.md
Normal 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)
|
||||
@ -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
275
cookbooks/VISION.md
Normal 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
342
cookbooks/landing-page.md
Normal 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
|
||||
8
deployments/k8s/base/namespace-projects.yaml
Normal file
8
deployments/k8s/base/namespace-projects.yaml
Normal 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
|
||||
@ -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
|
||||
|
||||
43
deployments/k8s/base/templates/astro-landing/.woodpecker.yml
Normal file
43
deployments/k8s/base/templates/astro-landing/.woodpecker.yml
Normal 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
|
||||
20
deployments/k8s/base/templates/astro-landing/Dockerfile
Normal file
20
deployments/k8s/base/templates/astro-landing/Dockerfile
Normal 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;"]
|
||||
32
deployments/k8s/base/templates/astro-landing/README.md
Normal file
32
deployments/k8s/base/templates/astro-landing/README.md
Normal 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.
|
||||
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [tailwind()],
|
||||
output: 'static',
|
||||
});
|
||||
27
deployments/k8s/base/templates/astro-landing/nginx.conf
Normal file
27
deployments/k8s/base/templates/astro-landing/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
18
deployments/k8s/base/templates/astro-landing/package.json
Normal file
18
deployments/k8s/base/templates/astro-landing/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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: [],
|
||||
};
|
||||
29
deployments/k8s/base/templates/default/.woodpecker.yml
Normal file
29
deployments/k8s/base/templates/default/.woodpecker.yml
Normal 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
|
||||
9
deployments/k8s/base/templates/default/Dockerfile
Normal file
9
deployments/k8s/base/templates/default/Dockerfile
Normal 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;"]
|
||||
21
deployments/k8s/base/templates/default/README.md
Normal file
21
deployments/k8s/base/templates/default/README.md
Normal 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}}
|
||||
43
deployments/k8s/base/templates/go-api/.woodpecker.yml
Normal file
43
deployments/k8s/base/templates/go-api/.woodpecker.yml
Normal 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
|
||||
23
deployments/k8s/base/templates/go-api/Dockerfile
Normal file
23
deployments/k8s/base/templates/go-api/Dockerfile
Normal 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"]
|
||||
33
deployments/k8s/base/templates/go-api/README.md
Normal file
33
deployments/k8s/base/templates/go-api/README.md
Normal 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.
|
||||
47
deployments/k8s/base/templates/go-api/cmd/api/main.go
Normal file
47
deployments/k8s/base/templates/go-api/cmd/api/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
5
deployments/k8s/base/templates/go-api/go.mod
Normal file
5
deployments/k8s/base/templates/go-api/go.mod
Normal file
@ -0,0 +1,5 @@
|
||||
module github.com/orchard9/{{PROJECT_NAME}}
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/go-chi/chi/v5 v5.0.12
|
||||
584
docs/features/multi-provider.md
Normal file
584
docs/features/multi-provider.md
Normal 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
5
go.mod
@ -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
18
go.sum
@ -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=
|
||||
|
||||
346
internal/adapter/codeagent/claudecode/adapter.go
Normal file
346
internal/adapter/codeagent/claudecode/adapter.go
Normal 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)
|
||||
}
|
||||
289
internal/adapter/codeagent/claudecode/adapter_test.go
Normal file
289
internal/adapter/codeagent/claudecode/adapter_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
162
internal/adapter/codeagent/claudecode/parser.go
Normal file
162
internal/adapter/codeagent/claudecode/parser.go
Normal 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"
|
||||
}
|
||||
350
internal/adapter/codeagent/claudecode/parser_test.go
Normal file
350
internal/adapter/codeagent/claudecode/parser_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
388
internal/adapter/codeagent/opencode/adapter.go
Normal file
388
internal/adapter/codeagent/opencode/adapter.go
Normal 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
|
||||
}
|
||||
462
internal/adapter/codeagent/opencode/adapter_test.go
Normal file
462
internal/adapter/codeagent/opencode/adapter_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
310
internal/adapter/codeagent/opencode/client.go
Normal file
310
internal/adapter/codeagent/opencode/client.go
Normal 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)
|
||||
}
|
||||
}
|
||||
122
internal/adapter/codeagent/registry.go
Normal file
122
internal/adapter/codeagent/registry.go
Normal 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)
|
||||
}
|
||||
261
internal/adapter/codeagent/registry_test.go
Normal file
261
internal/adapter/codeagent/registry_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
233
internal/adapter/postgres/credential_store.go
Normal file
233
internal/adapter/postgres/credential_store.go
Normal 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
|
||||
}
|
||||
526
internal/adapter/postgres/work_queue.go
Normal file
526
internal/adapter/postgres/work_queue.go
Normal 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
|
||||
}
|
||||
230
internal/adapter/templates/provider.go
Normal file
230
internal/adapter/templates/provider.go
Normal 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
|
||||
}
|
||||
143
internal/adapter/templates/provider_test.go
Normal file
143
internal/adapter/templates/provider_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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;"]
|
||||
32
internal/adapter/templates/templates/astro-landing/README.md
Normal file
32
internal/adapter/templates/templates/astro-landing/README.md
Normal 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.
|
||||
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [tailwind()],
|
||||
output: 'static',
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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: [],
|
||||
};
|
||||
29
internal/adapter/templates/templates/default/.woodpecker.yml
Normal file
29
internal/adapter/templates/templates/default/.woodpecker.yml
Normal 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
|
||||
9
internal/adapter/templates/templates/default/Dockerfile
Normal file
9
internal/adapter/templates/templates/default/Dockerfile
Normal 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;"]
|
||||
21
internal/adapter/templates/templates/default/README.md
Normal file
21
internal/adapter/templates/templates/default/README.md
Normal 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}}
|
||||
43
internal/adapter/templates/templates/go-api/.woodpecker.yml
Normal file
43
internal/adapter/templates/templates/go-api/.woodpecker.yml
Normal 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
|
||||
23
internal/adapter/templates/templates/go-api/Dockerfile
Normal file
23
internal/adapter/templates/templates/go-api/Dockerfile
Normal 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"]
|
||||
33
internal/adapter/templates/templates/go-api/README.md
Normal file
33
internal/adapter/templates/templates/go-api/README.md
Normal 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.
|
||||
47
internal/adapter/templates/templates/go-api/cmd/api/main.go
Normal file
47
internal/adapter/templates/templates/go-api/cmd/api/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
5
internal/adapter/templates/templates/go-api/go.mod.tmpl
Normal file
5
internal/adapter/templates/templates/go-api/go.mod.tmpl
Normal file
@ -0,0 +1,5 @@
|
||||
module github.com/orchard9/{{PROJECT_NAME}}
|
||||
|
||||
go 1.22
|
||||
|
||||
require github.com/go-chi/chi/v5 v5.0.12
|
||||
313
internal/adapter/woodpecker/client.go
Normal file
313
internal/adapter/woodpecker/client.go
Normal 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
|
||||
}
|
||||
}
|
||||
230
internal/adapter/woodpecker/client_test.go
Normal file
230
internal/adapter/woodpecker/client_test.go
Normal 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.
|
||||
})
|
||||
}
|
||||
}
|
||||
43
internal/db/migrations/009_credentials.sql
Normal file
43
internal/db/migrations/009_credentials.sql
Normal 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';
|
||||
58
internal/db/migrations/010_work_queue.sql
Normal file
58
internal/db/migrations/010_work_queue.sql
Normal 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
88
internal/domain/ci.go
Normal 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
|
||||
}
|
||||
197
internal/domain/code_agent.go
Normal file
197
internal/domain/code_agent.go
Normal 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
|
||||
}
|
||||
168
internal/domain/code_agent_test.go
Normal file
168
internal/domain/code_agent_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
58
internal/domain/credential.go
Normal file
58
internal/domain/credential.go
Normal 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"
|
||||
)
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
235
internal/handlers/credentials.go
Normal file
235
internal/handlers/credentials.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
368
internal/handlers/projects_commands.go
Normal file
368
internal/handlers/projects_commands.go
Normal 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)
|
||||
}()
|
||||
}
|
||||
88
internal/handlers/projects_stream.go
Normal file
88
internal/handlers/projects_stream.go
Normal 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
459
internal/handlers/work.go
Normal 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)
|
||||
}
|
||||
780
internal/handlers/work_test.go
Normal file
780
internal/handlers/work_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
32
internal/port/ci_provider.go
Normal file
32
internal/port/ci_provider.go
Normal 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
|
||||
}
|
||||
61
internal/port/code_agent.go
Normal file
61
internal/port/code_agent.go
Normal 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
|
||||
}
|
||||
37
internal/port/credential_store.go
Normal file
37
internal/port/credential_store.go
Normal 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
|
||||
}
|
||||
33
internal/port/template_provider.go
Normal file
33
internal/port/template_provider.go
Normal 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
215
internal/port/work_queue.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
223
internal/service/project_service_agent.go
Normal file
223
internal/service/project_service_agent.go
Normal 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
Loading…
Reference in New Issue
Block a user