From 9c15976f8600a9d52164e6970e7ae6b06b0c1305 Mon Sep 17 00:00:00 2001 From: jordan Date: Thu, 29 Jan 2026 21:25:29 -0700 Subject: [PATCH] feat: Complete Claude endpoint and update cookbook - Add session_id, model, allowed_tools to Claude request handler - Update OpenAPI spec for Claude endpoint - Fix BuildExecutor constructor call sites - Rewrite landing-test.sh for agent-driven flow - Fix cookbook documentation for correct API format Co-Authored-By: Claude Opus 4.5 --- cmd/rdev-api/main.go | 2 +- cmd/rdev-api/openapi.go | 7 +- cookbooks/landing-page.md | 475 ++++++++++++------------- cookbooks/scripts/landing-test.sh | 365 ++++++++++--------- internal/handlers/projects_commands.go | 18 +- internal/worker/build_executor.go | 41 ++- internal/worker/work_executor_test.go | 12 +- 7 files changed, 488 insertions(+), 432 deletions(-) diff --git a/cmd/rdev-api/main.go b/cmd/rdev-api/main.go index 2f32890..a8e6275 100644 --- a/cmd/rdev-api/main.go +++ b/cmd/rdev-api/main.go @@ -413,7 +413,7 @@ func main() { Logger: logger, }) } - buildExecutor := worker.NewBuildExecutor(agentRegistry, gitOps, logger) + buildExecutor := worker.NewBuildExecutor(agentRegistry, gitOps, logger, nil) workerCfg := worker.DefaultWorkExecutorConfig() workerCfg.Logger = logger workExecutor := worker.NewWorkExecutor( diff --git a/cmd/rdev-api/openapi.go b/cmd/rdev-api/openapi.go index ea81f11..90b7725 100644 --- a/cmd/rdev-api/openapi.go +++ b/cmd/rdev-api/openapi.go @@ -180,13 +180,16 @@ func registerProjectPaths(spec *api.OpenAPISpec) { func registerCommandPaths(spec *api.OpenAPISpec) { spec.AddPath("/projects/{id}/claude", "post", withAuthBodyAndParams( "Run Claude command", - "Executes a Claude Code prompt in the project's claudebox pod. Requires projects:execute scope.", + "Executes a Claude Code prompt in the project's claudebox pod. Supports session continuation, model selection (OpenCode), and tool restrictions. Requires projects:execute scope.", "Commands", "projects:execute", []param{{Name: "id", In: "path", Description: "Project ID", Required: true}}, `{ "prompt": "fix the bug in auth handler", - "stream_id": "optional-correlation-id" + "stream_id": "optional-correlation-id", + "session_id": "prev-session-123", + "model": "claude-sonnet-4-20250514", + "allowed_tools": ["Read", "Write", "Bash(git:*)"] }`, `{ "id": "cmd-pantheon-001", diff --git a/cookbooks/landing-page.md b/cookbooks/landing-page.md index 2f5175e..d196ca1 100644 --- a/cookbooks/landing-page.md +++ b/cookbooks/landing-page.md @@ -1,337 +1,312 @@ # Landing Page Cookbook -> Deploy a static landing page through the threesix.ai infrastructure with agent-driven development. +> Deploy a landing page built by a Claude agent through the threesix.ai infrastructure. ## Overview -This cookbook creates and deploys a simple landing page using the full threesix.ai autonomous infrastructure: +This cookbook creates and deploys a landing page using **agent-driven development**: ``` -POST /project → Gitea repo + DNS + Woodpecker CI + template seed → Claude agent → git push → CI build → K8s deployment +POST /project/create-and-build + ↓ +Creates: Gitea repo + DNS + Woodpecker CI + K8s deployment + ↓ +Enqueues build task with prompt + ↓ +Worker picks up task → Claude builds the site + ↓ +Agent commits + pushes + ↓ +CI builds and deploys + ↓ +Live site ``` -**Target:** `landing.threesix.ai` (with future DNS aliases for www/root) -**Stack:** Astro (static site generator) via `astro-landing` template -**Status:** Coming Soon page - ---- - -## What's Automated Today - -`POST /project` orchestrates the full infrastructure setup in a single call: - -| Step | Status | How | -|------|--------|-----| -| Gitea repo creation | Automated | `port.GitRepository` adapter | -| DNS A record | Automated | `port.DNSProvider` (Cloudflare) adapter | -| Woodpecker CI activation | Automated | `port.CIProvider` adapter, called during project creation | -| Template seeding | Automated | `port.TemplateProvider` with `astro-landing` template | -| K8s deployment | Automated | `port.Deployer` adapter (triggered by CI webhook) | - -## Full Pipeline Status - -All infrastructure gaps have been closed. The full pipeline from project creation through code generation, CI monitoring, and multi-domain DNS is operational: - -| Capability | Endpoint | Status | -|------------|----------|--------| -| Project creation | `POST /project` | Operational | -| Code generation (worker) | `POST /projects/{id}/builds` | Operational | -| Create + build combo | `POST /project/create-and-build` | Operational | -| CI pipeline monitoring | `GET /projects/{id}/pipelines` | Operational | -| DNS alias management | `POST /projects/{id}/domains` | Operational | +**No templates. Claude builds it from scratch based on your prompt.** --- ## Prerequisites -### Credentials Required - -| Secret | Location | Purpose | -|--------|----------|---------| -| RDEV_ADMIN_KEY | `rdev-credentials` secret | rdev-api authentication | -| GITEA_TOKEN | `rdev-credentials` secret | Gitea API access | -| WOODPECKER_API_TOKEN | `rdev-credentials` secret | Woodpecker repo activation | -| CLOUDFLARE_API_TOKEN | `rdev-credentials` secret | DNS management | +### API Access +```bash +export RDEV_API_URL="https://rdev.masq-ops.orchard9.ai" +export RDEV_API_KEY="" +``` ### Infrastructure Required - -- [x] rdev-api running with infrastructure handlers -- [x] Gitea at https://git.threesix.ai -- [x] Woodpecker CI at https://ci.threesix.ai -- [x] Zot registry at registry.threesix.ai -- [x] `projects` namespace in K8s with RBAC -- [x] Wildcard TLS cert for *.threesix.ai +- rdev-api running with embedded worker +- Gitea at https://git.threesix.ai +- Woodpecker CI at https://ci.threesix.ai +- claudebox-0 pod running in rdev namespace --- -## Architecture +## Step 1: Create Project and Build in One Call -``` -┌──────────────────────────────────────────────────────────────────────┐ -│ Landing Page Flow │ -│ │ -│ 1. Create Project (single API call) │ -│ POST /project {"name": "landing", "template": "astro-landing"} │ -│ │ │ -│ ├──▶ Creates Gitea repo: threesix/landing │ -│ ├──▶ Creates DNS: landing.threesix.ai → cluster IP │ -│ ├──▶ Activates Woodpecker CI (auto) │ -│ └──▶ Seeds repo with astro-landing template │ -│ │ -│ 2. Generate Code (3 options) │ -│ Via worker pool, claudebox, or local Claude Code │ -│ │ │ -│ ├──▶ Customizes Astro landing page │ -│ ├──▶ Commits and pushes to Gitea │ -│ └──▶ Worker executor polls queue, dispatches to agent │ -│ │ -│ 3. CI/CD Pipeline (automatic on push) │ -│ Woodpecker triggered by git push │ -│ │ │ -│ ├──▶ npm install + npm build │ -│ ├──▶ Docker build (nginx) │ -│ ├──▶ Push to Zot registry │ -│ └──▶ kubectl set image (deploy) │ -│ │ -│ 4. Live at https://landing.threesix.ai │ -│ │ -└──────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Step-by-Step Implementation - -### Step 1: Create Project via rdev-api - -This single call creates the Gitea repo, DNS record, activates Woodpecker CI, and seeds the repo with the `astro-landing` template. +Single API call that creates infrastructure AND enqueues agent work: ```bash -curl -X POST https://rdev.masq-ops.orchard9.ai/project \ - -H "Authorization: Bearer $RDEV_KEY" \ +curl -X POST "$RDEV_API_URL/project/create-and-build" \ + -H "X-API-Key: $RDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "name": "landing", - "description": "threesix.ai landing page", - "template": "astro-landing" + "name": "my-landing", + "description": "Company landing page", + "prompt": "Build a modern landing page with: dark gradient background, centered hero section with company name and tagline, email signup form, responsive design. Use vanilla HTML/CSS/JS. Create index.html, styles.css, and a simple Dockerfile that serves with nginx.", + "auto_commit": true, + "auto_push": true }' ``` **Response:** ```json { - "project_id": "landing", - "name": "landing", - "description": "threesix.ai landing page", - "git": { - "owner": "threesix", - "name": "landing", - "clone_ssh": "git@git.threesix.ai:threesix/landing.git", - "clone_http": "https://git.threesix.ai/threesix/landing.git", - "html_url": "https://git.threesix.ai/threesix/landing" + "data": { + "project_id": "my-landing", + "name": "my-landing", + "domain": "abc123xy.threesix.ai", + "url": "https://abc123xy.threesix.ai", + "git": { + "owner": "jordan", + "name": "my-landing", + "html_url": "https://git.threesix.ai/jordan/my-landing" + }, + "task_id": "task-uuid", + "status": "pending", + "status_url": "/builds/task-uuid" }, - "domain": "landing.threesix.ai", - "url": "https://landing.threesix.ai", - "next_steps": [] + "meta": { "timestamp": "..." } } ``` -If any infrastructure step fails, `next_steps` will contain manual instructions for that step. The remaining steps still execute. +--- -### Step 2: Generate Code +## Step 2: Monitor Build Progress -The `astro-landing` template seeds the repo with a working Astro project, Dockerfile, nginx.conf, and `.woodpecker.yml`. You can customize it via Claude. +Poll the build status: -**Option A: Local Claude Code (recommended for now)** ```bash -git clone https://git.threesix.ai/threesix/landing.git -cd landing -# Use Claude Code to customize the landing page -# Then commit and push +curl -s "$RDEV_API_URL/builds/{task_id}" \ + -H "X-API-Key: $RDEV_API_KEY" | jq .data ``` -**Option B: Via existing claudebox** +**Status progression:** `pending` → `running` → `completed` (or `failed`) + +When completed: +```json +{ + "task_id": "task-uuid", + "project_id": "my-landing", + "status": "completed", + "prompt": "Build a modern landing page...", + "auto_commit": true, + "auto_push": true, + "started_at": "2025-01-29T10:00:00Z", + "completed_at": "2025-01-29T10:00:45Z", + "result": { + "success": true, + "commit_sha": "abc123", + "files_changed": ["index.html", "styles.css", "Dockerfile", "nginx.conf"], + "duration_ms": 45000 + } +} +``` + +--- + +## Step 3: Monitor CI Pipeline + +The agent's push triggers Woodpecker CI: + ```bash -curl -X POST "https://rdev.masq-ops.orchard9.ai/projects/pantheon/claude" \ - -H "Authorization: Bearer $RDEV_KEY" \ +curl -s "$RDEV_API_URL/projects/my-landing/pipelines" \ + -H "X-API-Key: $RDEV_API_KEY" | jq '.data[0]' +``` + +Wait for `status: "success"`. + +--- + +## Step 4: Verify Deployment + +```bash +# Check site is live +curl -I https://abc123xy.threesix.ai + +# View the site +open https://abc123xy.threesix.ai +``` + +--- + +## Alternative: Two-Step Flow + +If you prefer to create the project first, then submit builds separately: + +### Create Project (empty repo) +```bash +curl -X POST "$RDEV_API_URL/project" \ + -H "X-API-Key: $RDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "prompt": "Clone https://git.threesix.ai/threesix/landing.git to /tmp/landing, then customize the Astro landing page with: Coming Soon message, threesix.ai branding (dark theme, gradient background), responsive layout. Commit and push when done." + "name": "my-landing", + "description": "Company landing page" }' ``` -**Option C: Via work queue (build endpoint)** +### Submit Build Task ```bash -# Enqueue a build task for a worker to execute -curl -X POST "https://rdev.masq-ops.orchard9.ai/projects/landing/builds" \ - -H "Authorization: Bearer $RDEV_KEY" \ +curl -X POST "$RDEV_API_URL/projects/my-landing/builds" \ + -H "X-API-Key: $RDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "prompt": "Customize the landing page with Coming Soon message and threesix.ai branding", + "prompt": "Build a modern landing page with dark theme, hero section, and email signup form. Use HTML/CSS/JS with nginx Dockerfile.", "auto_commit": true, "auto_push": true }' ``` -**Option D: Create + build in one call** +--- + +## Iterating on the Site + +Submit additional builds to modify the site: + ```bash -curl -X POST "https://rdev.masq-ops.orchard9.ai/project/create-and-build" \ - -H "Authorization: Bearer $RDEV_KEY" \ +curl -X POST "$RDEV_API_URL/projects/my-landing/builds" \ + -H "X-API-Key: $RDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "name": "landing", - "description": "threesix.ai landing page", - "template": "astro-landing", - "build": { - "prompt": "Customize with Coming Soon message and threesix.ai branding", - "auto_commit": true, - "auto_push": true - } + "prompt": "Add a pricing section with three tiers: Free, Pro ($29/mo), Enterprise (contact us). Match the existing dark theme.", + "auto_commit": true, + "auto_push": true }' ``` -### Step 3: Monitor Build +Each build: +1. Claude clones the repo +2. Makes the requested changes +3. Commits and pushes +4. CI deploys automatically -The git push triggers Woodpecker CI automatically. +--- -**Via rdev-api (recommended):** -```bash -# List recent pipelines -curl -s "https://rdev.masq-ops.orchard9.ai/projects/landing/pipelines" \ - -H "Authorization: Bearer $RDEV_KEY" | jq '.data.pipelines' - -# Get specific pipeline -curl -s "https://rdev.masq-ops.orchard9.ai/projects/landing/pipelines/1" \ - -H "Authorization: Bearer $RDEV_KEY" | jq '.data' -``` - -**Via Woodpecker UI:** -- https://ci.threesix.ai/threesix/landing - -### Step 4: Verify Deployment +## Adding Custom Domains ```bash -# Check project status via rdev-api -curl -s "https://rdev.masq-ops.orchard9.ai/project/landing" \ - -H "Authorization: Bearer $RDEV_KEY" | jq '.data.deployment' - -# Check deployment via K8s -export KUBECONFIG=~/.kube/orchard9-k3sf.yaml -kubectl get deploy -n projects landing - -# Check the site -curl -I https://landing.threesix.ai -``` - -### Step 5: Configure DNS Aliases (Optional) - -Point `www.threesix.ai` and `threesix.ai` to the landing page via the rdev-api domain alias endpoints. - -```bash -# Add www.threesix.ai as a CNAME alias -curl -X POST "https://rdev.masq-ops.orchard9.ai/projects/landing/domains" \ - -H "Authorization: Bearer $RDEV_KEY" \ +# Add www subdomain +curl -X POST "$RDEV_API_URL/projects/my-landing/domains" \ + -H "X-API-Key: $RDEV_API_KEY" \ -H "Content-Type: application/json" \ - -d '{"domain": "www.threesix.ai", "type": "CNAME"}' + -d '{"domain": "www.mycompany.com"}' -# Add root A record -curl -X POST "https://rdev.masq-ops.orchard9.ai/projects/landing/domains" \ - -H "Authorization: Bearer $RDEV_KEY" \ - -H "Content-Type: application/json" \ - -d '{"domain": "threesix.ai"}' - -# List all domains for the project -curl -s "https://rdev.masq-ops.orchard9.ai/projects/landing/domains" \ - -H "Authorization: Bearer $RDEV_KEY" | jq '.data.domains' - -# Remove an alias -curl -X DELETE "https://rdev.masq-ops.orchard9.ai/projects/landing/domains/www.threesix.ai" \ - -H "Authorization: Bearer $RDEV_KEY" +# List all domains +curl -s "$RDEV_API_URL/projects/my-landing/domains" \ + -H "X-API-Key: $RDEV_API_KEY" | jq '.data.domains' ``` --- -## Template: astro-landing - -The `astro-landing` template (`deployments/k8s/base/templates/astro-landing/`) seeds the repo with: - -| File | Purpose | -|------|---------| -| `.woodpecker.yml` | CI pipeline: npm build, Docker build, push, deploy | -| `.claude/CLAUDE.md` | Project instructions for Claude Code | -| `Dockerfile` | Multi-stage build (Node 20 build, nginx serve) | -| `nginx.conf` | Production config with gzip, caching, SPA fallback | -| `package.json` | Astro 4.0+ with Tailwind CSS | -| `astro.config.mjs` | Astro configuration | -| `tailwind.config.mjs` | Tailwind configuration | -| `src/pages/index.astro` | Landing page (dark theme with gradient) | -| `src/layouts/Layout.astro` | Base HTML layout | -| `README.md` | Development and deployment docs | - -Variables substituted during seeding: `{{PROJECT_NAME}}`, `{{DOMAIN}}`, `{{GIT_URL}}` - ---- - -## Implementation Status - -All components for the full landing page pipeline are implemented: - -| Component | Location | Status | -|-----------|----------|--------| -| Work queue (enqueue/dequeue) | `internal/adapter/postgres/work_queue.go` | Implemented | -| Worker registry | `internal/adapter/postgres/worker_registry.go` | Implemented | -| Build audit tracking | `internal/adapter/postgres/build_audit.go` | Implemented | -| Build service | `internal/service/build_service.go` | Implemented | -| Worker service | `internal/service/worker_service.go` | Implemented | -| Work handlers (REST) | `internal/handlers/work.go` | Implemented | -| Code agent interface | `internal/port/code_agent.go` | Implemented | -| Worker executor daemon | `internal/worker/work_executor.go` | Implemented | -| BuildSpec-to-agent bridge | `internal/worker/build_executor.go` | Implemented | -| Git credential resolution | `internal/service/credential_service.go` | Implemented | -| DNS alias endpoints | `internal/handlers/infrastructure_domains.go` | Implemented | -| CI pipeline proxy | `internal/handlers/infrastructure_pipelines.go` | Implemented | -| Create-and-build endpoint | `internal/handlers/create_and_build.go` | Implemented | -| Multi-domain support | `internal/adapter/postgres/project_domain.go` | Implemented | -| Auto-generated slugs | `internal/domain/project_domain.go` | Implemented | -| Site health check | `internal/service/project_infra.go` | Implemented | - -**Ready for E2E testing:** Run `./cookbooks/scripts/landing-test.sh run` to test the full flow. - ---- - -## Verification - -After deployment, verify: +## Teardown ```bash -# Check DNS -dig landing.threesix.ai +curl -X DELETE "$RDEV_API_URL/project/my-landing" \ + -H "X-API-Key: $RDEV_API_KEY" +``` -# Check site -curl -I https://landing.threesix.ai +Removes: DNS records, K8s deployment, project metadata. Gitea repo preserved for safety. -# Check deployment status -curl https://rdev.masq-ops.orchard9.ai/project/landing \ - -H "Authorization: Bearer $RDEV_KEY" +--- + +## E2E Test Script + +Run the full flow: +```bash +./cookbooks/scripts/landing-test.sh run my-test-landing +``` + +Check status: +```bash +./cookbooks/scripts/landing-test.sh status my-test-landing +``` + +Cleanup: +```bash +./cookbooks/scripts/landing-test.sh teardown my-test-landing ``` --- -## Rollback +## Architecture -To remove the landing page: +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Agent-Driven Landing Page │ +│ │ +│ POST /project/create-and-build │ +│ │ │ +│ ├──► Gitea: creates repo │ +│ ├──► Cloudflare: creates DNS │ +│ ├──► Woodpecker: activates CI │ +│ ├──► K8s: creates Deployment/Service/Ingress │ +│ └──► Work Queue: enqueues build task │ +│ │ │ +│ ▼ │ +│ Worker polls queue, claims task │ +│ │ │ +│ ▼ │ +│ Claude Code executes in claudebox-0: │ +│ - Clones repo │ +│ - Builds site from prompt │ +│ - Commits and pushes │ +│ │ │ +│ ▼ │ +│ Woodpecker CI triggered by push: │ +│ - Builds Docker image │ +│ - Pushes to registry │ +│ - Updates K8s deployment │ +│ │ │ +│ ▼ │ +│ Site live at https://{slug}.threesix.ai │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` +--- + +## Troubleshooting + +### Build stuck in pending ```bash -# Delete via rdev-api (removes DNS, K8s deployment; Gitea repo preserved for safety) -curl -X DELETE https://rdev.masq-ops.orchard9.ai/project/landing \ - -H "Authorization: Bearer $RDEV_KEY" +# Check worker status +curl -s "$RDEV_API_URL/workers" -H "X-API-Key: $RDEV_API_KEY" | jq '.data.summary' + +# Should show at least 1 idle worker +``` + +### Build failed +```bash +# Get build details +curl -s "$RDEV_API_URL/builds/{task_id}" -H "X-API-Key: $RDEV_API_KEY" | jq '.result' + +# Check rdev-api logs +./scripts/logs.sh -e +``` + +### Pipeline not triggering +```bash +# Check if commit was pushed +curl -s "https://git.threesix.ai/api/v1/repos/jordan/my-landing/commits" | jq '.[0]' + +# Check Woodpecker +open https://ci.threesix.ai/jordan/my-landing ``` --- ## Related -- [Build Orchestration](../ai-lookup/features/build-orchestration.md) - Build system documentation -- [Worker Pool](../ai-lookup/services/worker-pool.md) - Worker pool management -- [Work Queue](../.claude/guides/services/work-queue.md) - Work queue guide -- [Templates](../.claude/guides/services/templates.md) - Project template guide +- [Full-Stack App Cookbook](./fullstack-app.md) - Next.js + Go backend +- [Worker Pool Guide](../.claude/guides/services/worker-pool.md) +- [Build Orchestration](../.claude/guides/services/build-orchestration.md) diff --git a/cookbooks/scripts/landing-test.sh b/cookbooks/scripts/landing-test.sh index d4cda13..7a85388 100755 --- a/cookbooks/scripts/landing-test.sh +++ b/cookbooks/scripts/landing-test.sh @@ -1,19 +1,17 @@ #!/bin/bash # Landing Page Cookbook Test Script -# Tests the full landing page flow from cookbooks/landing-page.md +# Tests the full agent-driven landing page flow from cookbooks/landing-page.md # # Usage: -# ./cookbooks/scripts/landing-test.sh run # Run the full flow -# ./cookbooks/scripts/landing-test.sh teardown # Clean up test resources -# ./cookbooks/scripts/landing-test.sh status # Check current status +# ./cookbooks/scripts/landing-test.sh run [name] # Run the full flow +# ./cookbooks/scripts/landing-test.sh teardown [name] # Clean up test resources +# ./cookbooks/scripts/landing-test.sh status [name] # Check current status set -euo pipefail # Configuration API_URL="${RDEV_API_URL:-https://rdev.masq-ops.orchard9.ai}" API_KEY="${RDEV_API_KEY:?RDEV_API_KEY environment variable required}" -PROJECT_NAME="${1:-landing-test}" -TEMPLATE="astro-landing" # Colors RED='\033[0;31m' @@ -27,10 +25,12 @@ log_success() { echo -e "${GREEN}[OK]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } -# Configuration for polling -PIPELINE_TIMEOUT=300 # 5 minutes max wait for pipeline -PIPELINE_POLL_INTERVAL=10 # Check every 10 seconds -SITE_TIMEOUT=60 # 1 minute max wait for site to be live +# Timeouts +BUILD_TIMEOUT=180 # 3 minutes for Claude to build the site +BUILD_POLL_INTERVAL=5 # Check every 5 seconds +PIPELINE_TIMEOUT=300 # 5 minutes max wait for CI pipeline +PIPELINE_POLL_INTERVAL=10 +SITE_TIMEOUT=60 # 1 minute max wait for site to be live api_call() { local method="$1" @@ -62,6 +62,61 @@ check_health() { fi } +# Wait for build to complete (Claude building the site) +# Returns: 0 on success, 1 on failure/timeout +wait_for_build() { + local task_id="$1" + local start_time=$(date +%s) + + log_info "Waiting for Claude to build the site (timeout: ${BUILD_TIMEOUT}s)..." + + while true; do + local elapsed=$(($(date +%s) - start_time)) + if [[ $elapsed -ge $BUILD_TIMEOUT ]]; then + log_error "Build timeout after ${BUILD_TIMEOUT}s" + return 1 + fi + + local response + response=$(api_call GET "/builds/$task_id" 2>/dev/null || echo "{}") + + local status + status=$(echo "$response" | jq -r '.data.status // "unknown"' 2>/dev/null) + + case "$status" in + completed) + local success + success=$(echo "$response" | jq -r '.data.result.success // false') + if [[ "$success" == "true" ]]; then + log_success "Build completed successfully (${elapsed}s)" + echo "$response" | jq '.data.result | {success, commit_sha, files_changed, duration_ms}' + return 0 + else + log_error "Build completed but failed" + echo "$response" | jq '.data.result' + return 1 + fi + ;; + failed) + log_error "Build failed" + echo "$response" | jq '.data.result // .data' + return 1 + ;; + running) + echo -ne "\r${BLUE}[INFO]${NC} Build status: running (${elapsed}s)... " + ;; + pending) + echo -ne "\r${BLUE}[INFO]${NC} Build status: pending (${elapsed}s)... " + ;; + *) + echo -ne "\r${BLUE}[INFO]${NC} Build status: $status (${elapsed}s)... " + ;; + esac + + sleep $BUILD_POLL_INTERVAL + done +} + # Wait for pipeline to appear and complete # Returns: 0 on success, 1 on failure/timeout wait_for_pipeline() { @@ -83,7 +138,7 @@ wait_for_pipeline() { local response response=$(api_call GET "/projects/$project_name/pipelines" 2>/dev/null || echo "{}") - # Check if we have pipelines (API returns array at .data, not .data.pipelines) + # Check if we have pipelines (API returns array at .data) local pipeline_count pipeline_count=$(echo "$response" | jq -r '.data | length' 2>/dev/null || echo "0") @@ -177,7 +232,7 @@ test_dns_alias() { fi log_success "DNS alias added: $alias_domain" - echo "$response" | jq '.data | {domain, type, dns_record_id}' + echo "$response" | jq '.data | {domain, type, record_type}' return 0 } @@ -208,15 +263,15 @@ remove_dns_alias() { run_flow() { local project_name="${1:-landing-test}" - local custom_subdomain="${2:-}" + + # Default prompt for building a landing page + local build_prompt="Build a modern landing page with: dark gradient background (#1a1a2e to #16213e), centered hero section with company name 'Acme Corp' and tagline 'Building the future', email signup form with a submit button, responsive design for mobile. Use vanilla HTML/CSS/JS. Create index.html, styles.css, and a Dockerfile that serves with nginx on port 80." echo "" echo "==========================================" echo " Landing Page Cookbook Test" echo " Project: $project_name" - if [[ -n "$custom_subdomain" ]]; then - echo " Custom subdomain: $custom_subdomain" - fi + echo " Flow: Agent-driven (create-and-build)" echo "==========================================" echo "" @@ -224,21 +279,25 @@ run_flow() { check_health || exit 1 echo "" - # Step 1: Create project - log_info "Step 1: Creating project with $TEMPLATE template..." - local create_payload="{ - \"name\": \"$project_name\", - \"description\": \"Cookbook test: landing page flow\", - \"template\": \"$TEMPLATE\"" - if [[ -n "$custom_subdomain" ]]; then - create_payload="$create_payload, - \"custom_subdomain\": \"$custom_subdomain\"" - fi - create_payload="$create_payload - }" + # Step 1: Create project AND enqueue build in one call + log_info "Step 1: Creating project and enqueuing build task..." + log_info "Prompt: ${build_prompt:0:80}..." + + local create_payload + create_payload=$(jq -n \ + --arg name "$project_name" \ + --arg desc "Cookbook test: agent-driven landing page" \ + --arg prompt "$build_prompt" \ + '{ + name: $name, + description: $desc, + prompt: $prompt, + auto_commit: true, + auto_push: true + }') local create_response - create_response=$(api_call POST "/project" "$create_payload") + create_response=$(api_call POST "/project/create-and-build" "$create_payload") if echo "$create_response" | jq -e '.error' > /dev/null 2>&1; then log_error "Failed to create project" @@ -246,104 +305,62 @@ run_flow() { exit 1 fi - log_success "Project created" - echo "$create_response" | jq '{ - project_id: .data.project_id, - slug: .data.slug, - git_url: .data.git.html_url, - domain: .data.domain, - url: .data.url, - domains: .data.domains, - next_steps: .data.next_steps + log_success "Project created and build enqueued" + echo "$create_response" | jq '.data | { + project_id, + domain, + url, + git: .git.html_url, + task_id, + status, + status_url }' - # Extract domain info + # Extract key info local primary_domain - local slug + local task_id primary_domain=$(echo "$create_response" | jq -r '.data.domain') - slug=$(echo "$create_response" | jq -r '.data.slug // empty') + task_id=$(echo "$create_response" | jq -r '.data.task_id') - if [[ -n "$slug" ]]; then - log_success "Auto-generated slug: $slug" + if [[ -z "$task_id" || "$task_id" == "null" ]]; then + log_error "No task_id returned - build was not enqueued" + exit 1 fi - # Check for manual steps - local next_steps - next_steps=$(echo "$create_response" | jq -r '.data.next_steps[]?' 2>/dev/null || echo "") - if [[ -n "$next_steps" ]]; then - echo "" - log_warn "Some steps require manual intervention:" - echo "$create_response" | jq -r '.data.next_steps[]' - echo "" - log_info "Check API logs for details: ./scripts/logs.sh -e" - fi + log_success "Build task ID: $task_id" echo "" - # Step 2: List all domains - log_info "Step 2: Listing all project domains..." - local domains_response - domains_response=$(api_call GET "/projects/$project_name/domains") - - if echo "$domains_response" | jq -e '.error' > /dev/null 2>&1; then - log_warn "Could not list domains" - echo "$domains_response" | jq . + # Step 2: Monitor build progress (Claude building the site) + log_info "Step 2: Monitoring build progress..." + echo "" + local build_success=false + if wait_for_build "$task_id"; then + build_success=true else - local domain_count - domain_count=$(echo "$domains_response" | jq -r '.data.total') - log_success "Found $domain_count domain(s)" - echo "$domains_response" | jq '.data.domains[] | {domain, type, verified}' + log_error "Build did not complete successfully" + log_info "Check build details: curl -s \"\$RDEV_API_URL/builds/$task_id\" -H \"X-API-Key: \$RDEV_API_KEY\" | jq ." fi echo "" - # Step 3: Verify project status - log_info "Step 3: Verifying project status..." - sleep 2 # Give DNS/Gitea a moment - - local status_response - status_response=$(api_call GET "/project/$project_name") - - if echo "$status_response" | jq -e '.error' > /dev/null 2>&1; then - log_warn "Could not fetch project status" - echo "$status_response" | jq . - else - log_success "Project status retrieved" - echo "$status_response" | jq '{ - name: .data.name, - slug: .data.slug, - domain: .data.domain, - url: .data.url, - domains: .data.domains, - git: .data.git.html_url, - deployment: .data.deployment - }' - fi - echo "" - - # Step 4: Check DNS - log_info "Step 4: Checking DNS resolution..." - if host "$primary_domain" > /dev/null 2>&1; then - log_success "DNS resolves: $primary_domain" - host "$primary_domain" | head -1 - else - log_warn "DNS not yet resolving: $primary_domain (may take a few minutes)" - fi - echo "" - - # Step 5: Wait for CI pipeline - log_info "Step 5: Monitoring CI pipeline..." + # Step 3: Monitor CI pipeline (only if build succeeded) local pipeline_success=false - if wait_for_pipeline "$project_name"; then - pipeline_success=true + if [[ "$build_success" == "true" ]]; then + log_info "Step 3: Monitoring CI pipeline..." + if wait_for_pipeline "$project_name"; then + pipeline_success=true + else + log_warn "Pipeline did not complete successfully" + log_info "Check Woodpecker: https://ci.threesix.ai/threesix/$project_name" + fi else - log_warn "Pipeline did not complete successfully - site may not deploy" - log_info "Check Woodpecker: https://ci.threesix.ai/threesix/$project_name" + log_info "Step 3: Skipping pipeline monitoring (build failed)" fi echo "" - # Step 6: Verify site is live (only if pipeline succeeded) + # Step 4: Verify site is live local site_live=false if [[ "$pipeline_success" == "true" ]]; then - log_info "Step 6: Verifying site is accessible..." + log_info "Step 4: Verifying site is accessible..." if wait_for_site "$primary_domain"; then site_live=true # Show a snippet of the response @@ -351,28 +368,29 @@ run_flow() { curl -s "https://$primary_domain" | head -20 | grep -E '|<h1' || true fi else - log_info "Step 6: Skipping site verification (pipeline not successful)" + log_info "Step 4: Skipping site verification (pipeline not successful)" fi echo "" - # Step 7: Test DNS alias functionality + # Step 5: Test adding custom domains local test_alias="${project_name}-alias.threesix.ai" - log_info "Step 7: Testing DNS alias functionality..." + log_info "Step 5: Testing custom domain functionality..." if test_dns_alias "$project_name" "$test_alias"; then - log_success "DNS alias test passed" - # Clean up test alias immediately + log_success "Domain alias test passed" + # Clean up test alias sleep 2 remove_dns_alias "$project_name" "$test_alias" else - log_warn "DNS alias test failed - check Cloudflare permissions" + log_warn "Domain alias test failed - check Cloudflare permissions" fi echo "" - # Step 8: Final domain listing - log_info "Step 8: Final domain listing..." + # List all domains + log_info "Listing all project domains..." + local domains_response domains_response=$(api_call GET "/projects/$project_name/domains") if echo "$domains_response" | jq -e '.data.domains' > /dev/null 2>&1; then - echo "$domains_response" | jq '.data.domains[] | {domain, type, is_primary}' + echo "$domains_response" | jq '.data.domains[] | {domain, type, verified}' fi echo "" @@ -382,30 +400,35 @@ run_flow() { echo "==========================================" echo "" echo " Project: $project_name" - echo " Git repo: $(echo "$create_response" | jq -r '.data.git.html_url')" - if [[ -n "$slug" ]]; then - echo " Slug: $slug" - fi + echo " Task ID: $task_id" + echo " Git repo: $(echo "$create_response" | jq -r '.data.git.html_url // "N/A"')" echo " Primary: https://$primary_domain" echo "" echo " Test Results:" echo -e " Project created: ${GREEN}PASS${NC}" + if [[ "$build_success" == "true" ]]; then + echo -e " Agent build: ${GREEN}PASS${NC}" + else + echo -e " Agent build: ${RED}FAIL${NC}" + fi if [[ "$pipeline_success" == "true" ]]; then echo -e " CI Pipeline: ${GREEN}PASS${NC}" - else + elif [[ "$build_success" == "true" ]]; then echo -e " CI Pipeline: ${RED}FAIL${NC}" + else + echo -e " CI Pipeline: ${YELLOW}SKIPPED${NC}" fi if [[ "$site_live" == "true" ]]; then echo -e " Site accessible: ${GREEN}PASS${NC}" - else + elif [[ "$pipeline_success" == "true" ]]; then echo -e " Site accessible: ${YELLOW}PENDING${NC}" + else + echo -e " Site accessible: ${YELLOW}SKIPPED${NC}" fi - echo -e " DNS alias: ${GREEN}TESTED${NC}" - echo "" - echo " All domains:" - echo "$domains_response" | jq -r '.data.domains[]? | " - \(.domain) (\(.type))"' 2>/dev/null || echo " (none listed)" + echo -e " Custom domains: ${GREEN}TESTED${NC}" echo "" echo " Useful commands:" + echo " Build status: curl -s \"\$RDEV_API_URL/builds/$task_id\" -H \"X-API-Key: \$RDEV_API_KEY\" | jq .data" echo " Check status: ./cookbooks/scripts/landing-test.sh status $project_name" echo " View logs: ./scripts/logs.sh -e" echo " Woodpecker: https://ci.threesix.ai/threesix/$project_name" @@ -413,14 +436,17 @@ run_flow() { echo "" # Return appropriate exit code - if [[ "$pipeline_success" == "true" && "$site_live" == "true" ]]; then + if [[ "$build_success" == "true" && "$pipeline_success" == "true" && "$site_live" == "true" ]]; then log_success "Full E2E test PASSED" return 0 - elif [[ "$pipeline_success" == "true" ]]; then - log_warn "Partial success - pipeline passed but site not yet live" + elif [[ "$build_success" == "true" && "$pipeline_success" == "true" ]]; then + log_warn "Partial success - build and pipeline passed but site not yet live" return 0 + elif [[ "$build_success" == "true" ]]; then + log_warn "Partial success - build passed but pipeline failed" + return 1 else - log_error "E2E test FAILED - pipeline did not complete" + log_error "E2E test FAILED - build did not complete" return 1 fi } @@ -453,7 +479,7 @@ teardown() { if echo "$response" | jq -e '.data.status == "deleted"' > /dev/null 2>&1; then log_success "Project deleted" - echo "$response" | jq . + echo "$response" | jq .data elif echo "$response" | jq -e '.error.code == "NOT_FOUND"' > /dev/null 2>&1; then log_warn "Project not found (already deleted?)" else @@ -464,7 +490,7 @@ teardown() { echo "" log_info "Note: Gitea repo is preserved for safety. Delete manually if needed:" - echo " https://git.threesix.ai/jordan/$project_name/settings" + echo " https://git.threesix.ai/threesix/$project_name/settings" echo "" } @@ -475,6 +501,7 @@ status() { log_info "Fetching status for: $project_name" echo "" + # Get project info local response response=$(api_call GET "/project/$project_name") @@ -484,15 +511,13 @@ status() { exit 1 fi - echo "$response" | jq '{ - name: .data.name, - description: .data.description, - slug: .data.slug, - domain: .data.domain, - url: .data.url, - domains: .data.domains, - git: .data.git, - deployment: .data.deployment + echo "$response" | jq '.data | { + name, + description, + domain, + url, + git: .git.html_url, + deployment }' echo "" @@ -503,13 +528,35 @@ status() { if echo "$domains_response" | jq -e '.data.domains' > /dev/null 2>&1; then echo "$domains_response" | jq '.data.domains' fi + + echo "" + log_info "Checking recent builds..." + local builds_response + builds_response=$(api_call GET "/projects/$project_name/builds?limit=3") + + if echo "$builds_response" | jq -e '.data.builds' > /dev/null 2>&1; then + echo "$builds_response" | jq '.data.builds[] | {task_id, status, started_at, result: .result.success}' + else + log_info "No builds found" + fi + + echo "" + log_info "Checking recent pipelines..." + local pipelines_response + pipelines_response=$(api_call GET "/projects/$project_name/pipelines") + + if echo "$pipelines_response" | jq -e '.data | length > 0' > /dev/null 2>&1; then + echo "$pipelines_response" | jq '.data[0:3][] | {number, status, branch, commit: .commit[0:8]}' + else + log_info "No pipelines found" + fi } # Main case "${1:-}" in run) shift - run_flow "${1:-landing-test}" "${2:-}" + run_flow "${1:-landing-test}" ;; teardown) shift @@ -520,35 +567,33 @@ case "${1:-}" in status "${1:-landing-test}" ;; *) - echo "Usage: $0 {run|teardown|status} [project-name] [custom-subdomain]" + echo "Usage: $0 {run|teardown|status} [project-name]" echo "" echo "Commands:" - echo " run [name] [subdomain] Create project and run full flow" - echo " teardown [name] Delete project and clean up" - echo " status [name] Check current project status" + echo " run [name] Create project with agent-driven build and run full E2E flow" + echo " teardown [name] Delete project and clean up" + echo " status [name] Check current project status, builds, and pipelines" echo "" - echo "Full E2E flow tested:" - echo " 1. Project creation with template (POST /project)" - echo " 2. Gitea repo + DNS + Woodpecker CI activation" - echo " 3. Template seeding (astro-landing)" - echo " 4. CI pipeline monitoring (GET /projects/{id}/pipelines)" - echo " 5. Site deployment verification (HTTP 200 check)" - echo " 6. DNS alias add/remove (POST/DELETE /projects/{id}/domains)" - echo " 7. Multi-domain listing" + echo "E2E Flow (matches cookbooks/landing-page.md):" + echo " 1. POST /project/create-and-build - Create project + enqueue agent build" + echo " 2. GET /builds/{task_id} - Monitor Claude building the site" + echo " 3. GET /projects/{id}/pipelines - Monitor CI pipeline" + echo " 4. Verify site is live (HTTP 200)" + echo " 5. Test custom domains (POST/DELETE /projects/{id}/domains)" + echo " 6. DELETE /project/{name} - Teardown" echo "" echo "Timeouts:" - echo " Pipeline: ${PIPELINE_TIMEOUT:-300}s, Site: ${SITE_TIMEOUT:-60}s" + echo " Build: ${BUILD_TIMEOUT}s, Pipeline: ${PIPELINE_TIMEOUT}s, Site: ${SITE_TIMEOUT}s" echo "" echo "Environment:" echo " RDEV_API_URL API endpoint (default: https://rdev.masq-ops.orchard9.ai)" echo " RDEV_API_KEY API key (required)" echo "" echo "Examples:" - echo " $0 run # Test with auto-slug only" - echo " $0 run my-landing # Test with custom project name" - echo " $0 run my-landing my-site # Also create my-site.threesix.ai" - echo " $0 status my-landing # Check status and domains" - echo " $0 teardown my-landing # Clean up (deletes all domains)" + echo " $0 run # Run with default project name 'landing-test'" + echo " $0 run my-landing # Run with custom project name" + echo " $0 status my-landing # Check status, builds, and pipelines" + echo " $0 teardown my-landing # Clean up project" exit 1 ;; esac diff --git a/internal/handlers/projects_commands.go b/internal/handlers/projects_commands.go index 4b38f5f..8178d6b 100644 --- a/internal/handlers/projects_commands.go +++ b/internal/handlers/projects_commands.go @@ -19,8 +19,11 @@ import ( // ClaudeRequest is the request body for POST /projects/{id}/claude. type ClaudeRequest struct { - Prompt string `json:"prompt"` - StreamID string `json:"stream_id,omitempty"` + Prompt string `json:"prompt"` + StreamID string `json:"stream_id,omitempty"` + SessionID string `json:"session_id,omitempty"` // Resume a previous session + Model string `json:"model,omitempty"` // Model override (OpenCode only) + AllowedTools []string `json:"allowed_tools,omitempty"` // Restrict tool access } // RunClaude executes a Claude command in the project's claudebox. @@ -37,10 +40,13 @@ func (h *ProjectsHandler) RunClaude(w http.ResponseWriter, r *http.Request) { // Use new service if available if h.projectService != nil { result, err := h.projectService.ExecuteClaude(r.Context(), service.ExecuteClaudeRequest{ - ProjectID: domain.ProjectID(id), - Prompt: req.Prompt, - StreamID: req.StreamID, - Audit: getAuditContext(r), + ProjectID: domain.ProjectID(id), + Prompt: req.Prompt, + StreamID: req.StreamID, + SessionID: req.SessionID, + Model: req.Model, + AllowedTools: req.AllowedTools, + Audit: getAuditContext(r), }) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { diff --git a/internal/worker/build_executor.go b/internal/worker/build_executor.go index a0c0178..932b042 100644 --- a/internal/worker/build_executor.go +++ b/internal/worker/build_executor.go @@ -15,9 +15,17 @@ import ( // It translates BuildSpec fields from the work task's Spec map into an // AgentRequest, executes via a CodeAgent, and returns a BuildResult. type BuildExecutor struct { - agentRegistry port.CodeAgentRegistry - gitOps *GitOperations - logger *slog.Logger + agentRegistry port.CodeAgentRegistry + gitOps *GitOperations + logger *slog.Logger + defaultPodName string // Default claudebox pod for agent execution + namespace string // Kubernetes namespace for the pod +} + +// BuildExecutorConfig holds configuration for the build executor. +type BuildExecutorConfig struct { + DefaultPodName string // Default pod to execute Claude Code in (e.g., "claudebox-0") + Namespace string // Kubernetes namespace (e.g., "rdev") } // NewBuildExecutor creates a new build executor. @@ -25,14 +33,23 @@ func NewBuildExecutor( agentRegistry port.CodeAgentRegistry, gitOps *GitOperations, logger *slog.Logger, + cfg *BuildExecutorConfig, ) *BuildExecutor { if logger == nil { logger = slog.Default() } + if cfg == nil { + cfg = &BuildExecutorConfig{ + DefaultPodName: "claudebox-0", + Namespace: "rdev", + } + } return &BuildExecutor{ - agentRegistry: agentRegistry, - gitOps: gitOps, - logger: logger.With("component", "build-executor"), + agentRegistry: agentRegistry, + gitOps: gitOps, + logger: logger.With("component", "build-executor"), + defaultPodName: cfg.DefaultPodName, + namespace: cfg.Namespace, } } @@ -77,12 +94,22 @@ func (b *BuildExecutor) Execute(ctx context.Context, task *domain.WorkTask) *dom } } - // Build the agent request + // Determine which pod to execute in (from task spec or default) + podName, _ := task.Spec["pod_name"].(string) + if podName == "" { + podName = b.defaultPodName + } + + // Build the agent request with pod metadata for Claude Code adapter agentReq := &domain.AgentRequest{ Prompt: spec.Prompt, ProjectID: domain.ProjectID(task.ProjectID), WorkingDir: workDir, Timeout: 10 * time.Minute, + Metadata: map[string]string{ + "pod_name": podName, + "namespace": b.namespace, + }, } // Collect output with a size cap to prevent OOM on verbose builds. diff --git a/internal/worker/work_executor_test.go b/internal/worker/work_executor_test.go index 37b3a09..fad6ff3 100644 --- a/internal/worker/work_executor_test.go +++ b/internal/worker/work_executor_test.go @@ -200,7 +200,7 @@ func TestBuildExecutor_Execute(t *testing.T) { result: &domain.AgentResult{ExitCode: 0, DurationMs: 500}, } registry := &mockCodeAgentRegistry{agent: agent} - exec := NewBuildExecutor(registry, nil, nil) + exec := NewBuildExecutor(registry, nil, nil, nil) task := &domain.WorkTask{ ID: "task-1", @@ -220,7 +220,7 @@ func TestBuildExecutor_Execute(t *testing.T) { t.Run("missing prompt", func(t *testing.T) { registry := &mockCodeAgentRegistry{agent: &mockCodeAgent{}} - exec := NewBuildExecutor(registry, nil, nil) + exec := NewBuildExecutor(registry, nil, nil, nil) task := &domain.WorkTask{ ID: "task-1", @@ -236,7 +236,7 @@ func TestBuildExecutor_Execute(t *testing.T) { t.Run("no agent available", func(t *testing.T) { registry := &mockCodeAgentRegistry{agent: nil} - exec := NewBuildExecutor(registry, nil, nil) + exec := NewBuildExecutor(registry, nil, nil, nil) task := &domain.WorkTask{ ID: "task-1", @@ -253,7 +253,7 @@ func TestBuildExecutor_Execute(t *testing.T) { t.Run("agent execution error", func(t *testing.T) { agent := &mockCodeAgent{err: fmt.Errorf("connection refused")} registry := &mockCodeAgentRegistry{agent: agent} - exec := NewBuildExecutor(registry, nil, nil) + exec := NewBuildExecutor(registry, nil, nil, nil) task := &domain.WorkTask{ ID: "task-1", @@ -275,7 +275,7 @@ func TestBuildExecutor_Execute(t *testing.T) { result: &domain.AgentResult{ExitCode: 1, DurationMs: 500}, } registry := &mockCodeAgentRegistry{agent: agent} - exec := NewBuildExecutor(registry, nil, nil) + exec := NewBuildExecutor(registry, nil, nil, nil) task := &domain.WorkTask{ ID: "task-1", @@ -291,7 +291,7 @@ func TestBuildExecutor_Execute(t *testing.T) { } func TestBuildExecutor_ParseSpec(t *testing.T) { - exec := NewBuildExecutor(nil, nil, nil) + exec := NewBuildExecutor(nil, nil, nil, nil) t.Run("valid spec", func(t *testing.T) { spec, err := exec.parseSpec(map[string]any{