diff --git a/CLAUDE.md b/CLAUDE.md index 94e4931..b19518b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,24 @@ Both use `lib/pq` driver. The `type: postgres` component API provisions **Cockro | **cert-manager / TLS certificates** | [ops/cert-manager.md](.claude/guides/ops/cert-manager.md) | | **Notify / email delivery** | [services/notify.md](.claude/guides/services/notify.md) | | **Structured logging** | `internal/logging/` - field constants, context propagation, redaction | +| **Update the AionUi SDK** | [SDK Update Workflow](#sdk-update-workflow) | + +## SDK Update Workflow + +When you add/change API endpoints in rdev, update the AionUi hand-written SDK: + +```bash +# Step 1: Regenerate openapi.json in rdev +make sdk + +# Step 2: Sync to AionUi and typecheck +cd /path/to/AionUi +RDEV_REPO=/path/to/rdev ./scripts/sync-rdev-sdk.sh +``` + +The sync script shows added/removed endpoints and fails if TypeScript breaks. You must **manually** update `src/sdk/rdev/types.ts` and `src/sdk/rdev/resources/*.ts` for new endpoints. + +For CI drift detection: add `make sdk-check` to the Woodpecker pipeline. ## Critical Rules diff --git a/Dockerfile b/Dockerfile index fd3ea36..5917fb1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,8 +33,8 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && apt-get install -y nodejs \ && rm -rf /var/lib/apt/lists/* -# Install Claude Code CLI -RUN npm install -g @anthropic-ai/claude-code +# Install Claude Code CLI and web UI +RUN npm install -g @anthropic-ai/claude-code @siteboon/claude-code-ui # Copy Go binaries from builder stage COPY --from=builder /build/sdlc /usr/local/bin/sdlc @@ -59,8 +59,8 @@ WORKDIR /workspace RUN echo '#!/bin/bash\nclaude --version > /dev/null 2>&1' > /healthcheck.sh \ && chmod +x /healthcheck.sh -# Expose sidecar HTTP port -EXPOSE 8080 +# Expose sidecar HTTP port and web UI port +EXPOSE 8080 3001 # Run claudebox-sidecar by default (HTTP server mode) CMD ["claudebox-sidecar"] diff --git a/Makefile b/Makefile index e7af747..00482bb 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # rdev-api development commands # Run 'make help' to see available targets -.PHONY: help setup run test build clean db-up db-down db-reset db-shell db-logs +.PHONY: help setup run test build sdk sdk-check clean db-up db-down db-reset db-shell db-logs # Load .env.local if it exists ifneq (,$(wildcard .env.local)) @@ -41,6 +41,16 @@ test: ## Run all tests build: ## Build the binary CGO_ENABLED=0 go build -o rdev-api ./cmd/rdev-api +sdk: ## Regenerate sdk/openapi.json from the embedded OpenAPI spec + go run ./cmd/rdev-api --export-openapi > sdk/openapi.json + @echo "sdk/openapi.json updated" + +sdk-check: ## Verify sdk/openapi.json matches the embedded spec (CI drift detection) + @go run ./cmd/rdev-api --export-openapi > /tmp/openapi-check.json + @diff sdk/openapi.json /tmp/openapi-check.json > /dev/null || \ + (echo "ERROR: sdk/openapi.json is out of sync. Run 'make sdk'." && exit 1) + @echo "sdk/openapi.json is up to date" + clean: ## Remove build artifacts rm -f rdev-api diff --git a/cookbooks/scripts/aeries-daeya-test.sh b/cookbooks/scripts/aeries-daeya-test.sh new file mode 100755 index 0000000..192f8a2 --- /dev/null +++ b/cookbooks/scripts/aeries-daeya-test.sh @@ -0,0 +1,147 @@ +#!/bin/bash +set -euo pipefail + +# Aeries Daeya E2E Test Script +# Privacy-first avatar social platform: character creation, AI styling from photos, +# mutation explorer, album management. +# +# Usage: ./cookbooks/scripts/aeries-daeya-test.sh +# Commands: run, status, diagnose, teardown +# +# Examples: +# ./cookbooks/scripts/aeries-daeya-test.sh run my-daeya +# ./cookbooks/scripts/aeries-daeya-test.sh run my-daeya --auto-teardown +# AUTO_TEARDOWN=true ./cookbooks/scripts/aeries-daeya-test.sh run my-daeya + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +# Parse --auto-teardown flag from args +ARGS=("$@") +for i in "${!ARGS[@]}"; do + if [[ "${ARGS[$i]}" == "--auto-teardown" ]]; then + AUTO_TEARDOWN="true" + unset 'ARGS[$i]' + fi +done +ARGS=("${ARGS[@]}") + +COMMAND="${ARGS[0]:-}" +PROJECT_NAME="${ARGS[1]:-}" + +register_cleanup_trap + +if [[ -z "$COMMAND" || -z "$PROJECT_NAME" ]]; then + echo "Usage: $0 " + echo "Commands:" + echo " run - Deploy full aeries-daeya platform via tree runner" + echo " status - Check project and component status" + echo " diagnose - Deep diagnostic of pipeline and site issues" + echo " teardown - Delete the project and all resources" + exit 1 +fi + +run_flow() { + print_header "Aeries Daeya: Privacy-First Avatar Platform" + + echo "Running tree: aeries-daeya for project: $PROJECT_NAME" + echo "" + echo " Phase 1 — Infrastructure (DB + Redis + service + worker + app)" + echo " Phase 2 — Avatar & Look Data Model (characters, looks, albums, posts, mutations)" + echo " Phase 3 — AI Generation Pipeline (portrait gen, outfit styling, mutation explorer)" + echo " Phase 4 — Studio UI (creation wizard, mutation explorer, look panel, albums)" + echo "" + + "$SCRIPT_DIR/tree-runner.sh" run aeries-daeya \ + --project-name "$PROJECT_NAME" \ + ${AUTO_TEARDOWN:+--auto-teardown} + + DOMAIN=$(api_call GET "/project/$PROJECT_NAME" | jq -r '.data.domain // empty') + + if [[ -n "$DOMAIN" ]]; then + print_success "Aeries Daeya is live at https://$DOMAIN" + echo "" + echo " Studio: https://$DOMAIN" + echo " API health: https://$DOMAIN/api/daeya-api/health" + echo " Characters: https://$DOMAIN/api/daeya-api/characters (requires auth)" + echo "" + echo " Flow:" + echo " 1. Open https://$DOMAIN → login with OTP" + echo " 2. Create Character → 4-step wizard (describe, shape, soul, generate)" + echo " 3. Open Character → use Mutation Explorer to adjust skin tone, background, lighting" + echo " 4. Add Look → upload a photo of an outfit to style on your character" + fi +} + +check_status() { + print_header "Project Status: $PROJECT_NAME" + + local project + project=$(api_call GET "/project/$PROJECT_NAME") + local domain + domain=$(echo "$project" | jq -r '.data.domain // "unknown"') + local status + status=$(echo "$project" | jq -r '.data.status // "unknown"') + + echo " Domain: $domain" + echo " Status: $status" + echo "" + + print_header "Components" + api_call GET "/projects/$PROJECT_NAME/components" | jq -r '.data[] | " \(.name) (\(.type)): \(.status)"' 2>/dev/null || echo " (no components)" + + echo "" + print_header "Recent Pipelines" + api_call GET "/projects/$PROJECT_NAME/pipelines" | jq -r '.data[0:3][] | " [\(.status)] \(.number) — \(.created_at)"' 2>/dev/null || echo " (no pipelines)" + + echo "" + if [[ "$domain" != "unknown" ]]; then + local health_status + health_status=$(curl -sf "https://$domain/api/daeya-api/health" 2>/dev/null | jq -r '.data.status // "unreachable"' || echo "unreachable") + echo " daeya-api health: $health_status" + fi +} + +diagnose() { + print_header "Diagnostic: $PROJECT_NAME" + + local domain + domain=$(api_call GET "/project/$PROJECT_NAME" | jq -r '.data.domain // empty') + + diagnose_pipeline_failure "$PROJECT_NAME" + + if [[ -n "$domain" ]]; then + diagnose_site_failure "$domain" "$PROJECT_NAME" + fi + + print_diagnostic_header "AI Generation Checks" + echo "" + echo " If characters are stuck in 'pending' status:" + print_fix "media-worker may not be running or Redis connection may be broken" + print_cmd "kubectl logs -n projects -l project=$PROJECT_NAME,component=media-worker --tail=50" + echo "" + echo " If mutation explorer generates but never shows result:" + print_fix "SSE channel:daeya subscription may not be connected" + print_cmd "curl -N -H 'Authorization: Bearer ' https://$domain/api/daeya-api/events/channel:daeya" + echo "" + echo " If look generation fails with photo upload:" + print_fix "GEMINI_API_KEY may not be injected or vision endpoint unreachable" + print_cmd "kubectl exec -n projects deploy/$PROJECT_NAME-media-worker -- env | grep GEMINI" +} + +teardown() { + print_header "Tearing down: $PROJECT_NAME" + + local result + result=$(api_call DELETE "/project/$PROJECT_NAME") + print_success "Project $PROJECT_NAME deleted" + echo "$result" | jq -r '.message // empty' 2>/dev/null || true +} + +case "$COMMAND" in + run) run_flow ;; + status) check_status ;; + diagnose) diagnose ;; + teardown) teardown ;; + *) echo "Unknown command: $COMMAND"; exit 1 ;; +esac diff --git a/cookbooks/scripts/common.sh b/cookbooks/scripts/common.sh index ee0c47f..9794382 100755 --- a/cookbooks/scripts/common.sh +++ b/cookbooks/scripts/common.sh @@ -163,13 +163,24 @@ wait_for_pipeline() { echo -e "${CYAN}Waiting for new CI pipeline...${NC}" # Record the current latest pipeline number BEFORE waiting - # so we only track pipelines triggered AFTER this point + # so we only track pipelines triggered AFTER this point. + # Race condition guard: if the triggering step pushed fast enough that its pipeline + # already appears as the latest, track that pipeline directly instead of waiting for + # a newer one that will never come. local baseline_number=0 - local initial_result + local initial_result initial_status initial_result=$(api_call GET "/projects/$project_id/pipelines" 2>/dev/null) if echo "$initial_result" | jq -e '.data[0]' >/dev/null 2>&1; then baseline_number=$(echo "$initial_result" | jq -r '.data[0].number // 0') - echo " Baseline pipeline: #$baseline_number — waiting for a newer one" + initial_status=$(echo "$initial_result" | jq -r '.data[0].status // "unknown"') + # If the latest pipeline is already running or pending, it was triggered by the + # preceding step — track it directly rather than waiting for a newer one. + if [[ "$initial_status" == "running" || "$initial_status" == "pending" || "$initial_status" == "started" ]]; then + tracked_pipeline="$baseline_number" + echo " Detected in-progress pipeline #$baseline_number (status: $initial_status) — tracking it" + else + echo " Baseline pipeline: #$baseline_number (status: $initial_status) — waiting for a newer one" + fi fi while [[ $attempt -lt $max_attempts ]]; do @@ -192,15 +203,17 @@ wait_for_pipeline() { pipeline_number=$(echo "$result" | jq -r '.data[0].number // 0') status=$(echo "$result" | jq -r '.data[0].status // "unknown"') - # Skip any pipeline that is not newer than our baseline - if [[ "$pipeline_number" -le "$baseline_number" ]]; then + # Skip any pipeline that is not newer than our baseline. + # Exception: if tracked_pipeline is already set (we detected an in-progress + # pipeline at startup), bypass the baseline check and go straight to status. + if [[ -z "$tracked_pipeline" && "$pipeline_number" -le "$baseline_number" ]]; then echo " Waiting for new pipeline (latest is #$pipeline_number, baseline #$baseline_number)... (attempt $((attempt + 1))/$max_attempts)" sleep "$poll_interval" ((attempt++)) continue fi - # A new pipeline exists — track it + # A new pipeline exists — track it (if not already tracking) if [[ -z "$tracked_pipeline" ]]; then tracked_pipeline="$pipeline_number" echo " Tracking new pipeline #$tracked_pipeline" diff --git a/cookbooks/trees/aeries-daeya.yaml b/cookbooks/trees/aeries-daeya.yaml new file mode 100644 index 0000000..b2b7b04 --- /dev/null +++ b/cookbooks/trees/aeries-daeya.yaml @@ -0,0 +1,188 @@ +name: aeries-daeya +description: "Aeries Daeya: Privacy-first avatar social platform. Create a digital identity, style it with AI from photo references, share experiences without exposing your real face. Mutation explorer for iterating on looks and backgrounds." +version: 1 + +vars: + project_name: "" + service_name: "daeya-api" + worker_name: "media-worker" + app_name: "studio-ui" + +steps: + # --- Infrastructure --- + create-project: + action: api + method: POST + endpoint: /project + body: + name: "{{ .vars.project_name }}" + description: "Aeries Daeya: Privacy-first avatar social platform" + outputs: + - project_id: .data.name + - domain: .data.domain + + add-db: + description: CockroachDB for characters, looks, albums, posts, mutation history + depends_on: [create-project] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/components" + body: { type: postgres, name: "daeya-db" } + + add-redis: + description: Redis for SSE pub/sub and AI generation job queue + depends_on: [create-project] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/components" + body: { type: redis, name: "event-bus" } + + add-components: + description: Add daeya-api + media-worker + studio-ui as a single atomic commit + depends_on: [add-db, add-redis] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/components/batch" + body: + components: + - type: service + name: "{{ .vars.service_name }}" + - type: worker + name: "{{ .vars.worker_name }}" + - type: app-react + name: "{{ .vars.app_name }}" + + wait-infra: + description: Wait for CI to build and deploy all components + depends_on: [add-components] + action: wait_pipeline + project_id: "{{ .outputs.create-project.project_id }}" + max_attempts: 120 + poll_interval: 10 + + # --- Feature 1: Avatar & Look Data Model --- + implement-avatar-model: + description: "Characters, looks, albums, posts, mutation history — full data layer + CRUD API" + depends_on: [wait-infra] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/implement-feature avatar-model --requirements 'DB migrations in daeya-api: (1) CREATE TABLE characters (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, name TEXT NOT NULL, skin_tone INT NOT NULL DEFAULT 5, face_shape TEXT NOT NULL DEFAULT ''oval'', hair_color TEXT NOT NULL DEFAULT ''brown'', hair_style TEXT NOT NULL DEFAULT ''medium'', eye_color TEXT NOT NULL DEFAULT ''brown'', body_type TEXT NOT NULL DEFAULT ''average'', description TEXT NOT NULL DEFAULT '''', avatar_url TEXT, status TEXT NOT NULL DEFAULT ''pending'', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (2) CREATE TABLE looks (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE, name TEXT NOT NULL, outfit_description TEXT NOT NULL DEFAULT '''', outfit_url TEXT, source_photo_urls TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], status TEXT NOT NULL DEFAULT ''pending'', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (3) CREATE TABLE albums (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, name TEXT NOT NULL, cover_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (4) CREATE TABLE posts (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, character_id UUID REFERENCES characters(id), look_id UUID REFERENCES looks(id), album_id UUID REFERENCES albums(id), image_url TEXT, video_url TEXT, caption TEXT NOT NULL DEFAULT '''', location_name TEXT, location_lat DECIMAL, location_lng DECIMAL, prompt_text TEXT, show_prompt BOOLEAN NOT NULL DEFAULT false, frame_style TEXT NOT NULL DEFAULT ''none'', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (5) CREATE TABLE mutation_history (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE, parameters_json JSONB NOT NULL, result_url TEXT, committed BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()). Domain models: Character, Look, Album, Post, MutationRecord with same fields. Services with interfaces in ports: CharacterService (Create, GetByID, ListByUser, Update, UpdateAvatarURL), LookService (Create, GetByID, ListByCharacter, UpdateOutfitURL), AlbumService (Create, GetByID, ListByUser), PostService (Create, GetByID, ListByAlbum, ListByUser), MutationService (Create, GetByCharacter, Commit). Endpoints all under /api/daeya-api (require JWT auth via skeleton auth.Middleware): POST /characters (body: {name, description, skin_tone?, face_shape?, hair_color?, hair_style?, eye_color?, body_type?} → 202 + character), GET /characters (returns user characters), GET /characters/{id}, PATCH /characters/{id} (update fields), POST /characters/{id}/looks (body: {name, outfit_description?, source_photo_urls?[]}, → 202 + look), GET /characters/{id}/looks, POST /characters/{id}/mutations (body: {skin_tone?, background?, lighting?, style_filter?} → 202 + mutation_id), POST /characters/{id}/mutations/{mutation_id}/commit (commits mutation as new character baseline), POST /albums (body: {name}), GET /albums, GET /albums/{id}/posts, POST /posts (body: {character_id, look_id?, album_id?, caption?, location_name?, location_lat?, location_lng?, prompt_text?, show_prompt?, frame_style?} → post), GET /posts/{id}. After creating a character enqueue job {type:generate_character, character_id}. After creating a look enqueue job {type:generate_look, look_id}. After creating a mutation enqueue job {type:generate_mutation, mutation_id}. Publish SSE events via SSE hub: character_updated {character_id, status, avatar_url} to channel:daeya, look_updated {look_id, status, outfit_url} to channel:daeya, mutation_ready {mutation_id, character_id, result_url} to channel:daeya.'" + auto_commit: true + auto_push: true + git_clone_url: "https://git.threesix.ai/threesix/{{ .outputs.create-project.project_id }}.git" + outputs: + - build_id: .data.task_id + + wait-avatar-model: + depends_on: [implement-avatar-model] + action: wait_build + build_id: "{{ .outputs.implement-avatar-model.build_id }}" + max_attempts: 120 + poll_interval: 5 + + # --- Feature 2: AI Generation Pipeline --- + implement-generation: + description: "Character portrait gen → look styling from photos → mutation explorer → action expressions" + depends_on: [wait-avatar-model] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/implement-feature ai-generation --requirements 'Implement generation pipeline in media-worker using skeleton ai-client package (LAOZHANG_API_KEY for image generation via Gemini vision for photo analysis). Worker consumes jobs from queue by type: JOB generate_character {character_id}: Load character row. Build a portrait prompt from character fields: face_shape, hair_color, hair_style, eye_color, skin_tone (map 1-10 to descriptive terms: 1=very fair, 3=light, 5=medium, 7=tan, 9=deep, 10=very dark), body_type, description. Prompt template: \"Portrait photo of a [gender-neutral] person. [description]. [skin_tone_desc] skin, [face_shape] face, [hair_color] [hair_style] hair, [eye_color] eyes. [body_type] build. Front-facing, neutral expression, soft studio lighting, clean white background. High quality, photorealistic.\" Generate one image via LAOZHANG_API_KEY. Save URL to character.avatar_url, set status=ready. Publish SSE character_updated {character_id, status:ready, avatar_url}. JOB generate_look {look_id}: Load look + character rows. If source_photo_urls non-empty: use Gemini vision (GEMINI_API_KEY) to analyze first photo and extract style descriptors (garment type, color palette, fabric, silhouette, styling details). Build outfit prompt using descriptors + character appearance. If no source photos: use outfit_description directly. Prompt template: \"The same person from the reference portrait is now wearing [outfit_description]. Full body view. [character appearance details]. Natural lighting. High quality, photorealistic.\" Use character.avatar_url as style reference if available. Generate one image. Save to look.outfit_url, set status=ready. Publish SSE look_updated {look_id, status:ready, outfit_url}. JOB generate_mutation {mutation_id}: Load mutation_history + character rows. Parameters from parameters_json may include: skin_tone (1-10, update descriptive term), background (city_day|city_night|nature_park|studio_white|studio_dark|beach|mountain), lighting (natural|golden_hour|dramatic|neon|soft_box), style_filter (realistic|stylized|anime|painterly). Build variation prompt starting from character description but applying parameter overrides. Background mapping: city_day=busy city street daytime, city_night=city street at night with neon lights, nature_park=lush green park, studio_white=plain white studio, studio_dark=dark minimalist studio, beach=sandy beach golden hour, mountain=mountain vista clear sky. Style filter suffix: anime=anime art style illustration, painterly=oil painting impressionist style (others=photorealistic). Generate image. Save to mutation_history.result_url. Publish SSE mutation_ready {mutation_id, character_id, result_url}. JOB generate_action {character_id, look_id, action_text, post_id}: Load character + look. Build action prompt: the character performing the action described. Examples: spin while showing outfit=full body 3/4 turn pose showing clothing detail; say Happy Birthday=person holding birthday cake smiling waving; wave=person waving at camera smiling. Generate image (static). Save image_url to posts table for post_id. Publish SSE action_ready {post_id, image_url}.'" + auto_commit: true + auto_push: true + git_clone_url: "https://git.threesix.ai/threesix/{{ .outputs.create-project.project_id }}.git" + outputs: + - build_id: .data.task_id + + wait-generation: + depends_on: [implement-generation] + action: wait_build + build_id: "{{ .outputs.implement-generation.build_id }}" + max_attempts: 120 + poll_interval: 5 + + wait-deploy-2: + description: Deploy generation pipeline + depends_on: [wait-generation] + action: wait_pipeline + project_id: "{{ .outputs.create-project.project_id }}" + max_attempts: 120 + poll_interval: 10 + + # --- Feature 3: Studio UI --- + implement-studio-ui: + description: "Character creation wizard, mutation explorer, look styling, album management" + depends_on: [wait-deploy-2] + action: api + method: POST + endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" + body: + prompt: "/implement-feature studio-ui --requirements 'Build the React studio UI using skeleton packages (@project/ui, @project/auth, @project/layout, @project/realtime, @project/api-client). DashboardShell layout from @project/layout. Routes: /login (public) and all others protected via ProtectedRoute from @project/auth. LOGIN PAGE: OTP flow — email input, Send Code, then code input + Verify. DASHBOARD (/): Header with \"Create Character\" button. Grid of CharacterCard components showing avatar_url (circular, 96px), character name, look count badge. Clicking card goes to /characters/:id. Empty state: centered illustration + \"Create your first character\" button. CHARACTER CREATION (/characters/new): 4-step wizard with progress indicator. STEP 1 SPARK: Large prompt: \"Describe your character\". Textarea for free-form description. Skin tone picker: row of 10 circular swatches from #FDDBB4 to #3D1A00, selected = ring highlight. Hair color pills: Blonde, Brown, Black, Red, Auburn, White, Silver. Face shape pills: Oval, Round, Square, Heart, Diamond. Step nav: Back (disabled on step 1) + Continue. STEP 2 SHAPE: Hair style pills: Short, Medium, Long, Curly, Wavy, Straight, Bob, Bun. Eye color pills: Brown, Blue, Green, Hazel, Gray, Amber. Body type pills: Slim, Athletic, Average, Curvy, Plus. Name field with placeholder \"Give your character a name\". Live summary card on right: lists all selected traits as pills. STEP 3 SOUL (optional): \"What does your character like to do?\". Interest pills (multi-select up to 3): Travel, Fashion, Music, Food, Art, Fitness, Books, Tech, Nature. tagline field: \"Add a tagline\" (optional). STEP 4 CREATE: Shows summary card. \"Generate Character\" button. On submit POST /api/daeya-api/characters. Shows generating spinner with messages cycling: \"Bringing your character to life...\", \"Crafting their appearance...\", \"Almost there...\". Redirects to /characters/:id on success. CHARACTER DETAIL (/characters/:id): Left column (1/3): circular avatar image (200px, pending state shows pulsing gray circle). Character name + tagline. Trait pills row (skin tone swatch + hair + eyes). \"Add Look\" button. Looks list: each row shows look name, outfit_url thumbnail (48px), status badge. Clicking look shows full outfit image modal. Right column (2/3): MUTATION EXPLORER panel. Header: \"Explore Variations\". Parameter controls: Skin Tone — horizontal slider (1-10) with live swatch preview at current value. Background — pill row: City Day, City Night, Nature, Studio, Beach, Mountain. Lighting — pill row: Natural, Golden Hour, Dramatic, Neon. Style — pill row: Realistic, Stylized, Anime, Painterly. \"Generate Preview\" button — calls POST /api/daeya-api/characters/:id/mutations with current params. Preview area: shows result_url when ready, else shows generating spinner. \"Commit as New Baseline\" button (enabled only when preview ready) — calls POST /api/daeya-api/characters/:id/mutations/:mutation_id/commit, then refreshes character avatar. \"Discard\" button resets params to character current values. ADD LOOK PANEL (slide-in from right): Look name field. Photo upload area: drag-drop or click to upload up to 3 reference photos. \"Or describe the outfit\" textarea (shown when no photos uploaded). \"Generate Look\" button. On submit POST /api/daeya-api/characters/:id/looks with uploaded photo URLs (or description). Shows generating state. ALBUMS (/albums): Grid of AlbumCard (cover_url thumbnail, name, post count). \"New Album\" button → modal with name input. REALTIME: useEventChannel from @project/realtime subscribed to channel:daeya. On character_updated: refresh CharacterCard avatar_url + status. On look_updated: refresh look thumbnail in character detail. On mutation_ready: show result in Mutation Explorer preview area. On action_ready: show generated post image.'" + auto_commit: true + auto_push: true + git_clone_url: "https://git.threesix.ai/threesix/{{ .outputs.create-project.project_id }}.git" + outputs: + - build_id: .data.task_id + + wait-studio-ui: + depends_on: [implement-studio-ui] + action: wait_build + build_id: "{{ .outputs.implement-studio-ui.build_id }}" + max_attempts: 120 + poll_interval: 5 + + wait-deploy-final: + description: Deploy final build + depends_on: [wait-studio-ui] + action: wait_pipeline + project_id: "{{ .outputs.create-project.project_id }}" + max_attempts: 120 + poll_interval: 10 + + # --- Verification --- + verify-health: + description: Verify daeya-api is healthy + depends_on: [wait-deploy-final] + action: shell + command: | + HEALTH=$(curl -sf "https://{{ .outputs.create-project.domain }}/api/daeya-api/health" | jq -r '.data.status // empty') + if [ "$HEALTH" = "healthy" ]; then + echo "daeya-api healthy" + exit 0 + else + echo "daeya-api not healthy: $HEALTH" + exit 1 + fi + + verify-site: + description: Verify studio-ui frontend loads + depends_on: [wait-deploy-final] + action: wait_site + domain: "{{ .outputs.create-project.domain }}" + project_id: "{{ .outputs.create-project.project_id }}" + max_attempts: 30 + poll_interval: 5 + + verify-auth-endpoint: + description: Verify character endpoint requires auth (401 confirms it) + depends_on: [verify-health] + on_error: continue + action: shell + command: | + DOMAIN="{{ .outputs.create-project.domain }}" + STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + "https://$DOMAIN/api/daeya-api/characters" \ + -H "Content-Type: application/json") + echo "GET /characters without auth returned: $STATUS" + if [ "$STATUS" = "401" ]; then echo "Auth guard confirmed"; exit 0; fi + echo "Unexpected status — endpoint may not exist" + exit 1 + +teardown: + - description: Delete project and all provisioned resources + action: api + method: DELETE + endpoint: "/project/{{ .outputs.create-project.project_id }}" diff --git a/internal/adapter/postgres/session_repository.go b/internal/adapter/postgres/session_repository.go index 7d9ac8e..ab8d972 100644 --- a/internal/adapter/postgres/session_repository.go +++ b/internal/adapter/postgres/session_repository.go @@ -30,9 +30,9 @@ func (r *SessionRepository) Create(ctx context.Context, session *domain.Session) err := r.db.QueryRowContext(ctx, ` INSERT INTO sessions ( project_id, checkout_id, pod_name, preview_url, preview_host, - created_by, created_at, expires_at, status, last_activity_at + created_by, created_at, expires_at, status, last_activity_at, web_ui ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id `, string(session.ProjectID), @@ -45,6 +45,7 @@ func (r *SessionRepository) Create(ctx context.Context, session *domain.Session) session.ExpiresAt, string(session.Status), session.LastActivityAt, + session.WebUI, ).Scan(&id) if err != nil { @@ -89,7 +90,8 @@ func (r *SessionRepository) Get(ctx context.Context, id domain.SessionID) (*doma session, err := r.scanSession(r.db.QueryRowContext(ctx, ` SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host, created_by, created_at, expires_at, status, last_activity_at, ended_at, - COALESCE(claude_session_id, ''), COALESCE(conversation_record_id::text, '') + COALESCE(claude_session_id, ''), COALESCE(conversation_record_id::text, ''), + web_ui FROM sessions WHERE id = $1 `, string(id))) @@ -108,7 +110,8 @@ func (r *SessionRepository) GetActiveByProject(ctx context.Context, projectID do session, err := r.scanSession(r.db.QueryRowContext(ctx, ` SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host, created_by, created_at, expires_at, status, last_activity_at, ended_at, - COALESCE(claude_session_id, ''), COALESCE(conversation_record_id::text, '') + COALESCE(claude_session_id, ''), COALESCE(conversation_record_id::text, ''), + web_ui FROM sessions WHERE project_id = $1 AND status = 'active' `, string(projectID))) @@ -127,7 +130,8 @@ func (r *SessionRepository) ListByProject(ctx context.Context, projectID domain. rows, err := r.db.QueryContext(ctx, ` SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host, created_by, created_at, expires_at, status, last_activity_at, ended_at, - COALESCE(claude_session_id, ''), COALESCE(conversation_record_id::text, '') + COALESCE(claude_session_id, ''), COALESCE(conversation_record_id::text, ''), + web_ui FROM sessions WHERE project_id = $1 ORDER BY created_at DESC @@ -191,7 +195,8 @@ func (r *SessionRepository) CleanupExpired(ctx context.Context) ([]*domain.Sessi AND last_activity_at < NOW() - INTERVAL '30 minutes' RETURNING id, project_id, checkout_id, pod_name, preview_url, preview_host, created_by, created_at, expires_at, status, last_activity_at, ended_at, - COALESCE(claude_session_id, ''), COALESCE(conversation_record_id::text, '') + COALESCE(claude_session_id, ''), COALESCE(conversation_record_id::text, ''), + web_ui `) if err != nil { return nil, fmt.Errorf("cleanup expired sessions: %w", err) @@ -234,6 +239,7 @@ func (r *SessionRepository) scanSessionFields(scanner sessionScanner) (*domain.S &endedAt, &claudeSessionID, &conversationRecordID, + &session.WebUI, ) if err != nil { return nil, err diff --git a/internal/db/migrations/027_session_web_ui.sql b/internal/db/migrations/027_session_web_ui.sql new file mode 100644 index 0000000..4bf6eac --- /dev/null +++ b/internal/db/migrations/027_session_web_ui.sql @@ -0,0 +1,5 @@ +-- Add web_ui flag to sessions for Claude Code web UI mode. +-- When true, the session starts claude-code-ui in the pod and routes the +-- preview URL to its port instead of the sidecar. +ALTER TABLE sessions + ADD COLUMN IF NOT EXISTS web_ui BOOLEAN NOT NULL DEFAULT false; diff --git a/internal/domain/session.go b/internal/domain/session.go index dee3bf1..3e64a88 100644 --- a/internal/domain/session.go +++ b/internal/domain/session.go @@ -68,6 +68,10 @@ type Session struct { // ConversationRecordID links this session to its conversation message history. // Set on first Claude exec; messages are written to the conversations/messages tables. ConversationRecordID string + + // WebUI indicates whether this session runs claude-code-ui for browser-based interaction. + // When true, the preview URL routes to the web UI instead of the sidecar port. + WebUI bool } // IsActive returns true if the session can still be used. diff --git a/internal/handlers/sessions.go b/internal/handlers/sessions.go index 27b97ab..3ca5178 100644 --- a/internal/handlers/sessions.go +++ b/internal/handlers/sessions.go @@ -86,6 +86,7 @@ type CreateSessionRequest struct { FeatureSlug string `json:"feature_slug,omitempty"` ExpiresIn string `json:"expires_in,omitempty"` // Duration string (e.g., "24h", "7d") PreviewPort int `json:"preview_port,omitempty"` // Default: 8080 + WebUI bool `json:"web_ui,omitempty"` // Start Claude Code web UI in the pod } // SessionResponse is the JSON response for a session. @@ -105,6 +106,7 @@ type SessionResponse struct { Instructions string `json:"instructions,omitempty"` // Only at creation ClaudeSessionID string `json:"claude_session_id,omitempty"` // Set after first claude exec ConversationRecordID string `json:"conversation_record_id,omitempty"` // Linked conversation + WebUI bool `json:"web_ui,omitempty"` // Web UI mode active } // SessionCheckinRequest is the JSON body for ending a session. @@ -226,6 +228,7 @@ func (h *SessionsHandler) Create(w http.ResponseWriter, r *http.Request) { ExpiresIn: expiresIn, PreviewPort: req.PreviewPort, CreatedBy: createdBy, + WebUI: req.WebUI, }) if err != nil { if errors.Is(err, domain.ErrProjectNotFound) { @@ -258,6 +261,11 @@ func (h *SessionsHandler) Create(w http.ResponseWriter, r *http.Request) { return } + // Start Claude Code web UI in the pod if requested. + if req.WebUI { + h.startWebUI(r.Context(), result.Session) + } + resp := sessionToResponse(result.Session) resp.AuthCloneURL = result.AuthenticatedCloneURL resp.Branch = result.Branch @@ -340,6 +348,11 @@ func (h *SessionsHandler) Checkin(w http.ResponseWriter, r *http.Request) { return } + // Kill web UI process before tearing down the session. + if session.WebUI { + h.stopWebUI(r.Context(), session) + } + result, err := h.sessionService.EndSession(ctx, service.EndSessionRequest{ SessionID: domain.SessionID(sid), SkipReview: req.SkipReview, @@ -404,6 +417,11 @@ func (h *SessionsHandler) Delete(w http.ResponseWriter, r *http.Request) { return } + // Kill web UI process before force-ending the session. + if session.WebUI { + h.stopWebUI(r.Context(), session) + } + if err := h.sessionService.ForceEnd(ctx, domain.SessionID(sid)); err != nil { if errors.Is(err, domain.ErrSessionNotFound) { api.WriteNotFound(w, r, "session not found") @@ -438,6 +456,7 @@ func sessionToResponse(s *domain.Session) SessionResponse { ExpiresAt: s.ExpiresAt.Format(time.RFC3339), ClaudeSessionID: s.ClaudeSessionID, ConversationRecordID: s.ConversationRecordID, + WebUI: s.WebUI, } if s.EndedAt != nil { t := s.EndedAt.Format(time.RFC3339) diff --git a/internal/handlers/sessions_webui.go b/internal/handlers/sessions_webui.go new file mode 100644 index 0000000..f138d67 --- /dev/null +++ b/internal/handlers/sessions_webui.go @@ -0,0 +1,69 @@ +package handlers + +import ( + "context" + "fmt" + "time" + + "github.com/orchard9/rdev/internal/domain" + "github.com/orchard9/rdev/internal/logging" + "github.com/orchard9/rdev/internal/service" +) + +// startWebUI launches claude-code-ui as a background process in the session pod. +// This is fire-and-forget — UI startup failure does not block session creation. +func (h *SessionsHandler) startWebUI(ctx context.Context, session *domain.Session) { + log := logging.FromContext(ctx).WithService("SessionsHandler") + + bgCtx := context.WithoutCancel(ctx) + go func() { + startCtx, cancel := context.WithTimeout(bgCtx, TimeoutStandard) + defer cancel() + + cmd := &domain.Command{ + ID: domain.CommandID(fmt.Sprintf("webui-%s", session.ID)), + ProjectID: session.ProjectID, + Type: domain.CommandTypeShell, + Args: []string{fmt.Sprintf("nohup claude-code-ui --port %d > /tmp/claude-code-ui.log 2>&1 &", service.WebUIPort)}, + StartedAt: time.Now(), + } + + if _, err := h.executor.Execute(startCtx, cmd, session.PodName, func(_ domain.OutputLine) {}); err != nil { + log.Error("failed to start claude-code-ui", + logging.FieldError, err, + "session_id", session.ID, + logging.FieldProjectID, session.ProjectID, + ) + } else { + log.Info("claude-code-ui started", + "session_id", session.ID, + "port", service.WebUIPort, + ) + } + }() +} + +// stopWebUI kills the claude-code-ui process in the session pod. +// Best-effort — failure is logged but does not block session teardown. +func (h *SessionsHandler) stopWebUI(ctx context.Context, session *domain.Session) { + log := logging.FromContext(ctx).WithService("SessionsHandler") + + killCtx, cancel := context.WithTimeout(ctx, TimeoutFastLookup) + defer cancel() + + cmd := &domain.Command{ + ID: domain.CommandID(fmt.Sprintf("webui-stop-%s", session.ID)), + ProjectID: session.ProjectID, + Type: domain.CommandTypeShell, + Args: []string{"pkill -f claude-code-ui || true"}, + StartedAt: time.Now(), + } + + if _, err := h.executor.Execute(killCtx, cmd, session.PodName, func(_ domain.OutputLine) {}); err != nil { + log.Warn("failed to stop claude-code-ui", + logging.FieldError, err, + "session_id", session.ID, + logging.FieldProjectID, session.ProjectID, + ) + } +} diff --git a/internal/service/session_service.go b/internal/service/session_service.go index 48fb57d..ba0a7f9 100644 --- a/internal/service/session_service.go +++ b/internal/service/session_service.go @@ -59,6 +59,9 @@ func NewSessionService( } } +// WebUIPort is the default port for the Claude Code web UI. +const WebUIPort = 3001 + // CreateSessionRequest contains parameters for creating a session. type CreateSessionRequest struct { ProjectID domain.ProjectID @@ -69,6 +72,7 @@ type CreateSessionRequest struct { ExpiresIn time.Duration PreviewPort int CreatedBy string + WebUI bool } // SessionResult contains the result of a session creation. @@ -134,7 +138,11 @@ func (s *SessionService) CreateSession(ctx context.Context, req CreateSessionReq // Determine preview port. previewPort := req.PreviewPort if previewPort == 0 { - previewPort = 8080 + if req.WebUI { + previewPort = WebUIPort + } else { + previewPort = 8080 + } } // Create session record first to get the ID. @@ -150,6 +158,7 @@ func (s *SessionService) CreateSession(ctx context.Context, req CreateSessionReq ExpiresAt: now.Add(expiry), LastActivityAt: now, Status: domain.SessionStatusActive, + WebUI: req.WebUI, } if err := s.sessionRepo.Create(ctx, session); err != nil { @@ -177,10 +186,35 @@ func (s *SessionService) CreateSession(ctx context.Context, req CreateSessionReq "preview_url", previewURL, "pod", project.PodName, "branch", checkoutResult.Checkout.Branch, + "web_ui", req.WebUI, ) branch := checkoutResult.Checkout.Branch - instructions := fmt.Sprintf(`Session started. + + var instructions string + if req.WebUI { + instructions = fmt.Sprintf(`Session started (Web UI mode). + +Web UI: %s +Clone URL: %s +Branch: %s +Pod: %s + +Open the Web UI URL in your browser to interact with Claude Code. + +End session: + POST /projects/%s/sessions/%s/checkin + +Session expires: %s`, + previewURL, + checkoutResult.AuthenticatedCloneURL, + branch, + project.PodName, + req.ProjectID, session.ID, + session.ExpiresAt.Format(time.RFC3339), + ) + } else { + instructions = fmt.Sprintf(`Session started. Preview URL: %s Clone URL: %s @@ -195,15 +229,16 @@ End session: POST /projects/%s/sessions/%s/checkin Session expires: %s`, - previewURL, - checkoutResult.AuthenticatedCloneURL, - branch, - project.PodName, - req.ProjectID, session.ID, - req.ProjectID, session.ID, - req.ProjectID, session.ID, - session.ExpiresAt.Format(time.RFC3339), - ) + previewURL, + checkoutResult.AuthenticatedCloneURL, + branch, + project.PodName, + req.ProjectID, session.ID, + req.ProjectID, session.ID, + req.ProjectID, session.ID, + session.ExpiresAt.Format(time.RFC3339), + ) + } return &SessionResult{ Session: session,