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
|
/rdev-api
|
||||||
coverage.out
|
coverage.out
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
|
||||||
# Deploy keys (generated, never commit)
|
# Deploy keys (generated, never commit)
|
||||||
*-deploy-key
|
*-deploy-key
|
||||||
*-deploy-key.pub
|
*-deploy-key.pub
|
||||||
|
|||||||
111
CLAUDE.md
111
CLAUDE.md
@ -1,46 +1,117 @@
|
|||||||
# rdev - Remote Developer
|
# 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
|
## Quick Reference
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Set kubeconfig for k3s (REQUIRED before any kubectl)
|
# Set kubeconfig (REQUIRED)
|
||||||
export KUBECONFIG=~/.kube/orchard9-k3sf.yaml
|
export KUBECONFIG=~/.kube/orchard9-k3sf.yaml
|
||||||
|
|
||||||
|
# Run locally
|
||||||
|
go run ./cmd/rdev-api
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
# Deploy
|
# Deploy
|
||||||
kubectl apply -k deployments/k8s/base
|
kubectl apply -k deployments/k8s/base
|
||||||
|
|
||||||
# Verify
|
# Verify pods
|
||||||
kubectl exec -n rdev claudebox-0 -- claude --version
|
kubectl get pods -n rdev
|
||||||
|
|
||||||
# Test Claude
|
# Check workers
|
||||||
kubectl exec -it -n rdev claudebox-0 -- claude "say hello"
|
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)
|
cmd/rdev-api/ # Entry point, DI, OpenAPI spec
|
||||||
├── claudebox-0 (StatefulSet)
|
internal/
|
||||||
│ ├── Claude Code CLI
|
├── domain/ # Pure business models (no deps)
|
||||||
│ ├── /workspace (PVC)
|
├── port/ # Interface contracts
|
||||||
│ └── /root/.claude (credentials secret)
|
├── service/ # Business logic orchestration
|
||||||
└── Future: discord-bot, more claudebox pods
|
├── 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)
|
See `k3s-fleet/tmp/address-the-gaps.md` for full implementation details:
|
||||||
2. Create Claude credentials secret
|
|
||||||
3. Apply manifests: `kubectl apply -k deployments/k8s/base`
|
| 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
|
## Constraints
|
||||||
|
|
||||||
- **ON-PREM k3s** - not GKE, always set KUBECONFIG
|
- **ON-PREM k3s** - not GKE, always set KUBECONFIG
|
||||||
- **Kustomize only** - no ArgoCD
|
- **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/kubernetes"
|
||||||
"github.com/orchard9/rdev/internal/adapter/memory"
|
"github.com/orchard9/rdev/internal/adapter/memory"
|
||||||
"github.com/orchard9/rdev/internal/adapter/postgres"
|
"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/auth"
|
||||||
"github.com/orchard9/rdev/internal/db"
|
"github.com/orchard9/rdev/internal/db"
|
||||||
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
"github.com/orchard9/rdev/internal/handlers"
|
"github.com/orchard9/rdev/internal/handlers"
|
||||||
"github.com/orchard9/rdev/internal/metrics"
|
"github.com/orchard9/rdev/internal/metrics"
|
||||||
"github.com/orchard9/rdev/internal/middleware"
|
"github.com/orchard9/rdev/internal/middleware"
|
||||||
|
"github.com/orchard9/rdev/internal/port"
|
||||||
"github.com/orchard9/rdev/internal/service"
|
"github.com/orchard9/rdev/internal/service"
|
||||||
"github.com/orchard9/rdev/internal/telemetry"
|
"github.com/orchard9/rdev/internal/telemetry"
|
||||||
"github.com/orchard9/rdev/internal/webhook"
|
"github.com/orchard9/rdev/internal/webhook"
|
||||||
@ -75,6 +79,14 @@ func main() {
|
|||||||
// Load configuration from environment
|
// Load configuration from environment
|
||||||
cfg := loadConfig()
|
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
|
// Initialize database with auto-migrations
|
||||||
database, err := db.New(db.Config{
|
database, err := db.New(db.Config{
|
||||||
Host: cfg.DBHost,
|
Host: cfg.DBHost,
|
||||||
@ -93,6 +105,12 @@ func main() {
|
|||||||
// Initialize auth service
|
// Initialize auth service
|
||||||
authService := auth.NewService(database.DB, cfg.AdminKey)
|
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)
|
// Create adapters (dependency injection)
|
||||||
namespace := getEnv("K8S_NAMESPACE", "rdev")
|
namespace := getEnv("K8S_NAMESPACE", "rdev")
|
||||||
|
|
||||||
@ -129,6 +147,9 @@ func main() {
|
|||||||
// Initialize command queue
|
// Initialize command queue
|
||||||
commandQueue := postgres.NewCommandQueueRepository(database.DB)
|
commandQueue := postgres.NewCommandQueueRepository(database.DB)
|
||||||
|
|
||||||
|
// Initialize work queue (for worker pool tasks)
|
||||||
|
workQueueRepo := postgres.NewWorkQueueRepository(database.DB)
|
||||||
|
|
||||||
// Initialize webhook repository and dispatcher
|
// Initialize webhook repository and dispatcher
|
||||||
webhookRepo := postgres.NewWebhookRepository(database.DB)
|
webhookRepo := postgres.NewWebhookRepository(database.DB)
|
||||||
webhookDispatcher := webhook.NewDispatcher(webhookRepo, &webhook.DispatcherConfig{
|
webhookDispatcher := webhook.NewDispatcher(webhookRepo, &webhook.DispatcherConfig{
|
||||||
@ -144,33 +165,57 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize infrastructure adapters (optional - only if configured)
|
// Initialize infrastructure adapters (optional - only if configured)
|
||||||
|
// Uses infraCfg which loads from credential store with env var fallback
|
||||||
var giteaClient *gitea.Client
|
var giteaClient *gitea.Client
|
||||||
if cfg.GiteaToken != "" && cfg.GiteaURL != "" {
|
if infraCfg.GiteaToken != "" && infraCfg.GiteaURL != "" {
|
||||||
var err error
|
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 {
|
if err != nil {
|
||||||
logger.Warn("failed to initialize gitea client", "error", err)
|
logger.Warn("failed to initialize gitea client", "error", err)
|
||||||
} else {
|
} 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
|
var dnsClient *cloudflare.Client
|
||||||
if cfg.CloudflareToken != "" && cfg.CloudflareZoneID != "" {
|
if infraCfg.CloudflareToken != "" && infraCfg.CloudflareZoneID != "" {
|
||||||
dnsClient = cloudflare.NewClient(cfg.CloudflareToken, cfg.CloudflareZoneID, cfg.DefaultDomain)
|
dnsClient = cloudflare.NewClient(infraCfg.CloudflareToken, infraCfg.CloudflareZoneID, infraCfg.DefaultDomain)
|
||||||
logger.Info("cloudflare DNS client initialized", "domain", cfg.DefaultDomain)
|
logger.Info("cloudflare DNS client initialized", "domain", infraCfg.DefaultDomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
var deployerAdapter *deployer.Deployer
|
var deployerAdapter *deployer.Deployer
|
||||||
if k8sClient != nil {
|
if k8sClient != nil {
|
||||||
deployerAdapter = deployer.NewDeployer(k8sClient, deployer.Config{
|
deployerAdapter = deployer.NewDeployer(k8sClient, deployer.Config{
|
||||||
Namespace: cfg.DeployNamespace,
|
Namespace: infraCfg.DeployNamespace,
|
||||||
IngressClass: "traefik",
|
IngressClass: "traefik",
|
||||||
TLSIssuer: cfg.DeployTLSIssuer,
|
TLSIssuer: infraCfg.DeployTLSIssuer,
|
||||||
DefaultDomain: cfg.DefaultDomain,
|
DefaultDomain: infraCfg.DefaultDomain,
|
||||||
DefaultReplicas: 1,
|
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
|
// Create services
|
||||||
@ -179,6 +224,11 @@ func main() {
|
|||||||
WithCommandQueue(commandQueue).
|
WithCommandQueue(commandQueue).
|
||||||
WithWebhookDispatcher(webhookDispatcher)
|
WithWebhookDispatcher(webhookDispatcher)
|
||||||
|
|
||||||
|
// Create work service (for worker pool task management)
|
||||||
|
workService := service.NewWorkService(workQueueRepo, service.WorkServiceConfig{
|
||||||
|
Logger: logger,
|
||||||
|
}).WithWebhookDispatcher(webhookDispatcher)
|
||||||
|
|
||||||
// Create app
|
// Create app
|
||||||
app := api.New("rdev-api",
|
app := api.New("rdev-api",
|
||||||
api.WithPort(cfg.Port),
|
api.WithPort(cfg.Port),
|
||||||
@ -209,6 +259,7 @@ func main() {
|
|||||||
auditHandler := handlers.NewAuditHandler(auditLogger)
|
auditHandler := handlers.NewAuditHandler(auditLogger)
|
||||||
queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo)
|
queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo)
|
||||||
webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo)
|
webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo)
|
||||||
|
workHandler := handlers.NewWorkHandler(workService)
|
||||||
|
|
||||||
// Initialize infrastructure handler (for threesix.ai git/deploy/dns)
|
// Initialize infrastructure handler (for threesix.ai git/deploy/dns)
|
||||||
infraHandler := handlers.NewInfrastructureHandler(
|
infraHandler := handlers.NewInfrastructureHandler(
|
||||||
@ -217,8 +268,8 @@ func main() {
|
|||||||
deployerAdapter,
|
deployerAdapter,
|
||||||
projectRepo,
|
projectRepo,
|
||||||
handlers.InfrastructureConfig{
|
handlers.InfrastructureConfig{
|
||||||
DefaultGitOwner: cfg.GiteaDefaultOrg,
|
DefaultGitOwner: infraCfg.GiteaDefaultOrg,
|
||||||
DefaultDomain: cfg.DefaultDomain,
|
DefaultDomain: infraCfg.DefaultDomain,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -228,10 +279,12 @@ func main() {
|
|||||||
giteaClient,
|
giteaClient,
|
||||||
dnsClient,
|
dnsClient,
|
||||||
deployerAdapter,
|
deployerAdapter,
|
||||||
|
woodpeckerClient, // CI provider for auto-activating repos
|
||||||
|
templateProvider, // Template provider for seeding repos
|
||||||
service.ProjectInfraConfig{
|
service.ProjectInfraConfig{
|
||||||
DefaultGitOwner: cfg.GiteaDefaultOrg,
|
DefaultGitOwner: infraCfg.GiteaDefaultOrg,
|
||||||
DefaultDomain: cfg.DefaultDomain,
|
DefaultDomain: infraCfg.DefaultDomain,
|
||||||
ClusterIP: cfg.ClusterIP,
|
ClusterIP: infraCfg.ClusterIP,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -244,14 +297,17 @@ func main() {
|
|||||||
deployerAdapter,
|
deployerAdapter,
|
||||||
dnsClient,
|
dnsClient,
|
||||||
handlers.WoodpeckerWebhookConfig{
|
handlers.WoodpeckerWebhookConfig{
|
||||||
WebhookSecret: cfg.WoodpeckerWebhookSecret,
|
WebhookSecret: infraCfg.WoodpeckerWebhookSecret,
|
||||||
DefaultDomain: cfg.DefaultDomain,
|
DefaultDomain: infraCfg.DefaultDomain,
|
||||||
RegistryURL: cfg.RegistryURL,
|
RegistryURL: infraCfg.RegistryURL,
|
||||||
ClusterIP: cfg.ClusterIP,
|
ClusterIP: infraCfg.ClusterIP,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Initialize credentials handler (superadmin only)
|
||||||
|
credentialsHandler := handlers.NewCredentialsHandler(credentialStore)
|
||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
projectsHandler.Mount(app.Router())
|
projectsHandler.Mount(app.Router())
|
||||||
keysHandler.Mount(app.Router())
|
keysHandler.Mount(app.Router())
|
||||||
@ -259,9 +315,11 @@ func main() {
|
|||||||
auditHandler.Mount(app.Router())
|
auditHandler.Mount(app.Router())
|
||||||
queueHandler.Mount(app.Router())
|
queueHandler.Mount(app.Router())
|
||||||
webhookHandler.Mount(app.Router())
|
webhookHandler.Mount(app.Router())
|
||||||
|
workHandler.Mount(app.Router())
|
||||||
infraHandler.Mount(app.Router())
|
infraHandler.Mount(app.Router())
|
||||||
projectMgmtHandler.Mount(app.Router())
|
projectMgmtHandler.Mount(app.Router())
|
||||||
woodpeckerHandler.Mount(app.Router())
|
woodpeckerHandler.Mount(app.Router())
|
||||||
|
credentialsHandler.Mount(app.Router())
|
||||||
|
|
||||||
// Start queue processor worker
|
// Start queue processor worker
|
||||||
queueProcessor := worker.NewQueueProcessor(
|
queueProcessor := worker.NewQueueProcessor(
|
||||||
@ -324,7 +382,10 @@ type Config struct {
|
|||||||
DBSSLMode string
|
DBSSLMode string
|
||||||
AdminKey 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
|
GiteaURL string
|
||||||
GiteaToken string
|
GiteaToken string
|
||||||
GiteaDefaultOrg string
|
GiteaDefaultOrg string
|
||||||
@ -335,6 +396,26 @@ type Config struct {
|
|||||||
DeployTLSIssuer string
|
DeployTLSIssuer string
|
||||||
ClusterIP string
|
ClusterIP string
|
||||||
RegistryURL 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
|
WoodpeckerWebhookSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -363,10 +444,14 @@ func loadConfig() Config {
|
|||||||
DBSSLMode: getEnv("DB_SSL_MODE", "disable"),
|
DBSSLMode: getEnv("DB_SSL_MODE", "disable"),
|
||||||
AdminKey: os.Getenv("RDEV_ADMIN_KEY"),
|
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"),
|
GiteaURL: getEnv("GITEA_URL", "https://git.threesix.ai"),
|
||||||
GiteaToken: os.Getenv("GITEA_TOKEN"),
|
GiteaToken: os.Getenv("GITEA_TOKEN"),
|
||||||
GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "threesix"),
|
GiteaDefaultOrg: getEnv("GITEA_DEFAULT_ORG", "jordan"),
|
||||||
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
|
CloudflareToken: os.Getenv("CLOUDFLARE_API_TOKEN"),
|
||||||
CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
|
CloudflareZoneID: os.Getenv("CLOUDFLARE_ZONE_ID"),
|
||||||
DefaultDomain: getEnv("DEFAULT_DOMAIN", "threesix.ai"),
|
DefaultDomain: getEnv("DEFAULT_DOMAIN", "threesix.ai"),
|
||||||
@ -374,6 +459,8 @@ func loadConfig() Config {
|
|||||||
DeployTLSIssuer: getEnv("DEPLOY_TLS_ISSUER", "letsencrypt-threesix"),
|
DeployTLSIssuer: getEnv("DEPLOY_TLS_ISSUER", "letsencrypt-threesix"),
|
||||||
ClusterIP: getEnv("CLUSTER_IP", "208.122.204.172"),
|
ClusterIP: getEnv("CLUSTER_IP", "208.122.204.172"),
|
||||||
RegistryURL: getEnv("REGISTRY_URL", "zot.threesix.svc.cluster.local:5000"),
|
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"),
|
WoodpeckerWebhookSecret: os.Getenv("WOODPECKER_WEBHOOK_SECRET"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -384,3 +471,60 @@ func getEnv(key, defaultVal string) string {
|
|||||||
}
|
}
|
||||||
return defaultVal
|
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
|
serviceAccountName: rdev-api
|
||||||
containers:
|
containers:
|
||||||
- name: rdev-api
|
- name: rdev-api
|
||||||
image: ghcr.io/orchard9/rdev-api:v0.6.0
|
image: ghcr.io/orchard9/rdev-api:v0.7.2
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
@ -88,6 +88,32 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: rdev-credentials
|
name: rdev-credentials
|
||||||
key: RDEV_ADMIN_KEY
|
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:
|
imagePullSecrets:
|
||||||
- name: ghcr-secret
|
- name: ghcr-secret
|
||||||
@ -151,3 +177,44 @@ roleRef:
|
|||||||
kind: Role
|
kind: Role
|
||||||
name: rdev-api
|
name: rdev-api
|
||||||
apiGroup: rbac.authorization.k8s.io
|
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/google/uuid v1.6.0
|
||||||
github.com/lib/pq v1.10.9
|
github.com/lib/pq v1.10.9
|
||||||
github.com/prometheus/client_golang v1.23.2
|
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 v1.39.0
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc 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/sdk v1.39.0
|
||||||
go.opentelemetry.io/otel/trace 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/api v0.35.0
|
||||||
k8s.io/apimachinery v0.35.0
|
k8s.io/apimachinery v0.35.0
|
||||||
k8s.io/client-go 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/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // 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/go-openapi/swag v0.23.0 // indirect
|
||||||
github.com/google/gnostic-models v0.7.0 // indirect
|
github.com/google/gnostic-models v0.7.0 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // 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/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/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.66.1 // indirect
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
github.com/prometheus/procfs v0.16.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/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
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 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
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.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU=
|
||||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4=
|
||||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
|
||||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
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-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
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/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 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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.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 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
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.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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
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/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 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY=
|
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
|
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.
|
// NewClient creates a new Gitea client.
|
||||||
// url is the Gitea server URL (e.g., https://git.threesix.ai)
|
// url is the Gitea server URL (e.g., https://git.threesix.ai)
|
||||||
// token is an API access token with repo permissions
|
// 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 {
|
switch cmd.Type {
|
||||||
case domain.CommandTypeClaude:
|
case domain.CommandTypeClaude:
|
||||||
// claude "prompt"
|
// claude -p --dangerously-skip-permissions "prompt" (non-interactive mode)
|
||||||
args = []string{
|
args = []string{
|
||||||
"exec", "-n", namespace, podName, "--",
|
"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:
|
case domain.CommandTypeShell:
|
||||||
// bash -c "command"
|
// bash -c "command"
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/orchard9/rdev/internal/port"
|
"github.com/orchard9/rdev/internal/port"
|
||||||
|
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
k8serrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/watch"
|
"k8s.io/apimachinery/pkg/watch"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
@ -375,7 +376,7 @@ func (r *ProjectRepository) getPodStatus(ctx context.Context, podName string) (d
|
|||||||
if r.client != nil {
|
if r.client != nil {
|
||||||
pod, err := r.client.CoreV1().Pods(r.namespace).Get(ctx, podName, metav1.GetOptions{})
|
pod, err := r.client.CoreV1().Pods(r.namespace).Get(ctx, podName, metav1.GetOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if k8serrors.IsNotFound(err) {
|
||||||
return domain.ProjectStatusNotFound, nil
|
return domain.ProjectStatusNotFound, nil
|
||||||
}
|
}
|
||||||
return domain.ProjectStatusUnknown, fmt.Errorf("get pod: %w", err)
|
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"
|
||||||
|
)
|
||||||
@ -8,6 +8,10 @@ var (
|
|||||||
// Project errors
|
// Project errors
|
||||||
ErrProjectNotFound = errors.New("project not found")
|
ErrProjectNotFound = errors.New("project not found")
|
||||||
ErrProjectNotRunning = errors.New("project is not running")
|
ErrProjectNotRunning = errors.New("project is not running")
|
||||||
|
ErrInvalidProjectName = errors.New("invalid project name")
|
||||||
|
|
||||||
|
// Template errors
|
||||||
|
ErrTemplateNotFound = errors.New("template not found")
|
||||||
|
|
||||||
// Command errors
|
// Command errors
|
||||||
ErrCommandNotFound = errors.New("command not found")
|
ErrCommandNotFound = errors.New("command not found")
|
||||||
@ -17,6 +21,14 @@ var (
|
|||||||
ErrInvalidCommand = errors.New("invalid command")
|
ErrInvalidCommand = errors.New("invalid command")
|
||||||
ErrCommandSanitization = errors.New("command failed sanitization")
|
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
|
// API Key errors
|
||||||
ErrKeyNotFound = errors.New("api key not found")
|
ErrKeyNotFound = errors.New("api key not found")
|
||||||
ErrKeyRevoked = errors.New("api key has been revoked")
|
ErrKeyRevoked = errors.New("api key has been revoked")
|
||||||
|
|||||||
@ -13,6 +13,10 @@ type Project struct {
|
|||||||
PodName string
|
PodName string
|
||||||
Status ProjectStatus
|
Status ProjectStatus
|
||||||
Workspace string
|
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.
|
// ProjectStatus represents the current state of a project's pod.
|
||||||
@ -49,6 +53,9 @@ const (
|
|||||||
// LabelWorkspace specifies the workspace path inside the pod.
|
// LabelWorkspace specifies the workspace path inside the pod.
|
||||||
LabelWorkspace = "rdev.orchard9.ai/workspace"
|
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 provides a human-readable description of the project.
|
||||||
AnnotDescription = "rdev.orchard9.ai/description"
|
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.
|
// Mount registers the infrastructure routes.
|
||||||
func (h *InfrastructureHandler) Mount(r api.Router) {
|
func (h *InfrastructureHandler) Mount(r api.Router) {
|
||||||
r.Route("/projects", func(r chi.Router) {
|
|
||||||
// Git repository endpoints
|
// Git repository endpoints
|
||||||
r.Post("/{id}/repo", h.CreateRepo)
|
r.Post("/projects/{id}/repo", h.CreateRepo)
|
||||||
r.Get("/{id}/repo", h.GetRepo)
|
r.Get("/projects/{id}/repo", h.GetRepo)
|
||||||
r.Delete("/{id}/repo", h.DeleteRepo)
|
r.Delete("/projects/{id}/repo", h.DeleteRepo)
|
||||||
|
|
||||||
// Deployment endpoints
|
// Deployment endpoints
|
||||||
r.Post("/{id}/deploy", h.Deploy)
|
r.Post("/projects/{id}/deploy", h.Deploy)
|
||||||
r.Get("/{id}/deploy/status", h.GetDeployStatus)
|
r.Get("/projects/{id}/deploy/status", h.GetDeployStatus)
|
||||||
r.Delete("/{id}/deploy", h.Undeploy)
|
r.Delete("/projects/{id}/deploy", h.Undeploy)
|
||||||
r.Post("/{id}/deploy/restart", h.RestartDeploy)
|
r.Post("/projects/{id}/deploy/restart", h.RestartDeploy)
|
||||||
r.Post("/{id}/deploy/scale", h.ScaleDeploy)
|
r.Post("/projects/{id}/deploy/scale", h.ScaleDeploy)
|
||||||
r.Get("/{id}/deploy/logs", h.GetDeployLogs)
|
r.Get("/projects/{id}/deploy/logs", h.GetDeployLogs)
|
||||||
|
|
||||||
// Domain endpoints
|
// Domain endpoints
|
||||||
r.Post("/{id}/domain", h.AddDomain)
|
r.Post("/projects/{id}/domain", h.AddDomain)
|
||||||
r.Delete("/{id}/domain", h.RemoveDomain)
|
r.Delete("/projects/{id}/domain", h.RemoveDomain)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateRepoRequest is the request body for POST /projects/{id}/repo.
|
// CreateRepoRequest is the request body for POST /projects/{id}/repo.
|
||||||
|
|||||||
@ -4,12 +4,13 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
"github.com/orchard9/rdev/internal/service"
|
"github.com/orchard9/rdev/internal/service"
|
||||||
"github.com/orchard9/rdev/pkg/api"
|
"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.Get("/{name}", h.Status) // GET /project/{name} - Get project status
|
||||||
r.Delete("/{name}", h.Delete) // DELETE /project/{name} - Delete project
|
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.
|
// CreateRequest is the request body for POST /project.
|
||||||
@ -41,6 +46,7 @@ type CreateRequest struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Private bool `json:"private,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.
|
// 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,
|
Name: req.Name,
|
||||||
Description: req.Description,
|
Description: req.Description,
|
||||||
Private: req.Private,
|
Private: req.Private,
|
||||||
|
Template: req.Template,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check for validation errors (user input) vs internal errors
|
// 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())
|
api.WriteBadRequest(w, r, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -158,7 +165,7 @@ func (h *ProjectManagementHandler) Status(w http.ResponseWriter, r *http.Request
|
|||||||
status, err := h.infraService.GetStatus(ctx, name)
|
status, err := h.infraService.GetStatus(ctx, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check if it's a "not found" error
|
// 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")
|
api.WriteNotFound(w, r, "project not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -205,7 +212,7 @@ func (h *ProjectManagementHandler) Delete(w http.ResponseWriter, r *http.Request
|
|||||||
err := h.infraService.DeleteProject(ctx, name)
|
err := h.infraService.DeleteProject(ctx, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check if it's a "not found" error
|
// 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")
|
api.WriteNotFound(w, r, "project not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -219,3 +226,66 @@ func (h *ProjectManagementHandler) Delete(w http.ResponseWriter, r *http.Request
|
|||||||
"project": name,
|
"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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -17,9 +15,7 @@ import (
|
|||||||
"github.com/orchard9/rdev/internal/auth"
|
"github.com/orchard9/rdev/internal/auth"
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
"github.com/orchard9/rdev/internal/port"
|
"github.com/orchard9/rdev/internal/port"
|
||||||
"github.com/orchard9/rdev/internal/sanitize"
|
|
||||||
"github.com/orchard9/rdev/internal/service"
|
"github.com/orchard9/rdev/internal/service"
|
||||||
"github.com/orchard9/rdev/internal/validate"
|
|
||||||
"github.com/orchard9/rdev/pkg/api"
|
"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))
|
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.
|
// Events streams command output via Server-Sent Events.
|
||||||
// GET /projects/{id}/events
|
// GET /projects/{id}/events
|
||||||
// Supports Last-Event-ID header for reconnection with event replay.
|
// 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.
|
// ProjectRepository returns the project repository for use by other handlers.
|
||||||
func (h *ProjectsHandler) ProjectRepository() *kubernetes.ProjectRepository {
|
func (h *ProjectsHandler) ProjectRepository() *kubernetes.ProjectRepository {
|
||||||
return h.projectRepo
|
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,12 +50,14 @@ func ValidateProjectName(name string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProjectInfraService orchestrates project infrastructure operations.
|
// 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 {
|
type ProjectInfraService struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
gitRepo port.GitRepository
|
gitRepo port.GitRepository
|
||||||
dns port.DNSProvider
|
dns port.DNSProvider
|
||||||
deployer port.Deployer
|
deployer port.Deployer
|
||||||
|
ciProvider port.CIProvider
|
||||||
|
templateProvider port.TemplateProvider
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
@ -78,6 +80,8 @@ func NewProjectInfraService(
|
|||||||
gitRepo port.GitRepository,
|
gitRepo port.GitRepository,
|
||||||
dns port.DNSProvider,
|
dns port.DNSProvider,
|
||||||
deployer port.Deployer,
|
deployer port.Deployer,
|
||||||
|
ciProvider port.CIProvider,
|
||||||
|
templateProvider port.TemplateProvider,
|
||||||
cfg ProjectInfraConfig,
|
cfg ProjectInfraConfig,
|
||||||
) *ProjectInfraService {
|
) *ProjectInfraService {
|
||||||
logger := cfg.Logger
|
logger := cfg.Logger
|
||||||
@ -89,6 +93,8 @@ func NewProjectInfraService(
|
|||||||
gitRepo: gitRepo,
|
gitRepo: gitRepo,
|
||||||
dns: dns,
|
dns: dns,
|
||||||
deployer: deployer,
|
deployer: deployer,
|
||||||
|
ciProvider: ciProvider,
|
||||||
|
templateProvider: templateProvider,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
defaultGitOwner: cfg.DefaultGitOwner,
|
defaultGitOwner: cfg.DefaultGitOwner,
|
||||||
defaultDomain: cfg.DefaultDomain,
|
defaultDomain: cfg.DefaultDomain,
|
||||||
@ -101,6 +107,7 @@ type CreateProjectRequest struct {
|
|||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
Private bool
|
Private bool
|
||||||
|
Template string // Template to seed the repo with (default: "default")
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateProjectResult contains the result of project creation.
|
// 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) {
|
func (s *ProjectInfraService) CreateProject(ctx context.Context, req CreateProjectRequest) (*CreateProjectResult, error) {
|
||||||
// Validate project name first
|
// Validate project name first
|
||||||
if err := ValidateProjectName(req.Name); err != nil {
|
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)
|
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")
|
result.NextSteps = append(result.NextSteps, "DNS service not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Add next steps for Woodpecker activation
|
// 4. Activate CI (Woodpecker)
|
||||||
if result.HTMLURL != "" {
|
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,
|
result.NextSteps = append(result.NextSteps,
|
||||||
fmt.Sprintf("Activate in Woodpecker: https://ci.%s → Add Repository → %s/%s", s.defaultDomain, s.defaultGitOwner, req.Name),
|
fmt.Sprintf("Activate Woodpecker manually: https://ci.%s → Add Repository → %s/%s", s.defaultDomain, result.GitRepoOwner, result.GitRepoName),
|
||||||
"Add .woodpecker.yml to your repo for CI/CD",
|
|
||||||
)
|
)
|
||||||
|
} 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",
|
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,
|
&status.DeploymentImage, &status.DeploymentStatus, &status.DeploymentReplicas,
|
||||||
)
|
)
|
||||||
if err == sql.ErrNoRows {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get project: %w", err)
|
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)
|
s.logger.Info("project deleted", "project", projectID)
|
||||||
return nil
|
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
|
auditLogger port.AuditLogger // Optional audit logger
|
||||||
queue port.CommandQueue // Optional command queue
|
queue port.CommandQueue // Optional command queue
|
||||||
webhookDispatcher port.WebhookDispatcher // Optional webhook dispatcher
|
webhookDispatcher port.WebhookDispatcher // Optional webhook dispatcher
|
||||||
|
agentRegistry port.CodeAgentRegistry // Optional code agent registry
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
cmdID atomic.Uint64
|
cmdID atomic.Uint64
|
||||||
}
|
}
|
||||||
@ -67,6 +68,12 @@ func (s *ProjectService) WithWebhookDispatcher(dispatcher port.WebhookDispatcher
|
|||||||
return s
|
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.
|
// AuditContext contains audit-related information from the request.
|
||||||
type AuditContext struct {
|
type AuditContext struct {
|
||||||
APIKeyID string
|
APIKeyID string
|
||||||
@ -108,6 +115,9 @@ type ExecuteClaudeRequest struct {
|
|||||||
ProjectID domain.ProjectID
|
ProjectID domain.ProjectID
|
||||||
Prompt string
|
Prompt string
|
||||||
StreamID 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
|
Audit *AuditContext // Optional audit context
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,6 +125,8 @@ type ExecuteClaudeRequest struct {
|
|||||||
type ExecuteClaudeResult struct {
|
type ExecuteClaudeResult struct {
|
||||||
CommandID domain.CommandID
|
CommandID domain.CommandID
|
||||||
StreamURL string
|
StreamURL string
|
||||||
|
SessionID string // Session ID for continuation
|
||||||
|
AgentProvider domain.AgentProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteClaude runs a Claude command in the project's pod.
|
// ExecuteClaude runs a Claude command in the project's pod.
|
||||||
@ -174,170 +186,32 @@ func (s *ProjectService) ExecuteClaude(ctx context.Context, req ExecuteClaudeReq
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute in background
|
// Resolve agent and execute
|
||||||
go s.executeCommand(project.PodName, cmd)
|
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{
|
return &ExecuteClaudeResult{
|
||||||
CommandID: cmdID,
|
CommandID: cmdID,
|
||||||
StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, 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
|
}, 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
|
// Fallback to legacy executor
|
||||||
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)
|
go s.executeCommand(project.PodName, cmd)
|
||||||
|
|
||||||
return &ExecuteShellResult{
|
return &ExecuteClaudeResult{
|
||||||
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,
|
CommandID: cmdID,
|
||||||
StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID),
|
StreamURL: fmt.Sprintf("/projects/%s/events?stream_id=%s", req.ProjectID, cmdID),
|
||||||
}, nil
|
}, nil
|
||||||
@ -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()) {
|
func (s *ProjectService) SubscribeFromID(streamID, lastEventID string) (<-chan port.StreamEvent, func()) {
|
||||||
return s.streams.SubscribeFromID(streamID, lastEventID)
|
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