diff --git a/apps/cli/package.json b/apps/cli/package.json index 4a8cea9b..14727cdd 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -7,7 +7,7 @@ "cyrusd": "src/cli.ts" }, "scripts": { - "cli": "bun src/cli.ts", + "dev": "bun src/cli.ts", "check:types": "tsc --noEmit", "build": "bun build src/cli.ts --compile --env 'CLI_PUBLIC_*' --outfile dist/cyrusd" }, @@ -16,13 +16,15 @@ "@agentclientprotocol/sdk": "^1.1.0", "@commander-js/extra-typings": "^15.0.0", "@cyrus/connections": "workspace:*", + "@cyrus/database": "workspace:*", + "@cyrus/utils": "workspace:*", + "@tursodatabase/database": "^0.6.1", "@orpc/server": "catalog:", "@t3-oss/env-core": "catalog:", "better-auth": "catalog:", "better-result": "catalog:", "commander": "^15.0.0", "diff": "^7.0.0", - "random-word-slugs": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/apps/cli/src/commands/auth/login.ts b/apps/cli/src/commands/auth/login.ts index 55a431fd..7fe87177 100644 --- a/apps/cli/src/commands/auth/login.ts +++ b/apps/cli/src/commands/auth/login.ts @@ -1,7 +1,7 @@ +import { generateName } from "@cyrus/utils/identity"; import { Result } from "better-result"; import { authClient } from "@/lib/auth"; import { getOrCreate, set } from "@/store/config"; -import { generateName } from "@/utils/identity"; import { blue, bold, cyan, print, underline } from "@/utils/style"; export const CLIENT_ID = "cyrus-cli"; diff --git a/apps/cli/src/commands/service/worker.ts b/apps/cli/src/commands/service/worker.ts index 31529389..fdd0f623 100644 --- a/apps/cli/src/commands/service/worker.ts +++ b/apps/cli/src/commands/service/worker.ts @@ -1,12 +1,15 @@ import { connectSignaling } from "@cyrus/connections/rtc/session"; import { serveWorker } from "@cyrus/connections/rtc/worker"; +import { connection } from "@cyrus/database/connection"; +import { generateName, randomId } from "@cyrus/utils/identity"; +import { Result } from "better-result"; import { createWorkerRuntime } from "@/core"; import { createControllerRouter } from "@/handlers/controller"; import { workerRouter } from "@/handlers/worker"; import { authClient } from "@/lib/auth"; import { env } from "@/lib/env"; import { get, getOrCreate } from "@/store/config"; -import { generateId, generateName } from "@/utils/identity"; +import { initDatabase } from "@/store/database"; import { print } from "@/utils/style"; export async function worker(): Promise { @@ -22,12 +25,17 @@ export async function worker(): Promise { process.exit(1); } - const id = await getOrCreate("id", generateId); + const id = await getOrCreate("id", randomId); const name = await getOrCreate("name", generateName); const room = session.user.id; const runtime = createWorkerRuntime(); + (await Result.tryPromise(() => initDatabase())).tapError((err) => { + print.error`Failed to initialize database: ${String(err)}`; + process.exit(1); + }); + print.dim`worker "${name}" joining hub`; const signalingSession = await connectSignaling({ @@ -53,6 +61,7 @@ export async function worker(): Promise { await runtime.agentPool.shutdown(); device.close(); signalingSession.close(); + await connection.close(); process.exit(0); }; process.on("SIGINT", shutdown); diff --git a/apps/cli/src/constants/file.ts b/apps/cli/src/constants/file.ts index afd4b2bd..a7508bd3 100644 --- a/apps/cli/src/constants/file.ts +++ b/apps/cli/src/constants/file.ts @@ -2,3 +2,4 @@ export const CONFIG_FILE = "config.yml"; export const AGENTS_FILE = "agents.yml"; export const WORKER_PID = "worker.pid"; export const WORKER_LOG = "worker.log"; +export const DATABASE_FILE = "store.db"; diff --git a/apps/cli/src/core/acp/events.ts b/apps/cli/src/core/acp/events.ts index c90e0b66..064e235d 100644 --- a/apps/cli/src/core/acp/events.ts +++ b/apps/cli/src/core/acp/events.ts @@ -9,9 +9,11 @@ import { type AgentEvent, AgentEventSchema, ApprovalRequestEventSchema, + MessageCompletedEventSchema, PlanEventSchema, PlanRemovedEventSchema, PlanUpdateEventSchema, + ReasoningCompletedEventSchema, ThoughtEventSchema, TokenEventSchema, ToolCallEventSchema, @@ -32,6 +34,14 @@ export function mapRuntimeSessionEvent( messageId: event.messageId, }), ]; + case "message.completed": + return [ + MessageCompletedEventSchema.parse({ + type: "message_completed", + text: event.content, + messageId: event.messageId, + }), + ]; case "reasoning.delta": return [ ThoughtEventSchema.parse({ @@ -40,6 +50,14 @@ export function mapRuntimeSessionEvent( messageId: event.reasoningId, }), ]; + case "reasoning.completed": + return [ + ReasoningCompletedEventSchema.parse({ + type: "reasoning_completed", + text: event.content, + messageId: event.reasoningId, + }), + ]; case "tool.start": return [ ToolCallEventSchema.parse({ diff --git a/apps/cli/src/core/threads/coordinator.ts b/apps/cli/src/core/threads/coordinator.ts index e75fdd88..85e8d8ae 100644 --- a/apps/cli/src/core/threads/coordinator.ts +++ b/apps/cli/src/core/threads/coordinator.ts @@ -1,9 +1,10 @@ import type { ModelOption } from "@cyrus/connections/schemas/rtc/catalog"; import type { AgentEvent } from "@cyrus/connections/schemas/rtc/chat"; import type { SelectOption } from "@cyrus/connections/schemas/rtc/common"; +import { resolveProjectCwd } from "@cyrus/database/repositories/projects"; +import { repositoryErrorMessage } from "@cyrus/database/utils/error"; import type { AgentPool } from "@/core/acp/pool"; import { AgentRuntime } from "@/core/agents/runtime"; -import { resolveProjectCwd } from "@/store/projects"; export class ThreadCoordinator { private readonly agents = new Map(); @@ -44,7 +45,7 @@ export class ThreadCoordinator { projectId: string, modelId: string ): Promise { - const cwd = resolveProjectCwd(projectId); + const cwd = await this.resolveCwd(projectId); await this.getAgent(agentName).setModel(threadId, projectId, cwd, modelId); } @@ -54,7 +55,7 @@ export class ThreadCoordinator { projectId: string, modeId: string ): Promise { - const cwd = resolveProjectCwd(projectId); + const cwd = await this.resolveCwd(projectId); await this.getAgent(agentName).setMode(threadId, projectId, cwd, modeId); } @@ -64,7 +65,7 @@ export class ThreadCoordinator { projectId: string, effortId: string ): Promise { - const cwd = resolveProjectCwd(projectId); + const cwd = await this.resolveCwd(projectId); await this.getAgent(agentName).setEffort( threadId, projectId, @@ -79,7 +80,7 @@ export class ThreadCoordinator { projectId: string, personaId: string ): Promise { - const cwd = resolveProjectCwd(projectId); + const cwd = await this.resolveCwd(projectId); await this.getAgent(agentName).setPersona( threadId, projectId, @@ -94,7 +95,7 @@ export class ThreadCoordinator { projectId: string, content: string ): AsyncGenerator { - const cwd = resolveProjectCwd(projectId); + const cwd = await this.resolveCwd(projectId); yield* this.getAgent(agentName).prompt(threadId, projectId, cwd, content); } @@ -102,6 +103,14 @@ export class ThreadCoordinator { await this.getAgent(agentName).cancel(threadId); } + private async resolveCwd(projectId: string): Promise { + const result = await resolveProjectCwd(projectId); + if (result.isErr()) { + throw new Error(repositoryErrorMessage(result.error)); + } + return result.value; + } + async close(agentName: string, threadId: string): Promise { await this.getAgent(agentName).close(threadId); } diff --git a/apps/cli/src/handlers/controller/chat.ts b/apps/cli/src/handlers/controller/chat.ts index 2f7fe977..60af81f5 100644 --- a/apps/cli/src/handlers/controller/chat.ts +++ b/apps/cli/src/handlers/controller/chat.ts @@ -1,31 +1,58 @@ import type { ChatChunk } from "@cyrus/connections/schemas/rtc/chat"; +import { appendConversation } from "@cyrus/database/repositories/conversations"; +import { ensureThread } from "@cyrus/database/repositories/threads"; +import { randomId } from "@cyrus/utils/identity"; import { env } from "@/lib/env"; -import { appendConversation, ensureThread } from "@/store/threads"; +import { throwOrpcFromRepositoryError } from "@/utils/error"; +import { + isStreamingDelta, + resolvePersistEvent, + trackDelta, +} from "@/utils/streams"; import type { ControllerDeps } from "./deps"; export function chatHandlers({ os, runtime }: ControllerDeps) { return { chat: os.chat.handler(async function* ({ input, context }) { - const { + const { agentName, threadId = randomId(), message, projectId } = input; + + const thread = await ensureThread(threadId, projectId, { agentName, - threadId = Bun.randomUUIDv7(), - message, - projectId, - } = input; + firstMessage: message, + }); + if (thread.isErr()) throwOrpcFromRepositoryError(thread.error); + + const turnId = randomId(); + const messageBuffers = new Map(); + const thoughtBuffers = new Map(); - ensureThread(threadId, projectId, { agentName, firstMessage: message }); + async function emit(event: ChatChunk["event"]): Promise { + trackDelta(event, messageBuffers, thoughtBuffers); - const turnId = Bun.randomUUIDv7(); + if (isStreamingDelta(event)) { + const chunk: ChatChunk = { threadId, turnId, seq: 0, event }; + context.broadcaster.broadcast(chunk, context.peerId); + return chunk; + } - function emit(event: ChatChunk["event"]): ChatChunk { - const chunk = { threadId, turnId, event }; + const persistEvent = resolvePersistEvent( + event, + messageBuffers, + thoughtBuffers + ); + const entry = await appendConversation(threadId, { + threadId, + turnId, + event: persistEvent, + }); + if (entry.isErr()) throwOrpcFromRepositoryError(entry.error); + const chunk = entry.value.chunk; context.broadcaster.broadcast(chunk, context.peerId); - appendConversation(threadId, chunk); return chunk; } - yield emit({ type: "user_message", content: message }); - yield emit({ type: "thread_started", threadId }); + yield await emit({ type: "user_message", content: message }); + yield await emit({ type: "thread_started", threadId }); const gen = runtime.threadCoordinator.prompt( agentName, @@ -33,9 +60,15 @@ export function chatHandlers({ os, runtime }: ControllerDeps) { projectId, message ); - for await (const event of gen) { - yield emit(event); - await Bun.sleep(env.CYRUS_STREAM_THROTTLING_MS); + try { + for await (const event of gen) { + yield await emit(event); + await Bun.sleep(env.CYRUS_STREAM_THROTTLING_MS); + } + yield await emit({ type: "turn_completed" }); + } catch (error) { + yield await emit({ type: "turn_interrupted" }); + throw error; } }), diff --git a/apps/cli/src/handlers/controller/projects.ts b/apps/cli/src/handlers/controller/projects.ts index 63fa193e..6a040704 100644 --- a/apps/cli/src/handlers/controller/projects.ts +++ b/apps/cli/src/handlers/controller/projects.ts @@ -1,43 +1,49 @@ -import { ORPCError } from "@orpc/server"; -import { Result } from "better-result"; import { createProject as createStoredProject, deleteProject as deleteStoredProject, listProjects, renameProject, -} from "@/store/projects"; -import { deleteThreadsForProject } from "@/store/threads"; +} from "@cyrus/database/repositories/projects"; +import { deleteThreadsForProject } from "@cyrus/database/repositories/threads"; +import { throwOrpcFromRepositoryError } from "@/utils/error"; import type { ControllerOs } from "./deps"; export function projectsHandlers(os: ControllerOs) { return { - listProjects: os.listProjects.handler(async () => ({ - projects: listProjects(), - })), + listProjects: os.listProjects.handler(async () => + (await listProjects()).match({ + ok: (projects) => ({ projects }), + err: throwOrpcFromRepositoryError, + }) + ), - createProject: os.createProject.handler(({ input }) => ({ - project: createStoredProject(input.name, input.cwd), - })), + createProject: os.createProject.handler(async ({ input }) => + (await createStoredProject(input.name, input.cwd)).match({ + ok: (project) => ({ project }), + err: throwOrpcFromRepositoryError, + }) + ), - renameProject: os.renameProject.handler(({ input }) => - Result.try(() => renameProject(input.projectId, input.name)).match({ + renameProject: os.renameProject.handler(async ({ input }) => + (await renameProject(input.projectId, input.name)).match({ ok: () => ({}), - err: () => { - throw new ORPCError("NOT_FOUND", { - message: `project not found: ${input.projectId}`, - }); - }, + err: throwOrpcFromRepositoryError, }) ), - deleteProject: os.deleteProject.handler(({ input }) => { - const deleted = deleteStoredProject(input.projectId); - if (!deleted) - throw new ORPCError("NOT_FOUND", { - message: `project not found: ${input.projectId}`, + deleteProject: os.deleteProject.handler(async ({ input }) => { + const deleted = await deleteStoredProject(input.projectId); + if (deleted.isErr()) throwOrpcFromRepositoryError(deleted.error); + if (!deleted.value) { + throwOrpcFromRepositoryError({ + type: "not_found", + entity: "project", + id: input.projectId, }); + } - deleteThreadsForProject(input.projectId); + const threads = await deleteThreadsForProject(input.projectId); + if (threads.isErr()) throwOrpcFromRepositoryError(threads.error); return {}; }), }; diff --git a/apps/cli/src/handlers/controller/threads.ts b/apps/cli/src/handlers/controller/threads.ts index c7ce998b..633bb11b 100644 --- a/apps/cli/src/handlers/controller/threads.ts +++ b/apps/cli/src/handlers/controller/threads.ts @@ -1,59 +1,65 @@ -import { ORPCError } from "@orpc/server"; -import { Result } from "better-result"; +import { getConversations } from "@cyrus/database/repositories/conversations"; import { createThread as createStoredThread, deleteThread, - getConversations, getThread, listThreads, renameThread, -} from "@/store/threads"; +} from "@cyrus/database/repositories/threads"; +import { throwOrpcFromRepositoryError } from "@/utils/error"; import type { ControllerOs } from "./deps"; export function threadsHandlers(os: ControllerOs) { return { - listThreads: os.listThreads.handler(async ({ input }) => ({ - threads: listThreads(input.projectId), - })), + listThreads: os.listThreads.handler(async ({ input }) => + (await listThreads(input.projectId)).match({ + ok: (threads) => ({ threads }), + err: throwOrpcFromRepositoryError, + }) + ), - createThread: os.createThread.handler(({ input }) => - Result.try(() => createStoredThread(input.projectId)).match({ + createThread: os.createThread.handler(async ({ input }) => + (await createStoredThread(input.projectId)).match({ ok: (thread) => ({ thread }), - err: () => { - throw new ORPCError("NOT_FOUND", { - message: `project not found: ${input.projectId}`, - }); - }, + err: throwOrpcFromRepositoryError, }) ), - getConversations: os.getConversations.handler(({ input }) => { - if (!getThread(input.threadId)) { - throw new ORPCError("NOT_FOUND", { - message: `thread not found: ${input.threadId}`, + getConversations: os.getConversations.handler(async ({ input }) => { + const thread = await getThread(input.threadId); + if (thread.isErr()) throwOrpcFromRepositoryError(thread.error); + if (!thread.value) { + throwOrpcFromRepositoryError({ + type: "not_found", + entity: "thread", + id: input.threadId, }); } - return { conversations: getConversations(input.threadId) }; + + return (await getConversations(input.threadId, input.afterSeq)).match({ + ok: (conversations) => ({ conversations }), + err: throwOrpcFromRepositoryError, + }); }), - renameThread: os.renameThread.handler(({ input }) => - Result.try(() => renameThread(input.threadId, input.name)).match({ + renameThread: os.renameThread.handler(async ({ input }) => + (await renameThread(input.threadId, input.name)).match({ ok: () => ({}), - err: () => { - throw new ORPCError("NOT_FOUND", { - message: `thread not found: ${input.threadId}`, - }); - }, + err: throwOrpcFromRepositoryError, }) ), - deleteThread: os.deleteThread.handler(({ input }) => { - const deleted = deleteThread(input.threadId); - if (!deleted) { - throw new ORPCError("NOT_FOUND", { - message: `thread not found: ${input.threadId}`, + deleteThread: os.deleteThread.handler(async ({ input }) => { + const deleted = await deleteThread(input.threadId); + if (deleted.isErr()) throwOrpcFromRepositoryError(deleted.error); + if (!deleted.value) { + throwOrpcFromRepositoryError({ + type: "not_found", + entity: "thread", + id: input.threadId, }); } + return {}; }), }; diff --git a/apps/cli/src/store/database.ts b/apps/cli/src/store/database.ts new file mode 100644 index 00000000..6ec68276 --- /dev/null +++ b/apps/cli/src/store/database.ts @@ -0,0 +1,16 @@ +import { join } from "node:path"; +import { connection } from "@cyrus/database/connection"; +import { connect } from "@tursodatabase/database"; +import { DATABASE_FILE } from "@/constants/file"; +import { env } from "@/lib/env"; +import { ensureDir } from "@/utils/dir"; + +const STORE_DB_PATH = join(env.CYRUS_HOME, DATABASE_FILE); + +export async function initDatabase() { + await ensureDir(); + return connection.open( + () => connect(STORE_DB_PATH, { timeout: 5000 }), + "worker" + ); +} diff --git a/apps/cli/src/store/projects.ts b/apps/cli/src/store/projects.ts deleted file mode 100644 index c5402e71..00000000 --- a/apps/cli/src/store/projects.ts +++ /dev/null @@ -1,58 +0,0 @@ -// TODO: Delete this and store in turso -import { mkdirSync } from "node:fs"; -import path from "node:path"; -import { generateId } from "@/utils/identity"; - -export type Project = { - id: string; - cwd: string; - name?: string; -}; - -export const DEFAULT_PROJECT_ID = "default"; - -const DEFAULT_CWD = "/tmp/cyrus-agent-test"; - -mkdirSync(DEFAULT_CWD, { recursive: true }); - -const projects = new Map(); - -export function listProjects(): Project[] { - return [...projects.values()].sort((a, b) => a.id.localeCompare(b.id)); -} - -export function getProject(projectId: string): Project | undefined { - return projects.get(projectId); -} - -export function resolveProjectCwd(projectId: string): string { - const project = getProject(projectId); - if (!project) { - throw new Error(`project not found: ${projectId}`); - } - mkdirSync(project.cwd, { recursive: true }); - return project.cwd; -} - -export function createProject(name: string, cwd = ""): Project { - const id = generateId(); - const resolvedCwd = cwd.trim() || path.join(DEFAULT_CWD, id); - mkdirSync(resolvedCwd, { recursive: true }); - const project: Project = { id, cwd: resolvedCwd, name }; - projects.set(id, project); - return project; -} - -export function renameProject(projectId: string, name: string): Project { - const project = projects.get(projectId); - if (!project) { - throw new Error(`project not found: ${projectId}`); - } - const updated = { ...project, name }; - projects.set(projectId, updated); - return updated; -} - -export function deleteProject(projectId: string): boolean { - return projects.delete(projectId); -} diff --git a/apps/cli/src/store/threads.ts b/apps/cli/src/store/threads.ts deleted file mode 100644 index c854add3..00000000 --- a/apps/cli/src/store/threads.ts +++ /dev/null @@ -1,128 +0,0 @@ -// TODO: Delete this and store in turso -import type { ChatChunk } from "@cyrus/connections/schemas/rtc/chat"; -import type { - ConversationEntry, - Thread, -} from "@cyrus/connections/schemas/rtc/threads"; -import { generateId } from "@/utils/identity"; -import { getProject } from "./projects"; - -const threads = new Map(); -const conversations = new Map(); - -let conversationSeq = 0; - -function nowISO(): string { - return new Date().toISOString(); -} - -export function threadNameFromPrompt(message: string): string { - const trimmed = message.trim(); - if (!trimmed) { - return "New thread"; - } - return trimmed.slice(0, 50); -} - -export function ensureThread( - id: string, - projectId: string, - options?: { agentName?: string; firstMessage?: string } -): Thread { - if (!getProject(projectId)) { - throw new Error(`project not found: ${projectId}`); - } - - const existing = threads.get(id); - if (existing) { - const name = - options?.firstMessage && existing.name === "New thread" - ? threadNameFromPrompt(options.firstMessage) - : existing.name; - const thread: Thread = { - ...existing, - name, - agentName: options?.agentName ?? existing.agentName, - updatedAt: nowISO(), - }; - threads.set(id, thread); - return thread; - } - - const thread: Thread = { - id, - projectId, - name: options?.firstMessage - ? threadNameFromPrompt(options.firstMessage) - : "New thread", - agentName: options?.agentName, - createdAt: nowISO(), - updatedAt: nowISO(), - }; - threads.set(id, thread); - conversations.set(id, []); - return thread; -} - -export function createThread(projectId: string): Thread { - return ensureThread(generateId(), projectId); -} - -export function listThreads(projectId: string): Thread[] { - return [...threads.values()] - .filter((thread) => thread.projectId === projectId) - .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); -} - -export function appendConversation( - threadId: string, - chunk: ChatChunk -): ConversationEntry { - const entry: ConversationEntry = { - id: String(++conversationSeq), - threadId, - chunk, - createdAt: nowISO(), - }; - const list = conversations.get(threadId) ?? []; - list.push(entry); - conversations.set(threadId, list); - - const thread = threads.get(threadId); - if (thread) { - threads.set(threadId, { ...thread, updatedAt: nowISO() }); - } - - return entry; -} - -export function getConversations(threadId: string): ConversationEntry[] { - return [...(conversations.get(threadId) ?? [])]; -} - -export function deleteThread(threadId: string): boolean { - conversations.delete(threadId); - return threads.delete(threadId); -} - -export function deleteThreadsForProject(projectId: string): void { - for (const thread of threads.values()) { - if (thread.projectId === projectId) { - deleteThread(thread.id); - } - } -} - -export function renameThread(threadId: string, name: string): Thread { - const thread = threads.get(threadId); - if (!thread) { - throw new Error(`thread not found: ${threadId}`); - } - const updated = { ...thread, name, updatedAt: nowISO() }; - threads.set(threadId, updated); - return updated; -} - -export function getThread(threadId: string): Thread | undefined { - return threads.get(threadId); -} diff --git a/apps/cli/src/utils/error.ts b/apps/cli/src/utils/error.ts index 095bcb2b..3e95f2e1 100644 --- a/apps/cli/src/utils/error.ts +++ b/apps/cli/src/utils/error.ts @@ -1,3 +1,13 @@ +import type { RepositoryError } from "@cyrus/database/utils/error"; +import { repositoryErrorMessage } from "@cyrus/database/utils/error"; +import { ORPCError } from "@orpc/server"; + export function toMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +export function throwOrpcFromRepositoryError(error: RepositoryError): never { + const code = + error.type === "not_found" ? "NOT_FOUND" : "INTERNAL_SERVER_ERROR"; + throw new ORPCError(code, { message: repositoryErrorMessage(error) }); +} diff --git a/apps/cli/src/utils/streams.ts b/apps/cli/src/utils/streams.ts new file mode 100644 index 00000000..dac30f10 --- /dev/null +++ b/apps/cli/src/utils/streams.ts @@ -0,0 +1,43 @@ +import type { AgentEvent } from "@cyrus/connections/schemas/rtc/chat"; + +export function isStreamingDelta(event: AgentEvent): boolean { + return event.type === "token" || event.type === "thought"; +} + +export function trackDelta( + event: AgentEvent, + messageBuffers: Map, + thoughtBuffers: Map +): void { + if (event.type !== "token" && event.type !== "thought") return; + if (!event.messageId) return; + + const buffer = { + token: messageBuffers, + thought: thoughtBuffers, + }[event.type]; + + buffer.set(event.messageId, (buffer.get(event.messageId) ?? "") + event.text); +} + +export function resolvePersistEvent( + event: AgentEvent, + messageBuffers: Map, + thoughtBuffers: Map +): AgentEvent { + if ( + event.type !== "message_completed" && + event.type !== "reasoning_completed" + ) + return event; + + const buffer = { + message_completed: messageBuffers, + reasoning_completed: thoughtBuffers, + }[event.type]; + + const messageId = event.messageId ?? "default"; + const text = buffer.get(messageId) ?? event.text; + buffer.delete(messageId); + return { ...event, text }; +} diff --git a/apps/server/package.json b/apps/server/package.json index e2d9c775..652dd79e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -34,7 +34,7 @@ "@cyrus/typescript": "workspace:*", "@types/bun": "catalog:", "@types/pg": "^8.16.0", - "drizzle-kit": "^0.31.8", + "drizzle-kit": "catalog:", "typescript": "catalog:" } } diff --git a/apps/server/src/db/index.ts b/apps/server/src/db/index.ts index 3a3bf98f..3415d747 100644 --- a/apps/server/src/db/index.ts +++ b/apps/server/src/db/index.ts @@ -1,13 +1,8 @@ -import { neon } from "@neondatabase/serverless"; import { drizzle } from "drizzle-orm/neon-http"; import { log } from "evlog"; import { env } from "../config/env"; -// biome-ignore lint/performance/noNamespaceImport: drizzle adapter requires schema as namespace -import * as models from "./models"; -const sql = neon(env.DATABASE_URL); -export const db = drizzle(sql, { - schema: models, +export const db = drizzle(env.DATABASE_URL, { logger: { logQuery: (query, params) => log.debug({ diff --git a/apps/server/src/db/migrations/0000_db_init.sql b/apps/server/src/db/migrations/20260624170920_db_init/migration.sql similarity index 100% rename from apps/server/src/db/migrations/0000_db_init.sql rename to apps/server/src/db/migrations/20260624170920_db_init/migration.sql diff --git a/apps/server/src/db/migrations/20260624170920_db_init/snapshot.json b/apps/server/src/db/migrations/20260624170920_db_init/snapshot.json new file mode 100644 index 00000000..aac19659 --- /dev/null +++ b/apps/server/src/db/migrations/20260624170920_db_init/snapshot.json @@ -0,0 +1,614 @@ +{ + "id": "9574a1fb-26d2-497c-8ad4-05a1c9f1c1b1", + "prevIds": ["00000000-0000-0000-0000-000000000000"], + "version": "8", + "dialect": "postgres", + "ddl": [ + { + "isRlsEnabled": false, + "name": "account", + "schema": "public", + "entityType": "tables" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pkey", + "schema": "public", + "table": "account", + "entityType": "pks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_id", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id_token", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token_expires_at", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token_expires_at", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scope", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "account_userId_idx", + "schema": "public", + "table": "account", + "entityType": "indexes" + }, + { + "nameExplicit": false, + "columns": ["user_id"], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "account_user_id_user_id_fk", + "schema": "public", + "table": "account", + "entityType": "fks" + }, + { + "isRlsEnabled": false, + "name": "session", + "schema": "public", + "entityType": "tables" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pkey", + "schema": "public", + "table": "session", + "entityType": "pks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_address", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_agent", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "nameExplicit": false, + "columns": ["token"], + "nullsNotDistinct": false, + "name": "session_token_unique", + "schema": "public", + "table": "session", + "entityType": "uniques" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "session_userId_idx", + "schema": "public", + "table": "session", + "entityType": "indexes" + }, + { + "nameExplicit": false, + "columns": ["user_id"], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "session_user_id_user_id_fk", + "schema": "public", + "table": "session", + "entityType": "fks" + }, + { + "isRlsEnabled": false, + "name": "user", + "schema": "public", + "entityType": "tables" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "user_pkey", + "schema": "public", + "table": "user", + "entityType": "pks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "schema": "public", + "table": "user", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "schema": "public", + "table": "user", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "schema": "public", + "table": "user", + "entityType": "columns" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "email_verified", + "schema": "public", + "table": "user", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image", + "schema": "public", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "schema": "public", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "schema": "public", + "table": "user", + "entityType": "columns" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "user_email_unique", + "schema": "public", + "table": "user", + "entityType": "uniques" + }, + { + "isRlsEnabled": false, + "name": "verification", + "schema": "public", + "entityType": "tables" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "verification_pkey", + "schema": "public", + "table": "verification", + "entityType": "pks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "schema": "public", + "table": "verification", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "identifier", + "schema": "public", + "table": "verification", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "schema": "public", + "table": "verification", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "schema": "public", + "table": "verification", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "schema": "public", + "table": "verification", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "schema": "public", + "table": "verification", + "entityType": "columns" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "identifier", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "verification_identifier_idx", + "schema": "public", + "table": "verification", + "entityType": "indexes" + } + ], + "renames": [] +} diff --git a/apps/server/src/db/migrations/0001_device_authorization.sql b/apps/server/src/db/migrations/20260628062857_device_authorization/migration.sql similarity index 100% rename from apps/server/src/db/migrations/0001_device_authorization.sql rename to apps/server/src/db/migrations/20260628062857_device_authorization/migration.sql diff --git a/apps/server/src/db/migrations/20260628062857_device_authorization/snapshot.json b/apps/server/src/db/migrations/20260628062857_device_authorization/snapshot.json new file mode 100644 index 00000000..dcffd942 --- /dev/null +++ b/apps/server/src/db/migrations/20260628062857_device_authorization/snapshot.json @@ -0,0 +1,758 @@ +{ + "id": "267b3224-556c-4bf1-97f7-0b45a1a38fa4", + "prevIds": ["9574a1fb-26d2-497c-8ad4-05a1c9f1c1b1"], + "version": "8", + "dialect": "postgres", + "ddl": [ + { + "isRlsEnabled": false, + "name": "account", + "schema": "public", + "entityType": "tables" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pkey", + "schema": "public", + "table": "account", + "entityType": "pks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "account_id", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "provider_id", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id_token", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "access_token_expires_at", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "refresh_token_expires_at", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scope", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "password", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "schema": "public", + "table": "account", + "entityType": "columns" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "account_userId_idx", + "schema": "public", + "table": "account", + "entityType": "indexes" + }, + { + "nameExplicit": false, + "columns": ["user_id"], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "account_user_id_user_id_fk", + "schema": "public", + "table": "account", + "entityType": "fks" + }, + { + "isRlsEnabled": false, + "name": "device_code", + "schema": "public", + "entityType": "tables" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "device_code_pkey", + "schema": "public", + "table": "device_code", + "entityType": "pks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "schema": "public", + "table": "device_code", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_code", + "schema": "public", + "table": "device_code", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_code", + "schema": "public", + "table": "device_code", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "schema": "public", + "table": "device_code", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "schema": "public", + "table": "device_code", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "status", + "schema": "public", + "table": "device_code", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_polled_at", + "schema": "public", + "table": "device_code", + "entityType": "columns" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "polling_interval", + "schema": "public", + "table": "device_code", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "client_id", + "schema": "public", + "table": "device_code", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "scope", + "schema": "public", + "table": "device_code", + "entityType": "columns" + }, + { + "isRlsEnabled": false, + "name": "session", + "schema": "public", + "entityType": "tables" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pkey", + "schema": "public", + "table": "session", + "entityType": "pks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ip_address", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_agent", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "schema": "public", + "table": "session", + "entityType": "columns" + }, + { + "nameExplicit": false, + "columns": ["token"], + "nullsNotDistinct": false, + "name": "session_token_unique", + "schema": "public", + "table": "session", + "entityType": "uniques" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "user_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "session_userId_idx", + "schema": "public", + "table": "session", + "entityType": "indexes" + }, + { + "nameExplicit": false, + "columns": ["user_id"], + "schemaTo": "public", + "tableTo": "user", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "session_user_id_user_id_fk", + "schema": "public", + "table": "session", + "entityType": "fks" + }, + { + "isRlsEnabled": false, + "name": "user", + "schema": "public", + "entityType": "tables" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "user_pkey", + "schema": "public", + "table": "user", + "entityType": "pks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "schema": "public", + "table": "user", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "schema": "public", + "table": "user", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "schema": "public", + "table": "user", + "entityType": "columns" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "email_verified", + "schema": "public", + "table": "user", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "image", + "schema": "public", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "schema": "public", + "table": "user", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "schema": "public", + "table": "user", + "entityType": "columns" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "user_email_unique", + "schema": "public", + "table": "user", + "entityType": "uniques" + }, + { + "isRlsEnabled": false, + "name": "verification", + "schema": "public", + "entityType": "tables" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "verification_pkey", + "schema": "public", + "table": "verification", + "entityType": "pks" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "schema": "public", + "table": "verification", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "identifier", + "schema": "public", + "table": "verification", + "entityType": "columns" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "value", + "schema": "public", + "table": "verification", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "schema": "public", + "table": "verification", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "created_at", + "schema": "public", + "table": "verification", + "entityType": "columns" + }, + { + "type": "timestamp", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "now()", + "generated": null, + "identity": null, + "name": "updated_at", + "schema": "public", + "table": "verification", + "entityType": "columns" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "identifier", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "verification_identifier_idx", + "schema": "public", + "table": "verification", + "entityType": "indexes" + } + ], + "renames": [] +} diff --git a/apps/server/src/db/migrations/meta/0000_snapshot.json b/apps/server/src/db/migrations/meta/0000_snapshot.json deleted file mode 100644 index 55aba88e..00000000 --- a/apps/server/src/db/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,362 +0,0 @@ -{ - "id": "9574a1fb-26d2-497c-8ad4-05a1c9f1c1b1", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.account": { - "name": "account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "account_userId_idx": { - "name": "account_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "session_userId_idx": { - "name": "session_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": ["token"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": ["email"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "verification_identifier_idx": { - "name": "verification_identifier_idx", - "columns": [ - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/apps/server/src/db/migrations/meta/0001_snapshot.json b/apps/server/src/db/migrations/meta/0001_snapshot.json deleted file mode 100644 index 11eb5c68..00000000 --- a/apps/server/src/db/migrations/meta/0001_snapshot.json +++ /dev/null @@ -1,447 +0,0 @@ -{ - "id": "267b3224-556c-4bf1-97f7-0b45a1a38fa4", - "prevId": "9574a1fb-26d2-497c-8ad4-05a1c9f1c1b1", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.account": { - "name": "account", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "account_id": { - "name": "account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "access_token": { - "name": "access_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "id_token": { - "name": "id_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "access_token_expires_at": { - "name": "access_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "refresh_token_expires_at": { - "name": "refresh_token_expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "account_userId_idx": { - "name": "account_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "account_user_id_user_id_fk": { - "name": "account_user_id_user_id_fk", - "tableFrom": "account", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.device_code": { - "name": "device_code", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "device_code": { - "name": "device_code", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_code": { - "name": "user_code", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "last_polled_at": { - "name": "last_polled_at", - "type": "timestamp", - "primaryKey": false, - "notNull": false - }, - "polling_interval": { - "name": "polling_interval", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "client_id": { - "name": "client_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.session": { - "name": "session", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "ip_address": { - "name": "ip_address", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_agent": { - "name": "user_agent", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "session_userId_idx": { - "name": "session_userId_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "session_token_unique": { - "name": "session_token_unique", - "nullsNotDistinct": false, - "columns": [ - "token" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.user": { - "name": "user", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "image": { - "name": "image", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "user_email_unique": { - "name": "user_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.verification": { - "name": "verification", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "verification_identifier_idx": { - "name": "verification_identifier_idx", - "columns": [ - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/_journal.json b/apps/server/src/db/migrations/meta/_journal.json deleted file mode 100644 index 2ed8f302..00000000 --- a/apps/server/src/db/migrations/meta/_journal.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1782320960545, - "tag": "0000_db_init", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1782628137712, - "tag": "0001_device_authorization", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/apps/server/src/db/models/auth.ts b/apps/server/src/db/models/auth.ts index 8313adc5..ece963bc 100644 --- a/apps/server/src/db/models/auth.ts +++ b/apps/server/src/db/models/auth.ts @@ -1,4 +1,4 @@ -import { relations } from "drizzle-orm"; +import { relations } from "drizzle-orm/_relations"; import { boolean, index, diff --git a/apps/server/src/handlers/signaling.ts b/apps/server/src/handlers/signaling.ts index 324a447d..4a703343 100644 --- a/apps/server/src/handlers/signaling.ts +++ b/apps/server/src/handlers/signaling.ts @@ -46,11 +46,8 @@ export function broadcastSignalingEvent( event: ServerEvent, excludeIds: string[] = [] ): void { - for (const conn of connections) { - if (!excludeIds.includes(conn.id)) { - pushEvent(conn, event); - } - } + for (const conn of connections) + if (!excludeIds.includes(conn.id)) pushEvent(conn, event); } const router = { @@ -58,9 +55,9 @@ const router = { const target = [...context.server.getConnections()].find( (c) => c.id === input.to ); - if (!target) { + if (!target) throw new ORPCError("NOT_FOUND", { message: "peer not connected" }); - } + pushEvent(target, { type: "offer", from: context.ws.id, @@ -72,9 +69,9 @@ const router = { const target = [...context.server.getConnections()].find( (c) => c.id === input.to ); - if (!target) { + if (!target) throw new ORPCError("NOT_FOUND", { message: "peer not connected" }); - } + pushEvent(target, { type: "answer", from: context.ws.id, @@ -86,9 +83,9 @@ const router = { const target = [...context.server.getConnections()].find( (c) => c.id === input.to ); - if (!target) { + if (!target) throw new ORPCError("NOT_FOUND", { message: "peer not connected" }); - } + pushEvent(target, { type: "ice-candidate", from: context.ws.id, diff --git a/apps/web/package.json b/apps/web/package.json index 92c050f8..6232f825 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,13 +38,11 @@ "better-result": "catalog:", "class-variance-authority": "^0.7.1", "cnfast": "^0.0.8", - "date-fns": "catalog:", "evlog": "^2.19.2", "input-otp": "^1.4.2", "lucide-react": "^1.21.0", "next-themes": "^0.4.6", "radix-ui": "^1.6.0", - "random-word-slugs": "catalog:", "react": "catalog:", "react-call": "^2.0.2", "react-day-picker": "^10.0.1", diff --git a/apps/web/src/components/chat/main/thread-workspace.tsx b/apps/web/src/components/chat/main/thread-workspace.tsx index d51957ff..1e8b644b 100644 --- a/apps/web/src/components/chat/main/thread-workspace.tsx +++ b/apps/web/src/components/chat/main/thread-workspace.tsx @@ -22,12 +22,13 @@ export function ThreadWorkspace({ const navigate = useNavigate(); const { threads, sendMessage, stopThread, createThread } = useControllerThreads(); - const { diffOpen, setDiffOpen } = useChatUiStore(); + const { diffOpen, setDiffOpen, streamingThreadIds } = useChatUiStore(); const baseThread = threads.find((item) => item.id === threadId) ?? null; const conversation = useThreadConversation(baseThread ? threadId : undefined); const thread = baseThread ? { ...baseThread, ...conversation } : null; - const busy = thread?.status === "running"; + const busy = + Boolean(streamingThreadIds[threadId]) || thread?.status === "running"; useEffect(() => { if (thread && thread.projectId !== projectId) { diff --git a/apps/web/src/constants/storage-keys.ts b/apps/web/src/constants/storage-keys.ts index 1c17ea95..0b19223e 100644 --- a/apps/web/src/constants/storage-keys.ts +++ b/apps/web/src/constants/storage-keys.ts @@ -1,4 +1,3 @@ export const PROJECT_ORDER = "cyrus:project-order"; export const SIDEBAR_WIDTH = "cyrus:sidebar-width"; -export const CONTROLLER_ID = "cyrus:controller-id"; -export const CONTROLLER_NAME = "cyrus:controller-name"; +export const CONTROLLER_IDENTITY = "cyrus:controller-identity"; diff --git a/apps/web/src/hooks/use-controller-threads.ts b/apps/web/src/hooks/use-controller-threads.ts index bbec0ac8..5d1b3ccc 100644 --- a/apps/web/src/hooks/use-controller-threads.ts +++ b/apps/web/src/hooks/use-controller-threads.ts @@ -5,6 +5,7 @@ import { useProjects } from "@/hooks/projects/use-projects"; import { useThreads } from "@/hooks/threads/use-threads"; import type { ControllerConnection } from "@/lib/orpc"; import { useAgentCatalogStore } from "@/stores/agent-catalog"; +import { useChatUiStore } from "@/stores/chat-ui"; import { appendChunkToCache } from "@/utils/conversation-cache"; // plain hook — safe to call from as many components as need it. @@ -67,16 +68,22 @@ export function useControllerThreads() { const thread = threads.find((item) => item.id === threadId); if (!thread) throw new Error(`thread not found: ${threadId}`); - const iterator = await workerConnection.client.chat({ - agentName: resolveAgentName(threadId), - message: text, - projectId: thread.projectId, - threadId, - }); - for await (const chunk of iterator) { - appendChunkToCache(queryClient, chunk); + const { setThreadStreaming } = useChatUiStore.getState(); + setThreadStreaming(threadId, true); + try { + const iterator = await workerConnection.client.chat({ + agentName: resolveAgentName(threadId), + message: text, + projectId: thread.projectId, + threadId, + }); + for await (const chunk of iterator) + appendChunkToCache(queryClient, chunk); + + invalidateThreads(thread.projectId); + } finally { + setThreadStreaming(threadId, false); } - invalidateThreads(thread.projectId); } async function stopThread(threadId: string) { diff --git a/apps/web/src/lib/orpc.ts b/apps/web/src/lib/orpc.ts index a84e2404..e49859d4 100644 --- a/apps/web/src/lib/orpc.ts +++ b/apps/web/src/lib/orpc.ts @@ -4,7 +4,7 @@ import type { RtcConnection } from "@cyrus/connections/rtc/dial"; import type { SignalingSession } from "@cyrus/connections/rtc/session"; import { connectSignaling } from "@cyrus/connections/rtc/session"; import { createTanstackQueryUtils } from "@orpc/tanstack-query"; -import { getControllerId, getControllerName } from "@/utils/identity"; +import { getControllerId, getControllerName } from "@/stores/identity"; import { authClient } from "./auth"; import { env } from "./env"; diff --git a/apps/web/src/stores/chat-ui.ts b/apps/web/src/stores/chat-ui.ts index 30666681..17572927 100644 --- a/apps/web/src/stores/chat-ui.ts +++ b/apps/web/src/stores/chat-ui.ts @@ -4,10 +4,20 @@ type ChatUiState = { diffOpen: boolean; setDiffOpen: (open: boolean) => void; toggleDiffOpen: () => void; + streamingThreadIds: Record; + setThreadStreaming: (threadId: string, streaming: boolean) => void; }; export const useChatUiStore = create((set) => ({ diffOpen: false, setDiffOpen: (open) => set({ diffOpen: open }), toggleDiffOpen: () => set((state) => ({ diffOpen: !state.diffOpen })), + streamingThreadIds: {}, + setThreadStreaming: (threadId, streaming) => + set((state) => { + const next = { ...state.streamingThreadIds }; + if (streaming) next[threadId] = true; + else delete next[threadId]; + return { streamingThreadIds: next }; + }), })); diff --git a/apps/web/src/stores/identity.ts b/apps/web/src/stores/identity.ts new file mode 100644 index 00000000..da246947 --- /dev/null +++ b/apps/web/src/stores/identity.ts @@ -0,0 +1,43 @@ +import { generateName, randomId } from "@cyrus/utils/identity"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { CONTROLLER_IDENTITY } from "@/constants/storage-keys"; + +type IdentityState = { + id: string; + name: string; + ensureId: () => string; + ensureName: () => string; +}; + +export const useIdentityStore = create()( + persist( + (set, get) => ({ + id: "", + name: "", + ensureId: () => { + const { id } = get(); + if (id) return id; + const next = randomId(); + set({ id: next }); + return next; + }, + ensureName: () => { + const { name } = get(); + if (name) return name; + const next = generateName(); + set({ name: next }); + return next; + }, + }), + { name: CONTROLLER_IDENTITY } + ) +); + +export function getControllerId(): string { + return useIdentityStore.getState().ensureId(); +} + +export function getControllerName(): string { + return useIdentityStore.getState().ensureName(); +} diff --git a/apps/web/src/utils/conversation-cache.ts b/apps/web/src/utils/conversation-cache.ts index ef630846..1e51abef 100644 --- a/apps/web/src/utils/conversation-cache.ts +++ b/apps/web/src/utils/conversation-cache.ts @@ -19,6 +19,7 @@ export function appendChunkToCache(queryClient: QueryClient, chunk: ChatChunk) { chunk, createdAt: new Date().toISOString(), id: `local-${chunk.turnId}-${++syntheticEntrySeq}`, + seq: chunk.seq, threadId: chunk.threadId, }, ], diff --git a/apps/web/src/utils/identity.ts b/apps/web/src/utils/identity.ts deleted file mode 100644 index 24e07a16..00000000 --- a/apps/web/src/utils/identity.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { generateSlug } from "random-word-slugs"; -import { CONTROLLER_ID, CONTROLLER_NAME } from "@/constants/storage-keys"; - -function generateId(): string { - return crypto.randomUUID(); -} - -function generateName(): string { - return generateSlug(2, { partsOfSpeech: ["adjective", "noun"] }); -} - -function getOrCreate(key: string, create: () => string): string { - const existing = localStorage.getItem(key); - if (existing) { - return existing; - } - const value = create(); - localStorage.setItem(key, value); - return value; -} - -export function getControllerId(): string { - return getOrCreate(CONTROLLER_ID, generateId); -} - -export function getControllerName(): string { - return getOrCreate(CONTROLLER_NAME, generateName); -} diff --git a/bun.lock b/bun.lock index 063607da..7a87a7d8 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@biomejs/biome": "^2.4.15", "@cyrus/typescript": "workspace:*", "lefthook": "^2.1.9", - "turbo": "^2.10.0", + "turbo": "^2.10.4", "typescript": "catalog:", "ultracite": "^7.8.1", }, @@ -24,13 +24,15 @@ "@agentclientprotocol/sdk": "^1.1.0", "@commander-js/extra-typings": "^15.0.0", "@cyrus/connections": "workspace:*", + "@cyrus/database": "workspace:*", + "@cyrus/utils": "workspace:*", "@orpc/server": "catalog:", "@t3-oss/env-core": "catalog:", + "@tursodatabase/database": "^0.6.1", "better-auth": "catalog:", "better-result": "catalog:", "commander": "^15.0.0", "diff": "^7.0.0", - "random-word-slugs": "catalog:", "zod": "catalog:", }, "devDependencies": { @@ -99,7 +101,7 @@ "@cyrus/typescript": "workspace:*", "@types/bun": "catalog:", "@types/pg": "^8.16.0", - "drizzle-kit": "^0.31.8", + "drizzle-kit": "catalog:", "typescript": "catalog:", }, }, @@ -135,13 +137,11 @@ "better-result": "catalog:", "class-variance-authority": "^0.7.1", "cnfast": "^0.0.8", - "date-fns": "catalog:", "evlog": "^2.19.2", "input-otp": "^1.4.2", "lucide-react": "^1.21.0", "next-themes": "^0.4.6", "radix-ui": "^1.6.0", - "random-word-slugs": "catalog:", "react": "catalog:", "react-call": "^2.0.2", "react-day-picker": "^10.0.1", @@ -188,6 +188,23 @@ "@cyrus/typescript": "workspace:*", }, }, + "shared/database": { + "name": "@cyrus/database", + "version": "0.0.0", + "dependencies": { + "@cyrus/connections": "workspace:*", + "@cyrus/utils": "workspace:*", + "@tursodatabase/database-common": "^0.6.1", + "better-result": "catalog:", + "drizzle-kit": "catalog:", + "drizzle-orm": "catalog:", + }, + "devDependencies": { + "@cyrus/typescript": "workspace:*", + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "shared/hooks": { "name": "@cyrus/hooks", "version": "0.0.0", @@ -207,7 +224,8 @@ "name": "@cyrus/utils", "version": "0.0.0", "dependencies": { - "date-fns": "catalog:", + "date-fns": "^4.4.0", + "random-word-slugs": "^0.1.7", }, "devDependencies": { "@cyrus/typescript": "workspace:*", @@ -239,11 +257,10 @@ "better-auth": "1.6.21", "better-call": "^1.3.7", "better-result": "^2.9.2", - "date-fns": "^4.4.0", - "drizzle-orm": "^0.45.1", + "drizzle-kit": "1.0.0-rc.4", + "drizzle-orm": "1.0.0-rc.4", "evlog": "^2.18.1", "nanostores": "^1.3.0", - "random-word-slugs": "^0.1.7", "react": "19.2.3", "react-dom": "19.2.3", "typescript": "^6", @@ -360,6 +377,8 @@ "@cyrus/connections": ["@cyrus/connections@workspace:shared/connections"], + "@cyrus/database": ["@cyrus/database@workspace:shared/database"], + "@cyrus/desktop": ["@cyrus/desktop@workspace:apps/desktop"], "@cyrus/github": ["@cyrus/github@workspace:tooling/github"], @@ -396,7 +415,7 @@ "@dotenvx/primitives": ["@dotenvx/primitives@0.8.0", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="], - "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="], "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], @@ -494,6 +513,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="], + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], "@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@1.1.1", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-RTBGWL5FWQcg9orDOCcp4LvItNzUPcyEU9bwaeJX0rJ1IQxzucC48Y0/sQLp/g6t99IQgAlGIaesJS+gTn7tVQ=="], @@ -1002,17 +1023,29 @@ "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], - "@turbo/darwin-64": ["@turbo/darwin-64@2.10.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EwvHThXzpY0KGd1/NAmuewI5D+aVa3Rl/OlxE36yfjUKb/+ySrfJrSlEFt8aD1OXwnnaHnQnPKHFndor0Zxlsg=="], + "@turbo/darwin-64": ["@turbo/darwin-64@2.10.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-m1MUEI4MJ69r5CwfMYxmHi0H0rrgiYCBOp0tgBZ9x/YVvOb5uu/lRIDyDwdtH054R2yWeQaIigUGu6aCX9f8cA=="], + + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VQ1Yxs5zkPT+2z7t1P4mvn6JmcKLkOCAsPuK9XbOvuVj0DlTlETfIXNisX0771v/vTWHOQqiwoGi+TtAUq8efw=="], + + "@turbo/linux-64": ["@turbo/linux-64@2.10.4", "", { "os": "linux", "cpu": "x64" }, "sha512-IzV1QovmwX7mfGnVinmE++2IB8tbeo38weltiuH5zNqwCTBjLs/DytyRKx+bmnhHdXIq9SheR8p0Nip/LBUPHg=="], + + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-rfujSQkP5aYiRn0PgTM7F00WkJCP/bKDVZbOx3WmrZwa/vHA0bplhCl328kpX7VI9HH2vI90ISGwuSVgJgoqTw=="], + + "@turbo/windows-64": ["@turbo/windows-64@2.10.4", "", { "os": "win32", "cpu": "x64" }, "sha512-NnspP7Wd5fa3Wwnqv9bKfhegqZzuHBgbPxdZU/idTLQcazx/vgKu95JlCx2YHY0hdvKCnPcARrDwM+KEUmaO7A=="], + + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Iv02YgOpaEShc2OkG7mgCJ2pEw1RUKiKbs0h8W5wAf4jZ5vpmraTEjuGTgHRuOORQnC1GN3KHo5WB+hu1abRMA=="], - "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.10.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9d2fTyyG0lf5Wq1bwJA9qUaeecViMkLcdctWaMMmCkxZ/JqypmqOwK3W6vmejeKVgkr06gSoiX8bD+xN5Jpxcg=="], + "@tursodatabase/database": ["@tursodatabase/database@0.6.1", "", { "dependencies": { "@tursodatabase/database-common": "^0.6.1" }, "optionalDependencies": { "@tursodatabase/database-darwin-arm64": "0.6.1", "@tursodatabase/database-linux-arm64-gnu": "0.6.1", "@tursodatabase/database-linux-x64-gnu": "0.6.1", "@tursodatabase/database-win32-x64-msvc": "0.6.1" } }, "sha512-qg6LO1XHA9kc2s97crg0/nHiKFk3xTzylRuZPYggO0vA4hx2rp8fk0Je2vFGOkfVv+ZmzsXOIb+DBHmdrG+72A=="], - "@turbo/linux-64": ["@turbo/linux-64@2.10.0", "", { "os": "linux", "cpu": "x64" }, "sha512-sZBtjMuufitanjzi6UssoUpJMnnPlLMcdcJj3m3ptNsSq31Xh7MnjhwA5nWvLDTfEFg8GPcbYFXMo8vSdKRfqQ=="], + "@tursodatabase/database-common": ["@tursodatabase/database-common@0.6.1", "", {}, "sha512-fkmkhgSUDg3MXkcziTG9Ime3AY5+YvvDN7VvUDEMNV7MP+XgwE/i5tU6eQP1EKa/zDkdSYPq8gPcmROmKmKL8Q=="], - "@turbo/linux-arm64": ["@turbo/linux-arm64@2.10.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vkq/Z8R+1DQ+kifWFa810IjRy2NNBVvha3cg9sWA3nFh6nnGrHSMnnJKrzH7c/No9kq4Jb55Ru44YKsCSBgrKg=="], + "@tursodatabase/database-darwin-arm64": ["@tursodatabase/database-darwin-arm64@0.6.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2/nlqulI/kWlrh7vHMwF5fkuavGRWB83SIhzHZmoHSQovq0a28Z4+WAHerjh9yNs7DTj70U9PIsvAtRdh77l7g=="], - "@turbo/windows-64": ["@turbo/windows-64@2.10.0", "", { "os": "win32", "cpu": "x64" }, "sha512-CRUEguLWxFQHptYZS7HjPhNhAFawfea07iR+xAQ5e4klgLrPCMdexBkXwSCwOxqTFknJ7RZFN3gOaADsw+Gttg=="], + "@tursodatabase/database-linux-arm64-gnu": ["@tursodatabase/database-linux-arm64-gnu@0.6.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-ixooF2xyxV84GYIYPLI5D9VEp+cX3WPBmXRyoCGw7+l9G66eOp8QppGF0dKFYhtp/AJ8YmOp+lemqBoCue9N8Q=="], - "@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-dVHGaf9F8twzgibcBqKoADT/LLqf9++jDb+hq/LPWWaOmRpp4M+/pVOm7vy4z9D++xg8eaxWLT0+wQxFwhYu9A=="], + "@tursodatabase/database-linux-x64-gnu": ["@tursodatabase/database-linux-x64-gnu@0.6.1", "", { "os": "linux", "cpu": "x64" }, "sha512-cNZ3FS8XBn4X8zHSzd31nvEeBbR4hCDBGs2P3ViND1Egu5oDZ822a+hWxNxMgMPgUJf/aGBEAk5p+j11fAC/QQ=="], + + "@tursodatabase/database-win32-x64-msvc": ["@tursodatabase/database-win32-x64-msvc@0.6.1", "", { "os": "win32", "cpu": "x64" }, "sha512-3CASiW3BbeNT4qryxPKKZECGxaAtfUeM/Bjx1NWIxShCLLtRpnaEUt7WLOT2hz+sqU322H6fmMuaN9sYrcwBxw=="], "@tweenjs/tween.js": ["@tweenjs/tween.js@23.1.3", "", {}, "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA=="], @@ -1294,9 +1327,9 @@ "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], - "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + "drizzle-kit": ["drizzle-kit@1.0.0-rc.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.12.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-KZCpjRyu+oYHLj/UJogfFlOkWhHVkaEI2EOT1U3NDVXUzLoTyPjqwFxwOrlQxsY6jzRyxcz4EacqboGfhEeYrA=="], - "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "drizzle-orm": ["drizzle-orm@1.0.0-rc.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-d1": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-libsql": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-mysql2": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-pg": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-pglite": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-bun": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-do": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-node": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-wasm": ">=4.0.0-beta.83 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.6.0-pre.28 || >=0.6.0", "@tursodatabase/database-common": ">=0.6.0-pre.28 || >=0.6.0", "@tursodatabase/database-wasm": ">=0.6.0-pre.28 || >=0.6.0", "@tursodatabase/serverless": ">=1.1.3", "@tursodatabase/sync": ">=0.6.0-pre.28 || >=0.6.0", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.83 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-d1", "@effect/sql-libsql", "@effect/sql-mysql2", "@effect/sql-pg", "@effect/sql-pglite", "@effect/sql-sqlite-bun", "@effect/sql-sqlite-do", "@effect/sql-sqlite-node", "@effect/sql-sqlite-wasm", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@tursodatabase/serverless", "@tursodatabase/sync", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-BT+pf+qoiYHqltoA88Jmf6ilGMXPlpfE0hEJKc2adRtMCAl25Swk/t5gXcWxZNAwdtf3F5gCd2FpeOyP/pT0Hw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -1558,6 +1591,8 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], + "jsc-safe-url": ["jsc-safe-url@0.2.4", "", {}, "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -2200,7 +2235,7 @@ "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], - "turbo": ["turbo@2.10.0", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.0", "@turbo/darwin-arm64": "2.10.0", "@turbo/linux-64": "2.10.0", "@turbo/linux-arm64": "2.10.0", "@turbo/windows-64": "2.10.0", "@turbo/windows-arm64": "2.10.0" }, "bin": { "turbo": "bin/turbo" } }, "sha512-o016H9PPtuH2deb3mh3Vci3Avfi9UYgM/RONQisY7HnloupP0IFSbFS3gFYJgFJP8nwBrByHWFQIDa8T2zIXPw=="], + "turbo": ["turbo@2.10.4", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.4", "@turbo/darwin-arm64": "2.10.4", "@turbo/linux-64": "2.10.4", "@turbo/linux-arm64": "2.10.4", "@turbo/windows-64": "2.10.4", "@turbo/windows-arm64": "2.10.4" }, "bin": { "turbo": "bin/turbo" } }, "sha512-GQpduILaKjoaGljw097ScsSyKTtZSY7cZ3bJktzfTkPMyCf3ShKLuXK2IaOEN2Plziml+ArR7WJ1m+V4VbnaKQ=="], "type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], @@ -2316,6 +2351,8 @@ "@better-auth/api-key/better-call": ["better-call@1.3.6", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-no1jI+h6Bkxs1NVBo4rONbVIzsPjZ8IUu7IHaJBiFwVX1XEQGN8KpHots5fSWmXe9nNyLuLIcgx6WEUcE6EDaA=="], + "@better-auth/drizzle-adapter/drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "@better-auth/passkey/better-call": ["better-call@1.3.6", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-no1jI+h6Bkxs1NVBo4rONbVIzsPjZ8IUu7IHaJBiFwVX1XEQGN8KpHots5fSWmXe9nNyLuLIcgx6WEUcE6EDaA=="], "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], @@ -2374,6 +2411,10 @@ "abort-controller/event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "better-auth/drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "better-auth/drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "body-parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -2550,6 +2591,8 @@ "@tokenizer/inflate/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "better-auth/drizzle-kit/@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "body-parser/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "connect/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], diff --git a/openspec/changes/replace-cli-store-with-turso/.openspec.yaml b/openspec/changes/archive/2026-07-08-replace-cli-store-with-turso/.openspec.yaml similarity index 100% rename from openspec/changes/replace-cli-store-with-turso/.openspec.yaml rename to openspec/changes/archive/2026-07-08-replace-cli-store-with-turso/.openspec.yaml diff --git a/openspec/changes/replace-cli-store-with-turso/design.md b/openspec/changes/archive/2026-07-08-replace-cli-store-with-turso/design.md similarity index 100% rename from openspec/changes/replace-cli-store-with-turso/design.md rename to openspec/changes/archive/2026-07-08-replace-cli-store-with-turso/design.md diff --git a/openspec/changes/replace-cli-store-with-turso/proposal.md b/openspec/changes/archive/2026-07-08-replace-cli-store-with-turso/proposal.md similarity index 100% rename from openspec/changes/replace-cli-store-with-turso/proposal.md rename to openspec/changes/archive/2026-07-08-replace-cli-store-with-turso/proposal.md diff --git a/openspec/changes/replace-cli-store-with-turso/specs/acp-session-router/spec.md b/openspec/changes/archive/2026-07-08-replace-cli-store-with-turso/specs/acp-session-router/spec.md similarity index 100% rename from openspec/changes/replace-cli-store-with-turso/specs/acp-session-router/spec.md rename to openspec/changes/archive/2026-07-08-replace-cli-store-with-turso/specs/acp-session-router/spec.md diff --git a/openspec/changes/replace-cli-store-with-turso/specs/conversation-persistence/spec.md b/openspec/changes/archive/2026-07-08-replace-cli-store-with-turso/specs/conversation-persistence/spec.md similarity index 100% rename from openspec/changes/replace-cli-store-with-turso/specs/conversation-persistence/spec.md rename to openspec/changes/archive/2026-07-08-replace-cli-store-with-turso/specs/conversation-persistence/spec.md diff --git a/openspec/changes/replace-cli-store-with-turso/tasks.md b/openspec/changes/archive/2026-07-08-replace-cli-store-with-turso/tasks.md similarity index 58% rename from openspec/changes/replace-cli-store-with-turso/tasks.md rename to openspec/changes/archive/2026-07-08-replace-cli-store-with-turso/tasks.md index 0cb08828..9934c3f3 100644 --- a/openspec/changes/replace-cli-store-with-turso/tasks.md +++ b/openspec/changes/archive/2026-07-08-replace-cli-store-with-turso/tasks.md @@ -1,42 +1,42 @@ ## 1. Turso setup -- [ ] 1.1 Add `@tursodatabase/database` dependency (beta) to `apps/cli/package.json` -- [ ] 1.2 Resolve the DB file path under `CYRUS_HOME` (`apps/cli/src/lib/env.ts`) and open a local-file Turso connection -- [ ] 1.3 Write `CREATE TABLE IF NOT EXISTS` schema for `projects`, `threads`, and `conversation_entries` (with `conversation_entries.seq` as `INTEGER PRIMARY KEY AUTOINCREMENT`, `chunk` as a JSON text column), run on connection init +- [x] 1.1 Add `@tursodatabase/database` dependency (beta) to `apps/cli/package.json` +- [x] 1.2 Resolve the DB file path under `CYRUS_HOME` (`apps/cli/src/lib/env.ts`) and open a local-file Turso connection +- [x] 1.3 Write `CREATE TABLE IF NOT EXISTS` schema for `projects`, `threads`, and `conversation_entries` (with `conversation_entries.seq` as `INTEGER PRIMARY KEY AUTOINCREMENT`, `chunk` as a JSON text column), run on connection init ## 2. Project and thread store rewrite -- [ ] 2.1 Rewrite `apps/cli/src/store/projects.ts` (`listProjects`, `getProject`, `createProject`, `renameProject`, `deleteProject`, `resolveProjectCwd`) against Turso, keeping existing signatures -- [ ] 2.2 Rewrite `apps/cli/src/store/threads.ts`'s thread functions (`getThread`, `listThreads`, `renameThread`, `deleteThread`, `deleteThreadsForProject`, `ensureThread`, `createThread`) against Turso, keeping existing signatures -- [ ] 2.3 Remove the `// TODO: Delete this and store in turso` comments once replaced +- [x] 2.1 Rewrite `apps/cli/src/store/projects.ts` (`listProjects`, `getProject`, `createProject`, `renameProject`, `deleteProject`, `resolveProjectCwd`) against Turso, keeping existing signatures +- [x] 2.2 Rewrite `apps/cli/src/store/threads.ts`'s thread functions (`getThread`, `listThreads`, `renameThread`, `deleteThread`, `deleteThreadsForProject`, `ensureThread`, `createThread`) against Turso, keeping existing signatures +- [x] 2.3 Remove the `// TODO: Delete this and store in turso` comments once replaced ## 3. Conversation entry persistence with sequence and cursor -- [ ] 3.1 Add `seq: z.number()` to `ConversationEntrySchema` in `shared/connections/src/schemas/rtc/threads.ts` -- [ ] 3.2 Add `seq: z.number()` to `ChatChunkSchema` in `shared/connections/src/schemas/rtc/chat.ts` -- [ ] 3.3 Rewrite `appendConversation(threadId, chunk)` in `apps/cli/src/store/threads.ts` to `INSERT ... RETURNING seq` into `conversation_entries`, returning the persisted entry (including `seq`) -- [ ] 3.4 Add an optional `afterSeq` field to `ThreadQueryInputSchema` (or a dedicated schema) in `shared/connections/src/schemas/rtc/threads.ts` -- [ ] 3.5 Rewrite `getConversations(threadId, afterSeq?)` in `apps/cli/src/store/threads.ts` to query Turso, filtering by `seq > afterSeq` when provided, ordered by `seq` -- [ ] 3.6 Update the `getConversations` handler in `apps/cli/src/handlers/controller/threads.ts` to pass through `afterSeq` +- [x] 3.1 Add `seq: z.number()` to `ConversationEntrySchema` in `shared/connections/src/schemas/rtc/threads.ts` +- [x] 3.2 Add `seq: z.number()` to `ChatChunkSchema` in `shared/connections/src/schemas/rtc/chat.ts` +- [x] 3.3 Rewrite `appendConversation(threadId, chunk)` in `apps/cli/src/store/threads.ts` to `INSERT ... RETURNING seq` into `conversation_entries`, returning the persisted entry (including `seq`) +- [x] 3.4 Add an optional `afterSeq` field to `ThreadQueryInputSchema` (or a dedicated schema) in `shared/connections/src/schemas/rtc/threads.ts` +- [x] 3.5 Rewrite `getConversations(threadId, afterSeq?)` in `apps/cli/src/store/threads.ts` to query Turso, filtering by `seq > afterSeq` when provided, ordered by `seq` +- [x] 3.6 Update the `getConversations` handler in `apps/cli/src/handlers/controller/threads.ts` to pass through `afterSeq` ## 4. Persist-before-broadcast ordering -- [ ] 4.1 In `apps/cli/src/handlers/controller/chat.ts`'s `emit()`, call `appendConversation` before `context.broadcaster.broadcast()` -- [ ] 4.2 Attach the `seq` returned from the persisted write onto the `ChatChunk` before broadcasting it +- [x] 4.1 In `apps/cli/src/handlers/controller/chat.ts`'s `emit()`, call `appendConversation` before `context.broadcaster.broadcast()` +- [x] 4.2 Attach the `seq` returned from the persisted write onto the `ChatChunk` before broadcasting it ## 5. Coalesced message persistence -- [ ] 5.1 Add a `message.completed` case in `apps/cli/src/core/acp/events.ts` mapping to a new first-class `AgentEvent` variant (e.g. `message_completed`) carrying the full accumulated text, instead of falling through to `session_update` -- [ ] 5.2 Add the equivalent `reasoning.completed` case for thought/reasoning messages -- [ ] 5.3 Add the new event variant(s) to `AgentEventSchema` in `shared/connections/src/schemas/rtc/chat.ts` -- [ ] 5.4 In the chat turn's write path (`apps/cli/src/handlers/controller/chat.ts` and/or `apps/cli/src/core/agents/runtime.ts`), buffer `token`/`thought` deltas per `messageId` for the duration of the turn -- [ ] 5.5 On each delta: broadcast immediately as today, but skip the Turso write -- [ ] 5.6 On `message_completed`/`reasoning_completed`: persist exactly one `ConversationEntry` with the full buffered text, then broadcast that completion event -- [ ] 5.7 Confirm tool call, tool call update, and plan events are unaffected and continue to persist immediately +- [x] 5.1 Add a `message.completed` case in `apps/cli/src/core/acp/events.ts` mapping to a new first-class `AgentEvent` variant (e.g. `message_completed`) carrying the full accumulated text, instead of falling through to `session_update` +- [x] 5.2 Add the equivalent `reasoning.completed` case for thought/reasoning messages +- [x] 5.3 Add the new event variant(s) to `AgentEventSchema` in `shared/connections/src/schemas/rtc/chat.ts` +- [x] 5.4 In the chat turn's write path (`apps/cli/src/handlers/controller/chat.ts` and/or `apps/cli/src/core/agents/runtime.ts`), buffer `token`/`thought` deltas per `messageId` for the duration of the turn +- [x] 5.5 On each delta: broadcast immediately as today, but skip the Turso write +- [x] 5.6 On `message_completed`/`reasoning_completed`: persist exactly one `ConversationEntry` with the full buffered text, then broadcast that completion event +- [x] 5.7 Confirm tool call, tool call update, and plan events are unaffected and continue to persist immediately ## 6. Verification -- [ ] 6.1 Manual test: send a multi-turn chat through `apps/cli`, restart the worker process, confirm `getConversations` still returns full prior history -- [ ] 6.2 Manual test: confirm exactly one persisted `ConversationEntry` per streamed assistant message, not one per delta (inspect the Turso DB file directly) -- [ ] 6.3 Manual test: call `getConversations` with `afterSeq` set to a mid-conversation value and confirm only later entries are returned -- [ ] 6.4 Run `bun check:types` and `bun check` across `apps/cli` and `shared/connections` +- [x] 6.1 Manual test: send a multi-turn chat through `apps/cli`, restart the worker process, confirm `getConversations` still returns full prior history +- [x] 6.2 Manual test: confirm exactly one persisted `ConversationEntry` per streamed assistant message, not one per delta (inspect the Turso DB file directly) +- [x] 6.3 Manual test: call `getConversations` with `afterSeq` set to a mid-conversation value and confirm only later entries are returned +- [x] 6.4 Run `bun check:types` and `bun check` across `apps/cli` and `shared/connections` diff --git a/openspec/specs/acp-session-router/spec.md b/openspec/specs/acp-session-router/spec.md index e7405b63..e158d9f9 100644 --- a/openspec/specs/acp-session-router/spec.md +++ b/openspec/specs/acp-session-router/spec.md @@ -45,13 +45,18 @@ The worker SHALL attempt to recover sessions after an agent subprocess respawn, ### Requirement: ACP event mapping -The worker SHALL map ACP `session/update` notifications to Cyrus `AgentEvent` values for streaming to connected UI peers. +The worker SHALL map ACP `session/update` notifications to Cyrus `AgentEvent` values for streaming to connected UI peers, including a discrete completion event carrying the full accumulated text for a finished message. #### Scenario: Agent message streamed - **WHEN** the ACP agent sends a `session/update` with `agent_message_chunk` content - **THEN** the worker emits a corresponding `AgentEvent` on the thread's event stream +#### Scenario: Agent message completed + +- **WHEN** the normalized ACP runtime reports a message as complete (`message.completed`/`reasoning.completed`), carrying the full accumulated text +- **THEN** the worker emits a discrete "message completed" `AgentEvent` containing that full text, distinct from the preceding per-fragment delta events + ### Requirement: Session cancellation The worker SHALL support cancelling an in-flight prompt via ACP `session/cancel`. diff --git a/openspec/specs/conversation-persistence/spec.md b/openspec/specs/conversation-persistence/spec.md new file mode 100644 index 00000000..2df62e4c --- /dev/null +++ b/openspec/specs/conversation-persistence/spec.md @@ -0,0 +1,86 @@ +# Conversation Persistence + +## Purpose + +Persist Cyrus projects, threads, and conversation entries locally so metadata and transcripts survive worker restarts. + +## Requirements + +### Requirement: Durable project and thread storage + +The CLI worker SHALL persist `Project` and `Thread` records to a local Turso database instead of in-memory storage, so they survive process restarts. + +#### Scenario: Restart preserves projects and threads + +- **WHEN** the CLI worker process restarts +- **THEN** previously created projects and threads are still retrievable via `listProjects`/`listThreads` + +### Requirement: Durable conversation entry storage + +The CLI worker SHALL persist each `ConversationEntry` for a thread to the Turso database, so conversation history survives process restarts and does not rely on the ACP agent's own session state. + +#### Scenario: Restart preserves conversation history + +- **WHEN** the CLI worker process restarts after a thread has received messages +- **THEN** `getConversations` for that thread returns the same entries as before the restart + +#### Scenario: ACP session/load does not substitute for persistence + +- **WHEN** an ACP agent subprocess respawns and the worker recovers the session via `session/load` +- **THEN** the worker does not rely on ACP to replay conversation content to the client — the persisted entries in Turso remain the source of truth for the client-facing transcript + +### Requirement: Local database location + +The Turso database file SHALL live under the CLI's `CYRUS_HOME` directory, consistent with other per-device local state such as `agents.yml`. + +#### Scenario: Default location + +- **WHEN** `CYRUS_HOME` is not overridden +- **THEN** the Turso database file is created under `~/.cyrus` + +### Requirement: Monotonic conversation sequence + +Each persisted `ConversationEntry` SHALL be assigned a monotonically increasing `seq` value at write time, and `seq` SHALL be included on the `ConversationEntry` and its corresponding `ChatChunk`. + +#### Scenario: Sequence increases across entries + +- **WHEN** two conversation entries are persisted in succession, for the same or different threads +- **THEN** the second entry's `seq` is strictly greater than the first's + +### Requirement: Cursor-based conversation reads + +The `getConversations` operation SHALL accept an optional `afterSeq` parameter and, when provided, SHALL return only entries for the thread with `seq` strictly greater than `afterSeq`. + +#### Scenario: Fetch without cursor + +- **WHEN** `getConversations` is called for a thread with no `afterSeq` +- **THEN** all persisted entries for that thread are returned, ordered by `seq` + +#### Scenario: Fetch with cursor + +- **WHEN** `getConversations` is called for a thread with `afterSeq` set to a previously observed `seq` +- **THEN** only entries with a strictly greater `seq` are returned + +### Requirement: Persist-before-broadcast ordering + +The worker SHALL persist a conversation entry and obtain its assigned `seq` before broadcasting the corresponding `ChatChunk` to connected peers. + +#### Scenario: Broadcast carries a durable sequence + +- **WHEN** an event is emitted during a chat turn +- **THEN** the entry is written to Turso first, and the `ChatChunk` broadcast to peers includes the `seq` returned by that write + +### Requirement: Coalesced message persistence + +The worker SHALL buffer streamed text and thought deltas for a message in memory, keyed by `messageId`, and SHALL persist exactly one `ConversationEntry` containing the full accumulated text when the message completes — not one entry per raw delta. Tool call, tool call update, and plan events SHALL continue to be persisted immediately as their own entries. + +#### Scenario: Streamed message persists once + +- **WHEN** an agent message is streamed as multiple text deltas followed by a completion event +- **THEN** exactly one `ConversationEntry` is persisted for that message, containing the fully accumulated text +- **AND** each individual delta is still broadcast live to connected peers as it arrives + +#### Scenario: Tool call persists immediately + +- **WHEN** a tool call event or tool call update event is emitted +- **THEN** it is persisted as its own `ConversationEntry` without buffering diff --git a/package.json b/package.json index 979f0f19..de005fb7 100644 --- a/package.json +++ b/package.json @@ -20,11 +20,10 @@ "better-auth": "1.6.21", "better-call": "^1.3.7", "better-result": "^2.9.2", - "date-fns": "^4.4.0", - "drizzle-orm": "^0.45.1", + "drizzle-kit": "1.0.0-rc.4", + "drizzle-orm": "1.0.0-rc.4", "evlog": "^2.18.1", "nanostores": "^1.3.0", - "random-word-slugs": "^0.1.7", "react": "19.2.3", "react-dom": "19.2.3", "typescript": "^6", @@ -43,9 +42,10 @@ "build:desktop": "turbo -F @cyrus/desktop build:stable", "build:desktop:canary": "turbo -F @cyrus/desktop build:canary", "dev:server": "turbo -F @cyrus/server dev", - "dev:cli": "turbo -F @cyrus/cli dev", + "dev:cli": "turbo -F @cyrus/cli dev --", "db:push": "turbo -F @cyrus/server db:push", "db:studio": "turbo -F @cyrus/server db:studio", + "db:file:studio": "turbo -F @cyrus/database db:studio", "db:generate": "turbo -F @cyrus/server db:generate", "db:migrate": "turbo -F @cyrus/server db:migrate", "auth:generate": "turbo -F @cyrus/server auth:generate", @@ -57,7 +57,7 @@ "@biomejs/biome": "^2.4.15", "@cyrus/typescript": "workspace:*", "lefthook": "^2.1.9", - "turbo": "^2.10.0", + "turbo": "^2.10.4", "typescript": "catalog:", "ultracite": "^7.8.1" }, diff --git a/shared/connections/src/schemas/rtc/chat.ts b/shared/connections/src/schemas/rtc/chat.ts index 273f2f57..a950f80f 100644 --- a/shared/connections/src/schemas/rtc/chat.ts +++ b/shared/connections/src/schemas/rtc/chat.ts @@ -211,6 +211,18 @@ export const SessionUpdateEventSchema = z.object({ raw: z.unknown(), }); +export const MessageCompletedEventSchema = z.object({ + type: z.literal("message_completed"), + text: z.string(), + messageId: z.string().nullish(), +}); + +export const ReasoningCompletedEventSchema = z.object({ + type: z.literal("reasoning_completed"), + text: z.string(), + messageId: z.string().nullish(), +}); + export const ThreadStartedEventSchema = z.object({ type: z.literal("thread_started"), threadId: z.string(), @@ -221,11 +233,23 @@ export const UserMessageEventSchema = z.object({ content: z.string(), }); +export const TurnCompletedEventSchema = z.object({ + type: z.literal("turn_completed"), +}); + +export const TurnInterruptedEventSchema = z.object({ + type: z.literal("turn_interrupted"), +}); + export const AgentEventSchema = z.discriminatedUnion("type", [ ThreadStartedEventSchema, UserMessageEventSchema, + TurnCompletedEventSchema, + TurnInterruptedEventSchema, TokenEventSchema, ThoughtEventSchema, + MessageCompletedEventSchema, + ReasoningCompletedEventSchema, ToolCallEventSchema, ToolCallUpdateEventSchema, PlanEventSchema, @@ -249,13 +273,14 @@ export type Diff = z.infer; export const ChatInputSchema = z.object({ agentName: z.string(), message: z.string(), - threadId: z.uuidv7().optional(), + threadId: z.uuid().optional(), projectId: z.string(), }); export const ChatChunkSchema = z.object({ threadId: z.string(), turnId: z.string(), + seq: z.number(), event: AgentEventSchema, }); diff --git a/shared/connections/src/schemas/rtc/common.ts b/shared/connections/src/schemas/rtc/common.ts index 19b76429..77d00385 100644 --- a/shared/connections/src/schemas/rtc/common.ts +++ b/shared/connections/src/schemas/rtc/common.ts @@ -1,5 +1,10 @@ import { z } from "zod"; +export const optionalString = z + .string() + .nullish() + .transform((value) => value ?? undefined); + export const VoidOutputSchema = z.object({}); export const AgentQueryInputSchema = z.object({ @@ -8,7 +13,7 @@ export const AgentQueryInputSchema = z.object({ export const AgentMutationInputSchema = z.object({ agentName: z.string(), - threadId: z.uuidv7(), + threadId: z.uuid(), projectId: z.string(), }); diff --git a/shared/connections/src/schemas/rtc/projects.ts b/shared/connections/src/schemas/rtc/projects.ts index 32368849..b8bb631b 100644 --- a/shared/connections/src/schemas/rtc/projects.ts +++ b/shared/connections/src/schemas/rtc/projects.ts @@ -1,9 +1,10 @@ import { z } from "zod"; +import { optionalString } from "./common"; export const ProjectSchema = z.object({ id: z.string(), cwd: z.string(), - name: z.string().optional(), + name: optionalString, }); export const ListProjectsOutputSchema = z.object({ diff --git a/shared/connections/src/schemas/rtc/threads.ts b/shared/connections/src/schemas/rtc/threads.ts index e1f1fe6c..c8cd68b9 100644 --- a/shared/connections/src/schemas/rtc/threads.ts +++ b/shared/connections/src/schemas/rtc/threads.ts @@ -1,11 +1,12 @@ import { z } from "zod"; import { ChatChunkSchema } from "./chat"; +import { optionalString } from "./common"; export const ThreadSchema = z.object({ id: z.string(), projectId: z.string(), name: z.string(), - agentName: z.string().optional(), + agentName: optionalString, createdAt: z.string(), updatedAt: z.string(), }); @@ -13,6 +14,7 @@ export const ThreadSchema = z.object({ export const ConversationEntrySchema = z.object({ id: z.string(), threadId: z.string(), + seq: z.number(), chunk: ChatChunkSchema, createdAt: z.string(), }); @@ -23,6 +25,7 @@ export const ProjectQueryInputSchema = z.object({ export const ThreadQueryInputSchema = z.object({ threadId: z.string(), + afterSeq: z.number().optional(), }); export const RenameThreadInputSchema = z.object({ diff --git a/shared/database/.env.example b/shared/database/.env.example new file mode 100644 index 00000000..ff65effb --- /dev/null +++ b/shared/database/.env.example @@ -0,0 +1 @@ +DATABASE_PATH= diff --git a/shared/database/drizzle.config.ts b/shared/database/drizzle.config.ts new file mode 100644 index 00000000..7dc2b308 --- /dev/null +++ b/shared/database/drizzle.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/models/index.ts", + dialect: "turso", + dbCredentials: { + url: process.env.DATABASE_PATH ?? "file:store.db", + }, +}); diff --git a/shared/database/package.json b/shared/database/package.json new file mode 100644 index 00000000..8757bca5 --- /dev/null +++ b/shared/database/package.json @@ -0,0 +1,27 @@ +{ + "name": "@cyrus/database", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "scripts": { + "check:types": "tsc --noEmit", + "db:push": "dotenvx run -- drizzle-kit push", + "db:studio": "dotenvx run -- drizzle-kit studio" + }, + "dependencies": { + "@cyrus/connections": "workspace:*", + "@cyrus/utils": "workspace:*", + "@tursodatabase/database-common": "^0.6.1", + "better-result": "catalog:", + "drizzle-kit": "catalog:", + "drizzle-orm": "catalog:" + }, + "devDependencies": { + "@cyrus/typescript": "workspace:*", + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/shared/database/src/connection.ts b/shared/database/src/connection.ts new file mode 100644 index 00000000..69915775 --- /dev/null +++ b/shared/database/src/connection.ts @@ -0,0 +1,85 @@ +import type { DatabasePromise } from "@tursodatabase/database-common"; +import { pushSchema } from "drizzle-kit/payload/sqlite"; +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/tursodatabase/database"; +import type { TursoDatabaseDatabase } from "drizzle-orm/tursodatabase/driver-core"; +import { commonModels as models } from "./models"; + +export type DrizzleDb = TursoDatabaseDatabase; + +export type DatabaseConnect = () => Promise; + +export class DatabaseConnection { + private nativeClient: DatabasePromise | null = null; + private drizzleDb: DrizzleDb | null = null; + + get client(): DatabasePromise { + if (!this.nativeClient) + throw new Error("DatabaseConnection is not initialized"); + + return this.nativeClient; + } + + get db(): DrizzleDb { + if (!this.drizzleDb) + throw new Error("DatabaseConnection is not initialized"); + + return this.drizzleDb; + } + + open( + connect: DatabaseConnect, + // NOTE: If there is any diff in models between worker and controller, this + // is where we control it via role. + role: "worker" | "controller" + ): Promise { + return this.setup(connect, models, role); + } + + async setup( + connect: DatabaseConnect, + schema: Record, + _role: "worker" | "controller" + ): Promise { + const bootstrap = await connect(); + await this.push(bootstrap, schema); + await bootstrap.close(); + + const client = await connect(); + this.nativeClient = client; + this.drizzleDb = drizzle({ client }); + await this.configure(); + return this.drizzleDb; + } + + async close(): Promise { + if (!this.nativeClient) return; + await this.nativeClient.close(); + this.nativeClient = null; + this.drizzleDb = null; + } + + private async configure(): Promise { + await this.db.run(sql`PRAGMA foreign_keys = ON`); + await this.db.run(sql`PRAGMA journal_mode = WAL`); + } + + private async push( + client: DatabasePromise, + schema: Record + ): Promise { + const result = await pushSchema(schema, { + query: async (query, params) => { + const stmt = await client.prepare(query); + return stmt.all(...(params ?? [])); + }, + run: (query) => client.exec(query), + batch: async (statements) => { + for (const statement of statements) await client.exec(statement); + }, + }); + await result.apply(); + } +} + +export const connection = new DatabaseConnection(); diff --git a/shared/database/src/models/conversations.ts b/shared/database/src/models/conversations.ts new file mode 100644 index 00000000..e6cfea5b --- /dev/null +++ b/shared/database/src/models/conversations.ts @@ -0,0 +1,18 @@ +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { threads } from "./threads"; + +export const conversations = sqliteTable( + "conversations", + { + seq: integer("seq").primaryKey({ autoIncrement: true }), + id: text("id").notNull(), + threadId: text("thread_id") + .notNull() + .references(() => threads.id, { onDelete: "cascade" }), + chunk: text("chunk").notNull(), + createdAt: text("created_at").notNull(), + }, + (table) => [ + index("idx_conversations_thread_seq").on(table.threadId, table.seq), + ] +); diff --git a/shared/database/src/models/index.ts b/shared/database/src/models/index.ts new file mode 100644 index 00000000..fd95e38f --- /dev/null +++ b/shared/database/src/models/index.ts @@ -0,0 +1,9 @@ +import { conversations } from "./conversations"; +import { projects } from "./projects"; +import { threads } from "./threads"; + +export const commonModels = { + conversations, + projects, + threads, +}; diff --git a/shared/database/src/models/projects.ts b/shared/database/src/models/projects.ts new file mode 100644 index 00000000..f0a0e190 --- /dev/null +++ b/shared/database/src/models/projects.ts @@ -0,0 +1,7 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core"; + +export const projects = sqliteTable("projects", { + id: text("id").primaryKey(), + cwd: text("cwd").notNull(), + name: text("name"), +}); diff --git a/shared/database/src/models/threads.ts b/shared/database/src/models/threads.ts new file mode 100644 index 00000000..910dcf36 --- /dev/null +++ b/shared/database/src/models/threads.ts @@ -0,0 +1,13 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { projects } from "./projects"; + +export const threads = sqliteTable("threads", { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + name: text("name").notNull(), + agentName: text("agent_name"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), +}); diff --git a/shared/database/src/repositories/conversations.ts b/shared/database/src/repositories/conversations.ts new file mode 100644 index 00000000..3964e8ce --- /dev/null +++ b/shared/database/src/repositories/conversations.ts @@ -0,0 +1,68 @@ +import type { ChatChunk } from "@cyrus/connections/schemas/rtc/chat"; +import { ConversationEntrySchema } from "@cyrus/connections/schemas/rtc/threads"; +import { randomId } from "@cyrus/utils/identity"; +import { nowISO } from "@cyrus/utils/time"; +import { and, asc, eq, gt } from "drizzle-orm"; +import { connection } from "../connection"; +import { conversations } from "../models/conversations"; +import { threads } from "../models/threads"; +import type { RepositoryError } from "../utils/error"; +import { tryRepo } from "../utils/error"; + +function parseConversationEntry(row: typeof conversations.$inferSelect) { + const { chunk, ...rest } = row; + return ConversationEntrySchema.parse({ + ...rest, + chunk: { ...JSON.parse(chunk), seq: row.seq }, + }); +} + +export function appendConversation( + threadId: string, + chunk: Omit +) { + return tryRepo(async () => { + const id = randomId(); + const createdAt = nowISO(); + const [row] = await connection.db + .insert(conversations) + .values({ + id, + threadId, + chunk: JSON.stringify(chunk), + createdAt, + }) + .returning(); + if (!row) { + throw { + type: "persist_failed", + message: `failed to persist conversation entry for thread ${threadId}`, + } satisfies RepositoryError; + } + + await connection.db + .update(threads) + .set({ updatedAt: createdAt }) + .where(eq(threads.id, threadId)); + + return parseConversationEntry(row); + }); +} + +export function getConversations(threadId: string, afterSeq?: number) { + return tryRepo(async () => { + const where = + afterSeq === undefined + ? eq(conversations.threadId, threadId) + : and( + eq(conversations.threadId, threadId), + gt(conversations.seq, afterSeq) + ); + const rows = await connection.db + .select() + .from(conversations) + .where(where) + .orderBy(asc(conversations.seq)); + return rows.map(parseConversationEntry); + }); +} diff --git a/shared/database/src/repositories/projects.ts b/shared/database/src/repositories/projects.ts new file mode 100644 index 00000000..b704e78c --- /dev/null +++ b/shared/database/src/repositories/projects.ts @@ -0,0 +1,98 @@ +import { mkdir } from "node:fs/promises"; +import type { Project } from "@cyrus/connections/schemas/rtc/projects"; +import { ProjectSchema } from "@cyrus/connections/schemas/rtc/projects"; +import { randomId } from "@cyrus/utils/identity"; +import { Result } from "better-result"; +import { asc, eq } from "drizzle-orm"; +import { connection } from "../connection"; +import { projects } from "../models/projects"; +import type { RepositoryError } from "../utils/error"; +import { notFound, tryRepo } from "../utils/error"; + +export type { Project } from "@cyrus/connections/schemas/rtc/projects"; + +export function listProjects(): Promise> { + return tryRepo(async () => { + const rows = await connection.db + .select() + .from(projects) + .orderBy(asc(projects.id)); + return rows.map((row) => ProjectSchema.parse(row)); + }); +} + +export function getProject( + projectId: string +): Promise> { + return tryRepo(async () => { + const [row] = await connection.db + .select() + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + return row ? ProjectSchema.parse(row) : undefined; + }); +} + +export async function resolveProjectCwd( + projectId: string +): Promise> { + const project = await getProject(projectId); + if (project.isErr()) return Result.err(project.error); + if (!project.value) return Result.err(notFound("project", projectId)); + + const cwd = project.value.cwd.trim(); + if (cwd) await mkdir(cwd, { recursive: true }); + + return Result.ok(cwd); +} + +export function createProject( + name: string, + cwd = "" +): Promise> { + return tryRepo(async () => { + const id = randomId(); + const resolvedCwd = cwd.trim(); + if (resolvedCwd) await mkdir(resolvedCwd, { recursive: true }); + + await connection.db.insert(projects).values({ + id, + cwd: resolvedCwd, + name, + }); + return ProjectSchema.parse({ id, cwd: resolvedCwd, name }); + }); +} + +export async function renameProject( + projectId: string, + name: string +): Promise> { + const existing = await getProject(projectId); + if (existing.isErr()) return Result.err(existing.error); + if (!existing.value) { + return Result.err(notFound("project", projectId)); + } + const current = existing.value; + + return tryRepo(async () => { + await connection.db + .update(projects) + .set({ name }) + .where(eq(projects.id, projectId)); + return ProjectSchema.parse({ ...current, name }); + }); +} + +export function deleteProject( + projectId: string +): Promise> { + return tryRepo(async () => { + const deleted = await connection.db + .delete(projects) + .where(eq(projects.id, projectId)) + .returning({ id: projects.id }); + return deleted.length > 0; + }); +} diff --git a/shared/database/src/repositories/threads.ts b/shared/database/src/repositories/threads.ts new file mode 100644 index 00000000..57f86931 --- /dev/null +++ b/shared/database/src/repositories/threads.ts @@ -0,0 +1,155 @@ +import type { Thread } from "@cyrus/connections/schemas/rtc/threads"; +import { ThreadSchema } from "@cyrus/connections/schemas/rtc/threads"; +import { randomId } from "@cyrus/utils/identity"; +import { nowISO } from "@cyrus/utils/time"; +import { Result } from "better-result"; +import { and, desc, eq } from "drizzle-orm"; +import { connection } from "../connection"; +import { threads } from "../models/threads"; +import type { RepositoryError } from "../utils/error"; +import { notFound, tryRepo } from "../utils/error"; +import { getProject } from "./projects"; + +export function threadNameFromPrompt(message: string): string { + const trimmed = message.trim(); + if (!trimmed) return "New thread"; + return trimmed.slice(0, 50); +} + +export async function ensureThread( + id: string, + projectId: string, + options?: { agentName?: string; firstMessage?: string } +): Promise> { + const project = await getProject(projectId); + if (project.isErr()) return Result.err(project.error); + if (!project.value) { + return Result.err(notFound("project", projectId)); + } + + return tryRepo(async () => { + const [existing] = await connection.db + .select() + .from(threads) + .where(and(eq(threads.id, id), eq(threads.projectId, projectId))) + .limit(1); + + if (existing) { + const name = + options?.firstMessage && existing.name === "New thread" + ? threadNameFromPrompt(options.firstMessage) + : existing.name; + const updatedAt = nowISO(); + const agentName = options?.agentName ?? existing.agentName ?? undefined; + await connection.db + .update(threads) + .set({ + name, + agentName: agentName ?? null, + updatedAt, + }) + .where(and(eq(threads.id, id), eq(threads.projectId, projectId))); + return ThreadSchema.parse({ + id, + projectId: existing.projectId, + name, + agentName, + createdAt: existing.createdAt, + updatedAt, + }); + } + + const createdAt = nowISO(); + const thread = ThreadSchema.parse({ + id, + projectId, + name: options?.firstMessage + ? threadNameFromPrompt(options.firstMessage) + : "New thread", + agentName: options?.agentName, + createdAt, + updatedAt: createdAt, + }); + await connection.db.insert(threads).values({ + id: thread.id, + projectId: thread.projectId, + name: thread.name, + agentName: thread.agentName ?? null, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + }); + return thread; + }); +} + +export function createThread( + projectId: string +): Promise> { + return ensureThread(randomId(), projectId); +} + +export function listThreads( + projectId: string +): Promise> { + return tryRepo(async () => { + const rows = await connection.db + .select() + .from(threads) + .where(eq(threads.projectId, projectId)) + .orderBy(desc(threads.updatedAt)); + return rows.map((row) => ThreadSchema.parse(row)); + }); +} + +export function deleteThread( + threadId: string +): Promise> { + return tryRepo(async () => { + const deleted = await connection.db + .delete(threads) + .where(eq(threads.id, threadId)) + .returning({ id: threads.id }); + return deleted.length > 0; + }); +} + +export function deleteThreadsForProject( + projectId: string +): Promise> { + return tryRepo(async () => { + await connection.db.delete(threads).where(eq(threads.projectId, projectId)); + }); +} + +export async function renameThread( + threadId: string, + name: string +): Promise> { + const thread = await getThread(threadId); + if (thread.isErr()) return Result.err(thread.error); + if (!thread.value) return Result.err(notFound("thread", threadId)); + + const current = thread.value; + + return tryRepo(async () => { + const updatedAt = nowISO(); + await connection.db + .update(threads) + .set({ name, updatedAt }) + .where(eq(threads.id, threadId)); + return ThreadSchema.parse({ ...current, name, updatedAt }); + }); +} + +export function getThread( + threadId: string +): Promise> { + return tryRepo(async () => { + const [row] = await connection.db + .select() + .from(threads) + .where(eq(threads.id, threadId)) + .limit(1); + return row ? ThreadSchema.parse(row) : undefined; + }); +} diff --git a/shared/database/src/utils/error.ts b/shared/database/src/utils/error.ts new file mode 100644 index 00000000..120ddd21 --- /dev/null +++ b/shared/database/src/utils/error.ts @@ -0,0 +1,48 @@ +import { Result } from "better-result"; + +export type RepositoryError = + | { type: "not_found"; entity: string; id: string } + | { type: "database"; message: string } + | { type: "persist_failed"; message: string }; + +export function notFound(entity: string, id: string): RepositoryError { + return { type: "not_found", entity, id }; +} + +export function repositoryErrorMessage(error: RepositoryError): string { + switch (error.type) { + case "not_found": + return `${error.entity} not found: ${error.id}`; + case "database": + case "persist_failed": + return error.message; + default: { + const _exhaustive: never = error; + return _exhaustive; + } + } +} + +function isRepositoryError(error: unknown): error is RepositoryError { + if (typeof error !== "object" || error === null || !("type" in error)) + return false; + + const type = (error as { type: unknown }).type; + return ( + type === "not_found" || type === "database" || type === "persist_failed" + ); +} + +function fromUnknown(error: unknown): RepositoryError { + if (isRepositoryError(error)) return error; + return { + type: "database", + message: error instanceof Error ? error.message : String(error), + }; +} + +export async function tryRepo( + fn: () => Promise +): Promise> { + return (await Result.tryPromise(fn)).mapError(fromUnknown); +} diff --git a/shared/database/tsconfig.json b/shared/database/tsconfig.json new file mode 100644 index 00000000..4716ed9e --- /dev/null +++ b/shared/database/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@cyrus/typescript/tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["bun"], + "strictNullChecks": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/shared/hooks/src/derive-thread.ts b/shared/hooks/src/derive-thread.ts index cf2f1f17..06b17ee8 100644 --- a/shared/hooks/src/derive-thread.ts +++ b/shared/hooks/src/derive-thread.ts @@ -149,6 +149,67 @@ function applyToolCallUpdate( upsertDiffs(state.diffs, event.content, turnId); } +function applyMessageCompleted( + state: MutableState, + entry: ConversationEntry, + event: Extract, + turnId: string +): void { + const key = event.messageId ?? turnId; + const existing = state.messages.get(key); + if (existing) { + existing.content = event.text; + return; + } + state.messages.set(key, { + content: event.text, + createdAt: entry.createdAt, + id: key, + role: "assistant", + turnId, + }); +} + +function inferTurnState( + turnEntries: ConversationEntry[], + isLatest: boolean +): Turn["state"] { + const events = turnEntries.map((entry) => entry.chunk.event); + + if (events.some((event) => event.type === "turn_interrupted")) { + return "interrupted"; + } + if ( + events.some( + (event) => + event.type === "turn_completed" || event.type === "message_completed" + ) + ) { + return "complete"; + } + if (!isLatest) return "complete"; + + const hasInProgressTool = events.some((event) => { + if (event.type !== "tool_call" && event.type !== "tool_call_update") { + return false; + } + return event.status === "pending" || event.status === "in_progress"; + }); + if (hasInProgressTool) return "running"; + + const hasStreamingDelta = events.some( + (event) => event.type === "token" || event.type === "thought" + ); + if (hasStreamingDelta) return "running"; + + const isAwaitingAgent = events.every( + (event) => event.type === "user_message" || event.type === "thread_started" + ); + if (isAwaitingAgent) return "running"; + + return "complete"; +} + function applyEvent(state: MutableState, entry: ConversationEntry): void { const { turnId, event } = entry.chunk; switch (event.type) { @@ -158,6 +219,9 @@ function applyEvent(state: MutableState, entry: ConversationEntry): void { case "token": applyToken(state, entry, event, turnId); return; + case "message_completed": + applyMessageCompleted(state, entry, event, turnId); + return; case "tool_call": applyToolCall(state, entry, event, turnId); return; @@ -174,11 +238,6 @@ function applyEvent(state: MutableState, entry: ConversationEntry): void { * the aggregated shape the UI renders. Mirrors how use-thread-feed.ts folds a * Thread into FeedEntry[] — this is the layer below it, building the Thread * fields in the first place. - * - * Known limitation: the wire protocol has no turn-completion marker, so a - * turn's `state` can only be inferred (latest turn = "running", everything - * earlier = "complete") rather than asserted. Good enough until a - * turn_completed event exists. */ export function deriveThreadFromConversation( entries: ConversationEntry[] @@ -197,14 +256,22 @@ export function deriveThreadFromConversation( const orderedTurns = [...turns.values()]; const latestTurn = orderedTurns.at(-1); - if (latestTurn) latestTurn.state = "running"; + + for (const turn of orderedTurns) { + const turnEntries = entries.filter( + (entry) => entry.chunk.turnId === turn.id + ); + turn.state = inferTurnState(turnEntries, turn.id === latestTurn?.id); + } return { diffs: [...state.diffs.values()], messages: [...state.messages.values()].map((message) => ({ ...message, streaming: - message.role === "assistant" && message.turnId === latestTurn?.id, + message.role === "assistant" && + message.turnId === latestTurn?.id && + latestTurn?.state === "running", })), toolCalls: [...state.toolCalls.values()], turns: orderedTurns, diff --git a/shared/utils/package.json b/shared/utils/package.json index 6bdbef99..fa0db738 100644 --- a/shared/utils/package.json +++ b/shared/utils/package.json @@ -7,7 +7,8 @@ "./*": "./src/*.ts" }, "dependencies": { - "date-fns": "catalog:" + "date-fns": "^4.4.0", + "random-word-slugs": "^0.1.7" }, "devDependencies": { "@cyrus/typescript": "workspace:*", diff --git a/apps/cli/src/utils/identity.ts b/shared/utils/src/identity.ts similarity index 70% rename from apps/cli/src/utils/identity.ts rename to shared/utils/src/identity.ts index 963af067..8c7f3123 100644 --- a/apps/cli/src/utils/identity.ts +++ b/shared/utils/src/identity.ts @@ -1,7 +1,7 @@ import { generateSlug } from "random-word-slugs"; -export function generateId(): string { - return Bun.randomUUIDv7(); +export function randomId(): string { + return crypto.randomUUID(); } export function generateName(): string { diff --git a/shared/utils/src/time.ts b/shared/utils/src/time.ts index b84f42da..ca47f6ac 100644 --- a/shared/utils/src/time.ts +++ b/shared/utils/src/time.ts @@ -7,9 +7,14 @@ import { differenceInWeeks, differenceInYears, format, + formatISO, isValid, } from "date-fns"; +export function nowISO(): string { + return formatISO(new Date()); +} + function parseTimeInput(input: string | number | Date): Date | null { const date = input instanceof Date ? input : new Date(input); return isValid(date) ? date : null; diff --git a/turbo.json b/turbo.json index 2143aadc..15c62972 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://v2-9-18.turborepo.dev/schema.json", + "$schema": "https://v2-10-4.turborepo.dev/schema.json", "ui": "stream", "globalPassThroughEnv": [ "DATABASE_URL",