diff --git a/.env.example b/.env.example index a2c17071d80..464107ea1a4 100644 --- a/.env.example +++ b/.env.example @@ -65,18 +65,6 @@ POSTHOG_PROJECT_ID= # ----------------------------------------------------------------------------- FREESTYLE_API_KEY= -# ----------------------------------------------------------------------------- -# Streams (AI Chat Server) -# ----------------------------------------------------------------------------- -# Clients (Desktop Web Mobile) -NEXT_PUBLIC_STREAMS_URL=http://localhost:8080 - -# Streams server internals (optional) -STREAMS_PORT=8080 -STREAMS_INTERNAL_PORT=8081 -STREAMS_INTERNAL_URL=http://localhost:8081 -STREAMS_DATA_DIR=./data - # ----------------------------------------------------------------------------- # Sentry Error Tracking # ----------------------------------------------------------------------------- diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index f62b6de29f4..e6b246a81a5 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -91,7 +91,6 @@ jobs: NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_DOCS_URL: ${{ secrets.NEXT_PUBLIC_DOCS_URL }} - NEXT_PUBLIC_STREAMS_URL: ${{ secrets.NEXT_PUBLIC_STREAMS_URL }} SENTRY_DSN_DESKTOP: ${{ secrets.SENTRY_DSN_DESKTOP }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SUPERSET_WORKSPACE_NAME: superset @@ -195,7 +194,6 @@ jobs: NEXT_PUBLIC_WEB_URL: ${{ secrets.NEXT_PUBLIC_WEB_URL }} NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }} NEXT_PUBLIC_DOCS_URL: ${{ secrets.NEXT_PUBLIC_DOCS_URL }} - NEXT_PUBLIC_STREAMS_URL: ${{ secrets.NEXT_PUBLIC_STREAMS_URL }} NEXT_PUBLIC_ELECTRIC_URL: ${{ secrets.NEXT_PUBLIC_ELECTRIC_URL }} SENTRY_DSN_DESKTOP: ${{ secrets.SENTRY_DSN_DESKTOP }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.superset/lib/setup/steps.sh b/.superset/lib/setup/steps.sh index 37bbaf02af4..b5243edb138 100644 --- a/.superset/lib/setup/steps.sh +++ b/.superset/lib/setup/steps.sh @@ -362,8 +362,6 @@ step_write_env() { local DOCS_PORT=$((BASE + 4)) local DESKTOP_VITE_PORT=$((BASE + 5)) local DESKTOP_NOTIFICATIONS_PORT=$((BASE + 6)) - local STREAMS_PORT=$((BASE + 7)) - local STREAMS_INTERNAL_PORT=$((BASE + 8)) local ELECTRIC_PORT=$((BASE + 9)) local CADDY_ELECTRIC_PORT=$((BASE + 10)) local CODE_INSPECTOR_PORT=$((BASE + 11)) @@ -379,8 +377,6 @@ step_write_env() { write_env_var "DOCS_PORT" "$DOCS_PORT" write_env_var "DESKTOP_VITE_PORT" "$DESKTOP_VITE_PORT" write_env_var "DESKTOP_NOTIFICATIONS_PORT" "$DESKTOP_NOTIFICATIONS_PORT" - write_env_var "STREAMS_PORT" "$STREAMS_PORT" - write_env_var "STREAMS_INTERNAL_PORT" "$STREAMS_INTERNAL_PORT" write_env_var "ELECTRIC_PORT" "$ELECTRIC_PORT" write_env_var "CADDY_ELECTRIC_PORT" "$CADDY_ELECTRIC_PORT" write_env_var "CODE_INSPECTOR_PORT" "$CODE_INSPECTOR_PORT" @@ -396,13 +392,6 @@ step_write_env() { write_env_var "EXPO_PUBLIC_WEB_URL" "http://localhost:$WEB_PORT" write_env_var "EXPO_PUBLIC_API_URL" "http://localhost:$API_PORT" echo "" - echo "# Streams URLs (overrides from root .env)" - write_env_var "PORT" "$STREAMS_PORT" - write_env_var "STREAMS_URL" "http://localhost:$STREAMS_PORT" - write_env_var "NEXT_PUBLIC_STREAMS_URL" "http://localhost:$STREAMS_PORT" - write_env_var "EXPO_PUBLIC_STREAMS_URL" "http://localhost:$STREAMS_PORT" - write_env_var "STREAMS_INTERNAL_URL" "http://127.0.0.1:$STREAMS_INTERNAL_PORT" - echo "" echo "# Electric URLs (overrides from root .env)" write_env_var "ELECTRIC_URL" "http://localhost:$ELECTRIC_PORT/v1/shape" echo "# Caddy HTTPS proxy for HTTP/2 (avoids browser 6-connection limit with 10+ SSE streams)" @@ -435,8 +424,6 @@ step_write_env() { { "port": $DOCS_PORT, "label": "Docs" }, { "port": $DESKTOP_VITE_PORT, "label": "Desktop Vite" }, { "port": $DESKTOP_NOTIFICATIONS_PORT, "label": "Notifications" }, - { "port": $STREAMS_PORT, "label": "Streams" }, - { "port": $STREAMS_INTERNAL_PORT, "label": "Streams Internal" }, { "port": $ELECTRIC_PORT, "label": "Electric" }, { "port": $CADDY_ELECTRIC_PORT, "label": "Caddy Electric" }, { "port": $CODE_INSPECTOR_PORT, "label": "Code Inspector" } diff --git a/AGENTS.md b/AGENTS.md index 04397cdf780..af3fd2e0e49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,6 @@ Bun + Turbo monorepo with: - `apps/desktop` - Electron desktop application - `apps/docs` - Documentation site - `apps/mobile` - React Native mobile app (Expo) - - `apps/streams` - Streams service - `apps/electric-proxy` - Electric proxy service - **Packages**: - `packages/ui` - Shared UI components (shadcn/ui + TailwindCSS v4). @@ -26,7 +25,6 @@ Bun + Turbo monorepo with: - `packages/mcp` - MCP integration - `packages/desktop-mcp` - Desktop MCP server - `packages/local-db` - Local SQLite database - - `packages/durable-session` - Durable session management - `packages/email` - Email templates/sending - `packages/scripts` - CLI tooling - **Tooling**: diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 8b6f67d507f..c43d0781d1a 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -53,10 +53,6 @@ export default defineConfig({ process.env.NEXT_PUBLIC_API_URL, "https://api.superset.sh", ), - "process.env.NEXT_PUBLIC_STREAMS_URL": defineEnv( - process.env.NEXT_PUBLIC_STREAMS_URL, - "https://streams.superset.sh", - ), "process.env.NEXT_PUBLIC_WEB_URL": defineEnv( process.env.NEXT_PUBLIC_WEB_URL, "https://app.superset.sh", @@ -75,10 +71,6 @@ export default defineConfig({ "process.env.NEXT_PUBLIC_POSTHOG_HOST": defineEnv( process.env.NEXT_PUBLIC_POSTHOG_HOST, ), - "process.env.STREAMS_URL": defineEnv( - process.env.STREAMS_URL, - "https://superset-stream.fly.dev", - ), "process.env.DESKTOP_VITE_PORT": defineEnv(process.env.DESKTOP_VITE_PORT), "process.env.DESKTOP_NOTIFICATIONS_PORT": defineEnv( process.env.DESKTOP_NOTIFICATIONS_PORT, @@ -176,10 +168,6 @@ export default defineConfig({ "import.meta.env.SENTRY_DSN_DESKTOP": defineEnv( process.env.SENTRY_DSN_DESKTOP, ), - "process.env.STREAMS_URL": defineEnv( - process.env.STREAMS_URL, - "https://superset-stream.fly.dev", - ), "process.env.DESKTOP_VITE_PORT": defineEnv(process.env.DESKTOP_VITE_PORT), "process.env.DESKTOP_NOTIFICATIONS_PORT": defineEnv( process.env.DESKTOP_NOTIFICATIONS_PORT, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a523fe5dc54..ce6f0f96108 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -39,7 +39,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@durable-streams/client": "^0.2.1", "@electric-sql/client": "1.5.2", "@headless-tree/core": "^1.6.3", "@headless-tree/react": "^1.6.3", @@ -53,12 +52,12 @@ "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/desktop-mcp": "workspace:*", - "@superset/durable-session": "workspace:*", "@superset/local-db": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", "@t3-oss/env-core": "^0.13.8", + "@tanstack/ai": "^0.3.0", "@tanstack/db": "0.5.25", "@tanstack/electric-db-collection": "0.2.31", "@tanstack/react-db": "0.1.69", diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts index ad6480cc828..c6c7748ed4a 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts @@ -12,13 +12,12 @@ import { toAISdkV5Messages, } from "@superset/agent"; import { observable } from "@trpc/server/observable"; -import { env } from "main/env.main"; import { z } from "zod"; import { publicProcedure, router } from "../.."; -import { loadToken } from "../auth/utils/auth-functions"; import { getCredentialsFromConfig, getCredentialsFromKeychain, + getExistingClaudeCredentials, } from "./utils/auth/auth"; import { readClaudeSessionMessages, @@ -286,14 +285,40 @@ function scanCustomCommands(cwd: string): CommandEntry[] { export const createAiChatRouter = () => { return router({ - getConfig: publicProcedure.query(async () => { - const { token } = await loadToken(); + getAuthStatus: publicProcedure.query(() => { + const creds = getExistingClaudeCredentials(); + if (creds) { + return { + authenticated: true as const, + source: creds.source, + kind: creds.kind, + }; + } return { - proxyUrl: env.NEXT_PUBLIC_STREAMS_URL, - authToken: token, + authenticated: false as const, + source: null, + kind: null, }; }), + setApiKey: publicProcedure + .input(z.object({ apiKey: z.string().min(1) })) + .mutation(({ input }) => { + const isOauth = input.apiKey.startsWith("sk-ant-oat"); + if (isOauth) { + setAnthropicAuthToken(input.apiKey); + console.log( + "[ai-chat/setApiKey] Set OAuth token via setAnthropicAuthToken", + ); + } else { + setAnthropicAuthToken(input.apiKey); + console.log( + "[ai-chat/setApiKey] Set API key via setAnthropicAuthToken", + ); + } + return { success: true }; + }), + getModels: publicProcedure.query(() => getAvailableModels()), getMessages: publicProcedure @@ -359,15 +384,6 @@ export const createAiChatRouter = () => { return { success: true }; }), - interrupt: publicProcedure - .input(z.object({ sessionId: z.string() })) - .mutation(async ({ input }) => { - await chatSessionManager.interrupt({ - sessionId: input.sessionId, - }); - return { success: true }; - }), - stopSession: publicProcedure .input(z.object({ sessionId: z.string() })) .mutation(async ({ input }) => { @@ -694,25 +710,5 @@ export const createAiChatRouter = () => { }); return { success: true }; }), - - /** Legacy: approve tool use via session manager */ - approveToolUse: publicProcedure - .input( - z.object({ - sessionId: z.string(), - toolUseId: z.string(), - approved: z.boolean(), - updatedInput: z.record(z.string(), z.unknown()).optional(), - }), - ) - .mutation(({ input }) => { - chatSessionManager.resolvePermission({ - sessionId: input.sessionId, - toolUseId: input.toolUseId, - approved: input.approved, - updatedInput: input.updatedInput, - }); - return { success: true }; - }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/auth/auth.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/auth/auth.ts index f206884dd07..ccf2840c842 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/auth/auth.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/auth/auth.ts @@ -105,8 +105,40 @@ export function getCredentialsFromKeychain(): ClaudeCredentials | null { return null; } + // Claude Code stores OAuth credentials under "Claude Code-credentials" + // as a JSON blob with the same shape as ClaudeConfigFile. + try { + const raw = execSync( + 'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', + { encoding: "utf-8" }, + ).trim(); + + if (raw) { + try { + const config: ClaudeConfigFile = JSON.parse(raw); + if (config.claudeAiOauth?.accessToken) { + console.log( + "[claude/auth] Found OAuth credentials in macOS Keychain (Claude Code-credentials)", + ); + return { + apiKey: config.claudeAiOauth.accessToken, + source: "keychain", + kind: "oauth", + }; + } + } catch { + // Not valid JSON — treat as raw API key + console.log( + "[claude/auth] Found raw credentials in macOS Keychain (Claude Code-credentials)", + ); + return { apiKey: raw, source: "keychain", kind: "apiKey" }; + } + } + } catch { + // Not found in keychain, this is fine + } + try { - // Claude CLI stores credentials in the keychain with this service/account const result = execSync( 'security find-generic-password -s "claude-cli" -a "api-key" -w 2>/dev/null', { encoding: "utf-8" }, @@ -120,7 +152,6 @@ export function getCredentialsFromKeychain(): ClaudeCredentials | null { // Not found in keychain, this is fine } - // Try alternate keychain entry format try { const result = execSync( 'security find-generic-password -s "anthropic-api-key" -w 2>/dev/null', diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/agent-execution.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/agent-execution.ts deleted file mode 100644 index a6289dcf673..00000000000 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/agent-execution.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { - createPermissionRequest, - executeAgent, - resolvePendingPermission, -} from "@superset/agent"; -import { app } from "electron"; -import { buildClaudeEnv } from "../auth"; -import type { SessionStore } from "../session-store"; -import type { PermissionRequestEvent } from "./session-events"; -import type { ActiveSession } from "./session-types"; - -function getClaudeBinaryPath(): string | null { - let binaryPath: string; - if (app.isPackaged) { - binaryPath = join(process.resourcesPath, "bin", "claude"); - } else { - const platform = process.platform; - const arch = process.arch; - binaryPath = join( - app.getAppPath(), - "resources", - "bin", - `${platform}-${arch}`, - "claude", - ); - } - - if (!existsSync(binaryPath)) { - console.warn( - `[chat/agent] Claude binary not found at ${binaryPath} — will rely on SDK to resolve`, - ); - return null; - } - - return binaryPath; -} - -export interface ResolvePermissionInput { - sessionId: string; - toolUseId: string; - approved: boolean; - updatedInput?: Record; -} - -export interface ExecuteAgentInput { - session: ActiveSession; - sessionId: string; - prompt: string; - abortController: AbortController; - onChunk: (chunk: unknown) => void; -} - -interface AgentExecutionDeps { - store: SessionStore; - emitPermissionRequest: (event: PermissionRequestEvent) => void; -} - -export class AgentExecution { - constructor(private readonly deps: AgentExecutionDeps) {} - - async execute({ - session, - sessionId, - prompt, - abortController, - onChunk, - }: ExecuteAgentInput): Promise { - const agentEnv = buildClaudeEnv(); - const claudeBinaryPath = getClaudeBinaryPath(); - - await executeAgent({ - sessionId, - prompt, - cwd: session.cwd, - ...(claudeBinaryPath && { - pathToClaudeCodeExecutable: claudeBinaryPath, - }), - env: agentEnv, - model: session.model, - permissionMode: session.permissionMode ?? "default", - maxThinkingTokens: session.maxThinkingTokens, - signal: abortController.signal, - onChunk, - onPermissionRequest: async (params) => { - this.deps.emitPermissionRequest({ - type: "permission_request", - sessionId, - toolUseId: params.toolUseId, - toolName: params.toolName, - input: params.input, - }); - - return createPermissionRequest({ - toolUseId: params.toolUseId, - signal: params.signal, - }); - }, - onEvent: (event) => { - if (event.type === "session_initialized") { - this.deps.store - .update(sessionId, { - providerSessionId: event.claudeSessionId, - lastActiveAt: Date.now(), - }) - .catch((err: unknown) => { - console.error( - `[chat/session] Failed to update providerSessionId:`, - err, - ); - }); - } - }, - }); - } - - resolvePermission({ - sessionId, - toolUseId, - approved, - updatedInput, - }: ResolvePermissionInput): void { - const result = approved - ? { - behavior: "allow" as const, - updatedInput: updatedInput ?? {}, - } - : { behavior: "deny" as const, message: "User denied permission" }; - - const resolved = resolvePendingPermission({ toolUseId, result }); - if (!resolved) { - console.warn( - `[chat/session] No pending permission for toolUseId=${toolUseId} in session ${sessionId}`, - ); - } - } -} diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/agent-runner.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/agent-runner.ts deleted file mode 100644 index bf57cbdb423..00000000000 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/agent-runner.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { SessionStore } from "../session-store"; -import type { ResolvePermissionInput } from "./agent-execution"; -import { AgentExecution } from "./agent-execution"; -import { AgentStreamWriter } from "./agent-stream-writer"; -import type { ChunkBatcher } from "./chunk-batcher"; -import type { GenerationWatchdog } from "./generation-watchdog"; -import type { PermissionRequestEvent } from "./session-events"; -import type { ActiveSession, EnsureSessionReadyInput } from "./session-types"; - -export type { ResolvePermissionInput } from "./agent-execution"; - -export interface StartAgentInput { - sessionId: string; - prompt: string; -} - -interface AgentRunnerDeps { - store: SessionStore; - sessions: Map; - runningAgents: Map; - proxyUrl: string; - emitSessionError: (params: { sessionId: string; error: string }) => void; - emitPermissionRequest: (event: PermissionRequestEvent) => void; - ensureSessionReady: (input: EnsureSessionReadyInput) => Promise; -} - -export class AgentRunner { - private readonly execution: AgentExecution; - private readonly streamWriter: AgentStreamWriter; - - constructor(private readonly deps: AgentRunnerDeps) { - this.execution = new AgentExecution({ - store: deps.store, - emitPermissionRequest: deps.emitPermissionRequest, - }); - this.streamWriter = new AgentStreamWriter({ - proxyUrl: deps.proxyUrl, - emitSessionError: deps.emitSessionError, - ensureSessionReady: deps.ensureSessionReady, - isSessionActive: (sessionId) => this.deps.sessions.has(sessionId), - }); - } - - private abortExistingAgent({ sessionId }: { sessionId: string }): void { - const existingController = this.deps.runningAgents.get(sessionId); - if (!existingController) return; - console.warn(`[chat/session] Aborting previous agent run for ${sessionId}`); - existingController.abort(); - if (this.deps.runningAgents.get(sessionId) === existingController) { - this.deps.runningAgents.delete(sessionId); - } - } - - async startAgent({ sessionId, prompt }: StartAgentInput): Promise { - const session = this.deps.sessions.get(sessionId); - if (!session) { - console.error( - `[chat/session] Session ${sessionId} not found for startAgent`, - ); - this.deps.emitSessionError({ - sessionId, - error: "Session not active", - }); - return; - } - - this.abortExistingAgent({ sessionId }); - - const abortController = new AbortController(); - this.deps.runningAgents.set(sessionId, abortController); - - const messageId = crypto.randomUUID(); - let headers: Record | null = null; - let batcher: ChunkBatcher | null = null; - let watchdog: GenerationWatchdog | null = null; - - try { - const prepared = await this.streamWriter.prepareStream({ - sessionId, - session, - abortController, - }); - headers = prepared.headers; - batcher = prepared.batcher; - watchdog = prepared.watchdog; - - await this.execution.execute({ - session, - sessionId, - prompt, - abortController, - onChunk: (chunk) => { - this.streamWriter.onAssistantChunk({ - watchdog: watchdog as GenerationWatchdog, - batcher: batcher as ChunkBatcher, - messageId, - chunk, - }); - }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!abortController.signal.aborted) { - console.error( - `[chat/session] Agent execution failed for ${sessionId}:`, - message, - ); - this.deps.emitSessionError({ sessionId, error: message }); - } else if (watchdog?.wasTriggered) { - console.warn( - `[chat/session] Agent aborted by watchdog for ${sessionId}:`, - message, - ); - } - } finally { - watchdog?.clear(); - await this.streamWriter.drainChunkBatcher({ - sessionId, - batcher, - abortController, - }); - await this.streamWriter.finalizeGeneration({ - sessionId, - session, - messageId, - headers, - }); - if (this.deps.runningAgents.get(sessionId) === abortController) { - this.deps.runningAgents.delete(sessionId); - } - } - } - - resolvePermission(input: ResolvePermissionInput): void { - this.execution.resolvePermission(input); - } -} diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/agent-stream-writer.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/agent-stream-writer.ts deleted file mode 100644 index 48ffb05336b..00000000000 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/agent-stream-writer.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { ChunkBatcher } from "./chunk-batcher"; -import { GenerationWatchdog } from "./generation-watchdog"; -import { - buildProxyHeaders, - ProxyRequestError, - postJsonWithRetry, -} from "./proxy-requests"; -import type { ActiveSession, EnsureSessionReadyInput } from "./session-types"; - -const FIRST_CHUNK_TIMEOUT_MS = 30_000; -const CHUNK_INACTIVITY_TIMEOUT_MS = 45_000; -const TERMINAL_CHUNK_MAX_ATTEMPTS = 3; -const FINISH_MAX_ATTEMPTS = 3; - -interface AgentStreamWriterDeps { - proxyUrl: string; - emitSessionError: (params: { sessionId: string; error: string }) => void; - ensureSessionReady: (input: EnsureSessionReadyInput) => Promise; - isSessionActive: (sessionId: string) => boolean; -} - -export interface PrepareStreamResult { - headers: Record; - batcher: ChunkBatcher; - watchdog: GenerationWatchdog; -} - -interface PrepareStreamInput { - sessionId: string; - session: ActiveSession; - abortController: AbortController; -} - -interface DrainInput { - sessionId: string; - batcher: ChunkBatcher | null; - abortController: AbortController; -} - -interface FinalizeInput { - sessionId: string; - session: ActiveSession; - messageId: string; - headers: Record | null; -} - -export class AgentStreamWriter { - constructor(private readonly deps: AgentStreamWriterDeps) {} - - private createWatchdog({ - sessionId, - abortController, - }: { - sessionId: string; - abortController: AbortController; - }): GenerationWatchdog { - return new GenerationWatchdog(({ reason }) => { - if (abortController.signal.aborted) return; - console.error(`[chat/session] ${reason}`); - this.deps.emitSessionError({ sessionId, error: reason }); - abortController.abort(); - }); - } - - private isSessionNotFoundError(error: unknown): boolean { - if (error instanceof ProxyRequestError) { - if (error.status !== 404) { - return false; - } - if (!error.code) { - return true; - } - const normalizedCode = error.code.toLowerCase(); - return ( - normalizedCode === "session_not_found" || - normalizedCode === "session not found" - ); - } - - const message = error instanceof Error ? error.message : String(error); - const normalized = message.toLowerCase(); - return ( - normalized.includes("status 404") && - (normalized.includes("session_not_found") || - normalized.includes("session not found")) - ); - } - - private async recoverRemoteSession({ - sessionId, - session, - }: { - sessionId: string; - session: ActiveSession; - }): Promise { - if (!this.deps.isSessionActive(sessionId)) { - throw new Error( - `[chat/session] Session ${sessionId} is no longer active; refusing recovery`, - ); - } - - console.warn( - `[chat/session] Remote session missing for ${sessionId}; recreating`, - ); - await this.deps.ensureSessionReady({ - sessionId, - cwd: session.cwd, - model: session.model, - permissionMode: session.permissionMode, - maxThinkingTokens: session.maxThinkingTokens, - }); - } - - private async postWithSessionRecovery({ - sessionId, - session, - url, - headers, - body, - maxAttempts, - operation, - signal, - }: { - sessionId: string; - session: ActiveSession; - url: string; - headers: Record; - body: unknown; - maxAttempts: number; - operation: string; - signal?: AbortSignal; - }): Promise { - try { - await postJsonWithRetry({ - url, - headers, - body, - maxAttempts, - operation, - signal, - }); - } catch (error) { - if (!this.isSessionNotFoundError(error)) { - throw error; - } - if (signal?.aborted) { - throw error; - } - - await this.recoverRemoteSession({ sessionId, session }); - const refreshedHeaders = await buildProxyHeaders(); - await postJsonWithRetry({ - url, - headers: refreshedHeaders, - body, - maxAttempts, - operation: `${operation} (after session restore)`, - signal, - }); - } - } - - private createChunkBatcher({ - sessionId, - session, - proxyHeaders, - abortController, - }: { - sessionId: string; - session: ActiveSession; - proxyHeaders: Record; - abortController: AbortController; - }): ChunkBatcher { - return new ChunkBatcher({ - sendBatch: async (chunks) => { - await this.postWithSessionRecovery({ - sessionId, - session, - url: `${this.deps.proxyUrl}/v1/sessions/${sessionId}/chunks/batch`, - headers: proxyHeaders, - body: { chunks }, - maxAttempts: 1, - operation: "write chunk batch", - signal: abortController.signal, - }); - }, - onFatalError: (error) => { - if (abortController.signal.aborted) return; - const detail = error instanceof Error ? error.message : String(error); - console.error( - `[chat/session] Chunk persistence failed for ${sessionId}:`, - detail, - ); - this.deps.emitSessionError({ - sessionId, - error: `Chunk persistence failed: ${detail}`, - }); - abortController.abort(); - }, - }); - } - - async prepareStream({ - sessionId, - session, - abortController, - }: PrepareStreamInput): Promise { - await this.deps.ensureSessionReady({ - sessionId, - cwd: session.cwd, - model: session.model, - permissionMode: session.permissionMode, - maxThinkingTokens: session.maxThinkingTokens, - }); - const headers = await buildProxyHeaders(); - const batcher = this.createChunkBatcher({ - sessionId, - session, - proxyHeaders: headers, - abortController, - }); - const watchdog = this.createWatchdog({ sessionId, abortController }); - watchdog.arm({ - timeoutMs: FIRST_CHUNK_TIMEOUT_MS, - reason: `No assistant response within ${FIRST_CHUNK_TIMEOUT_MS}ms`, - }); - - return { headers, batcher, watchdog }; - } - - onAssistantChunk({ - watchdog, - batcher, - messageId, - chunk, - }: { - watchdog: GenerationWatchdog; - batcher: ChunkBatcher; - messageId: string; - chunk: unknown; - }): void { - watchdog.arm({ - timeoutMs: CHUNK_INACTIVITY_TIMEOUT_MS, - reason: `Assistant stream stalled for ${CHUNK_INACTIVITY_TIMEOUT_MS}ms`, - }); - batcher.push({ - messageId, - actorId: "claude", - role: "assistant", - chunk, - }); - } - - async drainChunkBatcher({ - sessionId, - batcher, - abortController, - }: DrainInput): Promise { - if (!batcher) return; - - try { - await batcher.drain(); - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - const isAbortError = - err instanceof DOMException && err.name === "AbortError"; - if (isAbortError && abortController.signal.aborted) { - console.debug(`[chat/session] Chunk drain aborted for ${sessionId}`); - return; - } - console.error( - `[chat/session] Failed to drain chunk batcher for ${sessionId}:`, - detail, - ); - this.deps.emitSessionError({ - sessionId, - error: `Chunk drain failed: ${detail}`, - }); - } - } - - private async persistTerminalChunk({ - sessionId, - session, - messageId, - headers, - }: { - sessionId: string; - session: ActiveSession; - messageId: string; - headers: Record; - }): Promise { - const terminalChunkPayload = { - messageId, - actorId: "claude", - role: "assistant", - chunk: { type: "message-end" as const }, - }; - - try { - await this.postWithSessionRecovery({ - sessionId, - session, - url: `${this.deps.proxyUrl}/v1/sessions/${sessionId}/chunks`, - headers, - body: terminalChunkPayload, - maxAttempts: TERMINAL_CHUNK_MAX_ATTEMPTS, - operation: "write terminal chunk", - }); - return true; - } catch (err) { - console.error( - `[chat/session] Failed to write terminal chunk for ${sessionId}:`, - err, - ); - } - - try { - await this.postWithSessionRecovery({ - sessionId, - session, - url: `${this.deps.proxyUrl}/v1/sessions/${sessionId}/chunks/batch`, - headers, - body: { chunks: [terminalChunkPayload] }, - maxAttempts: TERMINAL_CHUNK_MAX_ATTEMPTS, - operation: "write terminal chunk (batch fallback)", - }); - return true; - } catch (err) { - console.error( - `[chat/session] Failed to write terminal chunk fallback for ${sessionId}:`, - err, - ); - } - - return false; - } - - private async finishGeneration({ - sessionId, - session, - messageId, - headers, - }: { - sessionId: string; - session: ActiveSession; - messageId: string; - headers: Record; - }): Promise { - try { - await this.postWithSessionRecovery({ - sessionId, - session, - url: `${this.deps.proxyUrl}/v1/sessions/${sessionId}/generations/finish`, - headers, - body: { messageId }, - maxAttempts: FINISH_MAX_ATTEMPTS, - operation: "finish generation", - }); - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - console.error( - `[chat/session] POST /generations/finish failed for ${sessionId}:`, - detail, - ); - this.deps.emitSessionError({ - sessionId, - error: `Generation finish failed: ${detail}`, - }); - } - } - - async finalizeGeneration({ - sessionId, - session, - messageId, - headers, - }: FinalizeInput): Promise { - if (!headers) return; - - const terminalChunkPersisted = await this.persistTerminalChunk({ - sessionId, - session, - messageId, - headers, - }); - if (!terminalChunkPersisted) { - this.deps.emitSessionError({ - sessionId, - error: - "Assistant completion marker failed to persist. Message may stay loading.", - }); - } - - await this.finishGeneration({ sessionId, session, messageId, headers }); - } -} diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/chunk-batcher.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/chunk-batcher.ts deleted file mode 100644 index 7d3677d3884..00000000000 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/chunk-batcher.ts +++ /dev/null @@ -1,159 +0,0 @@ -export interface ChunkPayload { - messageId: string; - actorId: string; - role: string; - chunk: unknown; -} - -export class ChunkBatcher { - private buffer: ChunkPayload[] = []; - private flushTimer: ReturnType | null = null; - private sendChain = Promise.resolve(); - private dropped = 0; - private consecutiveFailures = 0; - private fatalError: Error | null = null; - - private readonly sendBatch: (chunks: ChunkPayload[]) => Promise; - private readonly lingerMs: number; - private readonly maxBatchSize: number; - private readonly maxBufferSize: number; - private readonly maxRetries: number; - private readonly retryBaseMs: number; - private readonly onFatalError?: (error: Error) => void; - - constructor({ - sendBatch, - lingerMs = 5, - maxBatchSize = 50, - maxBufferSize = 2000, - maxRetries = 3, - retryBaseMs = 50, - onFatalError, - }: { - sendBatch: (chunks: ChunkPayload[]) => Promise; - lingerMs?: number; - maxBatchSize?: number; - maxBufferSize?: number; - maxRetries?: number; - retryBaseMs?: number; - onFatalError?: (error: Error) => void; - }) { - this.sendBatch = sendBatch; - this.lingerMs = lingerMs; - this.maxBatchSize = maxBatchSize; - this.maxBufferSize = maxBufferSize; - this.maxRetries = maxRetries; - this.retryBaseMs = retryBaseMs; - this.onFatalError = onFatalError; - } - - private setFatalError(error: unknown): void { - const normalized = - error instanceof Error ? error : new Error(String(error)); - if (this.fatalError) return; - this.fatalError = normalized; - this.onFatalError?.(normalized); - } - - private async sendWithRetry(batch: ChunkPayload[]): Promise { - for (let attempt = 0; attempt <= this.maxRetries; attempt++) { - try { - await this.sendBatch(batch); - this.consecutiveFailures = 0; - return; - } catch (err) { - if (err instanceof DOMException && err.name === "AbortError") { - throw err; - } - - this.consecutiveFailures++; - - if (attempt === this.maxRetries) { - const detail = err instanceof Error ? err.message : String(err); - throw new Error( - `[chunk-batcher] Batch failed after ${this.maxRetries + 1} attempts for ${batch.length} chunk(s): ${detail}`, - ); - } - - const delayMs = this.retryBaseMs * 2 ** attempt; - await new Promise((r) => setTimeout(r, delayMs)); - } - } - } - - push(payload: ChunkPayload): void { - if (this.fatalError) { - this.dropped++; - if (this.dropped === 1 || this.dropped % 100 === 0) { - console.warn( - `[chunk-batcher] Ignoring chunk after fatal error, dropped ${this.dropped} chunk(s)`, - ); - } - return; - } - - if (this.buffer.length >= this.maxBufferSize) { - this.buffer.shift(); - this.dropped++; - if (this.dropped === 1 || this.dropped % 100 === 0) { - console.warn( - `[chunk-batcher] Buffer full, dropped ${this.dropped} chunk(s)`, - ); - } - } - - this.buffer.push(payload); - if (this.buffer.length >= this.maxBatchSize) { - this.flush(); - } else if (!this.flushTimer) { - this.flushTimer = setTimeout(() => { - this.flushTimer = null; - this.flush(); - }, this.lingerMs); - } - } - - private flush(): void { - if (this.flushTimer) { - clearTimeout(this.flushTimer); - this.flushTimer = null; - } - if (this.buffer.length === 0) return; - if (this.fatalError) { - this.dropped += this.buffer.length; - this.buffer = []; - return; - } - - const batch = this.buffer; - this.buffer = []; - this.sendChain = this.sendChain - .catch((error: unknown) => { - this.setFatalError(error); - }) - .then(async () => { - if (this.fatalError) return; - try { - await this.sendWithRetry(batch); - } catch (error) { - this.setFatalError(error); - } - }); - } - - async drain(): Promise { - this.flush(); - await this.sendChain; - if (this.fatalError) { - throw this.fatalError; - } - } - - get droppedCount(): number { - return this.dropped; - } - - get isHealthy(): boolean { - return !this.fatalError && this.consecutiveFailures < this.maxRetries; - } -} diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/generation-watchdog.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/generation-watchdog.ts deleted file mode 100644 index 8724f0034b7..00000000000 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/generation-watchdog.ts +++ /dev/null @@ -1,26 +0,0 @@ -export class GenerationWatchdog { - private timer: ReturnType | null = null; - private triggered = false; - - constructor( - private readonly onTimeout: (params: { reason: string }) => void, - ) {} - - arm({ timeoutMs, reason }: { timeoutMs: number; reason: string }): void { - this.clear(); - this.timer = setTimeout(() => { - this.triggered = true; - this.onTimeout({ reason }); - }, timeoutMs); - } - - clear(): void { - if (!this.timer) return; - clearTimeout(this.timer); - this.timer = null; - } - - get wasTriggered(): boolean { - return this.triggered; - } -} diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/index.ts index ae7300e6723..1661174f2bf 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/index.ts @@ -6,7 +6,6 @@ import { ChatSessionManager } from "./session-manager"; export type { ClaudeStreamEvent, ErrorEvent, - PermissionRequestEvent, SessionEndEvent, SessionStartEvent, } from "./session-events"; diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/proxy-requests.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/proxy-requests.ts deleted file mode 100644 index b42b0770d7c..00000000000 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/proxy-requests.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { loadToken } from "../../../auth/utils/auth-functions"; - -const DEFAULT_RETRY_BASE_DELAY_MS = 150; - -export class ProxyRequestError extends Error { - readonly status: number; - readonly code?: string; - readonly nonRetryable: boolean; - - constructor({ - message, - status, - code, - nonRetryable, - }: { - message: string; - status: number; - code?: string; - nonRetryable: boolean; - }) { - super(message); - this.name = "ProxyRequestError"; - this.status = status; - this.code = code; - this.nonRetryable = nonRetryable; - } -} - -export async function buildProxyHeaders(): Promise> { - const { token } = await loadToken(); - if (!token) { - throw new Error("User not authenticated"); - } - return { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }; -} - -async function sleep({ ms }: { ms: number }): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -export async function postJsonWithRetry({ - url, - headers, - body, - maxAttempts, - operation, - signal, - retryBaseDelayMs = DEFAULT_RETRY_BASE_DELAY_MS, -}: { - url: string; - headers: Record; - body: unknown; - maxAttempts: number; - operation: string; - signal?: AbortSignal; - retryBaseDelayMs?: number; -}): Promise { - let lastError: Error | null = null; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - const res = await fetch(url, { - method: "POST", - headers, - signal, - body: JSON.stringify(body), - }); - if (!res.ok) { - const rawDetail = await res.text().catch(() => ""); - const detail = rawDetail.trim(); - let code: string | undefined; - try { - const parsed = JSON.parse(detail) as { code?: unknown }; - if (typeof parsed.code === "string") { - code = parsed.code; - } - } catch { - // Ignore parse failures; detail may be plaintext. - } - throw new ProxyRequestError({ - message: `${operation} failed: status ${res.status}${detail ? ` (${detail.slice(0, 300)})` : ""}`, - status: res.status, - code, - nonRetryable: res.status >= 400 && res.status < 500, - }); - } - return; - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") { - throw error; - } - if (error instanceof ProxyRequestError && error.nonRetryable) { - throw error; - } - - lastError = error instanceof Error ? error : new Error(String(error)); - if (attempt === maxAttempts) { - break; - } - await sleep({ ms: retryBaseDelayMs * 2 ** (attempt - 1) }); - } - } - - throw ( - lastError ?? new Error(`${operation} failed after ${maxAttempts} attempts`) - ); -} diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-lifecycle.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-lifecycle.ts index 3414cf7637f..90a86c07f53 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-lifecycle.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-lifecycle.ts @@ -1,6 +1,5 @@ import { getClaudeSessionId } from "@superset/agent"; import type { SessionStore } from "../session-store"; -import { buildProxyHeaders } from "./proxy-requests"; import type { ActiveSession, EnsureSessionReadyInput, @@ -55,7 +54,6 @@ interface SessionLifecycleDeps { store: SessionStore; sessions: Map; runningAgents: Map; - proxyUrl: string; emitSessionStart: (params: { sessionId: string }) => void; emitSessionEnd: (params: { sessionId: string }) => void; emitSessionError: (params: { sessionId: string; error: string }) => void; @@ -71,21 +69,6 @@ export class SessionLifecycle { permissionMode, maxThinkingTokens, }: EnsureSessionReadyInput): Promise { - const headers = await buildProxyHeaders(); - - const createRes = await fetch( - `${this.deps.proxyUrl}/v1/sessions/${sessionId}`, - { - method: "PUT", - headers, - }, - ); - if (!createRes.ok) { - throw new Error( - `PUT /v1/sessions/${sessionId} failed: ${createRes.status}`, - ); - } - this.deps.sessions.set(sessionId, { sessionId, cwd, @@ -104,53 +87,6 @@ export class SessionLifecycle { } } - private async stopRemoteSession({ - sessionId, - headers, - logContext, - logLevel, - }: { - sessionId: string; - headers: Record; - logContext: string; - logLevel: "error" | "debug"; - }): Promise { - let response: Response; - try { - response = await fetch( - `${this.deps.proxyUrl}/v1/sessions/${sessionId}/stop`, - { - method: "POST", - headers, - body: JSON.stringify({}), - }, - ); - } catch (error) { - if (logLevel === "error") { - console.error(`[chat/session] ${logContext}:`, error); - throw error; - } else { - console.debug(`[chat/session] ${logContext}:`, error); - } - return; - } - - if (response.ok) { - return; - } - - const detail = await response.text().catch(() => ""); - const normalizedDetail = detail.trim(); - const error = new Error( - `POST /v1/sessions/${sessionId}/stop failed: ${response.status}${normalizedDetail ? ` (${normalizedDetail.slice(0, 300)})` : ""}`, - ); - if (logLevel === "error") { - console.error(`[chat/session] ${logContext}:`, error.message); - throw error; - } - console.debug(`[chat/session] ${logContext}:`, error.message); - } - private async updateDeactivatedSessionMeta({ sessionId, }: { @@ -266,12 +202,6 @@ export class SessionLifecycle { console.log(`[chat/session] Interrupting session ${sessionId}`); this.abortRunningAgent({ sessionId }); - await this.stopRemoteSession({ - sessionId, - headers: await buildProxyHeaders(), - logContext: "Interrupt proxy stop failed", - logLevel: "error", - }); } async deactivateSession({ @@ -283,12 +213,6 @@ export class SessionLifecycle { console.log(`[chat/session] Deactivating session ${sessionId}`); this.abortRunningAgent({ sessionId }); - await this.stopRemoteSession({ - sessionId, - headers: await buildProxyHeaders(), - logContext: "Stop during deactivate failed", - logLevel: "debug", - }); try { await this.updateDeactivatedSessionMeta({ sessionId }); @@ -305,39 +229,8 @@ export class SessionLifecycle { async deleteSession({ sessionId }: DeleteSessionInput): Promise { console.log(`[chat/session] Deleting session ${sessionId}`); - const headers = await buildProxyHeaders(); this.abortRunningAgent({ sessionId }); - await this.stopRemoteSession({ - sessionId, - headers, - logContext: "Stop during delete failed", - logLevel: "debug", - }); - - try { - const response = await fetch( - `${this.deps.proxyUrl}/v1/sessions/${sessionId}`, - { - method: "DELETE", - headers, - }, - ); - if (!response.ok) { - const detail = await response.text().catch(() => ""); - const normalizedDetail = detail.trim(); - throw new Error( - `DELETE /v1/sessions/${sessionId} failed: ${response.status}${normalizedDetail ? ` (${normalizedDetail.slice(0, 300)})` : ""}`, - ); - } - } catch (err) { - console.error(`[chat/session] DELETE request failed:`, err); - if (err instanceof Error) { - throw err; - } - throw new Error(String(err)); - } - await this.deps.store.archive(sessionId); this.deps.sessions.delete(sessionId); this.deps.emitSessionEnd({ sessionId }); diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts index 3d0b1f4bb21..5c904348560 100644 --- a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts @@ -1,14 +1,7 @@ import { EventEmitter } from "node:events"; -import { env } from "main/env.main"; import type { SessionStore } from "../session-store"; -import { - AgentRunner, - type ResolvePermissionInput, - type StartAgentInput, -} from "./agent-runner"; import type { ErrorEvent, - PermissionRequestEvent, SessionEndEvent, SessionStartEvent, } from "./session-events"; @@ -24,13 +17,10 @@ import { } from "./session-lifecycle"; import type { ActiveSession } from "./session-types"; -const PROXY_URL = env.NEXT_PUBLIC_STREAMS_URL; - export class ChatSessionManager extends EventEmitter { private readonly sessions = new Map(); private readonly runningAgents = new Map(); private readonly lifecycle: SessionLifecycle; - private readonly agentRunner: AgentRunner; constructor(readonly store: SessionStore) { super(); @@ -39,7 +29,6 @@ export class ChatSessionManager extends EventEmitter { store, sessions: this.sessions, runningAgents: this.runningAgents, - proxyUrl: PROXY_URL, emitSessionStart: ({ sessionId }) => { this.emit("event", { type: "session_start", @@ -61,24 +50,6 @@ export class ChatSessionManager extends EventEmitter { } satisfies ErrorEvent); }, }); - - this.agentRunner = new AgentRunner({ - store, - sessions: this.sessions, - runningAgents: this.runningAgents, - proxyUrl: PROXY_URL, - ensureSessionReady: (input) => this.lifecycle.ensureSessionReady(input), - emitSessionError: ({ sessionId, error }) => { - this.emit("event", { - type: "error", - sessionId, - error, - } satisfies ErrorEvent); - }, - emitPermissionRequest: (event: PermissionRequestEvent) => { - this.emit("event", event); - }, - }); } async startSession(input: StartSessionInput): Promise { @@ -89,14 +60,6 @@ export class ChatSessionManager extends EventEmitter { await this.lifecycle.restoreSession(input); } - async startAgent(input: StartAgentInput): Promise { - await this.agentRunner.startAgent(input); - } - - resolvePermission(input: ResolvePermissionInput): void { - this.agentRunner.resolvePermission(input); - } - async interrupt(input: InterruptInput): Promise { await this.lifecycle.interrupt(input); } diff --git a/apps/desktop/src/main/env.main.ts b/apps/desktop/src/main/env.main.ts index e954c2ad214..d7fb0d62768 100644 --- a/apps/desktop/src/main/env.main.ts +++ b/apps/desktop/src/main/env.main.ts @@ -15,7 +15,6 @@ export const env = createEnv({ .enum(["development", "production", "test"]) .default("development"), NEXT_PUBLIC_API_URL: z.url().default("https://api.superset.sh"), - NEXT_PUBLIC_STREAMS_URL: z.url().default("https://streams.superset.sh"), NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"), NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), NEXT_PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), @@ -28,7 +27,6 @@ export const env = createEnv({ // (spreading process.env only works at runtime, not for bundled apps) NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, - NEXT_PUBLIC_STREAMS_URL: process.env.NEXT_PUBLIC_STREAMS_URL, NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 1d79221b3bc..861251173da 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -11,11 +11,11 @@ - default-src 'self': Only allow resources from same origin - script-src 'self' 'wasm-unsafe-eval' https://*.posthog.com: Allow scripts from same origin + WebAssembly (for xterm ImageAddon) + PostHog - style-src 'self' 'unsafe-inline': Allow styles from same origin + inline (needed for CSS-in-JS) - - connect-src 'self' ws: wss: %NEXT_PUBLIC_API_URL% %NEXT_PUBLIC_ELECTRIC_URL% %NEXT_PUBLIC_STREAMS_URL% https://*.posthog.com https://*.sentry.io sentry-ipc:: Allow WebSocket + API + Electric proxy + Streams server + PostHog + Sentry + - connect-src 'self' ws: wss: %NEXT_PUBLIC_API_URL% %NEXT_PUBLIC_ELECTRIC_URL% https://*.posthog.com https://*.sentry.io sentry-ipc:: Allow WebSocket + API + Electric proxy + PostHog + Sentry - img-src 'self' data: https: Allow images from same origin + data URIs + any HTTPS source (needed for favicons from arbitrary sites in browser history) - font-src 'self': Allow fonts from same origin --> - + diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx index da99f14a433..e92f0316cc4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/ChatInterface.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { AuthPrompt } from "./components/AuthPrompt"; import { ChatInputFooter } from "./components/ChatInputFooter"; import { MessageList } from "./components/MessageList"; import { ToolApprovalBar } from "./components/ToolApprovalBar"; @@ -18,6 +19,8 @@ import type { import { hydrateMessages } from "./utils/hydrate-messages"; export function ChatInterface({ sessionId, cwd }: ChatInterfaceProps) { + const { data: authStatus } = electronTrpc.aiChat.getAuthStatus.useQuery(); + const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL); const [modelSelectorOpen, setModelSelectorOpen] = useState(false); @@ -113,24 +116,28 @@ export function ChatInterface({ sessionId, cwd }: ChatInterfaceProps) { /> )} - + {authStatus?.authenticated === false ? ( + + ) : ( + + )} ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/AuthPrompt/AuthPrompt.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/AuthPrompt/AuthPrompt.tsx new file mode 100644 index 00000000000..4c621aa1a57 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/AuthPrompt/AuthPrompt.tsx @@ -0,0 +1,65 @@ +import { Button } from "@superset/ui/button"; +import { Input } from "@superset/ui/input"; +import { KeyRound } from "lucide-react"; +import { useState } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +export function AuthPrompt() { + const [apiKey, setApiKey] = useState(""); + const [error, setError] = useState(null); + + const utils = electronTrpc.useUtils(); + const setApiKeyMutation = electronTrpc.aiChat.setApiKey.useMutation({ + onSuccess: () => { + setError(null); + void utils.aiChat.getAuthStatus.invalidate(); + }, + onError: (err) => { + setError(err.message || "Failed to set API key"); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = apiKey.trim(); + if (!trimmed) return; + setApiKeyMutation.mutate({ apiKey: trimmed }); + }; + + return ( +
+
+
+ +
+

