From ecd2033595454f8553fc8e785f2430fd38ffb631 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 28 Jan 2026 18:27:08 +0100 Subject: [PATCH 01/42] refactor: encapsulate deployments in project folders This Sprint 1 refactor moves all project files into a single folder for better ownership and simplify the port system from 4 ports per project to 3. Key Changes: - Encapsulated structure: Each project now has preview/ and production/ folders under the main project directory - Simplified ports: Removed nginx routing, now expose productionPort directly on localhost:8000-9999 range (deterministic based on project ID) - Production builds now use preview/src/ dist/ and deploy to production/{hash}/ - All deployment handlers updated to work without nginx (using docker run directly) - Rollback now rebuilds container rather than changing nginx routing Benefits: - Delete project = delete everything (no separate production/ folder needed) - No nginx requirement (simpler deployment) - Consistent production URLs (localhost:{productionPort} always the same for a project) - All versions tracked in production/{hash}/ folders with easy rollback Affected files: - Schema: Made productionPort NOT NULL with default 8001 - Paths: Added getProjectPreviewPath, getProjectProductionPath - Port allocation: Added allocateProjectProductionPort (8000-9999 range) - Setup: Creates preview/ and production/ subdirectories - Handlers: Updated productionBuild, productionStart, productionStop - Actions: Updated getProductionStatus, getProductionHistory, rollback --- drizzle/meta/0001_snapshot.json | 578 +++++++++++++++++++ drizzle/meta/_journal.json | 31 +- src/actions/projects.ts | 98 +++- src/server/db/schema.ts | 6 +- src/server/ports/allocate.ts | 49 ++ src/server/productions/cleanup.ts | 2 +- src/server/projects/paths.ts | 29 +- src/server/projects/setup.ts | 56 +- src/server/queue/handlers/productionBuild.ts | 82 ++- src/server/queue/handlers/productionStart.ts | 370 ++++-------- src/server/queue/handlers/productionStop.ts | 36 +- src/server/queue/handlers/projectCreate.ts | 4 +- 12 files changed, 1000 insertions(+), 341 deletions(-) create mode 100644 drizzle/meta/0001_snapshot.json diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 00000000..0ed290ec --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,578 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "91fcf61d-499f-4b36-becc-690c5cd34b82", + "prevId": "b0f2f20e-d051-4d78-aa66-ba082b5b9bb2", + "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/_journal.json b/drizzle/meta/_journal.json index d001927a..a4f11f43 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": 1767962667134, + "tag": "0000_cultured_mother_askani", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1769621154174, + "tag": "0001_tidy_orphan", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/actions/projects.ts b/src/actions/projects.ts index 91811c5e..ec9ed9e0 100644 --- a/src/actions/projects.ts +++ b/src/actions/projects.ts @@ -388,10 +388,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 +410,76 @@ 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 tempSymlink = `${symlinkPath}.tmp-${Date.now()}`; + + const fs = await import("node:fs/promises"); + try { + await fs.unlink(tempSymlink).catch(() => {}); + await fs.symlink(input.toHash, tempSymlink); + await fs.rename(tempSymlink, symlinkPath); + } catch { + // Symlink update failed, but container is running + } await updateProductionStatus(input.projectId, "running", { productionHash: input.toHash, @@ -689,13 +739,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 +791,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/server/db/schema.ts b/src/server/db/schema.ts index 09785635..a01d6f9d 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -77,11 +77,13 @@ export const projects = sqliteTable("projects", { .notNull() .default(false), // Production deployment fields - productionPort: integer("production_port"), + productionPort: integer("production_port").notNull(), productionUrl: text("production_url"), productionStatus: text("production_status", { enum: ["queued", "building", "running", "failed", "stopped"], - }).default("stopped"), + }) + .notNull() + .default("stopped"), productionStartedAt: integer("production_started_at", { mode: "timestamp" }), productionError: text("production_error"), productionHash: text("production_hash"), diff --git a/src/server/ports/allocate.ts b/src/server/ports/allocate.ts index ecc749b6..d4703a73 100644 --- a/src/server/ports/allocate.ts +++ b/src/server/ports/allocate.ts @@ -185,6 +185,55 @@ export function unregisterVersionPort(versionPort: number): void { logger.debug({ versionPort }, "Unregistered version port"); } +/** + * Allocate a deterministic production port for a project (8000-9999). + * The port is derived from the project ID hash, ensuring consistency. + */ +export async function allocateProjectProductionPort( + projectId: string, +): Promise { + const PROD_PORT_MIN = 8000; + const PROD_PORT_MAX = 9999; + + // Deterministic hash-based port allocation + let hash = 0; + for (let i = 0; i < projectId.length; i++) { + const char = projectId.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + + const offset = Math.abs(hash) % (PROD_PORT_MAX - PROD_PORT_MIN + 1); + const productionPort = PROD_PORT_MIN + offset; + + const available = await isPortAvailable(productionPort); + if (!available) { + logger.warn( + { projectId, productionPort }, + "Preferred production port not available, finding alternative", + ); + // Find next available port in the range + for (let port = productionPort + 1; port <= PROD_PORT_MAX; port++) { + const isAvail = await isPortAvailable(port); + if (isAvail) { + allocatedDevPorts.add(port); + logger.info( + { projectId, productionPort: port }, + "Allocated production port", + ); + return port; + } + } + throw new Error( + `No available production ports in range ${PROD_PORT_MIN}-${PROD_PORT_MAX}`, + ); + } + + allocatedDevPorts.add(productionPort); + logger.info({ projectId, productionPort }, "Allocated production port"); + return productionPort; +} + /** * Allocate two ports for a project: devPort (preview) and opencodePort. */ diff --git a/src/server/productions/cleanup.ts b/src/server/productions/cleanup.ts index df5ba168..b70a5cce 100644 --- a/src/server/productions/cleanup.ts +++ b/src/server/productions/cleanup.ts @@ -1,4 +1,4 @@ -import { promises as fs } from "node:fs"; +import * as fs from "node:fs/promises"; import * as path from "node:path"; import { logger } from "@/server/logger"; import { diff --git a/src/server/projects/paths.ts b/src/server/projects/paths.ts index 55f1ff6c..f5f8d544 100644 --- a/src/server/projects/paths.ts +++ b/src/server/projects/paths.ts @@ -43,6 +43,24 @@ export function getProjectPath(projectId: string): string { return path.join(getProjectsPath(), projectId); } +/** + * Get the absolute path to the preview directory for a project. + */ +export function getProjectPreviewPath(projectId: string): string { + return path.join(getProjectPath(projectId), "preview"); +} + +/** + * Get the absolute path to the production directory for a project. + */ +export function getProjectProductionPath( + projectId: string, + hash?: string, +): string { + const basePath = path.join(getProjectPath(projectId), "production"); + return hash ? path.join(basePath, hash) : basePath; +} + /** * Get the relative path on disk for a project (stored in DB). */ @@ -52,6 +70,7 @@ export function getProjectRelativePath(projectId: string): string { /** * Get the absolute path to the productions directory. + * @deprecated Production is now in project/[projectId]/production/ */ export function getProductionsPath(): string { return path.join(getDataPath(), PRODUCTIONS_DIR); @@ -59,16 +78,16 @@ export function getProductionsPath(): string { /** * Get the absolute path to a specific production directory. - * If hash is provided, returns the versioned hash directory. - * If hash is omitted, returns the project-level directory containing all versions. + * If hash is provided, returns the versioned hash directory inside the production folder. + * If hash is omitted, returns the production directory for the project. * * @param projectId - The project ID * @param hash - Optional: 8-character hash for versioned directory * @returns Absolute path to production directory */ export function getProductionPath(projectId: string, hash?: string): string { - const projectPath = path.join(getProductionsPath(), projectId); - return hash ? path.join(projectPath, hash) : projectPath; + const basePath = path.join(getProjectPath(projectId), "production"); + return hash ? path.join(basePath, hash) : basePath; } /** @@ -89,7 +108,7 @@ export function getProductionRelativePath( projectId: string, hash?: string, ): string { - const basePath = `${DATA_DIR}/${PRODUCTIONS_DIR}/${projectId}`; + const basePath = `${DATA_DIR}/${PROJECTS_DIR}/${projectId}/production`; return hash ? `${basePath}/${hash}` : basePath; } diff --git a/src/server/projects/setup.ts b/src/server/projects/setup.ts index 260a1eb0..39427420 100644 --- a/src/server/projects/setup.ts +++ b/src/server/projects/setup.ts @@ -1,10 +1,18 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { logger } from "@/server/logger"; -import { getProjectPath, getProjectsPath, getTemplatePath } from "./paths"; +import { allocateProjectProductionPort } from "@/server/ports/allocate"; +import { + getProjectPath, + getProjectPreviewPath, + getProjectProductionPath, + getProjectsPath, + getTemplatePath, +} from "./paths"; export interface SetupProjectFilesystemResult { projectPath: string; + productionPort: number; } export async function setupProjectFilesystem( @@ -13,22 +21,40 @@ export async function setupProjectFilesystem( opencodePort: number, ): Promise { const projectPath = getProjectPath(projectId); + const productionPort = await allocateProjectProductionPort(projectId); // Ensure projects directory exists await fs.mkdir(getProjectsPath(), { recursive: true }); - // Copy template to project directory - await copyTemplate(projectPath); - logger.debug({ projectPath }, "Copied template to project directory"); - - // Write .env file with ports and volume configuration - await writeProjectEnv(projectPath, devPort, opencodePort, projectId); - logger.debug({ devPort, opencodePort, projectId }, "Wrote project .env file"); - - // Create logs directory - await fs.mkdir(path.join(projectPath, "logs"), { recursive: true }); - - return { projectPath }; + // Copy template to preview directory + const previewPath = getProjectPreviewPath(projectId); + await fs.mkdir(previewPath, { recursive: true }); + await copyTemplate(previewPath); + logger.debug({ previewPath }, "Copied template to preview directory"); + + // Create production directory + const productionPath = getProjectProductionPath(projectId); + await fs.mkdir(productionPath, { recursive: true }); + logger.debug({ productionPath }, "Created production directory"); + + // Write .env file with all port configuration + await writeProjectEnv( + projectPath, + devPort, + opencodePort, + projectId, + productionPort, + ); + logger.debug( + { devPort, opencodePort, productionPort, projectId }, + "Wrote project .env file", + ); + + // Create logs directories + await fs.mkdir(path.join(previewPath, "logs"), { recursive: true }); + await fs.mkdir(path.join(productionPath, "logs"), { recursive: true }); + + return { projectPath, productionPort }; } /** @@ -107,14 +133,16 @@ async function writeProjectEnv( devPort: number, opencodePort: number, projectId: string, + productionPort: number, ): Promise { const volumeName = `doce_${projectId}_data`; const envContent = `# Generated by doce.dev DEV_PORT=${devPort} OPENCODE_PORT=${opencodePort} -PRODUCTION_PORT=5000 +PRODUCTION_PORT=${productionPort} PROJECT_DATA_VOLUME_NAME=${volumeName} PROJECT_ID=${projectId} +NODE_ENV=development `; await fs.writeFile(path.join(projectPath, ".env"), envContent); diff --git a/src/server/queue/handlers/productionBuild.ts b/src/server/queue/handlers/productionBuild.ts index a81d546e..0a5a93af 100644 --- a/src/server/queue/handlers/productionBuild.ts +++ b/src/server/queue/handlers/productionBuild.ts @@ -1,7 +1,12 @@ +import { promises as fs } from "node:fs/promises"; import * as path from "node:path"; import { logger } from "@/server/logger"; import { hashDistFolder } from "@/server/productions/hash"; import { updateProductionStatus } from "@/server/productions/productions.model"; +import { + getProjectPreviewPath, + getProjectProductionPath, +} from "@/server/projects/paths"; import { getProjectByIdIncludeDeleted } from "@/server/projects/projects.model"; import { spawnCommand } from "@/server/utils/execAsync"; import { enqueueProductionStart } from "../enqueue"; @@ -35,10 +40,12 @@ export async function handleProductionBuild( logger.info({ projectId: project.id }, "Starting production build"); - // Run pnpm run build in the project directory asynchronously - // This creates the dist/ folder that will be used by the production container + // Run pnpm run build in the preview/src/ directory + const previewPath = getProjectPreviewPath(project.id); + const srcPath = path.join(previewPath, "src"); + const result = await spawnCommand("pnpm", ["run", "build"], { - cwd: project.pathOnDisk, + cwd: srcPath, timeout: 5 * 60 * 1000, // 5 minute timeout }); @@ -57,15 +64,45 @@ export async function handleProductionBuild( logger.info({ projectId: project.id }, "Production build succeeded"); // Calculate hash of dist folder for atomic versioned deployment - const distPath = path.join(project.pathOnDisk, "dist"); + const distPath = path.join(previewPath, "dist"); const productionHash = await hashDistFolder(distPath); logger.debug( { projectId: project.id, productionHash }, "Calculated production hash", ); + // Create production/{hash}/ directory and copy files + await ctx.throwIfCancelRequested(); + const productionPath = getProjectProductionPath(project.id, productionHash); + await fs.mkdir(productionPath, { recursive: true }); + + // Copy required files to production version + await fs.copyFile( + path.join(previewPath, "package.json"), + path.join(productionPath, "package.json"), + ); + await fs.copyFile( + path.join(previewPath, "pnpm-lock.yaml"), + path.join(productionPath, "pnpm-lock.yaml"), + ); + await fs.cp(distPath, path.join(productionPath, "dist"), { + recursive: true, + }); + + // Create docker-compose.yml for this version + await createProductionComposeFile( + productionPath, + project.productionPort, + productionHash, + project.id, + ); + + logger.debug( + { projectId: project.id, productionHash, productionPath }, + "Production version files copied", + ); + // Enqueue next step: start production container with hash - // Port will be allocated by the production.start handler await enqueueProductionStart({ projectId: project.id, productionHash, @@ -76,3 +113,38 @@ export async function handleProductionBuild( "Enqueued production.start", ); } + +/** + * Create docker-compose.yml for production deployment. + */ +async function createProductionComposeFile( + productionPath: string, + productionPort: number, + hash: string, + projectId: string, +): Promise { + const composeContent = `services: + production: + build: + context: . + container_name: doce-prod-${projectId}-${hash.slice(0, 12)} + ports: + - ${productionPort}:3000 + environment: + - NODE_ENV=production + - HOST=0.0.0.0 + - PORT=3000 + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s +`; + + await fs.writeFile( + path.join(productionPath, "docker-compose.yml"), + composeContent, + ); +} diff --git a/src/server/queue/handlers/productionStart.ts b/src/server/queue/handlers/productionStart.ts index 7a612ebe..f7902b2f 100644 --- a/src/server/queue/handlers/productionStart.ts +++ b/src/server/queue/handlers/productionStart.ts @@ -1,157 +1,18 @@ -import { promises as fs } from "node:fs"; +import * as fs from "node:fs/promises"; import * as path from "node:path"; -import type { Project } from "@/server/db/schema"; -import { composeUpProduction } from "@/server/docker/compose"; import { logger } from "@/server/logger"; -import { - allocateProjectBasePort, - deriveVersionPort, - registerVersionPort, -} from "@/server/ports/allocate"; import { cleanupOldProductionVersions } from "@/server/productions/cleanup"; -import { - initializeProjectNginxConfig, - registerVersionInNginx, -} from "@/server/productions/nginx"; import { updateProductionStatus } from "@/server/productions/productions.model"; import { getProductionCurrentSymlink, getProductionPath, - getTemplatePath, - normalizeProjectPath, } from "@/server/projects/paths"; import { getProjectByIdIncludeDeleted } from "@/server/projects/projects.model"; +import { spawnCommand } from "@/server/utils/execAsync"; import { enqueueProductionWaitReady } from "../enqueue"; import type { QueueJobContext } from "../queue.worker"; import { parsePayload } from "../types"; -/** - * Setup production directory with pre-built artifacts. - * Creates a hash-versioned directory structure and updates the "current" symlink. - * Copies dist/, public/, package.json, pnpm-lock.yaml, Dockerfile.prod, and docker-compose.production.yml. - */ -async function setupProductionDirectory( - project: Project, - productionPath: string, - productionPort: number, - productionHash: string, -): Promise { - logger.info( - { projectId: project.id, productionPath, productionHash }, - "Setting up production directory", - ); - - // Create hash-versioned production directory - await fs.mkdir(productionPath, { recursive: true }); - - // Create logs directory - const logsDir = path.join(productionPath, "logs"); - await fs.mkdir(logsDir, { recursive: true }); - - const projectPath = normalizeProjectPath(project.pathOnDisk); - const templatePath = getTemplatePath(); - - // Copy src/ folder (required for building in production) - const srcSource = path.join(projectPath, "src"); - const srcDest = path.join(productionPath, "src"); - try { - await fs.cp(srcSource, srcDest, { recursive: true }); - logger.debug({ projectId: project.id }, "Copied src/ folder"); - } catch (error) { - logger.error( - { projectId: project.id, error }, - "Failed to copy src/ folder", - ); - throw error; - } - - // Copy public/ folder if it exists - const publicSource = path.join(projectPath, "public"); - const publicDest = path.join(productionPath, "public"); - try { - await fs.cp(publicSource, publicDest, { recursive: true }); - logger.debug({ projectId: project.id }, "Copied public/ folder"); - } catch (_error) { - // public/ folder might not exist, that's okay - logger.debug( - { projectId: project.id }, - "public/ folder not found (optional)", - ); - } - - // Copy package.json - const pkgSource = path.join(projectPath, "package.json"); - const pkgDest = path.join(productionPath, "package.json"); - await fs.cp(pkgSource, pkgDest); - logger.debug({ projectId: project.id }, "Copied package.json"); - - // Copy pnpm-lock.yaml - const lockSource = path.join(projectPath, "pnpm-lock.yaml"); - const lockDest = path.join(productionPath, "pnpm-lock.yaml"); - await fs.cp(lockSource, lockDest); - logger.debug({ projectId: project.id }, "Copied pnpm-lock.yaml"); - - // Copy astro.config.mjs - const astroConfigSource = path.join(projectPath, "astro.config.mjs"); - const astroConfigDest = path.join(productionPath, "astro.config.mjs"); - try { - await fs.cp(astroConfigSource, astroConfigDest); - logger.debug({ projectId: project.id }, "Copied astro.config.mjs"); - } catch (error) { - logger.error( - { projectId: project.id, error }, - "Failed to copy astro.config.mjs", - ); - throw error; - } - - // Copy tsconfig.json - const tsconfigSource = path.join(projectPath, "tsconfig.json"); - const tsconfigDest = path.join(productionPath, "tsconfig.json"); - try { - await fs.cp(tsconfigSource, tsconfigDest); - logger.debug({ projectId: project.id }, "Copied tsconfig.json"); - } catch (error) { - logger.error( - { projectId: project.id, error }, - "Failed to copy tsconfig.json", - ); - throw error; - } - - // Copy Dockerfile.prod from template - const dockerfileProdSource = path.join(templatePath, "Dockerfile.prod"); - const dockerfileProdDest = path.join(productionPath, "Dockerfile.prod"); - await fs.cp(dockerfileProdSource, dockerfileProdDest); - logger.debug({ projectId: project.id }, "Copied Dockerfile.prod"); - - // Copy docker-compose.production.yml from template - const composeProdSource = path.join( - templatePath, - "docker-compose.production.yml", - ); - const composeProdDest = path.join( - productionPath, - "docker-compose.production.yml", - ); - await fs.cp(composeProdSource, composeProdDest); - logger.debug( - { projectId: project.id }, - "Copied docker-compose.production.yml", - ); - - // Create .env with PRODUCTION_PORT - const envPath = path.join(productionPath, ".env"); - const envContent = `NODE_ENV=production\nPRODUCTION_PORT=${productionPort}\n`; - await fs.writeFile(envPath, envContent); - logger.debug({ projectId: project.id }, "Created .env with PRODUCTION_PORT"); - - logger.info( - { projectId: project.id, productionPath }, - "Production directory setup complete", - ); -} - export async function handleProductionStart( ctx: QueueJobContext, ): Promise { @@ -174,126 +35,83 @@ export async function handleProductionStart( return; } - try { - const payload = parsePayload("production.start", ctx.job.payloadJson); - - // Allocate base port on first deployment (only) - let basePort = project.productionBasePort; - if (!basePort) { - basePort = await allocateProjectBasePort(project.id); - logger.info( - { projectId: project.id, basePort }, - "Allocated base port for project", - ); - } - - // Derive version port from hash (deterministic - same hash = same port) - const versionPort = deriveVersionPort(project.id, payload.productionHash); - logger.info( - { - projectId: project.id, - hash: payload.productionHash.slice(0, 8), - versionPort, - }, - "Derived version port", + if (!project.productionPort) { + logger.warn( + { projectId: project.id }, + "Project has no productionPort, skipping deployment", ); + return; + } - await ctx.throwIfCancelRequested(); - - // Initialize nginx config for this project (first deployment only) - if (!project.productionBasePort) { - await initializeProjectNginxConfig( - project.id, - basePort, - payload.productionHash, - versionPort, - ); - logger.info( - { - projectId: project.id, - basePort, - hash: payload.productionHash.slice(0, 8), - versionPort, - }, - "Initialized nginx config", - ); - } else { - // Register this version in nginx without making it active yet - await registerVersionInNginx( - project.id, - payload.productionHash, - versionPort, - ); - logger.info( - { - projectId: project.id, - hash: payload.productionHash.slice(0, 8), - versionPort, - }, - "Registered version in nginx", - ); - } - - // Register version port in allocation tracker - registerVersionPort(project.id, payload.productionHash, versionPort); - - await ctx.throwIfCancelRequested(); - - // Setup production directory with pre-built artifacts in hash-versioned path + try { const productionPath = getProductionPath( project.id, payload.productionHash, ); + const productionPort = project.productionPort; + + await ctx.throwIfCancelRequested(); + + // Stop any existing production container for this project + await stopProductionContainer(project.id); + + // Build Docker image for this version + const imageName = `doce-prod-${project.id}-${payload.productionHash}`; logger.info( - { - projectId: project.id, - productionPath, - hash: payload.productionHash.slice(0, 8), - }, - "Setting up production directory", + { projectId: project.id, imageName }, + "Building production Docker image", ); - await setupProductionDirectory( - project, - productionPath, - versionPort, - payload.productionHash, + const buildResult = await spawnCommand( + "docker", + ["build", "-t", imageName, "-f", "Dockerfile.prod", "."], + { cwd: productionPath }, ); - await ctx.throwIfCancelRequested(); + if (!buildResult.success) { + const errorMsg = `Docker build failed: ${buildResult.stderr.slice(0, 500)}`; + logger.error( + { projectId: project.id, error: errorMsg }, + "Docker build failed", + ); + await updateProductionStatus(project.id, "failed", { + productionError: errorMsg, + }); + throw new Error(errorMsg); + } - // Start production container using docker compose - // Container runs on versionPort internally - // Nginx routes basePort to the current version logger.info( - { - projectId: project.id, - productionPath, - versionPort, - hash: payload.productionHash.slice(0, 8), - }, - "Starting production container", + { projectId: project.id, imageName }, + "Docker image built successfully", ); - const result = await composeUpProduction( - project.id, - productionPath, - versionPort, - payload.productionHash, - ); + await ctx.throwIfCancelRequested(); + // Start container with docker run (no nginx needed) + const containerName = `doce-prod-${project.id}`; logger.info( - { - projectId: project.id, - success: result.success, - exitCode: result.exitCode, - stderrSlice: result.stderr.slice(0, 200), - }, - "composeUpProduction result", + { projectId: project.id, containerName, productionPort }, + "Starting production container", ); - if (!result.success) { - const errorMsg = `compose up failed: ${result.stderr.slice(0, 500)}`; + const runResult = await spawnCommand("docker", [ + "run", + "-d", + "--name", + containerName, + "-p", + `${productionPort}:3000`, + "--restart", + "unless-stopped", + imageName, + ]); + + if (!runResult.success) { + const errorMsg = `Docker run failed: ${runResult.stderr.slice(0, 500)}`; + logger.error( + { projectId: project.id, error: errorMsg }, + "Docker run failed", + ); await updateProductionStatus(project.id, "failed", { productionError: errorMsg, }); @@ -303,31 +121,23 @@ export async function handleProductionStart( logger.info( { projectId: project.id, - versionPort, - basePort, - hash: payload.productionHash.slice(0, 8), + containerName, + productionPort, + } as unknown as typeof runResult & { + containerName: string; + productionPort: number; + projectId: string; }, - "Docker compose up succeeded for production", + "Production container started", ); - - // Atomically update the "current" symlink to point to the new hash directory - // This ensures atomic deployment and enables rollback + // Update the "current" symlink to point to this version const symlinkPath = getProductionCurrentSymlink(project.id); - const projectDir = getProductionPath(project.id); + await fs.mkdir(path.dirname(symlinkPath), { recursive: true }); - // Ensure project directory exists - await fs.mkdir(projectDir, { recursive: true }); - - // Create temporary symlink with a unique name for atomic rename const tempSymlink = `${symlinkPath}.tmp-${Date.now()}`; try { - // Remove temp symlink if it exists (shouldn't, but be safe) await fs.unlink(tempSymlink).catch(() => {}); - - // Create new symlink pointing to hash directory await fs.symlink(payload.productionHash, tempSymlink); - - // Atomically rename to final location (overwrites old symlink) await fs.rename(tempSymlink, symlinkPath); logger.debug( @@ -346,17 +156,16 @@ export async function handleProductionStart( throw error; } - // Update production status with base port and hash + // Update production status await updateProductionStatus(project.id, "building", { - productionBasePort: basePort, productionStartedAt: new Date(), productionHash: payload.productionHash, }); - // Enqueue next step: wait for production server to be ready + // Enqueue waitReady with production port await enqueueProductionWaitReady({ projectId: project.id, - productionPort: versionPort, + productionPort, startedAt: Date.now(), rescheduleCount: 0, }); @@ -364,15 +173,13 @@ export async function handleProductionStart( logger.debug( { projectId: project.id, - versionPort, - basePort, + productionPort, hash: payload.productionHash.slice(0, 8), }, "Enqueued production.waitReady", ); - // Clean up old production versions in the background (keep last 2) - // This is non-blocking - failures don't affect the deployment + // Clean up old production versions (keep last 2) cleanupOldProductionVersions(project.id, 2).catch((error) => { logger.warn( { projectId: project.id, error }, @@ -387,3 +194,32 @@ export async function handleProductionStart( throw error; } } + +/** + * Stop the existing production container for a project. + */ +async function stopProductionContainer(projectId: string): Promise { + const containerName = `doce-prod-${projectId}`; + + try { + // Try to stop container + await spawnCommand("docker", ["stop", containerName]); + + // Remove container + await spawnCommand("docker", ["rm", containerName]); + + logger.debug( + { projectId, containerName } as unknown as { + projectId: string; + containerName: string; + }, + "Stopped and removed production container", + ); + } catch (error) { + // Container might not exist, that's okay + logger.debug( + { projectId, error } as unknown as { projectId: string; error: unknown }, + "No existing production container to stop", + ); + } +} diff --git a/src/server/queue/handlers/productionStop.ts b/src/server/queue/handlers/productionStop.ts index 0def1199..57ed0759 100644 --- a/src/server/queue/handlers/productionStop.ts +++ b/src/server/queue/handlers/productionStop.ts @@ -1,7 +1,7 @@ import { logger } from "@/server/logger"; -import { removeProjectNginxConfig } from "@/server/productions/nginx"; import { updateProductionStatus } from "@/server/productions/productions.model"; import { getProjectByIdIncludeDeleted } from "@/server/projects/projects.model"; +import { spawnCommand } from "@/server/utils/execAsync"; import type { QueueJobContext } from "../queue.worker"; import { parsePayload } from "../types"; @@ -38,21 +38,43 @@ export async function handleProductionStop( return; } - // Remove project from nginx routing - // This makes the public-facing URL inaccessible - // Containers continue running so they can be rolled back if needed - await removeProjectNginxConfig(project.id); + const containerName = `doce-prod-${project.id}`; + + // Stop the container + logger.info( + { projectId: project.id, containerName }, + "Stopping production container", + ); + + const stopResult = await spawnCommand("docker", ["stop", containerName]); + + if (!stopResult.success) { + logger.warn( + { projectId: project.id, stderr: stopResult.stderr.slice(0, 200) }, + "Failed to stop production container", + ); + } + + // Remove the container + const removeResult = await spawnCommand("docker", ["rm", containerName]); + + if (!removeResult.success) { + logger.warn( + { projectId: project.id, stderr: removeResult.stderr.slice(0, 200) }, + "Failed to remove production container", + ); + } logger.info( { projectId: project.id, currentHash: currentHash.slice(0, 8), }, - "Production server removed from nginx (containers kept alive for rollback)", + "Production container stopped", ); // Update production status to stopped - // Note: We don't clear the hash, port, or URL - this allows rollback + // Keep hash, port, and URL for rollback await updateProductionStatus(project.id, "stopped"); } catch (error) { logger.error( diff --git a/src/server/queue/handlers/projectCreate.ts b/src/server/queue/handlers/projectCreate.ts index 19ff90d7..415b4f42 100644 --- a/src/server/queue/handlers/projectCreate.ts +++ b/src/server/queue/handlers/projectCreate.ts @@ -27,7 +27,7 @@ export async function handleProjectCreate(ctx: QueueJobContext): Promise { await ensureAuthDirectory(); - const { projectPath } = await setupProjectFilesystem( + const { projectPath, productionPort } = await setupProjectFilesystem( projectId, devPort, opencodePort, @@ -65,9 +65,9 @@ export async function handleProjectCreate(ctx: QueueJobContext): Promise { prompt, devPort, opencodePort, + productionPort, status: "created", pathOnDisk: projectPath, - currentModel: model || null, }); logger.info({ projectId, name, slug }, "Created project in database"); From 71379f8ad3b1686cf3632f6ff3d5cf41884bd35c Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Thu, 29 Jan 2026 13:15:51 +0100 Subject: [PATCH 02/42] fix: correct tar command syntax in pr-preview workflow - Move --exclude flags before the source directory (.) - tar requires options to precede positional arguments - This fixes 'Exiting with failure status due to previous errors' in PR deployments --- .github/workflows/pr-preview.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 9d454138..b39980f4 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -85,10 +85,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..." From 99f067bed566ecfc3d73185dc83436ad95902039 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Thu, 29 Jan 2026 13:17:05 +0100 Subject: [PATCH 03/42] run review on selfhost --- .github/workflows/opencode-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 4b7d3ea5..6ce6137c 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -43,7 +43,7 @@ jobs: env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # HOME: /home/runner # This is necessary for the self-hosted runner + HOME: /home/runner # This is necessary for the self-hosted runner with: model: opencode/glm-4.7-free prompt: | From 51d2a2dd9b3f746d30ae88de9b304bb2baf83a91 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Thu, 29 Jan 2026 13:17:41 +0100 Subject: [PATCH 04/42] no more free --- .github/workflows/opencode-developer.yml | 2 +- .github/workflows/opencode-review.yml | 2 +- .github/workflows/opencode-supervisor.yml | 2 +- .github/workflows/opencode.yml | 2 +- opencode.json | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/opencode-developer.yml b/.github/workflows/opencode-developer.yml index 59fd9992..b41522fe 100644 --- a/.github/workflows/opencode-developer.yml +++ b/.github/workflows/opencode-developer.yml @@ -28,7 +28,7 @@ jobs: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - model: opencode/glm-4.7-free + model: opencode/glm-4.7 prompt: | You are an autonomous developer agent. Your task is to: 1. List all open GitHub issues in this repository using `gh` command diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 6ce6137c..58b82078 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -45,7 +45,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOME: /home/runner # This is necessary for the self-hosted runner with: - model: opencode/glm-4.7-free + model: opencode/glm-4.7 prompt: | ${{ steps.pr-number.outputs.number }} diff --git a/.github/workflows/opencode-supervisor.yml b/.github/workflows/opencode-supervisor.yml index 595ef18b..476858b6 100644 --- a/.github/workflows/opencode-supervisor.yml +++ b/.github/workflows/opencode-supervisor.yml @@ -47,7 +47,7 @@ jobs: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - model: opencode/glm-4.7-free + model: opencode/glm-4.7 prompt: | Read and understand @AGENTS.md to learn the project's rules and conventions. Then analyze this codebase for any improvements that adhere to those rules. diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index caa1d800..199ed270 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -29,4 +29,4 @@ jobs: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - model: opencode/glm-4.7-free + model: opencode/glm-4.7 diff --git a/opencode.json b/opencode.json index 3488a8af..f872779d 100644 --- a/opencode.json +++ b/opencode.json @@ -2,13 +2,13 @@ "$schema": "https://opencode.ai/config.json", "agent": { "plan": { - "model": "opencode/glm-4.7-free" + "model": "opencode/glm-4.7" }, "build": { - "model": "opencode/glm-4.7-free" + "model": "opencode/glm-4.7" }, "debugger": { - "model": "opencode/glm-4.7-free" + "model": "opencode/glm-4.7" } } } From 90a41fccba002c3c5caf963af547618250aae991 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 29 Jan 2026 14:45:14 +0100 Subject: [PATCH 05/42] lets try this --- opencode.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/opencode.json b/opencode.json index f872779d..f2509e47 100644 --- a/opencode.json +++ b/opencode.json @@ -2,13 +2,13 @@ "$schema": "https://opencode.ai/config.json", "agent": { "plan": { - "model": "opencode/glm-4.7" + "model": "opencode/kimi-k2.5" }, "build": { - "model": "opencode/glm-4.7" + "model": "opencode/kimi-k2.5" }, "debugger": { - "model": "opencode/glm-4.7" + "model": "opencode/kimi-k2.5" } } } From 3fa7f021758683b90a1c9be2e2da761be76f4993 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 29 Jan 2026 14:49:41 +0100 Subject: [PATCH 06/42] fix: address critical bugs in deployment refactor This commit fixes several critical bugs identified in the deployment refactor: Critical Fixes: - Fix getProductionCurrentSymlink() to use correct path (data/projects/{id}/production/current) - Fix symlink target to use full path instead of just hash name - Add missing file copies in productionBuild (Dockerfile.prod, config files, src/) - Fix production container cleanup in projectDelete handler - Fix rollback action to use correct symlink paths Improvements: - Remove unused docker-compose.yml creation from productionBuild (using docker run) - Add Docker image cleanup in productionStop and projectDelete - Fix race condition in temp symlink names (add Math.random()) These fixes ensure deployments work correctly with the new encapsulated structure. --- src/actions/projects.ts | 7 +- src/server/projects/paths.ts | 2 +- src/server/queue/handlers/productionBuild.ts | 95 ++++++++++---------- src/server/queue/handlers/productionStart.ts | 9 +- src/server/queue/handlers/productionStop.ts | 18 ++++ src/server/queue/handlers/projectDelete.ts | 38 +++++++- 6 files changed, 112 insertions(+), 57 deletions(-) diff --git a/src/actions/projects.ts b/src/actions/projects.ts index ec9ed9e0..c7e989f9 100644 --- a/src/actions/projects.ts +++ b/src/actions/projects.ts @@ -466,16 +466,17 @@ export const projects = { } // Update symlink - const { getProductionCurrentSymlink } = await import( + const { getProductionPath, getProductionCurrentSymlink } = await import( "@/server/projects/paths" ); const symlinkPath = getProductionCurrentSymlink(project.id); - const tempSymlink = `${symlinkPath}.tmp-${Date.now()}`; + 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(input.toHash, tempSymlink); + await fs.symlink(hashPath, tempSymlink); await fs.rename(tempSymlink, symlinkPath); } catch { // Symlink update failed, but container is running diff --git a/src/server/projects/paths.ts b/src/server/projects/paths.ts index f5f8d544..950861aa 100644 --- a/src/server/projects/paths.ts +++ b/src/server/projects/paths.ts @@ -98,7 +98,7 @@ export function getProductionPath(projectId: string, hash?: string): string { * @returns Absolute path to current symlink */ export function getProductionCurrentSymlink(projectId: string): string { - return path.join(getProductionsPath(), projectId, "current"); + return path.join(getProjectProductionPath(projectId), "current"); } /** diff --git a/src/server/queue/handlers/productionBuild.ts b/src/server/queue/handlers/productionBuild.ts index 0a5a93af..4fd78bf1 100644 --- a/src/server/queue/handlers/productionBuild.ts +++ b/src/server/queue/handlers/productionBuild.ts @@ -1,4 +1,4 @@ -import { promises as fs } from "node:fs/promises"; +import * as fs from "node:fs/promises"; import * as path from "node:path"; import { logger } from "@/server/logger"; import { hashDistFolder } from "@/server/productions/hash"; @@ -76,26 +76,8 @@ export async function handleProductionBuild( const productionPath = getProjectProductionPath(project.id, productionHash); await fs.mkdir(productionPath, { recursive: true }); - // Copy required files to production version - await fs.copyFile( - path.join(previewPath, "package.json"), - path.join(productionPath, "package.json"), - ); - await fs.copyFile( - path.join(previewPath, "pnpm-lock.yaml"), - path.join(productionPath, "pnpm-lock.yaml"), - ); - await fs.cp(distPath, path.join(productionPath, "dist"), { - recursive: true, - }); - - // Create docker-compose.yml for this version - await createProductionComposeFile( - productionPath, - project.productionPort, - productionHash, - project.id, - ); + // Copy all required files for production build + await copyRequiredFiles(previewPath, productionPath, project.id); logger.debug( { projectId: project.id, productionHash, productionPath }, @@ -115,36 +97,53 @@ export async function handleProductionBuild( } /** - * Create docker-compose.yml for production deployment. + * Copy all required files from preview to production directory. */ -async function createProductionComposeFile( +async function copyRequiredFiles( + previewPath: string, productionPath: string, - productionPort: number, - hash: string, projectId: string, ): Promise { - const composeContent = `services: - production: - build: - context: . - container_name: doce-prod-${projectId}-${hash.slice(0, 12)} - ports: - - ${productionPort}:3000 - environment: - - NODE_ENV=production - - HOST=0.0.0.0 - - PORT=3000 - restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000"] - interval: 10s - timeout: 5s - retries: 3 - start_period: 30s -`; - - await fs.writeFile( - path.join(productionPath, "docker-compose.yml"), - composeContent, + // Core files + await fs.copyFile( + path.join(previewPath, "package.json"), + path.join(productionPath, "package.json"), + ); + await fs.copyFile( + path.join(previewPath, "pnpm-lock.yaml"), + path.join(productionPath, "pnpm-lock.yaml"), + ); + + // Docker build files (required by Dockerfile.prod) + await fs.copyFile( + path.join(previewPath, "Dockerfile.prod"), + path.join(productionPath, "Dockerfile.prod"), + ); + + // Config files (required by Dockerfile.prod for build stage) + await fs.copyFile( + path.join(previewPath, "astro.config.mjs"), + path.join(productionPath, "astro.config.mjs"), ); + await fs.copyFile( + path.join(previewPath, "tsconfig.json"), + path.join(productionPath, "tsconfig.json"), + ); + + // Source code (required by Dockerfile.prod for build stage) + await fs.cp(path.join(previewPath, "src"), path.join(productionPath, "src"), { + recursive: true, + }); + + // Public folder (optional - might contain assets) + try { + await fs.cp( + path.join(previewPath, "public"), + path.join(productionPath, "public"), + { recursive: true }, + ); + } catch (error) { + // public folder might not exist, that's okay + logger.debug({ projectId }, "public folder not found (optional)"); + } } diff --git a/src/server/queue/handlers/productionStart.ts b/src/server/queue/handlers/productionStart.ts index f7902b2f..d393c83f 100644 --- a/src/server/queue/handlers/productionStart.ts +++ b/src/server/queue/handlers/productionStart.ts @@ -134,17 +134,20 @@ export async function handleProductionStart( const symlinkPath = getProductionCurrentSymlink(project.id); await fs.mkdir(path.dirname(symlinkPath), { recursive: true }); - const tempSymlink = `${symlinkPath}.tmp-${Date.now()}`; + // Use full path to hash directory as symlink target + const hashPath = getProductionPath(project.id, payload.productionHash); + // Add random component to avoid collision if two builds complete at same millisecond + const tempSymlink = `${symlinkPath}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; try { await fs.unlink(tempSymlink).catch(() => {}); - await fs.symlink(payload.productionHash, tempSymlink); + await fs.symlink(hashPath, tempSymlink); await fs.rename(tempSymlink, symlinkPath); logger.debug( { projectId: project.id, symlinkPath, - target: payload.productionHash, + target: hashPath, }, "Updated production current symlink", ); diff --git a/src/server/queue/handlers/productionStop.ts b/src/server/queue/handlers/productionStop.ts index 57ed0759..572ba10c 100644 --- a/src/server/queue/handlers/productionStop.ts +++ b/src/server/queue/handlers/productionStop.ts @@ -73,6 +73,24 @@ export async function handleProductionStop( "Production container stopped", ); + // Clean up Docker image (best-effort, don't throw on failure) + const imageName = `doce-prod-${project.id}-${currentHash}`; + try { + const rmiResult = await spawnCommand("docker", ["rmi", imageName]); + if (rmiResult.success) { + logger.debug( + { projectId: project.id, imageName }, + "Removed Docker image", + ); + } + } catch (error) { + // Image might not exist or be in use, that's okay + logger.debug( + { projectId: project.id, imageName, error }, + "Failed to remove Docker image (might not exist)", + ); + } + // Update production status to stopped // Keep hash, port, and URL for rollback await updateProductionStatus(project.id, "stopped"); diff --git a/src/server/queue/handlers/projectDelete.ts b/src/server/queue/handlers/projectDelete.ts index c34e7b4c..c80e2cc9 100644 --- a/src/server/queue/handlers/projectDelete.ts +++ b/src/server/queue/handlers/projectDelete.ts @@ -7,6 +7,7 @@ import { hardDeleteProject, updateProjectStatus, } from "@/server/projects/projects.model"; +import { spawnCommand } from "@/server/utils/execAsync"; import type { QueueJobContext } from "../queue.worker"; import { parsePayload } from "../types"; @@ -53,13 +54,46 @@ export async function handleProjectDelete(ctx: QueueJobContext): Promise { // Step 2: Stop and remove Docker containers (best-effort) try { + // Stop dev containers (preview + opencode) await composeDownWithVolumes(project.id, project.pathOnDisk); - logger.debug({ projectId: project.id }, "Docker compose down completed"); + logger.debug( + { projectId: project.id }, + "Dev containers stopped (preview + opencode)", + ); + + // Stop production 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) { + logger.debug( + { projectId: project.id, containerName }, + "Production container stopped and removed", + ); + } + + // Clean up Docker images (best-effort) + const imagePrefix = `doce-prod-${project.id}-`; + const listResult = await spawnCommand("docker", [ + "images", + imagePrefix, + "--format", + "{{.Repository}}:{{.Tag}}", + ]); + + if (listResult.success && listResult.stdout) { + const images = listResult.stdout.trim().split("\n").filter(Boolean); + for (const image of images) { + await spawnCommand("docker", ["rmi", image]); + logger.debug({ projectId: project.id, image }, "Removed Docker image"); + } + } } catch (error) { // Non-critical: Docker might not be available or already stopped logger.warn( { error, projectId: project.id }, - "Failed to stop Docker containers", + "Failed to stop Docker containers or remove images", ); } From b5046f103f347626570f2805c75d2990d0eae9c1 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 29 Jan 2026 17:19:57 +0100 Subject: [PATCH 07/42] tmp dir --- .github/workflows/opencode-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 58b82078..5d1e2042 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -43,7 +43,7 @@ jobs: env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HOME: /home/runner # This is necessary for the self-hosted runner + HOME: /tmp/github-home-${{steps.pr-number.outputs.number}} # This is necessary for the self-hosted runner with: model: opencode/glm-4.7 prompt: | From bd0648234f2c0277e4acc8aa1f42825e342191f8 Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 29 Jan 2026 17:23:24 +0100 Subject: [PATCH 08/42] chore: switch all workflows from glm-4.7 to kimi-k2.5 --- .github/workflows/opencode-developer.yml | 2 +- .github/workflows/opencode-review.yml | 2 +- .github/workflows/opencode-supervisor.yml | 2 +- .github/workflows/opencode.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/opencode-developer.yml b/.github/workflows/opencode-developer.yml index b41522fe..203ea160 100644 --- a/.github/workflows/opencode-developer.yml +++ b/.github/workflows/opencode-developer.yml @@ -28,7 +28,7 @@ jobs: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - model: opencode/glm-4.7 + model: opencode/kimi-k2.5 prompt: | You are an autonomous developer agent. Your task is to: 1. List all open GitHub issues in this repository using `gh` command diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 5d1e2042..209eb530 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -45,7 +45,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOME: /tmp/github-home-${{steps.pr-number.outputs.number}} # This is necessary for the self-hosted runner with: - model: opencode/glm-4.7 + model: opencode/kimi-k2.5 prompt: | ${{ steps.pr-number.outputs.number }} diff --git a/.github/workflows/opencode-supervisor.yml b/.github/workflows/opencode-supervisor.yml index 476858b6..cbf9dca8 100644 --- a/.github/workflows/opencode-supervisor.yml +++ b/.github/workflows/opencode-supervisor.yml @@ -47,7 +47,7 @@ jobs: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - model: opencode/glm-4.7 + model: opencode/kimi-k2.5 prompt: | Read and understand @AGENTS.md to learn the project's rules and conventions. Then analyze this codebase for any improvements that adhere to those rules. diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 199ed270..c7054489 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -29,4 +29,4 @@ jobs: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - model: opencode/glm-4.7 + model: opencode/kimi-k2.5 From 29b35fe9e07fc2dd47ede372a569b668dbe4ff5d Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 29 Jan 2026 17:25:54 +0100 Subject: [PATCH 09/42] chore: use random HOME directory in opencode-review workflow --- .github/workflows/opencode-review.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 209eb530..fcab8dd5 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -30,6 +30,12 @@ jobs: echo "number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT" fi + - name: Generate random HOME directory + id: random-home + run: | + RANDOM_SUFFIX=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 12 | head -n 1) + echo "home=/tmp/github-home-${{steps.pr-number.outputs.number}}-${RANDOM_SUFFIX}" >> "$GITHUB_OUTPUT" + - name: Checkout repository uses: actions/checkout@v4 with: @@ -43,7 +49,7 @@ jobs: env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HOME: /tmp/github-home-${{steps.pr-number.outputs.number}} # This is necessary for the self-hosted runner + HOME: ${{steps.random-home.outputs.home}} # This is necessary for the self-hosted runner with: model: opencode/kimi-k2.5 prompt: | From 53ef58b9cc46e7b1a7a9322a070ed77b3ded0c8a Mon Sep 17 00:00:00 2001 From: Pablo Date: Thu, 29 Jan 2026 17:37:21 +0100 Subject: [PATCH 10/42] fix: run build from preview directory (not src/) to find node_modules --- src/server/queue/handlers/productionBuild.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/server/queue/handlers/productionBuild.ts b/src/server/queue/handlers/productionBuild.ts index 4fd78bf1..bf88fe5e 100644 --- a/src/server/queue/handlers/productionBuild.ts +++ b/src/server/queue/handlers/productionBuild.ts @@ -40,12 +40,11 @@ export async function handleProductionBuild( logger.info({ projectId: project.id }, "Starting production build"); - // Run pnpm run build in the preview/src/ directory + // Run pnpm run build in the preview directory (where package.json and node_modules are) const previewPath = getProjectPreviewPath(project.id); - const srcPath = path.join(previewPath, "src"); const result = await spawnCommand("pnpm", ["run", "build"], { - cwd: srcPath, + cwd: previewPath, timeout: 5 * 60 * 1000, // 5 minute timeout }); From ca33af7e901388b490da909a2c3b81644fd1887d Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Thu, 29 Jan 2026 19:55:52 +0100 Subject: [PATCH 11/42] fix db --- Dockerfile | 5 + docker-compose.preview.yml | 2 + ...ther_askani.sql => 0000_brief_tempest.sql} | 4 +- drizzle/meta/0000_snapshot.json | 1129 +++++++++-------- drizzle/meta/0001_snapshot.json | 578 --------- drizzle/meta/_journal.json | 11 +- package.json | 2 +- scripts/bootstrap.sh | 21 + scripts/start-preview.sh | 47 + 9 files changed, 657 insertions(+), 1142 deletions(-) rename drizzle/{0000_cultured_mother_askani.sql => 0000_brief_tempest.sql} (96%) delete mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 scripts/bootstrap.sh create mode 100644 scripts/start-preview.sh diff --git a/Dockerfile b/Dockerfile index 6b11689a..a8c60c76 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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..b52cb1f2 100644 --- a/docker-compose.preview.yml +++ b/docker-compose.preview.yml @@ -8,6 +8,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 +16,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/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 deleted file mode 100644 index 0ed290ec..00000000 --- a/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,578 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "91fcf61d-499f-4b36-becc-690c5cd34b82", - "prevId": "b0f2f20e-d051-4d78-aa66-ba082b5b9bb2", - "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/_journal.json b/drizzle/meta/_journal.json index a4f11f43..3c23dfb9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -5,15 +5,8 @@ { "idx": 0, "version": "6", - "when": 1767962667134, - "tag": "0000_cultured_mother_askani", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1769621154174, - "tag": "0001_tidy_orphan", + "when": 1769712818274, + "tag": "0000_brief_tempest", "breakpoints": true } ] diff --git a/package.json b/package.json index 10be6f2a..b6d9ebe0 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", 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..3a0ce5b8 --- /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 2>/dev/null; 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 From 08204369feef103981c038458378204f6ff6a271 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Thu, 29 Jan 2026 20:04:30 +0100 Subject: [PATCH 12/42] fix: use correct preview path for docker compose commands --- opencode.json | 25 ++++++++++++++++++- src/actions/projects.ts | 4 +-- src/server/queue/handlers/dockerComposeUp.ts | 4 ++- .../queue/handlers/dockerEnsureRunning.ts | 4 ++- src/server/queue/handlers/dockerStop.ts | 4 ++- src/server/queue/handlers/projectDelete.ts | 8 ++++-- 6 files changed, 41 insertions(+), 8 deletions(-) diff --git a/opencode.json b/opencode.json index f2509e47..d5520548 100644 --- a/opencode.json +++ b/opencode.json @@ -10,5 +10,28 @@ "debugger": { "model": "opencode/kimi-k2.5" } - } + }, + "mcp": { + "sequential-thinking": { + "type": "local", + "command": ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"] + }, + "context7": { + "type": "remote", + "url": "https://mcp.context7.com/mcp", + "headers": { + "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" + }, + "enabled": true + }, + "chrome-devtools": { + "type": "local", + "command": ["npx", "-y", "chrome-devtools-mcp@latest"] + }, + "grep.app": { + "type": "remote", + "url": "https://mcp.grep.app", + "enabled": true + } + } } diff --git a/src/actions/projects.ts b/src/actions/projects.ts index c7e989f9..c78e8fc2 100644 --- a/src/actions/projects.ts +++ b/src/actions/projects.ts @@ -466,12 +466,12 @@ export const projects = { } // Update symlink - const { getProductionPath, getProductionCurrentSymlink } = await import( + 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 tempSymlink = `${symlinkPath}.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const fs = await import("node:fs/promises"); try { diff --git a/src/server/queue/handlers/dockerComposeUp.ts b/src/server/queue/handlers/dockerComposeUp.ts index 31475953..0f395b1c 100644 --- a/src/server/queue/handlers/dockerComposeUp.ts +++ b/src/server/queue/handlers/dockerComposeUp.ts @@ -1,5 +1,6 @@ import { composeUp } from "@/server/docker/compose"; import { logger } from "@/server/logger"; +import { getProjectPreviewPath } from "@/server/projects/paths"; import { getProjectByIdIncludeDeleted, updateProjectStatus, @@ -33,7 +34,8 @@ export async function handleDockerComposeUp( await ctx.throwIfCancelRequested(); - const result = await composeUp(project.id, project.pathOnDisk); + const previewPath = getProjectPreviewPath(project.id); + const result = await composeUp(project.id, previewPath); if (!result.success) { const errorMsg = `compose up failed: ${result.stderr.slice(0, 500)}`; await updateProjectStatus(project.id, "error"); diff --git a/src/server/queue/handlers/dockerEnsureRunning.ts b/src/server/queue/handlers/dockerEnsureRunning.ts index 3e6bd4b8..f829aaa2 100644 --- a/src/server/queue/handlers/dockerEnsureRunning.ts +++ b/src/server/queue/handlers/dockerEnsureRunning.ts @@ -5,6 +5,7 @@ import { checkOpencodeReady, checkPreviewReady, } from "@/server/projects/health"; +import { getProjectPreviewPath } from "@/server/projects/paths"; import { getProjectByIdIncludeDeleted, updateProjectStatus, @@ -33,7 +34,8 @@ export async function handleDockerEnsureRunning( await ctx.throwIfCancelRequested(); - const result = await composeUp(project.id, project.pathOnDisk); + const previewPath = getProjectPreviewPath(project.id); + const result = await composeUp(project.id, previewPath); if (!result.success) { await updateProjectStatus(project.id, "error"); throw new Error(`compose up failed: ${result.stderr.slice(0, 500)}`); diff --git a/src/server/queue/handlers/dockerStop.ts b/src/server/queue/handlers/dockerStop.ts index 12fcec63..b17f08ad 100644 --- a/src/server/queue/handlers/dockerStop.ts +++ b/src/server/queue/handlers/dockerStop.ts @@ -1,4 +1,5 @@ import { composeDown } from "@/server/docker/compose"; +import { getProjectPreviewPath } from "@/server/projects/paths"; import { getProjectByIdIncludeDeleted, updateProjectStatus, @@ -22,7 +23,8 @@ export async function handleDockerStop(ctx: QueueJobContext): Promise { await ctx.throwIfCancelRequested(); - const result = await composeDown(project.id, project.pathOnDisk); + const previewPath = getProjectPreviewPath(project.id); + const result = await composeDown(project.id, previewPath); if (result.success) { await updateProjectStatus(project.id, "stopped"); diff --git a/src/server/queue/handlers/projectDelete.ts b/src/server/queue/handlers/projectDelete.ts index c80e2cc9..54a2f934 100644 --- a/src/server/queue/handlers/projectDelete.ts +++ b/src/server/queue/handlers/projectDelete.ts @@ -1,7 +1,10 @@ import * as fs from "node:fs/promises"; import { composeDownWithVolumes } from "@/server/docker/compose"; import { logger } from "@/server/logger"; -import { normalizeProjectPath } from "@/server/projects/paths"; +import { + getProjectPreviewPath, + normalizeProjectPath, +} from "@/server/projects/paths"; import { getProjectByIdIncludeDeleted, hardDeleteProject, @@ -55,7 +58,8 @@ export async function handleProjectDelete(ctx: QueueJobContext): Promise { // Step 2: Stop and remove Docker containers (best-effort) try { // Stop dev containers (preview + opencode) - await composeDownWithVolumes(project.id, project.pathOnDisk); + const previewPath = getProjectPreviewPath(project.id); + await composeDownWithVolumes(project.id, previewPath); logger.debug( { projectId: project.id }, "Dev containers stopped (preview + opencode)", From 2da7a9b7dda42e541263d80b660414b943c6656f Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Thu, 29 Jan 2026 20:11:14 +0100 Subject: [PATCH 13/42] fix: add --env-file flag to docker compose to load env from parent directory --- src/server/docker/compose.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/server/docker/compose.ts b/src/server/docker/compose.ts index 2c58d601..46c625d9 100644 --- a/src/server/docker/compose.ts +++ b/src/server/docker/compose.ts @@ -107,6 +107,11 @@ async function runComposeCommand( // Build args with optional profile and file flags (both must come BEFORE the subcommand) const fullArgs = [...compose.slice(1), ...baseArgs]; + + // Add env file from parent directory (project root) since we run from preview/ + const envFilePath = path.join(projectPath, "..", ".env"); + fullArgs.push("--env-file", envFilePath); + if (filePath) { fullArgs.push("-f", filePath); } From 4b764c826ad129399296d39829e83b5f1a5f0de3 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Thu, 29 Jan 2026 20:15:33 +0100 Subject: [PATCH 14/42] fix: use absolute path for --env-file flag --- src/server/docker/compose.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server/docker/compose.ts b/src/server/docker/compose.ts index 46c625d9..d48b5ac9 100644 --- a/src/server/docker/compose.ts +++ b/src/server/docker/compose.ts @@ -109,7 +109,8 @@ async function runComposeCommand( const fullArgs = [...compose.slice(1), ...baseArgs]; // Add env file from parent directory (project root) since we run from preview/ - const envFilePath = path.join(projectPath, "..", ".env"); + // Use resolve to get absolute path (docker compose needs absolute path for --env-file) + const envFilePath = path.resolve(path.join(projectPath, "..", ".env")); fullArgs.push("--env-file", envFilePath); if (filePath) { From 18da7a1ed81b2b4220a893d501d8d038a0e7c08e Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Thu, 29 Jan 2026 20:25:47 +0100 Subject: [PATCH 15/42] kimi --- src/components/dashboard/ModelSelector.tsx | 3 +++ src/components/ui/svgs/kimi.tsx | 22 ++++++++++++++++++++++ src/server/config/models.ts | 5 +++++ 3 files changed, 30 insertions(+) create mode 100644 src/components/ui/svgs/kimi.tsx diff --git a/src/components/dashboard/ModelSelector.tsx b/src/components/dashboard/ModelSelector.tsx index d4a866cf..76c18e3b 100644 --- a/src/components/dashboard/ModelSelector.tsx +++ b/src/components/dashboard/ModelSelector.tsx @@ -25,6 +25,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"; @@ -43,6 +44,8 @@ const VENDOR_LOGOS: Record< google: { light: Gemini, dark: Gemini }, "z.ai": { light: ZaiLight, dark: ZaiDark }, minimax: { light: Minimax, dark: MinimaxDark }, + kimi: { light: Kimi, dark: Kimi }, + moonshotai: { light: Kimi, dark: Kimi }, }; interface ModelSelectorProps { diff --git a/src/components/ui/svgs/kimi.tsx b/src/components/ui/svgs/kimi.tsx new file mode 100644 index 00000000..e105719d --- /dev/null +++ b/src/components/ui/svgs/kimi.tsx @@ -0,0 +1,22 @@ +import type { SVGProps } from "react"; + +const Kimi = (props: SVGProps) => ( + + + + + +); + +export { Kimi }; diff --git a/src/server/config/models.ts b/src/server/config/models.ts index 66a99c14..7734af1d 100644 --- a/src/server/config/models.ts +++ b/src/server/config/models.ts @@ -16,6 +16,11 @@ export const CURATED_MODELS = [ "claude-haiku-4-5", "anthropic/claude-haiku-4.5", "gemini-3-flash", + "google/gemini-3-flash", + "gemini-3-flash-preview", + "google/gemini-3-flash-preview", + "moonshotai/kimi-k2.5", + "kimi-k2.5", ] as const; /** From e1cce1cf32b743eee11f7976dd1718ea933c11c9 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Thu, 29 Jan 2026 20:34:36 +0100 Subject: [PATCH 16/42] fix: ensure log streaming works after server restarts The issue was that log streaming stopped working when the server was restarted because the streamingProcesses Map is stored in-memory only. When the server restarted, the Map was empty even though containers were still running. Changes: - Added isStreamingActive() to check if streaming is currently active - Added checkContainersRunning() to verify containers are running via docker compose ps - Added ensureLogStreaming() to create logs directory and restart streaming if needed - Modified logs API endpoint to call ensureLogStreaming when receiving requests This ensures logs are always available when viewing the terminal, even after server restarts. The fix is lazy (runs on first API call) and idempotent (safe to call multiple times). --- src/pages/api/projects/[id]/logs.ts | 13 +++- src/server/docker/logs.ts | 96 +++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/src/pages/api/projects/[id]/logs.ts b/src/pages/api/projects/[id]/logs.ts index 425614b4..67b35108 100644 --- a/src/pages/api/projects/[id]/logs.ts +++ b/src/pages/api/projects/[id]/logs.ts @@ -1,7 +1,11 @@ import * as path from "node:path"; import type { APIRoute } from "astro"; import { validateSession } from "@/server/auth/sessions"; -import { readLogFromOffset, readLogTail } from "@/server/docker/logs"; +import { + ensureLogStreaming, + readLogFromOffset, + readLogTail, +} from "@/server/docker/logs"; import { getProjectById, isProjectOwnedByUser, @@ -39,6 +43,13 @@ export const GET: APIRoute = async ({ params, url, cookies }) => { return new Response("Not found", { status: 404 }); } + // Ensure log streaming is active if containers are running + // This handles the case where the server was restarted and lost the streaming process + void ensureLogStreaming(projectId, project.pathOnDisk).catch((error) => { + // Non-critical error - log but don't fail the request + console.error("Failed to ensure log streaming:", error); + }); + // Get offset from query params const offsetParam = url.searchParams.get("offset"); const requestedOffset = offsetParam ? parseInt(offsetParam, 10) : null; diff --git a/src/server/docker/logs.ts b/src/server/docker/logs.ts index c62b887b..3554c1a0 100644 --- a/src/server/docker/logs.ts +++ b/src/server/docker/logs.ts @@ -297,6 +297,16 @@ export function stopStreamingContainerLogs(projectId: string): void { } } +/** + * Check if log streaming is currently active for a project. + */ +export function isStreamingActive(projectId: string): boolean { + const proc = streamingProcesses.get(projectId); + if (!proc) return false; + // Check if process is still running (not killed) + return proc.exitCode === null && proc.signalCode === null; +} + /** * Read the last N bytes of the log file. */ @@ -466,3 +476,89 @@ function truncateLine(line: string): string { } return `${line.slice(0, 197)}...`; } + +/** + * Check if containers are running for a project by running docker compose ps. + */ +async function checkContainersRunning( + projectId: string, + projectPath: string, +): Promise { + try { + const projectName = `doce_${projectId}`; + const result = await runCommand( + `docker compose --project-name ${projectName} ps --format json`, + { + cwd: projectPath, + timeout: 10_000, + }, + ); + + if (!result.success || !result.stdout) { + return false; + } + + // Parse the JSON output - each line is a container + const lines = result.stdout + .trim() + .split("\n") + .filter((l) => l.trim()); + for (const line of lines) { + try { + const container = JSON.parse(line) as { State?: string }; + if (container.State === "running") { + return true; + } + } catch { + // Ignore parse errors for individual lines + } + } + return false; + } catch (err) { + logger.debug( + { error: err, projectId }, + "Failed to check if containers are running", + ); + return false; + } +} + +/** + * Ensure log streaming is active for a project. + * Creates logs directory if needed, and starts streaming if containers are running. + * This is idempotent - safe to call multiple times. + * + * @returns Object indicating whether streaming was started and if containers are running + */ +export async function ensureLogStreaming( + projectId: string, + projectPath: string, +): Promise<{ streamingStarted: boolean; containersRunning: boolean }> { + const logsDir = path.join(projectPath, "logs"); + + // Ensure logs directory exists + await ensureLogsDir(logsDir); + + // Check if streaming is already active + if (isStreamingActive(projectId)) { + return { streamingStarted: false, containersRunning: true }; + } + + // Check if containers are running + const containersRunning = await checkContainersRunning( + projectId, + projectPath, + ); + if (!containersRunning) { + return { streamingStarted: false, containersRunning: false }; + } + + // Containers are running but streaming is not active - start streaming + logger.info( + { projectId }, + "Containers running but log streaming not active, starting streaming", + ); + streamContainerLogs(projectId, projectPath); + + return { streamingStarted: true, containersRunning: true }; +} From 7c5284f46d1c5a3b1170138737e160d591dd26f2 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Sat, 7 Feb 2026 23:45:33 +0100 Subject: [PATCH 17/42] refactor: improve opencode workflow security and efficiency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add OPENCODE_PERMISSION restrictions to all opencode workflows: * opencode.yml: deny all bash (read-only interactive) * opencode-developer/supervisor: allowlist git/gh/pnpm/npm/bun commands * opencode-review: deny gh pr review to prevent self-approval - Optimize checkout performance: * opencode-developer: fetch-depth 0 → 1 (30-60s faster) * opencode-supervisor: shallow checkout + fetch recent history - Restructure prompts for clarity (concise STEP-based format) Implements Phase 1 (security) and Phase 3 (efficiency) improvements from anomalyco/opencode best practices. --- .github/workflows/opencode-developer.yml | 60 ++++++++++++++++------- .github/workflows/opencode-review.yml | 47 +++++++++++------- .github/workflows/opencode-supervisor.yml | 45 ++++++++++++++--- .github/workflows/opencode.yml | 1 + 4 files changed, 108 insertions(+), 45 deletions(-) diff --git a/.github/workflows/opencode-developer.yml b/.github/workflows/opencode-developer.yml index 203ea160..222de037 100644 --- a/.github/workflows/opencode-developer.yml +++ b/.github/workflows/opencode-developer.yml @@ -17,7 +17,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -27,23 +27,47 @@ jobs: env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENCODE_PERMISSION: | + { + "bash": { + "*": "deny", + "gh issue*": "allow", + "gh pr*": "allow", + "git add*": "allow", + "git commit*": "allow", + "git push*": "allow", + "git checkout*": "allow", + "git branch*": "allow", + "git status*": "allow", + "git log*": "allow", + "pnpm*": "allow", + "npm*": "allow", + "bun*": "allow" + }, + "edit": "allow", + "write": "allow" + } with: model: opencode/kimi-k2.5 prompt: | - You are an autonomous developer agent. Your task is to: - 1. List all open GitHub issues in this repository using `gh` command - 2. For each issue, fetch its full description and details using `gh issue view` to understand requirements and context - 3. Analyze each issue for complexity, scope, and feasibility to complete in a timely manner - 4. Select ONE issue that you can confidently tackle - prioritize issues with clear requirements and minimal unknowns - 5. Implement the solution for the selected issue - 6. Commit your changes using `git add . && git commit -m "your message"` - 7. Push the branch with `git push -u origin branch-name` - 8. Create a pull request using `gh pr create` that references the issue in both the title and body - - Important rules: - - If no issues are suitable, exit gracefully with message "No suitable issues found" - - Make meaningful, focused changes that directly address the issue - - Ensure code compiles/runs without errors before committing - - ALWAYS commit your changes BEFORE pushing or creating a PR - - The PR title should be descriptive and include the issue number - - Use the `gh` CLI for all GitHub operations + **STEP 1: List & Select** + List open issues: `gh issue list --state open --json number,title,body` + Select ONE issue with clear requirements and minimal unknowns. If none suitable, output "No suitable issues found" and exit. + + **STEP 2: Create Branch** + `git checkout -b fix/issue-{NUMBER}` + + **STEP 3: Implement** + Read @AGENTS.md for project rules. Make focused changes addressing the issue. Ensure code compiles/runs without errors. + + **STEP 4: Commit & Push** + `git add . && git commit -m "fix: description (#{NUMBER})"` + `git push -u origin fix/issue-{NUMBER}` + + **STEP 5: Create PR** + `gh pr create --title "fix: description (#{NUMBER})" --body "Fixes #{NUMBER}"` + + **RULES:** + - ONE issue per run only + - ALWAYS commit BEFORE pushing + - Use `gh` CLI for all GitHub operations diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index fcab8dd5..eb07aa01 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -50,32 +50,41 @@ jobs: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOME: ${{steps.random-home.outputs.home}} # This is necessary for the self-hosted runner + OPENCODE_PERMISSION: | + { + "bash": { + "*": "deny", + "gh*": "allow", + "gh pr review*": "deny" + } + } with: model: opencode/kimi-k2.5 prompt: | - - ${{ steps.pr-number.outputs.number }} - + **PR #${{ steps.pr-number.outputs.number }}** - - ${{ github.event.pull_request.body }} - + **STEP 1: Fetch Changes** + Get PR diff and changed files using gh CLI. - Please check all the code changes in this pull request against the style guide on @AGENTS.md, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do. + **STEP 2: Review** + Read changed files (not just diff) for context. Check against @AGENTS.md for: + - Style guide violations + - Potential bugs + - Missing error handling - Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block. - If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors. - Generally, write a comment instead of writing suggested change if you can help it. - - Command MUST be like this. + **STEP 3: Post Comments** + For each issue, create PR line comment: ``` - gh api \ - --method POST \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - /repos/${{ github.repository }}/pulls/${{ steps.pr-number.outputs.number }}/comments \ - -f 'body=[summary of issue]' -f 'commit_id=${{ steps.pr-details.outputs.sha }}' -f 'path=[path-to-file]' -F "line=[line]" -f 'side=RIGHT' + gh api --method POST -H "Accept: application/vnd.github+json" /repos/${{ github.repository }}/pulls/${{ steps.pr-number.outputs.number }}/comments -f 'body=[desc]' -f 'commit_id=[sha]' -f 'path=[file]' -F "line=[n]" -f 'side=RIGHT' ``` - Only create comments for actual violations. If the code follows all guidelines, use the script `./scripts/upsert-pr-comment.sh` to post "lgtm". Use the marker "" and the PR number "${{ steps.pr-number.outputs.number }}". If you have a summary to provide, include it in the same comment update using the script. DO NOT post multiple "lgtm" comments. + **STEP 4: Summary** + IF issues found: Provide review summary + IF no issues: `./scripts/upsert-pr-comment.sh "" "✅ LGTM" "${{ steps.pr-number.outputs.number }}"` + + **RULES:** + - Only comment on actual violations + - Verify suggested fixes are syntactically valid + - Prefer comments over code suggestions when uncertain + - Never post multiple LGTM comments diff --git a/.github/workflows/opencode-supervisor.yml b/.github/workflows/opencode-supervisor.yml index cbf9dca8..0a03663a 100644 --- a/.github/workflows/opencode-supervisor.yml +++ b/.github/workflows/opencode-supervisor.yml @@ -19,7 +19,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: - fetch-depth: 0 + fetch-depth: 1 + + - name: Fetch recent history + run: git fetch --shallow-since="${{ env.LOOKBACK_HOURS }} hours ago" - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -46,15 +49,41 @@ jobs: env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENCODE_PERMISSION: | + { + "bash": { + "*": "deny", + "gh issue*": "allow", + "gh pr*": "allow", + "git add*": "allow", + "git commit*": "allow", + "git push*": "allow", + "git checkout*": "allow", + "git branch*": "allow", + "git status*": "allow", + "git log*": "allow", + "pnpm*": "allow", + "npm*": "allow", + "bun*": "allow" + }, + "edit": "allow", + "write": "allow" + } with: model: opencode/kimi-k2.5 prompt: | - Read and understand @AGENTS.md to learn the project's rules and conventions. - Then analyze this codebase for any improvements that adhere to those rules. + **STEP 1: Analyze** + Read @AGENTS.md for project rules. Analyze commits from the last ${{ env.LOOKBACK_HOURS }} hours for style violations, bugs, or refactoring opportunities. + + **STEP 2: Check Existing** + Verify no existing issue or PR already addresses these. If yes, exit silently. - **IMPORTANT**: Bundle all improvements into a SINGLE pull request. Do not create separate PRs for different domains. + **STEP 3: Create Deliverable** + Bundle ALL improvements into a SINGLE PR: + - IF confident: `git checkout -b refactor/supervisor-[date]`, implement, commit, push, `gh pr create` + - IF needs discussion: Create issue with analysis + - IF no improvements found: Exit silently - If you find improvements that align with AGENTS.md and you're confident, create one comprehensive PR with all the changes. - If you're not confident or it requires opinionated input, create an issue instead. - If no improvements are found, exit silently without creating a PR or issue. - If there's an existing issue or PR tackling that same concern, do not do anything. + **RULES:** + - ONE PR with bundled changes only + - Check for duplicates first diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index c7054489..af4de663 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -28,5 +28,6 @@ jobs: env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENCODE_PERMISSION: '{"bash": "deny"}' with: model: opencode/kimi-k2.5 From 7035cd9a0dff5f2b534b68a5b0d5c308582db372 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Sun, 15 Feb 2026 19:42:47 +0100 Subject: [PATCH 18/42] refactor: harden deployment flow and model config paths --- src/actions/projects.ts | 22 ++ src/components/dashboard/ModelSelector.tsx | 44 ++-- src/components/preview/DeployButton.tsx | 17 ++ src/components/providers/ThemeProvider.tsx | 4 +- src/components/queue/JobDetailLive.tsx | 9 +- src/hooks/useQueueActions.ts | 17 +- src/middleware.ts | 2 +- src/server/config/models.ts | 3 + src/server/db/ensure-db.ts | 34 ++- src/server/docker/compose.ts | 32 +++ src/server/productions/productions.model.ts | 131 +++++++++++ src/server/projects/paths.ts | 4 + src/server/projects/projects.config.ts | 61 +++++- src/server/projects/setup.ts | 25 --- src/server/queue/enqueue.ts | 3 +- .../queue/handlers/opencodeSendUserPrompt.ts | 5 +- .../queue/handlers/opencodeSessionCreate.ts | 5 +- src/server/queue/handlers/productionBuild.ts | 187 +++++++++------- src/server/queue/handlers/productionStart.ts | 51 +---- src/server/queue/handlers/productionStop.ts | 131 ++++++----- .../queue/handlers/productionWaitReady.ts | 205 ++++++++++-------- src/server/queue/handlers/projectCreate.ts | 15 +- src/server/queue/types.ts | 1 + templates/astro-starter/Dockerfile.prod | 2 +- 24 files changed, 649 insertions(+), 361 deletions(-) diff --git a/src/actions/projects.ts b/src/actions/projects.ts index c78e8fc2..b8e3e693 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, @@ -229,6 +236,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 +283,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 }; diff --git a/src/components/dashboard/ModelSelector.tsx b/src/components/dashboard/ModelSelector.tsx index 76c18e3b..1886f445 100644 --- a/src/components/dashboard/ModelSelector.tsx +++ b/src/components/dashboard/ModelSelector.tsx @@ -43,9 +43,11 @@ const VENDOR_LOGOS: Record< 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 }, moonshotai: { light: Kimi, dark: Kimi }, + moonshot: { light: Kimi, dark: Kimi }, }; interface ModelSelectorProps { @@ -192,29 +194,33 @@ export function ModelSelector({ onModelChange(modelKey); setOpen(false); }} - className={`flex items-center gap-2 ${ + className={`flex items-center justify-between gap-2 w-full ${ !isAvailable ? "opacity-50" : "" }`} > - {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/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} + +
+ )} +
+ + {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/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/preview/PreviewPanel.tsx b/src/components/preview/PreviewPanel.tsx index 0e622679..12f1c396 100644 --- a/src/components/preview/PreviewPanel.tsx +++ b/src/components/preview/PreviewPanel.tsx @@ -5,6 +5,7 @@ import { AssetsTab } from "@/components/assets/AssetsTab"; import { FilesTab } from "@/components/files/FilesTab"; import { DeployButton } from "@/components/preview/DeployButton"; import type { ProductionVersion } from "@/components/preview/DeploymentVersionHistory"; +import { ProjectDiagnosticBanner } from "@/components/projects/ProjectDiagnosticBanner"; import { TerminalDocks } from "@/components/terminal/TerminalDocks"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -19,6 +20,11 @@ interface PresenceResponse { message: string | null; nextPollMs: number; initialPromptCompleted?: boolean; + opencodeDiagnostic?: { + category: string | null; + message: string | null; + remediation: string[] | null; + } | null; } interface ProductionStatus { @@ -87,6 +93,10 @@ export function PreviewPanel({ const [productionVersions, setProductionVersions] = useState< ProductionVersion[] >([]); + const [opencodeDiagnostic, setOpencodeDiagnostic] = useState<{ + category: string | null; + message: string | null; + } | null>(null); const viewerIdRef = useRef(null); const heartbeatRef = useRef | null>(null); const pollTimeoutRef = useRef | null>(null); @@ -162,6 +172,7 @@ export function PreviewPanel({ } setMessage(data.message); + setOpencodeDiagnostic(data.opencodeDiagnostic ?? null); onStatusChange?.(data); // State machine @@ -404,6 +415,14 @@ export function PreviewPanel({ onValueChange={(value) => setActiveTab(value as TabType)} className="flex flex-col h-full" > + {/* OpenCode Diagnostic Banner */} + {opencodeDiagnostic && ( + + )} + {/* Header with integrated tabs */}
{/* Left: Tabs + Status */} @@ -500,6 +519,14 @@ export function PreviewPanel({
+ {opencodeDiagnostic && ( + setOpencodeDiagnostic(null)} + /> + )} + {/* Preview Tab Content */} 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/queue/JobDetailLive.tsx b/src/components/queue/JobDetailLive.tsx index 2f092634..73cb549d 100644 --- a/src/components/queue/JobDetailLive.tsx +++ b/src/components/queue/JobDetailLive.tsx @@ -2,6 +2,7 @@ import { useEffect, 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"; @@ -277,14 +278,10 @@ export function JobDetailLive({ initialJob }: JobDetailLiveProps) { - {job.lastError && ( -
-

Last Error

-
-							{job.lastError}
-						
-
- )} + handleAction("retry")} + /> {pendingAction && ( diff --git a/src/components/queue/QueueDiagnostic.tsx b/src/components/queue/QueueDiagnostic.tsx new file mode 100644 index 00000000..eeaa7cc3 --- /dev/null +++ b/src/components/queue/QueueDiagnostic.tsx @@ -0,0 +1,165 @@ +import { + AlertCircle, + ChevronDown, + ChevronUp, + RefreshCw, + Settings, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + classifyThrownError, + OPENCODE_ERROR_CATEGORY_METADATA, + type OpencodeErrorCategory, + type OpencodeErrorCategoryMetadata, + type RemediationAction, +} from "@/server/opencode/diagnostics"; + +interface QueueDiagnosticProps { + error: string | null; + onRetry?: () => void; +} + +function getCategoryFromError(error: string): { + category: OpencodeErrorCategory; + confidence: string; +} { + const classification = classifyThrownError(new Error(error)); + return { + category: classification.category as OpencodeErrorCategory, + confidence: classification.confidence, + }; +} + +function getCategoryBadgeVariant( + category: OpencodeErrorCategory, +): "default" | "destructive" | "outline" { + switch (category) { + case "auth": + case "runtime_unreachable": + return "destructive"; + case "provider_model": + return "outline"; + default: + return "default"; + } +} + +export function QueueDiagnostic({ error, onRetry }: QueueDiagnosticProps) { + const [showDetails, setShowDetails] = useState(false); + + if (!error) return null; + + const { category } = getCategoryFromError(error); + const metadata: OpencodeErrorCategoryMetadata = + OPENCODE_ERROR_CATEGORY_METADATA[category] ?? { + category, + displayTitle: "Error", + defaultMessage: "An error occurred", + defaultRemediation: [], + defaultRetryable: true, + }; + const badgeVariant = getCategoryBadgeVariant(category); + + 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 job..."); + 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 task"); + break; + case "wait": + toast.info("Please wait for the service to become available"); + break; + default: + toast.info(`${action.label} action triggered`); + } + }; + + const remediation: RemediationAction[] = metadata?.defaultRemediation ?? []; + + return ( +
+
+ +
+
+

+ {metadata?.displayTitle ?? "Error"} +

+ + {category} + +
+

+ {metadata?.defaultMessage ?? "An error occurred"} +

+ + {remediation.length > 0 && ( +
+ {remediation.map((action: RemediationAction) => ( + + ))} +
+ )} + +
+ + + {showDetails && ( +
+
{error}
+
+ )} +
+
+
+
+ ); +} diff --git a/src/hooks/useChatPanel.ts b/src/hooks/useChatPanel.ts index f3259e52..081790c0 100644 --- a/src/hooks/useChatPanel.ts +++ b/src/hooks/useChatPanel.ts @@ -64,6 +64,7 @@ export function useChatPanel({ isStreaming, pendingImages, pendingImageError, + latestDiagnostic, setSessionId, setOpenCodeReady, setInitialPromptSent, @@ -78,6 +79,7 @@ export function useChatPanel({ setItems, addItem, handleChatEvent, + setLatestDiagnostic, } = store; const [expandedTools, setExpandedTools] = useState>(new Set()); @@ -497,11 +499,13 @@ export function useChatPanel({ currentModel, expandedTools, scrollRef, + latestDiagnostic, setPendingImages, setPendingImageError, handleSend, handleModelChange, toggleToolExpanded, handleScroll, + clearDiagnostic: () => setLatestDiagnostic(null), }; } diff --git a/src/pages/api/projects/[id]/opencode/[...path].ts b/src/pages/api/projects/[id]/opencode/[...path].ts index 6ba4dfbf..de77eae3 100644 --- a/src/pages/api/projects/[id]/opencode/[...path].ts +++ b/src/pages/api/projects/[id]/opencode/[...path].ts @@ -1,6 +1,7 @@ import type { APIRoute } from "astro"; import { validateSession } from "@/server/auth/sessions"; import { logger } from "@/server/logger"; +import { createProxyDiagnostic } from "@/server/opencode/diagnostics"; import { getProjectById, isProjectOwnedByUser, @@ -177,27 +178,34 @@ export const ALL: APIRoute = async ({ params, request, cookies }) => { headers: responseHeaders, }); } catch (error) { - if (error instanceof Error && error.name === "AbortError") { - return new Response(JSON.stringify({ error: "Request timeout" }), { - status: 504, - headers: { "Content-Type": "application/json" }, - }); - } + const isTimeout = + error instanceof Error && + (error.name === "AbortError" || error.message.includes("timeout")); + + const status = isTimeout ? 504 : 502; + + const diagnostic = createProxyDiagnostic( + { + status, + error: error instanceof Error ? error.message : String(error), + }, + projectId, + ); - logger.error({ error, projectId, proxyPath }, "Proxy error"); + logger.error( + { error, projectId, proxyPath, category: diagnostic.category, isTimeout }, + "Proxy error", + ); - // For session endpoints, return empty array instead of 502 error - // This allows the frontend to treat "server not ready" as "no sessions yet" - // and keep polling until the server is ready if (proxyPath.startsWith("session")) { - return new Response(JSON.stringify([]), { - status: 200, + return new Response(JSON.stringify(diagnostic), { + status, headers: { "Content-Type": "application/json" }, }); } - return new Response(JSON.stringify({ error: "Upstream error" }), { - status: 502, + return new Response(JSON.stringify(diagnostic), { + status, headers: { "Content-Type": "application/json" }, }); } diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index a01d6f9d..e69e7017 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -87,6 +87,11 @@ export const projects = sqliteTable("projects", { productionStartedAt: integer("production_started_at", { mode: "timestamp" }), productionError: text("production_error"), productionHash: text("production_hash"), + opencodeErrorCategory: text("opencode_error_category"), + opencodeErrorCode: text("opencode_error_code"), + opencodeErrorMessage: text("opencode_error_message"), + opencodeErrorSource: text("opencode_error_source"), + opencodeErrorAt: integer("opencode_error_at", { mode: "timestamp" }), }); // Queue jobs table (durable background tasks) diff --git a/src/server/opencode/diagnostics/classify.ts b/src/server/opencode/diagnostics/classify.ts new file mode 100644 index 00000000..c4f9dd94 --- /dev/null +++ b/src/server/opencode/diagnostics/classify.ts @@ -0,0 +1,451 @@ +import type { EventSessionError } from "@opencode-ai/sdk/v2/client"; +import { sanitizeError, sanitizeObject } from "./sanitize"; +import { + classifyOpencodeError, + createOpencodeDiagnostic, + isApiError, + isMessageAbortedError, + isMessageOutputLengthError, + isProviderAuthError, + isSessionErrorEvent, + isUnknownError, + OPENCODE_ERROR_CATEGORY_METADATA, + type OpencodeDiagnostic, + type OpencodeErrorCategory, + type OpencodeErrorSource, + type OpencodeProxyError, + PROXY_STATUS_TO_CATEGORY, + SDK_ERROR_NAME_TO_CATEGORY, +} from "./types"; + +/** + * Classification result containing category and confidence. + */ +export interface ClassificationResult { + category: OpencodeErrorCategory; + confidence: "high" | "medium" | "low"; + reason: string; +} + +/** + * Classifies an SSE session.error event into a taxonomy category. + * + * @param event - The EventSessionError from SSE stream + * @returns Classification result with category and confidence + */ +export function classifySseError( + event: EventSessionError, +): ClassificationResult { + // Validate event structure + if (!isSessionErrorEvent(event)) { + return { + category: "unknown", + confidence: "low", + reason: "Invalid SSE event structure - missing type or properties", + }; + } + + const error = event.properties.error; + + // No error in properties + if (!error) { + return { + category: "unknown", + confidence: "low", + reason: "SSE event has no error in properties", + }; + } + + // Use SDK error name mapping + const category = SDK_ERROR_NAME_TO_CATEGORY[error.name]; + + if (category) { + return { + category, + confidence: "high", + reason: `SSE error mapped from SDK error name: ${error.name}`, + }; + } + + // Fallback: check for APIError with status code + if (isApiError(error)) { + const statusCode = error.data.statusCode; + if (statusCode) { + const statusCategory = PROXY_STATUS_TO_CATEGORY[statusCode]; + if (statusCategory) { + return { + category: statusCategory, + confidence: "high", + reason: `APIError with status code ${statusCode} mapped to ${statusCategory}`, + }; + } + } + } + + // Unknown error type + return { + category: "unknown", + confidence: "medium", + reason: `Unrecognized error type in SSE event: ${error.name ?? "unnamed"}`, + }; +} + +/** + * Classifies a proxy error response into a taxonomy category. + * + * @param response - The proxy error response + * @returns Classification result with category and confidence + */ +export function classifyProxyResponse( + response: OpencodeProxyError, +): ClassificationResult { + const { status, error, upstreamStatus } = response; + + // Use upstream status if available (more specific) + const effectiveStatus = upstreamStatus ?? status; + + // Map status code to category + const category = PROXY_STATUS_TO_CATEGORY[effectiveStatus]; + + if (category) { + return { + category, + confidence: "high", + reason: `HTTP status ${effectiveStatus} mapped to ${category}`, + }; + } + + // Fallback: analyze error message for patterns + if (typeof error === "string") { + const messageCategory = classifyErrorMessage(error); + if (messageCategory) { + return { + category: messageCategory, + confidence: "medium", + reason: `Error message pattern matched: ${error.slice(0, 50)}...`, + }; + } + } + + // Default for unmapped status codes + // 4xx -> provider_model, 5xx -> unknown + const fallbackCategory: OpencodeErrorCategory = + effectiveStatus >= 500 ? "unknown" : "provider_model"; + + return { + category: fallbackCategory, + confidence: "low", + reason: `Unmapped status code ${effectiveStatus}, using fallback: ${fallbackCategory}`, + }; +} + +/** + * Classifies a thrown Error value into a taxonomy category. + * + * @param error - The thrown error (could be SDK error, native Error, or unknown) + * @returns Classification result with category and confidence + */ +export function classifyThrownError(error: unknown): ClassificationResult { + // SDK error types - highest confidence + if (isProviderAuthError(error)) { + return { + category: "auth", + confidence: "high", + reason: "ProviderAuthError detected", + }; + } + + if (isApiError(error)) { + const statusCode = error.data.statusCode; + if (statusCode && PROXY_STATUS_TO_CATEGORY[statusCode]) { + return { + category: PROXY_STATUS_TO_CATEGORY[statusCode], + confidence: "high", + reason: `APIError with status ${statusCode}`, + }; + } + return { + category: "provider_model", + confidence: "high", + reason: "APIError without specific status code", + }; + } + + if (isMessageAbortedError(error)) { + return { + category: "timeout", + confidence: "high", + reason: "MessageAbortedError detected", + }; + } + + if (isMessageOutputLengthError(error)) { + return { + category: "provider_model", + confidence: "high", + reason: "MessageOutputLengthError detected", + }; + } + + if (isUnknownError(error)) { + return { + category: "unknown", + confidence: "high", + reason: "UnknownError detected", + }; + } + + // Native Error types + if (error instanceof Error) { + // Check error name first (high confidence) + if (error.name === "AbortError") { + return { + category: "timeout", + confidence: "high", + reason: "AbortError detected", + }; + } + + // Then check message patterns (medium confidence) + const messageCategory = classifyErrorMessage(error.message); + if (messageCategory) { + return { + category: messageCategory, + confidence: "medium", + reason: `Error message pattern: ${error.message.slice(0, 50)}...`, + }; + } + + return { + category: "unknown", + confidence: "medium", + reason: `Generic Error: ${error.name}`, + }; + } + + // Non-Error values + if (typeof error === "string") { + const messageCategory = classifyErrorMessage(error); + if (messageCategory) { + return { + category: messageCategory, + confidence: "medium", + reason: `String error message matched pattern`, + }; + } + } + + // Complete unknown + return { + category: "unknown", + confidence: "low", + reason: `Unclassifiable error type: ${typeof error}`, + }; +} + +/** + * Analyzes an error message for known patterns and returns the matching category. + * + * @param message - Error message to analyze + * @returns Category if pattern matched, null otherwise + */ +function classifyErrorMessage(message: string): OpencodeErrorCategory | null { + const normalizedMessage = message.toLowerCase(); + + // Timeout patterns + const timeoutPatterns = [ + "timeout", + "timed out", + "abort", + "aborted", + "deadline exceeded", + "context deadline", + ]; + if (timeoutPatterns.some((pattern) => normalizedMessage.includes(pattern))) { + return "timeout"; + } + + // Connection/Runtime unreachable patterns + const connectionPatterns = [ + "econnrefused", + "enotfound", + "fetch failed", + "connection refused", + "getaddrinfo", + "network error", + "unreachable", + "cannot connect", + "connection reset", + "socket hang up", + ]; + if ( + connectionPatterns.some((pattern) => normalizedMessage.includes(pattern)) + ) { + return "runtime_unreachable"; + } + + // Auth patterns + const authPatterns = [ + "unauthorized", + "authentication failed", + "invalid api key", + "invalid token", + "access denied", + "forbidden", + "401", + "403", + ]; + if (authPatterns.some((pattern) => normalizedMessage.includes(pattern))) { + return "auth"; + } + + // Rate limit patterns (provider_model) + const rateLimitPatterns = ["rate limit", "too many requests", "429"]; + if ( + rateLimitPatterns.some((pattern) => normalizedMessage.includes(pattern)) + ) { + return "provider_model"; + } + + return null; +} + +/** + * Creates a diagnostic from an SSE error event with full sanitization. + * + * @param event - The SSE session.error event + * @param projectId - Optional project ID for context + * @returns Sanitized diagnostic object + */ +export function createSseDiagnostic( + event: EventSessionError, + projectId?: string, +): OpencodeDiagnostic { + const classification = classifySseError(event); + const source: OpencodeErrorSource = { type: "sse", event }; + + // Create base diagnostic + const diagnostic = createOpencodeDiagnostic(source, projectId); + + // Override with classification if confidence is higher + if (classification.confidence === "high") { + diagnostic.category = classification.category; + const metadata = OPENCODE_ERROR_CATEGORY_METADATA[classification.category]; + diagnostic.title = metadata.displayTitle; + diagnostic.message = metadata.defaultMessage; + diagnostic.remediation = metadata.defaultRemediation; + diagnostic.isRetryable = metadata.defaultRetryable; + } + + // Sanitize technical details + if (diagnostic.technicalDetails?.metadata) { + diagnostic.technicalDetails.metadata = sanitizeObject( + diagnostic.technicalDetails.metadata as Record, + ); + } + + return diagnostic; +} + +/** + * Creates a diagnostic from a proxy error response with full sanitization. + * + * @param response - The proxy error response + * @param projectId - Optional project ID for context + * @returns Sanitized diagnostic object + */ +export function createProxyDiagnostic( + response: OpencodeProxyError, + projectId?: string, +): OpencodeDiagnostic { + const classification = classifyProxyResponse(response); + const source: OpencodeErrorSource = { type: "proxy", response }; + + // Create base diagnostic + const diagnostic = createOpencodeDiagnostic(source, projectId); + + // Override with classification + diagnostic.category = classification.category; + const metadata = OPENCODE_ERROR_CATEGORY_METADATA[classification.category]; + diagnostic.title = metadata.displayTitle; + diagnostic.message = metadata.defaultMessage; + diagnostic.remediation = metadata.defaultRemediation; + diagnostic.isRetryable = metadata.defaultRetryable; + + // Sanitize technical details + if (diagnostic.technicalDetails?.metadata) { + diagnostic.technicalDetails.metadata = sanitizeObject( + diagnostic.technicalDetails.metadata as Record, + ); + } + + return diagnostic; +} + +/** + * Creates a diagnostic from a thrown error with full sanitization. + * + * @param error - The thrown error + * @param projectId - Optional project ID for context + * @returns Sanitized diagnostic object + */ +export function createThrownErrorDiagnostic( + error: unknown, + projectId?: string, +): OpencodeDiagnostic { + const classification = classifyThrownError(error); + const source: OpencodeErrorSource = { type: "unknown", error }; + + // Create base diagnostic + const diagnostic = createOpencodeDiagnostic(source, projectId); + + // Override with classification + diagnostic.category = classification.category; + const metadata = OPENCODE_ERROR_CATEGORY_METADATA[classification.category]; + diagnostic.title = metadata.displayTitle; + diagnostic.message = metadata.defaultMessage; + diagnostic.remediation = metadata.defaultRemediation; + diagnostic.isRetryable = metadata.defaultRetryable; + + // Sanitize technical details + if (diagnostic.technicalDetails) { + diagnostic.technicalDetails = sanitizeError( + diagnostic.technicalDetails, + ) as OpencodeDiagnostic["technicalDetails"]; + } + + return diagnostic; +} + +/** + * Re-exports from types.ts for convenience. + */ +export { + classifyOpencodeError, + createOpencodeDiagnostic, + OPENCODE_ERROR_CATEGORY_METADATA, + PROXY_STATUS_TO_CATEGORY, + SDK_ERROR_NAME_TO_CATEGORY, +}; + +/** + * Re-exports type guards from types.ts. + */ +export { + isProviderAuthError, + isApiError, + isMessageAbortedError, + isMessageOutputLengthError, + isUnknownError, + isSessionErrorEvent, +}; + +/** + * Re-exports types from types.ts. + */ +export type { + OpencodeDiagnostic, + OpencodeErrorCategory, + OpencodeErrorSource, + OpencodeProxyError, +}; diff --git a/src/server/opencode/diagnostics/index.ts b/src/server/opencode/diagnostics/index.ts new file mode 100644 index 00000000..a4f41412 --- /dev/null +++ b/src/server/opencode/diagnostics/index.ts @@ -0,0 +1,42 @@ +export { + type ClassificationResult, + classifyOpencodeError, + classifyProxyResponse, + classifySseError, + classifyThrownError, + createOpencodeDiagnostic, + createProxyDiagnostic, + createSseDiagnostic, + createThrownErrorDiagnostic, + isApiError, + isMessageAbortedError, + isMessageOutputLengthError, + isProviderAuthError, + isSessionErrorEvent, + isUnknownError, + OPENCODE_ERROR_CATEGORY_METADATA, + PROXY_STATUS_TO_CATEGORY, + SDK_ERROR_NAME_TO_CATEGORY, +} from "./classify"; +export { + DEFAULT_SENSITIVE_KEYS, + REDACTION_PLACEHOLDER, + type SanitizeOptions, + sanitizeError, + sanitizeHeaders, + sanitizeObject, + sanitizeTechnicalDetails, +} from "./sanitize"; + +export { + OPENCODE_ERROR_CATEGORIES, + type OpencodeDiagnostic, + type OpencodeErrorCategory, + type OpencodeErrorCategoryMetadata, + type OpencodeErrorSource, + type OpencodeProxyError, + type OpencodeQueueError, + type OpencodeSdkError, + type OpencodeSessionErrorEvent, + type RemediationAction, +} from "./types"; diff --git a/src/server/opencode/diagnostics/sanitize.ts b/src/server/opencode/diagnostics/sanitize.ts new file mode 100644 index 00000000..3c9a7c4c --- /dev/null +++ b/src/server/opencode/diagnostics/sanitize.ts @@ -0,0 +1,344 @@ +/** + * OpenCode Diagnostics Sanitizer + * + * Sanitizes error objects and payloads by removing sensitive fields + * while preserving structural context for debugging. + * + * @module opencode/diagnostics/sanitize + */ + +/** + * Default sensitive keys that should be redacted from objects. + * These are case-insensitive and match partial key names. + */ +export const DEFAULT_SENSITIVE_KEYS: readonly string[] = [ + // Authentication tokens + "apikey", + "api_key", + "api-key", + "authorization", + "auth", + "token", + "access_token", + "accessToken", + "refresh_token", + "refreshToken", + "bearer", + "jwt", + "password", + "secret", + "secret_key", + "secretKey", + "private_key", + "privateKey", + // Session identifiers + "cookie", + "session", + "sessionid", + "session_id", + "sessionId", + // Credentials + "credential", + "credentials", + "key", + "api_secret", + "apiSecret", + // Headers that may contain auth + "x-api-key", + "x-auth-token", + "x-access-token", +] as const; + +/** + * Redaction placeholder used to indicate a value was removed. + */ +export const REDACTION_PLACEHOLDER = "[REDACTED]"; + +/** + * Options for sanitization. + */ +export interface SanitizeOptions { + /** Additional keys to redact beyond the defaults */ + additionalKeys?: readonly string[]; + /** Keys to allow (not redact) even if they match sensitive patterns */ + allowKeys?: readonly string[]; + /** Custom redaction placeholder */ + redactionPlaceholder?: string; + /** Maximum depth to traverse nested objects */ + maxDepth?: number; +} + +/** + * Checks if a key matches any of the sensitive key patterns. + * + * @param key - The object key to check + * @param sensitiveKeys - Array of sensitive key patterns + * @returns True if the key should be redacted + */ +function isSensitiveKey( + key: string, + sensitiveKeys: readonly string[], +): boolean { + const normalizedKey = key.toLowerCase().replace(/[-_]/g, ""); + return sensitiveKeys.some((sensitive) => { + const normalizedSensitive = sensitive.toLowerCase().replace(/[-_]/g, ""); + return ( + normalizedKey === normalizedSensitive || + normalizedKey.includes(normalizedSensitive) || + normalizedSensitive.includes(normalizedKey) + ); + }); +} + +/** + * Recursively sanitizes an object by redacting sensitive values. + * + * @param value - The value to sanitize + * @param sensitiveKeys - Keys that should be redacted + * @param allowedKeys - Keys that should never be redacted + * @param placeholder - Replacement text for redacted values + * @param depth - Current recursion depth + * @param maxDepth - Maximum recursion depth + * @returns Sanitized value + */ +function sanitizeValue( + value: unknown, + sensitiveKeys: readonly string[], + allowedKeys: readonly string[], + placeholder: string, + depth: number, + maxDepth: number, +): unknown { + // Stop at max depth + if (depth >= maxDepth) { + return typeof value === "object" && value !== null + ? "[Max Depth Reached]" + : value; + } + + // Handle null + if (value === null) { + return null; + } + + // Handle arrays + if (Array.isArray(value)) { + return value.map((item) => + sanitizeValue( + item, + sensitiveKeys, + allowedKeys, + placeholder, + depth + 1, + maxDepth, + ), + ); + } + + // Handle objects + if (typeof value === "object") { + const result: Record = {}; + for (const [key, val] of Object.entries(value)) { + // Check if key is explicitly allowed + if (allowedKeys.includes(key)) { + result[key] = sanitizeValue( + val, + sensitiveKeys, + allowedKeys, + placeholder, + depth + 1, + maxDepth, + ); + continue; + } + + // Check if key is sensitive + if (isSensitiveKey(key, sensitiveKeys)) { + result[key] = placeholder; + } else { + result[key] = sanitizeValue( + val, + sensitiveKeys, + allowedKeys, + placeholder, + depth + 1, + maxDepth, + ); + } + } + return result; + } + + // Handle strings that might contain sensitive data in specific patterns + if (typeof value === "string") { + return sanitizeString(value); + } + + // Return primitives as-is + return value; +} + +/** + * Sanitizes a string value by detecting and redacting common secret patterns. + * + * @param value - String to sanitize + * @returns Sanitized string + */ +function sanitizeString(value: string): string { + // Pattern for Bearer tokens + let sanitized = value.replace( + /bearer\s+[a-zA-Z0-9_\-.]+/gi, + `Bearer ${REDACTION_PLACEHOLDER}`, + ); + + // Pattern for Basic auth (base64 encoded credentials) + sanitized = sanitized.replace( + /basic\s+[a-zA-Z0-9+/=]+/gi, + `Basic ${REDACTION_PLACEHOLDER}`, + ); + + // Pattern for API keys in query strings + sanitized = sanitized.replace( + /([?&])(api[_-]?key|token|auth)=[^&]+/gi, + `$1$2=${REDACTION_PLACEHOLDER}`, + ); + + // Pattern for JWT tokens (three base64url parts separated by dots) + sanitized = sanitized.replace( + /eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*/g, + REDACTION_PLACEHOLDER, + ); + + return sanitized; +} + +/** + * Sanitizes an object by redacting sensitive fields. + * + * This function creates a deep copy of the input with all sensitive + * keys replaced with a placeholder. It preserves the structure of + * the object for debugging purposes while removing secrets. + * + * @param input - The object to sanitize + * @param options - Sanitization options + * @returns Sanitized object + * + * @example + * ```typescript + * const sanitized = sanitizeObject({ + * message: "Request failed", + * apiKey: "sk-1234567890", + * headers: { authorization: "Bearer token123" } + * }); + * // Result: { message: "Request failed", apiKey: "[REDACTED]", headers: { authorization: "[REDACTED]" } } + * ``` + */ +export function sanitizeObject>( + input: T, + options: SanitizeOptions = {}, +): Record { + const { + additionalKeys = [], + allowKeys = [], + redactionPlaceholder = REDACTION_PLACEHOLDER, + maxDepth = 10, + } = options; + + const sensitiveKeys = [...DEFAULT_SENSITIVE_KEYS, ...additionalKeys]; + + return sanitizeValue( + input, + sensitiveKeys, + allowKeys, + redactionPlaceholder, + 0, + maxDepth, + ) as Record; +} + +/** + * Sanitizes error objects specifically, preserving error-specific fields. + * + * @param error - The error to sanitize + * @param options - Sanitization options + * @returns Sanitized error object safe for logging/display + */ +export function sanitizeError( + error: unknown, + options: SanitizeOptions = {}, +): Record { + if (error instanceof Error) { + const errorObj: Record = { + name: error.name, + message: error.message, + stack: error.stack, + }; + + // Include any custom properties from the error + if (typeof error === "object" && error !== null) { + for (const [key, value] of Object.entries(error)) { + if (!(key in errorObj)) { + errorObj[key] = value; + } + } + } + + return sanitizeObject(errorObj, options); + } + + // Handle non-Error values + if (typeof error === "object" && error !== null) { + return sanitizeObject(error as Record, options); + } + + // Handle primitives + return { value: String(error) }; +} + +/** + * Sanitizes headers object, specifically designed for HTTP headers. + * + * @param headers - Headers object to sanitize + * @param options - Sanitization options + * @returns Sanitized headers + */ +export function sanitizeHeaders( + headers: Record, + options: SanitizeOptions = {}, +): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(headers)) { + const normalizedKey = key.toLowerCase(); + if ( + DEFAULT_SENSITIVE_KEYS.some( + (sensitive) => normalizedKey === sensitive.toLowerCase(), + ) || + key.toLowerCase().includes("auth") || + key.toLowerCase().includes("token") || + key.toLowerCase().includes("cookie") + ) { + result[key] = options.redactionPlaceholder ?? REDACTION_PLACEHOLDER; + } else if (typeof value === "string") { + result[key] = sanitizeString(value); + } else { + result[key] = value; + } + } + + return result; +} + +/** + * Creates a sanitized copy of technical details for diagnostics. + * This is specifically designed for the OpencodeDiagnostic.technicalDetails field. + * + * @param details - Technical details to sanitize + * @returns Sanitized technical details + */ +export function sanitizeTechnicalDetails( + details: Record | undefined, +): Record | undefined { + if (!details) return undefined; + return sanitizeObject(details, { maxDepth: 5 }); +} diff --git a/src/server/opencode/diagnostics/test.ts b/src/server/opencode/diagnostics/test.ts new file mode 100644 index 00000000..9aa4884a --- /dev/null +++ b/src/server/opencode/diagnostics/test.ts @@ -0,0 +1,488 @@ +/** + * Test script for OpenCode Diagnostics Module + * + * Run with: npx tsx src/server/opencode/diagnostics/test.ts + */ + +import { + classifyProxyResponse, + classifySseError, + classifyThrownError, + createProxyDiagnostic, + createSseDiagnostic, + createThrownErrorDiagnostic, + type OpencodeProxyError, + REDACTION_PLACEHOLDER, + sanitizeError, + sanitizeHeaders, + sanitizeObject, +} from "./index"; + +// ============================================================================ +// Test Utilities +// ============================================================================ + +interface TestResult { + test: string; + passed: boolean; + error?: string; +} + +const results: TestResult[] = []; + +function test(name: string, fn: () => void): void { + try { + fn(); + results.push({ test: name, passed: true }); + console.log(`✅ ${name}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + results.push({ test: name, passed: false, error: errorMessage }); + console.log(`❌ ${name}: ${errorMessage}`); + } +} + +function assertEqual( + actual: unknown, + expected: unknown, + message?: string, +): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + message || + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} + +function assertContains( + haystack: string, + needle: string, + message?: string, +): void { + if (!haystack.includes(needle)) { + throw new Error(message || `Expected "${haystack}" to contain "${needle}"`); + } +} + +function assertNotContains( + haystack: string, + needle: string, + message?: string, +): void { + if (haystack.includes(needle)) { + throw new Error( + message || `Expected "${haystack}" NOT to contain "${needle}"`, + ); + } +} + +// ============================================================================ +// Sanitize Tests +// ============================================================================ + +console.log("\n🧪 Testing Sanitize Module\n"); + +test("sanitizeObject strips apiKey field", () => { + const input = { + message: "Request failed", + apiKey: "sk-1234567890abcdef", + status: 500, + }; + const result = sanitizeObject(input); + assertEqual(result.apiKey, REDACTION_PLACEHOLDER); + assertEqual(result.message, "Request failed"); + assertEqual(result.status, 500); +}); + +test("sanitizeObject strips authorization header", () => { + const input = { + headers: { + authorization: "Bearer secret-token-123", + "content-type": "application/json", + }, + }; + const result = sanitizeObject(input); + assertEqual( + (result.headers as Record).authorization, + REDACTION_PLACEHOLDER, + ); + assertEqual( + (result.headers as Record)["content-type"], + "application/json", + ); +}); + +test("sanitizeObject strips nested sensitive fields", () => { + const input = { + config: { + api_key: "secret123", + timeout: 5000, + }, + data: { + token: "jwt-token-here", + user: "john", + }, + }; + const result = sanitizeObject(input); + assertEqual( + (result.config as Record).api_key, + REDACTION_PLACEHOLDER, + ); + assertEqual((result.config as Record).timeout, 5000); + assertEqual( + (result.data as Record).token, + REDACTION_PLACEHOLDER, + ); + assertEqual((result.data as Record).user, "john"); +}); + +test("sanitizeObject strips secrets from strings", () => { + const input = { + url: "https://api.example.com?api_key=secret123&user=john", + authHeader: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test", + }; + const result = sanitizeObject(input); + assertNotContains( + result.url as string, + "secret123", + "URL should not contain api_key secret", + ); + assertContains( + result.url as string, + REDACTION_PLACEHOLDER, + "URL should have redacted placeholder", + ); + assertNotContains( + result.authHeader as string, + "eyJhbGciOi", + "Auth header should not contain JWT", + ); +}); + +test("sanitizeObject handles arrays", () => { + const input = { + items: [ + { apiKey: "key1", name: "item1" }, + { apiKey: "key2", name: "item2" }, + ], + }; + const result = sanitizeObject(input); + const items = result.items as Array>; + if (!items[0] || !items[1]) { + throw new Error("Expected items to have at least 2 elements"); + } + assertEqual(items[0].apiKey, REDACTION_PLACEHOLDER); + assertEqual(items[0].name, "item1"); + assertEqual(items[1].apiKey, REDACTION_PLACEHOLDER); + assertEqual(items[1].name, "item2"); +}); + +test("sanitizeError handles Error objects", () => { + const error = new Error("Something went wrong"); + (error as Error & { apiKey: string }).apiKey = "secret-key"; + const result = sanitizeError(error); + assertEqual(result.message, "Something went wrong"); + assertEqual(result.name, "Error"); + assertEqual(result.apiKey, REDACTION_PLACEHOLDER); + assertContains(result.stack as string, "Error: Something went wrong"); +}); + +test("sanitizeHeaders redacts sensitive headers", () => { + const headers = { + Authorization: "Bearer token123", + "X-API-Key": "secret-key", + "Content-Type": "application/json", + Cookie: "session=abc123", + }; + const result = sanitizeHeaders(headers); + assertEqual(result.Authorization, REDACTION_PLACEHOLDER); + assertEqual(result["X-API-Key"], REDACTION_PLACEHOLDER); + assertEqual(result["Content-Type"], "application/json"); + assertEqual(result.Cookie, REDACTION_PLACEHOLDER); +}); + +test("sanitizeObject respects allowKeys option", () => { + const input = { + token: "should-be-redacted", + myToken: "should-be-allowed", + }; + const result = sanitizeObject(input, { allowKeys: ["myToken"] }); + assertEqual(result.token, REDACTION_PLACEHOLDER); + assertEqual(result.myToken, "should-be-allowed"); +}); + +test("sanitizeObject respects maxDepth option", () => { + const input = { + level1: { + level2: { + level3: { + level4: { + apiKey: "secret", + }, + }, + }, + }, + }; + const result = sanitizeObject(input, { maxDepth: 2 }); + assertEqual( + (result.level1 as Record).level2, + "[Max Depth Reached]", + ); +}); + +// ============================================================================ +// Classify Tests +// ============================================================================ + +console.log("\n🧪 Testing Classify Module\n"); + +test("classifySseError maps ProviderAuthError to auth", () => { + const event = { + type: "session.error" as const, + properties: { + error: { + name: "ProviderAuthError" as const, + data: { + providerID: "openai", + message: "Invalid API key", + }, + }, + }, + }; + const result = classifySseError(event); + assertEqual(result.category, "auth"); + assertEqual(result.confidence, "high"); +}); + +test("classifySseError maps MessageAbortedError to timeout", () => { + const event = { + type: "session.error" as const, + properties: { + error: { + name: "MessageAbortedError" as const, + data: { + message: "Request was aborted", + }, + }, + }, + }; + const result = classifySseError(event); + assertEqual(result.category, "timeout"); + assertEqual(result.confidence, "high"); +}); + +test("classifySseError maps APIError with 429 to provider_model", () => { + const event = { + type: "session.error" as const, + properties: { + error: { + name: "APIError" as const, + data: { + message: "Rate limited", + statusCode: 429, + isRetryable: true, + }, + }, + }, + }; + const result = classifySseError(event); + assertEqual(result.category, "provider_model"); + assertEqual(result.confidence, "high"); +}); + +test("classifySseError returns unknown for invalid event", () => { + const event = { + type: "session.error" as const, + properties: {}, + }; + const result = classifySseError(event); + assertEqual(result.category, "unknown"); + assertEqual(result.confidence, "low"); +}); + +test("classifyProxyResponse maps 401 to auth", () => { + const response: OpencodeProxyError = { + status: 401, + error: "Unauthorized", + }; + const result = classifyProxyResponse(response); + assertEqual(result.category, "auth"); + assertEqual(result.confidence, "high"); +}); + +test("classifyProxyResponse maps 502 to runtime_unreachable", () => { + const response: OpencodeProxyError = { + status: 502, + error: "Bad Gateway", + }; + const result = classifyProxyResponse(response); + assertEqual(result.category, "runtime_unreachable"); + assertEqual(result.confidence, "high"); +}); + +test("classifyProxyResponse uses upstreamStatus when available", () => { + const response: OpencodeProxyError = { + status: 502, + error: "Bad Gateway", + upstreamStatus: 429, + }; + const result = classifyProxyResponse(response); + assertEqual(result.category, "provider_model"); + assertEqual(result.confidence, "high"); +}); + +test("classifyProxyResponse falls back for unmapped status", () => { + const response: OpencodeProxyError = { + status: 418, // I'm a teapot + error: "I'm a teapot", + }; + const result = classifyProxyResponse(response); + assertEqual(result.category, "provider_model"); // 4xx fallback + assertEqual(result.confidence, "low"); +}); + +test("classifyThrownError recognizes ProviderAuthError", () => { + const error = { + name: "ProviderAuthError", + data: { + providerID: "openai", + message: "Invalid key", + }, + }; + const result = classifyThrownError(error); + assertEqual(result.category, "auth"); + assertEqual(result.confidence, "high"); +}); + +test("classifyThrownError recognizes AbortError", () => { + const error = new Error("Request aborted"); + error.name = "AbortError"; + const result = classifyThrownError(error); + assertEqual(result.category, "timeout"); + assertEqual(result.confidence, "high"); +}); + +test("classifyThrownError recognizes connection errors by message", () => { + const error = new Error("connect ECONNREFUSED 127.0.0.1:3000"); + const result = classifyThrownError(error); + assertEqual(result.category, "runtime_unreachable"); + assertEqual(result.confidence, "medium"); +}); + +test("classifyThrownError recognizes timeout by message", () => { + const error = new Error("Request timeout after 30000ms"); + const result = classifyThrownError(error); + assertEqual(result.category, "timeout"); + assertEqual(result.confidence, "medium"); +}); + +test("classifyThrownError recognizes auth by message", () => { + const error = new Error("401 Unauthorized: Invalid API key"); + const result = classifyThrownError(error); + assertEqual(result.category, "auth"); + assertEqual(result.confidence, "medium"); +}); + +test("classifyThrownError handles string errors", () => { + const result = classifyThrownError("Rate limit exceeded"); + assertEqual(result.category, "provider_model"); + assertEqual(result.confidence, "medium"); +}); + +test("classifyThrownError handles unknown types", () => { + const result = classifyThrownError(null); + assertEqual(result.category, "unknown"); + assertEqual(result.confidence, "low"); +}); + +// ============================================================================ +// Integration Tests +// ============================================================================ + +console.log("\n🧪 Testing Integration (Create Diagnostic)\n"); + +test("createSseDiagnostic creates sanitized diagnostic", () => { + const event = { + type: "session.error" as const, + properties: { + error: { + name: "ProviderAuthError" as const, + data: { + providerID: "openai", + message: "Invalid API key", + }, + }, + }, + }; + const diagnostic = createSseDiagnostic(event, "project-123"); + assertEqual(diagnostic.category, "auth"); + assertEqual(diagnostic.title, "Authentication Failed"); + assertEqual(diagnostic.source, "sse"); + assertEqual(diagnostic.isRetryable, false); + assertContains( + JSON.stringify(diagnostic.remediation), + "check_api_key", + "Should include check_api_key remediation", + ); +}); + +test("createProxyDiagnostic creates sanitized diagnostic", () => { + const response: OpencodeProxyError = { + status: 429, + error: "Rate limit exceeded", + }; + const diagnostic = createProxyDiagnostic(response, "project-456"); + assertEqual(diagnostic.category, "provider_model"); + assertEqual(diagnostic.title, "Model Error"); + assertEqual(diagnostic.source, "proxy"); + assertEqual(diagnostic.isRetryable, true); +}); + +test("createThrownErrorDiagnostic handles SDK errors", () => { + const error = { + name: "MessageAbortedError", + data: { message: "Request was aborted" }, + }; + const diagnostic = createThrownErrorDiagnostic(error, "project-789"); + assertEqual(diagnostic.category, "timeout"); + assertEqual(diagnostic.title, "Request Timed Out"); + assertEqual(diagnostic.source, "unknown"); +}); + +test("createThrownErrorDiagnostic sanitizes error details", () => { + const error = new Error("Connection failed"); + (error as Error & { apiKey: string }).apiKey = "secret-key"; + const diagnostic = createThrownErrorDiagnostic(error, "project-000"); + if (diagnostic.technicalDetails?.metadata) { + const metadata = diagnostic.technicalDetails.metadata as Record< + string, + unknown + >; + assertEqual(metadata.apiKey, REDACTION_PLACEHOLDER); + } +}); + +// ============================================================================ +// Summary +// ============================================================================ + +console.log("\n📊 Test Summary\n"); + +const passed = results.filter((r) => r.passed).length; +const failed = results.filter((r) => !r.passed).length; + +console.log(`Total: ${results.length}`); +console.log(`Passed: ${passed} ✅`); +console.log(`Failed: ${failed} ❌`); + +if (failed > 0) { + console.log("\n❌ Failed tests:"); + for (const r of results.filter((r) => !r.passed)) { + console.log(` - ${r.test}: ${r.error}`); + } + process.exit(1); +} else { + console.log("\n✅ All tests passed!"); + process.exit(0); +} diff --git a/src/server/opencode/diagnostics/types.ts b/src/server/opencode/diagnostics/types.ts new file mode 100644 index 00000000..56e8fcf7 --- /dev/null +++ b/src/server/opencode/diagnostics/types.ts @@ -0,0 +1,580 @@ +/** + * OpenCode Error Diagnostic Taxonomy + * + * Defines the contract for mapping OpenCode SDK errors, SSE events, and proxy responses + * into a consistent taxonomy of 5 categories: + * - auth: Authentication/authorization failures with providers + * - provider_model: Model-specific errors (rate limits, invalid models, content policy) + * - runtime_unreachable: OpenCode runtime not accessible (container down, network issues) + * - timeout: Request timeouts and abortions + * - unknown: Catch-all for unclassified errors + * + * @module opencode/diagnostics/types + */ + +import type { + ApiError, + EventSessionError, + MessageAbortedError, + MessageOutputLengthError, + ProviderAuthError, + UnknownError, +} from "@opencode-ai/sdk/v2/client"; + +// ============================================================================ +// Error Category Taxonomy +// ============================================================================ + +/** + * The five error categories in the OpenCode diagnostic taxonomy. + * Each category maps to specific error signals from SDK, SSE, and proxy layers. + */ +export type OpencodeErrorCategory = + | "auth" + | "provider_model" + | "runtime_unreachable" + | "timeout" + | "unknown"; + +/** + * All valid error category values as a const array for runtime validation. + */ +export const OPENCODE_ERROR_CATEGORIES: readonly OpencodeErrorCategory[] = [ + "auth", + "provider_model", + "runtime_unreachable", + "timeout", + "unknown", +] as const; + +// ============================================================================ +// Source-Specific Error Types +// ============================================================================ + +/** + * Error types from the OpenCode SDK (thrown as exceptions or returned in responses). + * These map to the error union in AssistantMessage.error from the SDK types. + */ +export type OpencodeSdkError = + | ProviderAuthError + | ApiError + | MessageOutputLengthError + | MessageAbortedError + | UnknownError; + +/** + * SSE EventSessionError structure (session.error event). + * Properties come from EventSessionError in SDK types. + */ +export type OpencodeSessionErrorEvent = EventSessionError; + +/** + * Proxy error response structure for 4xx/5xx responses. + * These are returned by the proxy at src/pages/api/projects/[id]/opencode/[...path].ts + */ +export interface OpencodeProxyError { + /** HTTP status code from the proxy response */ + status: number; + /** Error message or structured error data */ + error: string | Record; + /** Optional upstream status code if proxy forwarded an error */ + upstreamStatus?: number; +} + +/** + * Queue handler error context. + * Errors thrown in queue handlers (opencodeSessionCreate, opencodeSendUserPrompt) + * may be wrapped with additional context. + */ +export interface OpencodeQueueError { + /** Original error that was thrown */ + originalError: unknown; + /** Queue job type that failed */ + jobType: string; + /** Project ID associated with the error */ + projectId: string; +} + +// ============================================================================ +// Taxonomy Classification Input +// ============================================================================ + +/** + * Union type representing any error signal that needs classification. + * This is the input to the error classification function. + */ +export type OpencodeErrorSource = + | { type: "sdk"; error: OpencodeSdkError } + | { type: "sse"; event: OpencodeSessionErrorEvent } + | { type: "proxy"; response: OpencodeProxyError } + | { type: "queue"; error: OpencodeQueueError } + | { type: "unknown"; error: unknown }; + +// ============================================================================ +// Diagnostic Output Types +// ============================================================================ + +/** + * Remediation action that can be suggested to the user. + */ +export interface RemediationAction { + /** Unique identifier for the action type */ + id: string; + /** Human-readable label for the action button/link */ + label: string; + /** Description of what this action does */ + description: string; + /** Whether this action requires navigation */ + href?: string; + /** Whether this action triggers a function call */ + action?: string; +} + +/** + * Complete diagnostic information for an OpenCode error. + * This is the output of the error classification pipeline. + */ +export interface OpencodeDiagnostic { + /** The taxonomy category this error belongs to */ + category: OpencodeErrorCategory; + /** Short title suitable for display in UI (2-5 words) */ + title: string; + /** Human-readable message explaining what went wrong */ + message: string; + /** + * Technical details for debugging (may include raw error data). + * Not displayed to users by default. + */ + technicalDetails: + | { + /** Original error name/type */ + errorName: string; + /** Original error message */ + errorMessage: string; + /** Stack trace if available */ + stack: string | undefined; + /** Additional structured data from the error */ + metadata: Record | undefined; + } + | undefined; + /** Suggested remediation actions for the user */ + remediation: RemediationAction[]; + /** Whether this error is retryable */ + isRetryable: boolean; + /** Timestamp when the diagnostic was created */ + timestamp: string; + /** Source that generated this error */ + source: OpencodeErrorSource["type"]; +} + +// ============================================================================ +// Category-Specific Metadata +// ============================================================================ + +/** + * Metadata configuration for each error category. + * Used to generate consistent user-facing messages. + */ +export interface OpencodeErrorCategoryMetadata { + /** Category identifier */ + category: OpencodeErrorCategory; + /** Display title for the category */ + displayTitle: string; + /** Default message template for this category */ + defaultMessage: string; + /** Default remediation actions for this category */ + defaultRemediation: RemediationAction[]; + /** Whether errors in this category are typically retryable */ + defaultRetryable: boolean; +} + +/** + * Metadata for all error categories. + * This is the source of truth for user-facing strings. + */ +export const OPENCODE_ERROR_CATEGORY_METADATA: Record< + OpencodeErrorCategory, + OpencodeErrorCategoryMetadata +> = { + auth: { + category: "auth", + displayTitle: "Authentication Failed", + defaultMessage: + "Unable to authenticate with the AI provider. Your API key may be invalid or expired.", + defaultRemediation: [ + { + id: "check_api_key", + label: "Check API Key", + description: + "Verify your API key is correctly set in provider settings", + href: "/settings/providers", + }, + { + id: "reconnect_provider", + label: "Reconnect Provider", + description: + "Disconnect and reconnect the provider to refresh authentication", + action: "reconnectProvider", + }, + ], + defaultRetryable: false, + }, + provider_model: { + category: "provider_model", + displayTitle: "Model Error", + defaultMessage: + "The AI model encountered an error. This could be due to rate limiting, an invalid model selection, or content policy violations.", + defaultRemediation: [ + { + id: "try_different_model", + label: "Try Different Model", + description: "Switch to a different AI model", + href: "/settings/models", + }, + { + id: "wait_retry", + label: "Wait and Retry", + description: "Wait a moment and try again (for rate limits)", + action: "retry", + }, + ], + defaultRetryable: true, + }, + runtime_unreachable: { + category: "runtime_unreachable", + displayTitle: "OpenCode Unavailable", + defaultMessage: + "Cannot connect to the OpenCode runtime. The service may be starting up or experiencing issues.", + defaultRemediation: [ + { + id: "wait_startup", + label: "Wait for Startup", + description: "The OpenCode container may still be initializing", + action: "wait", + }, + { + id: "restart_project", + label: "Restart Project", + description: "Restart the project containers", + action: "restartProject", + }, + ], + defaultRetryable: true, + }, + timeout: { + category: "timeout", + displayTitle: "Request Timed Out", + defaultMessage: + "The request took too long to complete. This may be due to high load or a complex operation.", + defaultRemediation: [ + { + id: "retry", + label: "Retry", + description: "Try the request again", + action: "retry", + }, + { + id: "simplify_request", + label: "Simplify Request", + description: "Try a simpler prompt or break into smaller tasks", + action: "simplify", + }, + ], + defaultRetryable: true, + }, + unknown: { + category: "unknown", + displayTitle: "Unexpected Error", + defaultMessage: + "An unexpected error occurred. Please try again or contact support if the issue persists.", + defaultRemediation: [ + { + id: "retry", + label: "Retry", + description: "Try the operation again", + action: "retry", + }, + { + id: "view_logs", + label: "View Logs", + description: "Check the logs for more details", + href: "/logs", + }, + ], + defaultRetryable: true, + }, +} as const; + +// ============================================================================ +// Classification Functions +// ============================================================================ + +/** + * Type guard for ProviderAuthError. + */ +export function isProviderAuthError( + error: unknown, +): error is ProviderAuthError { + return ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "ProviderAuthError" + ); +} + +/** + * Type guard for ApiError. + */ +export function isApiError(error: unknown): error is ApiError { + return ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "APIError" + ); +} + +/** + * Type guard for MessageAbortedError. + */ +export function isMessageAbortedError( + error: unknown, +): error is MessageAbortedError { + return ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "MessageAbortedError" + ); +} + +/** + * Type guard for MessageOutputLengthError. + */ +export function isMessageOutputLengthError( + error: unknown, +): error is MessageOutputLengthError { + return ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "MessageOutputLengthError" + ); +} + +/** + * Type guard for UnknownError. + */ +export function isUnknownError(error: unknown): error is UnknownError { + return ( + typeof error === "object" && + error !== null && + "name" in error && + error.name === "UnknownError" + ); +} + +/** + * Type guard for EventSessionError. + */ +export function isSessionErrorEvent( + event: unknown, +): event is EventSessionError { + return ( + typeof event === "object" && + event !== null && + "type" in event && + event.type === "session.error" + ); +} + +// ============================================================================ +// Classification Maps +// ============================================================================ + +/** + * Maps SDK error names to their taxonomy categories. + * This is the definitive mapping from SDK error types to categories. + */ +export const SDK_ERROR_NAME_TO_CATEGORY: Record = + { + ProviderAuthError: "auth", + APIError: "provider_model", + MessageOutputLengthError: "provider_model", + MessageAbortedError: "timeout", + UnknownError: "unknown", + } as const; + +/** + * Maps HTTP status codes from proxy responses to taxonomy categories. + */ +export const PROXY_STATUS_TO_CATEGORY: Record = { + // 4xx - Client errors (usually auth or invalid requests) + 400: "provider_model", + 401: "auth", + 403: "auth", + 404: "runtime_unreachable", + 408: "timeout", + 413: "provider_model", + 429: "provider_model", // Rate limiting + + // 5xx - Server errors (runtime issues) + 500: "unknown", + 502: "runtime_unreachable", // Bad gateway (OpenCode container down) + 503: "runtime_unreachable", // Service unavailable + 504: "timeout", // Gateway timeout +} as const; + +/** + * Default category for unmapped status codes. + */ +export const DEFAULT_PROXY_CATEGORY: OpencodeErrorCategory = "unknown"; + +// ============================================================================ +// Error Classification Utility +// ============================================================================ + +/** + * Classifies an error source into a taxonomy category. + * This is the main entry point for error classification. + * + * @param source - The error source to classify + * @returns The taxonomy category for the error + */ +export function classifyOpencodeError( + source: OpencodeErrorSource, +): OpencodeErrorCategory { + switch (source.type) { + case "sdk": { + const errorName = (source.error as { name?: string }).name; + return SDK_ERROR_NAME_TO_CATEGORY[errorName ?? ""] ?? "unknown"; + } + case "sse": { + const error = source.event.properties.error; + if (!error) return "unknown"; + return SDK_ERROR_NAME_TO_CATEGORY[error.name] ?? "unknown"; + } + case "proxy": { + const status = source.response.status; + return PROXY_STATUS_TO_CATEGORY[status] ?? DEFAULT_PROXY_CATEGORY; + } + case "queue": { + // Try to classify based on the original error + const original = source.error.originalError; + if (isProviderAuthError(original)) return "auth"; + if (isApiError(original)) return "provider_model"; + if (isMessageAbortedError(original)) return "timeout"; + + // Check error message patterns + const message = + original instanceof Error ? original.message : String(original); + if (message.includes("timeout") || message.includes("AbortError")) { + return "timeout"; + } + if ( + message.includes("ECONNREFUSED") || + message.includes("ENOTFOUND") || + message.includes("fetch failed") + ) { + return "runtime_unreachable"; + } + return "unknown"; + } + case "unknown": + default: { + // Check for common patterns + const error = source.error; + if (error instanceof Error) { + if (error.name === "AbortError" || error.message.includes("timeout")) { + return "timeout"; + } + if ( + error.message.includes("ECONNREFUSED") || + error.message.includes("ENOTFOUND") || + error.message.includes("fetch failed") + ) { + return "runtime_unreachable"; + } + } + return "unknown"; + } + } +} + +// ============================================================================ +// Diagnostic Builder +// ============================================================================ + +/** + * Creates a complete diagnostic object from an error source. + * + * @param source - The error source + * @param projectId - Optional project ID for context + * @returns A complete OpencodeDiagnostic object + */ +export function createOpencodeDiagnostic( + source: OpencodeErrorSource, + _projectId?: string, +): OpencodeDiagnostic { + const category = classifyOpencodeError(source); + const metadata = OPENCODE_ERROR_CATEGORY_METADATA[category]; + const timestamp = new Date().toISOString(); + + // Extract technical details based on source type + let technicalDetails: OpencodeDiagnostic["technicalDetails"] | undefined; + let originalError: unknown; + + switch (source.type) { + case "sdk": + originalError = source.error; + break; + case "sse": + originalError = source.event.properties.error; + break; + case "proxy": + originalError = source.response; + break; + case "queue": + originalError = source.error.originalError; + break; + case "unknown": + default: + originalError = source.error; + } + + if (originalError instanceof Error) { + technicalDetails = { + errorName: originalError.name, + errorMessage: originalError.message, + stack: originalError.stack, + metadata: undefined, + }; + } else if (originalError && typeof originalError === "object") { + const err = originalError as { name?: string; message?: string }; + technicalDetails = { + errorName: err.name ?? "UnknownError", + errorMessage: err.message ?? String(originalError), + stack: undefined, + metadata: originalError as Record, + }; + } else { + technicalDetails = { + errorName: "UnknownError", + errorMessage: String(originalError), + stack: undefined, + metadata: undefined, + }; + } + + return { + category, + title: metadata.displayTitle, + message: metadata.defaultMessage, + technicalDetails, + remediation: metadata.defaultRemediation, + isRetryable: metadata.defaultRetryable, + timestamp, + source: source.type, + }; +} diff --git a/src/server/opencode/normalize.ts b/src/server/opencode/normalize.ts index 037a05bd..9f7205c8 100644 --- a/src/server/opencode/normalize.ts +++ b/src/server/opencode/normalize.ts @@ -13,11 +13,13 @@ import type { EventFileEdited, EventMessagePartUpdated, EventMessageUpdated, + EventSessionError, EventSessionStatus, ReasoningPart as SDKReasoningPart, TextPart as SDKTextPart, ToolPart as SDKToolPart, } from "@opencode-ai/sdk/v2/client"; +import { createSseDiagnostic, type OpencodeDiagnostic } from "./diagnostics"; // ============================================================================ // Normalized Event Types (for frontend consumption) @@ -30,6 +32,7 @@ export type NormalizedEventType = | "chat.tool.update" | "chat.reasoning.part" | "chat.file.changed" + | "chat.diagnostic" | "chat.event.unknown"; export interface NormalizedEventEnvelope { @@ -97,6 +100,10 @@ export interface UnknownEventPayload { upstreamData: unknown; } +export interface DiagnosticPayload { + diagnostic: OpencodeDiagnostic; +} + // ============================================================================ // Normalization State // ============================================================================ @@ -324,6 +331,19 @@ export function normalizeEvent( return null; } + // Session error - transform into diagnostic event + case "session.error": { + const errorEvent = event as EventSessionError; + const diagnostic = createSseDiagnostic(errorEvent, projectId); + + return { + type: "chat.diagnostic", + projectId, + time, + payload: { diagnostic } satisfies DiagnosticPayload, + }; + } + // Session events we can ignore case "session.updated": case "session.created": @@ -331,7 +351,6 @@ export function normalizeEvent( case "session.idle": case "session.compacted": case "session.diff": - case "session.error": return null; // Other events we don't need to handle diff --git a/src/server/presence/manager.ts b/src/server/presence/manager.ts index 31f8f4ec..ea6fadf2 100644 --- a/src/server/presence/manager.ts +++ b/src/server/presence/manager.ts @@ -52,6 +52,12 @@ export interface PresenceResponse { bootstrapSessionId: string | null; // Setup error (if a queue job failed during setup) setupError: string | null; + // OpenCode diagnostic (if there's an active error) + opencodeDiagnostic: { + category: string | null; + message: string | null; + remediation: string[] | null; + } | null; } // In-memory presence state @@ -202,6 +208,13 @@ export async function handlePresenceHeartbeat( slug: project.slug, bootstrapSessionId: project.bootstrapSessionId, setupError, + opencodeDiagnostic: project.opencodeErrorCategory + ? { + category: project.opencodeErrorCategory, + message: project.opencodeErrorMessage, + remediation: null, + } + : null, }; } @@ -325,6 +338,13 @@ export async function handlePresenceHeartbeat( slug: project.slug, bootstrapSessionId: project.bootstrapSessionId, setupError, + opencodeDiagnostic: project.opencodeErrorCategory + ? { + category: project.opencodeErrorCategory, + message: project.opencodeErrorMessage, + remediation: null, + } + : null, }; } finally { release(); diff --git a/src/server/projects/projects.db.ts b/src/server/projects/projects.db.ts index 002370b0..189417e1 100644 --- a/src/server/projects/projects.db.ts +++ b/src/server/projects/projects.db.ts @@ -1,6 +1,7 @@ import { and, desc, eq, isNull, ne } from "drizzle-orm"; import { db } from "@/server/db/client"; import { type NewProject, type Project, projects } from "@/server/db/schema"; +import type { OpencodeDiagnostic } from "@/server/opencode/diagnostics"; export type ProjectStatus = Project["status"]; @@ -189,3 +190,32 @@ export async function markUserPromptCompleted(id: string): Promise { }) .where(eq(projects.id, id)); } + +export async function updateProjectOpencodeError( + id: string, + diagnostic: OpencodeDiagnostic, +): Promise { + await db + .update(projects) + .set({ + opencodeErrorCategory: diagnostic.category, + opencodeErrorCode: diagnostic.technicalDetails?.errorName ?? null, + opencodeErrorMessage: diagnostic.message, + opencodeErrorSource: diagnostic.source, + opencodeErrorAt: new Date(), + }) + .where(eq(projects.id, id)); +} + +export async function clearProjectOpencodeError(id: string): Promise { + await db + .update(projects) + .set({ + opencodeErrorCategory: null, + opencodeErrorCode: null, + opencodeErrorMessage: null, + opencodeErrorSource: null, + opencodeErrorAt: null, + }) + .where(eq(projects.id, id)); +} diff --git a/src/stores/useChatStore.ts b/src/stores/useChatStore.ts index 78901956..d2aade55 100644 --- a/src/stores/useChatStore.ts +++ b/src/stores/useChatStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import type { OpencodeDiagnostic } from "@/server/opencode/diagnostics"; import { createTextPart, type ImagePart, type Message } from "@/types/message"; /** @@ -48,6 +49,10 @@ interface ChatStore { pendingImages: ImagePart[]; pendingImageError: string | null; + // State: Diagnostics + latestDiagnostic: OpencodeDiagnostic | null; + diagnosticHistory: OpencodeDiagnostic[]; + // Actions: Session & status setSessionId: (id: string | null) => void; setOpenCodeReady: (ready: boolean) => void; @@ -69,6 +74,11 @@ interface ChatStore { setPendingImages: (images: ImagePart[]) => void; setPendingImageError: (error: string | null) => void; + // Actions: Diagnostics + setLatestDiagnostic: (diagnostic: OpencodeDiagnostic | null) => void; + addDiagnostic: (diagnostic: OpencodeDiagnostic) => void; + clearDiagnostics: () => void; + // Actions: Message management setItems: (items: ChatItem[]) => void; addItem: (item: ChatItem) => void; @@ -99,6 +109,8 @@ const initialState = { isStreaming: false, pendingImages: [] as ImagePart[], pendingImageError: null as string | null, + latestDiagnostic: null as OpencodeDiagnostic | null, + diagnosticHistory: [] as OpencodeDiagnostic[], }; /** @@ -133,6 +145,15 @@ export function createChatStore() { ), })), + // Actions: Diagnostics + setLatestDiagnostic: (diagnostic) => set({ latestDiagnostic: diagnostic }), + addDiagnostic: (diagnostic) => + set((state) => ({ + diagnosticHistory: [...state.diagnosticHistory, diagnostic], + })), + clearDiagnostics: () => + set({ latestDiagnostic: null, diagnosticHistory: [] }), + // Complex event handling handleChatEvent: (event) => { const { type, sessionId: eventSessionId, payload } = event; @@ -395,6 +416,16 @@ export function createChatStore() { } break; } + + case "chat.diagnostic": { + const { diagnostic } = payload as { diagnostic: OpencodeDiagnostic }; + set((state) => ({ + latestDiagnostic: diagnostic, + diagnosticHistory: [...state.diagnosticHistory, diagnostic], + isStreaming: false, + })); + break; + } } }, @@ -409,9 +440,13 @@ const storeInstances = new Map>(); /** * Get or create a chat store for a project */ -export function useChatStore(projectId: string) { +export function useChatStore(projectId: string): ChatStore { if (!storeInstances.has(projectId)) { storeInstances.set(projectId, createChatStore()); } - return storeInstances.get(projectId)?.(); + const store = storeInstances.get(projectId); + if (!store) { + throw new Error(`Failed to create chat store for project ${projectId}`); + } + return store(); } From 07ca1e21b6964757feb9535064787f03efbf9ab2 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Mon, 16 Feb 2026 14:36:38 +0100 Subject: [PATCH 27/42] fix: add GH_TOKEN env for gh CLI in deploy job --- .github/workflows/pr-preview.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 538e4559..09f277ed 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 From 4489b836738a3f4fbba9b4f0a1d054fab109d025 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Mon, 16 Feb 2026 16:14:21 +0100 Subject: [PATCH 28/42] minimal debug scenario --- .opencode/agent/debugger.md | 252 +++--------------------------------- 1 file changed, 19 insertions(+), 233 deletions(-) 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 From 00021d3fc352b9f16c1ee48c40b8bd1266e8a037 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Mon, 16 Feb 2026 19:44:56 +0100 Subject: [PATCH 29/42] no need --- opencode.json | 37 ------------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 opencode.json diff --git a/opencode.json b/opencode.json deleted file mode 100644 index d5520548..00000000 --- a/opencode.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "agent": { - "plan": { - "model": "opencode/kimi-k2.5" - }, - "build": { - "model": "opencode/kimi-k2.5" - }, - "debugger": { - "model": "opencode/kimi-k2.5" - } - }, - "mcp": { - "sequential-thinking": { - "type": "local", - "command": ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"] - }, - "context7": { - "type": "remote", - "url": "https://mcp.context7.com/mcp", - "headers": { - "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" - }, - "enabled": true - }, - "chrome-devtools": { - "type": "local", - "command": ["npx", "-y", "chrome-devtools-mcp@latest"] - }, - "grep.app": { - "type": "remote", - "url": "https://mcp.grep.app", - "enabled": true - } - } -} From 812ee18199a42f23f445d3c181d30f792a21081d Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Mon, 16 Feb 2026 20:07:51 +0100 Subject: [PATCH 30/42] feat: add per-job queue log streaming --- src/components/queue/JobDetailLive.tsx | 162 ++++++++++++++++++++++++- src/pages/api/queue/job-logs-stream.ts | 160 ++++++++++++++++++++++++ src/pages/api/queue/job-stream.ts | 6 + src/pages/api/queue/jobs/[id].ts | 9 ++ src/pages/queue/[id].astro | 6 + src/server/logger.ts | 48 ++++++++ src/server/queue/access.ts | 33 +++++ src/server/queue/job-log-context.ts | 18 +++ src/server/queue/job-logs.ts | 128 +++++++++++++++++++ src/server/queue/queue.worker.ts | 125 +++++++++---------- 10 files changed, 632 insertions(+), 63 deletions(-) create mode 100644 src/pages/api/queue/job-logs-stream.ts create mode 100644 src/server/queue/access.ts create mode 100644 src/server/queue/job-log-context.ts create mode 100644 src/server/queue/job-logs.ts diff --git a/src/components/queue/JobDetailLive.tsx b/src/components/queue/JobDetailLive.tsx index 73cb549d..2fba9d69 100644 --- a/src/components/queue/JobDetailLive.tsx +++ b/src/components/queue/JobDetailLive.tsx @@ -1,4 +1,4 @@ -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"; @@ -14,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); @@ -21,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( @@ -46,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"; @@ -138,6 +247,7 @@ export function JobDetailLive({ initialJob }: JobDetailLiveProps) { {canCancel && ( - + ); 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/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() { )} From db54fe598df7a4597fe9f1eceecec539992f8b49 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Mon, 16 Feb 2026 23:38:15 +0100 Subject: [PATCH 37/42] fix file browser path --- src/pages/api/projects/[id]/files.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/api/projects/[id]/files.ts b/src/pages/api/projects/[id]/files.ts index faded4a5..571c8bb1 100644 --- a/src/pages/api/projects/[id]/files.ts +++ b/src/pages/api/projects/[id]/files.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { APIRoute } from "astro"; import { validateSession } from "@/server/auth/sessions"; -import { getProjectPath } from "@/server/projects/paths"; +import { getProjectPath, getProjectPreviewPath } from "@/server/projects/paths"; import { isProjectOwnedByUser } from "@/server/projects/projects.model"; const SESSION_COOKIE_NAME = "doce_session"; @@ -131,7 +131,7 @@ export const GET: APIRoute = async ({ params, request, cookies }) => { // Serve file content try { // Validate path doesn't escape src/ - const projectPath = getProjectPath(projectId); + const projectPath = getProjectPreviewPath(projectId); const srcPath = path.join(projectPath, "src"); // filePath is relative to src/, so join with srcPath const fullPath = path.join(srcPath, filePath); @@ -180,7 +180,7 @@ export const GET: APIRoute = async ({ params, request, cookies }) => { // Serve file tree try { - const projectPath = getProjectPath(projectId); + const projectPath = getProjectPreviewPath(projectId); const srcPath = path.join(projectPath, "src"); // Check if src directory exists From 86adab6be9cf746941a3bb01b5d65033f9887514 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Mon, 16 Feb 2026 23:40:38 +0100 Subject: [PATCH 38/42] unused import --- src/pages/api/projects/[id]/files.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/api/projects/[id]/files.ts b/src/pages/api/projects/[id]/files.ts index 571c8bb1..f5e485ff 100644 --- a/src/pages/api/projects/[id]/files.ts +++ b/src/pages/api/projects/[id]/files.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { APIRoute } from "astro"; import { validateSession } from "@/server/auth/sessions"; -import { getProjectPath, getProjectPreviewPath } from "@/server/projects/paths"; +import { getProjectPreviewPath } from "@/server/projects/paths"; import { isProjectOwnedByUser } from "@/server/projects/projects.model"; const SESSION_COOKIE_NAME = "doce_session"; From 0d8c36a97ff8d0e7c4ab108d4b456579ed3a1561 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Tue, 17 Feb 2026 00:07:05 +0100 Subject: [PATCH 39/42] fix assets --- src/actions/assets.ts | 29 +++++------------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/src/actions/assets.ts b/src/actions/assets.ts index c1fd2036..9c5d801a 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 }); @@ -186,12 +177,7 @@ export const assets = { }); } - const projectPath = path.join( - process.cwd(), - "data", - "projects", - input.projectId, - ); + const projectPath = getProjectPreviewPath(input.projectId); const publicPath = path.join(projectPath, "public"); const oldPath = path.join(publicPath, input.oldName); const newPath = path.join(publicPath, sanitizedNewName); @@ -275,12 +261,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"); const filePath = path.join(publicPath, input.path); From ae8b5a3adf2d40517482ac6cc01f0bcf38a7732c Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Tue, 17 Feb 2026 10:54:53 +0100 Subject: [PATCH 40/42] mobile view --- src/actions/assets.ts | 14 +- .../dashboard/CreateProjectFormContent.tsx | 8 +- src/components/dashboard/ModelSelector.tsx | 3 + src/components/files/FilesTab.tsx | 170 ++++++++++---- src/components/preview/PreviewPanel.tsx | 214 ++++++++++++++---- src/components/projects/ProjectCard.tsx | 10 +- .../projects/ProjectContentWrapper.tsx | 102 +++++---- src/components/queue/QueuePlayerControl.tsx | 8 +- src/components/queue/QueueTableLive.tsx | 132 ++++++++++- src/hooks/useResizablePanel.ts | 18 +- src/pages/settings.astro | 8 +- src/styles/globals.css | 12 + 12 files changed, 532 insertions(+), 167 deletions(-) diff --git a/src/actions/assets.ts b/src/actions/assets.ts index 9c5d801a..4a0cb74a 100644 --- a/src/actions/assets.ts +++ b/src/actions/assets.ts @@ -177,7 +177,12 @@ export const assets = { }); } - const projectPath = getProjectPreviewPath(input.projectId); + const projectPath = path.join( + process.cwd(), + "data", + "projects", + input.projectId, + ); const publicPath = path.join(projectPath, "public"); const oldPath = path.join(publicPath, input.oldName); const newPath = path.join(publicPath, sanitizedNewName); @@ -261,7 +266,12 @@ export const assets = { } try { - const projectPath = getProjectPreviewPath(input.projectId); + const projectPath = path.join( + process.cwd(), + "data", + "projects", + input.projectId, + ); const publicPath = path.join(projectPath, "public"); const filePath = path.join(publicPath, input.path); 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 8e08ed78..9893ca18 100644 --- a/src/components/dashboard/ModelSelector.tsx +++ b/src/components/dashboard/ModelSelector.tsx @@ -80,6 +80,7 @@ interface ModelSelectorProps { }>; selectedModelId: string; onModelChange: (modelId: string) => void; + triggerClassName?: string; } function getTierIcon(tier?: string) { @@ -110,6 +111,7 @@ export function ModelSelector({ models, selectedModelId, onModelChange, + triggerClassName, }: ModelSelectorProps) { const [theme, setTheme] = useState<"light" | "dark">("light"); const [open, setOpen] = useState(false); @@ -187,6 +189,7 @@ export function ModelSelector({ void; } +type MobilePane = "tree" | "editor"; + /** * Get all ancestor directory paths for a file path. * e.g., "pages/api/index.ts" => ["pages", "pages/api"] @@ -62,16 +66,25 @@ export function FilesTab({ const [isLoadingContent, setIsLoadingContent] = useState(false); const [error, setError] = useState(null); const [expandedPaths, setExpandedPaths] = useState>(new Set()); + const [mobilePane, setMobilePane] = useState("tree"); const containerRef = useRef(null); - const { leftPercent, rightPercent, isDragging, onSeparatorMouseDown } = - useResizablePanel({ - projectId: `files_${projectId}`, - minSize: 20, - maxSize: 50, - defaultSize: 25, - containerRef, - }); + const { + leftPercent, + rightPercent, + isDragging, + isMobile, + isResizable, + onSeparatorMouseDown, + } = useResizablePanel({ + projectId: `files_${projectId}`, + minSize: 20, + maxSize: 50, + defaultSize: 25, + containerRef, + }); + + const shouldUseMobilePanes = isMobile; const fetchFileContent = useCallback( async (path: string) => { @@ -139,6 +152,9 @@ export function FilesTab({ return newPaths; }); await fetchFileContent(lastSelectedFile); + if (isMobile) { + setMobilePane("editor"); + } } } } catch (err) { @@ -150,7 +166,7 @@ export function FilesTab({ }; fetchFileTree(); - }, [projectId, lastSelectedFile, fetchFileContent]); + }, [projectId, lastSelectedFile, fetchFileContent, isMobile]); if (isLoadingTree) { return ( @@ -176,48 +192,114 @@ export function FilesTab({ } return ( -
+
+ {/* Mobile toggle bar */} + {shouldUseMobilePanes && ( + setMobilePane(v as MobilePane)} + className="md:hidden" + > + + + + Tree + + + + Editor + + + + )} +
- {/* File Tree (left) */} -
- -
+ {/* Mobile: Show only active pane */} + {shouldUseMobilePanes ? ( + <> + {mobilePane === "tree" && ( +
+ { + fetchFileContent(path); + setMobilePane("editor"); + }} + selectedPath={selectedPath || undefined} + expandedPaths={expandedPaths} + onExpandedPathsChange={setExpandedPaths} + /> +
+ )} + {mobilePane === "editor" && ( +
+ {selectedPath ? ( + + ) : ( +
+

No file selected

+ +
+ )} +
+ )} + + ) : ( + <> + {/* 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 && (
; + onOpenFile?: (filePath: string) => void; + onStreamingStateChange?: ( + userMessageCount: number, + isStreaming: boolean, + ) => void; } type PreviewState = "initializing" | "starting" | "ready" | "error"; -type TabType = "preview" | "files" | "assets"; +type TabType = "chat" | "preview" | "files" | "assets"; export function PreviewPanel({ projectId, @@ -65,12 +89,23 @@ export function PreviewPanel({ onFileOpened, userMessageCount = 0, isStreaming = false, + forceTab, + models = [], + onOpenFile, + onStreamingStateChange, }: PreviewPanelProps) { + const isMobile = useIsMobile(); const [state, setState] = useState("initializing"); const [message, setMessage] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [iframeKey, setIframeKey] = useState(0); const [activeTab, setActiveTab] = useState(() => { + if (forceTab) { + return forceTab; + } + if (isMobile) { + return "chat"; + } if (typeof window !== "undefined") { const params = new URLSearchParams(window.location.search); const tab = params.get("tab"); @@ -443,7 +478,7 @@ export function PreviewPanel({ setActiveTab(value as TabType)} - className="flex flex-col h-full" + className="flex flex-col h-full w-full min-w-0 pb-[calc(4.25rem+env(safe-area-inset-bottom))] md:pb-0" > {/* OpenCode Diagnostic Banner */} {opencodeDiagnostic && ( @@ -454,29 +489,31 @@ export function PreviewPanel({ )} {/* Header with integrated tabs */} -
+
{/* Left: Tabs + Status */} -
+
{/* Tab Navigation */} - - Preview - Files - Assets - + {!isMobile && ( + + Preview + Files + Assets + + )} {/* Status indicators (only for preview tab) */} {activeTab === "preview" && ( -
+
{state === "starting" && ( <> - {message || "Starting..."} + {message || "Starting..."} )} {state === "error" && ( <> - {message || "Error"} + {message || "Error"} )}
@@ -484,48 +521,75 @@ export function PreviewPanel({
{/* Center: URL Bar with integrated buttons (only for preview tab when ready) */} - {activeTab === "preview" && state === "ready" && previewUrl && ( -
- 50 - ? `${previewUrl.slice(0, 50)}...` - : previewUrl - } - disabled - title={previewUrl} - className="flex-1 min-w-0 bg-transparent text-xs text-foreground cursor-default opacity-60 text-center border-0 outline-none" - readOnly - /> - - )} + )} {/* Right: Action buttons */}
+ {isMobile && + activeTab === "preview" && + state === "ready" && + previewUrl && ( + <> + + + + + + )} {activeTab === "preview" && state === "error" && (
diff --git a/src/components/queue/QueueTableLive.tsx b/src/components/queue/QueueTableLive.tsx index 9d1e976e..3501c764 100644 --- a/src/components/queue/QueueTableLive.tsx +++ b/src/components/queue/QueueTableLive.tsx @@ -93,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()); @@ -113,7 +182,7 @@ export function QueueTableLive({ return ( <> -
+

Queue

@@ -134,6 +203,7 @@ export function QueueTableLive({ variant="destructive" size="sm" disabled={isLoading} + className="w-full sm:w-auto" > Stop All Projects @@ -191,7 +261,55 @@ export function QueueTableLive({ )}
-
+
+ {jobs.length === 0 ? ( +
+ No jobs found +
+ ) : ( + jobs.map((job) => ( +
+ +
+
+ Type:{" "} + {job.type} +
+
+ + Project: + {" "} + {job.projectId || "—"} +
+
+ + Created: + {" "} + {formatCreatedAt(job.createdAt)} +
+
+ {renderMobileActions(job)} +
+ )) + )} +
+ +
@@ -237,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/hooks/useResizablePanel.ts b/src/hooks/useResizablePanel.ts index 29abdbea..4c0ba56c 100644 --- a/src/hooks/useResizablePanel.ts +++ b/src/hooks/useResizablePanel.ts @@ -5,6 +5,7 @@ import { useRef, useState, } from "react"; +import { useIsMobile } from "./use-mobile"; interface UseResizablePanelOptions { projectId: string; @@ -12,36 +13,39 @@ interface UseResizablePanelOptions { maxSize?: number; // percentage (default 75) defaultSize?: number; // percentage (default 50) containerRef?: RefObject; // Optional: pass the container ref for accurate positioning + disabled?: boolean; // Explicitly disable resizing (e.g., on mobile) } interface UseResizablePanelReturn { leftPercent: number; rightPercent: number; isDragging: boolean; + isMobile: boolean; + isResizable: boolean; onSeparatorMouseDown: (e: React.MouseEvent) => void; containerRef: RefObject; } -/** - * Custom hook for managing resizable panel layout - * Handles drag-to-resize with localStorage persistence - */ export function useResizablePanel({ projectId, minSize = 25, maxSize = 75, defaultSize = 50, containerRef: externalRef, + disabled, }: UseResizablePanelOptions): UseResizablePanelReturn { const internalRef = useRef(null); const containerRef = externalRef || internalRef; + const isMobile = useIsMobile(); const [leftPercent, setLeftPercent] = useState(defaultSize); const [isDragging, setIsDragging] = useState(false); - // Load from localStorage on mount + const isResizable = !disabled && !isMobile; + useEffect(() => { if (typeof window === "undefined") return; + if (isMobile || disabled) return; const storageKey = `resizable-panel-${projectId}`; const saved = localStorage.getItem(storageKey); @@ -56,7 +60,7 @@ export function useResizablePanel({ // Invalid storage, ignore } } - }, [projectId, minSize, maxSize]); + }, [projectId, minSize, maxSize, isMobile, disabled]); // Handle mouse move during drag const handleMouseMove = useCallback( @@ -114,6 +118,8 @@ export function useResizablePanel({ leftPercent, rightPercent: 100 - leftPercent, isDragging, + isMobile, + isResizable, onSeparatorMouseDown, containerRef, }; diff --git a/src/pages/settings.astro b/src/pages/settings.astro index 5cda2c19..3dd9d8d9 100644 --- a/src/pages/settings.astro +++ b/src/pages/settings.astro @@ -22,11 +22,11 @@ const projectCount = projects.length; --- -
-
+
+
-

Settings

-

Manage your doce.dev configuration

+

Settings

+

Manage your doce.dev configuration

diff --git a/src/styles/globals.css b/src/styles/globals.css index dbf805d7..dc923229 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -156,6 +156,18 @@ html { @apply font-sans; } + + /* Mobile viewport handling */ + html, + body { + min-height: 100vh; + min-height: 100dvh; + } + + /* Prevent horizontal scroll on mobile */ + body { + overflow-x: hidden; + } } @layer utilities { From 946f28540de78ed3dc7d9fba6421d118a21f8fb8 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Tue, 17 Feb 2026 12:51:49 +0100 Subject: [PATCH 41/42] no version --- docker-compose.preview.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.preview.yml b/docker-compose.preview.yml index b52cb1f2..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 From cccb200dda719f7554e3d7b12cfe99809d117731 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Tue, 17 Feb 2026 12:57:15 +0100 Subject: [PATCH 42/42] prune --- .github/workflows/pr-preview.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 09f277ed..b1b6ac6e 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -55,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: | @@ -249,7 +253,7 @@ jobs: 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 2>/dev/null || true" + "docker system prune -af --volumes 2>/dev/null || true" ssh -o StrictHostKeyChecking=no "$SSH_USER@$VM_HOST" "rm -rf \"$PR_DIR\" 2>/dev/null || true"