diff --git a/apps/server/package.json b/apps/server/package.json index 69ac42c480..48ced6b20b 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,6 +17,9 @@ "test": "vitest run" }, "dependencies": { + "@t3tools/core": "workspace:*", + "@t3tools/infra-sqlite": "workspace:*", + "effect": "^3.18.4", "node-pty": "^1.1.0", "open": "^10.1.0", "ws": "^8.18.0" diff --git a/apps/server/src/coreRuntime.ts b/apps/server/src/coreRuntime.ts new file mode 100644 index 0000000000..a8c5abf650 --- /dev/null +++ b/apps/server/src/coreRuntime.ts @@ -0,0 +1,108 @@ +import path from "node:path"; +import crypto from "node:crypto"; +import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts"; +import { EffectFanout, OrchestrationEngine, QueueProjector, type AppViewState } from "@t3tools/core"; +import { createSqliteStores } from "@t3tools/infra-sqlite"; + +import type { ProviderManager } from "./providerManager"; +import type { TerminalManager } from "./terminalManager"; + +function nowIso(): string { + return new Date().toISOString(); +} + +export class CoreRuntime { + private readonly fanout = new EffectFanout(); + private readonly stores; + private readonly projector: QueueProjector; + private readonly engine: OrchestrationEngine; + + constructor(dbDir: string) { + const dbPath = path.join(dbDir, "event-store.sqlite"); + this.stores = createSqliteStores(dbPath); + this.projector = new QueueProjector(this.stores.projectionStore, this.fanout); + this.engine = new OrchestrationEngine(this.stores.eventStore, this.stores.projectionStore, this.projector); + } + + async start(cwd: string, projectName: string): Promise { + await this.engine.start(); + await this.engine.execute({ + id: crypto.randomUUID(), + type: "app.bootstrap", + issuedAt: nowIso(), + payload: { cwd, projectName }, + }); + } + + async stop(): Promise { + await this.engine.stop(); + this.stores.db.close(); + } + + async state(): Promise { + return this.engine.currentState(); + } + + async dispatch(command: Parameters[0]): Promise { + return this.engine.execute(command); + } + + subscribe() { + return this.fanout.subscribe(); + } + + bindProviderEvents(providerManager: ProviderManager): void { + providerManager.on("event", (event: ProviderEvent) => { + void this.ingestProviderEvent(event); + }); + } + + bindTerminalEvents(terminalManager: TerminalManager): void { + terminalManager.on("event", (event: TerminalEvent) => { + if (event.type !== "activity" && event.type !== "error" && event.type !== "exited") return; + void this.dispatch({ + id: crypto.randomUUID(), + type: "thread.setTerminalActivity", + issuedAt: event.createdAt, + payload: { + threadId: event.threadId, + terminalId: event.terminalId, + running: event.type === "activity" ? event.hasRunningSubprocess : false, + }, + }); + }); + } + + async bindProviderSession(threadId: string, session: ProviderSession): Promise { + await this.dispatch({ + id: crypto.randomUUID(), + type: "thread.updateProviderSession", + issuedAt: nowIso(), + payload: { threadId, session }, + }); + } + + async clearProviderSession(threadId: string): Promise { + await this.dispatch({ + id: crypto.randomUUID(), + type: "thread.updateProviderSession", + issuedAt: nowIso(), + payload: { threadId, session: null }, + }); + } + + async ingestProviderEvent(event: ProviderEvent): Promise { + const state = await this.state(); + const target = state.threads.find((thread) => thread.session?.sessionId === event.sessionId); + if (!target) return; + await this.dispatch({ + id: crypto.randomUUID(), + type: "thread.recordProviderEvent", + issuedAt: event.createdAt, + payload: { + threadId: target.id, + event, + }, + }); + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index d340657608..103f72d9aa 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -121,6 +121,7 @@ async function main() { staticDir, devUrl, projectRegistry, + stateDir, authToken, }); await server.start(); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 325727827d..16ec5dde37 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import http from "node:http"; import os from "node:os"; import path from "node:path"; +import crypto from "node:crypto"; import type { Duplex } from "node:stream"; import { @@ -33,6 +34,7 @@ import { import { TerminalManager } from "./terminalManager"; import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; +import { CoreRuntime } from "./coreRuntime"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -57,6 +59,7 @@ export interface ServerOptions { devUrl?: string | undefined; logWebSocketEvents?: boolean | undefined; projectRegistry?: ProjectRegistry | undefined; + stateDir?: string | undefined; gitManager?: GitManager | undefined; terminalManager?: TerminalManager | undefined; authToken?: string | undefined; @@ -79,6 +82,7 @@ export function createServer(options: ServerOptions) { devUrl, logWebSocketEvents: explicitLogWsEvents, projectRegistry: providedRegistry, + stateDir, gitManager: providedGitManager, terminalManager: providedTerminalManager, authToken, @@ -88,8 +92,12 @@ export function createServer(options: ServerOptions) { const projectRegistry = providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); const gitManager = providedGitManager ?? new GitManager(); + const runtimeStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata"); + const coreRuntime = new CoreRuntime(runtimeStateDir); const clients = new Set(); const logger = createLogger("ws"); + const segments = cwd.split(/[/\\]/).filter(Boolean); + const projectName = segments[segments.length - 1] ?? "project"; const logWebSocketEvents = explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); let keybindingsConfig = loadResolvedKeybindingsConfig(logger); @@ -103,13 +111,7 @@ export function createServer(options: ServerOptions) { }); } - // Forward provider events to all connected WebSocket clients - providerManager.on("event", (event) => { - const push: WsPush = { - type: "push", - channel: WS_CHANNELS.providerEvent, - data: event, - }; + function broadcastPush(push: WsPush): void { const message = JSON.stringify(push); let recipients = 0; for (const client of clients) { @@ -119,25 +121,31 @@ export function createServer(options: ServerOptions) { } } logOutgoingPush(push, recipients); - }); + } const onTerminalEvent = (event: TerminalEvent) => { - const push: WsPush = { + broadcastPush({ type: "push", channel: WS_CHANNELS.terminalEvent, data: event, - }; - const message = JSON.stringify(push); - let recipients = 0; - for (const client of clients) { - if (client.readyState === client.OPEN) { - client.send(message); - recipients += 1; - } + }); + if (event.type === "activity" || event.type === "exited" || event.type === "error") { + void coreRuntime.dispatch({ + id: crypto.randomUUID(), + type: "thread.setTerminalActivity", + issuedAt: event.createdAt, + payload: { + threadId: event.threadId, + terminalId: event.terminalId, + running: event.type === "activity" ? event.hasRunningSubprocess : false, + }, + }); } - logOutgoingPush(push, recipients); }; terminalManager.on("event", onTerminalEvent); + coreRuntime.bindProviderEvents(providerManager); + let stateStreamStopped = false; + let stateStreamTask: Promise | null = null; // HTTP server — serves static files or redirects to Vite dev server const httpServer = http.createServer((req, res) => { @@ -235,10 +243,6 @@ export function createServer(options: ServerOptions) { wss.on("connection", (ws) => { clients.add(ws); - // Send welcome message with project info - const segments = cwd.split(/[/\\]/).filter(Boolean); - const projectName = segments[segments.length - 1] ?? "project"; - const welcome: WsPush = { type: "push", channel: WS_CHANNELS.serverWelcome, @@ -289,12 +293,44 @@ export function createServer(options: ServerOptions) { } async function routeRequest(request: WsRequest): Promise { + const requestNow = new Date().toISOString(); + const paramsObj = (request.params ?? {}) as Record; + const state = await coreRuntime.state(); + const findThreadBySessionId = (sessionId: string | undefined) => + sessionId + ? state.threads.find((thread) => thread.session?.sessionId === sessionId) + : undefined; + switch (request.method) { - case WS_METHODS.providersStartSession: - return providerManager.startSession(request.params as never); + case WS_METHODS.providersStartSession: { + const session = await providerManager.startSession(request.params as never); + const uiThreadId = typeof paramsObj.uiThreadId === "string" ? paramsObj.uiThreadId : undefined; + if (uiThreadId) { + await coreRuntime.bindProviderSession(uiThreadId, session); + } + return session; + } - case WS_METHODS.providersSendTurn: + case WS_METHODS.providersSendTurn: { + const sessionId = typeof paramsObj.sessionId === "string" ? paramsObj.sessionId : undefined; + const uiThreadId = typeof paramsObj.uiThreadId === "string" ? paramsObj.uiThreadId : undefined; + const targetThread = uiThreadId ? state.threads.find((thread) => thread.id === uiThreadId) : findThreadBySessionId(sessionId); + const inputText = typeof paramsObj.input === "string" ? paramsObj.input : undefined; + if (targetThread && inputText && inputText.trim().length > 0) { + await coreRuntime.dispatch({ + id: crypto.randomUUID(), + type: "thread.addUserMessage", + issuedAt: requestNow, + payload: { + threadId: targetThread.id, + messageId: crypto.randomUUID(), + text: inputText, + createdAt: requestNow, + }, + }); + } return providerManager.sendTurn(request.params as never); + } case WS_METHODS.providersInterruptTurn: return providerManager.interruptTurn(request.params as never); @@ -303,7 +339,12 @@ export function createServer(options: ServerOptions) { return providerManager.respondToRequest(request.params as never); case WS_METHODS.providersStopSession: { + const sessionId = typeof paramsObj.sessionId === "string" ? paramsObj.sessionId : undefined; + const boundThread = findThreadBySessionId(sessionId); providerManager.stopSession(request.params as never); + if (boundThread) { + await coreRuntime.clearProviderSession(boundThread.id); + } return undefined; } @@ -323,16 +364,51 @@ export function createServer(options: ServerOptions) { return projectRegistry.list(); case WS_METHODS.projectsAdd: - return projectRegistry.add(request.params as never); + { + const result = projectRegistry.add(request.params as never); + await coreRuntime.dispatch({ + id: crypto.randomUUID(), + type: "project.add", + issuedAt: requestNow, + payload: { + id: result.project.id, + name: result.project.name, + cwd: result.project.cwd, + model: "gpt-5-codex", + scripts: result.project.scripts, + }, + }); + return result; + } case WS_METHODS.projectsRemove: + { + const projectId = typeof paramsObj.id === "string" ? paramsObj.id : undefined; + if (projectId) { + await coreRuntime.dispatch({ + id: crypto.randomUUID(), + type: "project.remove", + issuedAt: requestNow, + payload: { id: projectId }, + }); + } projectRegistry.remove(request.params as never); return undefined; + } case WS_METHODS.projectsSearchEntries: return searchWorkspaceEntries(request.params as never); case WS_METHODS.projectsUpdateScripts: - return projectRegistry.updateScripts(request.params as never); + { + const result = projectRegistry.updateScripts(request.params as never); + await coreRuntime.dispatch({ + id: crypto.randomUUID(), + type: "project.updateScripts", + issuedAt: requestNow, + payload: { id: result.project.id, scripts: result.project.scripts }, + }); + return result; + } case WS_METHODS.shellOpenInEditor: { const params = request.params as { @@ -438,6 +514,61 @@ export function createServer(options: ServerOptions) { keybindings: keybindingsConfig, }; + case WS_METHODS.stateGetSnapshot: + return coreRuntime.state(); + + case WS_METHODS.stateCreateThread: { + const params = request.params as { + id: string; + projectId: string; + title: string; + model: string; + createdAt: string; + branch: string | null; + worktreePath: string | null; + }; + await coreRuntime.dispatch({ + id: crypto.randomUUID(), + type: "thread.create", + issuedAt: requestNow, + payload: params, + }); + return coreRuntime.state(); + } + + case WS_METHODS.stateDeleteThread: { + const params = request.params as { id: string }; + await coreRuntime.dispatch({ + id: crypto.randomUUID(), + type: "thread.delete", + issuedAt: requestNow, + payload: params, + }); + return coreRuntime.state(); + } + + case WS_METHODS.stateMarkThreadVisited: { + const params = request.params as { threadId: string; visitedAt?: string }; + await coreRuntime.dispatch({ + id: crypto.randomUUID(), + type: "thread.markVisited", + issuedAt: requestNow, + payload: { threadId: params.threadId, visitedAt: params.visitedAt ?? requestNow }, + }); + return undefined; + } + + case WS_METHODS.stateSetRuntimeMode: { + const params = request.params as { mode: "approval-required" | "full-access" }; + await coreRuntime.dispatch({ + id: crypto.randomUUID(), + type: "runtime.setMode", + issuedAt: requestNow, + payload: { mode: params.mode }, + }); + return coreRuntime.state(); + } + default: throw new Error(`Unknown method: ${request.method}`); } @@ -452,7 +583,26 @@ export function createServer(options: ServerOptions) { httpServer.once("error", onError); const onListening = () => { httpServer.off("error", onError); - resolve(); + void (async () => { + try { + await coreRuntime.start(cwd, projectName); + stateStreamStopped = false; + stateStreamTask = (async () => { + const iterable = await coreRuntime.subscribe(); + for await (const update of iterable) { + if (stateStreamStopped) break; + broadcastPush({ + type: "push", + channel: WS_CHANNELS.stateUpdated, + data: update.state, + }); + } + })(); + resolve(); + } catch (error) { + reject(error as Error); + } + })(); }; if (host) { httpServer.listen(port, host, onListening); @@ -463,6 +613,7 @@ export function createServer(options: ServerOptions) { } async function stop(): Promise { + stateStreamStopped = true; terminalManager.off("event", onTerminalEvent); providerManager.stopAll(); providerManager.dispose(); @@ -502,6 +653,10 @@ export function createServer(options: ServerOptions) { }); }); + if (stateStreamTask) { + await Promise.race([stateStreamTask, Promise.resolve()]); + } + await coreRuntime.stop(); await Promise.all([closeWebSocketServer, closeHttpServer]); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 92a6a73928..302b2269cb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1074,6 +1074,7 @@ export default function ChatView({ threadId }: ChatViewProps) { dispatch({ type: "SET_RUNTIME_MODE", mode }); scheduleComposerFocus(); if (!api) return; + await api.state.setRuntimeMode({ mode }); const sessionIds = state.threads .map((t) => t.session) @@ -1524,6 +1525,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setIsConnecting(true); try { const session = await api.providers.startSession({ + uiThreadId: activeThread.id, provider: "codex", cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, model: selectedModel || undefined, @@ -1799,6 +1801,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ).text : trimmed || undefined; await api.providers.sendTurn({ + uiThreadId: activeThread.id, sessionId: sessionInfo.sessionId, ...(input ? { input } : {}), ...(turnAttachments.length > 0 ? { attachments: turnAttachments } : {}), diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9291843ea9..7add58e8f8 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -10,7 +10,7 @@ import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; import { isChatNewLocalShortcut, isChatNewShortcut } from "../keybindings"; -import { type Project, type Thread } from "../types"; +import { type Thread } from "../types"; import { useNativeApi } from "../hooks/useNativeApi"; import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; @@ -140,7 +140,7 @@ export default function Sidebar() { }, [state.threads]); const handleNewThread = useCallback( - ( + async ( projectId: string, options?: { branch?: string | null; @@ -152,16 +152,21 @@ export default function Sidebar() { branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, }); - dispatch({ - type: "ADD_THREAD", - thread, + await api?.state.createThread({ + id: thread.id, + projectId: thread.projectId, + title: thread.title, + model: thread.model, + createdAt: thread.createdAt, + branch: thread.branch, + worktreePath: thread.worktreePath, }); void navigate({ to: "/$threadId", params: { threadId: thread.id }, }); }, - [dispatch, navigate, state.projects], + [api, navigate, state.projects], ); const focusMostRecentThreadForProject = useCallback( @@ -192,23 +197,11 @@ export default function Sidebar() { try { if (isElectron && api) { const result = await api.projects.add({ cwd }); - const project: Project = { - id: result.project.id, - name: result.project.name, - cwd: result.project.cwd, - model: DEFAULT_MODEL, - expanded: true, - scripts: result.project.scripts, - }; - const existingById = state.projects.find((p) => p.id === project.id); - const existingByCwd = state.projects.find((p) => p.cwd === project.cwd); - if (!existingById && !existingByCwd) { - dispatch({ type: "ADD_PROJECT", project }); - } - const resolvedProjectId = existingByCwd?.id ?? project.id; + const existingByCwd = state.projects.find((p) => p.cwd === result.project.cwd); + const resolvedProjectId = existingByCwd?.id ?? result.project.id; if (result.created) { - handleNewThread(resolvedProjectId); + await handleNewThread(resolvedProjectId); } else { focusMostRecentThreadForProject(resolvedProjectId); } @@ -218,18 +211,9 @@ export default function Sidebar() { focusMostRecentThreadForProject(existing.id); return; } - - const name = inferProjectName(cwd); - const project: Project = { - id: crypto.randomUUID(), - name, - cwd, - model: DEFAULT_MODEL, - expanded: true, - scripts: [], - }; - dispatch({ type: "ADD_PROJECT", project }); - handleNewThread(project.id); + if (!api) return; + const result = await api.projects.add({ cwd }); + await handleNewThread(result.project.id); } } finally { setIsAddingProject(false); @@ -321,7 +305,7 @@ export default function Sidebar() { const shouldNavigateToFallback = routeThreadId === threadId; const fallbackThreadId = state.threads.find((entry) => entry.id !== threadId)?.id ?? null; - dispatch({ type: "DELETE_THREAD", threadId }); + await api.state.deleteThread({ id: threadId }); if (shouldNavigateToFallback) { if (fallbackThreadId) { void navigate({ @@ -411,7 +395,6 @@ export default function Sidebar() { } } - dispatch({ type: "DELETE_PROJECT", projectId }); }, [api, dispatch, state.projects, state.threads], ); @@ -472,7 +455,7 @@ export default function Sidebar() { return; } const firstProject = state.projects[0]; - if (firstProject) handleNewThread(firstProject.id); + if (firstProject) void handleNewThread(firstProject.id); }} > + @@ -587,7 +570,9 @@ export default function Sidebar() { diff --git a/apps/web/src/persistenceSchema.test.ts b/apps/web/src/persistenceSchema.test.ts deleted file mode 100644 index 448806d8e2..0000000000 --- a/apps/web/src/persistenceSchema.test.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { DEFAULT_MODEL } from "./model-logic"; -import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; -import type { Thread } from "./types"; - -describe("hydratePersistedState", () => { - it("returns null for invalid payloads", () => { - expect(hydratePersistedState('{"projects":"bad"}', false)).toBeNull(); - expect(hydratePersistedState("not-json", false)).toBeNull(); - }); - - it("migrates the legacy default model to the current default", () => { - const payload = JSON.stringify({ - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.2-codex", - expanded: true, - scripts: [], - }, - ], - threads: [ - { - id: "t-1", - projectId: "p-1", - title: "Thread", - model: "gpt-5.2-codex", - messages: [ - { - id: "m-1", - role: "assistant", - text: "Hello", - createdAt: "2026-02-08T10:00:00.000Z", - streaming: true, - }, - ], - createdAt: "2026-02-08T10:00:00.000Z", - }, - ], - activeThreadId: "t-1", - }); - - const hydrated = hydratePersistedState(payload, true); - expect(hydrated).not.toBeNull(); - expect(hydrated?.projects[0]?.model).toBe(DEFAULT_MODEL); - expect(hydrated?.threads[0]?.model).toBe(DEFAULT_MODEL); - expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); - expect(hydrated?.threads[0]?.terminalOpen).toBe(false); - expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); - expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); - expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); - expect(hydrated?.threads[0]?.terminalGroups).toEqual([ - { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, - ]); - expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - expect(hydrated?.threads[0]?.messages[0]?.streaming).toBe(false); - expect(hydrated?.runtimeMode).toBe("full-access"); - }); - - it("filters unknown project references and repairs active thread", () => { - const payload = JSON.stringify({ - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: false, - scripts: [], - }, - ], - threads: [ - { - id: "t-1", - projectId: "p-1", - title: "Valid thread", - model: "gpt-5.3-codex", - messages: [], - createdAt: "2026-02-08T10:00:00.000Z", - }, - { - id: "t-2", - projectId: "p-missing", - title: "Dangling thread", - model: "gpt-5.3-codex", - messages: [], - createdAt: "2026-02-08T10:00:00.000Z", - }, - ], - activeThreadId: "t-2", - }); - - const hydrated = hydratePersistedState(payload, false); - expect(hydrated).not.toBeNull(); - expect(hydrated?.threads.map((thread) => thread.id)).toEqual(["t-1"]); - expect(hydrated?.threads[0]?.codexThreadId).toBeNull(); - expect(hydrated?.threads[0]?.terminalOpen).toBe(false); - expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); - expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); - expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); - expect(hydrated?.threads[0]?.terminalGroups).toEqual([ - { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, - ]); - expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - expect(hydrated?.runtimeMode).toBe("full-access"); - }); - - it("hydrates runtime mode from v3 payload", () => { - const payload = JSON.stringify({ - version: 3, - runtimeMode: "approval-required", - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: false, - scripts: [], - }, - ], - threads: [], - activeThreadId: null, - }); - - const hydrated = hydratePersistedState(payload, false); - expect(hydrated?.runtimeMode).toBe("approval-required"); - }); - - it("hydrates terminal fields from legacy v6 payload", () => { - const payload = JSON.stringify({ - version: 6, - runtimeMode: "full-access", - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - threads: [ - { - id: "t-1", - codexThreadId: null, - projectId: "p-1", - title: "Thread", - model: "gpt-5.3-codex", - terminalOpen: true, - terminalHeight: 360, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - activeTerminalId: "term-2", - terminalLayout: "tabs", - splitTerminalIds: [], - messages: [], - createdAt: "2026-02-08T10:00:00.000Z", - lastVisitedAt: "2026-02-08T10:01:00.000Z", - }, - ], - activeThreadId: "t-1", - }); - - const hydrated = hydratePersistedState(payload, false); - expect(hydrated?.threads[0]?.terminalOpen).toBe(true); - expect(hydrated?.threads[0]?.terminalHeight).toBe(360); - expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); - expect(hydrated?.threads[0]?.activeTerminalId).toBe("term-2"); - expect(hydrated?.threads[0]?.terminalGroups).toEqual([ - { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, - { id: "group-term-2", terminalIds: ["term-2"] }, - ]); - expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe("group-term-2"); - expect(hydrated?.threads[0]?.lastVisitedAt).toBe("2026-02-08T10:01:00.000Z"); - }); - - it("hydrates legacy split layout into a grouped split", () => { - const payload = JSON.stringify({ - version: 6, - runtimeMode: "full-access", - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - threads: [ - { - id: "t-1", - codexThreadId: null, - projectId: "p-1", - title: "Thread", - model: "gpt-5.3-codex", - terminalOpen: true, - terminalHeight: 360, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], - activeTerminalId: "term-2", - terminalLayout: "split", - splitTerminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - messages: [], - createdAt: "2026-02-08T10:00:00.000Z", - }, - ], - activeThreadId: "t-1", - }); - - const hydrated = hydratePersistedState(payload, false); - expect(hydrated?.threads[0]?.terminalGroups).toEqual([ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - }, - { id: "group-term-3", terminalIds: ["term-3"] }, - ]); - expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - }); - - it("defaults terminalHeight when hydrating v5 payloads", () => { - const payload = JSON.stringify({ - version: 5, - runtimeMode: "full-access", - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - threads: [ - { - id: "t-1", - codexThreadId: null, - projectId: "p-1", - title: "Thread", - model: "gpt-5.3-codex", - terminalOpen: true, - messages: [], - createdAt: "2026-02-08T10:00:00.000Z", - }, - ], - activeThreadId: "t-1", - }); - - const hydrated = hydratePersistedState(payload, false); - expect(hydrated?.threads[0]?.terminalHeight).toBe(DEFAULT_THREAD_TERMINAL_HEIGHT); - expect(hydrated?.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); - expect(hydrated?.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); - expect(hydrated?.threads[0]?.terminalGroups).toEqual([ - { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, - ]); - expect(hydrated?.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - }); - - it("hydrates persisted turn diff summaries as metadata-only entries", () => { - const payload = JSON.stringify({ - version: 7, - runtimeMode: "full-access", - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: true, - }, - ], - threads: [ - { - id: "t-1", - codexThreadId: "thr_1", - projectId: "p-1", - title: "Thread", - model: "gpt-5.3-codex", - messages: [], - createdAt: "2026-02-08T10:00:00.000Z", - turnDiffSummaries: [ - { - turnId: "turn-1", - completedAt: "2026-02-08T10:05:00.000Z", - checkpointTurnCount: 1, - files: [ - { - path: "src/app.ts", - kind: "modified", - additions: 3, - deletions: 1, - }, - ], - }, - ], - }, - ], - activeThreadId: "t-1", - }); - - const hydrated = hydratePersistedState(payload, false); - expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([ - { - turnId: "turn-1", - completedAt: "2026-02-08T10:05:00.000Z", - checkpointTurnCount: 1, - files: [ - { - path: "src/app.ts", - kind: "modified", - additions: 3, - deletions: 1, - }, - ], - }, - ]); - }); - - it("drops malformed persisted turn diff summaries instead of rejecting the whole snapshot", () => { - const payload = JSON.stringify({ - version: 7, - runtimeMode: "full-access", - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: true, - }, - ], - threads: [ - { - id: "t-1", - codexThreadId: "thr_1", - projectId: "p-1", - title: "Thread", - model: "gpt-5.3-codex", - messages: [], - createdAt: "2026-02-08T10:00:00.000Z", - turnDiffSummaries: [ - { - turnId: "turn-1", - completedAt: "2026-02-08T10:05:00.000Z", - files: [{ path: 123 }], - }, - ], - }, - ], - activeThreadId: "t-1", - }); - - const hydrated = hydratePersistedState(payload, false); - expect(hydrated).not.toBeNull(); - expect(hydrated?.threads[0]?.id).toBe("t-1"); - expect(hydrated?.threads[0]?.turnDiffSummaries).toEqual([]); - }); -}); - -describe("toPersistedState", () => { - it("writes v7 payload and strips non-persisted thread fields", () => { - const thread: Thread = { - id: "t-1", - codexThreadId: "thr_1", - projectId: "p-1", - title: "Thread", - model: "gpt-5.3-codex", - terminalOpen: true, - terminalHeight: 320, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - runningTerminalIds: [], - activeTerminalId: "term-2", - terminalGroups: [ - { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, - { id: "group-term-2", terminalIds: ["term-2"] }, - ], - activeTerminalGroupId: "group-term-2", - session: null, - messages: [ - { - id: "m-1", - role: "user", - text: "Hi", - attachments: [ - { - type: "image", - id: "img-1", - name: "diagram.png", - mimeType: "image/png", - sizeBytes: 4_096, - previewUrl: "blob:preview-1", - }, - ], - createdAt: "2026-02-08T10:00:00.000Z", - streaming: false, - }, - ], - events: [], - error: "boom", - createdAt: "2026-02-08T10:00:00.000Z", - lastVisitedAt: "2026-02-08T10:02:00.000Z", - branch: null, - worktreePath: null, - turnDiffSummaries: [ - { - turnId: "turn-1", - completedAt: "2026-02-08T10:05:00.000Z", - status: "completed", - checkpointTurnCount: 1, - files: [ - { - path: "src/app.ts", - kind: "modified", - additions: 3, - deletions: 1, - }, - ], - }, - ], - }; - - const persisted = toPersistedState({ - projects: [ - { - id: "p-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - threads: [thread], - runtimeMode: "full-access", - }); - - expect(persisted.version).toBe(7); - expect(persisted.runtimeMode).toBe("full-access"); - expect(persisted.threads[0]).toEqual({ - id: "t-1", - codexThreadId: "thr_1", - projectId: "p-1", - title: "Thread", - model: "gpt-5.3-codex", - terminalOpen: true, - terminalHeight: 320, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - activeTerminalId: "term-2", - terminalGroups: [ - { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, - { id: "group-term-2", terminalIds: ["term-2"] }, - ], - activeTerminalGroupId: "group-term-2", - messages: [ - { - id: "m-1", - role: "user", - text: "Hi", - attachments: [ - { - type: "image", - id: "img-1", - name: "diagram.png", - mimeType: "image/png", - sizeBytes: 4_096, - }, - ], - createdAt: "2026-02-08T10:00:00.000Z", - streaming: false, - }, - ], - createdAt: thread.createdAt, - lastVisitedAt: thread.lastVisitedAt, - branch: null, - worktreePath: null, - turnDiffSummaries: [ - { - turnId: "turn-1", - completedAt: "2026-02-08T10:05:00.000Z", - status: "completed", - checkpointTurnCount: 1, - files: [ - { - path: "src/app.ts", - kind: "modified", - additions: 3, - deletions: 1, - }, - ], - }, - ], - }); - const persistedThread = persisted.threads[0]; - expect(persistedThread).toBeDefined(); - if (!persistedThread) return; - - expect("error" in persistedThread).toBe(false); - expect("session" in persistedThread).toBe(false); - }); -}); diff --git a/apps/web/src/persistenceSchema.ts b/apps/web/src/persistenceSchema.ts deleted file mode 100644 index e1711ef694..0000000000 --- a/apps/web/src/persistenceSchema.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { z } from "zod"; -import { projectScriptSchema } from "@t3tools/contracts"; - -import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; -import { - DEFAULT_THREAD_TERMINAL_HEIGHT, - DEFAULT_THREAD_TERMINAL_ID, - DEFAULT_RUNTIME_MODE, - type Project, - type RuntimeMode, - type Thread, - type ThreadTerminalGroup, -} from "./types"; - -const LEGACY_DEFAULT_MODEL = "gpt-5.2-codex"; - -const persistedProjectSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - cwd: z.string().min(1), - model: z.string().min(1), - expanded: z.boolean(), - scripts: z.array(projectScriptSchema).default([]), -}); - -const persistedMessageSchema = z.object({ - id: z.string().min(1), - role: z.enum(["user", "assistant"]), - text: z.string(), - attachments: z - .array( - z.object({ - type: z.literal("image"), - id: z.string().min(1), - name: z.string().min(1), - mimeType: z.string().min(1), - sizeBytes: z.number().int().min(1), - }), - ) - .optional(), - createdAt: z.string().min(1), - streaming: z.boolean(), -}); - -const persistedTerminalGroupSchema = z.object({ - id: z.string().trim().min(1), - terminalIds: z.array(z.string().trim().min(1)), -}); - -const persistedTurnDiffFileChangeSchema = z.object({ - path: z.string().min(1), - kind: z.string().min(1).optional(), - additions: z.number().int().min(0).optional(), - deletions: z.number().int().min(0).optional(), -}); - -const persistedTurnDiffSummarySchema = z.object({ - turnId: z.string().min(1), - completedAt: z.string().min(1), - status: z.string().min(1).optional(), - files: z.array(persistedTurnDiffFileChangeSchema), - assistantMessageId: z.string().min(1).optional(), - checkpointTurnCount: z.number().int().min(0).optional(), -}); - -const persistedThreadSchema = z.object({ - id: z.string().min(1), - codexThreadId: z.string().min(1).nullable().default(null), - projectId: z.string().min(1), - title: z.string().min(1), - model: z.string().min(1), - terminalOpen: z.boolean().default(false), - terminalHeight: z.number().int().min(120).max(4_096).default(DEFAULT_THREAD_TERMINAL_HEIGHT), - terminalIds: z.array(z.string().trim().min(1)).default([DEFAULT_THREAD_TERMINAL_ID]), - activeTerminalId: z.string().trim().min(1).default(DEFAULT_THREAD_TERMINAL_ID), - terminalGroups: z.array(persistedTerminalGroupSchema).default([]), - activeTerminalGroupId: z.string().trim().min(1).optional(), - // Legacy v6 and older fields retained for migration. - terminalLayout: z.enum(["single", "split", "tabs"]).optional(), - splitTerminalIds: z.array(z.string().trim().min(1)).optional(), - messages: z.array(persistedMessageSchema), - createdAt: z.string().min(1), - lastVisitedAt: z.string().min(1).optional(), - branch: z.string().min(1).nullable().optional(), - worktreePath: z.string().min(1).nullable().optional(), - turnDiffSummaries: z.array(persistedTurnDiffSummarySchema).catch([]).default([]), -}); - -const persistedStateBodySchema = z.object({ - projects: z.array(persistedProjectSchema), - threads: z.array(persistedThreadSchema), - activeThreadId: z.string().min(1).nullable().optional(), -}); - -const runtimeModeSchema = z.enum(["approval-required", "full-access"]); - -export const persistedStateV1Schema = persistedStateBodySchema.extend({ - version: z.literal(1).optional(), -}); - -export const persistedStateV2Schema = persistedStateBodySchema.extend({ - version: z.literal(2).optional(), -}); - -export const persistedStateV3Schema = persistedStateBodySchema.extend({ - runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), - version: z.literal(3).optional(), -}); - -export const persistedStateV4Schema = persistedStateBodySchema.extend({ - runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), - version: z.literal(4).optional(), -}); - -export const persistedStateV6Schema = persistedStateBodySchema.extend({ - runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), - version: z.literal(6).optional(), -}); - -export const persistedStateV7Schema = persistedStateBodySchema.extend({ - runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), - version: z.literal(7).optional(), -}); - -export const persistedStateV5Schema = persistedStateBodySchema.extend({ - runtimeMode: runtimeModeSchema.default(DEFAULT_RUNTIME_MODE), - version: z.literal(5).optional(), -}); - -const persistedStateSchema = z.union([ - persistedStateV7Schema, - persistedStateV6Schema, - persistedStateV5Schema, - persistedStateV4Schema, - persistedStateV3Schema, - persistedStateV2Schema, - persistedStateV1Schema, -]); - -export interface PersistedStoreSnapshot { - projects: Project[]; - threads: Thread[]; - runtimeMode: RuntimeMode; -} - -function maybeMigrateLegacyModel(model: string, isLegacyPayload: boolean): string { - if (!isLegacyPayload) { - return model; - } - - return model === LEGACY_DEFAULT_MODEL ? DEFAULT_MODEL : model; -} - -function hydrateProject( - project: z.infer, - isLegacyPayload: boolean, -): Project { - return { - ...project, - model: resolveModelSlug(maybeMigrateLegacyModel(project.model, isLegacyPayload)), - scripts: project.scripts, - }; -} - -function hydrateThread( - thread: z.infer, - isLegacyPayload: boolean, -): Thread { - const terminalIds = [ - ...new Set(thread.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), - ]; - const safeTerminalIds = terminalIds.length > 0 ? terminalIds : [DEFAULT_THREAD_TERMINAL_ID]; - const activeTerminalId = safeTerminalIds.includes(thread.activeTerminalId) - ? thread.activeTerminalId - : (safeTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID); - const safeTerminalIdSet = new Set(safeTerminalIds); - const assignedTerminalIds = new Set(); - const usedGroupIds = new Set(); - const normalizedGroups: ThreadTerminalGroup[] = []; - const assignUniqueGroupId = (groupId: string): string => { - if (!usedGroupIds.has(groupId)) { - usedGroupIds.add(groupId); - return groupId; - } - let suffix = 2; - while (usedGroupIds.has(`${groupId}-${suffix}`)) { - suffix += 1; - } - const uniqueGroupId = `${groupId}-${suffix}`; - usedGroupIds.add(uniqueGroupId); - return uniqueGroupId; - }; - - for (const terminalGroup of thread.terminalGroups) { - const nextTerminalIds = [ - ...new Set(terminalGroup.terminalIds.map((id) => id.trim()).filter((id) => id.length > 0)), - ].filter((terminalId) => { - if (!safeTerminalIdSet.has(terminalId)) return false; - if (assignedTerminalIds.has(terminalId)) return false; - return true; - }); - if (nextTerminalIds.length === 0) continue; - for (const terminalId of nextTerminalIds) { - assignedTerminalIds.add(terminalId); - } - const baseGroupId = - terminalGroup.id.trim().length > 0 - ? terminalGroup.id.trim() - : `group-${nextTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`; - normalizedGroups.push({ - id: assignUniqueGroupId(baseGroupId), - terminalIds: nextTerminalIds, - }); - } - - if (normalizedGroups.length === 0 && thread.terminalLayout === "split") { - const splitTerminalIds = [ - ...new Set( - (thread.splitTerminalIds ?? []).map((id) => id.trim()).filter((id) => id.length > 0), - ), - ].filter((terminalId) => safeTerminalIdSet.has(terminalId)); - if (splitTerminalIds.length >= 2) { - const splitGroupTerminalIds = splitTerminalIds.slice(0, 2); - for (const terminalId of splitGroupTerminalIds) { - assignedTerminalIds.add(terminalId); - } - normalizedGroups.push({ - id: assignUniqueGroupId(`group-${splitGroupTerminalIds[0] ?? DEFAULT_THREAD_TERMINAL_ID}`), - terminalIds: splitGroupTerminalIds, - }); - } - } - - for (const terminalId of safeTerminalIds) { - if (assignedTerminalIds.has(terminalId)) continue; - normalizedGroups.push({ - id: assignUniqueGroupId(`group-${terminalId}`), - terminalIds: [terminalId], - }); - } - - const activeGroupIndexFromId = normalizedGroups.findIndex( - (terminalGroup) => terminalGroup.id === thread.activeTerminalGroupId, - ); - const activeGroupIndexFromTerminal = normalizedGroups.findIndex((terminalGroup) => - terminalGroup.terminalIds.includes(activeTerminalId), - ); - const activeGroupIndex = - activeGroupIndexFromId >= 0 - ? activeGroupIndexFromId - : activeGroupIndexFromTerminal >= 0 - ? activeGroupIndexFromTerminal - : 0; - const activeTerminalGroupId = - normalizedGroups[activeGroupIndex]?.id ?? - normalizedGroups[0]?.id ?? - `group-${DEFAULT_THREAD_TERMINAL_ID}`; - - return { - id: thread.id, - codexThreadId: thread.codexThreadId, - projectId: thread.projectId, - title: thread.title, - model: resolveModelSlug(maybeMigrateLegacyModel(thread.model, isLegacyPayload)), - terminalOpen: thread.terminalOpen ?? false, - terminalHeight: thread.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: safeTerminalIds, - runningTerminalIds: [], - activeTerminalId, - terminalGroups: normalizedGroups, - activeTerminalGroupId, - session: null, - messages: thread.messages.map((message) => { - const hydratedAttachments = message.attachments?.map((attachment) => ({ ...attachment })); - return { - id: message.id, - role: message.role, - text: message.text, - ...(hydratedAttachments && hydratedAttachments.length > 0 - ? { attachments: hydratedAttachments } - : {}), - createdAt: message.createdAt, - streaming: false, - }; - }), - events: [], - error: null, - createdAt: thread.createdAt, - lastVisitedAt: thread.lastVisitedAt, - branch: thread.branch ?? null, - worktreePath: thread.worktreePath ?? null, - turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({ - turnId: summary.turnId, - completedAt: summary.completedAt, - ...(summary.status ? { status: summary.status } : {}), - files: summary.files.map((file) => ({ - path: file.path, - ...(file.kind ? { kind: file.kind } : {}), - ...(typeof file.additions === "number" ? { additions: file.additions } : {}), - ...(typeof file.deletions === "number" ? { deletions: file.deletions } : {}), - })), - ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), - ...(typeof summary.checkpointTurnCount === "number" - ? { checkpointTurnCount: summary.checkpointTurnCount } - : {}), - })), - }; -} - -export function hydratePersistedState( - raw: string, - isLegacyPayload: boolean, -): PersistedStoreSnapshot | null { - let parsedJson: unknown; - try { - parsedJson = JSON.parse(raw); - } catch { - return null; - } - - const parsedState = persistedStateSchema.safeParse(parsedJson); - if (!parsedState.success) { - return null; - } - - const projects = parsedState.data.projects.map((project) => - hydrateProject(project, isLegacyPayload), - ); - const projectIds = new Set(projects.map((project) => project.id)); - const threads = parsedState.data.threads - .map((thread) => hydrateThread(thread, isLegacyPayload)) - .filter((thread) => projectIds.has(thread.projectId)); - return { - projects, - threads, - runtimeMode: - "runtimeMode" in parsedState.data ? parsedState.data.runtimeMode : DEFAULT_RUNTIME_MODE, - }; -} - -export function toPersistedState( - state: PersistedStoreSnapshot, -): z.infer { - return { - version: 7, - projects: state.projects, - threads: state.threads.map((thread) => ({ - id: thread.id, - codexThreadId: thread.codexThreadId, - projectId: thread.projectId, - title: thread.title, - model: thread.model, - terminalOpen: thread.terminalOpen, - terminalHeight: thread.terminalHeight, - terminalIds: thread.terminalIds, - activeTerminalId: thread.activeTerminalId, - terminalGroups: thread.terminalGroups, - activeTerminalGroupId: thread.activeTerminalGroupId, - messages: thread.messages.map((message) => ({ - id: message.id, - role: message.role, - text: message.text, - ...(message.attachments && message.attachments.length > 0 - ? { - attachments: message.attachments.map((attachment) => ({ - type: attachment.type, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - })), - } - : {}), - createdAt: message.createdAt, - streaming: message.streaming, - })), - createdAt: thread.createdAt, - ...(thread.lastVisitedAt ? { lastVisitedAt: thread.lastVisitedAt } : {}), - branch: thread.branch, - worktreePath: thread.worktreePath, - turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({ - turnId: summary.turnId, - completedAt: summary.completedAt, - ...(summary.status ? { status: summary.status } : {}), - files: summary.files.map((file) => ({ - path: file.path, - ...(file.kind ? { kind: file.kind } : {}), - ...(typeof file.additions === "number" ? { additions: file.additions } : {}), - ...(typeof file.deletions === "number" ? { deletions: file.deletions } : {}), - })), - ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), - ...(typeof summary.checkpointTurnCount === "number" - ? { checkpointTurnCount: summary.checkpointTurnCount } - : {}), - })), - })), - runtimeMode: state.runtimeMode, - }; -} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1e7bd0d84d..0627c30c5b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -4,18 +4,16 @@ import { type ErrorComponentProps, useParams, } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; -import { QueryClient, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { QueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider } from "../components/ui/toast"; import { isElectron } from "../env"; import { useNativeApi } from "../hooks/useNativeApi"; -import { invalidateGitQueries } from "../lib/gitReactQuery"; -import { DEFAULT_MODEL } from "../model-logic"; -import { useStore } from "../store"; -import { onServerWelcome } from "../wsNativeApi"; +import { type AppState, useStore } from "../store"; +import { onServerStateUpdate, onServerWelcome } from "../wsNativeApi"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -125,8 +123,6 @@ function errorDetails(error: unknown): string { function EventRouter() { const api = useNativeApi(); const { dispatch } = useStore(); - const queryClient = useQueryClient(); - const activeAssistantItemRef = useRef(null); const activeThreadId = useParams({ strict: false, select: (params) => params.threadId, @@ -134,91 +130,41 @@ function EventRouter() { useEffect(() => { if (!api) return; - return api.providers.onEvent((event) => { - if (event.method === "turn/completed") { - void invalidateGitQueries(queryClient); - } - if (event.method === "checkpoint/captured") { - const payload = event.payload as { turnCount?: number } | undefined; - const turnCount = payload?.turnCount; - void queryClient.invalidateQueries({ - queryKey: ["providers", "checkpointDiff"] as const, - predicate: (query) => { - if (typeof turnCount !== "number") return true; - return query.queryKey[5] === turnCount; - }, - }); - } - if (!activeThreadId) return; - dispatch({ - type: "APPLY_EVENT", - event, - activeAssistantItemRef, - activeThreadId, - }); + let mounted = true; + void api.state.getSnapshot().then((snapshot) => { + if (!mounted) return; + dispatch({ type: "SET_SERVER_STATE", state: snapshot as AppState }); }); - }, [activeThreadId, api, dispatch, queryClient]); + return () => { + mounted = false; + }; + }, [api, dispatch]); useEffect(() => { - if (!activeThreadId) return; - dispatch({ - type: "MARK_THREAD_VISITED", - threadId: activeThreadId, - visitedAt: new Date().toISOString(), + return onServerStateUpdate((snapshot) => { + dispatch({ type: "SET_SERVER_STATE", state: snapshot as AppState }); }); - }, [activeThreadId, dispatch]); + }, [dispatch]); useEffect(() => { + if (!activeThreadId) return; + const visitedAt = new Date().toISOString(); if (!api) return; - return api.terminal.onEvent((event) => { - dispatch({ - type: "APPLY_TERMINAL_EVENT", - event, - }); - }); - }, [api, dispatch]); + void api.state.markThreadVisited({ threadId: activeThreadId, visitedAt }); + }, [activeThreadId, api]); return null; } function AutoProjectBootstrap() { - const { state, dispatch } = useStore(); - const bootstrappedRef = useRef(false); + const { dispatch } = useStore(); useEffect(() => { - // Browser mode bootstraps from server welcome. - // Electron bootstraps from persisted projects via DesktopProjectBootstrap. if (isElectron) return; - - return onServerWelcome((payload) => { - if (bootstrappedRef.current) return; - - // Don't create duplicate projects for the same cwd - const existing = state.projects.find((project) => project.cwd === payload.cwd); - if (existing) { - bootstrappedRef.current = true; - dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); - return; - } - - bootstrappedRef.current = true; - - // Create project + thread from server cwd - const projectId = crypto.randomUUID(); - dispatch({ - type: "ADD_PROJECT", - project: { - id: projectId, - name: payload.projectName, - cwd: payload.cwd, - model: DEFAULT_MODEL, - expanded: true, - scripts: [], - }, - }); + return onServerWelcome(() => { dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); }); - }, [state.projects, dispatch]); + }, [dispatch]); return null; } @@ -226,50 +172,10 @@ function AutoProjectBootstrap() { function DesktopProjectBootstrap() { const api = useNativeApi(); const { dispatch } = useStore(); - const bootstrappedRef = useRef(false); useEffect(() => { - if (!isElectron || !api || bootstrappedRef.current) return; - - let disposed = false; - let retryDelayMs = 500; - let retryTimer: ReturnType | null = null; - - const attemptBootstrap = async () => { - try { - const projects = await api.projects.list(); - if (disposed) return; - dispatch({ - type: "SYNC_PROJECTS", - projects: projects.map((project) => ({ - id: project.id, - name: project.name, - cwd: project.cwd, - model: DEFAULT_MODEL, - expanded: true, - scripts: project.scripts, - })), - }); - dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); - bootstrappedRef.current = true; - } catch { - if (disposed) return; - retryTimer = setTimeout(() => { - retryTimer = null; - void attemptBootstrap(); - }, retryDelayMs); - retryDelayMs = Math.min(retryDelayMs * 2, 5_000); - } - }; - - void attemptBootstrap(); - - return () => { - disposed = true; - if (retryTimer) { - clearTimeout(retryTimer); - } - }; + if (!isElectron || !api) return; + dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); }, [api, dispatch]); return null; diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts deleted file mode 100644 index 6acd1e2859..0000000000 --- a/apps/web/src/store.test.ts +++ /dev/null @@ -1,983 +0,0 @@ -import type { ProviderEvent, ProviderSession, TerminalEvent } from "@t3tools/contracts"; -import { describe, expect, it } from "vitest"; - -import { type AppState, reducer } from "./store"; -import { - DEFAULT_THREAD_TERMINAL_HEIGHT, - DEFAULT_THREAD_TERMINAL_ID, - MAX_THREAD_TERMINAL_COUNT, -} from "./types"; -import type { Thread } from "./types"; - -type TerminalStartedEvent = Extract; -type TerminalActivityEvent = Extract; - -function makeSession(overrides: Partial = {}): ProviderSession { - return { - sessionId: "sess-1", - provider: "codex", - status: "ready", - createdAt: "2026-02-09T00:00:00.000Z", - updatedAt: "2026-02-09T00:00:00.000Z", - ...overrides, - }; -} - -function makeEvent(overrides: Partial = {}): ProviderEvent { - return { - id: "evt-1", - kind: "notification", - provider: "codex", - sessionId: "sess-1", - createdAt: "2026-02-09T00:00:01.000Z", - method: "thread/started", - ...overrides, - }; -} - -function makeThread(overrides: Partial = {}): Thread { - return { - id: "thread-local-1", - codexThreadId: null, - projectId: "project-1", - title: "Thread", - model: "gpt-5.3-codex", - terminalOpen: false, - terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: [], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - session: makeSession(), - messages: [], - events: [], - turnDiffSummaries: [], - error: null, - createdAt: "2026-02-09T00:00:00.000Z", - branch: null, - worktreePath: null, - ...overrides, - }; -} - -function makeState(thread: Thread): AppState { - return { - projects: [ - { - id: "project-1", - name: "Project", - cwd: "/tmp/project", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - threads: [thread], - threadsHydrated: true, - runtimeMode: "full-access", - }; -} - -function makeTerminalStartedEvent( - overrides: Partial = {}, -): TerminalStartedEvent { - return { - type: "started", - threadId: "thread-local-1", - terminalId: DEFAULT_THREAD_TERMINAL_ID, - createdAt: "2026-02-09T00:00:01.000Z", - snapshot: { - threadId: "thread-local-1", - terminalId: DEFAULT_THREAD_TERMINAL_ID, - cwd: "/tmp/project", - status: "running", - pid: 1234, - history: "", - exitCode: null, - exitSignal: null, - updatedAt: "2026-02-09T00:00:01.000Z", - }, - ...overrides, - }; -} - -function makeTerminalActivityEvent( - overrides: Partial = {}, -): TerminalActivityEvent { - return { - type: "activity", - threadId: "thread-local-1", - terminalId: DEFAULT_THREAD_TERMINAL_ID, - createdAt: "2026-02-09T00:00:02.000Z", - hasRunningSubprocess: true, - ...overrides, - }; -} - -describe("store reducer thread continuity", () => { - it("stores codexThreadId from UPDATE_SESSION", () => { - const state = makeState( - makeThread({ - session: null, - }), - ); - const next = reducer(state, { - type: "UPDATE_SESSION", - threadId: "thread-local-1", - session: makeSession({ threadId: "thr_123" }), - }); - - expect(next.threads[0]?.codexThreadId).toBe("thr_123"); - }); - - it("toggles terminal open state per thread", () => { - const state = makeState(makeThread({ terminalOpen: false })); - const next = reducer(state, { - type: "TOGGLE_THREAD_TERMINAL", - threadId: "thread-local-1", - }); - expect(next.threads[0]?.terminalOpen).toBe(true); - }); - - it("sets terminal open state per thread", () => { - const state = makeState(makeThread({ terminalOpen: true })); - const next = reducer(state, { - type: "SET_THREAD_TERMINAL_OPEN", - threadId: "thread-local-1", - open: false, - }); - expect(next.threads[0]?.terminalOpen).toBe(false); - }); - - it("sets terminal height per thread", () => { - const state = makeState(makeThread({ terminalHeight: 280 })); - const next = reducer(state, { - type: "SET_THREAD_TERMINAL_HEIGHT", - threadId: "thread-local-1", - height: 360, - }); - expect(next.threads[0]?.terminalHeight).toBe(360); - }); - - it("splits the active terminal into side-by-side mode", () => { - const state = makeState(makeThread()); - const next = reducer(state, { - type: "SPLIT_THREAD_TERMINAL", - threadId: "thread-local-1", - terminalId: "term-2", - }); - - expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); - expect(next.threads[0]?.activeTerminalId).toBe("term-2"); - expect(next.threads[0]?.terminalGroups).toEqual([ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - }, - ]); - expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - }); - - it("creates a new full-width terminal and switches to tab mode", () => { - const state = makeState(makeThread()); - const next = reducer(state, { - type: "NEW_THREAD_TERMINAL", - threadId: "thread-local-1", - terminalId: "term-2", - }); - - expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2"]); - expect(next.threads[0]?.activeTerminalId).toBe("term-2"); - expect(next.threads[0]?.terminalGroups).toEqual([ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - { id: "group-term-2", terminalIds: ["term-2"] }, - ]); - expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-2"); - }); - - it("switches the active terminal and restores its owning group", () => { - const state = makeState( - makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - }, - { id: "group-term-3", terminalIds: ["term-3"] }, - ], - activeTerminalGroupId: "group-term-3", - }), - ); - const next = reducer(state, { - type: "SET_THREAD_ACTIVE_TERMINAL", - threadId: "thread-local-1", - terminalId: "term-2", - }); - - expect(next.threads[0]?.activeTerminalId).toBe("term-2"); - expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - }); - - it("supports splitting beyond two terminals in the same group", () => { - const state = makeState( - makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - activeTerminalId: "term-2", - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - }), - ); - const next = reducer(state, { - type: "SPLIT_THREAD_TERMINAL", - threadId: "thread-local-1", - terminalId: "term-3", - }); - - expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"]); - expect(next.threads[0]?.terminalGroups).toEqual([ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], - }, - ]); - expect(next.threads[0]?.activeTerminalId).toBe("term-3"); - expect(next.threads[0]?.activeTerminalGroupId).toBe(`group-${DEFAULT_THREAD_TERMINAL_ID}`); - }); - - it("caps split terminals at four per thread", () => { - const state = makeState( - makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"], - activeTerminalId: "term-4", - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - }), - ); - const next = reducer(state, { - type: "SPLIT_THREAD_TERMINAL", - threadId: "thread-local-1", - terminalId: "term-5", - }); - - expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT); - expect(next.threads[0]?.terminalIds).toEqual([ - DEFAULT_THREAD_TERMINAL_ID, - "term-2", - "term-3", - "term-4", - ]); - expect(next.threads[0]?.activeTerminalId).toBe("term-4"); - }); - - it("caps new terminals at four per thread", () => { - const state = makeState( - makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3", "term-4"], - activeTerminalId: "term-4", - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - { id: "group-term-2", terminalIds: ["term-2"] }, - { id: "group-term-3", terminalIds: ["term-3"] }, - { id: "group-term-4", terminalIds: ["term-4"] }, - ], - activeTerminalGroupId: "group-term-4", - }), - ); - const next = reducer(state, { - type: "NEW_THREAD_TERMINAL", - threadId: "thread-local-1", - terminalId: "term-5", - }); - - expect(next.threads[0]?.terminalIds).toHaveLength(MAX_THREAD_TERMINAL_COUNT); - expect(next.threads[0]?.terminalIds).toEqual([ - DEFAULT_THREAD_TERMINAL_ID, - "term-2", - "term-3", - "term-4", - ]); - expect(next.threads[0]?.activeTerminalId).toBe("term-4"); - expect(next.threads[0]?.activeTerminalGroupId).toBe("group-term-4"); - }); - - it("closes a terminal and keeps grouped layout coherent", () => { - const state = makeState( - makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2", "term-3"], - activeTerminalId: "term-2", - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - }, - { id: "group-term-3", terminalIds: ["term-3"] }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - }), - ); - const next = reducer(state, { - type: "CLOSE_THREAD_TERMINAL", - threadId: "thread-local-1", - terminalId: "term-2", - }); - - expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID, "term-3"]); - expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); - expect(next.threads[0]?.terminalGroups).toEqual([ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - { id: "group-term-3", terminalIds: ["term-3"] }, - ]); - }); - - it("closes the final terminal and hides the drawer", () => { - const state = makeState( - makeThread({ - terminalOpen: true, - runningTerminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }), - ); - const next = reducer(state, { - type: "CLOSE_THREAD_TERMINAL", - threadId: "thread-local-1", - terminalId: DEFAULT_THREAD_TERMINAL_ID, - }); - - expect(next.threads[0]?.terminalOpen).toBe(false); - expect(next.threads[0]?.terminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); - expect(next.threads[0]?.runningTerminalIds).toEqual([]); - expect(next.threads[0]?.activeTerminalId).toBe(DEFAULT_THREAD_TERMINAL_ID); - expect(next.threads[0]?.terminalGroups).toEqual([ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ]); - }); - - it("tracks running terminals from subprocess activity events", () => { - const state = makeState(makeThread()); - const started = reducer(state, { - type: "APPLY_TERMINAL_EVENT", - event: makeTerminalStartedEvent(), - }); - expect(started.threads[0]?.runningTerminalIds).toEqual([]); - - const active = reducer(started, { - type: "APPLY_TERMINAL_EVENT", - event: makeTerminalActivityEvent(), - }); - expect(active.threads[0]?.runningTerminalIds).toEqual([DEFAULT_THREAD_TERMINAL_ID]); - - const idle = reducer(active, { - type: "APPLY_TERMINAL_EVENT", - event: makeTerminalActivityEvent({ hasRunningSubprocess: false }), - }); - expect(idle.threads[0]?.runningTerminalIds).toEqual([]); - - const exited = reducer(active, { - type: "APPLY_TERMINAL_EVENT", - event: { - type: "exited", - threadId: "thread-local-1", - terminalId: DEFAULT_THREAD_TERMINAL_ID, - createdAt: "2026-02-09T00:00:05.000Z", - exitCode: 0, - exitSignal: null, - }, - }); - expect(exited.threads[0]?.runningTerminalIds).toEqual([]); - }); - - it("keeps running status when another terminal in the thread is still running", () => { - const state = makeState( - makeThread({ - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - runningTerminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - activeTerminalId: "term-2", - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID, "term-2"], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - }), - ); - - const next = reducer(state, { - type: "APPLY_TERMINAL_EVENT", - event: { - type: "exited", - threadId: "thread-local-1", - terminalId: DEFAULT_THREAD_TERMINAL_ID, - createdAt: "2026-02-09T00:00:07.000Z", - exitCode: 0, - exitSignal: null, - }, - }); - - expect(next.threads[0]?.runningTerminalIds).toEqual(["term-2"]); - }); - - it("backfills codexThreadId from routed provider events", () => { - const state = makeState(makeThread({ codexThreadId: null })); - const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "thread/started", - payload: { thread: { id: "thr_backfilled" } }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(next.threads[0]?.codexThreadId).toBe("thr_backfilled"); - }); - - it("ignores events from a foreign thread within the same session", () => { - const state = makeState(makeThread({ codexThreadId: "thr_expected" })); - const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/started", - threadId: "thr_unexpected", - payload: { turn: { id: "turn-1" } }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(next).toBe(state); - }); - - it("rebases thread identity on thread/started during connect", () => { - const state = makeState( - makeThread({ - codexThreadId: "thr_old", - session: makeSession({ - status: "connecting", - threadId: "thr_old", - }), - }), - ); - const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "thread/started", - threadId: "thr_new", - payload: { thread: { id: "thr_new" } }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(next.threads[0]?.codexThreadId).toBe("thr_new"); - expect(next.threads[0]?.session?.threadId).toBe("thr_new"); - }); - - it("preserves persisted turn diffs when events were reset and appends new completed turn diffs", () => { - const state = makeState( - makeThread({ - events: [], - turnDiffSummaries: [ - { - turnId: "turn-1", - completedAt: "2026-02-09T00:00:01.000Z", - files: [{ path: "src/existing.ts", kind: "modified" }], - }, - ], - }), - ); - - const withFileChange = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "item/completed", - turnId: "turn-2", - createdAt: "2026-02-09T00:00:02.000Z", - payload: { - item: { - type: "fileChange", - changes: [{ path: "src/new.ts", kind: "added" }], - }, - }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(withFileChange.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ - "turn-1", - ]); - - const completed = reducer(withFileChange, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/completed", - turnId: "turn-2", - createdAt: "2026-02-09T00:00:03.000Z", - payload: { turn: { id: "turn-2", status: "completed" } }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(completed.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual([ - "turn-2", - "turn-1", - ]); - }); - - it("infers checkpoint turn counts when deriving turn summaries from an empty baseline", () => { - const state = makeState(makeThread({ turnDiffSummaries: [] })); - const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-09T00:00:03.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(next.threads[0]?.turnDiffSummaries[0]?.turnId).toBe("turn-1"); - expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(1); - }); - - it("reconciles project ids by cwd when syncing backend projects", () => { - const state: AppState = { - projects: [ - { - id: "project-old-a", - name: "A", - cwd: "/tmp/a", - model: "gpt-5.3-codex", - expanded: false, - scripts: [], - }, - { - id: "project-old-b", - name: "B", - cwd: "/tmp/b", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - threads: [ - makeThread({ - id: "thread-a", - projectId: "project-old-a", - }), - makeThread({ - id: "thread-b", - projectId: "project-old-b", - }), - ], - threadsHydrated: true, - runtimeMode: "full-access", - }; - - const next = reducer(state, { - type: "SYNC_PROJECTS", - projects: [ - { - id: "project-new-a", - name: "A", - cwd: "/tmp/a", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - }); - - expect(next.projects).toHaveLength(1); - expect(next.projects[0]?.id).toBe("project-new-a"); - // Preserve existing project UI preferences by cwd - expect(next.projects[0]?.expanded).toBe(false); - expect(next.threads).toHaveLength(1); - expect(next.threads[0]?.id).toBe("thread-a"); - expect(next.threads[0]?.projectId).toBe("project-new-a"); - }); - - it("treats empty scripts from sync as authoritative", () => { - const state: AppState = { - projects: [ - { - id: "project-old-a", - name: "A", - cwd: "/tmp/a", - model: "gpt-5.3-codex", - expanded: true, - scripts: [ - { - id: "test", - name: "Test", - command: "bun test", - icon: "test", - runOnWorktreeCreate: false, - }, - ], - }, - ], - threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })], - threadsHydrated: true, - runtimeMode: "full-access", - }; - - const next = reducer(state, { - type: "SYNC_PROJECTS", - projects: [ - { - id: "project-new-a", - name: "A", - cwd: "/tmp/a", - model: "gpt-5.3-codex", - expanded: false, - scripts: [], - }, - ], - }); - - expect(next.projects[0]?.scripts).toEqual([]); - }); - - it("updates project scripts", () => { - const state = makeState(makeThread()); - const next = reducer(state, { - type: "SET_PROJECT_SCRIPTS", - projectId: "project-1", - scripts: [ - { - id: "test", - name: "Test", - command: "bun test", - icon: "test", - runOnWorktreeCreate: false, - }, - ], - }); - - expect(next.projects[0]?.scripts).toEqual([ - { - id: "test", - name: "Test", - command: "bun test", - icon: "test", - runOnWorktreeCreate: false, - }, - ]); - }); - - it("marks threads hydration state", () => { - const state = makeState(makeThread()); - const next = reducer(state, { - type: "SET_THREADS_HYDRATED", - hydrated: false, - }); - - expect(next.threadsHydrated).toBe(false); - }); - - it("deletes a project and all of its threads", () => { - const state: AppState = { - projects: [ - { - id: "project-1", - name: "One", - cwd: "/tmp/one", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - { - id: "project-2", - name: "Two", - cwd: "/tmp/two", - model: "gpt-5.3-codex", - expanded: true, - scripts: [], - }, - ], - threads: [ - makeThread({ - id: "thread-a", - projectId: "project-1", - }), - makeThread({ - id: "thread-b", - projectId: "project-2", - }), - ], - threadsHydrated: true, - runtimeMode: "full-access", - }; - - const next = reducer(state, { - type: "DELETE_PROJECT", - projectId: "project-1", - }); - - expect(next.projects).toHaveLength(1); - expect(next.projects[0]?.id).toBe("project-2"); - expect(next.threads).toHaveLength(1); - expect(next.threads[0]?.id).toBe("thread-b"); - }); - - it("marks completion as seen immediately for the active thread", () => { - const state = makeState( - makeThread({ - session: makeSession({ - status: "running", - activeTurnId: "turn-1", - }), - lastVisitedAt: "2026-02-08T10:00:00.000Z", - }), - ); - - const completedAt = "2026-02-08T10:00:10.000Z"; - const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/completed", - turnId: "turn-1", - createdAt: completedAt, - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - activeAssistantItemRef: { current: null }, - activeThreadId: "thread-local-1", - }); - - expect(next.threads[0]?.latestTurnCompletedAt).toBe(completedAt); - expect(next.threads[0]?.lastVisitedAt).toBe(completedAt); - }); - - it("marks a thread as visited when selected", () => { - const state = makeState( - makeThread({ - latestTurnCompletedAt: "2026-02-08T10:00:10.000Z", - lastVisitedAt: "2026-02-08T10:00:00.000Z", - }), - ); - - const nextVisitedAt = "2026-02-08T10:00:20.000Z"; - const next = reducer(state, { - type: "MARK_THREAD_VISITED", - threadId: "thread-local-1", - visitedAt: nextVisitedAt, - }); - - expect(next.threads[0]?.lastVisitedAt).toBe(nextVisitedAt); - }); - - it("reverts thread state to a checkpoint snapshot", () => { - const state = makeState( - makeThread({ - codexThreadId: "thr_before", - session: makeSession({ - status: "running", - threadId: "thr_before", - activeTurnId: "turn-live", - }), - messages: [ - { - id: "m-1", - role: "user", - text: "First", - createdAt: "2026-02-08T10:00:00.000Z", - streaming: false, - }, - { - id: "m-2", - role: "assistant", - text: "First reply", - createdAt: "2026-02-08T10:00:01.000Z", - streaming: false, - }, - { - id: "m-3", - role: "user", - text: "Second", - createdAt: "2026-02-08T10:00:02.000Z", - streaming: false, - }, - ], - events: [ - makeEvent({ - method: "turn/started", - turnId: "turn-live", - }), - ], - turnDiffSummaries: [ - { - turnId: "turn_1", - completedAt: "2026-02-08T10:00:01.000Z", - files: [{ path: "src/first.ts", kind: "modified" }], - checkpointTurnCount: 1, - }, - { - turnId: "turn_2", - completedAt: "2026-02-08T10:00:03.000Z", - files: [{ path: "src/second.ts", kind: "modified" }], - checkpointTurnCount: 2, - }, - ], - error: "temporary failure", - latestTurnId: "turn-live", - latestTurnStartedAt: "2026-02-08T10:00:03.000Z", - }), - ); - - const next = reducer(state, { - type: "REVERT_TO_CHECKPOINT", - threadId: "thread-local-1", - sessionId: "sess-1", - threadRuntimeId: "thr_after", - turnCount: 1, - messageCount: 2, - }); - - expect(next.threads[0]?.codexThreadId).toBe("thr_after"); - expect(next.threads[0]?.messages.map((message) => message.id)).toEqual(["m-1", "m-2"]); - expect(next.threads[0]?.events).toEqual([]); - expect(next.threads[0]?.error).toBeNull(); - expect(next.threads[0]?.session?.status).toBe("ready"); - expect(next.threads[0]?.session?.activeTurnId).toBeUndefined(); - expect(next.threads[0]?.session?.threadId).toBe("thr_after"); - expect(next.threads[0]?.latestTurnId).toBeUndefined(); - expect(next.threads[0]?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn_1"]); - }); - - it("keeps existing turn file metadata when later events for the same turn arrive", () => { - const state = makeState( - makeThread({ - turnDiffSummaries: [ - { - turnId: "turn-1", - completedAt: "2026-02-09T00:00:03.000Z", - files: [ - { - path: "src/from-checkpoint.ts", - additions: 1, - deletions: 1, - }, - ], - }, - ], - }), - ); - - const next = reducer(state, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-09T00:00:04.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(next.threads[0]?.turnDiffSummaries[0]?.files.map((file) => file.path)).toEqual([ - "src/from-checkpoint.ts", - ]); - expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.additions).toBe(1); - expect(next.threads[0]?.turnDiffSummaries[0]?.files[0]?.deletions).toBe(1); - }); - - it("updates checkpoint turn counts from authoritative checkpoint mappings", () => { - const state = makeState( - makeThread({ - turnDiffSummaries: [ - { - turnId: "turn-1", - completedAt: "2026-02-09T00:00:03.000Z", - files: [], - checkpointTurnCount: 1, - }, - { - turnId: "turn-2", - completedAt: "2026-02-09T00:00:04.000Z", - files: [], - checkpointTurnCount: 2, - }, - ], - }), - ); - - const next = reducer(state, { - type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", - threadId: "thread-local-1", - checkpointTurnCountByTurnId: { - "turn-1": 2, - "turn-2": 3, - }, - }); - - expect(next.threads[0]?.turnDiffSummaries[0]?.checkpointTurnCount).toBe(2); - expect(next.threads[0]?.turnDiffSummaries[1]?.checkpointTurnCount).toBe(3); - }); - - it("does not rederive completed turn summaries from late turn diff events", () => { - const completed = reducer(makeState(makeThread()), { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-09T00:00:03.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(completed.threads[0]?.turnDiffSummaries[0]?.files).toEqual([]); - - const withLateDiff = reducer(completed, { - type: "APPLY_EVENT", - event: makeEvent({ - method: "turn/diff/updated", - turnId: "turn-1", - createdAt: "2026-02-09T00:00:04.000Z", - payload: { - diff: [ - "diff --git a/src/a.ts b/src/a.ts", - "@@ -1 +1 @@", - "-old", - "+new", - "diff --git a/src/b.ts b/src/b.ts", - "@@ -1 +1 @@", - "-old", - "+new", - ].join("\n"), - }, - }), - activeAssistantItemRef: { current: null }, - }); - - expect(withLateDiff.threads[0]?.turnDiffSummaries[0]?.files).toEqual([]); - }); -}); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index cb45b5506c..d57b5b6bc1 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -4,21 +4,11 @@ import { createContext, createElement, useContext, - useEffect, useReducer, } from "react"; -import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts"; +import { type ProviderSession, normalizeProjectScripts } from "@t3tools/contracts"; import { resolveModelSlug } from "./model-logic"; -import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; -import { - applyEventToMessages, - asObject, - asString, - deriveTurnDiffSummaries, - inferCheckpointTurnCountByTurnId, - evolveSession, -} from "./session-logic"; import { type ChatAttachment, DEFAULT_THREAD_TERMINAL_ID, @@ -34,6 +24,7 @@ import { // ── Actions ────────────────────────────────────────────────────────── type Action = + | { type: "SET_SERVER_STATE"; state: AppState } | { type: "ADD_PROJECT"; project: Project } | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } | { type: "SYNC_PROJECTS"; projects: Project[] } @@ -48,13 +39,6 @@ type Action = | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string } - | { - type: "APPLY_EVENT"; - event: ProviderEvent; - activeAssistantItemRef: { current: string | null }; - activeThreadId?: string | null; - } - | { type: "APPLY_TERMINAL_EVENT"; event: TerminalEvent } | { type: "UPDATE_SESSION"; threadId: string; session: ProviderSession } | { type: "PUSH_USER_MESSAGE"; @@ -98,18 +82,6 @@ export interface AppState { runtimeMode: RuntimeMode; } -const PERSISTED_STATE_KEY = "t3code:renderer-state:v7"; -const LEGACY_PERSISTED_STATE_KEYS = [ - "t3code:renderer-state:v6", - "t3code:renderer-state:v5", - "t3code:renderer-state:v4", - "t3code:renderer-state:v3", - "codething:renderer-state:v4", - "codething:renderer-state:v3", - "codething:renderer-state:v2", - "codething:renderer-state:v1", -] as const; - const initialState: AppState = { projects: [], threads: [], @@ -120,41 +92,11 @@ const initialState: AppState = { // ── Helpers ────────────────────────────────────────────────────────── function readPersistedState(): AppState { - if (typeof window === "undefined") return initialState; - - try { - const rawCurrent = window.localStorage.getItem(PERSISTED_STATE_KEY); - const legacyValues = LEGACY_PERSISTED_STATE_KEYS.map((key) => window.localStorage.getItem(key)); - const rawLegacy = legacyValues.find((value) => value !== null) ?? null; - const raw = rawCurrent ?? rawLegacy; - if (!raw) return initialState; - const rawCodethingV1 = window.localStorage.getItem("codething:renderer-state:v1"); - const hydrated = hydratePersistedState(raw, !rawCurrent && raw === rawCodethingV1); - if (!hydrated) return initialState; - - const threads = hydrated.threads.map((thread) => normalizeThreadTerminals(thread)); - - return { - ...hydrated, - threads, - threadsHydrated: threads.length > 0, - }; - } catch { - return initialState; - } + return initialState; } function persistState(state: AppState): void { - if (typeof window === "undefined") return; - - try { - window.localStorage.setItem(PERSISTED_STATE_KEY, JSON.stringify(toPersistedState(state))); - for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { - window.localStorage.removeItem(legacyKey); - } - } catch { - // Ignore quota/storage errors to avoid breaking chat UX. - } + void state; } function updateThread( @@ -165,64 +107,6 @@ function updateThread( return threads.map((t) => (t.id === threadId ? updater(t) : t)); } -function mergeTurnDiffSummaries( - existing: Thread["turnDiffSummaries"], - next: Thread["turnDiffSummaries"], -): Thread["turnDiffSummaries"] { - if (next.length === 0) return existing; - - const existingByTurnId = new Map(existing.map((summary) => [summary.turnId, summary] as const)); - const merged = next.map((summary) => { - const previous = existingByTurnId.get(summary.turnId); - if (!previous) { - return summary; - } - - const files = - summary.files.length === 0 && previous.files.length > 0 ? previous.files : summary.files; - - return { - ...summary, - files, - ...(summary.assistantMessageId - ? {} - : previous.assistantMessageId - ? { assistantMessageId: previous.assistantMessageId } - : {}), - ...(typeof summary.checkpointTurnCount === "number" - ? {} - : typeof previous.checkpointTurnCount === "number" - ? { checkpointTurnCount: previous.checkpointTurnCount } - : {}), - }; - }); - - const mergedTurnIds = new Set(merged.map((summary) => summary.turnId)); - for (const summary of existing) { - if (!mergedTurnIds.has(summary.turnId)) { - merged.push(summary); - } - } - - const sorted = merged.toSorted((a, b) => { - const aTime = Date.parse(a.completedAt); - const bTime = Date.parse(b.completedAt); - if (Number.isNaN(aTime) || Number.isNaN(bTime)) { - return b.completedAt.localeCompare(a.completedAt); - } - return bTime - aTime; - }); - - const inferredTurnCountByTurnId = inferCheckpointTurnCountByTurnId(sorted); - return sorted.map((summary) => - typeof summary.checkpointTurnCount === "number" - ? summary - : Object.assign({}, summary, { - checkpointTurnCount: inferredTurnCountByTurnId[summary.turnId], - }), - ); -} - function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT); @@ -409,93 +293,16 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { }); } -function findThreadBySessionId(threads: Thread[], sessionId: string): Thread | undefined { - return threads.find((t) => t.session?.sessionId === sessionId); -} - -function getEventTurnId(event: ProviderEvent): string | undefined { - if (event.turnId) return event.turnId; - const payload = asObject(event.payload); - const turn = asObject(payload?.turn); - return asString(turn?.id); -} - -function getEventThreadId(event: ProviderEvent): string | undefined { - if (event.threadId) return event.threadId; - const payload = asObject(event.payload); - const payloadThread = asObject(payload?.thread); - const payloadMessage = asObject(payload?.msg); - return ( - asString(payload?.threadId) ?? - asString(payloadThread?.id) ?? - asString(payload?.conversationId) ?? - asString(payload?.thread_id) ?? - asString(payloadMessage?.thread_id) - ); -} - -function shouldIgnoreForeignThreadEvent(thread: Thread, event: ProviderEvent): boolean { - const eventThreadId = getEventThreadId(event); - if (!eventThreadId) { - return false; - } - - const expectedThreadId = thread.session?.threadId ?? thread.codexThreadId; - if (!expectedThreadId || eventThreadId === expectedThreadId) { - return false; - } - - // During connect, accept a thread/started notification as an identity rebind. - if (event.method === "thread/started" && thread.session?.status === "connecting") { - return false; - } - - return true; -} - -function durationMs(startIso: string, endIso: string): number | undefined { - const start = Date.parse(startIso); - const end = Date.parse(endIso); - if (Number.isNaN(start) || Number.isNaN(end) || end < start) { - return undefined; - } - - return end - start; -} - -function updateTurnFields(thread: Thread, event: ProviderEvent): Partial { - if (event.method === "turn/started") { - return { - latestTurnId: getEventTurnId(event) ?? thread.latestTurnId, - latestTurnStartedAt: event.createdAt, - latestTurnCompletedAt: undefined, - latestTurnDurationMs: undefined, - }; - } - - if (event.method === "turn/completed") { - const completedTurnId = getEventTurnId(event) ?? thread.latestTurnId; - const startedAt = - completedTurnId && completedTurnId === thread.latestTurnId - ? thread.latestTurnStartedAt - : undefined; - const elapsed = - startedAt && startedAt.length > 0 ? durationMs(startedAt, event.createdAt) : undefined; - - return { - latestTurnId: completedTurnId ?? thread.latestTurnId, - latestTurnCompletedAt: event.createdAt, - latestTurnDurationMs: elapsed, - }; - } - - return {}; -} - // ── Reducer ────────────────────────────────────────────────────────── export function reducer(state: AppState, action: Action): AppState { switch (action.type) { + case "SET_SERVER_STATE": + return { + ...action.state, + threads: action.state.threads.map((thread) => normalizeThreadTerminals(thread)), + }; + case "ADD_PROJECT": if (state.projects.some((project) => project.cwd === action.project.cwd)) { return state; @@ -774,81 +581,6 @@ export function reducer(state: AppState, action: Action): AppState { ), }; - case "APPLY_TERMINAL_EVENT": - if (!state.threads.some((thread) => thread.id === action.event.threadId)) { - return state; - } - return { - ...state, - threads: updateThread(state.threads, action.event.threadId, (thread) => { - const normalizedThread = normalizeThreadTerminals(thread); - const runningTerminalIdSet = new Set(normalizedThread.runningTerminalIds); - if (action.event.type === "started" || action.event.type === "restarted") { - runningTerminalIdSet.delete(action.event.terminalId); - } else if (action.event.type === "activity") { - if (action.event.hasRunningSubprocess) { - runningTerminalIdSet.add(action.event.terminalId); - } else { - runningTerminalIdSet.delete(action.event.terminalId); - } - } else if (action.event.type === "exited" || action.event.type === "error") { - runningTerminalIdSet.delete(action.event.terminalId); - } - - return normalizeThreadTerminals({ - ...normalizedThread, - runningTerminalIds: [...runningTerminalIdSet], - }); - }), - }; - - case "APPLY_EVENT": { - const { event, activeAssistantItemRef, activeThreadId } = action; - const target = findThreadBySessionId(state.threads, event.sessionId); - if (!target) return state; - if (shouldIgnoreForeignThreadEvent(target, event)) return state; - - return { - ...state, - threads: updateThread(state.threads, target.id, (t) => { - const nextEvents = [event, ...t.events]; - const eventTurnId = getEventTurnId(event); - const hasCompletedSummaryForTurn = Boolean( - eventTurnId && t.turnDiffSummaries.some((summary) => summary.turnId === eventTurnId), - ); - const itemType = asString(asObject(asObject(event.payload)?.item)?.type); - const normalizedItemType = itemType?.replace(/[_-]/g, "").toLowerCase(); - const isMetadataItemCompleted = - event.method === "item/completed" && - (normalizedItemType === "agentmessage" || normalizedItemType === "filechange"); - const shouldRederiveDiffs = - event.method === "turn/completed" || - (hasCompletedSummaryForTurn && isMetadataItemCompleted); - const turnDiffSummaries = shouldRederiveDiffs - ? mergeTurnDiffSummaries(t.turnDiffSummaries, deriveTurnDiffSummaries(nextEvents)) - : t.turnDiffSummaries; - const eventThreadId = getEventThreadId(event); - const shouldRebindIdentity = - event.method === "thread/started" && t.session?.status === "connecting"; - return { - ...t, - codexThreadId: shouldRebindIdentity - ? (eventThreadId ?? t.codexThreadId) - : (t.codexThreadId ?? eventThreadId ?? null), - error: event.kind === "error" && event.message ? event.message : t.error, - session: t.session ? evolveSession(t.session, event) : t.session, - messages: applyEventToMessages(t.messages, event, activeAssistantItemRef), - events: nextEvents, - turnDiffSummaries, - ...updateTurnFields(t, event), - ...(event.method === "turn/completed" && t.id === activeThreadId - ? { lastVisitedAt: event.createdAt } - : {}), - }; - }), - }; - } - case "UPDATE_SESSION": return { ...state, @@ -1035,10 +767,6 @@ const StoreContext = createContext<{ export function StoreProvider({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(reducer, undefined, readPersistedState); - useEffect(() => { - persistState(state); - }, [state]); - return createElement(StoreContext.Provider, { value: { state, dispatch } }, children); } diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 9eacc9ed1f..4076b516b7 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -5,7 +5,9 @@ import { WsTransport } from "./wsTransport"; let instance: { api: NativeApi; transport: WsTransport } | null = null; const welcomeListeners = new Set<(payload: WsWelcomePayload) => void>(); +const stateListeners = new Set<(state: unknown) => void>(); let lastWelcome: WsWelcomePayload | null = null; +let lastStateSnapshot: unknown = null; /** * Subscribe to the server welcome message. If a welcome was already received @@ -29,6 +31,20 @@ export function onServerWelcome(listener: (payload: WsWelcomePayload) => void): }; } +export function onServerStateUpdate(listener: (state: unknown) => void): () => void { + stateListeners.add(listener); + if (lastStateSnapshot) { + try { + listener(lastStateSnapshot); + } catch { + // Swallow listener errors + } + } + return () => { + stateListeners.delete(listener); + }; +} + export function createWsNativeApi(): NativeApi { if (instance) return instance.api; @@ -47,6 +63,16 @@ export function createWsNativeApi(): NativeApi { } } }); + transport.subscribe(WS_CHANNELS.stateUpdated, (data) => { + lastStateSnapshot = data; + for (const listener of stateListeners) { + try { + listener(data); + } catch { + // Swallow listener errors + } + } + }); const api: NativeApi = { todos: { @@ -95,8 +121,7 @@ export function createWsNativeApi(): NativeApi { getCheckpointDiff: (input) => transport.request(WS_METHODS.providersGetCheckpointDiff, input), revertToCheckpoint: (input) => transport.request(WS_METHODS.providersRevertToCheckpoint, input), - onEvent: (callback) => - transport.subscribe(WS_CHANNELS.providerEvent, callback as (data: unknown) => void), + onEvent: () => () => {}, }, projects: { list: () => transport.request(WS_METHODS.projectsList), @@ -149,6 +174,13 @@ export function createWsNativeApi(): NativeApi { getConfig: () => transport.request(WS_METHODS.serverGetConfig), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), }, + state: { + getSnapshot: () => transport.request(WS_METHODS.stateGetSnapshot), + createThread: (input) => transport.request(WS_METHODS.stateCreateThread, input), + deleteThread: (input) => transport.request(WS_METHODS.stateDeleteThread, input), + markThreadVisited: (input) => transport.request(WS_METHODS.stateMarkThreadVisited, input), + setRuntimeMode: (input) => transport.request(WS_METHODS.stateSetRuntimeMode, input), + }, }; instance = { api, transport }; diff --git a/bun.lock b/bun.lock index 1798b17ea0..2370dc0948 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,9 @@ "t3": "./dist/index.mjs", }, "dependencies": { + "@t3tools/core": "workspace:*", + "@t3tools/infra-sqlite": "workspace:*", + "effect": "^3.18.4", "node-pty": "^1.1.0", "open": "^10.1.0", "ws": "^8.18.0", @@ -94,6 +97,33 @@ "typescript": "^5.7.3", }, }, + "packages/core": { + "name": "@t3tools/core", + "version": "0.0.0", + "dependencies": { + "effect": "^3.18.4", + }, + "devDependencies": { + "@types/node": "^25.3.0", + "typescript": "^5.7.3", + "vitest": "^4.0.0", + }, + }, + "packages/infra-sqlite": { + "name": "@t3tools/infra-sqlite", + "version": "0.0.0", + "dependencies": { + "@effect/sql": "^0.49.0", + "@effect/sql-sqlite-node": "^0.50.1", + "@t3tools/core": "workspace:*", + "effect": "^3.18.4", + }, + "devDependencies": { + "@types/node": "^25.3.0", + "typescript": "^5.7.3", + "vitest": "^4.0.0", + }, + }, }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -144,6 +174,14 @@ "@base-ui/utils": ["@base-ui/utils@0.2.5", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw=="], + "@effect/experimental": ["@effect/experimental@0.58.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/platform": "^0.94.0", "effect": "^3.19.13", "ioredis": "^5", "lmdb": "^3" }, "optionalPeers": ["ioredis", "lmdb"] }, "sha512-IEP9sapjF6rFy5TkoqDPc86st/fnqUfjT7Xa3pWJrFGr1hzaMXHo+mWsYOZS9LAOVKnpHuVziDK97EP5qsCHVA=="], + + "@effect/platform": ["@effect/platform@0.94.5", "", { "dependencies": { "find-my-way-ts": "^0.1.6", "msgpackr": "^1.11.4", "multipasta": "^0.2.7" }, "peerDependencies": { "effect": "^3.19.17" } }, "sha512-z05APUiDDPbodhTkH/RJqOLoCU11bU2IZLfcwLFrld03+ob1VeqRnELQlmueLIYm6NZifHAtjl32V+GRt34y4A=="], + + "@effect/sql": ["@effect/sql@0.49.0", "", { "dependencies": { "uuid": "^11.0.3" }, "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "effect": "^3.19.13" } }, "sha512-9UEKR+z+MrI/qMAmSvb/RiD9KlgIazjZUCDSpwNgm0lEK9/Q6ExEyfziiYFVCPiptp52cBw8uBHRic8hHnwqXA=="], + + "@effect/sql-sqlite-node": ["@effect/sql-sqlite-node@0.50.1", "", { "dependencies": { "better-sqlite3": "^12.6.2" }, "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.4", "@effect/sql": "^0.49.0", "effect": "^3.19.16" } }, "sha512-L7DlHcOVl9/9V5stttFyv9wjvA9PBbaXwsp9pU+KN8KqokWgAVBNBEDf/dVfboUrynbDpDWwMU9SJmRJ1LXASQ=="], + "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -244,6 +282,18 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@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=="], + "@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], @@ -418,8 +468,12 @@ "@t3tools/contracts": ["@t3tools/contracts@workspace:packages/contracts"], + "@t3tools/core": ["@t3tools/core@workspace:packages/core"], + "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"], + "@t3tools/infra-sqlite": ["@t3tools/infra-sqlite@workspace:packages/infra-sqlite"], + "@t3tools/web": ["@t3tools/web@workspace:apps/web"], "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], @@ -518,7 +572,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -582,18 +636,28 @@ "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], + "better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + "birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -624,6 +688,8 @@ "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], @@ -656,6 +722,8 @@ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], @@ -686,6 +754,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "effect": ["effect@3.19.19", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-Yc8U/SVXo2dHnaP7zNBlAo83h/nzSJpi7vph6Hzyl4ulgMBIgPmz3UzOjb9sBgpFE00gC0iETR244sfXDNLHRg=="], + "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], @@ -726,22 +796,32 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -760,6 +840,8 @@ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="], @@ -808,10 +890,16 @@ "http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "import-from": ["import-from@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ=="], "import-without-cache": ["import-without-cache@0.2.5", "", {}, "sha512-B6Lc2s6yApwnD2/pMzFh/d5AVjdsDXjgkeJ766FmFuJELIGHNycKRj+l3A39yZPM4CchqNCB4RITEAYB1KUM6A=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], @@ -1006,16 +1094,30 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "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=="], + "msw": ["msw@2.12.10", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw=="], + "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + "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-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], @@ -1060,6 +1162,8 @@ "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=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], @@ -1070,10 +1174,14 @@ "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="], + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], @@ -1082,6 +1190,8 @@ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], @@ -1132,6 +1242,8 @@ "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -1150,6 +1262,10 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1168,10 +1284,14 @@ "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], @@ -1192,6 +1312,10 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], @@ -1226,6 +1350,8 @@ "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=="], + "turbo": ["turbo@2.8.7", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.7", "turbo-darwin-arm64": "2.8.7", "turbo-linux-64": "2.8.7", "turbo-linux-arm64": "2.8.7", "turbo-windows-64": "2.8.7", "turbo-windows-arm64": "2.8.7" }, "bin": { "turbo": "bin/turbo" } }, "sha512-RBLh5caMAu1kFdTK1jgH2gH/z+jFsvX5rGbhgJ9nlIAWXSvxlzwId05uDlBA1+pBd3wO/UaKYzaQZQBXDd7kcA=="], "turbo-darwin-64": ["turbo-darwin-64@2.8.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A=="], @@ -1246,7 +1372,7 @@ "unconfig-core": ["unconfig-core@7.4.2", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg=="], - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], @@ -1276,6 +1402,10 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], @@ -1342,6 +1472,8 @@ "@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@t3tools/desktop/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + "@tailwindcss/node/lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -1366,6 +1498,16 @@ "@types/babel__template/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "@types/cacheable-request/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + + "@types/keyv/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + + "@types/responselike/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + + "@types/ws/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + + "@types/yauzl/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + "@vitejs/plugin-react/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -1392,6 +1534,8 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "t3/@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + "unixify/normalize-path": ["normalize-path@2.1.1", "", { "dependencies": { "remove-trailing-separator": "^1.0.1" } }, "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w=="], "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], @@ -1400,6 +1544,8 @@ "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], + "@t3tools/desktop/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], @@ -1422,8 +1568,20 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "@types/cacheable-request/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@types/keyv/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@types/responselike/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@types/ws/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@types/yauzl/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "electron/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "rolldown-plugin-dts/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.1", "", {}, "sha512-vi/pfmbrOtQmqgfboaBhaCU50G7mcySVu69VU8z+lYoPPB6WzI9VgV7WQfL908M4oeSH5fDkmoupIqoE0SdApw=="], + + "t3/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], } } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ea5bc6ec4d..2cbc427184 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -42,6 +42,7 @@ import type { ProjectUpdateScriptsResult, } from "./project"; import type { ServerConfig } from "./server"; +type RuntimeMode = "approval-required" | "full-access"; import type { TerminalClearInput, TerminalCloseInput, @@ -140,4 +141,19 @@ export interface NativeApi { getConfig: () => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; }; + state: { + getSnapshot: () => Promise; + createThread: (input: { + id: string; + projectId: string; + title: string; + model: string; + createdAt: string; + branch: string | null; + worktreePath: string | null; + }) => Promise; + deleteThread: (input: { id: string }) => Promise; + markThreadVisited: (input: { threadId: string; visitedAt?: string }) => Promise; + setRuntimeMode: (input: { mode: RuntimeMode }) => Promise; + }; } diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 06a7582a8b..eec13f8d7e 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -46,6 +46,7 @@ export const providerSessionSchema = z.object({ }); export const providerSessionStartInputSchema = z.object({ + uiThreadId: z.string().trim().min(1).optional(), provider: providerKindSchema.default("codex"), cwd: z.string().min(1).optional(), model: z.string().trim().min(1).optional(), @@ -80,6 +81,7 @@ export const providerSendTurnAttachmentInputSchema = z.discriminatedUnion("type" export const providerSendTurnInputSchema = z .object({ + uiThreadId: z.string().trim().min(1).optional(), sessionId: z.string().min(1), input: z.string().trim().min(1).max(PROVIDER_SEND_TURN_MAX_INPUT_CHARS).optional(), attachments: z diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 61f583354b..fb1c47754f 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -46,6 +46,13 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", serverUpsertKeybinding: "server.upsertKeybinding", + + // Server-authoritative state + stateGetSnapshot: "state.getSnapshot", + stateCreateThread: "state.createThread", + stateDeleteThread: "state.deleteThread", + stateMarkThreadVisited: "state.markThreadVisited", + stateSetRuntimeMode: "state.setRuntimeMode", } as const; // ── Push Event Channels ────────────────────────────────────────────── @@ -54,6 +61,7 @@ export const WS_CHANNELS = { providerEvent: "providers.event", terminalEvent: "terminal.event", serverWelcome: "server.welcome", + stateUpdated: "state.updated", } as const; // ── Client → Server (request) ──────────────────────────────────────── diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000000..f762399a94 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,26 @@ +{ + "name": "@t3tools/core", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "effect": "^3.18.4" + }, + "devDependencies": { + "@types/node": "^25.3.0", + "typescript": "^5.7.3", + "vitest": "^4.0.0" + } +} diff --git a/packages/core/src/application/decide.ts b/packages/core/src/application/decide.ts new file mode 100644 index 0000000000..66db2432ed --- /dev/null +++ b/packages/core/src/application/decide.ts @@ -0,0 +1,78 @@ +import type { AnyDomainCommand } from "../domain/commands"; +import type { NewDomainEvent } from "../domain/events"; +import type { AppViewState } from "../domain/models"; + +function eventFromCommand( + command: AnyDomainCommand, + eventType: NewDomainEvent["type"], + payload: NewDomainEvent["payload"], + streamId: string, +): NewDomainEvent { + return { + streamId, + type: eventType, + payload, + occurredAt: command.issuedAt, + ...(command.id ? { causationId: command.id } : {}), + ...(command.correlationId ? { correlationId: command.correlationId } : {}), + }; +} + +function streamIdFromCommand(command: AnyDomainCommand): string { + switch (command.type) { + case "app.bootstrap": + case "runtime.setMode": + return "app"; + case "project.add": + case "project.remove": + case "project.updateScripts": + return `project:${command.payload.id}`; + case "thread.create": + case "thread.delete": + return `thread:${command.payload.id}`; + case "thread.addUserMessage": + case "thread.updateProviderSession": + case "thread.recordProviderEvent": + case "thread.setBranch": + case "thread.setTerminalActivity": + case "thread.markVisited": + return `thread:${command.payload.threadId}`; + default: + return "app"; + } +} + +export function decide(command: AnyDomainCommand, currentState: AppViewState): ReadonlyArray { + switch (command.type) { + case "app.bootstrap": { + if (currentState.projects.length > 0) return []; + return [eventFromCommand(command, "app.bootstrapped", command.payload, streamIdFromCommand(command))]; + } + case "project.add": + return [eventFromCommand(command, "project.added", command.payload, streamIdFromCommand(command))]; + case "project.remove": + return [eventFromCommand(command, "project.removed", command.payload, streamIdFromCommand(command))]; + case "project.updateScripts": + return [eventFromCommand(command, "project.scriptsUpdated", command.payload, streamIdFromCommand(command))]; + case "thread.create": + return [eventFromCommand(command, "thread.created", command.payload, streamIdFromCommand(command))]; + case "thread.delete": + return [eventFromCommand(command, "thread.deleted", command.payload, streamIdFromCommand(command))]; + case "thread.addUserMessage": + return [eventFromCommand(command, "thread.userMessageAdded", command.payload, streamIdFromCommand(command))]; + case "thread.updateProviderSession": + return [eventFromCommand(command, "thread.providerSessionUpdated", command.payload, streamIdFromCommand(command))]; + case "thread.recordProviderEvent": + return [eventFromCommand(command, "thread.providerEventRecorded", command.payload, streamIdFromCommand(command))]; + case "thread.setBranch": + return [eventFromCommand(command, "thread.branchSet", command.payload, streamIdFromCommand(command))]; + case "thread.setTerminalActivity": + return [eventFromCommand(command, "thread.terminalActivitySet", command.payload, streamIdFromCommand(command))]; + case "thread.markVisited": + return [eventFromCommand(command, "thread.markVisited", command.payload, streamIdFromCommand(command))]; + case "runtime.setMode": + return [eventFromCommand(command, "runtime.modeSet", command.payload, streamIdFromCommand(command))]; + default: + return []; + } +} diff --git a/packages/core/src/application/engine.ts b/packages/core/src/application/engine.ts new file mode 100644 index 0000000000..68f7ec9574 --- /dev/null +++ b/packages/core/src/application/engine.ts @@ -0,0 +1,49 @@ +import { emptyAppViewState, type AppViewState } from "../domain/models"; +import type { AnyDomainCommand } from "../domain/commands"; +import { decide } from "./decide"; +import type { EventStorePort, ProjectionStorePort } from "../ports"; +import { QueueProjector } from "./projector"; +import { reduceEvents } from "../projections/reducer"; + +export class OrchestrationEngine { + constructor( + private readonly eventStore: EventStorePort, + projectionStore: ProjectionStorePort, + private readonly projector: QueueProjector, + ) { + this.projectionStore = projectionStore; + } + + private readonly projectionStore: ProjectionStorePort; + + async start(): Promise { + const state = await this.projectionStore.readState(); + if (!state) { + const allEvents = await this.eventStore.loadAll(); + const next = allEvents.length === 0 ? emptyAppViewState() : reduceEvents(allEvents, emptyAppViewState()); + await this.projectionStore.writeState(next); + } + await this.projector.start(); + const pending = await this.eventStore.loadAfterPosition((await this.currentState()).lastPosition); + if (pending.length > 0) { + await this.projector.enqueue(pending); + } + } + + async stop(): Promise { + await this.projector.stop(); + } + + async execute(command: AnyDomainCommand): Promise { + const current = await this.currentState(); + const pending = decide(command, current); + if (pending.length === 0) return current; + const appended = await this.eventStore.append(pending); + await this.projector.enqueue(appended); + return this.currentState(); + } + + async currentState(): Promise { + return this.projector.readState(); + } +} diff --git a/packages/core/src/application/fanout.ts b/packages/core/src/application/fanout.ts new file mode 100644 index 0000000000..9c028fdd31 --- /dev/null +++ b/packages/core/src/application/fanout.ts @@ -0,0 +1,64 @@ +import type { StateUpdatedNotification } from "../domain/events"; +import type { FanoutPort } from "../ports"; + +interface Sink { + push(value: T): void; + close(): void; + iterable: AsyncIterable; +} + +function createSink(): Sink { + const queue: T[] = []; + const waiters: Array<(value: IteratorResult) => void> = []; + let closed = false; + const push = (value: T) => { + if (closed) return; + const waiter = waiters.shift(); + if (waiter) { + waiter({ value, done: false }); + return; + } + queue.push(value); + }; + const close = () => { + closed = true; + while (waiters.length > 0) { + const waiter = waiters.shift(); + waiter?.({ value: undefined as never, done: true }); + } + }; + const iterable: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + async next() { + if (queue.length > 0) { + return { value: queue.shift() as T, done: false }; + } + if (closed) { + return { value: undefined as never, done: true }; + } + return new Promise>((resolve) => { + waiters.push(resolve); + }); + }, + }; + }, + }; + return { push, close, iterable }; +} + +export class EffectFanout implements FanoutPort { + private readonly sinks = new Set>(); + + async publish(notification: StateUpdatedNotification): Promise { + for (const sink of this.sinks) { + sink.push(notification); + } + } + + async subscribe(): Promise> { + const sink = createSink(); + this.sinks.add(sink); + return sink.iterable; + } +} diff --git a/packages/core/src/application/projector.ts b/packages/core/src/application/projector.ts new file mode 100644 index 0000000000..501469d79f --- /dev/null +++ b/packages/core/src/application/projector.ts @@ -0,0 +1,51 @@ +import type { DomainEventEnvelope } from "../domain/events"; +import { emptyAppViewState, type AppViewState } from "../domain/models"; +import type { FanoutPort, ProjectionStorePort } from "../ports"; +import { reduceEvents } from "../projections/reducer"; + +export class QueueProjector { + private readonly queue: ReadonlyArray[] = []; + private processing = false; + private stopSignal = false; + + constructor( + private readonly projectionStore: ProjectionStorePort, + private readonly fanout: FanoutPort, + ) {} + + async start(): Promise { + if (this.processing) return; + this.stopSignal = false; + this.processing = true; + void this.drain(); + } + + async stop(): Promise { + this.stopSignal = true; + this.processing = false; + } + + async enqueue(events: ReadonlyArray): Promise { + if (events.length === 0) return; + this.queue.push(events); + if (this.processing) { + void this.drain(); + } + } + + async readState(): Promise { + return (await this.projectionStore.readState()) ?? emptyAppViewState(); + } + + private async drain(): Promise { + if (!this.processing) return; + while (!this.stopSignal && this.queue.length > 0) { + const chunk = this.queue.shift(); + if (!chunk || chunk.length === 0) continue; + const current = (await this.projectionStore.readState()) ?? emptyAppViewState(); + const next = reduceEvents(chunk, current); + await this.projectionStore.writeState(next); + await this.fanout.publish({ state: next, events: chunk }); + } + } +} diff --git a/packages/core/src/domain/commands.ts b/packages/core/src/domain/commands.ts new file mode 100644 index 0000000000..f0df66ce31 --- /dev/null +++ b/packages/core/src/domain/commands.ts @@ -0,0 +1,158 @@ +import { Schema } from "effect"; + +import { ProjectScriptSchema, ProviderEventSchema, ProviderSessionViewSchema, RuntimeModeSchema } from "./models"; + +export const CommandTypeSchema = Schema.Literal( + "app.bootstrap", + "project.add", + "project.remove", + "project.updateScripts", + "thread.create", + "thread.delete", + "thread.addUserMessage", + "thread.updateProviderSession", + "thread.recordProviderEvent", + "thread.setBranch", + "thread.setTerminalActivity", + "thread.markVisited", + "runtime.setMode", +); +export type CommandType = Schema.Schema.Type; + +export interface DomainCommand { + readonly id: string; + readonly type: CommandType; + readonly payload: TPayload; + readonly issuedAt: string; + readonly correlationId?: string; +} + +export interface AppBootstrapCommand + extends DomainCommand<{ + cwd: string; + projectName: string; + }> { + readonly type: "app.bootstrap"; +} + +export interface ProjectAddCommand + extends DomainCommand<{ + id: string; + name: string; + cwd: string; + model: string; + scripts: Schema.Schema.Type[]; + }> { + readonly type: "project.add"; +} + +export interface ProjectRemoveCommand extends DomainCommand<{ id: string }> { + readonly type: "project.remove"; +} + +export interface ProjectUpdateScriptsCommand + extends DomainCommand<{ + id: string; + scripts: Schema.Schema.Type[]; + }> { + readonly type: "project.updateScripts"; +} + +export interface ThreadCreateCommand + extends DomainCommand<{ + id: string; + projectId: string; + title: string; + model: string; + createdAt: string; + branch: string | null; + worktreePath: string | null; + }> { + readonly type: "thread.create"; +} + +export interface ThreadDeleteCommand extends DomainCommand<{ id: string }> { + readonly type: "thread.delete"; +} + +export interface ThreadAddUserMessageCommand + extends DomainCommand<{ + threadId: string; + messageId: string; + text: string; + createdAt: string; + attachments?: Array<{ + type: "image"; + id: string; + name: string; + mimeType: string; + sizeBytes: number; + previewUrl?: string; + }>; + }> { + readonly type: "thread.addUserMessage"; +} + +export interface ThreadUpdateProviderSessionCommand + extends DomainCommand<{ + threadId: string; + session: Schema.Schema.Type | null; + }> { + readonly type: "thread.updateProviderSession"; +} + +export interface ThreadRecordProviderEventCommand + extends DomainCommand<{ + threadId: string; + event: Schema.Schema.Type; + }> { + readonly type: "thread.recordProviderEvent"; +} + +export interface ThreadSetBranchCommand + extends DomainCommand<{ + threadId: string; + branch: string | null; + worktreePath: string | null; + }> { + readonly type: "thread.setBranch"; +} + +export interface ThreadSetTerminalActivityCommand + extends DomainCommand<{ + threadId: string; + terminalId: string; + running: boolean; + }> { + readonly type: "thread.setTerminalActivity"; +} + +export interface ThreadMarkVisitedCommand + extends DomainCommand<{ + threadId: string; + visitedAt: string; + }> { + readonly type: "thread.markVisited"; +} + +export interface RuntimeSetModeCommand + extends DomainCommand<{ + mode: Schema.Schema.Type; + }> { + readonly type: "runtime.setMode"; +} + +export type AnyDomainCommand = + | AppBootstrapCommand + | ProjectAddCommand + | ProjectRemoveCommand + | ProjectUpdateScriptsCommand + | ThreadCreateCommand + | ThreadDeleteCommand + | ThreadAddUserMessageCommand + | ThreadUpdateProviderSessionCommand + | ThreadRecordProviderEventCommand + | ThreadSetBranchCommand + | ThreadSetTerminalActivityCommand + | ThreadMarkVisitedCommand + | RuntimeSetModeCommand; diff --git a/packages/core/src/domain/events.ts b/packages/core/src/domain/events.ts new file mode 100644 index 0000000000..e6550831c1 --- /dev/null +++ b/packages/core/src/domain/events.ts @@ -0,0 +1,149 @@ +import { Schema } from "effect"; + +import { + type AppViewState, + ProjectScriptSchema, + ProviderEventSchema, + ProviderSessionViewSchema, + RuntimeModeSchema, +} from "./models"; + +export const DomainEventTypeSchema = Schema.Literal( + "app.bootstrapped", + "project.added", + "project.removed", + "project.scriptsUpdated", + "thread.created", + "thread.deleted", + "thread.userMessageAdded", + "thread.providerSessionUpdated", + "thread.providerEventRecorded", + "thread.branchSet", + "thread.terminalActivitySet", + "thread.markVisited", + "runtime.modeSet", +); +export type DomainEventType = Schema.Schema.Type; + +const AppBootstrappedPayloadSchema = Schema.Struct({ + cwd: Schema.String, + projectName: Schema.String, +}); + +const ProjectAddedPayloadSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + cwd: Schema.String, + model: Schema.String, + scripts: Schema.Array(ProjectScriptSchema), +}); + +const ProjectRemovedPayloadSchema = Schema.Struct({ id: Schema.String }); + +const ProjectScriptsUpdatedPayloadSchema = Schema.Struct({ + id: Schema.String, + scripts: Schema.Array(ProjectScriptSchema), +}); + +const ThreadCreatedPayloadSchema = Schema.Struct({ + id: Schema.String, + projectId: Schema.String, + title: Schema.String, + model: Schema.String, + createdAt: Schema.String, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), +}); + +const ThreadDeletedPayloadSchema = Schema.Struct({ id: Schema.String }); + +const ThreadUserMessageAddedPayloadSchema = Schema.Struct({ + threadId: Schema.String, + messageId: Schema.String, + text: Schema.String, + createdAt: Schema.String, + attachments: Schema.optional( + Schema.Array( + Schema.Struct({ + type: Schema.Literal("image"), + id: Schema.String, + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + previewUrl: Schema.optional(Schema.String), + }), + ), + ), +}); + +const ThreadProviderSessionUpdatedPayloadSchema = Schema.Struct({ + threadId: Schema.String, + session: Schema.NullOr(ProviderSessionViewSchema), +}); + +const ThreadProviderEventRecordedPayloadSchema = Schema.Struct({ + threadId: Schema.String, + event: ProviderEventSchema, +}); + +const ThreadBranchSetPayloadSchema = Schema.Struct({ + threadId: Schema.String, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), +}); + +const ThreadTerminalActivitySetPayloadSchema = Schema.Struct({ + threadId: Schema.String, + terminalId: Schema.String, + running: Schema.Boolean, +}); + +const ThreadMarkVisitedPayloadSchema = Schema.Struct({ + threadId: Schema.String, + visitedAt: Schema.String, +}); + +const RuntimeModeSetPayloadSchema = Schema.Struct({ + mode: RuntimeModeSchema, +}); + +export type DomainEventPayload = + | Schema.Schema.Type + | Schema.Schema.Type + | Schema.Schema.Type + | Schema.Schema.Type + | Schema.Schema.Type + | Schema.Schema.Type + | Schema.Schema.Type + | Schema.Schema.Type + | Schema.Schema.Type + | Schema.Schema.Type + | Schema.Schema.Type + | Schema.Schema.Type + | Schema.Schema.Type; + +export interface DomainEventEnvelope { + readonly eventId: string; + readonly streamId: string; + readonly sequence: number; + readonly position: number; + readonly type: DomainEventType; + readonly occurredAt: string; + readonly payload: DomainEventPayload; + readonly causationId?: string; + readonly correlationId?: string; +} + +export interface NewDomainEvent { + readonly streamId: string; + readonly type: DomainEventType; + readonly payload: DomainEventPayload; + readonly occurredAt: string; + readonly causationId?: string; + readonly correlationId?: string; +} + +export interface StateUpdatedNotification { + readonly state: AppViewState; + readonly events: readonly DomainEventEnvelope[]; +} diff --git a/packages/core/src/domain/models.ts b/packages/core/src/domain/models.ts new file mode 100644 index 0000000000..1d57901fb2 --- /dev/null +++ b/packages/core/src/domain/models.ts @@ -0,0 +1,152 @@ +import { Schema } from "effect"; + +export const RuntimeModeSchema = Schema.Literal("approval-required", "full-access"); +export type RuntimeMode = Schema.Schema.Type; + +export const ProjectScriptSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + command: Schema.String, + keybinding: Schema.optional(Schema.String), +}); +export type ProjectScript = Schema.Schema.Type; + +export const ProjectViewSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + cwd: Schema.String, + model: Schema.String, + expanded: Schema.Boolean, + scripts: Schema.Array(ProjectScriptSchema), +}); +export type ProjectView = Schema.Schema.Type; + +export const ChatAttachmentSchema = Schema.Struct({ + type: Schema.Literal("image"), + id: Schema.String, + name: Schema.String, + mimeType: Schema.String, + sizeBytes: Schema.Number, + previewUrl: Schema.optional(Schema.String), +}); +export type ChatAttachment = Schema.Schema.Type; + +export const ChatMessageSchema = Schema.Struct({ + id: Schema.String, + role: Schema.Literal("user", "assistant"), + text: Schema.String, + attachments: Schema.optional(Schema.Array(ChatAttachmentSchema)), + createdAt: Schema.String, + streaming: Schema.Boolean, +}); +export type ChatMessage = Schema.Schema.Type; + +export const SessionStatusSchema = Schema.Literal( + "connecting", + "ready", + "running", + "error", + "closed", +); +export type SessionStatus = Schema.Schema.Type; + +export const ProviderSessionViewSchema = Schema.Struct({ + sessionId: Schema.String, + provider: Schema.Literal("codex", "claudeCode"), + status: SessionStatusSchema, + cwd: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + threadId: Schema.optional(Schema.String), + activeTurnId: Schema.optional(Schema.String), + createdAt: Schema.String, + updatedAt: Schema.String, + lastError: Schema.optional(Schema.String), +}); +export type ProviderSessionView = Schema.Schema.Type; + +export const ProviderEventSchema = Schema.Struct({ + id: Schema.String, + kind: Schema.Literal("session", "notification", "request", "error"), + method: Schema.String, + payload: Schema.optional(Schema.Unknown), + createdAt: Schema.String, + sessionId: Schema.String, + threadId: Schema.optional(Schema.String), + turnId: Schema.optional(Schema.String), + itemId: Schema.optional(Schema.String), + textDelta: Schema.optional(Schema.String), + requestId: Schema.optional(Schema.String), + requestKind: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), +}); +export type ProviderEvent = Schema.Schema.Type; + +export const TurnDiffFileChangeSchema = Schema.Struct({ + path: Schema.String, + kind: Schema.optional(Schema.String), + additions: Schema.optional(Schema.Number), + deletions: Schema.optional(Schema.Number), +}); +export type TurnDiffFileChange = Schema.Schema.Type; + +export const TurnDiffSummarySchema = Schema.Struct({ + turnId: Schema.String, + completedAt: Schema.String, + status: Schema.optional(Schema.String), + files: Schema.Array(TurnDiffFileChangeSchema), + assistantMessageId: Schema.optional(Schema.String), + checkpointTurnCount: Schema.optional(Schema.Number), +}); +export type TurnDiffSummary = Schema.Schema.Type; + +export const ThreadTerminalGroupSchema = Schema.Struct({ + id: Schema.String, + terminalIds: Schema.Array(Schema.String), +}); +export type ThreadTerminalGroup = Schema.Schema.Type; + +export const ThreadViewSchema = Schema.Struct({ + id: Schema.String, + codexThreadId: Schema.NullOr(Schema.String), + projectId: Schema.String, + title: Schema.String, + model: Schema.String, + terminalOpen: Schema.Boolean, + terminalHeight: Schema.Number, + terminalIds: Schema.Array(Schema.String), + runningTerminalIds: Schema.Array(Schema.String), + activeTerminalId: Schema.String, + terminalGroups: Schema.Array(ThreadTerminalGroupSchema), + activeTerminalGroupId: Schema.String, + session: Schema.NullOr(ProviderSessionViewSchema), + messages: Schema.Array(ChatMessageSchema), + events: Schema.Array(ProviderEventSchema), + error: Schema.NullOr(Schema.String), + createdAt: Schema.String, + latestTurnId: Schema.optional(Schema.String), + latestTurnStartedAt: Schema.optional(Schema.String), + latestTurnCompletedAt: Schema.optional(Schema.String), + latestTurnDurationMs: Schema.optional(Schema.Number), + lastVisitedAt: Schema.optional(Schema.String), + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + turnDiffSummaries: Schema.Array(TurnDiffSummarySchema), +}); +export type ThreadView = Schema.Schema.Type; + +export const AppViewStateSchema = Schema.Struct({ + projects: Schema.Array(ProjectViewSchema), + threads: Schema.Array(ThreadViewSchema), + threadsHydrated: Schema.Boolean, + runtimeMode: RuntimeModeSchema, + lastPosition: Schema.Number, +}); +export type AppViewState = Schema.Schema.Type; + +export const emptyAppViewState = (): AppViewState => ({ + projects: [], + threads: [], + threadsHydrated: false, + runtimeMode: "full-access", + lastPosition: 0, +}); diff --git a/packages/core/src/domain/queries.ts b/packages/core/src/domain/queries.ts new file mode 100644 index 0000000000..f98e8ef16b --- /dev/null +++ b/packages/core/src/domain/queries.ts @@ -0,0 +1,7 @@ +import type { AppViewState } from "./models"; + +export type DomainQuery = { readonly type: "app.getState" }; + +export interface QueryResultMap { + "app.getState": AppViewState; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000000..b23719f46f --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,10 @@ +export * from "./domain/models"; +export * from "./domain/commands"; +export * from "./domain/events"; +export * from "./domain/queries"; +export * from "./ports"; +export * from "./projections/reducer"; +export * from "./application/decide"; +export * from "./application/fanout"; +export * from "./application/projector"; +export * from "./application/engine"; diff --git a/packages/core/src/ports/index.ts b/packages/core/src/ports/index.ts new file mode 100644 index 0000000000..d364e29c92 --- /dev/null +++ b/packages/core/src/ports/index.ts @@ -0,0 +1,30 @@ +import type { AnyDomainCommand } from "../domain/commands"; +import type { DomainEventEnvelope, NewDomainEvent, StateUpdatedNotification } from "../domain/events"; +import type { AppViewState } from "../domain/models"; + +export interface EventStorePort { + append(events: ReadonlyArray): Promise>; + loadAll(): Promise>; + loadAfterPosition(position: number): Promise>; +} + +export interface ProjectionStorePort { + readState(): Promise; + writeState(state: AppViewState): Promise; +} + +export interface FanoutPort { + publish(notification: StateUpdatedNotification): Promise; + subscribe(): Promise>; +} + +export interface DeterministicProjectorPort { + start(): Promise; + stop(): Promise; + enqueue(events: ReadonlyArray): Promise; +} + +export interface CoreApplicationPort { + execute(command: AnyDomainCommand): Promise; + currentState(): Promise; +} diff --git a/packages/core/src/projections/reducer.ts b/packages/core/src/projections/reducer.ts new file mode 100644 index 0000000000..a734f1b353 --- /dev/null +++ b/packages/core/src/projections/reducer.ts @@ -0,0 +1,370 @@ +import type { DomainEventEnvelope } from "../domain/events"; +import type { AppViewState, ChatMessage, ProviderEvent, ProviderSessionView, ThreadView } from "../domain/models"; +import { emptyAppViewState } from "../domain/models"; + +function asObject(value: unknown): Record | undefined { + return value && typeof value === "object" ? (value as Record) : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function eventTurnId(event: ProviderEvent): string | undefined { + if (event.turnId) return event.turnId; + const turn = asObject(asObject(event.payload)?.turn); + return asString(turn?.id); +} + +function durationMs(startIso: string, endIso: string): number | undefined { + const start = Date.parse(startIso); + const end = Date.parse(endIso); + if (Number.isNaN(start) || Number.isNaN(end) || end < start) return undefined; + return end - start; +} + +function evolveSession(previous: ProviderSessionView, event: ProviderEvent): ProviderSessionView { + const payload = asObject(event.payload); + if (event.method === "thread/started") { + const thread = asObject(payload?.thread); + return { ...previous, threadId: asString(thread?.id) ?? event.threadId ?? previous.threadId, updatedAt: event.createdAt }; + } + if (event.method === "turn/started") { + const turn = asObject(payload?.turn); + return { + ...previous, + status: "running", + activeTurnId: asString(turn?.id) ?? event.turnId ?? previous.activeTurnId, + updatedAt: event.createdAt, + }; + } + if (event.method === "turn/completed") { + const turn = asObject(payload?.turn); + const status = asString(turn?.status); + const turnError = asObject(turn?.error); + return { + ...previous, + status: status === "failed" ? "error" : "ready", + activeTurnId: undefined, + lastError: asString(turnError?.message) ?? previous.lastError, + updatedAt: event.createdAt, + }; + } + if (event.kind === "error") { + return { + ...previous, + status: "error", + lastError: event.message ?? previous.lastError, + updatedAt: event.createdAt, + }; + } + if (event.method === "session/closed" || event.method === "session/exited") { + return { + ...previous, + status: "closed", + activeTurnId: undefined, + lastError: event.message ?? previous.lastError, + updatedAt: event.createdAt, + }; + } + return { ...previous, updatedAt: event.createdAt }; +} + +function applyEventToMessages(previous: ReadonlyArray, event: ProviderEvent): ChatMessage[] { + const payload = asObject(event.payload); + if (event.method === "item/started") { + const item = asObject(payload?.item); + if (asString(item?.type) !== "agentMessage") return [...previous]; + const itemId = asString(item?.id); + if (!itemId) return [...previous]; + const seedText = asString(item?.text) ?? ""; + return [ + ...previous.filter((entry) => entry.id !== itemId), + { id: itemId, role: "assistant", text: seedText, createdAt: event.createdAt, streaming: true }, + ]; + } + if (event.method === "item/agentMessage/delta") { + const itemId = event.itemId ?? asString(payload?.itemId); + const delta = event.textDelta ?? asString(payload?.delta) ?? ""; + if (!itemId || !delta) return [...previous]; + const idx = previous.findIndex((entry) => entry.id === itemId); + if (idx < 0) { + return [...previous, { id: itemId, role: "assistant", text: delta, createdAt: event.createdAt, streaming: true }]; + } + const next = [...previous]; + const current = next[idx]; + if (!current) return [...previous]; + next[idx] = { ...current, text: `${current.text}${delta}`, streaming: true }; + return next; + } + if (event.method === "item/completed") { + const item = asObject(payload?.item); + if (asString(item?.type) !== "agentMessage") return [...previous]; + const itemId = asString(item?.id); + if (!itemId) return [...previous]; + const fullText = asString(item?.text); + const idx = previous.findIndex((entry) => entry.id === itemId); + if (idx < 0) { + return [...previous, { id: itemId, role: "assistant", text: fullText ?? "", createdAt: event.createdAt, streaming: false }]; + } + const next = [...previous]; + const current = next[idx]; + if (!current) return [...previous]; + next[idx] = { ...current, text: fullText ?? current.text, streaming: false }; + return next; + } + if (event.method === "turn/completed") { + return previous.map((entry) => ({ ...entry, streaming: false })); + } + return [...previous]; +} + +function defaultThread(payload: { + id: string; + projectId: string; + title: string; + model: string; + createdAt: string; + branch: string | null; + worktreePath: string | null; +}): ThreadView { + return { + id: payload.id, + codexThreadId: null, + projectId: payload.projectId, + title: payload.title, + model: payload.model, + terminalOpen: false, + terminalHeight: 280, + terminalIds: ["default"], + runningTerminalIds: [], + activeTerminalId: "default", + terminalGroups: [{ id: "group-default", terminalIds: ["default"] }], + activeTerminalGroupId: "group-default", + session: null, + messages: [], + events: [], + error: null, + createdAt: payload.createdAt, + branch: payload.branch, + worktreePath: payload.worktreePath, + turnDiffSummaries: [], + }; +} + +export function applyDomainEvent(state: AppViewState, event: DomainEventEnvelope): AppViewState { + switch (event.type) { + case "app.bootstrapped": { + const payload = event.payload as { cwd: string; projectName: string }; + if (state.projects.length > 0) return { ...state, threadsHydrated: true, lastPosition: event.position }; + return { + ...state, + projects: [ + { + id: "bootstrap-project", + name: payload.projectName, + cwd: payload.cwd, + model: "gpt-5-codex", + expanded: true, + scripts: [], + }, + ], + threadsHydrated: true, + lastPosition: event.position, + }; + } + case "project.added": { + const payload = event.payload as { + id: string; + name: string; + cwd: string; + model: string; + scripts: Array<{ id: string; name: string; command: string; keybinding?: string }>; + }; + if (state.projects.some((project) => project.id === payload.id || project.cwd === payload.cwd)) { + return { ...state, lastPosition: event.position }; + } + return { + ...state, + projects: [...state.projects, { ...payload, expanded: true }], + lastPosition: event.position, + }; + } + case "project.removed": { + const payload = event.payload as { id: string }; + return { + ...state, + projects: state.projects.filter((project) => project.id !== payload.id), + threads: state.threads.filter((thread) => thread.projectId !== payload.id), + lastPosition: event.position, + }; + } + case "project.scriptsUpdated": { + const payload = event.payload as { + id: string; + scripts: Array<{ id: string; name: string; command: string; keybinding?: string }>; + }; + return { + ...state, + projects: state.projects.map((project) => + project.id === payload.id ? { ...project, scripts: payload.scripts } : project, + ), + lastPosition: event.position, + }; + } + case "thread.created": { + const payload = event.payload as { + id: string; + projectId: string; + title: string; + model: string; + createdAt: string; + branch: string | null; + worktreePath: string | null; + }; + if (state.threads.some((thread) => thread.id === payload.id)) { + return { ...state, lastPosition: event.position }; + } + return { ...state, threads: [...state.threads, defaultThread(payload)], lastPosition: event.position }; + } + case "thread.deleted": { + const payload = event.payload as { id: string }; + return { ...state, threads: state.threads.filter((thread) => thread.id !== payload.id), lastPosition: event.position }; + } + case "thread.userMessageAdded": { + const payload = event.payload as { + threadId: string; + messageId: string; + text: string; + createdAt: string; + attachments?: Array<{ + type: "image"; + id: string; + name: string; + mimeType: string; + sizeBytes: number; + previewUrl?: string; + }>; + }; + return { + ...state, + threads: state.threads.map((thread) => + thread.id === payload.threadId + ? { + ...thread, + messages: [ + ...thread.messages, + { + id: payload.messageId, + role: "user", + text: payload.text, + attachments: payload.attachments ? [...payload.attachments] : undefined, + createdAt: payload.createdAt, + streaming: false, + }, + ], + } + : thread, + ), + lastPosition: event.position, + }; + } + case "thread.providerSessionUpdated": { + const payload = event.payload as { threadId: string; session: ProviderSessionView | null }; + return { + ...state, + threads: state.threads.map((thread) => + thread.id === payload.threadId + ? { + ...thread, + session: payload.session, + codexThreadId: payload.session?.threadId ?? thread.codexThreadId, + ...(payload.session ? {} : { events: [], messages: [], turnDiffSummaries: [] }), + } + : thread, + ), + lastPosition: event.position, + }; + } + case "thread.providerEventRecorded": { + const payload = event.payload as { threadId: string; event: ProviderEvent }; + return { + ...state, + threads: state.threads.map((thread) => { + if (thread.id !== payload.threadId) return thread; + const nextEvents = [payload.event, ...thread.events]; + const nextSession = thread.session ? evolveSession(thread.session, payload.event) : thread.session; + const nextMessages = applyEventToMessages(thread.messages, payload.event); + const nextTurnId = payload.event.method === "turn/started" ? eventTurnId(payload.event) ?? thread.latestTurnId : thread.latestTurnId; + const nextTurnStartedAt = payload.event.method === "turn/started" ? payload.event.createdAt : thread.latestTurnStartedAt; + const nextTurnCompletedAt = payload.event.method === "turn/completed" ? payload.event.createdAt : thread.latestTurnCompletedAt; + const nextTurnDuration = + payload.event.method === "turn/completed" && thread.latestTurnStartedAt + ? durationMs(thread.latestTurnStartedAt, payload.event.createdAt) + : thread.latestTurnDurationMs; + return { + ...thread, + events: nextEvents, + messages: nextMessages, + session: nextSession, + error: payload.event.kind === "error" && payload.event.message ? payload.event.message : thread.error, + latestTurnId: nextTurnId, + latestTurnStartedAt: nextTurnStartedAt, + latestTurnCompletedAt: nextTurnCompletedAt, + latestTurnDurationMs: nextTurnDuration, + }; + }), + lastPosition: event.position, + }; + } + case "thread.branchSet": { + const payload = event.payload as { threadId: string; branch: string | null; worktreePath: string | null }; + return { + ...state, + threads: state.threads.map((thread) => + thread.id === payload.threadId + ? { ...thread, branch: payload.branch, worktreePath: payload.worktreePath } + : thread, + ), + lastPosition: event.position, + }; + } + case "thread.terminalActivitySet": { + const payload = event.payload as { threadId: string; terminalId: string; running: boolean }; + return { + ...state, + threads: state.threads.map((thread) => { + if (thread.id !== payload.threadId) return thread; + const running = new Set(thread.runningTerminalIds); + if (payload.running) running.add(payload.terminalId); + else running.delete(payload.terminalId); + const terminalIds = thread.terminalIds.includes(payload.terminalId) + ? thread.terminalIds + : [...thread.terminalIds, payload.terminalId]; + return { ...thread, terminalIds, runningTerminalIds: [...running] }; + }), + lastPosition: event.position, + }; + } + case "thread.markVisited": { + const payload = event.payload as { threadId: string; visitedAt: string }; + return { + ...state, + threads: state.threads.map((thread) => + thread.id === payload.threadId ? { ...thread, lastVisitedAt: payload.visitedAt } : thread, + ), + lastPosition: event.position, + }; + } + case "runtime.modeSet": { + const payload = event.payload as { mode: "approval-required" | "full-access" }; + return { ...state, runtimeMode: payload.mode, lastPosition: event.position }; + } + default: + return { ...state, lastPosition: event.position }; + } +} + +export function reduceEvents(events: ReadonlyArray, seed?: AppViewState): AppViewState { + return events.reduce((state, event) => applyDomainEvent(state, event), seed ?? emptyAppViewState()); +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000000..fcacec5855 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023"], + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/infra-sqlite/package.json b/packages/infra-sqlite/package.json new file mode 100644 index 0000000000..6f2e3688bd --- /dev/null +++ b/packages/infra-sqlite/package.json @@ -0,0 +1,29 @@ +{ + "name": "@t3tools/infra-sqlite", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "@effect/sql": "^0.49.0", + "@effect/sql-sqlite-node": "^0.50.1", + "@t3tools/core": "workspace:*", + "effect": "^3.18.4" + }, + "devDependencies": { + "@types/node": "^25.3.0", + "typescript": "^5.7.3", + "vitest": "^4.0.0" + } +} diff --git a/packages/infra-sqlite/src/index.ts b/packages/infra-sqlite/src/index.ts new file mode 100644 index 0000000000..002462b87c --- /dev/null +++ b/packages/infra-sqlite/src/index.ts @@ -0,0 +1 @@ +export * from "./sqliteStore"; diff --git a/packages/infra-sqlite/src/sqliteStore.ts b/packages/infra-sqlite/src/sqliteStore.ts new file mode 100644 index 0000000000..c499be1b10 --- /dev/null +++ b/packages/infra-sqlite/src/sqliteStore.ts @@ -0,0 +1,175 @@ +import fs from "node:fs"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import crypto from "node:crypto"; + +import type { AppViewState, DomainEventEnvelope, EventStorePort, NewDomainEvent, ProjectionStorePort } from "@t3tools/core"; + +interface EventRow { + event_id: string; + stream_id: string; + sequence: number; + position: number; + type: string; + occurred_at: string; + payload_json: string; + causation_id: string | null; + correlation_id: string | null; +} + +function ensureDir(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function parseState(raw: string | null): AppViewState | null { + if (!raw) return null; + return JSON.parse(raw) as AppViewState; +} + +export class SqliteEventStore implements EventStorePort { + constructor(private readonly db: DatabaseSync) {} + + async append(events: ReadonlyArray): Promise> { + if (events.length === 0) return []; + const out: DomainEventEnvelope[] = []; + this.db.exec("BEGIN IMMEDIATE TRANSACTION"); + try { + for (const event of events) { + const sequenceStmt = this.db.prepare( + "SELECT COALESCE(MAX(sequence), 0) + 1 AS nextSequence FROM events WHERE stream_id = ?", + ); + const sequenceRow = sequenceStmt.get(event.streamId) as { nextSequence: number }; + const insert = this.db.prepare( + [ + "INSERT INTO events(", + "event_id, stream_id, sequence, type, occurred_at, payload_json, causation_id, correlation_id", + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ].join(" "), + ); + const eventId = crypto.randomUUID(); + insert.run( + eventId, + event.streamId, + sequenceRow.nextSequence, + event.type, + event.occurredAt, + JSON.stringify(event.payload), + event.causationId ?? null, + event.correlationId ?? null, + ); + const positionRow = this.db + .prepare("SELECT position FROM events WHERE event_id = ?") + .get(eventId) as { position: number }; + out.push({ + eventId, + streamId: event.streamId, + sequence: sequenceRow.nextSequence, + position: positionRow.position, + type: event.type, + occurredAt: event.occurredAt, + payload: event.payload, + ...(event.causationId ? { causationId: event.causationId } : {}), + ...(event.correlationId ? { correlationId: event.correlationId } : {}), + }); + } + this.db.exec("COMMIT"); + return out; + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } + } + + async loadAll(): Promise> { + const stmt = this.db.prepare( + "SELECT event_id, stream_id, sequence, position, type, occurred_at, payload_json, causation_id, correlation_id FROM events ORDER BY position ASC", + ); + const rows = stmt.all() as unknown as EventRow[]; + return rows.map(mapEventRow); + } + + async loadAfterPosition(position: number): Promise> { + const stmt = this.db.prepare( + "SELECT event_id, stream_id, sequence, position, type, occurred_at, payload_json, causation_id, correlation_id FROM events WHERE position > ? ORDER BY position ASC", + ); + const rows = stmt.all(position) as unknown as EventRow[]; + return rows.map(mapEventRow); + } +} + +function mapEventRow(row: EventRow): DomainEventEnvelope { + return { + eventId: row.event_id, + streamId: row.stream_id, + sequence: row.sequence, + position: row.position, + type: row.type as DomainEventEnvelope["type"], + occurredAt: row.occurred_at, + payload: JSON.parse(row.payload_json) as DomainEventEnvelope["payload"], + ...(row.causation_id ? { causationId: row.causation_id } : {}), + ...(row.correlation_id ? { correlationId: row.correlation_id } : {}), + }; +} + +export class SqliteProjectionStore implements ProjectionStorePort { + constructor(private readonly db: DatabaseSync) {} + + async readState(): Promise { + const row = this.db + .prepare("SELECT state_json FROM projection_state WHERE projection_name = 'app_state'") + .get() as { state_json: string } | undefined; + return parseState(row?.state_json ?? null); + } + + async writeState(state: AppViewState): Promise { + this.db + .prepare( + [ + "INSERT INTO projection_state(projection_name, state_json, updated_at)", + "VALUES('app_state', ?, ?)", + "ON CONFLICT(projection_name) DO UPDATE SET", + "state_json = excluded.state_json,", + "updated_at = excluded.updated_at", + ].join(" "), + ) + .run(JSON.stringify(state), new Date().toISOString()); + } +} + +export function createSqliteStores(dbPath: string): { + db: DatabaseSync; + eventStore: SqliteEventStore; + projectionStore: SqliteProjectionStore; +} { + ensureDir(dbPath); + const db = new DatabaseSync(dbPath); + db.exec("PRAGMA journal_mode = WAL;"); + db.exec("PRAGMA foreign_keys = ON;"); + db.exec( + [ + "CREATE TABLE IF NOT EXISTS events(", + "position INTEGER PRIMARY KEY AUTOINCREMENT,", + "event_id TEXT NOT NULL UNIQUE,", + "stream_id TEXT NOT NULL,", + "sequence INTEGER NOT NULL,", + "type TEXT NOT NULL,", + "occurred_at TEXT NOT NULL,", + "payload_json TEXT NOT NULL,", + "causation_id TEXT,", + "correlation_id TEXT,", + "UNIQUE(stream_id, sequence)", + ");", + "CREATE INDEX IF NOT EXISTS idx_events_stream_position ON events(stream_id, position);", + "CREATE TABLE IF NOT EXISTS projection_state(", + "projection_name TEXT PRIMARY KEY,", + "state_json TEXT NOT NULL,", + "updated_at TEXT NOT NULL", + ");", + ].join(" "), + ); + return { + db, + eventStore: new SqliteEventStore(db), + projectionStore: new SqliteProjectionStore(db), + }; +} diff --git a/packages/infra-sqlite/tsconfig.json b/packages/infra-sqlite/tsconfig.json new file mode 100644 index 0000000000..fcacec5855 --- /dev/null +++ b/packages/infra-sqlite/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023"], + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}