diff --git a/drizzle/0006_keen_domino.sql b/drizzle/0006_keen_domino.sql new file mode 100644 index 00000000..126a0e65 --- /dev/null +++ b/drizzle/0006_keen_domino.sql @@ -0,0 +1,2 @@ +ALTER TABLE `projects` ADD `description` text DEFAULT '' NOT NULL;--> statement-breakpoint +UPDATE `projects` SET `description` = `prompt` WHERE `description` = ''; \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 00000000..d5ff51ea --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,966 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c7e7f55c-ce33-4306-90ed-80036a7036ae", + "prevId": "7241044c-4156-46f1-9d23-e803e498ecef", + "tables": { + "instance_settings": { + "name": "instance_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tailscale_enabled": { + "name": "tailscale_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "tailscale_auth_key": { + "name": "tailscale_auth_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tailscale_hostname": { + "name": "tailscale_hostname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tailscale_tailnet_name": { + "name": "tailscale_tailnet_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_favorites": { + "name": "model_favorites", + "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 + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_favorites_user_id_idx": { + "name": "model_favorites_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "model_favorites_user_id_users_id_fk": { + "name": "model_favorites_user_id_users_id_fk", + "tableFrom": "model_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "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 + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'✨'" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "dev_port": { + "name": "dev_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'created'" + }, + "desired_status": { + "name": "desired_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'created'" + }, + "observed_status": { + "name": "observed_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "last_reconciled_at": { + "name": "last_reconciled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + }, + "bootstrap_agent_status": { + "name": "bootstrap_agent_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "bootstrap_agent_last_activity_at": { + "name": "bootstrap_agent_last_activity_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "production_port": { + "name": "production_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "production_url": { + "name": "production_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "production_status": { + "name": "production_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'stopped'" + }, + "production_started_at": { + "name": "production_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "production_error": { + "name": "production_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "production_hash": { + "name": "production_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_error_category": { + "name": "opencode_error_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_error_code": { + "name": "opencode_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_error_message": { + "name": "opencode_error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_error_source": { + "name": "opencode_error_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_error_at": { + "name": "opencode_error_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "preferred_model": { + "name": "preferred_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "projects_owner_user_id_idx": { + "name": "projects_owner_user_id_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "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 + }, + "healed_at": { + "name": "healed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heal_reason": { + "name": "heal_reason", + "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": {} + }, + "system_health_snapshots": { + "name": "system_health_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "taken_at": { + "name": "taken_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "queue_jobs_queued": { + "name": "queue_jobs_queued", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queue_jobs_running": { + "name": "queue_jobs_running", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queue_jobs_failed": { + "name": "queue_jobs_failed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queue_orphaned_jobs": { + "name": "queue_orphaned_jobs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "queue_impossible_jobs": { + "name": "queue_impossible_jobs", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "projects_total": { + "name": "projects_total", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "projects_running": { + "name": "projects_running", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "projects_error": { + "name": "projects_error", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "projects_healthy_mismatch": { + "name": "projects_healthy_mismatch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opencode_healthy": { + "name": "opencode_healthy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "docker_network_exists": { + "name": "docker_network_exists", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "docker_volume_exists": { + "name": "docker_volume_exists", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "violations_found": { + "name": "violations_found", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "violations_healed": { + "name": "violations_healed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reconciliation_duration_ms": { + "name": "reconciliation_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "system_health_snapshots_taken_at_idx": { + "name": "system_health_snapshots_taken_at_idx", + "columns": [ + "taken_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "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 + }, + "fast_model": { + "name": "fast_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 513760f0..14c79eed 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1777728063460, "tag": "0005_spooky_senator_kelly", "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1778777722385, + "tag": "0006_keen_domino", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/components/projects/ProjectCard.tsx b/src/components/projects/ProjectCard.tsx index e3e6ed24..f1a8051c 100644 --- a/src/components/projects/ProjectCard.tsx +++ b/src/components/projects/ProjectCard.tsx @@ -321,7 +321,7 @@ export function ProjectCard({ project, onDeleted }: ProjectCardProps) {

- {project.prompt} + {project.description || project.prompt}

diff --git a/src/components/projects/ProjectsList.tsx b/src/components/projects/ProjectsList.tsx index b48b5eff..836504b8 100644 --- a/src/components/projects/ProjectsList.tsx +++ b/src/components/projects/ProjectsList.tsx @@ -102,9 +102,7 @@ function CreatingDraftCard({ prompt }: { prompt: string }) {
- - Creating project... - + New project
diff --git a/src/pages/api/projects/[id]/opencode/event.ts b/src/pages/api/projects/[id]/opencode/event.ts index 281a240a..30106b65 100644 --- a/src/pages/api/projects/[id]/opencode/event.ts +++ b/src/pages/api/projects/[id]/opencode/event.ts @@ -13,6 +13,7 @@ import { isProjectOwnedByUser, markUserPromptCompleted, } from "@/server/projects/projects.model"; +import { updateProjectDescriptionFromSessionTitle } from "@/server/projects/sessionDescription"; import { enqueueDockerEnsureRunning } from "@/server/queue/enqueue"; const KEEP_ALIVE_INTERVAL_MS = 15_000; @@ -150,6 +151,20 @@ export const GET: APIRoute = async ({ params, cookies }) => { } }; + const syncProjectDescription = async (parsed: { + type: string; + properties?: Record; + }) => { + if (parsed.type !== "session.updated") return; + + const info = parsed.properties?.info as + | { id?: unknown; title?: unknown } + | undefined; + if (info?.id !== project.bootstrapSessionId) return; + + await updateProjectDescriptionFromSessionTitle(projectId, info.title); + }; + // Callback to send events from within the stream let sendEventFn: ((event: object) => void) | null = null; @@ -233,6 +248,15 @@ export const GET: APIRoute = async ({ params, cookies }) => { ); } + syncProjectDescription(eventForCheck).catch( + (error: unknown) => { + logger.error( + { error, projectId }, + "Error syncing project description from session title", + ); + }, + ); + const normalized = normalizeEvent(projectId, parsed, state); if (normalized) { sendEvent(normalized); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 461e49e2..462194bf 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -66,6 +66,7 @@ export const projects = sqliteTable( icon: text("icon").notNull().default("✨"), slug: text("slug").notNull().unique(), prompt: text("prompt").notNull(), + description: text("description").notNull().default(""), devPort: integer("dev_port").notNull(), // Deprecated: use desired_status instead. Kept for backward compatibility. status: text("status", { diff --git a/src/server/effect/handlers.ts b/src/server/effect/handlers.ts index 7faf83f5..aa9a3082 100644 --- a/src/server/effect/handlers.ts +++ b/src/server/effect/handlers.ts @@ -13,6 +13,7 @@ import { handleProductionStop } from "@/server/queue/handlers/productionStop"; import { handleProductionWaitReady } from "@/server/queue/handlers/productionWaitReady"; import { handleProjectCreate } from "@/server/queue/handlers/projectCreate"; import { handleProjectDelete } from "@/server/queue/handlers/projectDelete"; +import { handleProjectDescriptionSync } from "@/server/queue/handlers/projectDescriptionSync"; import { handleProjectIdentityGenerate } from "@/server/queue/handlers/projectIdentityGenerate"; import { handleDeleteAllForUser } from "@/server/queue/handlers/projectsDeleteAllForUser"; import { wrapLegacyHandler } from "./handler-adapter"; @@ -24,6 +25,7 @@ export function registerAllHandlers(): void { "project.identityGenerate", wrapLegacyHandler(handleProjectIdentityGenerate), ); + registerHandler("project.descriptionSync", handleProjectDescriptionSync); registerHandler("project.delete", wrapLegacyHandler(handleProjectDelete)); registerHandler("docker.composeUp", handleDockerComposeUp); registerHandler("docker.waitReady", handleDockerWaitReady); diff --git a/src/server/effect/schemas.ts b/src/server/effect/schemas.ts index e96cc910..c3e2417a 100644 --- a/src/server/effect/schemas.ts +++ b/src/server/effect/schemas.ts @@ -2,6 +2,7 @@ import { Schema } from "effect"; export const QueueJobType = Schema.Literal( "project.create", + "project.descriptionSync", "project.delete", "projects.deleteAllForUser", "docker.composeUp", @@ -39,6 +40,14 @@ export type ProjectCreatePayload = Schema.Schema.Type< typeof ProjectCreatePayload >; +export const ProjectDescriptionSyncPayload = Schema.Struct({ + projectId: Schema.String.pipe(Schema.minLength(1)), +}); + +export type ProjectDescriptionSyncPayload = Schema.Schema.Type< + typeof ProjectDescriptionSyncPayload +>; + export const ProjectDeletePayload = Schema.Struct({ projectId: Schema.String.pipe(Schema.minLength(1)), requestedByUserId: Schema.String.pipe(Schema.minLength(1)), @@ -168,6 +177,11 @@ const payloadSchemas: Record> = { unknown, never >, + "project.descriptionSync": ProjectDescriptionSyncPayload as Schema.Schema< + unknown, + unknown, + never + >, "project.delete": ProjectDeletePayload as Schema.Schema< unknown, unknown, diff --git a/src/server/opencode/normalize.ts b/src/server/opencode/normalize.ts index 188ea5ed..6530e01a 100644 --- a/src/server/opencode/normalize.ts +++ b/src/server/opencode/normalize.ts @@ -34,6 +34,7 @@ import { createSseDiagnostic, type OpencodeDiagnostic } from "./diagnostics"; export type NormalizedEventType = | "chat.session.status" + | "chat.session.updated" | "chat.message.user" | "chat.message.part.added" | "chat.message.final" @@ -62,6 +63,10 @@ export interface SessionStatusPayload { cost?: number; } +export interface SessionUpdatedPayload { + title: string | null; +} + export interface MessagePartPayload { messageId: string; partId: string; @@ -514,8 +519,22 @@ export function normalizeEvent( }; } + case "session.updated": { + const properties = event.properties as + | { info?: { id?: string; title?: string | null } } + | undefined; + return { + type: "chat.session.updated", + projectId, + sessionId: properties?.info?.id, + time, + payload: { + title: properties?.info?.title ?? null, + } satisfies SessionUpdatedPayload, + }; + } + // Session events we can ignore - case "session.updated": case "session.created": case "session.deleted": case "session.idle": diff --git a/src/server/projects/projects.db.ts b/src/server/projects/projects.db.ts index bc985af7..a74adcb3 100644 --- a/src/server/projects/projects.db.ts +++ b/src/server/projects/projects.db.ts @@ -124,6 +124,14 @@ export async function updateProjectDisplayIdentity( emitProjectEvent(id); } +export async function updateProjectDescription( + id: string, + description: string, +): Promise { + await db.update(projects).set({ description }).where(eq(projects.id, id)); + emitProjectEvent(id); +} + export async function updateProjectModelInDb( id: string, model: string | null, diff --git a/src/server/projects/sessionDescription.ts b/src/server/projects/sessionDescription.ts new file mode 100644 index 00000000..17a83f40 --- /dev/null +++ b/src/server/projects/sessionDescription.ts @@ -0,0 +1,46 @@ +import { logger } from "@/server/logger"; +import { + getProjectByIdIncludeDeleted, + updateProjectDescription, +} from "./projects.db"; + +const MAX_PROJECT_DESCRIPTION_LENGTH = 160; +const DEFAULT_SESSION_TITLE_PATTERN = + /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +const IGNORED_SESSION_TITLES = new Set([ + "new conversation", + "new session", + "untitled session", +]); + +export function normalizeSessionTitleForProjectDescription( + title: unknown, +): string | null { + if (typeof title !== "string") return null; + + const description = title.trim().replace(/\s+/g, " "); + if (!description) return null; + if (DEFAULT_SESSION_TITLE_PATTERN.test(description)) return null; + if (IGNORED_SESSION_TITLES.has(description.toLowerCase())) return null; + + return description.slice(0, MAX_PROJECT_DESCRIPTION_LENGTH).trim(); +} + +export async function updateProjectDescriptionFromSessionTitle( + projectId: string, + title: unknown, +): Promise { + const description = normalizeSessionTitleForProjectDescription(title); + if (!description) return false; + + const project = await getProjectByIdIncludeDeleted(projectId); + if (!project || project.status === "deleting") return false; + if (project.description === description) return true; + + await updateProjectDescription(projectId, description); + logger.info( + { projectId, description }, + "Updated project description from OpenCode session title", + ); + return true; +} diff --git a/src/server/queue/enqueue.ts b/src/server/queue/enqueue.ts index 987201d5..b8fce73a 100644 --- a/src/server/queue/enqueue.ts +++ b/src/server/queue/enqueue.ts @@ -14,6 +14,7 @@ import type { ProductionWaitReadyPayload, ProjectCreatePayload, ProjectDeletePayload, + ProjectDescriptionSyncPayload, ProjectIdentityGeneratePayload, ProjectsDeleteAllForUserPayload, } from "./types"; @@ -44,6 +45,19 @@ export async function enqueueProjectIdentityGenerate( }); } +export async function enqueueProjectDescriptionSync( + input: ProjectDescriptionSyncPayload, +): Promise { + return enqueueJob({ + id: randomBytes(16).toString("hex"), + type: "project.descriptionSync", + projectId: input.projectId, + payload: input, + dedupeKey: `project.descriptionSync:${input.projectId}`, + maxAttempts: 120, + }); +} + export async function enqueueProjectDelete( input: ProjectDeletePayload, ): Promise { diff --git a/src/server/queue/handlers/opencodeSendUserPrompt.ts b/src/server/queue/handlers/opencodeSendUserPrompt.ts index 22978b4a..84b0b0b6 100644 --- a/src/server/queue/handlers/opencodeSendUserPrompt.ts +++ b/src/server/queue/handlers/opencodeSendUserPrompt.ts @@ -19,7 +19,10 @@ import { parseModelString, updateUserPromptMessageId, } from "@/server/projects/projects.model"; -import { enqueueDockerEnsureRunning } from "../enqueue"; +import { + enqueueDockerEnsureRunning, + enqueueProjectDescriptionSync, +} from "../enqueue"; import { type PromptAttachment, parsePayload } from "../types"; function toMiB(bytes: number): number { @@ -445,9 +448,20 @@ export function handleOpencodeSendUserPrompt( }), }); + yield* Effect.tryPromise({ + try: () => enqueueProjectDescriptionSync({ projectId: project.id }), + catch: (error) => + new ProjectError({ + projectId: project.id, + operation: "enqueueProjectDescriptionSync", + message: error instanceof Error ? error.message : String(error), + cause: error, + }), + }); + logger.info( { projectId: project.id }, - "Enqueued docker.ensureRunning after initial prompt", + "Enqueued post-prompt follow-up jobs", ); logger.info( diff --git a/src/server/queue/handlers/projectCreate.ts b/src/server/queue/handlers/projectCreate.ts index d5be339e..89ff8196 100644 --- a/src/server/queue/handlers/projectCreate.ts +++ b/src/server/queue/handlers/projectCreate.ts @@ -104,6 +104,7 @@ const createProjectEffect = (params: { icon: string; slug: string; prompt: string; + description: string; devPort: number; productionPort: number; projectPath: string; @@ -118,6 +119,7 @@ const createProjectEffect = (params: { icon: params.icon, slug: params.slug, prompt: params.prompt, + description: params.description, devPort: params.devPort, productionPort: params.productionPort, status: "created", @@ -212,6 +214,7 @@ export const handleProjectCreate: LegacyHandler = async (ctx) => { icon, slug, prompt, + description: prompt, devPort, productionPort, projectPath, diff --git a/src/server/queue/handlers/projectDescriptionSync.ts b/src/server/queue/handlers/projectDescriptionSync.ts new file mode 100644 index 00000000..caef0e68 --- /dev/null +++ b/src/server/queue/handlers/projectDescriptionSync.ts @@ -0,0 +1,87 @@ +import { Effect } from "effect"; +import { ProjectError } from "@/server/effect/errors"; +import type { QueueJobContext } from "@/server/effect/queue.worker"; +import { logger } from "@/server/logger"; +import { createOpencodeClient } from "@/server/opencode/client"; +import { getProjectByIdIncludeDeleted } from "@/server/projects/projects.model"; +import { updateProjectDescriptionFromSessionTitle } from "@/server/projects/sessionDescription"; +import { parsePayload } from "../types"; + +const RETRY_DELAY_MS = 2_000; +const SYNC_TIMEOUT_MS = 2 * 60_000; + +function getSessionTitle(data: unknown): string | null { + if (typeof data !== "object" || data === null) return null; + const title = (data as { title?: unknown }).title; + return typeof title === "string" ? title : null; +} + +export function handleProjectDescriptionSync( + ctx: QueueJobContext, +): Effect.Effect { + return Effect.gen(function* () { + const payload = parsePayload( + "project.descriptionSync", + ctx.job.payloadJson, + ); + + const project = yield* Effect.tryPromise({ + try: () => getProjectByIdIncludeDeleted(payload.projectId), + catch: (error) => + new ProjectError({ + projectId: payload.projectId, + operation: "getProjectByIdIncludeDeleted", + message: error instanceof Error ? error.message : String(error), + cause: error, + }), + }); + + if (!project || project.status === "deleting") return; + const sessionId = project.bootstrapSessionId; + if (!sessionId) return; + + const client = createOpencodeClient(); + const session = yield* Effect.tryPromise({ + try: () => client.session.get({ sessionID: sessionId }), + catch: (error) => + new ProjectError({ + projectId: project.id, + operation: "getOpenCodeSession", + message: error instanceof Error ? error.message : String(error), + cause: error, + }), + }); + + const updated = yield* Effect.tryPromise({ + try: () => + updateProjectDescriptionFromSessionTitle( + project.id, + getSessionTitle(session.data), + ), + catch: (error) => + new ProjectError({ + projectId: project.id, + operation: "updateProjectDescriptionFromSessionTitle", + message: error instanceof Error ? error.message : String(error), + cause: error, + }), + }); + + if (updated) return; + + const elapsed = Date.now() - ctx.job.createdAt.getTime(); + if (elapsed >= SYNC_TIMEOUT_MS) { + logger.debug( + { + projectId: project.id, + sessionId, + elapsed, + }, + "Stopped waiting for OpenCode session title", + ); + return; + } + + ctx.reschedule(RETRY_DELAY_MS); + }); +} diff --git a/src/server/queue/types.ts b/src/server/queue/types.ts index 2957fbbc..33aff094 100644 --- a/src/server/queue/types.ts +++ b/src/server/queue/types.ts @@ -4,6 +4,7 @@ export const queueJobTypeSchema = z.enum([ // Project lifecycle "project.create", "project.identityGenerate", + "project.descriptionSync", "project.delete", "projects.deleteAllForUser", // Docker lifecycle (fine-grained) @@ -59,6 +60,14 @@ export type ProjectIdentityGeneratePayload = z.infer< typeof projectIdentityGeneratePayloadSchema >; +export const projectDescriptionSyncPayloadSchema = z.object({ + projectId: z.string().min(1), +}); + +export type ProjectDescriptionSyncPayload = z.infer< + typeof projectDescriptionSyncPayloadSchema +>; + export const projectDeletePayloadSchema = z.object({ projectId: z.string().min(1), requestedByUserId: z.string().min(1), @@ -183,6 +192,7 @@ export type AppRestartPayload = z.infer; const payloadSchemaByType = { "project.create": projectCreatePayloadSchema, "project.identityGenerate": projectIdentityGeneratePayloadSchema, + "project.descriptionSync": projectDescriptionSyncPayloadSchema, "project.delete": projectDeletePayloadSchema, "projects.deleteAllForUser": projectsDeleteAllForUserPayloadSchema, "docker.composeUp": dockerComposeUpPayloadSchema, @@ -203,6 +213,7 @@ const payloadSchemaByType = { export type PayloadByType = { "project.create": ProjectCreatePayload; "project.identityGenerate": ProjectIdentityGeneratePayload; + "project.descriptionSync": ProjectDescriptionSyncPayload; "project.delete": ProjectDeletePayload; "projects.deleteAllForUser": ProjectsDeleteAllForUserPayload; "docker.composeUp": DockerComposeUpPayload; diff --git a/src/stores/useChatStore.ts b/src/stores/useChatStore.ts index 0de04b09..5603e524 100644 --- a/src/stores/useChatStore.ts +++ b/src/stores/useChatStore.ts @@ -240,11 +240,26 @@ export function createChatStore() { const { type, sessionId: eventSessionId, payload } = event; // Update session ID if provided - if (eventSessionId) { + if (eventSessionId && type !== "chat.session.updated") { set({ sessionId: eventSessionId as string }); } switch (type) { + case "chat.session.updated": { + const { title } = payload as { title?: string | null }; + set((state) => { + if ( + eventSessionId && + state.sessionId && + eventSessionId !== state.sessionId + ) { + return {}; + } + return { sessionTitle: title ?? null }; + }); + break; + } + case "chat.message.user": { const { messageId } = payload as { messageId: string }; set((state) => ({