feat: add session web UI mode + aeries-daeya cookbook tree

Session WebUI:
- Add `web_ui` flag to session create — launches claude-code-ui in pod on port 3001
- Install @siteboon/claude-code-ui in claudebox Dockerfile, expose port 3001
- Migration 027: add web_ui column to sessions table
- startWebUI/stopWebUI fire-and-forget helpers in SessionsHandler
- Service selects preview port 3001 (web UI) vs 8080 (sidecar) based on flag

Aeries Daeya cookbook:
- Add cookbooks/trees/aeries-daeya.yaml: privacy-first avatar social platform
  (infra → avatar data model → AI generation pipeline → studio UI)
- Add cookbooks/scripts/aeries-daeya-test.sh: run/status/diagnose/teardown harness
- Fix race condition in common.sh wait_for_pipeline: detect already-running pipelines
  at startup and track directly instead of waiting for a newer one

Docs/tooling:
- Add SDK Update Workflow section to CLAUDE.md
- Add `make sdk` and `make sdk-check` targets for OpenAPI spec management

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-26 23:14:08 -07:00
parent ae5fbd5034
commit e42c18a9a3
12 changed files with 542 additions and 28 deletions

View File

@ -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) | | **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) | | **Notify / email delivery** | [services/notify.md](.claude/guides/services/notify.md) |
| **Structured logging** | `internal/logging/` - field constants, context propagation, redaction | | **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 ## Critical Rules

View File

@ -33,8 +33,8 @@ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \ && apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install Claude Code CLI # Install Claude Code CLI and web UI
RUN npm install -g @anthropic-ai/claude-code RUN npm install -g @anthropic-ai/claude-code @siteboon/claude-code-ui
# Copy Go binaries from builder stage # Copy Go binaries from builder stage
COPY --from=builder /build/sdlc /usr/local/bin/sdlc 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 \ RUN echo '#!/bin/bash\nclaude --version > /dev/null 2>&1' > /healthcheck.sh \
&& chmod +x /healthcheck.sh && chmod +x /healthcheck.sh
# Expose sidecar HTTP port # Expose sidecar HTTP port and web UI port
EXPOSE 8080 EXPOSE 8080 3001
# Run claudebox-sidecar by default (HTTP server mode) # Run claudebox-sidecar by default (HTTP server mode)
CMD ["claudebox-sidecar"] CMD ["claudebox-sidecar"]

View File

@ -1,7 +1,7 @@
# rdev-api development commands # rdev-api development commands
# Run 'make help' to see available targets # 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 # Load .env.local if it exists
ifneq (,$(wildcard .env.local)) ifneq (,$(wildcard .env.local))
@ -41,6 +41,16 @@ test: ## Run all tests
build: ## Build the binary build: ## Build the binary
CGO_ENABLED=0 go build -o rdev-api ./cmd/rdev-api 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 clean: ## Remove build artifacts
rm -f rdev-api rm -f rdev-api

View File

@ -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 <command> <project-name>
# 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 <command> <project-name>"
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 <token>' 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

View File

@ -163,13 +163,24 @@ wait_for_pipeline() {
echo -e "${CYAN}Waiting for new CI pipeline...${NC}" echo -e "${CYAN}Waiting for new CI pipeline...${NC}"
# Record the current latest pipeline number BEFORE waiting # 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 baseline_number=0
local initial_result local initial_result initial_status
initial_result=$(api_call GET "/projects/$project_id/pipelines" 2>/dev/null) 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 if echo "$initial_result" | jq -e '.data[0]' >/dev/null 2>&1; then
baseline_number=$(echo "$initial_result" | jq -r '.data[0].number // 0') 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 fi
while [[ $attempt -lt $max_attempts ]]; do while [[ $attempt -lt $max_attempts ]]; do
@ -192,15 +203,17 @@ wait_for_pipeline() {
pipeline_number=$(echo "$result" | jq -r '.data[0].number // 0') pipeline_number=$(echo "$result" | jq -r '.data[0].number // 0')
status=$(echo "$result" | jq -r '.data[0].status // "unknown"') status=$(echo "$result" | jq -r '.data[0].status // "unknown"')
# Skip any pipeline that is not newer than our baseline # Skip any pipeline that is not newer than our baseline.
if [[ "$pipeline_number" -le "$baseline_number" ]]; then # 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)" echo " Waiting for new pipeline (latest is #$pipeline_number, baseline #$baseline_number)... (attempt $((attempt + 1))/$max_attempts)"
sleep "$poll_interval" sleep "$poll_interval"
((attempt++)) ((attempt++))
continue continue
fi fi
# A new pipeline exists — track it # A new pipeline exists — track it (if not already tracking)
if [[ -z "$tracked_pipeline" ]]; then if [[ -z "$tracked_pipeline" ]]; then
tracked_pipeline="$pipeline_number" tracked_pipeline="$pipeline_number"
echo " Tracking new pipeline #$tracked_pipeline" echo " Tracking new pipeline #$tracked_pipeline"

View File

@ -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 }}"