+ Connect your Anthropic account to start chatting +

+

+ Paste your Anthropic API key or Claude OAuth token +

+
+
+ setApiKey(e.target.value)} + className="flex-1" + autoFocus + /> + +
+ {error &&

{error}

} +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/AuthPrompt/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/AuthPrompt/index.ts new file mode 100644 index 00000000000..e664ce44cba --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/AuthPrompt/index.ts @@ -0,0 +1 @@ +export { AuthPrompt } from "./AuthPrompt"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx index e9297f090d4..d0e698c8b8f 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatInputFooter/ChatInputFooter.tsx @@ -59,7 +59,7 @@ export function ChatInputFooter({ onSlashCommandSend, }: ChatInputFooterProps) { return ( -
+
{error && (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx index 1d10134d223..54ea46d26f1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ChatMessageItem/ChatMessageItem.tsx @@ -1,9 +1,3 @@ -import type { - MessagePart, - ToolCallPart, - ToolResultPart, - UIMessage, -} from "@superset/durable-session/react"; import type { ExploringGroupItem } from "@superset/ui/ai-elements/exploring-group"; import { ExploringGroup } from "@superset/ui/ai-elements/exploring-group"; import { @@ -18,6 +12,12 @@ import { ReasoningContent, ReasoningTrigger, } from "@superset/ui/ai-elements/reasoning"; +import type { + MessagePart, + ToolCallPart, + ToolResultPart, + UIMessage, +} from "@tanstack/ai"; import { HiMiniArrowPath, HiMiniClipboard } from "react-icons/hi2"; import { safeParseJson } from "../../utils/map-tool-state"; import { getToolMeta, getToolStatus } from "../../utils/tool-registry"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ContextIndicator/ContextIndicator.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ContextIndicator/ContextIndicator.tsx deleted file mode 100644 index 4432659b15f..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ContextIndicator/ContextIndicator.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { DurableChatCollections } from "@superset/durable-session/react"; -import { - Context, - ContextContent, - ContextContentBody, - ContextContentFooter, - ContextContentHeader, - ContextInputUsage, - ContextOutputUsage, - ContextTrigger, -} from "@superset/ui/ai-elements/context"; -import { useLiveQuery } from "@tanstack/react-db"; - -const MAX_TOKENS = 200_000; - -interface ContextIndicatorProps { - collections: DurableChatCollections; - modelId: string; -} - -export function ContextIndicator({ - collections, - modelId, -}: ContextIndicatorProps) { - const { data: statsRows } = useLiveQuery((q) => - q.from({ s: collections.sessionStats }).select(({ s }) => ({ ...s })), - ); - - const stats = statsRows?.[0]; - const usedTokens = stats?.totalTokens ?? 0; - const usage = { - inputTokens: stats?.promptTokens ?? 0, - outputTokens: stats?.completionTokens ?? 0, - totalTokens: stats?.totalTokens ?? 0, - }; - - return ( - - - - - -
- - -
-
- -
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ContextIndicator/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ContextIndicator/index.ts deleted file mode 100644 index 6dc9d24f2fe..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ContextIndicator/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ContextIndicator } from "./ContextIndicator"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ModelPicker/ModelPicker.tsx index b1ea3ea20ff..d629cbff386 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ModelPicker/ModelPicker.tsx @@ -20,7 +20,12 @@ function providerToLogo(provider: string): string { const lower = provider.toLowerCase(); if (lower.includes("anthropic") || lower.includes("claude")) return "anthropic"; - if (lower.includes("openai") || lower.includes("gpt")) return "openai"; + if ( + lower.includes("openai") || + lower.includes("gpt") || + lower.includes("codex") + ) + return "openai"; if (lower.includes("google") || lower.includes("gemini")) return "google"; if (lower.includes("mistral")) return "mistral"; if (lower.includes("deepseek")) return "deepseek"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/PermissionModePicker/PermissionModePicker.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/PermissionModePicker/PermissionModePicker.tsx index 7597930510b..4256a3002b3 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/PermissionModePicker/PermissionModePicker.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/PermissionModePicker/PermissionModePicker.tsx @@ -25,7 +25,7 @@ interface PermissionModeOption { const PERMISSION_MODES: PermissionModeOption[] = [ { value: "bypassPermissions", - label: "Autonomous", + label: "Auto", description: "Tools run without approval", icon: ShieldOffIcon, }, @@ -59,9 +59,9 @@ export function PermissionModePicker({ - - {active.label} - + + {active.label} + diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx index ce3de0b1081..bd3cbc82d69 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/components/ToolCallBlock/ToolCallBlock.tsx @@ -1,7 +1,3 @@ -import type { - ToolCallPart, - ToolResultPart, -} from "@superset/durable-session/react"; import { BashTool } from "@superset/ui/ai-elements/bash-tool"; import { Confirmation, @@ -15,6 +11,7 @@ import { ToolCall } from "@superset/ui/ai-elements/tool-call"; import { UserQuestionTool } from "@superset/ui/ai-elements/user-question-tool"; import { WebFetchTool } from "@superset/ui/ai-elements/web-fetch-tool"; import { WebSearchTool } from "@superset/ui/ai-elements/web-search-tool"; +import type { ToolCallPart, ToolResultPart } from "@tanstack/ai"; import { MessageCircleQuestionIcon } from "lucide-react"; import { mapApproval, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/useClaudeCodeHistory.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/useClaudeCodeHistory.ts index 6bee6deca16..df4e1c157b8 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/useClaudeCodeHistory.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/hooks/useClaudeCodeHistory/useClaudeCodeHistory.ts @@ -1,4 +1,4 @@ -import type { UIMessage } from "@superset/durable-session/react"; +import type { UIMessage } from "@tanstack/ai"; import { useEffect, useMemo, useRef } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { extractTitleFromMessages } from "../../utils/extract-title"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/map-tool-state.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/map-tool-state.ts index abd189e2de7..36d95a7acd8 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/map-tool-state.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatInterface/utils/map-tool-state.ts @@ -1,8 +1,5 @@ -import type { - ToolCallPart, - ToolResultPart, -} from "@superset/durable-session/react"; import type { ToolDisplayState } from "@superset/ui/ai-elements/tool"; +import type { ToolCallPart, ToolResultPart } from "@tanstack/ai"; export function mapToolCallState( tc: ToolCallPart, diff --git a/apps/desktop/vite/helpers.ts b/apps/desktop/vite/helpers.ts index 577f5756c68..716aa2532d6 100644 --- a/apps/desktop/vite/helpers.ts +++ b/apps/desktop/vite/helpers.ts @@ -76,10 +76,6 @@ export function htmlEnvTransformPlugin(): Plugin { process.env.NEXT_PUBLIC_ELECTRIC_URL || "https://api.superset.sh/api/electric", ).origin, - ) - .replace( - /%NEXT_PUBLIC_STREAMS_URL%/g, - process.env.NEXT_PUBLIC_STREAMS_URL || "https://streams.superset.sh", ); }, }; diff --git a/apps/streams/.gitignore b/apps/streams/.gitignore deleted file mode 100644 index edc86f10e56..00000000000 --- a/apps/streams/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -data/ -.data/ diff --git a/apps/streams/ARCHITECTURE.md b/apps/streams/ARCHITECTURE.md deleted file mode 100644 index 95a24591b50..00000000000 --- a/apps/streams/ARCHITECTURE.md +++ /dev/null @@ -1,139 +0,0 @@ -# Durable Streams Architecture - -## System Overview - -``` -┌─────────────────────────────────────────────────────────┐ -│ Clients (packages/ai-chat) │ -│ │ -│ ┌─────────────────────────┐ ┌───────────────────────┐ │ -│ │ @durable-streams/client │ │ @durable-streams/state│ │ -│ │ │ │ │ │ -│ │ DurableStream │ │ State Protocol │ │ -│ │ .create(url) │ │ │ │ │ -│ │ .append(data) │ │ ▼ TanStack DB │ │ -│ │ .read(offset?) │ │ Reactive Collections │ │ -│ │ .subscribe(cb) │ │ │ │ -│ └────────────┬────────────┘ └───────────┬───────────┘ │ -└───────────────┼───────────────────────────┼─────────────┘ - │ HTTP + SSE │ HTTP + SSE - ▼ ▼ -┌─────────────────────────────────────────────────────────┐ -│ Server (apps/streams) │ -│ │ -│ ┌───────────────────────────────────────────────────┐ │ -│ │ @durable-streams/server │ │ -│ │ │ │ -│ │ DurableStreamTestServer (port, host, dataDir) │ │ -│ │ │ │ -│ │ ┌─────────────────────────────────────────────┐ │ │ -│ │ │ HTTP Protocol │ │ │ -│ │ │ │ │ │ -│ │ │ PUT /streams/:id ─ Create stream │ │ │ -│ │ │ POST /streams/:id ─ Append data │ │ │ -│ │ │ GET /streams/:id ─ Read / SSE │ │ │ -│ │ │ HEAD /streams/:id ─ Metadata │ │ │ -│ │ │ DELETE /streams/:id ─ Delete │ │ │ -│ │ └──────────────────┬──────────────────────────┘ │ │ -│ │ │ │ │ -│ │ ▼ │ │ -│ │ FileBackedStreamStore │ │ -│ └─────────────────────┬─────────────────────────────┘ │ -└────────────────────────┼────────────────────────────────┘ - ┌────┴─────┐ - ▼ ▼ -┌─────────────────────────────────────────────────────────┐ -│ Storage (./data) │ -│ │ -│ ┌──────────────────┐ ┌────────────────────────────┐ │ -│ │ LMDB │ │ Append-Only Logs │ │ -│ │ Metadata Index │ │ Stream Data │ │ -│ └──────────────────┘ └────────────────────────────┘ │ -└─────────────────────────────────────────────────────────┘ -``` - -## Request/Response Flow - -``` -Agent A (Writer) Server Agent B (Reader) - │ │ │ - │ PUT /streams/session-123 │ │ - │ Content-Type: app/json │ │ - │ ─────────────────────────>│ │ - │ │ │ - │ 201 Created │ │ - │ Stream-Next-Offset: 0_0 │ │ - │ <─────────────────────────│ │ - │ │ │ - │ POST /streams/session-123│ │ - │ Producer-Id: agent-a │ │ - │ Producer-Epoch: 0 │ │ - │ Producer-Seq: 0 │ │ - │ [{"type":"message",...}] │ │ - │ ─────────────────────────>│ │ - │ │ │ - │ 204 No Content │ │ - │ Stream-Next-Offset: 0_45 │ │ - │ <─────────────────────────│ │ - │ │ │ - │ │ GET /streams/session-123 │ - │ │ Accept: text/event-stream│ - │ │ <─────────────────────────│ - │ │ │ - │ │ SSE: event: data │ - │ │ [{"type":"message",...}] │ - │ │ ─────────────────────────>│ - │ │ │ - │ POST (more messages) │ │ - │ Producer-Seq: 1 │ │ - │ ─────────────────────────>│ │ - │ │ │ - │ 204 No Content │ │ - │ <─────────────────────────│ SSE: event: data │ - │ │ (real-time update) │ - │ │ ─────────────────────────>│ -``` - -## Producer Idempotency Headers - -``` -POST Request Headers Response Headers -┌──────────────────────────────────┐ ┌────────────────────────────────┐ -│ Producer-Id ─ Unique ID │ │ Stream-Next-Offset ─ Next pos │ -│ Producer-Epoch ─ Leader election│ │ Stream-Up-To-Date ─ No more │ -│ Producer-Seq ─ Sequence num │ │ Stream-Cursor ─ Cache key │ -└────────────────┬─────────────────┘ └────────────────────────────────┘ - │ - │ Enables - ▼ -┌──────────────────────────────────┐ -│ Features │ -│ │ -│ • Idempotent Writes ─ Dedup │ -│ • Zombie Fencing ─ Stale │ -│ producer rejection │ -│ • Ordering ─ Gap │ -│ detection │ -└──────────────────────────────────┘ -``` - -## Package Dependencies - -``` -┌──────────────────────────┐ ┌───────────────────────────────────┐ -│ apps/streams │ │ packages/ai-chat │ -│ │ │ │ -│ src/index.ts │ │ src/index.ts │ -│ │ │ │ ├──▶ @durable-streams/client │ -│ ▼ │ │ │ DurableStream │ -│ @durable-streams/server │ │ │ │ -│ DurableStreamTestServer│ │ └──▶ @durable-streams/state │ -│ FileBackedStreamStore │ │ State Protocol │ -│ │ │ TanStack DB │ -└────────────┬─────────────┘ └──────┬──────────────┬─────────────┘ - │ │ │ - │ Server API │ Client API │ State API - │◄────────────────────────────┘ │ - │◄───────────────────────────────────────────┘ - │ HTTP / SSE -``` diff --git a/apps/streams/Dockerfile b/apps/streams/Dockerfile deleted file mode 100644 index 6293a12228a..00000000000 --- a/apps/streams/Dockerfile +++ /dev/null @@ -1,53 +0,0 @@ -FROM oven/bun:1.3.0 AS builder - -WORKDIR /app - -# 1. Copy package.json files for workspace resolution -COPY package.json bun.lock ./ -COPY tooling/typescript/package.json tooling/typescript/ -COPY packages/db/package.json packages/db/ -COPY packages/durable-session/package.json packages/durable-session/ -COPY packages/shared/package.json packages/shared/ -COPY packages/ui/package.json packages/ui/ -COPY apps/streams/package.json apps/streams/ - -# 2. Copy source code BEFORE install so install doesn't get overwritten -# Exclude node_modules — host uses linker=isolated which breaks in container -COPY tooling/typescript tooling/typescript -COPY packages/db/src packages/db/src -COPY packages/db/tsconfig.json packages/db/ -COPY packages/db/drizzle.config.ts packages/db/ -COPY packages/db/drizzle packages/db/drizzle -COPY packages/durable-session/src packages/durable-session/src -COPY packages/durable-session/tsconfig.json packages/durable-session/ -COPY apps/streams/src apps/streams/src -COPY apps/streams/tsconfig.json apps/streams/ - -# 3. Install deps AFTER source copy so node_modules aren't overwritten -# bunfig.toml intentionally excluded — linker=isolated causes lockfile mismatch -RUN CI=false bun install --ignore-scripts - -# 4. Build -WORKDIR /app/apps/streams -RUN ./node_modules/.bin/tsup src/index.ts --format esm --dts - -# --- Runtime --- -FROM oven/bun:1.3.0 - -WORKDIR /app - -ENV NODE_ENV=production - -COPY --from=builder /app/package.json ./ -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/packages/db ./packages/db -COPY --from=builder /app/packages/durable-session ./packages/durable-session -COPY --from=builder /app/apps/streams/dist ./apps/streams/dist -COPY --from=builder /app/apps/streams/node_modules ./apps/streams/node_modules -COPY --from=builder /app/apps/streams/package.json ./apps/streams/ - -WORKDIR /app/apps/streams - -EXPOSE 8080 - -CMD ["bun", "dist/index.js"] diff --git a/apps/streams/fly.toml b/apps/streams/fly.toml deleted file mode 100644 index 6dba976197f..00000000000 --- a/apps/streams/fly.toml +++ /dev/null @@ -1,38 +0,0 @@ -app = "superset-stream" -primary_region = "iad" - -[build] - # Relative to this fly.toml; build context set to repo root via `flyctl deploy .` - dockerfile = "Dockerfile" - -[env] - STREAMS_PORT = "8080" - STREAMS_INTERNAL_PORT = "8081" - STREAMS_INTERNAL_URL = "http://127.0.0.1:8081" - NODE_ENV = "production" - CORS_ORIGINS = "https://app.superset.sh,https://admin.superset.sh,tauri://localhost" - STREAMS_DATA_DIR = "/data" - -[http_service] - internal_port = 8080 - force_https = true - auto_stop_machines = "stop" - auto_start_machines = true - min_machines_running = 1 - processes = ["app"] - -[[http_service.checks]] - interval = "15s" - timeout = "2s" - grace_period = "5s" - method = "GET" - path = "/health" - -[mounts] - source = "stream_data" - destination = "/data" - -[[vm]] - memory = "256mb" - cpu_kind = "shared" - cpus = 1 diff --git a/apps/streams/package.json b/apps/streams/package.json deleted file mode 100644 index a959cb7320f..00000000000 --- a/apps/streams/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@superset/streams", - "version": "0.0.1", - "description": "Durable Stream server for real-time token streaming", - "type": "module", - "main": "./dist/index.js", - "scripts": { - "dev": "bun --env-file=../../.env --watch src/index.ts", - "build": "tsup src/index.ts --format esm --dts", - "start": "node dist/index.js", - "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests", - "test:conformance": "npx @durable-streams/server-conformance-tests --run http://localhost:8080" - }, - "dependencies": { - "@durable-streams/client": "^0.2.1", - "@durable-streams/server": "^0.2.0", - "@hono/node-server": "^1.13.0", - "@superset/db": "workspace:*", - "@superset/durable-session": "workspace:*", - "@t3-oss/env-core": "^0.13.8", - "@tanstack/ai": "^0.3.0", - "@tanstack/db": "0.5.25", - "drizzle-orm": "0.45.1", - "hono": "^4.4.0", - "zod": "^4.3.5" - }, - "devDependencies": { - "@durable-streams/server-conformance-tests": "^0.2.0", - "@superset/typescript": "workspace:*", - "@types/node": "^24.9.1", - "fast-check": "^4.5.3", - "tsup": "^8.5.0", - "typescript": "^5.9.3", - "vitest": "^4.0.0" - } -} diff --git a/apps/streams/src/env.ts b/apps/streams/src/env.ts deleted file mode 100644 index 4cfd7b42e5d..00000000000 --- a/apps/streams/src/env.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { createEnv } from "@t3-oss/env-core"; -import { z } from "zod"; - -const DEFAULT_PORT = 8080; -const DEFAULT_INTERNAL_PORT = 8081; - -export const env = createEnv({ - server: { - STREAMS_PORT: z.coerce.number().default(DEFAULT_PORT), - STREAMS_INTERNAL_PORT: z.coerce.number().default(DEFAULT_INTERNAL_PORT), - STREAMS_INTERNAL_URL: z.string().url().optional(), - STREAMS_DATA_DIR: z.string().min(1).default("./data"), - DATABASE_URL: z.string().url(), - CORS_ORIGINS: z.string().optional(), - }, - clientPrefix: "PUBLIC_", - client: {}, - runtimeEnv: process.env, - emptyStringAsUndefined: true, - skipValidation: !!process.env.SKIP_ENV_VALIDATION, -}); diff --git a/apps/streams/src/handlers/index.ts b/apps/streams/src/handlers/index.ts deleted file mode 100644 index e62a16c8529..00000000000 --- a/apps/streams/src/handlers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { handleSendMessage } from "./send-message"; -export { createStreamWriter, StreamWriter } from "./stream-writer"; diff --git a/apps/streams/src/handlers/send-message.ts b/apps/streams/src/handlers/send-message.ts deleted file mode 100644 index c1d2389657b..00000000000 --- a/apps/streams/src/handlers/send-message.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Context } from "hono"; -import type { AIDBSessionProtocol } from "../protocol"; -import { - type SendMessageRequest, - type SendMessageResponse, - sendMessageRequestSchema, -} from "../types"; - -export async function handleSendMessage( - c: Context, - protocol: AIDBSessionProtocol, -): Promise { - const sessionId = c.req.param("sessionId"); - - let body: SendMessageRequest; - try { - const rawBody = await c.req.json(); - body = sendMessageRequestSchema.parse(rawBody); - } catch (error) { - return c.json( - { error: "Invalid request body", details: (error as Error).message }, - 400, - ); - } - - const actorId = - body.actorId ?? c.req.header("X-Actor-Id") ?? crypto.randomUUID(); - - const messageId = body.messageId ?? crypto.randomUUID(); - - try { - const stream = await protocol.getOrCreateSession(sessionId); - - await protocol.writeUserMessage( - stream, - sessionId, - messageId, - actorId, - body.content, - body.txid, - ); - - const response: SendMessageResponse = { messageId }; - return c.json(response, 200); - } catch (error) { - console.error("Failed to send message:", error); - return c.json( - { error: "Failed to send message", details: (error as Error).message }, - 500, - ); - } -} diff --git a/apps/streams/src/handlers/stream-writer.ts b/apps/streams/src/handlers/stream-writer.ts deleted file mode 100644 index 19e25d9010e..00000000000 --- a/apps/streams/src/handlers/stream-writer.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { DurableStream } from "@durable-streams/client"; -import type { AIDBSessionProtocol } from "../protocol"; -import type { StreamChunk } from "../types"; - -type MessageRole = "user" | "assistant" | "system"; - -export class StreamWriter { - constructor( - private readonly protocol: AIDBSessionProtocol, - private readonly stream: DurableStream, - private readonly sessionId: string, - ) {} - - async writeUserMessage( - messageId: string, - actorId: string, - content: string, - txid?: string, - ): Promise { - await this.protocol.writeUserMessage( - this.stream, - this.sessionId, - messageId, - actorId, - content, - txid, - ); - } - - async writeChunk( - messageId: string, - actorId: string, - role: MessageRole, - chunk: StreamChunk, - txid?: string, - ): Promise { - await this.protocol.writeChunk( - this.stream, - this.sessionId, - messageId, - actorId, - role, - chunk, - txid, - ); - } - - async writeToolResult( - messageId: string, - actorId: string, - toolCallId: string, - output: unknown, - error: string | null, - txid?: string, - ): Promise { - await this.protocol.writeToolResult( - this.stream, - this.sessionId, - messageId, - actorId, - toolCallId, - output, - error, - txid, - ); - } - - async writeApprovalResponse( - actorId: string, - approvalId: string, - approved: boolean, - txid?: string, - ): Promise { - await this.protocol.writeApprovalResponse( - this.stream, - this.sessionId, - actorId, - approvalId, - approved, - txid, - ); - } -} - -export function createStreamWriter( - protocol: AIDBSessionProtocol, - stream: DurableStream, - sessionId: string, -): StreamWriter { - return new StreamWriter(protocol, stream, sessionId); -} diff --git a/apps/streams/src/index.ts b/apps/streams/src/index.ts deleted file mode 100644 index 6c6bd575a35..00000000000 --- a/apps/streams/src/index.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { existsSync, mkdirSync } from "node:fs"; -import { DurableStreamTestServer } from "@durable-streams/server"; -import { serve } from "@hono/node-server"; -import { env } from "./env"; -import { createServer } from "./server"; - -if (!existsSync(env.STREAMS_DATA_DIR)) { - mkdirSync(env.STREAMS_DATA_DIR, { recursive: true }); -} - -const durableStreamServer = new DurableStreamTestServer({ - port: env.STREAMS_INTERNAL_PORT, - dataDir: env.STREAMS_DATA_DIR, -}); -await durableStreamServer.start(); -console.log( - `[streams] Durable stream server on port ${env.STREAMS_INTERNAL_PORT}`, -); - -const internalUrl = - env.STREAMS_INTERNAL_URL ?? `http://localhost:${env.STREAMS_INTERNAL_PORT}`; - -const corsOrigins = env.CORS_ORIGINS - ? env.CORS_ORIGINS.split(",").map((o) => o.trim()) - : undefined; - -const { app } = createServer({ - baseUrl: internalUrl, - cors: true, - corsOrigins, - logging: true, -}); - -const proxyServer = serve( - { fetch: app.fetch, port: env.STREAMS_PORT }, - (info) => { - console.log(`[streams] Proxy running on http://localhost:${info.port}`); - }, -); - -for (const signal of ["SIGINT", "SIGTERM"]) { - process.on(signal, async () => { - proxyServer.close(); - await durableStreamServer.stop(); - process.exit(0); - }); -} diff --git a/apps/streams/src/protocol.ts b/apps/streams/src/protocol.ts deleted file mode 100644 index a8f5feb126a..00000000000 --- a/apps/streams/src/protocol.ts +++ /dev/null @@ -1,582 +0,0 @@ -import { DurableStream, IdempotentProducer } from "@durable-streams/client"; -import { - createMessagesCollection, - createModelMessagesCollection, - createSessionDB, - sessionStateSchema, -} from "@superset/durable-session"; -import type { - AIDBProtocolOptions, - ProxySessionState, - StreamChunk, -} from "./types"; - -type MessageRole = "user" | "assistant" | "system"; - -const FLUSH_TIMEOUT_MS = 10_000; - -export class AIDBSessionProtocol { - private readonly baseUrl: string; - private streams = new Map(); - private producers = new Map(); - private messageSeqs = new Map(); - private sessionStates = new Map(); - private producerErrors = new Map(); - private producerHealthy = new Map(); - private activeGenerationIds = new Map(); - private sessionLocks = new Map>(); - - constructor(options: AIDBProtocolOptions) { - this.baseUrl = options.baseUrl; - } - - private async withSessionLock( - sessionId: string, - fn: () => Promise, - ): Promise { - const prev = this.sessionLocks.get(sessionId) ?? Promise.resolve(); - let release: (() => void) | undefined; - const current = new Promise((r) => { - release = r; - }); - this.sessionLocks.set(sessionId, current); - - await prev; - try { - return await fn(); - } finally { - if (this.sessionLocks.get(sessionId) === current) { - this.sessionLocks.delete(sessionId); - } - if (release) { - release(); - } - } - } - - private recordProducerError(sessionId: string, err: unknown): void { - const errors = this.producerErrors.get(sessionId); - if (errors) { - errors.push(err instanceof Error ? err : new Error(String(err))); - } - this.producerHealthy.set(sessionId, false); - } - - private drainProducerErrors(sessionId: string): Error[] { - const errors = this.producerErrors.get(sessionId); - if (!errors || errors.length === 0) return []; - const drained = [...errors]; - errors.length = 0; - return drained; - } - - async createSession(sessionId: string): Promise { - const stream = new DurableStream({ - url: `${this.baseUrl}/v1/stream/sessions/${sessionId}`, - }); - - await stream.create({ contentType: "application/json" }); - this.streams.set(sessionId, stream); - this.producerErrors.set(sessionId, []); - this.producerHealthy.set(sessionId, true); - - const producer = new IdempotentProducer(stream, `session-${sessionId}`, { - autoClaim: true, - lingerMs: 1, // Desktop ChunkBatcher already coalesces at 5ms - - maxInFlight: 5, - onError: (err) => { - console.error(`[protocol] Producer error for ${sessionId}:`, err); - this.recordProducerError(sessionId, err); - }, - }); - this.producers.set(sessionId, producer); - - await this.initializeSessionState(sessionId); - - return stream; - } - - async getOrCreateSession(sessionId: string): Promise { - let stream = this.streams.get(sessionId); - if (!stream) { - stream = await this.createSession(sessionId); - } - return stream; - } - - getSession(sessionId: string): DurableStream | undefined { - return this.streams.get(sessionId); - } - - async deleteSession(sessionId: string): Promise { - return this.withSessionLock(sessionId, async () => { - const producer = this.producers.get(sessionId); - if (producer) { - try { - await producer.flush(); - } catch (err) { - console.error( - `[protocol] Failed to flush producer for ${sessionId}:`, - err, - ); - } - try { - await producer.detach(); - } catch (err) { - console.error( - `[protocol] Failed to detach producer for ${sessionId}:`, - err, - ); - } - this.producers.delete(sessionId); - } - - const state = this.sessionStates.get(sessionId); - if (state) { - state.changeSubscription?.unsubscribe(); - state.sessionDB.close(); - } - - this.streams.delete(sessionId); - this.sessionStates.delete(sessionId); - this.producerErrors.delete(sessionId); - this.producerHealthy.delete(sessionId); - this.activeGenerationIds.delete(sessionId); - }); - } - - async resetSession(sessionId: string, _clearPresence = false): Promise { - return this.withSessionLock(sessionId, async () => { - const stream = this.streams.get(sessionId); - if (!stream) { - throw new Error(`Session ${sessionId} not found`); - } - - // Flush before reset so queued chunks are ordered before the control event - await this.flushSession(sessionId); - - await stream.append( - JSON.stringify({ headers: { control: "reset" as const } }), - ); - - const state = this.sessionStates.get(sessionId); - const generationMessageIds = new Set( - state?.activeGenerations ?? [], - ); - const activeMessageId = this.activeGenerationIds.get(sessionId); - if (activeMessageId) { - generationMessageIds.add(activeMessageId); - } - for (const generationMessageId of generationMessageIds) { - this.messageSeqs.delete(generationMessageId); - } - - this.producerErrors.set(sessionId, []); - this.producerHealthy.set(sessionId, true); - this.activeGenerationIds.delete(sessionId); - if (state) { - state.activeGenerations = []; - } - - this.updateLastActivity(sessionId); - }); - } - - private updateLastActivity(sessionId: string): void { - const state = this.sessionStates.get(sessionId); - if (state) { - state.lastActivityAt = new Date().toISOString(); - } - } - - private async initializeSessionState(sessionId: string): Promise { - const sessionDB = createSessionDB({ - sessionId, - baseUrl: this.baseUrl, - }); - - await sessionDB.preload(); - - const messages = createMessagesCollection({ - chunksCollection: sessionDB.collections.chunks, - }); - - const modelMessages = createModelMessagesCollection({ - messagesCollection: messages, - }); - - this.sessionStates.set(sessionId, { - createdAt: new Date().toISOString(), - lastActivityAt: new Date().toISOString(), - activeGenerations: [], - sessionDB, - messages, - modelMessages, - changeSubscription: null, - isReady: true, - }); - } - - private getNextSeq(messageId: string): number { - const current = this.messageSeqs.get(messageId) ?? -1; - const next = current + 1; - this.messageSeqs.set(messageId, next); - return next; - } - - private clearSeq(messageId: string): void { - this.messageSeqs.delete(messageId); - } - - async writeChunk( - _stream: DurableStream, - sessionId: string, - messageId: string, - actorId: string, - role: MessageRole, - chunk: StreamChunk, - txid?: string, - ): Promise { - const seq = this.getNextSeq(messageId); - - const event = sessionStateSchema.chunks.insert({ - key: `${messageId}:${seq}`, - value: { - messageId, - actorId, - role, - chunk: JSON.stringify(chunk), - seq, - createdAt: new Date().toISOString(), - }, - ...(txid && { headers: { txid } }), - }); - - await this.appendToStream(sessionId, JSON.stringify(event)); - this.updateLastActivity(sessionId); - } - - async writeChunks({ - sessionId, - chunks, - }: { - sessionId: string; - chunks: Array<{ - messageId: string; - actorId: string; - role: MessageRole; - chunk: StreamChunk; - txid?: string; - }>; - }): Promise { - for (const c of chunks) { - const seq = this.getNextSeq(c.messageId); - const event = sessionStateSchema.chunks.insert({ - key: `${c.messageId}:${seq}`, - value: { - messageId: c.messageId, - actorId: c.actorId, - role: c.role, - chunk: JSON.stringify(c.chunk), - seq, - createdAt: new Date().toISOString(), - }, - ...(c.txid && { headers: { txid: c.txid } }), - }); - await this.appendToStream(sessionId, JSON.stringify(event)); - } - this.updateLastActivity(sessionId); - } - - private async appendToStream( - sessionId: string, - data: string, - { flush = false }: { flush?: boolean } = {}, - ): Promise { - const producer = this.producers.get(sessionId); - const healthy = this.producerHealthy.get(sessionId) !== false; - - if (producer && healthy) { - producer.append(data); - if (flush) { - await producer.flush(); - } - return; - } - - const stream = this.streams.get(sessionId); - if (!stream) { - throw new Error(`Session ${sessionId} not found`); - } - await stream.append(data); - } - - async flushSession(sessionId: string): Promise { - const producer = this.producers.get(sessionId); - if (!producer) return; - - let timer: ReturnType | undefined; - const timeout = new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error(`Flush timed out for session ${sessionId}`)), - FLUSH_TIMEOUT_MS, - ); - }); - try { - await Promise.race([producer.flush(), timeout]); - } finally { - clearTimeout(timer); - } - this.producerHealthy.set(sessionId, true); - } - - startGeneration({ - sessionId, - messageId, - }: { - sessionId: string; - messageId: string; - }): void { - const existing = this.activeGenerationIds.get(sessionId); - if (existing) { - console.warn( - `[protocol] Overwriting active generation ${existing} with ${messageId} for ${sessionId}`, - ); - } - this.activeGenerationIds.set(sessionId, messageId); - } - - getActiveGeneration(sessionId: string): string | undefined { - return this.activeGenerationIds.get(sessionId); - } - - async finishGeneration({ - sessionId, - messageId, - }: { - sessionId: string; - messageId?: string; - }): Promise { - await this.flushSession(sessionId); - - if (messageId) { - this.clearSeq(messageId); - } - const activeMessageId = this.activeGenerationIds.get(sessionId); - if (!activeMessageId) { - // no-op - } else if (!messageId || messageId === activeMessageId) { - this.activeGenerationIds.delete(sessionId); - } else { - console.warn( - `[protocol] Ignoring stale finish for ${sessionId}: got ${messageId}, active is ${activeMessageId}`, - ); - } - - const errors = this.drainProducerErrors(sessionId); - if (errors.length > 0) { - throw new Error( - `Producer encountered ${errors.length} background error(s) during generation: ${errors.map((e) => e.message).join("; ")}`, - ); - } - } - - async writeUserMessage( - stream: DurableStream, - sessionId: string, - messageId: string, - actorId: string, - content: string, - txid?: string, - ): Promise { - const message = { - id: messageId, - role: "user" as const, - parts: [{ type: "text" as const, content }], - createdAt: new Date().toISOString(), - }; - - const event = sessionStateSchema.chunks.insert({ - key: `${messageId}:0`, - value: { - messageId, - actorId, - role: "user" as const, - chunk: JSON.stringify({ - type: "whole-message", - message, - }), - seq: 0, - createdAt: new Date().toISOString(), - }, - ...(txid && { headers: { txid } }), - }); - - // Flush producer for ordering, then write directly to stream - // so the txid header is immediately visible to subscribers. - await this.flushSession(sessionId); - await stream.append(JSON.stringify(event)); - this.updateLastActivity(sessionId); - } - - async writePresence( - _stream: DurableStream, - sessionId: string, - actorId: string, - deviceId: string, - actorType: "user" | "agent", - status: "online" | "offline" | "away", - name?: string, - ): Promise { - const event = sessionStateSchema.presence.upsert({ - key: `${actorId}:${deviceId}`, - value: { - actorId, - deviceId, - actorType, - name, - status, - lastSeenAt: new Date().toISOString(), - }, - }); - - await this.appendToStream(sessionId, JSON.stringify(event), { - flush: true, - }); - this.updateLastActivity(sessionId); - } - - async getDeviceIdsForActor( - sessionId: string, - actorId: string, - ): Promise { - const state = this.sessionStates.get(sessionId); - if (!state) { - return []; - } - - const presence = state.sessionDB.collections.presence; - const deviceIds: string[] = []; - - for (const row of presence.values()) { - if (row.actorId === actorId && row.status === "online") { - deviceIds.push(row.deviceId); - } - } - - return deviceIds; - } - - stopGeneration(_sessionId: string, _messageId: string | null): void { - // No-op: agent execution moved to desktop. Cross-client stop - // requires future signaling implementation. The /stop endpoint - // still exists so clients don't get 404. - } - - async writeToolResult( - stream: DurableStream, - sessionId: string, - messageId: string, - actorId: string, - toolCallId: string, - output: unknown, - error: string | null, - txid?: string, - ): Promise { - await this.writeChunk( - stream, - sessionId, - messageId, - actorId, - "user", - { - type: "tool-result", - toolCallId, - output, - error, - } as StreamChunk, - txid, - ); - - await this.flushSession(sessionId); - this.clearSeq(messageId); - } - - async writeApprovalResponse( - stream: DurableStream, - sessionId: string, - actorId: string, - approvalId: string, - approved: boolean, - txid?: string, - ): Promise { - const messageId = crypto.randomUUID(); - - await this.writeChunk( - stream, - sessionId, - messageId, - actorId, - "user", - { - type: "approval-response", - approvalId, - approved, - } as StreamChunk, - txid, - ); - - await this.flushSession(sessionId); - this.clearSeq(messageId); - } - - async forkSession( - sessionId: string, - _atMessageId: string | null, - newSessionId: string | null, - ): Promise<{ sessionId: string; offset: string }> { - const targetSessionId = newSessionId ?? crypto.randomUUID(); - - const sourceStream = this.streams.get(sessionId); - if (!sourceStream) { - throw new Error(`Session ${sessionId} not found`); - } - - await this.createSession(targetSessionId); - - const sourceState = this.sessionStates.get(sessionId); - if (sourceState) { - this.sessionStates.set(targetSessionId, { - ...sourceState, - createdAt: new Date().toISOString(), - lastActivityAt: new Date().toISOString(), - activeGenerations: [], - }); - } - - // TODO: Copy stream data up to atMessageId - return { - sessionId: targetSessionId, - offset: "-1", - }; - } - - async getMessageHistory( - sessionId: string, - ): Promise> { - const state = this.sessionStates.get(sessionId); - - if (!state || !state.isReady) { - console.warn( - `[Protocol] Session ${sessionId} not ready for message history`, - ); - return []; - } - - return state.modelMessages.toArray.map((msg) => ({ - role: msg.role, - content: msg.content, - })); - } -} diff --git a/apps/streams/src/routes/approvals.ts b/apps/streams/src/routes/approvals.ts deleted file mode 100644 index e1f687a488b..00000000000 --- a/apps/streams/src/routes/approvals.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Hono } from "hono"; -import { z } from "zod"; -import type { AIDBSessionProtocol } from "../protocol"; -import { approvalResponseRequestSchema } from "../types"; - -const answerRequestSchema = z.object({ - answers: z.record(z.string(), z.string()), - originalInput: z.record(z.string(), z.unknown()).optional(), -}); - -export function createApprovalRoutes(protocol: AIDBSessionProtocol) { - const app = new Hono(); - - app.post("/:sessionId/approvals/:approvalId", async (c) => { - const sessionId = c.req.param("sessionId"); - const approvalId = c.req.param("approvalId"); - - try { - const rawBody = await c.req.json(); - const body = approvalResponseRequestSchema.parse(rawBody); - - const actorId = c.req.header("X-Actor-Id") ?? crypto.randomUUID(); - - const stream = await protocol.getOrCreateSession(sessionId); - - await protocol.writeApprovalResponse( - stream, - sessionId, - actorId, - approvalId, - body.approved, - body.txid, - ); - - return new Response(null, { status: 204 }); - } catch (error) { - console.error("Failed to respond to approval:", error); - return c.json( - { - error: "Failed to respond to approval", - details: (error as Error).message, - }, - 500, - ); - } - }); - - app.post("/:sessionId/answers/:toolUseId", async (c) => { - const sessionId = c.req.param("sessionId"); - const toolUseId = c.req.param("toolUseId"); - - try { - const rawBody = await c.req.json(); - const parsed = answerRequestSchema.safeParse(rawBody); - if (!parsed.success) { - return c.json( - { error: "Invalid request body", details: parsed.error.message }, - 400, - ); - } - - console.log( - `[approvals] Received answer for ${toolUseId} in session ${sessionId}`, - ); - - return new Response(null, { status: 204 }); - } catch (error) { - console.error("Failed to process answer:", error); - return c.json( - { - error: "Failed to process answer", - details: (error as Error).message, - }, - 500, - ); - } - }); - - return app; -} diff --git a/apps/streams/src/routes/auth.ts b/apps/streams/src/routes/auth.ts deleted file mode 100644 index e18d0991bc4..00000000000 --- a/apps/streams/src/routes/auth.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { Hono } from "hono"; -import type { AIDBSessionProtocol } from "../protocol"; - -export function createAuthRoutes(protocol: AIDBSessionProtocol) { - const app = new Hono(); - - app.post("/:sessionId/login", async (c) => { - const sessionId = c.req.param("sessionId"); - - try { - let body: { actorId?: string; deviceId?: string; name?: string }; - try { - body = await c.req.json(); - } catch { - return c.json( - { error: "Invalid JSON body", code: "INVALID_BODY", sessionId }, - 400, - ); - } - - const { actorId, deviceId, name } = body as { - actorId: string; - deviceId: string; - name?: string; - }; - - if (!actorId || !deviceId) { - return c.json( - { - error: "actorId and deviceId are required", - code: "INVALID_BODY", - sessionId, - }, - 400, - ); - } - - const stream = await protocol.getOrCreateSession(sessionId); - - await protocol.writePresence( - stream, - sessionId, - actorId, - deviceId, - "user", - "online", - name ?? actorId, - ); - - return c.json({ success: true, actorId, deviceId, status: "online" }); - } catch (error) { - console.error("Failed to login:", error); - return c.json( - { - error: "Failed to login", - code: "LOGIN_FAILED", - sessionId, - }, - 500, - ); - } - }); - - app.post("/:sessionId/logout", async (c) => { - const sessionId = c.req.param("sessionId"); - - try { - const rawBody = await c.req.text(); - - let body: { actorId?: string; deviceId?: string; allDevices?: boolean }; - try { - body = JSON.parse(rawBody); - } catch (parseError) { - console.error("[AUTH] Failed to parse logout body:", parseError); - return c.json( - { error: "Invalid JSON body", code: "INVALID_BODY", sessionId }, - 400, - ); - } - - const { actorId, deviceId, allDevices } = body; - - if (!actorId) { - return c.json( - { error: "actorId is required", code: "INVALID_BODY", sessionId }, - 400, - ); - } - - if (!allDevices && !deviceId) { - return c.json( - { - error: "deviceId or allDevices is required", - code: "INVALID_BODY", - sessionId, - }, - 400, - ); - } - - const stream = protocol.getSession(sessionId); - if (!stream) { - return c.json( - { error: "Session not found", code: "SESSION_NOT_FOUND", sessionId }, - 404, - ); - } - - if (allDevices) { - const deviceIds = await protocol.getDeviceIdsForActor( - sessionId, - actorId, - ); - - for (const devId of deviceIds) { - await protocol.writePresence( - stream, - sessionId, - actorId, - devId, - "user", - "offline", - ); - } - - return c.json({ - success: true, - actorId, - devicesLoggedOut: deviceIds.length, - status: "offline", - }); - } else { - await protocol.writePresence( - stream, - sessionId, - actorId, - deviceId as string, - "user", - "offline", - ); - - return c.json({ success: true, actorId, deviceId, status: "offline" }); - } - } catch (error) { - console.error("[AUTH] Failed to logout:", error); - return c.json( - { - error: "Failed to logout", - code: "LOGOUT_FAILED", - sessionId, - }, - 500, - ); - } - }); - - return app; -} diff --git a/apps/streams/src/routes/chunks.test.ts b/apps/streams/src/routes/chunks.test.ts deleted file mode 100644 index 7f8acd57190..00000000000 --- a/apps/streams/src/routes/chunks.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { AIDBSessionProtocol } from "../protocol"; -import { createChunkRoutes } from "./chunks"; - -function createProtocolStub( - overrides: Partial = {}, -): AIDBSessionProtocol { - return { - getSession: vi.fn(() => ({})), - getActiveGeneration: vi.fn(() => undefined), - startGeneration: vi.fn(), - writeChunk: vi.fn(async () => {}), - writeChunks: vi.fn(async () => {}), - finishGeneration: vi.fn(async () => {}), - ...overrides, - } as unknown as AIDBSessionProtocol; -} - -describe("createChunkRoutes single-writer enforcement", () => { - it("rejects single chunk write when messageId mismatches active generation", async () => { - const protocol = createProtocolStub({ - getActiveGeneration: vi.fn(() => "active-message"), - }); - const app = createChunkRoutes(protocol); - - const response = await app.request("/session-1/chunks", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - messageId: "other-message", - actorId: "claude", - role: "assistant", - chunk: { type: "text-delta", text: "hello" }, - }), - }); - - expect(response.status).toBe(409); - await expect(response.json()).resolves.toMatchObject({ - code: "GENERATION_MISMATCH", - sessionId: "session-1", - messageId: "other-message", - activeMessageId: "active-message", - }); - expect(protocol.startGeneration).not.toHaveBeenCalled(); - expect(protocol.writeChunk).not.toHaveBeenCalled(); - }); - - it("rejects batch writes with mixed messageIds", async () => { - const protocol = createProtocolStub(); - const app = createChunkRoutes(protocol); - - const response = await app.request("/session-2/chunks/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - chunks: [ - { - messageId: "message-a", - actorId: "claude", - role: "assistant", - chunk: { type: "text-delta", text: "a" }, - }, - { - messageId: "message-b", - actorId: "claude", - role: "assistant", - chunk: { type: "text-delta", text: "b" }, - }, - ], - }), - }); - - expect(response.status).toBe(409); - await expect(response.json()).resolves.toMatchObject({ - code: "GENERATION_MISMATCH", - sessionId: "session-2", - messageId: "message-b", - activeMessageId: "message-a", - }); - expect(protocol.startGeneration).not.toHaveBeenCalled(); - expect(protocol.writeChunks).not.toHaveBeenCalled(); - }); - - it("rejects batch writes when active generation differs from batch messageId", async () => { - const protocol = createProtocolStub({ - getActiveGeneration: vi.fn(() => "active-message"), - }); - const app = createChunkRoutes(protocol); - - const response = await app.request("/session-3/chunks/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - chunks: [ - { - messageId: "batch-message", - actorId: "claude", - role: "assistant", - chunk: { type: "text-delta", text: "a" }, - }, - { - messageId: "batch-message", - actorId: "claude", - role: "assistant", - chunk: { type: "text-delta", text: "b" }, - }, - ], - }), - }); - - expect(response.status).toBe(409); - await expect(response.json()).resolves.toMatchObject({ - code: "GENERATION_MISMATCH", - sessionId: "session-3", - messageId: "batch-message", - activeMessageId: "active-message", - }); - expect(protocol.startGeneration).not.toHaveBeenCalled(); - expect(protocol.writeChunks).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/streams/src/routes/chunks.ts b/apps/streams/src/routes/chunks.ts deleted file mode 100644 index 927b1c2206d..00000000000 --- a/apps/streams/src/routes/chunks.ts +++ /dev/null @@ -1,352 +0,0 @@ -import { type Context, Hono } from "hono"; -import * as z from "zod"; -import type { AIDBSessionProtocol } from "../protocol"; -import type { StreamChunk } from "../types"; - -const chunkBodySchema = z.object({ - messageId: z.string(), - actorId: z.string(), - role: z.enum(["user", "assistant", "system"]), - chunk: z.record(z.string(), z.unknown()), - txid: z.string().optional(), -}); - -const finishBodySchema = z.object({ - messageId: z.string().optional(), -}); - -type ChunkBody = z.infer; -type PersistableChunk = { - messageId: string; - actorId: string; - role: ChunkBody["role"]; - chunk: StreamChunk; - txid?: string; -}; - -const VALID_ROLES = new Set(["user", "assistant", "system"]); - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isStreamChunk(value: unknown): value is StreamChunk { - return isRecord(value) && typeof value.type === "string"; -} - -export function createChunkRoutes(protocol: AIDBSessionProtocol) { - const app = new Hono(); - - const generationMismatchResponse = ({ - c, - sessionId, - messageId, - activeMessageId, - }: { - c: Context; - sessionId: string; - messageId: string; - activeMessageId: string; - }) => - c.json( - { - error: "Generation mismatch", - code: "GENERATION_MISMATCH", - sessionId, - messageId, - activeMessageId, - }, - 409, - ); - - app.post("/:id/chunks", async (c) => { - const sessionId = c.req.param("id"); - - let body: z.infer; - try { - const rawBody = await c.req.json(); - body = chunkBodySchema.parse(rawBody); - } catch (error) { - return c.json( - { - error: "Invalid request body", - code: "INVALID_BODY", - details: (error as Error).message, - }, - 400, - ); - } - - const { messageId, actorId, role, chunk, txid } = body; - - try { - const stream = protocol.getSession(sessionId); - if (!stream) { - return c.json( - { - error: "Session not found", - code: "SESSION_NOT_FOUND", - sessionId, - messageId, - }, - 404, - ); - } - - const activeMessageId = protocol.getActiveGeneration(sessionId); - if (!activeMessageId) { - protocol.startGeneration({ sessionId, messageId }); - } else if (activeMessageId !== messageId) { - return generationMismatchResponse({ - c, - sessionId, - messageId, - activeMessageId, - }); - } - - await protocol.writeChunk( - stream, - sessionId, - messageId, - actorId, - role, - chunk as never, - txid, - ); - - return c.json({ ok: true, sessionId, messageId }, 200); - } catch (error) { - console.error("[chunks] Failed to write chunk:", error); - return c.json( - { - error: "Failed to write chunk", - code: "WRITE_FAILED", - sessionId, - messageId, - details: (error as Error).message, - }, - 500, - ); - } - }); - - // Batch endpoint skips Zod for hot-path performance — this is an - // authenticated internal path from the desktop client. - app.post("/:id/chunks/batch", async (c) => { - const sessionId = c.req.param("id"); - - let chunks: PersistableChunk[]; - try { - const rawBody = await c.req.json(); - const rawChunks = rawBody?.chunks; - if (!Array.isArray(rawChunks) || rawChunks.length === 0) { - return c.json( - { - error: "chunks must be a non-empty array", - code: "INVALID_BODY", - sessionId, - }, - 400, - ); - } - - const validatedChunks: PersistableChunk[] = []; - for (const rawChunk of rawChunks) { - if (!isRecord(rawChunk)) { - return c.json( - { - error: "Each chunk must be an object", - code: "INVALID_BODY", - sessionId, - }, - 400, - ); - } - - const { messageId, actorId, role, chunk, txid } = rawChunk; - if (typeof messageId !== "string" || typeof actorId !== "string") { - return c.json( - { - error: "Each chunk must include string messageId and actorId", - code: "INVALID_BODY", - sessionId, - }, - 400, - ); - } - if ( - typeof role !== "string" || - !VALID_ROLES.has(role as ChunkBody["role"]) - ) { - return c.json( - { - error: "Each chunk must include a valid role", - code: "INVALID_BODY", - sessionId, - }, - 400, - ); - } - if (!isStreamChunk(chunk)) { - return c.json( - { - error: "Each chunk must include a chunk payload with string type", - code: "INVALID_BODY", - sessionId, - }, - 400, - ); - } - if (txid !== undefined && typeof txid !== "string") { - return c.json( - { - error: "txid must be a string when provided", - code: "INVALID_BODY", - sessionId, - }, - 400, - ); - } - - validatedChunks.push({ - messageId, - actorId, - role: role as ChunkBody["role"], - chunk, - ...(txid !== undefined ? { txid } : {}), - }); - } - chunks = validatedChunks; - } catch (error) { - return c.json( - { - error: "Invalid request body", - code: "INVALID_BODY", - sessionId, - details: (error as Error).message, - }, - 400, - ); - } - - try { - if (!protocol.getSession(sessionId)) { - return c.json( - { - error: "Session not found", - code: "SESSION_NOT_FOUND", - sessionId, - }, - 404, - ); - } - - const firstMessageId = chunks[0]?.messageId; - if (!firstMessageId) { - return c.json( - { - error: "Each chunk must include messageId", - code: "INVALID_BODY", - sessionId, - }, - 400, - ); - } - - const mixedMessageIdChunk = chunks.find( - (chunk) => chunk.messageId !== firstMessageId, - ); - if (mixedMessageIdChunk) { - return c.json( - { - error: "Batch chunks must belong to one generation", - code: "GENERATION_MISMATCH", - sessionId, - messageId: mixedMessageIdChunk.messageId, - activeMessageId: firstMessageId, - }, - 409, - ); - } - - const activeMessageId = protocol.getActiveGeneration(sessionId); - if (!activeMessageId) { - protocol.startGeneration({ sessionId, messageId: firstMessageId }); - } else if (activeMessageId !== firstMessageId) { - return generationMismatchResponse({ - c, - sessionId, - messageId: firstMessageId, - activeMessageId, - }); - } - - await protocol.writeChunks({ - sessionId, - chunks, - }); - - return c.json({ ok: true, sessionId, count: chunks.length }, 200); - } catch (error) { - console.error("[chunks] Failed to write batch:", error); - return c.json( - { - error: "Failed to write chunk batch", - code: "WRITE_FAILED", - sessionId, - details: (error as Error).message, - }, - 500, - ); - } - }); - - app.post("/:id/generations/finish", async (c) => { - const sessionId = c.req.param("id"); - - let messageId: string | undefined; - try { - const rawBody = await c.req.json(); - const parsed = finishBodySchema.parse(rawBody); - messageId = parsed.messageId; - } catch { - // No body or invalid JSON — messageId is optional - } - - try { - if (!protocol.getSession(sessionId)) { - return c.json( - { - ok: false, - error: "Session not found", - code: "SESSION_NOT_FOUND", - sessionId, - messageId, - }, - 404, - ); - } - await protocol.finishGeneration({ sessionId, messageId }); - return c.json({ ok: true, sessionId, messageId }, 200); - } catch (error) { - console.error( - "[chunks] Generation finish failed:", - (error as Error).message, - ); - return c.json( - { - ok: false, - error: "Generation finish failed", - code: "FINISH_FAILED", - sessionId, - messageId, - details: (error as Error).message, - }, - 500, - ); - } - }); - - return app; -} diff --git a/apps/streams/src/routes/fork.ts b/apps/streams/src/routes/fork.ts deleted file mode 100644 index cd133766497..00000000000 --- a/apps/streams/src/routes/fork.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Hono } from "hono"; -import type { AIDBSessionProtocol } from "../protocol"; -import { type ForkSessionResponse, forkSessionRequestSchema } from "../types"; - -export function createForkRoutes(protocol: AIDBSessionProtocol) { - const app = new Hono(); - - app.post("/:sessionId/fork", async (c) => { - const sessionId = c.req.param("sessionId"); - - try { - const rawBody = await c.req.json(); - const body = forkSessionRequestSchema.parse(rawBody); - - const result = await protocol.forkSession( - sessionId, - body.atMessageId ?? null, - body.newSessionId ?? null, - ); - - const response: ForkSessionResponse = { - sessionId: result.sessionId, - offset: result.offset, - }; - - return c.json(response, 201); - } catch (error) { - console.error("Failed to fork session:", error); - return c.json( - { error: "Failed to fork session", details: (error as Error).message }, - 500, - ); - } - }); - - return app; -} diff --git a/apps/streams/src/routes/health.ts b/apps/streams/src/routes/health.ts deleted file mode 100644 index 39a0681682a..00000000000 --- a/apps/streams/src/routes/health.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Hono } from "hono"; - -export function createHealthRoutes() { - const app = new Hono(); - - app.get("/", (c) => { - return c.json({ - status: "ok", - timestamp: new Date().toISOString(), - }); - }); - - app.get("/ready", (c) => { - return c.json({ - status: "ready", - timestamp: new Date().toISOString(), - }); - }); - - app.get("/live", (c) => { - return c.json({ - status: "live", - timestamp: new Date().toISOString(), - }); - }); - - return app; -} diff --git a/apps/streams/src/routes/index.ts b/apps/streams/src/routes/index.ts deleted file mode 100644 index fb1a0e4719a..00000000000 --- a/apps/streams/src/routes/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { createApprovalRoutes } from "./approvals"; -export { createAuthRoutes } from "./auth"; -export { createChunkRoutes } from "./chunks"; -export { createForkRoutes } from "./fork"; -export { createHealthRoutes } from "./health"; -export { createMessageRoutes } from "./messages"; -export { createSessionRoutes } from "./sessions"; -export { createStreamRoutes, PROTOCOL_RESPONSE_HEADERS } from "./stream"; -export { createToolResultRoutes } from "./tool-results"; diff --git a/apps/streams/src/routes/messages.ts b/apps/streams/src/routes/messages.ts deleted file mode 100644 index 27993d8385a..00000000000 --- a/apps/streams/src/routes/messages.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Hono } from "hono"; -import { handleSendMessage } from "../handlers/send-message"; -import type { AIDBSessionProtocol } from "../protocol"; -import { stopGenerationRequestSchema } from "../types"; - -export function createMessageRoutes(protocol: AIDBSessionProtocol) { - const app = new Hono(); - - app.post("/:sessionId/messages", async (c) => { - return handleSendMessage(c, protocol); - }); - - app.post("/:sessionId/stop", async (c) => { - const sessionId = c.req.param("sessionId"); - - try { - const rawBody = await c.req.json(); - const body = stopGenerationRequestSchema.parse(rawBody); - - await protocol.stopGeneration(sessionId, body.messageId ?? null); - - return new Response(null, { status: 204 }); - } catch (error) { - console.error("Failed to stop generation:", error); - return c.json( - { - error: "Failed to stop generation", - details: (error as Error).message, - }, - 500, - ); - } - }); - - return app; -} diff --git a/apps/streams/src/routes/sessions.ts b/apps/streams/src/routes/sessions.ts deleted file mode 100644 index 6f528dc3100..00000000000 --- a/apps/streams/src/routes/sessions.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Hono } from "hono"; -import type { AIDBSessionProtocol } from "../protocol"; - -export function createSessionRoutes(protocol: AIDBSessionProtocol) { - const app = new Hono(); - - app.put("/:sessionId", async (c) => { - const sessionId = c.req.param("sessionId"); - - try { - const _stream = await protocol.getOrCreateSession(sessionId); - - return c.json( - { - sessionId, - streamUrl: `/v1/stream/sessions/${sessionId}`, - }, - 200, - ); - } catch (error) { - console.error("Failed to create session:", error); - return c.json( - { - error: "Failed to create session", - code: "CREATE_FAILED", - sessionId, - details: (error as Error).message, - }, - 500, - ); - } - }); - - app.get("/:sessionId", async (c) => { - const sessionId = c.req.param("sessionId"); - - try { - const stream = await protocol.getSession(sessionId); - - if (!stream) { - return c.json( - { error: "Session not found", code: "SESSION_NOT_FOUND", sessionId }, - 404, - ); - } - - return c.json({ - sessionId, - streamUrl: `/v1/stream/sessions/${sessionId}`, - }); - } catch (error) { - console.error("Failed to get session:", error); - return c.json( - { - error: "Failed to get session", - code: "GET_FAILED", - sessionId, - details: (error as Error).message, - }, - 500, - ); - } - }); - - app.delete("/:sessionId", async (c) => { - const sessionId = c.req.param("sessionId"); - - try { - await protocol.deleteSession(sessionId); - return new Response(null, { status: 204 }); - } catch (error) { - console.error("Failed to delete session:", error); - return c.json( - { - error: "Failed to delete session", - code: "DELETE_FAILED", - sessionId, - details: (error as Error).message, - }, - 500, - ); - } - }); - - app.post("/:sessionId/reset", async (c) => { - const sessionId = c.req.param("sessionId"); - - try { - let clearPresence = false; - try { - const body = await c.req.json(); - clearPresence = body?.clearPresence === true; - } catch { - // No body or invalid JSON - use defaults - } - - await protocol.resetSession(sessionId, clearPresence); - - return c.json({ - success: true, - sessionId, - message: "Session reset. All connected clients will clear their state.", - }); - } catch (error) { - console.error("Failed to reset session:", error); - - if ((error as Error).message.includes("not found")) { - return c.json( - { error: "Session not found", code: "SESSION_NOT_FOUND", sessionId }, - 404, - ); - } - - return c.json( - { - error: "Failed to reset session", - code: "RESET_FAILED", - sessionId, - details: (error as Error).message, - }, - 500, - ); - } - }); - - return app; -} diff --git a/apps/streams/src/routes/stream.ts b/apps/streams/src/routes/stream.ts deleted file mode 100644 index 73d80c39025..00000000000 --- a/apps/streams/src/routes/stream.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { - DURABLE_STREAM_PROTOCOL_QUERY_PARAMS, - STREAM_CURSOR_HEADER, - STREAM_OFFSET_HEADER, - STREAM_UP_TO_DATE_HEADER, -} from "@durable-streams/client"; -import { Hono } from "hono"; - -export const PROTOCOL_RESPONSE_HEADERS = [ - STREAM_OFFSET_HEADER, - STREAM_CURSOR_HEADER, - STREAM_UP_TO_DATE_HEADER, - "Content-Type", - "Cache-Control", - "ETag", -] as const; - -const PROTOCOL_QUERY_PARAMS = DURABLE_STREAM_PROTOCOL_QUERY_PARAMS; - -const _HEADERS_TO_STRIP = [ - "content-encoding", - "content-length", - "transfer-encoding", - "connection", -] as const; - -export function createStreamRoutes(baseUrl: string) { - const app = new Hono(); - - app.get("/sessions/:sessionId", async (c) => { - const sessionId = c.req.param("sessionId"); - - const upstreamUrl = new URL(`${baseUrl}/v1/stream/sessions/${sessionId}`); - - for (const param of PROTOCOL_QUERY_PARAMS) { - const value = c.req.query(param); - if (value !== undefined) { - upstreamUrl.searchParams.set(param, value); - } - } - - try { - const upstreamResponse = await fetch(upstreamUrl.toString(), { - method: "GET", - headers: { - ...Object.fromEntries( - [...c.req.raw.headers.entries()].filter( - ([key]) => - key.toLowerCase() === "authorization" || - key.toLowerCase().startsWith("x-"), - ), - ), - }, - }); - - if (!upstreamResponse.ok) { - if (upstreamResponse.status === 404) { - return c.json({ error: "Stream not found" }, 404); - } - - const errorText = await upstreamResponse - .text() - .catch(() => "Unknown error"); - return c.json( - { - error: "Upstream error", - status: upstreamResponse.status, - details: errorText, - }, - upstreamResponse.status as 400 | 500, - ); - } - - const responseHeaders = new Headers(); - - for (const header of PROTOCOL_RESPONSE_HEADERS) { - const value = upstreamResponse.headers.get(header); - if (value !== null) { - responseHeaders.set(header, value); - } - } - - if (upstreamResponse.status === 204) { - const nextOffset = upstreamResponse.headers.get(STREAM_OFFSET_HEADER); - if (nextOffset) { - c.header(STREAM_OFFSET_HEADER, nextOffset); - } - return c.body(null, 204); - } - - if (!upstreamResponse.body) { - for (const [key, value] of responseHeaders.entries()) { - c.header(key, value); - } - return c.body(null, upstreamResponse.status as 200); - } - - for (const [key, value] of responseHeaders.entries()) { - c.header(key, value); - } - c.status(upstreamResponse.status as 200); - return c.body(upstreamResponse.body); - } catch (error) { - console.error("Stream proxy error:", error); - return c.json( - { - error: "Failed to proxy stream request", - details: (error as Error).message, - }, - 502, - ); - } - }); - - return app; -} diff --git a/apps/streams/src/routes/tool-results.ts b/apps/streams/src/routes/tool-results.ts deleted file mode 100644 index ca7e29912dc..00000000000 --- a/apps/streams/src/routes/tool-results.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Hono } from "hono"; -import type { AIDBSessionProtocol } from "../protocol"; -import { toolResultRequestSchema } from "../types"; - -export function createToolResultRoutes(protocol: AIDBSessionProtocol) { - const app = new Hono(); - - app.post("/:sessionId/tool-results", async (c) => { - const sessionId = c.req.param("sessionId"); - - try { - const rawBody = await c.req.json(); - const body = toolResultRequestSchema.parse(rawBody); - - const actorId = c.req.header("X-Actor-Id") ?? crypto.randomUUID(); - const messageId = body.messageId ?? crypto.randomUUID(); - - const stream = await protocol.getOrCreateSession(sessionId); - - await protocol.writeToolResult( - stream, - sessionId, - messageId, - actorId, - body.toolCallId, - body.output, - body.error ?? null, - body.txid, - ); - - return new Response(null, { status: 204 }); - } catch (error) { - console.error("Failed to add tool result:", error); - return c.json( - { - error: "Failed to add tool result", - details: (error as Error).message, - }, - 500, - ); - } - }); - - return app; -} diff --git a/apps/streams/src/server.ts b/apps/streams/src/server.ts deleted file mode 100644 index b4757615207..00000000000 --- a/apps/streams/src/server.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { db } from "@superset/db"; -import { sessions } from "@superset/db/schema/auth"; -import { and, eq, gt } from "drizzle-orm"; -import { Hono } from "hono"; -import { cors } from "hono/cors"; -import { logger } from "hono/logger"; -import { AIDBSessionProtocol } from "./protocol"; -import { - createApprovalRoutes, - createAuthRoutes, - createChunkRoutes, - createForkRoutes, - createHealthRoutes, - createMessageRoutes, - createSessionRoutes, - createStreamRoutes, - createToolResultRoutes, - PROTOCOL_RESPONSE_HEADERS, -} from "./routes"; -import type { AIDBProtocolOptions } from "./types"; - -type SessionEnv = { - Variables: { - userId: string; - }; -}; - -export interface AIDBProxyServerOptions extends AIDBProtocolOptions { - cors?: boolean; - logging?: boolean; - corsOrigins?: string | string[]; -} - -export function createServer(options: AIDBProxyServerOptions) { - const app = new Hono(); - - const protocol = new AIDBSessionProtocol({ - baseUrl: options.baseUrl, - storage: options.storage, - }); - - if (options.cors !== false) { - const allowedOrigins = options.corsOrigins - ? Array.isArray(options.corsOrigins) - ? options.corsOrigins - : [options.corsOrigins] - : null; - - app.use( - "*", - cors({ - // When allowedOrigins is configured, use a function that also permits - // null origins (Electron file://, non-browser clients). - // Auth is enforced via Bearer tokens, not cookies, so this is safe. - origin: allowedOrigins - ? (origin) => { - if (!origin || origin === "null") return origin ?? "*"; - return allowedOrigins.includes(origin) ? origin : ""; - } - : "*", - allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - allowHeaders: [ - "Content-Type", - "Authorization", - "X-Actor-Id", - "X-Actor-Type", - "X-Session-Id", - ], - exposeHeaders: [...PROTOCOL_RESPONSE_HEADERS], - }), - ); - } - - if (options.logging !== false) { - app.use("*", logger()); - } - - app.route("/health", createHealthRoutes()); - - // No auth on health; Bearer token required on /v1/* - app.use("/v1/*", async (c, next) => { - const authorization = c.req.header("Authorization"); - if (!authorization?.startsWith("Bearer ")) { - return c.json({ error: "Unauthorized" }, 401); - } - - const token = authorization.slice(7); - const [session] = await db - .select({ userId: sessions.userId }) - .from(sessions) - .where(and(eq(sessions.token, token), gt(sessions.expiresAt, new Date()))) - .limit(1); - - if (!session) { - return c.json({ error: "Unauthorized" }, 401); - } - - c.set("userId", session.userId); - return next(); - }); - - const v1 = new Hono(); - v1.route("/sessions", createSessionRoutes(protocol)); - v1.route("/sessions", createAuthRoutes(protocol)); - v1.route("/sessions", createMessageRoutes(protocol)); - v1.route("/sessions", createToolResultRoutes(protocol)); - v1.route("/sessions", createApprovalRoutes(protocol)); - v1.route("/sessions", createForkRoutes(protocol)); - v1.route("/sessions", createChunkRoutes(protocol)); - v1.route("/stream", createStreamRoutes(options.baseUrl)); - - app.route("/v1", v1); - - app.get("/", (c) => { - return c.json({ - name: "@superset/streams", - version: "0.1.0", - endpoints: { - health: "/health", - stream: "/v1/stream/sessions/:sessionId", - sessions: "/v1/sessions/:sessionId", - messages: "/v1/sessions/:sessionId/messages", - toolResults: "/v1/sessions/:sessionId/tool-results", - approvals: "/v1/sessions/:sessionId/approvals/:approvalId", - chunks: "/v1/sessions/:sessionId/chunks", - chunksBatch: "/v1/sessions/:sessionId/chunks/batch", - generationsFinish: "/v1/sessions/:sessionId/generations/finish", - fork: "/v1/sessions/:sessionId/fork", - stop: "/v1/sessions/:sessionId/stop", - reset: "/v1/sessions/:sessionId/reset", - }, - }); - }); - - return { app, protocol }; -} - -export default createServer; diff --git a/apps/streams/src/types.ts b/apps/streams/src/types.ts deleted file mode 100644 index 00a165c1a12..00000000000 --- a/apps/streams/src/types.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { - MessageRow, - ModelMessage, - SessionDB, -} from "@superset/durable-session"; -import type { Collection } from "@tanstack/db"; -import { z } from "zod"; - -export type ActorType = "user" | "agent"; - -export interface StreamRow { - sessionId: string; - messageId: string; - actorId: string; - actorType: ActorType; - chunk: string; - createdAt: string; - seq: number; -} - -export const streamRowSchema = z.object({ - sessionId: z.string(), - messageId: z.string(), - actorId: z.string(), - actorType: z.enum(["user", "agent"]), - chunk: z.string(), - createdAt: z.string(), - seq: z.number(), -}); - -export interface SendMessageRequest { - messageId?: string; - content: string; - role?: "user" | "assistant" | "system"; - actorId?: string; - actorType?: ActorType; - txid?: string; -} - -export const sendMessageRequestSchema = z.object({ - messageId: z.string().uuid().optional(), - content: z.string(), - role: z.enum(["user", "assistant", "system"]).optional(), - actorId: z.string().optional(), - actorType: z.enum(["user", "agent"]).optional(), - txid: z.string().uuid().optional(), -}); - -export interface ToolResultRequest { - toolCallId: string; - output: unknown; - error?: string | null; - messageId?: string; - txid?: string; -} - -export const toolResultRequestSchema = z.object({ - toolCallId: z.string(), - output: z.unknown(), - error: z.string().nullable().optional(), - messageId: z.string().optional(), - txid: z.string().uuid().optional(), -}); - -export interface ApprovalResponseRequest { - approved: boolean; - txid?: string; -} - -export const approvalResponseRequestSchema = z.object({ - approved: z.boolean(), - txid: z.string().uuid().optional(), -}); - -export interface ForkSessionRequest { - atMessageId?: string | null; - newSessionId?: string | null; -} - -export const forkSessionRequestSchema = z.object({ - atMessageId: z.string().nullable().optional(), - newSessionId: z.string().uuid().nullable().optional(), -}); - -export interface StopGenerationRequest { - messageId?: string | null; -} - -export const stopGenerationRequestSchema = z.object({ - messageId: z.string().nullable().optional(), -}); - -export interface SendMessageResponse { - messageId: string; -} - -export interface ForkSessionResponse { - sessionId: string; - offset: string; -} - -/** - * Terminal semantics: - * - * - `message-end` chunk: UI signal — client flips isLoading → false - * and renders the message as complete. Sent by desktop after agent - * execution finishes (or errors). - * - * - `/generations/finish`: server lifecycle signal — flushes the - * producer, clears seq state, drains producer errors, and removes - * the active generation. Always called after the `message-end` chunk. - * - * Both are required. `message-end` is the user-visible terminal event; - * `finish` is the durable-state cleanup boundary. - */ -export interface StreamChunk { - type: string; - [key: string]: unknown; -} - -export interface SessionState { - createdAt: string; - lastActivityAt: string; - activeGenerations: string[]; -} - -export interface ProxySessionState extends SessionState { - sessionDB: SessionDB; - messages: Collection; - modelMessages: Collection; - changeSubscription: { unsubscribe: () => void } | null; - isReady: boolean; -} - -export interface AIDBProtocolOptions { - baseUrl: string; - storage?: "memory" | "durable-object"; -} diff --git a/apps/streams/tsconfig.json b/apps/streams/tsconfig.json deleted file mode 100644 index d5b88c36c60..00000000000 --- a/apps/streams/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "@superset/typescript/base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "module": "ESNext", - "moduleResolution": "bundler", - "target": "ES2022" - }, - "include": ["src/**/*.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/bun.lock b/bun.lock index ca08a64e8b7..0f974a17ca7 100644 --- a/bun.lock +++ b/bun.lock @@ -104,13 +104,12 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.77", + "version": "0.0.78", "dependencies": { "@better-auth/stripe": "1.4.18", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@durable-streams/client": "^0.2.1", "@electric-sql/client": "1.5.2", "@headless-tree/core": "^1.6.3", "@headless-tree/react": "^1.6.3", @@ -124,12 +123,12 @@ "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/desktop-mcp": "workspace:*", - "@superset/durable-session": "workspace:*", "@superset/local-db": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", "@t3-oss/env-core": "^0.13.8", + "@tanstack/ai": "^0.3.0", "@tanstack/db": "0.5.25", "@tanstack/electric-db-collection": "0.2.31", "@tanstack/react-db": "0.1.69", @@ -456,32 +455,6 @@ "typescript": "^5.9.3", }, }, - "apps/streams": { - "name": "@superset/streams", - "version": "0.0.1", - "dependencies": { - "@durable-streams/client": "^0.2.1", - "@durable-streams/server": "^0.2.0", - "@hono/node-server": "^1.13.0", - "@superset/db": "workspace:*", - "@superset/durable-session": "workspace:*", - "@t3-oss/env-core": "^0.13.8", - "@tanstack/ai": "^0.3.0", - "@tanstack/db": "0.5.25", - "drizzle-orm": "0.45.1", - "hono": "^4.4.0", - "zod": "^4.3.5", - }, - "devDependencies": { - "@durable-streams/server-conformance-tests": "^0.2.0", - "@superset/typescript": "workspace:*", - "@types/node": "^24.9.1", - "fast-check": "^4.5.3", - "tsup": "^8.5.0", - "typescript": "^5.9.3", - "vitest": "^4.0.0", - }, - }, "apps/web": { "name": "@superset/web", "version": "0.1.0", @@ -538,7 +511,6 @@ "@mastra/ai-sdk": "^1.0.4", "@mastra/core": "^1.3.0", "@mastra/memory": "^1.2.0", - "@superset/durable-session": "workspace:*", "@tanstack/ai": "^0.3.0", "@tavily/core": "^0.7.1", "cheerio": "^1.2.0", @@ -612,31 +584,6 @@ "typescript": "^5.9.3", }, }, - "packages/durable-session": { - "name": "@superset/durable-session", - "version": "0.0.1", - "dependencies": { - "@durable-streams/state": "^0.2.0", - "@standard-schema/spec": "^1.0.0", - "@tanstack/ai": "^0.3.0", - "@tanstack/db": "0.5.25", - "@tanstack/db-ivm": "^0.1.17", - "zod": "^4.3.5", - }, - "devDependencies": { - "@superset/typescript": "workspace:*", - "@superset/ui": "workspace:*", - "@types/react": "~19.2.2", - "lucide-react": "^0.563.0", - "typescript": "^5.9.3", - }, - "peerDependencies": { - "@superset/ui": "workspace:*", - "@tanstack/react-db": "^0.1.69", - "lucide-react": ">=0.300.0", - "react": "^18.0.0 || ^19.0.0", - }, - }, "packages/email": { "name": "@superset/email", "version": "0.1.0", @@ -1089,14 +1036,6 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], - "@durable-streams/client": ["@durable-streams/client@0.2.1", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", "fastq": "^1.19.1" } }, "sha512-+mGdK6TuDR9fJPo8jw6DufPfoUv6g+27xoPES76GXQc6y3val9Oe/SK2o2FV9sqqLSE19HEUSxTp0D6CZebfZw=="], - - "@durable-streams/server": ["@durable-streams/server@0.2.1", "", { "dependencies": { "@durable-streams/client": "0.2.1", "@durable-streams/state": "0.2.1", "@neophi/sieve-cache": "^1.0.0", "lmdb": "^3.3.0" } }, "sha512-34Ss6CJIJJk0i8Leo9hhNqfbiAAfthSBWiYb13wYu4Wn72vJHorrCKH5Qr0stzZR7jp+fjdbZEnim4lExxEOjg=="], - - "@durable-streams/server-conformance-tests": ["@durable-streams/server-conformance-tests@0.2.1", "", { "dependencies": { "@durable-streams/client": "0.2.1", "fast-check": "^4.4.0", "vitest": "^4.0.0" }, "bin": { "server-conformance-tests": "dist/cli.js", "durable-streams-server-conformance": "dist/cli.js", "durable-streams-server-conformance-dev": "bin/conformance-dev.mjs" } }, "sha512-Us94fOweskeGqWNkQJo1sYyPtHuvYR8BYVmpJtaJEWWNkrkXFKcirfIOsC1vdCVJJyTwSXM3ZV8EuP9vpgSNhw=="], - - "@durable-streams/state": ["@durable-streams/state@0.2.1", "", { "dependencies": { "@durable-streams/client": "0.2.1", "@standard-schema/spec": "^1.0.0" }, "peerDependencies": { "@tanstack/db": ">=0.5.0" } }, "sha512-8piCcw/y1Jz6QJn0vOf+yhovFlp3xvRbzWRWtF51ndlorofJi3pOfCXnEINuvSueuNMIJosmKOg0AEvpMKfoCw=="], - "@electric-sql/client": ["@electric-sql/client@1.5.2", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "^4.18.1" } }, "sha512-4kDyEXBhRz7+84Y1JQtKXy0FdRyXkjU/9rM9N9TOmGxWT0zuAl6dWIG/iJUu+0geBELYo8Ot8fVSMTqg5bf/Tw=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], @@ -1255,8 +1194,6 @@ "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], - "@harperfast/extended-iterable": ["@harperfast/extended-iterable@1.0.3", "", {}, "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw=="], - "@headless-tree/core": ["@headless-tree/core@1.6.3", "", {}, "sha512-en0EOaZfiCRF2B8DEnhGhSaUf3hVr9Bauye8G8aswPbHOKSyhJiN4bsczz1GvqF4Xb7Ga3LP0vLA6Zih7YLoyw=="], "@headless-tree/react": ["@headless-tree/react@1.6.3", "", { "peerDependencies": { "@headless-tree/core": "*", "react": "*", "react-dom": "*" } }, "sha512-aiRwG6e2EPBSec9uLLy9GlTvAuCtSTouU30Nwcr5ZTsYjG/i7B/ouC8f8Zu4unzo/v1h5ztbemp+EH2TPTKh+g=="], @@ -1357,20 +1294,6 @@ "@linear/sdk": ["@linear/sdk@68.1.1", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.0", "graphql": "^15.4.0" } }, "sha512-6IDbyEGsOFORRrG9fL9oQkZ1FAbdtV4W1qfT82gYPVh4UzefXQwNpljgR3Nsm1Tb11cZyf3reoJpPFqGcj01zw=="], - "@lmdb/lmdb-darwin-arm64": ["@lmdb/lmdb-darwin-arm64@3.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tpfN4kKrrMpQ+If1l8bhmoNkECJi0iOu6AEdrTJvWVC+32sLxTARX5Rsu579mPImRP9YFWfWgeRQ5oav7zApQQ=="], - - "@lmdb/lmdb-darwin-x64": ["@lmdb/lmdb-darwin-x64@3.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+a2tTfc3rmWhLAolFUWRgJtpSuu+Fw/yjn4rF406NMxhfjbMuiOUTDRvRlMFV+DzyjkwnokisskHbCWkS3Ly5w=="], - - "@lmdb/lmdb-linux-arm": ["@lmdb/lmdb-linux-arm@3.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-0EgcE6reYr8InjD7V37EgXcYrloqpxVPINy3ig1MwDSbl6LF/vXTYRH9OE1Ti1D8YZnB35ZH9aTcdfSb5lql2A=="], - - "@lmdb/lmdb-linux-arm64": ["@lmdb/lmdb-linux-arm64@3.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-aoERa5B6ywXdyFeYGQ1gbQpkMkDbEo45qVoXE5QpIRavqjnyPwjOulMkmkypkmsbJ5z4Wi0TBztON8agCTG0Vg=="], - - "@lmdb/lmdb-linux-x64": ["@lmdb/lmdb-linux-x64@3.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-SqNDY1+vpji7bh0sFH5wlWyFTOzjbDOl0/kB5RLLYDAFyd/uw3n7wyrmas3rYPpAW7z18lMOi1yKlTPv967E3g=="], - - "@lmdb/lmdb-win32-arm64": ["@lmdb/lmdb-win32-arm64@3.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-50v0O1Lt37cwrmR9vWZK5hRW0Aw+KEmxJJ75fge/zIYdvNKB/0bSMSVR5Uc2OV9JhosIUyklOmrEvavwNJ8D6w=="], - - "@lmdb/lmdb-win32-x64": ["@lmdb/lmdb-win32-x64@3.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-qwosvPyl+zpUlp3gRb7UcJ3H8S28XHCzkv0Y0EgQToXjQP91ZD67EHSCDmaLjtKhe+GVIW5om1KUpzVLA0l6pg=="], - "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], "@lukeed/uuid": ["@lukeed/uuid@2.0.1", "", { "dependencies": { "@lukeed/csprng": "^1.1.0" } }, "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w=="], @@ -1403,22 +1326,8 @@ "@monogrid/gainmap-js": ["@monogrid/gainmap-js@3.4.0", "", { "dependencies": { "promise-worker-transferable": "^1.0.4" }, "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg=="], - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], - - "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], - - "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="], - - "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="], - - "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="], - - "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="], - "@neondatabase/serverless": ["@neondatabase/serverless@1.0.2", "", { "dependencies": { "@types/node": "^22.15.30", "@types/pg": "^8.8.0" } }, "sha512-I5sbpSIAHiB+b6UttofhrN/UJXII+4tZPAq1qugzwCwLIL8EZLV7F/JyHUrEIiGgQpEXzpnjlJ+zwcEhheGvCw=="], - "@neophi/sieve-cache": ["@neophi/sieve-cache@1.5.0", "", {}, "sha512-9T3nD5q51X1d4QYW6vouKW9hBSb2Tb/wB/2XoTr4oP5SCGtp3a7aTHHewQFylred1B21/Bhev6gy4x01FPBcbQ=="], - "@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], @@ -2009,8 +1918,6 @@ "@superset/docs": ["@superset/docs@workspace:apps/docs"], - "@superset/durable-session": ["@superset/durable-session@workspace:packages/durable-session"], - "@superset/email": ["@superset/email@workspace:packages/email"], "@superset/local-db": ["@superset/local-db@workspace:packages/local-db"], @@ -2025,8 +1932,6 @@ "@superset/shared": ["@superset/shared@workspace:packages/shared"], - "@superset/streams": ["@superset/streams@workspace:apps/streams"], - "@superset/trpc": ["@superset/trpc@workspace:packages/trpc"], "@superset/typescript": ["@superset/typescript@workspace:tooling/typescript"], @@ -2703,8 +2608,6 @@ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], - "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], - "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], @@ -3251,8 +3154,6 @@ "fast-base64-decode": ["fast-base64-decode@1.0.0", "", {}, "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q=="], - "fast-check": ["fast-check@4.5.3", "", { "dependencies": { "pure-rand": "^7.0.0" } }, "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA=="], - "fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -3299,8 +3200,6 @@ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - "fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="], - "flow-enums-runtime": ["flow-enums-runtime@0.0.6", "", {}, "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw=="], "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], @@ -3629,8 +3528,6 @@ "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], - "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], - "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], "js-string-escape": ["js-string-escape@1.0.1", "", {}, "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg=="], @@ -3715,8 +3612,6 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], - "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], - "line-column-path": ["line-column-path@3.0.0", "", { "dependencies": { "type-fest": "^2.0.0" } }, "sha512-Atocnm7Wr9nuvAn97yEPQa3pcQI5eLQGBz+m6iTb+CVw+IOzYB9MrYK7jI7BfC9ISnT4Fu0eiwhAScV//rp4Hw=="], "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], @@ -3725,12 +3620,8 @@ "linkifyjs": ["linkifyjs@4.3.2", "", {}, "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="], - "lmdb": ["lmdb@3.5.1", "", { "dependencies": { "@harperfast/extended-iterable": "^1.0.3", "msgpackr": "^1.11.2", "node-addon-api": "^6.1.0", "node-gyp-build-optional-packages": "5.2.2", "ordered-binary": "^1.5.3", "weak-lru-cache": "^1.2.2" }, "optionalDependencies": { "@lmdb/lmdb-darwin-arm64": "3.5.1", "@lmdb/lmdb-darwin-x64": "3.5.1", "@lmdb/lmdb-linux-arm": "3.5.1", "@lmdb/lmdb-linux-arm64": "3.5.1", "@lmdb/lmdb-linux-x64": "3.5.1", "@lmdb/lmdb-win32-arm64": "3.5.1", "@lmdb/lmdb-win32-x64": "3.5.1" }, "bin": { "download-lmdb-prebuilds": "bin/download-prebuilds.js" } }, "sha512-NYHA0MRPjvNX+vSw8Xxg6FLKxzAG+e7Pt8RqAQA/EehzHVXq9SxDqJIN3JL1hK0dweb884y8kIh6rkWvPyg9Wg=="], - "load-json-file": ["load-json-file@7.0.1", "", {}, "sha512-Gnxj3ev3mB5TkVBGad0JM6dmLiQL+o0t23JPBZ9sd+yvSLk05mFoqKBw5N8gbbkU4TNXyqCgIrl/VM17OgUIgQ=="], - "load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="], - "loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -3989,8 +3880,6 @@ "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], - "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], - "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], "monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="], @@ -4003,10 +3892,6 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], - - "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], - "multitars": ["multitars@0.2.4", "", {}, "sha512-XgLbg1HHchFauMCQPRwMj6MSyDd5koPlTA1hM3rUFkeXzGpjU/I9fP3to7yrObE9jcN8ChIOQGrM0tV0kUZaKg=="], "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], @@ -4043,8 +3928,6 @@ "node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="], - "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], - "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], @@ -4101,8 +3984,6 @@ "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], - "ordered-binary": ["ordered-binary@1.6.1", "", {}, "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w=="], - "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], "os-locale": ["os-locale@6.0.2", "", { "dependencies": { "lcid": "^3.1.1" } }, "sha512-qIb8bzRqaN/vVqEYZ7lTAg6PonskO7xOmM7OClD28F6eFa4s5XGe4bGpHUHMoCHbNNuR0pDYFeSLiW5bnjWXIA=="], @@ -4219,8 +4100,6 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - "postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="], - "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], @@ -4323,8 +4202,6 @@ "puppeteer-core": ["puppeteer-core@24.37.3", "", { "dependencies": { "@puppeteer/browsers": "2.12.1", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1566079", "typed-query-selector": "^2.12.0", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" } }, "sha512-fokQ8gv+hNgsRWqVuP5rUjGp+wzV5aMTP3fcm8ekNabmLGlJdFHas1OdMscAH9Gzq4Qcf7cfI/Pe6wEcAqQhqg=="], - "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], - "qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="], "qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="], @@ -4901,8 +4778,6 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="], - "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], @@ -4941,8 +4816,6 @@ "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], - "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], - "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], @@ -5059,8 +4932,6 @@ "wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="], - "weak-lru-cache": ["weak-lru-cache@1.2.2", "", {}, "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw=="], - "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-vitals": ["web-vitals@4.2.4", "", {}, "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="], @@ -5639,8 +5510,6 @@ "lighthouse-logger/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - "lmdb/node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="], - "make-fetch-happen/proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], "matcher/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -5685,8 +5554,6 @@ "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], - "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], - "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "node-gyp/proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="], @@ -5855,12 +5722,6 @@ "tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], - "tsup/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], - - "tsup/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], - - "tsup/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "tsx/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "tunnel-rat/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], @@ -6279,8 +6140,6 @@ "metro/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], - "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], @@ -6321,60 +6180,6 @@ "test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "tsup/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], - - "tsup/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - - "tsup/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - - "tsup/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - - "tsup/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - - "tsup/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - - "tsup/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - - "tsup/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - - "tsup/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - - "tsup/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - - "tsup/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - - "tsup/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - - "tsup/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - - "tsup/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - - "tsup/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - - "tsup/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - - "tsup/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - - "tsup/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - - "tsup/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - - "tsup/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - - "tsup/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - - "tsup/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], - - "tsup/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], - - "tsup/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - - "tsup/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - - "tsup/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - - "tsup/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], diff --git a/docs/ai-chat-plan.md b/docs/ai-chat-plan.md deleted file mode 100644 index 00b6175b806..00000000000 --- a/docs/ai-chat-plan.md +++ /dev/null @@ -1,1195 +0,0 @@ -# Multiplayer AI Chat with Claude Code - -Build a real-time multiplayer AI chat powered by Claude Code SDK with Durable Streams for token streaming. - -## Architecture - -``` -Any Client (Web/Desktop/Mobile) -┌──────────────────────────────────────────────────────────┐ -│ useDurableChat() │ -│ @superset/durable-session (vendored) │ -│ │ -│ DurableChatClient │ -│ → collections.messages (reactive, materialized) │ -│ → collections.presence │ -│ → collections.activeGenerations │ -│ → sendMessage() (optimistic insert + POST to proxy) │ -└───────────┬──────────────────────────────────────────────┘ - │ HTTP - ▼ -┌──────────────────────────────────────────────────────────┐ -│ Durable Session Proxy (apps/streams, port 8080) │ -│ @superset/durable-session-proxy (vendored from │ -│ electric-sql/transport) │ -│ │ -│ Hono routes: │ -│ PUT /v1/sessions/:id Create session │ -│ POST /v1/sessions/:id/messages Send message │ -│ POST /v1/sessions/:id/agents Register agent │ -│ POST /v1/sessions/:id/stop Stop generation │ -│ GET /v1/stream/sessions/:id SSE stream proxy │ -│ │ -│ AIDBSessionProtocol │ -│ → writeUserMessage() to durable stream │ -│ → notifyRegisteredAgents() on new user message │ -│ → writeChunk() for each agent SSE chunk │ -│ → stopGeneration() via AbortController │ -│ │ -│ ┌────────────────────────────────────────────────┐ │ -│ │ DurableStreamTestServer (internal port 8081) │ │ -│ │ @durable-streams/server │ │ -│ │ LMDB + append-only logs │ │ -│ └────────────────────────────────────────────────┘ │ -└───────────┬──────────────────────────────────────────────┘ - │ HTTP (agent invocation) - ▼ -┌──────────────────────────────────────────────────────────┐ -│ Claude Agent Endpoint (apps/streams/src/claude-agent.ts)│ -│ │ -│ POST / receives { messages } from proxy │ -│ → Extracts latest user message │ -│ → Runs query() from @anthropic-ai/claude-agent-sdk │ -│ → Converts SDKMessage → TanStack AI SSE chunks │ -│ → Returns SSE response │ -│ → Manages multi-turn resume via claudeSessionId │ -│ │ -│ SDK Message Conversion (sdk-to-ai-chunks.ts): │ -│ stream_event (text_delta) → text-delta chunk │ -│ stream_event (tool_use start) → tool-call-start │ -│ stream_event (input_json_delta)→ tool-call-delta │ -│ stream_event (thinking_delta) → reasoning chunk │ -│ user (tool_result) → tool-result chunk │ -│ result → finish chunk │ -└──────────────────────────────────────────────────────────┘ -``` - -### Message Flow - -1. Client calls `sendMessage("fix the bug")` via `useDurableChat` -2. Optimistic insert into local `chunks` collection (instant UI update) -3. POST to proxy `/v1/sessions/:id/messages` -4. Proxy writes user message chunk to durable stream -5. Proxy detects new user message, calls registered Claude agent endpoint -6. Agent runs `query()` with Claude SDK, streams SSE chunks back -7. Proxy writes each chunk to durable stream with `messageId` + `seq` -8. Client's `SessionDB` syncs new chunks via SSE -9. `messages` collection auto-rematerializes → UI updates reactively - -## Key Design Decisions - -1. **Vendor `@electric-sql/durable-session`** — Not published to npm. Vendored from [electric-sql/transport](https://github.com/electric-sql/transport) into `packages/durable-session/` (~20 files). Required compatibility fixes for unreleased `@tanstack/db` aggregates (`collect`, `minStr`) and `@tanstack/ai` types. Gives us reactive collections, optimistic mutations, TanStack AI compatibility. -2. **Proxy pattern** — Proxy handles message writing, agent invocation, stream fan-out. Clients never write to durable stream directly. -3. **Agent endpoint** — Claude SDK runs as an "agent" the proxy calls via HTTP. Agent handles entire tool loop server-side. Returns standard TanStack AI SSE chunks. -4. **TanStack AI message format** — Messages use `parts: MessagePart[]` (TextPart, ToolCallPart, ToolResultPart, ThinkingPart) not Anthropic-specific `BetaContentBlock[]`. SDK output converted at the agent boundary. -5. **Postgres for completed messages** — Single write on completion, Electric syncs history. Durable stream is the live source of truth during streaming. -6. **`@tanstack/ai` for materialization** — Official `StreamProcessor` handles chunk accumulation. No custom materialization needed. - -## Claude SDK Streaming Format - -The Claude Agent SDK emits `SDKMessage` objects when `includePartialMessages: true`: - -```typescript -// Types: system, stream_event, assistant, user, result -type SDKMessage = - | { type: 'system'; subtype: 'init'; session_id: string } - | { type: 'stream_event'; event: RawMessageStreamEvent } - | { type: 'assistant'; message: { content: BetaContentBlock[] } } - | { type: 'user'; message: { content: ToolResultBlock[] } } - | { type: 'result'; ... } -``` - -The agent endpoint converts these to TanStack AI `StreamChunk` format before writing to the durable stream. This is a one-way conversion at the write boundary — clients never see raw SDK messages. - ---- - -## Status - -| Component | Status | -|-----------|--------| -| Claude binary download | DONE — `apps/desktop/scripts/download-claude-binary.ts` | -| Auth (buildClaudeEnv) | DONE — `apps/desktop/src/lib/trpc/routers/ai-chat/utils/auth/auth.ts` | -| Session manager (v1) | DONE — `apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts` | -| Desktop tRPC router | DONE — `apps/desktop/src/lib/trpc/routers/ai-chat/index.ts` | -| Durable stream server (v1) | DONE — `apps/streams/` (vendored proxy from electric-sql/transport) | -| Vendored durable-session client | DONE — `packages/durable-session/` (vendored from electric-sql/transport) | -| React hook (useDurableChat) | DONE — `packages/durable-session/src/react/use-durable-chat.ts` | -| ChatInput component | DONE — `packages/durable-session/src/react/components/ChatInput/` | -| PresenceBar component | DONE — `packages/durable-session/src/react/components/PresenceBar/` | -| Old ai-chat package | REMOVED — replaced by `@superset/durable-session` | -| Vendored proxy (A2) | DONE — `apps/streams/src/` (vendored from electric-sql/transport, JSON.stringify fix for DurableStream.append) | -| Claude agent endpoint (B) | DONE — `apps/streams/src/claude-agent.ts` + `apps/streams/src/sdk-to-ai-chunks.ts` | -| Database schema | NOT BUILT | -| API chat router | NOT BUILT | -| Desktop chat UI (renderer) | NOT BUILT | -| Web chat UI | NOT BUILT | -| Message rendering component | NOT BUILT | - ---- - -## Phase A: Vendor `@electric-sql/durable-session` - -Source: [electric-sql/transport](https://github.com/electric-sql/transport) (unpublished, Apache-2.0) - -Reference source cloned to `/tmp/electric-sql-transport/` via: -```bash -git clone https://github.com/electric-sql/transport.git /tmp/electric-sql-transport -``` - -### A1. Create `packages/durable-session/` — DONE - -Vendor from `packages/durable-session` + `packages/react-durable-session` in the transport repo. - -#### File-by-File Vendoring Reference - -| Source (in `/tmp/electric-sql-transport/`) | Destination | Import Changes | -|---|---|---| -| `packages/durable-session/src/index.ts` | `packages/durable-session/src/index.ts` | None (relative imports) | -| `packages/durable-session/src/client.ts` | `packages/durable-session/src/client.ts` | None (relative imports) | -| `packages/durable-session/src/collection.ts` | `packages/durable-session/src/collection.ts` | None (relative imports) | -| `packages/durable-session/src/materialize.ts` | `packages/durable-session/src/materialize.ts` | None (relative imports) | -| `packages/durable-session/src/schema.ts` | `packages/durable-session/src/schema.ts` | None (relative imports) | -| `packages/durable-session/src/types.ts` | `packages/durable-session/src/types.ts` | None (relative imports) | -| `packages/durable-session/src/collections/index.ts` | `packages/durable-session/src/collections/index.ts` | None | -| `packages/durable-session/src/collections/messages.ts` | `packages/durable-session/src/collections/messages.ts` | None | -| `packages/durable-session/src/collections/active-generations.ts` | `packages/durable-session/src/collections/active-generations.ts` | None | -| `packages/durable-session/src/collections/session-meta.ts` | `packages/durable-session/src/collections/session-meta.ts` | None | -| `packages/durable-session/src/collections/session-stats.ts` | `packages/durable-session/src/collections/session-stats.ts` | None | -| `packages/durable-session/src/collections/model-messages.ts` | `packages/durable-session/src/collections/model-messages.ts` | None | -| `packages/durable-session/src/collections/presence.ts` | `packages/durable-session/src/collections/presence.ts` | None | -| `packages/react-durable-session/src/index.ts` | `packages/durable-session/src/react/index.ts` | `@electric-sql/durable-session` → `../` | -| `packages/react-durable-session/src/types.ts` | `packages/durable-session/src/react/types.ts` | `@electric-sql/durable-session` → `../` | -| `packages/react-durable-session/src/use-durable-chat.ts` | `packages/durable-session/src/react/use-durable-chat.ts` | `@electric-sql/durable-session` → `../` | - -**Specific import changes in react files:** -```typescript -// BEFORE (in react-durable-session source): -import { DurableChatClient, messageRowToUIMessage } from '@electric-sql/durable-session' -import type { DurableChatClientOptions } from '@electric-sql/durable-session' - -// AFTER (in packages/durable-session/src/react/): -import { DurableChatClient, messageRowToUIMessage } from '..' -import type { DurableChatClientOptions } from '..' -``` - -```typescript -// BEFORE (react index.ts re-exports): -export { DurableChatClient, ... } from '@electric-sql/durable-session' - -// AFTER: -export { DurableChatClient, ... } from '..' -``` - -#### Package Configuration - -**`packages/durable-session/package.json`:** -```json -{ - "name": "@superset/durable-session", - "version": "0.0.1", - "private": true, - "type": "module", - "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" - }, - "./react": { - "types": "./src/react/index.ts", - "default": "./src/react/index.ts" - } - }, - "dependencies": { - "@durable-streams/state": "^0.2.0", - "@standard-schema/spec": "^1.0.0", - "@tanstack/ai": "^0.3.0", - "@tanstack/db": "^0.5.22", - "@tanstack/db-ivm": "^0.1.17", - "zod": "^4.1.12" - }, - "peerDependencies": { - "react": "^18.0.0 || ^19.0.0", - "@tanstack/react-db": "^0.1.66" - } -} -``` - -**`packages/durable-session/tsconfig.json`:** -```json -{ - "extends": "@superset/typescript-config/react-library.json", - "compilerOptions": { - "outDir": "./dist" - }, - "include": ["src"] -} -``` - -#### Key Internals to Understand - -**Schema** (`schema.ts`) — Three STATE-PROTOCOL collections: -```typescript -export const sessionStateSchema = createStateSchema({ - chunks: { - schema: chunkValueSchema, // messageId, actorId, role, chunk (JSON), seq, createdAt - type: 'chunk', - primaryKey: 'id', // Injected from event.key = `${messageId}:${seq}` - allowSyncWhilePersisting: true, - }, - presence: { - schema: presenceValueSchema, // actorId, deviceId, actorType, name?, status, lastSeenAt - type: 'presence', - primaryKey: 'id', // Injected from event.key = `${actorId}:${deviceId}` - }, - agents: { - schema: agentValueSchema, // agentId, name?, endpoint, triggers? - type: 'agent', - primaryKey: 'agentId', - }, -}) -``` - -**Materialization pipeline** (`materialize.ts`): -- Uses `StreamProcessor` from `@tanstack/ai` (the key dependency) -- Two paths: `WholeMessageChunk` (type: 'whole-message') for user msgs, `StreamChunk[]` for assistant msgs -- `parseChunk(row.chunk)` → JSON.parse the chunk field -- `materializeWholeMessage()` → extract UIMessage from chunk, return MessageRow -- `materializeAssistantMessage()` → sort chunks by seq, feed to StreamProcessor, get parts -- `isDoneChunk()` / stop/error chunk types mark `isComplete: true` - -**Collection pipeline** (`collections/messages.ts`): -``` -chunks → groupBy(messageId) + count(chunk) + min(createdAt) - → orderBy(startedAt, 'asc') - → fn.select(imperatively gather chunks → materializeMessage(rows)) - → getKey: row.id -``` - -Derived collections use `.fn.where()`: -- `toolCalls`: `parts.some(p => p.type === 'tool-call')` -- `pendingApprovals`: `parts.some(p => p.type === 'tool-call' && p.approval?.needsApproval && p.approval.approved === undefined)` -- `toolResults`: `parts.some(p => p.type === 'tool-result')` -- `activeGenerations`: `!message.isComplete` → maps to `ActiveGenerationRow` - -**Session DB factory** (`collection.ts`): -```typescript -const streamUrl = `${baseUrl}/v1/stream/sessions/${sessionId}` -const rawDb = createStreamDB({ - streamOptions: { url: streamUrl, headers, signal }, - state: sessionStateSchema, -}) -``` - -**Chunk key format**: `${messageId}:${seq}` — e.g., "msg-1:0", "msg-2:5" - -**React hook** (`react/use-durable-chat.ts`): -- `useCollectionData()` — SSR-safe collection subscription using `useSyncExternalStore` -- Client created synchronously in render (ref-cached by `${sessionId}:${proxyUrl}` key) -- Handles Strict Mode: checks `client.isDisposed` and recreates if needed -- Auto-connects on mount if `autoConnect: true` (default) -- Returns TanStack AI-compatible API: messages, sendMessage, isLoading, etc. - -#### Compatibility Fixes Applied - -The transport repo uses `workspace:*` (unreleased local versions) of `@tanstack/db`, `@tanstack/ai`, and `@durable-streams/state`. The published npm versions differ, requiring these fixes: - -| Issue | Fix | -|-------|-----| -| `collect` aggregate not in `@tanstack/db` v0.5.22 | Rewrote `messages.ts`, `session-stats.ts`, `presence.ts` to use `groupBy + count` as change discriminator + `fn.select` with imperative collection filtering | -| `minStr` aggregate not in `@tanstack/db` v0.5.22 | Replaced with `min()` which handles strings at runtime | -| `DoneStreamChunk` not in `@tanstack/ai` v0.3.0 | Replaced with `chunk.type === 'RUN_FINISHED'` type guard | -| `LiveMode` not in `@durable-streams/state` v0.2.1 | Removed import and re-export (was already unused in practice) | - -#### UI Components Migrated - -`ChatInput` and `PresenceBar` from the old `packages/ai-chat` were moved into `packages/durable-session/src/react/components/`. They are exported from `@superset/durable-session/react`: - -```typescript -import { ChatInput, PresenceBar } from '@superset/durable-session/react' -``` - -The old `packages/ai-chat` package has been fully removed. - -### A2. Vendor proxy into `apps/streams/` — DONE - -Vendor from `packages/durable-session-proxy` in the transport repo. - -#### File-by-File Vendoring Reference - -| Source (in `/tmp/electric-sql-transport/`) | Destination | Import Changes | -|---|---|---| -| `packages/durable-session-proxy/src/index.ts` | `packages/durable-session-proxy/src/index.ts` (re-exports only, keep for reference) | N/A | -| `packages/durable-session-proxy/src/server.ts` | `apps/streams/src/server.ts` | `@electric-sql/durable-session` → `@superset/durable-session` | -| `packages/durable-session-proxy/src/protocol.ts` | `apps/streams/src/protocol.ts` | `@electric-sql/durable-session` → `@superset/durable-session` | -| `packages/durable-session-proxy/src/types.ts` | `apps/streams/src/types.ts` | `@electric-sql/durable-session` → `@superset/durable-session` | -| `packages/durable-session-proxy/src/handlers/index.ts` | `apps/streams/src/handlers/index.ts` | None | -| `packages/durable-session-proxy/src/handlers/send-message.ts` | `apps/streams/src/handlers/send-message.ts` | None (relative) | -| `packages/durable-session-proxy/src/handlers/invoke-agent.ts` | `apps/streams/src/handlers/invoke-agent.ts` | None (relative) | -| `packages/durable-session-proxy/src/handlers/stream-writer.ts` | `apps/streams/src/handlers/stream-writer.ts` | None (relative) | -| `packages/durable-session-proxy/src/routes/index.ts` | `apps/streams/src/routes/index.ts` | None | -| `packages/durable-session-proxy/src/routes/sessions.ts` | `apps/streams/src/routes/sessions.ts` | None (relative) | -| `packages/durable-session-proxy/src/routes/messages.ts` | `apps/streams/src/routes/messages.ts` | None (relative) | -| `packages/durable-session-proxy/src/routes/agents.ts` | `apps/streams/src/routes/agents.ts` | None (relative) | -| `packages/durable-session-proxy/src/routes/stream.ts` | `apps/streams/src/routes/stream.ts` | None | -| `packages/durable-session-proxy/src/routes/tool-results.ts` | `apps/streams/src/routes/tool-results.ts` | None (relative) | -| `packages/durable-session-proxy/src/routes/approvals.ts` | `apps/streams/src/routes/approvals.ts` | None (relative) | -| `packages/durable-session-proxy/src/routes/health.ts` | `apps/streams/src/routes/health.ts` | None | -| `packages/durable-session-proxy/src/routes/auth.ts` | `apps/streams/src/routes/auth.ts` | None (relative) | -| `packages/durable-session-proxy/src/routes/fork.ts` | `apps/streams/src/routes/fork.ts` | None (relative) | - -**Import change in proxy files** (3 files: `server.ts`, `protocol.ts`, `types.ts`): -```typescript -// BEFORE: -import { sessionStateSchema, createSessionDB, ... } from '@electric-sql/durable-session' -import type { SessionDB, MessageRow, ModelMessage } from '@electric-sql/durable-session' - -// AFTER: -import { sessionStateSchema, createSessionDB, ... } from '@superset/durable-session' -import type { SessionDB, MessageRow, ModelMessage } from '@superset/durable-session' -``` - -**Replace** existing `apps/streams/src/index.ts` and **delete** `session-registry.ts`. - -#### New entrypoint: `apps/streams/src/index.ts` - -Based on vendored `dev.ts` pattern, combined with existing DurableStreamTestServer. All env vars are validated via `env.ts` (required, no defaults). - -```typescript -import { DurableStreamTestServer } from '@durable-streams/server' -import { serve } from '@hono/node-server' -import { claudeAgentApp } from './claude-agent' -import { env } from './env' -import { createServer } from './server' - -const durableStreamServer = new DurableStreamTestServer({ - port: env.STREAMS_INTERNAL_PORT, - dataDir: env.STREAMS_DATA_DIR, -}) -await durableStreamServer.start() - -const { app } = createServer({ - baseUrl: env.STREAMS_INTERNAL_URL, - cors: true, - logging: true, - authToken: env.STREAMS_SECRET, -}) - -serve({ fetch: app.fetch, port: env.PORT }) -serve({ fetch: claudeAgentApp.fetch, port: env.STREAMS_AGENT_PORT }) - -for (const signal of ['SIGINT', 'SIGTERM']) { - process.on(signal, async () => { - /* graceful shutdown */ - }) -} -``` - -#### Key Protocol Internals (`protocol.ts`, ~917 lines) - -The `AIDBSessionProtocol` class manages: - -1. **Session lifecycle**: `createSession()` → creates DurableStream + SessionDB + reactive trigger -2. **Chunk writing** via `sessionStateSchema.chunks.insert({ key, value })`: - - User messages: single chunk with `{ type: 'whole-message', message: UIMessage }` - - Agent responses: sequential chunks with TanStack AI StreamChunk objects -3. **Reactive agent triggering**: After `preload()`, subscribes to `modelMessages.subscribeChanges()` — only triggers for NEW user messages (not historical) -4. **Agent invocation**: `fetch()` to agent endpoint → parse SSE → `writeChunk()` for each data line -5. **Active generation tracking**: Map for interrupt support -6. **Stop generation**: `abortController.abort()` → writes `{ type: 'stop', reason: 'aborted' }` chunk -7. **Message history**: Reads from materialized `modelMessages` collection (not raw chunks) - -**Add to `apps/streams/package.json`:** -```json -{ - "dependencies": { - "@durable-streams/server": "^0.2.0", - "@durable-streams/client": "^0.2.0", - "@hono/node-server": "^1.13.0", - "@superset/durable-session": "workspace:*", - "@tanstack/db": "^0.5.22", - "hono": "^4.4.0", - "zod": "^4.1.12" - } -} -``` - ---- - -## Phase B: Claude Agent Endpoint - -### B1. Create `apps/streams/src/claude-agent.ts` - -Hono app that acts as an AI agent endpoint the proxy can invoke. The proxy's `invokeAgent()` calls this endpoint via `fetch()` and parses the SSE response. - -```typescript -import { Hono } from 'hono' -import { query } from '@anthropic-ai/claude-agent-sdk' -import { convertSDKMessageToSSE } from './sdk-to-ai-chunks' - -const app = new Hono() - -// Session state for multi-turn resume -const claudeSessions = new Map() // sessionId → claudeSessionId - -app.post('/', async (c) => { - const { messages, stream: shouldStream, sessionId } = await c.req.json() - - // Extract prompt from latest user message - const latestUserMessage = messages.filter(m => m.role === 'user').pop() - if (!latestUserMessage) { - return c.json({ error: 'No user message found' }, 400) - } - - const prompt = latestUserMessage.content - const claudeSessionId = claudeSessions.get(sessionId) - - // Run Claude query - const result = query({ - prompt, - options: { - ...(claudeSessionId && { resume: claudeSessionId }), - model: 'claude-sonnet-4-5-20250929', - maxTurns: 25, - }, - abortSignal: c.req.raw.signal, - }) - - // Return SSE response - const encoder = new TextEncoder() - const readable = new ReadableStream({ - async start(controller) { - try { - for await (const message of result) { - // Extract claudeSessionId from system init - if (message.type === 'system' && message.subtype === 'init') { - claudeSessions.set(sessionId, message.session_id) - continue - } - - // Convert SDKMessage → TanStack AI SSE chunks - const chunks = convertSDKMessageToSSE(message) - for (const chunk of chunks) { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)) - } - } - controller.enqueue(encoder.encode('data: [DONE]\n\n')) - controller.close() - } catch (err) { - controller.error(err) - } - }, - }) - - return new Response(readable, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - }) -}) - -export { app as claudeAgentApp } -``` - -**Integration with proxy:** Register this agent endpoint in the proxy: -```typescript -// In session startup: -await protocol.registerAgent(sessionId, { - id: 'claude', - name: 'Claude Agent', - endpoint: `http://localhost:${CLAUDE_AGENT_PORT}/`, - method: 'POST', - triggers: 'user-messages', - bodyTemplate: { sessionId }, -}) -``` - -**Session state:** Maintains `Map` for multi-turn resume. - -**Abort handling:** When proxy calls `stopGeneration()`, it aborts the fetch to this endpoint. The agent detects the abort via `c.req.raw.signal` and the `query()` call is interrupted. - -### B2. Create `apps/streams/src/sdk-to-ai-chunks.ts` - -Pure conversion module. Maps Claude SDK `SDKMessage` types to TanStack AI `StreamChunk`. - -**The proxy expects standard JSON chunks** — it reads SSE `data: {...}` lines, parses JSON, and writes each chunk to the durable stream via `protocol.writeChunk()`. The `StreamProcessor` on the client side then materializes these into `MessagePart[]`. - -#### Conversion Table - -| SDKMessage | TanStack AI Chunk | Notes | -|---|---|---| -| `stream_event` → `content_block_start` (text) | — | No chunk, wait for deltas | -| `stream_event` → `content_block_delta` (text_delta) | `{ type: "text-delta", textDelta }` | | -| `stream_event` → `content_block_start` (tool_use) | `{ type: "tool-call-streaming-start", toolCallId, toolName }` | | -| `stream_event` → `content_block_delta` (input_json_delta) | `{ type: "tool-call-delta", toolCallId, argsTextDelta }` | | -| `stream_event` → `content_block_stop` (tool_use) | `{ type: "tool-call", toolCallId, toolName, args }` | Full args from accumulator | -| `stream_event` → `content_block_start` (thinking) | — | Wait for deltas | -| `stream_event` → `content_block_delta` (thinking_delta) | `{ type: "reasoning", textDelta }` | | -| `user` (tool_result blocks) | `{ type: "tool-result", toolCallId, result }` | Server-side tool execution | -| `result` | `{ type: "done", finishReason: "stop" }` | End of agent turn, maps to `DoneStreamChunk` | -| `system` (init) | — | Extract `claudeSessionId` internally | -| `assistant` | — | Skip (stream_events already cover content) | - -#### Implementation Skeleton - -```typescript -import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk' - -interface ConversionState { - // Map content_block index → block type + metadata - activeBlocks: Map -} - -export function createConverter(): { - state: ConversionState - convert: (message: SDKMessage) => StreamChunk[] -} { - const state: ConversionState = { activeBlocks: new Map() } - - return { - state, - convert(message: SDKMessage): StreamChunk[] { - if (message.type === 'stream_event') { - return handleStreamEvent(state, message.event) - } - if (message.type === 'user') { - // tool_result from Claude's internal tool execution - return message.message.content - .filter(block => block.type === 'tool_result') - .map(block => ({ - type: 'tool-result' as const, - toolCallId: block.tool_use_id, - result: block.content, - })) - } - if (message.type === 'result') { - return [{ type: 'done' as const, finishReason: 'stop' }] - } - return [] // Skip system, assistant - }, - } -} - -// Simpler stateless wrapper -export function convertSDKMessageToSSE(message: SDKMessage): StreamChunk[] { - // ... delegates to converter -} -``` - -**ConversionState** tracks: -- Active content block indices (to correlate starts with deltas) -- JSON accumulator per tool_use block (for partial → full args on `content_block_stop`) -- Current tool call IDs per block index - -**Key TanStack AI StreamChunk types** (from `@tanstack/ai`): -```typescript -type StreamChunk = - | { type: 'text-delta'; textDelta: string } - | { type: 'tool-call-streaming-start'; toolCallId: string; toolName: string } - | { type: 'tool-call-delta'; toolCallId: string; argsTextDelta: string } - | { type: 'tool-call'; toolCallId: string; toolName: string; args: Record } - | { type: 'tool-result'; toolCallId: string; result: unknown } - | { type: 'reasoning'; textDelta: string } - | { type: 'done'; finishReason: string } -``` - ---- - -## Phase C: Update Client Packages - -### C1. ~~Update `packages/ai-chat`~~ — DONE - -`packages/ai-chat` has been fully removed. All stream client code, hooks, materialization, and UI components are now in `packages/durable-session`. Consumers import directly: - -```typescript -// Data layer -import { DurableChatClient, createDurableChatClient } from '@superset/durable-session' - -// React hooks + components -import { useDurableChat, ChatInput, PresenceBar } from '@superset/durable-session/react' -``` - -### C2. Simplify desktop session manager - -**Rewrite** `apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts`: - -**Remove entirely:** -- `StreamWatcher` class (watched stream for user_input via SSE — proxy now handles this reactively) -- `IdempotentProducer` / `createProducer` / `closeProducer` (proxy writes to durable stream) -- `processUserMessage()` (moved to Claude agent endpoint) -- `binaryPathResolver` (moved to agent endpoint env) - -**New session manager** — thin HTTP orchestrator: - -```typescript -const PROXY_URL = process.env.STREAMS_URL || 'http://localhost:8080' - -export class ClaudeSessionManager extends EventEmitter { - private activeSessions = new Map() - - async startSession({ sessionId, cwd, env }: StartSessionOptions): Promise { - // 1. Create session on proxy - await fetch(`${PROXY_URL}/v1/sessions/${sessionId}`, { method: 'PUT' }) - - // 2. Register Claude agent - await fetch(`${PROXY_URL}/v1/sessions/${sessionId}/agents`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - agents: [{ - id: 'claude', - name: 'Claude Agent', - endpoint: `http://localhost:${CLAUDE_AGENT_PORT}/`, - triggers: 'user-messages', - bodyTemplate: { sessionId, cwd, env }, - }], - }), - }) - - this.activeSessions.set(sessionId, { sessionId }) - this.emit('session:started', { sessionId }) - } - - async stopSession(sessionId: string): Promise { - // 1. Stop active generations - await this.interrupt(sessionId) - // 2. Unregister agent - await fetch(`${PROXY_URL}/v1/sessions/${sessionId}/agents/claude`, { method: 'DELETE' }) - // 3. Delete session - await fetch(`${PROXY_URL}/v1/sessions/${sessionId}`, { method: 'DELETE' }) - - this.activeSessions.delete(sessionId) - this.emit('session:stopped', { sessionId }) - } - - async interrupt(sessionId: string): Promise { - await fetch(`${PROXY_URL}/v1/sessions/${sessionId}/stop`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }) - this.emit('session:interrupted', { sessionId }) - } - - isActive(sessionId: string): boolean { - return this.activeSessions.has(sessionId) - } - - getActiveSessions(): string[] { - return [...this.activeSessions.keys()] - } -} -``` - -**tRPC router** (`apps/desktop/src/lib/trpc/routers/ai-chat/index.ts`) keeps same shape — just the session manager internals are simpler. - -### C3. Handle drafts - -Official schema has `agents` instead of `drafts`. Typing indicators come from presence `status` field. - -- Draft content → local React state / Zustand -- Typing indicator → presence `status: 'typing'` (can extend presence schema) - ---- - -## Phase D: Database Schema - -**`packages/db/src/schema/chat.ts`** (new): -```typescript -export const chatSessions = pgTable("chat_sessions", { - id: uuid().primaryKey().defaultRandom(), - organizationId: uuid("organization_id").notNull().references(() => organizations.id), - repositoryId: uuid("repository_id").references(() => repositories.id), - workspaceId: text("workspace_id"), - title: text().notNull(), - claudeSessionId: text("claude_session_id"), - cwd: text(), - createdById: uuid("created_by_id").notNull().references(() => users.id), - archivedAt: timestamp("archived_at"), - createdAt: timestamp("created_at").notNull().defaultNow(), - updatedAt: timestamp("updated_at").notNull().defaultNow().$onUpdate(() => new Date()), -}); - -export const chatMessages = pgTable("chat_messages", { - id: uuid().primaryKey().defaultRandom(), - sessionId: uuid("session_id").notNull().references(() => chatSessions.id), - organizationId: uuid("organization_id").notNull().references(() => organizations.id), - role: text().notNull(), - content: text().notNull(), - toolCalls: jsonb("tool_calls"), - inputTokens: integer("input_tokens"), - outputTokens: integer("output_tokens"), - createdById: uuid("created_by_id").references(() => users.id), - processingStartedAt: timestamp("processing_started_at"), - processingExpiresAt: timestamp("processing_expires_at"), - processedAt: timestamp("processed_at"), - processingError: text("processing_error"), - createdAt: timestamp("created_at").notNull().defaultNow(), -}); - -export const chatParticipants = pgTable("chat_participants", { - id: uuid().primaryKey().defaultRandom(), - sessionId: uuid("session_id").notNull().references(() => chatSessions.id), - userId: uuid("user_id").notNull().references(() => users.id), - role: text().notNull().default("viewer"), - joinedAt: timestamp("joined_at").notNull().defaultNow(), -}); -``` - ---- - -## Phase E: API tRPC Router - -**`packages/trpc/src/router/chat/index.ts`**: -- `createSession`, `sendMessage`, `listSessions`, `getSession`, `getMessages` -- `saveAssistantMessage` (called by desktop on completion) -- `archiveSession` - ---- - -## Phase F: Desktop Chat UI ✅ DONE - -Chat pane integrated as a tab type in the desktop workspace view. The UI connects to the durable session proxy via `useDurableChat` and manages session lifecycle through the existing `ai-chat` tRPC router. - -``` -apps/desktop/src/renderer/.../ChatPane/ -├── ChatPane.tsx -- Threads sessionId + cwd from pane store/workspace -├── ChatInterface/ -│ ├── ChatInterface.tsx -- Core: useDurableChat + tRPC session lifecycle -│ ├── constants.ts -- MODELS, SUGGESTIONS -│ ├── types.ts -- ModelOption -│ ├── utils/ -│ │ └── map-tool-state.ts -- Maps TanStack AI ToolCallPart states → ToolDisplayState -│ └── components/ -│ ├── ChatMessageItem/ -- Renders UIMessage.parts[] (text, thinking, tool-call) -│ ├── ToolCallBlock/ -- ToolCallPart + ToolResultPart → Tool + Confirmation UI -│ ├── ModelPicker/ -│ ├── ContextIndicator/ -│ └── PlanBlock/ -``` - -### Session lifecycle - -1. `ChatPane` reads `sessionId` from pane store (generated by `createChatPane()`) and `cwd` from workspace query -2. `ChatInterface` mounts → tRPC `startSession.mutate()` → main process → HTTP PUT to proxy -3. tRPC `onSuccess` → `useDurableChat.connect()` opens SSE stream from proxy -4. User sends message → `sendMessage()` → proxy → Claude agent → streamed chunks → reactive UI -5. Unmount → tRPC `stopSession.mutate()` cleans up - -### Type bridge: TanStack AI → UI components - -`packages/ui` AI element components define a local `ToolDisplayState` type that covers both TanStack AI states (`awaiting-input`, `input-complete`, `approval-requested`, `approval-responded`) and UI-only states (`input-available`, `output-available`, `output-error`, `output-denied`). The `mapToolCallState()` utility in the desktop app bridges `ToolCallPart.state` → `ToolDisplayState`. - -### Environment variables - -| Variable | Description | -|----------|-------------| -| `STREAMS_URL` | Proxy URL exposed via tRPC `getConfig` query | -| `STREAMS_SECRET` | Bearer token for authenticated proxy | - ---- - -## Phase G: Web Chat UI - -``` -apps/web/src/app/(dashboard)/chat/ -├── page.tsx -├── [sessionId]/ -│ └── page.tsx -└── components/ - ├── ChatMessageList.tsx - ├── ChatMessage.tsx - ├── ChatInput.tsx - ├── PresenceBar.tsx - └── TypingIndicator.tsx -``` - -Web uses same `useDurableChat` hook pointing at deployed proxy URL. - ---- - -## Dependencies - -**New packages needed** (all published on npm): - -| Package | Version | Used By | -|---------|---------|---------| -| `@tanstack/ai` | ^0.3.0 | durable-session (StreamProcessor for materialization) | -| `@tanstack/db-ivm` | ^0.1.17 | durable-session (incremental view maintenance) | -| `@standard-schema/spec` | ^1.0.0 | durable-session (schema validation) | -| `hono` | ^4.4.0 | apps/streams (proxy HTTP framework) | -| `@hono/node-server` | ^1.13.0 | apps/streams (Hono Node.js HTTP adapter) | - -**Already installed:** -- `@durable-streams/client` ^0.2.0, `@durable-streams/server` ^0.2.0, `@durable-streams/state` ^0.2.0 -- `@tanstack/db` 0.5.22, `@tanstack/react-db` 0.1.66 -- `@anthropic-ai/claude-agent-sdk` ^0.2.19 -- `zod` ^4.3.5 - -## Environment Variables - -All env vars are required — the streams server throws at startup if any are missing. - -```bash -# Streams server (apps/streams) -PORT=8080 # Proxy port (set by Fly.io in production) -STREAMS_INTERNAL_PORT=8081 # Internal durable stream server port -STREAMS_AGENT_PORT=9090 # Claude agent endpoint port -STREAMS_INTERNAL_URL=http://127.0.0.1:8081 # Internal durable stream server URL -STREAMS_DATA_DIR=/data # Data directory for LMDB + session persistence -STREAMS_SECRET= # Bearer token for /v1/* route auth -ANTHROPIC_API_KEY=sk-ant-... # Claude API key - -# Desktop (apps/desktop) — validated in env.main.ts, required -STREAMS_URL=http://localhost:8080 # Proxy URL exposed via tRPC getConfig -STREAMS_SECRET= # Bearer token for authenticated proxy -``` - ---- - -## Complete File Operations Summary - -### Files CREATED (vendored client — Phase A1) ✅ - -All files below are created and typechecking. Compatibility fixes applied for unreleased `@tanstack/db` aggregates (`collect`, `minStr`) and `@tanstack/ai` types (`DoneStreamChunk`). - -| Destination | Source | Status | -|---|---|---| -| `packages/durable-session/package.json` | NEW | ✅ | -| `packages/durable-session/tsconfig.json` | NEW | ✅ | -| `packages/durable-session/src/index.ts` | `durable-session/src/index.ts` | ✅ | -| `packages/durable-session/src/client.ts` | `durable-session/src/client.ts` | ✅ (fixed) | -| `packages/durable-session/src/collection.ts` | `durable-session/src/collection.ts` | ✅ | -| `packages/durable-session/src/materialize.ts` | `durable-session/src/materialize.ts` | ✅ (fixed) | -| `packages/durable-session/src/schema.ts` | `durable-session/src/schema.ts` | ✅ | -| `packages/durable-session/src/types.ts` | `durable-session/src/types.ts` | ✅ (fixed) | -| `packages/durable-session/src/collections/index.ts` | `durable-session/src/collections/index.ts` | ✅ | -| `packages/durable-session/src/collections/messages.ts` | `durable-session/src/collections/messages.ts` | ✅ (rewritten) | -| `packages/durable-session/src/collections/active-generations.ts` | `durable-session/src/collections/active-generations.ts` | ✅ | -| `packages/durable-session/src/collections/session-meta.ts` | `durable-session/src/collections/session-meta.ts` | ✅ | -| `packages/durable-session/src/collections/session-stats.ts` | `durable-session/src/collections/session-stats.ts` | ✅ (rewritten) | -| `packages/durable-session/src/collections/model-messages.ts` | `durable-session/src/collections/model-messages.ts` | ✅ | -| `packages/durable-session/src/collections/presence.ts` | `durable-session/src/collections/presence.ts` | ✅ (rewritten) | -| `packages/durable-session/src/react/index.ts` | `react-durable-session/src/index.ts` | ✅ | -| `packages/durable-session/src/react/types.ts` | `react-durable-session/src/types.ts` | ✅ | -| `packages/durable-session/src/react/use-durable-chat.ts` | `react-durable-session/src/use-durable-chat.ts` | ✅ | -| `packages/durable-session/src/react/components/ChatInput/` | Migrated from `packages/ai-chat` | ✅ | -| `packages/durable-session/src/react/components/PresenceBar/` | Migrated from `packages/ai-chat` | ✅ | - -### Files CREATED (Phase B — Claude Agent Endpoint) ✅ - -| File | Description | Status | -|---|---|---| -| `apps/streams/src/claude-agent.ts` | Claude agent HTTP endpoint (Hono, SSE response) | ✅ | -| `apps/streams/src/sdk-to-ai-chunks.ts` | SDKMessage → TanStack AI AG-UI chunk converter | ✅ | - -### Files CREATED (vendored proxy — Phase A2) ✅ - -All files below are created and typechecking. Compatibility fix: `DurableStream.append()` in published `@durable-streams/client@0.2.1` only accepts `string | Uint8Array`, not `ChangeEvent` objects. All `stream.append(event)` calls wrapped with `JSON.stringify()`. - -| Destination | Source | Status | -|---|---|---| -| `apps/streams/src/server.ts` | `durable-session-proxy/src/server.ts` | ✅ | -| `apps/streams/src/protocol.ts` | `durable-session-proxy/src/protocol.ts` | ✅ (fixed: JSON.stringify for append) | -| `apps/streams/src/types.ts` | `durable-session-proxy/src/types.ts` | ✅ | -| `apps/streams/src/handlers/index.ts` | `durable-session-proxy/src/handlers/index.ts` | ✅ | -| `apps/streams/src/handlers/send-message.ts` | `durable-session-proxy/src/handlers/send-message.ts` | ✅ | -| `apps/streams/src/handlers/invoke-agent.ts` | `durable-session-proxy/src/handlers/invoke-agent.ts` | ✅ | -| `apps/streams/src/handlers/stream-writer.ts` | `durable-session-proxy/src/handlers/stream-writer.ts` | ✅ | -| `apps/streams/src/routes/index.ts` | `durable-session-proxy/src/routes/index.ts` | ✅ | -| `apps/streams/src/routes/sessions.ts` | `durable-session-proxy/src/routes/sessions.ts` | ✅ | -| `apps/streams/src/routes/messages.ts` | `durable-session-proxy/src/routes/messages.ts` | ✅ | -| `apps/streams/src/routes/agents.ts` | `durable-session-proxy/src/routes/agents.ts` | ✅ | -| `apps/streams/src/routes/stream.ts` | `durable-session-proxy/src/routes/stream.ts` | ✅ | -| `apps/streams/src/routes/tool-results.ts` | `durable-session-proxy/src/routes/tool-results.ts` | ✅ | -| `apps/streams/src/routes/approvals.ts` | `durable-session-proxy/src/routes/approvals.ts` | ✅ | -| `apps/streams/src/routes/health.ts` | `durable-session-proxy/src/routes/health.ts` | ✅ | -| `apps/streams/src/routes/auth.ts` | `durable-session-proxy/src/routes/auth.ts` | ✅ | -| `apps/streams/src/routes/fork.ts` | `durable-session-proxy/src/routes/fork.ts` | ✅ | - -### Files DELETED ✅ - -| File | Reason | Status | -|---|---|---| -| `packages/ai-chat/` (entire package) | Replaced by `@superset/durable-session` | ✅ Removed | - -### Files DELETED (Phase A2) ✅ - -| File | Reason | Status | -|---|---|---| -| `apps/streams/src/session-registry.ts` | Replaced by proxy's built-in session management | ✅ Removed | - -### Files REWRITTEN (Phase A2) ✅ - -| File | Description | Status | -|---|---|---| -| `apps/streams/src/index.ts` | New entrypoint with Hono proxy + DurableStreamTestServer | ✅ | - -### Files REWRITTEN (Phase C2) ✅ - -| File | Description | Status | -|---|---|---| -| `apps/desktop/.../session-manager.ts` | Thin HTTP orchestrator (no StreamWatcher/Producer) | ✅ | - -### Files MODIFIED (Phase A2) ✅ - -| File | Changes | Status | -|---|---|---| -| `apps/streams/package.json` | Added: hono, @hono/node-server, @durable-streams/client, @superset/durable-session, @tanstack/db, zod | ✅ | -| `packages/durable-session/src/client.ts` | Fixed: `response.json()` return type assertion for `ForkResult` | ✅ | - -### Files MODIFIED (Phase B) ✅ - -| File | Changes | Status | -|---|---|---| -| `apps/streams/package.json` | Added: @anthropic-ai/claude-agent-sdk, @tanstack/ai | ✅ | -| `apps/streams/src/index.ts` | Added: Claude agent endpoint on STREAMS_AGENT_PORT | ✅ | - ---- - -## Implementation Order - -1. ~~**Phase A1** — Vendor `@superset/durable-session` package~~ ✅ DONE -2. ~~**Phase C1** — Remove old `packages/ai-chat`, migrate UI components~~ ✅ DONE -3. ~~**Phase A2** — Vendor proxy into `apps/streams` (copy 17 files, adjust 3 import paths)~~ ✅ DONE -4. ~~**Phase B** — Claude agent endpoint + SDK-to-AI chunk converter (2 new files)~~ ✅ DONE -5. ~~**Phase C2** — Simplify desktop session manager~~ ✅ DONE -6. ~~**Phase F** — Desktop chat UI (works with existing proxy, no DB needed)~~ ✅ DONE -7. **Phase C3** — Handle drafts (local state + typing indicators) -8. **Phase G** — Web chat UI -9. **Phase D** — Database schema + migration (persistent storage) -10. **Phase E** — API tRPC router (web session management) - ---- - -## Risks - -| Risk | Impact | Mitigation | Status | -|------|--------|------------|--------| -| `@tanstack/ai` API mismatch with vendored code | Build breaks | Vendored code uses `workspace:*` — pin to compatible published versions, fix API differences | ✅ Resolved — `DoneStreamChunk` → `RUN_FINISHED`, `LiveMode` removed | -| `@tanstack/db` unreleased aggregates | Build breaks | Rewrite collection pipelines with `groupBy + count + fn.select` workaround | ✅ Resolved — `collect`/`minStr` replaced | -| SDKMessage → AI chunk conversion errors | Broken rendering | Comprehensive unit tests with real Claude output fixtures | Pending (Phase B) | -| Dual `StreamChunk` types | Type confusion, silent mismatches at module boundaries | `sdk-to-ai-chunks.ts` imports strict `StreamChunk` from `@tanstack/ai` (union of 14 AG-UI events). `types.ts` defines a loose `{ type: string; [key: string]: unknown }` used by `protocol.ts` and `stream-writer.ts`. Works at runtime because JSON serialization is the boundary, but `protocol.ts` gets zero type safety when constructing/consuming chunks. **Fix:** delete local `StreamChunk` from `types.ts`, use `@tanstack/ai`'s everywhere, replace `as StreamChunk` casts in `protocol.ts` with typed construction (~10 call sites). | Deferred — cleanup PR | -| Claude binary path outside Electron | Agent can't start | Claude agent SDK resolves binary automatically | ✅ Resolved — CLAUDE_BINARY_PATH removed | -| Multi-turn resume state lost on restart | Context lost | In-memory map + optional file-based persistence in data dir | Pending | -| Interrupt via HTTP abort | Claude subprocess continues | Agent detects fetch abort → calls `query.interrupt()` + `abortController.abort()` | Pending | -| Proxy `workspace:*` TanStack DB deps | Import errors | Pin all `@tanstack/*` to compatible published versions across monorepo | ✅ Resolved — imports changed to `@superset/durable-session`, `DurableStream.append()` wrapped with `JSON.stringify()` | - ---- - -## API Quick Reference - -### `useDurableChat(options)` Return Type - -```typescript -interface UseDurableChatReturn { - // TanStack AI useChat-compatible - messages: UIMessage[] // All messages (reactive) - sendMessage: (content: string) => Promise - append: (message: UIMessage | { role: string; content: string }) => Promise - reload: () => Promise // Regenerate last response - stop: () => void // Stop active generations - clear: () => void // Clear local messages - isLoading: boolean // Any generation active? - error: Error | undefined - addToolResult: (result: ToolResultInput) => Promise - addToolApprovalResponse: (response: ApprovalResponseInput) => Promise - - // Durable extensions - client: DurableChatClient // Underlying client instance - collections: DurableChatCollections // All reactive collections - connectionStatus: ConnectionStatus // 'disconnected' | 'connecting' | 'connected' | 'error' - fork: (options?: ForkOptions) => Promise - registerAgents: (agents: AgentSpec[]) => Promise - unregisterAgent: (agentId: string) => Promise - connect: () => Promise - disconnect: () => void - pause: () => void - resume: () => Promise -} -``` - -### `DurableChatCollections` - -```typescript -interface DurableChatCollections { - chunks: Collection // Root — synced from stream - presence: Collection // Aggregated per-actor presence - agents: Collection // Registered webhook agents - messages: Collection // Materialized messages - toolCalls: Collection // Messages with tool-call parts - pendingApprovals: Collection // Messages with unapproved tool calls - toolResults: Collection // Messages with tool-result parts - activeGenerations: Collection // Incomplete messages - sessionMeta: Collection // Local connection state - sessionStats: Collection // Aggregate statistics -} -``` - -### `MessageRow` (from materialized messages) - -```typescript -interface MessageRow { - id: string // messageId - role: 'user' | 'assistant' | 'system' - parts: MessagePart[] // TanStack AI parts (TextPart, ToolCallPart, etc.) - actorId: string - isComplete: boolean // Has finish/done chunk been received? - createdAt: Date -} -``` - -### Proxy HTTP API - -| Method | Endpoint | Body | Response | -|---|---|---|---| -| `PUT` | `/v1/sessions/:id` | — | `{ sessionId, streamUrl }` | -| `GET` | `/v1/sessions/:id` | — | `{ sessionId, streamUrl }` | -| `DELETE` | `/v1/sessions/:id` | — | 204 | -| `POST` | `/v1/sessions/:id/messages` | `{ content, actorId?, agent? }` | `{ messageId }` | -| `POST` | `/v1/sessions/:id/stop` | `{ messageId? }` | 204 | -| `POST` | `/v1/sessions/:id/regenerate` | `{ fromMessageId, content }` | `{ success }` | -| `POST` | `/v1/sessions/:id/reset` | `{ clearPresence? }` | `{ success }` | -| `POST` | `/v1/sessions/:id/agents` | `{ agents: AgentSpec[] }` | `{ success }` | -| `GET` | `/v1/sessions/:id/agents` | — | `{ agents }` | -| `DELETE` | `/v1/sessions/:id/agents/:agentId` | — | 204 | -| `POST` | `/v1/sessions/:id/tool-results` | `{ toolCallId, output, error? }` | 204 | -| `POST` | `/v1/sessions/:id/approvals/:id` | `{ approved }` | 204 | -| `POST` | `/v1/sessions/:id/fork` | `{ atMessageId?, newSessionId? }` | `{ sessionId, offset }` | -| `POST` | `/v1/sessions/:id/login` | `{ actorId, deviceId, name? }` | `{ success }` | -| `POST` | `/v1/sessions/:id/logout` | `{ actorId, deviceId }` | `{ success }` | -| `GET` | `/v1/stream/sessions/:id` | — | SSE stream (proxied to durable stream) | -| `GET` | `/health` | — | `{ status: 'ok' }` | - ---- - -## Testing Patterns - -The vendored source includes test helpers at `packages/durable-session/tests/fixtures/test-helpers.ts`. Key patterns: - -### Mock SessionDB for Unit Tests - -```typescript -import { createMockSessionDB } from '@superset/durable-session/test-helpers' - -// Create mock with controllable collections -const { sessionDB, controllers } = createMockSessionDB('test-session') - -const client = new DurableChatClient({ - sessionId: 'test-session', - proxyUrl: 'http://localhost:4000', - sessionDB, // Inject mock — skips real stream connection -}) - -await client.connect() - -// Emit test chunks via controller -controllers.chunks.emit([{ - id: 'msg-1:0', - messageId: 'msg-1', - actorId: 'user-1', - role: 'user', - chunk: JSON.stringify({ - type: 'whole-message', - message: { id: 'msg-1', role: 'user', parts: [{ type: 'text', content: 'Hello' }] } - }), - seq: 0, - createdAt: new Date().toISOString(), -}]) -controllers.chunks.markReady() - -// Wait for live query pipeline -await new Promise(r => setTimeout(r, 40)) - -// Assert materialized messages -const messages = [...client.collections.messages.values()] -expect(messages).toHaveLength(1) -expect(messages[0].role).toBe('user') -``` - -### SDK-to-AI Chunk Conversion Tests - -Test with captured SDKMessage fixtures to verify the conversion: -```typescript -import { convertSDKMessageToSSE } from './sdk-to-ai-chunks' - -it('converts text_delta to text-delta chunk', () => { - const sdkMessage = { - type: 'stream_event', - event: { - type: 'content_block_delta', - index: 0, - delta: { type: 'text_delta', text: 'Hello' }, - }, - } - const chunks = convertSDKMessageToSSE(sdkMessage) - expect(chunks).toEqual([{ type: 'text-delta', textDelta: 'Hello' }]) -}) - -it('converts result to done chunk', () => { - const sdkMessage = { type: 'result', result: { stop_reason: 'end_turn' } } - const chunks = convertSDKMessageToSSE(sdkMessage) - expect(chunks).toEqual([{ type: 'done', finishReason: 'stop' }]) -}) -``` - ---- - -## Verification - -### Phase A1 Verification (Vendored Package) ✅ PASSED -```bash -# 1. Install deps -cd packages/durable-session && bun install -# 2. Type check vendored package — 0 errors, 0 warnings -bunx tsc --noEmit -# 3. Lint — 0 errors, 0 warnings -bun run lint:fix -``` - -### Phase A2 + B Verification (Proxy + Agent) -```bash -# 1. Start streams server -cd apps/streams && bun dev - -# 2. Health check -curl http://localhost:8080/health -# → { "status": "ok", "timestamp": "..." } - -# 3. Create session -curl -X PUT http://localhost:8080/v1/sessions/test-1 -# → { "sessionId": "test-1", "streamUrl": "/v1/stream/sessions/test-1" } - -# 4. Register Claude agent -curl -X POST http://localhost:8080/v1/sessions/test-1/agents \ - -H 'Content-Type: application/json' \ - -d '{"agents":[{"id":"claude","endpoint":"http://localhost:9090/","triggers":"user-messages"}]}' - -# 5. Send message (triggers agent) -curl -X POST http://localhost:8080/v1/sessions/test-1/messages \ - -H 'Content-Type: application/json' \ - -d '{"content":"Hello","actorId":"user-1"}' -# → { "messageId": "..." } - -# 6. Read stream (verify chunks) -curl http://localhost:8080/v1/stream/sessions/test-1 -# → SSE events with chunk data - -# 7. Stop generation -curl -X POST http://localhost:8080/v1/sessions/test-1/stop \ - -H 'Content-Type: application/json' \ - -d '{}' -``` - -### Phase C Verification (Client Integration) -1. `useDurableChat({ sessionId: "test-1", proxyUrl: "http://localhost:8080" })` → messages render -2. Interrupt: POST `/v1/sessions/test-1/stop` → generation halts, `isLoading` becomes `false` -3. Reconnection: reload page → messages replayed from stream offset (not re-fetched) -4. Multi-client: open 2 tabs → both see same messages in real-time via SSE sync -5. Presence: both tabs show in `collections.presence` diff --git a/docs/productionize-chat.md b/docs/productionize-chat.md deleted file mode 100644 index a8f11a636b2..00000000000 --- a/docs/productionize-chat.md +++ /dev/null @@ -1,474 +0,0 @@ -# Productionize Chat GUI - -Full plan to take the AI chat from prototype to production, following established deployment patterns. - -## Current State - -| Component | Location | Status | -|-----------|----------|--------| -| Streams server (Hono + Durable Streams) | `apps/streams/` | Built, `fly.toml` exists, **not in CI/CD** | -| Durable session client + `useDurableChat` | `packages/durable-session/` | Built | -| Claude agent endpoint | `apps/streams/src/claude-agent.ts` | Built | -| AI element components (39+) | `packages/ui/src/components/ai-elements/` | Built | -| Desktop chat tRPC router | `apps/desktop/src/lib/trpc/routers/ai-chat/` | Built | -| Desktop chat renderer UI | `apps/desktop/.../ChatPane/` | Built | -| Web app chat UI | `apps/web/` | **Not built** | -| Auth on streams endpoints | `apps/streams/` | **Not built** | -| Chat history DB persistence | `packages/db/src/schema/` | **Not built** | -| Streams in production CI/CD | `.github/workflows/` | **Not built** | -| Streams in preview CI/CD | `.github/workflows/` | **Not built** | -| Observability (Sentry/PostHog) | `apps/streams/` | **Not built** | - -## Established Deployment Patterns - -These are the patterns already used in the repo. Chat should follow the same conventions. - -| Concern | Pattern | Example | -|---------|---------|---------| -| Next.js apps | **Vercel** via `vercel deploy --prod --prebuilt` | `deploy-production.yml` | -| Stateful/streaming services | **Fly.io** via `fly deploy` or `superfly/fly-pr-review-apps` | `fly.toml` (ElectricSQL) | -| Database | **Neon PostgreSQL** with per-PR branch | `neondatabase/create-branch-action@v6` | -| Secrets | **GitHub Secrets** per environment (`production`, `preview`) | All workflows | -| Error tracking | **Sentry** (`@sentry/nextjs`, per-app DSN) | Web, API, Marketing, Admin, Docs, Desktop | -| Analytics | **PostHog** (`posthog-js` client, `posthog-node` server) | Web, Admin | -| CI | **GitHub Actions** — sherif, lint, test, typecheck, build | `ci.yml` | -| Preview | Full isolated env per PR (Neon branch + Electric + all Vercel apps) | `deploy-preview.yml` | -| Cleanup | Delete preview resources on PR close | `cleanup-preview.yml` | - ---- - -## Phase 1: Deploy Streams Server to Production - -**Goal:** Get `apps/streams` reliably running on Fly.io with CI/CD. - -### 1.1 Add `deploy-streams` job to `deploy-production.yml` - -The streams server already has `fly.toml` (`app = "superset-stream"`, region `iad`, port 8080). Add a deploy job that follows the same pattern as ElectricSQL. - -```yaml -# .github/workflows/deploy-production.yml -deploy-streams: - name: Deploy Streams to Fly.io - runs-on: ubuntu-latest - environment: production - - steps: - - uses: actions/checkout@v4 - - - uses: superfly/flyctl-actions/setup-flyctl@master - - - name: Deploy to Fly.io - run: flyctl deploy --config apps/streams/fly.toml --remote-only - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - - - name: Set secrets - run: | - flyctl secrets set \ - ANTHROPIC_API_KEY="${{ secrets.ANTHROPIC_API_KEY }}" \ - STREAMS_SECRET="${{ secrets.STREAMS_SECRET }}" \ - --app superset-stream - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} -``` - -**Secrets to add to GitHub:** -- `STREAMS_SECRET` — bearer token for auth (generate a random 64-char string) - -**Env vars to add to `.env.example`:** -- `STREAMS_URL` — production URL (e.g., `https://superset-stream.fly.dev`) -- `STREAMS_SECRET` — bearer token for authenticated requests - -### 1.2 Add streams to preview deployments (`deploy-preview.yml`) - -Follow the ElectricSQL preview pattern with `superfly/fly-pr-review-apps`: - -```yaml -deploy-streams-preview: - name: Deploy Streams (Fly.io) - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Deploy Streams preview to Fly.io - uses: superfly/fly-pr-review-apps@1.3.0 - with: - name: superset-stream-pr-${{ github.event.pull_request.number }} - region: iad - org: ${{ vars.FLY_ORG }} - config: apps/streams/fly.toml - secrets: | - ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }} - STREAMS_SECRET=${{ secrets.STREAMS_SECRET }} - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} -``` - -Add to preview env: -``` -STREAMS_ALIAS: superset-stream-pr-${{ github.event.pull_request.number }}.fly.dev -``` - -### 1.3 Add streams cleanup to `cleanup-preview.yml` - -```yaml -- name: Destroy Streams Fly.io app - uses: superfly/fly-pr-review-apps@1.3.0 - with: - name: superset-stream-pr-${{ github.event.pull_request.number }} - org: ${{ vars.FLY_ORG }} - env: - FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} -``` - -### 1.4 Verify `fly.toml` is production-ready - -Current config is reasonable. Consider bumping resources for production: - -```toml -# apps/streams/fly.toml -[[vm]] - memory = "512mb" # bump from 256mb for production - cpu_kind = "shared" - cpus = 1 -``` - -The `auto_stop_machines = "stop"` + `auto_start_machines = true` + `min_machines_running = 1` config is correct — one machine always warm, extras auto-scale. - ---- - -## Phase 2: Auth on Streams Endpoints - -**Goal:** Prevent unauthorized access to chat sessions. - -### 2.1 Bearer token middleware - -Add a Hono middleware in `apps/streams/src/server.ts` that validates the `Authorization: Bearer ` header against `STREAMS_SECRET`. This is the simplest approach that works for both desktop and web clients. - -``` -Request flow: - Client → Bearer token in header → Streams server validates → Allow/Deny -``` - -**Scope:** All `/v1/sessions/*` routes. Exclude `/health`. - -### 2.2 Per-user session isolation (future) - -For multi-tenant production, sessions should be scoped to authenticated users. This requires: -1. Pass the user's session token (from Better Auth) to the streams server -2. Streams server validates the token against the API (`apps/api`) -3. Session IDs are prefixed/scoped per user - -This can be a follow-up — bearer token auth is sufficient for initial launch. - ---- - -## Phase 3: Web App Chat UI - -**Goal:** Add chat to `apps/web` using the same components and hooks already built. - -### 3.1 Chat route - -Create a chat page in the web app. The exact route depends on product decisions (standalone `/chat` page vs. embedded panel), but the wiring is the same: - -``` -apps/web/src/app/(app)/chat/ -├── page.tsx # Chat page -├── components/ -│ └── ChatView/ -│ ├── ChatView.tsx # Main chat container -│ └── index.ts -└── hooks/ - └── useChatSession/ - ├── useChatSession.ts # Session lifecycle - └── index.ts -``` - -### 3.2 Wire up `useDurableChat` - -The hook from `packages/durable-session` is client-agnostic. Connect it to the streams server: - -```typescript -const { messages, sendMessage, isLoading, stop } = useDurableChat({ - sessionId, - proxyUrl: env.NEXT_PUBLIC_STREAMS_URL, - autoConnect: true, - stream: { - headers: { Authorization: `Bearer ${authToken}` }, - }, -}); -``` - -### 3.3 Reuse `packages/ui/src/components/ai-elements/` - -The 39+ AI element components are already published from `packages/ui`. Import and compose: - -- `conversation.tsx` — message list container -- `message.tsx` — individual messages -- `prompt-input.tsx` — input with file attachment -- `tool-call.tsx`, `bash-tool.tsx`, etc. — tool rendering -- `reasoning.tsx` — extended thinking display -- `model-selector.tsx` — model picker - -### 3.4 Add `NEXT_PUBLIC_STREAMS_URL` to web deployment - -Add to `deploy-production.yml` `deploy-web` job and `deploy-preview.yml`: - -``` -NEXT_PUBLIC_STREAMS_URL=https://superset-stream.fly.dev # production -NEXT_PUBLIC_STREAMS_URL=https://superset-stream-pr-{N}.fly.dev # preview -``` - ---- - -## Phase 4: Chat History Persistence - -**Goal:** Persist completed chat sessions to PostgreSQL so users can browse history. - -### 4.1 Database schema - -Add tables to `packages/db/src/schema/`: - -```typescript -// chat-sessions table -export const chatSessions = pgTable("chat_sessions", { - id: text("id").primaryKey(), // durable stream session ID - userId: text("user_id").notNull().references(() => users.id), - workspaceId: text("workspace_id"), // optional, for workspace-scoped chats - title: text("title"), // auto-generated or user-edited - model: text("model"), // e.g. "claude-sonnet-4-5-20250929" - messageCount: integer("message_count").default(0), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), -}); - -// chat-messages table (optional — only if search/export needed) -export const chatMessages = pgTable("chat_messages", { - id: text("id").primaryKey(), // message ID from durable stream - sessionId: text("session_id").notNull().references(() => chatSessions.id), - role: text("role").notNull(), // "user" | "assistant" - content: text("content"), // plain text content - parts: jsonb("parts"), // full TanStack AI MessagePart[] - createdAt: timestamp("created_at").defaultNow().notNull(), -}); -``` - -### 4.2 Persist on session end - -When a chat session completes (all generations done), the streams server writes the session summary to the database. Two approaches: - -**Option A: Webhook from streams to API.** Streams server POSTs to `apps/api` on session end. API writes to DB. This keeps DB access in the API layer. - -**Option B: Direct write from streams.** Streams server connects to Neon directly. Simpler, but requires `DATABASE_URL` in streams env. - -Recommend **Option A** — follows existing separation of concerns (API owns DB writes). - -### 4.3 Chat history API - -Add a tRPC router in `apps/api` for listing/searching chat sessions: - -``` -chatSession.list → paginated list for current user -chatSession.get → single session with messages -chatSession.rename → update title -chatSession.delete → soft delete -``` - ---- - -## Phase 5: Observability - -**Goal:** Match the monitoring level of other production services. - -### 5.1 Sentry for streams server - -Add `@sentry/bun` (or `@sentry/node`) to `apps/streams`: - -```typescript -import * as Sentry from "@sentry/bun"; - -Sentry.init({ - dsn: process.env.SENTRY_DSN_STREAMS, - environment: process.env.NODE_ENV, - tracesSampleRate: 0.1, -}); -``` - -**Secret to add:** `SENTRY_DSN_STREAMS` — create a new Sentry project under `superset-sh` org. - -### 5.2 PostHog events - -Track key chat events server-side from streams: - -| Event | Properties | -|-------|------------| -| `chat_session_started` | `sessionId`, `model`, `userId` | -| `chat_message_sent` | `sessionId`, `role`, `messageLength` | -| `chat_tool_executed` | `sessionId`, `toolName`, `approved` | -| `chat_session_ended` | `sessionId`, `messageCount`, `durationMs` | -| `chat_error` | `sessionId`, `errorType`, `errorMessage` | - -Use `posthog-node` (already a dependency in the monorepo). - -### 5.3 Structured logging - -Replace `console.log` with structured JSON logs following existing patterns: - -```typescript -console.log("[streams/session] Session started:", { sessionId, model, userId }); -console.error("[streams/agent] Claude SDK error:", { sessionId, error: err.message }); -``` - ---- - -## Phase 6: Hardening - -**Goal:** Production safety for user-facing traffic. - -### 6.1 CORS - -Configure Hono CORS middleware in `apps/streams/src/server.ts`: - -```typescript -app.use("*", cors({ - origin: [ - process.env.ALLOWED_ORIGIN_WEB, // e.g. https://app.superset.sh - process.env.ALLOWED_ORIGIN_DESKTOP, // electron:// or localhost for dev - ].filter(Boolean), - credentials: true, -})); -``` - -### 6.2 Rate limiting - -Rate limit at the session/message level: - -| Endpoint | Limit | -|----------|-------| -| `PUT /v1/sessions/:id` (create) | 10/min per user | -| `POST /v1/sessions/:id/messages` (send) | 30/min per session | - -Implement with Upstash Redis (already used in the monorepo via `KV_REST_API_URL`): - -```typescript -import { Ratelimit } from "@upstash/ratelimit"; -import { Redis } from "@upstash/redis"; - -const ratelimit = new Ratelimit({ - redis: Redis.fromEnv(), - limiter: Ratelimit.slidingWindow(30, "1 m"), -}); -``` - -### 6.3 Input validation - -- Max message length (e.g., 100KB) -- Session ID format validation (UUID) -- Model allowlist validation -- Sanitize tool outputs before persisting - -### 6.4 Graceful shutdown - -Handle `SIGTERM` in the streams server for Fly.io rolling deploys: - -```typescript -process.on("SIGTERM", async () => { - console.log("[streams] SIGTERM received, draining connections..."); - // Stop accepting new sessions - // Wait for active generations to complete (with timeout) - // Close durable stream connections - process.exit(0); -}); -``` - ---- - -## Phase 7: Desktop App Updates - -**Goal:** Point desktop chat at production streams server. - -### 7.1 Config resolution - -Desktop tRPC router (`apps/desktop/src/lib/trpc/routers/ai-chat/`) already has a `getConfig()` procedure that returns `{ proxyUrl, authToken }`. Update it to: - -1. Read `STREAMS_URL` from `.env` (loaded in main process) -2. Pass the user's auth token (from Better Auth desktop flow) - -### 7.2 Desktop auto-update - -Desktop canary builds (`release-desktop-canary.yml`) will pick up chat changes automatically since chat code lives in `apps/desktop/src/renderer/`. No workflow changes needed. - ---- - -## Implementation Order - -``` -Phase 1: Deploy Streams (CI/CD) - ├── 1.1 Add deploy-streams to deploy-production.yml - ├── 1.2 Add deploy-streams-preview to deploy-preview.yml - ├── 1.3 Add cleanup to cleanup-preview.yml - └── 1.4 Verify fly.toml resources - -Phase 2: Auth - └── 2.1 Bearer token middleware on streams - -Phase 3: Web Chat UI - ├── 3.1 Chat route in apps/web - ├── 3.2 Wire useDurableChat - ├── 3.3 Compose ai-elements - └── 3.4 Add STREAMS_URL to web deploys - -Phase 4: Persistence - ├── 4.1 DB schema (chatSessions, chatMessages) - ├── 4.2 Session-end webhook → API → DB - └── 4.3 Chat history tRPC router - -Phase 5: Observability - ├── 5.1 Sentry in streams - ├── 5.2 PostHog events - └── 5.3 Structured logging - -Phase 6: Hardening - ├── 6.1 CORS - ├── 6.2 Rate limiting (Upstash) - ├── 6.3 Input validation - └── 6.4 Graceful shutdown - -Phase 7: Desktop updates - ├── 7.1 Config resolution for production URL - └── 7.2 Desktop auto-update (no changes needed) -``` - -Phases 1-2 are prerequisites. Phases 3-7 can largely be parallelized. - ---- - -## Secrets Checklist - -New secrets to add to GitHub (production + preview environments): - -| Secret | Purpose | Where | -|--------|---------|-------| -| `STREAMS_SECRET` | Bearer auth for streams API | `apps/streams`, clients | -| `SENTRY_DSN_STREAMS` | Error tracking for streams | `apps/streams` | -| `NEXT_PUBLIC_STREAMS_URL` | Streams server URL (client-side) | `apps/web` | -| `STREAMS_URL` | Streams server URL (server-side) | `apps/api` (for webhooks) | - -Existing secrets already available: -- `ANTHROPIC_API_KEY` — already in GitHub secrets -- `FLY_API_TOKEN` — already used for ElectricSQL -- `KV_REST_API_URL` / `KV_REST_API_TOKEN` — already used for rate limiting -- `POSTHOG_API_KEY` — already used across apps - ---- - -## Risks & Mitigations - -| Risk | Impact | Mitigation | -|------|--------|------------| -| Fly.io single machine for streams | Downtime during deploys | `min_machines_running = 1` + rolling deploys. Scale to 2 machines if latency matters. | -| LMDB data on Fly.io volumes | Data loss if volume fails | Durable streams are ephemeral by design. Persist completed sessions to PostgreSQL (Phase 4). | -| Anthropic API rate limits | Users blocked from chatting | Per-user rate limiting (Phase 6.2), queue overflow to retry, display user-facing error. | -| Claude Agent SDK instability | Agent crashes mid-conversation | Sentry alerts (Phase 5.1), session resumption (already built into SDK). | -| Cost runaway (Anthropic tokens) | Unexpected bills | Budget limits via `maxBudgetUsd` in agent config (already supported), admin dashboard monitoring. | diff --git a/docs/streaming-performance-reliability-recommendations.md b/docs/streaming-performance-reliability-recommendations.md deleted file mode 100644 index 36e2bcee3f0..00000000000 --- a/docs/streaming-performance-reliability-recommendations.md +++ /dev/null @@ -1,189 +0,0 @@ -# Streaming Performance and Reliability Recommendations - -This is the complete recommendation list for the current desktop + streams architecture and PR review. - -## Critical correctness fixes - -1. ~~Fail `/generations/finish` when producer background errors occurred earlier in the run (not just log them).~~ DONE -2. ~~In desktop, check `res.ok` for `/generations/finish`; treat non-2xx as failure.~~ DONE -3. ~~Make `deleteSession` await producer drain/detach before returning `204`.~~ DONE -4. ~~Flush producer before reset/control events so reset never races ahead of queued chunks.~~ DONE -5. ~~Use one write path per session (prefer producer) for all session events to preserve global ordering.~~ DONE -6. ~~Clear per-message seq state after normal assistant completion to avoid unbounded `messageSeqs` growth.~~ DONE -7. ~~Add abort signal to chunk POSTs so interrupt cancels in-flight sends quickly.~~ DONE -8. Decide API semantics explicitly: `/chunks` should be `202 Accepted` (async ack) or `200` only after durable write. -9. ~~If finish fails, emit an explicit terminal error marker so UI does not show a silent done.~~ DONE -10. ~~Guard session close/reset/delete with a per-session mutex to avoid concurrent lifecycle races.~~ DONE - -## Performance improvements (start streaming + stream path) - -11. ~~Remove `/generations/start` round trip; generate `messageId` client-side.~~ DONE -12. ~~Add `/chunks/batch` endpoint to reduce per-chunk HTTP overhead.~~ DONE -13. ~~Coalesce adjacent text deltas on desktop (small time/size window).~~ DONE (ChunkBatcher 5ms linger) -14. Replace per-chunk POST with one streaming upload channel (NDJSON or WebSocket) per generation. -15. ~~Tune `IdempotentProducer` params (`lingerMs`, `maxBatchBytes`, `maxInFlight`) using load tests.~~ DONE (lingerMs=1, maxInFlight=5) -16. Reuse HTTP connections aggressively (keep-alive/pooling) for desktop to proxy writes. -17. Optionally compress large chunk payloads. -18. Optionally drop/coalesce low-value chunks (for example verbose reasoning deltas) under pressure. -19. ~~Avoid unnecessary stringify/parse hops where possible in hot paths.~~ DONE (batch endpoint skips Zod) -20. ~~Add bounded queueing in desktop to prevent memory growth when proxy/network slows.~~ DONE (ChunkBatcher maxBufferSize=2000) - -## Reliability and retry model - -21. ~~Add retry with backoff for transient chunk POST failures.~~ DONE (ChunkBatcher 3 retries, 50ms base exponential) -22. ~~Add idempotency keys on chunk writes so retries do not duplicate logical chunks.~~ DONE (IdempotentProducer provides this via autoClaim/epoch) -23. ~~Track a per-session producer unhealthy state and fail fast until recovered.~~ DONE (producerHealthy map) -24. ~~Add fallback mode: switch to synchronous `stream.append` if producer repeatedly errors.~~ DONE (appendToStream checks producerHealthy) -25. ~~Fence stale writers with a generation token returned at generation start.~~ DONE (activeGenerationIds tracking) -26. ~~Ensure seq handling survives process restarts (or move seq assignment to client message stream).~~ DONE (IdempotentProducer autoClaim handles epoch) -27. ~~Add explicit chunk ordering guarantees in API contract.~~ DONE (IdempotentProducer provides ordering; ChunkBatcher sendChain preserves order) -28. ~~Add timeout + clear error for flush/finish so runs do not hang indefinitely.~~ DONE (FLUSH_TIMEOUT_MS = 10s) - -## Protocol/API cleanups - -29. ~~Collapse `start/chunks/finish` into one generation lifecycle API with explicit generation id.~~ DONE (removed /generations/start; generation auto-registers from first chunk) -30. ~~Add an optional strict-ack endpoint (`txid`) for flows that need synced-to-stream confirmation.~~ DONE (already in use via writeUserMessage txid pattern) -31. ~~Standardize terminal semantics (`done` vs `message-end` vs `stop/error`) and document one canonical end signal.~~ DONE (documented in types.ts: `message-end` = UI signal, `/finish` = server cleanup) -32. ~~Return structured error codes from finish/flush routes for better client behavior.~~ DONE (all routes have `code` field) -33. ~~Define whether `/chunks` supports multi-writer per session; enforce if single-writer.~~ DONE (single-writer via activeGenerationIds) -34. ~~Add request/session/message IDs in all responses for tracing.~~ DONE - -## Observability - -35. Add metrics: queue depth, enqueue-to-flush latency, finish latency, dropped/retried chunks. -36. Add error counters: producer onError, finish failures, delete/reset race failures. -37. Add tracing context: `sessionId`, `messageId`, generation id, request id in logs. -38. Add SLO dashboards for time to first visible token and finish success rate. -39. Alert on rising async-ack failures (`200` or `202` accepted but later flush failed). -40. Sample payload size histograms to guide batching/coalescing thresholds. - -## Tests to add - -41. Integration test: producer error during stream causes finish to fail. -42. Integration test: delete waits for producer drain. -43. Race test: reset/delete during active streaming does not reorder/corrupt stream. -44. Load test: long responses (thousands of chunks) with bounded memory. -45. Chaos test: intermittent network failure with retries + idempotency. -46. Benchmark: current per-chunk POST vs batch vs streaming-upload modes. - -## Rollout strategy - -47. Ship behind a feature flag for producer async-ack behavior. -48. Canary compare metrics before/after (time to first token, finish failure, chunk loss). -49. Keep a runtime toggle to force synchronous append as emergency fallback. -50. Document an operational runbook for flush failures and stuck sessions. - -## Non-stream PR issue - -51. `core.hooksPath=/dev/null` is not cross-platform (fails on Windows); use OS-specific null device handling. - -## Sources - -### External references - -- Durable Sessions blog post: - - https://electric-sql.com/blog/2026/01/12/durable-sessions-for-collaborative-ai -- Transport repo (Durable Session client, proxy, materialization, transport resume): - - https://github.com/electric-sql/transport - - https://raw.githubusercontent.com/electric-sql/transport/main/packages/durable-session/src/client.ts - - https://raw.githubusercontent.com/electric-sql/transport/main/packages/durable-session/src/collections/messages.ts - - https://raw.githubusercontent.com/electric-sql/transport/main/packages/durable-session/src/materialize.ts - - https://raw.githubusercontent.com/electric-sql/transport/main/packages/durable-session-proxy/src/protocol.ts - - https://raw.githubusercontent.com/electric-sql/transport/main/packages/transport/src/client.ts - - https://raw.githubusercontent.com/electric-sql/transport/main/packages/transport/src/stream.ts -- Electric examples (txid sync confirmation pattern): - - https://github.com/electric-sql/electric - - https://raw.githubusercontent.com/electric-sql/electric/main/examples/burn/assets/src/db/mutations.ts - - https://raw.githubusercontent.com/electric-sql/electric/main/examples/burn/assets/src/db/transaction.ts -- Durable Streams producer behavior: - - https://raw.githubusercontent.com/durable-streams/durable-streams/main/packages/client/src/idempotent-producer.ts - -### Internal references (this repo) - -- Stream protocol and producer usage: - - `apps/streams/src/protocol.ts` -- Chunk/start/finish routes: - - `apps/streams/src/routes/chunks.ts` -- Desktop chunk send ordering + finish call path: - - `apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts` -- Worktree hooks bypass change and tests: - - `apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts` - - `apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts` - -### Source mapping by recommendation numbers - -- `1-4`, `8-9`, `11`, `20`, `31`, `32`, `34`: supported by current implementation details in `apps/streams/src/protocol.ts`, `apps/streams/src/routes/chunks.ts`, and `apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts`. -- `15`, `21-24`, `27-28`: informed by `IdempotentProducer` semantics in durable-streams client (`idempotent-producer.ts`) covering batching, pipelining, retries, and error surfaces. -- `30`: based on txid + wait-for-sync patterns in `packages/durable-session/src/client.ts` and Electric example `examples/burn/assets/src/db/mutations.ts`. -- `3`, `5`, `29`, `31`: informed by durable-session/proxy protocol design and materialization pipeline in `packages/durable-session-proxy/src/protocol.ts`, `packages/durable-session/src/collections/messages.ts`, and `packages/durable-session/src/materialize.ts`. -- `11-14`: reinforced by durable transport patterns for resumable streaming in `packages/transport/src/client.ts` and `packages/transport/src/stream.ts`. -- `51`: based on current repo changes and tests in `apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts` and `apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts`. - -Recommendations not explicitly mapped above are engineering suggestions derived from standard distributed systems and streaming architecture tradeoffs, not direct one-to-one source prescriptions. - -## Adoption classification by item - -Legend: - -- `Local`: implement directly in this repo. -- `Vendor`: copy/adapt patterns from `electric-sql/transport` into workspace packages (`@superset/durable-session` / `apps/streams`) to keep tight control. -- `Package`: use upstream package capability directly (no vendoring). - -1. Local -2. Local -3. Local -4. Local -5. Local -6. Local -7. Local -8. Local -9. Local -10. Local -11. Local -12. Local -13. Local -14. Local -15. Package -16. Local -17. Local -18. Local -19. Local -20. Local -21. Local -22. Local -23. Local -24. Local -25. Local -26. Local -27. Local -28. Local -29. Vendor -30. Package -31. Vendor -32. Local -33. Local -34. Local -35. Local -36. Local -37. Local -38. Local -39. Local -40. Local -41. Local -42. Local -43. Local -44. Local -45. Local -46. Local -47. Local -48. Local -49. Local -50. Local -51. Local - -### Notes on `Vendor` and `Package` items - -- `15 (Package)`: use `@durable-streams/client` producer tuning knobs (`lingerMs`, `maxBatchBytes`, `maxInFlight`) directly. -- `29 (Vendor)`: if you collapse lifecycle APIs, adapt from `durable-session-proxy` patterns rather than hard-switching architecture. -- `30 (Package)`: use txid + await-sync capability from durable state/client primitives. -- `31 (Vendor)`: reuse durable-session materialization/terminal handling patterns from `electric-sql/transport` where it matches Superset semantics. diff --git a/package.json b/package.json index 4cd5bc7c074..2e41323e21e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "packageManager": "bun@1.3.6", "private": true, "scripts": { - "dev": "turbo run dev dev:caddy --filter=@superset/api --filter=@superset/web --filter=@superset/desktop --filter=@superset/streams --filter=//", + "dev": "turbo run dev dev:caddy --filter=@superset/api --filter=@superset/web --filter=@superset/desktop --filter=//", "dev:all": "turbo dev", "dev:caddy": "dotenv -- caddy run --config Caddyfile", "dev:docs": "turbo dev --filter=@superset/docs", diff --git a/packages/agent/package.json b/packages/agent/package.json index 824d2ba1987..3ee2e077d4c 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -19,7 +19,6 @@ "@mastra/ai-sdk": "^1.0.4", "@mastra/core": "^1.3.0", "@mastra/memory": "^1.2.0", - "@superset/durable-session": "workspace:*", "@tanstack/ai": "^0.3.0", "@tavily/core": "^0.7.1", "cheerio": "^1.2.0", diff --git a/packages/durable-session/src/enrich-text-segments.ts b/packages/agent/src/enrich-text-segments.ts similarity index 100% rename from packages/durable-session/src/enrich-text-segments.ts rename to packages/agent/src/enrich-text-segments.ts diff --git a/packages/agent/src/sdk-to-ai-chunks.ts b/packages/agent/src/sdk-to-ai-chunks.ts index fe043abcf50..660d74ad662 100644 --- a/packages/agent/src/sdk-to-ai-chunks.ts +++ b/packages/agent/src/sdk-to-ai-chunks.ts @@ -11,8 +11,8 @@ * - RUN_ERROR — error during execution */ -import { createTextSegmentEnricher } from "@superset/durable-session"; import type { StreamChunk } from "@tanstack/ai"; +import { createTextSegmentEnricher } from "./enrich-text-segments"; interface SDKPartialAssistantMessage { type: "stream_event"; diff --git a/packages/durable-session/package.json b/packages/durable-session/package.json deleted file mode 100644 index 14d03723adc..00000000000 --- a/packages/durable-session/package.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "@superset/durable-session", - "version": "0.0.1", - "private": true, - "type": "module", - "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" - }, - "./react": { - "types": "./src/react/index.ts", - "default": "./src/react/index.ts" - } - }, - "dependencies": { - "@durable-streams/state": "^0.2.0", - "@standard-schema/spec": "^1.0.0", - "@tanstack/ai": "^0.3.0", - "@tanstack/db": "0.5.25", - "@tanstack/db-ivm": "^0.1.17", - "zod": "^4.3.5" - }, - "devDependencies": { - "@superset/typescript": "workspace:*", - "@superset/ui": "workspace:*", - "@types/react": "~19.2.2", - "lucide-react": "^0.563.0", - "typescript": "^5.9.3" - }, - "peerDependencies": { - "@superset/ui": "workspace:*", - "@tanstack/react-db": "^0.1.69", - "lucide-react": ">=0.300.0", - "react": "^18.0.0 || ^19.0.0" - } -} diff --git a/packages/durable-session/src/client.ts b/packages/durable-session/src/client.ts deleted file mode 100644 index 35a2e99cdce..00000000000 --- a/packages/durable-session/src/client.ts +++ /dev/null @@ -1,788 +0,0 @@ -/** - * DurableChatClient - Framework-agnostic durable chat client. - * - * Provides TanStack AI-compatible API backed by Durable Streams - * with real-time sync and multi-agent support. - * - * All derived collections contain fully materialized MessageRow objects. - * Consumers filter message.parts to access specific part types (ToolCallPart, etc.). - */ - -import type { AnyClientTool, ToolCallPart, UIMessage } from "@tanstack/ai"; -import type { Transaction } from "@tanstack/db"; -import { createCollection, createOptimisticAction } from "@tanstack/db"; -import { createSessionDB, type SessionDB } from "./collection"; -import { - createActiveGenerationsCollection, - createInitialSessionMeta, - createMessagesCollection, - createPendingApprovalsCollection, - createPresenceCollection, - createSessionMetaCollectionOptions, - createSessionStatsCollection, - createToolCallsCollection, - createToolResultsCollection, - updateConnectionStatus, -} from "./collections"; -import { StreamError } from "./errors"; -import { extractTextContent, messageRowToUIMessage } from "./materialize"; -import type { - ActorType, - AgentSpec, - AnswerResponseInput, - ApprovalResponseInput, - ClientToolResultInput, - ConnectionStatus, - DurableChatClientOptions, - ForkOptions, - ForkResult, - MessageRow, - SessionMetaRow, - ToolResultInput, -} from "./types"; - -const AWAIT_TXID_TIMEOUT_MS = 12_000; - -/** - * Unified input for all message optimistic actions. - */ -interface MessageActionInput { - /** Message content */ - content: string; - /** Client-generated message ID */ - messageId: string; - /** Message role */ - role: "user" | "assistant" | "system"; - /** Optional agent to invoke (for user messages) */ - agent?: AgentSpec; -} - -/** - * DurableChatClient provides a TanStack AI-compatible chat interface - * backed by Durable Streams for persistence and real-time sync. - * - * All derived collections contain fully materialized objects. - * Access data directly from collections - no helper functions needed. - * - * @example - * ```typescript - * import { DurableChatClient } from '@superset/durable-session' - * - * const client = new DurableChatClient({ - * sessionId: 'my-session', - * proxyUrl: 'http://localhost:4000', - * }) - * - * await client.connect() - * - * // Use TanStack AI-compatible API - * await client.sendMessage('Hello!') - * console.log(client.messages) - * - * // Or use collections directly - * for (const message of client.collections.messages.values()) { - * console.log(message.id, message.role, message.parts) - * } - * - * // Filter tool calls - * const pending = [...client.collections.toolCalls.values()] - * .filter(tc => tc.state === 'pending') - * ``` - */ - -export class DurableChatClient< - TTools extends ReadonlyArray = AnyClientTool[], -> { - readonly sessionId: string; - readonly actorId: string; - readonly actorType: ActorType; - - private readonly options: DurableChatClientOptions; - - // Stream-db instance (created synchronously in constructor) - // Either from options.sessionDB (tests) or createSessionDB() (production) - private readonly _db: SessionDB; - - // Collections are typed via inference from createCollections() - // Created synchronously in constructor - always available - private readonly _collections: ReturnType< - DurableChatClient["createCollections"] - >; - - private _isConnected = false; - private _isDisposed = false; - private _error: Error | undefined; - - // AbortController created at construction time to pass signal to stream-db. - // Aborted on disconnect() to cancel the stream sync. - private readonly _abortController: AbortController; - - // Optimistic actions for mutations (created synchronously in constructor) - private readonly _messageAction: (input: MessageActionInput) => Transaction; - private readonly _addToolResultAction: ( - input: ClientToolResultInput, - ) => Transaction; - private readonly _addApprovalResponseAction: ( - input: ApprovalResponseInput, - ) => Transaction; - private readonly _addAnswerResponseAction: ( - input: AnswerResponseInput, - ) => Transaction; - - // ═══════════════════════════════════════════════════════════════════════ - // Constructor - // ═══════════════════════════════════════════════════════════════════════ - - constructor(options: DurableChatClientOptions) { - this.options = { - ...options, - proxyUrl: options.proxyUrl.replace(/\/+$/, ""), - }; - this.sessionId = options.sessionId; - this.actorId = options.actorId ?? crypto.randomUUID(); - this.actorType = options.actorType ?? "user"; - - this._abortController = new AbortController(); - - this._db = - options.sessionDB ?? - createSessionDB({ - sessionId: this.sessionId, - baseUrl: options.proxyUrl, - headers: options.stream?.headers, - signal: this._abortController.signal, - }); - - this._collections = this.createCollections(); - - this._collections.sessionMeta.insert( - createInitialSessionMeta(this.sessionId), - ); - - this._messageAction = this.createMessageAction(); - this._addToolResultAction = this.createAddToolResultAction(); - this._addApprovalResponseAction = this.createApprovalResponseAction(); - this._addAnswerResponseAction = this.createAnswerResponseAction(); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Collection Setup - // ═══════════════════════════════════════════════════════════════════════ - - /** - * Create all derived collections from the chunks collection. - * - * Pipeline architecture: - * - chunks → (subquery) → messages (root materialized collection) - * - Derived collections filter messages via .fn.where() on parts - * - * CRITICAL: Materialization happens inside fn.select(). No imperative code - * outside this pattern. - */ - private createCollections() { - const { chunks, presence: rawPresence, agents } = this._db.collections; - - // chunks → messages (inline subquery for chunk aggregation) - const messages = createMessagesCollection({ - chunksCollection: chunks, - }); - - // Derived collections filter on message parts - const toolCalls = createToolCallsCollection({ - messagesCollection: messages, - }); - - const pendingApprovals = createPendingApprovalsCollection({ - messagesCollection: messages, - }); - - const toolResults = createToolResultsCollection({ - messagesCollection: messages, - }); - - const activeGenerations = createActiveGenerationsCollection({ - messagesCollection: messages, - }); - - const sessionMeta = createCollection( - createSessionMetaCollectionOptions({ - sessionId: this.sessionId, - }), - ); - - const sessionStats = createSessionStatsCollection({ - sessionId: this.sessionId, - chunksCollection: chunks, - }); - - // Aggregated "who's online" view (groups rawPresence by actorId) - const presence = createPresenceCollection({ - sessionId: this.sessionId, - rawPresenceCollection: rawPresence, - }); - - return { - chunks, - presence, - agents, - messages, - toolCalls, - pendingApprovals, - toolResults, - activeGenerations, - sessionMeta, - sessionStats, - }; - } - - // ═══════════════════════════════════════════════════════════════════════ - // Core API (TanStack AI ChatClient compatible) - // ═══════════════════════════════════════════════════════════════════════ - - get messages(): UIMessage[] { - return [...this._collections.messages.values()].map(messageRowToUIMessage); - } - - get isLoading(): boolean { - return this._collections.activeGenerations.size > 0; - } - - get error(): Error | undefined { - return this._error; - } - - get isDisposed(): boolean { - return this._isDisposed; - } - - /** - * Send a user message and trigger agent response. - * - * Uses optimistic updates for instant UI feedback. The message appears - * immediately in the UI while the server request is in flight. - * - * @param content - Text content to send - */ - async sendMessage(content: string): Promise { - if (!this._isConnected) { - throw new Error("Client not connected. Call connect() first."); - } - - await this.executeAction(this._messageAction, { - content, - messageId: crypto.randomUUID(), - role: "user", - agent: this.options.agent, - }); - } - - /** - * Append a message to the conversation. - * - * Uses optimistic updates for instant UI feedback. - * For user messages, this triggers agent response if an agent is configured. - * - * @param message - UIMessage or ModelMessage to append - */ - async append( - message: UIMessage | { role: string; content: string }, - ): Promise { - if (!this._isConnected) { - throw new Error("Client not connected. Call connect() first."); - } - - const content = - "parts" in message - ? extractTextContent(message as MessageRow) - : (message as { content: string }).content; - - const role = message.role as "user" | "assistant" | "system"; - const messageId = "id" in message ? message.id : crypto.randomUUID(); - - await this.executeAction(this._messageAction, { - content, - messageId, - role, - agent: role === "user" ? this.options.agent : undefined, - }); - } - - private async executeAction( - action: (input: T) => Transaction, - input: T, - ): Promise { - try { - const transaction = action(input); - await transaction.isPersisted.promise; - } catch (error) { - this._error = error instanceof Error ? error : new Error(String(error)); - this.options.onError?.(this._error); - throw error; - } - } - - private async postToProxy( - path: string, - body: Record, - options?: { actorIdHeader?: boolean }, - ): Promise { - const headers: Record = { - ...this.options.stream?.headers, - "Content-Type": "application/json", - }; - if (options?.actorIdHeader) { - headers["X-Actor-Id"] = this.actorId; - } - - const response = await fetch(`${this.options.proxyUrl}${path}`, { - method: "POST", - headers, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw StreamError.fromResponse(response); - } - } - - private async awaitTxIdWithTimeout({ - txid, - operation, - timeoutMs = AWAIT_TXID_TIMEOUT_MS, - }: { - txid: string; - operation: string; - timeoutMs?: number; - }): Promise { - let timeoutHandle: ReturnType | null = null; - const timeoutPromise = new Promise((_, reject) => { - timeoutHandle = setTimeout(() => { - reject( - new Error( - `[durable-session/${operation}] Timed out waiting for txid sync after ${timeoutMs}ms`, - ), - ); - }, timeoutMs); - }); - - try { - await Promise.race([this._db.utils.awaitTxId(txid), timeoutPromise]); - } finally { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - } - } - } - - /** - * Create the unified optimistic action for all message types. - * Handles user, assistant, and system messages with the same pattern. - * - * Optimistic updates insert into the messages collection directly. - * This ensures the optimistic state propagates to all derived collections - * (toolCalls, pendingApprovals, toolResults, activeGenerations). - */ - private createMessageAction() { - return createOptimisticAction({ - onMutate: ({ content, messageId, role }) => { - const createdAt = new Date(); - - this._collections.messages.insert({ - id: messageId, - role, - parts: [{ type: "text" as const, content }], - actorId: this.actorId, - isComplete: true, - createdAt, - }); - }, - mutationFn: async ({ content, messageId, role, agent }) => { - const txid = crypto.randomUUID(); - - await this.postToProxy(`/v1/sessions/${this.sessionId}/messages`, { - messageId, - content, - role, - actorId: this.actorId, - actorType: this.actorType, - txid, - ...(agent && { agent }), - }); - - await this.awaitTxIdWithTimeout({ - txid, - operation: "send-message", - }); - }, - }); - } - - async stop(): Promise { - await this.postToProxy(`/v1/sessions/${this.sessionId}/stop`, { - messageId: null, - }); - } - - clear(): void { - this.options.onMessagesChange?.([]); - } - - /** - * Add a tool result. - * - * Uses optimistic updates for instant UI feedback. - * - * @param result - Tool result to add - */ - async addToolResult(result: ToolResultInput): Promise { - if (!this._isConnected) { - throw new Error("Client not connected. Call connect() first."); - } - - const inputWithMessageId: ClientToolResultInput = { - ...result, - messageId: result.messageId ?? crypto.randomUUID(), - }; - await this.executeAction(this._addToolResultAction, inputWithMessageId); - } - - private createAddToolResultAction() { - return createOptimisticAction({ - onMutate: ({ messageId, toolCallId, output, error }) => { - const createdAt = new Date(); - - this._collections.messages.insert({ - id: messageId, - role: "assistant", - parts: [ - { - type: "tool-result" as const, - toolCallId, - content: - typeof output === "string" ? output : JSON.stringify(output), - state: error ? ("error" as const) : ("complete" as const), - ...(error && { error }), - }, - ], - actorId: this.actorId, - isComplete: true, - createdAt, - }); - }, - mutationFn: async ({ messageId, toolCallId, output, error }) => { - const txid = crypto.randomUUID(); - - await this.postToProxy( - `/v1/sessions/${this.sessionId}/tool-results`, - { messageId, toolCallId, output, error: error ?? null, txid }, - { actorIdHeader: true }, - ); - - await this.awaitTxIdWithTimeout({ - txid, - operation: "add-tool-result", - }); - }, - }); - } - - /** - * Add an approval response. - * - * Uses optimistic updates for instant UI feedback. - * - * @param response - Approval response - */ - async addToolApprovalResponse( - response: ApprovalResponseInput, - ): Promise { - if (!this._isConnected) { - throw new Error("Client not connected. Call connect() first."); - } - - await this.executeAction(this._addApprovalResponseAction, response); - } - - /** - * Create the optimistic action for approval responses. - * - * Finds the message containing the tool call with the approval and updates - * the approval.approved field. This propagates to pendingApprovals collection. - */ - private createApprovalResponseAction() { - return createOptimisticAction({ - onMutate: ({ id, approved }) => { - for (const message of this._collections.messages.values()) { - for (const part of message.parts) { - if (part.type === "tool-call" && part.approval?.id === id) { - this._collections.messages.update(message.id, (draft) => { - for (const p of draft.parts) { - const toolCall = p as ToolCallPart; - if ( - p.type === "tool-call" && - toolCall.approval?.id === id && - toolCall.approval - ) { - toolCall.approval.approved = approved; - } - } - }); - return; - } - } - } - }, - mutationFn: async ({ id, approved }) => { - const txid = crypto.randomUUID(); - - await this.postToProxy( - `/v1/sessions/${this.sessionId}/approvals/${id}`, - { approved, txid }, - { actorIdHeader: true }, - ); - - await this.awaitTxIdWithTimeout({ - txid, - operation: "approval-response", - }); - }, - }); - } - - /** - * Submit an answer to a user question tool call. - * - * Forwards the answer to the agent via the proxy. Unlike approvals, - * answers don't modify local state — they trigger agent continuation. - * - * @param response - Answer response with tool call ID and answers - */ - async addToolAnswerResponse(response: AnswerResponseInput): Promise { - if (!this._isConnected) { - throw new Error("Client not connected. Call connect() first."); - } - - await this.executeAction(this._addAnswerResponseAction, response); - } - - /** - * Create the optimistic action for answer responses. - * - * Answers don't modify local message state (unlike approvals). - * They are forwarded to the agent which will continue the conversation. - */ - private createAnswerResponseAction() { - return createOptimisticAction({ - onMutate: () => { - // No optimistic update needed — answers trigger agent continuation, - // they don't modify existing tool call state. - }, - mutationFn: async ({ toolCallId, answers, originalInput }) => { - await this.postToProxy( - `/v1/sessions/${this.sessionId}/answers/${toolCallId}`, - { answers, ...(originalInput && { originalInput }) }, - { actorIdHeader: true }, - ); - }, - }); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Collections - // ═══════════════════════════════════════════════════════════════════════ - - get collections() { - return this._collections; - } - - // ═══════════════════════════════════════════════════════════════════════ - // Durable-specific features - // ═══════════════════════════════════════════════════════════════════════ - - get connectionStatus(): ConnectionStatus { - const meta = this._collections.sessionMeta.get(this.sessionId); - return meta?.connectionStatus ?? "disconnected"; - } - - /** - * Fork session at a message boundary. - * - * @param options - Fork options - * @returns New session info - */ - async fork(options?: ForkOptions): Promise { - const response = await fetch( - `${this.options.proxyUrl}/v1/sessions/${this.sessionId}/fork`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - atMessageId: options?.atMessageId ?? null, - newSessionId: options?.newSessionId ?? null, - }), - }, - ); - - if (!response.ok) { - throw StreamError.fromResponse(response); - } - - return (await response.json()) as ForkResult; - } - - /** - * Register agents to respond to session messages. - * - * @param agents - Agent specifications - */ - async registerAgents(agents: AgentSpec[]): Promise { - const response = await fetch( - `${this.options.proxyUrl}/v1/sessions/${this.sessionId}/agents`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ agents }), - }, - ); - - if (!response.ok) { - throw StreamError.fromResponse(response); - } - } - - /** - * Unregister an agent. - * - * @param agentId - Agent identifier - */ - async unregisterAgent(agentId: string): Promise { - const response = await fetch( - `${this.options.proxyUrl}/v1/sessions/${this.sessionId}/agents/${agentId}`, - { - method: "DELETE", - }, - ); - - if (!response.ok) { - throw StreamError.fromResponse(response); - } - } - - // ═══════════════════════════════════════════════════════════════════════ - // Lifecycle - // ═══════════════════════════════════════════════════════════════════════ - - /** - * Connect to the durable stream and start syncing. - * - * This method handles network operations only - collections are already - * created synchronously in the constructor and are immediately available. - */ - async connect(): Promise { - if (this._isConnected) return; - - try { - this.updateSessionMeta((meta) => - updateConnectionStatus(meta, "connecting"), - ); - - if (!this.options.sessionDB) { - const response = await fetch( - `${this.options.proxyUrl}/v1/sessions/${this.sessionId}`, - { - method: "PUT", - headers: this.options.stream?.headers, - signal: this._abortController.signal, - }, - ); - - if (!response.ok) { - throw StreamError.fromResponse(response); - } - } - - await this._db.preload(); - - this._isConnected = true; - - this.updateSessionMeta((meta) => - updateConnectionStatus(meta, "connected"), - ); - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - this._error = err; - this.updateSessionMeta((meta) => - updateConnectionStatus(meta, "error", { - message: err.message, - }), - ); - this.options.onError?.(this._error); - throw error; - } - } - - pause(): void { - // No-op: stream-db handles pausing internally via the abort signal - } - - async resume(): Promise { - if (!this._isConnected) { - await this.connect(); - return; - } - // No-op: stream-db handles resuming internally - } - - disconnect(): void { - this._db.close(); - - this._abortController.abort(); - this._isConnected = false; - - this.updateSessionMeta((meta) => - updateConnectionStatus(meta, "disconnected"), - ); - } - - /** - * Dispose the client and clean up resources. - * - * Note: We only disconnect here - we don't manually cleanup collections. - * All exposed collections could be used by application code via useLiveQuery, - * and manual cleanup would error: "Source collection was manually cleaned up - * while live query depends on it." - * - * TanStack DB will GC collections automatically when they have no subscribers. - */ - dispose(): void { - if (this._isDisposed) return; - this._isDisposed = true; - this.disconnect(); - } - - // ═══════════════════════════════════════════════════════════════════════ - // Private Helpers - // ═══════════════════════════════════════════════════════════════════════ - - private updateSessionMeta( - updater: (meta: SessionMetaRow) => SessionMetaRow, - ): void { - const current = this._collections.sessionMeta.get(this.sessionId); - if (current) { - const updated = updater(current); - this._collections.sessionMeta.update(this.sessionId, (draft) => { - Object.assign(draft, updated); - }); - } - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Factory Function -// ═══════════════════════════════════════════════════════════════════════════ - -export function createDurableChatClient< - TTools extends ReadonlyArray = AnyClientTool[], ->(options: DurableChatClientOptions): DurableChatClient { - return new DurableChatClient(options); -} diff --git a/packages/durable-session/src/collection.ts b/packages/durable-session/src/collection.ts deleted file mode 100644 index 8d278c61b6c..00000000000 --- a/packages/durable-session/src/collection.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Session stream-db factory. - * - * Creates a stream-backed database using `@durable-streams/state` for syncing - * a session's data from Durable Streams. This replaces the previous - * `@tanstack/durable-stream-db-collection` approach. - * - * The resulting StreamDB provides typed collections (chunks, presence, agents) - * that are automatically populated from the STATE-PROTOCOL events on the stream. - */ - -import { - createStreamDB, - type StreamDB, - type StreamDBMethods, -} from "@durable-streams/state"; -import type { Collection } from "@tanstack/db"; -import { - type AgentRow, - type ChunkRow, - type RawPresenceRow, - sessionStateSchema, -} from "./schema"; -import type { SessionDBConfig } from "./types"; - -// ============================================================================ -// Session StreamDB Types -// ============================================================================ - -/** - * Collections map with correct row types. - * - * stream-db injects the primary key field at runtime, so ChunkRow and - * RawPresenceRow include the `id` field even though it's not in the schema. - * We define the correct types here. - * - * Note: The presence collection here is the raw per-device presence. - * The aggregated per-actor presence is created in client.ts. - */ -export interface SessionCollections { - chunks: Collection; - presence: Collection; - agents: Collection; -} - -/** - * Type alias for a session stream-db instance. - * - * Provides typed access to: - * - `db.collections.chunks` - All message chunks - * - `db.collections.presence` - User/agent presence - * - `db.collections.agents` - Registered agents - * - * Plus stream-db methods: - * - `db.preload()` - Wait for initial sync - * - `db.close()` - Cleanup resources - * - `db.utils.awaitTxId(txid)` - Wait for specific write to sync - */ -export type SessionDB = { - collections: SessionCollections; -} & StreamDBMethods; - -/** - * Internal type for the raw stream-db instance. - * @internal - */ -type RawSessionDB = StreamDB; - -// ============================================================================ -// Session StreamDB Factory -// ============================================================================ - -/** - * Create a stream-db instance for a session. - * - * This function is synchronous - it creates the stream handle and collections - * but does not start the stream connection. Call `db.preload()` to connect - * and wait for the initial sync to complete. - * - * The returned SessionDB instance provides: - * - `db.collections.chunks` - Root chunks collection (for messages) - * - `db.collections.presence` - Presence tracking - * - `db.collections.agents` - Registered agents - * - * @example - * ```typescript - * import { createSessionDB } from '@superset/durable-session' - * - * // Create stream-db for this session (synchronous) - * const db = createSessionDB({ - * sessionId: 'my-session', - * baseUrl: 'http://localhost:4000', - * }) - * - * // Wait for initial data sync - * await db.preload() - * - * // Access typed collections - * for (const chunk of db.collections.chunks.values()) { - * console.log(chunk.messageId, chunk.role, chunk.chunk) - * } - * - * // Cleanup when done - * db.close() - * ``` - */ -export function createSessionDB(config: SessionDBConfig): SessionDB { - const { sessionId, baseUrl, headers, signal /* liveMode */ } = config; - - // Build the stream URL for this session - const streamUrl = `${baseUrl}/v1/stream/sessions/${sessionId}`; - - // Create the stream-db instance with our session state schema (synchronous) - const rawDb: RawSessionDB = createStreamDB({ - streamOptions: { - url: streamUrl, - headers, - signal, - }, - state: sessionStateSchema, - // liveMode, - }); - - // Cast to our SessionDB type which has correctly typed collections - // (stream-db injects the primary key at runtime, so our types reflect that) - return rawDb as unknown as SessionDB; -} - -// ============================================================================ -// Utility Functions -// ============================================================================ - -/** - * Get the primary key for a chunk (used for collection lookups). - * - * Key format: `${messageId}:${seq}` - * - * @param messageId - Message identifier - * @param seq - Sequence number within message - * @returns Primary key string - */ -export function getChunkKey(messageId: string, seq: number): string { - return `${messageId}:${seq}`; -} - -/** - * Parse a chunk key into its components. - * - * @param key - Chunk key in format `${messageId}:${seq}` - * @returns Parsed components or null if invalid - */ -export function parseChunkKey( - key: string, -): { messageId: string; seq: number } | null { - const lastColonIndex = key.lastIndexOf(":"); - if (lastColonIndex === -1) return null; - - const messageId = key.slice(0, lastColonIndex); - const seqStr = key.slice(lastColonIndex + 1); - const seq = parseInt(seqStr, 10); - - if (Number.isNaN(seq)) return null; - - return { messageId, seq }; -} diff --git a/packages/durable-session/src/collections/active-generations.ts b/packages/durable-session/src/collections/active-generations.ts deleted file mode 100644 index 27aaf5de0e4..00000000000 --- a/packages/durable-session/src/collections/active-generations.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Active generations collection - derived from messages. - * - * Tracks messages that are currently being streamed (have chunks but no finish chunk). - * This is derived from the messages collection by filtering for incomplete messages. - * - * This follows the pattern: derive from materialized data with fn.select - */ - -import type { Collection } from "@tanstack/db"; -import { createLiveQueryCollection } from "@tanstack/db"; -import type { ActiveGenerationRow, MessageRow } from "../types"; - -// ============================================================================ -// Active Generations Collection -// ============================================================================ - -/** - * Options for creating an active generations collection. - */ -export interface ActiveGenerationsCollectionOptions { - /** Messages collection to derive from */ - messagesCollection: Collection; -} - -/** - * Convert an incomplete message to an active generation row. - */ -function messageToActiveGeneration(message: MessageRow): ActiveGenerationRow { - return { - messageId: message.id, - actorId: message.actorId, - startedAt: message.createdAt, - lastChunkSeq: 0, // We don't track seq in messages, so use 0 as placeholder - lastChunkAt: message.createdAt, - }; -} - -/** - * Creates the active generations collection from messages. - * - * Filters messages to only include incomplete ones (isComplete === false) - * and transforms them into ActiveGenerationRow format. - * - * Active generations are useful for: - * - Showing typing indicators - * - Tracking streaming progress - * - Resuming interrupted generations - * - * @example - * ```typescript - * const activeGenerations = createActiveGenerationsCollection({ - * sessionId: 'my-session', - * messagesCollection, - * }) - * - * // Check if anything is generating - * const isLoading = activeGenerations.size > 0 - * - * // Access active generations directly - * for (const gen of activeGenerations.values()) { - * console.log(gen.messageId, gen.actorId, gen.startedAt) - * } - * ``` - */ -export function createActiveGenerationsCollection( - options: ActiveGenerationsCollectionOptions, -): Collection { - const { messagesCollection } = options; - - // Filter messages for incomplete ones and transform to ActiveGenerationRow - // Order by createdAt to ensure chronological ordering - return createLiveQueryCollection({ - query: (q) => - q - .from({ message: messagesCollection }) - .orderBy(({ message }) => message.createdAt, "asc") - .fn.where(({ message }) => !message.isComplete) - .fn.select(({ message }) => messageToActiveGeneration(message)), - }); -} diff --git a/packages/durable-session/src/collections/index.ts b/packages/durable-session/src/collections/index.ts deleted file mode 100644 index 3b4e169c362..00000000000 --- a/packages/durable-session/src/collections/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Collection exports for @superset/durable-session - * - * Pipeline architecture: - * - chunks → (subquery) → messages (root materialized collection) - * - Derived collections filter messages via .fn.where() on parts - * - * All derived collections return MessageRow[], preserving full message context. - * Consumers filter message.parts to access specific part types (ToolCallPart, etc.). - */ - -// Active generations collection (derived from messages) -export { - type ActiveGenerationsCollectionOptions, - createActiveGenerationsCollection, -} from "./active-generations"; -// Messages collection (root) and derived collections -export { - createMessagesCollection, - createPendingApprovalsCollection, - createToolCallsCollection, - createToolResultsCollection, - type DerivedMessagesCollectionOptions, - type MessagesCollectionOptions, -} from "./messages"; -// Model messages collection (for LLM invocation) -export { - createModelMessagesCollection, - type ModelMessage, - type ModelMessagesCollectionOptions, -} from "./model-messages"; -// Aggregated presence collection (derived from raw per-device presence) -export { - createPresenceCollection, - type PresenceCollectionOptions, -} from "./presence"; -// Session metadata collection (local state) -export { - createInitialSessionMeta, - createSessionMetaCollectionOptions, - type SessionMetaCollectionOptions, - updateConnectionStatus, - updateSyncProgress, -} from "./session-meta"; -// Session statistics collection (aggregated from chunks) -export { - computeSessionStats, - createEmptyStats, - createSessionStatsCollection, - type SessionStatsCollectionOptions, -} from "./session-stats"; diff --git a/packages/durable-session/src/collections/messages.ts b/packages/durable-session/src/collections/messages.ts deleted file mode 100644 index 8fdad1e450a..00000000000 --- a/packages/durable-session/src/collections/messages.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Messages collection - core live query pipeline. - * - * Architecture: - * - chunks → (groupBy messageId + count/min) → fn.select(materialize) - * - Derived collections use .fn.where() to filter by message parts - * - * Note: The upstream @tanstack/db `collect` aggregate is not yet published. - * Instead, we use groupBy + count as a change discriminator, then - * imperatively filter the chunks collection inside fn.select to gather - * all chunks for each message. - */ - -import type { ToolCallPart } from "@tanstack/ai"; -import type { Collection } from "@tanstack/db"; -import { count, createLiveQueryCollection, min } from "@tanstack/db"; -import { materializeMessage } from "../materialize"; -import type { ChunkRow } from "../schema"; -import type { MessageRow } from "../types"; - -// ============================================================================ -// Messages Collection (Root) -// ============================================================================ - -/** - * Options for creating a messages collection. - */ -export interface MessagesCollectionOptions { - /** Chunks collection from stream-db */ - chunksCollection: Collection; -} - -/** - * Creates the messages collection with inline subquery for chunk aggregation. - * - * This is the root materialized collection in the live query pipeline. - * All derived collections (toolCalls, pendingApprovals, etc.) derive from this. - * - * Uses groupBy + count/min as change discriminators, then fn.select - * imperatively gathers chunks from the collection per messageId. - */ -export function createMessagesCollection( - options: MessagesCollectionOptions, -): Collection { - const { chunksCollection } = options; - - return createLiveQueryCollection({ - query: (q) => { - // Subquery: group chunks by messageId with aggregates for change detection - const grouped = q - .from({ chunk: chunksCollection }) - .groupBy(({ chunk }) => chunk.messageId) - .select(({ chunk }) => ({ - messageId: chunk.messageId, - // min() handles strings (ISO 8601 sort lexicographically) - startedAt: min(chunk.createdAt), - // Count as discriminator to force re-evaluation when chunks change - rowCount: count(chunk), - })); - - // Main query: materialize messages from chunks - return q - .from({ grouped }) - .orderBy(({ grouped }) => grouped.startedAt, "asc") - .fn.select(({ grouped }) => { - // Imperatively gather all chunks for this messageId - const rows = [...chunksCollection.values()].filter( - (c) => (c as ChunkRow).messageId === grouped.messageId, - ) as ChunkRow[]; - return materializeMessage(rows); - }); - }, - getKey: (row) => row.id, - }); -} - -// ============================================================================ -// Derived Collections -// ============================================================================ - -/** - * Options for creating a derived collection from messages. - */ -export interface DerivedMessagesCollectionOptions { - /** Messages collection to derive from */ - messagesCollection: Collection; -} - -/** - * Creates a collection of messages that contain tool calls. - * - * Filters messages where at least one part has type 'tool-call'. - * The collection is lazy - filtering only runs when accessed. - */ -export function createToolCallsCollection( - options: DerivedMessagesCollectionOptions, -): Collection { - const { messagesCollection } = options; - - return createLiveQueryCollection({ - query: (q) => - q - .from({ message: messagesCollection }) - .fn.where(({ message }) => - message.parts.some((p): p is ToolCallPart => p.type === "tool-call"), - ) - .orderBy(({ message }) => message.createdAt, "asc"), - getKey: (row) => row.id, - }); -} - -/** - * Creates a collection of messages that have pending approval requests. - * - * Filters messages where at least one tool call part has: - * - approval.needsApproval === true - * - approval.approved === undefined (not yet responded) - */ -export function createPendingApprovalsCollection( - options: DerivedMessagesCollectionOptions, -): Collection { - const { messagesCollection } = options; - - return createLiveQueryCollection({ - query: (q) => - q - .from({ message: messagesCollection }) - .fn.where(({ message }) => - message.parts.some( - (p): p is ToolCallPart => - p.type === "tool-call" && - p.approval?.needsApproval === true && - p.approval.approved === undefined, - ), - ) - .orderBy(({ message }) => message.createdAt, "asc"), - getKey: (row) => row.id, - }); -} - -/** - * Creates a collection of messages that contain tool results. - * - * Filters messages where at least one part has type 'tool-result'. - */ -export function createToolResultsCollection( - options: DerivedMessagesCollectionOptions, -): Collection { - const { messagesCollection } = options; - - return createLiveQueryCollection({ - query: (q) => - q - .from({ message: messagesCollection }) - .fn.where(({ message }) => - message.parts.some((p) => p.type === "tool-result"), - ) - .orderBy(({ message }) => message.createdAt, "asc"), - getKey: (row) => row.id, - }); -} diff --git a/packages/durable-session/src/collections/model-messages.ts b/packages/durable-session/src/collections/model-messages.ts deleted file mode 100644 index d5dfb2fdea1..00000000000 --- a/packages/durable-session/src/collections/model-messages.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Model messages collection - LLM-ready message history. - * - * Derives from the messages collection: - * 1. Filters to complete messages only (isComplete === true) - * 2. Converts to { role, content } format expected by LLMs - * 3. Orders chronologically - */ - -import type { Collection } from "@tanstack/db"; -import { createLiveQueryCollection, eq } from "@tanstack/db"; -import { extractTextContent } from "../materialize"; -import type { MessageRow } from "../types"; - -// ============================================================================ -// Types -// ============================================================================ - -/** - * Message format expected by LLMs (OpenAI/Anthropic compatible). - */ -export interface ModelMessage { - id: string; - role: "user" | "assistant" | "system"; - content: string; -} - -/** - * Options for creating a model messages collection. - */ -export interface ModelMessagesCollectionOptions { - /** Messages collection (from createMessagesPipeline) */ - messagesCollection: Collection; -} - -// ============================================================================ -// Factory -// ============================================================================ - -/** - * Creates a collection of LLM-ready messages. - * - * This derived collection: - * - Filters to complete messages only (streaming messages excluded) - * - Extracts text content from message parts - * - Provides chronologically ordered { role, content } objects - * - * @example - * ```typescript - * const { messages } = createMessagesPipeline({ ... }) - * const modelMessages = createModelMessagesCollection({ - * messagesCollection: messages, - * }) - * - * // Get LLM-ready history - * // Note: toArray is a getter (property), not a method - * const history = modelMessages.toArray.map(m => ({ - * role: m.role, - * content: m.content, - * })) - * ``` - */ -export function createModelMessagesCollection( - options: ModelMessagesCollectionOptions, -): Collection { - const { messagesCollection } = options; - - return createLiveQueryCollection({ - query: (q) => - q - .from({ message: messagesCollection }) - .where(({ message }) => eq(message.isComplete, true)) - .orderBy(({ message }) => message.createdAt, "asc") - .fn.select(({ message }) => ({ - id: message.id, - role: message.role, - content: extractTextContent(message), - })), - getKey: (row) => row.id, - startSync: true, - }); -} diff --git a/packages/durable-session/src/collections/presence.ts b/packages/durable-session/src/collections/presence.ts deleted file mode 100644 index 99bcec52ca4..00000000000 --- a/packages/durable-session/src/collections/presence.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Aggregated presence collection - derived from raw per-device presence. - * - * The raw presence from stream-db tracks each (actorId, deviceId) pair. - * This collection aggregates devices per actor, filtering for online status, - * to provide a simple "who's online" view. - * - * Note: The upstream @tanstack/db `collect` aggregate is not yet published. - * Instead, we use groupBy + count as a change discriminator, then - * imperatively gather device IDs inside fn.select. - */ - -import type { Collection } from "@tanstack/db"; -import { count, createLiveQueryCollection, eq } from "@tanstack/db"; -import type { PresenceRow, RawPresenceRow } from "../schema"; - -// ============================================================================ -// Aggregated Presence Collection -// ============================================================================ - -/** - * Options for creating an aggregated presence collection. - */ -export interface PresenceCollectionOptions { - /** Session identifier */ - sessionId: string; - /** Raw presence collection from stream-db (per-device records) */ - rawPresenceCollection: Collection; -} - -/** - * Creates the aggregated presence collection. - * - * Uses a live query pipeline to: - * 1. Filter raw presence for status='online' - * 2. Group by actorId - * 3. Use fn.select to imperatively collect device IDs - * - * The result is one row per online actor, with their device count. - */ -export function createPresenceCollection( - options: PresenceCollectionOptions, -): Collection { - const { rawPresenceCollection } = options; - - return createLiveQueryCollection({ - query: (q) => { - // Subquery: filter for online, group by actorId, count for change detection - const grouped = q - .from({ presence: rawPresenceCollection }) - .where(({ presence }) => eq(presence.status, "online")) - .groupBy(({ presence }) => presence.actorId) - .select(({ presence }) => ({ - actorId: presence.actorId, - deviceCount: count(presence.deviceId), - })); - - // Main query: imperatively gather device info per actor - return q.from({ grouped }).fn.select(({ grouped }) => { - // Get all online presence rows for this actor - const actorPresence = [...rawPresenceCollection.values()].filter( - (p) => - (p as RawPresenceRow).actorId === grouped.actorId && - (p as RawPresenceRow).status === "online", - ) as RawPresenceRow[]; - - const first = actorPresence[0]; - return { - actorId: grouped.actorId as string, - actorType: (first?.actorType ?? "user") as "user" | "agent", - name: first?.name, - deviceIds: actorPresence.map((p) => p.deviceId), - deviceCount: actorPresence.length, - }; - }); - }, - startSync: true, - }); -} diff --git a/packages/durable-session/src/collections/session-meta.ts b/packages/durable-session/src/collections/session-meta.ts deleted file mode 100644 index eb53fc114c5..00000000000 --- a/packages/durable-session/src/collections/session-meta.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Session metadata collection - local state collection. - * - * Tracks connection state and sync progress. - * This is a local-only collection, not derived from stream. - */ - -import { localOnlyCollectionOptions } from "@tanstack/db"; -import type { ConnectionStatus, SessionMetaRow } from "../types"; - -/** - * Options for creating a session meta collection. - */ -export interface SessionMetaCollectionOptions { - /** Session identifier */ - sessionId: string; -} - -/** - * Creates collection config for the session metadata collection. - * - * This collection stores local state: - * - connectionStatus - * - lastSyncedOffset - * - lastSyncedAt - * - error - * - * The collection is a single-row collection keyed by sessionId. - * - * @example - * ```typescript - * import { createSessionMetaCollectionOptions } from '@superset/durable-session' - * import { createCollection } from '@tanstack/db' - * - * const sessionMetaCollection = createCollection( - * createSessionMetaCollectionOptions({ - * sessionId: 'my-session', - * }) - * ) - * ``` - */ -export function createSessionMetaCollectionOptions( - options: SessionMetaCollectionOptions, -) { - const { sessionId } = options; - - return localOnlyCollectionOptions({ - id: `session-meta:${sessionId}`, - getKey: (meta) => meta.sessionId, - }); -} - -/** - * Create initial session metadata. - * - * @param sessionId - Session identifier - * @returns Initial session metadata row - */ -export function createInitialSessionMeta(sessionId: string): SessionMetaRow { - return { - sessionId, - connectionStatus: "disconnected", - lastSyncedTxId: null, - lastSyncedAt: null, - error: null, - }; -} - -/** - * Update session metadata with new connection status. - * - * @param meta - Current metadata - * @param status - New connection status - * @param error - Optional error information - * @returns Updated metadata - */ -export function updateConnectionStatus( - meta: SessionMetaRow, - status: ConnectionStatus, - error?: { message: string; code?: string } | null, -): SessionMetaRow { - return { - ...meta, - connectionStatus: status, - error: error ?? (status === "connected" ? null : meta.error), - }; -} - -/** - * Update session metadata with sync progress. - * - * @param meta - Current metadata - * @param txId - Last synced transaction ID - * @returns Updated metadata - */ -export function updateSyncProgress( - meta: SessionMetaRow, - txId: string, -): SessionMetaRow { - return { - ...meta, - lastSyncedTxId: txId, - lastSyncedAt: new Date(), - connectionStatus: "connected", - error: null, - }; -} diff --git a/packages/durable-session/src/collections/session-stats.ts b/packages/durable-session/src/collections/session-stats.ts deleted file mode 100644 index d2244427a73..00000000000 --- a/packages/durable-session/src/collections/session-stats.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Session statistics collection - aggregated from chunks. - * - * Computes aggregate statistics from the stream by: - * 1. Counting all chunks for the session (as change discriminator) - * 2. Imperatively grouping by messageId and computing stats - * - * Uses TanStack AI's MessagePart types for type-safe filtering. - */ - -import type { ToolCallPart } from "@tanstack/ai"; -import type { Collection } from "@tanstack/db"; -import { count, createLiveQueryCollection } from "@tanstack/db"; -import { materializeMessage, parseChunk } from "../materialize"; -import type { ChunkRow } from "../schema"; -import type { MessageRow, SessionStatsRow } from "../types"; - -// ============================================================================ -// Session Stats Collection -// ============================================================================ - -/** - * Options for creating a session stats collection. - */ -export interface SessionStatsCollectionOptions { - /** Session identifier */ - sessionId: string; - /** Chunks collection from stream-db */ - chunksCollection: Collection; -} - -/** - * Creates the session stats collection. - * - * Uses groupBy with count as a change discriminator, then fn.select - * imperatively gathers all chunks and computes stats. - */ -export function createSessionStatsCollection( - options: SessionStatsCollectionOptions, -): Collection { - const { sessionId, chunksCollection } = options; - - // Single-stage: group by sessionId (constant), count for change detection, compute stats in fn.select - const collectedRows = createLiveQueryCollection({ - query: (q) => - q - .from({ chunk: chunksCollection }) - .groupBy(() => sessionId) - .select(({ chunk }) => ({ - sessionId, - rowCount: count(chunk), - })), - }); - - return createLiveQueryCollection({ - query: (q) => - q.from({ collected: collectedRows }).fn.select(({ collected }) => { - // Imperatively gather all chunks - const rows = [...chunksCollection.values()] as ChunkRow[]; - return computeSessionStats(collected.sessionId as string, rows); - }), - }); -} - -/** - * Group chunk rows by messageId. - */ -function groupRowsByMessage(rows: ChunkRow[]): Map { - const grouped = new Map(); - - for (const row of rows) { - const existing = grouped.get(row.messageId); - if (existing) { - existing.push(row); - } else { - grouped.set(row.messageId, [row]); - } - } - - return grouped; -} - -/** - * Compute session statistics from chunk rows. - * - * Materializes messages and counts parts by type to derive statistics. - * - * @param sessionId - Session identifier - * @param rows - All chunk rows - * @returns Computed statistics - */ -export function computeSessionStats( - sessionId: string, - rows: ChunkRow[], -): SessionStatsRow { - if (rows.length === 0) { - return createEmptyStats(sessionId); - } - - // Group rows by message - const grouped = groupRowsByMessage(rows); - - // Materialize messages for counting - const messages: MessageRow[] = []; - for (const [, messageRows] of grouped) { - try { - messages.push(materializeMessage(messageRows)); - } catch { - // Skip invalid messages - } - } - - // Count message types and extract part counts - let userMessageCount = 0; - let assistantMessageCount = 0; - let toolCallCount = 0; - let pendingApprovalCount = 0; - let activeGenerationCount = 0; - let firstMessageAt: Date | null = null; - let lastMessageAt: Date | null = null; - - for (const msg of messages) { - // Count by role - if (msg.role === "user") { - userMessageCount++; - } else if (msg.role === "assistant") { - assistantMessageCount++; - } - - // Track timestamps - if (!firstMessageAt || msg.createdAt < firstMessageAt) { - firstMessageAt = msg.createdAt; - } - if (!lastMessageAt || msg.createdAt > lastMessageAt) { - lastMessageAt = msg.createdAt; - } - - // Count tool calls and pending approvals from parts - for (const part of msg.parts) { - if (part.type === "tool-call") { - toolCallCount++; - const toolCallPart = part as ToolCallPart; - if ( - toolCallPart.approval?.needsApproval === true && - toolCallPart.approval.approved === undefined - ) { - pendingApprovalCount++; - } - } - } - - // Count active generations (incomplete messages) - if (!msg.isComplete) { - activeGenerationCount++; - } - } - - // Extract token usage from chunks - const { totalTokens, promptTokens, completionTokens } = - extractTokenUsage(rows); - - return { - sessionId, - messageCount: messages.length, - userMessageCount, - assistantMessageCount, - toolCallCount, - approvalCount: pendingApprovalCount, - totalTokens, - promptTokens, - completionTokens, - activeGenerationCount, - firstMessageAt, - lastMessageAt, - }; -} - -/** - * Extract token usage from chunk rows. - * - * @param rows - Chunk rows to extract from - * @returns Token usage counts - */ -function extractTokenUsage(rows: ChunkRow[]): { - totalTokens: number; - promptTokens: number; - completionTokens: number; -} { - let totalTokens = 0; - let promptTokens = 0; - let completionTokens = 0; - - for (const row of rows) { - const chunk = parseChunk(row.chunk); - if (!chunk) continue; - - // Look for usage information in chunks - const usage = ( - chunk as { - usage?: { - totalTokens?: number; - promptTokens?: number; - completionTokens?: number; - total_tokens?: number; - prompt_tokens?: number; - completion_tokens?: number; - }; - } - ).usage; - - if (usage) { - // Handle both camelCase and snake_case formats - totalTokens += usage.totalTokens ?? usage.total_tokens ?? 0; - promptTokens += usage.promptTokens ?? usage.prompt_tokens ?? 0; - completionTokens += - usage.completionTokens ?? usage.completion_tokens ?? 0; - } - } - - return { totalTokens, promptTokens, completionTokens }; -} - -/** - * Create empty session statistics. - * - * @param sessionId - Session identifier - * @returns Empty statistics row - */ -export function createEmptyStats(sessionId: string): SessionStatsRow { - return { - sessionId, - messageCount: 0, - userMessageCount: 0, - assistantMessageCount: 0, - toolCallCount: 0, - approvalCount: 0, - totalTokens: 0, - promptTokens: 0, - completionTokens: 0, - activeGenerationCount: 0, - firstMessageAt: null, - lastMessageAt: null, - }; -} diff --git a/packages/durable-session/src/errors.ts b/packages/durable-session/src/errors.ts deleted file mode 100644 index 6ad89ab6567..00000000000 --- a/packages/durable-session/src/errors.ts +++ /dev/null @@ -1,53 +0,0 @@ -const FRIENDLY_MESSAGES: Record = { - 401: "Your session has expired. Please sign in again.", - 403: "You don't have permission to access this chat.", - 404: "Chat session not found. It may have been deleted.", - 429: "Too many requests. Please wait a moment and try again.", - 500: "Something went wrong on our end. Please try again.", - 502: "Chat server is temporarily unavailable. Please try again.", - 503: "Chat server is temporarily unavailable. Please try again.", -}; - -const NETWORK_MESSAGE = - "Unable to connect to the chat server. Check your internet connection."; - -export class StreamError extends Error { - readonly status: number; - readonly friendlyMessage: string; - - constructor(status: number, detail?: string) { - const friendly = - FRIENDLY_MESSAGES[status] ?? `Unexpected error (${status})`; - super(detail ?? friendly); - this.name = "StreamError"; - this.status = status; - this.friendlyMessage = friendly; - } - - static fromResponse(response: Response): StreamError { - return new StreamError(response.status); - } - - static friendly(error: unknown): { message: string; code: string | null } { - if (error instanceof StreamError) { - return { - message: error.friendlyMessage, - code: error.status > 0 ? `HTTP ${error.status}` : "NETWORK_ERROR", - }; - } - if (error instanceof TypeError && error.message.includes("fetch")) { - return { message: NETWORK_MESSAGE, code: "NETWORK_ERROR" }; - } - if (error instanceof Error) { - if (error.message.includes("Content Security Policy")) { - return { - message: - "Connection blocked by security policy. The chat server URL may not be allowed.", - code: "CSP_VIOLATION", - }; - } - return { message: error.message, code: null }; - } - return { message: "An unexpected error occurred.", code: "UNKNOWN" }; - } -} diff --git a/packages/durable-session/src/index.ts b/packages/durable-session/src/index.ts deleted file mode 100644 index 9fe08eaf8c4..00000000000 --- a/packages/durable-session/src/index.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * @superset/durable-session - * - * Framework-agnostic durable chat client backed by TanStack DB and Durable Streams. - * - * This package provides: - * - TanStack AI-compatible API for chat applications - * - Durable persistence via Durable Streams - * - Real-time sync across tabs, devices, and users - * - Multi-agent support with webhook registration - * - Reactive collections for custom UI needs - * - * Architecture: - * - chunks → (subquery) → messages (root materialized collection) - * - Derived collections filter messages via .fn.where() on parts - * - All collections return MessageRow[], preserving full message context - * - Consumers filter message.parts to access specific part types - * - * @example - * ```typescript - * import { DurableChatClient } from '@superset/durable-session' - * - * const client = new DurableChatClient({ - * sessionId: 'my-session', - * proxyUrl: 'http://localhost:4000', - * }) - * - * await client.connect() - * - * // TanStack AI-compatible API - * await client.sendMessage('Hello!') - * console.log(client.messages) - * - * // Access collections directly - * for (const message of client.collections.messages.values()) { - * console.log(message.id, message.role, message.parts) - * } - * - * // Filter tool calls from message parts - * for (const message of client.collections.toolCalls.values()) { - * for (const part of message.parts) { - * if (part.type === 'tool-call') { - * console.log(part.name, part.state, part.arguments) - * } - * } - * } - * - * // Check for pending approvals - * for (const message of client.collections.pendingApprovals.values()) { - * for (const part of message.parts) { - * if (part.type === 'tool-call' && part.approval?.needsApproval) { - * console.log(`Approval needed: ${part.name}`) - * } - * } - * } - * ``` - * - * @packageDocumentation - */ - -// ============================================================================ -// Client -// ============================================================================ - -export { createDurableChatClient, DurableChatClient } from "./client"; -export { StreamError } from "./errors"; - -// ============================================================================ -// Schema (STATE-PROTOCOL) -// ============================================================================ - -export { - type AgentRow, - type AgentValue, - agentValueSchema, - type ChunkRow, - type ChunkValue, - chunkValueSchema, - type PresenceRow, - type PresenceValue, - presenceValueSchema, - type RawPresenceRow, - type SessionStateSchema, - sessionStateSchema, -} from "./schema"; - -// ============================================================================ -// Types -// ============================================================================ - -export type { - // Active generation types - ActiveGenerationRow, - // Actor types - ActorType, - AgentSpec, - // Agent types - AgentTrigger, - AnswerResponseInput, - ApprovalResponseInput, - // Session types - ConnectionStatus, - // Configuration types - DurableChatClientOptions, - // Collection types - DurableChatCollections, - // Fork types - ForkOptions, - ForkResult, - // Re-exported TanStack AI types for consumer convenience - MessagePart, - // Message types - MessageRole, - MessageRow, - SessionDBConfig, - SessionMetaRow, - SessionStatsRow, - TextPart, - ThinkingPart, - ToolCallPart, - // Input types - ToolResultInput, - ToolResultPart, - UIMessage, -} from "./types"; - -// ============================================================================ -// Session DB Factory -// ============================================================================ - -export { - createSessionDB, - getChunkKey, - parseChunkKey, - type SessionDB, -} from "./collection"; - -// ============================================================================ -// Collection Factories -// ============================================================================ - -export { - type ActiveGenerationsCollectionOptions, - computeSessionStats, - // Active generations collection - createActiveGenerationsCollection, - createEmptyStats, - createInitialSessionMeta, - // Messages collection (root) and derived collections - createMessagesCollection, - // Model messages collection (for LLM invocation) - createModelMessagesCollection, - createPendingApprovalsCollection, - // Aggregated presence collection - createPresenceCollection, - // Session metadata collection (local state) - createSessionMetaCollectionOptions, - // Session statistics collection - createSessionStatsCollection, - createToolCallsCollection, - createToolResultsCollection, - type DerivedMessagesCollectionOptions, - type MessagesCollectionOptions, - type ModelMessage, - type ModelMessagesCollectionOptions, - type PresenceCollectionOptions, - type SessionMetaCollectionOptions, - type SessionStatsCollectionOptions, - updateConnectionStatus, - updateSyncProgress, -} from "./collections"; - -// ============================================================================ -// Materialization -// ============================================================================ - -export { - extractTextContent, - isAssistantMessage, - isUserMessage, - materializeMessage, - messageRowToUIMessage, - parseChunk, -} from "./materialize"; - -// ============================================================================ -// Stream Utilities -// ============================================================================ - -export { createTextSegmentEnricher } from "./enrich-text-segments"; diff --git a/packages/durable-session/src/materialize.ts b/packages/durable-session/src/materialize.ts deleted file mode 100644 index 92a4fce2e68..00000000000 --- a/packages/durable-session/src/materialize.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { StreamChunk, UIMessage } from "@tanstack/ai"; -import { StreamProcessor } from "@tanstack/ai"; -import { createTextSegmentEnricher } from "./enrich-text-segments"; -import type { ChunkRow } from "./schema"; -import type { - DurableStreamChunk, - MessageRole, - MessageRow, - WholeMessageChunk, -} from "./types"; - -function isDoneChunk(chunk: StreamChunk): boolean { - return chunk.type === "RUN_FINISHED"; -} - -function isWholeMessageChunk( - chunk: DurableStreamChunk | null, -): chunk is WholeMessageChunk { - return chunk !== null && chunk.type === "whole-message"; -} - -export function parseChunk(chunkJson: string): DurableStreamChunk | null { - try { - return JSON.parse(chunkJson) as DurableStreamChunk; - } catch { - return null; - } -} - -function materializeWholeMessage( - row: ChunkRow, - chunk: WholeMessageChunk, -): MessageRow { - const { message } = chunk; - - return { - id: message.id, - role: message.role as MessageRole, - parts: message.parts, - actorId: row.actorId, - isComplete: true, - createdAt: message.createdAt - ? new Date(message.createdAt) - : new Date(row.createdAt), - }; -} - -function materializeAssistantMessage(rows: ChunkRow[]): MessageRow { - const sorted = [...rows].sort((a, b) => a.seq - b.seq); - const first = sorted[0] as ChunkRow; - - const processor = new StreamProcessor(); - processor.startAssistantMessage(); - - let isComplete = false; - const enrichChunk = createTextSegmentEnricher(); - - for (const row of sorted) { - const chunk = parseChunk(row.chunk); - if (!chunk) continue; - - const type = (chunk as { type: string }).type as string; - - if (type === "message-start" || type === "message-end") { - if (type === "message-end") { - isComplete = true; - } - continue; - } - - if (isWholeMessageChunk(chunk)) continue; - - try { - processor.processChunk( - enrichChunk(chunk as StreamChunk & { [key: string]: unknown }), - ); - } catch (err) { - console.debug("[materialize] processChunk error:", err); - } - - if (isDoneChunk(chunk as StreamChunk)) { - isComplete = true; - } - - if (type === "stop" || type === "error" || type === "RUN_ERROR") { - isComplete = true; - } - } - - if (isComplete) { - processor.finalizeStream(); - } - - const messages = processor.getMessages(); - const message = messages[messages.length - 1]; - const parts = message?.parts ?? []; - - return { - id: first.messageId, - role: first.role as MessageRole, - parts, - actorId: first.actorId, - isComplete, - createdAt: new Date(first.createdAt), - }; -} - -export function materializeMessage(rows: ChunkRow[]): MessageRow { - if (!rows || rows.length === 0) { - throw new Error("Cannot materialize message from empty rows"); - } - - const sorted = [...rows].sort((a, b) => a.seq - b.seq); - const firstRow = sorted[0] as ChunkRow; - const firstChunk = parseChunk(firstRow.chunk); - - if (!firstChunk) { - throw new Error("Failed to parse first chunk"); - } - - if (isWholeMessageChunk(firstChunk)) { - return materializeWholeMessage(firstRow, firstChunk); - } - - return materializeAssistantMessage(sorted); -} - -export function extractTextContent(message: { - parts: Array<{ type: string; text?: string; content?: string }>; -}): string { - return message.parts - .filter((p) => p.type === "text") - .map((p) => p.text ?? p.content ?? "") - .join(""); -} - -export function isUserMessage(row: MessageRow): boolean { - return row.role === "user"; -} - -export function isAssistantMessage(row: MessageRow): boolean { - return row.role === "assistant"; -} - -export function messageRowToUIMessage( - row: MessageRow, -): UIMessage & { actorId: string } { - return { - id: row.id, - role: row.role, - parts: row.parts, - createdAt: row.createdAt, - actorId: row.actorId, - }; -} diff --git a/packages/durable-session/src/react/components/ChatInput/ChatInput.tsx b/packages/durable-session/src/react/components/ChatInput/ChatInput.tsx deleted file mode 100644 index bc893e83a0b..00000000000 --- a/packages/durable-session/src/react/components/ChatInput/ChatInput.tsx +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Chat input component with send button - */ - -import { Button } from "@superset/ui/button"; -import { Textarea } from "@superset/ui/textarea"; -import { cn } from "@superset/ui/utils"; -import { Send } from "lucide-react"; -import { type KeyboardEvent, useCallback, useRef, useState } from "react"; - -export interface ChatInputProps { - onSend: (content: string) => void; - onTypingChange?: (isTyping: boolean) => void; - disabled?: boolean; - placeholder?: string; - className?: string; - /** Button style: "icon" shows Send icon, "text" shows "Send" label */ - buttonVariant?: "icon" | "text"; - /** Auto-resize textarea as content grows (default: true) */ - autoResize?: boolean; - /** Controlled value (optional - if provided, component is controlled) */ - value?: string; - /** Controlled onChange (optional - required if value is provided) */ - onChange?: (value: string) => void; -} - -export function ChatInput({ - onSend, - onTypingChange, - disabled = false, - placeholder = "Type a message...", - className, - buttonVariant = "icon", - autoResize = true, - value: controlledValue, - onChange: controlledOnChange, -}: ChatInputProps) { - const [internalValue, setInternalValue] = useState(""); - const textareaRef = useRef(null); - const typingTimeoutRef = useRef | null>(null); - - // Support both controlled and uncontrolled modes - const isControlled = controlledValue !== undefined; - const value = isControlled ? controlledValue : internalValue; - const setValue = isControlled - ? (v: string) => controlledOnChange?.(v) - : setInternalValue; - - const handleSubmit = useCallback(() => { - const trimmed = value.trim(); - if (!trimmed || disabled) return; - - onSend(trimmed); - setValue(""); - onTypingChange?.(false); - - // Reset textarea height and focus - if (textareaRef.current) { - if (autoResize) { - textareaRef.current.style.height = "auto"; - } - textareaRef.current.focus(); - } - }, [value, disabled, onSend, onTypingChange, autoResize, setValue]); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } - }, - [handleSubmit], - ); - - const handleChange = useCallback( - (e: React.ChangeEvent) => { - const newValue = e.target.value; - setValue(newValue); - - // Auto-resize textarea - if (autoResize) { - const textarea = e.target; - textarea.style.height = "auto"; - textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; - } - - // Typing indicator with debounce - if (typingTimeoutRef.current) { - clearTimeout(typingTimeoutRef.current); - } - - if (newValue.trim()) { - onTypingChange?.(true); - typingTimeoutRef.current = setTimeout(() => { - onTypingChange?.(false); - }, 2000); - } else { - onTypingChange?.(false); - } - }, - [onTypingChange, autoResize, setValue], - ); - - return ( -
-