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:
parent
ae5fbd5034
commit
e42c18a9a3
18
CLAUDE.md
18
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) |
|
| **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
|
||||||
|
|
||||||
|
|||||||
@ -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"]
|
||||||
|
|||||||
12
Makefile
12
Makefile
@ -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
|
||||||
|
|
||||||
|
|||||||
147
cookbooks/scripts/aeries-daeya-test.sh
Executable file
147
cookbooks/scripts/aeries-daeya-test.sh
Executable 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
|
||||||
@ -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"
|
||||||
|
|||||||
188
cookbooks/trees/aeries-daeya.yaml
Normal file
188
cookbooks/trees/aeries-daeya.yaml
Normal 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 }}"
|
||||||
@ -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
|
||||||
|
|||||||
5
internal/db/migrations/027_session_web_ui.sql
Normal file
5
internal/db/migrations/027_session_web_ui.sql
Normal 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;
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
69
internal/handlers/sessions_webui.go
Normal file
69
internal/handlers/sessions_webui.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user