Compare commits

...

10 Commits

Author SHA1 Message Date
jordan
dc00921703 ci: add Woodpecker CI for self-hosted builds
- Add .woodpecker.yml with build steps for api, worker, claudebox
- Update K8s manifests to use registry.threesix.ai/rdev/*
- Remove ghcr-secret imagePullSecrets (Zot is unauthenticated)

Builds will run on Woodpecker using kaniko, pushing to our internal
Zot registry. This eliminates the QEMU cross-compilation issues on
Apple Silicon.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 19:26:44 -07:00
jordan
3b35900a2d feat: enterprise worker pool with HTTP sidecar pattern
Implements horizontally-scalable worker pool architecture:
- claudebox-sidecar: HTTP server for Claude Code, git, and SDLC ops
- rdev-worker: standalone worker binary polling rdev-api for tasks
- HTTP client adapter for sidecar communication
- HPA with custom Prometheus metrics for autoscaling
- ServiceMonitor for metrics scraping

Code review fixes applied:
- URL-encode query parameters in GitStatus (Critical #1)
- Remove unused shellQuote function (Critical #2)
- Use stdlib strings.Split/TrimSpace (Critical #3)
- Add version injection via ldflags (Warning #4)
- Add debug logging for swallowed git/sdlc errors (Warning #5, #6)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 16:21:11 -07:00
jordan
3b0779fbe8 fix: slackpath trees use batch endpoint for atomic multi-component adds
Updates slackpath-2 and slackpath-4 to use POST /projects/{id}/components/batch
for adding multiple Go components atomically in a single git commit. This
prevents the go.work race condition where individual commits reference modules
that don't exist yet.

Also adds on_error: continue for infrastructure provisioning steps that may
already exist from skeleton (redis, postgres).

Verified:
- slackpath-1:  Complete (wait_build polled 5 times, detected success)
- slackpath-2:  Complete (wait_build polled 111 times, detected success)
- slackpath-3:  Infrastructure passed (worker capacity limited testing)
- slackpath-4:  Infrastructure passed (worker capacity limited testing)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 14:44:53 -07:00
jordan
da482b48b4 release: v0.10.56 - fix: worker template unused pkg/config import 2026-02-05 13:46:45 -07:00
jordan
0c7282b9eb release: v0.10.55 - fix: Dockerfile templates use GOWORK=off for independent component builds 2026-02-05 13:09:35 -07:00
jordan
a7fcba3587 release: v0.10.54 - fix: go.work race condition with batch components 2026-02-05 12:46:22 -07:00
jordan
853ec4cf81 fix: go.work race condition with batch components and idempotent provisioning
Three coordinated fixes for CI pipeline race conditions:

1. Woodpecker step dependencies: Added depends_on: [deps] to all 6 component
   templates (service, worker, cli, app-astro, app-react, app-nextjs) so build
   steps wait for go work sync to complete.

2. Idempotent resource provisioning: Modified provisionResources() to check
   for existing database/cache before creating, preventing "already exists"
   errors on component re-adds.

3. Batch component endpoint: POST /projects/{id}/components/batch enables
   atomic multi-component additions in a single git commit. Validates all
   components upfront, provisions infra sequentially, commits code components
   atomically.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 12:31:40 -07:00
jordan
19837f7251 release: v0.10.53 - fix: shell-quote SDLC command args to handle spaces in titles 2026-02-05 00:44:34 -07:00
jordan
022184ef6a chore: update claudebox to v0.4.0 (includes sdlc binary) 2026-02-05 00:18:02 -07:00
jordan
4766a54314 release: v0.10.52 - feat: SDLC worker routing for skeleton projects with auto-init 2026-02-05 00:16:29 -07:00
81 changed files with 10380 additions and 494 deletions

View File

@ -0,0 +1,585 @@
# Composable Monorepo Templates
**When to use:** Implementing the monorepo skeleton, component templates, or the component addition API.
## Overview
Composable Monorepo Templates evolve rdev from single-template projects to full monorepo scaffolding:
1. **Skeleton**: Every `POST /projects` creates the monorepo base (shared `pkg/`, scripts, CI)
2. **Component Templates**: `POST /projects/{id}/components` adds services/workers/apps/cli
3. **Deployment**: Target whole monorepo or individual components
**Terminology:** "skeleton" = generated project code; "platform" = rdev itself. See [ai-lookup/terminology.md](../../../ai-lookup/terminology.md).
**Plan Document:** `tmp/template-monorepo-plan.md`
## Implementation Phases
### Phase 1: Skeleton Template
Create `templates/skeleton/` with base monorepo structure.
**Files to create:**
```
internal/adapter/templates/templates/skeleton/
├── CLAUDE.md.tmpl
├── README.md.tmpl
├── Procfile.tmpl
├── docker-compose.yml.tmpl
├── go.work.tmpl
├── .golangci.yml
├── .gitignore
├── .woodpecker.yml.tmpl # ⚠️ Template-provided CI (never AI-generated)
├── .claude/
│ ├── settings.local.json
│ ├── guides/
│ └── skills/
├── scripts/
│ ├── install.sh
│ ├── quality.sh
│ ├── dev.sh
│ └── discover.sh
└── pkg/
├── go.mod.tmpl
├── app/
├── middleware/
├── httpcontext/
├── httpresponse/
├── httpvalidation/
├── logging/
├── config/
└── httpclient/
```
**Critical: CI Must Be Templated**
AI-generated `.woodpecker.yml` produces invalid YAML (broken anchor syntax). All CI comes from templates:
- Skeleton provides base `.woodpecker.yml.tmpl`
- Components provide `.woodpecker.step.yml.tmpl`
- AddComponent service inserts component steps into main pipeline
**Template Variables:**
```go
{{.ProjectName}} // "acme"
{{.GoModule}} // "github.com/orchard9/acme"
{{.Description}} // "Project description"
```
**Provider Changes:**
```go
// internal/port/template_provider.go
type TemplateProvider interface {
// Existing
GetTemplate(name string) (*Template, error)
ListTemplates() []Template
// New
GetSkeleton() (*Template, error)
GetComponentTemplate(componentType, templateName string) (*Template, error)
ListComponentTemplates(componentType string) []Template
}
```
### Phase 1.5: Shared Packages (pkg/)
Extract and combine packages from Aeries + Colix:
| Package | Source | Key Files |
|---------|--------|-----------|
| `app/` | Aeries `pkg/chassis/` | `app.go` |
| `middleware/` | Colix `pkg/middleware/` | `cors.go`, `recovery.go`, `request_id.go`, `logger.go` |
| `httpcontext/` | Colix `pkg/httpcontext/` | `keys.go` |
| `httpresponse/` | Both | `response.go`, `envelope.go` |
| `httpvalidation/` | Colix `pkg/httpvalidation/` | `validator.go`, `errors.go` |
| `logging/` | Both | `logger.go` |
| `config/` | Aeries `pkg/config/` | `config.go` |
| `httpclient/` | Both | `client.go` |
### Phase 2: Component Templates
Migrate existing templates to component format:
```
internal/adapter/templates/templates/components/
├── service/ # Renamed from go-api
│ ├── cmd/server/main.go.tmpl
│ ├── internal/
│ ├── Makefile.tmpl
│ ├── Dockerfile.tmpl
│ ├── component.yaml.tmpl
│ └── .woodpecker.step.yml.tmpl # CI step for this component type
├── worker/ # NEW
├── app-astro/ # Renamed from astro-landing
├── app-react/ # NEW
└── cli/ # NEW
```
Each component includes `.woodpecker.step.yml.tmpl` that defines its Kaniko build step.
When components are added, the service renders this template and inserts it into the main pipeline.
**Component Variables:**
```go
{{.ProjectName}} // "acme"
{{.ComponentName}} // "auth-api"
{{.ComponentNameCamel}} // "AuthApi"
{{.GoModule}} // "github.com/orchard9/acme"
{{.Port}} // 8080
```
### Phase 3: Add Component API
Create endpoint for adding components to existing projects.
**Handler:**
```go
// internal/handlers/components.go
func (h *Handler) AddComponent(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "projectID")
var req AddComponentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
api.Error(w, http.StatusBadRequest, "invalid request")
return
}
component, err := h.svc.AddComponent(r.Context(), projectID, req.Type, req.Name, req.Template)
if err != nil {
api.Error(w, http.StatusInternalServerError, err.Error())
return
}
api.OK(w, component)
}
```
**Request/Response:**
```go
type AddComponentRequest struct {
Type string `json:"type"` // service, worker, app, cli
Name string `json:"name"` // auth-api, dashboard, etc.
Template string `json:"template"` // optional: specific variant
}
```
**Service Logic:**
```go
func (s *Service) AddComponent(ctx context.Context, projectID, compType, name, template string) (*domain.Component, error) {
// 1. Render component template
// 2. Commit files to project repo
// 3. Update Procfile with new entry
// 4. Update go.work if Go component
// 5. Update CLAUDE.md routing
// 6. Update .woodpecker.yml with component's build step
// 7. Return component metadata
}
```
### Phase 4: Component-Aware Deployment
Extend deploy to support component targets:
```go
type DeployRequest struct {
Component string `json:"component,omitempty"` // e.g., "services/auth-api"
}
// POST /projects/{id}/deploy → full monorepo
// POST /projects/{id}/deploy/services/auth-api → single component
```
### Phase 5: Discovery Scripts
Scripts that walk the monorepo and operate on all components:
```bash
# scripts/discover.sh
#!/bin/bash
for type in services workers apps cli; do
for dir in "$type"/*/; do
[ -d "$dir" ] && echo "$type/$(basename $dir)"
done
done
# scripts/install.sh
#!/bin/bash
for service in services/*/; do
[ -f "$service/go.mod" ] && (cd "$service" && go mod download)
done
for app in apps/*/; do
[ -f "$app/package.json" ] && (cd "$app" && npm install)
done
# scripts/quality.sh
#!/bin/bash
golangci-lint run ./services/... ./workers/... ./cli/...
for app in apps/*/; do
[ -f "$app/package.json" ] && (cd "$app" && npm run lint)
done
# scripts/dev.sh
#!/bin/bash
docker-compose up -d
overmind start
```
### Phase 6: Quality Hooks
Pre-commit hooks for monorepo quality:
```bash
# .githooks/pre-commit
#!/bin/bash
# File length check (500 lines)
for file in $(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(go|ts|tsx)$'); do
lines=$(wc -l < "$file")
if [ "$lines" -gt 500 ]; then
echo "Error: $file exceeds 500 lines ($lines)"
exit 1
fi
done
# Go formatting
gofmt -l -w $(git diff --cached --name-only --diff-filter=ACM | grep '\.go$')
# Linting
golangci-lint run ./services/... ./workers/... ./cli/...
```
## Domain Model
```go
// internal/domain/component.go
type ComponentType string
const (
// Code components (scaffold template files)
ComponentTypeService ComponentType = "service"
ComponentTypeWorker ComponentType = "worker"
ComponentTypeAppAstro ComponentType = "app-astro"
ComponentTypeAppReact ComponentType = "app-react"
ComponentTypeCLI ComponentType = "cli"
// Infrastructure components (provision resources)
ComponentTypePostgres ComponentType = "postgres"
ComponentTypeRedis ComponentType = "redis"
)
type Component struct {
Type ComponentType
Name string
Template string
Path string // "services/auth-api" or "infra/postgres"
Port int // from component.yaml or auto-assigned
Dependencies []string // postgres, redis, etc.
BuildOrder int
}
```
## Infrastructure Components
Infrastructure components (`postgres`, `redis`) don't scaffold files — they provision actual resources:
### Adding Database (CockroachDB)
```bash
POST /projects/acme/components
{
"type": "postgres",
"name": "main-db"
}
```
This:
1. Creates a CockroachDB database: `project_acme`
2. Creates a database user with full permissions
3. Stores `DATABASE_URL` and `DATABASE_URL_STAGING` in credential store
### Adding Cache (Redis)
```bash
POST /projects/acme/components
{
"type": "redis",
"name": "job-queue"
}
```
This:
1. Creates a Redis ACL user: `proj-acme`
2. Scopes access to keys matching `project:acme:*`
3. Stores `REDIS_URL`, `REDIS_URL_STAGING`, and `REDIS_PREFIX` in credential store
## Credential Injection
**Critical:** When code components are deployed, credentials are automatically injected.
The `createInitialComponentDeployment()` function:
1. Calls `fetchProjectCredentials(projectID)` to retrieve stored credentials
2. Populates `DeploySpec.Secrets` with `DATABASE_URL`, `REDIS_URL`, etc.
3. Deployer creates K8s Secret and mounts it as environment variables
**Available credentials (if provisioned):**
- `DATABASE_URL` - CockroachDB connection string
- `DATABASE_URL_STAGING` - Staging database (same as prod currently)
- `REDIS_URL` - Redis connection string with auth
- `REDIS_URL_STAGING` - Staging Redis (same as prod currently)
- `REDIS_PREFIX` - Key prefix for isolation (e.g., `project:acme:`)
**Service Discovery:** Sibling services are also injected as env vars:
- `AUTH_SVC_URL=http://acme-auth-svc:8001`
- `CHAT_SVC_URL=http://acme-chat-svc:8002`
**File Pointers:**
- Credential injection: `internal/service/component_deploy.go:35-48, 160-212`
- Infrastructure provisioning: `internal/service/component_infra.go`
- Deployer secret creation: `internal/adapter/deployer/resources.go:81-135`
```
## API Reference
**Important Route Note:** Project CRUD uses `/project` (singular), but component/build/pipeline operations use `/projects/{id}/...` (plural).
### Create Project (Skeleton)
```bash
POST /project
{
"name": "acme",
"description": "Acme Corp platform"
}
# Response: Creates monorepo skeleton in repo
```
### Add Component
```bash
POST /projects/acme/components
{
"type": "service",
"name": "auth-api",
"template": "service" # optional
}
# Response:
{
"type": "service",
"name": "auth-api",
"path": "services/auth-api",
"port": 8001
}
```
### Deploy
```bash
# Component deploy (single component at a time)
POST /projects/acme/deploy
{
"component": "services/auth-api"
}
# Note: Full monorepo deploy is handled by CI pipeline, not a single API call
```
### List Components
```bash
GET /projects/acme/components
# Response:
{
"components": [
{"type": "service", "name": "auth-api", "path": "services/auth-api", "port": 8001},
{"type": "app-react", "name": "dashboard", "path": "apps/dashboard", "port": 3001}
]
}
```
### Delete Project
```bash
DELETE /project/acme
```
### List Component Templates
```bash
GET /templates/components
# Response shows available component types: service, worker, app-astro, app-react, cli
```
## Routing Patterns
### Service URL Convention
Services are accessed via a consistent URL pattern:
```
https://{project-domain}/api/{service-name}/...
```
**Examples:**
- `https://acme.threesix.ai/api/auth/login` → auth-api service
- `https://acme.threesix.ai/api/chat/messages` → chat-api service
- `https://acme.threesix.ai/` → frontend app (no `/api/` prefix)
### How Routing Works
```
[Client] → [Cloudflare DNS] → [Ingress Controller] → [K8s Service] → [Pod]
1. DNS: *.threesix.ai → cluster IP
2. Ingress: Routes by host + path prefix
3. Service: Load balances to pods
4. Pod: Runs your container on assigned port
```
### Configuring Routes in Trees
When adding services via cookbook trees, routes are configured automatically based on component type and name:
```yaml
# Adding a service creates these routes:
add-auth-service:
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
body:
type: service
name: auth # Routes to /api/auth/*
```
**Port assignment:** Services get auto-assigned ports (8001, 8002, ...). The ingress handles external-to-internal port mapping.
### Common Routing Mistakes
| Mistake | Symptom | Fix |
|---------|---------|-----|
| Missing `/api/` prefix in client | 404 on service calls | Use `/api/{service}/...` |
| Hardcoded localhost:8001 | Works locally, fails in K8s | Use relative paths or env vars |
| Wrong service name in path | 404 or wrong service | Match name from component add |
| CORS errors | Browser blocks requests | Ensure middleware/cors.go is configured |
| Trailing slash mismatch | 301 redirect loops | Be consistent: `/api/auth` not `/api/auth/` |
### Multi-Service Routing
When multiple services exist, they share the domain but have isolated path prefixes:
```yaml
# Project with 3 services
components:
- type: service, name: auth # /api/auth/*
- type: service, name: chat # /api/chat/*
- type: service, name: billing # /api/billing/*
- type: app-react, name: web # /* (catch-all for frontend)
```
**Frontend API calls:**
```typescript
// In React app - use relative paths
fetch('/api/auth/login', { method: 'POST', body: ... })
fetch('/api/chat/messages')
fetch('/api/billing/invoices')
```
### Internal Service Communication
Services communicate internally via K8s service names, not external URLs:
```yaml
# Service discovery environment variables (auto-injected)
AUTH_SVC_URL=http://acme-auth-svc:8001
CHAT_SVC_URL=http://acme-chat-svc:8002
# In code - use env vars
authURL := os.Getenv("AUTH_SVC_URL")
resp, err := http.Get(authURL + "/internal/validate-token")
```
**Internal vs External:**
- **External** (from browser): `https://acme.threesix.ai/api/auth/...`
- **Internal** (service-to-service): `http://acme-auth-svc:8001/...`
Internal routes can have endpoints not exposed externally (e.g., `/internal/*`).
## Testing
```go
func TestAddComponent(t *testing.T) {
// Setup project first
project := createTestProject(t, "test-monorepo")
// Add service component
req := AddComponentRequest{
Type: "service",
Name: "auth-api",
Template: "go-api",
}
component, err := svc.AddComponent(ctx, project.ID, req.Type, req.Name, req.Template)
require.NoError(t, err)
assert.Equal(t, "service", string(component.Type))
assert.Equal(t, "auth-api", component.Name)
assert.Equal(t, "services/auth-api", component.Path)
// Verify files created in repo
files, err := gitea.ListFiles(project.Owner, project.Name, "services/auth-api")
require.NoError(t, err)
assert.Contains(t, files, "cmd/server/main.go")
assert.Contains(t, files, "Makefile")
}
```
## Troubleshooting
### Component endpoint returns 404
The component service is only initialized when `GITEA_URL`, `GITEA_TOKEN`, and template provider are all configured. Check startup logs for "component service initialized":
```bash
kubectl logs -n rdev deployment/rdev-api | grep "component service"
```
If missing, verify the rdev-api deployment has the required env vars.
### Component addition fails with "directory exists"
The component path already exists. Either delete it or choose a different name.
### Component addition fails with "connection refused"
**Hairpin NAT issue.** rdev-api is trying to reach Gitea via external URL which doesn't work from within the cluster. Use internal service hostnames:
```yaml
# In deployments/k8s/base/rdev-api.yaml
- name: GITEA_URL
value: "http://gitea.threesix.svc.cluster.local"
```
See [ops/networking.md](../ops/networking.md) for details.
### CI pipeline linter errors
Woodpecker may show linter warnings about oneOf/anyOf validation. These are usually benign. Check for actual schema violations like invalid `when.event` values.
**Critical:** The `.woodpecker.yml` marker must be exactly `# COMPONENT_STEPS_BELOW` on its own line. If the marker has trailing text (like `- Do not remove`), it will be appended to the last line of inserted component steps, corrupting the YAML.
**File Pointer:** `internal/adapter/templates/templates/skeleton/.woodpecker.yml.tmpl:11`
### Procfile not updating
Check that the template includes the Procfile.tmpl and the service has write access to the repo.
### go.work not syncing
Run `./scripts/discover.sh` to verify component detection, then manually run `go work sync`.
## Related
- [Project Templates](./templates.md) - Current single-template system
- [Build Orchestration](./build-orchestration.md) - Component builds
- [ai-lookup: Composable Monorepo](../../ai-lookup/features/composable-monorepo.md) - Quick facts

View File

@ -0,0 +1,508 @@
# Cookbook Tree System
Checkpoint-based cookbook execution with YAML tree definitions. Enables resumable, debuggable E2E test workflows.
## Quick Reference
```bash
# Validate tree and show execution plan (safe preview)
./cookbooks/scripts/tree-runner.sh run landing-page --project-name my-test --dry-run
# Run a tree (creates checkpoint on each step)
./cookbooks/scripts/tree-runner.sh run landing-page --project-name my-test
# Run with auto-cleanup on exit
./cookbooks/scripts/tree-runner.sh run landing-page --project-name my-test --auto-teardown
# Resume from last checkpoint after failure
./cookbooks/scripts/tree-runner.sh resume landing-page
# Run only a specific step (debugging)
./cookbooks/scripts/tree-runner.sh only landing-page wait-pipeline
# Check status of a tree run
./cookbooks/scripts/tree-runner.sh status landing-page
# Teardown resources (runs tree's teardown section)
./cookbooks/scripts/tree-runner.sh teardown landing-page
# List all available trees
./cookbooks/scripts/tree-runner.sh list
# Clean checkpoint (discard state)
./cookbooks/scripts/tree-runner.sh clean landing-page
```
### Global Flags
| Flag | Description |
|------|-------------|
| `--dry-run` | Validate tree and show execution plan without running |
| `--auto-teardown` | Run teardown steps on exit (success or failure) |
## Dependencies
Required tools (pre-flight checks verify these):
- `yq` - YAML parser (`brew install yq`)
- `jq` - JSON parser (`brew install jq`)
- `curl` - HTTP client (usually pre-installed)
Required environment variables:
- `RDEV_API_URL` - API endpoint (e.g., `https://rdev.masq-ops.orchard9.ai`)
- `RDEV_API_KEY` - API key for authentication
Optional:
- `API_TIMEOUT` - Seconds before API calls timeout (default: 60)
## Tree YAML Format
Tree definitions live in `cookbooks/trees/` and define workflow steps as a DAG.
```yaml
name: landing-page
description: Deploy a landing page
version: 1
# Variables (can be overridden via --var-name)
vars:
project_name: "" # Required, no default
template: "app-astro" # Optional, has default
steps:
create-project:
description: Create the project skeleton
action: api
method: POST
endpoint: /project
body:
name: "{{ .vars.project_name }}"
description: "Landing page E2E test"
outputs:
- project_id: .data.name
- domain: .data.domain
add-component:
description: Add landing page component
depends_on: [create-project]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
body:
type: "{{ .vars.template }}"
name: landing
template: "{{ .vars.template }}"
wait-pipeline:
description: Wait for CI pipeline to complete
depends_on: [add-component]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
on_error: continue # Don't fail the whole tree
verify-site:
description: Verify site is accessible
depends_on: [wait-pipeline]
action: wait_site
domain: "{{ .outputs.create-project.domain }}"
project_id: "{{ .outputs.create-project.project_id }}"
# Teardown runs in reverse order on failure or explicit teardown
teardown:
- description: Delete project
action: api
method: DELETE
endpoint: "/project/{{ .outputs.create-project.project_id }}"
```
### Step Properties
| Property | Required | Description |
|----------|----------|-------------|
| `description` | No | Human-readable description |
| `action` | Yes | Action type: `api`, `wait_pipeline`, `wait_build`, `wait_site`, `diagnose`, `shell` |
| `depends_on` | No | Array of step names that must complete first |
| `on_error` | No | `fail` (default) or `continue` |
| `outputs` | No | Extract values from response (jq paths) |
### Action Types
#### api
Make an authenticated API call.
```yaml
action: api
method: POST # GET, POST, DELETE, PUT, PATCH
endpoint: /projects/{{ .project_id }}/components
body: # Optional, for POST/PUT/PATCH
type: service
name: api
```
#### wait_pipeline
Wait for a CI pipeline to complete.
```yaml
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
max_attempts: 60 # Optional, default 60
poll_interval: 5 # Optional, default 5 seconds
```
#### wait_build
Wait for a build/agent task to complete. Replaces shell-based polling loops.
```yaml
action: wait_build
build_id: "{{ .outputs.implement-feature.build_id }}"
max_attempts: 120 # Optional, default 120
poll_interval: 5 # Optional, default 5 seconds
```
#### wait_site
Wait for a site to be accessible.
```yaml
action: wait_site
domain: "{{ .outputs.create-project.domain }}"
project_id: "{{ .outputs.create-project.project_id }}" # For diagnostics
max_attempts: 30
poll_interval: 5
```
#### diagnose
Run diagnostic checks.
```yaml
action: diagnose
type: pipeline # or 'site'
project_id: "{{ .outputs.create-project.project_id }}"
domain: "{{ .outputs.create-project.domain }}" # For site diagnostics
```
#### shell
Run a shell command.
```yaml
action: shell
command: "curl -s https://{{ .outputs.create-project.domain }}/api/health | jq ."
outputs:
- health_status: .status
```
### Template Variables
Variables are expanded using Go template syntax (`{{ .path }}`):
- `.vars.<name>` - Variables from CLI flags or tree defaults
- `.outputs.<step>.<key>` - Outputs captured from previous steps
## Checkpoint Format
Checkpoints are stored in `cookbooks/.checkpoints/` (gitignored) as JSON:
```json
{
"tree": "landing-page",
"run_id": "landing-page-1706889600",
"status": "partial",
"vars": {
"project_name": "test-landing"
},
"steps": {
"create-project": {
"status": "completed",
"started_at": "2025-02-01T10:00:00Z",
"completed_at": "2025-02-01T10:00:05Z",
"output": {
"project_id": "test-landing",
"domain": "test-landing.threesix.ai"
}
},
"wait-pipeline": {
"status": "failed",
"started_at": "2025-02-01T10:00:05Z",
"completed_at": "2025-02-01T10:05:00Z",
"error": "Pipeline #3 failed with status: failure"
}
},
"last_completed_step": "create-project"
}
```
### Checkpoint Status Values
- `pending` - Tree started but no steps completed
- `partial` - Some steps completed, some pending/failed
- `completed` - All steps completed successfully
- `failed` - A step failed with `on_error: fail`
## Creating a New Tree
1. Create `cookbooks/trees/<name>.yaml`
2. Define steps with dependencies
3. Add teardown section
4. Test with `tree-runner.sh run <name> --project-name test-$(date +%s)`
### Best Practices
- **Always include teardown** - Clean up resources even if the tree fails
- **Use descriptive step names** - They appear in status output
- **Set on_error: continue for non-critical steps** - Pipeline failures shouldn't block site verification
- **Capture outputs** - Pass data between steps via outputs, not hardcoded values
- **Use vars for inputs** - Makes trees reusable with different parameters
### Common Mistakes
#### 1. YAML Indentation Errors
YAML requires consistent indentation with **spaces only** (no tabs). Steps must be indented under `steps:`:
```yaml
# WRONG - tabs or inconsistent spacing
steps:
create-project: # Tab character - will fail
action: api
# CORRECT - 2-space indent
steps:
create-project:
action: api
```
#### 2. Missing Output Dependencies
If you reference `{{ .outputs.step-name.key }}`, the referencing step **must** have `step-name` in its `depends_on` array. Validation will catch this:
```yaml
# WRONG - references create-project but doesn't depend on it
wait-pipeline:
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
# Missing: depends_on: [create-project]
# CORRECT
wait-pipeline:
depends_on: [create-project]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
```
**Error message:** `wait-pipeline: references outputs from "create-project" but does not depend on it (directly or transitively)`
**Note:** Transitive dependencies are valid. If A depends on B, and B depends on C, then A can use outputs from C.
#### 3. Template Escaping in Shell Commands
Shell commands with template variables need proper quoting to handle spaces and special characters:
```yaml
# RISKY - unquoted expansion
action: shell
command: curl https://{{ .outputs.create-project.domain }}/api/health
# SAFER - quoted expansion
action: shell
command: 'curl "https://{{ .outputs.create-project.domain }}/api/health"'
```
#### 4. Outputs Array Syntax
Outputs must be an array of single-key objects, not a flat object:
```yaml
# WRONG - flat object
outputs:
project_id: .data.name
domain: .data.domain
# CORRECT - array of objects
outputs:
- project_id: .data.name
- domain: .data.domain
```
#### 5. Circular Dependencies
Dependencies form a DAG (directed acyclic graph). Cycles cause validation failures:
```yaml
# WRONG - circular dependency
step-a:
depends_on: [step-b]
step-b:
depends_on: [step-a] # Creates cycle!
# CORRECT - linear or fan-out dependencies
step-a:
depends_on: []
step-b:
depends_on: [step-a]
step-c:
depends_on: [step-a] # Fan-out OK
```
**Error message:** `Dependency cycle detected`
#### 6. Hardcoded Values Instead of Outputs
Avoid hardcoding values that should come from previous steps:
```yaml
# WRONG - hardcoded project name
wait-pipeline:
depends_on: [create-project]
action: wait_pipeline
project_id: "my-test-project" # Should use output!
# CORRECT - use captured output
wait-pipeline:
depends_on: [create-project]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
```
## Migrating from Script to Tree
Compare script steps to tree steps:
| Script Pattern | Tree Equivalent |
|----------------|-----------------|
| `api_call POST /project "$json"` | `action: api`, `method: POST` |
| `wait_for_pipeline "$project"` | `action: wait_pipeline` |
| `wait_for_site "$domain" 30 5 "$project"` | `action: wait_site` |
| `diagnose_pipeline_failure "$project"` | `action: diagnose`, `type: pipeline` |
| `curl ... \| jq ...` | `action: shell`, `command: "..."` |
## Troubleshooting
### Pre-flight check failures
```
Pre-flight checks failed:
✗ RDEV_API_URL environment variable is not set
✗ RDEV_API_KEY environment variable is not set
```
Set the required environment variables before running trees.
### Tree not found
```
Error: Tree 'foo' not found
Available trees: landing-page, composable-app, sdlc-flow
```
Check that `cookbooks/trees/foo.yaml` exists.
### yq not found
```
Error: yq is required but not installed
```
Install with `brew install yq`.
### Resume finds no checkpoint
```
No checkpoint found for tree 'landing-page'
```
Run `tree-runner.sh run landing-page ...` first.
### Step failed but outputs missing
```
Error: Output 'project_id' not found in step 'create-project'
```
The step may have failed silently. Check the checkpoint file:
```bash
cat cookbooks/.checkpoints/landing-page.json | jq '.steps["create-project"]'
```
### API timeout
```
curl: (28) Operation timed out
```
Increase timeout with `API_TIMEOUT=120 ./tree-runner.sh run ...`
## Available Trees
### Basic Trees
| Tree | Description |
|------|-------------|
| `landing-page` | Single-page landing site with astro |
| `composable-app` | Multi-component monorepo with service + app |
| `sdlc-flow` | Feature lifecycle with SDLC orchestration |
### Aeries Trees (Multi-Phase Game Development)
Multi-phase workflow demonstrating progressive complexity for an AI agent simulation game:
| Tree | Description | Infrastructure |
|------|-------------|----------------|
| `aeries-1-genesis` | Monolith: Core API + React app for agent creation | Postgres |
| `aeries-2-simulation` | Extraction: Simulation service via strangler pattern | - |
| `aeries-3-society` | Social layer: Spatial service + Redis pub/sub | Redis |
**Running the Aeries sequence:**
```bash
# Phase 1: Create the monolith
./tree-runner.sh run aeries-1-genesis --project-name aeries-test
# Phase 2: Extract simulation service (operates on existing project)
./tree-runner.sh run aeries-2-simulation --project-id aeries-test
# Phase 3: Add social layer
./tree-runner.sh run aeries-3-society --project-id aeries-test
```
These trees demonstrate:
- **Multi-phase patterns** - Later phases take `project_id` not `project_name`
- **Build polling** - Shell-based waits for long-running SDLC builds
- **Service extraction** - Strangler pattern via `/extract-service` command
- **No teardown in phases 2+** - Project lifecycle owned by Phase 1
### Slackpath Trees (Reference Architectures)
Progressive complexity paths for building Slack-like platforms:
| Tree | Description | Infrastructure |
|------|-------------|----------------|
| `slackpath-1-authenticated-service` | Identity layer: User auth, JWT, protected routes | CockroachDB |
| `slackpath-2-async-worker-pipeline` | Background jobs: Producer/consumer with Redis | Redis |
| `slackpath-3-realtime-chat` | WebSockets: Pub/sub broadcasting | Redis |
| `slackpath-4-microservice-constellation` | Service mesh: Auth + Chat + Worker coordination | CockroachDB + Redis |
**Running a slackpath:**
```bash
./cookbooks/scripts/tree-runner.sh run slackpath-1-authenticated-service \
--project-name auth-test-$(date +%s)
```
These trees demonstrate:
- Infrastructure provisioning (`type: postgres`, `type: redis`)
- Automatic credential injection (`DATABASE_URL`, `REDIS_URL`)
- SDLC-driven implementation via `/implement-feature` prompts
- End-to-end verification scripts
## Files
```
cookbooks/
├── .checkpoints/ # Checkpoint storage (gitignored)
│ └── landing-page.json
├── scripts/
│ ├── lib/
│ │ ├── checkpoint.sh # Checkpoint I/O
│ │ └── tree-parser.sh # YAML parsing
│ └── tree-runner.sh # Main executable
└── trees/
├── landing-page.yaml
├── composable-app.yaml
├── sdlc-flow.yaml
├── aeries-1-genesis.yaml # Multi-phase: monolith
├── aeries-2-simulation.yaml # Multi-phase: extraction
├── aeries-3-society.yaml # Multi-phase: social layer
├── slackpath-1-authenticated-service.yaml
├── slackpath-2-async-worker-pipeline.yaml
├── slackpath-3-realtime-chat.yaml
└── slackpath-4-microservice-constellation.yaml
```
## Related
- [E2E Testing Strategy](./e2e-testing-strategy.md) — When to run trees, philosophy, history tracking
- [Composable Monorepo Templates](./composable-monorepo.md) — Template structure tested by trees

74
.woodpecker.yml Normal file
View File

@ -0,0 +1,74 @@
# Woodpecker CI for rdev platform
# Builds and deploys rdev-api, rdev-worker, and rdev-claudebox
variables:
- &registry "registry.threesix.ai"
- &when_main
branch: main
event: push
steps:
# Run tests first
test:
image: golang:1.22-alpine
commands:
- apk add --no-cache git
- go test ./...
# Build rdev-api image
build-api:
image: gcr.io/kaniko-project/executor:v1.23.2-debug
commands:
- /kaniko/executor
--context=/woodpecker/src
--dockerfile=Dockerfile.api
--destination=registry.threesix.ai/rdev/api:${CI_COMMIT_SHA:0:8}
--destination=registry.threesix.ai/rdev/api:latest
--cache=true
--skip-tls-verify
when:
<<: *when_main
# Build rdev-worker image
build-worker:
image: gcr.io/kaniko-project/executor:v1.23.2-debug
commands:
- /kaniko/executor
--context=/woodpecker/src
--dockerfile=Dockerfile.worker
--destination=registry.threesix.ai/rdev/worker:${CI_COMMIT_SHA:0:8}
--destination=registry.threesix.ai/rdev/worker:latest
--cache=true
--skip-tls-verify
when:
<<: *when_main
# Build rdev-claudebox image
build-claudebox:
image: gcr.io/kaniko-project/executor:v1.23.2-debug
commands:
- /kaniko/executor
--context=/woodpecker/src
--dockerfile=Dockerfile
--destination=registry.threesix.ai/rdev/claudebox:${CI_COMMIT_SHA:0:8}
--destination=registry.threesix.ai/rdev/claudebox:latest
--cache=true
--skip-tls-verify
when:
<<: *when_main
# Deploy to k3s cluster
deploy:
image: bitnami/kubectl:latest
commands:
- echo "Deploying rdev-api..."
- kubectl set image deployment/rdev-api rdev-api=registry.threesix.ai/rdev/api:${CI_COMMIT_SHA:0:8} -n rdev
- kubectl rollout status deployment/rdev-api -n rdev --timeout=120s
- echo "Deploying rdev-worker..."
- kubectl set image deployment/rdev-worker rdev-worker=registry.threesix.ai/rdev/worker:${CI_COMMIT_SHA:0:8} -n rdev
- kubectl rollout status deployment/rdev-worker -n rdev --timeout=120s
- echo "Deploying claudebox..."
- kubectl set image statefulset/claudebox claudebox=registry.threesix.ai/rdev/claudebox:${CI_COMMIT_SHA:0:8} -n rdev
- kubectl rollout status statefulset/claudebox -n rdev --timeout=300s
when:
<<: *when_main

View File

@ -35,6 +35,7 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" =
| **Composable monorepo templates** | [services/composable-monorepo.md](.claude/guides/services/composable-monorepo.md) |
| **E2E testing strategy** | [services/e2e-testing-strategy.md](.claude/guides/services/e2e-testing-strategy.md) |
| **Cookbook tree system (commands)** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md) |
| **Slackpath reference architectures** | [services/cookbook-trees.md](.claude/guides/services/cookbook-trees.md#slackpath-trees-reference-architectures) |
| **Write E2E cookbook scripts** | [cookbook-scripts/SKILL.md](.claude/skills/cookbook-scripts/SKILL.md) |
| **Build orchestration** | [services/build-orchestration.md](.claude/guides/services/build-orchestration.md) |
| **Build event streaming** | [services/build-streaming.md](.claude/guides/services/build-streaming.md) |
@ -72,6 +73,7 @@ When discussing code: "add to **platform**" = edit rdev; "add to **skeleton**" =
- **JSON decoding:** ALWAYS use `api.DecodeJSON(r, &req)` to decode request bodies. NEVER use raw `json.NewDecoder(r.Body).Decode()`. The helper handles nil body, EOF, and returns typed errors. Decode error message is always `"invalid request body"`.
- **Validation:** Use `validate.New()` accumulator for 2+ field checks in handlers: `v := validate.New(); v.Required(req.Name, "name"); v.Required(req.Type, "type"); if err := v.Error() { ... }`. Single-field checks can stay inline. NEVER duplicate validation logic that exists in `internal/validate`.
- **Error wrapping:** ALWAYS use `%w` (not `%v`) when wrapping errors in `fmt.Errorf`. Using `%v` stringifies the error and breaks `errors.Is`/`errors.As` chains. For non-error types (structs, slices), create a typed error implementing `error` instead of stringifying with `%v`.
- **Context propagation:** NEVER use `context.Background()` in handlers, services, or adapters that receive a context parameter. Always derive from parent context. Use `context.WithoutCancel(ctx)` for fire-and-forget goroutines that need tracing but independent cancellation.
## Quick Reference
@ -101,6 +103,9 @@ go test ./...
kubectl apply -f deployments/k8s/base/rdev-api.yaml
kubectl rollout restart -n rdev deployment/rdev-api
# Deploy claudebox worker (when Dockerfile changes)
./scripts/build-push.sh v0.4.0 claudebox && kubectl apply -f deployments/k8s/base/claudebox.yaml && kubectl rollout restart -n rdev statefulset/claudebox
# Verify pods
kubectl get pods -n rdev

View File

@ -1,5 +1,5 @@
# rdev claudebox - Claude Code in a container
# v0.4 - Git integration + SDLC CLI
# v0.5 - HTTP sidecar mode (replaces kubectl exec)
# Build stage for Go binaries
FROM golang:1.25-alpine AS builder
@ -8,6 +8,7 @@ COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o sdlc ./cmd/sdlc
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o claudebox-sidecar ./cmd/claudebox-sidecar
# Runtime stage
FROM ubuntu:22.04
@ -35,8 +36,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
# Install Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
# Copy sdlc binary from builder stage
# Copy Go binaries from builder stage
COPY --from=builder /build/sdlc /usr/local/bin/sdlc
COPY --from=builder /build/claudebox-sidecar /usr/local/bin/claudebox-sidecar
# Configure git for rdev-bot identity
RUN git config --global user.name "rdev-bot" \
@ -57,5 +59,8 @@ WORKDIR /workspace
RUN echo '#!/bin/bash\nclaude --version > /dev/null 2>&1' > /healthcheck.sh \
&& chmod +x /healthcheck.sh
# Keep container running (will exec into it)
CMD ["tail", "-f", "/dev/null"]
# Expose sidecar HTTP port
EXPOSE 8080
# Run claudebox-sidecar by default (HTTP server mode)
CMD ["claudebox-sidecar"]

31
Dockerfile.worker Normal file
View File

@ -0,0 +1,31 @@
# rdev-worker - Standalone worker for the rdev platform
# Runs as a standalone container with a claudebox sidecar for execution.
# Build stage
FROM golang:1.25-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o rdev-worker ./cmd/rdev-worker
# Runtime stage - minimal Alpine image
FROM alpine:3.19
# Install ca-certificates for HTTPS
RUN apk add --no-cache ca-certificates
# Copy worker binary
COPY --from=builder /build/rdev-worker /usr/local/bin/rdev-worker
# Create non-root user
RUN adduser -D -u 1000 worker
USER worker
# Default environment
ENV RDEV_API_URL="http://rdev-api.rdev.svc.cluster.local:8080"
ENV CLAUDEBOX_URL="http://localhost:8080"
ENV WORKER_POLL_INTERVAL="5s"
# Run worker
CMD ["rdev-worker"]

View File

@ -11,10 +11,12 @@ Composable Monorepo Templates evolve rdev's project scaffolding from single temp
**Key Facts:**
- `POST /projects` creates monorepo skeleton (not single template)
- `POST /projects/{id}/components` adds services/workers/apps/cli
- `POST /projects/{id}/components` adds services/workers/apps/cli (code) or postgres/redis (infrastructure)
- **Infrastructure provisioning:** `type: postgres` creates CockroachDB database, `type: redis` creates Redis cache
- **Automatic credential injection:** Components deployed after infrastructure get `DATABASE_URL`, `REDIS_URL` as env vars
- Convention-based discovery: `services/*/`, `workers/*/`, `apps/*/`, `cli/*/`
- Optional `component.yaml` per component for ports, dependencies, build order
- Shared `pkg/` from Aeries chassis + Colix patterns (8 packages)
- Shared `pkg/` from Aeries chassis + Colix patterns (8+ packages including queue, auth, database, realtime)
- Deployment supports whole-monorepo or individual-component targets
- **CI is template-provided** - skeleton has `.woodpecker.yml.tmpl`, components have `.woodpecker.step.yml.tmpl`
@ -95,10 +97,25 @@ Combines best patterns from Aeries (chassis) and Colix (modular):
| Type | Directory | Template | Identifier |
|------|-----------|----------|------------|
| Service | `services/` | go-api | `Makefile` or `go.mod` |
| Service | `services/` | service | `Makefile` or `go.mod` |
| Worker | `workers/` | worker | `Makefile` or `go.mod` |
| App | `apps/` | app-astro, app-react | `package.json` |
| App | `apps/` | app-astro, app-react, app-nextjs | `package.json` |
| CLI | `cli/` | cli | `Makefile` or `go.mod` |
| Postgres | `infra/postgres` | (provisioned) | CockroachDB database |
| Redis | `infra/redis` | (provisioned) | Redis cache with ACL |
## Infrastructure Provisioning
When you add `type: postgres` or `type: redis`:
1. **Database (postgres):** Creates CockroachDB database + user, stores `DATABASE_URL` in credential store
2. **Cache (redis):** Creates Redis ACL user with scoped prefix, stores `REDIS_URL` and `REDIS_PREFIX`
**Credential Injection:** When code components (service, worker, app) are deployed, the system automatically fetches stored credentials and injects them as K8s secrets → env vars.
**File Pointers:**
- Provisioning: `internal/service/component_infra.go`
- Credential injection: `internal/service/component_deploy.go:fetchProjectCredentials()`
## Template Migration

View File

@ -24,7 +24,7 @@ Quick reference for rdev concepts and facts.
| 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 | 2026-01 | Bot-driven build specs with audit trail |
| Composable Monorepo | [features/composable-monorepo.md](./features/composable-monorepo.md) | High | 2026-01 | Monorepo skeleton + component templates |
| Composable Monorepo | [features/composable-monorepo.md](./features/composable-monorepo.md) | High | 2026-02 | Monorepo skeleton + component templates + infra provisioning |
| **SDLC** |
| SDLC Orchestration | [services/sdlc.md](./services/sdlc.md) | High | 2026-02 | Feature lifecycle, classifier engine, rdev API integration |

928
app-vision-gaps.md Normal file
View File

@ -0,0 +1,928 @@
# Orchard Studio: Gap Analysis
This document maps the delta between current `rdev` capabilities and what Orchard Studio requires.
## Current Foundation (What We Have)
| Capability | Status | Location |
|------------|--------|----------|
| SDLC Classifier | ✅ Complete | `internal/sdlc/classifier.go` |
| Feature State Machine | ✅ Complete | `internal/sdlc/` (10 phases, 31 rules) |
| Composable Templates | ✅ Complete | `internal/adapter/templates/` |
| Worker Pod Execution | ✅ Complete | `internal/worker/sdlc_executor.go` |
| Webhook Dispatcher | ✅ Complete | `internal/webhook/dispatcher.go` |
| Project Provisioning | ✅ Complete | K8s namespace, DNS, git repo |
| Database Provisioning | ✅ Complete | CockroachDB adapter |
| Tree Workflows | ✅ Proven | `cookbooks/trees/*.yaml` |
---
## Gap 0: Design Reference Capture & Processing
**Current:** No mechanism for users to provide visual inspiration. Features are described purely in text.
**Required:** Users can provide URLs or screenshots as design references, which inform the Architect's questions and the Blueprint's design system section.
### What's Missing
```
┌─────────────────────────────────────────────────────────────────────────┐
│ CURRENT FLOW │
│ │
│ User: "Build a pricing page" │
│ Architect: *asks about data model, endpoints...*
│ (No visual context, design decisions are guesswork) │
└─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ REQUIRED FLOW │
│ │
│ User: "Build a pricing page like this" + [URL or screenshot] │
│ System: Captures screenshot, stores with Blueprint │
│ Architect: "I see a dark theme with 3 tiers..." → asks clarifying Qs │
│ Blueprint: Populates designSystem section with extracted tokens │
└─────────────────────────────────────────────────────────────────────────┘
```
### Two Input Types
| Input | Capture Method | Storage |
|-------|----------------|---------|
| **URL** | Playwright screenshots the page automatically | `/references/{blueprintId}/{refId}.png` |
| **Screenshot** | User uploads image (drag/drop, paste, file picker) | Same storage path |
### Implementation Required
1. **Reference Capture Service:**
- For URLs: Reuse `verify_executor.go` pattern (Playwright pod)
- For uploads: Standard file upload handling
- Store thumbnails alongside Blueprint
2. **Chat Endpoint Enhancement:**
- Accept `references[]` array in request body
- Process references before LLM call
- Include reference images in Architect prompt context
3. **Architect Prompt Updates:**
- Describe what it observes in natural language
- Ask clarifying questions about design intent
- Extract structured design tokens into Blueprint
4. **Blueprint Schema:**
- Add `references.items[]` array
- Add `sections.designSystem` section
- Track which references informed which design decisions
5. **Plan Pane Rendering:**
- Show reference thumbnails in UI
- Display extracted design tokens
- Allow user to add annotations
### Complexity: Medium
- URL capture reuses existing Playwright infrastructure
- File upload is standard pattern
- Main work is Architect prompt engineering for visual understanding
- LLM vision capabilities needed (Claude can see images natively)
---
## Gap 1: Blueprint Storage & Chat API
**Current:** Features are created via `POST /sdlc/features` with a complete spec. No iterative refinement.
**Required:** Multi-turn conversation that builds a Blueprint incrementally.
### What's Missing
```
┌─────────────────────────────────────────────────────────────────┐
│ CURRENT FLOW │
│ │
│ User writes spec → POST /sdlc/features → Feature created │
│ (one shot, no iteration) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ REQUIRED FLOW │
│ │
│ User message → Architect responds + updates Blueprint → │
│ User message → Architect responds + updates Blueprint → │
│ ...repeat until ready... │
│ User: "build it" → Blueprint → SDLC Feature → Build │
└─────────────────────────────────────────────────────────────────┘
```
### Implementation Required
1. **Database Tables:**
- `blueprints` - stores structured Blueprint JSON
- `blueprint_messages` - conversation history with snapshots
2. **API Endpoints:**
- `POST /projects/{id}/blueprint/chat` - send message, get reply + updated blueprint
- `GET /projects/{id}/blueprints` - list blueprints
- `GET /projects/{id}/blueprints/{id}` - get specific blueprint
- `DELETE /projects/{id}/blueprints/{id}` - discard draft
3. **Service Layer:**
- `ArchitectService` - manages conversation, calls LLM, updates Blueprint
### Complexity: Medium
- Schema is defined (see app-vision.md)
- Standard CRUD + LLM integration
- Most work is in prompt engineering for Architect
---
## Gap 2: Architect Agent Persona
**Current:** We have coding agents (`/implement-feature`). They write code, not specs.
**Required:** An agent that asks questions, fills in a structured Blueprint, knows when to stop.
### What's Missing
```
┌─────────────────────────────────────────────────────────────────┐
│ CURRENT AGENTS │
│ │
│ User: "Add cat photos" │
│ Agent: *immediately writes code*
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ ARCHITECT AGENT │
│ │
│ User: "Add cat photos" │
│ Architect: "Should photos be public or friends-only?" │
│ User: "Public" │
│ Architect: "Got it. Do you want likes, comments, or neither?" │
│ ...continues until Blueprint is complete... │
└─────────────────────────────────────────────────────────────────┘
```
### Implementation Required
1. **System Prompt:**
- `.claude/agents/architect.md` - detailed persona
- Structured output format (reply + Blueprint JSON)
- Question strategy (when to ask vs assume)
2. **Structured Output Parsing:**
- LLM returns `{reply: string, blueprint: Blueprint}`
- Validate Blueprint against schema
- Handle partial updates (delta vs full replacement)
3. **Completeness Logic:**
- `isReadyToBuild(blueprint)` function
- Clear rules for when questions are resolved
- Override mechanism for user to force build
### Complexity: Medium-High
- Prompt engineering is iterative
- Structured output from LLMs can be fragile
- Need fallback handling for malformed responses
---
## Gap 3: Operation Tracking (Tree Runner in DB)
**Current:** Tree workflows run via shell script (`tree-runner.sh`). State in local JSON files.
**Required:** Operations tracked in database, queryable via API, streamable to UI.
### What's Missing
```
┌─────────────────────────────────────────────────────────────────┐
│ CURRENT │
│ │
│ ./tree-runner.sh slackpath-1.yaml │
│ → Runs in terminal │
│ → State in .checkpoints/slackpath-1.json │
│ → No API visibility │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ REQUIRED │
│ │
│ POST /operations/start {tree: "slackpath-1"} │
│ → Returns operation_id │
│ → State in operations table │
│ → GET /operations/{id}/stream returns SSE events │
└─────────────────────────────────────────────────────────────────┘
```
### Implementation Required
1. **Database Tables:**
- `operations` - tracks running/completed operations
- `operation_events` - event log for replay/streaming
2. **Service Layer:**
- `OrchestratorService` - manages operation lifecycle
- Port tree-runner logic from bash to Go
- Event emission during execution
3. **API Endpoints:**
- `POST /projects/{id}/operations` - start operation
- `GET /projects/{id}/operations/{id}` - get status
- `GET /projects/{id}/operations/{id}/stream` - SSE stream
4. **Worker Integration:**
- SDLC executor emits events as it progresses
- Events written to `operation_events` table
- SSE handler reads from table and streams
### Complexity: High
- Tree runner logic is non-trivial (dependencies, outputs, error handling)
- SSE streaming requires careful connection management
- Need to handle operation cancellation, resumption
---
## Gap 4: Real-Time Progress Streaming
**Current:** Webhooks fire on build complete. No per-step visibility.
**Required:** SSE stream showing "Designing schema... Writing handlers... Running tests..."
### What's Missing
```
┌─────────────────────────────────────────────────────────────────┐
│ CURRENT │
│ │
│ Build starts → ... silence ... → Webhook: "build complete" │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ REQUIRED │
│ │
│ Build starts → │
│ event: {"phase": "spec", "status": "complete"} │
│ event: {"phase": "design", "status": "in_progress"} │
│ event: {"phase": "design", "status": "complete"} │
│ event: {"phase": "implement", "progress": 0.5} │
│ ... │
│ event: {"status": "complete", "url": "..."} │
└─────────────────────────────────────────────────────────────────┘
```
### Implementation Required
1. **SDLC Executor Changes:**
- Emit events at phase transitions
- Emit progress within phases (task completion)
- Write events to `operation_events` table
2. **SSE Handler:**
- `GET /operations/{id}/stream`
- Long-lived connection
- Read events from DB (or Redis pub/sub)
- Handle client disconnection gracefully
3. **Event Types:**
```go
type OperationEvent struct {
Type string // "phase", "progress", "artifact", "error", "complete"
Phase string // "spec", "design", "implement", "test", "deploy"
Status string // "in_progress", "complete", "failed"
Message string // Human-readable
Progress float64 // 0.0 to 1.0 for granular progress
Timestamp time.Time
}
```
### Complexity: Medium
- SSE is straightforward in Go
- Main work is instrumenting SDLC executor
- Need to balance granularity vs noise
---
## Gap 5: Blueprint → SDLC Feature Conversion
**Current:** SDLC features are created manually with spec documents.
**Required:** Automated conversion from structured Blueprint to SDLC feature spec.
### What's Missing
```
┌─────────────────────────────────────────────────────────────────┐
│ CURRENT │
│ │
│ Human writes: spec.md with prose description │
│ → POST /sdlc/features │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ REQUIRED │
│ │
│ Blueprint JSON → Template rendering → spec.md │
│ → Automated POST /sdlc/features │
└─────────────────────────────────────────────────────────────────┘
```
### Implementation Required
1. **Spec Template:**
```markdown
# Feature: {{.Feature}}
## Summary
{{.Summary}}
## Data Model
{{range .Sections.DataModel.Entities}}
### {{.Name}}
| Field | Type |
|-------|------|
{{range .Fields}}| {{.Name}} | {{.Type}} |
{{end}}
{{end}}
## API Endpoints
{{range .Sections.APIEndpoints.Endpoints}}
- `{{.Method}} {{.Path}}` - {{.Description}}
{{end}}
## UI Components
{{range .Sections.UIComponents.Components}}
- **{{.Name}}**: {{.Purpose}}
{{end}}
## Assumptions
{{range .Assumptions}}
- {{.Assumption}}
{{end}}
```
2. **Conversion Service:**
- Takes Blueprint, renders spec.md
- Creates SDLC feature via existing API
- Links Blueprint to created feature (`built_feature_slug`)
### Complexity: Low
- Template rendering is straightforward
- SDLC feature creation already exists
- Main work is template design
---
## Gap 6: Frontend (Next.js Studio)
**Current:** No frontend. All interaction via API/CLI.
**Required:** Three-pane interface (Chat, Plan, Preview).
### What's Missing
Everything. This is a new application.
### Implementation Required
1. **Project Setup:**
- Next.js 14 with App Router
- Tailwind CSS for styling
- Authentication (integrate with rdev auth)
2. **Core Components:**
```
apps/studio/
├── app/
│ ├── page.tsx # Template selection
│ ├── projects/
│ │ └── [id]/
│ │ └── page.tsx # Three-pane workspace
│ └── api/ # Proxy to rdev-api
├── components/
│ ├── ChatPane.tsx
│ ├── PlanPane.tsx
│ ├── PreviewPane.tsx
│ ├── ActivityFeed.tsx
│ └── BuildProgress.tsx
└── lib/
├── api.ts # rdev-api client
└── sse.ts # SSE connection manager
```
3. **State Management:**
- Blueprint state (updated on each chat response)
- Operation state (updated via SSE)
- UI state (which pane is focused, etc.)
4. **Key Interactions:**
- Send chat message → receive reply + blueprint
- Click "Build It" → start operation → show progress
- Operation complete → refresh preview iframe
### Complexity: Medium
- Standard Next.js app
- SSE client requires careful handling
- Most complexity is in polish and UX
---
## Gap 7: Platform Service Infrastructure
**Current:** Projects manage their own integrations. No shared services, no credential management.
**Required:** A service catalog with provisioning, credential injection, and upgrade paths for existing projects.
### The "Upgrade" Problem
```
┌─────────────────────────────────────────────────────────────────┐
│ CURRENT │
│ │
│ Project created 3 months ago │
│ → No centralized logging │
│ → No analytics │
│ → Rolling your own email │
│ → No easy way to add platform services │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ REQUIRED │
│ │
│ POST /projects/{id}/services │
│ { "type": "logging", "provider": "loki" } │
│ │
│ → Provision credentials │
│ → Inject into K8s secrets │
│ → Create integration PR with config changes │
│ → Project now ships logs to centralized system │
└─────────────────────────────────────────────────────────────────┘
```
### Service Rollout Order
Build infrastructure with simplest service first, then add complexity:
| Order | Service | Why This Order |
|-------|---------|----------------|
| 1 | **Logging** | Pure infrastructure, no user-facing code changes |
| 2 | **Email** | Simple API calls, clear success/failure |
| 3 | **Stats** | Frontend SDK + backend events |
| 4 | **Auth** | Most complex (middleware, user model, protected routes) |
### Implementation Required
#### 1. Service Catalog
```yaml
# internal/platform/catalog.yaml
services:
logging:
description: "Centralized log aggregation"
providers:
loki:
name: "Grafana Loki"
credentials:
- LOKI_URL
- LOKI_TENANT_ID
integration:
go:
config_template: "loki-logger.go.tmpl"
env_example: ["LOKI_URL", "LOKI_TENANT_ID"]
node:
packages: ["pino", "pino-loki"]
config_template: "pino-loki.ts.tmpl"
email:
description: "Transactional email"
providers:
resend:
name: "Resend"
credentials:
- RESEND_API_KEY
integration:
go:
packages: ["github.com/resendlabs/resend-go"]
service_template: "email-service.go.tmpl"
node:
packages: ["resend"]
service_template: "email-client.ts.tmpl"
stats:
description: "Product analytics"
providers:
posthog:
name: "PostHog"
credentials:
- POSTHOG_API_KEY
- POSTHOG_HOST
integration:
go:
packages: ["github.com/posthog/posthog-go"]
node:
packages: ["posthog-js", "posthog-node"]
provider_template: "analytics-provider.tsx.tmpl"
auth:
description: "User authentication"
providers:
clerk:
name: "Clerk"
credentials:
- CLERK_SECRET_KEY
- NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
integration:
node:
packages: ["@clerk/nextjs"]
middleware_template: "clerk-middleware.ts.tmpl"
provider_template: "clerk-provider.tsx.tmpl"
```
#### 2. Database Schema
```sql
-- Track which services a project uses
CREATE TABLE project_services (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES projects(id),
service_type TEXT NOT NULL, -- 'logging', 'email', 'stats', 'auth'
provider TEXT NOT NULL, -- 'loki', 'resend', 'posthog', 'clerk'
environment TEXT NOT NULL, -- 'staging', 'production', 'all'
-- Encrypted credentials
credentials_encrypted BYTEA,
-- Non-sensitive config
config JSONB NOT NULL DEFAULT '{}',
-- Status tracking
status TEXT NOT NULL DEFAULT 'provisioning',
-- provisioning → active → needs_update → deprovisioned
-- Integration tracking
integration_status TEXT DEFAULT 'pending',
-- pending → pr_created → integrated → needs_update
integration_pr_url TEXT,
integration_commit TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(project_id, service_type, environment)
);
```
#### 3. Provisioner Interface
```go
// internal/port/platform_provisioner.go
type PlatformProvisioner interface {
// Provision creates credentials for a project
Provision(ctx context.Context, req ProvisionRequest) (*ProvisionResult, error)
// Verify checks if credentials are still valid
Verify(ctx context.Context, projectID string, creds map[string]string) error
// Deprovision cleans up (optional, for account removal)
Deprovision(ctx context.Context, projectID string) error
}
type ProvisionRequest struct {
ProjectID uuid.UUID
ProjectName string
Environment string // "staging", "production"
}
type ProvisionResult struct {
Credentials map[string]string // Encrypted before storage
Config map[string]string // Non-sensitive config
}
```
#### 4. Service Addition API
```
POST /projects/{projectId}/services
{
"serviceType": "logging",
"provider": "loki" // Optional, uses platform default
}
Response:
{
"serviceId": "svc_abc123",
"status": "provisioning",
"integrationMethod": "pr", // or "direct"
"prUrl": null // Populated when PR is created
}
GET /projects/{projectId}/services/{serviceId}
{
"serviceId": "svc_abc123",
"serviceType": "logging",
"provider": "loki",
"status": "active",
"integrationStatus": "integrated",
"integrationCommit": "abc123...",
"credentials": {
"LOKI_URL": "[redacted]",
"LOKI_TENANT_ID": "project-xyz"
}
}
```
#### 5. Integration Flow
```
POST /projects/{id}/services {type: "logging", provider: "loki"}
┌─────────────────────────────────────────────────────────────────┐
│ 1. PROVISION │
│ │
│ LokiProvisioner.Provision() │
│ → Create tenant in Loki (or use shared with project prefix) │
│ → Generate credentials │
│ → Store encrypted in project_services │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 2. INJECT │
│ │
│ K8sSecretInjector.Inject() │
│ → Add LOKI_URL, LOKI_TENANT_ID to project's K8s secret │
│ → Trigger deployment restart to pick up new env vars │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 3. INTEGRATE │
│ │
│ IntegrationService.CreatePR() or .DirectCommit() │
│ → Clone project repo │
│ → Apply integration templates: │
│ • Update logger config to ship to Loki │
│ • Add env vars to .env.example │
│ • Update deployment to mount secrets │
│ → Create PR (or direct commit for new projects) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 4. VERIFY │
│ │
│ After PR merge / deploy: │
│ → Check logs appearing in Loki │
│ → Update integration_status to "integrated" │
└─────────────────────────────────────────────────────────────────┘
```
### Complexity: High
- Service catalog is straightforward (YAML/DB)
- Each provisioner is unique (Loki vs Resend vs PostHog)
- Credential encryption and management needs care
- Integration templates need to handle Go + Node + various frameworks
- PR creation requires git operations
### Starting Point: Logging with Loki
```go
// internal/adapter/loki/provisioner.go
type LokiProvisioner struct {
lokiURL string
adminToken string // For tenant creation if using multi-tenant Loki
}
func (p *LokiProvisioner) Provision(ctx context.Context, req ProvisionRequest) (*ProvisionResult, error) {
// For single-tenant Loki, just create a unique label prefix
tenantID := fmt.Sprintf("project-%s", req.ProjectID)
return &ProvisionResult{
Credentials: map[string]string{
"LOKI_URL": p.lokiURL,
"LOKI_TENANT_ID": tenantID,
},
Config: map[string]string{
"service_name": req.ProjectName,
},
}, nil
}
```
---
## Gap 8: Dual Environment Support
**Current:** Single deployment per project. Main branch = production.
**Required:** Staging + Production environments. Build deploys to staging, "Publish" promotes to production.
### The Environment Model
```
┌─────────────────────────────────────────────────────────────────┐
│ Project: cool-project │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ STAGING │ │
│ │ staging.cool-project.threesix.ai │ │
│ │ │ │
│ │ • Where development happens │ │
│ │ • Preview pane shows this │ │
│ │ • "Build It" deploys here │ │
│ │ • May use test credentials for services │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ [Publish] │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PRODUCTION │ │
│ │ cool-project.threesix.ai │ │
│ │ │ │
│ │ • User-facing, stable │ │
│ │ • Only updated via explicit "Publish" │ │
│ │ • Production credentials for services │ │
│ │ • Enabled after first publish │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### Implementation Required
#### 1. DNS Changes
```go
// On project creation, create both records (prod may be placeholder)
CreateDNSRecord("staging.cool-project.threesix.ai", stagingIP)
CreateDNSRecord("cool-project.threesix.ai", prodIP) // Or placeholder until first publish
```
#### 2. K8s Deployment Model
```yaml
# Option A: Two deployments in same namespace
apiVersion: apps/v1
kind: Deployment
metadata:
name: cool-project-staging
namespace: cool-project
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: cool-project-production
namespace: cool-project
# Option B: Two namespaces (cleaner isolation)
# cool-project-staging namespace
# cool-project-production namespace
```
**Recommendation:** Same namespace, two deployments. Simpler to manage, secrets can be shared or scoped.
#### 3. Database Model
Two options:
**A. Same database, schema prefixes:**
```sql
-- Staging tables
staging_users, staging_posts, staging_...
-- Production tables
prod_users, prod_posts, prod_...
```
**B. Separate databases (cleaner):**
```
cool-project-staging (CockroachDB database)
cool-project-production (CockroachDB database)
```
**Recommendation:** Separate databases. Cleaner isolation, no risk of cross-env data access.
#### 4. Project Schema Updates
```sql
ALTER TABLE projects ADD COLUMN environments JSONB NOT NULL DEFAULT '{
"staging": {"enabled": true, "deployed_at": null},
"production": {"enabled": false, "deployed_at": null, "published_at": null}
}';
```
#### 5. Publish API
```
POST /projects/{projectId}/publish
{
"fromEnvironment": "staging", // Usually staging
"toEnvironment": "production"
}
Response:
{
"operationId": "op_xyz789",
"status": "publishing",
"streamUrl": "/operations/{operationId}/stream"
}
```
**Publish Flow:**
1. Validate staging is healthy
2. Provision production credentials for any services (if not exist)
3. Run migrations on production database
4. Deploy staging image to production deployment
5. Health check production
6. Update DNS if needed
7. Update project.environments.production
### Complexity: Medium
- DNS: Already have CloudflareAdapter, just create two records
- K8s: Straightforward deployment duplication
- Database: CockroachDB adapter supports multiple databases
- Main complexity is the publish flow coordination
### Defer Until After Gap 7
Dual environments can work with platform services, but we can build Gap 7 (services) first:
- Services provision for a single environment initially
- Then extend to environment-aware provisioning
- Then add the publish flow that syncs services to production
---
## Summary: Work Required
| Gap | Effort | Dependencies | Critical Path |
|-----|--------|--------------|---------------|
| 0. Design References | 2-3 days | Gap 1 (storage) | Yes (for design flows) |
| 1. Blueprint Storage | 2-3 days | None | Yes |
| 2. Architect Agent | 3-5 days | Gap 1 | Yes |
| 3. Operation Tracking | 4-6 days | None | Yes |
| 4. Progress Streaming | 2-3 days | Gap 3 | Yes |
| 5. Blueprint → SDLC | 1-2 days | Gap 1 | Yes |
| 6. Frontend | 5-7 days | Gaps 1-5 | Yes |
| 7. Platform Services | 5-8 days | None (can start now) | Parallel track |
| 8. Dual Environments | 3-5 days | Gap 7 | After services work |
**Total Estimate:** 4-5 weeks of focused work (Gaps 7-8 can parallel with 1-6)
**Service Rollout (within Gap 7):**
1. Logging (Loki) - 2 days
2. Email (Resend) - 2 days
3. Stats (PostHog) - 2 days
4. Auth (Clerk) - 3 days
**Note:** Gap 0 (Design References) can be implemented in parallel with Gap 2 (Architect Agent) since both involve Architect prompt engineering. The reference capture infrastructure (Gap 0) builds on Gap 1's storage layer.
### Critical Path
```
┌──► Gap 0 (References) ──┐
│ │
Gap 1 (Blueprint) ──┼──► Gap 2 (Architect) ───┼──► Gap 5 (Conversion)
│ │
│ └──► Gap 6 (Frontend)
│ ▲
Gap 3 (Operations) ─┴──► Gap 4 (Streaming) ────────┘
Parallel Track:
Gap 7 (Services) ──► Logging ──► Email ──► Stats ──► Auth
└──► Gap 8 (Environments) ──► Publish Flow
```
Gap 7 can start immediately and run parallel to the Studio work.
Gap 8 depends on Gap 7 for service credential handling per environment.
---
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Architect outputs malformed JSON | High | Medium | JSON schema validation, retry logic |
| SSE connections drop | Medium | Low | Client-side reconnection, event replay from DB |
| Blueprint schema too restrictive | Medium | Medium | Start minimal, add sections iteratively |
| LLM latency affects chat UX | Low | High | Stream partial responses, show typing indicator |
| Build failures leave broken state | Low | Medium | SDLC already handles partial state |
---
## What's NOT a Gap
These are already solved by the current rdev foundation:
- **Project provisioning** - K8s, DNS, git all work
- **Template seeding** - Composable monorepo templates
- **SDLC execution** - Classifier + worker + artifact tracking
- **CI/CD** - Woodpecker integration
- **Database provisioning** - CockroachDB adapter
- **Webhooks** - Event dispatcher with retry
The foundation is solid. The gaps are about **exposing** existing capabilities through a conversational UI, not rebuilding core functionality.

1384
app-vision-roadmap.md Normal file

File diff suppressed because it is too large Load Diff

1220
app-vision.md Normal file

File diff suppressed because it is too large Load Diff

11
changelog/v0.10.52.md Normal file
View File

@ -0,0 +1,11 @@
# v0.10.52
**Released:** 2026-02-05
## Changes
feat: SDLC worker routing for skeleton projects with auto-init
---
**Image:** `ghcr.io/orchard9/rdev-api:v0.10.52`

11
changelog/v0.10.53.md Normal file
View File

@ -0,0 +1,11 @@
# v0.10.53
**Released:** 2026-02-05
## Changes
fix: shell-quote SDLC command args to handle spaces in titles
---
**Image:** `ghcr.io/orchard9/rdev-api:v0.10.53`

11
changelog/v0.10.54.md Normal file
View File

@ -0,0 +1,11 @@
# v0.10.54
**Released:** 2026-02-05
## Changes
fix: go.work race condition with batch components
---
**Image:** `ghcr.io/orchard9/rdev-api:v0.10.54`

11
changelog/v0.10.55.md Normal file
View File

@ -0,0 +1,11 @@
# v0.10.55
**Released:** 2026-02-05
## Changes
fix: Dockerfile templates use GOWORK=off for independent component builds
---
**Image:** `ghcr.io/orchard9/rdev-api:v0.10.55`

11
changelog/v0.10.56.md Normal file
View File

@ -0,0 +1,11 @@
# v0.10.56
**Released:** 2026-02-05
## Changes
fix: worker template unused pkg/config import
---
**Image:** `ghcr.io/orchard9/rdev-api:v0.10.56`

View File

@ -0,0 +1,111 @@
// Package main provides the claudebox-sidecar HTTP server.
// This sidecar runs alongside Claude Code in worker pods, exposing HTTP endpoints
// for execute, git, and SDLC operations - replacing kubectl exec calls.
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/orchard9/rdev/internal/claudebox"
"github.com/orchard9/rdev/internal/envutil"
"github.com/orchard9/rdev/internal/logging"
)
func main() {
// Configure logging
logLevel := logging.LevelInfo
if envutil.GetEnvBool("DEBUG", false) {
logLevel = logging.LevelDebug
}
log := logging.New(logging.Config{
Level: logLevel,
Format: logging.FormatJSON,
})
// Configuration from environment
port := envutil.GetEnv("PORT", "8080")
workDir := envutil.GetEnv("WORKSPACE_DIR", "/workspace")
giteaToken := os.Getenv("GITEA_TOKEN") // Required for git push auth
gitUser := envutil.GetEnv("GIT_USER", "rdev-worker")
gitEmail := envutil.GetEnv("GIT_EMAIL", "worker@threesix.ai")
// Create server components
executor := claudebox.NewExecutor(workDir)
gitOps := claudebox.NewGitOperations(claudebox.GitOperationsConfig{
WorkDir: workDir,
GiteaToken: giteaToken,
GitUser: gitUser,
GitEmail: gitEmail,
Logger: log.Slog(),
})
sdlcRunner := claudebox.NewSDLCRunner(claudebox.SDLCRunnerConfig{
WorkDir: workDir,
Logger: log.Slog(),
})
// Create the server
server := claudebox.NewServer(claudebox.ServerConfig{
Executor: executor,
GitOps: gitOps,
SDLCRunner: sdlcRunner,
Logger: log.Slog(),
})
// Create router
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(logging.Middleware(logging.MiddlewareConfig{
Logger: log,
}))
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(10 * time.Minute))
// Mount server routes
server.Mount(r)
// Create HTTP server
addr := fmt.Sprintf(":%s", port)
httpServer := &http.Server{
Addr: addr,
Handler: r,
ReadTimeout: 30 * time.Second,
WriteTimeout: 15 * time.Minute, // Long timeout for streaming responses
IdleTimeout: 60 * time.Second,
}
// Start server in goroutine
go func() {
log.Info("starting claudebox-sidecar", "addr", addr, "workDir", workDir)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("server error", logging.FieldError, err)
os.Exit(1)
}
}()
// Wait for shutdown signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info("shutting down server")
// Graceful shutdown with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
log.Error("server shutdown error", logging.FieldError, err)
os.Exit(1)
}
log.Info("server stopped")
}

266
cmd/rdev-worker/main.go Normal file
View File

@ -0,0 +1,266 @@
// Package main provides the standalone rdev-worker binary.
// This worker runs as a separate container alongside a claudebox sidecar,
// polling the rdev-api for tasks and executing them via HTTP calls to the sidecar.
package main
import (
"context"
"os"
"os/signal"
"strings"
"syscall"
"time"
claudeboxclient "github.com/orchard9/rdev/internal/adapter/claudebox"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/envutil"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/internal/worker"
)
// version is set via ldflags at build time:
// go build -ldflags "-X main.version=v1.0.0" ./cmd/rdev-worker
var version = "dev"
func main() {
// Configure logging
logLevel := logging.LevelInfo
if envutil.GetEnvBool("DEBUG", false) {
logLevel = logging.LevelDebug
}
log := logging.New(logging.Config{
Level: logLevel,
Format: logging.FormatJSON,
}).WithWorker("rdev-worker")
// Configuration from environment
cfg := loadConfig()
log.Info("starting rdev-worker",
"worker_id", cfg.WorkerID,
"rdev_api_url", cfg.RdevAPIURL,
"claudebox_url", cfg.ClaudeboxURL,
"poll_interval", cfg.PollInterval,
)
// Create API client for rdev-api
apiClient := worker.NewAPIClient(worker.APIClientConfig{
BaseURL: cfg.RdevAPIURL,
APIKey: cfg.APIKey,
Timeout: 30 * time.Second,
})
// Create claudebox client for sidecar
claudeboxClient := claudeboxclient.NewClient(claudeboxclient.ClientConfig{
BaseURL: cfg.ClaudeboxURL,
Timeout: 15 * time.Minute,
})
// Create context with cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Register worker
hostname, _ := os.Hostname()
if err := apiClient.Register(ctx, &worker.RegisterRequest{
ID: cfg.WorkerID,
Hostname: hostname,
Version: version,
Capabilities: cfg.Capabilities,
}); err != nil {
log.Error("failed to register worker", logging.FieldError, err)
os.Exit(1)
}
log.Info("worker registered", "worker_id", cfg.WorkerID)
// Create executors
buildExecutor := worker.NewHTTPBuildExecutor(worker.HTTPBuildExecutorConfig{
ClaudeboxClient: claudeboxClient,
WorkDir: "/workspace",
})
sdlcExecutor := worker.NewHTTPSDLCTaskExecutor(worker.HTTPSDLCTaskExecutorConfig{
ClaudeboxClient: claudeboxClient,
WorkDir: "/workspace",
})
// Start heartbeat loop
go runHeartbeat(ctx, apiClient, cfg.WorkerID, cfg.HeartbeatInterval, log)
// Start work loop
go runWorkLoop(ctx, apiClient, buildExecutor, sdlcExecutor, cfg, log)
// Wait for shutdown signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info("shutting down worker")
cancel()
// Give ongoing work a chance to complete
time.Sleep(5 * time.Second)
log.Info("worker stopped")
}
// Config holds worker configuration.
type Config struct {
WorkerID string
RdevAPIURL string
ClaudeboxURL string
APIKey string
PollInterval time.Duration
HeartbeatInterval time.Duration
TaskTimeout time.Duration
Capabilities []string
}
// loadConfig loads configuration from environment variables.
func loadConfig() *Config {
hostname, _ := os.Hostname()
workerID := envutil.GetEnv("WORKER_ID", hostname)
return &Config{
WorkerID: workerID,
RdevAPIURL: envutil.GetEnv("RDEV_API_URL", "http://rdev-api.rdev.svc.cluster.local:8080"),
ClaudeboxURL: envutil.GetEnv("CLAUDEBOX_URL", "http://localhost:8080"),
APIKey: os.Getenv("RDEV_API_KEY"),
PollInterval: parseDuration(envutil.GetEnv("WORKER_POLL_INTERVAL", "5s"), 5*time.Second),
HeartbeatInterval: parseDuration(envutil.GetEnv("WORKER_HEARTBEAT_INTERVAL", "30s"), 30*time.Second),
TaskTimeout: parseDuration(envutil.GetEnv("WORKER_TASK_TIMEOUT", "15m"), 15*time.Minute),
Capabilities: parseCapabilities(os.Getenv("WORKER_CAPABILITIES")),
}
}
// parseDuration parses a duration string with a default fallback.
func parseDuration(s string, defaultVal time.Duration) time.Duration {
d, err := time.ParseDuration(s)
if err != nil {
return defaultVal
}
return d
}
// parseCapabilities parses a comma-separated list of capabilities.
func parseCapabilities(s string) []string {
if s == "" {
return []string{"build", "sdlc"}
}
var caps []string
for _, c := range strings.Split(s, ",") {
c = strings.TrimSpace(c)
if c != "" {
caps = append(caps, c)
}
}
return caps
}
// runHeartbeat runs the heartbeat loop.
func runHeartbeat(ctx context.Context, client *worker.APIClient, workerID string, interval time.Duration, log *logging.Logger) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := client.Heartbeat(ctx, workerID); err != nil {
log.Warn("heartbeat failed", logging.FieldError, err)
}
}
}
}
// runWorkLoop runs the main work polling loop.
func runWorkLoop(
ctx context.Context,
client *worker.APIClient,
buildExecutor *worker.HTTPBuildExecutor,
sdlcExecutor *worker.HTTPSDLCTaskExecutor,
cfg *Config,
log *logging.Logger,
) {
ticker := time.NewTicker(cfg.PollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
// Try to claim a task
task, err := client.ClaimTask(ctx, cfg.WorkerID)
if err != nil {
log.Warn("failed to claim task", logging.FieldError, err)
continue
}
if task == nil {
// No tasks available
continue
}
log.Info("task claimed",
"task_id", task.ID,
logging.FieldProjectID, task.ProjectID,
"type", task.Type,
)
// Execute the task
executeTask(ctx, client, buildExecutor, sdlcExecutor, task, cfg, log)
}
}
}
// executeTask executes a single task.
func executeTask(
ctx context.Context,
client *worker.APIClient,
buildExecutor *worker.HTTPBuildExecutor,
sdlcExecutor *worker.HTTPSDLCTaskExecutor,
task *domain.WorkTask,
cfg *Config,
log *logging.Logger,
) {
// Create task context with timeout
taskCtx, cancel := context.WithTimeout(ctx, cfg.TaskTimeout)
defer cancel()
var result *domain.BuildResult
switch task.Type {
case domain.WorkTaskTypeBuild:
result = buildExecutor.Execute(taskCtx, task)
case domain.WorkTaskTypeSDLC:
result = sdlcExecutor.Execute(taskCtx, task)
default:
result = &domain.BuildResult{
Success: false,
Error: "unsupported task type: " + string(task.Type),
}
}
// Report result back to API
if result.Success {
if err := client.CompleteTask(ctx, cfg.WorkerID, task.ID, result); err != nil {
log.Error("failed to complete task", "task_id", task.ID, logging.FieldError, err)
} else {
log.Info("task completed",
"task_id", task.ID,
"duration_ms", result.DurationMs,
)
}
} else {
if err := client.FailTask(ctx, cfg.WorkerID, task.ID, result.Error, result.Output, result.DurationMs); err != nil {
log.Error("failed to report task failure", "task_id", task.ID, logging.FieldError, err)
} else {
log.Warn("task failed",
"task_id", task.ID,
"error", result.Error,
"duration_ms", result.DurationMs,
)
}
}
}

View File

@ -13,9 +13,10 @@
set -euo pipefail
# Require environment variables
: "${RDEV_API_URL:?RDEV_API_URL must be set}"
: "${RDEV_API_KEY:?RDEV_API_KEY must be set}"
# Environment variables (checked at runtime by preflight_check, not on source)
# This allows commands like 'list' to work without credentials
RDEV_API_URL="${RDEV_API_URL:-}"
RDEV_API_KEY="${RDEV_API_KEY:-}"
# Auto-cleanup configuration
# Set AUTO_TEARDOWN=true to automatically clean up projects on exit
@ -66,6 +67,9 @@ BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Default API timeout in seconds (can be overridden with API_TIMEOUT env var)
API_TIMEOUT="${API_TIMEOUT:-60}"
# Make an authenticated API call
# Arguments: method endpoint [data]
# Example: api_call GET "/projects"
@ -76,12 +80,12 @@ api_call() {
local data="${3:-}"
if [[ -n "$data" ]]; then
curl -s -X "$method" "$RDEV_API_URL$endpoint" \
curl -s --max-time "$API_TIMEOUT" -X "$method" "$RDEV_API_URL$endpoint" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d "$data"
else
curl -s -X "$method" "$RDEV_API_URL$endpoint" \
curl -s --max-time "$API_TIMEOUT" -X "$method" "$RDEV_API_URL$endpoint" \
-H "X-API-Key: $RDEV_API_KEY"
fi
}

View File

@ -57,10 +57,8 @@ tree_parse() {
tree_get_meta() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
echo "$tree" | jq '{name: .name, description: .description, version: .version}'
# Pipe directly to avoid newline corruption in bash variables
tree_parse "$tree_name" | jq '{name: .name, description: .description, version: .version}'
}
# Get default vars from tree
@ -69,10 +67,8 @@ tree_get_meta() {
tree_get_default_vars() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
echo "$tree" | jq '.vars // {}'
# Pipe directly to avoid newline corruption in bash variables
tree_parse "$tree_name" | jq '.vars // {}'
}
# Get a specific step definition
@ -83,19 +79,31 @@ tree_get_step() {
local tree_name="$1"
local step_name="$2"
local tree
tree=$(tree_parse "$tree_name") || return 1
# Use a temp file to handle multi-line JSON safely
local tmpfile
tmpfile=$(mktemp)
local step
step=$(echo "$tree" | jq --arg step "$step_name" '.steps[$step] // null')
if [[ "$step" == "null" ]]; then
echo "Error: Step '$step_name' not found in tree '$tree_name'" >&2
# Parse tree directly to temp file to avoid bash variable corruption
if ! tree_parse "$tree_name" > "$tmpfile" 2>/dev/null; then
rm -f "$tmpfile"
return 1
fi
# Add step name to the JSON for convenience
echo "$step" | jq --arg name "$step_name" '. + {name: $name}'
# Check if step exists
local step_exists
step_exists=$(jq --arg step "$step_name" '.steps | has($step)' "$tmpfile")
if [[ "$step_exists" != "true" ]]; then
echo "Error: Step '$step_name' not found in tree '$tree_name'" >&2
rm -f "$tmpfile"
return 1
fi
# Extract step and add name field
local result
result=$(jq --arg step "$step_name" --arg name "$step_name" '.steps[$step] + {name: $name}' "$tmpfile")
rm -f "$tmpfile"
echo "$result"
}
# Get all step names
@ -104,10 +112,8 @@ tree_get_step() {
tree_get_steps() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
echo "$tree" | jq -r '.steps | keys[]'
# Pipe directly to avoid newline corruption in bash variables
tree_parse "$tree_name" | jq -r '.steps | keys[]'
}
# Get dependencies for a step
@ -131,16 +137,19 @@ tree_get_deps() {
tree_execution_order() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
# Pipe directly through jq to avoid bash variable corruption
# Use a temp file to safely handle multi-line shell commands in YAML
local tmpfile
tmpfile=$(mktemp)
# Kahn's algorithm for topological sort
# Build adjacency list and in-degree count
local steps_json
steps_json=$(echo "$tree" | jq '.steps')
if ! tree_parse "$tree_name" > "$tmpfile" 2>/dev/null; then
rm -f "$tmpfile"
return 1
fi
# Use jq to compute the topological order
echo "$steps_json" | jq -r '
# Kahn's algorithm for topological sort - use jq on file directly
local result
result=$(jq -r '.steps |
# Build in-degree map and adjacency list
. as $steps |
(keys | map({key: ., value: 0}) | from_entries) as $initial_degrees |
@ -178,7 +187,9 @@ tree_execution_order() {
)
) |
.result[]
'
' "$tmpfile")
rm -f "$tmpfile"
echo "$result"
}
# Check if a step's dependencies are satisfied
@ -301,10 +312,8 @@ tree_list_detail() {
tree_get_teardown() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
echo "$tree" | jq '.teardown // []'
# Pipe directly to avoid newline corruption in bash variables
tree_parse "$tree_name" | jq '.teardown // []'
}
# Get step action type
@ -344,21 +353,28 @@ tree_step_outputs() {
tree_validate() {
local tree_name="$1"
local tree
tree=$(tree_parse "$tree_name") || return 1
# Use a temp file to handle multi-line JSON safely
local tmpfile
tmpfile=$(mktemp)
if ! tree_parse "$tree_name" > "$tmpfile" 2>/dev/null; then
echo "Error: Failed to parse tree '$tree_name'" >&2
rm -f "$tmpfile"
return 1
fi
local errors=()
# Check required fields
local name
name=$(echo "$tree" | jq -r '.name // ""')
name=$(jq -r '.name // ""' "$tmpfile")
if [[ -z "$name" ]]; then
errors+=("Missing required field: name")
fi
# Check that all steps have action field
local steps_without_action
steps_without_action=$(echo "$tree" | jq -r '.steps | to_entries | .[] | select(.value.action == null) | .key')
steps_without_action=$(jq -r '.steps | to_entries | .[] | select(.value.action == null) | .key' "$tmpfile")
if [[ -n "$steps_without_action" ]]; then
while IFS= read -r step; do
errors+=("Step '$step' missing required field: action")
@ -366,16 +382,15 @@ tree_validate() {
fi
# Check that dependencies reference existing steps
local all_steps
all_steps=$(echo "$tree" | jq -r '.steps | keys')
local invalid_deps
invalid_deps=$(echo "$tree" | jq -r --argjson all_steps "$all_steps" '
.steps | to_entries | .[] |
invalid_deps=$(jq -r '
.steps | keys as $all_steps |
to_entries | .[] |
.key as $step |
(.value.depends_on // [])[] |
select(. as $dep | $all_steps | index($dep) == null) |
"\($step) depends on non-existent step: \(.)"
')
' "$tmpfile")
if [[ -n "$invalid_deps" ]]; then
while IFS= read -r err; do
errors+=("$err")
@ -387,6 +402,48 @@ tree_validate() {
errors+=("Dependency cycle detected")
fi
# Check output references have proper depends_on (transitive)
local output_ref_errors
output_ref_errors=$(jq -r '
.steps as $steps |
# Build transitive dependency closure for each step
def transitive_deps($step_name):
def visit($s; $visited):
if $visited | index($s) then $visited
else
($visited + [$s]) as $v |
reduce (($steps[$s].depends_on // [])[] | select(. as $d | $steps | has($d))) as $dep
($v; visit($dep; .))
end;
visit($step_name; []) | .[1:]; # Remove self from result
$steps | keys[] as $step_name |
$steps[$step_name] as $step |
(transitive_deps($step_name)) as $all_deps |
# Convert step to string and find all {{ .outputs.X.Y }} patterns
($step | tostring | [match("\\{\\{\\s*\\.outputs\\.([a-zA-Z_][a-zA-Z0-9_-]*)\\.[a-zA-Z_][a-zA-Z0-9_]*\\s*\\}\\}"; "g")] | map(.captures[0].string) | unique) as $refs |
$refs[] |
. as $ref |
# Check if ref exists as a step
if ($steps | has($ref) | not) then
"\($step_name): references outputs from non-existent step \"\($ref)\""
# Check if ref is in transitive dependencies
elif ($all_deps | index($ref) == null) then
"\($step_name): references outputs from \"\($ref)\" but does not depend on it (directly or transitively)"
else
empty
end
' "$tmpfile")
if [[ -n "$output_ref_errors" ]]; then
while IFS= read -r err; do
errors+=("$err")
done <<< "$output_ref_errors"
fi
# Clean up temp file
rm -f "$tmpfile"
# Report errors
if [[ ${#errors[@]} -gt 0 ]]; then
echo "Validation errors in tree '$tree_name':" >&2

View File

@ -4,7 +4,7 @@ set -euo pipefail
# Tree Runner - Execute cookbook trees with checkpoint support
#
# Usage:
# ./tree-runner.sh run <tree> [--var-name value]...
# ./tree-runner.sh run <tree> [--var-name value]... [--dry-run]
# ./tree-runner.sh resume <tree>
# ./tree-runner.sh only <tree> <step>
# ./tree-runner.sh status <tree>
@ -12,8 +12,13 @@ set -euo pipefail
# ./tree-runner.sh list
# ./tree-runner.sh clean <tree>
#
# Flags:
# --dry-run Validate tree and show execution plan without running
# --auto-teardown Run teardown on exit (success or failure)
#
# Examples:
# ./tree-runner.sh run landing-page --project-name my-test
# ./tree-runner.sh run landing-page --project-name test --dry-run
# ./tree-runner.sh resume landing-page
# ./tree-runner.sh only landing-page wait-pipeline
# ./tree-runner.sh status landing-page
@ -28,13 +33,20 @@ source "$SCRIPT_DIR/common.sh"
source "$SCRIPT_DIR/lib/checkpoint.sh"
source "$SCRIPT_DIR/lib/tree-parser.sh"
# Parse --auto-teardown flag from args
# Parse global flags from args
DRY_RUN="false"
ARGS=("$@")
for i in "${!ARGS[@]}"; do
if [[ "${ARGS[$i]}" == "--auto-teardown" ]]; then
case "${ARGS[$i]}" in
--auto-teardown)
AUTO_TEARDOWN="true"
unset 'ARGS[$i]'
fi
;;
--dry-run)
DRY_RUN="true"
unset 'ARGS[$i]'
;;
esac
done
ARGS=("${ARGS[@]}") # Re-index array
set -- "${ARGS[@]}" # Reset positional params
@ -56,8 +68,13 @@ if [[ -z "$COMMAND" ]]; then
echo " list List available trees"
echo " clean <tree> Delete checkpoint for a tree"
echo ""
echo "Global Flags:"
echo " --dry-run Validate and show execution plan without running"
echo " --auto-teardown Run teardown on exit (success or failure)"
echo ""
echo "Examples:"
echo " $0 run landing-page --project-name my-test-\$(date +%s)"
echo " $0 run landing-page --project-name test --dry-run"
echo " $0 resume landing-page"
echo " $0 only landing-page wait-pipeline"
echo " $0 status landing-page"
@ -124,6 +141,25 @@ execute_wait_site_step() {
wait_for_site "$domain" "$max_attempts" "$poll_interval" "$project_id"
}
# Execute a wait_build step
# Arguments: step_json
# Returns: 0 on success, 1 on failure, 2 on timeout
execute_wait_build_step() {
local step_json="$1"
local build_id max_attempts poll_interval
build_id=$(echo "$step_json" | jq -r '.build_id')
max_attempts=$(echo "$step_json" | jq -r '.max_attempts // 120')
poll_interval=$(echo "$step_json" | jq -r '.poll_interval // 5')
if [[ -z "$build_id" || "$build_id" == "null" ]]; then
print_error "wait_build: build_id is required"
return 1
fi
wait_for_build "$build_id" "$max_attempts" "$poll_interval"
}
# Execute a diagnose step
# Arguments: step_json
execute_diagnose_step() {
@ -157,7 +193,9 @@ execute_shell_step() {
local command
command=$(echo "$step_json" | jq -r '.command')
eval "$command"
# Use bash -c instead of eval to run command in a subshell
# This is safer than eval and still allows shell features
bash -c "$command"
}
# Extract outputs from response
@ -244,6 +282,11 @@ execute_step() {
execute_wait_site_step "$step" >&2 || step_failed=1
response="{}"
;;
wait_build)
# Redirect status output to stderr so it doesn't pollute JSON return
execute_wait_build_step "$step" >&2 || step_failed=1
response="{}"
;;
diagnose)
execute_diagnose_step "$step" >&2
response="{}"
@ -267,6 +310,7 @@ execute_step() {
if [[ "$on_error" == "continue" ]]; then
print_warning "Step failed but continuing (on_error: continue)" >&2
checkpoint_step_complete "$tree_name" "$step_name" "{}"
echo "{}" # Return empty outputs for caller to merge
return 0
fi
return 1
@ -313,6 +357,121 @@ build_outputs_from_checkpoint() {
# Commands
# ============================================================================
# Dry-run: validate tree and show execution plan without running
# Arguments: tree_name vars_json
cmd_dryrun() {
local tree_name="$1"
local vars_json="$2"
print_header "Dry Run: $tree_name"
echo -e "${CYAN}This is a preview. No actions will be taken.${NC}"
echo ""
# Show tree metadata
local meta
meta=$(tree_get_meta "$tree_name")
echo "Tree: $(echo "$meta" | jq -r '.name')"
echo "Description: $(echo "$meta" | jq -r '.description // "No description"')"
echo "Version: $(echo "$meta" | jq -r '.version // 1')"
echo ""
# Show variables
echo "Variables:"
echo "$vars_json" | jq -r 'to_entries | .[] | " \(.key): \(.value)"'
echo ""
# Get execution order
local execution_order
execution_order=$(tree_execution_order "$tree_name")
echo "Execution Plan:"
local step_num=0
while IFS= read -r step_name; do
((step_num++))
# Get step details - use temp file approach to avoid bash variable corruption
local tmpfile
tmpfile=$(mktemp)
tree_parse "$tree_name" > "$tmpfile" 2>/dev/null
local step_json
step_json=$(jq --arg step "$step_name" '.steps[$step]' "$tmpfile")
rm -f "$tmpfile"
local action description deps
action=$(echo "$step_json" | jq -r '.action // "unknown"')
description=$(echo "$step_json" | jq -r '.description // ""')
deps=$(echo "$step_json" | jq -r '(.depends_on // []) | join(", ")')
# Format action type with color
local action_color
case "$action" in
api) action_color="${GREEN}api${NC}" ;;
shell) action_color="${YELLOW}shell${NC}" ;;
wait_pipeline|wait_site|wait_build) action_color="${BLUE}wait${NC}" ;;
diagnose) action_color="${RED}diagnose${NC}" ;;
*) action_color="$action" ;;
esac
echo -e " ${step_num}. ${CYAN}$step_name${NC} [$action_color]"
if [[ -n "$description" ]]; then
echo " $description"
fi
if [[ -n "$deps" ]]; then
echo " depends_on: $deps"
fi
# Show details for specific action types
case "$action" in
api)
local method endpoint
method=$(echo "$step_json" | jq -r '.method // "GET"')
endpoint=$(echo "$step_json" | jq -r '.endpoint')
echo "$method $endpoint"
;;
shell)
local cmd_preview
cmd_preview=$(echo "$step_json" | jq -r '.command' | head -1 | cut -c1-60)
if [[ ${#cmd_preview} -eq 60 ]]; then
cmd_preview="${cmd_preview}..."
fi
echo "$cmd_preview"
;;
wait_pipeline)
echo " → Wait for CI pipeline to complete"
;;
wait_site)
local domain
domain=$(echo "$step_json" | jq -r '.domain // "N/A"')
echo " → Wait for https://$domain"
;;
wait_build)
local build_id_tmpl max_attempts
build_id_tmpl=$(echo "$step_json" | jq -r '.build_id // "N/A"')
max_attempts=$(echo "$step_json" | jq -r '.max_attempts // 120')
echo " → Wait for build $build_id_tmpl (max ${max_attempts} attempts)"
;;
esac
echo ""
done <<< "$execution_order"
# Show teardown steps
local teardown
teardown=$(tree_get_teardown "$tree_name")
local teardown_count
teardown_count=$(echo "$teardown" | jq 'length')
if [[ "$teardown_count" -gt 0 ]]; then
echo "Teardown Steps: ($teardown_count steps)"
echo "$teardown" | jq -r '.[] | " - \(.action): \(.description // .endpoint // "cleanup")"'
echo ""
fi
print_success "Dry run complete. Tree is valid and ready to execute."
echo ""
echo "To run for real:"
echo " $0 run $tree_name $(echo "$vars_json" | jq -r 'to_entries | map("--\(.key | gsub("_"; "-")) \(.value)") | join(" ")')"
}
# Auto-teardown handler for tree runner
# Called on exit when AUTO_TEARDOWN=true
tree_auto_teardown() {
@ -328,6 +487,55 @@ tree_auto_teardown() {
# Track tree name for auto-teardown (set during cmd_run)
TREE_AUTO_TEARDOWN_NAME=""
# Pre-flight checks before tree execution
# Returns: 0 if all checks pass, 1 with error messages if not
preflight_check() {
local errors=()
# Check required environment variables
if [[ -z "${RDEV_API_URL:-}" ]]; then
errors+=("RDEV_API_URL environment variable is not set")
fi
if [[ -z "${RDEV_API_KEY:-}" ]]; then
errors+=("RDEV_API_KEY environment variable is not set")
fi
# Check required tools
if ! command -v yq &> /dev/null; then
errors+=("yq is not installed (brew install yq)")
fi
if ! command -v jq &> /dev/null; then
errors+=("jq is not installed (brew install jq)")
fi
if ! command -v curl &> /dev/null; then
errors+=("curl is not installed")
fi
# Check API reachability (quick health check, only if env vars are set)
if [[ -n "${RDEV_API_URL:-}" && -n "${RDEV_API_KEY:-}" ]]; then
local health_response
health_response=$(curl -s --max-time 5 "$RDEV_API_URL/health" -H "X-API-Key: $RDEV_API_KEY" 2>/dev/null || echo '{"error":"unreachable"}')
if echo "$health_response" | jq -e '.error' > /dev/null 2>&1; then
local error_msg
error_msg=$(echo "$health_response" | jq -r '.error // "API unreachable"')
errors+=("API health check failed: $error_msg (check RDEV_API_URL: $RDEV_API_URL)")
fi
fi
# Report errors
if [[ ${#errors[@]} -gt 0 ]]; then
echo -e "${RED}Pre-flight checks failed:${NC}" >&2
for err in "${errors[@]}"; do
echo "$err" >&2
done
echo "" >&2
echo "Fix these issues before running trees." >&2
return 1
fi
return 0
}
# Run a tree from the beginning
cmd_run() {
local tree_name="${1:-}"
@ -338,6 +546,11 @@ cmd_run() {
exit 1
fi
# Run pre-flight checks
if ! preflight_check; then
exit 1
fi
# Register auto-teardown trap
TREE_AUTO_TEARDOWN_NAME="$tree_name"
trap tree_auto_teardown EXIT INT TERM
@ -351,6 +564,12 @@ cmd_run() {
exit 1
fi
# Validate tree structure
if ! tree_validate "$tree_name"; then
print_error "Tree '$tree_name' has validation errors (see above)"
exit 1
fi
# Parse variables from args
local vars_json
vars_json=$(tree_get_default_vars "$tree_name")
@ -375,15 +594,21 @@ cmd_run() {
esac
done
# Check required vars (empty string values)
# Check required vars (empty string values) - skip for dry-run to allow preview with placeholders
local missing_vars
missing_vars=$(echo "$vars_json" | jq -r 'to_entries | .[] | select(.value == "") | .key')
if [[ -n "$missing_vars" ]]; then
if [[ -n "$missing_vars" && "$DRY_RUN" != "true" ]]; then
print_error "Missing required variables:"
echo "$missing_vars" | sed 's/^/ --/'
exit 1
fi
# Handle dry-run mode
if [[ "$DRY_RUN" == "true" ]]; then
cmd_dryrun "$tree_name" "$vars_json"
exit 0
fi
# Initialize checkpoint
local run_id
run_id=$(checkpoint_init "$tree_name" "$vars_json")

View File

@ -73,15 +73,12 @@ steps:
- build_id: .data.task_id
wait-spec:
action: shell
command: |
for i in {1..60}; do
STATUS=$(curl -s "$RDEV_API_URL/builds/{{ .outputs.spec-feature.build_id }}" -H "X-API-Key: $RDEV_API_KEY" | jq -r '.data.status // .status')
if [ "$STATUS" == "completed" ]; then exit 0; fi
if [ "$STATUS" == "failed" ]; then exit 1; fi
sleep 5
done
exit 1
description: Wait for spec generation
depends_on: [spec-feature]
action: wait_build
build_id: "{{ .outputs.spec-feature.build_id }}"
max_attempts: 60
poll_interval: 5
implement-backend:
description: "Implement GET/POST /agents in Core API"
@ -98,15 +95,12 @@ steps:
- build_id: .data.task_id
wait-backend:
action: shell
command: |
for i in {1..120}; do
STATUS=$(curl -s "$RDEV_API_URL/builds/{{ .outputs.implement-backend.build_id }}" -H "X-API-Key: $RDEV_API_KEY" | jq -r '.data.status // .status')
if [ "$STATUS" == "completed" ]; then exit 0; fi
if [ "$STATUS" == "failed" ]; then exit 1; fi
sleep 5
done
exit 1
description: Wait for backend implementation
depends_on: [implement-backend]
action: wait_build
build_id: "{{ .outputs.implement-backend.build_id }}"
max_attempts: 120
poll_interval: 5
wait-deploy:
action: wait_pipeline

View File

@ -0,0 +1,60 @@
name: aeries-2-simulation
description: "Aeries Phase 2: The Spark of Life. Extracts Agent Simulation logic into a dedicated service."
version: 1
vars:
project_id: "" # Required - ID from genesis run
feature_slug: "extract-simulation"
steps:
# --- Step 1: Mitosis (Extraction) ---
create-simulation-svc:
description: "Scaffold new Simulation Service"
action: api
method: POST
endpoint: "/projects/{{ .vars.project_id }}/components"
body: { type: worker, name: "simulation-svc" }
extract-logic:
description: "Agent moves Agent Logic from Core to Simulation Service"
action: api
method: POST
endpoint: "/projects/{{ .vars.project_id }}/builds"
body:
prompt: "/extract-service core-api/internal/domain/agent_logic simulation-svc --pattern strangler"
auto_commit: true
auto_push: true
git_clone_url: "https://git.threesix.ai/jordan/{{ .vars.project_id }}.git"
outputs:
- build_id: .data.task_id
wait-extraction:
description: Wait for extraction to complete
depends_on: [extract-logic]
action: wait_build
build_id: "{{ .outputs.extract-logic.build_id }}"
max_attempts: 120
poll_interval: 5
wait-deploy:
action: wait_pipeline
project_id: "{{ .vars.project_id }}"
# --- Verification: Parity ---
verify-parity:
description: "Ensure Core API still returns Agent data (now proxied)"
depends_on: [wait-deploy]
action: shell
command: |
DOMAIN=$(curl -s "$RDEV_API_URL/projects/{{ .vars.project_id }}" -H "X-API-Key: $RDEV_API_KEY" | jq -r .domain)
# Assuming we have an agent from Genesis
ID=$(curl -s "https://$DOMAIN/api/agents" | jq -r '.[0].id')
RESP=$(curl -s "https://$DOMAIN/api/agents/$ID")
if [[ -n "$ID" && "$ID" != "null" ]] && echo "$RESP" | grep -q "$ID"; then
echo "Parity Verified: Proxied request succeeded"
exit 0
else
echo "Failure: Request failed after extraction"
exit 1
fi

View File

@ -0,0 +1,71 @@
name: aeries-3-society
description: "Aeries Phase 3: The Social Layer. Adds Spatial Service and Redis Pub/Sub for agent interactions."
version: 1
vars:
project_id: "" # Required
feature_slug: "spatial-social"
steps:
# --- Infrastructure ---
add-redis:
description: "Add Redis for Real-time Events"
action: api
method: POST
endpoint: "/projects/{{ .vars.project_id }}/components"
body: { type: redis, name: "world-state" }
add-spatial-svc:
description: "Add Spatial Service to track positions"
depends_on: [add-redis]
action: api
method: POST
endpoint: "/projects/{{ .vars.project_id }}/components"
body: { type: service, name: "spatial-svc" }
wait-infra:
action: wait_pipeline
project_id: "{{ .vars.project_id }}"
# --- Feature: Proximity Chat ---
implement-social:
description: "Agent connects Simulation to Spatial via Redis"
depends_on: [wait-infra]
action: api
method: POST
endpoint: "/projects/{{ .vars.project_id }}/builds"
body:
prompt: "/implement-feature {{ .vars.feature_slug }} --requirements 'Simulation SVC publishes agent moves to Redis. Spatial SVC tracks proximity. If two agents are near, Core UI shows a chat bubble.'"
auto_commit: true
auto_push: true
git_clone_url: "https://git.threesix.ai/jordan/{{ .vars.project_id }}.git"
outputs:
- build_id: .data.task_id
wait-code:
description: Wait for social layer implementation
depends_on: [implement-social]
action: wait_build
build_id: "{{ .outputs.implement-social.build_id }}"
max_attempts: 120
poll_interval: 5
wait-deploy:
action: wait_pipeline
project_id: "{{ .vars.project_id }}"
# --- Verification ---
verify-society:
description: "Test Event Stream"
depends_on: [wait-deploy]
action: shell
command: |
DOMAIN=$(curl -s "$RDEV_API_URL/projects/{{ .vars.project_id }}" -H "X-API-Key: $RDEV_API_KEY" | jq -r .domain)
# Check if events endpoint exists
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://$DOMAIN/api/events")
if [[ "$HTTP_CODE" == "200" || "$HTTP_CODE" == "101" ]]; then
echo "Society Layer Live"
exit 0
else
exit 1
fi

View File

@ -69,18 +69,10 @@ steps:
wait-feature-build:
description: Wait for the spec generation to finish
depends_on: [generate-spec]
action: shell
command: |
echo "Waiting for build {{ .outputs.generate-spec.build_id }}..."
for i in {1..60}; do
STATUS=$(curl -s "$RDEV_API_URL/builds/{{ .outputs.generate-spec.build_id }}" -H "X-API-Key: $RDEV_API_KEY" | jq -r '.data.status // .status')
echo "Attempt $i: Build status is $STATUS"
if [ "$STATUS" == "completed" ]; then exit 0; fi
if [ "$STATUS" == "failed" ]; then echo "Build failed"; exit 1; fi
sleep 5
done
echo "Timeout waiting for build"
exit 1
action: wait_build
build_id: "{{ .outputs.generate-spec.build_id }}"
max_attempts: 60
poll_interval: 5
check-artifact:
description: Verify spec artifact was created

View File

@ -1,5 +1,5 @@
name: full-stack-feature
description: End-to-end enterprise feature development: Spec -> Design -> Implementation (DB + API) -> Verification
description: "End-to-end enterprise feature development: Spec -> Design -> Implementation (DB + API) -> Verification"
version: 1
vars:
@ -69,15 +69,10 @@ steps:
wait-spec:
description: Wait for spec generation
depends_on: [generate-spec]
action: shell
command: |
for i in {1..60}; do
STATUS=$(curl -s "$RDEV_API_URL/builds/{{ .outputs.generate-spec.build_id }}" -H "X-API-Key: $RDEV_API_KEY" | jq -r '.data.status // .status')
if [ "$STATUS" == "completed" ]; then exit 0; fi
if [ "$STATUS" == "failed" ]; then exit 1; fi
sleep 5
done
exit 1
action: wait_build
build_id: "{{ .outputs.generate-spec.build_id }}"
max_attempts: 60
poll_interval: 5
approve-spec:
description: Approve the Spec artifact
@ -104,15 +99,10 @@ steps:
wait-design:
description: Wait for design generation
depends_on: [generate-design]
action: shell
command: |
for i in {1..60}; do
STATUS=$(curl -s "$RDEV_API_URL/builds/{{ .outputs.generate-design.build_id }}" -H "X-API-Key: $RDEV_API_KEY" | jq -r '.data.status // .status')
if [ "$STATUS" == "completed" ]; then exit 0; fi
if [ "$STATUS" == "failed" ]; then exit 1; fi
sleep 5
done
exit 1
action: wait_build
build_id: "{{ .outputs.generate-design.build_id }}"
max_attempts: 60
poll_interval: 5
approve-design:
description: Approve the Design artifact
@ -152,15 +142,10 @@ steps:
wait-implementation:
description: Wait for code generation
depends_on: [implement-backend]
action: shell
command: |
for i in {1..120}; do
STATUS=$(curl -s "$RDEV_API_URL/builds/{{ .outputs.implement-backend.build_id }}" -H "X-API-Key: $RDEV_API_KEY" | jq -r '.data.status // .status')
if [ "$STATUS" == "completed" ]; then exit 0; fi
if [ "$STATUS" == "failed" ]; then exit 1; fi
sleep 5
done
exit 1
action: wait_build
build_id: "{{ .outputs.implement-backend.build_id }}"
max_attempts: 120
poll_interval: 5
wait-deploy:
description: Wait for CI/CD to deploy the new feature

View File

@ -44,6 +44,7 @@ steps:
template: service
wait-init:
depends_on: [add-service]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
@ -72,46 +73,74 @@ implement-auth:
- build_id: .data.task_id
wait-build:
action: shell
command: |
for i in {1..120}; do
STATUS=$(curl -s "$RDEV_API_URL/builds/{{ .outputs.implement-auth.build_id }}" -H "X-API-Key: $RDEV_API_KEY" | jq -r '.data.status // .status')
if [ "$STATUS" == "completed" ]; then exit 0; fi
if [ "$STATUS" == "failed" ]; then exit 1; fi
sleep 5
done
exit 1
description: Wait for agent code generation
depends_on: [implement-auth]
action: wait_build
build_id: "{{ .outputs.implement-auth.build_id }}"
max_attempts: 120
poll_interval: 5
wait-deploy:
depends_on: [wait-build]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
# --- Verification ---
verify-security:
description: "Ensure protected routes reject unauthenticated requests"
verify-service-running:
description: "Verify the auth service is running and reachable"
depends_on: [wait-deploy]
action: shell
command: |
HTTP_CODE=$(curl -s -o /dev/null -w "%{{http_code}}" "https://{{ .outputs.create-project.domain }}/api/me")
if [ "$HTTP_CODE" == "401" ]; then echo "Security OK"; exit 0; else echo "Fail: /me returned $HTTP_CODE"; exit 1; fi
DOMAIN="{{ .outputs.create-project.domain }}"
SERVICE_NAME="{{ .vars.service_name }}"
# Check health endpoint
HEALTH=$(curl -s "https://$DOMAIN/api/$SERVICE_NAME/health" | jq -r '.data.status // empty')
if [ "$HEALTH" == "healthy" ]; then
echo "Service healthy: /api/$SERVICE_NAME/health returned healthy"
exit 0
else
echo "Fail: service not healthy"
exit 1
fi
verify-login-flow:
description: "Register -> Login -> Access Protected Route"
depends_on: [verify-security]
description: "Register -> Login -> Access Protected Route (optional - depends on agent implementation)"
depends_on: [verify-service-running]
on_error: continue
action: shell
command: |
DOMAIN="{{ .outputs.create-project.domain }}"
EMAIL="test-{{ .outputs.create-project.project_id }}@example.com"
SERVICE_NAME="{{ .vars.service_name }}"
PROJECT_ID="{{ .outputs.create-project.project_id }}"
EMAIL="test-${PROJECT_ID}@example.com"
BASE_URL="https://$DOMAIN/api/$SERVICE_NAME"
# 1. Register
curl -X POST "https://$DOMAIN/api/register" -d "{{\"email\":\"$EMAIL\",\"password\":\"hunter2\"}}"
echo "Registering $EMAIL..."
REGISTER_RESP=$(curl -s -X POST "$BASE_URL/register" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$EMAIL\",\"password\":\"hunter2\"}")
echo "Register response: $REGISTER_RESP"
# 2. Login
TOKEN=$(curl -s -X POST "https://$DOMAIN/api/login" -d "{{\"email\":\"$EMAIL\",\"password\":\"hunter2\"}}" | jq -r .token)
echo "Logging in..."
LOGIN_RESP=$(curl -s -X POST "$BASE_URL/login" \
-H "Content-Type: application/json" \
-d "{\"email\":\"$EMAIL\",\"password\":\"hunter2\"}")
echo "Login response: $LOGIN_RESP"
TOKEN=$(echo "$LOGIN_RESP" | jq -r .token)
if [ -z "$TOKEN" ] || [ "$TOKEN" == "null" ]; then
echo "Failed: Could not get token from login response"
exit 1
fi
# 3. Access Protected
RESP=$(curl -s -H "Authorization: Bearer $TOKEN" "https://$DOMAIN/api/me")
if echo "$RESP" | grep -q "$EMAIL"; then exit 0; else exit 1; fi
echo "Accessing protected route..."
RESP=$(curl -s -H "Authorization: Bearer $TOKEN" "$BASE_URL/me")
echo "Protected response: $RESP"
if echo "$RESP" | grep -q "$EMAIL"; then echo "Login flow OK"; exit 0; else echo "Failed: Email not found in response"; exit 1; fi
teardown:
- action: api

View File

@ -20,8 +20,9 @@ steps:
- domain: .data.domain
add-redis:
description: Add Redis for job queue
description: Add Redis for job queue (may already exist from skeleton)
depends_on: [create-project]
on_error: continue
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
@ -29,34 +30,28 @@ steps:
type: redis
name: "job-queue"
add-api:
description: Public API (Producer)
depends_on: [add-redis]
add-components:
description: Add API + Worker atomically (single git commit)
depends_on: [create-project, add-redis]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components/batch"
body:
type: service
components:
- type: service
name: "api"
add-worker:
description: Worker Service (Consumer)
depends_on: [add-redis]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
body:
type: worker
- type: worker
name: "background-processor"
wait-infra:
depends_on: [create-project, add-components]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
# --- Implementation ---
implement-queue:
description: "Agent implements Job Queue logic in API and Worker"
depends_on: [wait-infra]
depends_on: [create-project, wait-infra]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds"
@ -69,24 +64,38 @@ steps:
- build_id: .data.task_id
wait-code:
action: shell
command: |
for i in {1..120}; do
STATUS=$(curl -s "$RDEV_API_URL/builds/{{ .outputs.implement-queue.build_id }}" -H "X-API-Key: $RDEV_API_KEY" | jq -r '.data.status // .status')
if [ "$STATUS" == "completed" ]; then exit 0; fi
if [ "$STATUS" == "failed" ]; then exit 1; fi
sleep 5
done
exit 1
description: Wait for agent code generation
depends_on: [implement-queue]
action: wait_build
build_id: "{{ .outputs.implement-queue.build_id }}"
max_attempts: 120
poll_interval: 5
wait-deploy:
depends_on: [create-project, wait-code]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
# --- Verification ---
verify-service-running:
description: "Verify API service is running"
depends_on: [create-project, wait-deploy]
action: shell
command: |
DOMAIN="{{ .outputs.create-project.domain }}"
HEALTH=$(curl -s "https://$DOMAIN/api/api/health" | jq -r '.data.status // empty')
if [ "$HEALTH" == "healthy" ]; then
echo "API service healthy"
exit 0
else
echo "Fail: API service not healthy"
exit 1
fi
verify-async:
description: "Create Job -> Verify Acceptance -> Poll for Completion"
depends_on: [wait-deploy]
description: "Create Job -> Verify Acceptance -> Poll for Completion (optional)"
depends_on: [create-project, verify-service-running]
on_error: continue
action: shell
command: |
DOMAIN="{{ .outputs.create-project.domain }}"

View File

@ -20,8 +20,9 @@ steps:
- domain: .data.domain
add-redis:
description: Add Redis for Pub/Sub
description: Add Redis for Pub/Sub (may already exist from skeleton)
depends_on: [create-project]
on_error: continue
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
@ -40,6 +41,7 @@ steps:
name: "chat-api"
wait-init:
depends_on: [add-service]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
@ -59,27 +61,40 @@ steps:
- build_id: .data.task_id
wait-build:
action: shell
command: |
for i in {1..120}; do
STATUS=$(curl -s "$RDEV_API_URL/builds/{{ .outputs.implement-sockets.build_id }}" -H "X-API-Key: $RDEV_API_KEY" | jq -r '.data.status // .status')
if [ "$STATUS" == "completed" ]; then exit 0; fi
if [ "$STATUS" == "failed" ]; then exit 1; fi
sleep 5
done
exit 1
description: Wait for agent code generation
depends_on: [implement-sockets]
action: wait_build
build_id: "{{ .outputs.implement-sockets.build_id }}"
max_attempts: 120
poll_interval: 5
wait-deploy:
depends_on: [wait-build]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
# --- Verification ---
# Note: Requires a tool that can speak WebSocket (e.g. wscat or python script)
# We will use a python script injected into the shell command
verify-chat:
description: "Connect Client A, Send from Client B, Verify Receipt"
verify-service-running:
description: "Verify chat service is running"
depends_on: [wait-deploy]
action: shell
command: |
DOMAIN="{{ .outputs.create-project.domain }}"
HEALTH=$(curl -s "https://$DOMAIN/api/chat-api/health" | jq -r '.data.status // empty')
if [ "$HEALTH" == "healthy" ]; then
echo "Chat service healthy"
exit 0
else
echo "Fail: Chat service not healthy"
exit 1
fi
# Note: WebSocket verification requires special tooling
verify-chat:
description: "Connect Client A, Send from Client B, Verify Receipt (optional)"
depends_on: [verify-service-running]
on_error: continue
action: shell
command: |
DOMAIN="{{ .outputs.create-project.domain }}"

View File

@ -20,8 +20,9 @@ steps:
- domain: .data.domain
add-db:
description: Add CockroachDB for user/auth storage
description: Add CockroachDB for user/auth storage (may already exist from skeleton)
depends_on: [create-project]
on_error: continue
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
@ -30,8 +31,9 @@ steps:
name: "main-db"
add-redis:
description: Add Redis for job queue and pub/sub
description: Add Redis for job queue and pub/sub (may already exist from skeleton)
depends_on: [create-project]
on_error: continue
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
@ -39,28 +41,23 @@ steps:
type: redis
name: "job-queue"
add-auth:
depends_on: [add-db]
add-components:
description: Add auth, chat, and worker atomically (single git commit)
depends_on: [add-db, add-redis]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
body: { type: service, name: "auth-svc" }
add-chat:
depends_on: [add-redis]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
body: { type: service, name: "chat-svc" }
add-worker:
depends_on: [add-redis]
action: api
method: POST
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components"
body: { type: worker, name: "worker-svc" }
endpoint: "/projects/{{ .outputs.create-project.project_id }}/components/batch"
body:
components:
- type: service
name: "auth-svc"
- type: service
name: "chat-svc"
- type: worker
name: "worker-svc"
wait-infra:
depends_on: [add-components]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
@ -80,25 +77,41 @@ steps:
- build_id: .data.task_id
wait-build:
action: shell
command: |
for i in {1..120}; do
STATUS=$(curl -s "$RDEV_API_URL/builds/{{ .outputs.implement-mesh.build_id }}" -H "X-API-Key: $RDEV_API_KEY" | jq -r '.data.status // .status')
if [ "$STATUS" == "completed" ]; then exit 0; fi
if [ "$STATUS" == "failed" ]; then exit 1; fi
sleep 5
done
exit 1
description: Wait for agent code generation
depends_on: [implement-mesh]
action: wait_build
build_id: "{{ .outputs.implement-mesh.build_id }}"
max_attempts: 120
poll_interval: 5
wait-deploy:
depends_on: [wait-build]
action: wait_pipeline
project_id: "{{ .outputs.create-project.project_id }}"
# --- Verification ---
verify-e2e:
description: "Call Chat Service (which calls Auth internally)"
verify-services-running:
description: "Verify auth and chat services are healthy"
depends_on: [wait-deploy]
action: shell
command: |
DOMAIN="{{ .outputs.create-project.domain }}"
AUTH_HEALTH=$(curl -s "https://$DOMAIN/api/auth-svc/health" | jq -r '.data.status // empty')
CHAT_HEALTH=$(curl -s "https://$DOMAIN/api/chat-svc/health" | jq -r '.data.status // empty')
if [ "$AUTH_HEALTH" == "healthy" ] && [ "$CHAT_HEALTH" == "healthy" ]; then
echo "Both services healthy"
exit 0
else
echo "Auth: $AUTH_HEALTH, Chat: $CHAT_HEALTH"
exit 1
fi
verify-e2e:
description: "Call Chat Service (which calls Auth internally) - optional"
depends_on: [verify-services-running]
on_error: continue
action: shell
command: |
DOMAIN="{{ .outputs.create-project.domain }}"

View File

@ -22,7 +22,7 @@ spec:
spec:
containers:
- name: claudebox
image: ghcr.io/orchard9/rdev-claudebox:v0.3.0
image: registry.threesix.ai/rdev/claudebox:latest
imagePullPolicy: Always
resources:
@ -70,9 +70,6 @@ spec:
persistentVolumeClaim:
claimName: claudebox-claude-config
# Pull from GitHub Container Registry
imagePullSecrets:
- name: ghcr-secret
---
# Headless service for StatefulSet
apiVersion: v1

View File

@ -0,0 +1,51 @@
# HorizontalPodAutoscaler for rdev-worker based on queue depth.
# Scales workers up when pending tasks accumulate, scales down when queue drains.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: rdev-worker
namespace: rdev
labels:
app.kubernetes.io/name: rdev-worker
app.kubernetes.io/part-of: rdev
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: rdev-worker
minReplicas: 1
maxReplicas: 10
metrics:
# Scale based on pending tasks in the work queue
- type: External
external:
metric:
name: rdev_pending_tasks
target:
type: AverageValue
# Target 2 pending tasks per worker
# With 2 workers and 4 pending, we'd scale up
averageValue: "2"
behavior:
# Scale up quickly when work accumulates
scaleUp:
stabilizationWindowSeconds: 60 # Wait 1 minute before scaling up again
policies:
- type: Pods
value: 2 # Add up to 2 pods at a time
periodSeconds: 60
- type: Percent
value: 100 # Or double the current count
periodSeconds: 60
selectPolicy: Max
# Scale down slowly to avoid thrashing
scaleDown:
stabilizationWindowSeconds: 300 # Wait 5 minutes before scaling down
policies:
- type: Pods
value: 1 # Remove 1 pod at a time
periodSeconds: 120
selectPolicy: Min

View File

@ -0,0 +1,78 @@
# Prometheus Adapter rules for exposing rdev metrics for HPA.
# These rules make rdev_pending_tasks available as an external metric.
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-adapter-config
namespace: monitoring # Adjust to match your prometheus-adapter namespace
labels:
app.kubernetes.io/name: prometheus-adapter
app.kubernetes.io/part-of: rdev
data:
config.yaml: |
# Default rules from prometheus-adapter
rules:
- seriesQuery: '{__name__=~"^container_.*",container!="POD",namespace!="",pod!=""}'
seriesFilters: []
resources:
overrides:
namespace:
resource: namespace
pod:
resource: pod
name:
matches: ^container_(.*)_seconds_total$
as: ""
metricsQuery: sum(rate(<<.Series>>{<<.LabelMatchers>>,container!="POD"}[5m])) by (<<.GroupBy>>)
- seriesQuery: '{__name__=~"^container_.*",container!="POD",namespace!="",pod!=""}'
seriesFilters:
- isNot: ^container_.*_seconds_total$
resources:
overrides:
namespace:
resource: namespace
pod:
resource: pod
name:
matches: ^container_(.*)_total$
as: ""
metricsQuery: sum(rate(<<.Series>>{<<.LabelMatchers>>,container!="POD"}[5m])) by (<<.GroupBy>>)
- seriesQuery: '{__name__=~"^container_.*",container!="POD",namespace!="",pod!=""}'
seriesFilters:
- isNot: ^container_.*_total$
resources:
overrides:
namespace:
resource: namespace
pod:
resource: pod
name:
matches: ^container_(.*)$
as: ""
metricsQuery: sum(<<.Series>>{<<.LabelMatchers>>,container!="POD"}) by (<<.GroupBy>>)
# rdev external metrics for HPA
externalRules:
- seriesQuery: 'rdev_work_queue_pending_tasks'
resources:
namespaced: false
name:
matches: "rdev_work_queue_pending_tasks"
as: "rdev_pending_tasks"
metricsQuery: sum(rdev_work_queue_pending_tasks)
- seriesQuery: 'rdev_work_queue_running_tasks'
resources:
namespaced: false
name:
matches: "rdev_work_queue_running_tasks"
as: "rdev_running_tasks"
metricsQuery: sum(rdev_work_queue_running_tasks)
- seriesQuery: 'rdev_workers_idle'
resources:
namespaced: false
name:
matches: "rdev_workers_idle"
as: "rdev_idle_workers"
metricsQuery: sum(rdev_workers_idle)

View File

@ -24,7 +24,7 @@ spec:
serviceAccountName: rdev-api
containers:
- name: rdev-api
image: ghcr.io/orchard9/rdev-api:v0.10.51
image: registry.threesix.ai/rdev/api:latest
imagePullPolicy: Always
ports:
@ -147,8 +147,6 @@ spec:
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "otel-collector.observability.svc.cluster.local:4317"
imagePullSecrets:
- name: ghcr-secret
---
# Service for rdev-api
apiVersion: v1

View File

@ -0,0 +1,167 @@
# Standalone worker deployment with claudebox sidecar.
# Workers poll rdev-api for tasks and execute them via HTTP calls to the local sidecar.
apiVersion: apps/v1
kind: Deployment
metadata:
name: rdev-worker
namespace: rdev
labels:
app.kubernetes.io/name: rdev-worker
app.kubernetes.io/part-of: rdev
spec:
replicas: 2
selector:
matchLabels:
app: rdev-worker
template:
metadata:
labels:
app: rdev-worker
app.kubernetes.io/name: rdev-worker
app.kubernetes.io/part-of: rdev
rdev.orchard9.ai/role: worker
spec:
containers:
# Main worker container - polls for tasks and orchestrates execution
- name: worker
image: registry.threesix.ai/rdev/worker:latest
imagePullPolicy: Always
env:
- name: RDEV_API_URL
value: "http://rdev-api.rdev.svc.cluster.local:8080"
- name: CLAUDEBOX_URL
value: "http://localhost:8080"
- name: RDEV_API_KEY
valueFrom:
secretKeyRef:
name: rdev-worker-credentials
key: api-key
- name: WORKER_ID
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: WORKER_POLL_INTERVAL
value: "5s"
- name: WORKER_HEARTBEAT_INTERVAL
value: "30s"
- name: WORKER_TASK_TIMEOUT
value: "15m"
- name: WORKER_CAPABILITIES
value: "build,sdlc"
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
livenessProbe:
exec:
command:
- test
- -f
- /usr/local/bin/rdev-worker
initialDelaySeconds: 5
periodSeconds: 60
# Claudebox sidecar - provides Claude Code execution via HTTP
- name: claudebox
image: registry.threesix.ai/rdev/claudebox:latest
imagePullPolicy: Always
env:
- name: PORT
value: "8080"
- name: WORKSPACE_DIR
value: "/workspace"
- name: GITEA_TOKEN
valueFrom:
secretKeyRef:
name: rdev-worker-credentials
key: gitea-token
optional: true
- name: GIT_USER
value: "rdev-worker"
- name: GIT_EMAIL
value: "worker@threesix.ai"
ports:
- name: http
containerPort: 8080
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "2"
memory: "4Gi"
volumeMounts:
- name: workspace
mountPath: /workspace
- name: claude-config
mountPath: /root/.claude
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
volumes:
# EmptyDir for workspace - ephemeral per-pod
- name: workspace
emptyDir:
sizeLimit: 10Gi
# Shared Claude config volume for authentication
# Uses the same PVC as the claudebox statefulset
- name: claude-config
persistentVolumeClaim:
claimName: claudebox-claude-config
---
# Secret for worker credentials
apiVersion: v1
kind: Secret
metadata:
name: rdev-worker-credentials
namespace: rdev
labels:
app.kubernetes.io/name: rdev-worker
app.kubernetes.io/part-of: rdev
type: Opaque
stringData:
# API key for workers to authenticate with rdev-api
# Create with: kubectl create secret generic rdev-worker-credentials --from-literal=api-key=<key> --from-literal=gitea-token=<token>
api-key: "placeholder-replace-me"
gitea-token: "placeholder-replace-me"
---
# Service for accessing worker metrics (optional)
apiVersion: v1
kind: Service
metadata:
name: rdev-worker
namespace: rdev
labels:
app.kubernetes.io/name: rdev-worker
app.kubernetes.io/part-of: rdev
spec:
selector:
app: rdev-worker
ports:
- port: 8080
name: claudebox
targetPort: 8080

View File

@ -0,0 +1,46 @@
# ServiceMonitor for scraping worker metrics with Prometheus.
# Requires Prometheus Operator to be installed.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: rdev-worker
namespace: rdev
labels:
app.kubernetes.io/name: rdev-worker
app.kubernetes.io/part-of: rdev
release: prometheus # Matches Prometheus Operator label selector
spec:
selector:
matchLabels:
app.kubernetes.io/name: rdev-worker
namespaceSelector:
matchNames:
- rdev
endpoints:
- port: claudebox
path: /metrics
interval: 30s
scrapeTimeout: 10s
---
# ServiceMonitor for rdev-api (queue metrics)
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: rdev-api
namespace: rdev
labels:
app.kubernetes.io/name: rdev-api
app.kubernetes.io/part-of: rdev
release: prometheus
spec:
selector:
matchLabels:
app.kubernetes.io/name: rdev-api
namespaceSelector:
matchNames:
- rdev
endpoints:
- port: http
path: /metrics
interval: 30s
scrapeTimeout: 10s

View File

@ -0,0 +1,394 @@
// Package claudebox provides an HTTP client for the claudebox sidecar.
// This client is used by standalone workers to communicate with the local
// claudebox sidecar instead of using kubectl exec.
package claudebox
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// Client is an HTTP client for the claudebox sidecar.
type Client struct {
baseURL string
httpClient *http.Client
}
// ClientConfig holds configuration for the claudebox client.
type ClientConfig struct {
// BaseURL is the base URL of the claudebox sidecar (e.g., "http://localhost:8080").
BaseURL string
// Timeout is the default request timeout.
Timeout time.Duration
}
// NewClient creates a new claudebox client.
func NewClient(cfg ClientConfig) *Client {
if cfg.Timeout == 0 {
cfg.Timeout = 10 * time.Minute
}
return &Client{
baseURL: strings.TrimSuffix(cfg.BaseURL, "/"),
httpClient: &http.Client{
Timeout: cfg.Timeout,
},
}
}
// HealthResponse is the health check response.
type HealthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
WorkDir string `json:"work_dir"`
}
// Health checks if the claudebox sidecar is healthy.
func (c *Client) Health(ctx context.Context) (*HealthResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("health check: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("health check returned status %d", resp.StatusCode)
}
var result HealthResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &result, nil
}
// ExecuteRequest is the request to execute Claude Code.
type ExecuteRequest struct {
Prompt string `json:"prompt"`
AllowedTools []string `json:"allowed_tools,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Timeout int `json:"timeout_seconds,omitempty"` // seconds
Metadata map[string]string `json:"metadata,omitempty"`
}
// ExecuteResponse is the response from executing Claude Code.
type ExecuteResponse struct {
Success bool `json:"success"`
Output string `json:"output"`
ExitCode int `json:"exit_code"`
DurationMs int64 `json:"duration_ms"`
Error string `json:"error,omitempty"`
SessionID string `json:"session_id,omitempty"`
FinalOutput string `json:"final_output,omitempty"`
Artifacts map[string]string `json:"artifacts,omitempty"`
}
// Execute runs Claude Code and returns the complete result.
func (c *Client) Execute(ctx context.Context, req *ExecuteRequest) (*ExecuteResponse, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/execute", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("execute: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("execute returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
var result ExecuteResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &result, nil
}
// StreamEvent is an SSE event from streaming execution.
type StreamEvent struct {
Type string `json:"type"`
Content string `json:"content,omitempty"`
Stream string `json:"stream,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Data map[string]any `json:"data,omitempty"`
Timestamp string `json:"timestamp"`
}
// StreamEventHandler is called for each event during streaming execution.
type StreamEventHandler func(StreamEvent)
// ExecuteStream runs Claude Code and streams events to the handler.
func (c *Client) ExecuteStream(ctx context.Context, req *ExecuteRequest, handler StreamEventHandler) error {
body, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/execute/stream", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("execute stream: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("execute stream returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
// Parse SSE events
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "" {
continue
}
var event StreamEvent
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue // Skip malformed events
}
handler(event)
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("read stream: %w", err)
}
return nil
}
// GitCloneRequest is the request to clone a repository.
type GitCloneRequest struct {
CloneURL string `json:"clone_url"`
WorkDir string `json:"work_dir,omitempty"`
}
// GitCloneResponse is the response from cloning.
type GitCloneResponse struct {
Success bool `json:"success"`
Cloned bool `json:"cloned"`
Error string `json:"error,omitempty"`
}
// GitClone clones or updates a git repository.
func (c *Client) GitClone(ctx context.Context, cloneURL, workDir string) (*GitCloneResponse, error) {
req := GitCloneRequest{
CloneURL: cloneURL,
WorkDir: workDir,
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/git/clone", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("git clone: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("git clone returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
var result GitCloneResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &result, nil
}
// GitCommitAndPushRequest is the request to commit and push changes.
type GitCommitAndPushRequest struct {
Message string `json:"message"`
Push bool `json:"push"`
WorkDir string `json:"work_dir,omitempty"`
}
// GitCommitAndPushResponse is the response from commit and push.
type GitCommitAndPushResponse struct {
Success bool `json:"success"`
HasChanges bool `json:"has_changes"`
CommitSHA string `json:"commit_sha,omitempty"`
FilesChanged []string `json:"files_changed,omitempty"`
Pushed bool `json:"pushed"`
Error string `json:"error,omitempty"`
}
// GitCommitAndPush commits and optionally pushes changes.
func (c *Client) GitCommitAndPush(ctx context.Context, message string, push bool, workDir string) (*GitCommitAndPushResponse, error) {
req := GitCommitAndPushRequest{
Message: message,
Push: push,
WorkDir: workDir,
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/git/commit-and-push", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("git commit: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("git commit returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
var result GitCommitAndPushResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &result, nil
}
// GitStatusResponse is the response from git status.
type GitStatusResponse struct {
IsRepo bool `json:"is_repo"`
HasChanges bool `json:"has_changes"`
ChangedFiles []string `json:"changed_files,omitempty"`
Branch string `json:"branch,omitempty"`
Error string `json:"error,omitempty"`
}
// GitStatus returns the git status of the workspace.
func (c *Client) GitStatus(ctx context.Context, workDir string) (*GitStatusResponse, error) {
reqURL := c.baseURL + "/git/status"
if workDir != "" {
reqURL += "?work_dir=" + url.QueryEscape(workDir)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("git status: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("git status returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
var result GitStatusResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &result, nil
}
// SDLCRequest is the request to run an SDLC command.
type SDLCRequest struct {
Command string `json:"command"`
Args []string `json:"args,omitempty"`
WorkDir string `json:"work_dir,omitempty"`
}
// SDLCResponse is the response from running an SDLC command.
type SDLCResponse struct {
Success bool `json:"success"`
Output string `json:"output"`
Data json.RawMessage `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// RunSDLC executes an SDLC CLI command.
func (c *Client) RunSDLC(ctx context.Context, command string, args []string, workDir string) (*SDLCResponse, error) {
req := SDLCRequest{
Command: command,
Args: args,
WorkDir: workDir,
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sdlc", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("sdlc: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("sdlc returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
var result SDLCResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return &result, nil
}

View File

@ -213,12 +213,12 @@ func (e *Executor) CheckConnection(ctx context.Context) error {
// ExecSimple executes a shell command and returns the output as a string.
// This is a convenience method for simple commands that don't need streaming.
func (e *Executor) ExecSimple(podName, command string) (string, error) {
func (e *Executor) ExecSimple(ctx context.Context, podName, command string) (string, error) {
e.mu.RLock()
namespace := e.namespace
e.mu.RUnlock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
args := []string{

View File

@ -2,6 +2,7 @@
# Add this step to your .woodpecker.yml
build-{{COMPONENT_NAME}}:
depends_on: [deps]
image: woodpeckerci/plugin-kaniko
settings:
registry: registry.threesix.ai

View File

@ -2,6 +2,7 @@
# Add this step to your .woodpecker.yml
build-{{COMPONENT_NAME}}:
depends_on: [deps]
image: woodpeckerci/plugin-kaniko
settings:
registry: registry.threesix.ai

View File

@ -2,6 +2,7 @@
# Add this step to your .woodpecker.yml
build-{{COMPONENT_NAME}}:
depends_on: [deps]
image: woodpeckerci/plugin-kaniko
settings:
registry: registry.threesix.ai

View File

@ -5,6 +5,7 @@
# This step builds and tests the CLI.
build-{{COMPONENT_NAME}}:
depends_on: [deps]
image: golang:1.23-alpine
commands:
- cd cli/{{COMPONENT_NAME}}

View File

@ -2,6 +2,7 @@
# Add this step to your .woodpecker.yml
build-{{COMPONENT_NAME}}:
depends_on: [deps]
image: woodpeckerci/plugin-kaniko
settings:
registry: registry.threesix.ai

View File

@ -3,21 +3,19 @@ FROM golang:1.23-alpine AS builder
RUN apk add --no-cache git
# Configure Go workspace and private modules
# Configure Go private modules
# Disable workspace mode - each component builds independently with replace directives
ENV GOPRIVATE=git.threesix.ai/*
ENV GOWORK=/app/go.work
ENV GOWORK=off
WORKDIR /app
# Copy go workspace and all source (workspace deps are local)
# Note: go.work.sum may not exist if no external dependencies have been synced yet
COPY go.work ./
COPY go.work.su[m] ./
# Copy shared pkg and this service only
COPY pkg/ ./pkg/
COPY services/{{COMPONENT_NAME}}/ ./services/{{COMPONENT_NAME}}/
# Build from workspace root
RUN CGO_ENABLED=0 go build -o /{{COMPONENT_NAME}} ./services/{{COMPONENT_NAME}}/cmd/server
# Build from the service directory (uses replace directive for ../pkg)
RUN cd services/{{COMPONENT_NAME}} && CGO_ENABLED=0 go build -o /{{COMPONENT_NAME}} ./cmd/server
# Production stage
FROM alpine:3.19

View File

@ -3,15 +3,27 @@ package main
import (
"{{GO_MODULE}}/pkg/app"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/adapter/memory"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
)
func main() {
// Create logger
logger := logging.Default()
// Create adapters (repositories)
exampleRepo := memory.NewExampleRepository()
// Create services (business logic)
exampleService := service.NewExampleService(exampleRepo, logger)
// Create application
application := app.New("{{COMPONENT_NAME}}", app.WithDefaultPort({{PORT}}))
// Register routes
api.RegisterRoutes(application)
// Register routes with dependency injection
api.RegisterRoutes(application, exampleService)
// Start server
application.Run()

View File

@ -0,0 +1,106 @@
// Package memory provides in-memory implementations of repository interfaces.
// Useful for development, testing, and prototyping.
package memory
import (
"context"
"sync"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port"
)
// Compile-time verification that ExampleRepository implements port.ExampleRepository.
var _ port.ExampleRepository = (*ExampleRepository)(nil)
// ExampleRepository is a thread-safe in-memory implementation of port.ExampleRepository.
type ExampleRepository struct {
mu sync.RWMutex
examples map[domain.ExampleID]*domain.Example
}
// NewExampleRepository creates a new in-memory example repository.
func NewExampleRepository() *ExampleRepository {
return &ExampleRepository{
examples: make(map[domain.ExampleID]*domain.Example),
}
}
// List returns all examples.
func (r *ExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]domain.Example, 0, len(r.examples))
for _, e := range r.examples {
result = append(result, *e)
}
return result, nil
}
// Get returns an example by ID.
// Returns domain.ErrExampleNotFound if not found.
func (r *ExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
r.mu.RLock()
defer r.mu.RUnlock()
e, ok := r.examples[id]
if !ok {
return nil, domain.ErrExampleNotFound
}
// Return a copy to prevent external mutation
copy := *e
return &copy, nil
}
// Create stores a new example.
func (r *ExampleRepository) Create(ctx context.Context, example *domain.Example) error {
r.mu.Lock()
defer r.mu.Unlock()
// Store a copy to prevent external mutation
copy := *example
r.examples[example.ID] = &copy
return nil
}
// Update modifies an existing example.
// Returns domain.ErrExampleNotFound if not found.
func (r *ExampleRepository) Update(ctx context.Context, example *domain.Example) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.examples[example.ID]; !ok {
return domain.ErrExampleNotFound
}
// Store a copy to prevent external mutation
copy := *example
r.examples[example.ID] = &copy
return nil
}
// Delete removes an example by ID.
// Returns domain.ErrExampleNotFound if not found.
func (r *ExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.examples[id]; !ok {
return domain.ErrExampleNotFound
}
delete(r.examples, id)
return nil
}
// ExistsByName checks if an example with the given name exists.
func (r *ExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, e := range r.examples {
if e.Name == name {
return true, nil
}
}
return false, nil
}

View File

@ -1,6 +1,7 @@
package handlers
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
@ -10,16 +11,22 @@ import (
"{{GO_MODULE}}/pkg/httperror"
"{{GO_MODULE}}/pkg/httpresponse"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
)
// Example demonstrates the Wrap pattern for error-returning handlers.
// Example handles HTTP requests for example resources.
type Example struct {
svc *service.ExampleService
logger *logging.Logger
}
// NewExample creates a new Example handler.
func NewExample(logger *logging.Logger) *Example {
return &Example{logger: logger}
// NewExample creates a new Example handler with injected dependencies.
func NewExample(svc *service.ExampleService, logger *logging.Logger) *Example {
return &Example{
svc: svc,
logger: logger.WithComponent("ExampleHandler"),
}
}
// CreateRequest is the request body for creating an example.
@ -30,7 +37,7 @@ type CreateRequest struct {
// UpdateRequest is the request body for updating an example.
type UpdateRequest struct {
Name string `json:"name" validate:"omitempty,min=1,max=100"`
Name string `json:"name" validate:"required,min=1,max=100"`
Description string `json:"description" validate:"max=500"`
}
@ -43,43 +50,34 @@ type ExampleResponse struct {
UpdatedAt string `json:"updated_at"`
}
// List returns a paginated list of examples.
// Demonstrates pagination query params and list responses.
func (h *Example) List(w http.ResponseWriter, r *http.Request) error {
// Example: Parse pagination query params
// page := r.URL.Query().Get("page")
// perPage := r.URL.Query().Get("per_page")
// Example: Fetch from database
// items, total, err := h.repo.List(r.Context(), page, perPage)
// if err != nil {
// return err
// }
// Placeholder response
items := []ExampleResponse{
{
ID: "550e8400-e29b-41d4-a716-446655440000",
Name: "Example Item 1",
Description: "First example item",
CreatedAt: "2024-01-15T10:30:00Z",
UpdatedAt: "2024-01-15T10:30:00Z",
},
{
ID: "550e8400-e29b-41d4-a716-446655440001",
Name: "Example Item 2",
Description: "Second example item",
CreatedAt: "2024-01-16T12:00:00Z",
UpdatedAt: "2024-01-16T12:00:00Z",
},
// toResponse converts a domain example to an API response.
func toResponse(e *domain.Example) ExampleResponse {
return ExampleResponse{
ID: e.ID.String(),
Name: e.Name,
Description: e.Description,
CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: e.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
httpresponse.OK(w, r, items)
// List returns all examples.
func (h *Example) List(w http.ResponseWriter, r *http.Request) error {
examples, err := h.svc.List(r.Context())
if err != nil {
return err
}
result := make([]ExampleResponse, len(examples))
for i, e := range examples {
result[i] = toResponse(&e)
}
httpresponse.OK(w, r, result)
return nil
}
// Get returns an example by ID.
// Demonstrates returning HTTPErrors for common error cases.
func (h *Example) Get(w http.ResponseWriter, r *http.Request) error {
id := chi.URLParam(r, "id")
@ -88,65 +86,35 @@ func (h *Example) Get(w http.ResponseWriter, r *http.Request) error {
return httperror.BadRequest("invalid id format")
}
// Example: Fetch from database
// item, err := h.repo.Get(r.Context(), id)
// if err != nil {
// if errors.Is(err, ErrNotFound) {
// return httperror.NotFoundf("example %s not found", id)
// }
// return err
// }
example, err := h.svc.Get(r.Context(), domain.ExampleID(id))
if err != nil {
return mapDomainError(err)
}
// Placeholder response
httpresponse.OK(w, r, ExampleResponse{
ID: id,
Name: "Example Item",
Description: "This is an example item",
CreatedAt: "2024-01-15T10:30:00Z",
UpdatedAt: "2024-01-15T10:30:00Z",
})
httpresponse.OK(w, r, toResponse(example))
return nil
}
// Create creates a new example.
// Demonstrates using BindAndValidate for request parsing and validation.
func (h *Example) Create(w http.ResponseWriter, r *http.Request) error {
var req CreateRequest
// Bind and validate request body
if err := app.BindAndValidate(r, &req); err != nil {
return err
}
// Example: Check for duplicates
// if exists, _ := h.repo.GetByName(r.Context(), req.Name); exists != nil {
// return httperror.Conflict("example with this name already exists")
// }
// Example: Create in database
// item, err := h.repo.Create(r.Context(), req)
// if err != nil {
// return err
// }
// Example: Access authenticated user
// user := auth.GetUser(r.Context())
// h.logger.Info("example created", "by", user.ID, "name", req.Name)
id := uuid.New().String()
httpresponse.Created(w, r, ExampleResponse{
ID: id,
example, err := h.svc.Create(r.Context(), service.CreateInput{
Name: req.Name,
Description: req.Description,
CreatedAt: "2024-01-15T10:30:00Z",
UpdatedAt: "2024-01-15T10:30:00Z",
})
if err != nil {
return mapDomainError(err)
}
httpresponse.Created(w, r, toResponse(example))
return nil
}
// Update updates an existing example.
// Demonstrates partial updates with BindAndValidate.
func (h *Example) Update(w http.ResponseWriter, r *http.Request) error {
id := chi.URLParam(r, "id")
@ -159,30 +127,19 @@ func (h *Example) Update(w http.ResponseWriter, r *http.Request) error {
return err
}
// Example: Fetch existing, apply updates, save
// item, err := h.repo.Get(r.Context(), id)
// if err != nil {
// if errors.Is(err, ErrNotFound) {
// return httperror.NotFoundf("example %s not found", id)
// }
// return err
// }
// if err := h.repo.Update(r.Context(), id, req); err != nil {
// return err
// }
httpresponse.OK(w, r, ExampleResponse{
ID: id,
example, err := h.svc.Update(r.Context(), domain.ExampleID(id), service.UpdateInput{
Name: req.Name,
Description: req.Description,
CreatedAt: "2024-01-15T10:30:00Z",
UpdatedAt: "2024-01-16T14:00:00Z",
})
if err != nil {
return mapDomainError(err)
}
httpresponse.OK(w, r, toResponse(example))
return nil
}
// Delete deletes an example by ID.
// Demonstrates no-content response.
// Delete removes an example by ID.
func (h *Example) Delete(w http.ResponseWriter, r *http.Request) error {
id := chi.URLParam(r, "id")
@ -190,14 +147,24 @@ func (h *Example) Delete(w http.ResponseWriter, r *http.Request) error {
return httperror.BadRequest("invalid id format")
}
// Example: Delete from database
// if err := h.repo.Delete(r.Context(), id); err != nil {
// if errors.Is(err, ErrNotFound) {
// return httperror.NotFoundf("example %s not found", id)
// }
// return err
// }
if err := h.svc.Delete(r.Context(), domain.ExampleID(id)); err != nil {
return mapDomainError(err)
}
httpresponse.NoContent(w)
return nil
}
// mapDomainError converts domain errors to HTTP errors.
func mapDomainError(err error) error {
switch {
case errors.Is(err, domain.ErrExampleNotFound):
return httperror.NotFound("example not found")
case errors.Is(err, domain.ErrDuplicateExample):
return httperror.Conflict("example with this name already exists")
case errors.Is(err, domain.ErrInvalidExampleName):
return httperror.BadRequest("invalid example name")
default:
return err
}
}

View File

@ -2,25 +2,115 @@ package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/go-chi/chi/v5"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
)
func newTestLogger() *logging.Logger {
return logging.New(logging.Config{
Level: logging.LevelDebug,
Format: logging.FormatText,
})
// mockExampleRepository implements port.ExampleRepository for testing.
type mockExampleRepository struct {
mu sync.RWMutex
examples map[domain.ExampleID]*domain.Example
}
var _ port.ExampleRepository = (*mockExampleRepository)(nil)
func newMockExampleRepository() *mockExampleRepository {
return &mockExampleRepository{
examples: make(map[domain.ExampleID]*domain.Example),
}
}
func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]domain.Example, 0, len(m.examples))
for _, e := range m.examples {
result = append(result, *e)
}
return result, nil
}
func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
m.mu.RLock()
defer m.mu.RUnlock()
e, ok := m.examples[id]
if !ok {
return nil, domain.ErrExampleNotFound
}
copy := *e
return &copy, nil
}
func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
m.mu.Lock()
defer m.mu.Unlock()
copy := *example
m.examples[example.ID] = &copy
return nil
}
func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.examples[example.ID]; !ok {
return domain.ErrExampleNotFound
}
copy := *example
m.examples[example.ID] = &copy
return nil
}
func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.examples[id]; !ok {
return domain.ErrExampleNotFound
}
delete(m.examples, id)
return nil
}
func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, e := range m.examples {
if e.Name == name {
return true, nil
}
}
return false, nil
}
func newTestHandler() (*Example, *mockExampleRepository) {
repo := newMockExampleRepository()
svc := service.NewExampleService(repo, logging.Nop())
handler := NewExample(svc, logging.Nop())
return handler, repo
}
func TestExample_List(t *testing.T) {
handler := NewExample(newTestLogger())
handler, repo := newTestHandler()
// Seed data
ex, _ := domain.NewExample("test-id-1", "Test Example", "Description")
_ = repo.Create(context.Background(), ex)
r := chi.NewRouter()
r.Get("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
@ -52,13 +142,17 @@ func TestExample_List(t *testing.T) {
t.Fatal("expected 'data' to be an array")
}
if len(items) == 0 {
t.Error("expected at least one item in response")
if len(items) != 1 {
t.Errorf("expected 1 item, got %d", len(items))
}
}
func TestExample_Get(t *testing.T) {
handler := NewExample(newTestLogger())
handler, repo := newTestHandler()
// Seed data
ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Test Example", "Description")
_ = repo.Create(context.Background(), ex)
tests := []struct {
name string
@ -66,10 +160,15 @@ func TestExample_Get(t *testing.T) {
wantStatus int
}{
{
name: "valid uuid",
name: "valid uuid - found",
id: "550e8400-e29b-41d4-a716-446655440000",
wantStatus: http.StatusOK,
},
{
name: "valid uuid - not found",
id: "550e8400-e29b-41d4-a716-446655440001",
wantStatus: http.StatusNotFound,
},
{
name: "invalid uuid",
id: "not-a-uuid",
@ -82,8 +181,15 @@ func TestExample_Get(t *testing.T) {
r := chi.NewRouter()
r.Get("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
if err := handler.Get(w, r); err != nil {
// Error-returning handler: convert error to status
// Map error to status for testing
switch tt.wantStatus {
case http.StatusNotFound:
w.WriteHeader(http.StatusNotFound)
case http.StatusBadRequest:
w.WriteHeader(http.StatusBadRequest)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}
})
@ -100,7 +206,11 @@ func TestExample_Get(t *testing.T) {
}
func TestExample_Create(t *testing.T) {
handler := NewExample(newTestLogger())
handler, repo := newTestHandler()
// Seed existing data for duplicate test
ex, _ := domain.NewExample("existing-id", "Existing Name", "")
_ = repo.Create(context.Background(), ex)
tests := []struct {
name string
@ -110,7 +220,7 @@ func TestExample_Create(t *testing.T) {
{
name: "valid request",
body: CreateRequest{
Name: "Test Example",
Name: "New Example",
Description: "A test description",
},
wantStatus: http.StatusCreated,
@ -121,11 +231,12 @@ func TestExample_Create(t *testing.T) {
wantStatus: http.StatusBadRequest,
},
{
name: "missing required name",
body: map[string]string{
"description": "no name provided",
name: "duplicate name",
body: CreateRequest{
Name: "Existing Name",
Description: "Conflict",
},
wantStatus: http.StatusUnprocessableEntity,
wantStatus: http.StatusConflict,
},
}
@ -134,8 +245,14 @@ func TestExample_Create(t *testing.T) {
r := chi.NewRouter()
r.Post("/api/v1/examples", func(w http.ResponseWriter, r *http.Request) {
if err := handler.Create(w, r); err != nil {
// Simulate Wrap behavior for tests
switch tt.wantStatus {
case http.StatusBadRequest:
w.WriteHeader(http.StatusBadRequest)
case http.StatusConflict:
w.WriteHeader(http.StatusConflict)
default:
w.WriteHeader(http.StatusInternalServerError)
}
return
}
})
@ -154,30 +271,132 @@ func TestExample_Create(t *testing.T) {
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
// For the valid case, check 201
if tt.name == "valid request" && w.Code != http.StatusCreated {
t.Errorf("expected status %d, got %d", http.StatusCreated, w.Code)
if w.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
}
})
}
}
func TestExample_Delete(t *testing.T) {
handler := NewExample(newTestLogger())
handler, repo := newTestHandler()
// Seed data
ex, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "To Delete", "")
_ = repo.Create(context.Background(), ex)
tests := []struct {
name string
id string
wantStatus int
}{
{
name: "existing example",
id: "550e8400-e29b-41d4-a716-446655440000",
wantStatus: http.StatusNoContent,
},
{
name: "non-existent example",
id: "550e8400-e29b-41d4-a716-446655440001",
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := chi.NewRouter()
r.Delete("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
if err := handler.Delete(w, r); err != nil {
if tt.wantStatus == http.StatusNotFound {
w.WriteHeader(http.StatusNotFound)
} else {
w.WriteHeader(http.StatusBadRequest)
}
return
}
})
req := httptest.NewRequest(http.MethodDelete, "/api/v1/examples/550e8400-e29b-41d4-a716-446655440000", nil)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/examples/"+tt.id, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d", w.Code)
if w.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
}
})
}
}
func TestExample_Update(t *testing.T) {
handler, repo := newTestHandler()
// Seed data
ex1, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440000", "Example 1", "")
_ = repo.Create(context.Background(), ex1)
ex2, _ := domain.NewExample("550e8400-e29b-41d4-a716-446655440001", "Example 2", "")
_ = repo.Create(context.Background(), ex2)
tests := []struct {
name string
id string
body UpdateRequest
wantStatus int
}{
{
name: "valid update",
id: "550e8400-e29b-41d4-a716-446655440000",
body: UpdateRequest{
Name: "Updated Name",
Description: "Updated",
},
wantStatus: http.StatusOK,
},
{
name: "name conflict",
id: "550e8400-e29b-41d4-a716-446655440000",
body: UpdateRequest{
Name: "Example 2",
Description: "Conflict",
},
wantStatus: http.StatusConflict,
},
{
name: "not found",
id: "550e8400-e29b-41d4-a716-446655440099",
body: UpdateRequest{
Name: "Whatever",
Description: "",
},
wantStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := chi.NewRouter()
r.Put("/api/v1/examples/{id}", func(w http.ResponseWriter, r *http.Request) {
if err := handler.Update(w, r); err != nil {
switch tt.wantStatus {
case http.StatusNotFound:
w.WriteHeader(http.StatusNotFound)
case http.StatusConflict:
w.WriteHeader(http.StatusConflict)
default:
w.WriteHeader(http.StatusBadRequest)
}
return
}
})
body, _ := json.Marshal(tt.body)
req := httptest.NewRequest(http.MethodPut, "/api/v1/examples/"+tt.id, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, w.Code)
}
})
}
}

View File

@ -6,6 +6,7 @@ import (
"{{GO_MODULE}}/pkg/auth"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/api/handlers"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/config"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/service"
)
// RegisterRoutes registers all HTTP routes for the service.
@ -13,13 +14,13 @@ import (
// This allows the monorepo to expose multiple services under a single domain:
// - https://domain/api/{{COMPONENT_NAME}}/health
// - https://domain/api/{{COMPONENT_NAME}}/examples
func RegisterRoutes(application *app.App) {
func RegisterRoutes(application *app.App, exampleService *service.ExampleService) {
logger := application.Logger()
cfg := config.Load()
// Initialize handlers
// Initialize handlers with injected services
healthHandler := handlers.NewHealth(logger)
exampleHandler := handlers.NewExample(logger)
exampleHandler := handlers.NewExample(exampleService, logger)
// Build and mount OpenAPI spec
spec := NewServiceSpec()

View File

@ -0,0 +1,21 @@
// Package domain contains pure domain models with no external dependencies.
// These types represent the core business concepts of the service.
package domain
import "errors"
// Domain errors - these are business-level errors that should be translated
// to appropriate HTTP status codes by the handler layer.
var (
// ErrNotFound indicates a requested resource does not exist.
ErrNotFound = errors.New("not found")
// ErrExampleNotFound indicates the requested example does not exist.
ErrExampleNotFound = errors.New("example not found")
// ErrDuplicateExample indicates an example with the same name already exists.
ErrDuplicateExample = errors.New("example with this name already exists")
// ErrInvalidExampleName indicates the example name is invalid.
ErrInvalidExampleName = errors.New("invalid example name")
)

View File

@ -0,0 +1,89 @@
package domain
import (
"time"
"unicode/utf8"
)
// ExampleID is a strongly-typed identifier for examples.
type ExampleID string
// String returns the string representation of the ID.
func (id ExampleID) String() string {
return string(id)
}
// IsZero returns true if the ID is empty.
func (id ExampleID) IsZero() bool {
return id == ""
}
// Example name constraints.
const (
MinExampleNameLen = 1
MaxExampleNameLen = 100
MaxDescriptionLen = 500
)
// Example represents an example domain entity.
// This is a pure domain model with no external dependencies.
type Example struct {
ID ExampleID
Name string
Description string
CreatedAt time.Time
UpdatedAt time.Time
}
// NewExample creates a new Example with validation.
// Returns ErrInvalidExampleName if the name is invalid.
func NewExample(id ExampleID, name, description string) (*Example, error) {
if err := validateExampleName(name); err != nil {
return nil, err
}
if err := validateDescription(description); err != nil {
return nil, err
}
now := time.Now().UTC()
return &Example{
ID: id,
Name: name,
Description: description,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
// Update modifies the example's mutable fields with validation.
// Returns ErrInvalidExampleName if the name is invalid.
func (e *Example) Update(name, description string) error {
if err := validateExampleName(name); err != nil {
return err
}
if err := validateDescription(description); err != nil {
return err
}
e.Name = name
e.Description = description
e.UpdatedAt = time.Now().UTC()
return nil
}
// validateExampleName validates an example name.
func validateExampleName(name string) error {
length := utf8.RuneCountInString(name)
if length < MinExampleNameLen || length > MaxExampleNameLen {
return ErrInvalidExampleName
}
return nil
}
// validateDescription validates a description.
func validateDescription(desc string) error {
if utf8.RuneCountInString(desc) > MaxDescriptionLen {
return ErrInvalidExampleName
}
return nil
}

View File

@ -0,0 +1,37 @@
// Package port defines interfaces (ports) for external dependencies.
// These interfaces define the contracts between the application core and
// infrastructure adapters, enabling testability and flexibility.
package port
import (
"context"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain"
)
// ExampleRepository defines the interface for example persistence operations.
// Implementations may use databases, in-memory storage, or external services.
type ExampleRepository interface {
// List returns all examples.
List(ctx context.Context) ([]domain.Example, error)
// Get returns an example by ID.
// Returns domain.ErrExampleNotFound if not found.
Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error)
// Create stores a new example.
// The example must have a valid ID set.
Create(ctx context.Context, example *domain.Example) error
// Update modifies an existing example.
// Returns domain.ErrExampleNotFound if not found.
Update(ctx context.Context, example *domain.Example) error
// Delete removes an example by ID.
// Returns domain.ErrExampleNotFound if not found.
Delete(ctx context.Context, id domain.ExampleID) error
// ExistsByName checks if an example with the given name exists.
// Used for duplicate detection.
ExistsByName(ctx context.Context, name string) (bool, error)
}

View File

@ -0,0 +1,137 @@
// Package service provides business logic / use cases for the application.
// Services orchestrate domain operations using port interfaces.
package service
import (
"context"
"errors"
"github.com/google/uuid"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port"
)
// ExampleService handles example-related business logic.
type ExampleService struct {
repo port.ExampleRepository
logger *logging.Logger
}
// NewExampleService creates a new example service.
func NewExampleService(repo port.ExampleRepository, logger *logging.Logger) *ExampleService {
return &ExampleService{
repo: repo,
logger: logger.WithService("ExampleService"),
}
}
// List returns all examples.
func (s *ExampleService) List(ctx context.Context) ([]domain.Example, error) {
return s.repo.List(ctx)
}
// Get returns an example by ID.
// Returns domain.ErrExampleNotFound if not found.
func (s *ExampleService) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
return s.repo.Get(ctx, id)
}
// CreateInput contains the data needed to create an example.
type CreateInput struct {
Name string
Description string
}
// Create creates a new example with duplicate detection.
// Returns domain.ErrDuplicateExample if name already exists.
// Returns domain.ErrInvalidExampleName if name is invalid.
func (s *ExampleService) Create(ctx context.Context, input CreateInput) (*domain.Example, error) {
// Check for duplicates
exists, err := s.repo.ExistsByName(ctx, input.Name)
if err != nil {
return nil, err
}
if exists {
return nil, domain.ErrDuplicateExample
}
// Generate new ID
id := domain.ExampleID(uuid.New().String())
// Create domain entity (validates name)
example, err := domain.NewExample(id, input.Name, input.Description)
if err != nil {
return nil, err
}
// Persist
if err := s.repo.Create(ctx, example); err != nil {
return nil, err
}
s.logger.Info("example created", "id", id, "name", input.Name)
return example, nil
}
// UpdateInput contains the data needed to update an example.
type UpdateInput struct {
Name string
Description string
}
// Update modifies an existing example.
// Returns domain.ErrExampleNotFound if not found.
// Returns domain.ErrDuplicateExample if new name conflicts with another example.
// Returns domain.ErrInvalidExampleName if name is invalid.
func (s *ExampleService) Update(ctx context.Context, id domain.ExampleID, input UpdateInput) (*domain.Example, error) {
// Fetch existing
example, err := s.repo.Get(ctx, id)
if err != nil {
return nil, err
}
// Check for name conflicts (only if name changed)
if example.Name != input.Name {
exists, err := s.repo.ExistsByName(ctx, input.Name)
if err != nil {
return nil, err
}
if exists {
return nil, domain.ErrDuplicateExample
}
}
// Update domain entity (validates name)
if err := example.Update(input.Name, input.Description); err != nil {
return nil, err
}
// Persist
if err := s.repo.Update(ctx, example); err != nil {
return nil, err
}
s.logger.Info("example updated", "id", id, "name", input.Name)
return example, nil
}
// Delete removes an example by ID.
// Returns domain.ErrExampleNotFound if not found.
func (s *ExampleService) Delete(ctx context.Context, id domain.ExampleID) error {
// Verify exists before delete
if _, err := s.repo.Get(ctx, id); err != nil {
if errors.Is(err, domain.ErrExampleNotFound) {
return domain.ErrExampleNotFound
}
return err
}
if err := s.repo.Delete(ctx, id); err != nil {
return err
}
s.logger.Info("example deleted", "id", id)
return nil
}

View File

@ -0,0 +1,282 @@
package service
import (
"context"
"sync"
"testing"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/domain"
"{{GO_MODULE}}/services/{{COMPONENT_NAME}}/internal/port"
)
// mockExampleRepository implements port.ExampleRepository for testing.
type mockExampleRepository struct {
mu sync.RWMutex
examples map[domain.ExampleID]*domain.Example
}
var _ port.ExampleRepository = (*mockExampleRepository)(nil)
func newMockExampleRepository() *mockExampleRepository {
return &mockExampleRepository{
examples: make(map[domain.ExampleID]*domain.Example),
}
}
func (m *mockExampleRepository) List(ctx context.Context) ([]domain.Example, error) {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]domain.Example, 0, len(m.examples))
for _, e := range m.examples {
result = append(result, *e)
}
return result, nil
}
func (m *mockExampleRepository) Get(ctx context.Context, id domain.ExampleID) (*domain.Example, error) {
m.mu.RLock()
defer m.mu.RUnlock()
e, ok := m.examples[id]
if !ok {
return nil, domain.ErrExampleNotFound
}
// Return a copy to avoid mutation
copy := *e
return &copy, nil
}
func (m *mockExampleRepository) Create(ctx context.Context, example *domain.Example) error {
m.mu.Lock()
defer m.mu.Unlock()
// Store a copy
copy := *example
m.examples[example.ID] = &copy
return nil
}
func (m *mockExampleRepository) Update(ctx context.Context, example *domain.Example) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.examples[example.ID]; !ok {
return domain.ErrExampleNotFound
}
// Store a copy
copy := *example
m.examples[example.ID] = &copy
return nil
}
func (m *mockExampleRepository) Delete(ctx context.Context, id domain.ExampleID) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.examples[id]; !ok {
return domain.ErrExampleNotFound
}
delete(m.examples, id)
return nil
}
func (m *mockExampleRepository) ExistsByName(ctx context.Context, name string) (bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, e := range m.examples {
if e.Name == name {
return true, nil
}
}
return false, nil
}
func TestExampleService_Create(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
t.Run("creates example successfully", func(t *testing.T) {
example, err := svc.Create(context.Background(), CreateInput{
Name: "Test Example",
Description: "A test description",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if example.Name != "Test Example" {
t.Errorf("expected name 'Test Example', got '%s'", example.Name)
}
if example.ID.IsZero() {
t.Error("expected non-empty ID")
}
})
t.Run("rejects duplicate name", func(t *testing.T) {
_, err := svc.Create(context.Background(), CreateInput{
Name: "Test Example",
Description: "Another description",
})
if err != domain.ErrDuplicateExample {
t.Errorf("expected ErrDuplicateExample, got %v", err)
}
})
t.Run("rejects empty name", func(t *testing.T) {
_, err := svc.Create(context.Background(), CreateInput{
Name: "",
Description: "Description",
})
if err != domain.ErrInvalidExampleName {
t.Errorf("expected ErrInvalidExampleName, got %v", err)
}
})
}
func TestExampleService_Get(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
// Create an example first
created, _ := svc.Create(context.Background(), CreateInput{
Name: "Get Test",
Description: "Description",
})
t.Run("returns existing example", func(t *testing.T) {
example, err := svc.Get(context.Background(), created.ID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if example.Name != "Get Test" {
t.Errorf("expected name 'Get Test', got '%s'", example.Name)
}
})
t.Run("returns not found for missing example", func(t *testing.T) {
_, err := svc.Get(context.Background(), "nonexistent-id")
if err != domain.ErrExampleNotFound {
t.Errorf("expected ErrExampleNotFound, got %v", err)
}
})
}
func TestExampleService_Update(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
// Create examples
example1, _ := svc.Create(context.Background(), CreateInput{
Name: "Update Test 1",
Description: "Original",
})
_, _ = svc.Create(context.Background(), CreateInput{
Name: "Update Test 2",
Description: "Other",
})
t.Run("updates example successfully", func(t *testing.T) {
updated, err := svc.Update(context.Background(), example1.ID, UpdateInput{
Name: "Updated Name",
Description: "Updated description",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if updated.Name != "Updated Name" {
t.Errorf("expected name 'Updated Name', got '%s'", updated.Name)
}
})
t.Run("allows same name on same example", func(t *testing.T) {
_, err := svc.Update(context.Background(), example1.ID, UpdateInput{
Name: "Updated Name",
Description: "Same name",
})
if err != nil {
t.Errorf("unexpected error updating with same name: %v", err)
}
})
t.Run("rejects name conflict", func(t *testing.T) {
_, err := svc.Update(context.Background(), example1.ID, UpdateInput{
Name: "Update Test 2",
Description: "Conflict",
})
if err != domain.ErrDuplicateExample {
t.Errorf("expected ErrDuplicateExample, got %v", err)
}
})
t.Run("returns not found for missing example", func(t *testing.T) {
_, err := svc.Update(context.Background(), "nonexistent-id", UpdateInput{
Name: "Anything",
Description: "",
})
if err != domain.ErrExampleNotFound {
t.Errorf("expected ErrExampleNotFound, got %v", err)
}
})
}
func TestExampleService_Delete(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
// Create an example first
created, _ := svc.Create(context.Background(), CreateInput{
Name: "Delete Test",
Description: "To be deleted",
})
t.Run("deletes example successfully", func(t *testing.T) {
err := svc.Delete(context.Background(), created.ID)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify deleted
_, err = svc.Get(context.Background(), created.ID)
if err != domain.ErrExampleNotFound {
t.Errorf("expected ErrExampleNotFound after delete, got %v", err)
}
})
t.Run("returns not found for missing example", func(t *testing.T) {
err := svc.Delete(context.Background(), "nonexistent-id")
if err != domain.ErrExampleNotFound {
t.Errorf("expected ErrExampleNotFound, got %v", err)
}
})
}
func TestExampleService_List(t *testing.T) {
repo := newMockExampleRepository()
svc := NewExampleService(repo, logging.Nop())
t.Run("returns empty list initially", func(t *testing.T) {
examples, err := svc.List(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(examples) != 0 {
t.Errorf("expected 0 examples, got %d", len(examples))
}
})
// Create some examples
_, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 1", Description: ""})
_, _ = svc.Create(context.Background(), CreateInput{Name: "List Test 2", Description: ""})
t.Run("returns all examples", func(t *testing.T) {
examples, err := svc.List(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(examples) != 2 {
t.Errorf("expected 2 examples, got %d", len(examples))
}
})
}

View File

@ -2,6 +2,7 @@
# Add this step to your .woodpecker.yml
build-{{COMPONENT_NAME}}:
depends_on: [deps]
image: woodpeckerci/plugin-kaniko
settings:
registry: registry.threesix.ai

View File

@ -3,21 +3,19 @@ FROM golang:1.23-alpine AS builder
RUN apk add --no-cache git
# Configure Go workspace and private modules
# Configure Go private modules
# Disable workspace mode - each component builds independently with replace directives
ENV GOPRIVATE=git.threesix.ai/*
ENV GOWORK=/app/go.work
ENV GOWORK=off
WORKDIR /app
# Copy go workspace and all source (workspace deps are local)
# Note: go.work.sum may not exist if no external dependencies have been synced yet
COPY go.work ./
COPY go.work.su[m] ./
# Copy shared pkg and this worker only
COPY pkg/ ./pkg/
COPY workers/{{COMPONENT_NAME}}/ ./workers/{{COMPONENT_NAME}}/
# Build from workspace root
RUN CGO_ENABLED=0 go build -o /{{COMPONENT_NAME}} ./workers/{{COMPONENT_NAME}}/cmd/worker
# Build from the worker directory (uses replace directive for ../pkg)
RUN cd workers/{{COMPONENT_NAME}} && CGO_ENABLED=0 go build -o /{{COMPONENT_NAME}} ./cmd/worker
# Production stage
FROM alpine:3.19

View File

@ -9,12 +9,11 @@ import (
"syscall"
"time"
"{{GO_MODULE}}/pkg/config"
"{{GO_MODULE}}/pkg/database"
"{{GO_MODULE}}/pkg/logging"
"{{GO_MODULE}}/pkg/queue"
"{{GO_MODULE}}/workers/{{COMPONENT_NAME}}/internal/config"
"{{GO_MODULE}}/workers/{{COMPONENT_NAME}}/internal/handlers"
workerconfig "{{GO_MODULE}}/workers/{{COMPONENT_NAME}}/internal/config"
)
//go:embed migrations/*.sql
@ -28,7 +27,7 @@ func main() {
}).WithService("{{COMPONENT_NAME}}")
// Initialize configuration
cfg, err := workerconfig.Load()
cfg, err := config.Load()
if err != nil {
logger.Error("failed to load config", "error", err)
os.Exit(1)

View File

@ -0,0 +1,333 @@
package claudebox
import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"strings"
"sync"
"time"
)
// Default allowed tools for Claude Code execution.
var defaultAllowedTools = []string{
"Bash", "Edit", "Write", "Read", "Glob", "Grep", "Task", "WebFetch", "WebSearch",
}
// Executor runs Claude Code locally in the container.
type Executor struct {
workDir string
}
// NewExecutor creates a new local executor.
func NewExecutor(workDir string) *Executor {
return &Executor{
workDir: workDir,
}
}
// ExecuteResult contains the result of a Claude Code execution.
type ExecuteResult struct {
Success bool
Output string
ExitCode int
DurationMs int64
Error error
SessionID string
FinalOutput string
}
// Execute runs Claude Code and returns the complete result.
func (e *Executor) Execute(ctx context.Context, req *ExecuteRequest) *ExecuteResult {
var output strings.Builder
start := time.Now()
result := &ExecuteResult{}
// Apply timeout if specified
if req.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(req.Timeout)*time.Second)
defer cancel()
}
// Build command args
args := e.buildArgs(req)
// Execute claude command
cmd := exec.CommandContext(ctx, "claude", args...)
// Get working directory
workDir := req.WorkingDir
if workDir == "" {
workDir = e.workDir
}
cmd.Dir = workDir
// Capture output
stdout, err := cmd.StdoutPipe()
if err != nil {
result.Error = fmt.Errorf("stdout pipe: %w", err)
result.DurationMs = time.Since(start).Milliseconds()
return result
}
stderr, err := cmd.StderrPipe()
if err != nil {
result.Error = fmt.Errorf("stderr pipe: %w", err)
result.DurationMs = time.Since(start).Milliseconds()
return result
}
if err := cmd.Start(); err != nil {
result.Error = fmt.Errorf("start: %w", err)
result.DurationMs = time.Since(start).Milliseconds()
return result
}
// Read output
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
scanner := bufio.NewScanner(stdout)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
output.WriteString(scanner.Text())
output.WriteString("\n")
}
}()
var stderrOutput strings.Builder
go func() {
defer wg.Done()
scanner := bufio.NewScanner(stderr)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
stderrOutput.WriteString(scanner.Text())
stderrOutput.WriteString("\n")
}
}()
wg.Wait()
cmdErr := cmd.Wait()
result.DurationMs = time.Since(start).Milliseconds()
result.Output = output.String()
result.FinalOutput = output.String()
if cmdErr != nil {
if exitErr, ok := cmdErr.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
} else {
result.ExitCode = 1
result.Error = cmdErr
}
// Append stderr to error message
if stderrOutput.Len() > 0 {
if result.Error != nil {
result.Error = fmt.Errorf("%w\nstderr: %s", result.Error, stderrOutput.String())
} else {
result.Error = fmt.Errorf("stderr: %s", stderrOutput.String())
}
}
} else {
result.Success = true
}
return result
}
// StreamEventHandler is called for each event during streaming execution.
type StreamEventHandler func(StreamEvent)
// ExecuteStream runs Claude Code and streams events to the handler.
func (e *Executor) ExecuteStream(ctx context.Context, req *ExecuteRequest, handler StreamEventHandler) *ExecuteResult {
start := time.Now()
result := &ExecuteResult{}
// Apply timeout if specified
if req.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(req.Timeout)*time.Second)
defer cancel()
}
// Build command args with stream-json output
args := e.buildStreamArgs(req)
cmd := exec.CommandContext(ctx, "claude", args...)
workDir := req.WorkingDir
if workDir == "" {
workDir = e.workDir
}
cmd.Dir = workDir
stdout, err := cmd.StdoutPipe()
if err != nil {
result.Error = fmt.Errorf("stdout pipe: %w", err)
result.DurationMs = time.Since(start).Milliseconds()
return result
}
stderr, err := cmd.StderrPipe()
if err != nil {
result.Error = fmt.Errorf("stderr pipe: %w", err)
result.DurationMs = time.Since(start).Milliseconds()
return result
}
if err := cmd.Start(); err != nil {
result.Error = fmt.Errorf("start: %w", err)
result.DurationMs = time.Since(start).Milliseconds()
return result
}
// Emit started event
handler(StreamEvent{
Type: "started",
Timestamp: time.Now().UTC().Format(time.RFC3339),
})
// Stream output
var wg sync.WaitGroup
var output strings.Builder
wg.Add(2)
go func() {
defer wg.Done()
e.streamOutput(stdout, "stdout", handler, &output)
}()
go func() {
defer wg.Done()
e.streamStderr(stderr, handler)
}()
wg.Wait()
cmdErr := cmd.Wait()
result.DurationMs = time.Since(start).Milliseconds()
result.Output = output.String()
result.FinalOutput = output.String()
if cmdErr != nil {
if exitErr, ok := cmdErr.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
} else {
result.ExitCode = 1
result.Error = cmdErr
}
handler(StreamEvent{
Type: "failed",
Content: cmdErr.Error(),
Timestamp: time.Now().UTC().Format(time.RFC3339),
})
} else {
result.Success = true
handler(StreamEvent{
Type: "completed",
Timestamp: time.Now().UTC().Format(time.RFC3339),
Data: map[string]any{
"duration_ms": result.DurationMs,
"exit_code": result.ExitCode,
},
})
}
return result
}
// buildArgs constructs Claude Code command arguments.
func (e *Executor) buildArgs(req *ExecuteRequest) []string {
args := []string{
req.Prompt,
"-p",
}
// Add allowed tools
allowedTools := req.AllowedTools
if len(allowedTools) == 0 {
allowedTools = defaultAllowedTools
}
for _, tool := range allowedTools {
args = append(args, "--allowedTools", tool)
}
return args
}
// buildStreamArgs constructs Claude Code command arguments with streaming output.
func (e *Executor) buildStreamArgs(req *ExecuteRequest) []string {
args := []string{
req.Prompt,
"-p",
"--verbose",
"--output-format", "stream-json",
}
// Add allowed tools
allowedTools := req.AllowedTools
if len(allowedTools) == 0 {
allowedTools = defaultAllowedTools
}
for _, tool := range allowedTools {
args = append(args, "--allowedTools", tool)
}
return args
}
// streamOutput reads from stdout and sends events.
func (e *Executor) streamOutput(r io.Reader, stream string, handler StreamEventHandler, output *strings.Builder) {
scanner := bufio.NewScanner(r)
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
output.WriteString(line)
output.WriteString("\n")
handler(StreamEvent{
Type: "output",
Content: line,
Stream: stream,
Timestamp: time.Now().UTC().Format(time.RFC3339),
})
}
}
// streamStderr reads from stderr and sends error events.
func (e *Executor) streamStderr(r io.Reader, handler StreamEventHandler) {
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(StreamEvent{
Type: "error",
Content: line,
Stream: "stderr",
Timestamp: time.Now().UTC().Format(time.RFC3339),
})
}
}

287
internal/claudebox/git.go Normal file
View File

@ -0,0 +1,287 @@
package claudebox
import (
"bytes"
"context"
"fmt"
"log/slog"
"os/exec"
"strings"
)
// GitOperations provides local git operations in the container.
type GitOperations struct {
workDir string
giteaToken string
gitUser string
gitEmail string
logger *slog.Logger
}
// GitOperationsConfig holds configuration for git operations.
type GitOperationsConfig struct {
// WorkDir is the default working directory.
WorkDir string
// GiteaToken is the token for HTTPS push authentication.
GiteaToken string
// GitUser is the git commit author name.
GitUser string
// GitEmail is the git commit author email.
GitEmail string
// Logger is an optional logger for debug output.
Logger *slog.Logger
}
// NewGitOperations creates a new git operations helper.
func NewGitOperations(cfg GitOperationsConfig) *GitOperations {
if cfg.GitUser == "" {
cfg.GitUser = "rdev-worker"
}
if cfg.GitEmail == "" {
cfg.GitEmail = "worker@threesix.ai"
}
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &GitOperations{
workDir: cfg.WorkDir,
giteaToken: cfg.GiteaToken,
gitUser: cfg.GitUser,
gitEmail: cfg.GitEmail,
logger: logger,
}
}
// CloneResult contains the result of a git clone operation.
type CloneResult struct {
Cloned bool // True if repo was cloned, false if already existed
Error error
}
// CloneRepo clones a git repository into the workspace if it doesn't exist.
// If the workspace already contains a git repo, it pulls the latest changes.
func (g *GitOperations) CloneRepo(ctx context.Context, workDir, cloneURL string) *CloneResult {
result := &CloneResult{}
if cloneURL == "" {
result.Error = fmt.Errorf("git clone URL is required")
return result
}
// Check if already a git repo with the correct remote
if g.isGitRepo(ctx, workDir) {
currentRemote, err := g.runGitOutput(ctx, workDir, "config", "--get", "remote.origin.url")
currentRemote = strings.TrimSpace(currentRemote)
if err == nil && currentRemote == cloneURL {
// Pull latest changes
if err := g.runGit(ctx, workDir, "pull", "--ff-only"); err != nil {
// Pull failed but repo exists - continue with existing state
g.logger.Debug("git pull failed, continuing with existing state", "error", err, "work_dir", workDir)
}
return result
}
// Different remote - clear and re-clone
if err := g.clearDir(ctx, workDir); err != nil {
result.Error = fmt.Errorf("clear workspace: %w", err)
return result
}
}
// Inject token for authentication
authCloneURL := cloneURL
if g.giteaToken != "" {
authCloneURL = strings.Replace(cloneURL, "https://", "https://token:"+g.giteaToken+"@", 1)
}
// Clone the repository
cmd := exec.CommandContext(ctx, "git", "clone", authCloneURL, workDir)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := g.redactToken(stderr.String())
result.Error = fmt.Errorf("git clone failed: %s: %s", err, errMsg)
return result
}
result.Cloned = true
return result
}
// CommitAndPushResult contains the result of commit and push operations.
type CommitAndPushResult struct {
HasChanges bool
CommitSHA string
FilesChanged []string
Pushed bool
Error error
}
// CommitAndPush commits and optionally pushes changes.
func (g *GitOperations) CommitAndPush(ctx context.Context, workDir, message string, push bool) *CommitAndPushResult {
result := &CommitAndPushResult{}
// Configure git user
if err := g.runGit(ctx, workDir, "config", "user.name", g.gitUser); err != nil {
result.Error = fmt.Errorf("git config user.name: %w", err)
return result
}
if err := g.runGit(ctx, workDir, "config", "user.email", g.gitEmail); err != nil {
result.Error = fmt.Errorf("git config user.email: %w", err)
return result
}
// Check for changes
status, err := g.runGitOutput(ctx, workDir, "status", "--porcelain")
if err != nil {
result.Error = fmt.Errorf("git status: %w", err)
return result
}
if strings.TrimSpace(status) == "" {
return result // No changes
}
result.HasChanges = true
// Stage all changes
if err := g.runGit(ctx, workDir, "add", "-A"); err != nil {
result.Error = fmt.Errorf("git add: %w", err)
return result
}
// Get list of staged files
diffOutput, err := g.runGitOutput(ctx, workDir, "diff", "--cached", "--name-only")
if err != nil {
result.Error = fmt.Errorf("git diff: %w", err)
return result
}
for _, f := range strings.Split(strings.TrimSpace(diffOutput), "\n") {
if f != "" {
result.FilesChanged = append(result.FilesChanged, f)
}
}
// Commit
if err := g.runGit(ctx, workDir, "commit", "-m", message); err != nil {
result.Error = fmt.Errorf("git commit: %w", err)
return result
}
// Get commit SHA
sha, err := g.runGitOutput(ctx, workDir, "rev-parse", "HEAD")
if err != nil {
result.Error = fmt.Errorf("git rev-parse: %w", err)
return result
}
result.CommitSHA = strings.TrimSpace(sha)
// Push if requested
if push {
// Configure credential helper
if g.giteaToken != "" {
credHelper := fmt.Sprintf("!f() { echo username=token; echo password=%s; }; f", g.giteaToken)
if err := g.runGit(ctx, workDir, "config", "credential.helper", credHelper); err != nil {
g.logger.Debug("credential helper config failed, continuing with push", "error", err)
}
}
if err := g.runGit(ctx, workDir, "push", "origin", "HEAD"); err != nil {
result.Error = fmt.Errorf("git push: %w", err)
return result
}
result.Pushed = true
}
return result
}
// GitStatusResult contains git status information.
type GitStatusResult struct {
IsRepo bool `json:"is_repo"`
HasChanges bool `json:"has_changes"`
ChangedFiles []string `json:"changed_files,omitempty"`
Branch string `json:"branch,omitempty"`
}
// Status returns the git status of the workspace.
func (g *GitOperations) Status(ctx context.Context, workDir string) (*GitStatusResult, error) {
result := &GitStatusResult{}
if !g.isGitRepo(ctx, workDir) {
return result, nil
}
result.IsRepo = true
// Get current branch
branch, err := g.runGitOutput(ctx, workDir, "rev-parse", "--abbrev-ref", "HEAD")
if err == nil {
result.Branch = strings.TrimSpace(branch)
}
// Get status
status, err := g.runGitOutput(ctx, workDir, "status", "--porcelain")
if err != nil {
return result, fmt.Errorf("git status: %w", err)
}
lines := strings.Split(strings.TrimSpace(status), "\n")
for _, line := range lines {
if len(line) > 3 {
result.ChangedFiles = append(result.ChangedFiles, strings.TrimSpace(line[3:]))
}
}
result.HasChanges = len(result.ChangedFiles) > 0
return result, nil
}
// isGitRepo checks if the directory is a git repository.
func (g *GitOperations) isGitRepo(ctx context.Context, workDir string) bool {
cmd := exec.CommandContext(ctx, "test", "-d", workDir+"/.git")
return cmd.Run() == nil
}
// clearDir clears the contents of a directory.
func (g *GitOperations) clearDir(ctx context.Context, dir string) error {
cmd := exec.CommandContext(ctx, "sh", "-c", fmt.Sprintf("rm -rf %s/* %s/.[!.]*", dir, dir))
return cmd.Run()
}
// runGit executes a git command.
func (g *GitOperations) runGit(ctx context.Context, workDir string, args ...string) error {
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", workDir}, args...)...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := g.redactToken(stderr.String())
return fmt.Errorf("%s: %s", err, errMsg)
}
return nil
}
// runGitOutput executes a git command and returns stdout.
func (g *GitOperations) runGitOutput(ctx context.Context, workDir string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", workDir}, args...)...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := g.redactToken(stderr.String())
return "", fmt.Errorf("%s: %s", err, errMsg)
}
return stdout.String(), nil
}
// redactToken removes the Gitea token from output.
func (g *GitOperations) redactToken(s string) string {
if g.giteaToken == "" {
return s
}
return strings.ReplaceAll(s, g.giteaToken, "[REDACTED]")
}

100
internal/claudebox/sdlc.go Normal file
View File

@ -0,0 +1,100 @@
package claudebox
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"os/exec"
"strings"
)
// SDLCRunner executes SDLC CLI commands locally.
type SDLCRunner struct {
workDir string
logger *slog.Logger
}
// SDLCRunnerConfig holds configuration for the SDLC runner.
type SDLCRunnerConfig struct {
// WorkDir is the default working directory.
WorkDir string
// Logger is an optional logger for debug output.
Logger *slog.Logger
}
// NewSDLCRunner creates a new SDLC runner.
func NewSDLCRunner(cfg SDLCRunnerConfig) *SDLCRunner {
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &SDLCRunner{
workDir: cfg.WorkDir,
logger: logger,
}
}
// SDLCResult contains the result of an SDLC command.
type SDLCResult struct {
Success bool
Output string
Data json.RawMessage // Parsed JSON from sdlc --json output
Error error
}
// Run executes an SDLC CLI command.
func (s *SDLCRunner) Run(ctx context.Context, workDir, command string, args []string) *SDLCResult {
result := &SDLCResult{}
// Ensure .sdlc/ is initialized
if err := s.ensureInit(ctx, workDir); err != nil {
// Log but continue - command might still work
s.logger.Debug("sdlc init failed, continuing with command", "error", err, "work_dir", workDir)
}
// Build the command
sdlcArgs := []string{command}
sdlcArgs = append(sdlcArgs, args...)
sdlcArgs = append(sdlcArgs, "--json")
cmd := exec.CommandContext(ctx, "sdlc", sdlcArgs...)
cmd.Dir = workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
result.Error = fmt.Errorf("%s: %s", err, stderr.String())
result.Output = stdout.String()
return result
}
result.Success = true
result.Output = stdout.String()
// Try to parse JSON output
output := strings.TrimSpace(stdout.String())
if output != "" && (output[0] == '{' || output[0] == '[') {
result.Data = json.RawMessage(output)
}
return result
}
// ensureInit checks if .sdlc/ exists and runs `sdlc init` if it doesn't.
func (s *SDLCRunner) ensureInit(ctx context.Context, workDir string) error {
// Check if .sdlc/ directory exists
cmd := exec.CommandContext(ctx, "test", "-d", workDir+"/.sdlc")
if cmd.Run() == nil {
return nil // Already initialized
}
// Run sdlc init
initCmd := exec.CommandContext(ctx, "sdlc", "init", "--json")
initCmd.Dir = workDir
return initCmd.Run()
}

View File

@ -0,0 +1,368 @@
// Package claudebox provides HTTP server and handlers for the claudebox sidecar.
// This package enables HTTP-based execution of Claude Code, git, and SDLC operations
// instead of kubectl exec.
package claudebox
import (
"encoding/json"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/pkg/api"
)
// Server handles HTTP requests for claudebox operations.
type Server struct {
executor *Executor
gitOps *GitOperations
sdlcRunner *SDLCRunner
logger *slog.Logger
}
// ServerConfig holds configuration for the claudebox server.
type ServerConfig struct {
Executor *Executor
GitOps *GitOperations
SDLCRunner *SDLCRunner
Logger *slog.Logger
}
// NewServer creates a new claudebox HTTP server.
func NewServer(cfg ServerConfig) *Server {
return &Server{
executor: cfg.Executor,
gitOps: cfg.GitOps,
sdlcRunner: cfg.SDLCRunner,
logger: cfg.Logger,
}
}
// Mount registers server routes on the router.
func (s *Server) Mount(r chi.Router) {
r.Get("/health", s.handleHealth)
r.Post("/execute", s.handleExecute)
r.Post("/execute/stream", s.handleExecuteStream)
r.Post("/git/clone", s.handleGitClone)
r.Post("/git/commit-and-push", s.handleGitCommitAndPush)
r.Get("/git/status", s.handleGitStatus)
r.Post("/sdlc", s.handleSDLC)
}
// HealthResponse is the health check response.
type HealthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
WorkDir string `json:"work_dir"`
}
// handleHealth returns server health status.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := HealthResponse{
Status: "healthy",
Timestamp: time.Now().UTC().Format(time.RFC3339),
WorkDir: s.executor.workDir,
}
api.WriteJSON(w, r, http.StatusOK, resp)
}
// ExecuteRequest is the request to execute Claude Code.
type ExecuteRequest struct {
Prompt string `json:"prompt"`
AllowedTools []string `json:"allowed_tools,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Timeout int `json:"timeout_seconds,omitempty"` // seconds
Metadata map[string]string `json:"metadata,omitempty"`
}
// ExecuteResponse is the response from executing Claude Code.
type ExecuteResponse struct {
Success bool `json:"success"`
Output string `json:"output"`
ExitCode int `json:"exit_code"`
DurationMs int64 `json:"duration_ms"`
Error string `json:"error,omitempty"`
SessionID string `json:"session_id,omitempty"`
FinalOutput string `json:"final_output,omitempty"`
Artifacts map[string]string `json:"artifacts,omitempty"`
}
// handleExecute runs Claude Code and returns the complete result.
func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
log := logging.FromContext(ctx)
var req ExecuteRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Prompt == "" {
api.WriteBadRequest(w, r, "prompt is required")
return
}
log.Info("executing Claude Code", "prompt_len", len(req.Prompt))
result := s.executor.Execute(ctx, &req)
resp := ExecuteResponse{
Success: result.Success,
Output: result.Output,
ExitCode: result.ExitCode,
DurationMs: result.DurationMs,
SessionID: result.SessionID,
FinalOutput: result.FinalOutput,
}
if result.Error != nil {
resp.Error = result.Error.Error()
}
api.WriteJSON(w, r, http.StatusOK, resp)
}
// StreamEvent is an SSE event for streaming execution.
type StreamEvent struct {
Type string `json:"type"`
Content string `json:"content,omitempty"`
Stream string `json:"stream,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Data map[string]any `json:"data,omitempty"`
Timestamp string `json:"timestamp"`
}
// handleExecuteStream runs Claude Code and streams events via SSE.
func (s *Server) handleExecuteStream(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
log := logging.FromContext(ctx)
var req ExecuteRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Prompt == "" {
api.WriteBadRequest(w, r, "prompt is required")
return
}
// Set up SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher)
if !ok {
api.WriteInternalError(w, r, "streaming not supported")
return
}
log.Info("starting streaming execution", "prompt_len", len(req.Prompt))
// Stream events via callback
eventCh := make(chan StreamEvent, 100)
go func() {
defer close(eventCh)
s.executor.ExecuteStream(ctx, &req, func(evt StreamEvent) {
select {
case eventCh <- evt:
case <-ctx.Done():
}
})
}()
// Write events to client
for evt := range eventCh {
data, err := json.Marshal(evt)
if err != nil {
log.Warn("failed to marshal event", logging.FieldError, err)
continue
}
_, writeErr := w.Write([]byte("data: " + string(data) + "\n\n"))
if writeErr != nil {
log.Debug("client disconnected during stream")
return
}
flusher.Flush()
}
}
// GitCloneRequest is the request to clone a repository.
type GitCloneRequest struct {
CloneURL string `json:"clone_url"`
WorkDir string `json:"work_dir,omitempty"` // defaults to /workspace
}
// GitCloneResponse is the response from cloning.
type GitCloneResponse struct {
Success bool `json:"success"`
Cloned bool `json:"cloned"` // true if cloned, false if already existed
Error string `json:"error,omitempty"`
}
// handleGitClone clones or updates a git repository.
func (s *Server) handleGitClone(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req GitCloneRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.CloneURL == "" {
api.WriteBadRequest(w, r, "clone_url is required")
return
}
workDir := req.WorkDir
if workDir == "" {
workDir = s.gitOps.workDir
}
result := s.gitOps.CloneRepo(ctx, workDir, req.CloneURL)
resp := GitCloneResponse{
Success: result.Error == nil,
Cloned: result.Cloned,
}
if result.Error != nil {
resp.Error = result.Error.Error()
}
api.WriteJSON(w, r, http.StatusOK, resp)
}
// GitCommitAndPushRequest is the request to commit and push changes.
type GitCommitAndPushRequest struct {
Message string `json:"message"`
Push bool `json:"push"`
WorkDir string `json:"work_dir,omitempty"` // defaults to /workspace
}
// GitCommitAndPushResponse is the response from commit and push.
type GitCommitAndPushResponse struct {
Success bool `json:"success"`
HasChanges bool `json:"has_changes"`
CommitSHA string `json:"commit_sha,omitempty"`
FilesChanged []string `json:"files_changed,omitempty"`
Pushed bool `json:"pushed"`
Error string `json:"error,omitempty"`
}
// handleGitCommitAndPush commits and optionally pushes changes.
func (s *Server) handleGitCommitAndPush(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req GitCommitAndPushRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Message == "" {
api.WriteBadRequest(w, r, "message is required")
return
}
workDir := req.WorkDir
if workDir == "" {
workDir = s.gitOps.workDir
}
result := s.gitOps.CommitAndPush(ctx, workDir, req.Message, req.Push)
resp := GitCommitAndPushResponse{
Success: result.Error == nil,
HasChanges: result.HasChanges,
CommitSHA: result.CommitSHA,
FilesChanged: result.FilesChanged,
Pushed: result.Pushed,
}
if result.Error != nil {
resp.Error = result.Error.Error()
}
api.WriteJSON(w, r, http.StatusOK, resp)
}
// GitStatusResponse is the response from git status.
type GitStatusResponse struct {
IsRepo bool `json:"is_repo"`
HasChanges bool `json:"has_changes"`
ChangedFiles []string `json:"changed_files,omitempty"`
Branch string `json:"branch,omitempty"`
Error string `json:"error,omitempty"`
}
// handleGitStatus returns the git status of the workspace.
func (s *Server) handleGitStatus(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workDir := r.URL.Query().Get("work_dir")
if workDir == "" {
workDir = s.gitOps.workDir
}
status, err := s.gitOps.Status(ctx, workDir)
if err != nil {
api.WriteJSON(w, r, http.StatusOK, GitStatusResponse{
IsRepo: false,
Error: err.Error(),
})
return
}
api.WriteJSON(w, r, http.StatusOK, status)
}
// SDLCRequest is the request to run an SDLC command.
type SDLCRequest struct {
Command string `json:"command"`
Args []string `json:"args,omitempty"`
WorkDir string `json:"work_dir,omitempty"` // defaults to /workspace
}
// SDLCResponse is the response from running an SDLC command.
type SDLCResponse struct {
Success bool `json:"success"`
Output string `json:"output"`
Data json.RawMessage `json:"data,omitempty"` // Parsed JSON from sdlc --json output
Error string `json:"error,omitempty"`
}
// handleSDLC runs an SDLC CLI command.
func (s *Server) handleSDLC(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req SDLCRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Command == "" {
api.WriteBadRequest(w, r, "command is required")
return
}
workDir := req.WorkDir
if workDir == "" {
workDir = s.sdlcRunner.workDir
}
result := s.sdlcRunner.Run(ctx, workDir, req.Command, req.Args)
resp := SDLCResponse{
Success: result.Success,
Output: result.Output,
Data: result.Data,
}
if result.Error != nil {
resp.Error = result.Error.Error()
}
api.WriteJSON(w, r, http.StatusOK, resp)
}

View File

@ -0,0 +1,36 @@
-- Migration 017: Add capability-based task routing.
-- Workers can declare capabilities and tags, and tasks can require specific
-- capabilities/tags for routing to appropriate workers.
-- Add tags column to workers table for arbitrary metadata/labels.
-- Tags are key-value pairs that can be used for worker selection.
-- Example: {"gpu": "true", "region": "us-west"}
ALTER TABLE workers ADD COLUMN IF NOT EXISTS tags JSONB DEFAULT '{}';
COMMENT ON COLUMN workers.tags IS 'Key-value tags for worker selection (e.g., {"gpu": "true"})';
-- Add required_capabilities to work_queue for capability-based routing.
-- Tasks can require specific capabilities (from workers.capabilities array).
-- Example: ["gpu", "high-memory"]
ALTER TABLE work_queue ADD COLUMN IF NOT EXISTS required_capabilities TEXT[] DEFAULT '{}';
COMMENT ON COLUMN work_queue.required_capabilities IS 'Array of required worker capabilities for task routing';
-- Add required_tags to work_queue for tag-based routing.
-- Tasks can require workers to have specific tags.
-- Example: {"region": "us-west"}
ALTER TABLE work_queue ADD COLUMN IF NOT EXISTS required_tags JSONB DEFAULT '{}';
COMMENT ON COLUMN work_queue.required_tags IS 'Required worker tags for task routing (JSON key-value pairs)';
-- Create GIN index for capability-based routing queries.
-- Enables efficient queries like: WHERE required_capabilities <@ worker_capabilities
CREATE INDEX IF NOT EXISTS idx_work_queue_capabilities
ON work_queue USING GIN(required_capabilities)
WHERE required_capabilities != '{}';
-- Create GIN index for tag-based routing queries.
-- Enables efficient queries like: WHERE required_tags <@ worker_tags
CREATE INDEX IF NOT EXISTS idx_work_queue_tags
ON work_queue USING GIN(required_tags)
WHERE required_tags != '{}';

View File

@ -114,9 +114,9 @@ func (h *ClaudeConfigHandler) Overview(w http.ResponseWriter, r *http.Request) {
overview := ConfigOverview{
Project: id,
Path: "/workspace/.claude",
Commands: h.listItems(project.PodName, "commands"),
Skills: h.listItems(project.PodName, "skills"),
Agents: h.listItems(project.PodName, "agents"),
Commands: h.listItems(r.Context(), project.PodName, "commands"),
Skills: h.listItems(r.Context(), project.PodName, "skills"),
Agents: h.listItems(r.Context(), project.PodName, "agents"),
}
api.WriteSuccess(w, r, overview)
@ -234,9 +234,9 @@ func (h *ClaudeConfigHandler) DeleteAgent(w http.ResponseWriter, r *http.Request
// --- Helper methods ---
// listItems returns the names of items in a directory.
func (h *ClaudeConfigHandler) listItems(pod, itemType string) []string {
func (h *ClaudeConfigHandler) listItems(ctx context.Context, pod, itemType string) []string {
cmd := fmt.Sprintf("ls -1 /workspace/.claude/%s 2>/dev/null | sed 's/\\.md$//'", itemType)
output, err := h.executor.ExecSimple(pod, cmd)
output, err := h.executor.ExecSimple(ctx, pod, cmd)
if err != nil {
return []string{}
}
@ -264,7 +264,7 @@ func (h *ClaudeConfigHandler) listType(w http.ResponseWriter, r *http.Request, i
return
}
items := h.listItems(project.PodName, itemType)
items := h.listItems(r.Context(), project.PodName, itemType)
api.WriteSuccess(w, r, items)
}
@ -307,7 +307,7 @@ func (h *ClaudeConfigHandler) createItem(w http.ResponseWriter, r *http.Request,
// Ensure directory exists
dirCmd := fmt.Sprintf("mkdir -p /workspace/.claude/%s", itemType)
if _, err := h.executor.ExecSimple(project.PodName, dirCmd); err != nil {
if _, err := h.executor.ExecSimple(r.Context(), project.PodName, dirCmd); err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to create directory: %v", err))
return
}
@ -317,7 +317,7 @@ func (h *ClaudeConfigHandler) createItem(w http.ResponseWriter, r *http.Request,
filePath := fmt.Sprintf("/workspace/.claude/%s/%s.md", itemType, req.Name)
encoded := base64.StdEncoding.EncodeToString([]byte(req.Content))
writeCmd := fmt.Sprintf("echo '%s' | base64 -d > %s", encoded, filePath)
if _, err := h.executor.ExecSimple(project.PodName, writeCmd); err != nil {
if _, err := h.executor.ExecSimple(r.Context(), project.PodName, writeCmd); err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to write file: %v", err))
return
}
@ -353,7 +353,7 @@ func (h *ClaudeConfigHandler) getItem(w http.ResponseWriter, r *http.Request, it
filePath := fmt.Sprintf("/workspace/.claude/%s/%s.md", itemType, name)
cmd := fmt.Sprintf("cat %s 2>/dev/null", filePath)
output, err := h.executor.ExecSimple(project.PodName, cmd)
output, err := h.executor.ExecSimple(r.Context(), project.PodName, cmd)
if err != nil || output == "" {
api.WriteNotFound(w, r, fmt.Sprintf("%s not found: %s", itemType, name))
return
@ -405,7 +405,7 @@ func (h *ClaudeConfigHandler) updateItem(w http.ResponseWriter, r *http.Request,
// Check file exists
filePath := fmt.Sprintf("/workspace/.claude/%s/%s.md", itemType, name)
checkCmd := fmt.Sprintf("test -f %s && echo exists", filePath)
output, _ := h.executor.ExecSimple(project.PodName, checkCmd)
output, _ := h.executor.ExecSimple(r.Context(), project.PodName, checkCmd)
if strings.TrimSpace(output) != "exists" {
api.WriteNotFound(w, r, fmt.Sprintf("%s not found: %s", itemType, name))
return
@ -414,7 +414,7 @@ func (h *ClaudeConfigHandler) updateItem(w http.ResponseWriter, r *http.Request,
// Write file using base64 encoding to prevent shell injection
encoded := base64.StdEncoding.EncodeToString([]byte(req.Content))
writeCmd := fmt.Sprintf("echo '%s' | base64 -d > %s", encoded, filePath)
if _, err := h.executor.ExecSimple(project.PodName, writeCmd); err != nil {
if _, err := h.executor.ExecSimple(r.Context(), project.PodName, writeCmd); err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to write file: %v", err))
return
}
@ -452,7 +452,7 @@ func (h *ClaudeConfigHandler) deleteItem(w http.ResponseWriter, r *http.Request,
// Check file exists
checkCmd := fmt.Sprintf("test -f %s && echo exists", filePath)
output, _ := h.executor.ExecSimple(project.PodName, checkCmd)
output, _ := h.executor.ExecSimple(r.Context(), project.PodName, checkCmd)
if strings.TrimSpace(output) != "exists" {
api.WriteNotFound(w, r, fmt.Sprintf("%s not found: %s", itemType, name))
return
@ -460,7 +460,7 @@ func (h *ClaudeConfigHandler) deleteItem(w http.ResponseWriter, r *http.Request,
// Delete file
deleteCmd := fmt.Sprintf("rm %s", filePath)
if _, err := h.executor.ExecSimple(project.PodName, deleteCmd); err != nil {
if _, err := h.executor.ExecSimple(r.Context(), project.PodName, deleteCmd); err != nil {
api.WriteInternalError(w, r, fmt.Sprintf("failed to delete file: %v", err))
return
}

View File

@ -5,6 +5,7 @@ import (
"context"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/auth"
@ -43,6 +44,7 @@ func (h *ComponentsHandler) Mount(r api.Router) {
// Write operations
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/", h.Add)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Post("/batch", h.AddBatch)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).Delete("/*", h.Remove)
})
}
@ -166,6 +168,142 @@ func (h *ComponentsHandler) Add(w http.ResponseWriter, r *http.Request) {
api.WriteCreated(w, r, resp)
}
// AddComponentBatchRequest is the request body for POST /projects/{id}/components/batch.
type AddComponentBatchRequest struct {
Components []AddComponentRequest `json:"components"`
}
// AddBatch adds multiple components to a project's monorepo in a single atomic operation.
// POST /projects/{id}/components/batch
func (h *ComponentsHandler) AddBatch(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id")
ctx, cancel := context.WithTimeout(r.Context(), TimeoutLongRunning)
defer cancel()
// Validate project ID
if err := domain.ValidateProjectID(projectID); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
if h.service == nil {
api.WriteInternalError(w, r, "component service not configured")
return
}
var req AddComponentBatchRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
// Validate we have at least one component
if len(req.Components) == 0 {
api.WriteBadRequest(w, r, "at least one component is required")
return
}
// Validate each component's required fields
for i, comp := range req.Components {
v := validate.New()
v.Required(comp.Type, "components["+strconv.Itoa(i)+"].type")
v.Required(comp.Name, "components["+strconv.Itoa(i)+"].name")
if err := v.Error(); err != nil {
api.WriteBadRequest(w, r, err.Error())
return
}
}
// Convert to port requests
portReqs := make([]port.AddComponentRequest, len(req.Components))
for i, comp := range req.Components {
portReqs[i] = port.AddComponentRequest{
Type: comp.Type,
Name: comp.Name,
Template: comp.Template,
Port: comp.Port,
}
}
// Start operation tracking
var operationID string
if h.operationService != nil {
componentNames := make([]string, len(req.Components))
for i, c := range req.Components {
componentNames[i] = c.Type + "/" + c.Name
}
operationID, _ = h.operationService.StartOperation(ctx, projectID,
domain.OperationTypeComponentAdd,
map[string]any{"batch": true, "components": componentNames},
r.Header.Get("X-Request-ID"))
}
components, err := h.service.AddComponentBatch(ctx, projectID, portReqs)
if err != nil {
if h.operationService != nil && operationID != "" {
if opErr := h.operationService.FailOperation(ctx, operationID, err.Error(), ""); opErr != nil {
log := logging.FromContext(ctx).WithHandler("AddBatch")
log.Error("failed to record operation failure", logging.FieldError, opErr.Error(), logging.FieldOperation, operationID)
}
}
// Map domain errors to HTTP responses
switch {
case errors.Is(err, domain.ErrInvalidComponentType):
api.WriteBadRequest(w, r, err.Error())
case errors.Is(err, domain.ErrInvalidComponentName):
api.WriteBadRequest(w, r, err.Error())
case errors.Is(err, domain.ErrDuplicateComponent):
api.WriteError(w, r, http.StatusConflict, "CONFLICT", err.Error())
case errors.Is(err, domain.ErrProjectNotFound):
api.WriteNotFound(w, r, err.Error())
default:
log := logging.FromContext(ctx).WithHandler("AddBatch")
log.Error("failed to add components", logging.FieldError, err.Error(), logging.FieldProjectID, projectID)
api.WriteInternalError(w, r, "failed to add components")
}
return
}
if h.operationService != nil && operationID != "" {
paths := make([]string, len(components))
for i, c := range components {
paths[i] = c.Path
}
if opErr := h.operationService.CompleteOperation(ctx, operationID, map[string]any{
"paths": paths,
"count": len(components),
}); opErr != nil {
log := logging.FromContext(ctx).WithHandler("AddBatch")
log.Error("failed to record operation completion", logging.FieldError, opErr.Error(), logging.FieldOperation, operationID)
}
}
// Convert to response format
response := make([]ComponentResponse, len(components))
for i, c := range components {
deps := c.Dependencies
if deps == nil {
deps = []string{}
}
response[i] = ComponentResponse{
Type: string(c.Type),
Name: c.Name,
Path: c.Path,
Port: c.Port,
Template: c.Template,
Dependencies: deps,
}
}
resp := map[string]any{
"components": response,
}
if operationID != "" {
resp["operation_id"] = operationID
}
api.WriteCreated(w, r, resp)
}
// List lists all components in a project's monorepo.
// GET /projects/{id}/components
func (h *ComponentsHandler) List(w http.ResponseWriter, r *http.Request) {

View File

@ -16,6 +16,7 @@ import (
// mockComponentService is a mock implementation of port.ComponentService for testing.
type mockComponentService struct {
addComponent func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error)
addComponentBatch func(ctx context.Context, projectID string, reqs []port.AddComponentRequest) ([]*domain.Component, error)
listComponents func(ctx context.Context, projectID string) ([]domain.Component, error)
removeComponent func(ctx context.Context, projectID string, componentPath string) error
}
@ -27,6 +28,13 @@ func (m *mockComponentService) AddComponent(ctx context.Context, projectID strin
return nil, nil
}
func (m *mockComponentService) AddComponentBatch(ctx context.Context, projectID string, reqs []port.AddComponentRequest) ([]*domain.Component, error) {
if m.addComponentBatch != nil {
return m.addComponentBatch(ctx, projectID, reqs)
}
return nil, nil
}
func (m *mockComponentService) ListComponents(ctx context.Context, projectID string) ([]domain.Component, error) {
if m.listComponents != nil {
return m.listComponents(ctx, projectID)

View File

@ -119,7 +119,7 @@ func (h *ProjectsHandler) RunClaude(w http.ResponseWriter, r *http.Request) {
}
// Execute in background
go h.executeCommand(cmd, project.PodName)
go h.executeCommand(r.Context(), cmd, project.PodName)
api.WriteCreated(w, r, map[string]any{
"id": cmdID,
@ -227,7 +227,7 @@ func (h *ProjectsHandler) RunShell(w http.ResponseWriter, r *http.Request) {
}
// Execute in background
go h.executeCommand(cmd, project.PodName)
go h.executeCommand(r.Context(), cmd, project.PodName)
api.WriteCreated(w, r, map[string]any{
"id": cmdID,
@ -335,7 +335,7 @@ func (h *ProjectsHandler) RunGit(w http.ResponseWriter, r *http.Request) {
}
// Execute in background
go h.executeCommand(cmd, project.PodName)
go h.executeCommand(r.Context(), cmd, project.PodName)
api.WriteCreated(w, r, map[string]any{
"id": cmdID,
@ -347,8 +347,10 @@ func (h *ProjectsHandler) RunGit(w http.ResponseWriter, r *http.Request) {
}
// executeCommand runs a command and streams output to subscribers.
func (h *ProjectsHandler) executeCommand(cmd *domain.Command, podName string) {
ctx, cancel := context.WithTimeout(context.Background(), TimeoutLongRunning)
// Uses context.WithoutCancel to preserve tracing/values but allow independent timeout.
func (h *ProjectsHandler) executeCommand(parentCtx context.Context, cmd *domain.Command, podName string) {
// Derive from parent to preserve tracing/values, but with independent cancellation
ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), TimeoutLongRunning)
defer cancel()
cmdID := string(cmd.ID)

View File

@ -16,6 +16,7 @@ import (
// WorkersHandler handles worker pool management endpoints.
type WorkersHandler struct {
workerService *service.WorkerService
workService service.WorkServiceFailer
}
// NewWorkersHandler creates a new workers handler.
@ -25,6 +26,13 @@ func NewWorkersHandler(workerService *service.WorkerService) *WorkersHandler {
}
}
// WithWorkService adds a work service for task failure handling.
// This is required for standalone worker endpoints.
func (h *WorkersHandler) WithWorkService(ws service.WorkServiceFailer) *WorkersHandler {
h.workService = ws
return h
}
// Mount registers the worker pool routes.
func (h *WorkersHandler) Mount(r api.Router) {
r.Route("/workers", func(r chi.Router) {
@ -36,6 +44,11 @@ func (h *WorkersHandler) Mount(r api.Router) {
r.With(auth.RequireScope(auth.ScopeWorkersWrite, auth.ScopeAdmin)).Post("/register", h.Register)
r.With(auth.RequireScope(auth.ScopeWorkersWrite, auth.ScopeAdmin)).Post("/{workerId}/heartbeat", h.Heartbeat)
r.With(auth.RequireScope(auth.ScopeWorkersWrite, auth.ScopeAdmin)).Post("/{workerId}/drain", h.Drain)
// Standalone worker task operations
r.With(auth.RequireScope(auth.ScopeWorkersWrite, auth.ScopeAdmin)).Post("/{workerId}/claim", h.ClaimTask)
r.With(auth.RequireScope(auth.ScopeWorkersWrite, auth.ScopeAdmin)).Post("/{workerId}/complete/{taskId}", h.CompleteTask)
r.With(auth.RequireScope(auth.ScopeWorkersWrite, auth.ScopeAdmin)).Post("/{workerId}/fail/{taskId}", h.FailTask)
})
}
@ -230,3 +243,146 @@ func (h *WorkersHandler) Drain(w http.ResponseWriter, r *http.Request) {
"message": "worker will finish current task then stop accepting new work",
})
}
// ClaimTask claims the next available task for a worker.
// POST /workers/{workerId}/claim
func (h *WorkersHandler) ClaimTask(w http.ResponseWriter, r *http.Request) {
workerID := chi.URLParam(r, "workerId")
if workerID == "" {
api.WriteBadRequest(w, r, "worker ID is required")
return
}
task, err := h.workerService.ClaimTask(r.Context(), workerID)
if err != nil {
if errors.Is(err, domain.ErrWorkerNotFound) {
api.WriteNotFound(w, r, "worker not found: "+workerID)
return
}
api.WriteInternalError(w, r, "failed to claim task")
return
}
if task == nil {
// No tasks available - return 204 No Content
w.WriteHeader(http.StatusNoContent)
return
}
api.WriteSuccess(w, r, map[string]any{
"task": toWorkTaskDTO(task),
"worker_id": workerID,
})
}
// CompleteTaskRequest is the request body for POST /workers/{workerId}/complete/{taskId}.
type CompleteTaskRequest struct {
Success bool `json:"success"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
CommitSHA string `json:"commit_sha,omitempty"`
FilesChanged []string `json:"files_changed,omitempty"`
Artifacts map[string]string `json:"artifacts,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
}
// CompleteTask marks a task as complete.
// POST /workers/{workerId}/complete/{taskId}
func (h *WorkersHandler) CompleteTask(w http.ResponseWriter, r *http.Request) {
workerID := chi.URLParam(r, "workerId")
taskID := chi.URLParam(r, "taskId")
if workerID == "" {
api.WriteBadRequest(w, r, "worker ID is required")
return
}
if taskID == "" {
api.WriteBadRequest(w, r, "task ID is required")
return
}
var req CompleteTaskRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
result := &domain.BuildResult{
Success: req.Success,
Output: req.Output,
Error: req.Error,
CommitSHA: req.CommitSHA,
FilesChanged: req.FilesChanged,
DurationMs: req.DurationMs,
Artifacts: req.Artifacts,
}
if err := h.workerService.CompleteTask(r.Context(), workerID, taskID, result); err != nil {
if errors.Is(err, domain.ErrWorkerNotFound) {
api.WriteNotFound(w, r, "worker not found: "+workerID)
return
}
api.WriteInternalError(w, r, "failed to complete task")
return
}
api.WriteSuccess(w, r, map[string]any{
"task_id": taskID,
"worker_id": workerID,
"status": "completed",
})
}
// FailTaskRequest is the request body for POST /workers/{workerId}/fail/{taskId}.
type FailTaskRequest struct {
Error string `json:"error"`
Output string `json:"output,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
}
// FailTask marks a task as failed.
// POST /workers/{workerId}/fail/{taskId}
func (h *WorkersHandler) FailTask(w http.ResponseWriter, r *http.Request) {
workerID := chi.URLParam(r, "workerId")
taskID := chi.URLParam(r, "taskId")
if workerID == "" {
api.WriteBadRequest(w, r, "worker ID is required")
return
}
if taskID == "" {
api.WriteBadRequest(w, r, "task ID is required")
return
}
var req FailTaskRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if h.workService == nil {
api.WriteInternalError(w, r, "work service not configured")
return
}
result := &domain.BuildResult{
Success: false,
Output: req.Output,
Error: req.Error,
DurationMs: req.DurationMs,
}
if err := h.workerService.FailTask(r.Context(), workerID, taskID, result, h.workService); err != nil {
if errors.Is(err, domain.ErrWorkerNotFound) {
api.WriteNotFound(w, r, "worker not found: "+workerID)
return
}
api.WriteInternalError(w, r, "failed to fail task")
return
}
api.WriteSuccess(w, r, map[string]any{
"task_id": taskID,
"worker_id": workerID,
"status": "failed",
})
}

View File

@ -12,6 +12,10 @@ type ComponentService interface {
// AddComponent adds a new component to a project's monorepo.
AddComponent(ctx context.Context, projectID string, req AddComponentRequest) (*domain.Component, error)
// AddComponentBatch adds multiple components in a single atomic operation.
// All components are validated upfront, then committed in a single git commit.
AddComponentBatch(ctx context.Context, projectID string, reqs []AddComponentRequest) ([]*domain.Component, error)
// ListComponents lists all components in a project's monorepo.
ListComponents(ctx context.Context, projectID string) ([]domain.Component, error)

View File

@ -147,7 +147,9 @@ func (s *APIKeyService) Validate(ctx context.Context, rawKey string) (*domain.AP
return nil, domain.ErrKeyExpired
}
// Update last_used_at asynchronously
// Update last_used_at asynchronously (fire-and-forget: intentionally detached from
// request context since this is a non-critical audit update that should not block
// validation or be cancelled when request completes)
go func() {
_ = s.repo.UpdateLastUsed(context.Background(), apiKey.ID)
}()

View File

@ -0,0 +1,309 @@
package service
import (
"context"
"database/sql"
"encoding/base64"
"fmt"
"path/filepath"
"strconv"
"strings"
giteaadapter "github.com/orchard9/rdev/internal/adapter/gitea"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/internal/port"
)
// AddComponentBatch adds multiple components in a single atomic operation.
// All components are validated upfront, then committed in a single git commit.
// Infrastructure components (postgres, redis) are provisioned sequentially before code components.
func (s *ComponentService) AddComponentBatch(ctx context.Context, projectID string, reqs []port.AddComponentRequest) ([]*domain.Component, error) {
if len(reqs) == 0 {
return nil, fmt.Errorf("at least one component is required")
}
log := logging.FromContext(ctx).WithService("component")
// 1. Validate all components upfront
var infraReqs []port.AddComponentRequest
var codeReqs []port.AddComponentRequest
for _, req := range reqs {
// Validate component type
if !domain.IsValidComponentType(req.Type) {
return nil, fmt.Errorf("%w: %s", domain.ErrInvalidComponentType, req.Type)
}
componentType := domain.ComponentType(req.Type)
// Validate component name
if err := domain.ValidateComponentName(req.Name); err != nil {
return nil, fmt.Errorf("%w: %s", err, req.Name)
}
// Separate infrastructure from code components
if componentType.IsInfraComponent() {
infraReqs = append(infraReqs, req)
} else {
codeReqs = append(codeReqs, req)
}
}
// Check for duplicate names in the batch
seen := make(map[string]bool)
for _, req := range reqs {
key := req.Type + ":" + req.Name
if seen[key] {
return nil, fmt.Errorf("%w: duplicate component %s/%s in batch", domain.ErrDuplicateComponent, req.Type, req.Name)
}
seen[key] = true
}
results := make([]*domain.Component, 0, len(reqs))
// 2. Provision infrastructure components first (these don't need git commits)
for _, req := range infraReqs {
componentType := domain.ComponentType(req.Type)
component, err := s.addInfraComponent(ctx, projectID, componentType, req.Name)
if err != nil {
return results, fmt.Errorf("failed to provision %s component %s: %w", req.Type, req.Name, err)
}
results = append(results, component)
}
// 3. If no code components, we're done
if len(codeReqs) == 0 {
return results, nil
}
// 4. Get project info from database (needed for code components)
var gitRepoOwner, gitRepoName string
var projectDomain string
err := s.db.QueryRowContext(ctx, `
SELECT COALESCE(git_repo_owner, $2), COALESCE(git_repo_name, $1), COALESCE(domain, '')
FROM projects WHERE id = $1
`, projectID, s.defaultGitOwner).Scan(&gitRepoOwner, &gitRepoName, &projectDomain)
if err == sql.ErrNoRows {
return results, fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID)
}
if err != nil {
return results, fmt.Errorf("failed to get project: %w", err)
}
goModule := fmt.Sprintf("git.threesix.ai/%s/%s", gitRepoOwner, gitRepoName)
// 5. Prepare all file operations for code components
var allFileOps []giteaadapter.ChangeFileOperation
var codeComponents []*domain.Component
// Track files we've already fetched/modified to avoid duplicate fetches
type fileState struct {
content []byte
sha string
}
fileCache := make(map[string]*fileState)
// Helper to get file content (cached)
getFile := func(path string) ([]byte, string, error) {
if cached, ok := fileCache[path]; ok {
return cached.content, cached.sha, nil
}
content, sha, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, path)
if err != nil {
return nil, "", err
}
fileCache[path] = &fileState{content: content, sha: sha}
return content, sha, nil
}
// 6. Process each code component
for _, req := range codeReqs {
componentType := domain.ComponentType(req.Type)
destDir := componentType.DestDir()
componentPath := filepath.Join(destDir, req.Name)
// Check for duplicate component by checking for key files
checkFile := componentPath + "/go.mod"
if componentType == domain.ComponentTypeAppAstro || componentType == domain.ComponentTypeAppReact {
checkFile = componentPath + "/package.json"
}
existingContent, _, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, checkFile)
if err != nil {
return results, fmt.Errorf("failed to check for existing component %s: %w", req.Name, err)
}
if existingContent != nil {
return results, fmt.Errorf("%w: %s", domain.ErrDuplicateComponent, componentPath)
}
// Assign port if needed
port := req.Port
if port == 0 && componentType.NeedsPort() {
port, err = s.assignPort(ctx, projectID, componentType)
if err != nil {
return results, fmt.Errorf("failed to assign port for %s: %w", req.Name, err)
}
}
// Prepare template variables
vars := map[string]string{
"PROJECT_NAME": projectID,
"GO_MODULE": goModule,
"COMPONENT_NAME": req.Name,
"PORT": strconv.Itoa(port),
"DOMAIN": projectDomain,
}
// Get component template files
componentFiles, err := s.templateProvider.GetComponentFiles(ctx, req.Type, componentPath, vars)
if err != nil {
return results, fmt.Errorf("failed to get component template files for %s: %w", req.Name, err)
}
// Add component files to operations
for _, cf := range componentFiles {
if strings.HasSuffix(cf.Path, ".woodpecker.step.yml") {
continue
}
encodedContent := base64.StdEncoding.EncodeToString([]byte(cf.Content))
allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{
Operation: "create",
Path: cf.Path,
Content: encodedContent,
})
}
// Track component for later
codeComponents = append(codeComponents, &domain.Component{
Type: componentType,
Name: req.Name,
Path: componentPath,
Port: port,
Template: req.Type,
Dependencies: []string{},
})
}
// 7. Prepare monorepo file updates (Procfile, go.work, .woodpecker.yml, CLAUDE.md)
// These need to be accumulated across all components
// Update Procfile
procfileContent, procfileSHA, err := getFile("Procfile")
if err != nil {
return results, fmt.Errorf("failed to get Procfile: %w", err)
}
if procfileContent != nil {
updatedProcfile := string(procfileContent)
for i, comp := range codeComponents {
updatedProcfile = s.updateProcfile(updatedProcfile, comp.Type, comp.Name, comp.Path, comp.Port)
// Update cache for next iteration
fileCache["Procfile"] = &fileState{content: []byte(updatedProcfile), sha: procfileSHA}
_ = i // silence unused
}
allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{
Operation: "update",
Path: "Procfile",
Content: base64.StdEncoding.EncodeToString([]byte(updatedProcfile)),
SHA: procfileSHA,
})
}
// Update go.work (only for Go components)
goWorkContent, goWorkSHA, err := getFile("go.work")
if err != nil {
return results, fmt.Errorf("failed to get go.work: %w", err)
}
if goWorkContent != nil {
updatedGoWork := string(goWorkContent)
for _, comp := range codeComponents {
if comp.Type.IsGoComponent() {
updatedGoWork = s.updateGoWork(updatedGoWork, comp.Path)
}
}
allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{
Operation: "update",
Path: "go.work",
Content: base64.StdEncoding.EncodeToString([]byte(updatedGoWork)),
SHA: goWorkSHA,
})
}
// Update .woodpecker.yml
woodpeckerContent, woodpeckerSHA, err := getFile(".woodpecker.yml")
if err != nil {
return results, fmt.Errorf("failed to get .woodpecker.yml: %w", err)
}
if woodpeckerContent != nil {
updatedWoodpecker := string(woodpeckerContent)
for i, req := range codeReqs {
comp := codeComponents[i]
vars := map[string]string{
"PROJECT_NAME": projectID,
"GO_MODULE": goModule,
"COMPONENT_NAME": comp.Name,
"PORT": strconv.Itoa(comp.Port),
"DOMAIN": projectDomain,
}
stepYaml, err := s.templateProvider.GetComponentWoodpeckerStep(ctx, req.Type, vars)
if err != nil {
log.Warn("failed to get woodpecker step template", logging.FieldError, err, "component", comp.Name)
continue
}
updatedWoodpecker = s.updateWoodpeckerYml(updatedWoodpecker, stepYaml)
}
allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{
Operation: "update",
Path: ".woodpecker.yml",
Content: base64.StdEncoding.EncodeToString([]byte(updatedWoodpecker)),
SHA: woodpeckerSHA,
})
}
// Update CLAUDE.md
claudeMdContent, claudeMdSHA, err := getFile("CLAUDE.md")
if err != nil {
return results, fmt.Errorf("failed to get CLAUDE.md: %w", err)
}
if claudeMdContent != nil {
updatedClaudeMd := string(claudeMdContent)
for _, comp := range codeComponents {
updatedClaudeMd = s.updateClaudeMd(updatedClaudeMd, comp.Type, comp.Name, comp.Path)
}
allFileOps = append(allFileOps, giteaadapter.ChangeFileOperation{
Operation: "update",
Path: "CLAUDE.md",
Content: base64.StdEncoding.EncodeToString([]byte(updatedClaudeMd)),
SHA: claudeMdSHA,
})
}
// 8. Commit all files in a single atomic commit
componentNames := make([]string, len(codeReqs))
for i, req := range codeReqs {
componentNames[i] = req.Type + "/" + req.Name
}
opts := giteaadapter.ChangeFilesOptions{
Files: allFileOps,
Message: fmt.Sprintf("Add components: %s", strings.Join(componentNames, ", ")),
}
_, err = s.bulkClient.ChangeFiles(ctx, gitRepoOwner, gitRepoName, opts)
if err != nil {
return results, fmt.Errorf("failed to commit component files: %w", err)
}
log.Info("batch components added successfully",
logging.FieldProjectID, projectID,
"count", len(codeComponents),
"components", componentNames,
)
// 9. Create initial K8s deployments for components that need one
for _, comp := range codeComponents {
s.createInitialComponentDeployment(ctx, projectID, projectDomain, comp)
}
// 10. Combine infrastructure and code component results
results = append(results, codeComponents...)
return results, nil
}

View File

@ -329,12 +329,18 @@ func (s *ProjectInfraService) seedTemplate(ctx context.Context, req CreateProjec
// provisionResources provisions database and cache for a project.
// Credentials are stored in the credential store for injection into deployments.
// If credential storage fails after provisioning, the resources are rolled back to prevent orphans.
// This function is idempotent - it skips resources that already exist.
func (s *ProjectInfraService) provisionResources(ctx context.Context, result *CreateProjectResult) {
log := logging.FromContext(ctx).WithService("project_infra")
projectID := result.ProjectID
// Provision database
// Provision database (idempotent)
if s.dbProvisioner != nil {
// Check if already provisioned
existing, _ := s.dbProvisioner.GetProjectDatabase(ctx, projectID)
if existing != nil {
log.Info("database already provisioned, skipping", logging.FieldProjectID, projectID)
} else {
dbCreds, err := s.dbProvisioner.CreateProjectDatabase(ctx, projectID)
if err != nil {
log.Error("failed to provision database", logging.FieldProjectID, projectID, logging.FieldError, err)
@ -365,9 +371,15 @@ func (s *ProjectInfraService) provisionResources(ctx context.Context, result *Cr
}
}
}
}
// Provision cache
// Provision cache (idempotent)
if s.cacheProvisioner != nil {
// Check if already provisioned
existing, _ := s.cacheProvisioner.GetProjectCache(ctx, projectID)
if existing != nil {
log.Info("cache already provisioned, skipping", logging.FieldProjectID, projectID)
} else {
cacheCreds, err := s.cacheProvisioner.CreateProjectCache(ctx, projectID)
if err != nil {
log.Error("failed to provision cache", logging.FieldProjectID, projectID, logging.FieldError, err)
@ -403,6 +415,7 @@ func (s *ProjectInfraService) provisionResources(ctx context.Context, result *Cr
}
}
}
}
// storeCredential stores a project-scoped credential in the credential store.
// Keys are prefixed with the project ID for isolation (e.g., "myproject:DATABASE_URL").

View File

@ -193,7 +193,7 @@ func (s *ProjectService) ExecuteClaude(ctx context.Context, req ExecuteClaudeReq
AllowedTools: req.AllowedTools,
Metadata: map[string]string{"pod_name": project.PodName},
}
go s.executeAgentCommand(agent, agentReq, cmd)
go s.executeAgentCommand(ctx, agent, agentReq, cmd)
return &ExecuteClaudeResult{
CommandID: cmdID,
@ -204,7 +204,7 @@ func (s *ProjectService) ExecuteClaude(ctx context.Context, req ExecuteClaudeReq
}
// Fallback to legacy executor
go s.executeCommand(project.PodName, cmd)
go s.executeCommand(ctx, project.PodName, cmd)
return &ExecuteClaudeResult{
CommandID: cmdID,
@ -213,8 +213,10 @@ func (s *ProjectService) ExecuteClaude(ctx context.Context, req ExecuteClaudeReq
}
// executeCommand runs a command and streams output to subscribers.
func (s *ProjectService) executeCommand(podName string, cmd *domain.Command) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
// Uses context.WithoutCancel to preserve tracing/values but allow independent timeout.
func (s *ProjectService) executeCommand(parentCtx context.Context, podName string, cmd *domain.Command) {
// Derive from parent to preserve tracing/values, but with independent cancellation
ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), 10*time.Minute)
defer cancel()
log := logging.FromContext(ctx).WithService("ProjectService")

View File

@ -31,8 +31,10 @@ func (s *ProjectService) resolveAgent(project *domain.Project) port.CodeAgent {
}
// 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)
// Uses context.WithoutCancel to preserve tracing/values but allow independent timeout.
func (s *ProjectService) executeAgentCommand(parentCtx context.Context, agent port.CodeAgent, req *domain.AgentRequest, cmd *domain.Command) {
// Derive from parent to preserve tracing/values, but with independent cancellation
ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), 10*time.Minute)
defer cancel()
log := logging.FromContext(ctx).WithService("ProjectService")

View File

@ -87,7 +87,7 @@ func (s *ProjectService) ExecuteShell(ctx context.Context, req ExecuteShellReque
}
// Execute in background
go s.executeCommand(project.PodName, cmd)
go s.executeCommand(ctx, project.PodName, cmd)
return &ExecuteShellResult{
CommandID: cmdID,
@ -168,7 +168,7 @@ func (s *ProjectService) ExecuteGit(ctx context.Context, req ExecuteGitRequest)
}
// Execute in background
go s.executeCommand(project.PodName, cmd)
go s.executeCommand(ctx, project.PodName, cmd)
return &ExecuteGitResult{
CommandID: cmdID,

View File

@ -199,7 +199,9 @@ func (d *Dispatcher) worker(id int) {
func (d *Dispatcher) processJob(job deliveryJob) {
delivery := d.deliver(job)
// Record the delivery attempt
// Record the delivery attempt (fire-and-forget: uses dedicated context with
// 10s timeout since recording should not block the job processing loop or
// fail if the dispatcher context is cancelled)
recordCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

View File

@ -0,0 +1,308 @@
package worker
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/orchard9/rdev/internal/domain"
)
// APIClient is an HTTP client for standalone workers to communicate with rdev-api.
type APIClient struct {
baseURL string
apiKey string
httpClient *http.Client
}
// APIClientConfig holds configuration for the API client.
type APIClientConfig struct {
// BaseURL is the base URL of the rdev-api server.
BaseURL string
// APIKey is the API key for authentication.
APIKey string
// Timeout is the default request timeout.
Timeout time.Duration
}
// NewAPIClient creates a new API client for standalone workers.
func NewAPIClient(cfg APIClientConfig) *APIClient {
if cfg.Timeout == 0 {
cfg.Timeout = 30 * time.Second
}
return &APIClient{
baseURL: cfg.BaseURL,
apiKey: cfg.APIKey,
httpClient: &http.Client{
Timeout: cfg.Timeout,
},
}
}
// RegisterRequest is the request to register a worker.
type RegisterRequest struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
Version string `json:"version,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
}
// RegisterResponse is the response from registering a worker.
type RegisterResponse struct {
Success bool `json:"success"`
Data struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
Status string `json:"status"`
Capabilities []string `json:"capabilities,omitempty"`
RegisteredAt string `json:"registered_at"`
LastHeartbeat string `json:"last_heartbeat"`
Version string `json:"version,omitempty"`
} `json:"data"`
Error string `json:"error,omitempty"`
}
// Register registers the worker with rdev-api.
func (c *APIClient) Register(ctx context.Context, req *RegisterRequest) error {
body, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/workers/register", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(httpReq)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("register: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("register returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
// Heartbeat sends a heartbeat to keep the worker alive.
func (c *APIClient) Heartbeat(ctx context.Context, workerID string) error {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
fmt.Sprintf("%s/workers/%s/heartbeat", c.baseURL, workerID), nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(httpReq)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("heartbeat: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("heartbeat returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
// ClaimTaskResponse is the response from claiming a task.
type ClaimTaskResponse struct {
Success bool `json:"success"`
Data struct {
Task *WorkTaskData `json:"task"`
WorkerID string `json:"worker_id"`
} `json:"data"`
Error string `json:"error,omitempty"`
}
// WorkTaskData is the task data returned from the API.
type WorkTaskData 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"`
RetryCount int `json:"retry_count"`
MaxRetries int `json:"max_retries"`
}
// ToWorkTask converts the API task data to a domain work task.
func (d *WorkTaskData) ToWorkTask() *domain.WorkTask {
if d == nil {
return nil
}
task := &domain.WorkTask{
ID: d.ID,
ProjectID: d.ProjectID,
Type: domain.WorkTaskType(d.Type),
Spec: d.Spec,
Status: domain.WorkTaskStatus(d.Status),
Priority: d.Priority,
WorkerID: d.WorkerID,
CallbackURL: d.CallbackURL,
RetryCount: d.RetryCount,
MaxRetries: d.MaxRetries,
}
if d.CreatedAt != "" {
task.CreatedAt, _ = time.Parse(time.RFC3339, d.CreatedAt)
}
if d.StartedAt != "" {
t, _ := time.Parse(time.RFC3339, d.StartedAt)
task.StartedAt = &t
}
return task
}
// ClaimTask claims the next available task from the queue.
// Returns nil if no tasks are available.
func (c *APIClient) ClaimTask(ctx context.Context, workerID string) (*domain.WorkTask, error) {
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
fmt.Sprintf("%s/workers/%s/claim", c.baseURL, workerID), nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
c.setHeaders(httpReq)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("claim task: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// 204 No Content = no tasks available
if resp.StatusCode == http.StatusNoContent {
return nil, nil
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("claim task returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
var result ClaimTaskResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
return result.Data.Task.ToWorkTask(), nil
}
// CompleteTaskRequest is the request to complete a task.
type CompleteTaskRequest struct {
Success bool `json:"success"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
CommitSHA string `json:"commit_sha,omitempty"`
FilesChanged []string `json:"files_changed,omitempty"`
Artifacts map[string]string `json:"artifacts,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
}
// CompleteTask marks a task as complete.
func (c *APIClient) CompleteTask(ctx context.Context, workerID, taskID string, result *domain.BuildResult) error {
req := &CompleteTaskRequest{
Success: result.Success,
Output: result.Output,
Error: result.Error,
CommitSHA: result.CommitSHA,
DurationMs: result.DurationMs,
}
if result.FilesChanged != nil {
req.FilesChanged = result.FilesChanged
}
if result.Artifacts != nil {
req.Artifacts = result.Artifacts
}
body, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
fmt.Sprintf("%s/workers/%s/complete/%s", c.baseURL, workerID, taskID), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(httpReq)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("complete task: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("complete task returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
// FailTaskRequest is the request to fail a task.
type FailTaskRequest struct {
Error string `json:"error"`
Output string `json:"output,omitempty"`
DurationMs int64 `json:"duration_ms,omitempty"`
}
// FailTask marks a task as failed.
func (c *APIClient) FailTask(ctx context.Context, workerID, taskID string, errMsg, output string, durationMs int64) error {
req := &FailTaskRequest{
Error: errMsg,
Output: output,
DurationMs: durationMs,
}
body, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
fmt.Sprintf("%s/workers/%s/fail/%s", c.baseURL, workerID, taskID), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(httpReq)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("fail task: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("fail task returned status %d: %s", resp.StatusCode, string(bodyBytes))
}
return nil
}
// setHeaders sets common headers on the request.
func (c *APIClient) setHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
if c.apiKey != "" {
req.Header.Set("X-API-Key", c.apiKey)
}
}

View File

@ -0,0 +1,315 @@
package worker
import (
"context"
"fmt"
"strings"
"time"
claudeboxclient "github.com/orchard9/rdev/internal/adapter/claudebox"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
"github.com/orchard9/rdev/internal/port"
)
// HTTPBuildExecutor handles WorkTaskTypeBuild tasks using HTTP calls to the
// local claudebox sidecar instead of kubectl exec.
type HTTPBuildExecutor struct {
client *claudeboxclient.Client
streams port.StreamPublisher
workDir string
}
// HTTPBuildExecutorConfig holds configuration for the HTTP build executor.
type HTTPBuildExecutorConfig struct {
// ClaudeboxClient is the HTTP client for the claudebox sidecar.
ClaudeboxClient *claudeboxclient.Client
// Streams is the SSE stream publisher for real-time events.
Streams port.StreamPublisher
// WorkDir is the default working directory in the container.
WorkDir string
}
// NewHTTPBuildExecutor creates a new HTTP-based build executor.
func NewHTTPBuildExecutor(cfg HTTPBuildExecutorConfig) *HTTPBuildExecutor {
if cfg.WorkDir == "" {
cfg.WorkDir = "/workspace"
}
return &HTTPBuildExecutor{
client: cfg.ClaudeboxClient,
streams: cfg.Streams,
workDir: cfg.WorkDir,
}
}
// Execute runs a build task using the claudebox sidecar HTTP API.
func (e *HTTPBuildExecutor) Execute(ctx context.Context, task *domain.WorkTask) *domain.BuildResult {
log := logging.FromContext(ctx).WithWorker("http-build-executor")
start := time.Now()
streamID := task.ID
// Publish started event
e.publishEvent(streamID, BuildEventStarted, map[string]any{
"task_id": task.ID,
"project_id": task.ProjectID,
"started_at": start.Format(time.RFC3339),
})
// Parse build spec
spec, err := e.parseSpec(task.Spec)
if err != nil {
e.publishEvent(streamID, BuildEventFailed, map[string]any{
"task_id": task.ID,
"error": fmt.Sprintf("invalid build spec: %v", err),
})
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("invalid build spec: %v", err),
DurationMs: time.Since(start).Milliseconds(),
}
}
// Clone or update repository if git operations are needed
if (spec.AutoCommit || spec.AutoPush) && e.client != nil {
if spec.GitCloneURL == "" {
e.publishEvent(streamID, BuildEventFailed, map[string]any{
"task_id": task.ID,
"error": "git_clone_url is required when auto_commit or auto_push is enabled",
})
return &domain.BuildResult{
Success: false,
Error: "git_clone_url is required when auto_commit or auto_push is enabled",
DurationMs: time.Since(start).Milliseconds(),
}
}
log.Info("cloning repository via HTTP", "task_id", task.ID)
cloneResp, err := e.client.GitClone(ctx, spec.GitCloneURL, e.workDir)
if err != nil {
e.publishEvent(streamID, BuildEventFailed, map[string]any{
"task_id": task.ID,
"error": fmt.Sprintf("git clone failed: %v", err),
})
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("git clone failed: %v", err),
DurationMs: time.Since(start).Milliseconds(),
}
}
if !cloneResp.Success {
e.publishEvent(streamID, BuildEventFailed, map[string]any{
"task_id": task.ID,
"error": fmt.Sprintf("git clone failed: %s", cloneResp.Error),
})
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("git clone failed: %s", cloneResp.Error),
DurationMs: time.Since(start).Milliseconds(),
}
}
if cloneResp.Cloned {
e.publishEvent(streamID, BuildEventOutput, map[string]any{
"content": fmt.Sprintf("Cloned repository to %s", e.workDir),
})
}
}
// Execute Claude Code via HTTP
log.Info("executing Claude Code via HTTP", "task_id", task.ID, "prompt_len", len(spec.Prompt))
var output strings.Builder
const maxOutputSize = 1 << 20 // 1MB
// Use streaming execution
execErr := e.client.ExecuteStream(ctx, &claudeboxclient.ExecuteRequest{
Prompt: spec.Prompt,
WorkingDir: e.workDir,
Timeout: 600, // 10 minutes
}, func(evt claudeboxclient.StreamEvent) {
// Map event types
eventType := BuildEventOutput
switch evt.Type {
case "tool_use":
eventType = BuildEventToolUse
case "tool_result":
eventType = BuildEventToolResult
case "error":
eventType = BuildEventError
}
e.publishEvent(streamID, eventType, map[string]any{
"content": evt.Content,
"stream": evt.Stream,
"tool_name": evt.ToolName,
})
// Buffer output
if evt.Type == "output" || evt.Type == "error" {
if output.Len() >= maxOutputSize {
return
}
if output.Len() > 0 {
output.WriteString("\n")
}
remaining := maxOutputSize - output.Len()
if len(evt.Content) > remaining {
output.WriteString(evt.Content[:remaining])
output.WriteString("\n... [output truncated at 1MB]")
} else {
output.WriteString(evt.Content)
}
}
})
if execErr != nil {
e.publishEvent(streamID, BuildEventFailed, map[string]any{
"task_id": task.ID,
"error": fmt.Sprintf("agent execution failed: %v", execErr),
"duration_ms": time.Since(start).Milliseconds(),
})
e.closeStream(ctx, streamID)
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("agent execution failed: %v", execErr),
Output: output.String(),
DurationMs: time.Since(start).Milliseconds(),
}
}
result := &domain.BuildResult{
Success: true,
Output: output.String(),
DurationMs: time.Since(start).Milliseconds(),
Artifacts: make(map[string]string),
}
// Include SDLC context in artifacts for callback routing
if spec.SDLCContext != nil {
if spec.SDLCContext.Feature != "" {
result.Artifacts["sdlc_feature"] = spec.SDLCContext.Feature
}
if spec.SDLCContext.ArtifactType != "" {
result.Artifacts["sdlc_artifact_type"] = spec.SDLCContext.ArtifactType
}
if spec.SDLCContext.TaskID != "" {
result.Artifacts["sdlc_task_id"] = spec.SDLCContext.TaskID
}
}
// Post-build git operations: commit and push changes
if result.Success && spec.AutoCommit && e.client != nil {
commitMsg := fmt.Sprintf("build: %s", truncate(spec.Prompt, 72))
gitResp, err := e.client.GitCommitAndPush(ctx, commitMsg, spec.AutoPush, e.workDir)
if err != nil {
log.Warn("post-build git operations failed", "task_id", task.ID, "error", err)
result.Success = false
result.Error = fmt.Sprintf("build succeeded but git operations failed: %v", err)
} else if !gitResp.Success {
log.Warn("post-build git operations failed", "task_id", task.ID, "error", gitResp.Error)
result.Success = false
result.Error = fmt.Sprintf("build succeeded but git operations failed: %s", gitResp.Error)
} else if gitResp.HasChanges {
result.CommitSHA = gitResp.CommitSHA
result.FilesChanged = gitResp.FilesChanged
log.Info("post-build git operations completed",
"task_id", task.ID,
"commit", gitResp.CommitSHA,
"files", len(gitResp.FilesChanged),
"pushed", gitResp.Pushed,
)
} else {
log.Info("no changes to commit after build", "task_id", task.ID)
}
}
// Publish completion event
if result.Success {
e.publishEvent(streamID, BuildEventCompleted, map[string]any{
"task_id": task.ID,
"success": true,
"commit_sha": result.CommitSHA,
"files_changed": result.FilesChanged,
"duration_ms": result.DurationMs,
})
} else {
e.publishEvent(streamID, BuildEventFailed, map[string]any{
"task_id": task.ID,
"error": result.Error,
"duration_ms": result.DurationMs,
})
}
e.closeStream(ctx, streamID)
return result
}
// publishEvent publishes an event to the SSE stream.
func (e *HTTPBuildExecutor) publishEvent(streamID, eventType string, data map[string]any) {
if e.streams == nil {
return
}
e.streams.Publish(streamID, port.StreamEvent{
Type: eventType,
Data: data,
})
}
// closeStream closes the stream after a delay.
func (e *HTTPBuildExecutor) closeStream(ctx context.Context, streamID string) {
if e.streams == nil {
return
}
go func() {
select {
case <-ctx.Done():
e.streams.Close(streamID)
case <-time.After(streamCloseDelay):
e.streams.Close(streamID)
}
}()
}
// httpBuildSpec holds typed fields extracted from the task spec map.
type httpBuildSpec struct {
Prompt string
AutoCommit bool
AutoPush bool
GitCloneURL string
SDLCContext *sdlcContext
}
// parseSpec extracts typed BuildSpec fields from the generic map.
func (e *HTTPBuildExecutor) parseSpec(spec map[string]any) (*httpBuildSpec, error) {
prompt, _ := spec["prompt"].(string)
if prompt == "" {
return nil, fmt.Errorf("prompt is required")
}
autoCommit, _ := spec["auto_commit"].(bool)
autoPush, _ := spec["auto_push"].(bool)
gitCloneURL, _ := spec["git_clone_url"].(string)
parsed := &httpBuildSpec{
Prompt: prompt,
AutoCommit: autoCommit,
AutoPush: autoPush,
GitCloneURL: gitCloneURL,
}
// Extract SDLC context if present
if sdlcCtx, ok := spec["sdlc_context"].(map[string]any); ok {
parsed.SDLCContext = &sdlcContext{
Feature: stringFromMap(sdlcCtx, "feature"),
ArtifactType: stringFromMap(sdlcCtx, "artifact_type"),
TaskID: stringFromMap(sdlcCtx, "task_id"),
}
}
return parsed, nil
}

View File

@ -0,0 +1,184 @@
package worker
import (
"context"
"fmt"
"time"
claudeboxclient "github.com/orchard9/rdev/internal/adapter/claudebox"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
)
// HTTPSDLCTaskExecutor handles WorkTaskTypeSDLC tasks using HTTP calls to the
// local claudebox sidecar instead of kubectl exec.
type HTTPSDLCTaskExecutor struct {
client *claudeboxclient.Client
workDir string
}
// HTTPSDLCTaskExecutorConfig holds configuration for the HTTP SDLC executor.
type HTTPSDLCTaskExecutorConfig struct {
// ClaudeboxClient is the HTTP client for the claudebox sidecar.
ClaudeboxClient *claudeboxclient.Client
// WorkDir is the default working directory in the container.
WorkDir string
}
// NewHTTPSDLCTaskExecutor creates a new HTTP-based SDLC executor.
func NewHTTPSDLCTaskExecutor(cfg HTTPSDLCTaskExecutorConfig) *HTTPSDLCTaskExecutor {
if cfg.WorkDir == "" {
cfg.WorkDir = "/workspace"
}
return &HTTPSDLCTaskExecutor{
client: cfg.ClaudeboxClient,
workDir: cfg.WorkDir,
}
}
// Execute runs an SDLC task using the claudebox sidecar HTTP API.
func (e *HTTPSDLCTaskExecutor) Execute(ctx context.Context, task *domain.WorkTask) *domain.BuildResult {
start := time.Now()
log := logging.FromContext(ctx).WithWorker("http-sdlc-executor")
// Parse SDLC spec
spec, err := e.parseSpec(task.Spec)
if err != nil {
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("invalid SDLC spec: %v", err),
DurationMs: time.Since(start).Milliseconds(),
}
}
log.Info("executing SDLC task via HTTP",
"task_id", task.ID,
logging.FieldProjectID, task.ProjectID,
"command", spec.Command,
)
// Clone repo to workspace
cloneResp, err := e.client.GitClone(ctx, spec.GitCloneURL, e.workDir)
if err != nil {
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("git clone failed: %v", err),
DurationMs: time.Since(start).Milliseconds(),
}
}
if !cloneResp.Success {
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("git clone failed: %s", cloneResp.Error),
DurationMs: time.Since(start).Milliseconds(),
}
}
// Run SDLC command
sdlcResp, err := e.client.RunSDLC(ctx, spec.Command, spec.Args, e.workDir)
if err != nil {
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("sdlc command failed: %v", err),
DurationMs: time.Since(start).Milliseconds(),
}
}
if !sdlcResp.Success {
return &domain.BuildResult{
Success: false,
Error: fmt.Sprintf("sdlc command failed: %s", sdlcResp.Error),
Output: sdlcResp.Output,
DurationMs: time.Since(start).Milliseconds(),
}
}
result := &domain.BuildResult{
Success: true,
Output: sdlcResp.Output,
DurationMs: time.Since(start).Milliseconds(),
}
// Commit and push if enabled
if spec.AutoCommit {
commitMsg := fmt.Sprintf("sdlc: %s", spec.Command)
gitResp, err := e.client.GitCommitAndPush(ctx, commitMsg, spec.AutoPush, e.workDir)
if err != nil {
result.Success = false
result.Error = fmt.Sprintf("git operations failed: %v", err)
return result
}
if !gitResp.Success {
result.Success = false
result.Error = fmt.Sprintf("git operations failed: %s", gitResp.Error)
return result
}
if gitResp.HasChanges {
result.CommitSHA = gitResp.CommitSHA
result.FilesChanged = gitResp.FilesChanged
log.Info("SDLC changes committed",
"task_id", task.ID,
"commit", gitResp.CommitSHA,
"files", len(gitResp.FilesChanged),
"pushed", gitResp.Pushed,
)
}
}
log.Info("SDLC task completed",
"task_id", task.ID,
"command", spec.Command,
logging.FieldDuration, result.DurationMs,
)
return result
}
// httpSDLCSpec holds typed fields extracted from the task spec map.
type httpSDLCSpec struct {
Command string
Args []string
GitCloneURL string
AutoCommit bool
AutoPush bool
}
// parseSpec extracts typed SDLCTaskSpec fields from the generic map.
func (e *HTTPSDLCTaskExecutor) parseSpec(spec map[string]any) (*httpSDLCSpec, error) {
command, _ := spec["command"].(string)
if command == "" {
return nil, fmt.Errorf("command is required")
}
gitCloneURL, _ := spec["git_clone_url"].(string)
if gitCloneURL == "" {
return nil, fmt.Errorf("git_clone_url is required")
}
autoCommit, _ := spec["auto_commit"].(bool)
autoPush, _ := spec["auto_push"].(bool)
// Parse args (can be []string or []any from JSON)
var args []string
if argsRaw, ok := spec["args"]; ok {
switch v := argsRaw.(type) {
case []string:
args = v
case []any:
for _, a := range v {
if s, ok := a.(string); ok {
args = append(args, s)
}
}
}
}
return &httpSDLCSpec{
Command: command,
Args: args,
GitCloneURL: gitCloneURL,
AutoCommit: autoCommit,
AutoPush: autoPush,
}, nil
}

View File

@ -188,8 +188,11 @@ func (e *SDLCTaskExecutor) ensureSDLCInit(ctx context.Context, podName, workDir
// runSDLCCommand executes the sdlc CLI command in the worker pod.
func (e *SDLCTaskExecutor) runSDLCCommand(ctx context.Context, podName, workDir, command string, args []string) (string, error) {
// Build the full command: sdlc {command} {args...} --json
sdlcArgs := []string{command}
sdlcArgs = append(sdlcArgs, args...)
// Each argument is quoted to handle values with spaces (e.g., --title "My Feature")
sdlcArgs := []string{shellQuote(command)}
for _, arg := range args {
sdlcArgs = append(sdlcArgs, shellQuote(arg))
}
sdlcArgs = append(sdlcArgs, "--json")
// Build kubectl exec command
@ -266,3 +269,16 @@ type SDLCResult struct {
Data json.RawMessage `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// shellQuote escapes a string for safe use in a shell command.
// It wraps the string in single quotes and escapes any single quotes within.
func shellQuote(s string) string {
// If the string contains no special characters, return as-is
if !strings.ContainsAny(s, " \t\n'\"\\$`!*?[]{}|&;<>()") {
return s
}
// Escape single quotes by ending the quoted section, adding an escaped quote, and restarting
// 'foo'bar' becomes 'foo'"'"'bar'
escaped := strings.ReplaceAll(s, "'", "'\"'\"'")
return "'" + escaped + "'"
}

View File

@ -155,3 +155,29 @@ func TestSDLCTaskSpec_Valid(t *testing.T) {
t.Errorf("got %d args, want 3", len(spec.Args))
}
}
func TestShellQuote(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"simple", "auth-flow", "auth-flow"},
{"with space", "Authentication System", "'Authentication System'"},
{"with single quote", "it's working", "'it'\"'\"'s working'"},
{"flag", "--title", "--title"},
{"empty", "", ""},
{"with dollar", "$HOME", "'$HOME'"},
{"with backtick", "`cmd`", "'`cmd`'"},
{"with semicolon", "a;b", "'a;b'"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shellQuote(tt.input)
if got != tt.want {
t.Errorf("shellQuote(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

View File

@ -2,10 +2,11 @@
# Build and push rdev images to GitHub Container Registry
#
# Usage:
# ./build-push.sh # Build both images with 'latest' tag
# ./build-push.sh v0.4.0 # Build both images with version tag
# ./build-push.sh # Build all images with 'latest' tag
# ./build-push.sh v0.4.0 # Build all images with version tag
# ./build-push.sh v0.4.0 claudebox # Build only claudebox image
# ./build-push.sh v0.4.0 api # Build only api image
# ./build-push.sh v0.4.0 worker # Build only worker image
set -e
@ -61,6 +62,27 @@ build_api() {
echo "Pushed: $IMAGE_TAG"
}
build_worker() {
local IMAGE_TAG="$REGISTRY/rdev-worker:$VERSION"
echo "Building rdev-worker image..."
echo "Image: $IMAGE_TAG"
echo ""
# Build the image for linux/amd64
docker build --platform linux/amd64 \
-t "$IMAGE_TAG" \
-t "$REGISTRY/rdev-worker:latest" \
-f Dockerfile.worker \
.
echo ""
echo "Pushing rdev-worker to GitHub Container Registry..."
docker push "$IMAGE_TAG"
docker push "$REGISTRY/rdev-worker:latest"
echo "Pushed: $IMAGE_TAG"
}
case "$TARGET" in
claudebox)
build_claudebox
@ -68,16 +90,23 @@ case "$TARGET" in
api)
build_api
;;
worker)
build_worker
;;
all)
build_claudebox
echo ""
echo "---"
echo ""
build_api
echo ""
echo "---"
echo ""
build_worker
;;
*)
echo "Unknown target: $TARGET"
echo "Usage: $0 [version] [claudebox|api|all]"
echo "Usage: $0 [version] [claudebox|api|worker|all]"
exit 1
;;
esac