View File

@ -30,9 +30,9 @@ func (r *SessionRepository) Create(ctx context.Context, session *domain.Session)
err := r.db.QueryRowContext(ctx, ` err := r.db.QueryRowContext(ctx, `
INSERT INTO sessions ( INSERT INTO sessions (
project_id, checkout_id, pod_name, preview_url, preview_host, 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 RETURNING id
`, `,
string(session.ProjectID), string(session.ProjectID),
@ -45,6 +45,7 @@ func (r *SessionRepository) Create(ctx context.Context, session *domain.Session)
session.ExpiresAt, session.ExpiresAt,
string(session.Status), string(session.Status),
session.LastActivityAt, session.LastActivityAt,
session.WebUI,
).Scan(&id) ).Scan(&id)
if err != nil { 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, ` session, err := r.scanSession(r.db.QueryRowContext(ctx, `
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host, SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
created_by, created_at, expires_at, status, last_activity_at, ended_at, 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 FROM sessions
WHERE id = $1 WHERE id = $1
`, string(id))) `, string(id)))
@ -108,7 +110,8 @@ func (r *SessionRepository) GetActiveByProject(ctx context.Context, projectID do
session, err := r.scanSession(r.db.QueryRowContext(ctx, ` session, err := r.scanSession(r.db.QueryRowContext(ctx, `
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host, SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
created_by, created_at, expires_at, status, last_activity_at, ended_at, 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 FROM sessions
WHERE project_id = $1 AND status = 'active' WHERE project_id = $1 AND status = 'active'
`, string(projectID))) `, string(projectID)))
@ -127,7 +130,8 @@ func (r *SessionRepository) ListByProject(ctx context.Context, projectID domain.
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host, SELECT id, project_id, checkout_id, pod_name, preview_url, preview_host,
created_by, created_at, expires_at, status, last_activity_at, ended_at, 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 FROM sessions
WHERE project_id = $1 WHERE project_id = $1
ORDER BY created_at DESC 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' AND last_activity_at < NOW() - INTERVAL '30 minutes'
RETURNING id, project_id, checkout_id, pod_name, preview_url, preview_host, RETURNING id, project_id, checkout_id, pod_name, preview_url, preview_host,
created_by, created_at, expires_at, status, last_activity_at, ended_at, 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 { if err != nil {
return nil, fmt.Errorf("cleanup expired sessions: %w", err) return nil, fmt.Errorf("cleanup expired sessions: %w", err)
@ -234,6 +239,7 @@ func (r *SessionRepository) scanSessionFields(scanner sessionScanner) (*domain.S
&endedAt, &endedAt,
&claudeSessionID, &claudeSessionID,
&conversationRecordID, &conversationRecordID,
&session.WebUI,
) )
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -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;

View File

@ -68,6 +68,10 @@ type Session struct {
// ConversationRecordID links this session to its conversation message history. // ConversationRecordID links this session to its conversation message history.
// Set on first Claude exec; messages are written to the conversations/messages tables. // Set on first Claude exec; messages are written to the conversations/messages tables.
ConversationRecordID string 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. // IsActive returns true if the session can still be used.

View File

@ -86,6 +86,7 @@ type CreateSessionRequest struct {
FeatureSlug string `json:"feature_slug,omitempty"` FeatureSlug string `json:"feature_slug,omitempty"`
ExpiresIn string `json:"expires_in,omitempty"` // Duration string (e.g., "24h", "7d") ExpiresIn string `json:"expires_in,omitempty"` // Duration string (e.g., "24h", "7d")
PreviewPort int `json:"preview_port,omitempty"` // Default: 8080 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. // SessionResponse is the JSON response for a session.
@ -105,6 +106,7 @@ type SessionResponse struct {
Instructions string `json:"instructions,omitempty"` // Only at creation Instructions string `json:"instructions,omitempty"` // Only at creation
ClaudeSessionID string `json:"claude_session_id,omitempty"` // Set after first claude exec ClaudeSessionID string `json:"claude_session_id,omitempty"` // Set after first claude exec
ConversationRecordID string `json:"conversation_record_id,omitempty"` // Linked conversation 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. // 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, ExpiresIn: expiresIn,
PreviewPort: req.PreviewPort, PreviewPort: req.PreviewPort,
CreatedBy: createdBy, CreatedBy: createdBy,
WebUI: req.WebUI,
}) })
if err != nil { if err != nil {
if errors.Is(err, domain.ErrProjectNotFound) { if errors.Is(err, domain.ErrProjectNotFound) {
@ -258,6 +261,11 @@ func (h *SessionsHandler) Create(w http.ResponseWriter, r *http.Request) {
return 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 := sessionToResponse(result.Session)
resp.AuthCloneURL = result.AuthenticatedCloneURL resp.AuthCloneURL = result.AuthenticatedCloneURL
resp.Branch = result.Branch resp.Branch = result.Branch
@ -340,6 +348,11 @@ func (h *SessionsHandler) Checkin(w http.ResponseWriter, r *http.Request) {
return 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{ result, err := h.sessionService.EndSession(ctx, service.EndSessionRequest{
SessionID: domain.SessionID(sid), SessionID: domain.SessionID(sid),
SkipReview: req.SkipReview, SkipReview: req.SkipReview,
@ -404,6 +417,11 @@ func (h *SessionsHandler) Delete(w http.ResponseWriter, r *http.Request) {
return 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 err := h.sessionService.ForceEnd(ctx, domain.SessionID(sid)); err != nil {
if errors.Is(err, domain.ErrSessionNotFound) { if errors.Is(err, domain.ErrSessionNotFound) {
api.WriteNotFound(w, r, "session not found") api.WriteNotFound(w, r, "session not found")
@ -438,6 +456,7 @@ func sessionToResponse(s *domain.Session) SessionResponse {
ExpiresAt: s.ExpiresAt.Format(time.RFC3339), ExpiresAt: s.ExpiresAt.Format(time.RFC3339),
ClaudeSessionID: s.ClaudeSessionID, ClaudeSessionID: s.ClaudeSessionID,
ConversationRecordID: s.ConversationRecordID, ConversationRecordID: s.ConversationRecordID,
WebUI: s.WebUI,
} }
if s.EndedAt != nil { if s.EndedAt != nil {
t := s.EndedAt.Format(time.RFC3339) t := s.EndedAt.Format(time.RFC3339)

View File

@ -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,
)
}
}

View File

@ -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. // CreateSessionRequest contains parameters for creating a session.
type CreateSessionRequest struct { type CreateSessionRequest struct {
ProjectID domain.ProjectID ProjectID domain.ProjectID
@ -69,6 +72,7 @@ type CreateSessionRequest struct {
ExpiresIn time.Duration ExpiresIn time.Duration
PreviewPort int PreviewPort int
CreatedBy string CreatedBy string
WebUI bool
} }
// SessionResult contains the result of a session creation. // SessionResult contains the result of a session creation.
@ -134,7 +138,11 @@ func (s *SessionService) CreateSession(ctx context.Context, req CreateSessionReq
// Determine preview port. // Determine preview port.
previewPort := req.PreviewPort previewPort := req.PreviewPort
if previewPort == 0 { if previewPort == 0 {
previewPort = 8080 if req.WebUI {
previewPort = WebUIPort
} else {
previewPort = 8080
}
} }
// Create session record first to get the ID. // 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), ExpiresAt: now.Add(expiry),
LastActivityAt: now, LastActivityAt: now,
Status: domain.SessionStatusActive, Status: domain.SessionStatusActive,
WebUI: req.WebUI,
} }
if err := s.sessionRepo.Create(ctx, session); err != nil { 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, "preview_url", previewURL,
"pod", project.PodName, "pod", project.PodName,
"branch", checkoutResult.Checkout.Branch, "branch", checkoutResult.Checkout.Branch,
"web_ui", req.WebUI,
) )
branch := checkoutResult.Checkout.Branch 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 Preview URL: %s
Clone URL: %s Clone URL: %s
@ -195,15 +229,16 @@ End session:
POST /projects/%s/sessions/%s/checkin POST /projects/%s/sessions/%s/checkin
Session expires: %s`, Session expires: %s`,
previewURL, previewURL,
checkoutResult.AuthenticatedCloneURL, checkoutResult.AuthenticatedCloneURL,
branch, branch,
project.PodName, project.PodName,
req.ProjectID, session.ID, req.ProjectID, session.ID,
req.ProjectID, session.ID, req.ProjectID, session.ID,
req.ProjectID, session.ID, req.ProjectID, session.ID,
session.ExpiresAt.Format(time.RFC3339), session.ExpiresAt.Format(time.RFC3339),
) )
}
return &SessionResult{ return &SessionResult{
Session: session, Session: session,