diff --git a/apps/cli/package.json b/apps/cli/package.json index 5b869e72..075f5d93 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -26,6 +26,7 @@ "better-result": "catalog:", "commander": "^15.0.0", "diff": "^7.0.0", + "evlog": "catalog:", "zod": "catalog:" }, "devDependencies": { diff --git a/apps/cli/src/commands/agents/doctor.ts b/apps/cli/src/commands/agents/doctor.ts index 146191bb..1f161e99 100644 --- a/apps/cli/src/commands/agents/doctor.ts +++ b/apps/cli/src/commands/agents/doctor.ts @@ -18,9 +18,7 @@ async function checkAgent(entry: AgentEntry): Promise { function printHealth(name: string, result: HealthResult): void { const status = result.healthy ? green("healthy") : red("unhealthy"); print.line`${name}: ${status}`; - if (result.error) { - print.line` ${result.error}`; - } + if (result.error) print.line` ${result.error}`; } async function checkSingleAgentHealth(name: string) { diff --git a/apps/cli/src/commands/agents/rm.ts b/apps/cli/src/commands/agents/rm.ts index c54710bb..6b7f9671 100644 --- a/apps/cli/src/commands/agents/rm.ts +++ b/apps/cli/src/commands/agents/rm.ts @@ -4,9 +4,7 @@ import { print } from "@/utils/style"; export async function rm(name: string): Promise { const removed = await removeAgent(name); removed.match({ - ok: () => { - print.success`✓ removed agent "${name}"`; - }, + ok: () => print.success`✓ removed agent "${name}"`, err: (message) => { print.error`${message}`; process.exit(1); diff --git a/apps/cli/src/commands/auth/login.ts b/apps/cli/src/commands/auth/login.ts index 7fe87177..0b5a79ea 100644 --- a/apps/cli/src/commands/auth/login.ts +++ b/apps/cli/src/commands/auth/login.ts @@ -4,7 +4,7 @@ import { authClient } from "@/lib/auth"; import { getOrCreate, set } from "@/store/config"; import { blue, bold, cyan, print, underline } from "@/utils/style"; -export const CLIENT_ID = "cyrus-cli"; +export const CLIENT_ID = "cyrusd"; export const GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; export async function login(): Promise { diff --git a/apps/cli/src/commands/auth/whoami.ts b/apps/cli/src/commands/auth/whoami.ts index 123d4b77..88bc6cba 100644 --- a/apps/cli/src/commands/auth/whoami.ts +++ b/apps/cli/src/commands/auth/whoami.ts @@ -24,11 +24,8 @@ export async function whoami(opts: WhoamiOptions): Promise { } const name = await get("name"); + print.line`${field("User")}${data.user.name}`; - if (name) { - print.line`${field("Device")}${name}`; - } - if (opts.email) { - print.line`${field("Email")}${data.user.email}`; - } + if (name) print.line`${field("Device")}${name}`; + if (opts.email) print.line`${field("Email")}${data.user.email}`; } diff --git a/apps/cli/src/commands/service/start.ts b/apps/cli/src/commands/service/start.ts index cbc471c5..95f1e149 100644 --- a/apps/cli/src/commands/service/start.ts +++ b/apps/cli/src/commands/service/start.ts @@ -19,9 +19,7 @@ async function runWorker(): Promise { process.on("exit", () => { Result.try(() => { const stored = Number.parseInt(readFileSync(PID_PATH, "utf8").trim(), 10); - if (stored === ownPid) { - unlinkSync(PID_PATH); - } + if (stored === ownPid) unlinkSync(PID_PATH); }); }); const { worker } = await import("./worker"); @@ -29,10 +27,7 @@ async function runWorker(): Promise { } export async function start(opts: StartOptions): Promise { - if (env.CYRUS_DAEMON) { - await runWorker(); - return; - } + if (env.CYRUS_DAEMON) return await runWorker(); const running = await runningPid(); if (running !== null) { @@ -50,9 +45,7 @@ export async function start(opts: StartOptions): Promise { }); closeSync(log); child.unref(); - if (child.pid) { - await writePid(child.pid); - } + if (child.pid) await writePid(child.pid); // brief wait to catch immediate startup failures (e.g. not logged in) await Bun.sleep(300); diff --git a/apps/cli/src/commands/service/stop.ts b/apps/cli/src/commands/service/stop.ts index bae262a9..31d03dec 100644 --- a/apps/cli/src/commands/service/stop.ts +++ b/apps/cli/src/commands/service/stop.ts @@ -11,9 +11,8 @@ export async function stop(): Promise { Result.try(() => process.kill(pid, "SIGTERM")); - for (let i = 0; i < 20 && isAlive(pid); i++) { - await Bun.sleep(50); - } + for (let i = 0; i < 20 && isAlive(pid); i++) await Bun.sleep(50); + if (isAlive(pid)) { print.error`Worker (pid ${pid}) did not stop in time.`; process.exit(1); diff --git a/apps/cli/src/commands/service/worker.ts b/apps/cli/src/commands/service/worker.ts index fdd0f623..d85f0ee7 100644 --- a/apps/cli/src/commands/service/worker.ts +++ b/apps/cli/src/commands/service/worker.ts @@ -8,6 +8,7 @@ import { createControllerRouter } from "@/handlers/controller"; import { workerRouter } from "@/handlers/worker"; import { authClient } from "@/lib/auth"; import { env } from "@/lib/env"; +import { createThreadEventBus } from "@/queue/bus"; import { get, getOrCreate } from "@/store/config"; import { initDatabase } from "@/store/database"; import { print } from "@/utils/style"; @@ -48,9 +49,12 @@ export async function worker(): Promise { }); print.success`✓ connected — waiting for message…`; + const eventBus = createThreadEventBus(); + const device = serveWorker({ signaling: signalingSession.signaling, events: signalingSession.events, + eventBus, routers: { controller: createControllerRouter(runtime), worker: workerRouter, @@ -64,6 +68,7 @@ export async function worker(): Promise { await connection.close(); process.exit(0); }; + process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); process.on("SIGBREAK", shutdown); diff --git a/apps/cli/src/handlers/controller/chat.ts b/apps/cli/src/handlers/controller/chat.ts index 0ae0d0d5..088f3a57 100644 --- a/apps/cli/src/handlers/controller/chat.ts +++ b/apps/cli/src/handlers/controller/chat.ts @@ -2,7 +2,8 @@ import { appendConversation } from "@cyrus/database/repositories/conversations"; import { ensureThread } from "@cyrus/database/repositories/threads"; import type { ChatChunk } from "@cyrus/schemas/rtc/chat"; import { randomId } from "@cyrus/utils/identity"; -import { env } from "@/lib/env"; +import { Result } from "better-result"; +import { log } from "evlog"; import { throwOrpcFromRepositoryError } from "@/utils/error"; import { isStreamingDelta, @@ -11,10 +12,67 @@ import { } from "@/utils/streams"; import type { ControllerDeps } from "./deps"; +type RunTurnOptions = { + agentName: string; + threadId: string; + projectId: string; + message: string; + turnId: string; + emit: (event: ChatChunk["event"]) => Promise; + runtime: ControllerDeps["runtime"]; +}; + +async function runTurn({ + agentName, + threadId, + projectId, + message, + emit, + emitTerminal, + runtime, +}: Omit & { + emitTerminal: ( + event: Extract< + ChatChunk["event"], + { type: "turn_completed" | "turn_interrupted" } + > + ) => Promise; +}): Promise> { + const started = await Result.tryPromise(async () => { + await emit({ type: "user_message", content: message }); + await emit({ type: "thread_started", threadId }); + }); + if (started.isErr()) return started; + + const streamed = await Result.tryPromise(async () => { + const gen = runtime.threadCoordinator.prompt( + agentName, + threadId, + projectId, + message + ); + for await (const event of gen) await emit(event); + }); + + if (streamed.isErr()) { + await emitTerminal({ type: "turn_interrupted" }); + return streamed; + } + + await emitTerminal({ type: "turn_completed" }); + return Result.ok(undefined); +} + export function chatHandlers({ os, runtime }: ControllerDeps) { return { - chat: os.chat.handler(async function* ({ input, context }) { - const { agentName, threadId = randomId(), message, projectId } = input; + chat: os.chat.handler(async ({ input, context }) => { + const { + agentName, + threadId = randomId(), + turnId = randomId(), + message, + projectId, + } = input; const thread = await ensureThread(threadId, projectId, { agentName, @@ -22,18 +80,20 @@ export function chatHandlers({ os, runtime }: ControllerDeps) { }); if (thread.isErr()) throwOrpcFromRepositoryError(thread.error); - const turnId = randomId(); const messageBuffers = new Map(); const thoughtBuffers = new Map(); - async function emit(event: ChatChunk["event"]): Promise { + context.eventBus.ensureWatch(context.peerId, threadId); + + function publishChunk(chunk: ChatChunk): void { + context.eventBus.publish(chunk); + } + + async function emit(event: ChatChunk["event"]): Promise { trackDelta(event, messageBuffers, thoughtBuffers); - if (isStreamingDelta(event)) { - const chunk: ChatChunk = { threadId, turnId, seq: 0, event }; - context.broadcaster.broadcast(chunk, context.peerId); - return chunk; - } + if (isStreamingDelta(event)) + return publishChunk({ threadId, turnId, seq: 0, event }); const persistEvent = resolvePersistEvent( event, @@ -46,39 +106,92 @@ export function chatHandlers({ os, runtime }: ControllerDeps) { event: persistEvent, }); if (entry.isErr()) throwOrpcFromRepositoryError(entry.error); - const chunk = entry.value.chunk; - context.broadcaster.broadcast(chunk, context.peerId); - return chunk; + publishChunk(entry.value.chunk); } - yield await emit({ type: "user_message", content: message }); - yield await emit({ type: "thread_started", threadId }); + async function emitTerminal( + event: Extract< + ChatChunk["event"], + { type: "turn_completed" | "turn_interrupted" } + > + ): Promise { + trackDelta(event, messageBuffers, thoughtBuffers); - const gen = runtime.threadCoordinator.prompt( + const persistEvent = resolvePersistEvent( + event, + messageBuffers, + thoughtBuffers + ); + const entry = await appendConversation(threadId, { + threadId, + turnId, + event: persistEvent, + }); + + if (entry.isOk()) { + publishChunk(entry.value.chunk); + return; + } + + log.error({ + kind: "terminal_event_persist", + error: entry.error, + threadId, + turnId, + event: event.type, + }); + publishChunk({ threadId, turnId, seq: 0, event }); + } + + runTurn({ agentName, threadId, projectId, - message - ); - 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; - } + message, + emit, + emitTerminal, + runtime, + }) + .then((result) => { + result.tapError((error) => { + log.error({ kind: "chat_turn_failed", error, threadId, turnId }); + }); + }) + .catch((error) => { + log.error({ kind: "chat_turn_failed", error, threadId, turnId }); + }); + + return { threadId, turnId }; }), subscribe: os.subscribe.handler(async function* ({ context }) { - for await (const chunk of context.broadcaster.subscribe(context.peerId)) + for await (const chunk of context.eventBus.subscribe(context.peerId)) yield chunk; }), - cancel: os.cancel.handler(async ({ input }) => { + cancel: os.cancel.handler(async ({ input, context }) => { + // Snapshot before awaiting — a follow-up chat() can start while cancel + // is in flight and must not be interrupted by this request. + const snapshottedTurnIds = context.eventBus.getActiveTurnIdsForThread( + input.threadId + ); + await runtime.threadCoordinator.cancel(input.agentName, input.threadId); + + const stillActiveTurnIds = new Set( + context.eventBus.getActiveTurnIdsForThread(input.threadId) + ); + + for (const turnId of snapshottedTurnIds) { + if (!stillActiveTurnIds.has(turnId)) continue; + context.eventBus.publish({ + threadId: input.threadId, + turnId, + seq: 0, + event: { type: "turn_interrupted" }, + }); + } + return {}; }), }; diff --git a/apps/cli/src/handlers/controller/threads.ts b/apps/cli/src/handlers/controller/threads.ts index 633bb11b..3850ba67 100644 --- a/apps/cli/src/handlers/controller/threads.ts +++ b/apps/cli/src/handlers/controller/threads.ts @@ -1,4 +1,7 @@ -import { getConversations } from "@cyrus/database/repositories/conversations"; +import { + getConversations, + getSnapshotHighWaterMark, +} from "@cyrus/database/repositories/conversations"; import { createThread as createStoredThread, deleteThread, @@ -62,5 +65,29 @@ export function threadsHandlers(os: ControllerOs) { return {}; }), + + watchThread: os.watchThread.handler(async ({ input, context }) => { + const thread = await getThread(input.threadId); + if (thread.isErr()) throwOrpcFromRepositoryError(thread.error); + if (!thread.value) { + throwOrpcFromRepositoryError({ + type: "not_found", + entity: "thread", + id: input.threadId, + }); + } + + context.eventBus.watch(context.peerId, input.threadId); + + return (await getSnapshotHighWaterMark(input.threadId)).match({ + ok: (snapshotHighWaterMark) => ({ snapshotHighWaterMark }), + err: throwOrpcFromRepositoryError, + }); + }), + + unwatchThread: os.unwatchThread.handler(({ input, context }) => { + context.eventBus.unwatch(context.peerId, input.threadId); + return {}; + }), }; } diff --git a/apps/cli/src/lib/env.ts b/apps/cli/src/lib/env.ts index ac2c213a..bc23e532 100644 --- a/apps/cli/src/lib/env.ts +++ b/apps/cli/src/lib/env.ts @@ -25,7 +25,6 @@ export const env = createEnv({ .int() .positive() .default(30 * 60 * 1000), - CYRUS_STREAM_THROTTLING_MS: z.coerce.number().positive().default(25), }, runtimeEnv: process.env, emptyStringAsUndefined: true, diff --git a/apps/cli/src/queue/bus.ts b/apps/cli/src/queue/bus.ts new file mode 100644 index 00000000..22fc8d28 --- /dev/null +++ b/apps/cli/src/queue/bus.ts @@ -0,0 +1,191 @@ +import type { ThreadEventBus } from "@cyrus/connections/rtc/bus"; +import type { ChatChunk } from "@cyrus/schemas/rtc/chat"; + +const DEFAULT_MAX_CHUNKS_PER_TURN = 10_000; + +type PeerDelivery = { + queue: ChatChunk[]; + resolve: (() => void) | null; + closed: boolean; +}; + +export type CreateThreadEventBusOptions = { + maxChunksPerTurn?: number; +}; + +function isTerminalEvent(event: ChatChunk["event"]): boolean { + return event.type === "turn_completed" || event.type === "turn_interrupted"; +} + +export function createThreadEventBus( + options: CreateThreadEventBusOptions = {} +): ThreadEventBus { + const { maxChunksPerTurn = DEFAULT_MAX_CHUNKS_PER_TURN } = options; + + const peers = new Map(); + const watchedThreads = new Map>(); + const activeTurnLogs = new Map(); + const turnThreads = new Map(); + let closed = false; + + function getWatchedThreads(peerId: string): Set { + let set = watchedThreads.get(peerId); + if (!set) { + set = new Set(); + watchedThreads.set(peerId, set); + } + return set; + } + + function closePeerDelivery(peerId: string): void { + const peer = peers.get(peerId); + if (peer) { + peer.closed = true; + peer.resolve?.(); + } + } + + function deliver(peerId: string, chunk: ChatChunk): void { + const peer = peers.get(peerId); + if (!peer || peer.closed) return; + peer.queue.push(chunk); + peer.resolve?.(); + peer.resolve = null; + } + + function fanOut(chunk: ChatChunk): void { + for (const [peerId, threads] of watchedThreads) { + if (threads.has(chunk.threadId)) { + deliver(peerId, chunk); + } + } + } + + function trimTurnLog(log: ChatChunk[]): void { + while (log.length > maxChunksPerTurn) { + const deltaIndex = log.findIndex((chunk) => chunk.seq === 0); + if (deltaIndex === -1) break; + log.splice(deltaIndex, 1); + } + while (log.length > maxChunksPerTurn) log.shift(); + } + + function appendToTurnLog(chunk: ChatChunk): void { + const { turnId } = chunk; + let log = activeTurnLogs.get(turnId); + if (!log) { + log = []; + activeTurnLogs.set(turnId, log); + turnThreads.set(turnId, chunk.threadId); + } + log.push(chunk); + trimTurnLog(log); + } + + function evictTurnLog(turnId: string): void { + activeTurnLogs.delete(turnId); + turnThreads.delete(turnId); + } + + function replayThread(peerId: string, threadId: string): void { + for (const [turnId, log] of activeTurnLogs) { + if (turnThreads.get(turnId) !== threadId) continue; + for (const chunk of log) deliver(peerId, chunk); + } + } + + function registerWatch(peerId: string, threadId: string): void { + const threads = getWatchedThreads(peerId); + const isNew = !threads.has(threadId); + threads.add(threadId); + if (isNew) replayThread(peerId, threadId); + } + + return { + publish(chunk) { + if (closed) return; + + const terminal = isTerminalEvent(chunk.event); + if (!terminal) { + appendToTurnLog(chunk); + } + fanOut(chunk); + if (terminal) { + evictTurnLog(chunk.turnId); + } + }, + + watch(peerId, threadId) { + registerWatch(peerId, threadId); + }, + + unwatch(peerId, threadId) { + watchedThreads.get(peerId)?.delete(threadId); + }, + + ensureWatch(peerId, threadId) { + if (!getWatchedThreads(peerId).has(threadId)) { + registerWatch(peerId, threadId); + } + }, + + isWatching(peerId, threadId) { + return watchedThreads.get(peerId)?.has(threadId) ?? false; + }, + + getActiveTurnIdsForThread(threadId) { + const turnIds: string[] = []; + for (const turnId of activeTurnLogs.keys()) { + if (turnThreads.get(turnId) === threadId) turnIds.push(turnId); + } + return turnIds; + }, + + async *subscribe(peerId) { + closePeerDelivery(peerId); + const peer: PeerDelivery = { + queue: [], + resolve: null, + closed: false, + }; + peers.set(peerId, peer); + + for (const threadId of getWatchedThreads(peerId)) { + replayThread(peerId, threadId); + } + + try { + while (!peer.closed) { + while (peer.queue.length > 0) { + yield peer.queue.shift() as ChatChunk; + } + if (!peer.closed) { + await new Promise((resolve) => { + peer.resolve = resolve; + }); + } + } + } finally { + if (peers.get(peerId) === peer) { + peers.delete(peerId); + } + } + }, + + close(peerId) { + closePeerDelivery(peerId); + watchedThreads.delete(peerId); + }, + + closeAll() { + closed = true; + for (const peerId of [...peers.keys()]) { + closePeerDelivery(peerId); + } + peers.clear(); + watchedThreads.clear(); + activeTurnLogs.clear(); + turnThreads.clear(); + }, + }; +} diff --git a/apps/cli/src/utils/io.ts b/apps/cli/src/utils/io.ts deleted file mode 100644 index 6aa19efa..00000000 --- a/apps/cli/src/utils/io.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function stdinWritable(stdin: Bun.FileSink): WritableStream { - return new WritableStream({ - write(chunk) { - stdin.write(chunk); - stdin.flush(); - }, - close() { - stdin.end(); - }, - abort() { - stdin.end(); - }, - }); -} diff --git a/apps/web/src/components/auth/provider-button.tsx b/apps/web/src/components/auth/provider-button.tsx index b2f67bc9..b3caf44e 100644 --- a/apps/web/src/components/auth/provider-button.tsx +++ b/apps/web/src/components/auth/provider-button.tsx @@ -1,5 +1,3 @@ -"use client"; - import { authMutationKeys, getProviderName } from "@better-auth-ui/core"; import { providerIcons, useAuth, useSignInSocial } from "@better-auth-ui/react"; import { useIsMutating } from "@tanstack/react-query"; diff --git a/apps/web/src/components/chat/composer/index.tsx b/apps/web/src/components/chat/composer/index.tsx index 3b2f1530..5dd898da 100644 --- a/apps/web/src/components/chat/composer/index.tsx +++ b/apps/web/src/components/chat/composer/index.tsx @@ -8,12 +8,14 @@ export function Composer({ onSend, onStop, busy = false, + stopping = false, }: { projectId: string; threadId: string; onSend: (text: string) => void; onStop?: () => void; busy?: boolean; + stopping?: boolean; }) { const [value, setValue] = useState(""); const textareaRef = useRef(null); @@ -30,7 +32,7 @@ export function Composer({ function submit() { const text = value.trim(); - if (!text || busy) return; + if (!text || busy || stopping) return; onSend(text); setValue(""); @@ -97,6 +99,7 @@ export function Composer({ busy={busy} canSend={value.trim().length > 0} onStop={onStop} + stopping={stopping} /> diff --git a/apps/web/src/components/chat/composer/primary-action.tsx b/apps/web/src/components/chat/composer/primary-action.tsx index fbceba80..5f520117 100644 --- a/apps/web/src/components/chat/composer/primary-action.tsx +++ b/apps/web/src/components/chat/composer/primary-action.tsx @@ -4,11 +4,25 @@ export function ComposerPrimaryAction({ busy, canSend, onStop, + stopping = false, }: { busy: boolean; canSend: boolean; onStop?: () => void; + stopping?: boolean; }) { + if (stopping) + return ( + + ); + if (busy) return (