diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..68515970 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,58 @@ +# Version control +.git +.gitignore + +# Dependencies (rebuilt in build stage) +node_modules + +# Build outputs (rebuilt in build stage) +dist + +# Runtime data +data + +# Environment files +.env +.env.* +!.env.example + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# OS files +.DS_Store +Thumbs.db + +# Agent artifacts +.sisyphus + +# CI/CD workflows +.github + +# IDE +.idea +.vscode +*.swp +*.swo + +# Documentation (not needed in image) +README.md +AGENTS.md +CONTRIBUTING.md +LICENSE + +# Test files +*.test.ts +*.test.tsx +*.spec.ts +*.spec.tsx +__tests__ +coverage +.e2e + +# Docker files (not needed in image) +Dockerfile +docker-compose.yml +.dockerignore diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index ed6894b2..4cbc84dd 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -28,5 +28,7 @@ jobs: env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENCODE_PERMISSION: '{"bash": "deny"}' with: + model: opencode/kimi-k2.5 model: opencode/minimax-m2.5 diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 9d454138..b1b6ac6e 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -14,6 +14,8 @@ jobs: permissions: contents: read pull-requests: write + env: + GH_TOKEN: ${{ github.token }} steps: - name: Checkout code @@ -53,6 +55,10 @@ jobs: chmod 700 ~/.ssh ssh-keyscan -H doce.pangolin-frog.ts.net >> ~/.ssh/known_hosts 2>/dev/null || true + - name: Docker cleanup + run: | + ssh root@doce.pangolin-frog.ts.net "docker system prune -af --volumes || true" + - name: Deploy preview id: deploy run: | @@ -66,8 +72,42 @@ jobs: echo "๐Ÿš€ Deploying PR #$PR_NUM to $VM_HOST" - # Step 1: Clean up stale previews - echo "๐Ÿ“ฆ Cleaning up stale previews..." + # Step 1: Clean up stale previews for closed PRs + echo "๐Ÿงน Cleaning up stale previews..." + + # Get list of open PR numbers from GitHub + OPEN_PRS=$(gh pr list --state open --json number --jq '.[].number') + + # Get list of existing PR directories on server + EXISTING_DIRS=$(ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" \ + "ls -d /root/previews/pr-* 2>/dev/null | xargs -n1 basename 2>/dev/null || true") + + # For each existing directory, check if the PR is still open + for dir in $EXISTING_DIRS; do + DIR_PR_NUM=$(echo "$dir" | sed 's/pr-//') + IS_OPEN=false + for open_pr in $OPEN_PRS; do + if [ "$DIR_PR_NUM" = "$open_pr" ]; then + IS_OPEN=true + break + fi + done + + if [ "$IS_OPEN" = "false" ]; then + echo "๐Ÿ—‘๏ธ Cleaning up stale preview for PR #$DIR_PR_NUM (PR is closed)" + ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" \ + "cd /root/previews/$dir && docker-compose down 2>/dev/null || true" + ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" \ + "$SCRIPTS_DIR/release-port.sh $DIR_PR_NUM 2>/dev/null || true" + ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" \ + "docker rmi -f doce-pr-$DIR_PR_NUM:latest 2>/dev/null || true" + ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" \ + "rm -rf /root/previews/$dir" + fi + done + + # Also clean up any leftover containers from previous runs of this PR + echo "๐Ÿ“ฆ Cleaning up stale containers for PR #$PR_NUM..." ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" \ "docker rm -f doce-pr-$PR_NUM 2>/dev/null || true; cd /root/previews && for dir in pr-*; do [ -d \"\$dir\" ] && (cd \"\$dir\" && docker-compose down 2>/dev/null || true) || true; done" @@ -85,10 +125,10 @@ jobs: ssh -o StrictHostKeyChecking=no $SSH_USER@$VM_HOST \ "docker volume create doce-global-pnpm-store 2>/dev/null || true" - # Step 3: Copy PR code to VM via tar - echo "๐Ÿ“‹ Copying PR code..." - tar czf - . --exclude=node_modules --exclude=.git --exclude=dist --exclude=data | \ - ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" "cd \"$REPO_DIR\" && tar xzf -" + # Step 3: Copy PR code to VM via tar + echo "๐Ÿ“‹ Copying PR code..." + tar czf - --exclude=node_modules --exclude=.git --exclude=dist --exclude=data . | \ + ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" "cd \"$REPO_DIR\" && tar xzf -" # Step 4: Allocate port echo "๐Ÿ”Œ Allocating port..." @@ -100,8 +140,10 @@ jobs: if [ "$DOCKERFILE_CHANGED" = "true" ]; then echo "๐Ÿ”จ Building Docker image (Dockerfile changed)..." + echo "โฑ๏ธ Build started at $(date)" ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" \ - "cd \"$REPO_DIR\" && docker build -t doce-pr-$PR_NUM:latest ." + "cd \"$REPO_DIR\" && DOCKER_BUILDKIT=1 docker build -t doce-pr-$PR_NUM:latest ." + echo "โฑ๏ธ Build completed at $(date)" else echo "๐Ÿ“ฅ Pulling pre-built image from registry (Dockerfile unchanged)..." ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" \ @@ -202,17 +244,18 @@ jobs: echo "๐Ÿงน Cleaning up PR #$PR_NUM preview" + # Stop containers (ignore if directory doesn't exist) ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" \ - "cd \"$PR_DIR\" && docker-compose down 2>/dev/null || true" + "[ -d \"$PR_DIR\" ] && cd \"$PR_DIR\" && docker-compose down 2>/dev/null || true" - ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" "$SCRIPTS_DIR/release-port.sh $PR_NUM" + ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" "$SCRIPTS_DIR/release-port.sh $PR_NUM 2>/dev/null || true" - ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" "docker rmi -f doce-pr-$PR_NUM:latest || true" + ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" "docker rmi -f doce-pr-$PR_NUM:latest 2>/dev/null || true" ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" \ - "docker image prune -a --force" || true + "docker system prune -af --volumes 2>/dev/null || true" - ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" "rm -rf \"$PR_DIR\" || true" + ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" "rm -rf \"$PR_DIR\" 2>/dev/null || true" echo "โœ… PR preview cleaned up!" diff --git a/.gitignore b/.gitignore index 43a556dd..41f4dbc4 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,6 @@ coverage/ # Astro .astro + +# Agent artifacts +.sisyphus diff --git a/.opencode/agent/debugger.md b/.opencode/agent/debugger.md index e55c2435..6891fadc 100644 --- a/.opencode/agent/debugger.md +++ b/.opencode/agent/debugger.md @@ -1,245 +1,31 @@ --- -description: >- - Run a live dev server and test the app in the browser to solve whatever you're asked to +description: Debug web applications using browser DevTools and server logs mode: primary --- -Expert in debugging web applications using Chrome DevTools MCP tools and server-side log analysis. +Debug web applications using Chrome DevTools MCP tools and server-side log analysis. -## Core Expertise -- Dev Server Management: background processes, log piping, cleanup -- Browser Automation: navigation, page inspection, UI interaction -- Console Debugging: error tracking, log analysis, message filtering -- Network Analysis: request inspection, response analysis, performance monitoring -- Screenshot & Snapshot: page capture, element documentation, visual debugging -- Performance Tracing: Core Web Vitals, insight analysis, load time breakdown -- Server-Side Debugging: log file analysis, error detection, process monitoring +## Required MCPs -## Use Context7 for Documentation -```bash -# Resolve and fetch Chrome DevTools Protocol docs -context7_resolve-library-id({ libraryName: "Chrome DevTools Protocol" }) -context7_query-docs({ - context7CompatibleLibraryID: "/ChromeDevTools/devtools-protocol", - query: "Page Runtime Network Console DOM snapshots performance" -}) -``` +- `chrome-devtools` - Browser automation, debugging, screenshots, network analysis +- `skill_mcp` (with `playwright` skill) - Alternative browser automation for complex flows -## Essential Patterns +## Debug Behavior -### Dev Server Management -```bash -# Start dev server in background with log piping -pnpm dev > /tmp/dev-server.log 2>&1 & +1. **Start dev server**: Run `pnpm dev` in background, pipe logs to `/tmp/dev-server.log` +2. **Navigate**: Open the page, take snapshot to understand current state +3. **Inspect**: Check console errors, network requests, and page structure +4. **Interact**: Click, fill forms, trigger actions to reproduce issues +5. **Verify**: Confirm fix by re-testing the scenario -# Monitor server logs for errors -tail -f /tmp/dev-server.log -grep -i "error" /tmp/dev-server.log +Always check both browser console AND server logs when debugging. -# Find and kill the dev server process -ps aux | grep "pnpm dev" | grep -v grep -pkill -f "pnpm dev" -``` +## Happy Path (Default Test Flow) -### Page Navigation -```javascript -// List available pages -chrome-devtools_list_pages() +When no specific test is requested: -// Navigate to URL -chrome-devtools_navigate_page({ type: "url", url: "http://localhost:3000" }) - -// Navigate back/forward/reload -chrome-devtools_navigate_page({ type: "back" }) -chrome-devtools_navigate_page({ type: "forward" }) -chrome-devtools_navigate_page({ type: "reload", ignoreCache: true }) -``` - -### Page Inspection -```javascript -// Take snapshot (preferred over screenshots) -chrome-devtools_take_snapshot() - -// Take full page screenshot -chrome-devtools_take_screenshot({ fullPage: true }) - -// Take element screenshot -chrome-devtools_take_screenshot({ uid: "element-uid" }) - -// Resize viewport -chrome-devtools_resize_page({ width: 1920, height: 1080 }) -``` - -### UI Interaction -```javascript -// Click element -chrome-devtools_click({ uid: "button-uid" }) - -// Double click -chrome-devtools_click({ uid: "element-uid", dblClick: true }) - -// Fill form element -chrome-devtools_fill({ uid: "input-uid", value: "text" }) - -// Fill multiple form elements at once -chrome-devtools_fill_form({ - elements: [ - { uid: "email-uid", value: "test@example.com" }, - { uid: "password-uid", value: "secret123" } - ] -}) - -// Hover over element -chrome-devtools_hover({ uid: "menu-uid" }) - -// Press key or key combination -chrome-devtools_press_key({ key: "Enter" }) -chrome-devtools_press_key({ key: "Control+Shift+R" }) -``` - -### Console Debugging -```javascript -// List all console messages -chrome-devtools_list_console_messages() - -// Filter by message type -chrome-devtools_list_console_messages({ - types: ["error", "warn"] -}) - -// Get specific console message details -chrome-devtools_get_console_message({ msgid: 123 }) -``` - -### Network Analysis -```javascript -// List all network requests -chrome-devtools_list_network_requests() - -// Filter by resource type -chrome-devtools_list_network_requests({ - resourceTypes: ["xhr", "fetch"] -}) - -// Get specific network request details -chrome-devtools_get_network_request({ reqid: 456 }) - -// Use pagination for large request lists -chrome-devtools_list_network_requests({ - pageIdx: 0, - pageSize: 50 -}) -``` - -### Performance Tracing -```javascript -// Start performance trace with reload -chrome-devtools_performance_start_trace({ - reload: true, - autoStop: true, - filePath: "trace.json" -}) - -// Stop trace manually -chrome-devtools_performance_stop_trace({ filePath: "trace.json" }) - -// Analyze specific performance insight -chrome-devtools_performance_analyze_insight({ - insightSetId: "insight-set-id", - insightName: "LCPBreakdown" -}) -``` - -### Emulation -```javascript -// Emulate geolocation -chrome-devtools_emulate({ - geolocation: { latitude: 37.7749, longitude: -122.4194 } -}) - -// Throttle network -chrome-devtools_emulate({ - networkConditions: "Slow 4G" -}) - -// Throttle CPU -chrome-devtools_emulate({ - cpuThrottlingRate: 4 -}) - -// Clear emulation -chrome-devtools_emulate({ - geolocation: null, - networkConditions: "No emulation", - cpuThrottlingRate: 1 -}) -``` - -### JavaScript Evaluation -```javascript -// Run JavaScript in page context -chrome-devtools_evaluate_script({ - function: "() => { return document.title; }" -}) - -// Pass element as argument -chrome-devtools_evaluate_script({ - function: "(el) => { return el.innerText; }", - args: [{ uid: "element-uid" }] -}) -``` - -### File Upload -```javascript -// Upload file through file input -chrome-devtools_upload_file({ - uid: "file-input-uid", - filePath: "/path/to/file.txt" -}) -``` - -### Dialog Handling -```javascript -// Accept dialog -chrome-devtools_handle_dialog({ action: "accept" }) - -// Dismiss dialog -chrome-devtools_handle_dialog({ action: "dismiss" }) - -// Accept with prompt text -chrome-devtools_handle_dialog({ - action: "accept", - promptText: "Enter text here" -}) -``` - -### Waiting -```javascript -// Wait for text to appear -chrome-devtools_wait_for({ text: "Welcome" }) - -// Wait with custom timeout -chrome-devtools_wait_for({ text: "Loaded", timeout: 10000 }) -``` - -### Drag and Drop -```javascript -// Drag element onto another -chrome-devtools_drag({ - from_uid: "draggable-uid", - to_uid: "dropzone-uid" -}) -``` - -## Best Practices -- Always pipe dev server logs to `/tmp/dev-server.log` for background processes -- Use snapshots instead of screenshots when possible - they're faster and more accessible -- Check both browser console (`chrome-devtools_list_console_messages`) and server logs (`/tmp/dev-server.log`) when debugging -- Use specific element UIDs from snapshots rather than generic selectors -- Always cleanup dev server processes when done debugging -- Use performance traces when investigating slow load times or Core Web Vitals -- Filter console messages and network requests to focus on relevant data -- Use pagination for large request/response lists to avoid overwhelming output -- Test responsive behavior by resizing viewport to common sizes: 375x667 (mobile), 768x1024 (tablet), 1920x1080 (desktop) -- Emulate network conditions to test slow connections -- Check for JavaScript errors first when pages aren't behaving as expected +1. **Auth** - Signup/login with `admin/admin` +2. **Setup API Key** - Set OpenRouter key (from `$OPENROUTER_API_KEY` or ask user) +3. **Create Project** - "Minimal digital clock. Big, on the center. HH:MM" +4. **Verify Preview** - Check website loads in preview panel +5. **Request Change** - "Change clock to be red" and verify update diff --git a/AGENTS.md b/AGENTS.md index 56224d14..dd0e3204 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,10 @@ An open-source, self-hostable web UI for building and deploying websites with AI * For CRUD operations, prefer React hooks or programmatic fetch calls (stil, astro actions) over HTML forms. * Avoid success/error/info callouts in components, always use `sonner` component for feedback after actions. * Avoid `any` types when possible, everything should be typed correctly without much type duplication or casting. If there's a valid reason to use `any`, mention it in a comment. +* Use Biome (`pnpm format`) for formatting/linting, not ESLint/Prettier. +* Use `defineAction` with Zod `input` schemas for all server actions. +* Use `@/server/logger` (Pino) instead of `console.*` in server code. +* API routes handle their own auth via cookies; don't assume middleware auth for `/_actions` and `/api`. ## Clean code diff --git a/Dockerfile b/Dockerfile index 6b11689a..11a8d38b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,7 @@ RUN pnpm build FROM node:22-alpine # Install dumb-init for proper PID 1 handling and docker for preview environments -RUN apk add --no-cache dumb-init curl docker-cli docker-cli-compose docker-compose git +RUN apk add --no-cache dumb-init curl docker-cli docker-cli-compose docker-compose git bash # Install pnpm for running migrations RUN npm install -g pnpm@10.20.0 @@ -40,10 +40,15 @@ COPY --from=builder /app/drizzle ./drizzle COPY --from=builder /app/templates ./templates COPY package.json ./ COPY drizzle.config.ts ./ +COPY scripts/start-preview.sh ./scripts/ +COPY scripts/bootstrap.sh ./scripts/ # Create data directory for SQLite database RUN mkdir -p /app/data +# Make scripts executable +RUN chmod +x /app/scripts/start-preview.sh /app/scripts/bootstrap.sh + # Run as root for Docker socket access in preview environments # (container runs as root for Docker daemon access) diff --git a/docker-compose.preview.yml b/docker-compose.preview.yml index 3d368fdf..13493d90 100644 --- a/docker-compose.preview.yml +++ b/docker-compose.preview.yml @@ -1,4 +1,3 @@ -version: '3.8' services: app: image: doce-pr-${PR_NUM}:latest @@ -8,6 +7,7 @@ services: PORT: 4321 HOST: 0.0.0.0 DOCE_NETWORK: doce-preview-${PR_NUM} + PREVIEW_ENV: "true" volumes: - pr_${PR_NUM}_data:/app/data - /var/run/docker.sock:/var/run/docker.sock @@ -15,6 +15,7 @@ services: - preview - doce-shared restart: unless-stopped + command: ["/bin/sh", "/app/scripts/start-preview.sh"] healthcheck: test: ["CMD", "curl", "-f", "http://localhost:4321/"] interval: 10s diff --git a/drizzle/0000_cultured_mother_askani.sql b/drizzle/0000_brief_tempest.sql similarity index 96% rename from drizzle/0000_cultured_mother_askani.sql rename to drizzle/0000_brief_tempest.sql index 2ab7c2f4..91822fd4 100644 --- a/drizzle/0000_cultured_mother_askani.sql +++ b/drizzle/0000_brief_tempest.sql @@ -15,9 +15,9 @@ CREATE TABLE `projects` ( `bootstrap_session_id` text, `user_prompt_message_id` text, `user_prompt_completed` integer DEFAULT false NOT NULL, - `production_port` integer, + `production_port` integer NOT NULL, `production_url` text, - `production_status` text DEFAULT 'stopped', + `production_status` text DEFAULT 'stopped' NOT NULL, `production_started_at` integer, `production_error` text, `production_hash` text, diff --git a/drizzle/0001_supreme_yellowjacket.sql b/drizzle/0001_supreme_yellowjacket.sql new file mode 100644 index 00000000..6e0b59a2 --- /dev/null +++ b/drizzle/0001_supreme_yellowjacket.sql @@ -0,0 +1,5 @@ +ALTER TABLE `projects` ADD `opencode_error_category` text;--> statement-breakpoint +ALTER TABLE `projects` ADD `opencode_error_code` text;--> statement-breakpoint +ALTER TABLE `projects` ADD `opencode_error_message` text;--> statement-breakpoint +ALTER TABLE `projects` ADD `opencode_error_source` text;--> statement-breakpoint +ALTER TABLE `projects` ADD `opencode_error_at` integer; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json index a1d2dfe2..576fef12 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/meta/0000_snapshot.json @@ -1,553 +1,578 @@ { - "version": "6", - "dialect": "sqlite", - "id": "b0f2f20e-d051-4d78-aa66-ba082b5b9bb2", - "prevId": "00000000-0000-0000-0000-000000000000", - "tables": { - "projects": { - "name": "projects", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "owner_user_id": { - "name": "owner_user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "deleted_at": { - "name": "deleted_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "slug": { - "name": "slug", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "prompt": { - "name": "prompt", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "dev_port": { - "name": "dev_port", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "opencode_port": { - "name": "opencode_port", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'created'" - }, - "path_on_disk": { - "name": "path_on_disk", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "initial_prompt_sent": { - "name": "initial_prompt_sent", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "initial_prompt_completed": { - "name": "initial_prompt_completed", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "bootstrap_session_id": { - "name": "bootstrap_session_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "user_prompt_message_id": { - "name": "user_prompt_message_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "user_prompt_completed": { - "name": "user_prompt_completed", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "production_port": { - "name": "production_port", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "production_url": { - "name": "production_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "production_status": { - "name": "production_status", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": "'stopped'" - }, - "production_started_at": { - "name": "production_started_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "production_error": { - "name": "production_error", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "production_hash": { - "name": "production_hash", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - } - }, - "indexes": { - "projects_slug_unique": { - "name": "projects_slug_unique", - "columns": ["slug"], - "isUnique": true - } - }, - "foreignKeys": { - "projects_owner_user_id_users_id_fk": { - "name": "projects_owner_user_id_users_id_fk", - "tableFrom": "projects", - "tableTo": "users", - "columnsFrom": ["owner_user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "queue_jobs": { - "name": "queue_jobs", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "state": { - "name": "state", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'queued'" - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "payload_json": { - "name": "payload_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "priority": { - "name": "priority", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "attempts": { - "name": "attempts", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 0 - }, - "max_attempts": { - "name": "max_attempts", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 3 - }, - "run_at": { - "name": "run_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "locked_at": { - "name": "locked_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "lock_expires_at": { - "name": "lock_expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "locked_by": { - "name": "locked_by", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "dedupe_key": { - "name": "dedupe_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "dedupe_active": { - "name": "dedupe_active", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "cancel_requested_at": { - "name": "cancel_requested_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "cancelled_at": { - "name": "cancelled_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_error": { - "name": "last_error", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "queue_jobs_project_id_idx": { - "name": "queue_jobs_project_id_idx", - "columns": ["project_id"], - "isUnique": false - }, - "queue_jobs_runnable_idx": { - "name": "queue_jobs_runnable_idx", - "columns": ["state", "run_at", "lock_expires_at"], - "isUnique": false - }, - "queue_jobs_dedupe_idx": { - "name": "queue_jobs_dedupe_idx", - "columns": ["dedupe_key", "dedupe_active"], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "queue_settings": { - "name": "queue_settings", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "paused": { - "name": "paused", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": false - }, - "concurrency": { - "name": "concurrency", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": 2 - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "sessions": { - "name": "sessions", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "token_hash": { - "name": "token_hash", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": { - "sessions_token_hash_unique": { - "name": "sessions_token_hash_unique", - "columns": ["token_hash"], - "isUnique": true - } - }, - "foreignKeys": { - "sessions_user_id_users_id_fk": { - "name": "sessions_user_id_users_id_fk", - "tableFrom": "sessions", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "user_settings": { - "name": "user_settings", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "openrouter_api_key": { - "name": "openrouter_api_key", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "default_model": { - "name": "default_model", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "user_settings_user_id_users_id_fk": { - "name": "user_settings_user_id_users_id_fk", - "tableFrom": "user_settings", - "tableTo": "users", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "users": { - "name": "users", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "password_hash": { - "name": "password_hash", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} + "version": "6", + "dialect": "sqlite", + "id": "78ee3b27-2fbe-4d21-9c4c-842c6e86e079", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dev_port": { + "name": "dev_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "opencode_port": { + "name": "opencode_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'created'" + }, + "path_on_disk": { + "name": "path_on_disk", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initial_prompt_sent": { + "name": "initial_prompt_sent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "initial_prompt_completed": { + "name": "initial_prompt_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bootstrap_session_id": { + "name": "bootstrap_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_prompt_message_id": { + "name": "user_prompt_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_prompt_completed": { + "name": "user_prompt_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "production_port": { + "name": "production_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "production_url": { + "name": "production_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "production_status": { + "name": "production_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stopped'" + }, + "production_started_at": { + "name": "production_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "production_error": { + "name": "production_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "production_hash": { + "name": "production_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "projects_owner_user_id_users_id_fk": { + "name": "projects_owner_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queue_jobs": { + "name": "queue_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lock_expires_at": { + "name": "lock_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupe_active": { + "name": "dedupe_active", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_requested_at": { + "name": "cancel_requested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "queue_jobs_project_id_idx": { + "name": "queue_jobs_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "queue_jobs_runnable_idx": { + "name": "queue_jobs_runnable_idx", + "columns": [ + "state", + "run_at", + "lock_expires_at" + ], + "isUnique": false + }, + "queue_jobs_dedupe_idx": { + "name": "queue_jobs_dedupe_idx", + "columns": [ + "dedupe_key", + "dedupe_active" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queue_settings": { + "name": "queue_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "paused": { + "name": "paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "concurrency": { + "name": "concurrency", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_settings": { + "name": "user_settings", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "openrouter_api_key": { + "name": "openrouter_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 00000000..e6a93b40 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,613 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "45a88c9f-24d9-40ac-b81a-8dc71439747e", + "prevId": "78ee3b27-2fbe-4d21-9c4c-842c6e86e079", + "tables": { + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dev_port": { + "name": "dev_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "opencode_port": { + "name": "opencode_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'created'" + }, + "path_on_disk": { + "name": "path_on_disk", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "initial_prompt_sent": { + "name": "initial_prompt_sent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "initial_prompt_completed": { + "name": "initial_prompt_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "bootstrap_session_id": { + "name": "bootstrap_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_prompt_message_id": { + "name": "user_prompt_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_prompt_completed": { + "name": "user_prompt_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "production_port": { + "name": "production_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "production_url": { + "name": "production_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "production_status": { + "name": "production_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stopped'" + }, + "production_started_at": { + "name": "production_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "production_error": { + "name": "production_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "production_hash": { + "name": "production_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_error_category": { + "name": "opencode_error_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_error_code": { + "name": "opencode_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_error_message": { + "name": "opencode_error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_error_source": { + "name": "opencode_error_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_error_at": { + "name": "opencode_error_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "projects_owner_user_id_users_id_fk": { + "name": "projects_owner_user_id_users_id_fk", + "tableFrom": "projects", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queue_jobs": { + "name": "queue_jobs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "run_at": { + "name": "run_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lock_expires_at": { + "name": "lock_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locked_by": { + "name": "locked_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dedupe_active": { + "name": "dedupe_active", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_requested_at": { + "name": "cancel_requested_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "queue_jobs_project_id_idx": { + "name": "queue_jobs_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "queue_jobs_runnable_idx": { + "name": "queue_jobs_runnable_idx", + "columns": [ + "state", + "run_at", + "lock_expires_at" + ], + "isUnique": false + }, + "queue_jobs_dedupe_idx": { + "name": "queue_jobs_dedupe_idx", + "columns": [ + "dedupe_key", + "dedupe_active" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "queue_settings": { + "name": "queue_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "paused": { + "name": "paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "concurrency": { + "name": "concurrency", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_settings": { + "name": "user_settings", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "openrouter_api_key": { + "name": "openrouter_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d001927a..3bb019a1 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1,13 +1,20 @@ { - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1767962667134, - "tag": "0000_cultured_mother_askani", - "breakpoints": true - } - ] -} + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1769712818274, + "tag": "0000_brief_tempest", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1771242358542, + "tag": "0001_supreme_yellowjacket", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/opencode.json b/opencode.json deleted file mode 100644 index 3488a8af..00000000 --- a/opencode.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "agent": { - "plan": { - "model": "opencode/glm-4.7-free" - }, - "build": { - "model": "opencode/glm-4.7-free" - }, - "debugger": { - "model": "opencode/glm-4.7-free" - } - } -} diff --git a/package.json b/package.json index 10be6f2a..f6cb9bb2 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "type": "module", "scripts": { - "bootstrap": "mkdir -p data && pnpm install && pnpm drizzle:migrate", + "bootstrap": "bash scripts/bootstrap.sh", "postinstall": "command -v git >/dev/null 2>&1 && git config core.hooksPath .githooks || true", "dev": "astro dev", "build": "astro build", @@ -66,6 +66,7 @@ "drizzle-kit": "^0.31.8", "pino-pretty": "^13.1.3", "sharp": "^0.34.5", + "tsx": "^4.21.0", "typescript": "^5.9.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35f14cb7..bf2fd71d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,10 @@ importers: dependencies: '@astrojs/node': specifier: 10.0.0-beta.0 - version: 10.0.0-beta.0(astro@6.0.0-beta.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(typescript@5.9.3)) + version: 10.0.0-beta.0(astro@6.0.0-beta.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(tsx@4.21.0)(typescript@5.9.3)) '@astrojs/react': specifier: 5.0.0-beta.1 - version: 5.0.0-beta.1(@types/node@25.0.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 5.0.0-beta.1(@types/node@25.0.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tsx@4.21.0) '@base-ui/react': specifier: ^1.0.0 version: 1.0.0(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -34,7 +34,7 @@ importers: version: 0.3.10 '@tailwindcss/vite': specifier: ^4.1.18 - version: 4.1.18(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)) + version: 4.1.18(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) ai: specifier: ^6.0.17 version: 6.0.17(zod@4.1.13) @@ -43,7 +43,7 @@ importers: version: 0.44.0 astro: specifier: 6.0.0-beta.1 - version: 6.0.0-beta.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(typescript@5.9.3) + version: 6.0.0-beta.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(tsx@4.21.0)(typescript@5.9.3) better-sqlite3: specifier: ^12.5.0 version: 12.5.0 @@ -141,6 +141,9 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -3910,6 +3913,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -4316,10 +4324,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/node@10.0.0-beta.0(astro@6.0.0-beta.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(typescript@5.9.3))': + '@astrojs/node@10.0.0-beta.0(astro@6.0.0-beta.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(tsx@4.21.0)(typescript@5.9.3))': dependencies: '@astrojs/internal-helpers': 0.7.5 - astro: 6.0.0-beta.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(typescript@5.9.3) + astro: 6.0.0-beta.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(tsx@4.21.0)(typescript@5.9.3) send: 1.2.1 server-destroy: 1.0.1 transitivePeerDependencies: @@ -4329,15 +4337,15 @@ snapshots: dependencies: prismjs: 1.30.0 - '@astrojs/react@5.0.0-beta.1(@types/node@25.0.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@astrojs/react@5.0.0-beta.1(@types/node@25.0.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(tsx@4.21.0)': dependencies: '@types/react': 19.2.7 '@types/react-dom': 19.2.3(@types/react@19.2.7) - '@vitejs/plugin-react': 4.7.0(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)) + '@vitejs/plugin-react': 4.7.0(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) ultrahtml: 1.6.0 - vite: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - '@types/node' - jiti @@ -5490,12 +5498,12 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.18 - '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2))': + '@tailwindcss/vite@4.1.18(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@tailwindcss/node': 4.1.18 '@tailwindcss/oxide': 4.1.18 tailwindcss: 4.1.18 - vite: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) '@ts-morph/common@0.27.0': dependencies: @@ -5577,7 +5585,7 @@ snapshots: '@vercel/oidc@3.1.0': {} - '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2))': + '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -5585,7 +5593,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -5657,7 +5665,7 @@ snapshots: dependencies: tslib: 2.8.1 - astro@6.0.0-beta.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(typescript@5.9.3): + astro@6.0.0-beta.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(rollup@4.53.3)(tsx@4.21.0)(typescript@5.9.3): dependencies: '@astrojs/compiler': 0.0.0-render-script-20251003120459 '@astrojs/internal-helpers': 0.7.5 @@ -5711,8 +5719,8 @@ snapshots: unist-util-visit: 5.0.0 unstorage: 1.17.3 vfile: 6.0.3 - vite: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2) - vitefu: 1.1.1(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)) + vite: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + vitefu: 1.1.1(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)) xxhash-wasm: 1.1.0 yargs-parser: 21.1.1 yocto-spinner: 0.2.3 @@ -8197,6 +8205,13 @@ snapshots: tslib@2.8.1: {} + tsx@4.21.0: + dependencies: + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -8346,7 +8361,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2): + vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -8359,10 +8374,11 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 + tsx: 4.21.0 - vitefu@1.1.1(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)): + vitefu@1.1.1(vite@7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)): optionalDependencies: - vite: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2) + vite: 7.3.1(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) web-namespaces@2.0.1: {} diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100644 index 00000000..9cb0bbe8 --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Bootstrap script that never fails - handles fresh database setup +set -e + +mkdir -p data + +# Install dependencies +pnpm install + +# If no migrations exist, generate them from schema first +if [ ! -f "drizzle/meta/_journal.json" ]; then + echo "No migrations found, generating from schema..." + pnpm drizzle-kit generate +fi + +# Run migrations +echo "Running database migrations..." +pnpm drizzle-kit migrate + +echo "Bootstrap completed successfully!" diff --git a/scripts/start-preview.sh b/scripts/start-preview.sh new file mode 100644 index 00000000..308e8f14 --- /dev/null +++ b/scripts/start-preview.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +# Startup script for preview environments +# Handles migrations with automatic DB reset on failure + +set -e + +echo "๐Ÿš€ Starting doce.dev preview environment..." + +# Function to run bootstrap with fallback to DB wipe on preview environments +run_bootstrap() { + local is_preview="${PREVIEW_ENV:-false}" + + # Try to run bootstrap normally + echo "๐Ÿ“ฆ Running bootstrap..." + if pnpm bootstrap; then + echo "โœ… Bootstrap completed successfully!" + return 0 + fi + + # If bootstrap failed and we're in a preview environment, wipe the DB and retry + if [ "$is_preview" = "true" ]; then + echo "โš ๏ธ Bootstrap failed in preview environment" + echo "๐Ÿงน Wiping database and retrying..." + + # Remove database files + rm -f /app/data/db.sqlite + rm -f /app/data/db.sqlite-shm + rm -f /app/data/db.sqlite-wal + + # Retry bootstrap + if pnpm bootstrap; then + echo "โœ… Bootstrap completed after DB wipe!" + return 0 + fi + fi + + echo "โŒ Bootstrap failed" + return 1 +} + +# Run bootstrap (with DB wipe fallback on preview) +run_bootstrap + +# Start the application +echo "๐ŸŽฏ Starting application..." +exec node ./dist/server/entry.mjs diff --git a/src/actions/assets.ts b/src/actions/assets.ts index c1fd2036..4a0cb74a 100644 --- a/src/actions/assets.ts +++ b/src/actions/assets.ts @@ -2,6 +2,7 @@ import { ActionError, defineAction } from "astro:actions"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { z } from "astro/zod"; +import { getProjectPreviewPath } from "@/server/projects/paths"; import { isProjectOwnedByUser } from "@/server/projects/projects.model"; import { buildAssetsList, @@ -33,12 +34,7 @@ export const assets = { } try { - const projectPath = path.join( - process.cwd(), - "data", - "projects", - input.projectId, - ); + const projectPath = getProjectPreviewPath(input.projectId); const publicPath = path.join(projectPath, "public"); try { @@ -82,12 +78,7 @@ export const assets = { } try { - const projectPath = path.join( - process.cwd(), - "data", - "projects", - input.projectId, - ); + const projectPath = getProjectPreviewPath(input.projectId); const publicPath = path.join(projectPath, "public"); await fs.mkdir(publicPath, { recursive: true }); diff --git a/src/actions/projects.ts b/src/actions/projects.ts index 91811c5e..8dcd7c31 100644 --- a/src/actions/projects.ts +++ b/src/actions/projects.ts @@ -154,6 +154,13 @@ export const projects = { await updateProjectStatus(input.projectId, "deleting"); } catch {} + try { + const { cancelActiveProductionJobs } = await import( + "@/server/productions/productions.model" + ); + await cancelActiveProductionJobs(input.projectId); + } catch {} + const job = await enqueueProjectDelete({ projectId: input.projectId, requestedByUserId: user.id, @@ -193,6 +200,57 @@ export const projects = { }, }), + restart: defineAction({ + input: z.object({ + projectId: z.string(), + }), + handler: async (input, context) => { + const user = context.locals.user; + if (!user) { + throw new ActionError({ + code: "UNAUTHORIZED", + message: "You must be logged in to restart a project", + }); + } + + const isOwner = await isProjectOwnedByUser(input.projectId, user.id); + if (!isOwner) { + throw new ActionError({ + code: "FORBIDDEN", + message: "You don't have access to this project", + }); + } + + const { getProjectById } = await import( + "@/server/projects/projects.model" + ); + const project = await getProjectById(input.projectId); + if (!project) { + throw new ActionError({ + code: "NOT_FOUND", + message: "Project not found", + }); + } + + const { enqueueDockerEnsureRunning } = await import( + "@/server/queue/enqueue" + ); + + const { updateProjectStatus } = await import( + "@/server/projects/projects.model" + ); + + await updateProjectStatus(input.projectId, "starting"); + + const job = await enqueueDockerEnsureRunning({ + projectId: input.projectId, + reason: "user", + }); + + return { success: true, jobId: job.id }; + }, + }), + deploy: defineAction({ input: z.object({ projectId: z.string(), @@ -229,6 +287,16 @@ export const projects = { }); } + const { hasActiveDeployment } = await import( + "@/server/productions/productions.model" + ); + if (await hasActiveDeployment(input.projectId)) { + throw new ActionError({ + code: "CONFLICT", + message: "A deployment is already in progress", + }); + } + const job = await enqueueProductionBuild({ projectId: input.projectId, }); @@ -266,6 +334,11 @@ export const projects = { }); } + const { cancelActiveProductionJobs } = await import( + "@/server/productions/productions.model" + ); + await cancelActiveProductionJobs(input.projectId); + const job = await enqueueProductionStop(input.projectId); return { success: true, jobId: job.id }; @@ -388,10 +461,7 @@ export const projects = { const { getProductionVersions } = await import( "@/server/productions/cleanup" ); - const { deriveVersionPort } = await import("@/server/ports/allocate"); - const { updateProjectNginxRouting } = await import( - "@/server/productions/nginx" - ); + const { getProductionPath } = await import("@/server/projects/paths"); const { updateProductionStatus } = await import( "@/server/productions/productions.model" ); @@ -413,23 +483,77 @@ export const projects = { }); } - const basePort = project.productionPort; - if (!basePort) { + const productionPort = project.productionPort; + if (!productionPort) { throw new ActionError({ code: "BAD_REQUEST", message: "Project not initialized for production", }); } - const targetVersionPort = deriveVersionPort( - input.projectId, - input.toHash, + // Build Docker image for rollback version + const productionPath = getProductionPath(project.id, input.toHash); + const imageName = `doce-prod-${project.id}-${input.toHash}`; + + const { spawnCommand } = await import("@/server/utils/execAsync"); + const buildResult = await spawnCommand( + "docker", + ["build", "-t", imageName, "-f", "Dockerfile.prod", "."], + { cwd: productionPath }, ); - await updateProjectNginxRouting( - input.projectId, - input.toHash, - targetVersionPort, + + if (!buildResult.success) { + throw new ActionError({ + code: "INTERNAL_SERVER_ERROR", + message: `Docker build failed: ${buildResult.stderr.slice(0, 200)}`, + }); + } + + // Stop and remove old container + const containerName = `doce-prod-${project.id}`; + const stopResult = await spawnCommand("docker", ["stop", containerName]); + const removeResult = await spawnCommand("docker", ["rm", containerName]); + + if (!stopResult.success || !removeResult.success) { + // Container might not exist, continue + } + + // Start new container + const runResult = await spawnCommand("docker", [ + "run", + "-d", + "--name", + containerName, + "-p", + `${productionPort}:3000`, + "--restart", + "unless-stopped", + imageName, + ]); + + if (!runResult.success) { + throw new ActionError({ + code: "INTERNAL_SERVER_ERROR", + message: `Docker run failed: ${runResult.stderr.slice(0, 200)}`, + }); + } + + // Update symlink + const { getProductionCurrentSymlink } = await import( + "@/server/projects/paths" ); + const symlinkPath = getProductionCurrentSymlink(project.id); + const hashPath = getProductionPath(project.id, input.toHash); + const tempSymlink = `${symlinkPath}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + const fs = await import("node:fs/promises"); + try { + await fs.unlink(tempSymlink).catch(() => {}); + await fs.symlink(hashPath, tempSymlink); + await fs.rename(tempSymlink, symlinkPath); + } catch { + // Symlink update failed, but container is running + } await updateProductionStatus(input.projectId, "running", { productionHash: input.toHash, @@ -526,6 +650,7 @@ export const projects = { const SETUP_JOBS = [ "project.create", "docker.composeUp", + "docker.ensureRunning", "docker.waitReady", "opencode.sessionCreate", "opencode.sendUserPrompt", @@ -539,6 +664,14 @@ export const projects = { limit: 100, }); + type SetupJob = { + type: string; + state: "pending" | (typeof jobs)[number]["state"]; + error?: string; + completedAt?: number; + createdAt?: number; + }; + const jobsByType = new Map(); for (const job of jobs) { if ( @@ -555,12 +688,19 @@ export const projects = { } } - const setupJobs: Record = {}; + const setupJobs: Record = {}; let hasError = false; let errorMessage: string | undefined; let promptSentAt: number | undefined; let isSetupComplete = true; + const projectCreateJob = jobsByType.get("project.create"); + const dockerComposeUpJob = jobsByType.get("docker.composeUp"); + const dockerEnsureRunningJob = jobsByType.get("docker.ensureRunning"); + const dockerWaitReadyJob = jobsByType.get("docker.waitReady"); + const sessionCreateJob = jobsByType.get("opencode.sessionCreate"); + const sendPromptJob = jobsByType.get("opencode.sendUserPrompt"); + for (const jobType of SETUP_JOBS) { const job = jobsByType.get(jobType); if (!job) { @@ -570,14 +710,19 @@ export const projects = { setupJobs[jobType] = { type: jobType, state: job.state, - error: job.lastError || undefined, + ...(job.lastError ? { error: job.lastError } : {}), completedAt: job.updatedAt.getTime(), createdAt: job.createdAt.getTime(), }; if (job.state === "failed") { - hasError = true; - errorMessage = job.lastError || `${jobType} failed`; - isSetupComplete = false; + const isRecoveredDockerJob = + jobType === "docker.composeUp" && + dockerEnsureRunningJob?.state === "succeeded"; + if (!isRecoveredDockerJob) { + hasError = true; + errorMessage = job.lastError || `${jobType} failed`; + isSetupComplete = false; + } } if (jobType === "opencode.sendUserPrompt") { promptSentAt = job.updatedAt.getTime(); @@ -585,16 +730,17 @@ export const projects = { } } - const projectCreateJob = jobsByType.get("project.create"); - const dockerComposeUpJob = jobsByType.get("docker.composeUp"); - const dockerWaitReadyJob = jobsByType.get("docker.waitReady"); - const sessionCreateJob = jobsByType.get("opencode.sessionCreate"); - const sendPromptJob = jobsByType.get("opencode.sendUserPrompt"); + const dockerJob = + dockerComposeUpJob?.state === "succeeded" + ? dockerComposeUpJob + : dockerEnsureRunningJob?.state === "succeeded" + ? dockerEnsureRunningJob + : dockerComposeUpJob || dockerEnsureRunningJob; let currentStep = 0; if ( projectCreateJob?.state === "succeeded" && - dockerComposeUpJob?.state === "succeeded" && + dockerJob?.state === "succeeded" && dockerWaitReadyJob?.state === "succeeded" && sessionCreateJob?.state === "succeeded" && sendPromptJob?.state === "succeeded" @@ -602,7 +748,7 @@ export const projects = { currentStep = 4; } else if ( projectCreateJob?.state === "succeeded" && - dockerComposeUpJob?.state === "succeeded" && + dockerJob?.state === "succeeded" && dockerWaitReadyJob?.state === "succeeded" && sessionCreateJob?.state === "succeeded" ) { @@ -610,7 +756,7 @@ export const projects = { isSetupComplete = false; } else if ( projectCreateJob?.state === "succeeded" && - dockerComposeUpJob?.state === "succeeded" && + dockerJob?.state === "succeeded" && dockerWaitReadyJob?.state === "succeeded" ) { currentStep = 2; @@ -689,13 +835,13 @@ export const projects = { const status = getProductionStatus(project); const activeJob = await getActiveProductionJob(input.projectId); - const basePort = project.productionPort; - const url = basePort ? `http://localhost:${basePort}` : null; + const productionPort = project.productionPort; + const url = productionPort ? `http://localhost:${productionPort}` : null; return { status: status.status, url, - basePort, + productionPort, port: status.port, error: status.error, startedAt: status.startedAt?.toISOString() || null, @@ -741,27 +887,23 @@ export const projects = { const { getProductionVersions } = await import( "@/server/productions/cleanup" ); - const { deriveVersionPort } = await import("@/server/ports/allocate"); const versions = await getProductionVersions(input.projectId); - const basePort = project.productionPort; + const productionPort = project.productionPort; return { - basePort, - baseUrl: basePort ? `http://localhost:${basePort}` : null, + productionPort, + baseUrl: productionPort ? `http://localhost:${productionPort}` : null, versions: versions.map((v) => { - const versionPort = deriveVersionPort(input.projectId, v.hash); return { hash: v.hash, isActive: v.isActive, createdAt: v.mtimeIso, url: - v.isActive && basePort - ? `http://localhost:${basePort}` + v.isActive && productionPort + ? `http://localhost:${productionPort}` : undefined, - basePort, - versionPort, - previewUrl: `http://localhost:${versionPort}`, + productionPort, }; }), }; diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 11aba0f9..10f6e3cf 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -89,7 +89,9 @@ export const providers = { // Filter to only include curated models const filteredModels = availableModels.filter((model) => - CURATED_MODELS.includes(model.id as any), + CURATED_MODELS.some( + (curatedModelId) => curatedModelId === model.id, + ), ); // Enrich with vision support data diff --git a/src/actions/setup.ts b/src/actions/setup.ts index 92f5c8c5..d76a1030 100644 --- a/src/actions/setup.ts +++ b/src/actions/setup.ts @@ -10,9 +10,26 @@ import { logger } from "@/server/logger"; const SESSION_COOKIE_NAME = "doce_session"; +type SetupActionContext = { + request: Request; + cookies: { + set: ( + name: string, + value: string, + options: { + path: string; + httpOnly: boolean; + sameSite: "lax" | "strict" | "none"; + secure: boolean; + maxAge: number; + }, + ) => void; + }; +}; + async function doCreateAdmin( input: { username: string; password: string; confirmPassword: string }, - context: any, + context: SetupActionContext, ) { // Check if admin already exists const existingUsers = await db.select().from(users).limit(1); diff --git a/src/components/assets/AssetItem.tsx b/src/components/assets/AssetItem.tsx index d9372358..de399e5e 100644 --- a/src/components/assets/AssetItem.tsx +++ b/src/components/assets/AssetItem.tsx @@ -32,7 +32,6 @@ export function AssetItem({ isLoading = false, previewUrl, }: AssetItemProps) { - const [isHovering, setIsHovering] = useState(false); const [isRenaming, setIsRenaming] = useState(false); const [newName, setNewName] = useState(asset.name); const [showDeleteDialog, setShowDeleteDialog] = useState(false); @@ -134,11 +133,7 @@ export function AssetItem({ return ( <> -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - > +
{/* Thumbnail/Icon */}
{asset.isImage ? ( @@ -154,8 +149,8 @@ export function AssetItem({ )} {/* Action buttons overlay */} - {isHovering && !isRenaming && ( -
+ {!isRenaming && ( +
)} -
+ ); } diff --git a/src/components/chat/ChatDiagnostic.tsx b/src/components/chat/ChatDiagnostic.tsx new file mode 100644 index 00000000..8ffc4d02 --- /dev/null +++ b/src/components/chat/ChatDiagnostic.tsx @@ -0,0 +1,195 @@ +import { + AlertCircle, + ChevronDown, + ChevronUp, + RefreshCw, + Settings, + X, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import type { + OpencodeDiagnostic, + RemediationAction, +} from "@/server/opencode/diagnostics"; + +interface ChatDiagnosticProps { + diagnostic: OpencodeDiagnostic; + onDismiss?: () => void; + onRetry?: () => void; +} + +function getCategoryBadgeVariant( + category: OpencodeDiagnostic["category"], +): "default" | "destructive" | "outline" { + switch (category) { + case "auth": + case "runtime_unreachable": + return "destructive"; + case "provider_model": + return "outline"; + default: + return "default"; + } +} + +export function ChatDiagnostic({ + diagnostic, + onDismiss, + onRetry, +}: ChatDiagnosticProps) { + const [showDetails, setShowDetails] = useState(false); + + const handleRemediationClick = (action: RemediationAction) => { + if (action.href) { + window.location.href = action.href; + toast.info(`Navigating to ${action.label}...`); + return; + } + + if (!action.action) return; + + switch (action.action) { + case "retry": + onRetry?.(); + toast.success("Retrying your request..."); + break; + case "reconnectProvider": + toast.info("Reconnecting provider..."); + break; + case "restartProject": + toast.info("Restarting project containers..."); + break; + case "simplify": + toast.info( + "Try a simpler prompt or break your request into smaller tasks", + ); + break; + case "wait": + toast.info("Please wait for the service to become available"); + break; + default: + toast.info(`${action.label} action triggered`); + } + }; + + const badgeVariant = getCategoryBadgeVariant(diagnostic.category); + + return ( +
+
+ +
+
+

{diagnostic.title}

+ + {diagnostic.category} + +
+

+ {diagnostic.message} +

+ + {diagnostic.remediation.length > 0 && ( +
+ {diagnostic.remediation.map((action) => ( + handleRemediationClick(action)} + /> + ))} +
+ )} + + {diagnostic.technicalDetails && ( +
+ + + {showDetails && ( +
+
+
Error: {diagnostic.technicalDetails.errorName}
+
+ Message: {diagnostic.technicalDetails.errorMessage} +
+ {diagnostic.technicalDetails.stack && ( +
+ + Stack trace + +
+													{diagnostic.technicalDetails.stack}
+												
+
+ )} +
+
+ )} +
+ )} +
+ + {onDismiss && ( + + )} +
+
+ ); +} + +interface RemediationActionButtonProps { + action: RemediationAction; + onClick: () => void; +} + +function RemediationActionButton({ + action, + onClick, +}: RemediationActionButtonProps) { + const getIcon = () => { + if (action.action === "retry") return ; + if (action.href?.includes("settings")) + return ; + return null; + }; + + const icon = getIcon(); + + return ( + + ); +} diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 8dd5c0d4..62869e6e 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -4,6 +4,7 @@ import { type DragEvent, type FormEvent, type KeyboardEvent, + useCallback, useEffect, useRef, useState, @@ -89,7 +90,7 @@ export function ChatInput({ } }; - const adjustTextareaHeight = () => { + const adjustTextareaHeight = useCallback(() => { const textarea = textareaRef.current; if (!textarea) return; @@ -97,7 +98,7 @@ export function ChatInput({ const scrollHeight = textarea.scrollHeight; const maxHeight = 200; textarea.style.height = `${Math.min(scrollHeight, maxHeight)}px`; - }; + }, []); useEffect(() => { adjustTextareaHeight(); @@ -168,7 +169,7 @@ export function ChatInput({ } }; - const handleDragOver = (e: DragEvent) => { + const handleDragOver = (e: DragEvent) => { // Don't show drag state if images aren't supported if (!supportsImages) return; @@ -177,13 +178,13 @@ export function ChatInput({ setIsDragging(true); }; - const handleDragLeave = (e: DragEvent) => { + const handleDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }; - const handleDrop = (e: DragEvent) => { + const handleDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); @@ -258,12 +259,13 @@ export function ChatInput({ return (
-
e.preventDefault()} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} @@ -352,7 +354,7 @@ export function ChatInput({
-
+
); diff --git a/src/components/chat/ChatMessages.tsx b/src/components/chat/ChatMessages.tsx index 3bd75558..c50d28a1 100644 --- a/src/components/chat/ChatMessages.tsx +++ b/src/components/chat/ChatMessages.tsx @@ -47,7 +47,10 @@ function groupConsecutiveTools( )[] = []; for (let i = 0; i < items.length; i++) { - const item = items[i]!; + const item = items[i]; + if (!item) { + continue; + } if (item.type === "tool") { const toolGroup: ToolCall[] = [item.data as ToolCall]; diff --git a/src/components/chat/ChatPanel.tsx b/src/components/chat/ChatPanel.tsx index 1557647a..6c156219 100644 --- a/src/components/chat/ChatPanel.tsx +++ b/src/components/chat/ChatPanel.tsx @@ -1,5 +1,6 @@ import { Loader2 } from "lucide-react"; import { useChatPanel } from "@/hooks/useChatPanel"; +import { ChatDiagnostic } from "./ChatDiagnostic"; import { ChatInput } from "./ChatInput"; import { ChatMessages } from "./ChatMessages"; @@ -33,12 +34,14 @@ export function ChatPanel({ currentModel, expandedTools, scrollRef, + latestDiagnostic, setPendingImages, setPendingImageError, handleSend, handleModelChange, toggleToolExpanded, handleScroll, + clearDiagnostic, } = useChatPanel({ projectId, models, onStreamingStateChange }); return ( @@ -67,6 +70,12 @@ export function ChatPanel({ onOpenFile={onOpenFile} /> )} + {latestDiagnostic && ( + + )}
{(() => { diff --git a/src/components/dashboard/CreateProjectFormContent.tsx b/src/components/dashboard/CreateProjectFormContent.tsx index f4826fd6..0b5cb131 100644 --- a/src/components/dashboard/CreateProjectFormContent.tsx +++ b/src/components/dashboard/CreateProjectFormContent.tsx @@ -103,13 +103,14 @@ export function CreateProjectFormContent({ {imageError && (

{imageError}

)} -
+
-
+
{/* Hidden File Input - only render when images are supported */} {currentModelSupportsImages && ( {isLoading ? ( ) : ( )} - + Create diff --git a/src/components/dashboard/ModelSelector.tsx b/src/components/dashboard/ModelSelector.tsx index d4a866cf..9893ca18 100644 --- a/src/components/dashboard/ModelSelector.tsx +++ b/src/components/dashboard/ModelSelector.tsx @@ -8,6 +8,7 @@ import { Lock, Zap, } from "lucide-react"; +import type { ComponentType, SVGProps } from "react"; import { useEffect, useState } from "react"; import { Command, @@ -25,6 +26,7 @@ import { import { AnthropicBlack } from "@/components/ui/svgs/anthropicBlack"; import { AnthropicWhite } from "@/components/ui/svgs/anthropicWhite"; import { Gemini } from "@/components/ui/svgs/gemini"; +import { Kimi } from "@/components/ui/svgs/kimi"; import { Minimax } from "@/components/ui/svgs/minimax"; import { MinimaxDark } from "@/components/ui/svgs/minimaxDark"; import { Openai } from "@/components/ui/svgs/openai"; @@ -32,19 +34,38 @@ import { OpenaiDark } from "@/components/ui/svgs/openaiDark"; import { ZaiDark } from "@/components/ui/svgs/zaiDark"; import { ZaiLight } from "@/components/ui/svgs/zaiLight"; import { Tooltip, TooltipContent } from "@/components/ui/tooltip"; +import { toVendorSlug } from "@/lib/modelVendor"; import { cn } from "@/lib/utils"; const VENDOR_LOGOS: Record< string, - { light: React.ComponentType; dark: React.ComponentType } + { + light: ComponentType>; + dark: ComponentType>; + } > = { openai: { light: Openai, dark: OpenaiDark }, anthropic: { light: AnthropicBlack, dark: AnthropicWhite }, google: { light: Gemini, dark: Gemini }, - "z.ai": { light: ZaiLight, dark: ZaiDark }, + "z-ai": { light: ZaiLight, dark: ZaiDark }, minimax: { light: Minimax, dark: MinimaxDark }, + kimi: { light: Kimi, dark: Kimi }, + moonshot: { light: Kimi, dark: Kimi }, }; +function getVendorLogoVariants( + vendor: string, + provider: string, +): { + light: ComponentType>; + dark: ComponentType>; +} | null { + const vendorKey = toVendorSlug(vendor); + const providerKey = toVendorSlug(provider); + + return VENDOR_LOGOS[vendorKey] ?? VENDOR_LOGOS[providerKey] ?? null; +} + interface ModelSelectorProps { models: ReadonlyArray<{ id: string; @@ -59,6 +80,7 @@ interface ModelSelectorProps { }>; selectedModelId: string; onModelChange: (modelId: string) => void; + triggerClassName?: string; } function getTierIcon(tier?: string) { @@ -89,6 +111,7 @@ export function ModelSelector({ models, selectedModelId, onModelChange, + triggerClassName, }: ModelSelectorProps) { const [theme, setTheme] = useState<"light" | "dark">("light"); const [open, setOpen] = useState(false); @@ -119,12 +142,15 @@ export function ModelSelector({ // Auto-select first model if current selection is invalid useEffect(() => { if (!selectedModel && models.length > 0) { - onModelChange(getModelKey(models[0]?.provider, models[0]?.id)); + const firstModel = models[0]; + if (firstModel) { + onModelChange(getModelKey(firstModel.provider, firstModel.id)); + } } }, [models, selectedModel, onModelChange]); const vendorLogo = selectedModel - ? VENDOR_LOGOS[selectedModel.vendor.toLowerCase()] + ? getVendorLogoVariants(selectedModel.vendor, selectedModel.provider) : null; const renderLogo = (logoVariants: typeof vendorLogo) => { @@ -133,7 +159,7 @@ export function ModelSelector({ return ; }; - const grouped = Array.from( + const grouped: Array<[string, Array<(typeof models)[number]>]> = Array.from( models.reduce((acc, model) => { const provider = model.provider; if (!acc.has(provider)) { @@ -145,13 +171,25 @@ export function ModelSelector({ } return acc; }, new Map>()), - ).sort((a, b) => a[0].localeCompare(b[0])); + ) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([provider, providerModels]) => { + const sortedModels = [...providerModels].sort((a, b) => + a.name.localeCompare(b.name, undefined, { + numeric: true, + sensitivity: "base", + }), + ); + + return [provider, sortedModels]; + }); return ( {providerModels.map((model) => { const isAvailable = model.available !== false; - const modelVendorLogo = - VENDOR_LOGOS[model.vendor.toLowerCase()]; + const modelVendorLogo = getVendorLogoVariants( + model.vendor, + model.provider, + ); const modelKey = getModelKey(model.provider, model.id); const item = ( - {modelVendorLogo && ( -
- {renderLogo(modelVendorLogo)} -
- )} - {model.name} - {getTierIcon(model.tier)} - {getImageSupportIcon(model.supportsImages)} - {!isAvailable && ( - - )} - + {modelVendorLogo && ( +
+ {renderLogo(modelVendorLogo)} +
+ )} + {model.name} +
+
+ {getTierIcon(model.tier)} + {getImageSupportIcon(model.supportsImages)} + {!isAvailable && ( + )} - /> + +
); diff --git a/src/components/error/DockerUnavailablePage.tsx b/src/components/error/DockerUnavailablePage.tsx index ff573d46..1d711d82 100644 --- a/src/components/error/DockerUnavailablePage.tsx +++ b/src/components/error/DockerUnavailablePage.tsx @@ -10,6 +10,7 @@ export function DockerUnavailablePage() {
+
+ )} +
+ )} + + ) : ( + <> + {/* Desktop split view */} +
+ +
- {/* Draggable separator */} - + {isResizable && ( + + )} - {/* Editor (right) */} -
- {selectedPath ? ( - - ) : ( -
-

Select a file to view its contents

+
+ {selectedPath ? ( + + ) : ( +
+

Select a file to view its contents

+
+ )}
- )} -
+ + )} - {/* Transparent overlay to capture mouse events during drag */} {isDragging && (
{ - (window.location.href = "/")}> + { + window.location.href = "/"; + }} + > Projects - (window.location.href = "/queue")}> + { + window.location.href = "/queue"; + }} + > Queue (window.location.href = "/settings")} + onClick={() => { + window.location.href = "/settings"; + }} > Settings diff --git a/src/components/preview/DeployButton.tsx b/src/components/preview/DeployButton.tsx index ada46fdb..91be685e 100644 --- a/src/components/preview/DeployButton.tsx +++ b/src/components/preview/DeployButton.tsx @@ -176,6 +176,23 @@ export function DeployButton({ Deployed
+ + {state.url && ( +
+

+ Production URL: +

+ + {state.url} + +
+ )} +
- + {!isMobile && + activeTab === "preview" && + state === "ready" && + previewUrl && ( + -
- )} + )} {/* Right: Action buttons */}
+ {isMobile && + activeTab === "preview" && + state === "ready" && + previewUrl && ( + <> + + + + + + )} {activeTab === "preview" && state === "error" && (
+ {opencodeDiagnostic && ( + setOpencodeDiagnostic(null)} + /> + )} + + {isMobile && ( + + { + onOpenFile?.(path); + setActiveTab("files"); + }} + onStreamingStateChange={(count, streaming) => { + onStreamingStateChange?.(count, streaming); + }} + /> + + )} + {/* Preview Tab Content */} {/* Preview iframe */}
@@ -558,7 +698,7 @@ export function PreviewPanel({ {/* Files Tab Content */} + + {isMobile && ( +
+ + + + Chat + + + + Preview + + + + Files + + + + Assets + + +
+ )} ); } diff --git a/src/components/preview/ResizableSeparator.tsx b/src/components/preview/ResizableSeparator.tsx index 7b6322c2..15704754 100644 --- a/src/components/preview/ResizableSeparator.tsx +++ b/src/components/preview/ResizableSeparator.tsx @@ -1,5 +1,5 @@ interface ResizableSeparatorProps { - onMouseDown: (e: React.MouseEvent) => void; + onMouseDown: (e: React.MouseEvent) => void; } /** @@ -8,12 +8,9 @@ interface ResizableSeparatorProps { */ export function ResizableSeparator({ onMouseDown }: ResizableSeparatorProps) { return ( -
); } diff --git a/src/components/projects/ProjectCard.tsx b/src/components/projects/ProjectCard.tsx index 89dc0463..1152ab2a 100644 --- a/src/components/projects/ProjectCard.tsx +++ b/src/components/projects/ProjectCard.tsx @@ -103,16 +103,16 @@ export function ProjectCard({ project, onDeleted }: ProjectCardProps) { <> -
+
{project.name}
-
+
{(() => { const style = getStatusStyle(project.status); return ( {isLoading && } {statusLabels[project.status]} @@ -126,8 +126,8 @@ export function ProjectCard({ project, onDeleted }: ProjectCardProps) {

{project.prompt}

-
-
+
+
{isRunning && ( (null); - // Use custom resizable panel hook for managing layout with constraints - const { leftPercent, rightPercent, isDragging, onSeparatorMouseDown } = - useResizablePanel({ - projectId, - minSize: 25, - maxSize: 75, - defaultSize: 33.33, - containerRef, - }); + const { + leftPercent, + rightPercent, + isDragging, + isMobile, + isResizable, + onSeparatorMouseDown, + } = useResizablePanel({ + projectId, + minSize: 25, + maxSize: 75, + defaultSize: 33.33, + containerRef, + }); // Check if containers are already ready on mount useEffect(() => { @@ -69,7 +74,6 @@ export function ProjectContentWrapper({ return (
- {/* Container restart display - shown until startup is complete */} {showStartupDisplay && ( )} - {/* Chat and preview panels - shown after startup or if already ready */} {!showStartupDisplay && (
- {/* Chat panel (left) */} -
- - + setFileToOpen(null)} + userMessageCount={userMessageCount} + isStreaming={isStreaming} models={models} onOpenFile={setFileToOpen} onStreamingStateChange={(count, streaming) => { @@ -102,30 +106,48 @@ export function ProjectContentWrapper({ setIsStreaming(streaming); }} /> - -
+
+ ) : ( + <> +
+ + { + setUserMessageCount(count); + setIsStreaming(streaming); + }} + /> + +
- {/* Draggable separator */} - + {isResizable && ( + + )} - {/* Preview panel (right) */} -
- - setFileToOpen(null)} - userMessageCount={userMessageCount} - isStreaming={isStreaming} - /> - -
+
+ + setFileToOpen(null)} + userMessageCount={userMessageCount} + isStreaming={isStreaming} + /> + +
+ + )} - {/* Transparent overlay to capture mouse events during drag */} {isDragging && (
void; +} + +const CATEGORY_VARIANTS: Record< + OpencodeErrorCategory, + "default" | "secondary" | "destructive" | "outline" +> = { + auth: "destructive", + provider_model: "outline", + runtime_unreachable: "secondary", + timeout: "outline", + unknown: "secondary", +}; + +const CATEGORY_TITLES: Record = { + auth: "Authentication Failed", + provider_model: "Model Error", + runtime_unreachable: "OpenCode Unavailable", + timeout: "Request Timed Out", + unknown: "Unexpected Error", +}; + +export function ProjectDiagnosticBanner({ + category, + message, + onDismiss, +}: ProjectDiagnosticBannerProps) { + if (!category || !message) return null; + + const title = CATEGORY_TITLES[category] ?? CATEGORY_TITLES.unknown; + const badgeVariant = + CATEGORY_VARIANTS[category as OpencodeErrorCategory] ?? "secondary"; + + const handleCheckSettings = () => { + window.location.href = "/settings/providers"; + toast.info("Navigating to provider settings..."); + }; + + return ( +
+
+ +
+
+

{title}

+ + {category} + +
+

{message}

+ +
+ +
+
+ + {onDismiss && ( + + )} +
+
+ ); +} diff --git a/src/components/providers/ThemeProvider.tsx b/src/components/providers/ThemeProvider.tsx index 14caec53..a8ae37f9 100644 --- a/src/components/providers/ThemeProvider.tsx +++ b/src/components/providers/ThemeProvider.tsx @@ -1,7 +1,13 @@ "use client"; import type React from "react"; -import { createContext, useContext, useEffect, useState } from "react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; type Theme = "light" | "dark"; @@ -29,7 +35,7 @@ const ThemeProvider: React.FC = ({ children }) => { const [mounted, setMounted] = useState(false); // Function declaration is hoisted to the top of the component scope - function applyTheme(newTheme: Theme) { + const applyTheme = useCallback((newTheme: Theme) => { if (typeof document === "undefined") return; const htmlElement = document.documentElement; if (newTheme === "dark") { @@ -37,7 +43,7 @@ const ThemeProvider: React.FC = ({ children }) => { } else { htmlElement.classList.remove("dark"); } - } + }, []); // Initialize theme on mount useEffect(() => { @@ -56,7 +62,7 @@ const ThemeProvider: React.FC = ({ children }) => { } setMounted(true); - }, []); + }, [applyTheme]); // Listen to storage changes (cross-tab sync) useEffect(() => { @@ -72,7 +78,7 @@ const ThemeProvider: React.FC = ({ children }) => { window.addEventListener("storage", handleStorageChange); return () => window.removeEventListener("storage", handleStorageChange); - }, [mounted]); + }, [mounted, applyTheme]); const toggleTheme = () => { const newTheme = theme === "light" ? "dark" : "light"; diff --git a/src/components/queue/JobDetailLive.tsx b/src/components/queue/JobDetailLive.tsx index b23bfbf9..2fba9d69 100644 --- a/src/components/queue/JobDetailLive.tsx +++ b/src/components/queue/JobDetailLive.tsx @@ -1,6 +1,8 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import type { QueueJob } from "@/server/db/schema"; import { ConfirmQueueActionDialog } from "./ConfirmQueueActionDialog"; +import { QueueDiagnostic } from "./QueueDiagnostic"; interface JobStreamData { type: "init" | "update"; @@ -12,6 +14,36 @@ interface JobDetailLiveProps { initialJob: QueueJob; } +interface JobLogChunkEvent { + jobId: string; + offset: number; + nextOffset: number; + text: string; + truncated: boolean; +} + +interface ParsedJobLogLine { + timestamp: string; + level: string; + message: string; +} + +function parseJobLogChunk(text: string): ParsedJobLogLine[] { + return text + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line) => { + const [timestamp = "", level = "info", ...messageParts] = + line.split("\t"); + const message = messageParts.join("\t").trim(); + return { + timestamp, + level, + message: message || line, + }; + }); +} + export function JobDetailLive({ initialJob }: JobDetailLiveProps) { const [job, setJob] = useState(initialJob); const [dialogOpen, setDialogOpen] = useState(false); @@ -19,6 +51,10 @@ export function JobDetailLive({ initialJob }: JobDetailLiveProps) { "cancel" | "forceUnlock" | null >(null); const [isLoading, setIsLoading] = useState(false); + const [jobLogs, setJobLogs] = useState([]); + const [logsTruncated, setLogsTruncated] = useState(false); + const [logsConnected, setLogsConnected] = useState(false); + const logsContainerRef = useRef(null); useEffect(() => { const eventSource = new EventSource( @@ -44,6 +80,81 @@ export function JobDetailLive({ initialJob }: JobDetailLiveProps) { }; }, [initialJob.id]); + useEffect(() => { + let eventSource: EventSource | null = null; + let reconnectTimer: ReturnType | null = null; + let isUnmounted = false; + let nextOffset = 0; + + setJobLogs([]); + setLogsTruncated(false); + + const connect = () => { + if (isUnmounted) return; + + const params = new URLSearchParams({ jobId: initialJob.id }); + if (nextOffset > 0) { + params.set("offset", String(nextOffset)); + } + + eventSource = new EventSource( + `/api/queue/job-logs-stream?${params.toString()}`, + ); + eventSource.addEventListener("open", () => { + setLogsConnected(true); + }); + + eventSource.addEventListener("log.chunk", (event) => { + try { + const data = JSON.parse(event.data) as JobLogChunkEvent; + nextOffset = data.nextOffset; + + if (data.truncated) { + setLogsTruncated(true); + } + + if (!data.text) { + return; + } + + const newLines = parseJobLogChunk(data.text); + setJobLogs((prev) => { + const merged = [...prev, ...newLines]; + const MAX_LOG_LINES = 1_000; + return merged.length > MAX_LOG_LINES + ? merged.slice(-MAX_LOG_LINES) + : merged; + }); + requestAnimationFrame(() => { + if (!logsContainerRef.current) return; + logsContainerRef.current.scrollTop = + logsContainerRef.current.scrollHeight; + }); + } catch { + toast.error("Failed to parse job log stream data"); + } + }); + + eventSource.addEventListener("error", () => { + setLogsConnected(false); + eventSource?.close(); + eventSource = null; + if (!isUnmounted) { + reconnectTimer = setTimeout(connect, 1_000); + } + }); + }; + + connect(); + + return () => { + isUnmounted = true; + setLogsConnected(false); + if (reconnectTimer) clearTimeout(reconnectTimer); + eventSource?.close(); + }; + }, [initialJob.id]); + const canCancel = job.state === "queued" || job.state === "running"; const canRunNow = job.state === "queued"; const canForceUnlock = job.state === "running"; @@ -77,7 +188,9 @@ export function JobDetailLive({ initialJob }: JobDetailLiveProps) { throw new Error("Failed to perform action"); } } catch (err) { - alert(err instanceof Error ? err.message : "Failed to perform action"); + toast.error( + err instanceof Error ? err.message : "Failed to perform action", + ); } finally { setIsLoading(false); setPendingAction(null); @@ -97,7 +210,7 @@ export function JobDetailLive({ initialJob }: JobDetailLiveProps) { throw new Error("Failed to run job"); } } catch (err) { - alert(err instanceof Error ? err.message : "Failed to run job"); + toast.error(err instanceof Error ? err.message : "Failed to run job"); } } else if (action === "retry") { try { @@ -111,7 +224,7 @@ export function JobDetailLive({ initialJob }: JobDetailLiveProps) { throw new Error("Failed to retry job"); } } catch (err) { - alert(err instanceof Error ? err.message : "Failed to retry job"); + toast.error(err instanceof Error ? err.message : "Failed to retry job"); } } else if (action === "cancel" || action === "forceUnlock") { handleActionClick(action); @@ -134,6 +247,7 @@ export function JobDetailLive({ initialJob }: JobDetailLiveProps) {
{canCancel && (
- {job.lastError && ( -
-

Last Error

-
-							{job.lastError}
-						
+ handleAction("retry")} + /> + +
+
+

Logs

+
+ {logsConnected ? "Live" : "Reconnecting..."} +
- )} + {logsTruncated && ( +
+ Showing the latest log tail. +
+ )} +
+ {jobLogs.length === 0 ? ( +
No logs yet...
+ ) : ( + jobLogs.map((line, index) => { + const level = line.level.toLowerCase(); + const isError = level === "error" || level === "fatal"; + const colorClass = isError + ? "text-status-error" + : level === "warn" + ? "text-yellow-300" + : "text-status-success"; + + return ( +
+ {line.timestamp && ( + [{line.timestamp}] + )} + + {line.level} + {" "} + {line.message} +
+ ); + }) + )} +
+
{pendingAction && ( diff --git a/src/components/queue/Pagination.tsx b/src/components/queue/Pagination.tsx index 26029271..ea8dde51 100644 --- a/src/components/queue/Pagination.tsx +++ b/src/components/queue/Pagination.tsx @@ -17,6 +17,7 @@ export function Pagination({ return (
+ ))} +
+ )} + +
+ + + {showDetails && ( +
+
{error}
+
+ )} +
+
+
+
+ ); +} diff --git a/src/components/queue/QueuePlayerControl.tsx b/src/components/queue/QueuePlayerControl.tsx index 2ce089bd..388b47d4 100644 --- a/src/components/queue/QueuePlayerControl.tsx +++ b/src/components/queue/QueuePlayerControl.tsx @@ -62,9 +62,9 @@ export function QueuePlayerControl({ }; return ( -
+
{/* Concurrency Slider */} -
+
Concurrency @@ -90,7 +90,7 @@ export function QueuePlayerControl({
{/* Status and Stats */} -
+
{/* Play/Pause Icon Button */}
diff --git a/src/components/queue/QueueTableLive.tsx b/src/components/queue/QueueTableLive.tsx index 55bd5771..3501c764 100644 --- a/src/components/queue/QueueTableLive.tsx +++ b/src/components/queue/QueueTableLive.tsx @@ -45,26 +45,19 @@ interface QueueTableLiveProps { export function QueueTableLive({ initialJobs, initialPage = 1, - initialPagination, + initialPagination: _initialPagination, initialPaused, initialConcurrency = 2, filters = {}, }: QueueTableLiveProps) { - const { - jobs, - paused, - concurrency, - pagination, - hasNewJobs, - setHasNewJobs, - setPagination, - } = useQueueStream( - initialPage, - initialJobs, - initialPaused, - initialConcurrency, - filters, - ); + const { jobs, paused, concurrency, pagination, hasNewJobs, setPagination } = + useQueueStream( + initialPage, + initialJobs, + initialPaused, + initialConcurrency, + filters, + ); const { isLoading, @@ -76,7 +69,6 @@ export function QueueTableLive({ handleToggleQueue, handleStopAll, handleConfirmStopAll, - handleActionClick, handleBulkDelete, handleConfirmAction, handleAction, @@ -101,6 +93,75 @@ export function QueueTableLive({ } }; + const formatCreatedAt = (createdAt: QueueJob["createdAt"]) => { + return new Date(createdAt).toLocaleString("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + }; + + const renderMobileActions = (job: QueueJob) => ( +
+ {job.state === "queued" && ( + + )} + {(job.state === "queued" || job.state === "running") && ( + + )} + {job.state === "failed" && ( + + )} + {job.state === "running" && ( + + )} + {(job.state === "succeeded" || + job.state === "failed" || + job.state === "cancelled") && ( + + )} +
+ ); + const handlePageChange = (newPage: number) => { const params = new URLSearchParams(); params.set("page", newPage.toString()); @@ -121,7 +182,7 @@ export function QueueTableLive({ return ( <> -
+

Queue

@@ -142,6 +203,7 @@ export function QueueTableLive({ variant="destructive" size="sm" disabled={isLoading} + className="w-full sm:w-auto" > Stop All Projects @@ -199,7 +261,55 @@ export function QueueTableLive({ )}
-
+
+ {jobs.length === 0 ? ( +
+ No jobs found +
+ ) : ( + jobs.map((job) => ( +
+
+
+ + {getStateIcon(job.state)} + + + {job.id.slice(0, 8)} + +
+ + {job.state} + +
+
+
+ Type:{" "} + {job.type} +
+
+ + Project: + {" "} + {job.projectId || "โ€”"} +
+
+ + Created: + {" "} + {formatCreatedAt(job.createdAt)} +
+
+ {renderMobileActions(job)} +
+ )) + )} +
+ +
@@ -245,15 +355,7 @@ export function QueueTableLive({ {job.projectId || "โ€”"}
- {new Date(job.createdAt).toLocaleString("en-US", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - })} + {formatCreatedAt(job.createdAt)} {job.state === "queued" && ( diff --git a/src/components/setup/SetupStatusDisplay.tsx b/src/components/setup/SetupStatusDisplay.tsx index 7128d99a..fceb8aa2 100644 --- a/src/components/setup/SetupStatusDisplay.tsx +++ b/src/components/setup/SetupStatusDisplay.tsx @@ -42,6 +42,7 @@ export function SetupStatusDisplay({ projectId }: SetupStatusDisplayProps) { const [jobTimeoutWarning, setJobTimeoutWarning] = useState( null, ); + const [isRestarting, setIsRestarting] = useState(false); const eventSourceRef = useRef(null); const promptSentTimeRef = useRef(null); @@ -140,6 +141,23 @@ export function SetupStatusDisplay({ projectId }: SetupStatusDisplayProps) { [], ); + const handleRestart = async () => { + setIsRestarting(true); + setSetupError(null); + try { + const { error } = await actions.projects.restart({ projectId }); + if (error) { + setSetupError(error.message || "Failed to restart project"); + } + } catch (err) { + setSetupError( + err instanceof Error ? err.message : "Failed to restart project", + ); + } finally { + setIsRestarting(false); + } + }; + // Connect to opencode event stream to show progress useEffect(() => { if (isComplete) return; @@ -341,10 +359,18 @@ export function SetupStatusDisplay({ projectId }: SetupStatusDisplayProps) {

)} diff --git a/src/components/terminal/TerminalDock.tsx b/src/components/terminal/TerminalDock.tsx index 56af9469..dff859a8 100644 --- a/src/components/terminal/TerminalDock.tsx +++ b/src/components/terminal/TerminalDock.tsx @@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; interface LogLine { + id: number; text: string; type: "docker" | "app"; streamType: "out" | "err"; @@ -25,6 +26,7 @@ export function TerminalDock({ const [nextOffset, setNextOffset] = useState(null); const terminalRef = useRef(null); const eventSourceRef = useRef(null); + const lineIdRef = useRef(0); const scrollToBottom = useCallback(() => { if (terminalRef.current) { @@ -44,6 +46,15 @@ export function TerminalDock({ const eventSource = new EventSource(url); eventSourceRef.current = eventSource; + const createLogLine = ( + text: string, + type: "docker" | "app", + streamType: "out" | "err", + ): LogLine => { + lineIdRef.current += 1; + return { id: lineIdRef.current, text, type, streamType }; + }; + eventSource.addEventListener("log.chunk", (e) => { try { const data = JSON.parse(e.data); @@ -52,11 +63,11 @@ export function TerminalDock({ if (truncated && lines.length === 0) { // Show truncation indicator setLines([ - { - text: "[...showing last portion of logs...]", - type: logType, - streamType: "out", - }, + createLogLine( + "[...showing last portion of logs...]", + logType, + "out", + ), ]); } @@ -92,7 +103,7 @@ export function TerminalDock({ return null; } - return { text: displayText, type, streamType }; + return createLogLine(displayText, type, streamType); }) .filter((line: LogLine | null): line is LogLine => line !== null); @@ -135,6 +146,7 @@ export function TerminalDock({ > {/* Header */}