From 2001d3947031f68b37aa04a09c881b32a2e17b4c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 21 Feb 2026 17:10:49 -0800 Subject: [PATCH 01/98] Add server-side orchestration engine with event sourcing Move state management from client to server using an event-sourced architecture: - Add SQLite-backed event store with append-only event log - Add orchestration engine with command queue, reducer, and read model - Add Effect-based PubSub for real-time read model updates to clients - Define orchestration schemas in contracts (commands, events, read model) - Simplify client-side store by removing persistence layer - Remove obsolete client-side persistence tests Co-Authored-By: Claude Opus 4.5 --- README.md | 4 +- apps/server/package.json | 3 + apps/server/src/index.ts | 1 + apps/server/src/orchestration/engine.test.ts | 119 +++ apps/server/src/orchestration/engine.ts | 274 +++++ apps/server/src/orchestration/eventStore.ts | 102 ++ apps/server/src/orchestration/reducer.ts | 217 ++++ .../src/orchestration/uiContractInventory.ts | 66 ++ apps/server/src/wsServer.test.ts | 38 +- apps/server/src/wsServer.ts | 197 +++- apps/web/src/components/BranchToolbar.tsx | 22 +- apps/web/src/components/ChatView.tsx | 178 ++-- apps/web/src/components/Sidebar.tsx | 111 +- apps/web/src/hooks/useNativeApi.ts | 2 +- apps/web/src/hooks/useTurnDiffSummaries.ts | 20 +- apps/web/src/nativeApi.ts | 18 + apps/web/src/persistenceSchema.test.ts | 507 --------- apps/web/src/persistenceSchema.ts | 399 ------- apps/web/src/routes/__root.tsx | 149 +-- apps/web/src/session-logic.test.ts | 935 ----------------- apps/web/src/session-logic.ts | 942 ++--------------- apps/web/src/store.test.ts | 983 ------------------ apps/web/src/store.ts | 593 ++--------- apps/web/src/threadFactory.ts | 1 - apps/web/src/types.ts | 2 - apps/web/src/worktreeCleanup.test.ts | 1 - apps/web/src/wsNativeApi.ts | 34 +- bun.lock | 74 ++ packages/contracts/package.json | 1 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 15 +- packages/contracts/src/orchestration.ts | 223 ++++ packages/contracts/src/ws.ts | 2 - 33 files changed, 1688 insertions(+), 4546 deletions(-) create mode 100644 apps/server/src/orchestration/engine.test.ts create mode 100644 apps/server/src/orchestration/engine.ts create mode 100644 apps/server/src/orchestration/eventStore.ts create mode 100644 apps/server/src/orchestration/reducer.ts create mode 100644 apps/server/src/orchestration/uiContractInventory.ts create mode 100644 apps/web/src/nativeApi.ts delete mode 100644 apps/web/src/persistenceSchema.test.ts delete mode 100644 apps/web/src/persistenceSchema.ts delete mode 100644 apps/web/src/session-logic.test.ts delete mode 100644 apps/web/src/store.test.ts create mode 100644 packages/contracts/src/orchestration.ts diff --git a/README.md b/README.md index 425a70e404101..0a863d8c42e8e 100644 --- a/README.md +++ b/README.md @@ -106,12 +106,12 @@ T3 Code has a global runtime mode switch in the chat toolbar: The web app communicates with the server via WebSocket using a simple JSON-RPC-style protocol: - **Request/Response**: `{ id, method, params }` → `{ id, result }` or `{ id, error }` -- **Push events**: `{ type: "push", channel, data }` for streaming provider events +- **Push events**: `{ type: "push", channel, data }` for orchestration read-model updates Methods mirror the `NativeApi` interface defined in `@t3tools/contracts`: - `providers.startSession`, `providers.sendTurn`, `providers.interruptTurn` -- `providers.respondToRequest`, `providers.stopSession`, `providers.listSessions` +- `providers.respondToRequest`, `providers.stopSession` - `shell.openInEditor`, `server.getConfig` Codex is the only implemented provider. `claudeCode` is reserved in contracts/UI. diff --git a/apps/server/package.json b/apps/server/package.json index 69ac42c480946..e8e6a978eb172 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,6 +17,8 @@ "test": "vitest run" }, "dependencies": { + "better-sqlite3": "^12.6.2", + "effect": "^3.19.19", "node-pty": "^1.1.0", "open": "^10.1.0", "ws": "^8.18.0" @@ -24,6 +26,7 @@ "devDependencies": { "@t3tools/contracts": "workspace:*", "@t3tools/web": "workspace:*", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.2", "@types/ws": "^8.5.13", "tsdown": "^0.20.3", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index d340657608777..159dbbc2a88d5 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -118,6 +118,7 @@ async function main() { port, host: mode === "desktop" ? "127.0.0.1" : undefined, cwd, + stateDir, staticDir, devUrl, projectRegistry, diff --git a/apps/server/src/orchestration/engine.test.ts b/apps/server/src/orchestration/engine.test.ts new file mode 100644 index 0000000000000..527ff57f6aeed --- /dev/null +++ b/apps/server/src/orchestration/engine.test.ts @@ -0,0 +1,119 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { OrchestrationEngine } from "./engine"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("OrchestrationEngine", () => { + it("replays to the same deterministic snapshot", async () => { + const stateDir = makeTempDir("t3code-orchestration-"); + const createdAt = new Date().toISOString(); + const projectId = "project-1"; + const threadId = "thread-1"; + + const engineA = new OrchestrationEngine(stateDir); + await engineA.start(); + await engineA.dispatch({ + type: "project.create", + commandId: "cmd-1", + projectId, + name: "demo", + cwd: "/tmp/demo", + model: "gpt-5-codex", + createdAt, + }); + await engineA.dispatch({ + type: "thread.create", + commandId: "cmd-2", + threadId, + projectId, + title: "Thread", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt, + }); + await engineA.dispatch({ + type: "message.send", + commandId: "cmd-3", + threadId, + messageId: "msg-1", + role: "user", + text: "hello", + streaming: false, + createdAt, + }); + const snapshotA = engineA.getSnapshot(); + await engineA.stop(); + + const engineB = new OrchestrationEngine(stateDir); + await engineB.start(); + const snapshotB = engineB.getSnapshot(); + expect(snapshotB).toEqual(snapshotA); + await engineB.stop(); + }); + + it("fans out read-model updates to subscribers", async () => { + const stateDir = makeTempDir("t3code-orchestration-fanout-"); + const engine = new OrchestrationEngine(stateDir); + await engine.start(); + const updates: number[] = []; + const unsubscribe = engine.subscribeToReadModel((snapshot) => { + updates.push(snapshot.sequence); + }); + await engine.dispatch({ + type: "project.create", + commandId: "cmd-project", + projectId: "project-2", + name: "fanout", + cwd: "/tmp/fanout", + model: "gpt-5-codex", + createdAt: new Date().toISOString(), + }); + unsubscribe(); + expect(updates.length).toBeGreaterThan(0); + await engine.stop(); + }); + + it("replays append-only events from sequence", async () => { + const stateDir = makeTempDir("t3code-orchestration-replay-"); + const engine = new OrchestrationEngine(stateDir); + await engine.start(); + await engine.dispatch({ + type: "project.create", + commandId: "cmd-a", + projectId: "project-replay", + name: "replay", + cwd: "/tmp/replay", + model: "gpt-5-codex", + createdAt: new Date().toISOString(), + }); + await engine.dispatch({ + type: "project.delete", + commandId: "cmd-b", + projectId: "project-replay", + createdAt: new Date().toISOString(), + }); + const events = await engine.replayEvents(0); + expect(events.length).toBe(2); + expect(events[0]?.type).toBe("project.created"); + expect(events[1]?.type).toBe("project.deleted"); + await engine.stop(); + }); +}); diff --git a/apps/server/src/orchestration/engine.ts b/apps/server/src/orchestration/engine.ts new file mode 100644 index 0000000000000..5b93c507a3f94 --- /dev/null +++ b/apps/server/src/orchestration/engine.ts @@ -0,0 +1,274 @@ +import path from "node:path"; + +import type { + OrchestrationCommand, + OrchestrationEvent, + OrchestrationReadModel, +} from "@t3tools/contracts"; +import { OrchestrationCommandSchema } from "@t3tools/contracts"; +import { PubSub, Queue, Schema, Stream, Effect, Fiber, Runtime, Either } from "effect"; + +import { createLogger } from "../logger"; +import { SqliteEventStore } from "./eventStore"; +import { createEmptyReadModel, reduceEvent } from "./reducer"; +import { UI_ENTITY_CONTRACTS } from "./uiContractInventory"; + +type CommandEnvelope = { + command: OrchestrationCommand; + resolve: (result: { sequence: number }) => void; + reject: (error: Error) => void; +}; + +function mapCommandToEvent(command: OrchestrationCommand): Omit { + const eventId = crypto.randomUUID(); + switch (command.type) { + case "project.create": + return { + eventId, + type: "project.created", + aggregateType: "project", + aggregateId: command.projectId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + id: command.projectId, + name: command.name, + cwd: command.cwd, + model: command.model, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + case "project.delete": + return { + eventId, + type: "project.deleted", + aggregateType: "project", + aggregateId: command.projectId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + id: command.projectId, + deletedAt: command.createdAt, + }, + }; + case "thread.create": + return { + eventId, + type: "thread.created", + aggregateType: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + id: command.threadId, + projectId: command.projectId, + title: command.title, + model: command.model, + branch: command.branch, + worktreePath: command.worktreePath, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + case "thread.delete": + return { + eventId, + type: "thread.deleted", + aggregateType: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + id: command.threadId, + deletedAt: command.createdAt, + }, + }; + case "thread.meta.update": + return { + eventId, + type: "thread.meta-updated", + aggregateType: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + threadId: command.threadId, + ...(command.title !== undefined ? { title: command.title } : {}), + ...(command.model !== undefined ? { model: command.model } : {}), + ...(command.branch !== undefined ? { branch: command.branch } : {}), + ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + updatedAt: command.createdAt, + }, + }; + case "message.send": + return { + eventId, + type: "message.sent", + aggregateType: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + id: command.messageId, + role: command.role, + text: command.text, + threadId: command.threadId, + createdAt: command.createdAt, + streaming: command.streaming === true, + }, + }; + case "thread.session": + return { + eventId, + type: "thread.session-set", + aggregateType: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + threadId: command.threadId, + session: command.session, + }, + }; + case "git.readModel.upsert": + return { + eventId, + type: "git.read-model-upsert", + aggregateType: "project", + aggregateId: command.projectId, + occurredAt: command.createdAt, + commandId: command.commandId, + payload: { + projectId: command.projectId, + branch: command.branch, + hasWorkingTreeChanges: command.hasWorkingTreeChanges, + aheadCount: command.aheadCount, + behindCount: command.behindCount, + updatedAt: command.createdAt, + }, + }; + } +} + +export class OrchestrationEngine { + private readonly logger = createLogger("orchestration"); + private readonly runtime = Runtime.defaultRuntime; + private readonly eventStore: SqliteEventStore; + + private readModel: OrchestrationReadModel; + private commandQueue: Queue.Queue; + private readModelPubSub: PubSub.PubSub; + private eventPubSub: PubSub.PubSub; + private workerFiber: Fiber.RuntimeFiber | null = null; + private readonly readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); + private readonly domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); + + constructor(stateDir: string) { + const dbPath = path.join(stateDir, "orchestration.sqlite"); + this.eventStore = new SqliteEventStore(dbPath); + this.readModel = createEmptyReadModel(new Date().toISOString()); + this.commandQueue = Runtime.runSync(this.runtime)(Queue.unbounded()); + this.readModelPubSub = Runtime.runSync(this.runtime)(PubSub.unbounded()); + this.eventPubSub = Runtime.runSync(this.runtime)(PubSub.unbounded()); + } + + async start(): Promise { + const existingEvents = await Runtime.runPromise(this.runtime)(this.eventStore.readAll()); + for (const event of existingEvents) { + this.readModel = reduceEvent(this.readModel, event); + } + + const worker = Stream.fromQueue(this.commandQueue).pipe( + Stream.runForEach((envelope) => + Effect.tryPromise({ + try: async () => { + const eventBase = mapCommandToEvent(envelope.command); + const savedEvent = await Runtime.runPromise(this.runtime)(this.eventStore.append(eventBase)); + this.readModel = reduceEvent(this.readModel, savedEvent); + await Promise.all([ + Runtime.runPromise(this.runtime)(PubSub.publish(this.eventPubSub, savedEvent)), + Runtime.runPromise(this.runtime)(PubSub.publish(this.readModelPubSub, this.readModel)), + ]); + for (const listener of this.domainEventListeners) { + listener(savedEvent); + } + for (const listener of this.readModelListeners) { + listener(this.readModel); + } + envelope.resolve({ sequence: savedEvent.sequence }); + }, + catch: (error) => { + const message = error instanceof Error ? error.message : "Unknown command processing error"; + envelope.reject(new Error(message)); + return undefined; + }, + }), + ), + ); + + this.workerFiber = Runtime.runFork(this.runtime)(worker); + this.logger.info("orchestration engine started", { + sequence: this.readModel.sequence, + contracts: UI_ENTITY_CONTRACTS.length, + }); + } + + async stop(): Promise { + if (this.workerFiber) { + await Runtime.runPromise(this.runtime)(Fiber.interrupt(this.workerFiber)); + this.workerFiber = null; + } + } + + getSnapshot(): OrchestrationReadModel { + return this.readModel; + } + + async replayEvents(fromSequenceExclusive: number): Promise { + return Runtime.runPromise(this.runtime)(this.eventStore.readFromSequence(fromSequenceExclusive)); + } + + async dispatchUnknown(command: unknown): Promise<{ sequence: number }> { + const decode = Schema.decodeUnknownEither(OrchestrationCommandSchema); + let payload: unknown = command; + if (typeof command === "string") { + try { + payload = JSON.parse(command) as unknown; + } catch { + throw new Error("Invalid orchestration command: payload is not valid JSON"); + } + } + const decoded = decode(payload); + if (Either.isLeft(decoded)) { + const issues = decoded.left.toString(); + throw new Error(`Invalid orchestration command: ${issues}`); + } + return this.dispatch(decoded.right); + } + + async dispatch(command: OrchestrationCommand): Promise<{ sequence: number }> { + return new Promise<{ sequence: number }>((resolve, reject) => { + Runtime.runPromise(this.runtime)( + Queue.offer(this.commandQueue, { command, resolve, reject }), + ).catch((error) => { + const message = error instanceof Error ? error.message : "Queue offer failed"; + reject(new Error(message)); + }); + }); + } + + subscribeToReadModel(callback: (snapshot: OrchestrationReadModel) => void): () => void { + this.readModelListeners.add(callback); + return () => { + this.readModelListeners.delete(callback); + }; + } + + subscribeToDomainEvents(callback: (event: OrchestrationEvent) => void): () => void { + this.domainEventListeners.add(callback); + return () => { + this.domainEventListeners.delete(callback); + }; + } +} diff --git a/apps/server/src/orchestration/eventStore.ts b/apps/server/src/orchestration/eventStore.ts new file mode 100644 index 0000000000000..bc54c9767d681 --- /dev/null +++ b/apps/server/src/orchestration/eventStore.ts @@ -0,0 +1,102 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { OrchestrationEvent } from "@t3tools/contracts"; +import Database from "better-sqlite3"; +import { Effect } from "effect"; + +interface EventRow { + sequence: number; + event_id: string; + event_type: string; + aggregate_type: string; + aggregate_id: string; + occurred_at: string; + command_id: string | null; + payload_json: string; +} + +export class SqliteEventStore { + private readonly db: Database.Database; + + constructor(dbPath: string) { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + this.db = new Database(dbPath); + this.db.pragma("journal_mode = WAL"); + this.db.pragma("foreign_keys = ON"); + this.migrate(); + } + + private migrate(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS orchestration_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + occurred_at TEXT NOT NULL, + command_id TEXT, + payload_json TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate + ON orchestration_events(aggregate_type, aggregate_id, sequence); + `); + } + + append(event: Omit): Effect.Effect { + return Effect.sync(() => { + const stmt = this.db.prepare(` + INSERT INTO orchestration_events ( + event_id, event_type, aggregate_type, aggregate_id, occurred_at, command_id, payload_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + const result = stmt.run( + event.eventId, + event.type, + event.aggregateType, + event.aggregateId, + event.occurredAt, + event.commandId, + JSON.stringify(event.payload), + ); + + return { + ...event, + sequence: Number(result.lastInsertRowid), + }; + }); + } + + readFromSequence(sequenceExclusive: number, limit = 1_000): Effect.Effect { + return Effect.sync(() => { + const rows = this.db + .prepare( + ` + SELECT sequence, event_id, event_type, aggregate_type, aggregate_id, occurred_at, command_id, payload_json + FROM orchestration_events + WHERE sequence > ? + ORDER BY sequence ASC + LIMIT ? + `, + ) + .all(sequenceExclusive, limit) as EventRow[]; + return rows.map((row) => ({ + sequence: row.sequence, + eventId: row.event_id, + type: row.event_type, + aggregateType: row.aggregate_type, + aggregateId: row.aggregate_id, + occurredAt: row.occurred_at, + commandId: row.command_id, + payload: JSON.parse(row.payload_json) as unknown, + })); + }); + } + + readAll(): Effect.Effect { + return this.readFromSequence(0, Number.MAX_SAFE_INTEGER); + } +} diff --git a/apps/server/src/orchestration/reducer.ts b/apps/server/src/orchestration/reducer.ts new file mode 100644 index 0000000000000..0aafdc2646bc3 --- /dev/null +++ b/apps/server/src/orchestration/reducer.ts @@ -0,0 +1,217 @@ +import type { + OrchestrationEvent, + OrchestrationGitReadModel, + OrchestrationMessage, + OrchestrationProject, + OrchestrationReadModel, + OrchestrationSession, + OrchestrationThread, +} from "@t3tools/contracts"; + +type ThreadPatch = Partial>; + +function updateThread( + threads: ReadonlyArray, + threadId: string, + patch: ThreadPatch, +): OrchestrationThread[] { + return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); +} + +function asObject(value: unknown): Record | null { + if (!value || typeof value !== "object") return null; + return value as Record; +} + +function asString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { + return { + sequence: 0, + projects: [], + threads: [], + gitByProjectId: {}, + updatedAt: nowIso, + }; +} + +export function reduceEvent( + model: OrchestrationReadModel, + event: OrchestrationEvent, +): OrchestrationReadModel { + const payload = asObject(event.payload); + const nextBase: OrchestrationReadModel = { + ...model, + sequence: event.sequence, + updatedAt: event.occurredAt, + }; + + switch (event.type) { + case "project.created": { + if (!payload) return nextBase; + const project: OrchestrationProject = { + id: asString(payload.id) ?? event.aggregateId, + name: asString(payload.name) ?? "project", + cwd: asString(payload.cwd) ?? "", + model: asString(payload.model) ?? "gpt-5-codex", + createdAt: asString(payload.createdAt) ?? event.occurredAt, + updatedAt: asString(payload.updatedAt) ?? event.occurredAt, + }; + const existing = nextBase.projects.find((entry) => entry.id === project.id); + return { + ...nextBase, + projects: existing + ? nextBase.projects.map((entry) => (entry.id === project.id ? project : entry)) + : [...nextBase.projects, project], + }; + } + case "project.deleted": + return { + ...nextBase, + projects: nextBase.projects.filter((project) => project.id !== event.aggregateId), + threads: nextBase.threads.filter((thread) => thread.projectId !== event.aggregateId), + }; + case "thread.created": { + if (!payload) return nextBase; + const thread: OrchestrationThread = { + id: asString(payload.id) ?? event.aggregateId, + projectId: asString(payload.projectId) ?? "", + title: asString(payload.title) ?? "New thread", + model: asString(payload.model) ?? "gpt-5-codex", + branch: asString(payload.branch), + worktreePath: asString(payload.worktreePath), + createdAt: asString(payload.createdAt) ?? event.occurredAt, + updatedAt: asString(payload.updatedAt) ?? event.occurredAt, + latestTurnId: null, + latestTurnStartedAt: null, + latestTurnCompletedAt: null, + latestTurnDurationMs: null, + messages: [], + session: null, + turnDiffSummaries: [], + error: null, + }; + const existing = nextBase.threads.find((entry) => entry.id === thread.id); + return { + ...nextBase, + threads: existing + ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry)) + : [...nextBase.threads, thread], + }; + } + case "thread.deleted": + return { + ...nextBase, + threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId), + }; + case "thread.meta-updated": { + if (!payload) return nextBase; + const threadId = asString(payload.threadId) ?? event.aggregateId; + const thread = nextBase.threads.find((entry) => entry.id === threadId); + if (!thread) return nextBase; + return { + ...nextBase, + threads: updateThread(nextBase.threads, threadId, { + ...(asString(payload.title) !== null ? { title: asString(payload.title) ?? thread.title } : {}), + ...(asString(payload.model) !== null ? { model: asString(payload.model) ?? thread.model } : {}), + ...(payload.branch !== undefined ? { branch: asString(payload.branch) } : {}), + ...(payload.worktreePath !== undefined ? { worktreePath: asString(payload.worktreePath) } : {}), + updatedAt: asString(payload.updatedAt) ?? event.occurredAt, + }), + }; + } + case "message.sent": { + if (!payload) return nextBase; + const threadId = asString(payload.threadId) ?? event.aggregateId; + const message: OrchestrationMessage = { + id: asString(payload.id) ?? crypto.randomUUID(), + role: (asString(payload.role) as OrchestrationMessage["role"] | null) ?? "user", + text: asString(payload.text) ?? "", + createdAt: asString(payload.createdAt) ?? event.occurredAt, + streaming: payload.streaming === true, + }; + const targetThread = nextBase.threads.find((thread) => thread.id === threadId); + if (!targetThread) return nextBase; + const existingMessage = targetThread.messages.find((entry) => entry.id === message.id); + const nextMessages = existingMessage + ? targetThread.messages.map((entry) => + entry.id === message.id + ? { + ...entry, + text: + message.streaming + ? `${entry.text}${message.text}` + : message.text.length > 0 + ? message.text + : entry.text, + streaming: message.streaming, + createdAt: message.createdAt, + } + : entry, + ) + : [...targetThread.messages, message]; + return { + ...nextBase, + threads: updateThread(nextBase.threads, threadId, { + messages: nextMessages, + updatedAt: event.occurredAt, + }), + }; + } + case "thread.session-set": { + if (!payload) return nextBase; + const threadId = asString(payload.threadId) ?? event.aggregateId; + const thread = nextBase.threads.find((entry) => entry.id === threadId); + if (!thread) return nextBase; + const sessionPayload = asObject(payload.session); + if (!sessionPayload) return nextBase; + const session: OrchestrationSession = { + sessionId: asString(sessionPayload.sessionId) ?? crypto.randomUUID(), + status: + (asString(sessionPayload.status) as OrchestrationSession["status"] | null) ?? "connecting", + provider: + (asString(sessionPayload.provider) as OrchestrationSession["provider"] | null) ?? "codex", + threadId, + activeTurnId: asString(sessionPayload.activeTurnId), + createdAt: asString(sessionPayload.createdAt) ?? event.occurredAt, + updatedAt: asString(sessionPayload.updatedAt) ?? event.occurredAt, + lastError: asString(sessionPayload.lastError), + }; + return { + ...nextBase, + threads: updateThread(nextBase.threads, threadId, { + session, + updatedAt: event.occurredAt, + error: session.lastError, + }), + }; + } + case "git.read-model-upsert": { + if (!payload) return nextBase; + const projectId = asString(payload.projectId) ?? event.aggregateId; + const gitReadModel: OrchestrationGitReadModel = { + projectId, + branch: asString(payload.branch), + hasWorkingTreeChanges: payload.hasWorkingTreeChanges === true, + aheadCount: asNumber(payload.aheadCount) ?? 0, + behindCount: asNumber(payload.behindCount) ?? 0, + updatedAt: asString(payload.updatedAt) ?? event.occurredAt, + }; + return { + ...nextBase, + gitByProjectId: { + ...nextBase.gitByProjectId, + [projectId]: gitReadModel, + }, + }; + } + default: + return nextBase; + } +} diff --git a/apps/server/src/orchestration/uiContractInventory.ts b/apps/server/src/orchestration/uiContractInventory.ts new file mode 100644 index 0000000000000..d138b5871245e --- /dev/null +++ b/apps/server/src/orchestration/uiContractInventory.ts @@ -0,0 +1,66 @@ +export interface UiEntityContract { + entity: string; + requiredFields: string[]; + consumedBy: string[]; +} + +// Explicit UI inventory used to keep server read models aligned +// with what the current frontend renders. +export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [ + { + entity: "Project", + requiredFields: ["id", "name", "cwd", "model"], + consumedBy: ["apps/web/src/components/Sidebar.tsx"], + }, + { + entity: "Thread", + requiredFields: [ + "id", + "projectId", + "title", + "model", + "branch", + "worktreePath", + "createdAt", + "latestTurnId", + "latestTurnStartedAt", + "latestTurnCompletedAt", + "latestTurnDurationMs", + "error", + ], + consumedBy: [ + "apps/web/src/components/Sidebar.tsx", + "apps/web/src/components/ChatView.tsx", + "apps/web/src/components/BranchToolbar.tsx", + ], + }, + { + entity: "Message", + requiredFields: ["id", "role", "text", "createdAt", "streaming"], + consumedBy: ["apps/web/src/components/ChatView.tsx"], + }, + { + entity: "ProviderSession", + requiredFields: [ + "sessionId", + "provider", + "status", + "threadId", + "activeTurnId", + "createdAt", + "updatedAt", + "lastError", + ], + consumedBy: ["apps/web/src/components/ChatView.tsx", "apps/web/src/components/Sidebar.tsx"], + }, + { + entity: "TurnDiffSummary", + requiredFields: ["turnId", "completedAt", "status", "files"], + consumedBy: ["apps/web/src/components/DiffPanel.tsx", "apps/web/src/components/ChatView.tsx"], + }, + { + entity: "GitReadModel", + requiredFields: ["projectId", "branch", "hasWorkingTreeChanges", "aheadCount", "behindCount"], + consumedBy: ["apps/web/src/components/GitActionsControl.tsx", "apps/web/src/components/BranchToolbar.tsx"], + }, +]; diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 48782bd26e404..a9b9cc110241b 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -621,7 +621,8 @@ describe("WebSocket Server", () => { const ws = await connectWs(port); connections.push(ws); - // Consume welcome + // Consume welcome + initial orchestration snapshot pushes + await waitForMessage(ws); await waitForMessage(ws); const response = await sendRequest(ws, "nonexistent.method"); @@ -629,23 +630,6 @@ describe("WebSocket Server", () => { expect(response.error!.message).toContain("Unknown method"); }); - it("responds to providers.listSessions with empty array", async () => { - server = createTestServer({ cwd: "/test" }); - await server.start(); - const addr = server.httpServer.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - - const ws = await connectWs(port); - connections.push(ws); - - // Consume welcome - await waitForMessage(ws); - - const response = await sendRequest(ws, WS_METHODS.providersListSessions); - expect(response.error).toBeUndefined(); - expect(response.result).toEqual([]); - }); - it("returns unknown-session errors for checkpoint RPCs without an active provider session", async () => { server = createTestServer({ cwd: "/test" }); await server.start(); @@ -800,9 +784,21 @@ describe("WebSocket Server", () => { // Send garbage ws.send("not json at all"); - const response = (await waitForMessage(ws)) as WsResponse; - expect(response.error).toBeDefined(); - expect(response.error!.message).toContain("Invalid request format"); + let response: WsResponse | null = null; + for (let attempt = 0; attempt < 5; attempt += 1) { + const message = (await waitForMessage(ws)) as Record; + if (typeof message.id === "string" && message.id === "unknown") { + response = message as WsResponse; + break; + } + if (message.error) { + response = message as WsResponse; + break; + } + } + expect(response).toBeDefined(); + expect(response!.error).toBeDefined(); + expect(response!.error!.message).toContain("Invalid request format"); }); it("supports projects list/add/dedupe/remove", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 325727827d2fb..a4cfef7dad2d1 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -7,8 +7,11 @@ import type { Duplex } from "node:stream"; import { EDITORS, + ORCHESTRATION_WS_CHANNELS, + ORCHESTRATION_WS_METHODS, WS_CHANNELS, WS_METHODS, + type OrchestrationReadModelPush, type TerminalEvent, type WsPush, type WsRequest, @@ -33,6 +36,7 @@ import { import { TerminalManager } from "./terminalManager"; import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; +import { OrchestrationEngine } from "./orchestration/engine"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -53,6 +57,7 @@ export interface ServerOptions { port: number; host?: string | undefined; cwd: string; + stateDir?: string | undefined; staticDir?: string | undefined; devUrl?: string | undefined; logWebSocketEvents?: boolean | undefined; @@ -75,6 +80,7 @@ export function createServer(options: ServerOptions) { port, host, cwd, + stateDir, staticDir, devUrl, logWebSocketEvents: explicitLogWsEvents, @@ -88,6 +94,9 @@ export function createServer(options: ServerOptions) { const projectRegistry = providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); const gitManager = providedGitManager ?? new GitManager(); + const orchestrationEngine = new OrchestrationEngine( + stateDir ?? path.join(os.homedir(), ".t3", "userdata"), + ); const clients = new Set(); const logger = createLogger("ws"); const logWebSocketEvents = @@ -103,11 +112,30 @@ export function createServer(options: ServerOptions) { }); } - // Forward provider events to all connected WebSocket clients - providerManager.on("event", (event) => { + const unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => { const push: WsPush = { type: "push", - channel: WS_CHANNELS.providerEvent, + channel: ORCHESTRATION_WS_CHANNELS.readModel, + data: { + sequence: snapshot.sequence, + snapshot, + } satisfies OrchestrationReadModelPush, + }; + const message = JSON.stringify(push); + let recipients = 0; + for (const client of clients) { + if (client.readyState === client.OPEN) { + client.send(message); + recipients += 1; + } + } + logOutgoingPush(push, recipients); + }); + + const unsubscribeDomainEvents = orchestrationEngine.subscribeToDomainEvents((event) => { + const push: WsPush = { + type: "push", + channel: ORCHESTRATION_WS_CHANNELS.domainEvent, data: event, }; const message = JSON.stringify(push); @@ -121,6 +149,78 @@ export function createServer(options: ServerOptions) { logOutgoingPush(push, recipients); }); + // Forward provider events to all connected WebSocket clients + providerManager.on("event", (event) => { + void (async () => { + const snapshot = orchestrationEngine.getSnapshot(); + const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId); + if (!thread) return; + const now = event.createdAt; + if (event.method === "turn/started" || event.method === "turn/completed") { + await orchestrationEngine.dispatch({ + type: "thread.session", + commandId: crypto.randomUUID(), + threadId: thread.id, + session: { + sessionId: event.sessionId, + provider: event.provider, + status: event.method === "turn/started" ? "running" : "ready", + threadId: thread.id, + activeTurnId: event.method === "turn/started" ? (event.turnId ?? null) : null, + createdAt: thread.session?.createdAt ?? now, + updatedAt: now, + lastError: null, + }, + createdAt: now, + }); + } + if (event.textDelta && event.textDelta.length > 0) { + const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; + await orchestrationEngine.dispatch({ + type: "message.send", + commandId: crypto.randomUUID(), + threadId: thread.id, + messageId: assistantMessageId, + role: "assistant", + text: event.textDelta, + streaming: true, + createdAt: now, + }); + } + if (event.method === "turn/completed") { + const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; + await orchestrationEngine.dispatch({ + type: "message.send", + commandId: crypto.randomUUID(), + threadId: thread.id, + messageId: assistantMessageId, + role: "assistant", + text: "", + streaming: false, + createdAt: now, + }); + } + if (event.kind === "error" && event.message) { + await orchestrationEngine.dispatch({ + type: "thread.session", + commandId: crypto.randomUUID(), + threadId: thread.id, + session: { + sessionId: event.sessionId, + provider: event.provider, + status: "error", + threadId: thread.id, + activeTurnId: event.turnId ?? null, + createdAt: thread.session?.createdAt ?? now, + updatedAt: now, + lastError: event.message, + }, + createdAt: now, + }); + } + })().catch(() => undefined); + }); + const onTerminalEvent = (event: TerminalEvent) => { const push: WsPush = { type: "push", @@ -247,6 +347,17 @@ export function createServer(options: ServerOptions) { logOutgoingPush(welcome, 1); ws.send(JSON.stringify(welcome)); + const snapshotPush: WsPush = { + type: "push", + channel: ORCHESTRATION_WS_CHANNELS.readModel, + data: { + sequence: orchestrationEngine.getSnapshot().sequence, + snapshot: orchestrationEngine.getSnapshot(), + } satisfies OrchestrationReadModelPush, + }; + logOutgoingPush(snapshotPush, 1); + ws.send(JSON.stringify(snapshotPush)); + ws.on("message", (raw) => { void handleMessage(ws, raw); }); @@ -307,9 +418,6 @@ export function createServer(options: ServerOptions) { return undefined; } - case WS_METHODS.providersListSessions: - return providerManager.listSessions(); - case WS_METHODS.providersListCheckpoints: return providerManager.listCheckpoints(request.params as never); @@ -323,11 +431,32 @@ export function createServer(options: ServerOptions) { return projectRegistry.list(); case WS_METHODS.projectsAdd: - return projectRegistry.add(request.params as never); + { + const result = await projectRegistry.add(request.params as never); + await orchestrationEngine.dispatch({ + type: "project.create", + commandId: crypto.randomUUID(), + projectId: result.project.id, + name: result.project.name, + cwd: result.project.cwd, + model: "gpt-5-codex", + createdAt: new Date().toISOString(), + }); + return result; + } case WS_METHODS.projectsRemove: - projectRegistry.remove(request.params as never); - return undefined; + { + const params = request.params as { id: string }; + projectRegistry.remove(request.params as never); + await orchestrationEngine.dispatch({ + type: "project.delete", + commandId: crypto.randomUUID(), + projectId: params.id, + createdAt: new Date().toISOString(), + }); + return undefined; + } case WS_METHODS.projectsSearchEntries: return searchWorkspaceEntries(request.params as never); @@ -379,7 +508,24 @@ export function createServer(options: ServerOptions) { } case WS_METHODS.gitStatus: - return gitManager.status(request.params as never); + { + const params = request.params as { cwd: string }; + const status = await gitManager.status(request.params as never); + const project = projectRegistry.list().find((entry) => entry.cwd === params.cwd); + if (project) { + await orchestrationEngine.dispatch({ + type: "git.readModel.upsert", + commandId: crypto.randomUUID(), + projectId: project.id, + branch: status.branch, + hasWorkingTreeChanges: status.hasWorkingTreeChanges, + aheadCount: status.aheadCount, + behindCount: status.behindCount, + createdAt: new Date().toISOString(), + }); + } + return status; + } case WS_METHODS.gitPull: return pullGitBranch(request.params as never); @@ -438,6 +584,25 @@ export function createServer(options: ServerOptions) { keybindings: keybindingsConfig, }; + case ORCHESTRATION_WS_METHODS.getSnapshot: + return orchestrationEngine.getSnapshot(); + + case ORCHESTRATION_WS_METHODS.dispatchCommand: + return orchestrationEngine.dispatchUnknown(request.params); + + case ORCHESTRATION_WS_METHODS.replayEvents: + return orchestrationEngine.replayEvents( + Math.max( + 0, + Math.floor( + Number( + (request.params as { fromSequenceExclusive?: number } | undefined) + ?.fromSequenceExclusive ?? 0, + ), + ), + ), + ); + default: throw new Error(`Unknown method: ${request.method}`); } @@ -450,9 +615,14 @@ export function createServer(options: ServerOptions) { reject(error); }; httpServer.once("error", onError); - const onListening = () => { + const onListening = async () => { httpServer.off("error", onError); - resolve(); + try { + await orchestrationEngine.start(); + resolve(); + } catch (error) { + reject(error instanceof Error ? error : new Error("Failed to start orchestration engine")); + } }; if (host) { httpServer.listen(port, host, onListening); @@ -463,6 +633,9 @@ export function createServer(options: ServerOptions) { } async function stop(): Promise { + unsubscribeReadModel(); + unsubscribeDomainEvents(); + await orchestrationEngine.stop(); terminalManager.off("event", onTerminalEvent); providerManager.stopAll(); providerManager.dispose(); diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 21113658fcd3d..6fe19dfd95464 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -92,13 +92,23 @@ export default function BranchToolbar({ }); if (!activeThreadId || !syncedBranch) return; + if (api) { + void api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: crypto.randomUUID(), + threadId: activeThreadId, + branch: syncedBranch, + worktreePath: null, + createdAt: new Date().toISOString(), + }); + } dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch: syncedBranch, worktreePath: null, }); - }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, envMode, dispatch]); + }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, envMode, dispatch, api]); useEffect(() => { if (isBranchMenuOpen) return; @@ -120,6 +130,16 @@ export default function BranchToolbar({ if (sessionId && worktreePath !== activeWorktreePath) { void api?.providers.stopSession({ sessionId }).catch(() => undefined); } + if (api) { + void api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: crypto.randomUUID(), + threadId: activeThreadId, + branch, + worktreePath, + createdAt: new Date().toISOString(), + }); + } dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, branch, worktreePath }); }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 92a6a7392854b..0a1423863ea33 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -57,7 +57,6 @@ import { derivePhase, deriveTurnDiffFilesFromUnifiedDiff, deriveTimelineEntries, - type TurnDiffSummary, type PendingApproval, deriveWorkLogEntries, formatDuration, @@ -72,6 +71,7 @@ import { MAX_THREAD_TERMINAL_COUNT, type ChatImageAttachment, type TurnDiffFileChange, + type TurnDiffSummary, } from "../types"; import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; @@ -433,28 +433,13 @@ export default function ChatView({ threadId }: ChatViewProps) { const phase = derivePhase(activeThread?.session ?? null); const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); - const workLogEntries = useMemo( - () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), - [activeThread?.events], - ); + const workLogEntries = useMemo(() => deriveWorkLogEntries([], undefined), []); const latestTurnWorkEntries = useMemo( - () => deriveWorkLogEntries(activeThread?.events ?? [], activeThread?.latestTurnId), - [activeThread?.events, activeThread?.latestTurnId], - ); - const pendingApprovals = useMemo( - () => derivePendingApprovals(activeThread?.events ?? []), - [activeThread?.events], + () => deriveWorkLogEntries([], activeThread?.latestTurnId), + [activeThread?.latestTurnId], ); - const assistantCompletionByItemId = useMemo(() => { - const map = new Map(); - const ordered = [...(activeThread?.events ?? [])].toReversed(); - for (const event of ordered) { - if (event.method !== "item/completed") continue; - if (!event.itemId) continue; - map.set(event.itemId, event.createdAt); - } - return map; - }, [activeThread?.events]); + const pendingApprovals = useMemo(() => derivePendingApprovals([]), []); + const assistantCompletionByItemId = useMemo(() => new Map(), []); const timelineEntries = useMemo( () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), [activeThread?.messages, workLogEntries], @@ -565,36 +550,6 @@ export default function ChatView({ threadId }: ChatViewProps) { return byUserMessageId; }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); - useEffect(() => { - if (!activeThreadId || turnDiffSummaries.length === 0) { - return; - } - - const inferredMissingTurnCounts = turnDiffSummaries.reduce>( - (acc, summary) => { - if (typeof summary.checkpointTurnCount === "number") { - return acc; - } - const inferredTurnCount = inferredCheckpointTurnCountByTurnId[summary.turnId]; - if (typeof inferredTurnCount !== "number") { - return acc; - } - acc[summary.turnId] = inferredTurnCount; - return acc; - }, - {}, - ); - if (Object.keys(inferredMissingTurnCounts).length === 0) { - return; - } - - dispatch({ - type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", - threadId: activeThreadId, - checkpointTurnCountByTurnId: inferredMissingTurnCounts, - }); - }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); - const completionSummary = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; @@ -1473,28 +1428,14 @@ export default function ChatView({ threadId }: ChatViewProps) { checkpointTurnCountSyncFingerprintRef.current.set(syncKey, turnFingerprint); try { - const result = await api.providers.listCheckpoints({ + await api.providers.listCheckpoints({ sessionId: input.sessionId, }); - const turnIdSet = new Set(turnIds); - const checkpointTurnCountByTurnId = Object.fromEntries( - result.checkpoints - .filter((checkpoint) => checkpoint.turnCount > 0 && turnIdSet.has(checkpoint.id)) - .map((checkpoint) => [checkpoint.id, checkpoint.turnCount] as const), - ); - if (Object.keys(checkpointTurnCountByTurnId).length === 0) { - return; - } - dispatch({ - type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", - threadId: input.threadId, - checkpointTurnCountByTurnId, - }); } catch { checkpointTurnCountSyncFingerprintRef.current.delete(syncKey); } }, - [api, dispatch, turnDiffSummaries], + [api, turnDiffSummaries], ); const ensureSession = useCallback( @@ -1533,10 +1474,21 @@ export default function ChatView({ threadId }: ChatViewProps) { approvalPolicy: runtimeApprovalPolicy, sandboxMode: runtimeSandboxMode, }); - dispatch({ - type: "UPDATE_SESSION", + await api.orchestration.dispatchCommand({ + type: "thread.session", + commandId: crypto.randomUUID(), threadId: activeThread.id, - session, + session: { + sessionId: session.sessionId, + provider: session.provider, + status: session.status, + threadId: session.threadId ?? activeThread.id, + activeTurnId: session.activeTurnId ?? null, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + lastError: session.lastError ?? null, + }, + createdAt: new Date().toISOString(), }); const resolvedThreadId = session.threadId ?? null; const continuityState: SessionContinuityState = @@ -1572,7 +1524,6 @@ export default function ChatView({ threadId }: ChatViewProps) { api, codexBinaryPath, codexHomePath, - dispatch, runtimeApprovalPolicy, runtimeSandboxMode, selectedModel, @@ -1642,27 +1593,10 @@ export default function ChatView({ threadId }: ChatViewProps) { if (!sessionInfo) { return; } - const result = await api.providers.revertToCheckpoint({ + await api.providers.revertToCheckpoint({ sessionId: sessionInfo.sessionId, turnCount, }); - dispatch({ - type: "REVERT_TO_CHECKPOINT", - threadId: activeThread.id, - sessionId: sessionInfo.sessionId, - threadRuntimeId: result.threadId, - turnCount: result.turnCount, - messageCount: result.messageCount, - }); - dispatch({ - type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", - threadId: activeThread.id, - checkpointTurnCountByTurnId: Object.fromEntries( - result.checkpoints - .filter((entry) => entry.turnCount > 0) - .map((entry) => [entry.id, entry.turnCount] as const), - ), - }); } catch (err) { setThreadError( activeThread.id, @@ -1709,12 +1643,16 @@ export default function ChatView({ threadId }: ChatViewProps) { newBranch, }); sessionCwd = result.worktree.path; - dispatch({ - type: "SET_THREAD_BRANCH", - threadId: activeThread.id, - branch: result.worktree.branch, - worktreePath: result.worktree.path, - }); + if (api) { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: crypto.randomUUID(), + threadId: activeThread.id, + branch: result.worktree.branch, + worktreePath: result.worktree.path, + createdAt: new Date().toISOString(), + }); + } const setupScript = setupProjectScript(activeProject.scripts); if (setupScript) { void runProjectScript(setupScript, { @@ -1741,11 +1679,15 @@ export default function ChatView({ threadId }: ChatViewProps) { ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` : "New thread"); const title = truncateTitle(titleSeed); - dispatch({ - type: "SET_THREAD_TITLE", - threadId: activeThread.id, - title, - }); + if (api) { + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: crypto.randomUUID(), + threadId: activeThread.id, + title, + createdAt: new Date().toISOString(), + }); + } } setThreadError(activeThread.id, null); @@ -1757,13 +1699,19 @@ export default function ChatView({ threadId }: ChatViewProps) { sizeBytes: image.sizeBytes, previewUrl: image.previewUrl, })); - dispatch({ - type: "PUSH_USER_MESSAGE", - threadId: activeThread.id, - id: crypto.randomUUID(), - text: trimmed, - ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), - }); + const userMessageId = crypto.randomUUID(); + if (api) { + await api.orchestration.dispatchCommand({ + type: "message.send", + commandId: crypto.randomUUID(), + threadId: activeThread.id, + messageId: userMessageId, + role: "user", + text: trimmed, + streaming: false, + createdAt: new Date().toISOString(), + }); + } const previousMessages = activeThread.messages; promptRef.current = ""; setPrompt(""); @@ -1852,14 +1800,18 @@ export default function ChatView({ threadId }: ChatViewProps) { const onModelSelect = useCallback( (model: ModelSlug) => { if (!activeThread) return; - dispatch({ - type: "SET_THREAD_MODEL", - threadId: activeThread.id, - model: resolveModelSlug(model), - }); + if (api) { + void api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: crypto.randomUUID(), + threadId: activeThread.id, + model: resolveModelSlug(model), + createdAt: new Date().toISOString(), + }); + } scheduleComposerFocus(); }, - [activeThread, dispatch, scheduleComposerFocus], + [activeThread, api, scheduleComposerFocus], ); const onEffortSelect = useCallback( (effort: ReasoningEffort) => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9291843ea9d07..0d2ad398f2cdd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -7,16 +7,14 @@ import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; import { APP_STAGE_LABEL } from "../branding"; 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"; import { toastManager } from "./ui/toast"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; -import { createThread } from "../threadFactory"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; @@ -131,13 +129,7 @@ export default function Sidebar() { const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); const [isAddingProject, setIsAddingProject] = useState(false); - const pendingApprovalByThreadId = useMemo(() => { - const map = new Map(); - for (const thread of state.threads) { - map.set(thread.id, derivePendingApprovals(thread.events).length > 0); - } - return map; - }, [state.threads]); + const pendingApprovalByThreadId = useMemo(() => new Map(), []); const handleNewThread = useCallback( ( @@ -147,21 +139,27 @@ export default function Sidebar() { worktreePath?: string | null; }, ) => { - const thread = createThread(projectId, { - model: state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL, + if (!api) return; + const threadId = crypto.randomUUID(); + const createdAt = new Date().toISOString(); + const model = state.projects.find((project) => project.id === projectId)?.model ?? DEFAULT_MODEL; + void api.orchestration.dispatchCommand({ + type: "thread.create", + commandId: crypto.randomUUID(), + threadId, + projectId, + title: "New thread", + model, branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, - }); - dispatch({ - type: "ADD_THREAD", - thread, + createdAt, }); void navigate({ to: "/$threadId", - params: { threadId: thread.id }, + params: { threadId }, }); }, - [dispatch, navigate, state.projects], + [api, dispatch, navigate, state.projects], ); const focusMostRecentThreadForProject = useCallback( @@ -190,47 +188,30 @@ export default function Sidebar() { setIsAddingProject(true); 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; - - if (result.created) { - handleNewThread(resolvedProjectId); - } else { - focusMostRecentThreadForProject(resolvedProjectId); - } - } else { - const existing = state.projects.find((project) => project.cwd === cwd); - if (existing) { - 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 existing = state.projects.find((project) => project.cwd === cwd); + if (existing) { + focusMostRecentThreadForProject(existing.id); + return; } + + const projectId = crypto.randomUUID(); + const now = new Date().toISOString(); + const name = inferProjectName(cwd); + if (isElectron) { + // Keep project registry in sync for desktop integrations. + await api.projects.add({ cwd }); + } + await api.orchestration.dispatchCommand({ + type: "project.create", + commandId: crypto.randomUUID(), + projectId, + name, + cwd, + model: DEFAULT_MODEL, + createdAt: now, + }); + handleNewThread(projectId); } finally { setIsAddingProject(false); setNewCwd(""); @@ -321,7 +302,12 @@ 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.orchestration.dispatchCommand({ + type: "thread.delete", + commandId: crypto.randomUUID(), + threadId, + createdAt: new Date().toISOString(), + }); if (shouldNavigateToFallback) { if (fallbackThreadId) { void navigate({ @@ -411,7 +397,12 @@ export default function Sidebar() { } } - dispatch({ type: "DELETE_PROJECT", projectId }); + await api.orchestration.dispatchCommand({ + type: "project.delete", + commandId: crypto.randomUUID(), + projectId, + createdAt: new Date().toISOString(), + }); }, [api, dispatch, state.projects, state.threads], ); diff --git a/apps/web/src/hooks/useNativeApi.ts b/apps/web/src/hooks/useNativeApi.ts index 6e24884718d1e..deb3656e8e92a 100644 --- a/apps/web/src/hooks/useNativeApi.ts +++ b/apps/web/src/hooks/useNativeApi.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { readNativeApi } from "../session-logic"; +import { readNativeApi } from "../nativeApi"; export function useNativeApi() { return useMemo(() => readNativeApi(), []); diff --git a/apps/web/src/hooks/useTurnDiffSummaries.ts b/apps/web/src/hooks/useTurnDiffSummaries.ts index cdab41de82f3e..869603a28de0f 100644 --- a/apps/web/src/hooks/useTurnDiffSummaries.ts +++ b/apps/web/src/hooks/useTurnDiffSummaries.ts @@ -1,29 +1,13 @@ -import type { ProviderEvent } from "@t3tools/contracts"; import { useMemo } from "react"; -import { deriveTurnDiffSummaries, inferCheckpointTurnCountByTurnId } from "../session-logic"; +import { inferCheckpointTurnCountByTurnId } from "../session-logic"; import type { Thread, TurnDiffSummary } from "../types"; -const derivedTurnDiffSummaryCache = new WeakMap(); - -function deriveTurnDiffSummariesCached(events: ProviderEvent[]): TurnDiffSummary[] { - const cached = derivedTurnDiffSummaryCache.get(events); - if (cached) { - return cached; - } - const derived = deriveTurnDiffSummaries(events); - derivedTurnDiffSummaryCache.set(events, derived); - return derived; -} - export function useTurnDiffSummaries(activeThread: Thread | undefined) { const turnDiffSummaries = useMemo(() => { if (!activeThread) { return []; } - if (activeThread.turnDiffSummaries.length > 0) { - return activeThread.turnDiffSummaries; - } - return deriveTurnDiffSummariesCached(activeThread.events); + return activeThread.turnDiffSummaries; }, [activeThread]); const inferredCheckpointTurnCountByTurnId = useMemo( diff --git a/apps/web/src/nativeApi.ts b/apps/web/src/nativeApi.ts new file mode 100644 index 0000000000000..8c01860e0a66b --- /dev/null +++ b/apps/web/src/nativeApi.ts @@ -0,0 +1,18 @@ +import type { NativeApi } from "@t3tools/contracts"; + +import { createWsNativeApi } from "./wsNativeApi"; + +let cachedApi: NativeApi | undefined; + +export function readNativeApi(): NativeApi | undefined { + if (typeof window === "undefined") return undefined; + if (cachedApi) return cachedApi; + + if (window.nativeApi) { + cachedApi = window.nativeApi; + return cachedApi; + } + + cachedApi = createWsNativeApi(); + return cachedApi; +} diff --git a/apps/web/src/persistenceSchema.test.ts b/apps/web/src/persistenceSchema.test.ts deleted file mode 100644 index 448806d8e2638..0000000000000 --- 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 e1711ef694146..0000000000000 --- 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 1e7bd0d84d949..1ef0eabe4bcbf 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -2,17 +2,15 @@ import { Outlet, createRootRouteWithContext, type ErrorComponentProps, - useParams, } from "@tanstack/react-router"; import { useEffect, useRef } from "react"; -import { QueryClient, useQueryClient } from "@tanstack/react-query"; +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"; @@ -125,67 +123,33 @@ 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, - }); 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, - }); - }); - }, [activeThreadId, api, dispatch, queryClient]); - - useEffect(() => { - if (!activeThreadId) return; - dispatch({ - type: "MARK_THREAD_VISITED", - threadId: activeThreadId, - visitedAt: new Date().toISOString(), - }); - }, [activeThreadId, dispatch]); - - useEffect(() => { - if (!api) return; - return api.terminal.onEvent((event) => { - dispatch({ - type: "APPLY_TERMINAL_EVENT", - event, - }); + void api.orchestration + .getSnapshot() + .then((snapshot) => { + dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); + }) + .catch(() => undefined); + const unsubReadModel = api.orchestration.onReadModel((snapshot) => { + dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); }); + return () => { + unsubReadModel(); + }; }, [api, dispatch]); return null; } function AutoProjectBootstrap() { + const api = useNativeApi(); const { state, dispatch } = useStore(); const bootstrappedRef = useRef(false); useEffect(() => { + if (!api) return; // Browser mode bootstraps from server welcome. // Electron bootstraps from persisted projects via DesktopProjectBootstrap. if (isElectron) return; @@ -202,75 +166,40 @@ function AutoProjectBootstrap() { } bootstrappedRef.current = true; - - // Create project + thread from server cwd const projectId = crypto.randomUUID(); - dispatch({ - type: "ADD_PROJECT", - project: { - id: projectId, + const threadId = crypto.randomUUID(); + const now = new Date().toISOString(); + void api.orchestration + .dispatchCommand({ + type: "project.create", + commandId: crypto.randomUUID(), + projectId, name: payload.projectName, cwd: payload.cwd, model: DEFAULT_MODEL, - expanded: true, - scripts: [], - }, - }); - dispatch({ type: "SET_THREADS_HYDRATED", hydrated: true }); + createdAt: now, + }) + .then(() => + api.orchestration.dispatchCommand({ + type: "thread.create", + commandId: crypto.randomUUID(), + threadId, + projectId, + title: "New thread", + model: DEFAULT_MODEL, + branch: null, + worktreePath: null, + createdAt: now, + }), + ) + .catch(() => undefined); }); - }, [state.projects, dispatch]); + }, [api, state.projects, dispatch]); return null; } 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); - } - }; - }, [api, dispatch]); - + // Desktop now hydrates from orchestration read-model snapshots. return null; } diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts deleted file mode 100644 index 7dee1239cabed..0000000000000 --- a/apps/web/src/session-logic.test.ts +++ /dev/null @@ -1,935 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { ProviderEvent, ProviderSession } from "@t3tools/contracts"; -import { - type WorkLogEntry, - applyEventToMessages, - derivePendingApprovals, - deriveTurnDiffFilesFromUnifiedDiff, - deriveTurnDiffSummaries, - deriveTimelineEntries, - deriveWorkLogEntries, - evolveSession, -} from "./session-logic"; -import type { ChatMessage } from "./types"; - -function makeEvent(overrides: Partial): ProviderEvent { - return { - id: "evt-1", - kind: "notification", - provider: "codex", - sessionId: "sess-1", - createdAt: "2026-02-08T10:00:00.000Z", - method: "noop", - ...overrides, - }; -} - -function makeSession(overrides: Partial = {}): ProviderSession { - return { - sessionId: "sess-1", - provider: "codex", - status: "ready", - createdAt: "2026-02-08T09:59:00.000Z", - updatedAt: "2026-02-08T09:59:00.000Z", - ...overrides, - }; -} - -describe("deriveTimelineEntries", () => { - it("interleaves messages and work entries by timestamp", () => { - const messages: ChatMessage[] = [ - { - id: "m-user", - role: "user", - text: "Hi", - createdAt: "2026-02-08T10:00:00.000Z", - streaming: false, - }, - { - id: "m-assistant", - role: "assistant", - text: "Hello", - createdAt: "2026-02-08T10:05:00.000Z", - streaming: false, - }, - ]; - const workEntries: WorkLogEntry[] = [ - { - id: "w-1", - label: "Tool call", - createdAt: "2026-02-08T10:02:00.000Z", - tone: "tool", - }, - { - id: "w-2", - label: "Preamble", - createdAt: "2026-02-08T10:03:00.000Z", - tone: "thinking", - }, - ]; - - const timeline = deriveTimelineEntries(messages, workEntries); - - expect(timeline.map((entry) => entry.id)).toEqual([ - "message:m-user", - "work:w-1", - "work:w-2", - "message:m-assistant", - ]); - }); - - it("prefers work entries when timestamps are equal", () => { - const messages: ChatMessage[] = [ - { - id: "m-1", - role: "assistant", - text: "Done", - createdAt: "2026-02-08T10:00:00.000Z", - streaming: false, - }, - ]; - const workEntries: WorkLogEntry[] = [ - { - id: "w-1", - label: "Tool call", - createdAt: "2026-02-08T10:00:00.000Z", - tone: "tool", - }, - ]; - - const timeline = deriveTimelineEntries(messages, workEntries); - - expect(timeline.map((entry) => entry.id)).toEqual(["work:w-1", "message:m-1"]); - }); -}); - -describe("deriveWorkLogEntries", () => { - it("drops preamble/work events from the visible work log", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-1", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - payload: { item: { type: "preamble", text: "thinking" } }, - }), - makeEvent({ - id: "evt-2", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { item: { type: "work", text: "planning" } }, - }), - makeEvent({ - id: "evt-3", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { item: { type: "tool_call", command: "ls -la" } }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(1); - expect(entries[0]?.label).toBe("Tool call"); - }); - - it("does not surface successful turn completion as a work-log row", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-start", - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - }), - makeEvent({ - id: "evt-complete", - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:10.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(0); - }); - - it("shows failed turn completion in the work log", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-start", - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - }), - makeEvent({ - id: "evt-complete", - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:10.000Z", - payload: { - turn: { - id: "turn-1", - status: "failed", - error: { message: "sandbox denied" }, - }, - }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(1); - expect(entries[0]?.label).toBe("Turn failed"); - expect(entries[0]?.detail).toBe("sandbox denied"); - }); - - it("hides reasoning and agent-message noise from the visible work log", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-turn-start", - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - }), - makeEvent({ - id: "evt-summary-part", - method: "item/reasoning/summaryPartAdded", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - }), - makeEvent({ - id: "evt-summary-delta", - method: "item/reasoning/summaryTextDelta", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:02.000Z", - }), - makeEvent({ - id: "evt-agent-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:03.000Z", - payload: { - item: { id: "item-msg", type: "agentMessage", text: "Working..." }, - }, - }), - makeEvent({ - id: "evt-agent-complete", - method: "item/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:04.000Z", - payload: { - item: { - id: "item-msg", - type: "agentMessage", - text: "Done response", - }, - }, - }), - makeEvent({ - id: "evt-tool-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:05.000Z", - payload: { - item: { id: "item-tool", type: "tool_call", command: "ls -la" }, - }, - }), - makeEvent({ - id: "evt-tool-complete", - method: "item/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:06.000Z", - payload: { - item: { id: "item-tool", type: "tool_call", command: "ls -la" }, - }, - }), - makeEvent({ - id: "evt-turn-complete", - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:08.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - ], - "turn-1", - ); - - expect(entries.map((entry) => entry.label)).toEqual(["Tool call"]); - }); - - it("coalesces command start/completed lifecycle into one entry", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-command-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { - item: { - id: "item-command", - type: "command_execution", - command: "git status --short", - }, - }, - }), - makeEvent({ - id: "evt-command-complete", - method: "item/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { - item: { - id: "item-command", - type: "command_execution", - command: "git status --short", - }, - }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(1); - expect(entries[0]?.label).toBe("Command run"); - expect(entries[0]?.detail).toBe("git status --short"); - }); - - it("preserves full tool-call detail text without data truncation", () => { - const longCommand = - 'node ./scripts/sync.js --project ct-round-5 --mode dry-run --include "very long argument with lots of detail and metadata to verify there is no hard truncation in session logic"'; - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-tool-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:05.000Z", - payload: { - item: { id: "item-tool", type: "tool_call", command: longCommand }, - }, - }), - ], - "turn-1", - ); - - expect(entries[0]?.detail).toBe(longCommand); - }); - - it("hides generic tool-call rows that have no identifying detail", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-tool-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:05.000Z", - payload: { - item: { id: "item-tool", type: "tool_call" }, - }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(0); - }); - - it("includes collab tool-call rows with tool name detail", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-collab-start", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:05.000Z", - payload: { - item: { - id: "item-collab", - type: "collabAgentToolCall", - tool: "spawnAgent", - }, - }, - }), - ], - "turn-1", - ); - - expect(entries).toHaveLength(1); - expect(entries[0]?.label).toBe("Tool call"); - expect(entries[0]?.detail).toBe("spawnAgent"); - }); - - it("can derive work-log entries across all turns when not scoped", () => { - const entries = deriveWorkLogEntries( - [ - makeEvent({ - id: "evt-turn-1-tool", - method: "item/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { item: { type: "tool_call", command: "ls -la" } }, - }), - makeEvent({ - id: "evt-turn-2-tool", - method: "item/started", - turnId: "turn-2", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { item: { type: "tool_call", command: "pwd" } }, - }), - ], - undefined, - ); - - expect(entries).toHaveLength(2); - expect(entries.map((entry) => entry.detail)).toEqual(["pwd", "ls -la"]); - }); -}); - -describe("deriveTurnDiffSummaries", () => { - it("tracks completed turns and links them to assistant message completion", () => { - const summaries = deriveTurnDiffSummaries([ - makeEvent({ - id: "evt-turn-start", - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - }), - makeEvent({ - id: "evt-file-change", - method: "item/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { - item: { - id: "item-file-change", - type: "fileChange", - status: "completed", - changes: [ - { - path: "src/a.ts", - kind: "modified", - diff: "diff --git a/src/a.ts b/src/a.ts\n@@ -1 +1 @@\n-old\n+new\n", - }, - ], - }, - }, - }), - makeEvent({ - id: "evt-turn-diff-updated", - method: "turn/diff/updated", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { - threadId: "thr-1", - turnId: "turn-1", - 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", - "@@ -0,0 +1 @@", - "+created", - ].join("\n"), - }, - }), - makeEvent({ - id: "evt-agent-msg", - method: "item/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:03.000Z", - payload: { - item: { - id: "msg-1", - type: "agentMessage", - text: "Done", - }, - }, - }), - makeEvent({ - id: "evt-turn-completed", - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:04.000Z", - payload: { - turn: { - id: "turn-1", - status: "completed", - }, - }, - }), - makeEvent({ - id: "evt-turn-2-file-change", - method: "item/completed", - turnId: "turn-2", - createdAt: "2026-02-08T10:00:05.000Z", - payload: { - item: { - id: "item-file-change-2", - type: "fileChange", - status: "completed", - changes: [ - { - path: "src/unfinished.ts", - kind: "modified", - diff: "diff --git a/src/unfinished.ts b/src/unfinished.ts", - }, - ], - }, - }, - }), - ]); - - expect(summaries).toHaveLength(1); - expect(summaries[0]?.turnId).toBe("turn-1"); - expect(summaries[0]?.assistantMessageId).toBe("msg-1"); - expect(summaries[0]?.files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]); - expect(summaries[0]?.files[0]?.kind).toBe("modified"); - expect(summaries[0]?.files[0]?.additions).toBe(1); - expect(summaries[0]?.files[0]?.deletions).toBe(1); - expect(summaries[0]).not.toHaveProperty("unifiedDiff"); - }); - - it("includes completed turns even when no fileChange tool events were emitted", () => { - const summaries = deriveTurnDiffSummaries([ - makeEvent({ - id: "evt-turn-start", - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:00.000Z", - }), - makeEvent({ - id: "evt-turn-completed", - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { - turn: { - id: "turn-1", - status: "completed", - }, - }, - }), - ]); - - expect(summaries).toHaveLength(1); - expect(summaries[0]?.turnId).toBe("turn-1"); - expect(summaries[0]?.files).toEqual([]); - }); - - it("keeps newest diff data when older events provide stale values", () => { - const summaries = deriveTurnDiffSummaries([ - makeEvent({ - id: "evt-turn-diff-old", - method: "turn/diff/updated", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { - diff: [ - "diff --git a/src/example.ts b/src/example.ts", - "@@ -1 +1,2 @@", - "-old", - "+older", - "+extra", - ].join("\n"), - }, - }), - makeEvent({ - id: "evt-turn-diff-new", - method: "turn/diff/updated", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { - diff: [ - "diff --git a/src/example.ts b/src/example.ts", - "@@ -1 +1 @@", - "-old", - "+newest", - ].join("\n"), - }, - }), - makeEvent({ - id: "evt-turn-completed", - method: "turn/completed", - turnId: "turn-1", - createdAt: "2026-02-08T10:00:03.000Z", - payload: { - turn: { - id: "turn-1", - status: "completed", - }, - }, - }), - ]); - - expect(summaries).toHaveLength(1); - expect(summaries[0]?.files[0]?.path).toBe("src/example.ts"); - expect(summaries[0]?.files[0]?.additions).toBe(1); - expect(summaries[0]?.files[0]?.deletions).toBe(1); - }); -}); - -describe("deriveTurnDiffFilesFromUnifiedDiff", () => { - it("splits a multi-file patch into sorted per-file entries", () => { - const files = deriveTurnDiffFilesFromUnifiedDiff( - [ - "diff --git a/src/b.ts b/src/b.ts", - "@@ -1 +1 @@", - "-old-b", - "+new-b", - "diff --git a/src/a.ts b/src/a.ts", - "@@ -1 +1 @@", - "-old-a", - "+new-a", - ].join("\n"), - ); - - expect(files.map((file) => file.path)).toEqual(["src/a.ts", "src/b.ts"]); - expect(files[0]?.additions).toBe(1); - expect(files[0]?.deletions).toBe(1); - }); - - it("parses git headers when file paths include ` b/`", () => { - const files = deriveTurnDiffFilesFromUnifiedDiff( - [ - "diff --git a/subdir b/example.ts b/subdir b/example.ts", - "--- a/subdir b/example.ts", - "+++ b/subdir b/example.ts", - "@@ -1 +1 @@", - "-old", - "+new", - ].join("\n"), - ); - - expect(files).toHaveLength(1); - expect(files[0]?.path).toBe("subdir b/example.ts"); - }); - - it("parses git header fallback for binary diffs with ` b/` in path", () => { - const files = deriveTurnDiffFilesFromUnifiedDiff( - [ - "diff --git a/subdir b/image.png b/subdir b/image.png", - "Binary files a/subdir b/image.png and b/subdir b/image.png differ", - ].join("\n"), - ); - - expect(files).toHaveLength(1); - expect(files[0]?.path).toBe("subdir b/image.png"); - }); -}); - -describe("derivePendingApprovals", () => { - it("returns pending command/file-change approvals", () => { - const approvals = derivePendingApprovals([ - makeEvent({ - id: "evt-request", - kind: "request", - method: "item/commandExecution/requestApproval", - requestId: "req-1", - requestKind: "command", - createdAt: "2026-02-08T10:00:00.000Z", - payload: { command: "git commit -m 'msg'" }, - }), - ]); - - expect(approvals).toEqual([ - { - requestId: "req-1", - requestKind: "command", - createdAt: "2026-02-08T10:00:00.000Z", - detail: "git commit -m 'msg'", - }, - ]); - }); - - it("removes approvals after a decision event", () => { - const approvals = derivePendingApprovals([ - makeEvent({ - id: "evt-decision", - method: "item/requestApproval/decision", - requestId: "req-1", - createdAt: "2026-02-08T10:00:01.000Z", - }), - makeEvent({ - id: "evt-request", - kind: "request", - method: "item/fileChange/requestApproval", - requestId: "req-1", - requestKind: "file-change", - createdAt: "2026-02-08T10:00:00.000Z", - }), - ]); - - expect(approvals).toHaveLength(0); - }); - - it("clears pending approvals after turn completion", () => { - const approvals = derivePendingApprovals([ - makeEvent({ - id: "evt-turn-complete", - method: "turn/completed", - createdAt: "2026-02-08T10:00:01.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - makeEvent({ - id: "evt-request", - kind: "request", - method: "item/commandExecution/requestApproval", - requestId: "req-1", - requestKind: "command", - createdAt: "2026-02-08T10:00:00.000Z", - }), - ]); - - expect(approvals).toHaveLength(0); - }); -}); - -describe("evolveSession", () => { - it("updates thread id when thread starts", () => { - const previous = makeSession(); - const next = evolveSession( - previous, - makeEvent({ - method: "thread/started", - createdAt: "2026-02-08T10:01:00.000Z", - payload: { thread: { id: "thread-1" } }, - }), - ); - - expect(next.threadId).toBe("thread-1"); - expect(next.updatedAt).toBe("2026-02-08T10:01:00.000Z"); - }); - - it("moves to running and records active turn on turn start", () => { - const previous = makeSession(); - const next = evolveSession( - previous, - makeEvent({ - method: "turn/started", - turnId: "turn-1", - createdAt: "2026-02-08T10:02:00.000Z", - }), - ); - - expect(next.status).toBe("running"); - expect(next.activeTurnId).toBe("turn-1"); - expect(next.updatedAt).toBe("2026-02-08T10:02:00.000Z"); - }); - - it("returns to ready and clears active turn on successful completion", () => { - const previous = makeSession({ - status: "running", - activeTurnId: "turn-1", - lastError: "older error", - }); - const next = evolveSession( - previous, - makeEvent({ - method: "turn/completed", - createdAt: "2026-02-08T10:03:00.000Z", - payload: { turn: { id: "turn-1", status: "completed" } }, - }), - ); - - expect(next.status).toBe("ready"); - expect(next.activeTurnId).toBeUndefined(); - expect(next.lastError).toBe("older error"); - }); - - it("marks session as error when turn fails", () => { - const previous = makeSession({ - status: "running", - activeTurnId: "turn-1", - }); - const next = evolveSession( - previous, - makeEvent({ - method: "turn/completed", - createdAt: "2026-02-08T10:03:00.000Z", - payload: { - turn: { id: "turn-1", status: "failed", error: { message: "boom" } }, - }, - }), - ); - - expect(next.status).toBe("error"); - expect(next.activeTurnId).toBeUndefined(); - expect(next.lastError).toBe("boom"); - }); - - it("moves to error on runtime error events", () => { - const previous = makeSession(); - const next = evolveSession( - previous, - makeEvent({ - kind: "error", - method: "runtime/error", - createdAt: "2026-02-08T10:04:00.000Z", - message: "runtime failure", - }), - ); - - expect(next.status).toBe("error"); - expect(next.lastError).toBe("runtime failure"); - }); - - it("closes session on close lifecycle events", () => { - const previous = makeSession({ - status: "running", - activeTurnId: "turn-1", - }); - const next = evolveSession( - previous, - makeEvent({ - method: "session/closed", - createdAt: "2026-02-08T10:05:00.000Z", - message: "closed", - }), - ); - - expect(next.status).toBe("closed"); - expect(next.activeTurnId).toBeUndefined(); - expect(next.lastError).toBe("closed"); - }); -}); - -describe("applyEventToMessages", () => { - it("handles start/delta/completed flow for assistant messages", () => { - const activeAssistantItemRef = { current: null as string | null }; - const started = applyEventToMessages( - [], - makeEvent({ - method: "item/started", - createdAt: "2026-02-08T10:00:00.000Z", - payload: { - item: { - id: "item-1", - type: "agentMessage", - text: "Hello", - }, - }, - }), - activeAssistantItemRef, - ); - - expect(started).toEqual([ - { - id: "item-1", - role: "assistant", - text: "Hello", - createdAt: "2026-02-08T10:00:00.000Z", - streaming: true, - }, - ]); - expect(activeAssistantItemRef.current).toBe("item-1"); - - const withDelta = applyEventToMessages( - started, - makeEvent({ - method: "item/agentMessage/delta", - createdAt: "2026-02-08T10:00:01.000Z", - itemId: "item-1", - textDelta: " world", - }), - activeAssistantItemRef, - ); - expect(withDelta[0]?.text).toBe("Hello world"); - expect(withDelta[0]?.streaming).toBe(true); - - const completed = applyEventToMessages( - withDelta, - makeEvent({ - method: "item/completed", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { - item: { - id: "item-1", - type: "agentMessage", - text: "Hello world!", - }, - }, - }), - activeAssistantItemRef, - ); - expect(completed[0]?.text).toBe("Hello world!"); - expect(completed[0]?.streaming).toBe(false); - expect(activeAssistantItemRef.current).toBeNull(); - }); - - it("supports out-of-order delta before started/completed", () => { - const activeAssistantItemRef = { current: null as string | null }; - const withDelta = applyEventToMessages( - [], - makeEvent({ - method: "item/agentMessage/delta", - createdAt: "2026-02-08T10:00:01.000Z", - itemId: "item-2", - textDelta: "Partial", - }), - activeAssistantItemRef, - ); - expect(withDelta[0]?.id).toBe("item-2"); - expect(withDelta[0]?.text).toBe("Partial"); - expect(withDelta[0]?.streaming).toBe(true); - expect(activeAssistantItemRef.current).toBe("item-2"); - - const completed = applyEventToMessages( - withDelta, - makeEvent({ - method: "item/completed", - createdAt: "2026-02-08T10:00:02.000Z", - payload: { - item: { - id: "item-2", - type: "agentMessage", - text: "Partial + final", - }, - }, - }), - activeAssistantItemRef, - ); - - expect(completed[0]?.text).toBe("Partial + final"); - expect(completed[0]?.streaming).toBe(false); - }); - - it("clears all streaming flags when turn completes", () => { - const previous: ChatMessage[] = [ - { - id: "m-user", - role: "user", - text: "Hi", - createdAt: "2026-02-08T10:00:00.000Z", - streaming: false, - }, - { - id: "m-assistant", - role: "assistant", - text: "Typing", - createdAt: "2026-02-08T10:00:01.000Z", - streaming: true, - }, - ]; - - const next = applyEventToMessages( - previous, - makeEvent({ - method: "turn/completed", - }), - { current: null }, - ); - - expect(next.every((entry) => entry.streaming === false)).toBe(true); - }); -}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index b36a9648d9a31..f8a5592be86ea 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,6 +1,6 @@ -import type { NativeApi, ProviderEvent, ProviderKind, ProviderSession } from "@t3tools/contracts"; +import type { ProviderKind, ProviderSession } from "@t3tools/contracts"; + import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types"; -import { createWsNativeApi } from "./wsNativeApi"; export const PROVIDER_OPTIONS: Array<{ value: ProviderKind; @@ -11,73 +11,6 @@ export const PROVIDER_OPTIONS: Array<{ { value: "claudeCode", label: "Claude Code (soon)", available: false }, ]; -let cachedApi: NativeApi | undefined; - -export function readNativeApi(): NativeApi | undefined { - if (typeof window === "undefined") return undefined; - if (cachedApi) return cachedApi; - - // Prefer Electron preload bridge if available - if (window.nativeApi) { - cachedApi = window.nativeApi; - return cachedApi; - } - - // Fall back to WebSocket transport - cachedApi = createWsNativeApi(); - return cachedApi; -} - -export function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") return undefined; - return value as Record; -} - -function asArray(value: unknown): unknown[] | undefined { - return Array.isArray(value) ? value : undefined; -} - -export function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function asNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -export function formatTimestamp(isoDate: string): string { - return new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", - second: "2-digit", - }).format(new Date(isoDate)); -} - -export function formatDuration(durationMs: number): string { - if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; - if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; - if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`; - if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; - - const minutes = Math.floor(durationMs / 60_000); - const seconds = Math.round((durationMs % 60_000) / 1_000); - if (seconds === 0) return `${minutes}m`; - if (seconds === 60) return `${minutes + 1}m`; - return `${minutes}m ${seconds}s`; -} - -export function formatElapsed(startIso: string, endIso: string | undefined): string | null { - if (!endIso) return null; - - const startedAt = Date.parse(startIso); - const endedAt = Date.parse(endIso); - if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { - return null; - } - - return formatDuration(endedAt - startedAt); -} - export interface WorkLogEntry { id: string; createdAt: string; @@ -107,680 +40,94 @@ export type TimelineEntry = entry: WorkLogEntry; }; -export type { TurnDiffFileChange, TurnDiffSummary } from "./types"; - -function normalizeDetail(value: string | undefined): string | undefined { - if (!value) return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function approvalDetail(event: ProviderEvent): string | undefined { - const payload = asObject(event.payload); - const command = asString(payload?.command); - if (command) return command; - return asString(payload?.reason); -} - -export function derivePendingApprovals(events: ProviderEvent[]): PendingApproval[] { - const pending = new Map(); - const ordered = [...events].toReversed(); - - for (const event of ordered) { - if ( - event.method === "session/closed" || - event.method === "session/exited" || - event.method === "turn/completed" - ) { - pending.clear(); - continue; - } - - const requestId = event.requestId ?? asString(asObject(event.payload)?.requestId); - if (!requestId) continue; - - if ( - event.kind === "request" && - (event.requestKind === "command" || event.requestKind === "file-change") - ) { - const detail = approvalDetail(event); - pending.set(requestId, { - requestId, - requestKind: event.requestKind, - createdAt: event.createdAt, - ...(detail ? { detail } : {}), - }); - continue; - } - - if (event.method === "item/requestApproval/decision") { - pending.delete(requestId); - } - } - - return Array.from(pending.values()); -} - -function normalizeItemType(raw: string | undefined): string { - if (!raw) return "item"; - return raw - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replace(/[._/-]/g, " ") - .replace(/\s+/g, " ") - .trim() - .toLowerCase(); -} - -function shouldDropItemType(type: string): boolean { - if (type.includes("preamble") || type.includes("reasoning") || type.includes("thought")) { - return true; - } - - return type === "work" || type.startsWith("work "); -} - -function shouldShowItemLifecycle(type: string): boolean { - return type.includes("tool") || type.includes("command") || type.includes("file change"); -} - -function shouldDropMethod(method: string): boolean { - return /(^|\/)(preamble|work|reasoning|thought)(\/|$)/i.test(method); -} - -function itemTypeMeta(type: string): { - label: string; - tone: WorkLogEntry["tone"]; -} { - if (type.includes("command")) { - return { label: "Command run", tone: "tool" }; - } - if (type.includes("file change")) { - return { label: "File change", tone: "tool" }; - } - if (type.includes("tool")) { - return { label: "Tool call", tone: "tool" }; - } - return { label: "Work item", tone: "info" }; -} - -interface ItemLifecycleCandidate { - id: string; - itemId?: string; - createdAt: string; - label: string; - detail?: string; - tone: WorkLogEntry["tone"]; - phase: "started" | "completed"; -} - -function extractDetail( - payload: Record | undefined, - item: Record | undefined, -): string | undefined { - const candidates = [ - asString(item?.command), - asString(item?.tool), - asString(item?.name), - asString(item?.title), - asString(item?.summary), - asString(item?.text), - asString(item?.prompt), - asString(payload?.message), - asString(payload?.prompt), - asString(payload?.command), - ]; - - for (const candidate of candidates) { - const detail = normalizeDetail(candidate); - if (detail) return detail; - } - return undefined; -} - -function lifecycleCandidateFromItemEvent(event: ProviderEvent): ItemLifecycleCandidate | null { - const payload = asObject(event.payload); - const item = asObject(payload?.item); - const normalizedType = normalizeItemType(asString(item?.type)); - if (shouldDropItemType(normalizedType) || shouldDropMethod(event.method)) { - return null; - } - if (!shouldShowItemLifecycle(normalizedType)) { - return null; - } - - const meta = itemTypeMeta(normalizedType); - const isStarted = event.method === "item/started"; - const isCompleted = event.method === "item/completed"; - if (!isStarted && !isCompleted) { - return null; - } - - const detail = extractDetail(payload, item); - const itemId = event.itemId ?? asString(item?.id); - return { - id: event.id, - ...(itemId ? { itemId } : {}), - createdAt: event.createdAt, - label: meta.label, - ...(detail ? { detail } : {}), - tone: meta.tone, - phase: isCompleted ? "completed" : "started", - }; +export function formatTimestamp(isoDate: string): string { + return new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }).format(new Date(isoDate)); } -function entryFromRequest(event: ProviderEvent): WorkLogEntry | null { - if (event.kind !== "request") return null; - if (shouldDropMethod(event.method)) return null; - - if (event.method.includes("commandExecution")) { - return { - id: event.id, - createdAt: event.createdAt, - label: "Command approval requested", - tone: "tool", - }; - } - - if (event.method.includes("fileChange")) { - return { - id: event.id, - createdAt: event.createdAt, - label: "File-change approval requested", - tone: "tool", - }; - } - - if (event.method.includes("requestUserInput")) { - return { - id: event.id, - createdAt: event.createdAt, - label: "Tool requested user input", - tone: "tool", - }; - } - - return { - id: event.id, - createdAt: event.createdAt, - label: `Request: ${event.method}`, - tone: "info", - }; +export function formatDuration(durationMs: number): string { + if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; + if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; + if (durationMs < 10_000) return `${(durationMs / 1_000).toFixed(1)}s`; + if (durationMs < 60_000) return `${Math.round(durationMs / 1_000)}s`; + const minutes = Math.floor(durationMs / 60_000); + const seconds = Math.round((durationMs % 60_000) / 1_000); + if (seconds === 0) return `${minutes}m`; + if (seconds === 60) return `${minutes + 1}m`; + return `${minutes}m ${seconds}s`; } -function entryFromNotification(event: ProviderEvent): WorkLogEntry | null { - if (event.kind !== "notification") return null; - if (shouldDropMethod(event.method)) return null; - if (event.method === "item/agentMessage/delta") return null; - if (event.method === "turn/started") { +export function formatElapsed(startIso: string, endIso: string | undefined): string | null { + if (!endIso) return null; + const startedAt = Date.parse(startIso); + const endedAt = Date.parse(endIso); + if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { return null; } - if (event.method === "thread/started") return null; - - if (event.method === "turn/completed") { - const payload = asObject(event.payload); - const turn = asObject(payload?.turn); - const status = asString(turn?.status); - if (status !== "failed") { - return null; - } - const turnError = asObject(turn?.error); - const turnErrorMessage = asString(turnError?.message); - const turnErrorDetail = normalizeDetail(turnErrorMessage); - - return { - id: event.id, - createdAt: event.createdAt, - label: "Turn failed", - ...(turnErrorDetail ? { detail: turnErrorDetail } : {}), - tone: "error", - }; - } - - if (event.method.startsWith("item/")) return null; - - return null; -} - -function entryFromError(event: ProviderEvent): WorkLogEntry | null { - if (event.kind !== "error") return null; - if (shouldDropMethod(event.method)) return null; - const detail = normalizeDetail(event.message); - - return { - id: event.id, - createdAt: event.createdAt, - label: "Runtime error", - ...(detail ? { detail } : {}), - tone: "error", - }; + return formatDuration(endedAt - startedAt); } -function eventTurnId(event: ProviderEvent): string | undefined { - const payload = asObject(event.payload); - const turn = asObject(payload?.turn); - return event.turnId ?? asString(turn?.id); +export function derivePendingApprovals(_events: unknown[]): PendingApproval[] { + return []; } export function deriveWorkLogEntries( - events: ProviderEvent[], - turnId: string | undefined, + _events: unknown[], + _latestTurnId: string | undefined, ): WorkLogEntry[] { - const ordered = [...events].toReversed(); - const entries: WorkLogEntry[] = []; - const turnStartedAtIso = turnId - ? ordered.find((event) => { - if (event.method !== "turn/started") return false; - return eventTurnId(event) === turnId; - })?.createdAt - : undefined; - const turnStartedAt = turnStartedAtIso ? Date.parse(turnStartedAtIso) : Number.NaN; - - const shouldIncludeEvent = (event: ProviderEvent): boolean => { - if (!turnId) return true; - const scopedTurnId = eventTurnId(event); - if (scopedTurnId && scopedTurnId !== turnId) { - return false; - } - - if (!scopedTurnId && !Number.isNaN(turnStartedAt)) { - const eventAt = Date.parse(event.createdAt); - if (!Number.isNaN(eventAt) && eventAt < turnStartedAt) { - return false; - } - } - - return true; - }; - - const completedLifecycleItemIds = new Set(); - for (const event of ordered) { - if (!shouldIncludeEvent(event)) continue; - const candidate = lifecycleCandidateFromItemEvent(event); - if (candidate?.phase === "completed" && candidate.itemId) { - completedLifecycleItemIds.add(candidate.itemId); - } - } - - for (const event of ordered) { - if (!shouldIncludeEvent(event)) continue; - - const lifecycleCandidate = lifecycleCandidateFromItemEvent(event); - if (lifecycleCandidate) { - if ( - lifecycleCandidate.phase === "started" && - lifecycleCandidate.itemId && - completedLifecycleItemIds.has(lifecycleCandidate.itemId) - ) { - continue; - } - if (lifecycleCandidate.label === "Tool call" && !lifecycleCandidate.detail) { - continue; - } - - entries.push({ - id: lifecycleCandidate.id, - createdAt: lifecycleCandidate.createdAt, - label: lifecycleCandidate.label, - ...(lifecycleCandidate.detail ? { detail: lifecycleCandidate.detail } : {}), - tone: lifecycleCandidate.tone, - }); - continue; - } - - const fromRequest = entryFromRequest(event); - if (fromRequest) { - entries.push(fromRequest); - continue; - } - - const fromNotification = entryFromNotification(event); - if (fromNotification) { - entries.push(fromNotification); - continue; - } - - const fromError = entryFromError(event); - if (fromError) { - entries.push(fromError); - } - } - - return entries; -} - -interface MutableTurnDiffSummary { - turnId: string; - completedAt: string | undefined; - status: string | undefined; - assistantMessageId: string | undefined; - hasTurnDiffUpdate: boolean; - filesByPath: Map; -} - -function parseFileChangeEntriesFromEvent(event: ProviderEvent): TurnDiffFileChange[] { - if (event.method !== "item/completed") { - return []; - } - - const item = asObject(asObject(event.payload)?.item); - const itemType = normalizeItemType(asString(item?.type)); - if (itemType !== "file change") { - return []; - } - - const changes = asArray(item?.changes) ?? []; - const parsed: TurnDiffFileChange[] = []; - for (const rawChange of changes) { - const change = asObject(rawChange); - if (!change) continue; - - const diff = normalizeDetail(asString(change.diff)); - const path = normalizeDetail(asString(change.path)) ?? (diff ? parsePathFromDiff(diff) : undefined); - if (!path) continue; - - const additions = asNumber(change.additions); - const deletions = asNumber(change.deletions); - const stat = additions !== undefined && deletions !== undefined ? null : diff ? countDiffStat(diff) : null; - const kind = normalizeDetail(asString(change.kind)); - parsed.push({ - path, - ...(kind ? { kind } : {}), - ...(additions !== undefined ? { additions } : stat ? { additions: stat.additions } : {}), - ...(deletions !== undefined ? { deletions } : stat ? { deletions: stat.deletions } : {}), - }); - } - - return parsed; -} - -function mergeTurnDiffFileChange( - byPath: Map, - incoming: TurnDiffFileChange, -): void { - const existing = byPath.get(incoming.path); - if (!existing) { - byPath.set(incoming.path, incoming); - return; - } - - if (existing.kind === undefined && incoming.kind !== undefined) { - existing.kind = incoming.kind; - } - if (existing.additions === undefined && incoming.additions !== undefined) { - existing.additions = incoming.additions; - } - if (existing.deletions === undefined && incoming.deletions !== undefined) { - existing.deletions = incoming.deletions; - } + return []; } -function parsePathFromDiff(diff: string): string | undefined { - const normalized = diff.replace(/\r\n/g, "\n"); - const bPath = normalized.match(/^\+\+\+ b\/(.+)$/m); - if (bPath && bPath[1]) return bPath[1]; - const gitHeader = normalized.match(/^diff --git a\/(.+) b\/\1$/m); - if (gitHeader && gitHeader[1]) return gitHeader[1]; - const direct = normalized.match(/^\+\+\+ (.+)$/m); - if (!direct || !direct[1] || direct[1] === "/dev/null") { - return undefined; - } - return direct[1]; -} - -export function splitUnifiedDiffByFile(diff: string): Map { - const normalized = diff.replace(/\r\n/g, "\n"); - const byPath = new Map(); - const headerMatches = [...normalized.matchAll(/^diff --git .+$/gm)]; - - if (headerMatches.length === 0) { - const path = parsePathFromDiff(normalized); - if (path) { - byPath.set(path, normalized.trim()); - } - return byPath; - } - - for (let index = 0; index < headerMatches.length; index += 1) { - const match = headerMatches[index]; - if (!match) continue; - const start = match.index ?? 0; - const nextStart = headerMatches[index + 1]?.index ?? normalized.length; - const segment = normalized.slice(start, nextStart).trim(); - const path = parsePathFromDiff(segment); - if (!path || segment.length === 0) continue; - byPath.set(path, segment); - } - - return byPath; +export function deriveTimelineEntries( + messages: ChatMessage[], + workEntries: WorkLogEntry[], +): TimelineEntry[] { + const messageRows: TimelineEntry[] = messages.map((message) => ({ + id: message.id, + kind: "message", + createdAt: message.createdAt, + message, + })); + const workRows: TimelineEntry[] = workEntries.map((entry) => ({ + id: entry.id, + kind: "work", + createdAt: entry.createdAt, + entry, + })); + return [...messageRows, ...workRows].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] { - const fileDiffsByPath = splitUnifiedDiffByFile(diff); - return Array.from(fileDiffsByPath.entries()) - .map(([path, fileDiff]) => { - const stat = countDiffStat(fileDiff); - return { - path, - additions: stat.additions, - deletions: stat.deletions, - }; - }) - .toSorted((a, b) => a.path.localeCompare(b.path)); + const files: TurnDiffFileChange[] = []; + const lines = diff.split("\n"); + for (const line of lines) { + if (!line.startsWith("diff --git ")) continue; + const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/); + const filePath = match?.[2] ?? match?.[1]; + if (!filePath) continue; + files.push({ path: filePath }); + } + return files; } export function inferCheckpointTurnCountByTurnId( summaries: TurnDiffSummary[], ): Record { - if (summaries.length === 0) { - return {}; - } - - const sorted = [...summaries].toSorted((a, b) => { - const aTime = Date.parse(a.completedAt); - const bTime = Date.parse(b.completedAt); - if (Number.isNaN(aTime) || Number.isNaN(bTime)) { - return a.completedAt.localeCompare(b.completedAt); - } - return aTime - bTime; - }); - - const next: Record = {}; - let fallbackTurnCount = 1; - for (const summary of sorted) { - if (typeof summary.checkpointTurnCount === "number") { - next[summary.turnId] = summary.checkpointTurnCount; - fallbackTurnCount = Math.max(fallbackTurnCount, summary.checkpointTurnCount + 1); - continue; - } - next[summary.turnId] = fallbackTurnCount; - fallbackTurnCount += 1; + const sorted = [...summaries].sort((a, b) => a.completedAt.localeCompare(b.completedAt)); + const result: Record = {}; + for (let index = 0; index < sorted.length; index += 1) { + const summary = sorted[index]; + if (!summary) continue; + result[summary.turnId] = index + 1; } - - return next; + return result; } -export function countDiffStat(patch: string | undefined): { additions: number; deletions: number } { - if (!patch) { - return { additions: 0, deletions: 0 }; - } - - let additions = 0; - let deletions = 0; - for (const line of patch.replace(/\r\n/g, "\n").split("\n")) { - if (line.startsWith("+++ ") || line.startsWith("--- ")) continue; - if (line.startsWith("+")) { - additions += 1; - continue; - } - if (line.startsWith("-")) { - deletions += 1; - } - } - - return { additions, deletions }; -} - -export function deriveTurnDiffSummaries(events: ProviderEvent[]): TurnDiffSummary[] { - const firstEventAt = Date.parse(events[0]?.createdAt ?? ""); - const lastEventAt = Date.parse(events.at(-1)?.createdAt ?? ""); - const ordered = - events.length < 2 || - Number.isNaN(firstEventAt) || - Number.isNaN(lastEventAt) || - firstEventAt >= lastEventAt - ? events - : [...events].toReversed(); - const byTurnId = new Map(); - - const ensureSummary = (turnId: string): MutableTurnDiffSummary => { - const existing = byTurnId.get(turnId); - if (existing) return existing; - const next: MutableTurnDiffSummary = { - turnId, - completedAt: undefined, - status: undefined, - assistantMessageId: undefined, - hasTurnDiffUpdate: false, - filesByPath: new Map(), - }; - byTurnId.set(turnId, next); - return next; - }; - - for (const event of ordered) { - const turnId = eventTurnId(event); - if (!turnId) continue; - const summary = ensureSummary(turnId); - - if (event.method === "turn/completed") { - if (summary.completedAt === undefined) { - summary.completedAt = event.createdAt; - } - const turn = asObject(asObject(event.payload)?.turn); - if (summary.status === undefined) { - summary.status = normalizeDetail(asString(turn?.status)); - } - } - - if (event.method === "item/completed") { - const item = asObject(asObject(event.payload)?.item); - if (asString(item?.type) === "agentMessage") { - const itemId = normalizeDetail(asString(item?.id)); - if (itemId && summary.assistantMessageId === undefined) { - summary.assistantMessageId = itemId; - } - } - } - - if (event.method === "turn/diff/updated" && !summary.hasTurnDiffUpdate) { - summary.hasTurnDiffUpdate = true; - const diff = normalizeDetail(asString(asObject(event.payload)?.diff)); - if (diff) { - for (const file of deriveTurnDiffFilesFromUnifiedDiff(diff)) { - mergeTurnDiffFileChange(summary.filesByPath, file); - } - } - } - - for (const file of parseFileChangeEntriesFromEvent(event)) { - mergeTurnDiffFileChange(summary.filesByPath, file); - } - } - - const summaries: TurnDiffSummary[] = []; - for (const summary of byTurnId.values()) { - if (!summary.completedAt) continue; - - summaries.push({ - turnId: summary.turnId, - completedAt: summary.completedAt, - ...(summary.status ? { status: summary.status } : {}), - files: Array.from(summary.filesByPath.values()).toSorted((a, b) => - a.path.localeCompare(b.path), - ), - ...(summary.assistantMessageId ? { assistantMessageId: summary.assistantMessageId } : {}), - }); - } - - summaries.sort((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; - }); - - return summaries; -} - -function toTimestamp(isoDate: string): number { - const parsed = Date.parse(isoDate); - return Number.isNaN(parsed) ? Number.POSITIVE_INFINITY : parsed; -} - -export function deriveTimelineEntries( - messages: ChatMessage[], - workEntries: WorkLogEntry[], -): TimelineEntry[] { - const timeline: TimelineEntry[] = []; - let messageIndex = 0; - let workIndex = 0; - - while (messageIndex < messages.length || workIndex < workEntries.length) { - const message = messages[messageIndex]; - const workEntry = workEntries[workIndex]; - - if (!message && workEntry) { - timeline.push({ - id: `work:${workEntry.id}`, - kind: "work", - createdAt: workEntry.createdAt, - entry: workEntry, - }); - workIndex += 1; - continue; - } - - if (!workEntry && message) { - timeline.push({ - id: `message:${message.id}`, - kind: "message", - createdAt: message.createdAt, - message, - }); - messageIndex += 1; - continue; - } - - if (!message || !workEntry) { - break; - } - - const messageAt = toTimestamp(message.createdAt); - const workAt = toTimestamp(workEntry.createdAt); - - if (workAt <= messageAt) { - timeline.push({ - id: `work:${workEntry.id}`, - kind: "work", - createdAt: workEntry.createdAt, - entry: workEntry, - }); - workIndex += 1; - continue; - } - - timeline.push({ - id: `message:${message.id}`, - kind: "message", - createdAt: message.createdAt, - message, - }); - messageIndex += 1; - } - - return timeline; +export function deriveTurnDiffSummaries(_events: unknown[]): TurnDiffSummary[] { + return []; } export function derivePhase(session: ProviderSession | null): SessionPhase { @@ -789,162 +136,3 @@ export function derivePhase(session: ProviderSession | null): SessionPhase { if (session.status === "running") return "running"; return "ready"; } - -export function evolveSession(previous: ProviderSession, event: ProviderEvent): ProviderSession { - 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 }; -} - -export function applyEventToMessages( - previous: ChatMessage[], - event: ProviderEvent, - activeAssistantItemRef: { current: string | null }, -): 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; - - activeAssistantItemRef.current = itemId; - const seedText = asString(item?.text) ?? ""; - const filtered = previous.filter((entry) => entry.id !== itemId); - return [ - ...filtered, - { - 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 existingIndex = previous.findIndex((entry) => entry.id === itemId); - if (existingIndex === -1) { - activeAssistantItemRef.current = itemId; - return [ - ...previous, - { - id: itemId, - role: "assistant", - text: delta, - createdAt: event.createdAt, - streaming: true, - }, - ]; - } - - const updated = [...previous]; - const existing = updated[existingIndex]; - if (!existing) return previous; - updated[existingIndex] = { - ...existing, - text: `${existing.text}${delta}`, - streaming: true, - }; - return updated; - } - - 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 existingIndex = previous.findIndex((entry) => entry.id === itemId); - if (existingIndex === -1) { - return [ - ...previous, - { - id: itemId, - role: "assistant", - text: fullText ?? "", - createdAt: event.createdAt, - streaming: false, - }, - ]; - } - - const updated = [...previous]; - const existing = updated[existingIndex]; - if (!existing) return previous; - updated[existingIndex] = { - ...existing, - text: fullText ?? existing.text, - streaming: false, - }; - - if (activeAssistantItemRef.current === itemId) { - activeAssistantItemRef.current = null; - } - return updated; - } - - if (event.method === "turn/completed") { - return previous.map((entry) => ({ ...entry, streaming: false })); - } - - return previous; -} diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts deleted file mode 100644 index 6acd1e2859ec7..0000000000000 --- 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 cb45b5506cdcd..1df450ac06a24 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -8,19 +8,13 @@ import { useReducer, } from "react"; -import { type ProviderEvent, type ProviderSession, type TerminalEvent, normalizeProjectScripts } from "@t3tools/contracts"; -import { resolveModelSlug } from "./model-logic"; -import { hydratePersistedState, toPersistedState } from "./persistenceSchema"; import { - applyEventToMessages, - asObject, - asString, - deriveTurnDiffSummaries, - inferCheckpointTurnCountByTurnId, - evolveSession, -} from "./session-logic"; + type OrchestrationReadModel, + normalizeProjectScripts, +} from "@t3tools/contracts"; +import { resolveModelSlug } from "./model-logic"; import { - type ChatAttachment, + DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, DEFAULT_RUNTIME_MODE, MAX_THREAD_TERMINAL_COUNT, @@ -34,13 +28,10 @@ import { // ── Actions ────────────────────────────────────────────────────────── type Action = - | { type: "ADD_PROJECT"; project: Project } + | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } - | { type: "SYNC_PROJECTS"; projects: Project[] } | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } | { type: "TOGGLE_PROJECT"; projectId: string } - | { type: "DELETE_PROJECT"; projectId: string } - | { type: "ADD_THREAD"; thread: Thread } | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } | { type: "SET_THREAD_TERMINAL_HEIGHT"; threadId: string; height: number } @@ -48,46 +39,14 @@ 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"; - threadId: string; - id: string; - text: string; - attachments?: ChatAttachment[]; - } | { type: "SET_ERROR"; threadId: string; error: string | null } - | { type: "SET_THREAD_TITLE"; threadId: string; title: string } - | { type: "SET_THREAD_MODEL"; threadId: string; model: string } - | { - type: "REVERT_TO_CHECKPOINT"; - threadId: string; - sessionId: string; - threadRuntimeId: string; - turnCount: number; - messageCount: number; - } - | { - type: "SET_THREAD_TURN_CHECKPOINT_COUNTS"; - threadId: string; - checkpointTurnCountByTurnId: Record; - } | { type: "SET_THREAD_BRANCH"; threadId: string; branch: string | null; worktreePath: string | null; } - | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string } - | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode } - | { type: "DELETE_THREAD"; threadId: string }; + | { type: "SET_RUNTIME_MODE"; mode: RuntimeMode }; // ── State ──────────────────────────────────────────────────────────── @@ -116,6 +75,7 @@ const initialState: AppState = { threadsHydrated: false, runtimeMode: DEFAULT_RUNTIME_MODE, }; +const persistedExpandedProjectCwds = new Set(); // ── Helpers ────────────────────────────────────────────────────────── @@ -123,21 +83,24 @@ 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; + const raw = window.localStorage.getItem(PERSISTED_STATE_KEY); 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)); - + const parsed = JSON.parse(raw) as { + runtimeMode?: RuntimeMode; + expandedProjectCwds?: string[]; + }; + persistedExpandedProjectCwds.clear(); + for (const cwd of parsed.expandedProjectCwds ?? []) { + if (typeof cwd === "string" && cwd.length > 0) { + persistedExpandedProjectCwds.add(cwd); + } + } return { - ...hydrated, - threads, - threadsHydrated: threads.length > 0, + ...initialState, + runtimeMode: + parsed.runtimeMode === "approval-required" || parsed.runtimeMode === "full-access" + ? parsed.runtimeMode + : DEFAULT_RUNTIME_MODE, }; } catch { return initialState; @@ -148,7 +111,15 @@ function persistState(state: AppState): void { if (typeof window === "undefined") return; try { - window.localStorage.setItem(PERSISTED_STATE_KEY, JSON.stringify(toPersistedState(state))); + window.localStorage.setItem( + PERSISTED_STATE_KEY, + JSON.stringify({ + runtimeMode: state.runtimeMode, + expandedProjectCwds: state.projects + .filter((project) => project.expanded) + .map((project) => project.cwd), + }), + ); for (const legacyKey of LEGACY_PERSISTED_STATE_KEYS) { window.localStorage.removeItem(legacyKey); } @@ -165,63 +136,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); @@ -409,108 +323,80 @@ 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 "ADD_PROJECT": - if (state.projects.some((project) => project.cwd === action.project.cwd)) { - return state; - } + case "SYNC_SERVER_READ_MODEL": { + const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); + const projects = action.readModel.projects.map((project) => { + const existing = state.projects.find((entry) => entry.id === project.id); + return { + id: project.id, + name: project.name, + cwd: project.cwd, + model: resolveModelSlug(project.model), + expanded: + existing?.expanded ?? + (persistedExpandedProjectCwds.size > 0 + ? persistedExpandedProjectCwds.has(project.cwd) + : true), + scripts: existing?.scripts ?? [], + }; + }); + const threads = action.readModel.threads.map((thread) => { + const existing = existingThreadById.get(thread.id); + return normalizeThreadTerminals({ + id: thread.id, + codexThreadId: thread.session?.threadId ?? null, + projectId: thread.projectId, + title: thread.title, + model: resolveModelSlug(thread.model), + terminalOpen: existing?.terminalOpen ?? false, + terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID], + runningTerminalIds: existing?.runningTerminalIds ?? [], + activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: existing?.terminalGroups ?? [ + { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, + ], + activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, + session: thread.session + ? { + sessionId: thread.session.sessionId, + provider: thread.session.provider, + status: thread.session.status, + threadId: thread.session.threadId, + activeTurnId: thread.session.activeTurnId ?? undefined, + createdAt: thread.session.createdAt, + updatedAt: thread.session.updatedAt, + ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), + } + : null, + messages: thread.messages.map((message) => ({ ...message })), + error: thread.error, + createdAt: thread.createdAt, + latestTurnId: thread.latestTurnId ?? undefined, + latestTurnStartedAt: thread.latestTurnStartedAt ?? undefined, + latestTurnCompletedAt: thread.latestTurnCompletedAt ?? undefined, + latestTurnDurationMs: thread.latestTurnDurationMs ?? undefined, + lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + turnDiffSummaries: thread.turnDiffSummaries.map((summary) => ({ + ...summary, + files: summary.files.map((file) => ({ ...file })), + })), + }); + }); return { ...state, - projects: [ - ...state.projects, - { - ...action.project, - model: resolveModelSlug(action.project.model), - scripts: normalizeProjectScripts(action.project.scripts), - }, - ], + projects, + threads, + threadsHydrated: true, }; + } case "SET_PROJECT_SCRIPTS": return { @@ -531,46 +417,6 @@ export function reducer(state: AppState, action: Action): AppState { threadsHydrated: action.hydrated, }; - case "SYNC_PROJECTS": { - const previousByCwd = new Map( - state.projects.map((project) => [project.cwd, project] as const), - ); - const nextProjects = action.projects.map((project) => { - const previous = previousByCwd.get(project.cwd); - const scripts = normalizeProjectScripts(project.scripts); - return { - ...project, - model: resolveModelSlug(previous?.model ?? project.model), - expanded: previous?.expanded ?? project.expanded, - scripts, - }; - }); - const previousProjectById = new Map( - state.projects.map((project) => [project.id, project] as const), - ); - const nextProjectIdByCwd = new Map( - nextProjects.map((project) => [project.cwd, project.id] as const), - ); - const nextThreads = state.threads - .map((thread) => { - const previousProject = previousProjectById.get(thread.projectId); - if (!previousProject) return null; - const mappedProjectId = nextProjectIdByCwd.get(previousProject.cwd); - if (!mappedProjectId) return null; - return normalizeThreadTerminals({ - ...thread, - projectId: mappedProjectId, - }); - }) - .filter((thread): thread is Thread => thread !== null); - - return { - ...state, - projects: nextProjects, - threads: nextThreads, - }; - } - case "TOGGLE_PROJECT": return { ...state, @@ -579,34 +425,6 @@ export function reducer(state: AppState, action: Action): AppState { ), }; - case "DELETE_PROJECT": { - const projects = state.projects.filter((project) => project.id !== action.projectId); - if (projects.length === state.projects.length) { - return state; - } - - const threads = state.threads.filter((thread) => thread.projectId !== action.projectId); - - return { - ...state, - projects, - threads, - }; - } - - case "ADD_THREAD": { - const nextThread = normalizeThreadTerminals({ - ...action.thread, - model: resolveModelSlug(action.thread.model), - lastVisitedAt: action.thread.lastVisitedAt ?? action.thread.createdAt, - turnDiffSummaries: action.thread.turnDiffSummaries ?? [], - }); - return { - ...state, - threads: [...state.threads, nextThread], - }; - } - case "TOGGLE_THREAD_TERMINAL": return { ...state, @@ -774,118 +592,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, - threads: updateThread(state.threads, action.threadId, (t) => ({ - ...t, - session: action.session, - codexThreadId: action.session.threadId ?? t.codexThreadId, - events: [], - error: null, - latestTurnId: undefined, - latestTurnStartedAt: undefined, - latestTurnCompletedAt: undefined, - latestTurnDurationMs: undefined, - })), - }; - - case "PUSH_USER_MESSAGE": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => ({ - ...t, - messages: [ - ...t.messages, - { - id: action.id, - role: "user" as const, - text: action.text, - ...(action.attachments && action.attachments.length > 0 - ? { attachments: action.attachments } - : {}), - createdAt: new Date().toISOString(), - streaming: false, - }, - ], - })), - }; - case "SET_ERROR": return { ...state, @@ -895,90 +601,6 @@ export function reducer(state: AppState, action: Action): AppState { })), }; - case "SET_THREAD_TITLE": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => ({ - ...t, - title: action.title, - })), - }; - - case "SET_THREAD_MODEL": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => ({ - ...t, - model: resolveModelSlug(action.model), - })), - }; - - case "REVERT_TO_CHECKPOINT": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => { - const nextMessageCount = Math.max(0, Math.floor(action.messageCount)); - const nextTurnCount = Math.max(0, Math.floor(action.turnCount)); - const now = new Date().toISOString(); - return { - ...t, - codexThreadId: action.threadRuntimeId, - session: - t.session?.sessionId === action.sessionId - ? { - ...t.session, - status: "ready", - threadId: action.threadRuntimeId, - activeTurnId: undefined, - updatedAt: now, - lastError: undefined, - } - : t.session, - messages: t.messages.slice(0, nextMessageCount), - events: [], - turnDiffSummaries: t.turnDiffSummaries.filter( - (summary) => - typeof summary.checkpointTurnCount === "number" && - summary.checkpointTurnCount <= nextTurnCount, - ), - error: null, - latestTurnId: undefined, - latestTurnStartedAt: undefined, - latestTurnCompletedAt: undefined, - latestTurnDurationMs: undefined, - lastVisitedAt: now, - }; - }), - }; - - case "SET_THREAD_TURN_CHECKPOINT_COUNTS": - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => { - const hasUpdates = t.turnDiffSummaries.some( - (summary) => - action.checkpointTurnCountByTurnId[summary.turnId] !== undefined && - action.checkpointTurnCountByTurnId[summary.turnId] !== summary.checkpointTurnCount, - ); - if (!hasUpdates) { - return t; - } - return { - ...t, - turnDiffSummaries: t.turnDiffSummaries.map((summary) => { - const turnCount = action.checkpointTurnCountByTurnId[summary.turnId]; - if (turnCount === undefined) { - return summary; - } - return { - ...summary, - checkpointTurnCount: turnCount, - }; - }), - }; - }), - }; - case "SET_THREAD_BRANCH": { return { ...state, @@ -997,29 +619,12 @@ export function reducer(state: AppState, action: Action): AppState { }; } - case "MARK_THREAD_VISITED": { - const visitedAt = action.visitedAt ?? new Date().toISOString(); - return { - ...state, - threads: updateThread(state.threads, action.threadId, (t) => ({ - ...t, - lastVisitedAt: visitedAt, - })), - }; - } - case "SET_RUNTIME_MODE": return { ...state, runtimeMode: action.mode, }; - case "DELETE_THREAD": - return { - ...state, - threads: state.threads.filter((t) => t.id !== action.threadId), - }; - default: return state; } diff --git a/apps/web/src/threadFactory.ts b/apps/web/src/threadFactory.ts index 8b84491640fa1..01a638140b98c 100644 --- a/apps/web/src/threadFactory.ts +++ b/apps/web/src/threadFactory.ts @@ -32,7 +32,6 @@ export function createThread(projectId: string, options: CreateThreadOptions = { activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, session: null, messages: [], - events: [], turnDiffSummaries: [], error: null, createdAt, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index e2902680bf354..88188e426da5f 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,6 +1,5 @@ import type { ProjectScript as ContractProjectScript, - ProviderEvent, ProviderSession, } from "@t3tools/contracts"; @@ -77,7 +76,6 @@ export interface Thread { activeTerminalGroupId: string; session: ProviderSession | null; messages: ChatMessage[]; - events: ProviderEvent[]; error: string | null; createdAt: string; latestTurnId?: string | undefined; diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index d56270697e34b..423b86d3fc7d1 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -24,7 +24,6 @@ function makeThread(overrides: Partial = {}): Thread { activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, session: null, messages: [], - events: [], turnDiffSummaries: [], error: null, createdAt: "2026-02-13T00:00:00.000Z", diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 9eacc9ed1f29f..7a52f75365bb7 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -1,4 +1,13 @@ -import { type NativeApi, WS_CHANNELS, WS_METHODS, type WsWelcomePayload } from "@t3tools/contracts"; +import { + ORCHESTRATION_WS_CHANNELS, + ORCHESTRATION_WS_METHODS, + type NativeApi, + WS_CHANNELS, + WS_METHODS, + type OrchestrationEvent, + type OrchestrationReadModel, + type WsWelcomePayload, +} from "@t3tools/contracts"; import { showContextMenuFallback } from "./contextMenuFallback"; import { WsTransport } from "./wsTransport"; @@ -90,13 +99,10 @@ export function createWsNativeApi(): NativeApi { interruptTurn: (input) => transport.request(WS_METHODS.providersInterruptTurn, input), respondToRequest: (input) => transport.request(WS_METHODS.providersRespondToRequest, input), stopSession: (input) => transport.request(WS_METHODS.providersStopSession, input), - listSessions: () => transport.request(WS_METHODS.providersListSessions), listCheckpoints: (input) => transport.request(WS_METHODS.providersListCheckpoints, input), 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), }, projects: { list: () => transport.request(WS_METHODS.projectsList), @@ -149,6 +155,26 @@ export function createWsNativeApi(): NativeApi { getConfig: () => transport.request(WS_METHODS.serverGetConfig), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), }, + orchestration: { + getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), + dispatchCommand: (command) => + transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command), + replayEvents: (fromSequenceExclusive) => + transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), + onReadModel: (callback) => + transport.subscribe( + ORCHESTRATION_WS_CHANNELS.readModel, + (data: unknown) => { + const payload = data as { snapshot: OrchestrationReadModel }; + callback(payload.snapshot); + }, + ), + onDomainEvent: (callback) => + transport.subscribe( + ORCHESTRATION_WS_CHANNELS.domainEvent, + callback as (data: unknown) => void, + ), + }, }; instance = { api, transport }; diff --git a/bun.lock b/bun.lock index 1798b17ea0a7a..ae9ab554c3c50 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,8 @@ "t3": "./dist/index.mjs", }, "dependencies": { + "better-sqlite3": "^12.6.2", + "effect": "^3.19.19", "node-pty": "^1.1.0", "open": "^10.1.0", "ws": "^8.18.0", @@ -41,6 +43,7 @@ "devDependencies": { "@t3tools/contracts": "workspace:*", "@t3tools/web": "workspace:*", + "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.2", "@types/ws": "^8.5.13", "tsdown": "^0.20.3", @@ -87,6 +90,7 @@ "name": "@t3tools/contracts", "version": "0.0.0", "dependencies": { + "effect": "^3.19.19", "zod": "^3.24.1", }, "devDependencies": { @@ -494,6 +498,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], + "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -582,18 +588,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 +640,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 +674,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 +706,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 +748,30 @@ "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=="], "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 +790,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 +840,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,6 +1044,8 @@ "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=="], "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=="], @@ -1014,6 +1054,10 @@ "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-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], @@ -1060,6 +1104,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 +1116,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 +1132,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 +1184,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 +1204,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 +1226,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 +1254,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 +1292,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=="], @@ -1276,6 +1344,8 @@ "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=="], + "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=="], @@ -1366,6 +1436,8 @@ "@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/better-sqlite3/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], + "@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=="], @@ -1422,6 +1494,8 @@ "@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/better-sqlite3/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "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=="], diff --git a/packages/contracts/package.json b/packages/contracts/package.json index bed19b011ad96..87ccb84cef60a 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -23,6 +23,7 @@ "test": "vitest run" }, "dependencies": { + "effect": "^3.19.19", "zod": "^3.24.1" }, "devDependencies": { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 5e705e418f4a6..fea1328075006 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -9,3 +9,4 @@ export * from "./keybindings"; export * from "./server"; export * from "./project"; export * from "./git"; +export * from "./orchestration"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ea5bc6ec4de8f..b3649d0907f84 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -16,7 +16,6 @@ import type { GitStatusResult, } from "./git"; import type { - ProviderEvent, ProviderGetCheckpointDiffInput, ProviderGetCheckpointDiffResult, ProviderInterruptTurnInput, @@ -53,6 +52,11 @@ import type { } from "./terminal"; import type { NewTodoInput, Todo } from "./todo"; import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; +import type { + OrchestrationCommand, + OrchestrationEvent, + OrchestrationReadModel, +} from "./orchestration"; export const EDITORS = [ { id: "cursor", label: "Cursor", command: "cursor" }, @@ -94,7 +98,6 @@ export interface NativeApi { interruptTurn: (input: ProviderInterruptTurnInput) => Promise; respondToRequest: (input: ProviderRespondToRequestInput) => Promise; stopSession: (input: ProviderStopSessionInput) => Promise; - listSessions: () => Promise; listCheckpoints: ( input: ProviderListCheckpointsInput, ) => Promise; @@ -104,7 +107,6 @@ export interface NativeApi { revertToCheckpoint: ( input: ProviderRevertToCheckpointInput, ) => Promise; - onEvent: (callback: (event: ProviderEvent) => void) => () => void; }; projects: { list: () => Promise; @@ -140,4 +142,11 @@ export interface NativeApi { getConfig: () => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; }; + orchestration: { + getSnapshot: () => Promise; + dispatchCommand: (command: OrchestrationCommand) => Promise<{ sequence: number }>; + replayEvents: (fromSequenceExclusive: number) => Promise; + onReadModel: (callback: (snapshot: OrchestrationReadModel) => void) => () => void; + onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void; + }; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts new file mode 100644 index 0000000000000..2e1ee674cca40 --- /dev/null +++ b/packages/contracts/src/orchestration.ts @@ -0,0 +1,223 @@ +import { Schema } from "effect"; + +export const ORCHESTRATION_WS_METHODS = { + getSnapshot: "orchestration.getSnapshot", + dispatchCommand: "orchestration.dispatchCommand", + replayEvents: "orchestration.replayEvents", +} as const; + +export const ORCHESTRATION_WS_CHANNELS = { + readModel: "orchestration.readModel", + domainEvent: "orchestration.domainEvent", +} as const; + +const IsoDateTimeSchema = Schema.String; +const IdSchema = Schema.String; + +export const OrchestrationMessageSchema = Schema.Struct({ + id: IdSchema, + role: Schema.Literal("user", "assistant"), + text: Schema.String, + createdAt: IsoDateTimeSchema, + streaming: Schema.Boolean, +}); + +export type OrchestrationMessage = Schema.Schema.Type; + +export const OrchestrationSessionSchema = Schema.Struct({ + sessionId: IdSchema, + status: Schema.Literal("connecting", "ready", "running", "error", "closed"), + provider: Schema.Literal("codex", "claudeCode"), + threadId: IdSchema, + activeTurnId: Schema.NullOr(Schema.String), + createdAt: IsoDateTimeSchema, + updatedAt: IsoDateTimeSchema, + lastError: Schema.NullOr(Schema.String), +}); + +export type OrchestrationSession = Schema.Schema.Type; + +export const OrchestrationTurnDiffFileSchema = Schema.Struct({ + path: Schema.String, + kind: Schema.optional(Schema.String), + additions: Schema.optional(Schema.Number), + deletions: Schema.optional(Schema.Number), +}); + +export type OrchestrationTurnDiffFile = Schema.Schema.Type; + +export const OrchestrationTurnDiffSummarySchema = Schema.Struct({ + turnId: IdSchema, + completedAt: IsoDateTimeSchema, + status: Schema.optional(Schema.String), + files: Schema.Array(OrchestrationTurnDiffFileSchema), + assistantMessageId: Schema.optional(Schema.String), + checkpointTurnCount: Schema.optional(Schema.Number), +}); + +export type OrchestrationTurnDiffSummary = Schema.Schema.Type< + typeof OrchestrationTurnDiffSummarySchema +>; + +export const OrchestrationThreadSchema = Schema.Struct({ + id: IdSchema, + projectId: IdSchema, + title: Schema.String, + model: Schema.String, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + createdAt: IsoDateTimeSchema, + updatedAt: IsoDateTimeSchema, + latestTurnId: Schema.NullOr(Schema.String), + latestTurnStartedAt: Schema.NullOr(Schema.String), + latestTurnCompletedAt: Schema.NullOr(Schema.String), + latestTurnDurationMs: Schema.NullOr(Schema.Number), + messages: Schema.Array(OrchestrationMessageSchema), + session: Schema.NullOr(OrchestrationSessionSchema), + turnDiffSummaries: Schema.Array(OrchestrationTurnDiffSummarySchema), + error: Schema.NullOr(Schema.String), +}); + +export type OrchestrationThread = Schema.Schema.Type; + +export const OrchestrationProjectSchema = Schema.Struct({ + id: IdSchema, + name: Schema.String, + cwd: Schema.String, + model: Schema.String, + createdAt: IsoDateTimeSchema, + updatedAt: IsoDateTimeSchema, +}); + +export type OrchestrationProject = Schema.Schema.Type; + +export const OrchestrationGitReadModelSchema = Schema.Struct({ + projectId: IdSchema, + branch: Schema.NullOr(Schema.String), + hasWorkingTreeChanges: Schema.Boolean, + aheadCount: Schema.Number, + behindCount: Schema.Number, + updatedAt: IsoDateTimeSchema, +}); + +export type OrchestrationGitReadModel = Schema.Schema.Type; + +export const OrchestrationReadModelSchema = Schema.Struct({ + sequence: Schema.Number, + projects: Schema.Array(OrchestrationProjectSchema), + threads: Schema.Array(OrchestrationThreadSchema), + gitByProjectId: Schema.Record({ key: Schema.String, value: OrchestrationGitReadModelSchema }), + updatedAt: IsoDateTimeSchema, +}); + +export type OrchestrationReadModel = Schema.Schema.Type; + +export const CreateProjectCommandSchema = Schema.Struct({ + type: Schema.Literal("project.create"), + commandId: IdSchema, + projectId: IdSchema, + name: Schema.String, + cwd: Schema.String, + model: Schema.String, + createdAt: IsoDateTimeSchema, +}); + +export const DeleteProjectCommandSchema = Schema.Struct({ + type: Schema.Literal("project.delete"), + commandId: IdSchema, + projectId: IdSchema, + createdAt: IsoDateTimeSchema, +}); + +export const CreateThreadCommandSchema = Schema.Struct({ + type: Schema.Literal("thread.create"), + commandId: IdSchema, + threadId: IdSchema, + projectId: IdSchema, + title: Schema.String, + model: Schema.String, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + createdAt: IsoDateTimeSchema, +}); + +export const DeleteThreadCommandSchema = Schema.Struct({ + type: Schema.Literal("thread.delete"), + commandId: IdSchema, + threadId: IdSchema, + createdAt: IsoDateTimeSchema, +}); + +export const UpdateThreadMetaCommandSchema = Schema.Struct({ + type: Schema.Literal("thread.meta.update"), + commandId: IdSchema, + threadId: IdSchema, + title: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + branch: Schema.optional(Schema.NullOr(Schema.String)), + worktreePath: Schema.optional(Schema.NullOr(Schema.String)), + createdAt: IsoDateTimeSchema, +}); + +export const SendMessageCommandSchema = Schema.Struct({ + type: Schema.Literal("message.send"), + commandId: IdSchema, + threadId: IdSchema, + messageId: IdSchema, + role: Schema.Literal("user", "assistant"), + text: Schema.String, + streaming: Schema.optional(Schema.Boolean), + createdAt: IsoDateTimeSchema, +}); + +export const SetThreadSessionSchema = Schema.Struct({ + type: Schema.Literal("thread.session"), + commandId: IdSchema, + threadId: IdSchema, + session: OrchestrationSessionSchema, + createdAt: IsoDateTimeSchema, +}); + +export const UpsertGitReadModelCommandSchema = Schema.Struct({ + type: Schema.Literal("git.readModel.upsert"), + commandId: IdSchema, + projectId: IdSchema, + branch: Schema.NullOr(Schema.String), + hasWorkingTreeChanges: Schema.Boolean, + aheadCount: Schema.Number, + behindCount: Schema.Number, + createdAt: IsoDateTimeSchema, +}); + +export const OrchestrationCommandSchema = Schema.Union( + CreateProjectCommandSchema, + DeleteProjectCommandSchema, + CreateThreadCommandSchema, + DeleteThreadCommandSchema, + UpdateThreadMetaCommandSchema, + SendMessageCommandSchema, + SetThreadSessionSchema, + UpsertGitReadModelCommandSchema, +); + +export type OrchestrationCommand = Schema.Schema.Type; + +export const OrchestrationEventSchema = Schema.Struct({ + sequence: Schema.Number, + eventId: IdSchema, + type: Schema.String, + aggregateType: Schema.String, + aggregateId: Schema.String, + occurredAt: IsoDateTimeSchema, + commandId: Schema.NullOr(Schema.String), + payload: Schema.Unknown, +}); + +export type OrchestrationEvent = Schema.Schema.Type; + +export const OrchestrationReadModelPushSchema = Schema.Struct({ + sequence: Schema.Number, + snapshot: OrchestrationReadModelSchema, +}); + +export type OrchestrationReadModelPush = Schema.Schema.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 61f583354bd2a..22804a98cc59c 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -9,7 +9,6 @@ export const WS_METHODS = { providersInterruptTurn: "providers.interruptTurn", providersRespondToRequest: "providers.respondToRequest", providersStopSession: "providers.stopSession", - providersListSessions: "providers.listSessions", providersListCheckpoints: "providers.listCheckpoints", providersGetCheckpointDiff: "providers.getCheckpointDiff", providersRevertToCheckpoint: "providers.revertToCheckpoint", @@ -51,7 +50,6 @@ export const WS_METHODS = { // ── Push Event Channels ────────────────────────────────────────────── export const WS_CHANNELS = { - providerEvent: "providers.event", terminalEvent: "terminal.event", serverWelcome: "server.welcome", } as const; From c3b7cc713c7f67d5130096bb0f03e14485e26e72 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 21 Feb 2026 19:04:59 -0800 Subject: [PATCH 02/98] Persist turn diffs and checkpoint reverts in orchestration - map `checkpoint/captured` to `thread.turnDiff.complete` and record turn status/id - handle `thread.revert` to truncate messages and turn summaries in read model - migrate event store from `better-sqlite3` to `node:sqlite` and add safe close/idempotent stop tests --- apps/server/package.json | 2 - apps/server/src/orchestration/engine.test.ts | 194 +++++++++++++++++++ apps/server/src/orchestration/engine.ts | 56 +++++- apps/server/src/orchestration/eventStore.ts | 86 ++++++-- apps/server/src/orchestration/reducer.ts | 105 ++++++++++ apps/server/src/providerManager.test.ts | 15 +- apps/server/src/providerManager.ts | 34 +++- apps/server/src/wsServer.ts | 70 ++++++- bun.lock | 66 ------- packages/contracts/src/orchestration.ts | 24 +++ 10 files changed, 553 insertions(+), 99 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index e8e6a978eb172..5571a4a7cb488 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,7 +17,6 @@ "test": "vitest run" }, "dependencies": { - "better-sqlite3": "^12.6.2", "effect": "^3.19.19", "node-pty": "^1.1.0", "open": "^10.1.0", @@ -26,7 +25,6 @@ "devDependencies": { "@t3tools/contracts": "workspace:*", "@t3tools/web": "workspace:*", - "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.2", "@types/ws": "^8.5.13", "tsdown": "^0.20.3", diff --git a/apps/server/src/orchestration/engine.test.ts b/apps/server/src/orchestration/engine.test.ts index 527ff57f6aeed..d4a0123169c36 100644 --- a/apps/server/src/orchestration/engine.test.ts +++ b/apps/server/src/orchestration/engine.test.ts @@ -116,4 +116,198 @@ describe("OrchestrationEngine", () => { expect(events[1]?.type).toBe("project.deleted"); await engine.stop(); }); + + it("stores completed turn summaries even when no files changed", async () => { + const stateDir = makeTempDir("t3code-orchestration-turn-diff-"); + const engine = new OrchestrationEngine(stateDir); + const completedAt = new Date().toISOString(); + await engine.start(); + await engine.dispatch({ + type: "project.create", + commandId: "cmd-project-turn-diff", + projectId: "project-turn-diff", + name: "turn-diff", + cwd: "/tmp/turn-diff", + model: "gpt-5-codex", + createdAt: completedAt, + }); + await engine.dispatch({ + type: "thread.create", + commandId: "cmd-thread-turn-diff", + threadId: "thread-turn-diff", + projectId: "project-turn-diff", + title: "Turn diff thread", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: completedAt, + }); + await engine.dispatch({ + type: "thread.turnDiff.complete", + commandId: "cmd-turn-diff-complete", + threadId: "thread-turn-diff", + turnId: "turn-1", + completedAt, + status: "completed", + files: [], + checkpointTurnCount: 1, + createdAt: completedAt, + }); + + const firstThread = engine.getSnapshot().threads.find((thread) => thread.id === "thread-turn-diff"); + expect(firstThread?.turnDiffSummaries).toEqual([ + { + turnId: "turn-1", + completedAt, + status: "completed", + files: [], + checkpointTurnCount: 1, + }, + ]); + await engine.stop(); + + const restartedEngine = new OrchestrationEngine(stateDir); + await restartedEngine.start(); + const restartedThread = restartedEngine + .getSnapshot() + .threads.find((thread) => thread.id === "thread-turn-diff"); + expect(restartedThread?.turnDiffSummaries).toEqual([ + { + turnId: "turn-1", + completedAt, + status: "completed", + files: [], + checkpointTurnCount: 1, + }, + ]); + await restartedEngine.stop(); + }); + + it("reverts thread messages and turn summaries to a checkpoint", async () => { + const stateDir = makeTempDir("t3code-orchestration-revert-"); + const engine = new OrchestrationEngine(stateDir); + const createdAt = new Date().toISOString(); + await engine.start(); + await engine.dispatch({ + type: "project.create", + commandId: "cmd-project-revert", + projectId: "project-revert", + name: "revert", + cwd: "/tmp/revert", + model: "gpt-5-codex", + createdAt, + }); + await engine.dispatch({ + type: "thread.create", + commandId: "cmd-thread-revert", + threadId: "thread-revert", + projectId: "project-revert", + title: "Revert thread", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt, + }); + await engine.dispatch({ + type: "message.send", + commandId: "cmd-msg-1-user", + threadId: "thread-revert", + messageId: "user-1", + role: "user", + text: "first", + streaming: false, + createdAt, + }); + await engine.dispatch({ + type: "message.send", + commandId: "cmd-msg-1-assistant", + threadId: "thread-revert", + messageId: "assistant:turn-1", + role: "assistant", + text: "first-response", + streaming: false, + createdAt, + }); + await engine.dispatch({ + type: "thread.turnDiff.complete", + commandId: "cmd-turn-1-complete", + threadId: "thread-revert", + turnId: "turn-1", + completedAt: createdAt, + status: "completed", + files: [], + assistantMessageId: "assistant:turn-1", + checkpointTurnCount: 1, + createdAt, + }); + + const createdAtSecond = new Date(Date.now() + 1_000).toISOString(); + await engine.dispatch({ + type: "message.send", + commandId: "cmd-msg-2-user", + threadId: "thread-revert", + messageId: "user-2", + role: "user", + text: "second", + streaming: false, + createdAt: createdAtSecond, + }); + await engine.dispatch({ + type: "message.send", + commandId: "cmd-msg-2-assistant", + threadId: "thread-revert", + messageId: "assistant:turn-2", + role: "assistant", + text: "second-response", + streaming: false, + createdAt: createdAtSecond, + }); + await engine.dispatch({ + type: "thread.turnDiff.complete", + commandId: "cmd-turn-2-complete", + threadId: "thread-revert", + turnId: "turn-2", + completedAt: createdAtSecond, + status: "completed", + files: [], + assistantMessageId: "assistant:turn-2", + checkpointTurnCount: 2, + createdAt: createdAtSecond, + }); + + await engine.dispatch({ + type: "thread.revert", + commandId: "cmd-thread-revert-apply", + threadId: "thread-revert", + turnCount: 1, + messageCount: 2, + createdAt: createdAtSecond, + }); + + const thread = engine.getSnapshot().threads.find((entry) => entry.id === "thread-revert"); + expect(thread?.messages.map((message) => message.id)).toEqual(["user-1", "assistant:turn-1"]); + expect(thread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); + expect(thread?.latestTurnId).toBe("turn-1"); + await engine.stop(); + + const restarted = new OrchestrationEngine(stateDir); + await restarted.start(); + const restartedThread = restarted + .getSnapshot() + .threads.find((entry) => entry.id === "thread-revert"); + expect(restartedThread?.messages.map((message) => message.id)).toEqual([ + "user-1", + "assistant:turn-1", + ]); + expect(restartedThread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); + await restarted.stop(); + }); + + it("allows stop to be called multiple times", async () => { + const stateDir = makeTempDir("t3code-orchestration-stop-"); + const engine = new OrchestrationEngine(stateDir); + await engine.start(); + await engine.stop(); + await expect(engine.stop()).resolves.toBeUndefined(); + }); }); diff --git a/apps/server/src/orchestration/engine.ts b/apps/server/src/orchestration/engine.ts index 5b93c507a3f94..28ef5e976e457 100644 --- a/apps/server/src/orchestration/engine.ts +++ b/apps/server/src/orchestration/engine.ts @@ -148,6 +148,42 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit()); - this.readModelPubSub = Runtime.runSync(this.runtime)(PubSub.unbounded()); + this.readModelPubSub = Runtime.runSync(this.runtime)( + PubSub.unbounded(), + ); this.eventPubSub = Runtime.runSync(this.runtime)(PubSub.unbounded()); } @@ -184,11 +222,15 @@ export class OrchestrationEngine { Effect.tryPromise({ try: async () => { const eventBase = mapCommandToEvent(envelope.command); - const savedEvent = await Runtime.runPromise(this.runtime)(this.eventStore.append(eventBase)); + const savedEvent = await Runtime.runPromise(this.runtime)( + this.eventStore.append(eventBase), + ); this.readModel = reduceEvent(this.readModel, savedEvent); await Promise.all([ Runtime.runPromise(this.runtime)(PubSub.publish(this.eventPubSub, savedEvent)), - Runtime.runPromise(this.runtime)(PubSub.publish(this.readModelPubSub, this.readModel)), + Runtime.runPromise(this.runtime)( + PubSub.publish(this.readModelPubSub, this.readModel), + ), ]); for (const listener of this.domainEventListeners) { listener(savedEvent); @@ -199,7 +241,8 @@ export class OrchestrationEngine { envelope.resolve({ sequence: savedEvent.sequence }); }, catch: (error) => { - const message = error instanceof Error ? error.message : "Unknown command processing error"; + const message = + error instanceof Error ? error.message : "Unknown command processing error"; envelope.reject(new Error(message)); return undefined; }, @@ -219,6 +262,7 @@ export class OrchestrationEngine { await Runtime.runPromise(this.runtime)(Fiber.interrupt(this.workerFiber)); this.workerFiber = null; } + this.eventStore.close(); } getSnapshot(): OrchestrationReadModel { @@ -226,7 +270,9 @@ export class OrchestrationEngine { } async replayEvents(fromSequenceExclusive: number): Promise { - return Runtime.runPromise(this.runtime)(this.eventStore.readFromSequence(fromSequenceExclusive)); + return Runtime.runPromise(this.runtime)( + this.eventStore.readFromSequence(fromSequenceExclusive), + ); } async dispatchUnknown(command: unknown): Promise<{ sequence: number }> { diff --git a/apps/server/src/orchestration/eventStore.ts b/apps/server/src/orchestration/eventStore.ts index bc54c9767d681..636879785c1f2 100644 --- a/apps/server/src/orchestration/eventStore.ts +++ b/apps/server/src/orchestration/eventStore.ts @@ -1,8 +1,9 @@ import fs from "node:fs"; import path from "node:path"; +import { DatabaseSync, StatementSync } from "node:sqlite"; +import type { SQLOutputValue } from "node:sqlite"; import type { OrchestrationEvent } from "@t3tools/contracts"; -import Database from "better-sqlite3"; import { Effect } from "effect"; interface EventRow { @@ -16,15 +17,56 @@ interface EventRow { payload_json: string; } +function readRequiredString( + row: Record, + column: keyof EventRow, +): string { + const value = row[column]; + if (typeof value !== "string") { + throw new Error(`Invalid SQLite value for ${String(column)}: expected string`); + } + return value; +} + +function readOptionalString( + row: Record, + column: keyof EventRow, +): string | null { + const value = row[column]; + if (value === null) return null; + if (typeof value !== "string") { + throw new Error(`Invalid SQLite value for ${String(column)}: expected string or null`); + } + return value; +} + +function readRequiredNumber( + row: Record, + column: keyof EventRow, +): number { + const value = row[column]; + if (typeof value === "number") return value; + if (typeof value === "bigint") return Number(value); + throw new Error(`Invalid SQLite value for ${String(column)}: expected number`); +} + export class SqliteEventStore { - private readonly db: Database.Database; + private readonly db: DatabaseSync; + private readonly insertStatement: StatementSync; + private closed = false; constructor(dbPath: string) { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); - this.db = new Database(dbPath); - this.db.pragma("journal_mode = WAL"); - this.db.pragma("foreign_keys = ON"); + this.db = new DatabaseSync(dbPath); + this.db.exec("PRAGMA journal_mode = WAL;"); + this.db.exec("PRAGMA foreign_keys = ON;"); this.migrate(); + this.insertStatement = this.db.prepare(` + INSERT INTO orchestration_events ( + event_id, event_type, aggregate_type, aggregate_id, occurred_at, command_id, payload_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); } private migrate(): void { @@ -47,13 +89,7 @@ export class SqliteEventStore { append(event: Omit): Effect.Effect { return Effect.sync(() => { - const stmt = this.db.prepare(` - INSERT INTO orchestration_events ( - event_id, event_type, aggregate_type, aggregate_id, occurred_at, command_id, payload_json - ) - VALUES (?, ?, ?, ?, ?, ?, ?) - `); - const result = stmt.run( + const result = this.insertStatement.run( event.eventId, event.type, event.aggregateType, @@ -82,16 +118,16 @@ export class SqliteEventStore { LIMIT ? `, ) - .all(sequenceExclusive, limit) as EventRow[]; + .all(sequenceExclusive, limit); return rows.map((row) => ({ - sequence: row.sequence, - eventId: row.event_id, - type: row.event_type, - aggregateType: row.aggregate_type, - aggregateId: row.aggregate_id, - occurredAt: row.occurred_at, - commandId: row.command_id, - payload: JSON.parse(row.payload_json) as unknown, + sequence: readRequiredNumber(row, "sequence"), + eventId: readRequiredString(row, "event_id"), + type: readRequiredString(row, "event_type"), + aggregateType: readRequiredString(row, "aggregate_type"), + aggregateId: readRequiredString(row, "aggregate_id"), + occurredAt: readRequiredString(row, "occurred_at"), + commandId: readOptionalString(row, "command_id"), + payload: JSON.parse(readRequiredString(row, "payload_json")) as unknown, })); }); } @@ -99,4 +135,12 @@ export class SqliteEventStore { readAll(): Effect.Effect { return this.readFromSequence(0, Number.MAX_SAFE_INTEGER); } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + this.db.close(); + } } diff --git a/apps/server/src/orchestration/reducer.ts b/apps/server/src/orchestration/reducer.ts index 0aafdc2646bc3..14e2c237fb86c 100644 --- a/apps/server/src/orchestration/reducer.ts +++ b/apps/server/src/orchestration/reducer.ts @@ -31,6 +31,10 @@ function asNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { return { sequence: 0, @@ -192,6 +196,107 @@ export function reduceEvent( }), }; } + case "thread.turn-diff-completed": { + if (!payload) return nextBase; + const threadId = asString(payload.threadId) ?? event.aggregateId; + const thread = nextBase.threads.find((entry) => entry.id === threadId); + const turnId = asString(payload.turnId); + if (!thread || !turnId) return nextBase; + + const files = asArray(payload.files).flatMap((filePayload) => { + const file = asObject(filePayload); + const filePath = asString(file?.path); + if (!filePath) { + return []; + } + + const nextFile: { + path: string; + kind?: string; + additions?: number; + deletions?: number; + } = { path: filePath }; + const kind = asString(file?.kind); + if (kind !== null) { + nextFile.kind = kind; + } + const additions = asNumber(file?.additions); + if (additions !== null) { + nextFile.additions = additions; + } + const deletions = asNumber(file?.deletions); + if (deletions !== null) { + nextFile.deletions = deletions; + } + return [nextFile]; + }); + + const nextSummary = { + turnId, + completedAt: asString(payload.completedAt) ?? event.occurredAt, + ...(asString(payload.status) !== null ? { status: asString(payload.status) ?? undefined } : {}), + files, + ...(asString(payload.assistantMessageId) !== null + ? { assistantMessageId: asString(payload.assistantMessageId) ?? undefined } + : {}), + ...(asNumber(payload.checkpointTurnCount) !== null + ? { checkpointTurnCount: asNumber(payload.checkpointTurnCount) ?? undefined } + : {}), + }; + const turnDiffSummaries = [...thread.turnDiffSummaries.filter((summary) => summary.turnId !== turnId), nextSummary] + .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); + + return { + ...nextBase, + threads: updateThread(nextBase.threads, threadId, { + latestTurnId: turnId, + latestTurnCompletedAt: nextSummary.completedAt, + turnDiffSummaries, + updatedAt: event.occurredAt, + }), + }; + } + case "thread.reverted": { + if (!payload) return nextBase; + const threadId = asString(payload.threadId) ?? event.aggregateId; + const thread = nextBase.threads.find((entry) => entry.id === threadId); + if (!thread) return nextBase; + + const targetTurnCount = Math.max(0, Math.floor(asNumber(payload.turnCount) ?? 0)); + const targetMessageCount = Math.max(0, Math.floor(asNumber(payload.messageCount) ?? 0)); + const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) => + left.completedAt.localeCompare(right.completedAt), + ); + const inferredTurnCountByTurnId = new Map(); + for (let index = 0; index < sortedSummaries.length; index += 1) { + const summary = sortedSummaries[index]; + if (!summary) continue; + inferredTurnCountByTurnId.set(summary.turnId, index + 1); + } + + const turnDiffSummaries = thread.turnDiffSummaries + .filter((summary) => { + const checkpointTurnCount = + summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0; + return checkpointTurnCount <= targetTurnCount; + }) + .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); + const latestSummary = + turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null; + + return { + ...nextBase, + threads: updateThread(nextBase.threads, threadId, { + messages: thread.messages.slice(0, Math.min(targetMessageCount, thread.messages.length)), + turnDiffSummaries, + latestTurnId: latestSummary?.turnId ?? null, + latestTurnStartedAt: null, + latestTurnCompletedAt: latestSummary?.completedAt ?? null, + latestTurnDurationMs: null, + updatedAt: event.occurredAt, + }), + }; + } case "git.read-model-upsert": { if (!payload) return nextBase; const projectId = asString(payload.projectId) ?? event.aggregateId; diff --git a/apps/server/src/providerManager.test.ts b/apps/server/src/providerManager.test.ts index c26f34cb6ae55..08281bdee2003 100644 --- a/apps/server/src/providerManager.test.ts +++ b/apps/server/src/providerManager.test.ts @@ -298,12 +298,14 @@ describe("ProviderManager", () => { it("emits checkpoint/captured after turn completion checkpoint capture", async () => { const manager = new ProviderManager(); - const events: Array<{ method: string; payload?: unknown; threadId?: string }> = []; + const events: Array<{ method: string; payload?: unknown; threadId?: string; turnId?: string }> = + []; manager.on("event", (event) => { events.push({ method: event.method, ...(event.payload !== undefined ? { payload: event.payload } : {}), ...(event.threadId ? { threadId: event.threadId } : {}), + ...(event.turnId ? { turnId: event.turnId } : {}), }); }); @@ -316,6 +318,8 @@ describe("ProviderManager", () => { createdAt: string; method: string; threadId: string; + turnId?: string; + payload?: unknown; }) => void; codex: { hasSession: (sessionId: string) => boolean; @@ -351,6 +355,12 @@ describe("ProviderManager", () => { createdAt: "2026-02-19T00:00:00.000Z", method: "turn/completed", threadId: "thr_1", + turnId: "turn_1", + payload: { + turn: { + status: "completed", + }, + }, }); await vi.waitFor(() => { @@ -364,9 +374,12 @@ describe("ProviderManager", () => { const checkpointCapturedEvent = events.find((event) => event.method === "checkpoint/captured"); expect(checkpointCapturedEvent?.threadId).toBe("thr_1"); + expect(checkpointCapturedEvent?.turnId).toBe("turn_1"); expect(checkpointCapturedEvent?.payload).toEqual({ threadId: "thr_1", turnCount: 1, + turnId: "turn_1", + status: "completed", }); manager.dispose(); diff --git a/apps/server/src/providerManager.ts b/apps/server/src/providerManager.ts index d8442e6c7dee4..5844b172a39c5 100644 --- a/apps/server/src/providerManager.ts +++ b/apps/server/src/providerManager.ts @@ -130,6 +130,12 @@ function summarizeTurn(turn: CodexThreadTurnSnapshot): { }; } +function readTurnCompletionStatus(event: ProviderEvent): string | undefined { + const payload = asObject(event.payload); + const turn = asObject(payload?.turn); + return asString(turn?.status); +} + function buildCheckpoints(turns: CodexThreadTurnSnapshot[]): ProviderCheckpoint[] { const checkpoints: ProviderCheckpoint[] = []; let messageCount = 0; @@ -475,6 +481,7 @@ export class ProviderManager extends EventEmitter { return; } + const turnStatus = readTurnCompletionStatus(event); const checkpointCwd = this.sessionCheckpointCwds.get(event.sessionId); if (!checkpointCwd) { void this.getOrInitializeFilesystemCheckpointCwd(event.sessionId) @@ -483,7 +490,13 @@ export class ProviderManager extends EventEmitter { return; } const snapshot = await this.codex.readThread(event.sessionId); - this.emitCheckpointCaptured(event.sessionId, snapshot.threadId, snapshot.turns.length); + this.emitCheckpointCaptured( + event.sessionId, + snapshot.threadId, + snapshot.turns.length, + event.turnId, + turnStatus, + ); }) .catch((error) => { const message = @@ -504,14 +517,26 @@ export class ProviderManager extends EventEmitter { threadId: snapshot.threadId, turnCount: snapshot.turns.length, }); - this.emitCheckpointCaptured(event.sessionId, snapshot.threadId, snapshot.turns.length); + this.emitCheckpointCaptured( + event.sessionId, + snapshot.threadId, + snapshot.turns.length, + event.turnId, + turnStatus, + ); }).catch((error) => { const message = error instanceof Error ? error.message : "Failed to capture checkpoint."; this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); }); } - private emitCheckpointCaptured(sessionId: string, threadId: string, turnCount: number): void { + private emitCheckpointCaptured( + sessionId: string, + threadId: string, + turnCount: number, + turnId?: string, + status?: string, + ): void { this.emit("event", { id: randomUUID(), kind: "notification", @@ -520,9 +545,12 @@ export class ProviderManager extends EventEmitter { createdAt: new Date().toISOString(), method: "checkpoint/captured", threadId, + ...(turnId ? { turnId } : {}), payload: { threadId, turnCount, + ...(turnId ? { turnId } : {}), + ...(status ? { status } : {}), }, }); } diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index a4cfef7dad2d1..e0e811c7b6980 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -75,6 +75,19 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { return undefined; } +function asObject(value: unknown): Record | null { + if (!value || typeof value !== "object") return null; + return value as Record; +} + +function asString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function asNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + export function createServer(options: ServerOptions) { const { port, @@ -200,6 +213,26 @@ export function createServer(options: ServerOptions) { createdAt: now, }); } + if (event.method === "checkpoint/captured") { + const payload = asObject(event.payload); + const turnId = event.turnId ?? asString(payload?.turnId); + if (turnId) { + await orchestrationEngine.dispatch({ + type: "thread.turnDiff.complete", + commandId: crypto.randomUUID(), + threadId: thread.id, + turnId, + completedAt: now, + ...(asString(payload?.status) !== null ? { status: asString(payload?.status) ?? undefined } : {}), + files: [], + assistantMessageId: `assistant:${turnId}`, + ...(asNumber(payload?.turnCount) !== null + ? { checkpointTurnCount: asNumber(payload?.turnCount) ?? undefined } + : {}), + createdAt: now, + }); + } + } if (event.kind === "error" && event.message) { await orchestrationEngine.dispatch({ type: "thread.session", @@ -425,7 +458,42 @@ export function createServer(options: ServerOptions) { return providerManager.getCheckpointDiff(request.params as never); case WS_METHODS.providersRevertToCheckpoint: - return providerManager.revertToCheckpoint(request.params as never); + { + const result = await providerManager.revertToCheckpoint(request.params as never); + const params = request.params as { sessionId?: string }; + const sessionId = params.sessionId; + if (typeof sessionId === "string") { + const snapshot = orchestrationEngine.getSnapshot(); + const thread = snapshot.threads.find((entry) => entry.session?.sessionId === sessionId); + if (thread) { + const now = new Date().toISOString(); + await orchestrationEngine.dispatch({ + type: "thread.revert", + commandId: crypto.randomUUID(), + threadId: thread.id, + turnCount: result.turnCount, + messageCount: result.messageCount, + createdAt: now, + }); + if (thread.session) { + await orchestrationEngine.dispatch({ + type: "thread.session", + commandId: crypto.randomUUID(), + threadId: thread.id, + session: { + ...thread.session, + status: "ready", + activeTurnId: null, + updatedAt: now, + lastError: null, + }, + createdAt: now, + }); + } + } + } + return result; + } case WS_METHODS.projectsList: return projectRegistry.list(); diff --git a/bun.lock b/bun.lock index ae9ab554c3c50..56b5a99dbead1 100644 --- a/bun.lock +++ b/bun.lock @@ -34,7 +34,6 @@ "t3": "./dist/index.mjs", }, "dependencies": { - "better-sqlite3": "^12.6.2", "effect": "^3.19.19", "node-pty": "^1.1.0", "open": "^10.1.0", @@ -43,7 +42,6 @@ "devDependencies": { "@t3tools/contracts": "workspace:*", "@t3tools/web": "workspace:*", - "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.10.2", "@types/ws": "^8.5.13", "tsdown": "^0.20.3", @@ -498,8 +496,6 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], - "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -588,28 +584,18 @@ "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=="], @@ -640,8 +626,6 @@ "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=="], @@ -674,8 +658,6 @@ "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=="], @@ -748,8 +730,6 @@ "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=="], @@ -762,16 +742,12 @@ "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=="], "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=="], @@ -790,8 +766,6 @@ "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=="], @@ -840,16 +814,10 @@ "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=="], @@ -1044,8 +1012,6 @@ "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=="], "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=="], @@ -1054,10 +1020,6 @@ "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-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], @@ -1104,8 +1066,6 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - "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=="], @@ -1122,8 +1082,6 @@ "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=="], @@ -1132,8 +1090,6 @@ "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=="], @@ -1184,8 +1140,6 @@ "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=="], @@ -1204,10 +1158,6 @@ "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=="], @@ -1226,14 +1176,10 @@ "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=="], @@ -1254,10 +1200,6 @@ "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=="], @@ -1292,8 +1234,6 @@ "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=="], @@ -1344,8 +1284,6 @@ "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=="], - "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=="], @@ -1436,8 +1374,6 @@ "@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/better-sqlite3/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], - "@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=="], @@ -1494,8 +1430,6 @@ "@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/better-sqlite3/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "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=="], diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 2e1ee674cca40..e9f53c19db947 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -189,6 +189,28 @@ export const UpsertGitReadModelCommandSchema = Schema.Struct({ createdAt: IsoDateTimeSchema, }); +export const CompleteThreadTurnDiffCommandSchema = Schema.Struct({ + type: Schema.Literal("thread.turnDiff.complete"), + commandId: IdSchema, + threadId: IdSchema, + turnId: IdSchema, + completedAt: IsoDateTimeSchema, + status: Schema.optional(Schema.String), + files: Schema.Array(OrchestrationTurnDiffFileSchema), + assistantMessageId: Schema.optional(Schema.String), + checkpointTurnCount: Schema.optional(Schema.Number), + createdAt: IsoDateTimeSchema, +}); + +export const RevertThreadCommandSchema = Schema.Struct({ + type: Schema.Literal("thread.revert"), + commandId: IdSchema, + threadId: IdSchema, + turnCount: Schema.Number, + messageCount: Schema.Number, + createdAt: IsoDateTimeSchema, +}); + export const OrchestrationCommandSchema = Schema.Union( CreateProjectCommandSchema, DeleteProjectCommandSchema, @@ -198,6 +220,8 @@ export const OrchestrationCommandSchema = Schema.Union( SendMessageCommandSchema, SetThreadSessionSchema, UpsertGitReadModelCommandSchema, + CompleteThreadTurnDiffCommandSchema, + RevertThreadCommandSchema, ); export type OrchestrationCommand = Schema.Schema.Type; From 4bddf3a606abf7ddf3731c7a9031f63d6c7f1c20 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 21 Feb 2026 19:25:31 -0800 Subject: [PATCH 03/98] more effect: stage 1 --- apps/server/src/orchestration/engine.ts | 15 +++++-- apps/server/src/orchestration/eventStore.ts | 9 +++- apps/server/src/orchestration/layers.test.ts | 47 ++++++++++++++++++++ apps/server/src/orchestration/layers.ts | 28 ++++++++++++ apps/server/src/orchestration/runtime.ts | 14 ++++++ apps/server/src/orchestration/services.ts | 22 +++++++++ apps/server/src/wsServer.ts | 4 +- 7 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/orchestration/layers.test.ts create mode 100644 apps/server/src/orchestration/layers.ts create mode 100644 apps/server/src/orchestration/runtime.ts create mode 100644 apps/server/src/orchestration/services.ts diff --git a/apps/server/src/orchestration/engine.ts b/apps/server/src/orchestration/engine.ts index 28ef5e976e457..9021aa45030da 100644 --- a/apps/server/src/orchestration/engine.ts +++ b/apps/server/src/orchestration/engine.ts @@ -9,6 +9,7 @@ import { OrchestrationCommandSchema } from "@t3tools/contracts"; import { PubSub, Queue, Schema, Stream, Effect, Fiber, Runtime, Either } from "effect"; import { createLogger } from "../logger"; +import type { OrchestrationEventStore } from "./eventStore"; import { SqliteEventStore } from "./eventStore"; import { createEmptyReadModel, reduceEvent } from "./reducer"; import { UI_ENTITY_CONTRACTS } from "./uiContractInventory"; @@ -190,7 +191,7 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit; @@ -200,9 +201,15 @@ export class OrchestrationEngine { private readonly readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); private readonly domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); - constructor(stateDir: string) { - const dbPath = path.join(stateDir, "orchestration.sqlite"); - this.eventStore = new SqliteEventStore(dbPath); + constructor(stateDir: string); + constructor(eventStore: OrchestrationEventStore); + constructor(stateDirOrEventStore: string | OrchestrationEventStore) { + if (typeof stateDirOrEventStore === "string") { + const dbPath = path.join(stateDirOrEventStore, "orchestration.sqlite"); + this.eventStore = new SqliteEventStore(dbPath); + } else { + this.eventStore = stateDirOrEventStore; + } this.readModel = createEmptyReadModel(new Date().toISOString()); this.commandQueue = Runtime.runSync(this.runtime)(Queue.unbounded()); this.readModelPubSub = Runtime.runSync(this.runtime)( diff --git a/apps/server/src/orchestration/eventStore.ts b/apps/server/src/orchestration/eventStore.ts index 636879785c1f2..ee0c21da6fc93 100644 --- a/apps/server/src/orchestration/eventStore.ts +++ b/apps/server/src/orchestration/eventStore.ts @@ -6,6 +6,13 @@ import type { SQLOutputValue } from "node:sqlite"; import type { OrchestrationEvent } from "@t3tools/contracts"; import { Effect } from "effect"; +export interface OrchestrationEventStore { + append(event: Omit): Effect.Effect; + readFromSequence(sequenceExclusive: number, limit?: number): Effect.Effect; + readAll(): Effect.Effect; + close(): void; +} + interface EventRow { sequence: number; event_id: string; @@ -50,7 +57,7 @@ function readRequiredNumber( throw new Error(`Invalid SQLite value for ${String(column)}: expected number`); } -export class SqliteEventStore { +export class SqliteEventStore implements OrchestrationEventStore { private readonly db: DatabaseSync; private readonly insertStatement: StatementSync; private closed = false; diff --git a/apps/server/src/orchestration/layers.test.ts b/apps/server/src/orchestration/layers.test.ts new file mode 100644 index 0000000000000..da24a9b7c79c7 --- /dev/null +++ b/apps/server/src/orchestration/layers.test.ts @@ -0,0 +1,47 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createOrchestrationEngine } from "./runtime"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("orchestration layers", () => { + it("creates an engine through Context.Tag wiring", async () => { + const stateDir = makeTempDir("t3code-orchestration-layer-"); + const createdAt = new Date().toISOString(); + const engine = createOrchestrationEngine(stateDir); + + await engine.start(); + await engine.dispatch({ + type: "project.create", + commandId: "cmd-layer-1", + projectId: "project-layer-1", + name: "layer", + cwd: "/tmp/layer", + model: "gpt-5-codex", + createdAt, + }); + const firstSnapshot = engine.getSnapshot(); + await engine.stop(); + + const restarted = createOrchestrationEngine(stateDir); + await restarted.start(); + expect(restarted.getSnapshot()).toEqual(firstSnapshot); + await restarted.stop(); + }); +}); diff --git a/apps/server/src/orchestration/layers.ts b/apps/server/src/orchestration/layers.ts new file mode 100644 index 0000000000000..7c0220fd8a825 --- /dev/null +++ b/apps/server/src/orchestration/layers.ts @@ -0,0 +1,28 @@ +import path from "node:path"; + +import { Effect, Layer } from "effect"; + +import { OrchestrationEngine } from "./engine"; +import { SqliteEventStore } from "./eventStore"; +import { + OrchestrationConfig, + OrchestrationEngineService, + OrchestrationEventStoreService, +} from "./services"; + +export const OrchestrationEventStoreLive = Layer.effect( + OrchestrationEventStoreService, + Effect.map(OrchestrationConfig, ({ stateDir }) => { + const dbPath = path.join(stateDir, "orchestration.sqlite"); + return new SqliteEventStore(dbPath); + }), +); + +export const OrchestrationEngineLive = Layer.effect( + OrchestrationEngineService, + Effect.map(OrchestrationEventStoreService, (eventStore) => new OrchestrationEngine(eventStore)), +); + +export const OrchestrationLive = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationEventStoreLive), +); diff --git a/apps/server/src/orchestration/runtime.ts b/apps/server/src/orchestration/runtime.ts new file mode 100644 index 0000000000000..cbb875b5f907d --- /dev/null +++ b/apps/server/src/orchestration/runtime.ts @@ -0,0 +1,14 @@ +import { Effect, Layer, Runtime } from "effect"; + +import type { OrchestrationEngine } from "./engine"; +import { OrchestrationLive } from "./layers"; +import { OrchestrationConfig, OrchestrationEngineService } from "./services"; + +export function createOrchestrationEngine(stateDir: string): OrchestrationEngine { + const orchestrationLayer = OrchestrationLive.pipe( + Layer.provide(Layer.succeed(OrchestrationConfig, { stateDir })), + ); + return Runtime.runSync(Runtime.defaultRuntime)( + Effect.provide(OrchestrationEngineService, orchestrationLayer), + ); +} diff --git a/apps/server/src/orchestration/services.ts b/apps/server/src/orchestration/services.ts new file mode 100644 index 0000000000000..5218c7a5884ab --- /dev/null +++ b/apps/server/src/orchestration/services.ts @@ -0,0 +1,22 @@ +import { Context } from "effect"; + +import { OrchestrationEngine } from "./engine"; +import type { OrchestrationEventStore } from "./eventStore"; + +export interface OrchestrationConfigShape { + readonly stateDir: string; +} + +export class OrchestrationConfig extends Context.Tag("orchestration/Config")< + OrchestrationConfig, + OrchestrationConfigShape +>() {} + +export class OrchestrationEventStoreService extends Context.Tag( + "orchestration/EventStore", +)() {} + +export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")< + OrchestrationEngineService, + OrchestrationEngine +>() {} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index e0e811c7b6980..cdee56f031e6e 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -36,7 +36,7 @@ import { import { TerminalManager } from "./terminalManager"; import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; -import { OrchestrationEngine } from "./orchestration/engine"; +import { createOrchestrationEngine } from "./orchestration/runtime"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -107,7 +107,7 @@ export function createServer(options: ServerOptions) { const projectRegistry = providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); const gitManager = providedGitManager ?? new GitManager(); - const orchestrationEngine = new OrchestrationEngine( + const orchestrationEngine = createOrchestrationEngine( stateDir ?? path.join(os.homedir(), ".t3", "userdata"), ); const clients = new Set(); From 35758ec368b7efbbc6d2d46a3faf8a61c79bb9a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 21 Feb 2026 19:29:23 -0800 Subject: [PATCH 04/98] more effect: engine momre effect native --- apps/server/src/orchestration/engine.test.ts | 65 +++++++++++ apps/server/src/orchestration/engine.ts | 116 ++++++++++++------- 2 files changed, 137 insertions(+), 44 deletions(-) diff --git a/apps/server/src/orchestration/engine.test.ts b/apps/server/src/orchestration/engine.test.ts index d4a0123169c36..05cf57c72f282 100644 --- a/apps/server/src/orchestration/engine.test.ts +++ b/apps/server/src/orchestration/engine.test.ts @@ -2,9 +2,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import type { OrchestrationEvent } from "@t3tools/contracts"; +import { Effect } from "effect"; import { afterEach, describe, expect, it } from "vitest"; import { OrchestrationEngine } from "./engine"; +import type { OrchestrationEventStore } from "./eventStore"; const tempDirs: string[] = []; @@ -310,4 +313,66 @@ describe("OrchestrationEngine", () => { await engine.stop(); await expect(engine.stop()).resolves.toBeUndefined(); }); + + it("keeps processing queued commands after a storage failure", async () => { + const events: OrchestrationEvent[] = []; + let nextSequence = 1; + let shouldFailFirstAppend = true; + + const flakyStore: OrchestrationEventStore = { + append(event) { + if (shouldFailFirstAppend) { + shouldFailFirstAppend = false; + return Effect.sync(() => { + throw new Error("append failed"); + }); + } + const savedEvent = { + ...event, + sequence: nextSequence, + } satisfies OrchestrationEvent; + nextSequence += 1; + events.push(savedEvent); + return Effect.succeed(savedEvent); + }, + readFromSequence(sequenceExclusive) { + return Effect.succeed(events.filter((event) => event.sequence > sequenceExclusive)); + }, + readAll() { + return Effect.succeed(events); + }, + close() {}, + }; + + const engine = new OrchestrationEngine(flakyStore); + const createdAt = new Date().toISOString(); + await engine.start(); + + await expect( + engine.dispatch({ + type: "project.create", + commandId: "cmd-flaky-1", + projectId: "project-flaky-fail", + name: "flaky-fail", + cwd: "/tmp/flaky-fail", + model: "gpt-5-codex", + createdAt, + }), + ).rejects.toThrow("append failed"); + + const result = await engine.dispatch({ + type: "project.create", + commandId: "cmd-flaky-2", + projectId: "project-flaky-ok", + name: "flaky-ok", + cwd: "/tmp/flaky-ok", + model: "gpt-5-codex", + createdAt, + }); + + expect(result.sequence).toBe(1); + expect(engine.getSnapshot().projects.map((project) => project.id)).toEqual(["project-flaky-ok"]); + + await engine.stop(); + }); }); diff --git a/apps/server/src/orchestration/engine.ts b/apps/server/src/orchestration/engine.ts index 9021aa45030da..5b8590eea5fc8 100644 --- a/apps/server/src/orchestration/engine.ts +++ b/apps/server/src/orchestration/engine.ts @@ -6,7 +6,18 @@ import type { OrchestrationReadModel, } from "@t3tools/contracts"; import { OrchestrationCommandSchema } from "@t3tools/contracts"; -import { PubSub, Queue, Schema, Stream, Effect, Fiber, Runtime, Either } from "effect"; +import { + PubSub, + Queue, + Schema, + Stream, + Effect, + Fiber, + Runtime, + Either, + Deferred, + Cause, +} from "effect"; import { createLogger } from "../logger"; import type { OrchestrationEventStore } from "./eventStore"; @@ -16,10 +27,13 @@ import { UI_ENTITY_CONTRACTS } from "./uiContractInventory"; type CommandEnvelope = { command: OrchestrationCommand; - resolve: (result: { sequence: number }) => void; - reject: (error: Error) => void; + result: Deferred.Deferred<{ sequence: number }, Error>; }; +function asError(error: unknown, fallbackMessage: string): Error { + return error instanceof Error ? error : new Error(fallbackMessage); +} + function mapCommandToEvent(command: OrchestrationCommand): Omit { const eventId = crypto.randomUUID(); switch (command.type) { @@ -218,43 +232,57 @@ export class OrchestrationEngine { this.eventPubSub = Runtime.runSync(this.runtime)(PubSub.unbounded()); } + private processEnvelope(envelope: CommandEnvelope): Effect.Effect { + return Effect.gen(this, function* () { + const eventBase = mapCommandToEvent(envelope.command); + const savedEvent = yield* this.eventStore.append(eventBase); + + yield* Effect.sync(() => { + this.readModel = reduceEvent(this.readModel, savedEvent); + }); + + const snapshot = this.readModel; + yield* Effect.all([ + PubSub.publish(this.eventPubSub, savedEvent), + PubSub.publish(this.readModelPubSub, snapshot), + ]); + + yield* Effect.sync(() => { + for (const listener of this.domainEventListeners) { + listener(savedEvent); + } + for (const listener of this.readModelListeners) { + listener(snapshot); + } + }); + + yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); + }).pipe( + Effect.catchAllCause((cause) => + Deferred.fail( + envelope.result, + asError(Cause.squash(cause), "Unknown command processing error"), + ).pipe(Effect.asVoid), + ), + ); + } + + private bootstrapReadModel(): Effect.Effect { + return Effect.gen(this, function* () { + const existingEvents = yield* this.eventStore.readAll(); + yield* Effect.sync(() => { + for (const event of existingEvents) { + this.readModel = reduceEvent(this.readModel, event); + } + }); + }); + } + async start(): Promise { - const existingEvents = await Runtime.runPromise(this.runtime)(this.eventStore.readAll()); - for (const event of existingEvents) { - this.readModel = reduceEvent(this.readModel, event); - } + await Runtime.runPromise(this.runtime)(this.bootstrapReadModel()); const worker = Stream.fromQueue(this.commandQueue).pipe( - Stream.runForEach((envelope) => - Effect.tryPromise({ - try: async () => { - const eventBase = mapCommandToEvent(envelope.command); - const savedEvent = await Runtime.runPromise(this.runtime)( - this.eventStore.append(eventBase), - ); - this.readModel = reduceEvent(this.readModel, savedEvent); - await Promise.all([ - Runtime.runPromise(this.runtime)(PubSub.publish(this.eventPubSub, savedEvent)), - Runtime.runPromise(this.runtime)( - PubSub.publish(this.readModelPubSub, this.readModel), - ), - ]); - for (const listener of this.domainEventListeners) { - listener(savedEvent); - } - for (const listener of this.readModelListeners) { - listener(this.readModel); - } - envelope.resolve({ sequence: savedEvent.sequence }); - }, - catch: (error) => { - const message = - error instanceof Error ? error.message : "Unknown command processing error"; - envelope.reject(new Error(message)); - return undefined; - }, - }), - ), + Stream.runForEach((envelope) => this.processEnvelope(envelope)), ); this.workerFiber = Runtime.runFork(this.runtime)(worker); @@ -301,13 +329,13 @@ export class OrchestrationEngine { } async dispatch(command: OrchestrationCommand): Promise<{ sequence: number }> { - return new Promise<{ sequence: number }>((resolve, reject) => { - Runtime.runPromise(this.runtime)( - Queue.offer(this.commandQueue, { command, resolve, reject }), - ).catch((error) => { - const message = error instanceof Error ? error.message : "Queue offer failed"; - reject(new Error(message)); - }); + const program = Effect.gen(this, function* () { + const result = yield* Deferred.make<{ sequence: number }, Error>(); + yield* Queue.offer(this.commandQueue, { command, result }); + return yield* Deferred.await(result); + }); + return Runtime.runPromise(this.runtime)(program).catch((error) => { + throw asError(error, "Queue offer failed"); }); } From 4c014e4515a2902f61016fec97a2a7deef5c524c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 21 Feb 2026 19:34:19 -0800 Subject: [PATCH 05/98] more effect: sqlschema --- apps/server/package.json | 2 + apps/server/src/orchestration/eventStore.ts | 276 ++++++++++++-------- bun.lock | 92 +++++++ 3 files changed, 262 insertions(+), 108 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 5571a4a7cb488..cdd7b268c35d9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,6 +17,8 @@ "test": "vitest run" }, "dependencies": { + "@effect/sql": "^0.49.0", + "@effect/sql-sqlite-node": "^0.50.1", "effect": "^3.19.19", "node-pty": "^1.1.0", "open": "^10.1.0", diff --git a/apps/server/src/orchestration/eventStore.ts b/apps/server/src/orchestration/eventStore.ts index ee0c21da6fc93..09086d959e81b 100644 --- a/apps/server/src/orchestration/eventStore.ts +++ b/apps/server/src/orchestration/eventStore.ts @@ -1,10 +1,13 @@ import fs from "node:fs"; import path from "node:path"; -import { DatabaseSync, StatementSync } from "node:sqlite"; -import type { SQLOutputValue } from "node:sqlite"; import type { OrchestrationEvent } from "@t3tools/contracts"; -import { Effect } from "effect"; +import { OrchestrationEventSchema } from "@t3tools/contracts"; +import type { SqlClient as SqlClientService } from "@effect/sql/SqlClient"; +import * as SqlClient from "@effect/sql/SqlClient"; +import * as SqlSchema from "@effect/sql/SqlSchema"; +import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; +import { Effect, ManagedRuntime, Schema } from "effect"; export interface OrchestrationEventStore { append(event: Omit): Effect.Effect; @@ -13,130 +16,187 @@ export interface OrchestrationEventStore { close(): void; } -interface EventRow { - sequence: number; - event_id: string; - event_type: string; - aggregate_type: string; - aggregate_id: string; - occurred_at: string; - command_id: string | null; - payload_json: string; -} +const decodeEvent = Schema.decodeUnknownSync(OrchestrationEventSchema); -function readRequiredString( - row: Record, - column: keyof EventRow, -): string { - const value = row[column]; - if (typeof value !== "string") { - throw new Error(`Invalid SQLite value for ${String(column)}: expected string`); - } - return value; -} +const EventRowSchema = Schema.Struct({ + sequence: Schema.Number, + eventId: Schema.String, + type: Schema.String, + aggregateType: Schema.String, + aggregateId: Schema.String, + occurredAt: Schema.String, + commandId: Schema.NullOr(Schema.String), + payloadJson: Schema.String, +}); -function readOptionalString( - row: Record, - column: keyof EventRow, -): string | null { - const value = row[column]; - if (value === null) return null; - if (typeof value !== "string") { - throw new Error(`Invalid SQLite value for ${String(column)}: expected string or null`); - } - return value; -} +type EventRow = Schema.Schema.Type; + +const AppendEventRequestSchema = Schema.Struct({ + eventId: Schema.String, + type: Schema.String, + aggregateType: Schema.String, + aggregateId: Schema.String, + occurredAt: Schema.String, + commandId: Schema.NullOr(Schema.String), + payloadJson: Schema.String, +}); + +const ReadFromSequenceRequestSchema = Schema.Struct({ + sequenceExclusive: Schema.Number, + limit: Schema.Number, +}); + +const appendEventRow = SqlSchema.single({ + Request: AppendEventRequestSchema, + Result: EventRowSchema, + execute: (request) => + Effect.flatMap(SqlClient.SqlClient, (sql) => + sql` + INSERT INTO orchestration_events ( + event_id, + event_type, + aggregate_type, + aggregate_id, + occurred_at, + command_id, + payload_json + ) + VALUES ( + ${request.eventId}, + ${request.type}, + ${request.aggregateType}, + ${request.aggregateId}, + ${request.occurredAt}, + ${request.commandId}, + ${request.payloadJson} + ) + RETURNING + sequence, + event_id AS "eventId", + event_type AS "type", + aggregate_type AS "aggregateType", + aggregate_id AS "aggregateId", + occurred_at AS "occurredAt", + command_id AS "commandId", + payload_json AS "payloadJson" + `, + ), +}); + +const readEventRowsFromSequence = SqlSchema.findAll({ + Request: ReadFromSequenceRequestSchema, + Result: EventRowSchema, + execute: (request) => + Effect.flatMap(SqlClient.SqlClient, (sql) => + sql` + SELECT + sequence, + event_id AS "eventId", + event_type AS "type", + aggregate_type AS "aggregateType", + aggregate_id AS "aggregateId", + occurred_at AS "occurredAt", + command_id AS "commandId", + payload_json AS "payloadJson" + FROM orchestration_events + WHERE sequence > ${request.sequenceExclusive} + ORDER BY sequence ASC + LIMIT ${request.limit} + `, + ), +}); -function readRequiredNumber( - row: Record, - column: keyof EventRow, -): number { - const value = row[column]; - if (typeof value === "number") return value; - if (typeof value === "bigint") return Number(value); - throw new Error(`Invalid SQLite value for ${String(column)}: expected number`); +function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { + return decodeEvent({ + sequence: row.sequence, + eventId: row.eventId, + type: row.type, + aggregateType: row.aggregateType, + aggregateId: row.aggregateId, + occurredAt: row.occurredAt, + commandId: row.commandId, + payload: JSON.parse(row.payloadJson) as unknown, + }); } export class SqliteEventStore implements OrchestrationEventStore { - private readonly db: DatabaseSync; - private readonly insertStatement: StatementSync; + private readonly runtime: ManagedRuntime.ManagedRuntime; + private readonly migrated: Promise; private closed = false; constructor(dbPath: string) { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); - this.db = new DatabaseSync(dbPath); - this.db.exec("PRAGMA journal_mode = WAL;"); - this.db.exec("PRAGMA foreign_keys = ON;"); - this.migrate(); - this.insertStatement = this.db.prepare(` - INSERT INTO orchestration_events ( - event_id, event_type, aggregate_type, aggregate_id, occurred_at, command_id, payload_json - ) - VALUES (?, ?, ?, ?, ?, ?, ?) - `); + this.runtime = ManagedRuntime.make( + SqliteClient.layer({ + filename: dbPath, + }), + ); + this.migrated = this.runtime.runPromise(this.migrate()).then(() => undefined); } - private migrate(): void { - this.db.exec(` - CREATE TABLE IF NOT EXISTS orchestration_events ( - sequence INTEGER PRIMARY KEY AUTOINCREMENT, - event_id TEXT NOT NULL UNIQUE, - event_type TEXT NOT NULL, - aggregate_type TEXT NOT NULL, - aggregate_id TEXT NOT NULL, - occurred_at TEXT NOT NULL, - command_id TEXT, - payload_json TEXT NOT NULL - ); + private provideSql( + effect: Effect.Effect, + ): Effect.Effect { + return Effect.provide(effect, this.runtime); + } + + private ensureMigrated(): Effect.Effect { + return Effect.promise(() => this.migrated); + } - CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate - ON orchestration_events(aggregate_type, aggregate_id, sequence); - `); + private migrate(): Effect.Effect { + return Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`PRAGMA journal_mode = WAL;`; + yield* sql`PRAGMA foreign_keys = ON;`; + yield* sql` + CREATE TABLE IF NOT EXISTS orchestration_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + occurred_at TEXT NOT NULL, + command_id TEXT, + payload_json TEXT NOT NULL + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate + ON orchestration_events(aggregate_type, aggregate_id, sequence) + `; + }); } append(event: Omit): Effect.Effect { - return Effect.sync(() => { - const result = this.insertStatement.run( - event.eventId, - event.type, - event.aggregateType, - event.aggregateId, - event.occurredAt, - event.commandId, - JSON.stringify(event.payload), + return Effect.gen(this, function* () { + yield* this.ensureMigrated(); + const row = yield* this.provideSql( + appendEventRow({ + eventId: event.eventId, + type: event.type, + aggregateType: event.aggregateType, + aggregateId: event.aggregateId, + occurredAt: event.occurredAt, + commandId: event.commandId, + payloadJson: JSON.stringify(event.payload), + }), ); - - return { - ...event, - sequence: Number(result.lastInsertRowid), - }; - }); + return eventRowToOrchestrationEvent(row); + }).pipe(Effect.orDie); } readFromSequence(sequenceExclusive: number, limit = 1_000): Effect.Effect { - return Effect.sync(() => { - const rows = this.db - .prepare( - ` - SELECT sequence, event_id, event_type, aggregate_type, aggregate_id, occurred_at, command_id, payload_json - FROM orchestration_events - WHERE sequence > ? - ORDER BY sequence ASC - LIMIT ? - `, - ) - .all(sequenceExclusive, limit); - return rows.map((row) => ({ - sequence: readRequiredNumber(row, "sequence"), - eventId: readRequiredString(row, "event_id"), - type: readRequiredString(row, "event_type"), - aggregateType: readRequiredString(row, "aggregate_type"), - aggregateId: readRequiredString(row, "aggregate_id"), - occurredAt: readRequiredString(row, "occurred_at"), - commandId: readOptionalString(row, "command_id"), - payload: JSON.parse(readRequiredString(row, "payload_json")) as unknown, - })); - }); + return Effect.gen(this, function* () { + yield* this.ensureMigrated(); + const rows = yield* this.provideSql( + readEventRowsFromSequence({ + sequenceExclusive, + limit, + }), + ); + return rows.map(eventRowToOrchestrationEvent); + }).pipe(Effect.orDie); } readAll(): Effect.Effect { @@ -148,6 +208,6 @@ export class SqliteEventStore implements OrchestrationEventStore { return; } this.closed = true; - this.db.close(); + void this.runtime.dispose(); } } diff --git a/bun.lock b/bun.lock index 56b5a99dbead1..be1fceaab7490 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,8 @@ "t3": "./dist/index.mjs", }, "dependencies": { + "@effect/sql": "^0.49.0", + "@effect/sql-sqlite-node": "^0.50.1", "effect": "^3.19.19", "node-pty": "^1.1.0", "open": "^10.1.0", @@ -146,6 +148,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=="], @@ -246,6 +256,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=="], @@ -584,18 +606,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=="], @@ -626,6 +658,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=="], @@ -658,6 +692,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=="], @@ -730,6 +766,8 @@ "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=="], @@ -742,12 +780,18 @@ "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=="], @@ -766,6 +810,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=="], @@ -814,10 +860,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=="], @@ -1012,16 +1064,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=="], @@ -1066,6 +1132,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=="], @@ -1082,6 +1150,8 @@ "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=="], @@ -1090,6 +1160,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=="], @@ -1140,6 +1212,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=="], @@ -1158,6 +1232,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=="], @@ -1176,10 +1254,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=="], @@ -1200,6 +1282,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=="], @@ -1234,6 +1320,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=="], @@ -1284,6 +1372,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=="], From 3bafb330911d32e3f713d02fc34d083f83da9605 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 21 Feb 2026 19:42:08 -0800 Subject: [PATCH 06/98] migrations and repository --- apps/server/src/orchestration/engine.test.ts | 4 +- apps/server/src/orchestration/engine.ts | 12 +- .../src/orchestration/eventRepository.ts | 20 +++ .../src/orchestration/eventStore.test.ts | 90 +++++++++++++ apps/server/src/orchestration/eventStore.ts | 125 +++++++----------- apps/server/src/orchestration/layers.ts | 21 ++- apps/server/src/orchestration/migrations.ts | 31 +++++ apps/server/src/orchestration/services.ts | 5 - 8 files changed, 210 insertions(+), 98 deletions(-) create mode 100644 apps/server/src/orchestration/eventRepository.ts create mode 100644 apps/server/src/orchestration/eventStore.test.ts create mode 100644 apps/server/src/orchestration/migrations.ts diff --git a/apps/server/src/orchestration/engine.test.ts b/apps/server/src/orchestration/engine.test.ts index 05cf57c72f282..5325ae35a86ec 100644 --- a/apps/server/src/orchestration/engine.test.ts +++ b/apps/server/src/orchestration/engine.test.ts @@ -7,7 +7,7 @@ import { Effect } from "effect"; import { afterEach, describe, expect, it } from "vitest"; import { OrchestrationEngine } from "./engine"; -import type { OrchestrationEventStore } from "./eventStore"; +import type { OrchestrationEventRepositoryShape } from "./eventRepository"; const tempDirs: string[] = []; @@ -319,7 +319,7 @@ describe("OrchestrationEngine", () => { let nextSequence = 1; let shouldFailFirstAppend = true; - const flakyStore: OrchestrationEventStore = { + const flakyStore: OrchestrationEventRepositoryShape = { append(event) { if (shouldFailFirstAppend) { shouldFailFirstAppend = false; diff --git a/apps/server/src/orchestration/engine.ts b/apps/server/src/orchestration/engine.ts index 5b8590eea5fc8..826b5143f5e63 100644 --- a/apps/server/src/orchestration/engine.ts +++ b/apps/server/src/orchestration/engine.ts @@ -20,8 +20,8 @@ import { } from "effect"; import { createLogger } from "../logger"; -import type { OrchestrationEventStore } from "./eventStore"; -import { SqliteEventStore } from "./eventStore"; +import type { OrchestrationEventRepositoryShape } from "./eventRepository"; +import { makeSqliteOrchestrationEventRepository } from "./eventStore"; import { createEmptyReadModel, reduceEvent } from "./reducer"; import { UI_ENTITY_CONTRACTS } from "./uiContractInventory"; @@ -205,7 +205,7 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit; @@ -216,11 +216,11 @@ export class OrchestrationEngine { private readonly domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); constructor(stateDir: string); - constructor(eventStore: OrchestrationEventStore); - constructor(stateDirOrEventStore: string | OrchestrationEventStore) { + constructor(eventStore: OrchestrationEventRepositoryShape); + constructor(stateDirOrEventStore: string | OrchestrationEventRepositoryShape) { if (typeof stateDirOrEventStore === "string") { const dbPath = path.join(stateDirOrEventStore, "orchestration.sqlite"); - this.eventStore = new SqliteEventStore(dbPath); + this.eventStore = makeSqliteOrchestrationEventRepository(dbPath); } else { this.eventStore = stateDirOrEventStore; } diff --git a/apps/server/src/orchestration/eventRepository.ts b/apps/server/src/orchestration/eventRepository.ts new file mode 100644 index 0000000000000..503df4c18ca4c --- /dev/null +++ b/apps/server/src/orchestration/eventRepository.ts @@ -0,0 +1,20 @@ +import type { OrchestrationEvent } from "@t3tools/contracts"; +import { Context } from "effect"; +import type { Effect } from "effect"; + +export interface OrchestrationEventRepositoryShape { + readonly append: ( + event: Omit, + ) => Effect.Effect; + readonly readFromSequence: ( + sequenceExclusive: number, + limit?: number, + ) => Effect.Effect; + readonly readAll: () => Effect.Effect; + readonly close: () => void; +} + +export class OrchestrationEventRepository extends Context.Tag("orchestration/EventRepository")< + OrchestrationEventRepository, + OrchestrationEventRepositoryShape +>() {} diff --git a/apps/server/src/orchestration/eventStore.test.ts b/apps/server/src/orchestration/eventStore.test.ts new file mode 100644 index 0000000000000..e75b949276082 --- /dev/null +++ b/apps/server/src/orchestration/eventStore.test.ts @@ -0,0 +1,90 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import { Runtime } from "effect"; +import { afterEach, describe, expect, it } from "vitest"; + +import { makeSqliteOrchestrationEventRepository } from "./eventStore"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("SqliteEventStore", () => { + it("persists and replays events across restarts", async () => { + const stateDir = makeTempDir("t3code-event-store-"); + const dbPath = path.join(stateDir, "orchestration.sqlite"); + const runtime = Runtime.defaultRuntime; + const createdAt = new Date().toISOString(); + + const first = makeSqliteOrchestrationEventRepository(dbPath); + const saved = await Runtime.runPromise(runtime)( + first.append({ + eventId: "event-1", + type: "project.created", + aggregateType: "project", + aggregateId: "project-1", + occurredAt: createdAt, + commandId: "cmd-1", + payload: { id: "project-1", name: "demo" }, + }), + ); + expect(saved.sequence).toBe(1); + first.close(); + + const second = makeSqliteOrchestrationEventRepository(dbPath); + const replayed = await Runtime.runPromise(runtime)(second.readFromSequence(0)); + expect(replayed).toEqual([saved]); + second.close(); + }); + + it("creates and reuses the migrator tracking table", async () => { + const stateDir = makeTempDir("t3code-event-store-migrations-"); + const dbPath = path.join(stateDir, "orchestration.sqlite"); + const runtime = Runtime.defaultRuntime; + + const first = makeSqliteOrchestrationEventRepository(dbPath); + await Runtime.runPromise(runtime)(first.readAll()); + first.close(); + + const second = makeSqliteOrchestrationEventRepository(dbPath); + await Runtime.runPromise(runtime)(second.readAll()); + second.close(); + + const db = new DatabaseSync(dbPath, { readOnly: true }); + const tableRow = db + .prepare( + ` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name = 'orchestration_sql_migrations' + `, + ) + .get() as { name: string } | undefined; + expect(tableRow?.name).toBe("orchestration_sql_migrations"); + + const migrationRows = db + .prepare( + ` + SELECT * + FROM orchestration_sql_migrations + `, + ) + .all() as Array>; + expect(migrationRows).toHaveLength(1); + expect(Object.values(migrationRows[0] ?? {}).length).toBeGreaterThan(0); + db.close(); + }); +}); diff --git a/apps/server/src/orchestration/eventStore.ts b/apps/server/src/orchestration/eventStore.ts index 09086d959e81b..6ec7572dc7363 100644 --- a/apps/server/src/orchestration/eventStore.ts +++ b/apps/server/src/orchestration/eventStore.ts @@ -4,17 +4,13 @@ import path from "node:path"; import type { OrchestrationEvent } from "@t3tools/contracts"; import { OrchestrationEventSchema } from "@t3tools/contracts"; import type { SqlClient as SqlClientService } from "@effect/sql/SqlClient"; -import * as SqlClient from "@effect/sql/SqlClient"; +import * as SqlClient from "@effect/sql/SqlClient"; import * as SqlSchema from "@effect/sql/SqlSchema"; import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; import { Effect, ManagedRuntime, Schema } from "effect"; -export interface OrchestrationEventStore { - append(event: Omit): Effect.Effect; - readFromSequence(sequenceExclusive: number, limit?: number): Effect.Effect; - readAll(): Effect.Effect; - close(): void; -} +import type { OrchestrationEventRepositoryShape } from "./eventRepository"; +import { runOrchestrationMigrations } from "./migrations"; const decodeEvent = Schema.decodeUnknownSync(OrchestrationEventSchema); @@ -119,59 +115,36 @@ function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { }); } -export class SqliteEventStore implements OrchestrationEventStore { - private readonly runtime: ManagedRuntime.ManagedRuntime; - private readonly migrated: Promise; - private closed = false; - - constructor(dbPath: string) { - fs.mkdirSync(path.dirname(dbPath), { recursive: true }); - this.runtime = ManagedRuntime.make( - SqliteClient.layer({ - filename: dbPath, - }), - ); - this.migrated = this.runtime.runPromise(this.migrate()).then(() => undefined); - } - - private provideSql( +export function makeSqliteOrchestrationEventRepository( + dbPath: string, +): OrchestrationEventRepositoryShape { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + + const runtime: ManagedRuntime.ManagedRuntime = ManagedRuntime.make( + SqliteClient.layer({ + filename: dbPath, + }), + ); + let closed = false; + + const provideSql = ( effect: Effect.Effect, - ): Effect.Effect { - return Effect.provide(effect, this.runtime); - } - - private ensureMigrated(): Effect.Effect { - return Effect.promise(() => this.migrated); - } - - private migrate(): Effect.Effect { - return Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`PRAGMA journal_mode = WAL;`; - yield* sql`PRAGMA foreign_keys = ON;`; - yield* sql` - CREATE TABLE IF NOT EXISTS orchestration_events ( - sequence INTEGER PRIMARY KEY AUTOINCREMENT, - event_id TEXT NOT NULL UNIQUE, - event_type TEXT NOT NULL, - aggregate_type TEXT NOT NULL, - aggregate_id TEXT NOT NULL, - occurred_at TEXT NOT NULL, - command_id TEXT, - payload_json TEXT NOT NULL - ) - `; - yield* sql` - CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate - ON orchestration_events(aggregate_type, aggregate_id, sequence) - `; - }); - } - - append(event: Omit): Effect.Effect { - return Effect.gen(this, function* () { - yield* this.ensureMigrated(); - const row = yield* this.provideSql( + ): Effect.Effect => Effect.provide(effect, runtime); + + const initialize = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`PRAGMA journal_mode = WAL;`; + yield* sql`PRAGMA foreign_keys = ON;`; + yield* runOrchestrationMigrations; + }); + + const initialized = runtime.runPromise(initialize).then(() => undefined); + const ensureInitialized = Effect.promise(() => initialized); + + const append: OrchestrationEventRepositoryShape["append"] = (event) => + Effect.gen(function* () { + yield* ensureInitialized; + const row = yield* provideSql( appendEventRow({ eventId: event.eventId, type: event.type, @@ -184,12 +157,14 @@ export class SqliteEventStore implements OrchestrationEventStore { ); return eventRowToOrchestrationEvent(row); }).pipe(Effect.orDie); - } - readFromSequence(sequenceExclusive: number, limit = 1_000): Effect.Effect { - return Effect.gen(this, function* () { - yield* this.ensureMigrated(); - const rows = yield* this.provideSql( + const readFromSequence: OrchestrationEventRepositoryShape["readFromSequence"] = ( + sequenceExclusive, + limit = 1_000, + ) => + Effect.gen(function* () { + yield* ensureInitialized; + const rows = yield* provideSql( readEventRowsFromSequence({ sequenceExclusive, limit, @@ -197,17 +172,19 @@ export class SqliteEventStore implements OrchestrationEventStore { ); return rows.map(eventRowToOrchestrationEvent); }).pipe(Effect.orDie); - } - - readAll(): Effect.Effect { - return this.readFromSequence(0, Number.MAX_SAFE_INTEGER); - } - close(): void { - if (this.closed) { + const close = (): void => { + if (closed) { return; } - this.closed = true; - void this.runtime.dispose(); - } + closed = true; + void runtime.dispose(); + }; + + return { + append, + readFromSequence, + readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), + close, + }; } diff --git a/apps/server/src/orchestration/layers.ts b/apps/server/src/orchestration/layers.ts index 7c0220fd8a825..d4037a3a0cad4 100644 --- a/apps/server/src/orchestration/layers.ts +++ b/apps/server/src/orchestration/layers.ts @@ -2,27 +2,26 @@ import path from "node:path"; import { Effect, Layer } from "effect"; +import { OrchestrationEventRepository } from "./eventRepository"; import { OrchestrationEngine } from "./engine"; -import { SqliteEventStore } from "./eventStore"; -import { - OrchestrationConfig, - OrchestrationEngineService, - OrchestrationEventStoreService, -} from "./services"; +import { makeSqliteOrchestrationEventRepository } from "./eventStore"; +import { OrchestrationConfig, OrchestrationEngineService } from "./services"; -export const OrchestrationEventStoreLive = Layer.effect( - OrchestrationEventStoreService, +export const OrchestrationEventRepositoryLive = Layer.effect( + OrchestrationEventRepository, Effect.map(OrchestrationConfig, ({ stateDir }) => { const dbPath = path.join(stateDir, "orchestration.sqlite"); - return new SqliteEventStore(dbPath); + return makeSqliteOrchestrationEventRepository(dbPath); }), ); export const OrchestrationEngineLive = Layer.effect( OrchestrationEngineService, - Effect.map(OrchestrationEventStoreService, (eventStore) => new OrchestrationEngine(eventStore)), + Effect.map(OrchestrationEventRepository, (eventRepository) => { + return new OrchestrationEngine(eventRepository); + }), ); export const OrchestrationLive = OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationEventRepositoryLive), ); diff --git a/apps/server/src/orchestration/migrations.ts b/apps/server/src/orchestration/migrations.ts new file mode 100644 index 0000000000000..7d651dc4eb3bd --- /dev/null +++ b/apps/server/src/orchestration/migrations.ts @@ -0,0 +1,31 @@ +import * as Migrator from "@effect/sql/Migrator"; +import * as SqlClient from "@effect/sql/SqlClient"; +import { Effect } from "effect"; + +const ORCHESTRATION_MIGRATIONS = Migrator.fromRecord({ + "0001_create_orchestration_events": Effect.flatMap(SqlClient.SqlClient, (sql) => + Effect.gen(function* () { + yield* sql` + CREATE TABLE IF NOT EXISTS orchestration_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + occurred_at TEXT NOT NULL, + command_id TEXT, + payload_json TEXT NOT NULL + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate + ON orchestration_events(aggregate_type, aggregate_id, sequence) + `; + }), + ), +}); + +export const runOrchestrationMigrations = Migrator.make({})({ + loader: ORCHESTRATION_MIGRATIONS, + table: "orchestration_sql_migrations", +}); diff --git a/apps/server/src/orchestration/services.ts b/apps/server/src/orchestration/services.ts index 5218c7a5884ab..148a83ef8bdb0 100644 --- a/apps/server/src/orchestration/services.ts +++ b/apps/server/src/orchestration/services.ts @@ -1,7 +1,6 @@ import { Context } from "effect"; import { OrchestrationEngine } from "./engine"; -import type { OrchestrationEventStore } from "./eventStore"; export interface OrchestrationConfigShape { readonly stateDir: string; @@ -12,10 +11,6 @@ export class OrchestrationConfig extends Context.Tag("orchestration/Config")< OrchestrationConfigShape >() {} -export class OrchestrationEventStoreService extends Context.Tag( - "orchestration/EventStore", -)() {} - export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")< OrchestrationEngineService, OrchestrationEngine From a23fb5e03ad122ec7041648a53ee74cf5e8c3f57 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 21 Feb 2026 19:55:48 -0800 Subject: [PATCH 07/98] more ffect: Service/Layer separation --- apps/server/src/orchestration/engine.test.ts | 2 +- apps/server/src/orchestration/engine.ts | 4 +- apps/server/src/orchestration/layers.ts | 4 +- apps/server/src/orchestration/migrations.ts | 31 --------- .../Layers/OrchestrationEvents.test.ts} | 12 ++-- .../Layers/OrchestrationEvents.ts} | 6 +- apps/server/src/persistence/Migrations.ts | 67 +++++++++++++++++++ .../Migrations/001_OrchestrationEvents.ts | 24 +++++++ .../Services/OrchestrationEvents.ts} | 0 apps/server/tsconfig.json | 4 +- 10 files changed, 110 insertions(+), 44 deletions(-) delete mode 100644 apps/server/src/orchestration/migrations.ts rename apps/server/src/{orchestration/eventStore.test.ts => persistence/Layers/OrchestrationEvents.test.ts} (87%) rename apps/server/src/{orchestration/eventStore.ts => persistence/Layers/OrchestrationEvents.ts} (96%) create mode 100644 apps/server/src/persistence/Migrations.ts create mode 100644 apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts rename apps/server/src/{orchestration/eventRepository.ts => persistence/Services/OrchestrationEvents.ts} (100%) diff --git a/apps/server/src/orchestration/engine.test.ts b/apps/server/src/orchestration/engine.test.ts index 5325ae35a86ec..59789bfa744c9 100644 --- a/apps/server/src/orchestration/engine.test.ts +++ b/apps/server/src/orchestration/engine.test.ts @@ -6,8 +6,8 @@ import type { OrchestrationEvent } from "@t3tools/contracts"; import { Effect } from "effect"; import { afterEach, describe, expect, it } from "vitest"; +import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; import { OrchestrationEngine } from "./engine"; -import type { OrchestrationEventRepositoryShape } from "./eventRepository"; const tempDirs: string[] = []; diff --git a/apps/server/src/orchestration/engine.ts b/apps/server/src/orchestration/engine.ts index 826b5143f5e63..56ba03ace3848 100644 --- a/apps/server/src/orchestration/engine.ts +++ b/apps/server/src/orchestration/engine.ts @@ -20,8 +20,8 @@ import { } from "effect"; import { createLogger } from "../logger"; -import type { OrchestrationEventRepositoryShape } from "./eventRepository"; -import { makeSqliteOrchestrationEventRepository } from "./eventStore"; +import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; +import { makeSqliteOrchestrationEventRepository } from "../persistence/Layers/OrchestrationEvents"; import { createEmptyReadModel, reduceEvent } from "./reducer"; import { UI_ENTITY_CONTRACTS } from "./uiContractInventory"; diff --git a/apps/server/src/orchestration/layers.ts b/apps/server/src/orchestration/layers.ts index d4037a3a0cad4..e2b4d2d1c82dc 100644 --- a/apps/server/src/orchestration/layers.ts +++ b/apps/server/src/orchestration/layers.ts @@ -2,9 +2,9 @@ import path from "node:path"; import { Effect, Layer } from "effect"; -import { OrchestrationEventRepository } from "./eventRepository"; +import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents"; +import { makeSqliteOrchestrationEventRepository } from "../persistence/Layers/OrchestrationEvents"; import { OrchestrationEngine } from "./engine"; -import { makeSqliteOrchestrationEventRepository } from "./eventStore"; import { OrchestrationConfig, OrchestrationEngineService } from "./services"; export const OrchestrationEventRepositoryLive = Layer.effect( diff --git a/apps/server/src/orchestration/migrations.ts b/apps/server/src/orchestration/migrations.ts deleted file mode 100644 index 7d651dc4eb3bd..0000000000000 --- a/apps/server/src/orchestration/migrations.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as Migrator from "@effect/sql/Migrator"; -import * as SqlClient from "@effect/sql/SqlClient"; -import { Effect } from "effect"; - -const ORCHESTRATION_MIGRATIONS = Migrator.fromRecord({ - "0001_create_orchestration_events": Effect.flatMap(SqlClient.SqlClient, (sql) => - Effect.gen(function* () { - yield* sql` - CREATE TABLE IF NOT EXISTS orchestration_events ( - sequence INTEGER PRIMARY KEY AUTOINCREMENT, - event_id TEXT NOT NULL UNIQUE, - event_type TEXT NOT NULL, - aggregate_type TEXT NOT NULL, - aggregate_id TEXT NOT NULL, - occurred_at TEXT NOT NULL, - command_id TEXT, - payload_json TEXT NOT NULL - ) - `; - yield* sql` - CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate - ON orchestration_events(aggregate_type, aggregate_id, sequence) - `; - }), - ), -}); - -export const runOrchestrationMigrations = Migrator.make({})({ - loader: ORCHESTRATION_MIGRATIONS, - table: "orchestration_sql_migrations", -}); diff --git a/apps/server/src/orchestration/eventStore.test.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts similarity index 87% rename from apps/server/src/orchestration/eventStore.test.ts rename to apps/server/src/persistence/Layers/OrchestrationEvents.test.ts index e75b949276082..60ee3a7a4272b 100644 --- a/apps/server/src/orchestration/eventStore.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts @@ -6,7 +6,7 @@ import { DatabaseSync } from "node:sqlite"; import { Runtime } from "effect"; import { afterEach, describe, expect, it } from "vitest"; -import { makeSqliteOrchestrationEventRepository } from "./eventStore"; +import { makeSqliteOrchestrationEventRepository } from "./OrchestrationEvents"; const tempDirs: string[] = []; @@ -69,17 +69,21 @@ describe("SqliteEventStore", () => { ` SELECT name FROM sqlite_master - WHERE type = 'table' AND name = 'orchestration_sql_migrations' + WHERE type = 'table' AND name IN ('effect_sql_migrations', 'orchestration_sql_migrations') + ORDER BY name ASC `, ) .get() as { name: string } | undefined; - expect(tableRow?.name).toBe("orchestration_sql_migrations"); + expect(tableRow?.name).toBeDefined(); + if (!tableRow) { + throw new Error("Expected migrations table to exist"); + } const migrationRows = db .prepare( ` SELECT * - FROM orchestration_sql_migrations + FROM ${tableRow.name} `, ) .all() as Array>; diff --git a/apps/server/src/orchestration/eventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.ts similarity index 96% rename from apps/server/src/orchestration/eventStore.ts rename to apps/server/src/persistence/Layers/OrchestrationEvents.ts index 6ec7572dc7363..bafdcd2a434c0 100644 --- a/apps/server/src/orchestration/eventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.ts @@ -9,8 +9,8 @@ import * as SqlSchema from "@effect/sql/SqlSchema"; import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; import { Effect, ManagedRuntime, Schema } from "effect"; -import type { OrchestrationEventRepositoryShape } from "./eventRepository"; -import { runOrchestrationMigrations } from "./migrations"; +import type { OrchestrationEventRepositoryShape } from "../Services/OrchestrationEvents"; +import { runMigrations } from "../Migrations"; const decodeEvent = Schema.decodeUnknownSync(OrchestrationEventSchema); @@ -135,7 +135,7 @@ export function makeSqliteOrchestrationEventRepository( const sql = yield* SqlClient.SqlClient; yield* sql`PRAGMA journal_mode = WAL;`; yield* sql`PRAGMA foreign_keys = ON;`; - yield* runOrchestrationMigrations; + yield* runMigrations; }); const initialized = runtime.runPromise(initialize).then(() => undefined); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts new file mode 100644 index 0000000000000..129a6f3c6adc9 --- /dev/null +++ b/apps/server/src/persistence/Migrations.ts @@ -0,0 +1,67 @@ +/** + * MigrationsLive - Migration runner with inline loader + * + * Uses Migrator.make with fromRecord to define migrations inline. + * All migrations are statically imported - no dynamic file system loading. + * + * Migrations run automatically when the MigrationLayer is provided, + * ensuring the database schema is always up-to-date before the application starts. + */ + +import * as Migrator from "@effect/sql/Migrator"; +import * as Layer from "effect/Layer"; + +// Import all migrations statically +import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; + + +/** + * Migration loader with all migrations defined inline. + * + * Key format: "{id}_{name}" where: + * - id: numeric migration ID (determines execution order) + * - name: descriptive name for the migration + * + * Uses Migrator.fromRecord which parses the key format and + * returns migrations sorted by ID. + */ +const loader = Migrator.fromRecord({ + "1_OrchestrationEvents": Migration0001, +}); + +/** + * Migrator run function - no schema dumping needed + * Uses the base Migrator.make without platform dependencies + */ +const run = Migrator.make({}); + +/** + * Run all pending migrations. + * + * Creates the migrations tracking table (effect_sql_migrations) if it doesn't exist, + * then runs any migrations with ID greater than the latest recorded migration. + * + * Returns array of [id, name] tuples for migrations that were run. + * + * @returns Effect containing array of executed migrations + */ +export const runMigrations = run({ loader }); + +/** + * Layer that runs migrations when the layer is built. + * + * Use this to ensure migrations run before your application starts. + * Migrations are run automatically - no separate script is needed. + * + * @example + * ```typescript + * import { MigrationsLive } from "@acme/db/Migrations" + * import { PgClient } from "@effect/sql-pg" + * + * // Migrations run automatically when PgClient is provided + * const AppLayer = MigrationsLive.pipe( + * Layer.provideMerge(PgClient.layer({ url: Redacted.make("postgresql://...") })) + * ) + * ``` + */ +export const MigrationsLive = Layer.effectDiscard(runMigrations); \ No newline at end of file diff --git a/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts b/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts new file mode 100644 index 0000000000000..f2c3b9f9a2c2c --- /dev/null +++ b/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts @@ -0,0 +1,24 @@ +import { SqlClient } from "@effect/sql"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS orchestration_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + aggregate_type TEXT NOT NULL, + aggregate_id TEXT NOT NULL, + occurred_at TEXT NOT NULL, + command_id TEXT, + payload_json TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate + ON orchestration_events(aggregate_type, aggregate_id, sequence) + `; +}); \ No newline at end of file diff --git a/apps/server/src/orchestration/eventRepository.ts b/apps/server/src/persistence/Services/OrchestrationEvents.ts similarity index 100% rename from apps/server/src/orchestration/eventRepository.ts rename to apps/server/src/persistence/Services/OrchestrationEvents.ts diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index bf7111184a650..776a47cce0410 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -3,7 +3,9 @@ "compilerOptions": { "composite": true, "types": ["node"], - "lib": ["ES2023", "esnext.disposable"] + "lib": ["ES2023", "esnext.disposable"], + "noEmit": true, + "allowImportingTsExtensions": true }, "include": ["src", "tsdown.config.ts"] } From 10bd27da3d7c869d7ea9c4bd824f9ee730bd1792 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 21 Feb 2026 20:22:55 -0800 Subject: [PATCH 08/98] more effect: scoped layers --- apps/server/package.json | 2 + apps/server/src/orchestration/engine.test.ts | 64 ++++--- apps/server/src/orchestration/engine.ts | 19 +- apps/server/src/orchestration/layers.test.ts | 14 +- apps/server/src/orchestration/layers.ts | 21 ++- apps/server/src/orchestration/runtime.ts | 18 +- .../Layers/OrchestrationEvents.test.ts | 27 +-- .../persistence/Layers/OrchestrationEvents.ts | 166 ++++++++---------- .../Services/OrchestrationEvents.ts | 1 - apps/server/src/wsServer.ts | 139 +++++++++------ bun.lock | 46 +++++ 11 files changed, 307 insertions(+), 210 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index cdd7b268c35d9..d92adb10ee673 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,6 +17,8 @@ "test": "vitest run" }, "dependencies": { + "@effect/platform": "^0.94.5", + "@effect/platform-node": "^0.104.1", "@effect/sql": "^0.49.0", "@effect/sql-sqlite-node": "^0.50.1", "effect": "^3.19.19", diff --git a/apps/server/src/orchestration/engine.test.ts b/apps/server/src/orchestration/engine.test.ts index 59789bfa744c9..666dff3d5bbeb 100644 --- a/apps/server/src/orchestration/engine.test.ts +++ b/apps/server/src/orchestration/engine.test.ts @@ -8,6 +8,7 @@ import { afterEach, describe, expect, it } from "vitest"; import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; import { OrchestrationEngine } from "./engine"; +import { createOrchestrationSystem } from "./runtime"; const tempDirs: string[] = []; @@ -30,8 +31,8 @@ describe("OrchestrationEngine", () => { const projectId = "project-1"; const threadId = "thread-1"; - const engineA = new OrchestrationEngine(stateDir); - await engineA.start(); + const firstSystem = await createOrchestrationSystem(stateDir); + const engineA = firstSystem.engine; await engineA.dispatch({ type: "project.create", commandId: "cmd-1", @@ -63,19 +64,19 @@ describe("OrchestrationEngine", () => { createdAt, }); const snapshotA = engineA.getSnapshot(); - await engineA.stop(); + await firstSystem.dispose(); - const engineB = new OrchestrationEngine(stateDir); - await engineB.start(); + const secondSystem = await createOrchestrationSystem(stateDir); + const engineB = secondSystem.engine; const snapshotB = engineB.getSnapshot(); expect(snapshotB).toEqual(snapshotA); - await engineB.stop(); + await secondSystem.dispose(); }); it("fans out read-model updates to subscribers", async () => { const stateDir = makeTempDir("t3code-orchestration-fanout-"); - const engine = new OrchestrationEngine(stateDir); - await engine.start(); + const system = await createOrchestrationSystem(stateDir); + const engine = system.engine; const updates: number[] = []; const unsubscribe = engine.subscribeToReadModel((snapshot) => { updates.push(snapshot.sequence); @@ -91,13 +92,13 @@ describe("OrchestrationEngine", () => { }); unsubscribe(); expect(updates.length).toBeGreaterThan(0); - await engine.stop(); + await system.dispose(); }); it("replays append-only events from sequence", async () => { const stateDir = makeTempDir("t3code-orchestration-replay-"); - const engine = new OrchestrationEngine(stateDir); - await engine.start(); + const system = await createOrchestrationSystem(stateDir); + const engine = system.engine; await engine.dispatch({ type: "project.create", commandId: "cmd-a", @@ -117,14 +118,14 @@ describe("OrchestrationEngine", () => { expect(events.length).toBe(2); expect(events[0]?.type).toBe("project.created"); expect(events[1]?.type).toBe("project.deleted"); - await engine.stop(); + await system.dispose(); }); it("stores completed turn summaries even when no files changed", async () => { const stateDir = makeTempDir("t3code-orchestration-turn-diff-"); - const engine = new OrchestrationEngine(stateDir); + const firstSystem = await createOrchestrationSystem(stateDir); + const engine = firstSystem.engine; const completedAt = new Date().toISOString(); - await engine.start(); await engine.dispatch({ type: "project.create", commandId: "cmd-project-turn-diff", @@ -167,10 +168,10 @@ describe("OrchestrationEngine", () => { checkpointTurnCount: 1, }, ]); - await engine.stop(); + await firstSystem.dispose(); - const restartedEngine = new OrchestrationEngine(stateDir); - await restartedEngine.start(); + const secondSystem = await createOrchestrationSystem(stateDir); + const restartedEngine = secondSystem.engine; const restartedThread = restartedEngine .getSnapshot() .threads.find((thread) => thread.id === "thread-turn-diff"); @@ -183,14 +184,14 @@ describe("OrchestrationEngine", () => { checkpointTurnCount: 1, }, ]); - await restartedEngine.stop(); + await secondSystem.dispose(); }); it("reverts thread messages and turn summaries to a checkpoint", async () => { const stateDir = makeTempDir("t3code-orchestration-revert-"); - const engine = new OrchestrationEngine(stateDir); + const firstSystem = await createOrchestrationSystem(stateDir); + const engine = firstSystem.engine; const createdAt = new Date().toISOString(); - await engine.start(); await engine.dispatch({ type: "project.create", commandId: "cmd-project-revert", @@ -291,10 +292,10 @@ describe("OrchestrationEngine", () => { expect(thread?.messages.map((message) => message.id)).toEqual(["user-1", "assistant:turn-1"]); expect(thread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); expect(thread?.latestTurnId).toBe("turn-1"); - await engine.stop(); + await firstSystem.dispose(); - const restarted = new OrchestrationEngine(stateDir); - await restarted.start(); + const secondSystem = await createOrchestrationSystem(stateDir); + const restarted = secondSystem.engine; const restartedThread = restarted .getSnapshot() .threads.find((entry) => entry.id === "thread-revert"); @@ -303,12 +304,22 @@ describe("OrchestrationEngine", () => { "assistant:turn-1", ]); expect(restartedThread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); - await restarted.stop(); + await secondSystem.dispose(); }); it("allows stop to be called multiple times", async () => { - const stateDir = makeTempDir("t3code-orchestration-stop-"); - const engine = new OrchestrationEngine(stateDir); + const inMemoryStore: OrchestrationEventRepositoryShape = { + append(event) { + return Effect.succeed({ ...event, sequence: 1 }); + }, + readFromSequence() { + return Effect.succeed([]); + }, + readAll() { + return Effect.succeed([]); + }, + }; + const engine = new OrchestrationEngine(inMemoryStore); await engine.start(); await engine.stop(); await expect(engine.stop()).resolves.toBeUndefined(); @@ -341,7 +352,6 @@ describe("OrchestrationEngine", () => { readAll() { return Effect.succeed(events); }, - close() {}, }; const engine = new OrchestrationEngine(flakyStore); diff --git a/apps/server/src/orchestration/engine.ts b/apps/server/src/orchestration/engine.ts index 56ba03ace3848..4069645dd90f4 100644 --- a/apps/server/src/orchestration/engine.ts +++ b/apps/server/src/orchestration/engine.ts @@ -1,5 +1,3 @@ -import path from "node:path"; - import type { OrchestrationCommand, OrchestrationEvent, @@ -21,7 +19,6 @@ import { import { createLogger } from "../logger"; import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; -import { makeSqliteOrchestrationEventRepository } from "../persistence/Layers/OrchestrationEvents"; import { createEmptyReadModel, reduceEvent } from "./reducer"; import { UI_ENTITY_CONTRACTS } from "./uiContractInventory"; @@ -215,15 +212,8 @@ export class OrchestrationEngine { private readonly readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); private readonly domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); - constructor(stateDir: string); - constructor(eventStore: OrchestrationEventRepositoryShape); - constructor(stateDirOrEventStore: string | OrchestrationEventRepositoryShape) { - if (typeof stateDirOrEventStore === "string") { - const dbPath = path.join(stateDirOrEventStore, "orchestration.sqlite"); - this.eventStore = makeSqliteOrchestrationEventRepository(dbPath); - } else { - this.eventStore = stateDirOrEventStore; - } + constructor(eventStore: OrchestrationEventRepositoryShape) { + this.eventStore = eventStore; this.readModel = createEmptyReadModel(new Date().toISOString()); this.commandQueue = Runtime.runSync(this.runtime)(Queue.unbounded()); this.readModelPubSub = Runtime.runSync(this.runtime)( @@ -279,6 +269,10 @@ export class OrchestrationEngine { } async start(): Promise { + if (this.workerFiber) { + return; + } + await Runtime.runPromise(this.runtime)(this.bootstrapReadModel()); const worker = Stream.fromQueue(this.commandQueue).pipe( @@ -297,7 +291,6 @@ export class OrchestrationEngine { await Runtime.runPromise(this.runtime)(Fiber.interrupt(this.workerFiber)); this.workerFiber = null; } - this.eventStore.close(); } getSnapshot(): OrchestrationReadModel { diff --git a/apps/server/src/orchestration/layers.test.ts b/apps/server/src/orchestration/layers.test.ts index da24a9b7c79c7..440af03bbdb0d 100644 --- a/apps/server/src/orchestration/layers.test.ts +++ b/apps/server/src/orchestration/layers.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { createOrchestrationEngine } from "./runtime"; +import { createOrchestrationSystem } from "./runtime"; const tempDirs: string[] = []; @@ -24,9 +24,9 @@ describe("orchestration layers", () => { it("creates an engine through Context.Tag wiring", async () => { const stateDir = makeTempDir("t3code-orchestration-layer-"); const createdAt = new Date().toISOString(); - const engine = createOrchestrationEngine(stateDir); + const firstSystem = await createOrchestrationSystem(stateDir); + const { engine } = firstSystem; - await engine.start(); await engine.dispatch({ type: "project.create", commandId: "cmd-layer-1", @@ -37,11 +37,11 @@ describe("orchestration layers", () => { createdAt, }); const firstSnapshot = engine.getSnapshot(); - await engine.stop(); + await firstSystem.dispose(); - const restarted = createOrchestrationEngine(stateDir); - await restarted.start(); + const secondSystem = await createOrchestrationSystem(stateDir); + const restarted = secondSystem.engine; expect(restarted.getSnapshot()).toEqual(firstSnapshot); - await restarted.stop(); + await secondSystem.dispose(); }); }); diff --git a/apps/server/src/orchestration/layers.ts b/apps/server/src/orchestration/layers.ts index e2b4d2d1c82dc..63009c9c9b7bf 100644 --- a/apps/server/src/orchestration/layers.ts +++ b/apps/server/src/orchestration/layers.ts @@ -3,23 +3,28 @@ import path from "node:path"; import { Effect, Layer } from "effect"; import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents"; -import { makeSqliteOrchestrationEventRepository } from "../persistence/Layers/OrchestrationEvents"; +import { makeSqliteOrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents"; import { OrchestrationEngine } from "./engine"; import { OrchestrationConfig, OrchestrationEngineService } from "./services"; -export const OrchestrationEventRepositoryLive = Layer.effect( - OrchestrationEventRepository, +export const OrchestrationEventRepositoryLive = Layer.unwrapEffect( Effect.map(OrchestrationConfig, ({ stateDir }) => { const dbPath = path.join(stateDir, "orchestration.sqlite"); - return makeSqliteOrchestrationEventRepository(dbPath); + return makeSqliteOrchestrationEventRepositoryLive(dbPath); }), ); -export const OrchestrationEngineLive = Layer.effect( +export const OrchestrationEngineLive = Layer.scoped( OrchestrationEngineService, - Effect.map(OrchestrationEventRepository, (eventRepository) => { - return new OrchestrationEngine(eventRepository); - }), + Effect.acquireRelease( + Effect.gen(function* () { + const eventRepository = yield* OrchestrationEventRepository; + const engine = new OrchestrationEngine(eventRepository); + yield* Effect.promise(() => engine.start()); + return engine; + }), + (engine) => Effect.promise(() => engine.stop()), + ), ); export const OrchestrationLive = OrchestrationEngineLive.pipe( diff --git a/apps/server/src/orchestration/runtime.ts b/apps/server/src/orchestration/runtime.ts index cbb875b5f907d..67851be2f5432 100644 --- a/apps/server/src/orchestration/runtime.ts +++ b/apps/server/src/orchestration/runtime.ts @@ -1,14 +1,22 @@ -import { Effect, Layer, Runtime } from "effect"; +import { Layer, ManagedRuntime } from "effect"; import type { OrchestrationEngine } from "./engine"; import { OrchestrationLive } from "./layers"; import { OrchestrationConfig, OrchestrationEngineService } from "./services"; -export function createOrchestrationEngine(stateDir: string): OrchestrationEngine { +export interface OrchestrationSystem { + readonly engine: OrchestrationEngine; + readonly dispose: () => Promise; +} + +export async function createOrchestrationSystem(stateDir: string): Promise { const orchestrationLayer = OrchestrationLive.pipe( Layer.provide(Layer.succeed(OrchestrationConfig, { stateDir })), ); - return Runtime.runSync(Runtime.defaultRuntime)( - Effect.provide(OrchestrationEngineService, orchestrationLayer), - ); + const runtime = ManagedRuntime.make(orchestrationLayer); + const engine = await runtime.runPromise(OrchestrationEngineService); + return { + engine, + dispose: () => runtime.dispose(), + }; } diff --git a/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts index 60ee3a7a4272b..57f75fb55d610 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts @@ -3,10 +3,11 @@ import os from "node:os"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; -import { Runtime } from "effect"; +import { Runtime, ManagedRuntime } from "effect"; import { afterEach, describe, expect, it } from "vitest"; -import { makeSqliteOrchestrationEventRepository } from "./OrchestrationEvents"; +import { OrchestrationEventRepository } from "../Services/OrchestrationEvents"; +import { makeSqliteOrchestrationEventRepositoryLive } from "./OrchestrationEvents"; const tempDirs: string[] = []; @@ -22,14 +23,15 @@ afterEach(() => { } }); -describe("SqliteEventStore", () => { +describe("OrchestrationEventRepository", () => { it("persists and replays events across restarts", async () => { const stateDir = makeTempDir("t3code-event-store-"); const dbPath = path.join(stateDir, "orchestration.sqlite"); const runtime = Runtime.defaultRuntime; const createdAt = new Date().toISOString(); - const first = makeSqliteOrchestrationEventRepository(dbPath); + const firstRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); + const first = await firstRuntime.runPromise(OrchestrationEventRepository); const saved = await Runtime.runPromise(runtime)( first.append({ eventId: "event-1", @@ -42,12 +44,13 @@ describe("SqliteEventStore", () => { }), ); expect(saved.sequence).toBe(1); - first.close(); + await firstRuntime.dispose(); - const second = makeSqliteOrchestrationEventRepository(dbPath); + const secondRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); + const second = await secondRuntime.runPromise(OrchestrationEventRepository); const replayed = await Runtime.runPromise(runtime)(second.readFromSequence(0)); expect(replayed).toEqual([saved]); - second.close(); + await secondRuntime.dispose(); }); it("creates and reuses the migrator tracking table", async () => { @@ -55,13 +58,15 @@ describe("SqliteEventStore", () => { const dbPath = path.join(stateDir, "orchestration.sqlite"); const runtime = Runtime.defaultRuntime; - const first = makeSqliteOrchestrationEventRepository(dbPath); + const firstRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); + const first = await firstRuntime.runPromise(OrchestrationEventRepository); await Runtime.runPromise(runtime)(first.readAll()); - first.close(); + await firstRuntime.dispose(); - const second = makeSqliteOrchestrationEventRepository(dbPath); + const secondRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); + const second = await secondRuntime.runPromise(OrchestrationEventRepository); await Runtime.runPromise(runtime)(second.readAll()); - second.close(); + await secondRuntime.dispose(); const db = new DatabaseSync(dbPath, { readOnly: true }); const tableRow = db diff --git a/apps/server/src/persistence/Layers/OrchestrationEvents.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.ts index bafdcd2a434c0..56bdf08bdb8a5 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEvents.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.ts @@ -7,10 +7,13 @@ import type { SqlClient as SqlClientService } from "@effect/sql/SqlClient"; import * as SqlClient from "@effect/sql/SqlClient"; import * as SqlSchema from "@effect/sql/SqlSchema"; import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; -import { Effect, ManagedRuntime, Schema } from "effect"; +import { Context, Effect, Layer, Schema } from "effect"; -import type { OrchestrationEventRepositoryShape } from "../Services/OrchestrationEvents"; import { runMigrations } from "../Migrations"; +import { + OrchestrationEventRepository, + type OrchestrationEventRepositoryShape, +} from "../Services/OrchestrationEvents"; const decodeEvent = Schema.decodeUnknownSync(OrchestrationEventSchema); @@ -42,11 +45,31 @@ const ReadFromSequenceRequestSchema = Schema.Struct({ limit: Schema.Number, }); -const appendEventRow = SqlSchema.single({ - Request: AppendEventRequestSchema, - Result: EventRowSchema, - execute: (request) => - Effect.flatMap(SqlClient.SqlClient, (sql) => +class OrchestrationSql extends Context.Tag("persistence/OrchestrationSql")< + OrchestrationSql, + SqlClientService +>() {} + +function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { + return decodeEvent({ + sequence: row.sequence, + eventId: row.eventId, + type: row.type, + aggregateType: row.aggregateType, + aggregateId: row.aggregateId, + occurredAt: row.occurredAt, + commandId: row.commandId, + payload: JSON.parse(row.payloadJson) as unknown, + }); +} + +const makeRepository = Effect.gen(function* () { + const sql = yield* OrchestrationSql; + + const appendEventRow = SqlSchema.single({ + Request: AppendEventRequestSchema, + Result: EventRowSchema, + execute: (request) => sql` INSERT INTO orchestration_events ( event_id, @@ -76,14 +99,12 @@ const appendEventRow = SqlSchema.single({ command_id AS "commandId", payload_json AS "payloadJson" `, - ), -}); + }); -const readEventRowsFromSequence = SqlSchema.findAll({ - Request: ReadFromSequenceRequestSchema, - Result: EventRowSchema, - execute: (request) => - Effect.flatMap(SqlClient.SqlClient, (sql) => + const readEventRowsFromSequence = SqlSchema.findAll({ + Request: ReadFromSequenceRequestSchema, + Result: EventRowSchema, + execute: (request) => sql` SELECT sequence, @@ -99,92 +120,59 @@ const readEventRowsFromSequence = SqlSchema.findAll({ ORDER BY sequence ASC LIMIT ${request.limit} `, - ), -}); - -function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { - return decodeEvent({ - sequence: row.sequence, - eventId: row.eventId, - type: row.type, - aggregateType: row.aggregateType, - aggregateId: row.aggregateId, - occurredAt: row.occurredAt, - commandId: row.commandId, - payload: JSON.parse(row.payloadJson) as unknown, }); -} - -export function makeSqliteOrchestrationEventRepository( - dbPath: string, -): OrchestrationEventRepositoryShape { - fs.mkdirSync(path.dirname(dbPath), { recursive: true }); - - const runtime: ManagedRuntime.ManagedRuntime = ManagedRuntime.make( - SqliteClient.layer({ - filename: dbPath, - }), - ); - let closed = false; - - const provideSql = ( - effect: Effect.Effect, - ): Effect.Effect => Effect.provide(effect, runtime); - - const initialize = Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`PRAGMA journal_mode = WAL;`; - yield* sql`PRAGMA foreign_keys = ON;`; - yield* runMigrations; - }); - - const initialized = runtime.runPromise(initialize).then(() => undefined); - const ensureInitialized = Effect.promise(() => initialized); const append: OrchestrationEventRepositoryShape["append"] = (event) => - Effect.gen(function* () { - yield* ensureInitialized; - const row = yield* provideSql( - appendEventRow({ - eventId: event.eventId, - type: event.type, - aggregateType: event.aggregateType, - aggregateId: event.aggregateId, - occurredAt: event.occurredAt, - commandId: event.commandId, - payloadJson: JSON.stringify(event.payload), - }), - ); - return eventRowToOrchestrationEvent(row); - }).pipe(Effect.orDie); + appendEventRow({ + eventId: event.eventId, + type: event.type, + aggregateType: event.aggregateType, + aggregateId: event.aggregateId, + occurredAt: event.occurredAt, + commandId: event.commandId, + payloadJson: JSON.stringify(event.payload), + }).pipe(Effect.map(eventRowToOrchestrationEvent), Effect.orDie); const readFromSequence: OrchestrationEventRepositoryShape["readFromSequence"] = ( sequenceExclusive, limit = 1_000, ) => - Effect.gen(function* () { - yield* ensureInitialized; - const rows = yield* provideSql( - readEventRowsFromSequence({ - sequenceExclusive, - limit, - }), - ); - return rows.map(eventRowToOrchestrationEvent); - }).pipe(Effect.orDie); - - const close = (): void => { - if (closed) { - return; - } - closed = true; - void runtime.dispose(); - }; + readEventRowsFromSequence({ sequenceExclusive, limit }).pipe( + Effect.map((rows) => rows.map(eventRowToOrchestrationEvent)), + Effect.orDie, + ); return { append, readFromSequence, readAll: () => readFromSequence(0, Number.MAX_SAFE_INTEGER), - close, - }; + } satisfies OrchestrationEventRepositoryShape; +}); + +export const OrchestrationEventRepositoryLive = Layer.effect( + OrchestrationEventRepository, + makeRepository, +); + +export function makeSqliteOrchestrationEventRepositoryLive(dbPath: string) { + const SqliteClientLive = Effect.gen(function* () { + yield* Effect.try({ + try: () => fs.mkdirSync(path.dirname(dbPath), { recursive: true }), + catch: (error) => Effect.die(error), + }) + return SqliteClient.layer({ filename: dbPath }); + }).pipe(Layer.unwrapEffect) + + const OrchestrationSqlLive = Layer.scoped( + OrchestrationSql, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`PRAGMA journal_mode = WAL;`; + yield* sql`PRAGMA foreign_keys = ON;`; + yield* runMigrations; + return sql; + }), + ).pipe(Layer.provide(SqliteClientLive)); + + return OrchestrationEventRepositoryLive.pipe(Layer.provide(OrchestrationSqlLive)); } diff --git a/apps/server/src/persistence/Services/OrchestrationEvents.ts b/apps/server/src/persistence/Services/OrchestrationEvents.ts index 503df4c18ca4c..d785733358f48 100644 --- a/apps/server/src/persistence/Services/OrchestrationEvents.ts +++ b/apps/server/src/persistence/Services/OrchestrationEvents.ts @@ -11,7 +11,6 @@ export interface OrchestrationEventRepositoryShape { limit?: number, ) => Effect.Effect; readonly readAll: () => Effect.Effect; - readonly close: () => void; } export class OrchestrationEventRepository extends Context.Tag("orchestration/EventRepository")< diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index cdee56f031e6e..dd8f3fd6eb1a0 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -36,7 +36,7 @@ import { import { TerminalManager } from "./terminalManager"; import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; -import { createOrchestrationEngine } from "./orchestration/runtime"; +import { createOrchestrationSystem, type OrchestrationSystem } from "./orchestration/runtime"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -88,6 +88,8 @@ function asNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } +const noop = () => {}; + export function createServer(options: ServerOptions) { const { port, @@ -107,14 +109,15 @@ export function createServer(options: ServerOptions) { const projectRegistry = providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); const gitManager = providedGitManager ?? new GitManager(); - const orchestrationEngine = createOrchestrationEngine( - stateDir ?? path.join(os.homedir(), ".t3", "userdata"), - ); + const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata"); + let orchestrationSystem: OrchestrationSystem | null = null; const clients = new Set(); const logger = createLogger("ws"); const logWebSocketEvents = explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); let keybindingsConfig = loadResolvedKeybindingsConfig(logger); + let unsubscribeReadModel = noop; + let unsubscribeDomainEvents = noop; function logOutgoingPush(push: WsPush, recipients: number) { if (!logWebSocketEvents) return; @@ -125,46 +128,62 @@ export function createServer(options: ServerOptions) { }); } - const unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => { - const push: WsPush = { - type: "push", - channel: ORCHESTRATION_WS_CHANNELS.readModel, - data: { - sequence: snapshot.sequence, - snapshot, - } satisfies OrchestrationReadModelPush, - }; - const message = JSON.stringify(push); - let recipients = 0; - for (const client of clients) { - if (client.readyState === client.OPEN) { - client.send(message); - recipients += 1; - } + function requireOrchestrationEngine() { + if (!orchestrationSystem) { + throw new Error("Orchestration engine is not started"); } - logOutgoingPush(push, recipients); - }); + return orchestrationSystem.engine; + } - const unsubscribeDomainEvents = orchestrationEngine.subscribeToDomainEvents((event) => { - const push: WsPush = { - type: "push", - channel: ORCHESTRATION_WS_CHANNELS.domainEvent, - 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; + function attachOrchestrationSubscriptions() { + const orchestrationEngine = requireOrchestrationEngine(); + + unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => { + const push: WsPush = { + type: "push", + channel: ORCHESTRATION_WS_CHANNELS.readModel, + data: { + sequence: snapshot.sequence, + snapshot, + } satisfies OrchestrationReadModelPush, + }; + const message = JSON.stringify(push); + let recipients = 0; + for (const client of clients) { + if (client.readyState === client.OPEN) { + client.send(message); + recipients += 1; + } } - } - logOutgoingPush(push, recipients); - }); + logOutgoingPush(push, recipients); + }); + + unsubscribeDomainEvents = orchestrationEngine.subscribeToDomainEvents((event) => { + const push: WsPush = { + type: "push", + channel: ORCHESTRATION_WS_CHANNELS.domainEvent, + 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; + } + } + logOutgoingPush(push, recipients); + }); + } // Forward provider events to all connected WebSocket clients providerManager.on("event", (event) => { void (async () => { + const liveOrchestrationSystem = orchestrationSystem; + if (!liveOrchestrationSystem) { + return; + } + const orchestrationEngine = liveOrchestrationSystem.engine; const snapshot = orchestrationEngine.getSnapshot(); const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId); if (!thread) return; @@ -367,6 +386,7 @@ export function createServer(options: ServerOptions) { wss.on("connection", (ws) => { clients.add(ws); + const orchestrationEngine = requireOrchestrationEngine(); // Send welcome message with project info const segments = cwd.split(/[/\\]/).filter(Boolean); @@ -433,6 +453,8 @@ export function createServer(options: ServerOptions) { } async function routeRequest(request: WsRequest): Promise { + const orchestrationEngine = requireOrchestrationEngine(); + switch (request.method) { case WS_METHODS.providersStartSession: return providerManager.startSession(request.params as never); @@ -676,21 +698,42 @@ export function createServer(options: ServerOptions) { } } - function start() { + async function ensureOrchestrationSystemStarted() { + if (orchestrationSystem) { + return; + } + + orchestrationSystem = await createOrchestrationSystem(resolvedStateDir); + attachOrchestrationSubscriptions(); + } + + async function disposeOrchestrationSystem() { + unsubscribeReadModel(); + unsubscribeDomainEvents(); + unsubscribeReadModel = noop; + unsubscribeDomainEvents = noop; + + if (!orchestrationSystem) { + return; + } + + const system = orchestrationSystem; + orchestrationSystem = null; + await system.dispose(); + } + + async function start() { + await ensureOrchestrationSystemStarted(); + return new Promise((resolve, reject) => { const onError = (error: Error) => { httpServer.off("error", onError); - reject(error); + void disposeOrchestrationSystem().finally(() => reject(error)); }; httpServer.once("error", onError); - const onListening = async () => { + const onListening = () => { httpServer.off("error", onError); - try { - await orchestrationEngine.start(); - resolve(); - } catch (error) { - reject(error instanceof Error ? error : new Error("Failed to start orchestration engine")); - } + resolve(); }; if (host) { httpServer.listen(port, host, onListening); @@ -701,9 +744,7 @@ export function createServer(options: ServerOptions) { } async function stop(): Promise { - unsubscribeReadModel(); - unsubscribeDomainEvents(); - await orchestrationEngine.stop(); + await disposeOrchestrationSystem(); terminalManager.off("event", onTerminalEvent); providerManager.stopAll(); providerManager.dispose(); diff --git a/bun.lock b/bun.lock index be1fceaab7490..2528167f3753a 100644 --- a/bun.lock +++ b/bun.lock @@ -34,6 +34,8 @@ "t3": "./dist/index.mjs", }, "dependencies": { + "@effect/platform": "^0.94.5", + "@effect/platform-node": "^0.104.1", "@effect/sql": "^0.49.0", "@effect/sql-sqlite-node": "^0.50.1", "effect": "^3.19.19", @@ -148,14 +150,24 @@ "@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/cluster": ["@effect/cluster@0.56.4", "", { "dependencies": { "kubernetes-types": "^1.30.0" }, "peerDependencies": { "@effect/platform": "^0.94.5", "@effect/rpc": "^0.73.1", "@effect/sql": "^0.49.0", "@effect/workflow": "^0.16.0", "effect": "^3.19.17" } }, "sha512-7Je5/JlbZOlsSxsbKjr97dJed2cNGWsb+TLNgMcr5mRDbcWlFOTUGvsrisEJV6waosYLIg+2omPdvnvRoYKdhA=="], + "@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/platform-node": ["@effect/platform-node@0.104.1", "", { "dependencies": { "@effect/platform-node-shared": "^0.57.1", "mime": "^3.0.0", "undici": "^7.10.0", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-jT1a/z98niK6fnEU8pWHPPCdJMVDRCIdB65lolcOjse5rsTwVbczMjvKkhVQpF63mNWoOnol7OTRNkw5L54llg=="], + + "@effect/platform-node-shared": ["@effect/platform-node-shared@0.57.1", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "multipasta": "^0.2.7", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-oX/bApMdoKsyrDiNdJxo7U9Rz1RXsjRv+ecfAPp1qGlSdGIo32wVRvJ2XCHqYj0sqaYJS0pU0/GCulRfVGuJag=="], + + "@effect/rpc": ["@effect/rpc@0.73.2", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.94.5", "effect": "^3.19.18" } }, "sha512-td7LHDgBOYKg+VgGWEelD8rSAmvjXz7am17vfxZROX5qIYuvH7drL/z4p5xQFadhHZ7DYdlFpqdO9ggc77OCIw=="], + "@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=="], + "@effect/workflow": ["@effect/workflow@0.16.0", "", { "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "@effect/rpc": "^0.73.0", "effect": "^3.19.13" } }, "sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw=="], + "@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=="], @@ -336,6 +348,34 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.46.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DJbQsSJUr4KSi9uU0QqOgI7PX2C+fKGZX+YDprt3vM2sC0dWZsgVTLoN2vtkNyEWJSY2mnvRFUshWXT3bmo0Ug=="], + "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.16", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-McjTuEPuacSIcXdoI2O9W6VSHIOs9ApEHnEUwONKZnKqIo2GGv1vNg9Pr8tgBOL7lgBWNEHX5ROJ5z1X74sENQ=="], "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], @@ -920,6 +960,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], @@ -1056,6 +1098,8 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -1342,6 +1386,8 @@ "unconfig-core": ["unconfig-core@7.4.2", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-VgPCvLWugINbXvMQDf8Jh0mlbvNjNC6eSUziHsBCMpxR05OPrNrvDnyatdMjRgcHaaNsCqz+wjNXxNw1kRLHUg=="], + "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "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=="], From 69f6a8266327dee3a4ce50bb900ec2cb9180ce8c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 01:00:48 -0800 Subject: [PATCH 09/98] more effect: ProjectRegistry and shared runtime --- apps/server/src/index.ts | 3 - apps/server/src/orchestration/engine.test.ts | 106 ++-- apps/server/src/orchestration/engine.ts | 59 +- apps/server/src/orchestration/layers.test.ts | 47 -- apps/server/src/orchestration/layers.ts | 13 +- apps/server/src/orchestration/reducer.test.ts | 97 ++++ apps/server/src/orchestration/reducer.ts | 526 +++++++++--------- apps/server/src/orchestration/runtime.ts | 22 - apps/server/src/orchestration/services.ts | 9 - .../src/orchestration/uiContractInventory.ts | 10 +- .../Layers/OrchestrationEvents.test.ts | 10 +- .../persistence/Layers/OrchestrationEvents.ts | 37 +- .../src/persistence/Layers/Projects.test.ts | 83 +++ .../server/src/persistence/Layers/Projects.ts | 339 +++++++++++ apps/server/src/persistence/Layers/Sqlite.ts | 41 ++ apps/server/src/persistence/Migrations.ts | 5 +- .../Migrations/001_OrchestrationEvents.ts | 2 +- .../persistence/Migrations/002_Projects.ts | 22 + .../src/persistence/Services/Projects.ts | 25 + apps/server/src/projectRegistry.test.ts | 113 ---- apps/server/src/projectRegistry.ts | 235 -------- apps/server/src/wsServer.test.ts | 53 +- apps/server/src/wsServer.ts | 287 +++++----- apps/web/src/components/Sidebar.tsx | 67 +-- apps/web/src/routes/__root.tsx | 48 +- apps/web/src/store.ts | 49 +- packages/contracts/src/orchestration.ts | 31 -- 27 files changed, 1204 insertions(+), 1135 deletions(-) delete mode 100644 apps/server/src/orchestration/layers.test.ts create mode 100644 apps/server/src/orchestration/reducer.test.ts delete mode 100644 apps/server/src/orchestration/runtime.ts create mode 100644 apps/server/src/persistence/Layers/Projects.test.ts create mode 100644 apps/server/src/persistence/Layers/Projects.ts create mode 100644 apps/server/src/persistence/Layers/Sqlite.ts create mode 100644 apps/server/src/persistence/Migrations/002_Projects.ts create mode 100644 apps/server/src/persistence/Services/Projects.ts delete mode 100644 apps/server/src/projectRegistry.test.ts delete mode 100644 apps/server/src/projectRegistry.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 159dbbc2a88d5..00886cb8d054e 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url"; import { fixPath } from "./fixPath"; import { createLogger } from "./logger"; -import { ProjectRegistry } from "./projectRegistry"; import { createServer } from "./wsServer"; fixPath(); @@ -102,7 +101,6 @@ async function main() { const port = requestedPort ?? (mode === "desktop" ? DEFAULT_PORT : await findAvailablePort(DEFAULT_PORT)); const stateDir = resolveStateDir(process.env.T3CODE_STATE_DIR); - const projectRegistry = new ProjectRegistry(stateDir); const devUrl = process.env.VITE_DEV_SERVER_URL; const noBrowser = parseBooleanEnv(process.env.T3CODE_NO_BROWSER) ?? mode === "desktop"; const authToken = process.env.T3CODE_AUTH_TOKEN; @@ -121,7 +119,6 @@ async function main() { stateDir, staticDir, devUrl, - projectRegistry, authToken, }); await server.start(); diff --git a/apps/server/src/orchestration/engine.test.ts b/apps/server/src/orchestration/engine.test.ts index 666dff3d5bbeb..d8c7813cfc1da 100644 --- a/apps/server/src/orchestration/engine.test.ts +++ b/apps/server/src/orchestration/engine.test.ts @@ -8,7 +8,13 @@ import { afterEach, describe, expect, it } from "vitest"; import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; import { OrchestrationEngine } from "./engine"; -import { createOrchestrationSystem } from "./runtime"; + +import { Layer, ManagedRuntime } from "effect"; + +import { makeSqlitePersistenceLive } from "../persistence/Layers/Sqlite"; + +import { OrchestrationLive } from "./layers"; +import { OrchestrationEngineService } from "./services"; const tempDirs: string[] = []; @@ -24,6 +30,19 @@ afterEach(() => { } }); +export async function createOrchestrationSystem(stateDir: string) { + const dbPath = path.join(stateDir, "orchestration.sqlite"); + const orchestrationLayer = OrchestrationLive.pipe( + Layer.provide(makeSqlitePersistenceLive(dbPath)), + ); + const runtime = ManagedRuntime.make(orchestrationLayer); + const engine = await runtime.runPromise(OrchestrationEngineService); + return { + engine, + dispose: () => runtime.dispose(), + }; +} + describe("OrchestrationEngine", () => { it("replays to the same deterministic snapshot", async () => { const stateDir = makeTempDir("t3code-orchestration-"); @@ -33,18 +52,9 @@ describe("OrchestrationEngine", () => { const firstSystem = await createOrchestrationSystem(stateDir); const engineA = firstSystem.engine; - await engineA.dispatch({ - type: "project.create", - commandId: "cmd-1", - projectId, - name: "demo", - cwd: "/tmp/demo", - model: "gpt-5-codex", - createdAt, - }); await engineA.dispatch({ type: "thread.create", - commandId: "cmd-2", + commandId: "cmd-1", threadId, projectId, title: "Thread", @@ -82,12 +92,14 @@ describe("OrchestrationEngine", () => { updates.push(snapshot.sequence); }); await engine.dispatch({ - type: "project.create", - commandId: "cmd-project", + type: "thread.create", + commandId: "cmd-thread", + threadId: "thread-2", projectId: "project-2", - name: "fanout", - cwd: "/tmp/fanout", + title: "fanout", model: "gpt-5-codex", + branch: null, + worktreePath: null, createdAt: new Date().toISOString(), }); unsubscribe(); @@ -100,24 +112,26 @@ describe("OrchestrationEngine", () => { const system = await createOrchestrationSystem(stateDir); const engine = system.engine; await engine.dispatch({ - type: "project.create", + type: "thread.create", commandId: "cmd-a", + threadId: "thread-replay", projectId: "project-replay", - name: "replay", - cwd: "/tmp/replay", + title: "replay", model: "gpt-5-codex", + branch: null, + worktreePath: null, createdAt: new Date().toISOString(), }); await engine.dispatch({ - type: "project.delete", + type: "thread.delete", commandId: "cmd-b", - projectId: "project-replay", + threadId: "thread-replay", createdAt: new Date().toISOString(), }); const events = await engine.replayEvents(0); expect(events.length).toBe(2); - expect(events[0]?.type).toBe("project.created"); - expect(events[1]?.type).toBe("project.deleted"); + expect(events[0]?.type).toBe("thread.created"); + expect(events[1]?.type).toBe("thread.deleted"); await system.dispose(); }); @@ -126,15 +140,6 @@ describe("OrchestrationEngine", () => { const firstSystem = await createOrchestrationSystem(stateDir); const engine = firstSystem.engine; const completedAt = new Date().toISOString(); - await engine.dispatch({ - type: "project.create", - commandId: "cmd-project-turn-diff", - projectId: "project-turn-diff", - name: "turn-diff", - cwd: "/tmp/turn-diff", - model: "gpt-5-codex", - createdAt: completedAt, - }); await engine.dispatch({ type: "thread.create", commandId: "cmd-thread-turn-diff", @@ -158,7 +163,9 @@ describe("OrchestrationEngine", () => { createdAt: completedAt, }); - const firstThread = engine.getSnapshot().threads.find((thread) => thread.id === "thread-turn-diff"); + const firstThread = engine + .getSnapshot() + .threads.find((thread) => thread.id === "thread-turn-diff"); expect(firstThread?.turnDiffSummaries).toEqual([ { turnId: "turn-1", @@ -192,15 +199,6 @@ describe("OrchestrationEngine", () => { const firstSystem = await createOrchestrationSystem(stateDir); const engine = firstSystem.engine; const createdAt = new Date().toISOString(); - await engine.dispatch({ - type: "project.create", - commandId: "cmd-project-revert", - projectId: "project-revert", - name: "revert", - cwd: "/tmp/revert", - model: "gpt-5-codex", - createdAt, - }); await engine.dispatch({ type: "thread.create", commandId: "cmd-thread-revert", @@ -334,9 +332,7 @@ describe("OrchestrationEngine", () => { append(event) { if (shouldFailFirstAppend) { shouldFailFirstAppend = false; - return Effect.sync(() => { - throw new Error("append failed"); - }); + return Effect.die(new Error("append failed")); } const savedEvent = { ...event, @@ -360,28 +356,32 @@ describe("OrchestrationEngine", () => { await expect( engine.dispatch({ - type: "project.create", + type: "thread.create", commandId: "cmd-flaky-1", - projectId: "project-flaky-fail", - name: "flaky-fail", - cwd: "/tmp/flaky-fail", + threadId: "thread-flaky-fail", + projectId: "project-flaky", + title: "flaky-fail", model: "gpt-5-codex", + branch: null, + worktreePath: null, createdAt, }), ).rejects.toThrow("append failed"); const result = await engine.dispatch({ - type: "project.create", + type: "thread.create", commandId: "cmd-flaky-2", - projectId: "project-flaky-ok", - name: "flaky-ok", - cwd: "/tmp/flaky-ok", + threadId: "thread-flaky-ok", + projectId: "project-flaky", + title: "flaky-ok", model: "gpt-5-codex", + branch: null, + worktreePath: null, createdAt, }); expect(result.sequence).toBe(1); - expect(engine.getSnapshot().projects.map((project) => project.id)).toEqual(["project-flaky-ok"]); + expect(engine.getSnapshot().sequence).toBe(1); await engine.stop(); }); diff --git a/apps/server/src/orchestration/engine.ts b/apps/server/src/orchestration/engine.ts index 4069645dd90f4..fda67ce71a38e 100644 --- a/apps/server/src/orchestration/engine.ts +++ b/apps/server/src/orchestration/engine.ts @@ -34,36 +34,6 @@ function asError(error: unknown, fallbackMessage: string): Error { function mapCommandToEvent(command: OrchestrationCommand): Omit { const eventId = crypto.randomUUID(); switch (command.type) { - case "project.create": - return { - eventId, - type: "project.created", - aggregateType: "project", - aggregateId: command.projectId, - occurredAt: command.createdAt, - commandId: command.commandId, - payload: { - id: command.projectId, - name: command.name, - cwd: command.cwd, - model: command.model, - createdAt: command.createdAt, - updatedAt: command.createdAt, - }, - }; - case "project.delete": - return { - eventId, - type: "project.deleted", - aggregateType: "project", - aggregateId: command.projectId, - occurredAt: command.createdAt, - commandId: command.commandId, - payload: { - id: command.projectId, - deletedAt: command.createdAt, - }, - }; case "thread.create": return { eventId, @@ -226,10 +196,7 @@ export class OrchestrationEngine { return Effect.gen(this, function* () { const eventBase = mapCommandToEvent(envelope.command); const savedEvent = yield* this.eventStore.append(eventBase); - - yield* Effect.sync(() => { - this.readModel = reduceEvent(this.readModel, savedEvent); - }); + this.readModel = yield* reduceEvent(this.readModel, savedEvent); const snapshot = this.readModel; yield* Effect.all([ @@ -237,14 +204,12 @@ export class OrchestrationEngine { PubSub.publish(this.readModelPubSub, snapshot), ]); - yield* Effect.sync(() => { - for (const listener of this.domainEventListeners) { - listener(savedEvent); - } - for (const listener of this.readModelListeners) { - listener(snapshot); - } - }); + for (const listener of this.domainEventListeners) { + listener(savedEvent); + } + for (const listener of this.readModelListeners) { + listener(snapshot); + } yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); }).pipe( @@ -257,14 +222,12 @@ export class OrchestrationEngine { ); } - private bootstrapReadModel(): Effect.Effect { + private bootstrapReadModel(): Effect.Effect { return Effect.gen(this, function* () { const existingEvents = yield* this.eventStore.readAll(); - yield* Effect.sync(() => { - for (const event of existingEvents) { - this.readModel = reduceEvent(this.readModel, event); - } - }); + for (const event of existingEvents) { + this.readModel = yield* reduceEvent(this.readModel, event); + } }); } diff --git a/apps/server/src/orchestration/layers.test.ts b/apps/server/src/orchestration/layers.test.ts deleted file mode 100644 index 440af03bbdb0d..0000000000000 --- a/apps/server/src/orchestration/layers.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { createOrchestrationSystem } from "./runtime"; - -const tempDirs: string[] = []; - -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("orchestration layers", () => { - it("creates an engine through Context.Tag wiring", async () => { - const stateDir = makeTempDir("t3code-orchestration-layer-"); - const createdAt = new Date().toISOString(); - const firstSystem = await createOrchestrationSystem(stateDir); - const { engine } = firstSystem; - - await engine.dispatch({ - type: "project.create", - commandId: "cmd-layer-1", - projectId: "project-layer-1", - name: "layer", - cwd: "/tmp/layer", - model: "gpt-5-codex", - createdAt, - }); - const firstSnapshot = engine.getSnapshot(); - await firstSystem.dispose(); - - const secondSystem = await createOrchestrationSystem(stateDir); - const restarted = secondSystem.engine; - expect(restarted.getSnapshot()).toEqual(firstSnapshot); - await secondSystem.dispose(); - }); -}); diff --git a/apps/server/src/orchestration/layers.ts b/apps/server/src/orchestration/layers.ts index 63009c9c9b7bf..18670b49ff6f7 100644 --- a/apps/server/src/orchestration/layers.ts +++ b/apps/server/src/orchestration/layers.ts @@ -1,18 +1,9 @@ -import path from "node:path"; - import { Effect, Layer } from "effect"; import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents"; -import { makeSqliteOrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents"; +import { OrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents"; import { OrchestrationEngine } from "./engine"; -import { OrchestrationConfig, OrchestrationEngineService } from "./services"; - -export const OrchestrationEventRepositoryLive = Layer.unwrapEffect( - Effect.map(OrchestrationConfig, ({ stateDir }) => { - const dbPath = path.join(stateDir, "orchestration.sqlite"); - return makeSqliteOrchestrationEventRepositoryLive(dbPath); - }), -); +import { OrchestrationEngineService } from "./services"; export const OrchestrationEngineLive = Layer.scoped( OrchestrationEngineService, diff --git a/apps/server/src/orchestration/reducer.test.ts b/apps/server/src/orchestration/reducer.test.ts new file mode 100644 index 0000000000000..6ea2a6c813522 --- /dev/null +++ b/apps/server/src/orchestration/reducer.test.ts @@ -0,0 +1,97 @@ +import type { OrchestrationEvent } from "@t3tools/contracts"; +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { createEmptyReadModel, reduceEvent } from "./reducer"; + +describe("orchestration reducer", () => { + it("applies thread.created events using runtime schema decoding", async () => { + const now = new Date().toISOString(); + const model = createEmptyReadModel(now); + const event: OrchestrationEvent = { + sequence: 1, + eventId: "event-thread-created", + type: "thread.created", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-1", + payload: { + id: "thread-1", + projectId: "project-1", + title: "demo", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }; + + const next = await Effect.runPromise(reduceEvent(model, event)); + expect(next.sequence).toBe(1); + expect(next.threads).toEqual([ + { + id: "thread-1", + projectId: "project-1", + title: "demo", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + latestTurnId: null, + latestTurnStartedAt: null, + latestTurnCompletedAt: null, + latestTurnDurationMs: null, + messages: [], + session: null, + turnDiffSummaries: [], + error: null, + }, + ]); + }); + + it("fails when event payload cannot be decoded by runtime schema", async () => { + const now = new Date().toISOString(); + const model = createEmptyReadModel(now); + const event: OrchestrationEvent = { + sequence: 1, + eventId: "event-message-invalid", + type: "message.sent", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: now, + commandId: "cmd-1", + payload: { + id: "message-1", + threadId: "thread-1", + text: "hello", + createdAt: now, + streaming: false, + }, + }; + + await expect(Effect.runPromise(reduceEvent(model, event))).rejects.toBeDefined(); + }); + + it("keeps reducer forward-compatible for unknown event types", async () => { + const now = new Date().toISOString(); + const model = createEmptyReadModel(now); + const event: OrchestrationEvent = { + sequence: 7, + eventId: "event-unknown", + type: "custom.event.ignored", + aggregateType: "unknown", + aggregateId: "aggregate-1", + occurredAt: "2026-01-01T00:00:00.000Z", + commandId: null, + payload: { test: true }, + }; + + const next = await Effect.runPromise(reduceEvent(model, event)); + expect(next.sequence).toBe(7); + expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z"); + expect(next.threads).toEqual([]); + }); +}); diff --git a/apps/server/src/orchestration/reducer.ts b/apps/server/src/orchestration/reducer.ts index 14e2c237fb86c..1b05441b1f5e7 100644 --- a/apps/server/src/orchestration/reducer.ts +++ b/apps/server/src/orchestration/reducer.ts @@ -2,11 +2,20 @@ import type { OrchestrationEvent, OrchestrationGitReadModel, OrchestrationMessage, - OrchestrationProject, OrchestrationReadModel, OrchestrationSession, OrchestrationThread, } from "@t3tools/contracts"; +import { + OrchestrationGitReadModelSchema, + OrchestrationMessageSchema, + OrchestrationReadModelSchema, + OrchestrationSessionSchema, + OrchestrationThreadSchema, + OrchestrationTurnDiffFileSchema, + OrchestrationTurnDiffSummarySchema, +} from "@t3tools/contracts"; +import { Effect, Schema } from "effect"; type ThreadPatch = Partial>; @@ -18,38 +27,84 @@ function updateThread( return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); } -function asObject(value: unknown): Record | null { - if (!value || typeof value !== "object") return null; - return value as Record; -} +const decodeReadModelSync = Schema.decodeUnknownSync(OrchestrationReadModelSchema); +const decodeMessage = Schema.decodeUnknown(OrchestrationMessageSchema); +const decodeSession = Schema.decodeUnknown(OrchestrationSessionSchema); +const decodeThread = Schema.decodeUnknown(OrchestrationThreadSchema); +const decodeGitReadModel = Schema.decodeUnknown(OrchestrationGitReadModelSchema); +const decodeTurnDiffSummary = Schema.decodeUnknown(OrchestrationTurnDiffSummarySchema); -function asString(value: unknown): string | null { - return typeof value === "string" ? value : null; -} +const ThreadCreatedPayloadSchema = Schema.Struct({ + id: Schema.String, + projectId: Schema.String, + title: Schema.String, + model: Schema.String, + branch: Schema.NullOr(Schema.String), + worktreePath: Schema.NullOr(Schema.String), + createdAt: Schema.String, + updatedAt: Schema.String, +}); +const decodeThreadCreatedPayload = Schema.decodeUnknown(ThreadCreatedPayloadSchema); -function asNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; -} +const ThreadMetaUpdatedPayloadSchema = Schema.Struct({ + threadId: Schema.String, + title: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + branch: Schema.optional(Schema.NullOr(Schema.String)), + worktreePath: Schema.optional(Schema.NullOr(Schema.String)), + updatedAt: Schema.String, +}); +const decodeThreadMetaUpdatedPayload = Schema.decodeUnknown(ThreadMetaUpdatedPayloadSchema); -function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : []; -} +const MessageSentPayloadSchema = Schema.Struct({ + id: Schema.String, + role: Schema.Literal("user", "assistant"), + text: Schema.String, + threadId: Schema.String, + createdAt: Schema.String, + streaming: Schema.Boolean, +}); +const decodeMessageSentPayload = Schema.decodeUnknown(MessageSentPayloadSchema); + +const ThreadSessionSetPayloadSchema = Schema.Struct({ + threadId: Schema.String, + session: OrchestrationSessionSchema, +}); +const decodeThreadSessionSetPayload = Schema.decodeUnknown(ThreadSessionSetPayloadSchema); + +const ThreadTurnDiffCompletedPayloadSchema = Schema.Struct({ + threadId: Schema.String, + turnId: Schema.String, + completedAt: Schema.String, + status: Schema.optional(Schema.String), + files: Schema.Array(OrchestrationTurnDiffFileSchema), + assistantMessageId: Schema.optional(Schema.String), + checkpointTurnCount: Schema.optional(Schema.Number), +}); +const decodeThreadTurnDiffCompletedPayload = Schema.decodeUnknown( + ThreadTurnDiffCompletedPayloadSchema, +); + +const ThreadRevertedPayloadSchema = Schema.Struct({ + threadId: Schema.String, + turnCount: Schema.Number, + messageCount: Schema.Number, +}); +const decodeThreadRevertedPayload = Schema.decodeUnknown(ThreadRevertedPayloadSchema); export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { - return { + return decodeReadModelSync({ sequence: 0, - projects: [], threads: [], gitByProjectId: {}, updatedAt: nowIso, - }; + }); } export function reduceEvent( model: OrchestrationReadModel, event: OrchestrationEvent, -): OrchestrationReadModel { - const payload = asObject(event.payload); +): Effect.Effect { const nextBase: OrchestrationReadModel = { ...model, sequence: event.sequence, @@ -57,266 +112,215 @@ export function reduceEvent( }; switch (event.type) { - case "project.created": { - if (!payload) return nextBase; - const project: OrchestrationProject = { - id: asString(payload.id) ?? event.aggregateId, - name: asString(payload.name) ?? "project", - cwd: asString(payload.cwd) ?? "", - model: asString(payload.model) ?? "gpt-5-codex", - createdAt: asString(payload.createdAt) ?? event.occurredAt, - updatedAt: asString(payload.updatedAt) ?? event.occurredAt, - }; - const existing = nextBase.projects.find((entry) => entry.id === project.id); - return { - ...nextBase, - projects: existing - ? nextBase.projects.map((entry) => (entry.id === project.id ? project : entry)) - : [...nextBase.projects, project], - }; - } - case "project.deleted": - return { - ...nextBase, - projects: nextBase.projects.filter((project) => project.id !== event.aggregateId), - threads: nextBase.threads.filter((thread) => thread.projectId !== event.aggregateId), - }; - case "thread.created": { - if (!payload) return nextBase; - const thread: OrchestrationThread = { - id: asString(payload.id) ?? event.aggregateId, - projectId: asString(payload.projectId) ?? "", - title: asString(payload.title) ?? "New thread", - model: asString(payload.model) ?? "gpt-5-codex", - branch: asString(payload.branch), - worktreePath: asString(payload.worktreePath), - createdAt: asString(payload.createdAt) ?? event.occurredAt, - updatedAt: asString(payload.updatedAt) ?? event.occurredAt, - latestTurnId: null, - latestTurnStartedAt: null, - latestTurnCompletedAt: null, - latestTurnDurationMs: null, - messages: [], - session: null, - turnDiffSummaries: [], - error: null, - }; - const existing = nextBase.threads.find((entry) => entry.id === thread.id); - return { - ...nextBase, - threads: existing - ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry)) - : [...nextBase.threads, thread], - }; - } + case "thread.created": + return Effect.gen(function* () { + const payload = yield* decodeThreadCreatedPayload(event.payload); + const thread: OrchestrationThread = yield* decodeThread({ + id: payload.id, + projectId: payload.projectId, + title: payload.title, + model: payload.model, + branch: payload.branch, + worktreePath: payload.worktreePath, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, + latestTurnId: null, + latestTurnStartedAt: null, + latestTurnCompletedAt: null, + latestTurnDurationMs: null, + messages: [], + session: null, + turnDiffSummaries: [], + error: null, + }); + const existing = nextBase.threads.find((entry) => entry.id === thread.id); + return { + ...nextBase, + threads: existing + ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry)) + : [...nextBase.threads, thread], + }; + }); case "thread.deleted": - return { + return Effect.succeed({ ...nextBase, threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId), - }; - case "thread.meta-updated": { - if (!payload) return nextBase; - const threadId = asString(payload.threadId) ?? event.aggregateId; - const thread = nextBase.threads.find((entry) => entry.id === threadId); - if (!thread) return nextBase; - return { - ...nextBase, - threads: updateThread(nextBase.threads, threadId, { - ...(asString(payload.title) !== null ? { title: asString(payload.title) ?? thread.title } : {}), - ...(asString(payload.model) !== null ? { model: asString(payload.model) ?? thread.model } : {}), - ...(payload.branch !== undefined ? { branch: asString(payload.branch) } : {}), - ...(payload.worktreePath !== undefined ? { worktreePath: asString(payload.worktreePath) } : {}), - updatedAt: asString(payload.updatedAt) ?? event.occurredAt, + }); + case "thread.meta-updated": + return decodeThreadMetaUpdatedPayload(event.payload).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.model !== undefined ? { model: payload.model } : {}), + ...(payload.branch !== undefined ? { branch: payload.branch } : {}), + ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + updatedAt: payload.updatedAt, + }), + }; }), - }; - } - case "message.sent": { - if (!payload) return nextBase; - const threadId = asString(payload.threadId) ?? event.aggregateId; - const message: OrchestrationMessage = { - id: asString(payload.id) ?? crypto.randomUUID(), - role: (asString(payload.role) as OrchestrationMessage["role"] | null) ?? "user", - text: asString(payload.text) ?? "", - createdAt: asString(payload.createdAt) ?? event.occurredAt, - streaming: payload.streaming === true, - }; - const targetThread = nextBase.threads.find((thread) => thread.id === threadId); - if (!targetThread) return nextBase; - const existingMessage = targetThread.messages.find((entry) => entry.id === message.id); - const nextMessages = existingMessage - ? targetThread.messages.map((entry) => - entry.id === message.id - ? { - ...entry, - text: - message.streaming + ); + case "message.sent": + return Effect.gen(function* () { + const payload = yield* decodeMessageSentPayload(event.payload); + const targetThread = nextBase.threads.find((thread) => thread.id === payload.threadId); + if (!targetThread) { + return nextBase; + } + + const message: OrchestrationMessage = yield* decodeMessage({ + id: payload.id, + role: payload.role, + text: payload.text, + createdAt: payload.createdAt, + streaming: payload.streaming, + }); + const existingMessage = targetThread.messages.find((entry) => entry.id === message.id); + const nextMessages = existingMessage + ? targetThread.messages.map((entry) => + entry.id === message.id + ? { + ...entry, + text: message.streaming ? `${entry.text}${message.text}` : message.text.length > 0 ? message.text : entry.text, - streaming: message.streaming, - createdAt: message.createdAt, - } - : entry, - ) - : [...targetThread.messages, message]; - return { - ...nextBase, - threads: updateThread(nextBase.threads, threadId, { - messages: nextMessages, - updatedAt: event.occurredAt, - }), - }; - } - case "thread.session-set": { - if (!payload) return nextBase; - const threadId = asString(payload.threadId) ?? event.aggregateId; - const thread = nextBase.threads.find((entry) => entry.id === threadId); - if (!thread) return nextBase; - const sessionPayload = asObject(payload.session); - if (!sessionPayload) return nextBase; - const session: OrchestrationSession = { - sessionId: asString(sessionPayload.sessionId) ?? crypto.randomUUID(), - status: - (asString(sessionPayload.status) as OrchestrationSession["status"] | null) ?? "connecting", - provider: - (asString(sessionPayload.provider) as OrchestrationSession["provider"] | null) ?? "codex", - threadId, - activeTurnId: asString(sessionPayload.activeTurnId), - createdAt: asString(sessionPayload.createdAt) ?? event.occurredAt, - updatedAt: asString(sessionPayload.updatedAt) ?? event.occurredAt, - lastError: asString(sessionPayload.lastError), - }; - return { - ...nextBase, - threads: updateThread(nextBase.threads, threadId, { - session, - updatedAt: event.occurredAt, - error: session.lastError, - }), - }; - } - case "thread.turn-diff-completed": { - if (!payload) return nextBase; - const threadId = asString(payload.threadId) ?? event.aggregateId; - const thread = nextBase.threads.find((entry) => entry.id === threadId); - const turnId = asString(payload.turnId); - if (!thread || !turnId) return nextBase; + streaming: message.streaming, + createdAt: message.createdAt, + } + : entry, + ) + : [...targetThread.messages, message]; - const files = asArray(payload.files).flatMap((filePayload) => { - const file = asObject(filePayload); - const filePath = asString(file?.path); - if (!filePath) { - return []; + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + messages: nextMessages, + updatedAt: event.occurredAt, + }), + }; + }); + case "thread.session-set": + return Effect.gen(function* () { + const payload = yield* decodeThreadSessionSetPayload(event.payload); + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; } - const nextFile: { - path: string; - kind?: string; - additions?: number; - deletions?: number; - } = { path: filePath }; - const kind = asString(file?.kind); - if (kind !== null) { - nextFile.kind = kind; - } - const additions = asNumber(file?.additions); - if (additions !== null) { - nextFile.additions = additions; - } - const deletions = asNumber(file?.deletions); - if (deletions !== null) { - nextFile.deletions = deletions; - } - return [nextFile]; + const session: OrchestrationSession = yield* decodeSession({ + ...payload.session, + threadId: payload.threadId, + }); + + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + session, + updatedAt: event.occurredAt, + error: session.lastError, + }), + }; }); + case "thread.turn-diff-completed": + return Effect.gen(function* () { + const payload = yield* decodeThreadTurnDiffCompletedPayload(event.payload); + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } - const nextSummary = { - turnId, - completedAt: asString(payload.completedAt) ?? event.occurredAt, - ...(asString(payload.status) !== null ? { status: asString(payload.status) ?? undefined } : {}), - files, - ...(asString(payload.assistantMessageId) !== null - ? { assistantMessageId: asString(payload.assistantMessageId) ?? undefined } - : {}), - ...(asNumber(payload.checkpointTurnCount) !== null - ? { checkpointTurnCount: asNumber(payload.checkpointTurnCount) ?? undefined } - : {}), - }; - const turnDiffSummaries = [...thread.turnDiffSummaries.filter((summary) => summary.turnId !== turnId), nextSummary] - .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); + const nextSummary = yield* decodeTurnDiffSummary({ + turnId: payload.turnId, + completedAt: payload.completedAt, + ...(payload.status !== undefined ? { status: payload.status } : {}), + files: payload.files, + ...(payload.assistantMessageId !== undefined + ? { assistantMessageId: payload.assistantMessageId } + : {}), + ...(payload.checkpointTurnCount !== undefined + ? { checkpointTurnCount: payload.checkpointTurnCount } + : {}), + }); + const turnDiffSummaries = [ + ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId), + nextSummary, + ].toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); - return { - ...nextBase, - threads: updateThread(nextBase.threads, threadId, { - latestTurnId: turnId, - latestTurnCompletedAt: nextSummary.completedAt, - turnDiffSummaries, - updatedAt: event.occurredAt, - }), - }; - } - case "thread.reverted": { - if (!payload) return nextBase; - const threadId = asString(payload.threadId) ?? event.aggregateId; - const thread = nextBase.threads.find((entry) => entry.id === threadId); - if (!thread) return nextBase; + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + latestTurnId: payload.turnId, + latestTurnCompletedAt: nextSummary.completedAt, + turnDiffSummaries, + updatedAt: event.occurredAt, + }), + }; + }); + case "thread.reverted": + return decodeThreadRevertedPayload(event.payload).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } - const targetTurnCount = Math.max(0, Math.floor(asNumber(payload.turnCount) ?? 0)); - const targetMessageCount = Math.max(0, Math.floor(asNumber(payload.messageCount) ?? 0)); - const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) => - left.completedAt.localeCompare(right.completedAt), - ); - const inferredTurnCountByTurnId = new Map(); - for (let index = 0; index < sortedSummaries.length; index += 1) { - const summary = sortedSummaries[index]; - if (!summary) continue; - inferredTurnCountByTurnId.set(summary.turnId, index + 1); - } + const targetTurnCount = Math.max(0, Math.floor(payload.turnCount)); + const targetMessageCount = Math.max(0, Math.floor(payload.messageCount)); + const sortedSummaries = [...thread.turnDiffSummaries].toSorted((left, right) => + left.completedAt.localeCompare(right.completedAt), + ); + const inferredTurnCountByTurnId = new Map(); + for (let index = 0; index < sortedSummaries.length; index += 1) { + const summary = sortedSummaries[index]; + if (!summary) continue; + inferredTurnCountByTurnId.set(summary.turnId, index + 1); + } - const turnDiffSummaries = thread.turnDiffSummaries - .filter((summary) => { - const checkpointTurnCount = - summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0; - return checkpointTurnCount <= targetTurnCount; - }) - .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); - const latestSummary = - turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null; + const turnDiffSummaries = thread.turnDiffSummaries + .filter((summary) => { + const checkpointTurnCount = + summary.checkpointTurnCount ?? inferredTurnCountByTurnId.get(summary.turnId) ?? 0; + return checkpointTurnCount <= targetTurnCount; + }) + .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); + const latestSummary = + turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null; - return { - ...nextBase, - threads: updateThread(nextBase.threads, threadId, { - messages: thread.messages.slice(0, Math.min(targetMessageCount, thread.messages.length)), - turnDiffSummaries, - latestTurnId: latestSummary?.turnId ?? null, - latestTurnStartedAt: null, - latestTurnCompletedAt: latestSummary?.completedAt ?? null, - latestTurnDurationMs: null, - updatedAt: event.occurredAt, + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + messages: thread.messages.slice( + 0, + Math.min(targetMessageCount, thread.messages.length), + ), + turnDiffSummaries, + latestTurnId: latestSummary?.turnId ?? null, + latestTurnStartedAt: null, + latestTurnCompletedAt: latestSummary?.completedAt ?? null, + latestTurnDurationMs: null, + updatedAt: event.occurredAt, + }), + }; }), - }; - } - case "git.read-model-upsert": { - if (!payload) return nextBase; - const projectId = asString(payload.projectId) ?? event.aggregateId; - const gitReadModel: OrchestrationGitReadModel = { - projectId, - branch: asString(payload.branch), - hasWorkingTreeChanges: payload.hasWorkingTreeChanges === true, - aheadCount: asNumber(payload.aheadCount) ?? 0, - behindCount: asNumber(payload.behindCount) ?? 0, - updatedAt: asString(payload.updatedAt) ?? event.occurredAt, - }; - return { - ...nextBase, - gitByProjectId: { - ...nextBase.gitByProjectId, - [projectId]: gitReadModel, - }, - }; - } + ); + case "git.read-model-upsert": + return decodeGitReadModel(event.payload).pipe( + Effect.map((gitReadModel: OrchestrationGitReadModel) => ({ + ...nextBase, + gitByProjectId: { + ...nextBase.gitByProjectId, + [gitReadModel.projectId]: gitReadModel, + }, + })), + ); default: - return nextBase; + return Effect.succeed(nextBase); } } diff --git a/apps/server/src/orchestration/runtime.ts b/apps/server/src/orchestration/runtime.ts deleted file mode 100644 index 67851be2f5432..0000000000000 --- a/apps/server/src/orchestration/runtime.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Layer, ManagedRuntime } from "effect"; - -import type { OrchestrationEngine } from "./engine"; -import { OrchestrationLive } from "./layers"; -import { OrchestrationConfig, OrchestrationEngineService } from "./services"; - -export interface OrchestrationSystem { - readonly engine: OrchestrationEngine; - readonly dispose: () => Promise; -} - -export async function createOrchestrationSystem(stateDir: string): Promise { - const orchestrationLayer = OrchestrationLive.pipe( - Layer.provide(Layer.succeed(OrchestrationConfig, { stateDir })), - ); - const runtime = ManagedRuntime.make(orchestrationLayer); - const engine = await runtime.runPromise(OrchestrationEngineService); - return { - engine, - dispose: () => runtime.dispose(), - }; -} diff --git a/apps/server/src/orchestration/services.ts b/apps/server/src/orchestration/services.ts index 148a83ef8bdb0..f6621a54f3233 100644 --- a/apps/server/src/orchestration/services.ts +++ b/apps/server/src/orchestration/services.ts @@ -2,15 +2,6 @@ import { Context } from "effect"; import { OrchestrationEngine } from "./engine"; -export interface OrchestrationConfigShape { - readonly stateDir: string; -} - -export class OrchestrationConfig extends Context.Tag("orchestration/Config")< - OrchestrationConfig, - OrchestrationConfigShape ->() {} - export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")< OrchestrationEngineService, OrchestrationEngine diff --git a/apps/server/src/orchestration/uiContractInventory.ts b/apps/server/src/orchestration/uiContractInventory.ts index d138b5871245e..eb0dbed858ced 100644 --- a/apps/server/src/orchestration/uiContractInventory.ts +++ b/apps/server/src/orchestration/uiContractInventory.ts @@ -7,11 +7,6 @@ export interface UiEntityContract { // Explicit UI inventory used to keep server read models aligned // with what the current frontend renders. export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [ - { - entity: "Project", - requiredFields: ["id", "name", "cwd", "model"], - consumedBy: ["apps/web/src/components/Sidebar.tsx"], - }, { entity: "Thread", requiredFields: [ @@ -61,6 +56,9 @@ export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [ { entity: "GitReadModel", requiredFields: ["projectId", "branch", "hasWorkingTreeChanges", "aheadCount", "behindCount"], - consumedBy: ["apps/web/src/components/GitActionsControl.tsx", "apps/web/src/components/BranchToolbar.tsx"], + consumedBy: [ + "apps/web/src/components/GitActionsControl.tsx", + "apps/web/src/components/BranchToolbar.tsx", + ], }, ]; diff --git a/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts index 57f75fb55d610..b9768172b286a 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts @@ -35,12 +35,12 @@ describe("OrchestrationEventRepository", () => { const saved = await Runtime.runPromise(runtime)( first.append({ eventId: "event-1", - type: "project.created", - aggregateType: "project", - aggregateId: "project-1", + type: "thread.created", + aggregateType: "thread", + aggregateId: "thread-1", occurredAt: createdAt, commandId: "cmd-1", - payload: { id: "project-1", name: "demo" }, + payload: { id: "thread-1", projectId: "project-1", title: "demo" }, }), ); expect(saved.sequence).toBe(1); @@ -92,7 +92,7 @@ describe("OrchestrationEventRepository", () => { `, ) .all() as Array>; - expect(migrationRows).toHaveLength(1); + expect(migrationRows.length).toBeGreaterThanOrEqual(1); expect(Object.values(migrationRows[0] ?? {}).length).toBeGreaterThan(0); db.close(); }); diff --git a/apps/server/src/persistence/Layers/OrchestrationEvents.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.ts index 56bdf08bdb8a5..29f2565826973 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEvents.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.ts @@ -1,15 +1,10 @@ -import fs from "node:fs"; -import path from "node:path"; - import type { OrchestrationEvent } from "@t3tools/contracts"; import { OrchestrationEventSchema } from "@t3tools/contracts"; -import type { SqlClient as SqlClientService } from "@effect/sql/SqlClient"; import * as SqlClient from "@effect/sql/SqlClient"; import * as SqlSchema from "@effect/sql/SqlSchema"; -import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; -import { Context, Effect, Layer, Schema } from "effect"; +import { Effect, Layer, Schema } from "effect"; -import { runMigrations } from "../Migrations"; +import { makeSqlitePersistenceLive } from "./Sqlite"; import { OrchestrationEventRepository, type OrchestrationEventRepositoryShape, @@ -45,11 +40,6 @@ const ReadFromSequenceRequestSchema = Schema.Struct({ limit: Schema.Number, }); -class OrchestrationSql extends Context.Tag("persistence/OrchestrationSql")< - OrchestrationSql, - SqlClientService ->() {} - function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { return decodeEvent({ sequence: row.sequence, @@ -64,7 +54,7 @@ function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { } const makeRepository = Effect.gen(function* () { - const sql = yield* OrchestrationSql; + const sql = yield* SqlClient.SqlClient; const appendEventRow = SqlSchema.single({ Request: AppendEventRequestSchema, @@ -155,24 +145,5 @@ export const OrchestrationEventRepositoryLive = Layer.effect( ); export function makeSqliteOrchestrationEventRepositoryLive(dbPath: string) { - const SqliteClientLive = Effect.gen(function* () { - yield* Effect.try({ - try: () => fs.mkdirSync(path.dirname(dbPath), { recursive: true }), - catch: (error) => Effect.die(error), - }) - return SqliteClient.layer({ filename: dbPath }); - }).pipe(Layer.unwrapEffect) - - const OrchestrationSqlLive = Layer.scoped( - OrchestrationSql, - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`PRAGMA journal_mode = WAL;`; - yield* sql`PRAGMA foreign_keys = ON;`; - yield* runMigrations; - return sql; - }), - ).pipe(Layer.provide(SqliteClientLive)); - - return OrchestrationEventRepositoryLive.pipe(Layer.provide(OrchestrationSqlLive)); + return OrchestrationEventRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); } diff --git a/apps/server/src/persistence/Layers/Projects.test.ts b/apps/server/src/persistence/Layers/Projects.test.ts new file mode 100644 index 0000000000000..a53ca6b221063 --- /dev/null +++ b/apps/server/src/persistence/Layers/Projects.test.ts @@ -0,0 +1,83 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { Layer, ManagedRuntime } from "effect"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ProjectRepository } from "../Services/Projects"; +import { ProjectRepositoryLive } from "./Projects"; +import { makeSqlitePersistenceLive } from "./Sqlite"; + +function makeProjectRepositoryTest(dbPath: string) { + return ProjectRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); +} + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("ProjectRepository", () => { + it("persists projects and deduplicates adds by cwd", async () => { + const stateDir = makeTempDir("t3code-project-repo-state-"); + const projectDir = makeTempDir("t3code-project-repo-project-"); + const dbPath = path.join(stateDir, "orchestration.sqlite"); + + const firstRuntime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); + const first = await firstRuntime.runPromise(ProjectRepository); + + const created = await firstRuntime.runPromise(first.add({ cwd: projectDir })); + expect(created.created).toBe(true); + + const duplicate = await firstRuntime.runPromise(first.add({ cwd: projectDir })); + expect(duplicate.created).toBe(false); + expect(duplicate.project.id).toBe(created.project.id); + + await firstRuntime.dispose(); + + const secondRuntime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); + const second = await secondRuntime.runPromise(ProjectRepository); + const listed = await secondRuntime.runPromise(second.list()); + + expect(listed).toHaveLength(1); + expect(listed[0]?.cwd).toBe(projectDir); + + await secondRuntime.dispose(); + }); + + it("prunes missing project paths", async () => { + const stateDir = makeTempDir("t3code-project-repo-prune-state-"); + const existing = makeTempDir("t3code-project-repo-prune-existing-"); + const missing = makeTempDir("t3code-project-repo-prune-missing-"); + const dbPath = path.join(stateDir, "orchestration.sqlite"); + + const runtime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); + const repository = await runtime.runPromise(ProjectRepository); + + const existingProject = await runtime.runPromise(repository.add({ cwd: existing })); + const missingProject = await runtime.runPromise(repository.add({ cwd: missing })); + + expect(existingProject.created).toBe(true); + expect(missingProject.created).toBe(true); + + fs.rmSync(missing, { recursive: true, force: true }); + + await runtime.runPromise(repository.pruneMissing()); + const listed = await runtime.runPromise(repository.list()); + + expect(listed).toHaveLength(1); + expect(listed[0]?.cwd).toBe(existing); + + await runtime.dispose(); + }); +}); diff --git a/apps/server/src/persistence/Layers/Projects.ts b/apps/server/src/persistence/Layers/Projects.ts new file mode 100644 index 0000000000000..dc50541bc0cb1 --- /dev/null +++ b/apps/server/src/persistence/Layers/Projects.ts @@ -0,0 +1,339 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import type { + ProjectAddInput, + ProjectAddResult, + ProjectListResult, + ProjectRecord, + ProjectScript, + ProjectUpdateScriptsResult, +} from "@t3tools/contracts"; +import { + normalizeProjectScripts, + projectAddInputSchema, + projectRecordSchema, + projectRemoveInputSchema, + projectScriptsSchema, + projectUpdateScriptsInputSchema, +} from "@t3tools/contracts"; +import * as SqlClient from "@effect/sql/SqlClient"; +import * as SqlSchema from "@effect/sql/SqlSchema"; +import { Effect, Layer, Option, Schema } from "effect"; + +import { ProjectRepository, type ProjectRepositoryShape } from "../Services/Projects"; + +const ProjectRowSchema = Schema.Struct({ + id: Schema.String, + cwd: Schema.String, + name: Schema.String, + scriptsJson: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, +}); + +type ProjectRow = Schema.Schema.Type; + +const FindByIdRequestSchema = Schema.Struct({ + id: Schema.String, +}); + +const FindByCwdRequestSchema = Schema.Struct({ + cwd: Schema.String, +}); + +const InsertProjectRequestSchema = Schema.Struct({ + id: Schema.String, + cwd: Schema.String, + name: Schema.String, + scriptsJson: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, +}); + +const UpdateScriptsRequestSchema = Schema.Struct({ + id: Schema.String, + scriptsJson: Schema.String, + updatedAt: Schema.String, +}); + +const DeleteProjectRequestSchema = Schema.Struct({ + id: Schema.String, +}); + +function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] { + const cloned: ProjectScript[] = []; + for (const script of scripts) { + cloned.push({ + id: script.id, + name: script.name, + command: script.command, + icon: script.icon, + runOnWorktreeCreate: script.runOnWorktreeCreate, + }); + } + return cloned; +} + +function normalizeCwd(rawCwd: string): string { + const resolved = path.resolve(rawCwd.trim()); + const normalized = path.normalize(resolved); + if (process.platform === "win32") { + return normalized.toLowerCase(); + } + return normalized; +} + +function isDirectory(cwd: string): boolean { + try { + return fs.statSync(cwd).isDirectory(); + } catch { + return false; + } +} + +function inferProjectName(cwd: string): string { + const name = path.basename(cwd); + return name.length > 0 ? name : "project"; +} + +function parseScriptsJson(raw: string): ProjectScript[] { + try { + const parsed = JSON.parse(raw) as unknown; + const scripts = projectScriptsSchema.parse(parsed); + return normalizeProjectScripts(scripts); + } catch { + return []; + } +} + +function rowToProjectRecord(row: ProjectRow): ProjectRecord { + return projectRecordSchema.parse({ + id: row.id, + cwd: row.cwd, + name: row.name, + scripts: parseScriptsJson(row.scriptsJson), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); +} + +const makeRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const listProjectRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectRowSchema, + execute: () => + sql` + SELECT + id, + cwd, + name, + scripts_json AS "scriptsJson", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projects + ORDER BY created_at DESC + `, + }); + + const findProjectById = SqlSchema.findOne({ + Request: FindByIdRequestSchema, + Result: ProjectRowSchema, + execute: ({ id }) => + sql` + SELECT + id, + cwd, + name, + scripts_json AS "scriptsJson", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projects + WHERE id = ${id} + `, + }); + + const findProjectByCwd = SqlSchema.findOne({ + Request: FindByCwdRequestSchema, + Result: ProjectRowSchema, + execute: ({ cwd }) => + sql` + SELECT + id, + cwd, + name, + scripts_json AS "scriptsJson", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projects + WHERE cwd = ${cwd} + `, + }); + + const insertProject = SqlSchema.single({ + Request: InsertProjectRequestSchema, + Result: ProjectRowSchema, + execute: (request) => + sql` + INSERT INTO projects ( + id, + cwd, + name, + scripts_json, + created_at, + updated_at + ) + VALUES ( + ${request.id}, + ${request.cwd}, + ${request.name}, + ${request.scriptsJson}, + ${request.createdAt}, + ${request.updatedAt} + ) + RETURNING + id, + cwd, + name, + scripts_json AS "scriptsJson", + created_at AS "createdAt", + updated_at AS "updatedAt" + `, + }); + + const updateProjectScripts = SqlSchema.single({ + Request: UpdateScriptsRequestSchema, + Result: ProjectRowSchema, + execute: (request) => + sql` + UPDATE projects + SET + scripts_json = ${request.scriptsJson}, + updated_at = ${request.updatedAt} + WHERE id = ${request.id} + RETURNING + id, + cwd, + name, + scripts_json AS "scriptsJson", + created_at AS "createdAt", + updated_at AS "updatedAt" + `, + }); + + const deleteProject = SqlSchema.void({ + Request: DeleteProjectRequestSchema, + execute: ({ id }) => sql`DELETE FROM projects WHERE id = ${id}`, + }); + + const list: ProjectRepositoryShape["list"] = () => + listProjectRows(undefined).pipe( + Effect.map((rows): ProjectListResult => rows.map(rowToProjectRecord)), + Effect.map((projects) => + projects.map((project) => ({ ...project, scripts: cloneScripts(project.scripts) })), + ), + Effect.orDie, + ); + + const add: ProjectRepositoryShape["add"] = (rawInput) => + Effect.gen(function* () { + const input = projectAddInputSchema.parse(rawInput as ProjectAddInput); + const normalizedCwd = normalizeCwd(input.cwd); + if (!isDirectory(normalizedCwd)) { + return yield* Effect.fail(new Error(`Project path does not exist: ${normalizedCwd}`)); + } + + const existing = yield* findProjectByCwd({ cwd: normalizedCwd }).pipe(Effect.orDie); + if (Option.isSome(existing)) { + const project = rowToProjectRecord(existing.value); + const result: ProjectAddResult = { + project: { + ...project, + scripts: cloneScripts(project.scripts), + }, + created: false, + }; + return result; + } + + const now = new Date().toISOString(); + const row = yield* insertProject({ + id: randomUUID(), + cwd: normalizedCwd, + name: inferProjectName(normalizedCwd), + scriptsJson: "[]", + createdAt: now, + updatedAt: now, + }).pipe(Effect.orDie); + + const created = rowToProjectRecord(row); + const result: ProjectAddResult = { + project: { + ...created, + scripts: cloneScripts(created.scripts), + }, + created: true, + }; + return result; + }); + + const remove: ProjectRepositoryShape["remove"] = (rawInput) => + Effect.gen(function* () { + const input = projectRemoveInputSchema.parse(rawInput); + yield* deleteProject({ id: input.id }).pipe(Effect.orDie); + }); + + const updateScripts: ProjectRepositoryShape["updateScripts"] = (rawInput) => + Effect.gen(function* () { + const input = projectUpdateScriptsInputSchema.parse(rawInput); + const existing = yield* findProjectById({ id: input.id }).pipe(Effect.orDie); + if (Option.isNone(existing)) { + return yield* Effect.fail(new Error(`Project not found: ${input.id}`)); + } + + const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts)); + const nextUpdatedAt = new Date().toISOString(); + const updatedRow = yield* updateProjectScripts({ + id: input.id, + scriptsJson: JSON.stringify(nextScripts), + updatedAt: nextUpdatedAt, + }).pipe(Effect.orDie); + + const updated = rowToProjectRecord(updatedRow); + const result: ProjectUpdateScriptsResult = { + project: { + ...updated, + scripts: cloneScripts(updated.scripts), + }, + }; + return result; + }); + + const pruneMissing: ProjectRepositoryShape["pruneMissing"] = () => + listProjectRows(undefined).pipe( + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => { + const normalizedCwd = normalizeCwd(row.cwd); + if (isDirectory(normalizedCwd)) { + return Effect.void; + } + return deleteProject({ id: row.id }).pipe(Effect.orDie); + }), + ), + Effect.asVoid, + Effect.orDie, + ); + + return { + list, + add, + remove, + updateScripts, + pruneMissing, + } satisfies ProjectRepositoryShape; +}); + +export const ProjectRepositoryLive = Layer.effect(ProjectRepository, makeRepository); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts new file mode 100644 index 0000000000000..735a425221540 --- /dev/null +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -0,0 +1,41 @@ +import fs from "node:fs"; +import path from "node:path"; + +import * as SqlClient from "@effect/sql/SqlClient"; +import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; +import { Context, Effect, Layer } from "effect"; + +import { runMigrations } from "../Migrations"; + +export interface PersistenceConfigShape { + readonly dbPath: string; +} + +export class PersistenceConfig extends Context.Tag("persistence/Config")< + PersistenceConfig, + PersistenceConfigShape +>() {} + +export function makeSqlitePersistenceLive(dbPath: string) { + const SqliteClientLive = Layer.unwrapEffect( + Effect.sync(() => { + fs.mkdirSync(path.dirname(dbPath), { recursive: true }); + return SqliteClient.layer({ filename: dbPath }); + }), + ); + + return Layer.scoped( + SqlClient.SqlClient, + Effect.gen(function* () { + const sql = yield* SqliteClient.SqliteClient; + yield* sql`PRAGMA journal_mode = WAL;`; + yield* sql`PRAGMA foreign_keys = ON;`; + yield* runMigrations; + return sql; + }), + ).pipe(Layer.provide(SqliteClientLive)); +} + +export const SqlitePersistenceLive = Layer.unwrapEffect( + Effect.map(PersistenceConfig, ({ dbPath }) => makeSqlitePersistenceLive(dbPath)), +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 129a6f3c6adc9..628fecf88a560 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -13,7 +13,7 @@ import * as Layer from "effect/Layer"; // Import all migrations statically import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; - +import Migration0002 from "./Migrations/002_Projects.ts"; /** * Migration loader with all migrations defined inline. @@ -27,6 +27,7 @@ import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; */ const loader = Migrator.fromRecord({ "1_OrchestrationEvents": Migration0001, + "2_Projects": Migration0002, }); /** @@ -64,4 +65,4 @@ export const runMigrations = run({ loader }); * ) * ``` */ -export const MigrationsLive = Layer.effectDiscard(runMigrations); \ No newline at end of file +export const MigrationsLive = Layer.effectDiscard(runMigrations); diff --git a/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts b/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts index f2c3b9f9a2c2c..dac64e9e282f0 100644 --- a/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts +++ b/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts @@ -21,4 +21,4 @@ export default Effect.gen(function* () { CREATE INDEX IF NOT EXISTS idx_orch_events_aggregate ON orchestration_events(aggregate_type, aggregate_id, sequence) `; -}); \ No newline at end of file +}); diff --git a/apps/server/src/persistence/Migrations/002_Projects.ts b/apps/server/src/persistence/Migrations/002_Projects.ts new file mode 100644 index 0000000000000..7ec9785f699fb --- /dev/null +++ b/apps/server/src/persistence/Migrations/002_Projects.ts @@ -0,0 +1,22 @@ +import { SqlClient } from "@effect/sql"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + cwd TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + scripts_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projects_created_at + ON projects(created_at DESC) + `; +}); diff --git a/apps/server/src/persistence/Services/Projects.ts b/apps/server/src/persistence/Services/Projects.ts new file mode 100644 index 0000000000000..24288b3a673da --- /dev/null +++ b/apps/server/src/persistence/Services/Projects.ts @@ -0,0 +1,25 @@ +import type { + ProjectAddInput, + ProjectAddResult, + ProjectListResult, + ProjectRemoveInput, + ProjectUpdateScriptsInput, + ProjectUpdateScriptsResult, +} from "@t3tools/contracts"; +import { Context } from "effect"; +import type { Effect } from "effect"; + +export interface ProjectRepositoryShape { + readonly list: () => Effect.Effect; + readonly add: (input: ProjectAddInput) => Effect.Effect; + readonly remove: (input: ProjectRemoveInput) => Effect.Effect; + readonly updateScripts: ( + input: ProjectUpdateScriptsInput, + ) => Effect.Effect; + readonly pruneMissing: () => Effect.Effect; +} + +export class ProjectRepository extends Context.Tag("persistence/ProjectRepository")< + ProjectRepository, + ProjectRepositoryShape +>() {} diff --git a/apps/server/src/projectRegistry.test.ts b/apps/server/src/projectRegistry.test.ts deleted file mode 100644 index 199e69d000a66..0000000000000 --- a/apps/server/src/projectRegistry.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { ProjectRegistry } from "./projectRegistry"; - -const tempDirs: string[] = []; - -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("ProjectRegistry scripts", () => { - it("stores and reloads project scripts", () => { - const stateDir = makeTempDir("t3code-project-registry-state-"); - const projectDir = makeTempDir("t3code-project-registry-project-"); - const registry = new ProjectRegistry(stateDir); - - const created = registry.add({ cwd: projectDir }).project; - const updated = registry.updateScripts({ - id: created.id, - scripts: [ - { - id: "run", - name: "Run", - command: "bun run dev", - icon: "play", - runOnWorktreeCreate: false, - }, - { - id: "setup", - name: "Setup", - command: "bun install", - icon: "configure", - runOnWorktreeCreate: true, - }, - ], - }); - - expect(updated.project.scripts).toHaveLength(2); - expect(updated.project.scripts[1]?.id).toBe("setup"); - expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(true); - - const reloaded = new ProjectRegistry(stateDir); - const listed = reloaded.list(); - expect(listed).toHaveLength(1); - expect(listed[0]?.scripts).toHaveLength(2); - expect(listed[0]?.scripts[0]?.id).toBe("run"); - expect(listed[0]?.scripts[1]?.id).toBe("setup"); - }); - - it("deduplicates script ids and allows only one setup script", () => { - const stateDir = makeTempDir("t3code-project-registry-state-"); - const projectDir = makeTempDir("t3code-project-registry-project-"); - const registry = new ProjectRegistry(stateDir); - const created = registry.add({ cwd: projectDir }).project; - - const updated = registry.updateScripts({ - id: created.id, - scripts: [ - { - id: "setup", - name: "Setup", - command: "echo setup", - icon: "configure", - runOnWorktreeCreate: true, - }, - { - id: "setup", - name: "Setup Duplicate", - command: "echo duplicate", - icon: "configure", - runOnWorktreeCreate: true, - }, - { - id: "another", - name: "Another Setup", - command: "echo another", - icon: "play", - runOnWorktreeCreate: true, - }, - ], - }); - - expect(updated.project.scripts).toHaveLength(2); - expect(updated.project.scripts[0]?.id).toBe("setup"); - expect(updated.project.scripts[0]?.runOnWorktreeCreate).toBe(true); - expect(updated.project.scripts[1]?.id).toBe("another"); - expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(false); - }); - - it("throws when updating scripts for an unknown project id", () => { - const stateDir = makeTempDir("t3code-project-registry-state-"); - const registry = new ProjectRegistry(stateDir); - - expect(() => - registry.updateScripts({ - id: "missing-project", - scripts: [], - }), - ).toThrowError("Project not found"); - }); -}); diff --git a/apps/server/src/projectRegistry.ts b/apps/server/src/projectRegistry.ts deleted file mode 100644 index c849192d53ec4..0000000000000 --- a/apps/server/src/projectRegistry.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; - -import { - type ProjectAddInput, - type ProjectAddResult, - type ProjectListResult, - type ProjectRecord, - type ProjectRemoveInput, - type ProjectScript, - type ProjectUpdateScriptsInput, - type ProjectUpdateScriptsResult, - normalizeProjectScripts, - projectAddInputSchema, - projectRecordSchema, - projectRemoveInputSchema, - projectScriptsSchema, - projectUpdateScriptsInputSchema, -} from "@t3tools/contracts"; - -function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] { - const cloned: ProjectScript[] = []; - for (const script of scripts) { - cloned.push({ - id: script.id, - name: script.name, - command: script.command, - icon: script.icon, - runOnWorktreeCreate: script.runOnWorktreeCreate, - }); - } - return cloned; -} - -function normalizeCwd(rawCwd: string): string { - const resolved = path.resolve(rawCwd.trim()); - const normalized = path.normalize(resolved); - if (process.platform === "win32") { - return normalized.toLowerCase(); - } - return normalized; -} - -function isDirectory(cwd: string): boolean { - try { - return fs.statSync(cwd).isDirectory(); - } catch { - return false; - } -} - -function inferProjectName(cwd: string): string { - const name = path.basename(cwd); - return name.length > 0 ? name : "project"; -} - -export class ProjectRegistry { - private readonly stateDir: string; - private readonly filePath: string; - private projects: ProjectRecord[]; - - constructor(stateDir: string) { - this.stateDir = stateDir; - this.filePath = path.join(this.stateDir, "projects.json"); - fs.mkdirSync(this.stateDir, { recursive: true }); - this.projects = this.loadFromDisk(); - } - - list(): ProjectListResult { - return this.projects.map((project) => ({ - ...project, - scripts: cloneScripts(project.scripts), - })); - } - - add(raw: ProjectAddInput): ProjectAddResult { - const input = projectAddInputSchema.parse(raw); - const normalizedCwd = normalizeCwd(input.cwd); - if (!isDirectory(normalizedCwd)) { - throw new Error(`Project path does not exist: ${normalizedCwd}`); - } - - const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd); - if (existing) { - return { - project: { - ...existing, - scripts: cloneScripts(existing.scripts), - }, - created: false, - }; - } - - const now = new Date().toISOString(); - const project: ProjectRecord = { - id: randomUUID(), - cwd: normalizedCwd, - name: inferProjectName(normalizedCwd), - scripts: [], - createdAt: now, - updatedAt: now, - }; - - this.projects = [project, ...this.projects]; - this.persist(); - return { - project: { - ...project, - scripts: cloneScripts(project.scripts), - }, - created: true, - }; - } - - remove(raw: ProjectRemoveInput): void { - const input = projectRemoveInputSchema.parse(raw); - const next = this.projects.filter((project) => project.id !== input.id); - if (next.length === this.projects.length) { - return; - } - - this.projects = next; - this.persist(); - } - - updateScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult { - const input = projectUpdateScriptsInputSchema.parse(raw); - const index = this.projects.findIndex((project) => project.id === input.id); - if (index < 0) { - throw new Error(`Project not found: ${input.id}`); - } - - const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts)); - const nextUpdatedAt = new Date().toISOString(); - const nextProject: ProjectRecord = { - ...this.projects[index]!, - scripts: nextScripts, - updatedAt: nextUpdatedAt, - }; - - const nextProjects = [...this.projects]; - nextProjects[index] = nextProject; - this.projects = nextProjects; - this.persist(); - - return { - project: { ...nextProject, scripts: cloneScripts(nextProject.scripts) }, - }; - } - - private loadFromDisk(): ProjectRecord[] { - const loaded = this.readPersistedProjects(); - const deduped = new Map(); - const normalizedProjects: ProjectRecord[] = []; - - for (const project of loaded) { - const normalizedCwd = normalizeCwd(project.cwd); - if (!isDirectory(normalizedCwd)) { - continue; - } - if (deduped.has(normalizedCwd)) { - continue; - } - - const normalizedProject: ProjectRecord = { - ...project, - cwd: normalizedCwd, - name: - project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd), - scripts: normalizeProjectScripts(project.scripts), - }; - deduped.set(normalizedCwd, normalizedProject); - normalizedProjects.push(normalizedProject); - } - - const changed = - normalizedProjects.length !== loaded.length || - normalizedProjects.some((project, index) => { - const source = loaded[index]; - if (!source) return true; - return ( - source.id !== project.id || - source.cwd !== project.cwd || - source.name !== project.name || - JSON.stringify(source.scripts) !== JSON.stringify(project.scripts) || - source.createdAt !== project.createdAt || - source.updatedAt !== project.updatedAt - ); - }); - - if (changed) { - this.projects = normalizedProjects; - this.persist(); - } - - return normalizedProjects; - } - - private readPersistedProjects(): ProjectRecord[] { - try { - const raw = fs.readFileSync(this.filePath, "utf8"); - const parsed = JSON.parse(raw) as { projects?: unknown }; - if (!parsed || !Array.isArray(parsed.projects)) { - return []; - } - - const projects: ProjectRecord[] = []; - for (const candidate of parsed.projects) { - const row = projectRecordSchema.safeParse(candidate); - if (!row.success) { - continue; - } - projects.push(row.data); - } - return projects; - } catch { - return []; - } - } - - private persist(): void { - const payload = JSON.stringify( - { - version: 1, - projects: this.projects, - }, - null, - 2, - ); - const tempFile = `${this.filePath}.tmp-${process.pid}-${Date.now()}`; - fs.writeFileSync(tempFile, payload); - fs.renameSync(tempFile, this.filePath); - } -} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index a9b9cc110241b..c977418f05d69 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { EventEmitter } from "node:events"; +import { Layer, ManagedRuntime } from "effect"; import { describe, expect, it, afterEach, vi } from "vitest"; import { createServer } from "./wsServer"; import WebSocket from "ws"; @@ -16,8 +17,8 @@ import { type WsPush, type WsResponse, } from "@t3tools/contracts"; -import { ProjectRegistry } from "./projectRegistry"; import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; +import { ProjectRepository } from "./persistence/Services/Projects"; import type { TerminalClearInput, TerminalCloseInput, @@ -28,6 +29,8 @@ import type { TerminalWriteInput, } from "@t3tools/contracts"; import type { TerminalManager } from "./terminalManager"; +import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; +import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; interface PendingMessages { queue: unknown[]; @@ -249,9 +252,9 @@ describe("WebSocket Server", () => { return createServer({ port: 0, cwd: options.cwd ?? "/test/project", + stateDir, ...(options.devUrl ? { devUrl: options.devUrl } : {}), ...(options.authToken ? { authToken: options.authToken } : {}), - projectRegistry: new ProjectRegistry(stateDir), ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), }); @@ -888,7 +891,11 @@ describe("WebSocket Server", () => { it("supports projects.searchEntries", async () => { const workspace = makeTempDir("t3code-ws-workspace-entries-"); fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true }); - fs.writeFileSync(path.join(workspace, "src", "components", "Composer.tsx"), "export {};", "utf8"); + fs.writeFileSync( + path.join(workspace, "src", "components", "Composer.tsx"), + "export {};", + "utf8", + ); fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8"); fs.mkdirSync(path.join(workspace, ".git"), { recursive: true }); fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); @@ -1025,36 +1032,18 @@ describe("WebSocket Server", () => { it("prunes missing projects on startup", async () => { const stateDir = makeTempDir("t3code-ws-prune-state-"); const existing = makeTempDir("t3code-ws-existing-project-"); - const missing = path.join(stateDir, "definitely-missing"); - const now = new Date().toISOString(); - const projectsFile = path.join(stateDir, "projects.json"); + const missing = makeTempDir("t3code-ws-missing-project-"); - fs.writeFileSync( - projectsFile, - JSON.stringify( - { - version: 1, - projects: [ - { - id: "project-existing", - cwd: existing, - name: "existing", - createdAt: now, - updatedAt: now, - }, - { - id: "project-missing", - cwd: missing, - name: "missing", - createdAt: now, - updatedAt: now, - }, - ], - }, - null, - 2, + const projectRuntime = ManagedRuntime.make( + ProjectRepositoryLive.pipe( + Layer.provide(makeSqlitePersistenceLive(path.join(stateDir, "orchestration.sqlite"))), ), ); + const projectRepository = await projectRuntime.runPromise(ProjectRepository); + await projectRuntime.runPromise(projectRepository.add({ cwd: existing })); + await projectRuntime.runPromise(projectRepository.add({ cwd: missing })); + await projectRuntime.dispose(); + fs.rmSync(missing, { recursive: true, force: true }); server = createTestServer({ stateDir, cwd: "/test" }); await server.start(); @@ -1067,9 +1056,9 @@ describe("WebSocket Server", () => { const response = await sendRequest(ws, WS_METHODS.projectsList); expect(response.error).toBeUndefined(); - const listed = response.result as Array<{ id: string }>; + const listed = response.result as Array<{ cwd: string }>; expect(listed).toHaveLength(1); - expect(listed[0]?.id).toBe("project-existing"); + expect(listed[0]?.cwd).toBe(existing); }); it("rejects websocket connections without a valid auth token", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index dd8f3fd6eb1a0..d63b8677888da 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -18,10 +18,10 @@ import { type WsResponse, wsRequestSchema, } from "@t3tools/contracts"; +import { Layer, ManagedRuntime } from "effect"; import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; -import { ProjectRegistry } from "./projectRegistry"; import { ProviderManager } from "./providerManager"; import { GitManager } from "./gitManager"; import { @@ -36,7 +36,13 @@ import { import { TerminalManager } from "./terminalManager"; import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; -import { createOrchestrationSystem, type OrchestrationSystem } from "./orchestration/runtime"; +import type { OrchestrationEngine } from "./orchestration/engine"; +import { OrchestrationLive } from "./orchestration/layers"; +import { OrchestrationEngineService } from "./orchestration/services"; +import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; +import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; +import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects"; +import assert from "node:assert"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -61,12 +67,31 @@ export interface ServerOptions { staticDir?: string | undefined; devUrl?: string | undefined; logWebSocketEvents?: boolean | undefined; - projectRegistry?: ProjectRegistry | undefined; gitManager?: GitManager | undefined; terminalManager?: TerminalManager | undefined; authToken?: string | undefined; } +const isServerNotRunningError = (error: unknown): boolean => { + if (!(error instanceof Error)) return false; + const maybeCode = (error as NodeJS.ErrnoException).code; + return ( + maybeCode === "ERR_SERVER_NOT_RUNNING" || error.message.toLowerCase().includes("not running") + ); +}; + +function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { + socket.write( + `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + + "Connection: close\r\n" + + "Content-Type: text/plain\r\n" + + `Content-Length: ${Buffer.byteLength(message)}\r\n` + + "\r\n" + + message, + ); + socket.destroy(); +} + function parseBooleanEnv(value: string | undefined): boolean | undefined { if (value === undefined) return undefined; const normalized = value.trim().toLowerCase(); @@ -99,18 +124,19 @@ export function createServer(options: ServerOptions) { staticDir, devUrl, logWebSocketEvents: explicitLogWsEvents, - projectRegistry: providedRegistry, - gitManager: providedGitManager, - terminalManager: providedTerminalManager, + gitManager = new GitManager(), + terminalManager = new TerminalManager(), authToken, } = options; const providerManager = new ProviderManager(); - const terminalManager = providedTerminalManager ?? new TerminalManager(); - const projectRegistry = - providedRegistry ?? new ProjectRegistry(path.join(os.homedir(), ".t3", "userdata")); - const gitManager = providedGitManager ?? new GitManager(); + const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata"); - let orchestrationSystem: OrchestrationSystem | null = null; + let effectRuntime: ManagedRuntime.ManagedRuntime< + ProjectRepository | OrchestrationEngineService, + unknown + > | null = null; + let projectRepository: ProjectRepositoryShape | null = null; + let orchestrationEngine: OrchestrationEngine | null = null; const clients = new Set(); const logger = createLogger("ws"); const logWebSocketEvents = @@ -128,15 +154,23 @@ export function createServer(options: ServerOptions) { }); } - function requireOrchestrationEngine() { - if (!orchestrationSystem) { - throw new Error("Orchestration engine is not started"); - } - return orchestrationSystem.engine; - } + const getOrchestrationEngine = () => { + assert(orchestrationEngine, "Orchestration engine is not started"); + return orchestrationEngine; + }; + + const getProjectRepository = () => { + assert(projectRepository, "Project repository is not started"); + return projectRepository; + }; + + const getEffectRuntime = () => { + assert(effectRuntime, "Effect runtime is not started"); + return effectRuntime; + }; function attachOrchestrationSubscriptions() { - const orchestrationEngine = requireOrchestrationEngine(); + const orchestrationEngine = getOrchestrationEngine(); unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => { const push: WsPush = { @@ -179,17 +213,16 @@ export function createServer(options: ServerOptions) { // Forward provider events to all connected WebSocket clients providerManager.on("event", (event) => { void (async () => { - const liveOrchestrationSystem = orchestrationSystem; - if (!liveOrchestrationSystem) { + const liveOrchestrationEngine = orchestrationEngine; + if (!liveOrchestrationEngine) { return; } - const orchestrationEngine = liveOrchestrationSystem.engine; - const snapshot = orchestrationEngine.getSnapshot(); + const snapshot = liveOrchestrationEngine.getSnapshot(); const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId); if (!thread) return; const now = event.createdAt; if (event.method === "turn/started" || event.method === "turn/completed") { - await orchestrationEngine.dispatch({ + await liveOrchestrationEngine.dispatch({ type: "thread.session", commandId: crypto.randomUUID(), threadId: thread.id, @@ -208,7 +241,7 @@ export function createServer(options: ServerOptions) { } if (event.textDelta && event.textDelta.length > 0) { const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; - await orchestrationEngine.dispatch({ + await liveOrchestrationEngine.dispatch({ type: "message.send", commandId: crypto.randomUUID(), threadId: thread.id, @@ -221,7 +254,7 @@ export function createServer(options: ServerOptions) { } if (event.method === "turn/completed") { const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; - await orchestrationEngine.dispatch({ + await liveOrchestrationEngine.dispatch({ type: "message.send", commandId: crypto.randomUUID(), threadId: thread.id, @@ -236,13 +269,15 @@ export function createServer(options: ServerOptions) { const payload = asObject(event.payload); const turnId = event.turnId ?? asString(payload?.turnId); if (turnId) { - await orchestrationEngine.dispatch({ + await liveOrchestrationEngine.dispatch({ type: "thread.turnDiff.complete", commandId: crypto.randomUUID(), threadId: thread.id, turnId, completedAt: now, - ...(asString(payload?.status) !== null ? { status: asString(payload?.status) ?? undefined } : {}), + ...(asString(payload?.status) !== null + ? { status: asString(payload?.status) ?? undefined } + : {}), files: [], assistantMessageId: `assistant:${turnId}`, ...(asNumber(payload?.turnCount) !== null @@ -253,7 +288,7 @@ export function createServer(options: ServerOptions) { } } if (event.kind === "error" && event.message) { - await orchestrationEngine.dispatch({ + await liveOrchestrationEngine.dispatch({ type: "thread.session", commandId: crypto.randomUUID(), threadId: thread.id, @@ -350,18 +385,6 @@ export function createServer(options: ServerOptions) { // WebSocket server — upgrades from the HTTP server const wss = new WebSocketServer({ noServer: true }); - function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { - socket.write( - `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + - "Connection: close\r\n" + - "Content-Type: text/plain\r\n" + - `Content-Length: ${Buffer.byteLength(message)}\r\n` + - "\r\n" + - message, - ); - socket.destroy(); - } - httpServer.on("upgrade", (request, socket, head) => { if (authToken) { let providedToken: string | null = null; @@ -386,7 +409,7 @@ export function createServer(options: ServerOptions) { wss.on("connection", (ws) => { clients.add(ws); - const orchestrationEngine = requireOrchestrationEngine(); + const orchestrationEngine = getOrchestrationEngine(); // Send welcome message with project info const segments = cwd.split(/[/\\]/).filter(Boolean); @@ -453,7 +476,9 @@ export function createServer(options: ServerOptions) { } async function routeRequest(request: WsRequest): Promise { - const orchestrationEngine = requireOrchestrationEngine(); + const orchestrationEngine = getOrchestrationEngine(); + const projectRepository = getProjectRepository(); + const effectRuntime = getEffectRuntime(); switch (request.method) { case WS_METHODS.providersStartSession: @@ -479,79 +504,56 @@ export function createServer(options: ServerOptions) { case WS_METHODS.providersGetCheckpointDiff: return providerManager.getCheckpointDiff(request.params as never); - case WS_METHODS.providersRevertToCheckpoint: - { - const result = await providerManager.revertToCheckpoint(request.params as never); - const params = request.params as { sessionId?: string }; - const sessionId = params.sessionId; - if (typeof sessionId === "string") { - const snapshot = orchestrationEngine.getSnapshot(); - const thread = snapshot.threads.find((entry) => entry.session?.sessionId === sessionId); - if (thread) { - const now = new Date().toISOString(); + case WS_METHODS.providersRevertToCheckpoint: { + const result = await providerManager.revertToCheckpoint(request.params as never); + const params = request.params as { sessionId?: string }; + const sessionId = params.sessionId; + if (typeof sessionId === "string") { + const snapshot = orchestrationEngine.getSnapshot(); + const thread = snapshot.threads.find((entry) => entry.session?.sessionId === sessionId); + if (thread) { + const now = new Date().toISOString(); + await orchestrationEngine.dispatch({ + type: "thread.revert", + commandId: crypto.randomUUID(), + threadId: thread.id, + turnCount: result.turnCount, + messageCount: result.messageCount, + createdAt: now, + }); + if (thread.session) { await orchestrationEngine.dispatch({ - type: "thread.revert", + type: "thread.session", commandId: crypto.randomUUID(), threadId: thread.id, - turnCount: result.turnCount, - messageCount: result.messageCount, + session: { + ...thread.session, + status: "ready", + activeTurnId: null, + updatedAt: now, + lastError: null, + }, createdAt: now, }); - if (thread.session) { - await orchestrationEngine.dispatch({ - type: "thread.session", - commandId: crypto.randomUUID(), - threadId: thread.id, - session: { - ...thread.session, - status: "ready", - activeTurnId: null, - updatedAt: now, - lastError: null, - }, - createdAt: now, - }); - } } } - return result; } + return result; + } case WS_METHODS.projectsList: - return projectRegistry.list(); + return effectRuntime.runPromise(projectRepository.list()); case WS_METHODS.projectsAdd: - { - const result = await projectRegistry.add(request.params as never); - await orchestrationEngine.dispatch({ - type: "project.create", - commandId: crypto.randomUUID(), - projectId: result.project.id, - name: result.project.name, - cwd: result.project.cwd, - model: "gpt-5-codex", - createdAt: new Date().toISOString(), - }); - return result; - } + return effectRuntime.runPromise(projectRepository.add(request.params as never)); case WS_METHODS.projectsRemove: - { - const params = request.params as { id: string }; - projectRegistry.remove(request.params as never); - await orchestrationEngine.dispatch({ - type: "project.delete", - commandId: crypto.randomUUID(), - projectId: params.id, - createdAt: new Date().toISOString(), - }); - return undefined; - } + return effectRuntime.runPromise(projectRepository.remove(request.params as never)); case WS_METHODS.projectsSearchEntries: return searchWorkspaceEntries(request.params as never); case WS_METHODS.projectsUpdateScripts: - return projectRegistry.updateScripts(request.params as never); + return effectRuntime.runPromise(projectRepository.updateScripts(request.params as never)); case WS_METHODS.shellOpenInEditor: { const params = request.params as { @@ -597,25 +599,25 @@ export function createServer(options: ServerOptions) { return undefined; } - case WS_METHODS.gitStatus: - { - const params = request.params as { cwd: string }; - const status = await gitManager.status(request.params as never); - const project = projectRegistry.list().find((entry) => entry.cwd === params.cwd); - if (project) { - await orchestrationEngine.dispatch({ - type: "git.readModel.upsert", - commandId: crypto.randomUUID(), - projectId: project.id, - branch: status.branch, - hasWorkingTreeChanges: status.hasWorkingTreeChanges, - aheadCount: status.aheadCount, - behindCount: status.behindCount, - createdAt: new Date().toISOString(), - }); - } - return status; + case WS_METHODS.gitStatus: { + const params = request.params as { cwd: string }; + const status = await gitManager.status(request.params as never); + const projects = await effectRuntime.runPromise(projectRepository.list()); + const project = projects.find((entry) => entry.cwd === params.cwd); + if (project) { + await orchestrationEngine.dispatch({ + type: "git.readModel.upsert", + commandId: crypto.randomUUID(), + projectId: project.id, + branch: status.branch, + hasWorkingTreeChanges: status.hasWorkingTreeChanges, + aheadCount: status.aheadCount, + behindCount: status.behindCount, + createdAt: new Date().toISOString(), + }); } + return status; + } case WS_METHODS.gitPull: return pullGitBranch(request.params as never); @@ -698,37 +700,57 @@ export function createServer(options: ServerOptions) { } } - async function ensureOrchestrationSystemStarted() { - if (orchestrationSystem) { - return; + async function createEffectRuntime() { + const dbPath = path.join(resolvedStateDir, "orchestration.sqlite"); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const runtime = ManagedRuntime.make( + Layer.mergeAll(OrchestrationLive, ProjectRepositoryLive).pipe( + Layer.provide(persistenceLayer), + ), + ); + + try { + const [nextOrchestrationEngine, repository] = await Promise.all([ + runtime.runPromise(OrchestrationEngineService), + runtime.runPromise(ProjectRepository), + ]); + orchestrationEngine = nextOrchestrationEngine; + projectRepository = repository; + await runtime.runPromise(repository.pruneMissing()); + attachOrchestrationSubscriptions(); + } catch (error) { + await runtime.dispose().catch(() => undefined); + throw error; } - orchestrationSystem = await createOrchestrationSystem(resolvedStateDir); - attachOrchestrationSubscriptions(); + return runtime; } - async function disposeOrchestrationSystem() { + async function disposeEffectRuntime() { unsubscribeReadModel(); unsubscribeDomainEvents(); unsubscribeReadModel = noop; unsubscribeDomainEvents = noop; - if (!orchestrationSystem) { + const runtime = effectRuntime; + effectRuntime = null; + orchestrationEngine = null; + projectRepository = null; + + if (!runtime) { return; } - const system = orchestrationSystem; - orchestrationSystem = null; - await system.dispose(); + await runtime.dispose(); } async function start() { - await ensureOrchestrationSystemStarted(); + effectRuntime = await createEffectRuntime(); return new Promise((resolve, reject) => { const onError = (error: Error) => { httpServer.off("error", onError); - void disposeOrchestrationSystem().finally(() => reject(error)); + void disposeEffectRuntime().finally(() => reject(error)); }; httpServer.once("error", onError); const onListening = () => { @@ -744,7 +766,7 @@ export function createServer(options: ServerOptions) { } async function stop(): Promise { - await disposeOrchestrationSystem(); + await disposeEffectRuntime(); terminalManager.off("event", onTerminalEvent); providerManager.stopAll(); providerManager.dispose(); @@ -755,15 +777,6 @@ export function createServer(options: ServerOptions) { } clients.clear(); - const isServerNotRunningError = (error: unknown): boolean => { - if (!(error instanceof Error)) return false; - const maybeCode = (error as NodeJS.ErrnoException).code; - return ( - maybeCode === "ERR_SERVER_NOT_RUNNING" || - error.message.toLowerCase().includes("not running") - ); - }; - const closeWebSocketServer = new Promise((resolve, reject) => { wss.close((error) => { if (error && !isServerNotRunningError(error)) { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0d2ad398f2cdd..7371b19bc411b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -28,13 +28,6 @@ function formatRelativeTime(iso: string): string { return `${Math.floor(hours / 24)}d ago`; } -function inferProjectName(cwd: string): string { - const normalized = cwd.replace(/\\/g, "/").replace(/\/+$/, ""); - const parts = normalized.split("/"); - const last = parts[parts.length - 1]; - return last && last.length > 0 ? last : "project"; -} - interface ThreadStatusPill { label: "Working" | "Connecting" | "Completed" | "Awaiting response"; colorClass: string; @@ -195,23 +188,16 @@ export default function Sidebar() { return; } - const projectId = crypto.randomUUID(); - const now = new Date().toISOString(); - const name = inferProjectName(cwd); - if (isElectron) { - // Keep project registry in sync for desktop integrations. - await api.projects.add({ cwd }); + const result = await api.projects.add({ cwd }); + const projects = await api.projects.list(); + dispatch({ type: "SYNC_PROJECTS", projects }); + + const hasThread = state.threads.some((thread) => thread.projectId === result.project.id); + if (hasThread) { + focusMostRecentThreadForProject(result.project.id); + return; } - await api.orchestration.dispatchCommand({ - type: "project.create", - commandId: crypto.randomUUID(), - projectId, - name, - cwd, - model: DEFAULT_MODEL, - createdAt: now, - }); - handleNewThread(projectId); + handleNewThread(result.project.id); } finally { setIsAddingProject(false); setNewCwd(""); @@ -225,6 +211,7 @@ export default function Sidebar() { handleNewThread, isAddingProject, state.projects, + state.threads, ], ); @@ -381,28 +368,20 @@ export default function Sidebar() { ); if (!confirmed) return; - if (isElectron) { - try { - await api.projects.remove({ id: projectId }); - } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown error deleting project."; - console.error("Failed to remove project", { projectId, error }); - toastManager.add({ - type: "error", - title: `Failed to delete "${project.name}"`, - description: message, - }); - return; - } + try { + await api.projects.remove({ id: projectId }); + const projects = await api.projects.list(); + dispatch({ type: "SYNC_PROJECTS", projects }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown error deleting project."; + console.error("Failed to remove project", { projectId, error }); + toastManager.add({ + type: "error", + title: `Failed to delete "${project.name}"`, + description: message, + }); } - - await api.orchestration.dispatchCommand({ - type: "project.delete", - commandId: crypto.randomUUID(), - projectId, - createdAt: new Date().toISOString(), - }); }, [api, dispatch, state.projects, state.threads], ); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 1ef0eabe4bcbf..8a6ee0752bc72 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -126,9 +126,9 @@ function EventRouter() { useEffect(() => { if (!api) return; - void api.orchestration - .getSnapshot() - .then((snapshot) => { + void Promise.all([api.projects.list(), api.orchestration.getSnapshot()]) + .then(([projects, snapshot]) => { + dispatch({ type: "SYNC_PROJECTS", projects }); dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); }) .catch(() => undefined); @@ -150,8 +150,8 @@ function AutoProjectBootstrap() { useEffect(() => { if (!api) return; - // Browser mode bootstraps from server welcome. - // Electron bootstraps from persisted projects via DesktopProjectBootstrap. + // Browser mode auto-adds the current cwd project via server welcome. + // Desktop skips this because projects are already loaded from the repository. if (isElectron) return; return onServerWelcome((payload) => { @@ -166,40 +166,38 @@ function AutoProjectBootstrap() { } bootstrappedRef.current = true; - const projectId = crypto.randomUUID(); - const threadId = crypto.randomUUID(); const now = new Date().toISOString(); - void api.orchestration - .dispatchCommand({ - type: "project.create", - commandId: crypto.randomUUID(), - projectId, - name: payload.projectName, - cwd: payload.cwd, - model: DEFAULT_MODEL, - createdAt: now, - }) - .then(() => - api.orchestration.dispatchCommand({ + void api.projects + .add({ cwd: payload.cwd }) + .then(async (result) => { + const projects = await api.projects.list(); + dispatch({ type: "SYNC_PROJECTS", projects }); + + const hasThread = state.threads.some((thread) => thread.projectId === result.project.id); + if (hasThread) { + return; + } + + return api.orchestration.dispatchCommand({ type: "thread.create", commandId: crypto.randomUUID(), - threadId, - projectId, + threadId: crypto.randomUUID(), + projectId: result.project.id, title: "New thread", model: DEFAULT_MODEL, branch: null, worktreePath: null, createdAt: now, - }), - ) + }); + }) .catch(() => undefined); }); - }, [api, state.projects, dispatch]); + }, [api, state.projects, state.threads, dispatch]); return null; } function DesktopProjectBootstrap() { - // Desktop now hydrates from orchestration read-model snapshots. + // Desktop hydration runs through EventRouter project + orchestration sync. return null; } diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 1df450ac06a24..46de08875d1f5 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -10,9 +10,10 @@ import { import { type OrchestrationReadModel, + type ProjectListResult, normalizeProjectScripts, } from "@t3tools/contracts"; -import { resolveModelSlug } from "./model-logic"; +import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -29,6 +30,7 @@ import { type Action = | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } + | { type: "SYNC_PROJECTS"; projects: ProjectListResult } | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } | { type: "TOGGLE_PROJECT"; projectId: string } @@ -136,6 +138,29 @@ function updateThread( return threads.map((t) => (t.id === threadId ? updater(t) : t)); } +function mapProjects( + incoming: ProjectListResult, + previous: Project[], +): Project[] { + return incoming.map((project) => { + const existing = + previous.find((entry) => entry.id === project.id) ?? + previous.find((entry) => entry.cwd === project.cwd); + return { + id: project.id, + name: project.name, + cwd: project.cwd, + model: existing?.model ?? DEFAULT_MODEL, + expanded: + existing?.expanded ?? + (persistedExpandedProjectCwds.size > 0 + ? persistedExpandedProjectCwds.has(project.cwd) + : true), + scripts: normalizeProjectScripts(project.scripts), + }; + }); +} + function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); @@ -330,21 +355,6 @@ export function reducer(state: AppState, action: Action): AppState { switch (action.type) { case "SYNC_SERVER_READ_MODEL": { const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); - const projects = action.readModel.projects.map((project) => { - const existing = state.projects.find((entry) => entry.id === project.id); - return { - id: project.id, - name: project.name, - cwd: project.cwd, - model: resolveModelSlug(project.model), - expanded: - existing?.expanded ?? - (persistedExpandedProjectCwds.size > 0 - ? persistedExpandedProjectCwds.has(project.cwd) - : true), - scripts: existing?.scripts ?? [], - }; - }); const threads = action.readModel.threads.map((thread) => { const existing = existingThreadById.get(thread.id); return normalizeThreadTerminals({ @@ -392,12 +402,17 @@ export function reducer(state: AppState, action: Action): AppState { }); return { ...state, - projects, threads, threadsHydrated: true, }; } + case "SYNC_PROJECTS": + return { + ...state, + projects: mapProjects(action.projects, state.projects), + }; + case "SET_PROJECT_SCRIPTS": return { ...state, diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index e9f53c19db947..22f1c6411f185 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -80,17 +80,6 @@ export const OrchestrationThreadSchema = Schema.Struct({ export type OrchestrationThread = Schema.Schema.Type; -export const OrchestrationProjectSchema = Schema.Struct({ - id: IdSchema, - name: Schema.String, - cwd: Schema.String, - model: Schema.String, - createdAt: IsoDateTimeSchema, - updatedAt: IsoDateTimeSchema, -}); - -export type OrchestrationProject = Schema.Schema.Type; - export const OrchestrationGitReadModelSchema = Schema.Struct({ projectId: IdSchema, branch: Schema.NullOr(Schema.String), @@ -104,7 +93,6 @@ export type OrchestrationGitReadModel = Schema.Schema.Type; -export const CreateProjectCommandSchema = Schema.Struct({ - type: Schema.Literal("project.create"), - commandId: IdSchema, - projectId: IdSchema, - name: Schema.String, - cwd: Schema.String, - model: Schema.String, - createdAt: IsoDateTimeSchema, -}); - -export const DeleteProjectCommandSchema = Schema.Struct({ - type: Schema.Literal("project.delete"), - commandId: IdSchema, - projectId: IdSchema, - createdAt: IsoDateTimeSchema, -}); - export const CreateThreadCommandSchema = Schema.Struct({ type: Schema.Literal("thread.create"), commandId: IdSchema, @@ -212,8 +183,6 @@ export const RevertThreadCommandSchema = Schema.Struct({ }); export const OrchestrationCommandSchema = Schema.Union( - CreateProjectCommandSchema, - DeleteProjectCommandSchema, CreateThreadCommandSchema, DeleteThreadCommandSchema, UpdateThreadMetaCommandSchema, From 2fa10f7b5df52f69b7701a8041fcb05c225d4b8e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 01:04:59 -0800 Subject: [PATCH 10/98] rm unused --- apps/server/src/orchestration/engine.ts | 2 - .../src/orchestration/uiContractInventory.ts | 64 ------------------- 2 files changed, 66 deletions(-) delete mode 100644 apps/server/src/orchestration/uiContractInventory.ts diff --git a/apps/server/src/orchestration/engine.ts b/apps/server/src/orchestration/engine.ts index fda67ce71a38e..1f66c21d058af 100644 --- a/apps/server/src/orchestration/engine.ts +++ b/apps/server/src/orchestration/engine.ts @@ -20,7 +20,6 @@ import { import { createLogger } from "../logger"; import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; import { createEmptyReadModel, reduceEvent } from "./reducer"; -import { UI_ENTITY_CONTRACTS } from "./uiContractInventory"; type CommandEnvelope = { command: OrchestrationCommand; @@ -245,7 +244,6 @@ export class OrchestrationEngine { this.workerFiber = Runtime.runFork(this.runtime)(worker); this.logger.info("orchestration engine started", { sequence: this.readModel.sequence, - contracts: UI_ENTITY_CONTRACTS.length, }); } diff --git a/apps/server/src/orchestration/uiContractInventory.ts b/apps/server/src/orchestration/uiContractInventory.ts deleted file mode 100644 index eb0dbed858ced..0000000000000 --- a/apps/server/src/orchestration/uiContractInventory.ts +++ /dev/null @@ -1,64 +0,0 @@ -export interface UiEntityContract { - entity: string; - requiredFields: string[]; - consumedBy: string[]; -} - -// Explicit UI inventory used to keep server read models aligned -// with what the current frontend renders. -export const UI_ENTITY_CONTRACTS: UiEntityContract[] = [ - { - entity: "Thread", - requiredFields: [ - "id", - "projectId", - "title", - "model", - "branch", - "worktreePath", - "createdAt", - "latestTurnId", - "latestTurnStartedAt", - "latestTurnCompletedAt", - "latestTurnDurationMs", - "error", - ], - consumedBy: [ - "apps/web/src/components/Sidebar.tsx", - "apps/web/src/components/ChatView.tsx", - "apps/web/src/components/BranchToolbar.tsx", - ], - }, - { - entity: "Message", - requiredFields: ["id", "role", "text", "createdAt", "streaming"], - consumedBy: ["apps/web/src/components/ChatView.tsx"], - }, - { - entity: "ProviderSession", - requiredFields: [ - "sessionId", - "provider", - "status", - "threadId", - "activeTurnId", - "createdAt", - "updatedAt", - "lastError", - ], - consumedBy: ["apps/web/src/components/ChatView.tsx", "apps/web/src/components/Sidebar.tsx"], - }, - { - entity: "TurnDiffSummary", - requiredFields: ["turnId", "completedAt", "status", "files"], - consumedBy: ["apps/web/src/components/DiffPanel.tsx", "apps/web/src/components/ChatView.tsx"], - }, - { - entity: "GitReadModel", - requiredFields: ["projectId", "branch", "hasWorkingTreeChanges", "aheadCount", "behindCount"], - consumedBy: [ - "apps/web/src/components/GitActionsControl.tsx", - "apps/web/src/components/BranchToolbar.tsx", - ], - }, -]; From 9ef01eb97e9a4e7e174e383350febd08951a0dc3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 01:27:18 -0800 Subject: [PATCH 11/98] simplify layer composition --- apps/server/src/orchestration/Layer.test.ts | 439 ++++++++++++++++++ .../src/orchestration/{engine.ts => Layer.ts} | 208 ++++----- apps/server/src/orchestration/Service.ts | 25 + apps/server/src/orchestration/engine.test.ts | 388 ---------------- apps/server/src/orchestration/layers.ts | 23 - apps/server/src/orchestration/services.ts | 8 - apps/server/src/wsServer.ts | 360 +++++++------- 7 files changed, 760 insertions(+), 691 deletions(-) create mode 100644 apps/server/src/orchestration/Layer.test.ts rename apps/server/src/orchestration/{engine.ts => Layer.ts} (56%) create mode 100644 apps/server/src/orchestration/Service.ts delete mode 100644 apps/server/src/orchestration/engine.test.ts delete mode 100644 apps/server/src/orchestration/layers.ts delete mode 100644 apps/server/src/orchestration/services.ts diff --git a/apps/server/src/orchestration/Layer.test.ts b/apps/server/src/orchestration/Layer.test.ts new file mode 100644 index 0000000000000..87afdcea4c38d --- /dev/null +++ b/apps/server/src/orchestration/Layer.test.ts @@ -0,0 +1,439 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import type { OrchestrationEvent } from "@t3tools/contracts"; +import { Effect } from "effect"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + OrchestrationEventRepository, + type OrchestrationEventRepositoryShape, +} from "../persistence/Services/OrchestrationEvents"; + +import { Layer, ManagedRuntime } from "effect"; + +import { makeSqlitePersistenceLive } from "../persistence/Layers/Sqlite"; + +import { OrchestrationEngineLive } from "./Layer"; +import { OrchestrationEngineService } from "./Service"; +import { OrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +export async function createOrchestrationSystem(stateDir: string) { + const dbPath = path.join(stateDir, "orchestration.sqlite"); + const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationEventRepositoryLive), + Layer.provide(makeSqlitePersistenceLive(dbPath)), + ); + const runtime = ManagedRuntime.make(orchestrationLayer); + const engine = await runtime.runPromise(OrchestrationEngineService); + return { + engine, + run: (effect: Effect.Effect) => runtime.runPromise(effect), + dispose: () => runtime.dispose(), + }; +} + +describe("OrchestrationEngine", () => { + it("replays to the same deterministic snapshot", async () => { + const stateDir = makeTempDir("t3code-orchestration-"); + const createdAt = new Date().toISOString(); + const projectId = "project-1"; + const threadId = "thread-1"; + + const firstSystem = await createOrchestrationSystem(stateDir); + const engineA = firstSystem.engine; + await firstSystem.run( + engineA.dispatch({ + type: "thread.create", + commandId: "cmd-1", + threadId, + projectId, + title: "Thread", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt, + }), + ); + await firstSystem.run( + engineA.dispatch({ + type: "message.send", + commandId: "cmd-3", + threadId, + messageId: "msg-1", + role: "user", + text: "hello", + streaming: false, + createdAt, + }), + ); + const snapshotA = await firstSystem.run(engineA.getSnapshot()); + await firstSystem.dispose(); + + const secondSystem = await createOrchestrationSystem(stateDir); + const engineB = secondSystem.engine; + const snapshotB = await secondSystem.run(engineB.getSnapshot()); + expect(snapshotB).toEqual(snapshotA); + await secondSystem.dispose(); + }); + + it("fans out read-model updates to subscribers", async () => { + const stateDir = makeTempDir("t3code-orchestration-fanout-"); + const system = await createOrchestrationSystem(stateDir); + const engine = system.engine; + const updates: number[] = []; + const unsubscribe = await system.run( + engine.subscribeToReadModel((snapshot) => { + updates.push(snapshot.sequence); + }), + ); + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: "cmd-thread", + threadId: "thread-2", + projectId: "project-2", + title: "fanout", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: new Date().toISOString(), + }), + ); + unsubscribe(); + expect(updates.length).toBeGreaterThan(0); + await system.dispose(); + }); + + it("replays append-only events from sequence", async () => { + const stateDir = makeTempDir("t3code-orchestration-replay-"); + const system = await createOrchestrationSystem(stateDir); + const engine = system.engine; + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: "cmd-a", + threadId: "thread-replay", + projectId: "project-replay", + title: "replay", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: new Date().toISOString(), + }), + ); + await system.run( + engine.dispatch({ + type: "thread.delete", + commandId: "cmd-b", + threadId: "thread-replay", + createdAt: new Date().toISOString(), + }), + ); + const events = await system.run(engine.replayEvents(0)); + expect(events.length).toBe(2); + expect(events[0]?.type).toBe("thread.created"); + expect(events[1]?.type).toBe("thread.deleted"); + await system.dispose(); + }); + + it("stores completed turn summaries even when no files changed", async () => { + const stateDir = makeTempDir("t3code-orchestration-turn-diff-"); + const firstSystem = await createOrchestrationSystem(stateDir); + const engine = firstSystem.engine; + const completedAt = new Date().toISOString(); + await firstSystem.run( + engine.dispatch({ + type: "thread.create", + commandId: "cmd-thread-turn-diff", + threadId: "thread-turn-diff", + projectId: "project-turn-diff", + title: "Turn diff thread", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: completedAt, + }), + ); + await firstSystem.run( + engine.dispatch({ + type: "thread.turnDiff.complete", + commandId: "cmd-turn-diff-complete", + threadId: "thread-turn-diff", + turnId: "turn-1", + completedAt, + status: "completed", + files: [], + checkpointTurnCount: 1, + createdAt: completedAt, + }), + ); + + const firstThread = (await firstSystem.run(engine.getSnapshot())).threads.find( + (thread) => thread.id === "thread-turn-diff", + ); + expect(firstThread?.turnDiffSummaries).toEqual([ + { + turnId: "turn-1", + completedAt, + status: "completed", + files: [], + checkpointTurnCount: 1, + }, + ]); + await firstSystem.dispose(); + + const secondSystem = await createOrchestrationSystem(stateDir); + const restartedEngine = secondSystem.engine; + const restartedThread = (await secondSystem.run(restartedEngine.getSnapshot())).threads.find( + (thread) => thread.id === "thread-turn-diff", + ); + expect(restartedThread?.turnDiffSummaries).toEqual([ + { + turnId: "turn-1", + completedAt, + status: "completed", + files: [], + checkpointTurnCount: 1, + }, + ]); + await secondSystem.dispose(); + }); + + it("reverts thread messages and turn summaries to a checkpoint", async () => { + const stateDir = makeTempDir("t3code-orchestration-revert-"); + const firstSystem = await createOrchestrationSystem(stateDir); + const engine = firstSystem.engine; + const createdAt = new Date().toISOString(); + await firstSystem.run( + engine.dispatch({ + type: "thread.create", + commandId: "cmd-thread-revert", + threadId: "thread-revert", + projectId: "project-revert", + title: "Revert thread", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt, + }), + ); + await firstSystem.run( + engine.dispatch({ + type: "message.send", + commandId: "cmd-msg-1-user", + threadId: "thread-revert", + messageId: "user-1", + role: "user", + text: "first", + streaming: false, + createdAt, + }), + ); + await firstSystem.run( + engine.dispatch({ + type: "message.send", + commandId: "cmd-msg-1-assistant", + threadId: "thread-revert", + messageId: "assistant:turn-1", + role: "assistant", + text: "first-response", + streaming: false, + createdAt, + }), + ); + await firstSystem.run( + engine.dispatch({ + type: "thread.turnDiff.complete", + commandId: "cmd-turn-1-complete", + threadId: "thread-revert", + turnId: "turn-1", + completedAt: createdAt, + status: "completed", + files: [], + assistantMessageId: "assistant:turn-1", + checkpointTurnCount: 1, + createdAt, + }), + ); + + const createdAtSecond = new Date(Date.now() + 1_000).toISOString(); + await firstSystem.run( + engine.dispatch({ + type: "message.send", + commandId: "cmd-msg-2-user", + threadId: "thread-revert", + messageId: "user-2", + role: "user", + text: "second", + streaming: false, + createdAt: createdAtSecond, + }), + ); + await firstSystem.run( + engine.dispatch({ + type: "message.send", + commandId: "cmd-msg-2-assistant", + threadId: "thread-revert", + messageId: "assistant:turn-2", + role: "assistant", + text: "second-response", + streaming: false, + createdAt: createdAtSecond, + }), + ); + await firstSystem.run( + engine.dispatch({ + type: "thread.turnDiff.complete", + commandId: "cmd-turn-2-complete", + threadId: "thread-revert", + turnId: "turn-2", + completedAt: createdAtSecond, + status: "completed", + files: [], + assistantMessageId: "assistant:turn-2", + checkpointTurnCount: 2, + createdAt: createdAtSecond, + }), + ); + + await firstSystem.run( + engine.dispatch({ + type: "thread.revert", + commandId: "cmd-thread-revert-apply", + threadId: "thread-revert", + turnCount: 1, + messageCount: 2, + createdAt: createdAtSecond, + }), + ); + + const thread = (await firstSystem.run(engine.getSnapshot())).threads.find( + (entry) => entry.id === "thread-revert", + ); + expect(thread?.messages.map((message) => message.id)).toEqual(["user-1", "assistant:turn-1"]); + expect(thread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); + expect(thread?.latestTurnId).toBe("turn-1"); + await firstSystem.dispose(); + + const secondSystem = await createOrchestrationSystem(stateDir); + const restarted = secondSystem.engine; + const restartedThread = (await secondSystem.run(restarted.getSnapshot())).threads.find( + (entry) => entry.id === "thread-revert", + ); + expect(restartedThread?.messages.map((message) => message.id)).toEqual([ + "user-1", + "assistant:turn-1", + ]); + expect(restartedThread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); + await secondSystem.dispose(); + }); + + it("allows stop to be called multiple times", async () => { + const inMemoryStore: OrchestrationEventRepositoryShape = { + append(event) { + return Effect.succeed({ ...event, sequence: 1 }); + }, + readFromSequence() { + return Effect.succeed([]); + }, + readAll() { + return Effect.succeed([]); + }, + }; + const runtime = ManagedRuntime.make( + OrchestrationEngineLive.pipe( + Layer.provide(Layer.succeed(OrchestrationEventRepository, inMemoryStore)), + ), + ); + await runtime.runPromise(OrchestrationEngineService); + await runtime.dispose(); + await expect(runtime.dispose()).resolves.toBeUndefined(); + }); + + it("keeps processing queued commands after a storage failure", async () => { + const events: OrchestrationEvent[] = []; + let nextSequence = 1; + let shouldFailFirstAppend = true; + + const flakyStore: OrchestrationEventRepositoryShape = { + append(event) { + if (shouldFailFirstAppend) { + shouldFailFirstAppend = false; + return Effect.die(new Error("append failed")); + } + const savedEvent = { + ...event, + sequence: nextSequence, + } satisfies OrchestrationEvent; + nextSequence += 1; + events.push(savedEvent); + return Effect.succeed(savedEvent); + }, + readFromSequence(sequenceExclusive) { + return Effect.succeed(events.filter((event) => event.sequence > sequenceExclusive)); + }, + readAll() { + return Effect.succeed(events); + }, + }; + + const runtime = ManagedRuntime.make( + OrchestrationEngineLive.pipe( + Layer.provide(Layer.succeed(OrchestrationEventRepository, flakyStore)), + ), + ); + const engine = await runtime.runPromise(OrchestrationEngineService); + const createdAt = new Date().toISOString(); + + await expect( + runtime.runPromise( + engine.dispatch({ + type: "thread.create", + commandId: "cmd-flaky-1", + threadId: "thread-flaky-fail", + projectId: "project-flaky", + title: "flaky-fail", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt, + }), + ), + ).rejects.toThrow("append failed"); + + const result = await runtime.runPromise( + engine.dispatch({ + type: "thread.create", + commandId: "cmd-flaky-2", + threadId: "thread-flaky-ok", + projectId: "project-flaky", + title: "flaky-ok", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt, + }), + ); + + expect(result.sequence).toBe(1); + expect((await runtime.runPromise(engine.getSnapshot())).sequence).toBe(1); + + await runtime.dispose(); + }); +}); diff --git a/apps/server/src/orchestration/engine.ts b/apps/server/src/orchestration/Layer.ts similarity index 56% rename from apps/server/src/orchestration/engine.ts rename to apps/server/src/orchestration/Layer.ts index 1f66c21d058af..9974132fee21b 100644 --- a/apps/server/src/orchestration/engine.ts +++ b/apps/server/src/orchestration/Layer.ts @@ -5,21 +5,22 @@ import type { } from "@t3tools/contracts"; import { OrchestrationCommandSchema } from "@t3tools/contracts"; import { - PubSub, - Queue, - Schema, - Stream, + Cause, + Deferred, Effect, + Either, Fiber, + Layer, + PubSub, + Queue, Runtime, - Either, - Deferred, - Cause, + Schema, } from "effect"; import { createLogger } from "../logger"; -import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; +import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents"; import { createEmptyReadModel, reduceEvent } from "./reducer"; +import { OrchestrationEngineService, type OrchestrationEngineShape } from "./Service"; type CommandEnvelope = { command: OrchestrationCommand; @@ -168,45 +169,38 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit; - private readModelPubSub: PubSub.PubSub; - private eventPubSub: PubSub.PubSub; - private workerFiber: Fiber.RuntimeFiber | null = null; - private readonly readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); - private readonly domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); +export const makeOrchestrationEngine = Effect.gen(function* () { + const logger = createLogger("orchestration"); + const eventStore = yield* OrchestrationEventRepository; + const runtime = Runtime.defaultRuntime; - constructor(eventStore: OrchestrationEventRepositoryShape) { - this.eventStore = eventStore; - this.readModel = createEmptyReadModel(new Date().toISOString()); - this.commandQueue = Runtime.runSync(this.runtime)(Queue.unbounded()); - this.readModelPubSub = Runtime.runSync(this.runtime)( - PubSub.unbounded(), - ); - this.eventPubSub = Runtime.runSync(this.runtime)(PubSub.unbounded()); - } + let readModel = createEmptyReadModel(new Date().toISOString()); + + const commandQueue = yield* Queue.unbounded(); + const readModelPubSub = yield* PubSub.unbounded(); + const eventPubSub = yield* PubSub.unbounded(); - private processEnvelope(envelope: CommandEnvelope): Effect.Effect { - return Effect.gen(this, function* () { + const readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); + const domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); + + const processEnvelope = (envelope: CommandEnvelope): Effect.Effect => + Effect.gen(function* () { const eventBase = mapCommandToEvent(envelope.command); - const savedEvent = yield* this.eventStore.append(eventBase); - this.readModel = yield* reduceEvent(this.readModel, savedEvent); + const savedEvent = yield* eventStore.append(eventBase); + readModel = yield* reduceEvent(readModel, savedEvent); - const snapshot = this.readModel; + const snapshot = readModel; yield* Effect.all([ - PubSub.publish(this.eventPubSub, savedEvent), - PubSub.publish(this.readModelPubSub, snapshot), + PubSub.publish(eventPubSub, savedEvent), + PubSub.publish(readModelPubSub, snapshot), ]); - for (const listener of this.domainEventListeners) { + for (const listener of domainEventListeners) { listener(savedEvent); } - for (const listener of this.readModelListeners) { + for (const listener of readModelListeners) { listener(snapshot); } @@ -219,91 +213,85 @@ export class OrchestrationEngine { ).pipe(Effect.asVoid), ), ); - } - private bootstrapReadModel(): Effect.Effect { - return Effect.gen(this, function* () { - const existingEvents = yield* this.eventStore.readAll(); - for (const event of existingEvents) { - this.readModel = yield* reduceEvent(this.readModel, event); - } - }); - } - - async start(): Promise { - if (this.workerFiber) { - return; + const bootstrapReadModel: Effect.Effect = Effect.gen(function* () { + const existingEvents = yield* eventStore.readAll(); + for (const event of existingEvents) { + readModel = yield* reduceEvent(readModel, event); } + }); - await Runtime.runPromise(this.runtime)(this.bootstrapReadModel()); + yield* bootstrapReadModel; - const worker = Stream.fromQueue(this.commandQueue).pipe( - Stream.runForEach((envelope) => this.processEnvelope(envelope)), - ); + const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); + const workerFiber: Fiber.RuntimeFiber = Runtime.runFork(runtime)(worker); + logger.info("orchestration engine started", { + sequence: readModel.sequence, + }); - this.workerFiber = Runtime.runFork(this.runtime)(worker); - this.logger.info("orchestration engine started", { - sequence: this.readModel.sequence, - }); - } + yield* Effect.addFinalizer(() => Fiber.interrupt(workerFiber).pipe(Effect.orDie)); - async stop(): Promise { - if (this.workerFiber) { - await Runtime.runPromise(this.runtime)(Fiber.interrupt(this.workerFiber)); - this.workerFiber = null; - } - } + const getSnapshot: OrchestrationEngineShape["getSnapshot"] = () => + Effect.sync((): OrchestrationReadModel => readModel); - getSnapshot(): OrchestrationReadModel { - return this.readModel; - } + const replayEvents: OrchestrationEngineShape["replayEvents"] = (fromSequenceExclusive) => + eventStore.readFromSequence(fromSequenceExclusive); - async replayEvents(fromSequenceExclusive: number): Promise { - return Runtime.runPromise(this.runtime)( - this.eventStore.readFromSequence(fromSequenceExclusive), - ); - } + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => + Effect.gen(function* () { + const result = yield* Deferred.make<{ sequence: number }, Error>(); + yield* Queue.offer(commandQueue, { command, result }); + return yield* Deferred.await(result); + }).pipe(Effect.mapError((error) => asError(error, "Queue offer failed"))); - async dispatchUnknown(command: unknown): Promise<{ sequence: number }> { - const decode = Schema.decodeUnknownEither(OrchestrationCommandSchema); - let payload: unknown = command; - if (typeof command === "string") { - try { - payload = JSON.parse(command) as unknown; - } catch { - throw new Error("Invalid orchestration command: payload is not valid JSON"); + const dispatchUnknown: OrchestrationEngineShape["dispatchUnknown"] = (command) => + Effect.gen(function* () { + let payload: unknown = command; + if (typeof command === "string") { + try { + payload = JSON.parse(command) as unknown; + } catch { + return yield* Effect.fail( + new Error("Invalid orchestration command: payload is not valid JSON"), + ); + } } - } - const decoded = decode(payload); - if (Either.isLeft(decoded)) { - const issues = decoded.left.toString(); - throw new Error(`Invalid orchestration command: ${issues}`); - } - return this.dispatch(decoded.right); - } + const decoded = decodeUnknownCommand(payload); + if (Either.isLeft(decoded)) { + return yield* Effect.fail( + new Error(`Invalid orchestration command: ${decoded.left.toString()}`), + ); + } + return yield* dispatch(decoded.right); + }); - async dispatch(command: OrchestrationCommand): Promise<{ sequence: number }> { - const program = Effect.gen(this, function* () { - const result = yield* Deferred.make<{ sequence: number }, Error>(); - yield* Queue.offer(this.commandQueue, { command, result }); - return yield* Deferred.await(result); + const subscribeToReadModel: OrchestrationEngineShape["subscribeToReadModel"] = (callback) => + Effect.sync(() => { + readModelListeners.add(callback); + return () => { + readModelListeners.delete(callback); + }; }); - return Runtime.runPromise(this.runtime)(program).catch((error) => { - throw asError(error, "Queue offer failed"); + + const subscribeToDomainEvents: OrchestrationEngineShape["subscribeToDomainEvents"] = (callback) => + Effect.sync(() => { + domainEventListeners.add(callback); + return () => { + domainEventListeners.delete(callback); + }; }); - } - subscribeToReadModel(callback: (snapshot: OrchestrationReadModel) => void): () => void { - this.readModelListeners.add(callback); - return () => { - this.readModelListeners.delete(callback); - }; - } + return { + getSnapshot, + replayEvents, + dispatchUnknown, + dispatch, + subscribeToReadModel, + subscribeToDomainEvents, + } satisfies OrchestrationEngineShape; +}); - subscribeToDomainEvents(callback: (event: OrchestrationEvent) => void): () => void { - this.domainEventListeners.add(callback); - return () => { - this.domainEventListeners.delete(callback); - }; - } -} +export const OrchestrationEngineLive = Layer.scoped( + OrchestrationEngineService, + makeOrchestrationEngine, +); diff --git a/apps/server/src/orchestration/Service.ts b/apps/server/src/orchestration/Service.ts new file mode 100644 index 0000000000000..9959f897a4cfa --- /dev/null +++ b/apps/server/src/orchestration/Service.ts @@ -0,0 +1,25 @@ +import type { + OrchestrationCommand, + OrchestrationEvent, + OrchestrationReadModel, +} from "@t3tools/contracts"; +import { Context } from "effect"; +import type { Effect } from "effect"; + +export interface OrchestrationEngineShape { + readonly getSnapshot: () => Effect.Effect; + readonly replayEvents: (fromSequenceExclusive: number) => Effect.Effect; + readonly dispatchUnknown: (command: unknown) => Effect.Effect<{ sequence: number }, Error>; + readonly dispatch: (command: OrchestrationCommand) => Effect.Effect<{ sequence: number }, Error>; + readonly subscribeToReadModel: ( + callback: (snapshot: OrchestrationReadModel) => void, + ) => Effect.Effect<() => void>; + readonly subscribeToDomainEvents: ( + callback: (event: OrchestrationEvent) => void, + ) => Effect.Effect<() => void>; +} + +export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")< + OrchestrationEngineService, + OrchestrationEngineShape +>() {} diff --git a/apps/server/src/orchestration/engine.test.ts b/apps/server/src/orchestration/engine.test.ts deleted file mode 100644 index d8c7813cfc1da..0000000000000 --- a/apps/server/src/orchestration/engine.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import type { OrchestrationEvent } from "@t3tools/contracts"; -import { Effect } from "effect"; -import { afterEach, describe, expect, it } from "vitest"; - -import type { OrchestrationEventRepositoryShape } from "../persistence/Services/OrchestrationEvents"; -import { OrchestrationEngine } from "./engine"; - -import { Layer, ManagedRuntime } from "effect"; - -import { makeSqlitePersistenceLive } from "../persistence/Layers/Sqlite"; - -import { OrchestrationLive } from "./layers"; -import { OrchestrationEngineService } from "./services"; - -const tempDirs: string[] = []; - -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -export async function createOrchestrationSystem(stateDir: string) { - const dbPath = path.join(stateDir, "orchestration.sqlite"); - const orchestrationLayer = OrchestrationLive.pipe( - Layer.provide(makeSqlitePersistenceLive(dbPath)), - ); - const runtime = ManagedRuntime.make(orchestrationLayer); - const engine = await runtime.runPromise(OrchestrationEngineService); - return { - engine, - dispose: () => runtime.dispose(), - }; -} - -describe("OrchestrationEngine", () => { - it("replays to the same deterministic snapshot", async () => { - const stateDir = makeTempDir("t3code-orchestration-"); - const createdAt = new Date().toISOString(); - const projectId = "project-1"; - const threadId = "thread-1"; - - const firstSystem = await createOrchestrationSystem(stateDir); - const engineA = firstSystem.engine; - await engineA.dispatch({ - type: "thread.create", - commandId: "cmd-1", - threadId, - projectId, - title: "Thread", - model: "gpt-5-codex", - branch: null, - worktreePath: null, - createdAt, - }); - await engineA.dispatch({ - type: "message.send", - commandId: "cmd-3", - threadId, - messageId: "msg-1", - role: "user", - text: "hello", - streaming: false, - createdAt, - }); - const snapshotA = engineA.getSnapshot(); - await firstSystem.dispose(); - - const secondSystem = await createOrchestrationSystem(stateDir); - const engineB = secondSystem.engine; - const snapshotB = engineB.getSnapshot(); - expect(snapshotB).toEqual(snapshotA); - await secondSystem.dispose(); - }); - - it("fans out read-model updates to subscribers", async () => { - const stateDir = makeTempDir("t3code-orchestration-fanout-"); - const system = await createOrchestrationSystem(stateDir); - const engine = system.engine; - const updates: number[] = []; - const unsubscribe = engine.subscribeToReadModel((snapshot) => { - updates.push(snapshot.sequence); - }); - await engine.dispatch({ - type: "thread.create", - commandId: "cmd-thread", - threadId: "thread-2", - projectId: "project-2", - title: "fanout", - model: "gpt-5-codex", - branch: null, - worktreePath: null, - createdAt: new Date().toISOString(), - }); - unsubscribe(); - expect(updates.length).toBeGreaterThan(0); - await system.dispose(); - }); - - it("replays append-only events from sequence", async () => { - const stateDir = makeTempDir("t3code-orchestration-replay-"); - const system = await createOrchestrationSystem(stateDir); - const engine = system.engine; - await engine.dispatch({ - type: "thread.create", - commandId: "cmd-a", - threadId: "thread-replay", - projectId: "project-replay", - title: "replay", - model: "gpt-5-codex", - branch: null, - worktreePath: null, - createdAt: new Date().toISOString(), - }); - await engine.dispatch({ - type: "thread.delete", - commandId: "cmd-b", - threadId: "thread-replay", - createdAt: new Date().toISOString(), - }); - const events = await engine.replayEvents(0); - expect(events.length).toBe(2); - expect(events[0]?.type).toBe("thread.created"); - expect(events[1]?.type).toBe("thread.deleted"); - await system.dispose(); - }); - - it("stores completed turn summaries even when no files changed", async () => { - const stateDir = makeTempDir("t3code-orchestration-turn-diff-"); - const firstSystem = await createOrchestrationSystem(stateDir); - const engine = firstSystem.engine; - const completedAt = new Date().toISOString(); - await engine.dispatch({ - type: "thread.create", - commandId: "cmd-thread-turn-diff", - threadId: "thread-turn-diff", - projectId: "project-turn-diff", - title: "Turn diff thread", - model: "gpt-5-codex", - branch: null, - worktreePath: null, - createdAt: completedAt, - }); - await engine.dispatch({ - type: "thread.turnDiff.complete", - commandId: "cmd-turn-diff-complete", - threadId: "thread-turn-diff", - turnId: "turn-1", - completedAt, - status: "completed", - files: [], - checkpointTurnCount: 1, - createdAt: completedAt, - }); - - const firstThread = engine - .getSnapshot() - .threads.find((thread) => thread.id === "thread-turn-diff"); - expect(firstThread?.turnDiffSummaries).toEqual([ - { - turnId: "turn-1", - completedAt, - status: "completed", - files: [], - checkpointTurnCount: 1, - }, - ]); - await firstSystem.dispose(); - - const secondSystem = await createOrchestrationSystem(stateDir); - const restartedEngine = secondSystem.engine; - const restartedThread = restartedEngine - .getSnapshot() - .threads.find((thread) => thread.id === "thread-turn-diff"); - expect(restartedThread?.turnDiffSummaries).toEqual([ - { - turnId: "turn-1", - completedAt, - status: "completed", - files: [], - checkpointTurnCount: 1, - }, - ]); - await secondSystem.dispose(); - }); - - it("reverts thread messages and turn summaries to a checkpoint", async () => { - const stateDir = makeTempDir("t3code-orchestration-revert-"); - const firstSystem = await createOrchestrationSystem(stateDir); - const engine = firstSystem.engine; - const createdAt = new Date().toISOString(); - await engine.dispatch({ - type: "thread.create", - commandId: "cmd-thread-revert", - threadId: "thread-revert", - projectId: "project-revert", - title: "Revert thread", - model: "gpt-5-codex", - branch: null, - worktreePath: null, - createdAt, - }); - await engine.dispatch({ - type: "message.send", - commandId: "cmd-msg-1-user", - threadId: "thread-revert", - messageId: "user-1", - role: "user", - text: "first", - streaming: false, - createdAt, - }); - await engine.dispatch({ - type: "message.send", - commandId: "cmd-msg-1-assistant", - threadId: "thread-revert", - messageId: "assistant:turn-1", - role: "assistant", - text: "first-response", - streaming: false, - createdAt, - }); - await engine.dispatch({ - type: "thread.turnDiff.complete", - commandId: "cmd-turn-1-complete", - threadId: "thread-revert", - turnId: "turn-1", - completedAt: createdAt, - status: "completed", - files: [], - assistantMessageId: "assistant:turn-1", - checkpointTurnCount: 1, - createdAt, - }); - - const createdAtSecond = new Date(Date.now() + 1_000).toISOString(); - await engine.dispatch({ - type: "message.send", - commandId: "cmd-msg-2-user", - threadId: "thread-revert", - messageId: "user-2", - role: "user", - text: "second", - streaming: false, - createdAt: createdAtSecond, - }); - await engine.dispatch({ - type: "message.send", - commandId: "cmd-msg-2-assistant", - threadId: "thread-revert", - messageId: "assistant:turn-2", - role: "assistant", - text: "second-response", - streaming: false, - createdAt: createdAtSecond, - }); - await engine.dispatch({ - type: "thread.turnDiff.complete", - commandId: "cmd-turn-2-complete", - threadId: "thread-revert", - turnId: "turn-2", - completedAt: createdAtSecond, - status: "completed", - files: [], - assistantMessageId: "assistant:turn-2", - checkpointTurnCount: 2, - createdAt: createdAtSecond, - }); - - await engine.dispatch({ - type: "thread.revert", - commandId: "cmd-thread-revert-apply", - threadId: "thread-revert", - turnCount: 1, - messageCount: 2, - createdAt: createdAtSecond, - }); - - const thread = engine.getSnapshot().threads.find((entry) => entry.id === "thread-revert"); - expect(thread?.messages.map((message) => message.id)).toEqual(["user-1", "assistant:turn-1"]); - expect(thread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); - expect(thread?.latestTurnId).toBe("turn-1"); - await firstSystem.dispose(); - - const secondSystem = await createOrchestrationSystem(stateDir); - const restarted = secondSystem.engine; - const restartedThread = restarted - .getSnapshot() - .threads.find((entry) => entry.id === "thread-revert"); - expect(restartedThread?.messages.map((message) => message.id)).toEqual([ - "user-1", - "assistant:turn-1", - ]); - expect(restartedThread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); - await secondSystem.dispose(); - }); - - it("allows stop to be called multiple times", async () => { - const inMemoryStore: OrchestrationEventRepositoryShape = { - append(event) { - return Effect.succeed({ ...event, sequence: 1 }); - }, - readFromSequence() { - return Effect.succeed([]); - }, - readAll() { - return Effect.succeed([]); - }, - }; - const engine = new OrchestrationEngine(inMemoryStore); - await engine.start(); - await engine.stop(); - await expect(engine.stop()).resolves.toBeUndefined(); - }); - - it("keeps processing queued commands after a storage failure", async () => { - const events: OrchestrationEvent[] = []; - let nextSequence = 1; - let shouldFailFirstAppend = true; - - const flakyStore: OrchestrationEventRepositoryShape = { - append(event) { - if (shouldFailFirstAppend) { - shouldFailFirstAppend = false; - return Effect.die(new Error("append failed")); - } - const savedEvent = { - ...event, - sequence: nextSequence, - } satisfies OrchestrationEvent; - nextSequence += 1; - events.push(savedEvent); - return Effect.succeed(savedEvent); - }, - readFromSequence(sequenceExclusive) { - return Effect.succeed(events.filter((event) => event.sequence > sequenceExclusive)); - }, - readAll() { - return Effect.succeed(events); - }, - }; - - const engine = new OrchestrationEngine(flakyStore); - const createdAt = new Date().toISOString(); - await engine.start(); - - await expect( - engine.dispatch({ - type: "thread.create", - commandId: "cmd-flaky-1", - threadId: "thread-flaky-fail", - projectId: "project-flaky", - title: "flaky-fail", - model: "gpt-5-codex", - branch: null, - worktreePath: null, - createdAt, - }), - ).rejects.toThrow("append failed"); - - const result = await engine.dispatch({ - type: "thread.create", - commandId: "cmd-flaky-2", - threadId: "thread-flaky-ok", - projectId: "project-flaky", - title: "flaky-ok", - model: "gpt-5-codex", - branch: null, - worktreePath: null, - createdAt, - }); - - expect(result.sequence).toBe(1); - expect(engine.getSnapshot().sequence).toBe(1); - - await engine.stop(); - }); -}); diff --git a/apps/server/src/orchestration/layers.ts b/apps/server/src/orchestration/layers.ts deleted file mode 100644 index 18670b49ff6f7..0000000000000 --- a/apps/server/src/orchestration/layers.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Effect, Layer } from "effect"; - -import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents"; -import { OrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents"; -import { OrchestrationEngine } from "./engine"; -import { OrchestrationEngineService } from "./services"; - -export const OrchestrationEngineLive = Layer.scoped( - OrchestrationEngineService, - Effect.acquireRelease( - Effect.gen(function* () { - const eventRepository = yield* OrchestrationEventRepository; - const engine = new OrchestrationEngine(eventRepository); - yield* Effect.promise(() => engine.start()); - return engine; - }), - (engine) => Effect.promise(() => engine.stop()), - ), -); - -export const OrchestrationLive = OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationEventRepositoryLive), -); diff --git a/apps/server/src/orchestration/services.ts b/apps/server/src/orchestration/services.ts deleted file mode 100644 index f6621a54f3233..0000000000000 --- a/apps/server/src/orchestration/services.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Context } from "effect"; - -import { OrchestrationEngine } from "./engine"; - -export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")< - OrchestrationEngineService, - OrchestrationEngine ->() {} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index d63b8677888da..42fb00d352692 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -36,13 +36,13 @@ import { import { TerminalManager } from "./terminalManager"; import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; -import type { OrchestrationEngine } from "./orchestration/engine"; -import { OrchestrationLive } from "./orchestration/layers"; -import { OrchestrationEngineService } from "./orchestration/services"; +import { OrchestrationEngineLive } from "./orchestration/Layer"; +import { OrchestrationEngineService, type OrchestrationEngineShape } from "./orchestration/Service"; import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects"; import assert from "node:assert"; +import { OrchestrationEventRepositoryLive } from "./persistence/Layers/OrchestrationEvents"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -136,7 +136,7 @@ export function createServer(options: ServerOptions) { unknown > | null = null; let projectRepository: ProjectRepositoryShape | null = null; - let orchestrationEngine: OrchestrationEngine | null = null; + let orchestrationEngine: OrchestrationEngineShape | null = null; const clients = new Set(); const logger = createLogger("ws"); const logWebSocketEvents = @@ -169,141 +169,157 @@ export function createServer(options: ServerOptions) { return effectRuntime; }; - function attachOrchestrationSubscriptions() { - const orchestrationEngine = getOrchestrationEngine(); - - unsubscribeReadModel = orchestrationEngine.subscribeToReadModel((snapshot) => { - const push: WsPush = { - type: "push", - channel: ORCHESTRATION_WS_CHANNELS.readModel, - data: { - sequence: snapshot.sequence, - snapshot, - } satisfies OrchestrationReadModelPush, - }; - const message = JSON.stringify(push); - let recipients = 0; - for (const client of clients) { - if (client.readyState === client.OPEN) { - client.send(message); - recipients += 1; + async function attachOrchestrationSubscriptions( + runtime: ManagedRuntime.ManagedRuntime, + orchestrationEngine: OrchestrationEngineShape, + ) { + unsubscribeReadModel = await runtime.runPromise( + orchestrationEngine.subscribeToReadModel((snapshot) => { + const push: WsPush = { + type: "push", + channel: ORCHESTRATION_WS_CHANNELS.readModel, + data: { + sequence: snapshot.sequence, + snapshot, + } satisfies OrchestrationReadModelPush, + }; + const message = JSON.stringify(push); + let recipients = 0; + for (const client of clients) { + if (client.readyState === client.OPEN) { + client.send(message); + recipients += 1; + } } - } - logOutgoingPush(push, recipients); - }); + logOutgoingPush(push, recipients); + }), + ); - unsubscribeDomainEvents = orchestrationEngine.subscribeToDomainEvents((event) => { - const push: WsPush = { - type: "push", - channel: ORCHESTRATION_WS_CHANNELS.domainEvent, - 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; + unsubscribeDomainEvents = await runtime.runPromise( + orchestrationEngine.subscribeToDomainEvents((event) => { + const push: WsPush = { + type: "push", + channel: ORCHESTRATION_WS_CHANNELS.domainEvent, + 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; + } } - } - logOutgoingPush(push, recipients); - }); + logOutgoingPush(push, recipients); + }), + ); } // Forward provider events to all connected WebSocket clients providerManager.on("event", (event) => { void (async () => { const liveOrchestrationEngine = orchestrationEngine; - if (!liveOrchestrationEngine) { + const runtime = effectRuntime; + if (!liveOrchestrationEngine || !runtime) { return; } - const snapshot = liveOrchestrationEngine.getSnapshot(); + const snapshot = await runtime.runPromise(liveOrchestrationEngine.getSnapshot()); const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId); if (!thread) return; const now = event.createdAt; if (event.method === "turn/started" || event.method === "turn/completed") { - await liveOrchestrationEngine.dispatch({ - type: "thread.session", - commandId: crypto.randomUUID(), - threadId: thread.id, - session: { - sessionId: event.sessionId, - provider: event.provider, - status: event.method === "turn/started" ? "running" : "ready", + await runtime.runPromise( + liveOrchestrationEngine.dispatch({ + type: "thread.session", + commandId: crypto.randomUUID(), threadId: thread.id, - activeTurnId: event.method === "turn/started" ? (event.turnId ?? null) : null, - createdAt: thread.session?.createdAt ?? now, - updatedAt: now, - lastError: null, - }, - createdAt: now, - }); + session: { + sessionId: event.sessionId, + provider: event.provider, + status: event.method === "turn/started" ? "running" : "ready", + threadId: thread.id, + activeTurnId: event.method === "turn/started" ? (event.turnId ?? null) : null, + createdAt: thread.session?.createdAt ?? now, + updatedAt: now, + lastError: null, + }, + createdAt: now, + }), + ); } if (event.textDelta && event.textDelta.length > 0) { const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; - await liveOrchestrationEngine.dispatch({ - type: "message.send", - commandId: crypto.randomUUID(), - threadId: thread.id, - messageId: assistantMessageId, - role: "assistant", - text: event.textDelta, - streaming: true, - createdAt: now, - }); + await runtime.runPromise( + liveOrchestrationEngine.dispatch({ + type: "message.send", + commandId: crypto.randomUUID(), + threadId: thread.id, + messageId: assistantMessageId, + role: "assistant", + text: event.textDelta, + streaming: true, + createdAt: now, + }), + ); } if (event.method === "turn/completed") { const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; - await liveOrchestrationEngine.dispatch({ - type: "message.send", - commandId: crypto.randomUUID(), - threadId: thread.id, - messageId: assistantMessageId, - role: "assistant", - text: "", - streaming: false, - createdAt: now, - }); + await runtime.runPromise( + liveOrchestrationEngine.dispatch({ + type: "message.send", + commandId: crypto.randomUUID(), + threadId: thread.id, + messageId: assistantMessageId, + role: "assistant", + text: "", + streaming: false, + createdAt: now, + }), + ); } if (event.method === "checkpoint/captured") { const payload = asObject(event.payload); const turnId = event.turnId ?? asString(payload?.turnId); if (turnId) { - await liveOrchestrationEngine.dispatch({ - type: "thread.turnDiff.complete", - commandId: crypto.randomUUID(), - threadId: thread.id, - turnId, - completedAt: now, - ...(asString(payload?.status) !== null - ? { status: asString(payload?.status) ?? undefined } - : {}), - files: [], - assistantMessageId: `assistant:${turnId}`, - ...(asNumber(payload?.turnCount) !== null - ? { checkpointTurnCount: asNumber(payload?.turnCount) ?? undefined } - : {}), - createdAt: now, - }); + await runtime.runPromise( + liveOrchestrationEngine.dispatch({ + type: "thread.turnDiff.complete", + commandId: crypto.randomUUID(), + threadId: thread.id, + turnId, + completedAt: now, + ...(asString(payload?.status) !== null + ? { status: asString(payload?.status) ?? undefined } + : {}), + files: [], + assistantMessageId: `assistant:${turnId}`, + ...(asNumber(payload?.turnCount) !== null + ? { checkpointTurnCount: asNumber(payload?.turnCount) ?? undefined } + : {}), + createdAt: now, + }), + ); } } if (event.kind === "error" && event.message) { - await liveOrchestrationEngine.dispatch({ - type: "thread.session", - commandId: crypto.randomUUID(), - threadId: thread.id, - session: { - sessionId: event.sessionId, - provider: event.provider, - status: "error", + await runtime.runPromise( + liveOrchestrationEngine.dispatch({ + type: "thread.session", + commandId: crypto.randomUUID(), threadId: thread.id, - activeTurnId: event.turnId ?? null, - createdAt: thread.session?.createdAt ?? now, - updatedAt: now, - lastError: event.message, - }, - createdAt: now, - }); + session: { + sessionId: event.sessionId, + provider: event.provider, + status: "error", + threadId: thread.id, + activeTurnId: event.turnId ?? null, + createdAt: thread.session?.createdAt ?? now, + updatedAt: now, + lastError: event.message, + }, + createdAt: now, + }), + ); } })().catch(() => undefined); }); @@ -410,6 +426,7 @@ export function createServer(options: ServerOptions) { wss.on("connection", (ws) => { clients.add(ws); const orchestrationEngine = getOrchestrationEngine(); + const runtime = getEffectRuntime(); // Send welcome message with project info const segments = cwd.split(/[/\\]/).filter(Boolean); @@ -423,16 +440,24 @@ export function createServer(options: ServerOptions) { logOutgoingPush(welcome, 1); ws.send(JSON.stringify(welcome)); - const snapshotPush: WsPush = { - type: "push", - channel: ORCHESTRATION_WS_CHANNELS.readModel, - data: { - sequence: orchestrationEngine.getSnapshot().sequence, - snapshot: orchestrationEngine.getSnapshot(), - } satisfies OrchestrationReadModelPush, - }; - logOutgoingPush(snapshotPush, 1); - ws.send(JSON.stringify(snapshotPush)); + void runtime + .runPromise(orchestrationEngine.getSnapshot()) + .then((snapshot) => { + if (ws.readyState !== ws.OPEN) { + return; + } + const snapshotPush: WsPush = { + type: "push", + channel: ORCHESTRATION_WS_CHANNELS.readModel, + data: { + sequence: snapshot.sequence, + snapshot, + } satisfies OrchestrationReadModelPush, + }; + logOutgoingPush(snapshotPush, 1); + ws.send(JSON.stringify(snapshotPush)); + }) + .catch(() => undefined); ws.on("message", (raw) => { void handleMessage(ws, raw); @@ -509,32 +534,36 @@ export function createServer(options: ServerOptions) { const params = request.params as { sessionId?: string }; const sessionId = params.sessionId; if (typeof sessionId === "string") { - const snapshot = orchestrationEngine.getSnapshot(); + const snapshot = await effectRuntime.runPromise(orchestrationEngine.getSnapshot()); const thread = snapshot.threads.find((entry) => entry.session?.sessionId === sessionId); if (thread) { const now = new Date().toISOString(); - await orchestrationEngine.dispatch({ - type: "thread.revert", - commandId: crypto.randomUUID(), - threadId: thread.id, - turnCount: result.turnCount, - messageCount: result.messageCount, - createdAt: now, - }); - if (thread.session) { - await orchestrationEngine.dispatch({ - type: "thread.session", + await effectRuntime.runPromise( + orchestrationEngine.dispatch({ + type: "thread.revert", commandId: crypto.randomUUID(), threadId: thread.id, - session: { - ...thread.session, - status: "ready", - activeTurnId: null, - updatedAt: now, - lastError: null, - }, + turnCount: result.turnCount, + messageCount: result.messageCount, createdAt: now, - }); + }), + ); + if (thread.session) { + await effectRuntime.runPromise( + orchestrationEngine.dispatch({ + type: "thread.session", + commandId: crypto.randomUUID(), + threadId: thread.id, + session: { + ...thread.session, + status: "ready", + activeTurnId: null, + updatedAt: now, + lastError: null, + }, + createdAt: now, + }), + ); } } } @@ -605,16 +634,18 @@ export function createServer(options: ServerOptions) { const projects = await effectRuntime.runPromise(projectRepository.list()); const project = projects.find((entry) => entry.cwd === params.cwd); if (project) { - await orchestrationEngine.dispatch({ - type: "git.readModel.upsert", - commandId: crypto.randomUUID(), - projectId: project.id, - branch: status.branch, - hasWorkingTreeChanges: status.hasWorkingTreeChanges, - aheadCount: status.aheadCount, - behindCount: status.behindCount, - createdAt: new Date().toISOString(), - }); + await effectRuntime.runPromise( + orchestrationEngine.dispatch({ + type: "git.readModel.upsert", + commandId: crypto.randomUUID(), + projectId: project.id, + branch: status.branch, + hasWorkingTreeChanges: status.hasWorkingTreeChanges, + aheadCount: status.aheadCount, + behindCount: status.behindCount, + createdAt: new Date().toISOString(), + }), + ); } return status; } @@ -677,19 +708,21 @@ export function createServer(options: ServerOptions) { }; case ORCHESTRATION_WS_METHODS.getSnapshot: - return orchestrationEngine.getSnapshot(); + return effectRuntime.runPromise(orchestrationEngine.getSnapshot()); case ORCHESTRATION_WS_METHODS.dispatchCommand: - return orchestrationEngine.dispatchUnknown(request.params); + return effectRuntime.runPromise(orchestrationEngine.dispatchUnknown(request.params)); case ORCHESTRATION_WS_METHODS.replayEvents: - return orchestrationEngine.replayEvents( - Math.max( - 0, - Math.floor( - Number( - (request.params as { fromSequenceExclusive?: number } | undefined) - ?.fromSequenceExclusive ?? 0, + return effectRuntime.runPromise( + orchestrationEngine.replayEvents( + Math.max( + 0, + Math.floor( + Number( + (request.params as { fromSequenceExclusive?: number } | undefined) + ?.fromSequenceExclusive ?? 0, + ), ), ), ), @@ -703,11 +736,14 @@ export function createServer(options: ServerOptions) { async function createEffectRuntime() { const dbPath = path.join(resolvedStateDir, "orchestration.sqlite"); const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtime = ManagedRuntime.make( - Layer.mergeAll(OrchestrationLive, ProjectRepositoryLive).pipe( - Layer.provide(persistenceLayer), - ), + const orchestrationLayer = Layer.provide( + OrchestrationEngineLive, + OrchestrationEventRepositoryLive, + ); + const layer = Layer.mergeAll(orchestrationLayer, ProjectRepositoryLive).pipe( + Layer.provide(persistenceLayer), ); + const runtime = ManagedRuntime.make(layer); try { const [nextOrchestrationEngine, repository] = await Promise.all([ @@ -717,7 +753,7 @@ export function createServer(options: ServerOptions) { orchestrationEngine = nextOrchestrationEngine; projectRepository = repository; await runtime.runPromise(repository.pruneMissing()); - attachOrchestrationSubscriptions(); + await attachOrchestrationSubscriptions(runtime, nextOrchestrationEngine); } catch (error) { await runtime.dispose().catch(() => undefined); throw error; From 2c8e35ceb8aebd0a0db03970193dbbdd66247df4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 01:43:53 -0800 Subject: [PATCH 12/98] more effect: error handling --- apps/server/src/orchestration/Errors.ts | 95 +++++++ apps/server/src/orchestration/Layer.test.ts | 8 +- apps/server/src/orchestration/Layer.ts | 234 +++++++++--------- apps/server/src/orchestration/Service.ts | 76 +++++- apps/server/src/orchestration/reducer.ts | 179 +++++++++----- apps/server/src/persistence/Errors.ts | 126 ++++++++++ .../Layers/OrchestrationEvents.test.ts | 5 +- .../persistence/Layers/OrchestrationEvents.ts | 82 ++++-- .../server/src/persistence/Layers/Projects.ts | 191 +++++++++++--- .../Services/OrchestrationEvents.ts | 47 +++- .../src/persistence/Services/Projects.ts | 63 ++++- 11 files changed, 846 insertions(+), 260 deletions(-) create mode 100644 apps/server/src/orchestration/Errors.ts create mode 100644 apps/server/src/persistence/Errors.ts diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts new file mode 100644 index 0000000000000..0f199e2baf45e --- /dev/null +++ b/apps/server/src/orchestration/Errors.ts @@ -0,0 +1,95 @@ +import { ParseResult, Schema } from "effect"; + +import type { OrchestrationEventRepositoryError } from "../persistence/Errors"; + +export class OrchestrationCommandJsonParseError extends Schema.TaggedError()( + "OrchestrationCommandJsonParseError", + { + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Invalid orchestration command JSON: ${this.detail}`; + } +} + +export class OrchestrationCommandDecodeError extends Schema.TaggedError()( + "OrchestrationCommandDecodeError", + { + issue: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Invalid orchestration command payload: ${this.issue}`; + } +} + +export class OrchestrationReducerDecodeError extends Schema.TaggedError()( + "OrchestrationReducerDecodeError", + { + eventType: Schema.String, + issue: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Reducer decode failed for ${this.eventType}: ${this.issue}`; + } +} + +export class OrchestrationListenerCallbackError extends Schema.TaggedError()( + "OrchestrationListenerCallbackError", + { + listener: Schema.Literal("read-model", "domain-event"), + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Orchestration ${this.listener} listener failed: ${this.detail}`; + } +} + +export type OrchestrationDispatchError = + | OrchestrationEventRepositoryError + | OrchestrationReducerDecodeError + | OrchestrationListenerCallbackError; + +export type OrchestrationEngineError = + | OrchestrationDispatchError + | OrchestrationCommandJsonParseError + | OrchestrationCommandDecodeError; + +export function toOrchestrationCommandDecodeError(error: ParseResult.ParseError) { + return new OrchestrationCommandDecodeError({ + issue: ParseResult.TreeFormatter.formatErrorSync(error), + cause: error, + }); +} + +export function toReducerDecodeError(eventType: string) { + return (error: ParseResult.ParseError): OrchestrationReducerDecodeError => + new OrchestrationReducerDecodeError({ + eventType, + issue: ParseResult.TreeFormatter.formatErrorSync(error), + cause: error, + }); +} + +export function toOrchestrationJsonParseError(cause: unknown) { + return new OrchestrationCommandJsonParseError({ + detail: `Failed to parse orchestration command JSON: ${cause}`, + cause, + }); +} + +export function toListenerCallbackError(listener: "read-model" | "domain-event") { + return (cause: unknown): OrchestrationListenerCallbackError => + new OrchestrationListenerCallbackError({ + listener, + detail: `Failed to invoke orchestration ${listener} listener: ${cause}`, + cause, + }); +} diff --git a/apps/server/src/orchestration/Layer.test.ts b/apps/server/src/orchestration/Layer.test.ts index 87afdcea4c38d..76a028567a8ba 100644 --- a/apps/server/src/orchestration/Layer.test.ts +++ b/apps/server/src/orchestration/Layer.test.ts @@ -10,6 +10,7 @@ import { OrchestrationEventRepository, type OrchestrationEventRepositoryShape, } from "../persistence/Services/OrchestrationEvents"; +import { PersistenceSqlError } from "../persistence/Errors"; import { Layer, ManagedRuntime } from "effect"; @@ -375,7 +376,12 @@ describe("OrchestrationEngine", () => { append(event) { if (shouldFailFirstAppend) { shouldFailFirstAppend = false; - return Effect.die(new Error("append failed")); + return Effect.fail( + new PersistenceSqlError({ + operation: "test.append", + detail: "append failed", + }), + ); } const savedEvent = { ...event, diff --git a/apps/server/src/orchestration/Layer.ts b/apps/server/src/orchestration/Layer.ts index 9974132fee21b..3c9590501c7eb 100644 --- a/apps/server/src/orchestration/Layer.ts +++ b/apps/server/src/orchestration/Layer.ts @@ -4,33 +4,24 @@ import type { OrchestrationReadModel, } from "@t3tools/contracts"; import { OrchestrationCommandSchema } from "@t3tools/contracts"; -import { - Cause, - Deferred, - Effect, - Either, - Fiber, - Layer, - PubSub, - Queue, - Runtime, - Schema, -} from "effect"; +import { Deferred, Effect, Layer, PubSub, Queue, Schema } from "effect"; import { createLogger } from "../logger"; import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents"; +import { + toListenerCallbackError, + toOrchestrationCommandDecodeError, + toOrchestrationJsonParseError, + type OrchestrationDispatchError, +} from "./Errors"; import { createEmptyReadModel, reduceEvent } from "./reducer"; import { OrchestrationEngineService, type OrchestrationEngineShape } from "./Service"; type CommandEnvelope = { command: OrchestrationCommand; - result: Deferred.Deferred<{ sequence: number }, Error>; + result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; }; -function asError(error: unknown, fallbackMessage: string): Error { - return error instanceof Error ? error : new Error(fallbackMessage); -} - function mapCommandToEvent(command: OrchestrationCommand): Omit { const eventId = crypto.randomUUID(); switch (command.type) { @@ -169,129 +160,134 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit + Effect.gen(function* () { + const logger = createLogger("orchestration"); + const eventStore = yield* OrchestrationEventRepository; - let readModel = createEmptyReadModel(new Date().toISOString()); + let readModel = createEmptyReadModel(new Date().toISOString()); - const commandQueue = yield* Queue.unbounded(); - const readModelPubSub = yield* PubSub.unbounded(); - const eventPubSub = yield* PubSub.unbounded(); + const commandQueue = yield* Queue.unbounded(); + const readModelPubSub = yield* PubSub.unbounded(); + const eventPubSub = yield* PubSub.unbounded(); - const readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); - const domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); + const readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); + const domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); - const processEnvelope = (envelope: CommandEnvelope): Effect.Effect => - Effect.gen(function* () { - const eventBase = mapCommandToEvent(envelope.command); - const savedEvent = yield* eventStore.append(eventBase); - readModel = yield* reduceEvent(readModel, savedEvent); + const notifyReadModelListeners = (snapshot: OrchestrationReadModel) => + Effect.forEach(readModelListeners, (listener) => + Effect.try({ + try: () => listener(snapshot), + catch: toListenerCallbackError("read-model"), + }), + ).pipe(Effect.asVoid); - const snapshot = readModel; - yield* Effect.all([ - PubSub.publish(eventPubSub, savedEvent), - PubSub.publish(readModelPubSub, snapshot), - ]); + const notifyDomainEventListeners = (event: OrchestrationEvent) => + Effect.forEach(domainEventListeners, (listener) => + Effect.try({ + try: () => listener(event), + catch: toListenerCallbackError("domain-event"), + }), + ).pipe(Effect.asVoid); - for (const listener of domainEventListeners) { - listener(savedEvent); - } - for (const listener of readModelListeners) { - listener(snapshot); - } + const processEnvelope = (envelope: CommandEnvelope): Effect.Effect => + Effect.gen(function* () { + const eventBase = mapCommandToEvent(envelope.command); + const savedEvent = yield* eventStore.append(eventBase); + readModel = yield* reduceEvent(readModel, savedEvent); - yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); - }).pipe( - Effect.catchAllCause((cause) => - Deferred.fail( - envelope.result, - asError(Cause.squash(cause), "Unknown command processing error"), - ).pipe(Effect.asVoid), - ), - ); + const snapshot = readModel; + yield* Effect.all([ + PubSub.publish(eventPubSub, savedEvent), + PubSub.publish(readModelPubSub, snapshot), + ]); - const bootstrapReadModel: Effect.Effect = Effect.gen(function* () { - const existingEvents = yield* eventStore.readAll(); - for (const event of existingEvents) { - readModel = yield* reduceEvent(readModel, event); - } - }); + yield* notifyDomainEventListeners(savedEvent); + yield* notifyReadModelListeners(snapshot); - yield* bootstrapReadModel; + yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); + }).pipe( + Effect.catchAll((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid)), + ); - const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); - const workerFiber: Fiber.RuntimeFiber = Runtime.runFork(runtime)(worker); - logger.info("orchestration engine started", { - sequence: readModel.sequence, - }); + const bootstrapReadModel: Effect.Effect = Effect.gen( + function* () { + const existingEvents = yield* eventStore.readAll(); + for (const event of existingEvents) { + readModel = yield* reduceEvent(readModel, event); + } + }, + ); + + yield* bootstrapReadModel; - yield* Effect.addFinalizer(() => Fiber.interrupt(workerFiber).pipe(Effect.orDie)); + const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); + yield* Effect.forkScoped(worker); + logger.info("orchestration engine started", { + sequence: readModel.sequence, + }); - const getSnapshot: OrchestrationEngineShape["getSnapshot"] = () => - Effect.sync((): OrchestrationReadModel => readModel); + const getSnapshot: OrchestrationEngineShape["getSnapshot"] = () => + Effect.sync((): OrchestrationReadModel => readModel); - const replayEvents: OrchestrationEngineShape["replayEvents"] = (fromSequenceExclusive) => - eventStore.readFromSequence(fromSequenceExclusive); + const replayEvents: OrchestrationEngineShape["replayEvents"] = (fromSequenceExclusive) => + eventStore.readFromSequence(fromSequenceExclusive); - const dispatch: OrchestrationEngineShape["dispatch"] = (command) => - Effect.gen(function* () { - const result = yield* Deferred.make<{ sequence: number }, Error>(); - yield* Queue.offer(commandQueue, { command, result }); - return yield* Deferred.await(result); - }).pipe(Effect.mapError((error) => asError(error, "Queue offer failed"))); + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => + Effect.gen(function* () { + const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); + yield* Queue.offer(commandQueue, { command, result }); + return yield* Deferred.await(result); + }); - const dispatchUnknown: OrchestrationEngineShape["dispatchUnknown"] = (command) => - Effect.gen(function* () { - let payload: unknown = command; - if (typeof command === "string") { - try { - payload = JSON.parse(command) as unknown; - } catch { - return yield* Effect.fail( - new Error("Invalid orchestration command: payload is not valid JSON"), - ); - } - } - const decoded = decodeUnknownCommand(payload); - if (Either.isLeft(decoded)) { - return yield* Effect.fail( - new Error(`Invalid orchestration command: ${decoded.left.toString()}`), + const dispatchUnknown: OrchestrationEngineShape["dispatchUnknown"] = (command) => + Effect.gen(function* () { + const payload = + typeof command === "string" + ? yield* Effect.try({ + try: () => JSON.parse(command) as unknown, + catch: toOrchestrationJsonParseError, + }) + : command; + + const decoded = yield* decodeUnknownCommand(payload).pipe( + Effect.mapError(toOrchestrationCommandDecodeError), ); - } - return yield* dispatch(decoded.right); - }); - const subscribeToReadModel: OrchestrationEngineShape["subscribeToReadModel"] = (callback) => - Effect.sync(() => { - readModelListeners.add(callback); - return () => { - readModelListeners.delete(callback); - }; - }); + return yield* dispatch(decoded); + }); - const subscribeToDomainEvents: OrchestrationEngineShape["subscribeToDomainEvents"] = (callback) => - Effect.sync(() => { - domainEventListeners.add(callback); - return () => { - domainEventListeners.delete(callback); - }; - }); + const subscribeToReadModel: OrchestrationEngineShape["subscribeToReadModel"] = (callback) => + Effect.sync(() => { + readModelListeners.add(callback); + return () => { + readModelListeners.delete(callback); + }; + }); - return { - getSnapshot, - replayEvents, - dispatchUnknown, - dispatch, - subscribeToReadModel, - subscribeToDomainEvents, - } satisfies OrchestrationEngineShape; -}); + const subscribeToDomainEvents: OrchestrationEngineShape["subscribeToDomainEvents"] = ( + callback, + ) => + Effect.sync(() => { + domainEventListeners.add(callback); + return () => { + domainEventListeners.delete(callback); + }; + }); + + return { + getSnapshot, + replayEvents, + dispatchUnknown, + dispatch, + subscribeToReadModel, + subscribeToDomainEvents, + } satisfies OrchestrationEngineShape; + }); export const OrchestrationEngineLive = Layer.scoped( OrchestrationEngineService, - makeOrchestrationEngine, + makeOrchestrationEngine(), ); diff --git a/apps/server/src/orchestration/Service.ts b/apps/server/src/orchestration/Service.ts index 9959f897a4cfa..57eee9dabff91 100644 --- a/apps/server/src/orchestration/Service.ts +++ b/apps/server/src/orchestration/Service.ts @@ -1,3 +1,11 @@ +/** + * OrchestrationEngineService - Service interface for orchestration command handling. + * + * Uses Effect `Context.Tag` for dependency injection. Command dispatch, + * replay, and unknown-input decoding all return typed domain errors. + * + * @module OrchestrationEngineService + */ import type { OrchestrationCommand, OrchestrationEvent, @@ -6,19 +14,81 @@ import type { import { Context } from "effect"; import type { Effect } from "effect"; +import type { OrchestrationDispatchError, OrchestrationEngineError } from "./Errors"; +import type { OrchestrationEventRepositoryError } from "../persistence/Errors"; + export interface OrchestrationEngineShape { + /** + * Read the current in-memory orchestration snapshot. + * + * @returns Effect containing the latest read model. + */ readonly getSnapshot: () => Effect.Effect; - readonly replayEvents: (fromSequenceExclusive: number) => Effect.Effect; - readonly dispatchUnknown: (command: unknown) => Effect.Effect<{ sequence: number }, Error>; - readonly dispatch: (command: OrchestrationCommand) => Effect.Effect<{ sequence: number }, Error>; + + /** + * Replay persisted orchestration events from an exclusive sequence cursor. + * + * @param fromSequenceExclusive - Sequence cursor (exclusive). + * @returns Effect containing ordered events. + */ + readonly replayEvents: ( + fromSequenceExclusive: number, + ) => Effect.Effect; + + /** + * Validate and dispatch an unknown command payload. + * + * Accepts either parsed values or raw JSON strings. + * + * @param command - Unknown command payload. + * @returns Effect containing the sequence of the persisted event. + */ + readonly dispatchUnknown: ( + command: unknown, + ) => Effect.Effect<{ sequence: number }, OrchestrationEngineError>; + + /** + * Dispatch a validated orchestration command. + * + * @param command - Valid orchestration command. + * @returns Effect containing the sequence of the persisted event. + */ + readonly dispatch: ( + command: OrchestrationCommand, + ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError>; + + /** + * Subscribe to read model updates. + * + * @param callback - Handler invoked on every read model update. + * @returns Effect containing an unsubscribe function. + */ readonly subscribeToReadModel: ( callback: (snapshot: OrchestrationReadModel) => void, ) => Effect.Effect<() => void>; + + /** + * Subscribe to domain event fan-out. + * + * @param callback - Handler invoked for every persisted domain event. + * @returns Effect containing an unsubscribe function. + */ readonly subscribeToDomainEvents: ( callback: (event: OrchestrationEvent) => void, ) => Effect.Effect<() => void>; } +/** + * OrchestrationEngineService - Context tag for orchestration engine access. + * + * @example + * ```ts + * const program = Effect.gen(function* () { + * const engine = yield* OrchestrationEngineService + * return yield* engine.getSnapshot() + * }) + * ``` + */ export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")< OrchestrationEngineService, OrchestrationEngineShape diff --git a/apps/server/src/orchestration/reducer.ts b/apps/server/src/orchestration/reducer.ts index 1b05441b1f5e7..01f9e8c09755b 100644 --- a/apps/server/src/orchestration/reducer.ts +++ b/apps/server/src/orchestration/reducer.ts @@ -9,7 +9,6 @@ import type { import { OrchestrationGitReadModelSchema, OrchestrationMessageSchema, - OrchestrationReadModelSchema, OrchestrationSessionSchema, OrchestrationThreadSchema, OrchestrationTurnDiffFileSchema, @@ -17,6 +16,8 @@ import { } from "@t3tools/contracts"; import { Effect, Schema } from "effect"; +import { toReducerDecodeError, type OrchestrationReducerDecodeError } from "./Errors"; + type ThreadPatch = Partial>; function updateThread( @@ -27,12 +28,16 @@ function updateThread( return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread)); } -const decodeReadModelSync = Schema.decodeUnknownSync(OrchestrationReadModelSchema); -const decodeMessage = Schema.decodeUnknown(OrchestrationMessageSchema); -const decodeSession = Schema.decodeUnknown(OrchestrationSessionSchema); -const decodeThread = Schema.decodeUnknown(OrchestrationThreadSchema); -const decodeGitReadModel = Schema.decodeUnknown(OrchestrationGitReadModelSchema); -const decodeTurnDiffSummary = Schema.decodeUnknown(OrchestrationTurnDiffSummarySchema); +function decodeForEvent( + schema: Schema.Schema, + value: unknown, + eventType: string, + field: string, +): Effect.Effect { + return Schema.decodeUnknown(schema)(value).pipe( + Effect.mapError(toReducerDecodeError(`${eventType}:${field}`)), + ); +} const ThreadCreatedPayloadSchema = Schema.Struct({ id: Schema.String, @@ -44,7 +49,6 @@ const ThreadCreatedPayloadSchema = Schema.Struct({ createdAt: Schema.String, updatedAt: Schema.String, }); -const decodeThreadCreatedPayload = Schema.decodeUnknown(ThreadCreatedPayloadSchema); const ThreadMetaUpdatedPayloadSchema = Schema.Struct({ threadId: Schema.String, @@ -54,7 +58,6 @@ const ThreadMetaUpdatedPayloadSchema = Schema.Struct({ worktreePath: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.String, }); -const decodeThreadMetaUpdatedPayload = Schema.decodeUnknown(ThreadMetaUpdatedPayloadSchema); const MessageSentPayloadSchema = Schema.Struct({ id: Schema.String, @@ -64,13 +67,11 @@ const MessageSentPayloadSchema = Schema.Struct({ createdAt: Schema.String, streaming: Schema.Boolean, }); -const decodeMessageSentPayload = Schema.decodeUnknown(MessageSentPayloadSchema); const ThreadSessionSetPayloadSchema = Schema.Struct({ threadId: Schema.String, session: OrchestrationSessionSchema, }); -const decodeThreadSessionSetPayload = Schema.decodeUnknown(ThreadSessionSetPayloadSchema); const ThreadTurnDiffCompletedPayloadSchema = Schema.Struct({ threadId: Schema.String, @@ -81,30 +82,26 @@ const ThreadTurnDiffCompletedPayloadSchema = Schema.Struct({ assistantMessageId: Schema.optional(Schema.String), checkpointTurnCount: Schema.optional(Schema.Number), }); -const decodeThreadTurnDiffCompletedPayload = Schema.decodeUnknown( - ThreadTurnDiffCompletedPayloadSchema, -); const ThreadRevertedPayloadSchema = Schema.Struct({ threadId: Schema.String, turnCount: Schema.Number, messageCount: Schema.Number, }); -const decodeThreadRevertedPayload = Schema.decodeUnknown(ThreadRevertedPayloadSchema); export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { - return decodeReadModelSync({ + return { sequence: 0, threads: [], gitByProjectId: {}, updatedAt: nowIso, - }); + }; } export function reduceEvent( model: OrchestrationReadModel, event: OrchestrationEvent, -): Effect.Effect { +): Effect.Effect { const nextBase: OrchestrationReadModel = { ...model, sequence: event.sequence, @@ -114,25 +111,35 @@ export function reduceEvent( switch (event.type) { case "thread.created": return Effect.gen(function* () { - const payload = yield* decodeThreadCreatedPayload(event.payload); - const thread: OrchestrationThread = yield* decodeThread({ - id: payload.id, - projectId: payload.projectId, - title: payload.title, - model: payload.model, - branch: payload.branch, - worktreePath: payload.worktreePath, - createdAt: payload.createdAt, - updatedAt: payload.updatedAt, - latestTurnId: null, - latestTurnStartedAt: null, - latestTurnCompletedAt: null, - latestTurnDurationMs: null, - messages: [], - session: null, - turnDiffSummaries: [], - error: null, - }); + const payload = yield* decodeForEvent( + ThreadCreatedPayloadSchema, + event.payload, + event.type, + "payload", + ); + const thread: OrchestrationThread = yield* decodeForEvent( + OrchestrationThreadSchema, + { + id: payload.id, + projectId: payload.projectId, + title: payload.title, + model: payload.model, + branch: payload.branch, + worktreePath: payload.worktreePath, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, + latestTurnId: null, + latestTurnStartedAt: null, + latestTurnCompletedAt: null, + latestTurnDurationMs: null, + messages: [], + session: null, + turnDiffSummaries: [], + error: null, + }, + event.type, + "thread", + ); const existing = nextBase.threads.find((entry) => entry.id === thread.id); return { ...nextBase, @@ -147,7 +154,12 @@ export function reduceEvent( threads: nextBase.threads.filter((thread) => thread.id !== event.aggregateId), }); case "thread.meta-updated": - return decodeThreadMetaUpdatedPayload(event.payload).pipe( + return decodeForEvent( + ThreadMetaUpdatedPayloadSchema, + event.payload, + event.type, + "payload", + ).pipe( Effect.map((payload) => { const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); if (!thread) { @@ -167,19 +179,29 @@ export function reduceEvent( ); case "message.sent": return Effect.gen(function* () { - const payload = yield* decodeMessageSentPayload(event.payload); + const payload = yield* decodeForEvent( + MessageSentPayloadSchema, + event.payload, + event.type, + "payload", + ); const targetThread = nextBase.threads.find((thread) => thread.id === payload.threadId); if (!targetThread) { return nextBase; } - const message: OrchestrationMessage = yield* decodeMessage({ - id: payload.id, - role: payload.role, - text: payload.text, - createdAt: payload.createdAt, - streaming: payload.streaming, - }); + const message: OrchestrationMessage = yield* decodeForEvent( + OrchestrationMessageSchema, + { + id: payload.id, + role: payload.role, + text: payload.text, + createdAt: payload.createdAt, + streaming: payload.streaming, + }, + event.type, + "message", + ); const existingMessage = targetThread.messages.find((entry) => entry.id === message.id); const nextMessages = existingMessage ? targetThread.messages.map((entry) => @@ -208,16 +230,26 @@ export function reduceEvent( }); case "thread.session-set": return Effect.gen(function* () { - const payload = yield* decodeThreadSessionSetPayload(event.payload); + const payload = yield* decodeForEvent( + ThreadSessionSetPayloadSchema, + event.payload, + event.type, + "payload", + ); const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); if (!thread) { return nextBase; } - const session: OrchestrationSession = yield* decodeSession({ - ...payload.session, - threadId: payload.threadId, - }); + const session: OrchestrationSession = yield* decodeForEvent( + OrchestrationSessionSchema, + { + ...payload.session, + threadId: payload.threadId, + }, + event.type, + "session", + ); return { ...nextBase, @@ -230,24 +262,34 @@ export function reduceEvent( }); case "thread.turn-diff-completed": return Effect.gen(function* () { - const payload = yield* decodeThreadTurnDiffCompletedPayload(event.payload); + const payload = yield* decodeForEvent( + ThreadTurnDiffCompletedPayloadSchema, + event.payload, + event.type, + "payload", + ); const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); if (!thread) { return nextBase; } - const nextSummary = yield* decodeTurnDiffSummary({ - turnId: payload.turnId, - completedAt: payload.completedAt, - ...(payload.status !== undefined ? { status: payload.status } : {}), - files: payload.files, - ...(payload.assistantMessageId !== undefined - ? { assistantMessageId: payload.assistantMessageId } - : {}), - ...(payload.checkpointTurnCount !== undefined - ? { checkpointTurnCount: payload.checkpointTurnCount } - : {}), - }); + const nextSummary = yield* decodeForEvent( + OrchestrationTurnDiffSummarySchema, + { + turnId: payload.turnId, + completedAt: payload.completedAt, + ...(payload.status !== undefined ? { status: payload.status } : {}), + files: payload.files, + ...(payload.assistantMessageId !== undefined + ? { assistantMessageId: payload.assistantMessageId } + : {}), + ...(payload.checkpointTurnCount !== undefined + ? { checkpointTurnCount: payload.checkpointTurnCount } + : {}), + }, + event.type, + "turnDiffSummary", + ); const turnDiffSummaries = [ ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId), nextSummary, @@ -264,7 +306,7 @@ export function reduceEvent( }; }); case "thread.reverted": - return decodeThreadRevertedPayload(event.payload).pipe( + return decodeForEvent(ThreadRevertedPayloadSchema, event.payload, event.type, "payload").pipe( Effect.map((payload) => { const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); if (!thread) { @@ -311,7 +353,12 @@ export function reduceEvent( }), ); case "git.read-model-upsert": - return decodeGitReadModel(event.payload).pipe( + return decodeForEvent( + OrchestrationGitReadModelSchema, + event.payload, + event.type, + "payload", + ).pipe( Effect.map((gitReadModel: OrchestrationGitReadModel) => ({ ...nextBase, gitByProjectId: { diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts new file mode 100644 index 0000000000000..940357abe34a1 --- /dev/null +++ b/apps/server/src/persistence/Errors.ts @@ -0,0 +1,126 @@ +import { ParseResult, Schema } from "effect"; + +export class PersistenceSqlError extends Schema.TaggedError()( + "PersistenceSqlError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `SQL error in ${this.operation}: ${this.detail}`; + } +} + +export class PersistenceDecodeError extends Schema.TaggedError()( + "PersistenceDecodeError", + { + operation: Schema.String, + issue: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Decode error in ${this.operation}: ${this.issue}`; + } +} + +export class PersistenceSerializationError extends Schema.TaggedError()( + "PersistenceSerializationError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Serialization error in ${this.operation}: ${this.detail}`; + } +} + +export class ProjectValidationError extends Schema.TaggedError()( + "ProjectValidationError", + { + operation: Schema.String, + issue: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Project validation failed in ${this.operation}: ${this.issue}`; + } +} + +export class ProjectPathMissingError extends Schema.TaggedError()( + "ProjectPathMissingError", + { + cwd: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Project path does not exist: ${this.cwd}`; + } +} + +export class ProjectNotFoundError extends Schema.TaggedError()( + "ProjectNotFoundError", + { + projectId: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Project not found: ${this.projectId}`; + } +} + +export type ProjectRepositoryError = + | PersistenceSqlError + | PersistenceDecodeError + | PersistenceSerializationError + | ProjectValidationError + | ProjectPathMissingError + | ProjectNotFoundError; + +export type OrchestrationEventRepositoryError = + | PersistenceSqlError + | PersistenceDecodeError + | PersistenceSerializationError; + +export function toPersistenceSqlError(operation: string) { + return (cause: unknown): PersistenceSqlError => + new PersistenceSqlError({ + operation, + detail: `Failed to execute ${operation}`, + cause, + }); +} + +export function toPersistenceDecodeError(operation: string) { + return (error: ParseResult.ParseError): PersistenceDecodeError => + new PersistenceDecodeError({ + operation, + issue: ParseResult.TreeFormatter.formatErrorSync(error), + cause: error, + }); +} + +export function toPersistenceDecodeCauseError(operation: string) { + return (cause: unknown): PersistenceDecodeError => + new PersistenceDecodeError({ + operation, + issue: `Failed to execute ${operation}`, + cause, + }); +} + +export function toPersistenceSerializationError(operation: string) { + return (cause: unknown): PersistenceSerializationError => + new PersistenceSerializationError({ + operation, + detail: `Failed to execute ${operation}`, + cause, + }); +} diff --git a/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts index b9768172b286a..e9ca28dff0426 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts @@ -80,9 +80,8 @@ describe("OrchestrationEventRepository", () => { ) .get() as { name: string } | undefined; expect(tableRow?.name).toBeDefined(); - if (!tableRow) { - throw new Error("Expected migrations table to exist"); - } + expect(tableRow).toBeDefined(); + if (!tableRow) return; const migrationRows = db .prepare( diff --git a/apps/server/src/persistence/Layers/OrchestrationEvents.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.ts index 29f2565826973..d3a6841105907 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEvents.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.ts @@ -4,13 +4,20 @@ import * as SqlClient from "@effect/sql/SqlClient"; import * as SqlSchema from "@effect/sql/SqlSchema"; import { Effect, Layer, Schema } from "effect"; +import { + toPersistenceDecodeCauseError, + toPersistenceDecodeError, + toPersistenceSerializationError, + toPersistenceSqlError, + type OrchestrationEventRepositoryError, +} from "../Errors"; import { makeSqlitePersistenceLive } from "./Sqlite"; import { OrchestrationEventRepository, type OrchestrationEventRepositoryShape, } from "../Services/OrchestrationEvents"; -const decodeEvent = Schema.decodeUnknownSync(OrchestrationEventSchema); +const decodeEvent = Schema.decodeUnknown(OrchestrationEventSchema); const EventRowSchema = Schema.Struct({ sequence: Schema.Number, @@ -40,17 +47,27 @@ const ReadFromSequenceRequestSchema = Schema.Struct({ limit: Schema.Number, }); -function eventRowToOrchestrationEvent(row: EventRow): OrchestrationEvent { - return decodeEvent({ - sequence: row.sequence, - eventId: row.eventId, - type: row.type, - aggregateType: row.aggregateType, - aggregateId: row.aggregateId, - occurredAt: row.occurredAt, - commandId: row.commandId, - payload: JSON.parse(row.payloadJson) as unknown, - }); +function eventRowToOrchestrationEvent( + row: EventRow, + operation: string, +): Effect.Effect { + return Effect.try({ + try: () => JSON.parse(row.payloadJson) as unknown, + catch: toPersistenceDecodeCauseError(`${operation}:parsePayloadJson`), + }).pipe( + Effect.flatMap((payload) => + decodeEvent({ + sequence: row.sequence, + eventId: row.eventId, + type: row.type, + aggregateType: row.aggregateType, + aggregateId: row.aggregateId, + occurredAt: row.occurredAt, + commandId: row.commandId, + payload, + }).pipe(Effect.mapError(toPersistenceDecodeError(`${operation}:decodeEvent`))), + ), + ); } const makeRepository = Effect.gen(function* () { @@ -113,23 +130,42 @@ const makeRepository = Effect.gen(function* () { }); const append: OrchestrationEventRepositoryShape["append"] = (event) => - appendEventRow({ - eventId: event.eventId, - type: event.type, - aggregateType: event.aggregateType, - aggregateId: event.aggregateId, - occurredAt: event.occurredAt, - commandId: event.commandId, - payloadJson: JSON.stringify(event.payload), - }).pipe(Effect.map(eventRowToOrchestrationEvent), Effect.orDie); + Effect.try({ + try: () => JSON.stringify(event.payload), + catch: toPersistenceSerializationError("OrchestrationEventRepository.append:encodePayload"), + }).pipe( + Effect.flatMap((payloadJson) => + appendEventRow({ + eventId: event.eventId, + type: event.type, + aggregateType: event.aggregateType, + aggregateId: event.aggregateId, + occurredAt: event.occurredAt, + commandId: event.commandId, + payloadJson, + }).pipe( + Effect.mapError(toPersistenceSqlError("OrchestrationEventRepository.append:insert")), + Effect.flatMap((row) => + eventRowToOrchestrationEvent(row, "OrchestrationEventRepository.append:rowToEvent"), + ), + ), + ), + ); const readFromSequence: OrchestrationEventRepositoryShape["readFromSequence"] = ( sequenceExclusive, limit = 1_000, ) => readEventRowsFromSequence({ sequenceExclusive, limit }).pipe( - Effect.map((rows) => rows.map(eventRowToOrchestrationEvent)), - Effect.orDie, + Effect.mapError(toPersistenceSqlError("OrchestrationEventRepository.readFromSequence:query")), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + eventRowToOrchestrationEvent( + row, + "OrchestrationEventRepository.readFromSequence:rowToEvent", + ), + ), + ), ); return { diff --git a/apps/server/src/persistence/Layers/Projects.ts b/apps/server/src/persistence/Layers/Projects.ts index dc50541bc0cb1..1504545def066 100644 --- a/apps/server/src/persistence/Layers/Projects.ts +++ b/apps/server/src/persistence/Layers/Projects.ts @@ -8,6 +8,7 @@ import type { ProjectListResult, ProjectRecord, ProjectScript, + ProjectUpdateScriptsInput, ProjectUpdateScriptsResult, } from "@t3tools/contracts"; import { @@ -22,6 +23,16 @@ import * as SqlClient from "@effect/sql/SqlClient"; import * as SqlSchema from "@effect/sql/SqlSchema"; import { Effect, Layer, Option, Schema } from "effect"; +import { + PersistenceDecodeError, + ProjectNotFoundError, + ProjectPathMissingError, + ProjectValidationError, + toPersistenceDecodeCauseError, + toPersistenceSerializationError, + toPersistenceSqlError, + type ProjectRepositoryError, +} from "../Errors"; import { ProjectRepository, type ProjectRepositoryShape } from "../Services/Projects"; const ProjectRowSchema = Schema.Struct({ @@ -98,25 +109,101 @@ function inferProjectName(cwd: string): string { return name.length > 0 ? name : "project"; } -function parseScriptsJson(raw: string): ProjectScript[] { - try { - const parsed = JSON.parse(raw) as unknown; - const scripts = projectScriptsSchema.parse(parsed); - return normalizeProjectScripts(scripts); - } catch { - return []; +function parseProjectScripts( + value: unknown, + operation: string, +): Effect.Effect { + const parsed = projectScriptsSchema.safeParse(value); + if (!parsed.success) { + return Effect.fail( + new PersistenceDecodeError({ + operation, + issue: parsed.error.message, + cause: (parsed.error as { cause?: unknown }).cause, + }), + ); } + return Effect.succeed(normalizeProjectScripts(parsed.data)); } -function rowToProjectRecord(row: ProjectRow): ProjectRecord { - return projectRecordSchema.parse({ - id: row.id, - cwd: row.cwd, - name: row.name, - scripts: parseScriptsJson(row.scriptsJson), - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }); +function decodeScriptsJson( + raw: string, + operation: string, +): Effect.Effect { + return Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: toPersistenceDecodeCauseError(`${operation}:parseScriptsJson`), + }).pipe(Effect.flatMap((value) => parseProjectScripts(value, `${operation}:decodeScripts`))); +} + +function rowToProjectRecord( + row: ProjectRow, + operation: string, +): Effect.Effect { + return decodeScriptsJson(row.scriptsJson, `${operation}:scripts`).pipe( + Effect.flatMap((scripts) => { + const parsed = projectRecordSchema.safeParse({ + id: row.id, + cwd: row.cwd, + name: row.name, + scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + if (!parsed.success) { + return Effect.fail( + new PersistenceDecodeError({ + operation: `${operation}:projectRecord`, + issue: parsed.error.message, + cause: (parsed.error as { cause?: unknown }).cause, + }), + ); + } + return Effect.succeed(parsed.data); + }), + ); +} + +function decodeAddInput(rawInput: ProjectAddInput) { + const parsed = projectAddInputSchema.safeParse(rawInput); + if (!parsed.success) { + return Effect.fail( + new ProjectValidationError({ + operation: "ProjectRepository.add", + issue: parsed.error.message, + cause: (parsed.error as { cause?: unknown }).cause, + }), + ); + } + return Effect.succeed(parsed.data); +} + +function decodeUpdateScriptsInput(rawInput: ProjectUpdateScriptsInput) { + const parsed = projectUpdateScriptsInputSchema.safeParse(rawInput); + if (!parsed.success) { + return Effect.fail( + new ProjectValidationError({ + operation: "ProjectRepository.updateScripts", + issue: parsed.error.message, + cause: (parsed.error as { cause?: unknown }).cause, + }), + ); + } + return Effect.succeed(parsed.data); +} + +function decodeRemoveInput(rawInput: unknown) { + const parsed = projectRemoveInputSchema.safeParse(rawInput); + if (!parsed.success) { + return Effect.fail( + new ProjectValidationError({ + operation: "ProjectRepository.remove", + issue: parsed.error.message, + cause: (parsed.error as { cause?: unknown }).cause, + }), + ); + } + return Effect.succeed(parsed.data); } const makeRepository = Effect.gen(function* () { @@ -231,24 +318,31 @@ const makeRepository = Effect.gen(function* () { const list: ProjectRepositoryShape["list"] = () => listProjectRows(undefined).pipe( - Effect.map((rows): ProjectListResult => rows.map(rowToProjectRecord)), - Effect.map((projects) => - projects.map((project) => ({ ...project, scripts: cloneScripts(project.scripts) })), + Effect.mapError(toPersistenceSqlError("ProjectRepository.list:query")), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + rowToProjectRecord(row, "ProjectRepository.list:rowToProject"), + ), + ), + Effect.map( + (projects): ProjectListResult => + projects.map((project) => ({ ...project, scripts: cloneScripts(project.scripts) })), ), - Effect.orDie, ); const add: ProjectRepositoryShape["add"] = (rawInput) => Effect.gen(function* () { - const input = projectAddInputSchema.parse(rawInput as ProjectAddInput); + const input = yield* decodeAddInput(rawInput); const normalizedCwd = normalizeCwd(input.cwd); if (!isDirectory(normalizedCwd)) { - return yield* Effect.fail(new Error(`Project path does not exist: ${normalizedCwd}`)); + return yield* Effect.fail(new ProjectPathMissingError({ cwd: normalizedCwd })); } - const existing = yield* findProjectByCwd({ cwd: normalizedCwd }).pipe(Effect.orDie); + const existing = yield* findProjectByCwd({ cwd: normalizedCwd }).pipe( + Effect.mapError(toPersistenceSqlError("ProjectRepository.add:findByCwd")), + ); if (Option.isSome(existing)) { - const project = rowToProjectRecord(existing.value); + const project = yield* rowToProjectRecord(existing.value, "ProjectRepository.add:existing"); const result: ProjectAddResult = { project: { ...project, @@ -267,9 +361,9 @@ const makeRepository = Effect.gen(function* () { scriptsJson: "[]", createdAt: now, updatedAt: now, - }).pipe(Effect.orDie); + }).pipe(Effect.mapError(toPersistenceSqlError("ProjectRepository.add:insert"))); - const created = rowToProjectRecord(row); + const created = yield* rowToProjectRecord(row, "ProjectRepository.add:created"); const result: ProjectAddResult = { project: { ...created, @@ -282,27 +376,48 @@ const makeRepository = Effect.gen(function* () { const remove: ProjectRepositoryShape["remove"] = (rawInput) => Effect.gen(function* () { - const input = projectRemoveInputSchema.parse(rawInput); - yield* deleteProject({ id: input.id }).pipe(Effect.orDie); + const input = yield* decodeRemoveInput(rawInput); + const existing = yield* findProjectById({ id: input.id }).pipe( + Effect.mapError(toPersistenceSqlError("ProjectRepository.remove:findById")), + ); + if (Option.isNone(existing)) { + return yield* Effect.fail(new ProjectNotFoundError({ projectId: input.id })); + } + + yield* deleteProject({ id: input.id }).pipe( + Effect.mapError(toPersistenceSqlError("ProjectRepository.remove:delete")), + ); }); const updateScripts: ProjectRepositoryShape["updateScripts"] = (rawInput) => Effect.gen(function* () { - const input = projectUpdateScriptsInputSchema.parse(rawInput); - const existing = yield* findProjectById({ id: input.id }).pipe(Effect.orDie); + const input = yield* decodeUpdateScriptsInput(rawInput); + const existing = yield* findProjectById({ id: input.id }).pipe( + Effect.mapError(toPersistenceSqlError("ProjectRepository.updateScripts:findById")), + ); if (Option.isNone(existing)) { - return yield* Effect.fail(new Error(`Project not found: ${input.id}`)); + return yield* Effect.fail(new ProjectNotFoundError({ projectId: input.id })); } - const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts)); + const nextScripts = yield* parseProjectScripts( + input.scripts, + "ProjectRepository.updateScripts:decodeScripts", + ); + const scriptsJson = yield* Effect.try({ + try: () => JSON.stringify(nextScripts), + catch: toPersistenceSerializationError("ProjectRepository.updateScripts:encodeScripts"), + }); const nextUpdatedAt = new Date().toISOString(); const updatedRow = yield* updateProjectScripts({ id: input.id, - scriptsJson: JSON.stringify(nextScripts), + scriptsJson, updatedAt: nextUpdatedAt, - }).pipe(Effect.orDie); + }).pipe(Effect.mapError(toPersistenceSqlError("ProjectRepository.updateScripts:update"))); - const updated = rowToProjectRecord(updatedRow); + const updated = yield* rowToProjectRecord( + updatedRow, + "ProjectRepository.updateScripts:updated", + ); const result: ProjectUpdateScriptsResult = { project: { ...updated, @@ -314,17 +429,19 @@ const makeRepository = Effect.gen(function* () { const pruneMissing: ProjectRepositoryShape["pruneMissing"] = () => listProjectRows(undefined).pipe( + Effect.mapError(toPersistenceSqlError("ProjectRepository.pruneMissing:query")), Effect.flatMap((rows) => Effect.forEach(rows, (row) => { const normalizedCwd = normalizeCwd(row.cwd); if (isDirectory(normalizedCwd)) { return Effect.void; } - return deleteProject({ id: row.id }).pipe(Effect.orDie); + return deleteProject({ id: row.id }).pipe( + Effect.mapError(toPersistenceSqlError("ProjectRepository.pruneMissing:delete")), + ); }), ), Effect.asVoid, - Effect.orDie, ); return { diff --git a/apps/server/src/persistence/Services/OrchestrationEvents.ts b/apps/server/src/persistence/Services/OrchestrationEvents.ts index d785733358f48..745b54902daa6 100644 --- a/apps/server/src/persistence/Services/OrchestrationEvents.ts +++ b/apps/server/src/persistence/Services/OrchestrationEvents.ts @@ -1,18 +1,59 @@ +/** + * OrchestrationEventRepository - Repository interface for orchestration events. + * + * Uses Effect `Context.Tag` for dependency injection and exposes typed + * persistence/decode errors for event append and replay operations. + * + * @module OrchestrationEventRepository + */ import type { OrchestrationEvent } from "@t3tools/contracts"; import { Context } from "effect"; import type { Effect } from "effect"; +import type { OrchestrationEventRepositoryError } from "../Errors"; + export interface OrchestrationEventRepositoryShape { + /** + * Persist a new orchestration event. + * + * @param event - Event payload without sequence (assigned by storage). + * @returns Effect containing the stored event with assigned sequence. + */ readonly append: ( event: Omit, - ) => Effect.Effect; + ) => Effect.Effect; + + /** + * Replay events after the provided sequence. + * + * @param sequenceExclusive - Sequence cursor (exclusive). + * @param limit - Maximum number of events to return. + * @returns Effect containing ordered events. + */ readonly readFromSequence: ( sequenceExclusive: number, limit?: number, - ) => Effect.Effect; - readonly readAll: () => Effect.Effect; + ) => Effect.Effect; + + /** + * Read all events from the beginning of the stream. + * + * @returns Effect containing all stored events. + */ + readonly readAll: () => Effect.Effect; } +/** + * OrchestrationEventRepository - Context tag for orchestration event persistence. + * + * @example + * ```ts + * const program = Effect.gen(function* () { + * const events = yield* OrchestrationEventRepository + * return yield* events.readAll() + * }) + * ``` + */ export class OrchestrationEventRepository extends Context.Tag("orchestration/EventRepository")< OrchestrationEventRepository, OrchestrationEventRepositoryShape diff --git a/apps/server/src/persistence/Services/Projects.ts b/apps/server/src/persistence/Services/Projects.ts index 24288b3a673da..f16e5c49cdddb 100644 --- a/apps/server/src/persistence/Services/Projects.ts +++ b/apps/server/src/persistence/Services/Projects.ts @@ -1,3 +1,11 @@ +/** + * ProjectRepository - Repository interface for project persistence. + * + * Uses Effect `Context.Tag` for dependency injection and returns typed + * domain errors for all operations. + * + * @module ProjectRepository + */ import type { ProjectAddInput, ProjectAddResult, @@ -9,16 +17,61 @@ import type { import { Context } from "effect"; import type { Effect } from "effect"; +import type { ProjectRepositoryError } from "../Errors"; + export interface ProjectRepositoryShape { - readonly list: () => Effect.Effect; - readonly add: (input: ProjectAddInput) => Effect.Effect; - readonly remove: (input: ProjectRemoveInput) => Effect.Effect; + /** + * List all persisted projects. + * + * @returns Effect containing all project records. + */ + readonly list: () => Effect.Effect; + + /** + * Add a project by cwd, or return the existing project when the cwd already exists. + * + * @param input - Project creation payload. + * @returns Effect containing the project and whether it was newly created. + */ + readonly add: (input: ProjectAddInput) => Effect.Effect; + + /** + * Remove a persisted project. + * + * @param input - Project removal payload. + * @returns Effect containing void. + */ + readonly remove: (input: ProjectRemoveInput) => Effect.Effect; + + /** + * Update the scripts for an existing project. + * + * @param input - Script update payload. + * @returns Effect containing the updated project. + */ readonly updateScripts: ( input: ProjectUpdateScriptsInput, - ) => Effect.Effect; - readonly pruneMissing: () => Effect.Effect; + ) => Effect.Effect; + + /** + * Remove projects whose cwd no longer exists on disk. + * + * @returns Effect containing void. + */ + readonly pruneMissing: () => Effect.Effect; } +/** + * ProjectRepository - Context tag for project persistence dependency injection. + * + * @example + * ```ts + * const program = Effect.gen(function* () { + * const projects = yield* ProjectRepository + * return yield* projects.list() + * }) + * ``` + */ export class ProjectRepository extends Context.Tag("persistence/ProjectRepository")< ProjectRepository, ProjectRepositoryShape From b0c9c2a56e1fd2ca7c91ec1bb2f98efb75ad22c1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 11:07:16 -0800 Subject: [PATCH 13/98] effect v4 --- apps/server/package.json | 12 +-- apps/server/src/orchestration/Errors.ts | 22 ++--- apps/server/src/orchestration/Layer.test.ts | 24 ++--- apps/server/src/orchestration/Layer.ts | 20 ++-- apps/server/src/orchestration/Service.ts | 10 +- apps/server/src/orchestration/reducer.test.ts | 2 +- apps/server/src/orchestration/reducer.ts | 8 +- apps/server/src/persistence/Errors.ts | 18 ++-- .../Layers/OrchestrationEvents.test.ts | 26 ++--- .../persistence/Layers/OrchestrationEvents.ts | 14 +-- .../src/persistence/Layers/Projects.test.ts | 14 +-- .../server/src/persistence/Layers/Projects.ts | 20 ++-- apps/server/src/persistence/Layers/Sqlite.ts | 21 +++-- apps/server/src/persistence/Migrations.ts | 8 +- .../Migrations/001_OrchestrationEvents.ts | 2 +- .../persistence/Migrations/002_Projects.ts | 2 +- .../Services/OrchestrationEvents.ts | 8 +- .../src/persistence/Services/Projects.ts | 8 +- apps/server/src/wsServer.test.ts | 4 +- apps/server/src/wsServer.ts | 6 +- bun.lock | 94 ++++++++----------- packages/contracts/package.json | 2 +- packages/contracts/src/orchestration.ts | 14 +-- 23 files changed, 172 insertions(+), 187 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index d92adb10ee673..5b0ee327fc4ff 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -17,21 +17,21 @@ "test": "vitest run" }, "dependencies": { - "@effect/platform": "^0.94.5", - "@effect/platform-node": "^0.104.1", - "@effect/sql": "^0.49.0", - "@effect/sql-sqlite-node": "^0.50.1", - "effect": "^3.19.19", + "@effect/platform-node": "4.0.0-beta.10", + "@effect/sql-sqlite-node": "4.0.0-beta.10", + "effect": "4.0.0-beta.10", "node-pty": "^1.1.0", "open": "^10.1.0", "ws": "^8.18.0" }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.10", "@t3tools/contracts": "workspace:*", "@t3tools/web": "workspace:*", "@types/node": "^22.10.2", "@types/ws": "^8.5.13", "tsdown": "^0.20.3", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^4.0.0" } } diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts index 0f199e2baf45e..e7b4d8c0dd7b6 100644 --- a/apps/server/src/orchestration/Errors.ts +++ b/apps/server/src/orchestration/Errors.ts @@ -1,8 +1,8 @@ -import { ParseResult, Schema } from "effect"; +import { SchemaIssue, Schema } from "effect"; -import type { OrchestrationEventRepositoryError } from "../persistence/Errors"; +import type { OrchestrationEventRepositoryError } from "../persistence/Errors.ts"; -export class OrchestrationCommandJsonParseError extends Schema.TaggedError()( +export class OrchestrationCommandJsonParseError extends Schema.TaggedErrorClass()( "OrchestrationCommandJsonParseError", { detail: Schema.String, @@ -14,7 +14,7 @@ export class OrchestrationCommandJsonParseError extends Schema.TaggedError()( +export class OrchestrationCommandDecodeError extends Schema.TaggedErrorClass()( "OrchestrationCommandDecodeError", { issue: Schema.String, @@ -26,7 +26,7 @@ export class OrchestrationCommandDecodeError extends Schema.TaggedError()( +export class OrchestrationReducerDecodeError extends Schema.TaggedErrorClass()( "OrchestrationReducerDecodeError", { eventType: Schema.String, @@ -39,10 +39,10 @@ export class OrchestrationReducerDecodeError extends Schema.TaggedError()( +export class OrchestrationListenerCallbackError extends Schema.TaggedErrorClass()( "OrchestrationListenerCallbackError", { - listener: Schema.Literal("read-model", "domain-event"), + listener: Schema.Literals(["read-model", "domain-event"]), detail: Schema.String, cause: Schema.optional(Schema.Defect), }, @@ -62,18 +62,18 @@ export type OrchestrationEngineError = | OrchestrationCommandJsonParseError | OrchestrationCommandDecodeError; -export function toOrchestrationCommandDecodeError(error: ParseResult.ParseError) { +export function toOrchestrationCommandDecodeError(error: Schema.SchemaError) { return new OrchestrationCommandDecodeError({ - issue: ParseResult.TreeFormatter.formatErrorSync(error), + issue: SchemaIssue.makeFormatterDefault()(error.issue), cause: error, }); } export function toReducerDecodeError(eventType: string) { - return (error: ParseResult.ParseError): OrchestrationReducerDecodeError => + return (error: Schema.SchemaError): OrchestrationReducerDecodeError => new OrchestrationReducerDecodeError({ eventType, - issue: ParseResult.TreeFormatter.formatErrorSync(error), + issue: SchemaIssue.makeFormatterDefault()(error.issue), cause: error, }); } diff --git a/apps/server/src/orchestration/Layer.test.ts b/apps/server/src/orchestration/Layer.test.ts index 76a028567a8ba..fb383ab32253f 100644 --- a/apps/server/src/orchestration/Layer.test.ts +++ b/apps/server/src/orchestration/Layer.test.ts @@ -3,22 +3,18 @@ import os from "node:os"; import path from "node:path"; import type { OrchestrationEvent } from "@t3tools/contracts"; -import { Effect } from "effect"; +import { Effect, Layer, ManagedRuntime } from "effect"; import { afterEach, describe, expect, it } from "vitest"; import { OrchestrationEventRepository, type OrchestrationEventRepositoryShape, -} from "../persistence/Services/OrchestrationEvents"; -import { PersistenceSqlError } from "../persistence/Errors"; - -import { Layer, ManagedRuntime } from "effect"; - -import { makeSqlitePersistenceLive } from "../persistence/Layers/Sqlite"; - -import { OrchestrationEngineLive } from "./Layer"; -import { OrchestrationEngineService } from "./Service"; -import { OrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents"; +} from "../persistence/Services/OrchestrationEvents.ts"; +import { PersistenceSqlError } from "../persistence/Errors.ts"; +import { makeSqlitePersistenceLive } from "../persistence/Layers/Sqlite.ts"; +import { OrchestrationEngineLive } from "./Layer.ts"; +import { OrchestrationEngineService } from "./Service.ts"; +import { OrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents.ts"; const tempDirs: string[] = []; @@ -41,7 +37,7 @@ export async function createOrchestrationSystem(stateDir: string) { Layer.provide(makeSqlitePersistenceLive(dbPath)), ); const runtime = ManagedRuntime.make(orchestrationLayer); - const engine = await runtime.runPromise(OrchestrationEngineService); + const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); return { engine, run: (effect: Effect.Effect) => runtime.runPromise(effect), @@ -362,7 +358,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationEventRepository, inMemoryStore)), ), ); - await runtime.runPromise(OrchestrationEngineService); + await runtime.runPromise(Effect.service(OrchestrationEngineService)); await runtime.dispose(); await expect(runtime.dispose()).resolves.toBeUndefined(); }); @@ -404,7 +400,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationEventRepository, flakyStore)), ), ); - const engine = await runtime.runPromise(OrchestrationEngineService); + const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const createdAt = new Date().toISOString(); await expect( diff --git a/apps/server/src/orchestration/Layer.ts b/apps/server/src/orchestration/Layer.ts index 3c9590501c7eb..5c08697cc2c7c 100644 --- a/apps/server/src/orchestration/Layer.ts +++ b/apps/server/src/orchestration/Layer.ts @@ -6,16 +6,16 @@ import type { import { OrchestrationCommandSchema } from "@t3tools/contracts"; import { Deferred, Effect, Layer, PubSub, Queue, Schema } from "effect"; -import { createLogger } from "../logger"; -import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents"; +import { createLogger } from "../logger.ts"; +import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents.ts"; import { toListenerCallbackError, toOrchestrationCommandDecodeError, toOrchestrationJsonParseError, type OrchestrationDispatchError, -} from "./Errors"; -import { createEmptyReadModel, reduceEvent } from "./reducer"; -import { OrchestrationEngineService, type OrchestrationEngineShape } from "./Service"; +} from "./Errors.ts"; +import { createEmptyReadModel, reduceEvent } from "./reducer.ts"; +import { OrchestrationEngineService, type OrchestrationEngineShape } from "./Service.ts"; type CommandEnvelope = { command: OrchestrationCommand; @@ -160,9 +160,9 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit +const makeOrchestrationEngine = Effect.gen(function* () { const logger = createLogger("orchestration"); const eventStore = yield* OrchestrationEventRepository; @@ -209,7 +209,7 @@ const makeOrchestrationEngine = () => yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); }).pipe( - Effect.catchAll((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid)), + Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid)), ); const bootstrapReadModel: Effect.Effect = Effect.gen( @@ -287,7 +287,7 @@ const makeOrchestrationEngine = () => } satisfies OrchestrationEngineShape; }); -export const OrchestrationEngineLive = Layer.scoped( +export const OrchestrationEngineLive = Layer.effect( OrchestrationEngineService, - makeOrchestrationEngine(), + makeOrchestrationEngine, ); diff --git a/apps/server/src/orchestration/Service.ts b/apps/server/src/orchestration/Service.ts index 57eee9dabff91..1dadacce97b56 100644 --- a/apps/server/src/orchestration/Service.ts +++ b/apps/server/src/orchestration/Service.ts @@ -11,11 +11,11 @@ import type { OrchestrationEvent, OrchestrationReadModel, } from "@t3tools/contracts"; -import { Context } from "effect"; +import { ServiceMap } from "effect"; import type { Effect } from "effect"; -import type { OrchestrationDispatchError, OrchestrationEngineError } from "./Errors"; -import type { OrchestrationEventRepositoryError } from "../persistence/Errors"; +import type { OrchestrationDispatchError, OrchestrationEngineError } from "./Errors.ts"; +import type { OrchestrationEventRepositoryError } from "../persistence/Errors.ts"; export interface OrchestrationEngineShape { /** @@ -89,7 +89,7 @@ export interface OrchestrationEngineShape { * }) * ``` */ -export class OrchestrationEngineService extends Context.Tag("orchestration/Engine")< +export class OrchestrationEngineService extends ServiceMap.Service< OrchestrationEngineService, OrchestrationEngineShape ->() {} +>()("orchestration/Engine") {} diff --git a/apps/server/src/orchestration/reducer.test.ts b/apps/server/src/orchestration/reducer.test.ts index 6ea2a6c813522..ac887e8dd925d 100644 --- a/apps/server/src/orchestration/reducer.test.ts +++ b/apps/server/src/orchestration/reducer.test.ts @@ -2,7 +2,7 @@ import type { OrchestrationEvent } from "@t3tools/contracts"; import { Effect } from "effect"; import { describe, expect, it } from "vitest"; -import { createEmptyReadModel, reduceEvent } from "./reducer"; +import { createEmptyReadModel, reduceEvent } from "./reducer.ts"; describe("orchestration reducer", () => { it("applies thread.created events using runtime schema decoding", async () => { diff --git a/apps/server/src/orchestration/reducer.ts b/apps/server/src/orchestration/reducer.ts index 01f9e8c09755b..8fa8187af3bd7 100644 --- a/apps/server/src/orchestration/reducer.ts +++ b/apps/server/src/orchestration/reducer.ts @@ -16,7 +16,7 @@ import { } from "@t3tools/contracts"; import { Effect, Schema } from "effect"; -import { toReducerDecodeError, type OrchestrationReducerDecodeError } from "./Errors"; +import { toReducerDecodeError, type OrchestrationReducerDecodeError } from "./Errors.ts"; type ThreadPatch = Partial>; @@ -34,9 +34,9 @@ function decodeForEvent( eventType: string, field: string, ): Effect.Effect { - return Schema.decodeUnknown(schema)(value).pipe( + return Schema.decodeUnknownEffect(schema)(value).pipe( Effect.mapError(toReducerDecodeError(`${eventType}:${field}`)), - ); + ) as Effect.Effect } const ThreadCreatedPayloadSchema = Schema.Struct({ @@ -61,7 +61,7 @@ const ThreadMetaUpdatedPayloadSchema = Schema.Struct({ const MessageSentPayloadSchema = Schema.Struct({ id: Schema.String, - role: Schema.Literal("user", "assistant"), + role: Schema.Literals(["user", "assistant"]), text: Schema.String, threadId: Schema.String, createdAt: Schema.String, diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 940357abe34a1..d6363f6abeb7f 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -1,6 +1,6 @@ -import { ParseResult, Schema } from "effect"; +import { Schema, SchemaIssue } from "effect"; -export class PersistenceSqlError extends Schema.TaggedError()( +export class PersistenceSqlError extends Schema.TaggedErrorClass()( "PersistenceSqlError", { operation: Schema.String, @@ -13,7 +13,7 @@ export class PersistenceSqlError extends Schema.TaggedError } } -export class PersistenceDecodeError extends Schema.TaggedError()( +export class PersistenceDecodeError extends Schema.TaggedErrorClass()( "PersistenceDecodeError", { operation: Schema.String, @@ -26,7 +26,7 @@ export class PersistenceDecodeError extends Schema.TaggedError()( +export class PersistenceSerializationError extends Schema.TaggedErrorClass()( "PersistenceSerializationError", { operation: Schema.String, @@ -39,7 +39,7 @@ export class PersistenceSerializationError extends Schema.TaggedError()( +export class ProjectValidationError extends Schema.TaggedErrorClass()( "ProjectValidationError", { operation: Schema.String, @@ -52,7 +52,7 @@ export class ProjectValidationError extends Schema.TaggedError()( +export class ProjectPathMissingError extends Schema.TaggedErrorClass()( "ProjectPathMissingError", { cwd: Schema.String, @@ -64,7 +64,7 @@ export class ProjectPathMissingError extends Schema.TaggedError()( +export class ProjectNotFoundError extends Schema.TaggedErrorClass()( "ProjectNotFoundError", { projectId: Schema.String, @@ -99,10 +99,10 @@ export function toPersistenceSqlError(operation: string) { } export function toPersistenceDecodeError(operation: string) { - return (error: ParseResult.ParseError): PersistenceDecodeError => + return (error: Schema.SchemaError): PersistenceDecodeError => new PersistenceDecodeError({ operation, - issue: ParseResult.TreeFormatter.formatErrorSync(error), + issue: SchemaIssue.makeFormatterDefault()(error.issue), cause: error, }); } diff --git a/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts index e9ca28dff0426..0959c691e39b9 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts @@ -3,11 +3,11 @@ import os from "node:os"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; -import { Runtime, ManagedRuntime } from "effect"; +import { ManagedRuntime, Effect } from "effect"; import { afterEach, describe, expect, it } from "vitest"; -import { OrchestrationEventRepository } from "../Services/OrchestrationEvents"; -import { makeSqliteOrchestrationEventRepositoryLive } from "./OrchestrationEvents"; +import { OrchestrationEventRepository } from "../Services/OrchestrationEvents.ts"; +import { makeSqliteOrchestrationEventRepositoryLive } from "./OrchestrationEvents.ts"; const tempDirs: string[] = []; @@ -27,12 +27,12 @@ describe("OrchestrationEventRepository", () => { it("persists and replays events across restarts", async () => { const stateDir = makeTempDir("t3code-event-store-"); const dbPath = path.join(stateDir, "orchestration.sqlite"); - const runtime = Runtime.defaultRuntime; + const createdAt = new Date().toISOString(); const firstRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); - const first = await firstRuntime.runPromise(OrchestrationEventRepository); - const saved = await Runtime.runPromise(runtime)( + const first = await firstRuntime.runPromise(Effect.service(OrchestrationEventRepository)); + const saved = await firstRuntime.runPromise( first.append({ eventId: "event-1", type: "thread.created", @@ -47,8 +47,8 @@ describe("OrchestrationEventRepository", () => { await firstRuntime.dispose(); const secondRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); - const second = await secondRuntime.runPromise(OrchestrationEventRepository); - const replayed = await Runtime.runPromise(runtime)(second.readFromSequence(0)); + const second = await secondRuntime.runPromise(Effect.service(OrchestrationEventRepository)); + const replayed = await secondRuntime.runPromise(second.readFromSequence(0)); expect(replayed).toEqual([saved]); await secondRuntime.dispose(); }); @@ -56,16 +56,16 @@ describe("OrchestrationEventRepository", () => { it("creates and reuses the migrator tracking table", async () => { const stateDir = makeTempDir("t3code-event-store-migrations-"); const dbPath = path.join(stateDir, "orchestration.sqlite"); - const runtime = Runtime.defaultRuntime; + const firstRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); - const first = await firstRuntime.runPromise(OrchestrationEventRepository); - await Runtime.runPromise(runtime)(first.readAll()); + const first = await firstRuntime.runPromise(Effect.service(OrchestrationEventRepository)); + await firstRuntime.runPromise(first.readAll()); await firstRuntime.dispose(); const secondRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); - const second = await secondRuntime.runPromise(OrchestrationEventRepository); - await Runtime.runPromise(runtime)(second.readAll()); + const second = await secondRuntime.runPromise(Effect.service(OrchestrationEventRepository)); + await secondRuntime.runPromise(second.readAll()); await secondRuntime.dispose(); const db = new DatabaseSync(dbPath, { readOnly: true }); diff --git a/apps/server/src/persistence/Layers/OrchestrationEvents.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.ts index d3a6841105907..bbbc26c84237f 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEvents.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.ts @@ -1,7 +1,7 @@ import type { OrchestrationEvent } from "@t3tools/contracts"; import { OrchestrationEventSchema } from "@t3tools/contracts"; -import * as SqlClient from "@effect/sql/SqlClient"; -import * as SqlSchema from "@effect/sql/SqlSchema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { Effect, Layer, Schema } from "effect"; import { @@ -10,14 +10,14 @@ import { toPersistenceSerializationError, toPersistenceSqlError, type OrchestrationEventRepositoryError, -} from "../Errors"; -import { makeSqlitePersistenceLive } from "./Sqlite"; +} from "../Errors.ts"; +import { makeSqlitePersistenceLive } from "./Sqlite.ts"; import { OrchestrationEventRepository, type OrchestrationEventRepositoryShape, -} from "../Services/OrchestrationEvents"; +} from "../Services/OrchestrationEvents.ts"; -const decodeEvent = Schema.decodeUnknown(OrchestrationEventSchema); +const decodeEvent = Schema.decodeUnknownEffect(OrchestrationEventSchema); const EventRowSchema = Schema.Struct({ sequence: Schema.Number, @@ -73,7 +73,7 @@ function eventRowToOrchestrationEvent( const makeRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const appendEventRow = SqlSchema.single({ + const appendEventRow = SqlSchema.findOne({ Request: AppendEventRequestSchema, Result: EventRowSchema, execute: (request) => diff --git a/apps/server/src/persistence/Layers/Projects.test.ts b/apps/server/src/persistence/Layers/Projects.test.ts index a53ca6b221063..c8ee6215c92f2 100644 --- a/apps/server/src/persistence/Layers/Projects.test.ts +++ b/apps/server/src/persistence/Layers/Projects.test.ts @@ -2,12 +2,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { Layer, ManagedRuntime } from "effect"; +import { Effect, Layer, ManagedRuntime } from "effect"; import { afterEach, describe, expect, it } from "vitest"; -import { ProjectRepository } from "../Services/Projects"; -import { ProjectRepositoryLive } from "./Projects"; -import { makeSqlitePersistenceLive } from "./Sqlite"; +import { ProjectRepository } from "../Services/Projects.ts"; +import { ProjectRepositoryLive } from "./Projects.ts"; +import { makeSqlitePersistenceLive } from "./Sqlite.ts"; function makeProjectRepositoryTest(dbPath: string) { return ProjectRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); @@ -34,7 +34,7 @@ describe("ProjectRepository", () => { const dbPath = path.join(stateDir, "orchestration.sqlite"); const firstRuntime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); - const first = await firstRuntime.runPromise(ProjectRepository); + const first = await firstRuntime.runPromise(Effect.service(ProjectRepository)); const created = await firstRuntime.runPromise(first.add({ cwd: projectDir })); expect(created.created).toBe(true); @@ -46,7 +46,7 @@ describe("ProjectRepository", () => { await firstRuntime.dispose(); const secondRuntime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); - const second = await secondRuntime.runPromise(ProjectRepository); + const second = await secondRuntime.runPromise(Effect.service(ProjectRepository)); const listed = await secondRuntime.runPromise(second.list()); expect(listed).toHaveLength(1); @@ -62,7 +62,7 @@ describe("ProjectRepository", () => { const dbPath = path.join(stateDir, "orchestration.sqlite"); const runtime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); - const repository = await runtime.runPromise(ProjectRepository); + const repository = await runtime.runPromise(Effect.service(ProjectRepository)); const existingProject = await runtime.runPromise(repository.add({ cwd: existing })); const missingProject = await runtime.runPromise(repository.add({ cwd: missing })); diff --git a/apps/server/src/persistence/Layers/Projects.ts b/apps/server/src/persistence/Layers/Projects.ts index 1504545def066..bbda8fa644a7c 100644 --- a/apps/server/src/persistence/Layers/Projects.ts +++ b/apps/server/src/persistence/Layers/Projects.ts @@ -19,8 +19,8 @@ import { projectScriptsSchema, projectUpdateScriptsInputSchema, } from "@t3tools/contracts"; -import * as SqlClient from "@effect/sql/SqlClient"; -import * as SqlSchema from "@effect/sql/SqlSchema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { Effect, Layer, Option, Schema } from "effect"; import { @@ -32,8 +32,8 @@ import { toPersistenceSerializationError, toPersistenceSqlError, type ProjectRepositoryError, -} from "../Errors"; -import { ProjectRepository, type ProjectRepositoryShape } from "../Services/Projects"; +} from "../Errors.ts"; +import { ProjectRepository, type ProjectRepositoryShape } from "../Services/Projects.ts"; const ProjectRowSchema = Schema.Struct({ id: Schema.String, @@ -226,7 +226,7 @@ const makeRepository = Effect.gen(function* () { `, }); - const findProjectById = SqlSchema.findOne({ + const findProjectById = SqlSchema.findOneOption({ Request: FindByIdRequestSchema, Result: ProjectRowSchema, execute: ({ id }) => @@ -243,7 +243,7 @@ const makeRepository = Effect.gen(function* () { `, }); - const findProjectByCwd = SqlSchema.findOne({ + const findProjectByCwd = SqlSchema.findOneOption({ Request: FindByCwdRequestSchema, Result: ProjectRowSchema, execute: ({ cwd }) => @@ -260,7 +260,7 @@ const makeRepository = Effect.gen(function* () { `, }); - const insertProject = SqlSchema.single({ + const insertProject = SqlSchema.findOne({ Request: InsertProjectRequestSchema, Result: ProjectRowSchema, execute: (request) => @@ -291,7 +291,7 @@ const makeRepository = Effect.gen(function* () { `, }); - const updateProjectScripts = SqlSchema.single({ + const updateProjectScripts = SqlSchema.findOne({ Request: UpdateScriptsRequestSchema, Result: ProjectRowSchema, execute: (request) => @@ -381,7 +381,7 @@ const makeRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectRepository.remove:findById")), ); if (Option.isNone(existing)) { - return yield* Effect.fail(new ProjectNotFoundError({ projectId: input.id })); + return yield* (new ProjectNotFoundError({ projectId: input.id })); } yield* deleteProject({ id: input.id }).pipe( @@ -396,7 +396,7 @@ const makeRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectRepository.updateScripts:findById")), ); if (Option.isNone(existing)) { - return yield* Effect.fail(new ProjectNotFoundError({ projectId: input.id })); + return yield* (new ProjectNotFoundError({ projectId: input.id })); } const nextScripts = yield* parseProjectScripts( diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 735a425221540..c8ea33b12ebb6 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -1,30 +1,32 @@ import fs from "node:fs"; import path from "node:path"; -import * as SqlClient from "@effect/sql/SqlClient"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; -import { Context, Effect, Layer } from "effect"; +import { Effect, Layer, ServiceMap } from "effect"; -import { runMigrations } from "../Migrations"; + + +import { runMigrations } from "../Migrations.ts"; export interface PersistenceConfigShape { readonly dbPath: string; } -export class PersistenceConfig extends Context.Tag("persistence/Config")< +export class PersistenceConfig extends ServiceMap.Service< PersistenceConfig, PersistenceConfigShape ->() {} +>()("persistence/Config") {} export function makeSqlitePersistenceLive(dbPath: string) { - const SqliteClientLive = Layer.unwrapEffect( + const SqliteClientLive = Layer.unwrap( Effect.sync(() => { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); return SqliteClient.layer({ filename: dbPath }); }), ); - return Layer.scoped( + return Layer.effect( SqlClient.SqlClient, Effect.gen(function* () { const sql = yield* SqliteClient.SqliteClient; @@ -36,6 +38,7 @@ export function makeSqlitePersistenceLive(dbPath: string) { ).pipe(Layer.provide(SqliteClientLive)); } -export const SqlitePersistenceLive = Layer.unwrapEffect( - Effect.map(PersistenceConfig, ({ dbPath }) => makeSqlitePersistenceLive(dbPath)), + +export const SqlitePersistenceLive = Layer.unwrap( + Effect.map(Effect.service(PersistenceConfig), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)), ); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 628fecf88a560..d289361f91325 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -8,7 +8,7 @@ * ensuring the database schema is always up-to-date before the application starts. */ -import * as Migrator from "@effect/sql/Migrator"; +import * as Migrator from "effect/unstable/sql/Migrator"; import * as Layer from "effect/Layer"; // Import all migrations statically @@ -57,11 +57,11 @@ export const runMigrations = run({ loader }); * @example * ```typescript * import { MigrationsLive } from "@acme/db/Migrations" - * import { PgClient } from "@effect/sql-pg" + * import { SqliteClient } from "@effect/sql-sqlite-node" * - * // Migrations run automatically when PgClient is provided + * // Migrations run automatically when SqliteClient is provided * const AppLayer = MigrationsLive.pipe( - * Layer.provideMerge(PgClient.layer({ url: Redacted.make("postgresql://...") })) + * Layer.provideMerge(SqliteClient.layer({ filename: "database.sqlite" })) * ) * ``` */ diff --git a/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts b/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts index dac64e9e282f0..5e2ac44ee7d84 100644 --- a/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts +++ b/apps/server/src/persistence/Migrations/001_OrchestrationEvents.ts @@ -1,4 +1,4 @@ -import { SqlClient } from "@effect/sql"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as Effect from "effect/Effect"; export default Effect.gen(function* () { diff --git a/apps/server/src/persistence/Migrations/002_Projects.ts b/apps/server/src/persistence/Migrations/002_Projects.ts index 7ec9785f699fb..f061da2c9a48e 100644 --- a/apps/server/src/persistence/Migrations/002_Projects.ts +++ b/apps/server/src/persistence/Migrations/002_Projects.ts @@ -1,4 +1,4 @@ -import { SqlClient } from "@effect/sql"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as Effect from "effect/Effect"; export default Effect.gen(function* () { diff --git a/apps/server/src/persistence/Services/OrchestrationEvents.ts b/apps/server/src/persistence/Services/OrchestrationEvents.ts index 745b54902daa6..6761bf9823659 100644 --- a/apps/server/src/persistence/Services/OrchestrationEvents.ts +++ b/apps/server/src/persistence/Services/OrchestrationEvents.ts @@ -7,10 +7,10 @@ * @module OrchestrationEventRepository */ import type { OrchestrationEvent } from "@t3tools/contracts"; -import { Context } from "effect"; +import { ServiceMap } from "effect"; import type { Effect } from "effect"; -import type { OrchestrationEventRepositoryError } from "../Errors"; +import type { OrchestrationEventRepositoryError } from "../Errors.ts"; export interface OrchestrationEventRepositoryShape { /** @@ -54,7 +54,7 @@ export interface OrchestrationEventRepositoryShape { * }) * ``` */ -export class OrchestrationEventRepository extends Context.Tag("orchestration/EventRepository")< +export class OrchestrationEventRepository extends ServiceMap.Service< OrchestrationEventRepository, OrchestrationEventRepositoryShape ->() {} +>()("orchestration/EventRepository") {} diff --git a/apps/server/src/persistence/Services/Projects.ts b/apps/server/src/persistence/Services/Projects.ts index f16e5c49cdddb..5f8a67c784059 100644 --- a/apps/server/src/persistence/Services/Projects.ts +++ b/apps/server/src/persistence/Services/Projects.ts @@ -14,10 +14,10 @@ import type { ProjectUpdateScriptsInput, ProjectUpdateScriptsResult, } from "@t3tools/contracts"; -import { Context } from "effect"; +import { ServiceMap } from "effect"; import type { Effect } from "effect"; -import type { ProjectRepositoryError } from "../Errors"; +import type { ProjectRepositoryError } from "../Errors.ts"; export interface ProjectRepositoryShape { /** @@ -72,7 +72,7 @@ export interface ProjectRepositoryShape { * }) * ``` */ -export class ProjectRepository extends Context.Tag("persistence/ProjectRepository")< +export class ProjectRepository extends ServiceMap.Service< ProjectRepository, ProjectRepositoryShape ->() {} +>()("persistence/ProjectRepository") {} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index c977418f05d69..ade1641154361 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { EventEmitter } from "node:events"; -import { Layer, ManagedRuntime } from "effect"; +import { Effect, Layer, ManagedRuntime } from "effect"; import { describe, expect, it, afterEach, vi } from "vitest"; import { createServer } from "./wsServer"; import WebSocket from "ws"; @@ -1039,7 +1039,7 @@ describe("WebSocket Server", () => { Layer.provide(makeSqlitePersistenceLive(path.join(stateDir, "orchestration.sqlite"))), ), ); - const projectRepository = await projectRuntime.runPromise(ProjectRepository); + const projectRepository = await projectRuntime.runPromise(Effect.service(ProjectRepository)); await projectRuntime.runPromise(projectRepository.add({ cwd: existing })); await projectRuntime.runPromise(projectRepository.add({ cwd: missing })); await projectRuntime.dispose(); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 42fb00d352692..d9fde1cb73580 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -18,7 +18,7 @@ import { type WsResponse, wsRequestSchema, } from "@t3tools/contracts"; -import { Layer, ManagedRuntime } from "effect"; +import { Effect, Layer, ManagedRuntime } from "effect"; import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; @@ -747,8 +747,8 @@ export function createServer(options: ServerOptions) { try { const [nextOrchestrationEngine, repository] = await Promise.all([ - runtime.runPromise(OrchestrationEngineService), - runtime.runPromise(ProjectRepository), + runtime.runPromise(Effect.service(OrchestrationEngineService)), + runtime.runPromise(Effect.service(ProjectRepository)), ]); orchestrationEngine = nextOrchestrationEngine; projectRepository = repository; diff --git a/bun.lock b/bun.lock index 2528167f3753a..0292e127a49bf 100644 --- a/bun.lock +++ b/bun.lock @@ -34,22 +34,22 @@ "t3": "./dist/index.mjs", }, "dependencies": { - "@effect/platform": "^0.94.5", - "@effect/platform-node": "^0.104.1", - "@effect/sql": "^0.49.0", - "@effect/sql-sqlite-node": "^0.50.1", - "effect": "^3.19.19", + "@effect/platform-node": "4.0.0-beta.10", + "@effect/sql-sqlite-node": "4.0.0-beta.10", + "effect": "4.0.0-beta.10", "node-pty": "^1.1.0", "open": "^10.1.0", "ws": "^8.18.0", }, "devDependencies": { + "@effect/vitest": "4.0.0-beta.10", "@t3tools/contracts": "workspace:*", "@t3tools/web": "workspace:*", "@types/node": "^22.10.2", "@types/ws": "^8.5.13", "tsdown": "^0.20.3", "typescript": "^5.7.3", + "vitest": "^4.0.0", }, }, "apps/web": { @@ -92,7 +92,7 @@ "name": "@t3tools/contracts", "version": "0.0.0", "dependencies": { - "effect": "^3.19.19", + "effect": "4.0.0-beta.10", "zod": "^3.24.1", }, "devDependencies": { @@ -150,23 +150,13 @@ "@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/cluster": ["@effect/cluster@0.56.4", "", { "dependencies": { "kubernetes-types": "^1.30.0" }, "peerDependencies": { "@effect/platform": "^0.94.5", "@effect/rpc": "^0.73.1", "@effect/sql": "^0.49.0", "@effect/workflow": "^0.16.0", "effect": "^3.19.17" } }, "sha512-7Je5/JlbZOlsSxsbKjr97dJed2cNGWsb+TLNgMcr5mRDbcWlFOTUGvsrisEJV6waosYLIg+2omPdvnvRoYKdhA=="], - - "@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/platform-node": ["@effect/platform-node@0.104.1", "", { "dependencies": { "@effect/platform-node-shared": "^0.57.1", "mime": "^3.0.0", "undici": "^7.10.0", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-jT1a/z98niK6fnEU8pWHPPCdJMVDRCIdB65lolcOjse5rsTwVbczMjvKkhVQpF63mNWoOnol7OTRNkw5L54llg=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.10", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.10", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.10", "ioredis": "^5.7.0" } }, "sha512-ikZ/uyos5okYAHE+wWOwZkHgriFUTY3aY8Kdqbj/ro8xw0z9YXWN4fbyQAZUWkBcieDKS36N+QfHRMPG4fEWRA=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@0.57.1", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "multipasta": "^0.2.7", "ws": "^8.18.2" }, "peerDependencies": { "@effect/cluster": "^0.56.1", "@effect/platform": "^0.94.2", "@effect/rpc": "^0.73.0", "@effect/sql": "^0.49.0", "effect": "^3.19.15" } }, "sha512-oX/bApMdoKsyrDiNdJxo7U9Rz1RXsjRv+ecfAPp1qGlSdGIo32wVRvJ2XCHqYj0sqaYJS0pU0/GCulRfVGuJag=="], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.10", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.10" } }, "sha512-gBSeOG2bfchXFdgdi5oipHKynOzSd7BlzTy5QcjFOoCUTtLP9igqohPB9hTkV3a6seWE2JJqsgsQ79VuLb7fAg=="], - "@effect/rpc": ["@effect/rpc@0.73.2", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.94.5", "effect": "^3.19.18" } }, "sha512-td7LHDgBOYKg+VgGWEelD8rSAmvjXz7am17vfxZROX5qIYuvH7drL/z4p5xQFadhHZ7DYdlFpqdO9ggc77OCIw=="], + "@effect/sql-sqlite-node": ["@effect/sql-sqlite-node@4.0.0-beta.10", "", { "dependencies": { "better-sqlite3": "^12.6.2" }, "peerDependencies": { "effect": "^4.0.0-beta.10" } }, "sha512-oQl+gF31HaVM9R8YEpf96CIJypP1Roa5z1VPj8s7ZP+3aCtRVor1K+eMxYNAb8ph7R68Tz0LpmVkefq0mEI3CA=="], - "@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=="], - - "@effect/workflow": ["@effect/workflow@0.16.0", "", { "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "@effect/rpc": "^0.73.0", "effect": "^3.19.13" } }, "sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw=="], + "@effect/vitest": ["@effect/vitest@4.0.0-beta.10", "", { "peerDependencies": { "effect": "^4.0.0-beta.10", "vitest": "^3.0.0" } }, "sha512-WmE8vCNw+eDoAq9CwwymrAdIBw1f+8hDP3AUF7jyC7/Cj/kyAYOFwkbtKmDXIFaTlf7G/2fmulEXeCO5W8j0kQ=="], "@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=="], @@ -258,6 +248,8 @@ "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], + "@ioredis/commands": ["@ioredis/commands@1.5.0", "", {}, "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -348,34 +340,6 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.46.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DJbQsSJUr4KSi9uU0QqOgI7PX2C+fKGZX+YDprt3vM2sC0dWZsgVTLoN2vtkNyEWJSY2mnvRFUshWXT3bmo0Ug=="], - "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], - - "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], - - "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], - - "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], - - "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], - - "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], - - "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], - - "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], - - "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], - - "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], - - "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], - - "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], - - "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], - - "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], - "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.16", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-McjTuEPuacSIcXdoI2O9W6VSHIOs9ApEHnEUwONKZnKqIo2GGv1vNg9Pr8tgBOL7lgBWNEHX5ROJ5z1X74sENQ=="], "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], @@ -710,6 +674,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -750,6 +716,8 @@ "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -764,7 +732,7 @@ "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=="], + "effect": ["effect@4.0.0-beta.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-UvxWmT0uzWZqNmQODfW7y1zb0LMlH3xtSn/UESpck2Wvv6SogxibYPWKKNcUsRMm5wwprOoW2IwItYLQyZpe8Q=="], "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=="], @@ -814,7 +782,7 @@ "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=="], + "fast-check": ["fast-check@4.5.3", "", { "dependencies": { "pure-rand": "^7.0.0" } }, "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA=="], "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], @@ -908,10 +876,12 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + "ioredis": ["ioredis@5.9.3", "", { "dependencies": { "@ioredis/commands": "1.5.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], @@ -988,8 +958,12 @@ "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + "lodash.difference": ["lodash.difference@4.5.0", "", {}, "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA=="], + "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], @@ -1098,7 +1072,7 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], @@ -1188,7 +1162,7 @@ "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=="], + "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], @@ -1210,6 +1184,10 @@ "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=="], + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], + + "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], @@ -1290,6 +1268,8 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], @@ -1350,6 +1330,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], @@ -1420,7 +1402,7 @@ "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=="], + "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -1450,6 +1432,8 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -1530,6 +1514,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 87ccb84cef60a..c4843f4d79d6c 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -23,7 +23,7 @@ "test": "vitest run" }, "dependencies": { - "effect": "^3.19.19", + "effect": "4.0.0-beta.10", "zod": "^3.24.1" }, "devDependencies": { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 22f1c6411f185..ce12299721b7c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -16,7 +16,7 @@ const IdSchema = Schema.String; export const OrchestrationMessageSchema = Schema.Struct({ id: IdSchema, - role: Schema.Literal("user", "assistant"), + role: Schema.Literals(["user", "assistant"]), text: Schema.String, createdAt: IsoDateTimeSchema, streaming: Schema.Boolean, @@ -26,8 +26,8 @@ export type OrchestrationMessage = Schema.Schema.Type; From a096fe7372817e3c161f898d7a885cfa7fb6b8b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 11:07:39 -0800 Subject: [PATCH 14/98] fmt --- apps/server/src/orchestration/Layer.ts | 201 +++++++++--------- apps/server/src/orchestration/reducer.ts | 2 +- .../Layers/OrchestrationEvents.test.ts | 3 +- .../server/src/persistence/Layers/Projects.ts | 4 +- apps/server/src/persistence/Layers/Sqlite.ts | 3 - 5 files changed, 102 insertions(+), 111 deletions(-) diff --git a/apps/server/src/orchestration/Layer.ts b/apps/server/src/orchestration/Layer.ts index 5c08697cc2c7c..bd36e701c24b3 100644 --- a/apps/server/src/orchestration/Layer.ts +++ b/apps/server/src/orchestration/Layer.ts @@ -162,130 +162,125 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit(); - const readModelPubSub = yield* PubSub.unbounded(); - const eventPubSub = yield* PubSub.unbounded(); + const commandQueue = yield* Queue.unbounded(); + const readModelPubSub = yield* PubSub.unbounded(); + const eventPubSub = yield* PubSub.unbounded(); - const readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); - const domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); + const readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); + const domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); - const notifyReadModelListeners = (snapshot: OrchestrationReadModel) => - Effect.forEach(readModelListeners, (listener) => - Effect.try({ - try: () => listener(snapshot), - catch: toListenerCallbackError("read-model"), - }), - ).pipe(Effect.asVoid); + const notifyReadModelListeners = (snapshot: OrchestrationReadModel) => + Effect.forEach(readModelListeners, (listener) => + Effect.try({ + try: () => listener(snapshot), + catch: toListenerCallbackError("read-model"), + }), + ).pipe(Effect.asVoid); - const notifyDomainEventListeners = (event: OrchestrationEvent) => - Effect.forEach(domainEventListeners, (listener) => - Effect.try({ - try: () => listener(event), - catch: toListenerCallbackError("domain-event"), - }), - ).pipe(Effect.asVoid); + const notifyDomainEventListeners = (event: OrchestrationEvent) => + Effect.forEach(domainEventListeners, (listener) => + Effect.try({ + try: () => listener(event), + catch: toListenerCallbackError("domain-event"), + }), + ).pipe(Effect.asVoid); - const processEnvelope = (envelope: CommandEnvelope): Effect.Effect => - Effect.gen(function* () { - const eventBase = mapCommandToEvent(envelope.command); - const savedEvent = yield* eventStore.append(eventBase); - readModel = yield* reduceEvent(readModel, savedEvent); + const processEnvelope = (envelope: CommandEnvelope): Effect.Effect => + Effect.gen(function* () { + const eventBase = mapCommandToEvent(envelope.command); + const savedEvent = yield* eventStore.append(eventBase); + readModel = yield* reduceEvent(readModel, savedEvent); - const snapshot = readModel; - yield* Effect.all([ - PubSub.publish(eventPubSub, savedEvent), - PubSub.publish(readModelPubSub, snapshot), - ]); + const snapshot = readModel; + yield* Effect.all([ + PubSub.publish(eventPubSub, savedEvent), + PubSub.publish(readModelPubSub, snapshot), + ]); - yield* notifyDomainEventListeners(savedEvent); - yield* notifyReadModelListeners(snapshot); + yield* notifyDomainEventListeners(savedEvent); + yield* notifyReadModelListeners(snapshot); - yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); - }).pipe( - Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid)), - ); + yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); + }).pipe(Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid))); - const bootstrapReadModel: Effect.Effect = Effect.gen( - function* () { - const existingEvents = yield* eventStore.readAll(); - for (const event of existingEvents) { - readModel = yield* reduceEvent(readModel, event); - } - }, - ); + const bootstrapReadModel: Effect.Effect = Effect.gen( + function* () { + const existingEvents = yield* eventStore.readAll(); + for (const event of existingEvents) { + readModel = yield* reduceEvent(readModel, event); + } + }, + ); - yield* bootstrapReadModel; + yield* bootstrapReadModel; - const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); - yield* Effect.forkScoped(worker); - logger.info("orchestration engine started", { - sequence: readModel.sequence, - }); + const worker = Effect.forever(Queue.take(commandQueue).pipe(Effect.flatMap(processEnvelope))); + yield* Effect.forkScoped(worker); + logger.info("orchestration engine started", { + sequence: readModel.sequence, + }); - const getSnapshot: OrchestrationEngineShape["getSnapshot"] = () => - Effect.sync((): OrchestrationReadModel => readModel); + const getSnapshot: OrchestrationEngineShape["getSnapshot"] = () => + Effect.sync((): OrchestrationReadModel => readModel); - const replayEvents: OrchestrationEngineShape["replayEvents"] = (fromSequenceExclusive) => - eventStore.readFromSequence(fromSequenceExclusive); + const replayEvents: OrchestrationEngineShape["replayEvents"] = (fromSequenceExclusive) => + eventStore.readFromSequence(fromSequenceExclusive); - const dispatch: OrchestrationEngineShape["dispatch"] = (command) => - Effect.gen(function* () { - const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); - yield* Queue.offer(commandQueue, { command, result }); - return yield* Deferred.await(result); - }); + const dispatch: OrchestrationEngineShape["dispatch"] = (command) => + Effect.gen(function* () { + const result = yield* Deferred.make<{ sequence: number }, OrchestrationDispatchError>(); + yield* Queue.offer(commandQueue, { command, result }); + return yield* Deferred.await(result); + }); - const dispatchUnknown: OrchestrationEngineShape["dispatchUnknown"] = (command) => - Effect.gen(function* () { - const payload = - typeof command === "string" - ? yield* Effect.try({ - try: () => JSON.parse(command) as unknown, - catch: toOrchestrationJsonParseError, - }) - : command; + const dispatchUnknown: OrchestrationEngineShape["dispatchUnknown"] = (command) => + Effect.gen(function* () { + const payload = + typeof command === "string" + ? yield* Effect.try({ + try: () => JSON.parse(command) as unknown, + catch: toOrchestrationJsonParseError, + }) + : command; - const decoded = yield* decodeUnknownCommand(payload).pipe( - Effect.mapError(toOrchestrationCommandDecodeError), - ); + const decoded = yield* decodeUnknownCommand(payload).pipe( + Effect.mapError(toOrchestrationCommandDecodeError), + ); - return yield* dispatch(decoded); - }); + return yield* dispatch(decoded); + }); - const subscribeToReadModel: OrchestrationEngineShape["subscribeToReadModel"] = (callback) => - Effect.sync(() => { - readModelListeners.add(callback); - return () => { - readModelListeners.delete(callback); - }; - }); + const subscribeToReadModel: OrchestrationEngineShape["subscribeToReadModel"] = (callback) => + Effect.sync(() => { + readModelListeners.add(callback); + return () => { + readModelListeners.delete(callback); + }; + }); - const subscribeToDomainEvents: OrchestrationEngineShape["subscribeToDomainEvents"] = ( - callback, - ) => - Effect.sync(() => { - domainEventListeners.add(callback); - return () => { - domainEventListeners.delete(callback); - }; - }); + const subscribeToDomainEvents: OrchestrationEngineShape["subscribeToDomainEvents"] = (callback) => + Effect.sync(() => { + domainEventListeners.add(callback); + return () => { + domainEventListeners.delete(callback); + }; + }); - return { - getSnapshot, - replayEvents, - dispatchUnknown, - dispatch, - subscribeToReadModel, - subscribeToDomainEvents, - } satisfies OrchestrationEngineShape; - }); + return { + getSnapshot, + replayEvents, + dispatchUnknown, + dispatch, + subscribeToReadModel, + subscribeToDomainEvents, + } satisfies OrchestrationEngineShape; +}); export const OrchestrationEngineLive = Layer.effect( OrchestrationEngineService, diff --git a/apps/server/src/orchestration/reducer.ts b/apps/server/src/orchestration/reducer.ts index 8fa8187af3bd7..5ba4334e0fc7c 100644 --- a/apps/server/src/orchestration/reducer.ts +++ b/apps/server/src/orchestration/reducer.ts @@ -36,7 +36,7 @@ function decodeForEvent( ): Effect.Effect { return Schema.decodeUnknownEffect(schema)(value).pipe( Effect.mapError(toReducerDecodeError(`${eventType}:${field}`)), - ) as Effect.Effect + ) as Effect.Effect; } const ThreadCreatedPayloadSchema = Schema.Struct({ diff --git a/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts index 0959c691e39b9..6b11a973e2358 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; -import { ManagedRuntime, Effect } from "effect"; +import { ManagedRuntime, Effect } from "effect"; import { afterEach, describe, expect, it } from "vitest"; import { OrchestrationEventRepository } from "../Services/OrchestrationEvents.ts"; @@ -57,7 +57,6 @@ describe("OrchestrationEventRepository", () => { const stateDir = makeTempDir("t3code-event-store-migrations-"); const dbPath = path.join(stateDir, "orchestration.sqlite"); - const firstRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); const first = await firstRuntime.runPromise(Effect.service(OrchestrationEventRepository)); await firstRuntime.runPromise(first.readAll()); diff --git a/apps/server/src/persistence/Layers/Projects.ts b/apps/server/src/persistence/Layers/Projects.ts index bbda8fa644a7c..e38ef34587655 100644 --- a/apps/server/src/persistence/Layers/Projects.ts +++ b/apps/server/src/persistence/Layers/Projects.ts @@ -381,7 +381,7 @@ const makeRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectRepository.remove:findById")), ); if (Option.isNone(existing)) { - return yield* (new ProjectNotFoundError({ projectId: input.id })); + return yield* new ProjectNotFoundError({ projectId: input.id }); } yield* deleteProject({ id: input.id }).pipe( @@ -396,7 +396,7 @@ const makeRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectRepository.updateScripts:findById")), ); if (Option.isNone(existing)) { - return yield* (new ProjectNotFoundError({ projectId: input.id })); + return yield* new ProjectNotFoundError({ projectId: input.id }); } const nextScripts = yield* parseProjectScripts( diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index c8ea33b12ebb6..53a8b7d558291 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -5,8 +5,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; import { Effect, Layer, ServiceMap } from "effect"; - - import { runMigrations } from "../Migrations.ts"; export interface PersistenceConfigShape { @@ -38,7 +36,6 @@ export function makeSqlitePersistenceLive(dbPath: string) { ).pipe(Layer.provide(SqliteClientLive)); } - export const SqlitePersistenceLive = Layer.unwrap( Effect.map(Effect.service(PersistenceConfig), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)), ); From e7357970e162a7776bec61d6b11fbabd99525472 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 11:13:45 -0800 Subject: [PATCH 15/98] fmt --- apps/server/src/codexTextGenerator.ts | 12 +++++++----- apps/server/src/git.ts | 12 ++++++------ apps/server/src/orchestration/Layer.ts | 4 ++-- .../src/persistence/Layers/OrchestrationEvents.ts | 4 +--- apps/server/src/persistence/Layers/Projects.ts | 4 +--- apps/server/src/terminalManager.ts | 5 ++++- 6 files changed, 21 insertions(+), 20 deletions(-) diff --git a/apps/server/src/codexTextGenerator.ts b/apps/server/src/codexTextGenerator.ts index 51166b380fa09..ebc92ac9a855d 100644 --- a/apps/server/src/codexTextGenerator.ts +++ b/apps/server/src/codexTextGenerator.ts @@ -12,11 +12,13 @@ import type { } from "./coreServices"; import { type ProcessRunOptions, type ProcessRunResult, runProcess } from "./processRunner"; -type ProcessRunner = ( - command: string, - args: readonly string[], - options?: ProcessRunOptions, -) => Promise; +interface ProcessRunner { + ( + command: string, + args: readonly string[], + options?: ProcessRunOptions, + ): Promise; +} const CODEX_MODEL = "gpt-5.3-codex"; const CODEX_REASONING_EFFORT = "low"; diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts index a9a7aa062e557..bf84176a05933 100644 --- a/apps/server/src/git.ts +++ b/apps/server/src/git.ts @@ -29,7 +29,7 @@ export interface TerminalCommandInput { maxOutputBytes?: number; } -export type TerminalCommandResult = ProcessRunResult; + export interface GitStatusDetails extends Omit { upstreamRef: string | null; @@ -159,7 +159,7 @@ function normalizeGitSpawnError(cwd: string, error: unknown): Error { return new Error(`Failed to run git command: ${error.message}`); } -function normalizeGitExecutionError(args: readonly string[], result: TerminalCommandResult): Error { +function normalizeGitExecutionError(args: readonly string[], result: ProcessRunResult): Error { const stderr = result.stderr.trim(); if (stderr.toLowerCase().includes("not a git repository")) { return new Error("Current folder is not a git repository."); @@ -176,8 +176,8 @@ async function runGitOrThrow( cwd: string, args: readonly string[], options: RunGitOptions = {}, -): Promise { - let result: TerminalCommandResult; +): Promise { + let result: ProcessRunResult; try { result = await runGit(args, cwd, options.timeoutMs); } catch (error) { @@ -197,7 +197,7 @@ async function executeGit( cwd: string, args: readonly string[], options: ExecuteGitOptions = {}, -): Promise { +): Promise { const result = await runGitOrThrow(cwd, args, { timeoutMs: options.timeoutMs, allowNonZeroExit: true, @@ -680,7 +680,7 @@ const defaultGitCoreService = new GitCoreService(); export async function runTerminalCommand( input: TerminalCommandInput, -): Promise { +): Promise { const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const shellPath = process.platform === "win32" diff --git a/apps/server/src/orchestration/Layer.ts b/apps/server/src/orchestration/Layer.ts index bd36e701c24b3..1edacd925c0f3 100644 --- a/apps/server/src/orchestration/Layer.ts +++ b/apps/server/src/orchestration/Layer.ts @@ -17,10 +17,10 @@ import { import { createEmptyReadModel, reduceEvent } from "./reducer.ts"; import { OrchestrationEngineService, type OrchestrationEngineShape } from "./Service.ts"; -type CommandEnvelope = { +interface CommandEnvelope { command: OrchestrationCommand; result: Deferred.Deferred<{ sequence: number }, OrchestrationDispatchError>; -}; +} function mapCommandToEvent(command: OrchestrationCommand): Omit { const eventId = crypto.randomUUID(); diff --git a/apps/server/src/persistence/Layers/OrchestrationEvents.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.ts index bbbc26c84237f..bd9f0bd7c39af 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEvents.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.ts @@ -30,8 +30,6 @@ const EventRowSchema = Schema.Struct({ payloadJson: Schema.String, }); -type EventRow = Schema.Schema.Type; - const AppendEventRequestSchema = Schema.Struct({ eventId: Schema.String, type: Schema.String, @@ -48,7 +46,7 @@ const ReadFromSequenceRequestSchema = Schema.Struct({ }); function eventRowToOrchestrationEvent( - row: EventRow, + row: Schema.Schema.Type, operation: string, ): Effect.Effect { return Effect.try({ diff --git a/apps/server/src/persistence/Layers/Projects.ts b/apps/server/src/persistence/Layers/Projects.ts index e38ef34587655..fd6426ecb26e2 100644 --- a/apps/server/src/persistence/Layers/Projects.ts +++ b/apps/server/src/persistence/Layers/Projects.ts @@ -44,8 +44,6 @@ const ProjectRowSchema = Schema.Struct({ updatedAt: Schema.String, }); -type ProjectRow = Schema.Schema.Type; - const FindByIdRequestSchema = Schema.Struct({ id: Schema.String, }); @@ -137,7 +135,7 @@ function decodeScriptsJson( } function rowToProjectRecord( - row: ProjectRow, + row: Schema.Schema.Type, operation: string, ): Effect.Effect { return decodeScriptsJson(row.scriptsJson, `${operation}:scripts`).pipe( diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts index dcdf78615df5d..05217e90a4b86 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminalManager.ts @@ -69,7 +69,10 @@ interface ShellCandidate { args?: string[]; } -type TerminalStartInput = TerminalOpenInput & { cols: number; rows: number }; +interface TerminalStartInput extends TerminalOpenInput { + cols: number; + rows: number +}; function defaultShellResolver(): string { if (process.platform === "win32") { From e4319cecb7674c5da826b1c4beaacc843000d970 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 11:28:50 -0800 Subject: [PATCH 16/98] more effect: add new service definitions --- apps/server/src/provider/Errors.ts | 282 ++++++++++++++++++ .../src/provider/Services/CheckpointStore.ts | 91 ++++++ apps/server/src/provider/Services/Codex.ts | 110 +++++++ apps/server/src/provider/Services/Provider.ts | 113 +++++++ apps/server/src/provider/contracts.test.ts | 125 ++++++++ 5 files changed, 721 insertions(+) create mode 100644 apps/server/src/provider/Errors.ts create mode 100644 apps/server/src/provider/Services/CheckpointStore.ts create mode 100644 apps/server/src/provider/Services/Codex.ts create mode 100644 apps/server/src/provider/Services/Provider.ts create mode 100644 apps/server/src/provider/contracts.test.ts diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts new file mode 100644 index 0000000000000..9655b1f6521a8 --- /dev/null +++ b/apps/server/src/provider/Errors.ts @@ -0,0 +1,282 @@ +import { Schema } from "effect"; + +/** + * CheckpointValidationError - Invalid checkpoint API input. + */ +export class CheckpointValidationError extends Schema.TaggedErrorClass()( + "CheckpointValidationError", + { + operation: Schema.String, + issue: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint validation failed in ${this.operation}: ${this.issue}`; + } +} + +/** + * CheckpointGitCommandError - Git command execution failed in checkpoint store. + */ +export class CheckpointGitCommandError extends Schema.TaggedErrorClass()( + "CheckpointGitCommandError", + { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; + } +} + +/** + * CheckpointUnavailableError - Expected checkpoint does not exist. + */ +export class CheckpointUnavailableError extends Schema.TaggedErrorClass()( + "CheckpointUnavailableError", + { + threadId: Schema.String, + turnCount: Schema.Number, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint unavailable for thread ${this.threadId} turn ${this.turnCount}: ${this.detail}`; + } +} + +/** + * CheckpointRepositoryError - Checkpointing unavailable for cwd/repository. + */ +export class CheckpointRepositoryError extends Schema.TaggedErrorClass()( + "CheckpointRepositoryError", + { + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint repository error for ${this.cwd}: ${this.detail}`; + } +} + +/** + * CodexValidationError - Invalid Codex API input. + */ +export class CodexValidationError extends Schema.TaggedErrorClass()( + "CodexValidationError", + { + operation: Schema.String, + issue: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Codex validation failed in ${this.operation}: ${this.issue}`; + } +} + +/** + * CodexSessionNotFoundError - Session id is unknown. + */ +export class CodexSessionNotFoundError extends Schema.TaggedErrorClass()( + "CodexSessionNotFoundError", + { + sessionId: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Unknown codex session: ${this.sessionId}`; + } +} + +/** + * CodexSessionClosedError - Session exists but is closed. + */ +export class CodexSessionClosedError extends Schema.TaggedErrorClass()( + "CodexSessionClosedError", + { + sessionId: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Codex session is closed: ${this.sessionId}`; + } +} + +/** + * CodexProtocolError - Invalid/unexpected JSON-RPC payload from app-server. + */ +export class CodexProtocolError extends Schema.TaggedErrorClass()( + "CodexProtocolError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Codex protocol error in ${this.operation}: ${this.detail}`; + } +} + +/** + * CodexRequestError - JSON-RPC request failed or timed out. + */ +export class CodexRequestError extends Schema.TaggedErrorClass()( + "CodexRequestError", + { + method: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Codex request failed for ${this.method}: ${this.detail}`; + } +} + +/** + * CodexProcessError - codex app-server process lifecycle failure. + */ +export class CodexProcessError extends Schema.TaggedErrorClass()( + "CodexProcessError", + { + sessionId: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Codex process error for session ${this.sessionId}: ${this.detail}`; + } +} + +/** + * ProviderValidationError - Invalid provider API input. + */ +export class ProviderValidationError extends Schema.TaggedErrorClass()( + "ProviderValidationError", + { + operation: Schema.String, + issue: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Provider validation failed in ${this.operation}: ${this.issue}`; + } +} + +/** + * ProviderUnsupportedError - Requested provider is not implemented. + */ +export class ProviderUnsupportedError extends Schema.TaggedErrorClass()( + "ProviderUnsupportedError", + { + provider: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Provider '${this.provider}' is not implemented`; + } +} + +/** + * ProviderSessionNotFoundError - Provider-facing session not found. + */ +export class ProviderSessionNotFoundError extends Schema.TaggedErrorClass()( + "ProviderSessionNotFoundError", + { + sessionId: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Unknown provider session: ${this.sessionId}`; + } +} + +/** + * ProviderCheckpointUnavailableError - Checkpointing unavailable for this session. + */ +export class ProviderCheckpointUnavailableError extends Schema.TaggedErrorClass()( + "ProviderCheckpointUnavailableError", + { + sessionId: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Provider checkpoint unavailable for session ${this.sessionId}: ${this.detail}`; + } +} + +/** + * ProviderCheckpointRangeError - Requested checkpoint range is invalid. + */ +export class ProviderCheckpointRangeError extends Schema.TaggedErrorClass()( + "ProviderCheckpointRangeError", + { + sessionId: Schema.String, + fromTurnCount: Schema.Number, + toTurnCount: Schema.Number, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Provider checkpoint range error for session ${this.sessionId}: ${this.fromTurnCount}..${this.toTurnCount} (${this.detail})`; + } +} + +/** + * ProviderFilesystemError - Filesystem checkpoint capture/restore failure. + */ +export class ProviderFilesystemError extends Schema.TaggedErrorClass()( + "ProviderFilesystemError", + { + sessionId: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Provider filesystem checkpoint error for session ${this.sessionId}: ${this.detail}`; + } +} + +export type CheckpointStoreError = + | CheckpointValidationError + | CheckpointGitCommandError + | CheckpointUnavailableError + | CheckpointRepositoryError; + +export type CodexServiceError = + | CodexValidationError + | CodexSessionNotFoundError + | CodexSessionClosedError + | CodexProtocolError + | CodexRequestError + | CodexProcessError; + +export type ProviderServiceError = + | ProviderValidationError + | ProviderUnsupportedError + | ProviderSessionNotFoundError + | ProviderCheckpointUnavailableError + | ProviderCheckpointRangeError + | ProviderFilesystemError + | CodexServiceError + | CheckpointStoreError; diff --git a/apps/server/src/provider/Services/CheckpointStore.ts b/apps/server/src/provider/Services/CheckpointStore.ts new file mode 100644 index 0000000000000..98bbacbeda1b8 --- /dev/null +++ b/apps/server/src/provider/Services/CheckpointStore.ts @@ -0,0 +1,91 @@ +/** + * CheckpointStore - Repository interface for filesystem-backed workspace checkpoints. + * + * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed + * domain errors for checkpoint lifecycle operations. + * + * @module CheckpointStore + */ +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { CheckpointStoreError } from "../Errors.ts"; + +export interface CaptureCheckpointInput { + readonly cwd: string; + readonly threadId: string; + readonly turnCount: number; +} + +export interface EnsureRootCheckpointInput { + readonly cwd: string; + readonly threadId: string; +} + +export interface RestoreCheckpointInput { + readonly cwd: string; + readonly threadId: string; + readonly turnCount: number; +} + +export interface DiffCheckpointsInput { + readonly cwd: string; + readonly threadId: string; + readonly fromTurnCount: number; + readonly toTurnCount: number; +} + +export interface PruneAfterTurnInput { + readonly cwd: string; + readonly threadId: string; + readonly maxTurnCount: number; +} + +export interface CheckpointStoreShape { + /** + * Check whether cwd is inside a Git worktree. + */ + readonly isGitRepository: (cwd: string) => Effect.Effect; + + /** + * Capture a checkpoint for a thread at a turn count. + */ + readonly captureCheckpoint: (input: CaptureCheckpointInput) => Effect.Effect; + + /** + * Check whether a checkpoint ref exists. + */ + readonly hasCheckpoint: (input: RestoreCheckpointInput) => Effect.Effect; + + /** + * Ensure a turn-0 checkpoint exists for a thread. + */ + readonly ensureRootCheckpoint: ( + input: EnsureRootCheckpointInput, + ) => Effect.Effect; + + /** + * Restore workspace/staging state to a checkpoint. + */ + readonly restoreCheckpoint: ( + input: RestoreCheckpointInput, + ) => Effect.Effect; + + /** + * Compute patch diff between two checkpoint turn counts. + */ + readonly diffCheckpoints: (input: DiffCheckpointsInput) => Effect.Effect; + + /** + * Delete checkpoints newer than the provided turn count. + */ + readonly pruneAfterTurn: (input: PruneAfterTurnInput) => Effect.Effect; +} + +/** + * CheckpointStore - Context tag for checkpoint persistence and restore operations. + */ +export class CheckpointStore extends ServiceMap.Service< + CheckpointStore, + CheckpointStoreShape +>()("provider/CheckpointStore") {} diff --git a/apps/server/src/provider/Services/Codex.ts b/apps/server/src/provider/Services/Codex.ts new file mode 100644 index 0000000000000..06a556fa3e820 --- /dev/null +++ b/apps/server/src/provider/Services/Codex.ts @@ -0,0 +1,110 @@ +/** + * CodexService - Service interface for codex app-server session lifecycle and RPC. + * + * Uses Effect `ServiceMap.Service` for dependency injection and returns typed + * domain errors for process, protocol, and session operations. + * + * @module CodexService + */ +import type { + ProviderApprovalDecision, + ProviderEvent, + ProviderSendTurnInput, + ProviderSession, + ProviderSessionStartInput, + ProviderTurnStartResult, +} from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { CodexServiceError } from "../Errors.ts"; + +export interface CodexThreadTurnSnapshot { + readonly id: string; + readonly items: ReadonlyArray; +} + +export interface CodexThreadSnapshot { + readonly threadId: string; + readonly turns: ReadonlyArray; +} + +export interface CodexServiceShape { + /** + * Start a codex app-server-backed provider session. + */ + readonly startSession: ( + input: ProviderSessionStartInput, + ) => Effect.Effect; + + /** + * Send a new turn to an active codex session. + */ + readonly sendTurn: ( + input: ProviderSendTurnInput, + ) => Effect.Effect; + + /** + * Interrupt an active turn. + */ + readonly interruptTurn: ( + sessionId: string, + turnId?: string, + ) => Effect.Effect; + + /** + * Read a thread snapshot (including turns). + */ + readonly readThread: (sessionId: string) => Effect.Effect; + + /** + * Roll back a thread by N turns. + */ + readonly rollbackThread: ( + sessionId: string, + numTurns: number, + ) => Effect.Effect; + + /** + * Respond to an interactive approval request from codex app-server. + */ + readonly respondToRequest: ( + sessionId: string, + requestId: string, + decision: ProviderApprovalDecision, + ) => Effect.Effect; + + /** + * Stop one session. + */ + readonly stopSession: (sessionId: string) => Effect.Effect; + + /** + * List active sessions. + */ + readonly listSessions: () => Effect.Effect>; + + /** + * Check whether session id is currently active. + */ + readonly hasSession: (sessionId: string) => Effect.Effect; + + /** + * Stop all sessions. + */ + readonly stopAll: () => Effect.Effect; + + /** + * Subscribe to provider events emitted by codex app-server. + */ + readonly subscribeToEvents: ( + callback: (event: ProviderEvent) => void, + ) => Effect.Effect<() => void, CodexServiceError>; +} + +/** + * CodexService - Context tag for codex session and RPC operations. + */ +export class CodexService extends ServiceMap.Service()( + "provider/CodexService", +) {} diff --git a/apps/server/src/provider/Services/Provider.ts b/apps/server/src/provider/Services/Provider.ts new file mode 100644 index 0000000000000..6ae8ee3511876 --- /dev/null +++ b/apps/server/src/provider/Services/Provider.ts @@ -0,0 +1,113 @@ +/** + * ProviderService - Service interface for provider sessions, turns, and checkpoints. + * + * Uses Effect `ServiceMap.Service` for dependency injection and returns typed + * domain errors for validation, session, codex, and checkpoint workflows. + * + * @module ProviderService + */ +import type { + ProviderEvent, + ProviderGetCheckpointDiffInput, + ProviderGetCheckpointDiffResult, + ProviderInterruptTurnInput, + ProviderListCheckpointsInput, + ProviderListCheckpointsResult, + ProviderRespondToRequestInput, + ProviderRevertToCheckpointInput, + ProviderRevertToCheckpointResult, + ProviderSendTurnInput, + ProviderSession, + ProviderSessionStartInput, + ProviderStopSessionInput, + ProviderTurnStartResult, +} from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProviderServiceError } from "../Errors.ts"; + +export interface ProviderServiceShape { + /** + * Start a provider session. + */ + readonly startSession: ( + input: ProviderSessionStartInput, + ) => Effect.Effect; + + /** + * Send a provider turn. + */ + readonly sendTurn: ( + input: ProviderSendTurnInput, + ) => Effect.Effect; + + /** + * Interrupt a running provider turn. + */ + readonly interruptTurn: ( + input: ProviderInterruptTurnInput, + ) => Effect.Effect; + + /** + * Respond to a provider approval request. + */ + readonly respondToRequest: ( + input: ProviderRespondToRequestInput, + ) => Effect.Effect; + + /** + * Stop a provider session. + */ + readonly stopSession: (input: ProviderStopSessionInput) => Effect.Effect; + + /** + * List active provider sessions. + */ + readonly listSessions: () => Effect.Effect>; + + /** + * List checkpoints for a provider session. + */ + readonly listCheckpoints: ( + input: ProviderListCheckpointsInput, + ) => Effect.Effect; + + /** + * Diff two checkpoints for a provider session. + */ + readonly getCheckpointDiff: ( + input: ProviderGetCheckpointDiffInput, + ) => Effect.Effect; + + /** + * Revert a provider session to a checkpoint. + */ + readonly revertToCheckpoint: ( + input: ProviderRevertToCheckpointInput, + ) => Effect.Effect; + + /** + * Stop all provider sessions. + */ + readonly stopAll: () => Effect.Effect; + + /** + * Dispose provider resources (logs/listeners). + */ + readonly dispose: () => Effect.Effect; + + /** + * Subscribe to provider event stream. + */ + readonly subscribeToEvents: ( + callback: (event: ProviderEvent) => void, + ) => Effect.Effect<() => void, ProviderServiceError>; +} + +/** + * ProviderService - Context tag for provider orchestration. + */ +export class ProviderService extends ServiceMap.Service()( + "provider/ProviderService", +) {} diff --git a/apps/server/src/provider/contracts.test.ts b/apps/server/src/provider/contracts.test.ts new file mode 100644 index 0000000000000..3675af73ebda5 --- /dev/null +++ b/apps/server/src/provider/contracts.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import { Effect, Layer, ManagedRuntime } from "effect"; + +import { + CheckpointValidationError, + CodexValidationError, + ProviderFilesystemError, + ProviderUnsupportedError, + type CheckpointStoreError, + type CodexServiceError, + type ProviderServiceError, +} from "./Errors.ts"; +import { CheckpointStore } from "./Services/CheckpointStore.ts"; +import { CodexService } from "./Services/Codex.ts"; +import { ProviderService } from "./Services/Provider.ts"; + +describe("provider service contracts", () => { + it("resolves provider, codex, and checkpoint services from ServiceMap tags", async () => { + const providerLive: typeof ProviderService.Service = { + startSession: () => + Effect.fail(new ProviderUnsupportedError({ provider: "x" })), + sendTurn: () => + Effect.fail(new ProviderUnsupportedError({ provider: "x" })), + interruptTurn: () => + Effect.fail(new ProviderUnsupportedError({ provider: "x" })), + respondToRequest: () => + Effect.fail(new ProviderUnsupportedError({ provider: "x" })), + stopSession: () => Effect.void, + listSessions: () => Effect.succeed([]), + listCheckpoints: () => + Effect.fail(new ProviderUnsupportedError({ provider: "x" })), + getCheckpointDiff: () => + Effect.fail(new ProviderUnsupportedError({ provider: "x" })), + revertToCheckpoint: () => + Effect.fail(new ProviderUnsupportedError({ provider: "x" })), + stopAll: () => Effect.void, + dispose: () => Effect.void, + subscribeToEvents: () => Effect.succeed(() => undefined), + }; + + const codexLive: typeof CodexService.Service = { + startSession: () => + Effect.fail( + new CodexValidationError({ operation: "startSession", issue: "not implemented" }), + ), + sendTurn: () => + Effect.fail( + new CodexValidationError({ operation: "sendTurn", issue: "not implemented" }), + ), + interruptTurn: () => + Effect.fail( + new CodexValidationError({ operation: "interruptTurn", issue: "not implemented" }), + ), + readThread: () => + Effect.fail( + new CodexValidationError({ operation: "readThread", issue: "not implemented" }), + ), + rollbackThread: () => + Effect.fail( + new CodexValidationError({ operation: "rollbackThread", issue: "not implemented" }), + ), + respondToRequest: () => + Effect.fail( + new CodexValidationError({ operation: "respondToRequest", issue: "not implemented" }), + ), + stopSession: () => Effect.void, + listSessions: () => Effect.succeed([]), + hasSession: () => Effect.succeed(false), + stopAll: () => Effect.void, + subscribeToEvents: () => Effect.succeed(() => undefined), + }; + + const checkpointLive: typeof CheckpointStore.Service = { + isGitRepository: () => Effect.succeed(false), + captureCheckpoint: () => Effect.void, + hasCheckpoint: () => Effect.succeed(false), + ensureRootCheckpoint: () => Effect.succeed(false), + restoreCheckpoint: () => Effect.succeed(false), + diffCheckpoints: () => + Effect.fail( + new CheckpointValidationError({ + operation: "diffCheckpoints", + issue: "not implemented", + }), + ), + pruneAfterTurn: () => Effect.void, + }; + + const runtime = ManagedRuntime.make( + Layer.mergeAll( + Layer.succeed(ProviderService, providerLive), + Layer.succeed(CodexService, codexLive), + Layer.succeed(CheckpointStore, checkpointLive), + ), + ); + + const [provider, codex, checkpoint] = await Promise.all([ + runtime.runPromise(Effect.service(ProviderService)), + runtime.runPromise(Effect.service(CodexService)), + runtime.runPromise(Effect.service(CheckpointStore)), + ]); + + expect(provider).toBe(providerLive); + expect(codex).toBe(codexLive); + expect(checkpoint).toBe(checkpointLive); + + await runtime.dispose(); + }); +}); + +describe("provider error taxonomy", () => { + it("keeps defect cause and message on tagged errors", () => { + const cause = new Error("filesystem failed"); + const error = new ProviderFilesystemError({ + sessionId: "sess-1", + detail: "capture failed", + cause, + }); + + expect(error._tag).toBe("ProviderFilesystemError"); + expect(error.message).toContain("sess-1"); + expect(error.cause).toBe(cause); + }); +}); From 37bd1ecc1fd1a4b06ad7f60f8016fc69a3574f2f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 12:38:21 -0800 Subject: [PATCH 17/98] more effect: new service graph --- apps/server/src/checkpointing/Errors.ts | 148 ++++++++++++++ .../Services/CheckpointCatalog.ts | 83 ++++++++ .../Services/CheckpointService.ts | 78 ++++++++ .../Services/CheckpointStore.ts | 10 +- apps/server/src/orchestration/Service.ts | 8 +- .../Services/OrchestrationEvents.ts | 7 +- .../src/persistence/Services/Projects.ts | 7 +- apps/server/src/provider/Errors.ts | 145 ++++---------- apps/server/src/provider/Services/Codex.ts | 110 ----------- .../src/provider/Services/CodexAdapter.ts | 27 +++ .../src/provider/Services/ProviderAdapter.ts | 104 ++++++++++ .../Services/ProviderAdapterRegistry.ts | 37 ++++ .../{Provider.ts => ProviderService.ts} | 18 +- .../Services/ProviderSessionDirectory.ts | 61 ++++++ apps/server/src/provider/contracts.test.ts | 187 ++++++++++++++---- 15 files changed, 761 insertions(+), 269 deletions(-) create mode 100644 apps/server/src/checkpointing/Errors.ts create mode 100644 apps/server/src/checkpointing/Services/CheckpointCatalog.ts create mode 100644 apps/server/src/checkpointing/Services/CheckpointService.ts rename apps/server/src/{provider => checkpointing}/Services/CheckpointStore.ts (86%) delete mode 100644 apps/server/src/provider/Services/Codex.ts create mode 100644 apps/server/src/provider/Services/CodexAdapter.ts create mode 100644 apps/server/src/provider/Services/ProviderAdapter.ts create mode 100644 apps/server/src/provider/Services/ProviderAdapterRegistry.ts rename apps/server/src/provider/Services/{Provider.ts => ProviderService.ts} (84%) create mode 100644 apps/server/src/provider/Services/ProviderSessionDirectory.ts diff --git a/apps/server/src/checkpointing/Errors.ts b/apps/server/src/checkpointing/Errors.ts new file mode 100644 index 0000000000000..584ce90b7084b --- /dev/null +++ b/apps/server/src/checkpointing/Errors.ts @@ -0,0 +1,148 @@ +import { Schema } from "effect"; + +/** + * CheckpointValidationError - Invalid checkpoint API input. + */ +export class CheckpointValidationError extends Schema.TaggedErrorClass()( + "CheckpointValidationError", + { + operation: Schema.String, + issue: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint validation failed in ${this.operation}: ${this.issue}`; + } +} + +/** + * CheckpointGitCommandError - Git command execution failed in checkpoint store. + */ +export class CheckpointGitCommandError extends Schema.TaggedErrorClass()( + "CheckpointGitCommandError", + { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; + } +} + +/** + * CheckpointUnavailableError - Expected checkpoint does not exist. + */ +export class CheckpointUnavailableError extends Schema.TaggedErrorClass()( + "CheckpointUnavailableError", + { + threadId: Schema.String, + turnCount: Schema.Number, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint unavailable for thread ${this.threadId} turn ${this.turnCount}: ${this.detail}`; + } +} + +/** + * CheckpointRepositoryError - Checkpointing unavailable for cwd/repository. + */ +export class CheckpointRepositoryError extends Schema.TaggedErrorClass()( + "CheckpointRepositoryError", + { + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint repository error for ${this.cwd}: ${this.detail}`; + } +} + +/** + * CheckpointCatalogPersistenceError - Metadata persistence/read failure. + */ +export class CheckpointCatalogPersistenceError extends Schema.TaggedErrorClass()( + "CheckpointCatalogPersistenceError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint catalog persistence error in ${this.operation}: ${this.detail}`; + } +} + +/** + * CheckpointServiceValidationError - Invalid application-level checkpoint request. + */ +export class CheckpointServiceValidationError extends Schema.TaggedErrorClass()( + "CheckpointServiceValidationError", + { + operation: Schema.String, + issue: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint service validation failed in ${this.operation}: ${this.issue}`; + } +} + +/** + * CheckpointSessionNotFoundError - Session is missing in checkpoint workflow. + */ +export class CheckpointSessionNotFoundError extends Schema.TaggedErrorClass()( + "CheckpointSessionNotFoundError", + { + sessionId: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint session not found: ${this.sessionId}`; + } +} + +/** + * CheckpointInvariantError - Inconsistent provider/filesystem/catalog state. + */ +export class CheckpointInvariantError extends Schema.TaggedErrorClass()( + "CheckpointInvariantError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint invariant violation in ${this.operation}: ${this.detail}`; + } +} + +export type CheckpointStoreError = + | CheckpointValidationError + | CheckpointGitCommandError + | CheckpointUnavailableError + | CheckpointRepositoryError; + +export type CheckpointCatalogError = + | CheckpointValidationError + | CheckpointCatalogPersistenceError; + +export type CheckpointServiceError = + | CheckpointStoreError + | CheckpointCatalogError + | CheckpointServiceValidationError + | CheckpointSessionNotFoundError + | CheckpointInvariantError; diff --git a/apps/server/src/checkpointing/Services/CheckpointCatalog.ts b/apps/server/src/checkpointing/Services/CheckpointCatalog.ts new file mode 100644 index 0000000000000..b9e00e08a90dc --- /dev/null +++ b/apps/server/src/checkpointing/Services/CheckpointCatalog.ts @@ -0,0 +1,83 @@ +/** + * CheckpointCatalog - Metadata repository for checkpoints. + * + * Stores user-facing checkpoint metadata used by list/revert APIs. It does not + * touch the filesystem or hidden Git refs; `CheckpointStore` owns that state. + * It also does not drive provider conversation rollback. + * + * @module CheckpointCatalog + */ +import type { ProviderCheckpoint } from "@t3tools/contracts"; +import { Option, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { CheckpointCatalogError } from "../Errors.ts"; + +export interface UpsertCheckpointInput { + readonly providerSessionId: string; + readonly threadId: string; + readonly turnCount: number; + readonly messageCount: number; + readonly label: string; + readonly preview?: string; + readonly createdAt: string; +} + +export interface ListSessionCheckpointsInput { + readonly providerSessionId: string; +} + +export interface GetCheckpointInput { + readonly providerSessionId: string; + readonly turnCount: number; +} + +export interface DeleteAfterTurnInput { + readonly providerSessionId: string; + readonly maxTurnCount: number; +} + +export interface DeleteAllForSessionInput { + readonly providerSessionId: string; +} + +export interface CheckpointCatalogShape { + /** + * Insert or update one checkpoint metadata row. + */ + readonly upsertCheckpoint: (input: UpsertCheckpointInput) => Effect.Effect; + + /** + * List checkpoints for a provider session. + */ + readonly listCheckpoints: ( + input: ListSessionCheckpointsInput, + ) => Effect.Effect, CheckpointCatalogError>; + + /** + * Read one checkpoint by turn count. + */ + readonly getCheckpoint: ( + input: GetCheckpointInput, + ) => Effect.Effect, CheckpointCatalogError>; + + /** + * Delete checkpoint metadata newer than the provided turn count. + */ + readonly deleteAfterTurn: (input: DeleteAfterTurnInput) => Effect.Effect; + + /** + * Delete all checkpoint metadata for a provider session. + */ + readonly deleteAllForSession: ( + input: DeleteAllForSessionInput, + ) => Effect.Effect; +} + +/** + * CheckpointCatalog - Service tag for checkpoint metadata persistence. + */ +export class CheckpointCatalog extends ServiceMap.Service< + CheckpointCatalog, + CheckpointCatalogShape +>()("checkpointing/CheckpointCatalog") {} diff --git a/apps/server/src/checkpointing/Services/CheckpointService.ts b/apps/server/src/checkpointing/Services/CheckpointService.ts new file mode 100644 index 0000000000000..4622b1d182639 --- /dev/null +++ b/apps/server/src/checkpointing/Services/CheckpointService.ts @@ -0,0 +1,78 @@ +/** + * CheckpointService - Application orchestrator for checkpoint workflows. + * + * Coordinates `CheckpointStore` (Git refs), `CheckpointCatalog` (metadata), and + * provider timeline operations resolved through `ProviderSessionDirectory` and + * `ProviderAdapterRegistry` (read/rollback/hasSession on adapters). + * + * This service owns checkpoint workflow rules and validation, while concrete + * storage/process details stay in lower-level services. + * + * @module CheckpointService + */ +import type { + ProviderGetCheckpointDiffInput, + ProviderGetCheckpointDiffResult, + ProviderListCheckpointsInput, + ProviderListCheckpointsResult, + ProviderRevertToCheckpointInput, + ProviderRevertToCheckpointResult, +} from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { CheckpointServiceError } from "../Errors.ts"; + +export interface InitializeCheckpointSessionInput { + readonly providerSessionId: string; + readonly cwd: string; +} + +export interface CaptureCurrentTurnInput { + readonly providerSessionId: string; + readonly turnId?: string; + readonly status?: string; +} + +export interface CheckpointServiceShape { + /** + * Initialize root checkpoint and metadata for a provider session. + */ + readonly initializeForSession: ( + input: InitializeCheckpointSessionInput, + ) => Effect.Effect; + + /** + * Capture the current thread turn into checkpoint refs/metadata. + */ + readonly captureCurrentTurn: (input: CaptureCurrentTurnInput) => Effect.Effect; + + /** + * List checkpoints for a provider session. + */ + readonly listCheckpoints: ( + input: ProviderListCheckpointsInput, + ) => Effect.Effect; + + /** + * Diff two checkpoint turn counts for a provider session. + */ + readonly getCheckpointDiff: ( + input: ProviderGetCheckpointDiffInput, + ) => Effect.Effect; + + /** + * Revert provider/filesystem state to a checkpoint. + */ + readonly revertToCheckpoint: ( + input: ProviderRevertToCheckpointInput, + ) => Effect.Effect; +} + +/** + * CheckpointService - Service tag for checkpoint orchestration. + */ +export class CheckpointService extends ServiceMap.Service< + CheckpointService, + CheckpointServiceShape +>()("checkpointing/CheckpointService") {} diff --git a/apps/server/src/provider/Services/CheckpointStore.ts b/apps/server/src/checkpointing/Services/CheckpointStore.ts similarity index 86% rename from apps/server/src/provider/Services/CheckpointStore.ts rename to apps/server/src/checkpointing/Services/CheckpointStore.ts index 98bbacbeda1b8..81fa60c305f6f 100644 --- a/apps/server/src/provider/Services/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Services/CheckpointStore.ts @@ -1,8 +1,12 @@ /** * CheckpointStore - Repository interface for filesystem-backed workspace checkpoints. * + * Owns hidden Git-ref checkpoint capture/restore and diff computation for a + * workspace thread timeline. It does not store user-facing checkpoint metadata + * and does not coordinate provider conversation rollback. + * * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed - * domain errors for checkpoint lifecycle operations. + * domain errors for checkpoint storage operations. * * @module CheckpointStore */ @@ -83,9 +87,9 @@ export interface CheckpointStoreShape { } /** - * CheckpointStore - Context tag for checkpoint persistence and restore operations. + * CheckpointStore - Service tag for checkpoint persistence and restore operations. */ export class CheckpointStore extends ServiceMap.Service< CheckpointStore, CheckpointStoreShape ->()("provider/CheckpointStore") {} +>()("checkpointing/CheckpointStore") {} diff --git a/apps/server/src/orchestration/Service.ts b/apps/server/src/orchestration/Service.ts index 1dadacce97b56..e841d3afd1dac 100644 --- a/apps/server/src/orchestration/Service.ts +++ b/apps/server/src/orchestration/Service.ts @@ -1,7 +1,11 @@ /** * OrchestrationEngineService - Service interface for orchestration command handling. * - * Uses Effect `Context.Tag` for dependency injection. Command dispatch, + * Owns command validation/dispatch and in-memory read-model updates backed by + * `OrchestrationEventRepository` persistence. It does not own provider process + * management or transport concerns (e.g. websocket request parsing). + * + * Uses Effect `ServiceMap.Service` for dependency injection. Command dispatch, * replay, and unknown-input decoding all return typed domain errors. * * @module OrchestrationEngineService @@ -79,7 +83,7 @@ export interface OrchestrationEngineShape { } /** - * OrchestrationEngineService - Context tag for orchestration engine access. + * OrchestrationEngineService - Service tag for orchestration engine access. * * @example * ```ts diff --git a/apps/server/src/persistence/Services/OrchestrationEvents.ts b/apps/server/src/persistence/Services/OrchestrationEvents.ts index 6761bf9823659..88545f142df8c 100644 --- a/apps/server/src/persistence/Services/OrchestrationEvents.ts +++ b/apps/server/src/persistence/Services/OrchestrationEvents.ts @@ -1,7 +1,10 @@ /** * OrchestrationEventRepository - Repository interface for orchestration events. * - * Uses Effect `Context.Tag` for dependency injection and exposes typed + * Owns durable append/replay access to the orchestration event stream. It does + * not reduce events into read models or apply command validation rules. + * + * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed * persistence/decode errors for event append and replay operations. * * @module OrchestrationEventRepository @@ -44,7 +47,7 @@ export interface OrchestrationEventRepositoryShape { } /** - * OrchestrationEventRepository - Context tag for orchestration event persistence. + * OrchestrationEventRepository - Service tag for orchestration event persistence. * * @example * ```ts diff --git a/apps/server/src/persistence/Services/Projects.ts b/apps/server/src/persistence/Services/Projects.ts index 5f8a67c784059..c27ddab045d4d 100644 --- a/apps/server/src/persistence/Services/Projects.ts +++ b/apps/server/src/persistence/Services/Projects.ts @@ -1,7 +1,10 @@ /** * ProjectRepository - Repository interface for project persistence. * - * Uses Effect `Context.Tag` for dependency injection and returns typed + * Owns persisted project records (cwd, scripts, and cleanup of missing paths). + * It does not manage provider sessions, orchestration events, or websocket APIs. + * + * Uses Effect `ServiceMap.Service` for dependency injection and returns typed * domain errors for all operations. * * @module ProjectRepository @@ -62,7 +65,7 @@ export interface ProjectRepositoryShape { } /** - * ProjectRepository - Context tag for project persistence dependency injection. + * ProjectRepository - Service tag for project persistence dependency injection. * * @example * ```ts diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index 9655b1f6521a8..6339abc05e7f8 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -1,163 +1,104 @@ import { Schema } from "effect"; -/** - * CheckpointValidationError - Invalid checkpoint API input. - */ -export class CheckpointValidationError extends Schema.TaggedErrorClass()( - "CheckpointValidationError", - { - operation: Schema.String, - issue: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Checkpoint validation failed in ${this.operation}: ${this.issue}`; - } -} - -/** - * CheckpointGitCommandError - Git command execution failed in checkpoint store. - */ -export class CheckpointGitCommandError extends Schema.TaggedErrorClass()( - "CheckpointGitCommandError", - { - operation: Schema.String, - command: Schema.String, - cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Checkpoint git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; - } -} - -/** - * CheckpointUnavailableError - Expected checkpoint does not exist. - */ -export class CheckpointUnavailableError extends Schema.TaggedErrorClass()( - "CheckpointUnavailableError", - { - threadId: Schema.String, - turnCount: Schema.Number, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Checkpoint unavailable for thread ${this.threadId} turn ${this.turnCount}: ${this.detail}`; - } -} - -/** - * CheckpointRepositoryError - Checkpointing unavailable for cwd/repository. - */ -export class CheckpointRepositoryError extends Schema.TaggedErrorClass()( - "CheckpointRepositoryError", - { - cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Checkpoint repository error for ${this.cwd}: ${this.detail}`; - } -} +import type { CheckpointServiceError } from "../checkpointing/Errors.ts"; /** - * CodexValidationError - Invalid Codex API input. + * ProviderAdapterValidationError - Invalid adapter API input. */ -export class CodexValidationError extends Schema.TaggedErrorClass()( - "CodexValidationError", +export class ProviderAdapterValidationError extends Schema.TaggedErrorClass()( + "ProviderAdapterValidationError", { + provider: Schema.String, operation: Schema.String, issue: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Codex validation failed in ${this.operation}: ${this.issue}`; + return `Provider adapter validation failed (${this.provider}) in ${this.operation}: ${this.issue}`; } } /** - * CodexSessionNotFoundError - Session id is unknown. + * ProviderAdapterSessionNotFoundError - Adapter-owned session id is unknown. */ -export class CodexSessionNotFoundError extends Schema.TaggedErrorClass()( - "CodexSessionNotFoundError", +export class ProviderAdapterSessionNotFoundError extends Schema.TaggedErrorClass()( + "ProviderAdapterSessionNotFoundError", { + provider: Schema.String, sessionId: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Unknown codex session: ${this.sessionId}`; + return `Unknown ${this.provider} adapter session: ${this.sessionId}`; } } /** - * CodexSessionClosedError - Session exists but is closed. + * ProviderAdapterSessionClosedError - Adapter session exists but is closed. */ -export class CodexSessionClosedError extends Schema.TaggedErrorClass()( - "CodexSessionClosedError", +export class ProviderAdapterSessionClosedError extends Schema.TaggedErrorClass()( + "ProviderAdapterSessionClosedError", { + provider: Schema.String, sessionId: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Codex session is closed: ${this.sessionId}`; + return `${this.provider} adapter session is closed: ${this.sessionId}`; } } /** - * CodexProtocolError - Invalid/unexpected JSON-RPC payload from app-server. + * ProviderAdapterProtocolError - Invalid/unexpected provider protocol payload. */ -export class CodexProtocolError extends Schema.TaggedErrorClass()( - "CodexProtocolError", +export class ProviderAdapterProtocolError extends Schema.TaggedErrorClass()( + "ProviderAdapterProtocolError", { + provider: Schema.String, operation: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Codex protocol error in ${this.operation}: ${this.detail}`; + return `Provider adapter protocol error (${this.provider}) in ${this.operation}: ${this.detail}`; } } /** - * CodexRequestError - JSON-RPC request failed or timed out. + * ProviderAdapterRequestError - Provider protocol request failed or timed out. */ -export class CodexRequestError extends Schema.TaggedErrorClass()( - "CodexRequestError", +export class ProviderAdapterRequestError extends Schema.TaggedErrorClass()( + "ProviderAdapterRequestError", { + provider: Schema.String, method: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Codex request failed for ${this.method}: ${this.detail}`; + return `Provider adapter request failed (${this.provider}) for ${this.method}: ${this.detail}`; } } /** - * CodexProcessError - codex app-server process lifecycle failure. + * ProviderAdapterProcessError - Provider process lifecycle failure. */ -export class CodexProcessError extends Schema.TaggedErrorClass()( - "CodexProcessError", +export class ProviderAdapterProcessError extends Schema.TaggedErrorClass()( + "ProviderAdapterProcessError", { + provider: Schema.String, sessionId: Schema.String, detail: Schema.String, cause: Schema.optional(Schema.Defect), }, ) { override get message(): string { - return `Codex process error for session ${this.sessionId}: ${this.detail}`; + return `Provider adapter process error (${this.provider}) for session ${this.sessionId}: ${this.detail}`; } } @@ -257,19 +198,13 @@ export class ProviderFilesystemError extends Schema.TaggedErrorClass; -} - -export interface CodexThreadSnapshot { - readonly threadId: string; - readonly turns: ReadonlyArray; -} - -export interface CodexServiceShape { - /** - * Start a codex app-server-backed provider session. - */ - readonly startSession: ( - input: ProviderSessionStartInput, - ) => Effect.Effect; - - /** - * Send a new turn to an active codex session. - */ - readonly sendTurn: ( - input: ProviderSendTurnInput, - ) => Effect.Effect; - - /** - * Interrupt an active turn. - */ - readonly interruptTurn: ( - sessionId: string, - turnId?: string, - ) => Effect.Effect; - - /** - * Read a thread snapshot (including turns). - */ - readonly readThread: (sessionId: string) => Effect.Effect; - - /** - * Roll back a thread by N turns. - */ - readonly rollbackThread: ( - sessionId: string, - numTurns: number, - ) => Effect.Effect; - - /** - * Respond to an interactive approval request from codex app-server. - */ - readonly respondToRequest: ( - sessionId: string, - requestId: string, - decision: ProviderApprovalDecision, - ) => Effect.Effect; - - /** - * Stop one session. - */ - readonly stopSession: (sessionId: string) => Effect.Effect; - - /** - * List active sessions. - */ - readonly listSessions: () => Effect.Effect>; - - /** - * Check whether session id is currently active. - */ - readonly hasSession: (sessionId: string) => Effect.Effect; - - /** - * Stop all sessions. - */ - readonly stopAll: () => Effect.Effect; - - /** - * Subscribe to provider events emitted by codex app-server. - */ - readonly subscribeToEvents: ( - callback: (event: ProviderEvent) => void, - ) => Effect.Effect<() => void, CodexServiceError>; -} - -/** - * CodexService - Context tag for codex session and RPC operations. - */ -export class CodexService extends ServiceMap.Service()( - "provider/CodexService", -) {} diff --git a/apps/server/src/provider/Services/CodexAdapter.ts b/apps/server/src/provider/Services/CodexAdapter.ts new file mode 100644 index 0000000000000..2c33eabb77944 --- /dev/null +++ b/apps/server/src/provider/Services/CodexAdapter.ts @@ -0,0 +1,27 @@ +/** + * CodexAdapter - Codex implementation of the generic provider adapter contract. + * + * This service owns Codex app-server process / JSON-RPC semantics and emits + * Codex provider events. It does not perform cross-provider routing, shared + * event fan-out, or checkpoint orchestration. + * + * Uses Effect `ServiceMap.Service` for dependency injection and returns the + * shared provider-adapter error channel with `provider: "codex"` context. + * + * @module CodexAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface CodexAdapterShape extends ProviderAdapterShape { + readonly provider: "codex"; +} + +/** + * CodexAdapter - Service tag for Codex provider adapter operations. + */ +export class CodexAdapter extends ServiceMap.Service()( + "provider/CodexAdapter", +) {} diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts new file mode 100644 index 0000000000000..21b6d8c3dde24 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -0,0 +1,104 @@ +/** + * ProviderAdapter - Provider-specific runtime adapter contract. + * + * Defines the provider-native session/protocol operations that `ProviderService` + * routes to after resolving the target provider. Implementations should focus + * on provider behavior only and avoid cross-provider orchestration concerns. + * + * @module ProviderAdapter + */ +import type { + ProviderApprovalDecision, + ProviderEvent, + ProviderKind, + ProviderSendTurnInput, + ProviderSession, + ProviderSessionStartInput, + ProviderTurnStartResult, +} from "@t3tools/contracts"; +import type { Effect } from "effect"; + +export interface ProviderThreadTurnSnapshot { + readonly id: string; + readonly items: ReadonlyArray; +} + +export interface ProviderThreadSnapshot { + readonly threadId: string; + readonly turns: ReadonlyArray; +} + +export interface ProviderAdapterShape { + /** + * Provider kind implemented by this adapter. + */ + readonly provider: ProviderKind; + + /** + * Start a provider-backed session. + */ + readonly startSession: ( + input: ProviderSessionStartInput, + ) => Effect.Effect; + + /** + * Send a turn to an active provider session. + */ + readonly sendTurn: ( + input: ProviderSendTurnInput, + ) => Effect.Effect; + + /** + * Interrupt an active turn. + */ + readonly interruptTurn: (sessionId: string, turnId?: string) => Effect.Effect; + + /** + * Respond to an interactive approval request. + */ + readonly respondToRequest: ( + sessionId: string, + requestId: string, + decision: ProviderApprovalDecision, + ) => Effect.Effect; + + /** + * Stop one provider session. + */ + readonly stopSession: (sessionId: string) => Effect.Effect; + + /** + * List currently active provider sessions for this adapter. + */ + readonly listSessions: () => Effect.Effect>; + + /** + * Check whether this adapter owns an active session id. + */ + readonly hasSession: (sessionId: string) => Effect.Effect; + + /** + * Read a provider thread snapshot. + */ + readonly readThread: (sessionId: string) => Effect.Effect; + + /** + * Roll back a provider thread by N turns. + */ + readonly rollbackThread: ( + sessionId: string, + numTurns: number, + ) => Effect.Effect; + + /** + * Stop all sessions owned by this adapter. + */ + readonly stopAll: () => Effect.Effect; + + /** + * Subscribe to provider events emitted by this adapter. + */ + readonly subscribeToEvents: ( + callback: (event: ProviderEvent) => void, + ) => Effect.Effect<() => void, TError>; +} diff --git a/apps/server/src/provider/Services/ProviderAdapterRegistry.ts b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts new file mode 100644 index 0000000000000..23066db7911a3 --- /dev/null +++ b/apps/server/src/provider/Services/ProviderAdapterRegistry.ts @@ -0,0 +1,37 @@ +/** + * ProviderAdapterRegistry - Lookup boundary for provider adapter implementations. + * + * Maps a provider kind to the concrete adapter service (Codex, Claude, etc). + * It does not own session lifecycle or routing rules; `ProviderService` uses + * this registry together with `ProviderSessionDirectory`. + * + * @module ProviderAdapterRegistry + */ +import type { ProviderKind } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProviderAdapterError, ProviderUnsupportedError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface ProviderAdapterRegistryShape { + /** + * Resolve the adapter for a provider kind. + */ + readonly getByProvider: ( + provider: ProviderKind, + ) => Effect.Effect, ProviderUnsupportedError>; + + /** + * List provider kinds currently registered. + */ + readonly listProviders: () => Effect.Effect>; +} + +/** + * ProviderAdapterRegistry - Service tag for provider adapter lookup. + */ +export class ProviderAdapterRegistry extends ServiceMap.Service< + ProviderAdapterRegistry, + ProviderAdapterRegistryShape +>()("provider/ProviderAdapterRegistry") {} diff --git a/apps/server/src/provider/Services/Provider.ts b/apps/server/src/provider/Services/ProviderService.ts similarity index 84% rename from apps/server/src/provider/Services/Provider.ts rename to apps/server/src/provider/Services/ProviderService.ts index 6ae8ee3511876..6d546bee6e516 100644 --- a/apps/server/src/provider/Services/Provider.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -1,6 +1,11 @@ /** * ProviderService - Service interface for provider sessions, turns, and checkpoints. * + * Acts as the cross-provider facade used by transports (WebSocket/RPC). It + * resolves provider adapters through `ProviderAdapterRegistry`, routes + * session-scoped calls via `ProviderSessionDirectory`, and exposes one unified + * provider event stream to callers. + * * Uses Effect `ServiceMap.Service` for dependency injection and returns typed * domain errors for validation, session, codex, and checkpoint workflows. * @@ -59,7 +64,9 @@ export interface ProviderServiceShape { /** * Stop a provider session. */ - readonly stopSession: (input: ProviderStopSessionInput) => Effect.Effect; + readonly stopSession: ( + input: ProviderStopSessionInput, + ) => Effect.Effect; /** * List active provider sessions. @@ -92,13 +99,10 @@ export interface ProviderServiceShape { */ readonly stopAll: () => Effect.Effect; - /** - * Dispose provider resources (logs/listeners). - */ - readonly dispose: () => Effect.Effect; - /** * Subscribe to provider event stream. + * + * Fan-out is owned by ProviderService (not by a standalone event-bus service). */ readonly subscribeToEvents: ( callback: (event: ProviderEvent) => void, @@ -106,7 +110,7 @@ export interface ProviderServiceShape { } /** - * ProviderService - Context tag for provider orchestration. + * ProviderService - Service tag for provider orchestration. */ export class ProviderService extends ServiceMap.Service()( "provider/ProviderService", diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts new file mode 100644 index 0000000000000..70cb78b7c02af --- /dev/null +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -0,0 +1,61 @@ +/** + * ProviderSessionDirectory - Session ownership index across provider adapters. + * + * Tracks which provider owns each `sessionId` so `ProviderService` can route + * session-scoped calls to the correct adapter. It is metadata only and does not + * perform provider RPC or checkpoint operations. + * + * @module ProviderSessionDirectory + */ +import type { ProviderKind } from "@t3tools/contracts"; +import { Option, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; + +export interface ProviderSessionBinding { + readonly sessionId: string; + readonly provider: ProviderKind; + readonly threadId?: string; +} + +export interface ProviderSessionDirectoryShape { + /** + * Record or update ownership for one provider session. + */ + readonly upsert: ( + binding: ProviderSessionBinding, + ) => Effect.Effect; + + /** + * Resolve the provider owner for a session id. + */ + readonly getProvider: ( + sessionId: string, + ) => Effect.Effect; + + /** + * Resolve the tracked thread id for a session, if known. + */ + readonly getThreadId: ( + sessionId: string, + ) => Effect.Effect, ProviderSessionNotFoundError>; + + /** + * Remove a session binding. + */ + readonly remove: (sessionId: string) => Effect.Effect; + + /** + * List tracked session ids. + */ + readonly listSessionIds: () => Effect.Effect>; +} + +/** + * ProviderSessionDirectory - Service tag for session ownership lookup. + */ +export class ProviderSessionDirectory extends ServiceMap.Service< + ProviderSessionDirectory, + ProviderSessionDirectoryShape +>()("provider/ProviderSessionDirectory") {} diff --git a/apps/server/src/provider/contracts.test.ts b/apps/server/src/provider/contracts.test.ts index 3675af73ebda5..73756e09cedc9 100644 --- a/apps/server/src/provider/contracts.test.ts +++ b/apps/server/src/provider/contracts.test.ts @@ -1,22 +1,31 @@ import { describe, expect, it } from "vitest"; -import { Effect, Layer, ManagedRuntime } from "effect"; +import { Effect, Layer, ManagedRuntime, Option } from "effect"; import { - CheckpointValidationError, - CodexValidationError, + ProviderAdapterValidationError, ProviderFilesystemError, + ProviderSessionNotFoundError, ProviderUnsupportedError, - type CheckpointStoreError, - type CodexServiceError, + type ProviderAdapterError, type ProviderServiceError, } from "./Errors.ts"; -import { CheckpointStore } from "./Services/CheckpointStore.ts"; -import { CodexService } from "./Services/Codex.ts"; -import { ProviderService } from "./Services/Provider.ts"; +import { + CheckpointValidationError, + type CheckpointCatalogError, + type CheckpointServiceError, + type CheckpointStoreError, +} from "../checkpointing/Errors.ts"; +import { CheckpointCatalog } from "../checkpointing/Services/CheckpointCatalog.ts"; +import { CheckpointService } from "../checkpointing/Services/CheckpointService.ts"; +import { CheckpointStore } from "../checkpointing/Services/CheckpointStore.ts"; +import { CodexAdapter } from "./Services/CodexAdapter.ts"; +import { ProviderAdapterRegistry } from "./Services/ProviderAdapterRegistry.ts"; +import { ProviderService } from "./Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "./Services/ProviderSessionDirectory.ts"; -describe("provider service contracts", () => { - it("resolves provider, codex, and checkpoint services from ServiceMap tags", async () => { +describe("provider/checkpoint service contracts", () => { + it("resolves all service tags from ServiceMap", async () => { const providerLive: typeof ProviderService.Service = { startSession: () => Effect.fail(new ProviderUnsupportedError({ provider: "x" })), @@ -35,34 +44,58 @@ describe("provider service contracts", () => { revertToCheckpoint: () => Effect.fail(new ProviderUnsupportedError({ provider: "x" })), stopAll: () => Effect.void, - dispose: () => Effect.void, subscribeToEvents: () => Effect.succeed(() => undefined), }; - const codexLive: typeof CodexService.Service = { + const codexLive: typeof CodexAdapter.Service = { + provider: "codex", startSession: () => - Effect.fail( - new CodexValidationError({ operation: "startSession", issue: "not implemented" }), + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "startSession", + issue: "not implemented", + }), ), sendTurn: () => - Effect.fail( - new CodexValidationError({ operation: "sendTurn", issue: "not implemented" }), + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "sendTurn", + issue: "not implemented", + }), ), interruptTurn: () => - Effect.fail( - new CodexValidationError({ operation: "interruptTurn", issue: "not implemented" }), + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "interruptTurn", + issue: "not implemented", + }), ), readThread: () => - Effect.fail( - new CodexValidationError({ operation: "readThread", issue: "not implemented" }), + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "readThread", + issue: "not implemented", + }), ), rollbackThread: () => - Effect.fail( - new CodexValidationError({ operation: "rollbackThread", issue: "not implemented" }), + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "rollbackThread", + issue: "not implemented", + }), ), respondToRequest: () => - Effect.fail( - new CodexValidationError({ operation: "respondToRequest", issue: "not implemented" }), + Effect.fail( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "respondToRequest", + issue: "not implemented", + }), ), stopSession: () => Effect.void, listSessions: () => Effect.succeed([]), @@ -71,7 +104,7 @@ describe("provider service contracts", () => { subscribeToEvents: () => Effect.succeed(() => undefined), }; - const checkpointLive: typeof CheckpointStore.Service = { + const checkpointStoreLive: typeof CheckpointStore.Service = { isGitRepository: () => Effect.succeed(false), captureCheckpoint: () => Effect.void, hasCheckpoint: () => Effect.succeed(false), @@ -87,39 +120,117 @@ describe("provider service contracts", () => { pruneAfterTurn: () => Effect.void, }; + const checkpointCatalogLive: typeof CheckpointCatalog.Service = { + upsertCheckpoint: () => Effect.void, + listCheckpoints: () => Effect.succeed([]), + getCheckpoint: () => Effect.succeed(Option.none()), + deleteAfterTurn: () => Effect.void, + deleteAllForSession: () => Effect.void, + }; + + const checkpointServiceLive: typeof CheckpointService.Service = { + initializeForSession: () => Effect.void, + captureCurrentTurn: () => Effect.void, + listCheckpoints: () => + Effect.fail( + new CheckpointValidationError({ operation: "listCheckpoints", issue: "not implemented" }), + ), + getCheckpointDiff: () => + Effect.fail( + new CheckpointValidationError({ + operation: "getCheckpointDiff", + issue: "not implemented", + }), + ), + revertToCheckpoint: () => + Effect.fail( + new CheckpointValidationError({ + operation: "revertToCheckpoint", + issue: "not implemented", + }), + ), + }; + + const providerAdapterRegistryLive: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "codex" + ? Effect.succeed(codexLive) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex"]), + }; + + const providerSessionDirectoryLive: typeof ProviderSessionDirectory.Service = { + upsert: () => Effect.void, + getProvider: (sessionId) => + Effect.fail(new ProviderSessionNotFoundError({ sessionId })), + getThreadId: (sessionId) => + Effect.fail(new ProviderSessionNotFoundError({ sessionId })), + remove: () => Effect.void, + listSessionIds: () => Effect.succeed([]), + }; + const runtime = ManagedRuntime.make( Layer.mergeAll( Layer.succeed(ProviderService, providerLive), - Layer.succeed(CodexService, codexLive), - Layer.succeed(CheckpointStore, checkpointLive), + Layer.succeed(CodexAdapter, codexLive), + Layer.succeed(CheckpointStore, checkpointStoreLive), + Layer.succeed(CheckpointCatalog, checkpointCatalogLive), + Layer.succeed(CheckpointService, checkpointServiceLive), + Layer.succeed(ProviderAdapterRegistry, providerAdapterRegistryLive), + Layer.succeed(ProviderSessionDirectory, providerSessionDirectoryLive), ), ); - const [provider, codex, checkpoint] = await Promise.all([ + const [ + provider, + codex, + checkpointStore, + checkpointCatalog, + checkpointService, + providerAdapterRegistry, + providerSessionDirectory, + ] = await Promise.all([ runtime.runPromise(Effect.service(ProviderService)), - runtime.runPromise(Effect.service(CodexService)), + runtime.runPromise(Effect.service(CodexAdapter)), runtime.runPromise(Effect.service(CheckpointStore)), + runtime.runPromise(Effect.service(CheckpointCatalog)), + runtime.runPromise(Effect.service(CheckpointService)), + runtime.runPromise(Effect.service(ProviderAdapterRegistry)), + runtime.runPromise(Effect.service(ProviderSessionDirectory)), ]); expect(provider).toBe(providerLive); expect(codex).toBe(codexLive); - expect(checkpoint).toBe(checkpointLive); + expect(checkpointStore).toBe(checkpointStoreLive); + expect(checkpointCatalog).toBe(checkpointCatalogLive); + expect(checkpointService).toBe(checkpointServiceLive); + expect(providerAdapterRegistry).toBe(providerAdapterRegistryLive); + expect(providerSessionDirectory).toBe(providerSessionDirectoryLive); await runtime.dispose(); }); }); -describe("provider error taxonomy", () => { - it("keeps defect cause and message on tagged errors", () => { - const cause = new Error("filesystem failed"); - const error = new ProviderFilesystemError({ +describe("provider/checkpoint errors", () => { + it("preserves cause and message on tagged errors", () => { + const fsCause = new Error("filesystem failed"); + const fsError = new ProviderFilesystemError({ sessionId: "sess-1", detail: "capture failed", - cause, + cause: fsCause, + }); + + expect(fsError._tag).toBe("ProviderFilesystemError"); + expect(fsError.message).toContain("sess-1"); + expect(fsError.cause).toBe(fsCause); + }); + + it("accepts checkpoint catalog error channel type", () => { + const error: CheckpointCatalogError = new CheckpointValidationError({ + operation: "upsertCheckpoint", + issue: "turnCount must be >= 0", }); - expect(error._tag).toBe("ProviderFilesystemError"); - expect(error.message).toContain("sess-1"); - expect(error.cause).toBe(cause); + expect(error._tag).toBe("CheckpointValidationError"); }); }); From b3a691f8816a3d44ead95eacab0501d490011876 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 13:58:32 -0800 Subject: [PATCH 18/98] more effect: provider service layers --- .../src/provider/Layers/CodexAdapter.test.ts | 202 +++++++++ .../src/provider/Layers/CodexAdapter.ts | 213 +++++++++ .../Layers/ProviderAdapterRegistry.test.ts | 50 ++ .../Layers/ProviderAdapterRegistry.ts | 57 +++ .../provider/Layers/ProviderService.test.ts | 427 ++++++++++++++++++ .../src/provider/Layers/ProviderService.ts | 339 ++++++++++++++ .../Layers/ProviderSessionDirectory.test.ts | 60 +++ .../Layers/ProviderSessionDirectory.ts | 93 ++++ 8 files changed, 1441 insertions(+) create mode 100644 apps/server/src/provider/Layers/CodexAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/CodexAdapter.ts create mode 100644 apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts create mode 100644 apps/server/src/provider/Layers/ProviderAdapterRegistry.ts create mode 100644 apps/server/src/provider/Layers/ProviderService.test.ts create mode 100644 apps/server/src/provider/Layers/ProviderService.ts create mode 100644 apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts create mode 100644 apps/server/src/provider/Layers/ProviderSessionDirectory.ts diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts new file mode 100644 index 0000000000000..3e12a2a3e7c53 --- /dev/null +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -0,0 +1,202 @@ +import type { + ProviderApprovalDecision, + ProviderEvent, + ProviderSendTurnInput, + ProviderSession, + ProviderSessionStartInput, + ProviderTurnStartResult, +} from "@t3tools/contracts"; +import { afterAll, assert, it, vi } from "@effect/vitest"; +import { assertFailure } from "@effect/vitest/utils"; + +import { Effect } from "effect"; + +import { CodexAppServerManager } from "../../codexAppServerManager.ts"; +import { ProviderAdapterValidationError } from "../Errors.ts"; +import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { makeCodexAdapterLive } from "./CodexAdapter.ts"; + +class FakeCodexManager extends CodexAppServerManager { + public startSessionImpl = vi.fn( + async (input: ProviderSessionStartInput): Promise => { + const now = new Date().toISOString(); + return { + sessionId: "sess-1", + provider: "codex", + status: "ready", + threadId: "thread-1", + cwd: input.cwd, + createdAt: now, + updatedAt: now, + }; + }, + ); + + public sendTurnImpl = vi.fn( + async (_input: ProviderSendTurnInput): Promise => ({ + threadId: "thread-1", + turnId: "turn-1", + }), + ); + + public interruptTurnImpl = vi.fn( + async (_sessionId: string, _turnId?: string): Promise => undefined, + ); + + public readThreadImpl = vi.fn(async (_sessionId: string) => ({ + threadId: "thread-1", + turns: [], + })); + + public rollbackThreadImpl = vi.fn(async (_sessionId: string, _numTurns: number) => ({ + threadId: "thread-1", + turns: [], + })); + + public respondToRequestImpl = vi.fn( + async ( + _sessionId: string, + _requestId: string, + _decision: ProviderApprovalDecision, + ): Promise => undefined, + ); + + public stopAllImpl = vi.fn(() => undefined); + + override startSession(input: ProviderSessionStartInput): Promise { + return this.startSessionImpl(input); + } + + override sendTurn(input: ProviderSendTurnInput): Promise { + return this.sendTurnImpl(input); + } + + override interruptTurn(sessionId: string, turnId?: string): Promise { + return this.interruptTurnImpl(sessionId, turnId); + } + + override readThread(sessionId: string) { + return this.readThreadImpl(sessionId); + } + + override rollbackThread(sessionId: string, numTurns: number) { + return this.rollbackThreadImpl(sessionId, numTurns); + } + + override respondToRequest( + sessionId: string, + requestId: string, + decision: ProviderApprovalDecision, + ): Promise { + return this.respondToRequestImpl(sessionId, requestId, decision); + } + + override stopSession(_sessionId: string): void {} + + override listSessions(): ProviderSession[] { + return []; + } + + override hasSession(_sessionId: string): boolean { + return false; + } + + override stopAll(): void { + this.stopAllImpl(); + } +} + +const validationManager = new FakeCodexManager(); +const validationLayer = it.layer(makeCodexAdapterLive({ manager: validationManager })); + +validationLayer("CodexAdapterLive validation", (it) => { + it.effect("returns validation error for non-codex provider on startSession", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const result = yield* adapter + .startSession({ + provider: "claudeCode", + }) + .pipe(Effect.result); + + assertFailure( + result, + new ProviderAdapterValidationError({ + provider: "codex", + operation: "startSession", + issue: "Expected provider 'codex' but received 'claudeCode'.", + }), + ); + assert.equal(validationManager.startSessionImpl.mock.calls.length, 0); + }), + ); +}); + +const sessionErrorManager = new FakeCodexManager(); +sessionErrorManager.sendTurnImpl.mockImplementation(async () => { + throw new Error("Unknown session: sess-missing"); +}); +const sessionErrorLayer = it.layer(makeCodexAdapterLive({ manager: sessionErrorManager })); + +sessionErrorLayer("CodexAdapterLive session errors", (it) => { + it.effect("maps unknown-session sendTurn errors to ProviderAdapterSessionNotFoundError", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const result = yield* adapter + .sendTurn({ + sessionId: "sess-missing", + input: "hello", + attachments: [], + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag !== "Failure") { + return; + } + + assert.equal(result.failure._tag, "ProviderAdapterSessionNotFoundError"); + if (result.failure._tag !== "ProviderAdapterSessionNotFoundError") { + return; + } + assert.equal(result.failure.provider, "codex"); + assert.equal(result.failure.sessionId, "sess-missing"); + assert.instanceOf(result.failure.cause, Error); + }), + ); +}); + +const lifecycleManager = new FakeCodexManager(); +const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager })); + +lifecycleLayer("CodexAdapterLive lifecycle", (it) => { + it.effect("subscribes and unsubscribes from manager events", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const onEvent = vi.fn((_event: ProviderEvent) => undefined); + const unsubscribe = yield* adapter.subscribeToEvents(onEvent); + + const event: ProviderEvent = { + id: "evt-1", + kind: "notification", + provider: "codex", + sessionId: "sess-1", + createdAt: new Date().toISOString(), + method: "turn/started", + }; + + lifecycleManager.emit("event", event); + assert.equal(onEvent.mock.calls.length, 1); + assert.equal(onEvent.mock.calls[0]?.[0]?.method, "turn/started"); + + unsubscribe(); + lifecycleManager.emit("event", event); + assert.equal(onEvent.mock.calls.length, 1); + assert.equal(lifecycleManager.stopAllImpl.mock.calls.length, 0); + }), + ); +}); + +afterAll(() => { + assert.equal(lifecycleManager.stopAllImpl.mock.calls.length, 1); +}); diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts new file mode 100644 index 0000000000000..2a3307a9626a9 --- /dev/null +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -0,0 +1,213 @@ +/** + * CodexAdapterLive - Scoped live implementation for the Codex provider adapter. + * + * Wraps `CodexAppServerManager` behind the `CodexAdapter` service contract and + * maps manager failures into the shared `ProviderAdapterError` algebra. + * + * @module CodexAdapterLive + */ +import type { ProviderEvent } from "@t3tools/contracts"; +import { Effect, Layer } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { CodexAppServerManager } from "../../codexAppServerManager.ts"; + +const PROVIDER = "codex" as const; + +export interface CodexAdapterLiveOptions { + readonly manager?: CodexAppServerManager; + readonly makeManager?: () => CodexAppServerManager; +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function toSessionError( + sessionId: string, + cause: unknown, +): ProviderAdapterSessionNotFoundError | ProviderAdapterSessionClosedError | undefined { + const normalized = toMessage(cause, "").toLowerCase(); + if (normalized.includes("unknown session") || normalized.includes("unknown provider session")) { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + sessionId, + cause, + }); + } + if (normalized.includes("session is closed")) { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + sessionId, + cause, + }); + } + return undefined; +} + +function toRequestError(sessionId: string, method: string, cause: unknown): ProviderAdapterError { + const sessionError = toSessionError(sessionId, cause); + if (sessionError) { + return sessionError; + } + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: toMessage(cause, `${method} failed`), + cause, + }); +} + +const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => + Effect.gen(function* () { + const manager = yield* Effect.acquireRelease( + Effect.sync(() => { + if (options?.manager) { + return options.manager; + } + if (options?.makeManager) { + return options.makeManager(); + } + return new CodexAppServerManager(); + }), + (manager) => + Effect.sync(() => { + try { + manager.stopAll(); + } catch { + // Finalizers should never fail and block shutdown. + } + }), + ); + + const startSession: CodexAdapterShape["startSession"] = (input) => { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }), + ); + } + + return Effect.tryPromise({ + try: () => manager.startSession(input), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + sessionId: "pending", + detail: toMessage(cause, "Failed to start Codex adapter session."), + cause, + }), + }); + }; + + const sendTurn: CodexAdapterShape["sendTurn"] = (input) => + Effect.tryPromise({ + try: () => manager.sendTurn(input), + catch: (cause) => toRequestError(input.sessionId, "turn/start", cause), + }); + + const interruptTurn: CodexAdapterShape["interruptTurn"] = (sessionId, turnId) => + Effect.tryPromise({ + try: () => manager.interruptTurn(sessionId, turnId), + catch: (cause) => toRequestError(sessionId, "turn/interrupt", cause), + }); + + const readThread: CodexAdapterShape["readThread"] = (sessionId) => + Effect.tryPromise({ + try: () => manager.readThread(sessionId), + catch: (cause) => toRequestError(sessionId, "thread/read", cause), + }); + + const rollbackThread: CodexAdapterShape["rollbackThread"] = (sessionId, numTurns) => { + if (!Number.isInteger(numTurns) || numTurns < 1) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }), + ); + } + + return Effect.tryPromise({ + try: () => manager.rollbackThread(sessionId, numTurns), + catch: (cause) => toRequestError(sessionId, "thread/rollback", cause), + }); + }; + + const respondToRequest: CodexAdapterShape["respondToRequest"] = ( + sessionId, + requestId, + decision, + ) => + Effect.tryPromise({ + try: () => manager.respondToRequest(sessionId, requestId, decision), + catch: (cause) => toRequestError(sessionId, "item/requestApproval/decision", cause), + }); + + const stopSession: CodexAdapterShape["stopSession"] = (sessionId) => + Effect.sync(() => { + manager.stopSession(sessionId); + }); + + const listSessions: CodexAdapterShape["listSessions"] = () => + Effect.sync(() => manager.listSessions()); + + const hasSession: CodexAdapterShape["hasSession"] = (sessionId) => + Effect.sync(() => manager.hasSession(sessionId)); + + const stopAll: CodexAdapterShape["stopAll"] = () => + Effect.sync(() => { + manager.stopAll(); + }); + + const subscribeToEvents: CodexAdapterShape["subscribeToEvents"] = (callback) => + Effect.sync(() => { + const listener = (event: ProviderEvent) => { + try { + callback(event); + } catch { + // Adapter subscribers must not destabilize provider event processing. + } + }; + manager.on("event", listener); + return () => { + manager.off("event", listener); + }; + }); + + return { + provider: PROVIDER, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + stopSession, + listSessions, + hasSession, + stopAll, + subscribeToEvents, + } satisfies CodexAdapterShape; + }); + +export const CodexAdapterLive = Layer.effect(CodexAdapter, makeCodexAdapter()); + +export function makeCodexAdapterLive(options?: CodexAdapterLiveOptions) { + return Layer.effect(CodexAdapter, makeCodexAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts new file mode 100644 index 0000000000000..8384a212823f0 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -0,0 +1,50 @@ +import type { ProviderKind } from "@t3tools/contracts"; +import { it, assert, vi } from "@effect/vitest"; +import { assertFailure } from "@effect/vitest/utils"; + +import { Effect, Layer } from "effect"; + +import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; +import { makeProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; +import { ProviderUnsupportedError } from "../Errors.ts"; + +const fakeCodexAdapter: CodexAdapterShape = { + provider: "codex", + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + subscribeToEvents: vi.fn(), +}; + +const layer = it.layer( + makeProviderAdapterRegistryLive().pipe( + Layer.provide(Layer.succeed(CodexAdapter, fakeCodexAdapter)), + ), +); + +layer("ProviderAdapterRegistryLive", (it) => { + it("resolves a registered provider adapter", () => + Effect.gen(function* () { + const registry = yield* ProviderAdapterRegistry; + const adapter = yield* registry.getByProvider("codex"); + assert.equal(adapter, fakeCodexAdapter); + + const providers = yield* registry.listProviders(); + assert.deepEqual(providers, ["codex"]); + })); + + it("fails with ProviderUnsupportedError for unknown providers", () => + Effect.gen(function* () { + const registry = yield* ProviderAdapterRegistry; + const adapter = yield* registry.getByProvider("unknown" as ProviderKind).pipe(Effect.result); + assertFailure(adapter, new ProviderUnsupportedError({ provider: "unknown" })); + })); +}); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts new file mode 100644 index 0000000000000..ace64737ca35b --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -0,0 +1,57 @@ +/** + * ProviderAdapterRegistryLive - In-memory provider adapter lookup layer. + * + * Binds provider kinds (codex/claudeCode/...) to concrete adapter services. + * This layer only performs adapter lookup; it does not route session-scoped + * calls or own provider lifecycle workflows. + * + * @module ProviderAdapterRegistryLive + */ +import type { ProviderKind } from "@t3tools/contracts"; +import { Effect, Layer } from "effect"; + +import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import { + ProviderAdapterRegistry, + type ProviderAdapterRegistryShape, +} from "../Services/ProviderAdapterRegistry.ts"; +import { CodexAdapter } from "../Services/CodexAdapter.ts"; + +export interface ProviderAdapterRegistryLiveOptions { + readonly adapters?: ReadonlyArray>; +} + +const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => + Effect.gen(function* () { + const adapters = options?.adapters !== undefined ? options.adapters : [yield* CodexAdapter]; + const byProvider = new Map>(); + for (const adapter of adapters) { + byProvider.set(adapter.provider, adapter); + } + + const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { + const adapter = byProvider.get(provider); + if (!adapter) { + return Effect.fail(new ProviderUnsupportedError({ provider })); + } + return Effect.succeed(adapter); + }; + + const listProviders: ProviderAdapterRegistryShape["listProviders"] = () => + Effect.sync(() => Array.from(byProvider.keys())); + + return { + getByProvider, + listProviders, + } satisfies ProviderAdapterRegistryShape; + }); + +export const ProviderAdapterRegistryLive = Layer.effect( + ProviderAdapterRegistry, + makeProviderAdapterRegistry(), +); + +export function makeProviderAdapterRegistryLive(options?: ProviderAdapterRegistryLiveOptions) { + return Layer.effect(ProviderAdapterRegistry, makeProviderAdapterRegistry(options)); +} diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts new file mode 100644 index 0000000000000..726c190620e11 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -0,0 +1,427 @@ +import type { + ProviderApprovalDecision, + ProviderEvent, + ProviderGetCheckpointDiffInput, + ProviderGetCheckpointDiffResult, + ProviderListCheckpointsInput, + ProviderListCheckpointsResult, + ProviderRevertToCheckpointInput, + ProviderRevertToCheckpointResult, + ProviderSendTurnInput, + ProviderSession, + ProviderSessionStartInput, + ProviderTurnStartResult, +} from "@t3tools/contracts"; +import { providerSessionStartInputSchema } from "@t3tools/contracts"; +import { it, assert, vi } from "@effect/vitest"; +import { assertFailure } from "@effect/vitest/utils"; + +import { Effect, Layer } from "effect"; + +import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts"; +import { + ProviderAdapterSessionNotFoundError, + ProviderSessionNotFoundError, + ProviderValidationError, + ProviderUnsupportedError, + type ProviderAdapterError, +} from "../Errors.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; +import { ProviderService } from "../Services/ProviderService.ts"; +import { ProviderServiceLive } from "./ProviderService.ts"; +import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; + +function makeFakeCodexAdapter() { + const sessions = new Map(); + const eventListeners = new Set<(event: ProviderEvent) => void>(); + let nextSession = 1; + + const startSession = vi.fn((input: ProviderSessionStartInput) => + Effect.sync(() => { + const now = new Date().toISOString(); + const session: ProviderSession = { + sessionId: `sess-${nextSession}`, + provider: "codex", + status: "ready", + threadId: `thread-${nextSession}`, + cwd: input.cwd ?? process.cwd(), + createdAt: now, + updatedAt: now, + }; + nextSession += 1; + sessions.set(session.sessionId, session); + return session; + }), + ); + + const sendTurn = vi.fn( + ( + input: ProviderSendTurnInput, + ): Effect.Effect => { + if (!sessions.has(input.sessionId)) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: "codex", + sessionId: input.sessionId, + }), + ); + } + + return Effect.succeed({ + threadId: "thread-1", + turnId: "turn-1", + }); + }, + ); + + const interruptTurn = vi.fn( + (_sessionId: string, _turnId?: string): Effect.Effect => + Effect.void, + ); + + const respondToRequest = vi.fn( + ( + _sessionId: string, + _requestId: string, + _decision: ProviderApprovalDecision, + ): Effect.Effect => Effect.void, + ); + + const stopSession = vi.fn( + (sessionId: string): Effect.Effect => + Effect.sync(() => { + sessions.delete(sessionId); + }), + ); + + const listSessions = vi.fn( + (): Effect.Effect> => + Effect.sync(() => Array.from(sessions.values())), + ); + + const hasSession = vi.fn( + (sessionId: string): Effect.Effect => Effect.succeed(sessions.has(sessionId)), + ); + + const readThread = vi.fn( + ( + _sessionId: string, + ): Effect.Effect<{ threadId: string; turns: readonly [] }, ProviderAdapterError> => + Effect.succeed({ threadId: "thread-1", turns: [] }), + ); + + const rollbackThread = vi.fn( + ( + _sessionId: string, + _numTurns: number, + ): Effect.Effect<{ threadId: string; turns: readonly [] }, ProviderAdapterError> => + Effect.succeed({ threadId: "thread-1", turns: [] }), + ); + + const stopAll = vi.fn( + (): Effect.Effect => + Effect.sync(() => { + sessions.clear(); + }), + ); + + const subscribeToEvents = vi.fn( + (callback: (event: ProviderEvent) => void): Effect.Effect<() => void, ProviderAdapterError> => + Effect.sync(() => { + eventListeners.add(callback); + return () => { + eventListeners.delete(callback); + }; + }), + ); + + const adapter: ProviderAdapterShape = { + provider: "codex", + startSession, + sendTurn, + interruptTurn, + respondToRequest, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + subscribeToEvents, + }; + + const emit = (event: ProviderEvent): void => { + for (const listener of eventListeners) { + listener(event); + } + }; + + return { + adapter, + emit, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + subscribeToEvents, + }; +} + +function makeCheckpointServiceDouble() { + const initializeForSession = vi.fn( + (_: { providerSessionId: string; cwd: string }) => Effect.void, + ); + const captureCurrentTurn = vi.fn( + (_: { providerSessionId: string; turnId?: string; status?: string }) => Effect.void, + ); + const listCheckpoints = vi.fn( + (_input: ProviderListCheckpointsInput): Effect.Effect => + Effect.succeed({ + threadId: "thread-1", + checkpoints: [ + { + id: "root", + turnCount: 0, + messageCount: 0, + label: "Start of conversation", + isCurrent: true, + }, + ], + }), + ); + const getCheckpointDiff = vi.fn( + (input: ProviderGetCheckpointDiffInput): Effect.Effect => + Effect.succeed({ + threadId: "thread-1", + fromTurnCount: input.fromTurnCount, + toTurnCount: input.toTurnCount, + diff: "diff --git a/a.ts b/a.ts", + }), + ); + const revertToCheckpoint = vi.fn( + (input: ProviderRevertToCheckpointInput): Effect.Effect => + Effect.succeed({ + threadId: "thread-1", + turnCount: input.turnCount, + messageCount: 0, + rolledBackTurns: 0, + checkpoints: [ + { + id: "root", + turnCount: 0, + messageCount: 0, + label: "Start of conversation", + isCurrent: input.turnCount === 0, + }, + ], + }), + ); + + const service: typeof CheckpointService.Service = { + initializeForSession, + captureCurrentTurn, + listCheckpoints, + getCheckpointDiff, + revertToCheckpoint, + }; + + return { + service, + initializeForSession, + captureCurrentTurn, + listCheckpoints, + getCheckpointDiff, + revertToCheckpoint, + }; +} + +function makeProviderServiceLayer() { + const codex = makeFakeCodexAdapter(); + const checkpoint = makeCheckpointServiceDouble(); + const registry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "codex" + ? Effect.succeed(codex.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex"]), + }; + + const layer = it.layer( + ProviderServiceLive.pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(ProviderAdapterRegistry, registry), + ProviderSessionDirectoryLive, + Layer.succeed(CheckpointService, checkpoint.service), + ), + ), + ), + ); + + return { + codex, + checkpoint, + layer, + }; +} + +const routing = makeProviderServiceLayer(); +routing.layer("ProviderServiceLive routing", (it) => { + it.effect("routes provider operations and delegates checkpoint workflows", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const session = yield* provider.startSession({ + provider: "codex", + cwd: "/tmp/project", + }); + assert.equal(session.provider, "codex"); + assert.deepEqual(routing.checkpoint.initializeForSession.mock.calls, [ + [{ providerSessionId: session.sessionId, cwd: "/tmp/project" }], + ]); + + const sessions = yield* provider.listSessions(); + assert.equal(sessions.length, 1); + + yield* provider.sendTurn({ + sessionId: session.sessionId, + input: "hello", + attachments: [], + }); + assert.equal(routing.codex.sendTurn.mock.calls.length, 1); + + yield* provider.interruptTurn({ sessionId: session.sessionId }); + assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[session.sessionId, undefined]]); + + yield* provider.respondToRequest({ + sessionId: session.sessionId, + requestId: "req-1", + decision: "accept", + }); + assert.deepEqual(routing.codex.respondToRequest.mock.calls, [ + [session.sessionId, "req-1", "accept"], + ]); + + yield* provider.listCheckpoints({ sessionId: session.sessionId }); + yield* provider.getCheckpointDiff({ + sessionId: session.sessionId, + fromTurnCount: 0, + toTurnCount: 0, + }); + yield* provider.revertToCheckpoint({ + sessionId: session.sessionId, + turnCount: 0, + }); + + assert.deepEqual(routing.checkpoint.listCheckpoints.mock.calls, [ + [{ sessionId: session.sessionId }], + ]); + assert.deepEqual(routing.checkpoint.getCheckpointDiff.mock.calls, [ + [{ sessionId: session.sessionId, fromTurnCount: 0, toTurnCount: 0 }], + ]); + assert.deepEqual(routing.checkpoint.revertToCheckpoint.mock.calls, [ + [{ sessionId: session.sessionId, turnCount: 0 }], + ]); + + yield* provider.stopSession({ sessionId: session.sessionId }); + const sendAfterStop = yield* Effect.result( + provider.sendTurn({ + sessionId: session.sessionId, + input: "after-stop", + attachments: [], + }), + ); + assertFailure( + sendAfterStop, + new ProviderSessionNotFoundError({ sessionId: session.sessionId }), + ); + }), + ); +}); + +const fanout = makeProviderServiceLayer(); +fanout.layer("ProviderServiceLive fanout", (it) => { + it.effect("fans out adapter events and captures turn completion checkpoints", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const session = yield* provider.startSession({ + provider: "codex", + }); + + const onEvent = vi.fn((_event: ProviderEvent) => undefined); + const unsubscribe = yield* provider.subscribeToEvents(onEvent); + + const completedEvent: ProviderEvent = { + id: "evt-1", + kind: "notification", + provider: "codex", + sessionId: session.sessionId, + createdAt: new Date().toISOString(), + method: "turn/completed", + threadId: "thread-1", + turnId: "turn-1", + payload: { + turn: { + status: "completed", + }, + }, + }; + + fanout.codex.emit(completedEvent); + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + + assert.equal(onEvent.mock.calls.length, 1); + assert.equal(onEvent.mock.calls[0]?.[0]?.method, "turn/completed"); + assert.deepEqual(fanout.checkpoint.captureCurrentTurn.mock.calls, [ + [{ providerSessionId: session.sessionId, turnId: "turn-1", status: "completed" }], + ]); + + unsubscribe(); + fanout.codex.emit({ + ...completedEvent, + id: "evt-2", + }); + assert.equal(onEvent.mock.calls.length, 1); + }), + ); +}); + +const validation = makeProviderServiceLayer(); +validation.layer("ProviderServiceLive validation", (it) => { + it.effect("returns ProviderValidationError for invalid input payloads", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const parse = providerSessionStartInputSchema.safeParse({ + provider: "invalid-provider", + }); + assert.equal(parse.success, false); + if (parse.success) { + return; + } + + const failure = yield* Effect.result( + provider.startSession({ + provider: "invalid-provider", + } as never), + ); + + const cause = (parse.error as { cause?: unknown }).cause; + assertFailure( + failure, + new ProviderValidationError({ + operation: "ProviderService.startSession", + issue: parse.error.message, + ...(cause !== undefined ? { cause } : {}), + }), + ); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts new file mode 100644 index 0000000000000..77d1d0f9afc0e --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -0,0 +1,339 @@ +/** + * ProviderServiceLive - Cross-provider orchestration layer. + * + * Routes validated transport/API calls to provider adapters through + * `ProviderAdapterRegistry` and `ProviderSessionDirectory`, and exposes a + * unified provider event stream for subscribers. + * + * It does not implement provider protocol details (adapter concern) and does + * not implement checkpoint persistence mechanics (checkpointing concern). + * + * @module ProviderServiceLive + */ +import { randomUUID } from "node:crypto"; + +import type { ProviderEvent } from "@t3tools/contracts"; +import { + providerGetCheckpointDiffInputSchema, + providerInterruptTurnInputSchema, + providerListCheckpointsInputSchema, + providerRespondToRequestInputSchema, + providerRevertToCheckpointInputSchema, + providerSendTurnInputSchema, + providerSessionStartInputSchema, + providerStopSessionInputSchema, +} from "@t3tools/contracts"; +import { Effect, Layer } from "effect"; + +import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts"; +import { ProviderValidationError } from "../Errors.ts"; +import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; +import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function readTurnCompletionStatus(event: ProviderEvent): string | undefined { + const payload = asObject(event.payload); + const turn = asObject(payload?.turn); + return asString(turn?.status); +} + +function toCheckpointCaptureErrorEvent( + event: ProviderEvent, + error: { readonly message: string }, +): ProviderEvent { + return { + id: randomUUID(), + kind: "error", + provider: event.provider, + sessionId: event.sessionId, + createdAt: new Date().toISOString(), + method: "checkpoint/captureError", + message: error.message, + ...(event.threadId !== undefined ? { threadId: event.threadId } : {}), + ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), + }; +} + +function toValidationError( + operation: string, + issue: string, + cause?: unknown, +): ProviderValidationError { + return new ProviderValidationError({ + operation, + issue, + ...(cause !== undefined ? { cause } : {}), + }); +} + +const makeProviderService = Effect.gen(function* () { + const registry = yield* ProviderAdapterRegistry; + const directory = yield* ProviderSessionDirectory; + const checkpointService = yield* CheckpointService; + + const eventSubscribers = new Set<(event: ProviderEvent) => void>(); + const notifySubscribers = (event: ProviderEvent): void => { + for (const callback of eventSubscribers) { + try { + callback(event); + } catch { + // Subscriber callbacks must not destabilize provider event fanout. + } + } + }; + + const providers = yield* registry.listProviders(); + const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider)); + + yield* Effect.acquireRelease( + Effect.forEach(adapters, (adapter) => + adapter.subscribeToEvents((event) => { + notifySubscribers(event); + if (event.kind !== "notification" || event.method !== "turn/completed") { + return; + } + + const status = readTurnCompletionStatus(event); + const captureInput = { + providerSessionId: event.sessionId, + ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), + ...(status !== undefined ? { status } : {}), + }; + + Effect.runFork( + checkpointService.captureCurrentTurn(captureInput).pipe( + Effect.catch((error) => + Effect.sync(() => { + notifySubscribers(toCheckpointCaptureErrorEvent(event, error)); + }), + ), + ), + ); + }), + ), + (unsubscribeAll) => + Effect.forEach(unsubscribeAll, (unsubscribe) => + Effect.sync(() => { + unsubscribe(); + }), + ).pipe(Effect.asVoid), + ); + + const adapterForSession = (sessionId: string) => + directory + .getProvider(sessionId) + .pipe(Effect.flatMap((provider) => registry.getByProvider(provider))); + + const startSession: ProviderServiceShape["startSession"] = (rawInput) => + Effect.gen(function* () { + const parsed = providerSessionStartInputSchema.safeParse(rawInput); + if (!parsed.success) { + return yield* Effect.fail( + toValidationError( + "ProviderService.startSession", + parsed.error.message, + parsed.error.cause, + ), + ); + } + + const input = parsed.data; + const adapter = yield* registry.getByProvider(input.provider); + const session = yield* adapter.startSession(input); + + if (session.provider !== adapter.provider) { + return yield* Effect.fail( + toValidationError( + "ProviderService.startSession", + `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, + ), + ); + } + + yield* directory.upsert({ + sessionId: session.sessionId, + provider: session.provider, + ...(session.threadId !== undefined ? { threadId: session.threadId } : {}), + }); + + const checkpointCwd = session.cwd ?? input.cwd ?? process.cwd(); + yield* checkpointService.initializeForSession({ + providerSessionId: session.sessionId, + cwd: checkpointCwd, + }); + + return session; + }); + + const sendTurn: ProviderServiceShape["sendTurn"] = (rawInput) => + Effect.gen(function* () { + const parsed = providerSendTurnInputSchema.safeParse(rawInput); + if (!parsed.success) { + return yield* Effect.fail( + toValidationError("ProviderService.sendTurn", parsed.error.message, parsed.error.cause), + ); + } + + const input = parsed.data; + const adapter = yield* adapterForSession(input.sessionId); + return yield* adapter.sendTurn(input); + }); + + const interruptTurn: ProviderServiceShape["interruptTurn"] = (rawInput) => + Effect.gen(function* () { + const parsed = providerInterruptTurnInputSchema.safeParse(rawInput); + if (!parsed.success) { + return yield* Effect.fail( + toValidationError( + "ProviderService.interruptTurn", + parsed.error.message, + parsed.error.cause, + ), + ); + } + + const input = parsed.data; + const adapter = yield* adapterForSession(input.sessionId); + yield* adapter.interruptTurn(input.sessionId, input.turnId); + }); + + const respondToRequest: ProviderServiceShape["respondToRequest"] = (rawInput) => + Effect.gen(function* () { + const parsed = providerRespondToRequestInputSchema.safeParse(rawInput); + if (!parsed.success) { + return yield* Effect.fail( + toValidationError( + "ProviderService.respondToRequest", + parsed.error.message, + parsed.error.cause, + ), + ); + } + + const input = parsed.data; + const adapter = yield* adapterForSession(input.sessionId); + yield* adapter.respondToRequest(input.sessionId, input.requestId, input.decision); + }); + + const stopSession: ProviderServiceShape["stopSession"] = (rawInput) => + Effect.gen(function* () { + const parsed = providerStopSessionInputSchema.safeParse(rawInput); + if (!parsed.success) { + return yield* Effect.fail( + toValidationError( + "ProviderService.stopSession", + parsed.error.message, + parsed.error.cause, + ), + ); + } + + const input = parsed.data; + const adapter = yield* adapterForSession(input.sessionId); + yield* adapter.stopSession(input.sessionId); + yield* directory.remove(input.sessionId); + }); + + const listSessions: ProviderServiceShape["listSessions"] = () => + Effect.forEach(adapters, (adapter) => adapter.listSessions()).pipe( + Effect.map((sessionsByProvider) => sessionsByProvider.flatMap((sessions) => sessions)), + ); + + const listCheckpoints: ProviderServiceShape["listCheckpoints"] = (rawInput) => + Effect.gen(function* () { + const parsed = providerListCheckpointsInputSchema.safeParse(rawInput); + if (!parsed.success) { + return yield* Effect.fail( + toValidationError( + "ProviderService.listCheckpoints", + parsed.error.message, + parsed.error.cause, + ), + ); + } + + const input = parsed.data; + yield* directory.getProvider(input.sessionId); + return yield* checkpointService.listCheckpoints(input); + }); + + const getCheckpointDiff: ProviderServiceShape["getCheckpointDiff"] = (rawInput) => + Effect.gen(function* () { + const parsed = providerGetCheckpointDiffInputSchema.safeParse(rawInput); + if (!parsed.success) { + return yield* Effect.fail( + toValidationError( + "ProviderService.getCheckpointDiff", + parsed.error.message, + parsed.error.cause, + ), + ); + } + + const input = parsed.data; + yield* directory.getProvider(input.sessionId); + return yield* checkpointService.getCheckpointDiff(input); + }); + + const revertToCheckpoint: ProviderServiceShape["revertToCheckpoint"] = (rawInput) => + Effect.gen(function* () { + const parsed = providerRevertToCheckpointInputSchema.safeParse(rawInput); + if (!parsed.success) { + return yield* Effect.fail( + toValidationError( + "ProviderService.revertToCheckpoint", + parsed.error.message, + parsed.error.cause, + ), + ); + } + + const input = parsed.data; + yield* directory.getProvider(input.sessionId); + return yield* checkpointService.revertToCheckpoint(input); + }); + + const stopAll: ProviderServiceShape["stopAll"] = () => + Effect.gen(function* () { + yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); + const sessionIds = yield* directory.listSessionIds(); + yield* Effect.forEach(sessionIds, (sessionId) => directory.remove(sessionId)).pipe( + Effect.asVoid, + ); + }); + + const subscribeToEvents: ProviderServiceShape["subscribeToEvents"] = (callback) => + Effect.sync(() => { + eventSubscribers.add(callback); + return () => { + eventSubscribers.delete(callback); + }; + }); + + return { + startSession, + sendTurn, + interruptTurn, + respondToRequest, + stopSession, + listSessions, + listCheckpoints, + getCheckpointDiff, + revertToCheckpoint, + stopAll, + subscribeToEvents, + } satisfies ProviderServiceShape; +}); + +export const ProviderServiceLive = Layer.effect(ProviderService, makeProviderService); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts new file mode 100644 index 0000000000000..1780609d37054 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -0,0 +1,60 @@ +import { it, assert } from "@effect/vitest"; +import { assertFailure, assertSome } from "@effect/vitest/utils"; +import { Effect } from "effect"; + +import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; +import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; + +const layer = it.layer(ProviderSessionDirectoryLive); + +layer("ProviderSessionDirectoryLive", (it) => { + it("upserts, reads, and removes session bindings", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + + yield* directory.upsert({ + sessionId: "sess-1", + provider: "codex", + threadId: "thread-1", + }); + + const provider = yield* directory.getProvider("sess-1"); + assert.equal(provider, "codex"); + const threadId = yield* directory.getThreadId("sess-1"); + assertSome(threadId, "thread-1"); + + yield* directory.upsert({ + sessionId: "sess-1", + provider: "codex", + threadId: "thread-2", + }); + const updatedThreadId = yield* directory.getThreadId("sess-1"); + assertSome(updatedThreadId, "thread-2"); + + const sessionIds = yield* directory.listSessionIds(); + assert.deepEqual(sessionIds, ["sess-1"]); + + yield* directory.remove("sess-1"); + const missingProvider = yield* directory.getProvider("sess-1").pipe(Effect.result); + assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" })); + })); + + it("fails upsert for empty session id", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const result = yield* Effect.result( + directory.upsert({ + sessionId: " ", + provider: "codex", + }), + ); + assertFailure( + result, + new ProviderValidationError({ + operation: "ProviderSessionDirectory.upsert", + issue: "sessionId must be a non-empty string.", + }), + ); + })); +}); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts new file mode 100644 index 0000000000000..4ff2f04955906 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -0,0 +1,93 @@ +/** + * ProviderSessionDirectoryLive - In-memory session ownership index layer. + * + * Tracks provider ownership and optional thread id by `sessionId` so + * `ProviderService` can route session-scoped API calls to the right adapter. + * This layer stores metadata only and does not call provider adapters directly. + * + * @module ProviderSessionDirectoryLive + */ +import { Effect, Layer, Option, Ref } from "effect"; + +import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; +import { + ProviderSessionDirectory, + type ProviderSessionBinding, + type ProviderSessionDirectoryShape, +} from "../Services/ProviderSessionDirectory.ts"; + +const makeProviderSessionDirectory = Effect.gen(function* () { + const bindingsRef = yield* Ref.make(new Map()); + + const upsert: ProviderSessionDirectoryShape["upsert"] = (binding) => { + const sessionId = binding.sessionId.trim(); + if (sessionId.length === 0) { + return Effect.fail( + new ProviderValidationError({ + operation: "ProviderSessionDirectory.upsert", + issue: "sessionId must be a non-empty string.", + }), + ); + } + + const normalized: ProviderSessionBinding = { + sessionId, + provider: binding.provider, + ...(binding.threadId !== undefined ? { threadId: binding.threadId } : {}), + }; + + return Ref.update(bindingsRef, (current) => { + const next = new Map(current); + next.set(sessionId, normalized); + return next; + }); + }; + + const getProvider: ProviderSessionDirectoryShape["getProvider"] = (sessionId) => + Ref.get(bindingsRef).pipe( + Effect.flatMap((bindings) => { + const binding = bindings.get(sessionId); + if (!binding) { + return Effect.fail(new ProviderSessionNotFoundError({ sessionId })); + } + return Effect.succeed(binding.provider); + }), + ); + + const getThreadId: ProviderSessionDirectoryShape["getThreadId"] = (sessionId) => + Ref.get(bindingsRef).pipe( + Effect.flatMap((bindings) => { + const binding = bindings.get(sessionId); + if (!binding) { + return Effect.fail(new ProviderSessionNotFoundError({ sessionId })); + } + return Effect.succeed(Option.fromNullishOr(binding.threadId)); + }), + ); + + const remove: ProviderSessionDirectoryShape["remove"] = (sessionId) => + Ref.update(bindingsRef, (current) => { + if (!current.has(sessionId)) { + return current; + } + const next = new Map(current); + next.delete(sessionId); + return next; + }); + + const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () => + Ref.get(bindingsRef).pipe(Effect.map((bindings) => Array.from(bindings.keys()))); + + return { + upsert, + getProvider, + getThreadId, + remove, + listSessionIds, + } satisfies ProviderSessionDirectoryShape; +}); + +export const ProviderSessionDirectoryLive = Layer.effect( + ProviderSessionDirectory, + makeProviderSessionDirectory, +); From ab5bf3b6d27fd5f1efafea458d84edf8cb8c2234 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 16:12:48 -0800 Subject: [PATCH 19/98] more effect:checkpointing --- .plans/11-effect.md | 42 + apps/server/src/checkpointing/Errors.ts | 24 +- .../Layers/CheckpointService.test.ts | 404 +++++++ .../checkpointing/Layers/CheckpointService.ts | 1008 +++++++++++++++++ .../checkpointing/Layers/CheckpointStore.ts | 418 +++++++ .../Services/CheckpointCatalog.ts | 83 -- .../Services/CheckpointService.ts | 6 +- .../checkpointing/Services/CheckpointStore.ts | 51 +- apps/server/src/git/Errors.ts | 16 + apps/server/src/git/process.test.ts | 51 + apps/server/src/git/process.ts | 174 +++ apps/server/src/persistence/Errors.ts | 112 +- .../persistence/Layers/Checkpoints.test.ts | 95 ++ .../src/persistence/Layers/Checkpoints.ts | 363 ++++++ apps/server/src/persistence/Migrations.ts | 2 + .../persistence/Migrations/003_Checkpoints.ts | 26 + .../src/persistence/Services/Checkpoints.ts | 95 ++ apps/server/src/provider/contracts.test.ts | 45 +- 18 files changed, 2832 insertions(+), 183 deletions(-) create mode 100644 .plans/11-effect.md create mode 100644 apps/server/src/checkpointing/Layers/CheckpointService.test.ts create mode 100644 apps/server/src/checkpointing/Layers/CheckpointService.ts create mode 100644 apps/server/src/checkpointing/Layers/CheckpointStore.ts delete mode 100644 apps/server/src/checkpointing/Services/CheckpointCatalog.ts create mode 100644 apps/server/src/git/Errors.ts create mode 100644 apps/server/src/git/process.test.ts create mode 100644 apps/server/src/git/process.ts create mode 100644 apps/server/src/persistence/Layers/Checkpoints.test.ts create mode 100644 apps/server/src/persistence/Layers/Checkpoints.ts create mode 100644 apps/server/src/persistence/Migrations/003_Checkpoints.ts create mode 100644 apps/server/src/persistence/Services/Checkpoints.ts diff --git a/.plans/11-effect.md b/.plans/11-effect.md new file mode 100644 index 0000000000000..05f118dd77333 --- /dev/null +++ b/.plans/11-effect.md @@ -0,0 +1,42 @@ + + +PR 1: Service contracts + error taxonomy +Add ProviderService, CodexService, CheckpointStore as Context.Tag service defs. +Add typed Schema.TaggedError hierarchies for all 3 services (cause: Schema.optional(Schema.Defect) on each). +No behavior change yet, just interfaces and compile-time wiring points. +PR 2: CheckpointStore Effect adapter +Wrap current filesystemCheckpointStore behind CheckpointStoreLive (adapter). +Map all thrown/Promise errors to tagged errors. +Add service tests proving parity for isGitRepository, capture, restore, diff, prune. +PR 3: CodexService Effect adapter +Wrap current CodexAppServerManager behind CodexServiceLive (adapter). +Convert public API to Effect return types with typed errors. +Preserve existing EventEmitter internally for now, but expose Effect-friendly subscribe API. +PR 4: ProviderService Effect adapter +Wrap current ProviderManager behind ProviderServiceLive (adapter). +Provider methods become Effect methods with typed errors. +Route emitted provider events through an Effect PubSub surface. +PR 5: wsServer migration to Effect services +Stop instantiating provider/codex classes directly in wsServer. +Resolve ProviderService (and related services) from one runtime/layer graph. +Keep WS contract behavior identical. +PR 6: Native CheckpointStore implementation +Refactor checkpoint internals from Promise/throws to native Effect. +Replace ad-hoc locking with Effect concurrency primitive (keyed lock/semaphore/queue). +Keep adapter tests plus new failure-path tests. +PR 7: Codex transport/RPC core as native Effect +Split codex into scoped process layer + RPC request/response layer + session registry. +Replace timeout/pending maps with Deferred + Effect timeout/finalizer semantics. +Keep protocol behavior and ordering guarantees. +PR 8: Codex protocol decoding hardening +Replace ad-hoc unknown parsing with runtime schema decoding for inbound/outbound protocol shapes. +Map decode failures to typed tagged errors (with root cause). +Add regression tests for malformed/partial protocol messages. +PR 9: Native ProviderService orchestration +Rebuild provider logic in Effect using CodexService + CheckpointStore dependencies. +Move event fanout, checkpoint capture/revert orchestration, thread-log routing to Effect state/services. +Remove throw-based flow entirely from provider path. +PR 10: Cleanup + deprecation removal +Remove legacy class implementations/adapters once parity is proven. +Finalize layer composition and startup graph docs. +Add architecture notes for service boundaries and error model. \ No newline at end of file diff --git a/apps/server/src/checkpointing/Errors.ts b/apps/server/src/checkpointing/Errors.ts index 584ce90b7084b..2e9bbed0997a9 100644 --- a/apps/server/src/checkpointing/Errors.ts +++ b/apps/server/src/checkpointing/Errors.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import type { CheckpointRepositoryError as CheckpointMetadataRepositoryError } from "../persistence/Errors.ts"; /** * CheckpointValidationError - Invalid checkpoint API input. @@ -67,22 +68,6 @@ export class CheckpointRepositoryError extends Schema.TaggedErrorClass()( - "CheckpointCatalogPersistenceError", - { - operation: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect), - }, -) { - override get message(): string { - return `Checkpoint catalog persistence error in ${this.operation}: ${this.detail}`; - } -} - /** * CheckpointServiceValidationError - Invalid application-level checkpoint request. */ @@ -131,18 +116,13 @@ export class CheckpointInvariantError extends Schema.TaggedErrorClass + Effect.acquireRelease( + Effect.promise(() => mkdtemp(path.join(os.tmpdir(), prefix))), + (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })), + ); + +const runGit = (cwd: string, args: readonly string[]) => + runGitProcess({ + operation: "CheckpointService.test.git", + cwd, + args, + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.provide(NodeServices.layer), + ); + +function createRepo() { + return Effect.gen(function* () { + const cwd = yield* makeTemporaryDirectoryScoped("t3-checkpoint-service-repo-"); + yield* runGit(cwd, ["init", "--initial-branch=main"]); + yield* runGit(cwd, ["config", "user.email", "test@example.com"]); + yield* runGit(cwd, ["config", "user.name", "Test User"]); + yield* Effect.promise(() => writeFile(path.join(cwd, "README.md"), "v1\n")); + yield* runGit(cwd, ["add", "."]); + yield* runGit(cwd, ["commit", "-m", "Initial"]); + return cwd; + }); +} + +class InMemoryProviderAdapter implements ProviderAdapterShape { + readonly provider = "codex" as const; + + private active = true; + private snapshot: ProviderThreadSnapshot; + private readonly session: ProviderSession; + + constructor( + readonly sessionId: string, + readonly cwd: string, + turns: ReadonlyArray, + ) { + const now = new Date().toISOString(); + this.session = { + sessionId, + provider: "codex", + status: "ready", + threadId: "thread-1", + cwd, + createdAt: now, + updatedAt: now, + }; + this.snapshot = { + threadId: this.session.threadId ?? "thread-1", + turns, + }; + } + + setTurns(turns: ReadonlyArray): void { + this.snapshot = { + threadId: this.snapshot.threadId, + turns, + }; + } + + getTurns(): ReadonlyArray { + return this.snapshot.turns; + } + + readonly startSession = ( + _input: ProviderSessionStartInput, + ): Effect.Effect => + Effect.fail( + new ProviderAdapterValidationError({ + provider: this.provider, + operation: "startSession", + issue: "not implemented in test adapter", + }), + ); + + readonly sendTurn = ( + _input: ProviderSendTurnInput, + ): Effect.Effect => + Effect.fail( + new ProviderAdapterValidationError({ + provider: this.provider, + operation: "sendTurn", + issue: "not implemented in test adapter", + }), + ); + + readonly interruptTurn = ( + _sessionId: string, + _turnId?: string, + ): Effect.Effect => Effect.void; + + readonly respondToRequest = ( + _sessionId: string, + _requestId: string, + _decision: ProviderApprovalDecision, + ): Effect.Effect => Effect.void; + + readonly stopSession = (sessionId: string): Effect.Effect => + Effect.sync(() => { + if (sessionId === this.sessionId) { + this.active = false; + } + }); + + readonly listSessions = (): Effect.Effect> => + Effect.sync(() => (this.active ? [this.session] : [])); + + readonly hasSession = (sessionId: string): Effect.Effect => + Effect.succeed(this.active && sessionId === this.sessionId); + + readonly readThread = ( + sessionId: string, + ): Effect.Effect => + this.hasSession(sessionId).pipe( + Effect.flatMap((hasSession) => + hasSession + ? Effect.succeed(this.snapshot) + : Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: this.provider, + sessionId, + }), + ), + ), + ); + + readonly rollbackThread = ( + sessionId: string, + numTurns: number, + ): Effect.Effect => + this.readThread(sessionId).pipe( + Effect.flatMap((snapshot) => { + if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > snapshot.turns.length) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: this.provider, + operation: "rollbackThread", + issue: "invalid rollback input", + }), + ); + } + const next = { + threadId: snapshot.threadId, + turns: snapshot.turns.slice(0, snapshot.turns.length - numTurns), + } satisfies ProviderThreadSnapshot; + this.snapshot = next; + return Effect.succeed(next); + }), + ); + + readonly stopAll = (): Effect.Effect => + Effect.sync(() => { + this.active = false; + }); + + readonly subscribeToEvents = ( + _callback: (event: ProviderEvent) => void, + ): Effect.Effect<() => void, ProviderAdapterError> => Effect.succeed(() => undefined); +} + +function makeFixture(turns: ReadonlyArray) { + return Effect.gen(function* () { + const cwd = yield* createRepo(); + const stateDir = yield* makeTemporaryDirectoryScoped("t3-checkpoint-service-state-"); + const dbPath = path.join(stateDir, "orchestration.sqlite"); + + const sessionId = "sess-1"; + const adapter = new InMemoryProviderAdapter(sessionId, cwd, turns); + + const registry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider: ProviderKind) => + provider === "codex" + ? Effect.succeed(adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex"]), + }; + + const dependencies = Layer.mergeAll( + Layer.provide(CheckpointStoreLive, NodeServices.layer), + makeSqliteCheckpointRepositoryLive(dbPath), + Layer.succeed(ProviderAdapterRegistry, registry), + ProviderSessionDirectoryLive, + ); + + const layer = CheckpointServiceLive.pipe(Layer.provideMerge(dependencies)); + + return { + layer, + adapter, + sessionId, + cwd, + }; + }); +} + +it.effect( + "CheckpointServiceLive captures checkpoints, diffs refs, and reverts workspace + turns", + () => + Effect.gen(function* () { + const firstTurn: ProviderThreadTurnSnapshot = { + id: "turn-1", + items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }], + }; + const secondTurn: ProviderThreadTurnSnapshot = { + id: "turn-2", + items: [{ type: "agentMessage", text: "second" }], + }; + + const fixture = yield* makeFixture([]); + + yield* Effect.gen(function* () { + const service = yield* CheckpointService; + const directory = yield* ProviderSessionDirectory; + + yield* directory.upsert({ + sessionId: fixture.sessionId, + provider: "codex", + threadId: "thread-1", + }); + + yield* service.initializeForSession({ + providerSessionId: fixture.sessionId, + cwd: fixture.cwd, + }); + + yield* Effect.promise(() => writeFile(path.join(fixture.cwd, "README.md"), "v2\n")); + fixture.adapter.setTurns([firstTurn]); + + yield* service.captureCurrentTurn({ + providerSessionId: fixture.sessionId, + }); + + const listed = yield* service.listCheckpoints({ sessionId: fixture.sessionId }); + assert.equal(listed.checkpoints.length, 2); + assert.equal(listed.checkpoints[1]?.id, "turn-1"); + assert.equal(listed.checkpoints[1]?.isCurrent, true); + + const diff = yield* service.getCheckpointDiff({ + sessionId: fixture.sessionId, + fromTurnCount: 0, + toTurnCount: 1, + }); + assert.equal(diff.threadId, "thread-1"); + assert.equal(diff.diff.includes("diff --git a/README.md b/README.md"), true); + assert.equal(diff.diff.includes("-v1"), true); + assert.equal(diff.diff.includes("+v2"), true); + + yield* Effect.promise(() => writeFile(path.join(fixture.cwd, "README.md"), "v3\n")); + fixture.adapter.setTurns([firstTurn, secondTurn]); + + yield* service.captureCurrentTurn({ + providerSessionId: fixture.sessionId, + }); + + const beforeRevert = yield* Effect.promise(() => + readFile(path.join(fixture.cwd, "README.md"), "utf8"), + ); + assert.equal(beforeRevert, "v3\n"); + + const reverted = yield* service.revertToCheckpoint({ + sessionId: fixture.sessionId, + turnCount: 1, + }); + + const afterRevert = yield* Effect.promise(() => + readFile(path.join(fixture.cwd, "README.md"), "utf8"), + ); + assert.equal(afterRevert, "v2\n"); + assert.equal(reverted.turnCount, 1); + assert.equal(reverted.rolledBackTurns, 1); + assert.equal(reverted.checkpoints.length, 2); + assert.equal(reverted.checkpoints[1]?.id, "turn-1"); + assert.equal(reverted.checkpoints[1]?.isCurrent, true); + assert.equal(fixture.adapter.getTurns().length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), +); + +it.effect( + "CheckpointServiceLive fails with CheckpointSessionNotFoundError for unknown sessions", + () => + Effect.gen(function* () { + const fixture = yield* makeFixture([]); + + yield* Effect.gen(function* () { + const service = yield* CheckpointService; + const result = yield* service + .listCheckpoints({ + sessionId: "missing", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "CheckpointSessionNotFoundError"); + } + }).pipe(Effect.provide(fixture.layer)); + }), +); + +it.effect( + "CheckpointServiceLive fails with CheckpointUnavailableError when a checkpoint ref is missing", + () => + Effect.gen(function* () { + const firstTurn: ProviderThreadTurnSnapshot = { + id: "turn-1", + items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }], + }; + + const fixture = yield* makeFixture([]); + + yield* Effect.gen(function* () { + const service = yield* CheckpointService; + const store = yield* CheckpointStore; + const checkpointRepository = yield* CheckpointRepository; + const directory = yield* ProviderSessionDirectory; + + yield* directory.upsert({ + sessionId: fixture.sessionId, + provider: "codex", + threadId: "thread-1", + }); + + yield* service.initializeForSession({ + providerSessionId: fixture.sessionId, + cwd: fixture.cwd, + }); + + yield* Effect.promise(() => writeFile(path.join(fixture.cwd, "README.md"), "v2\n")); + fixture.adapter.setTurns([firstTurn]); + yield* service.captureCurrentTurn({ + providerSessionId: fixture.sessionId, + }); + + const checkpoint = yield* checkpointRepository.getCheckpoint({ + providerSessionId: fixture.sessionId, + turnCount: 1, + }); + if (checkpoint._tag !== "Some") { + assert.fail("Expected turn-1 checkpoint row to exist."); + } + + yield* store.deleteCheckpointRefs({ + cwd: fixture.cwd, + checkpointRefs: [checkpoint.value.checkpointRef], + }); + + const result = yield* service + .revertToCheckpoint({ + sessionId: fixture.sessionId, + turnCount: 1, + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "CheckpointUnavailableError"); + } + }).pipe(Effect.provide(fixture.layer)); + }), +); diff --git a/apps/server/src/checkpointing/Layers/CheckpointService.ts b/apps/server/src/checkpointing/Layers/CheckpointService.ts new file mode 100644 index 0000000000000..0677d7615cf03 --- /dev/null +++ b/apps/server/src/checkpointing/Layers/CheckpointService.ts @@ -0,0 +1,1008 @@ +/** + * CheckpointServiceLive - Checkpoint workflow orchestration layer. + * + * Coordinates provider thread snapshots, filesystem checkpoint refs + * (`CheckpointStore`), and checkpoint metadata persistence + * (`CheckpointRepository`) for initialize/capture/list/diff/revert workflows. + * + * This layer owns checkpoint workflow rules and invariants. It does not execute + * provider protocol details directly (adapter concern), and it does not perform + * raw SQL or Git operations directly (catalog/store concerns). + * + * @module CheckpointServiceLive + */ +import type { + ProviderCheckpoint, + ProviderGetCheckpointDiffInput, + ProviderListCheckpointsInput, + ProviderRevertToCheckpointInput, +} from "@t3tools/contracts"; +import { + providerGetCheckpointDiffInputSchema, + providerListCheckpointsInputSchema, + providerRevertToCheckpointInputSchema, +} from "@t3tools/contracts"; +import { Effect, Layer, Option, Ref, Semaphore } from "effect"; + +import { + CheckpointInvariantError, + CheckpointRepositoryError, + CheckpointServiceValidationError, + CheckpointSessionNotFoundError, + CheckpointUnavailableError, + type CheckpointServiceError, +} from "../Errors.ts"; +import { + CheckpointService, + type CaptureCurrentTurnInput, + type CheckpointServiceShape, + type InitializeCheckpointSessionInput, +} from "../Services/CheckpointService.ts"; +import { CheckpointStore } from "../Services/CheckpointStore.ts"; +import { ProviderAdapterRegistry } from "../../provider/Services/ProviderAdapterRegistry.ts"; +import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; +import { CheckpointRepository } from "../../persistence/Services/Checkpoints.ts"; +import type { + ProviderAdapterShape, + ProviderThreadSnapshot, + ProviderThreadTurnSnapshot, +} from "../../provider/Services/ProviderAdapter.ts"; +import type { ProviderAdapterError } from "../../provider/Errors.ts"; + +interface CheckpointCwdResolution { + readonly candidateCwd: string; + readonly checkpointCwd: Option.Option; +} + +const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; + +function checkpointRefThreadSegment(threadId: string): string { + return Buffer.from(threadId, "utf8").toString("base64url"); +} + +function checkpointRefForThreadTurn(threadId: string, turnCount: number): string { + return `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/${turnCount}`; +} + +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function trimToPreview(value: string): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= 120) { + return normalized; + } + return `${normalized.slice(0, 117)}...`; +} + +function summarizeUserMessageContent(content: unknown[]): string | undefined { + const segments: string[] = []; + + for (const part of content) { + const record = asObject(part); + const type = asString(record?.type); + if (!type) { + continue; + } + + if (type === "text") { + const text = asString(record?.text); + if (text && text.trim().length > 0) { + segments.push(text.trim()); + } + continue; + } + + if (type === "image") { + segments.push("[Image attachment]"); + continue; + } + + if (type === "localImage") { + segments.push("[Local image attachment]"); + } + } + + if (segments.length === 0) { + return undefined; + } + return trimToPreview(segments.join(" ")); +} + +function summarizeTurn(turn: ProviderThreadTurnSnapshot): { + readonly messageCountDelta: number; + readonly preview?: string; +} { + let messageCountDelta = 0; + let preview: string | undefined; + + for (const item of turn.items) { + const record = asObject(item); + const type = asString(record?.type); + if (!type) { + continue; + } + + if (type === "userMessage") { + messageCountDelta += 1; + if (!preview) { + const content = asArray(record?.content); + preview = summarizeUserMessageContent(content); + } + continue; + } + + if (type === "agentMessage") { + messageCountDelta += 1; + if (!preview) { + const text = asString(record?.text); + if (text && text.trim().length > 0) { + preview = trimToPreview(text); + } + } + } + } + + return { + messageCountDelta, + ...(preview ? { preview } : {}), + }; +} + +function buildCheckpoints(turns: ReadonlyArray): ProviderCheckpoint[] { + const checkpoints: ProviderCheckpoint[] = []; + let messageCount = 0; + const isEmpty = turns.length === 0; + + checkpoints.push({ + id: "root", + turnCount: 0, + messageCount: 0, + label: "Start of conversation", + isCurrent: isEmpty, + }); + + for (let index = 0; index < turns.length; index += 1) { + const turn = turns[index]; + if (!turn) { + continue; + } + + const turnSummary = summarizeTurn(turn); + messageCount += turnSummary.messageCountDelta; + checkpoints.push({ + id: turn.id, + turnCount: index + 1, + messageCount, + label: `Turn ${index + 1}`, + ...(turnSummary.preview ? { preview: turnSummary.preview } : {}), + isCurrent: index === turns.length - 1, + }); + } + + return checkpoints; +} + +function rootCheckpoint(): ProviderCheckpoint { + return { + id: "root", + turnCount: 0, + messageCount: 0, + label: "Start of conversation", + isCurrent: true, + }; +} + +function validationError( + operation: string, + issue: string, + cause?: unknown, +): CheckpointServiceValidationError { + return new CheckpointServiceValidationError({ + operation, + issue, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function invariantError( + operation: string, + detail: string, + cause?: unknown, +): CheckpointInvariantError { + return new CheckpointInvariantError({ + operation, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function normalizeNonEmpty( + value: string, + operation: string, + field: string, +): Effect.Effect { + const normalized = value.trim(); + if (normalized.length === 0) { + return Effect.fail(validationError(operation, `${field} must be a non-empty string.`)); + } + return Effect.succeed(normalized); +} + +function decodeInitializeInput( + input: InitializeCheckpointSessionInput, +): Effect.Effect { + return Effect.gen(function* () { + const operation = "CheckpointService.initializeForSession"; + const providerSessionId = yield* normalizeNonEmpty( + input.providerSessionId, + operation, + "providerSessionId", + ); + const cwd = yield* normalizeNonEmpty(input.cwd, operation, "cwd"); + return { + providerSessionId, + cwd, + } satisfies InitializeCheckpointSessionInput; + }); +} + +function decodeCaptureInput( + input: CaptureCurrentTurnInput, +): Effect.Effect { + return Effect.gen(function* () { + const operation = "CheckpointService.captureCurrentTurn"; + const providerSessionId = yield* normalizeNonEmpty( + input.providerSessionId, + operation, + "providerSessionId", + ); + const turnId = input.turnId?.trim(); + const status = input.status?.trim(); + + return { + providerSessionId, + ...(turnId ? { turnId } : {}), + ...(status ? { status } : {}), + } satisfies CaptureCurrentTurnInput; + }); +} + +function decodeListInput( + input: ProviderListCheckpointsInput, +): Effect.Effect { + const parsed = providerListCheckpointsInputSchema.safeParse(input); + if (!parsed.success) { + return Effect.fail( + validationError( + "CheckpointService.listCheckpoints", + parsed.error.message, + parsed.error.cause, + ), + ); + } + return Effect.succeed(parsed.data); +} + +function decodeDiffInput( + input: ProviderGetCheckpointDiffInput, +): Effect.Effect { + const parsed = providerGetCheckpointDiffInputSchema.safeParse(input); + if (!parsed.success) { + return Effect.fail( + validationError( + "CheckpointService.getCheckpointDiff", + parsed.error.message, + parsed.error.cause, + ), + ); + } + return Effect.succeed(parsed.data); +} + +function decodeRevertInput( + input: ProviderRevertToCheckpointInput, +): Effect.Effect { + const parsed = providerRevertToCheckpointInputSchema.safeParse(input); + if (!parsed.success) { + return Effect.fail( + validationError( + "CheckpointService.revertToCheckpoint", + parsed.error.message, + parsed.error.cause, + ), + ); + } + return Effect.succeed(parsed.data); +} + +const makeCheckpointService = Effect.gen(function* () { + const store = yield* CheckpointStore; + const checkpointRepository = yield* CheckpointRepository; + const registry = yield* ProviderAdapterRegistry; + const directory = yield* ProviderSessionDirectory; + + const sessionCheckpointCwdsRef = yield* Ref.make>(new Map()); + const sessionSemaphoresRef = yield* Ref.make>( + new Map(), + ); + + const getSessionSemaphore = (sessionId: string) => + Ref.modify(sessionSemaphoresRef, (current) => { + const existing = current.get(sessionId); + if (existing) { + return [existing, current] as const; + } + + const next = new Map(current); + const semaphore = Semaphore.makeUnsafe(1); + next.set(sessionId, semaphore); + return [semaphore, next] as const; + }); + + const withSessionLock = ( + sessionId: string, + effect: Effect.Effect, + ) => + getSessionSemaphore(sessionId).pipe( + Effect.flatMap((semaphore) => semaphore.withPermits(1)(effect)), + ); + + const setTrackedCheckpointCwd = (sessionId: string, cwd: string) => + Ref.update(sessionCheckpointCwdsRef, (current) => { + const next = new Map(current); + next.set(sessionId, cwd); + return next; + }); + + const clearTrackedCheckpointCwd = (sessionId: string) => + Ref.update(sessionCheckpointCwdsRef, (current) => { + if (!current.has(sessionId)) { + return current; + } + const next = new Map(current); + next.delete(sessionId); + return next; + }); + + const getTrackedCheckpointCwd = (sessionId: string) => + Ref.get(sessionCheckpointCwdsRef).pipe( + Effect.map((current) => Option.fromNullishOr(current.get(sessionId))), + ); + + const resolveAdapterForSession = ( + sessionId: string, + ): Effect.Effect, CheckpointServiceError> => + directory.getProvider(sessionId).pipe( + Effect.mapError( + (cause) => + new CheckpointSessionNotFoundError({ + sessionId, + cause, + }), + ), + Effect.flatMap((provider) => + registry + .getByProvider(provider) + .pipe( + Effect.mapError((cause) => + invariantError( + "CheckpointService.resolveAdapterForSession", + `Provider '${provider}' is not registered for session ${sessionId}.`, + cause, + ), + ), + ), + ), + ); + + const ensureSessionIsActive = ( + sessionId: string, + adapter: ProviderAdapterShape, + ): Effect.Effect => + adapter.hasSession(sessionId).pipe( + Effect.flatMap((hasSession) => + hasSession + ? Effect.void + : Effect.fail( + new CheckpointSessionNotFoundError({ + sessionId, + }), + ), + ), + ); + + const readThreadSnapshot = ( + sessionId: string, + adapter: ProviderAdapterShape, + operation: string, + ): Effect.Effect => + adapter + .readThread(sessionId) + .pipe( + Effect.mapError((cause) => + invariantError( + operation, + `Failed to read thread snapshot for session ${sessionId}.`, + cause, + ), + ), + ); + + const rollbackThreadSnapshot = ( + sessionId: string, + adapter: ProviderAdapterShape, + numTurns: number, + operation: string, + ): Effect.Effect => + adapter + .rollbackThread(sessionId, numTurns) + .pipe( + Effect.mapError((cause) => + invariantError(operation, `Failed to rollback thread for session ${sessionId}.`, cause), + ), + ); + + const persistCheckpointMetadata = (input: { + readonly providerSessionId: string; + readonly threadId: string; + readonly checkpoint: ProviderCheckpoint; + }): Effect.Effect => + checkpointRepository.upsertCheckpoint({ + providerSessionId: input.providerSessionId, + threadId: input.threadId, + checkpointId: input.checkpoint.id, + checkpointRef: checkpointRefForThreadTurn(input.threadId, input.checkpoint.turnCount), + turnCount: input.checkpoint.turnCount, + messageCount: input.checkpoint.messageCount, + label: input.checkpoint.label, + ...(input.checkpoint.preview !== undefined ? { preview: input.checkpoint.preview } : {}), + createdAt: new Date().toISOString(), + }); + + const captureAndPersistCheckpoint = (input: { + readonly providerSessionId: string; + readonly cwd: string; + readonly threadId: string; + readonly checkpoint: ProviderCheckpoint; + }): Effect.Effect => { + const checkpointRef = checkpointRefForThreadTurn(input.threadId, input.checkpoint.turnCount); + return Effect.gen(function* () { + yield* store.captureCheckpoint({ + cwd: input.cwd, + checkpointRef, + }); + + yield* persistCheckpointMetadata({ + providerSessionId: input.providerSessionId, + threadId: input.threadId, + checkpoint: input.checkpoint, + }).pipe( + Effect.catch((error) => + store + .deleteCheckpointRefs({ + cwd: input.cwd, + checkpointRefs: [checkpointRef], + }) + .pipe( + Effect.catch(() => Effect.void), + Effect.flatMap(() => Effect.fail(error)), + ), + ), + ); + }); + }; + + const latestCheckpointFromSnapshot = ( + snapshot: ProviderThreadSnapshot, + operation: string, + ): Effect.Effect => { + if (snapshot.turns.length === 0) { + return Effect.fail( + invariantError(operation, "Cannot derive latest checkpoint from an empty thread snapshot."), + ); + } + + const checkpoints = buildCheckpoints(snapshot.turns); + const latest = checkpoints[checkpoints.length - 1]; + if (!latest || latest.turnCount !== snapshot.turns.length) { + return Effect.fail( + invariantError( + operation, + `Unable to derive checkpoint metadata for turn ${snapshot.turns.length}.`, + ), + ); + } + + return Effect.succeed(latest); + }; + + const ensureRootCheckpointMetadata = ( + providerSessionId: string, + operation: string, + ): Effect.Effect => + checkpointRepository.getCheckpoint({ providerSessionId, turnCount: 0 }).pipe( + Effect.flatMap((rootEntry) => + Option.isSome(rootEntry) + ? Effect.void + : Effect.fail( + invariantError( + operation, + `Missing root checkpoint metadata for session ${providerSessionId}.`, + ), + ), + ), + ); + + const getCheckpointRefOrFail = ( + providerSessionId: string, + threadId: string, + turnCount: number, + operation: string, + ): Effect.Effect => + checkpointRepository.getCheckpoint({ providerSessionId, turnCount }).pipe( + Effect.mapError((error): CheckpointServiceError => error), + Effect.flatMap((checkpoint): Effect.Effect => { + if (Option.isNone(checkpoint)) { + return Effect.fail( + new CheckpointUnavailableError({ + threadId, + turnCount, + detail: `Filesystem checkpoint is unavailable for turn ${turnCount} in thread ${threadId}.`, + }), + ); + } + + const checkpointRef = checkpoint.value.checkpointRef.trim(); + if (checkpointRef.length === 0) { + return Effect.fail( + invariantError( + operation, + `Checkpoint metadata for turn ${turnCount} is missing checkpointRef.`, + ), + ); + } + + return Effect.succeed(checkpointRef); + }), + ); + + const resolveCandidateCwd = ( + sessionId: string, + adapter: ProviderAdapterShape, + ): Effect.Effect => + adapter.listSessions().pipe( + Effect.map((sessions) => sessions.find((session) => session.sessionId === sessionId)?.cwd), + Effect.map((cwd) => { + const candidate = cwd?.trim() || process.cwd(); + if (candidate.length > 0) { + return candidate; + } + return process.cwd(); + }), + ); + + const resolveCheckpointCwd = ( + sessionId: string, + adapter: ProviderAdapterShape, + ): Effect.Effect => + Effect.gen(function* () { + const tracked = yield* getTrackedCheckpointCwd(sessionId); + if (Option.isSome(tracked)) { + return { + candidateCwd: tracked.value, + checkpointCwd: tracked, + } satisfies CheckpointCwdResolution; + } + + const candidateCwd = yield* resolveCandidateCwd(sessionId, adapter); + const supportsGit = yield* store.isGitRepository(candidateCwd); + if (!supportsGit) { + yield* clearTrackedCheckpointCwd(sessionId); + return { + candidateCwd, + checkpointCwd: Option.none(), + } satisfies CheckpointCwdResolution; + } + + yield* setTrackedCheckpointCwd(sessionId, candidateCwd); + + return { + candidateCwd, + checkpointCwd: Option.some(candidateCwd), + } satisfies CheckpointCwdResolution; + }); + + const initializeForSession: CheckpointServiceShape["initializeForSession"] = (rawInput) => + decodeInitializeInput(rawInput).pipe( + Effect.flatMap((input) => + withSessionLock( + input.providerSessionId, + Effect.gen(function* () { + const adapter = yield* resolveAdapterForSession(input.providerSessionId); + yield* ensureSessionIsActive(input.providerSessionId, adapter); + const snapshot = yield* readThreadSnapshot( + input.providerSessionId, + adapter, + "CheckpointService.initializeForSession:readThread", + ); + if (snapshot.turns.length !== 0) { + return yield* Effect.fail( + invariantError( + "CheckpointService.initializeForSession", + `Expected empty thread snapshot during initialization, received ${snapshot.turns.length} turn(s).`, + ), + ); + } + + const supportsGit = yield* store.isGitRepository(input.cwd); + if (!supportsGit) { + yield* clearTrackedCheckpointCwd(input.providerSessionId); + return; + } + + const existingRoot = yield* checkpointRepository.getCheckpoint({ + providerSessionId: input.providerSessionId, + turnCount: 0, + }); + if (Option.isSome(existingRoot)) { + const existingRef = existingRoot.value.checkpointRef.trim(); + if (existingRef.length === 0) { + return yield* Effect.fail( + invariantError( + "CheckpointService.initializeForSession", + "Existing root checkpoint metadata is missing checkpointRef.", + ), + ); + } + const exists = yield* store.hasCheckpointRef({ + cwd: input.cwd, + checkpointRef: existingRef, + }); + if (!exists) { + return yield* Effect.fail( + invariantError( + "CheckpointService.initializeForSession", + "Root checkpoint metadata exists but filesystem ref is missing.", + ), + ); + } + yield* setTrackedCheckpointCwd(input.providerSessionId, input.cwd); + return; + } + + yield* captureAndPersistCheckpoint({ + providerSessionId: input.providerSessionId, + cwd: input.cwd, + threadId: snapshot.threadId, + checkpoint: rootCheckpoint(), + }); + yield* setTrackedCheckpointCwd(input.providerSessionId, input.cwd); + }), + ), + ), + ); + + const captureCurrentTurn: CheckpointServiceShape["captureCurrentTurn"] = (rawInput) => + decodeCaptureInput(rawInput).pipe( + Effect.flatMap((input) => + withSessionLock( + input.providerSessionId, + Effect.gen(function* () { + const adapter = yield* resolveAdapterForSession(input.providerSessionId); + yield* ensureSessionIsActive(input.providerSessionId, adapter); + const snapshot = yield* readThreadSnapshot( + input.providerSessionId, + adapter, + "CheckpointService.captureCurrentTurn:readThread", + ); + if (snapshot.turns.length === 0) { + return; + } + + const resolution = yield* resolveCheckpointCwd(input.providerSessionId, adapter); + if (Option.isNone(resolution.checkpointCwd)) { + return; + } + + const checkpoint = yield* latestCheckpointFromSnapshot( + snapshot, + "CheckpointService.captureCurrentTurn", + ); + + yield* ensureRootCheckpointMetadata( + input.providerSessionId, + "CheckpointService.captureCurrentTurn", + ); + + const existing = yield* checkpointRepository.getCheckpoint({ + providerSessionId: input.providerSessionId, + turnCount: checkpoint.turnCount, + }); + if (Option.isSome(existing)) { + const checkpointRef = existing.value.checkpointRef.trim(); + if (checkpointRef.length === 0) { + return yield* Effect.fail( + invariantError( + "CheckpointService.captureCurrentTurn", + `Checkpoint metadata for turn ${checkpoint.turnCount} is missing checkpointRef.`, + ), + ); + } + const exists = yield* store.hasCheckpointRef({ + cwd: resolution.checkpointCwd.value, + checkpointRef, + }); + if (!exists) { + return yield* Effect.fail( + invariantError( + "CheckpointService.captureCurrentTurn", + `Checkpoint metadata exists for turn ${checkpoint.turnCount} but filesystem ref is missing.`, + ), + ); + } + return; + } + + yield* captureAndPersistCheckpoint({ + providerSessionId: input.providerSessionId, + cwd: resolution.checkpointCwd.value, + threadId: snapshot.threadId, + checkpoint, + }); + }), + ), + ), + ); + + const listCheckpoints: CheckpointServiceShape["listCheckpoints"] = (rawInput) => + decodeListInput(rawInput).pipe( + Effect.flatMap((input) => + withSessionLock( + input.sessionId, + Effect.gen(function* () { + const adapter = yield* resolveAdapterForSession(input.sessionId); + yield* ensureSessionIsActive(input.sessionId, adapter); + const snapshot = yield* readThreadSnapshot( + input.sessionId, + adapter, + "CheckpointService.listCheckpoints:readThread", + ); + + const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); + if (Option.isNone(resolution.checkpointCwd)) { + return yield* Effect.fail( + new CheckpointRepositoryError({ + cwd: resolution.candidateCwd, + detail: "Filesystem checkpoints are unavailable for this session.", + }), + ); + } + + const checkpoints = yield* checkpointRepository.listCheckpoints({ + providerSessionId: input.sessionId, + }); + if (checkpoints.length === 0) { + return yield* Effect.fail( + invariantError( + "CheckpointService.listCheckpoints", + `Missing checkpoint metadata for active session ${input.sessionId}.`, + ), + ); + } + + return { + threadId: snapshot.threadId, + checkpoints: Array.from(checkpoints), + }; + }), + ), + ), + ); + + const getCheckpointDiff: CheckpointServiceShape["getCheckpointDiff"] = (rawInput) => + decodeDiffInput(rawInput).pipe( + Effect.flatMap((input) => + withSessionLock( + input.sessionId, + Effect.gen(function* () { + const adapter = yield* resolveAdapterForSession(input.sessionId); + yield* ensureSessionIsActive(input.sessionId, adapter); + const snapshot = yield* readThreadSnapshot( + input.sessionId, + adapter, + "CheckpointService.getCheckpointDiff:readThread", + ); + + if (input.toTurnCount > snapshot.turns.length) { + return yield* Effect.fail( + validationError( + "CheckpointService.getCheckpointDiff", + `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`, + ), + ); + } + + const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); + if (Option.isNone(resolution.checkpointCwd)) { + return yield* Effect.fail( + new CheckpointRepositoryError({ + cwd: resolution.candidateCwd, + detail: "Filesystem checkpoints are unavailable for this session.", + }), + ); + } + + const fromCheckpointRef = yield* getCheckpointRefOrFail( + input.sessionId, + snapshot.threadId, + input.fromTurnCount, + "CheckpointService.getCheckpointDiff:fromCheckpoint", + ); + const toCheckpointRef = yield* getCheckpointRefOrFail( + input.sessionId, + snapshot.threadId, + input.toTurnCount, + "CheckpointService.getCheckpointDiff:toCheckpoint", + ); + + const diff = yield* store.diffCheckpoints({ + cwd: resolution.checkpointCwd.value, + fromCheckpointRef, + toCheckpointRef, + fallbackFromToHead: input.fromTurnCount === 0, + }); + + return { + threadId: snapshot.threadId, + fromTurnCount: input.fromTurnCount, + toTurnCount: input.toTurnCount, + diff, + }; + }), + ), + ), + ); + + const revertToCheckpoint: CheckpointServiceShape["revertToCheckpoint"] = (rawInput) => + decodeRevertInput(rawInput).pipe( + Effect.flatMap((input) => + withSessionLock( + input.sessionId, + Effect.gen(function* () { + const adapter = yield* resolveAdapterForSession(input.sessionId); + yield* ensureSessionIsActive(input.sessionId, adapter); + const beforeSnapshot = yield* readThreadSnapshot( + input.sessionId, + adapter, + "CheckpointService.revertToCheckpoint:readThread", + ); + const currentTurnCount = beforeSnapshot.turns.length; + + if (input.turnCount > currentTurnCount) { + return yield* Effect.fail( + validationError( + "CheckpointService.revertToCheckpoint", + `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`, + ), + ); + } + + const resolution = yield* resolveCheckpointCwd(input.sessionId, adapter); + if (Option.isNone(resolution.checkpointCwd)) { + return yield* Effect.fail( + new CheckpointRepositoryError({ + cwd: resolution.candidateCwd, + detail: "Filesystem checkpoints are unavailable for this session.", + }), + ); + } + + const checkpointCwd = resolution.checkpointCwd.value; + const checkpointRef = yield* getCheckpointRefOrFail( + input.sessionId, + beforeSnapshot.threadId, + input.turnCount, + "CheckpointService.revertToCheckpoint:checkpointLookup", + ); + + const restored = yield* store.restoreCheckpoint({ + cwd: checkpointCwd, + checkpointRef, + fallbackToHead: input.turnCount === 0, + }); + if (!restored) { + return yield* Effect.fail( + new CheckpointUnavailableError({ + threadId: beforeSnapshot.threadId, + turnCount: input.turnCount, + detail: `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, + }), + ); + } + + const requestedRollbackTurns = currentTurnCount - input.turnCount; + const afterSnapshot = + requestedRollbackTurns > 0 + ? yield* rollbackThreadSnapshot( + input.sessionId, + adapter, + requestedRollbackTurns, + "CheckpointService.revertToCheckpoint:rollbackThread", + ) + : beforeSnapshot; + + const staleTurnCounts: number[] = []; + for (let turn = input.turnCount + 1; turn <= currentTurnCount; turn += 1) { + staleTurnCounts.push(turn); + } + + const staleCheckpointRefs = yield* Effect.forEach( + staleTurnCounts, + (turnCount) => + checkpointRepository.getCheckpoint({ + providerSessionId: input.sessionId, + turnCount, + }), + { discard: false }, + ).pipe( + Effect.map((entries) => + entries + .flatMap((entry) => + Option.isSome(entry) ? [entry.value.checkpointRef.trim()] : [], + ) + .filter((ref) => ref.length > 0), + ), + ); + + yield* store.deleteCheckpointRefs({ + cwd: checkpointCwd, + checkpointRefs: staleCheckpointRefs, + }); + yield* checkpointRepository.deleteAfterTurn({ + providerSessionId: input.sessionId, + maxTurnCount: input.turnCount, + }); + const checkpoints = yield* checkpointRepository.listCheckpoints({ + providerSessionId: input.sessionId, + }); + const currentCheckpoint = + checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? + checkpoints[checkpoints.length - 1] ?? + checkpoints[0]; + const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length); + + return { + threadId: afterSnapshot.threadId, + turnCount: currentCheckpoint?.turnCount ?? 0, + messageCount: currentCheckpoint?.messageCount ?? 0, + rolledBackTurns, + checkpoints: Array.from(checkpoints), + }; + }), + ), + ), + ); + + return { + initializeForSession, + captureCurrentTurn, + listCheckpoints, + getCheckpointDiff, + revertToCheckpoint, + } satisfies CheckpointServiceShape; +}); + +export const CheckpointServiceLive = Layer.effect(CheckpointService, makeCheckpointService); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts new file mode 100644 index 0000000000000..31b15cc6f0205 --- /dev/null +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -0,0 +1,418 @@ +/** + * CheckpointStoreLive - Filesystem checkpoint store adapter layer. + * + * Implements hidden Git-ref checkpoint capture/restore directly with + * Effect-native child process execution (`effect/unstable/process`). + * + * This layer owns filesystem/Git interactions only; it does not persist + * checkpoint metadata and does not coordinate provider rollback semantics. + * + * @module CheckpointStoreLive + */ +import { mkdtemp, rm } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import os from "node:os"; +import path from "node:path"; + +import { Effect, Layer } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + CheckpointGitCommandError, + CheckpointRepositoryError, + CheckpointUnavailableError, + type CheckpointStoreError, +} from "../Errors.ts"; +import { GitCommandError } from "../../git/Errors.ts"; +import { runGit, RunGitInput } from "../../git/Process.ts"; +import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; + +function causeDetail(cause: unknown, fallback: string): string { + if (cause instanceof GitCommandError) { + return cause.detail; + } + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function mapFilesystemFailure( + operation: string, + cwd: string, + cause: unknown, +): CheckpointStoreError { + const detail = causeDetail(cause, `${operation} failed.`); + + const unavailable = + /^Filesystem checkpoint is unavailable for turn (\d+) in thread (.+)\.?$/i.exec(detail); + if (unavailable) { + const [, rawTurnCount, threadId] = unavailable; + const turnCount = Number.parseInt(rawTurnCount ?? "", 10); + if (threadId && Number.isInteger(turnCount) && turnCount >= 0) { + return new CheckpointUnavailableError({ + threadId, + turnCount, + detail, + cause, + }); + } + } + + const lowered = detail.toLowerCase(); + if (lowered.includes("not a git repository")) { + return new CheckpointRepositoryError({ + cwd, + detail, + cause, + }); + } + + return new CheckpointGitCommandError({ + operation, + command: cause instanceof GitCommandError ? cause.command : "git", + cwd, + detail, + cause, + }); +} + +function resolveHeadCommit( + cwd: string, +): Effect.Effect { + return runGit({ + operation: "CheckpointStore.resolveHeadCommit", + cwd, + args: ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], + allowNonZeroExit: true, + }).pipe( + Effect.map((result) => { + if (result.code !== 0) { + return null; + } + const commit = result.stdout.trim(); + return commit.length > 0 ? commit : null; + }), + ); +} + +function hasHeadCommit( + cwd: string, +): Effect.Effect { + return runGit({ + operation: "CheckpointStore.hasHeadCommit", + cwd, + args: ["rev-parse", "--verify", "HEAD"], + allowNonZeroExit: true, + }).pipe(Effect.map((result) => result.code === 0)); +} + +function resolveCheckpointCommit( + cwd: string, + checkpointRef: string, +): Effect.Effect { + return runGit({ + operation: "CheckpointStore.resolveCheckpointCommit", + cwd, + args: ["rev-parse", "--verify", "--quiet", `${checkpointRef}^{commit}`], + allowNonZeroExit: true, + }).pipe( + Effect.map((result) => { + if (result.code !== 0) { + return null; + } + const commit = result.stdout.trim(); + return commit.length > 0 ? commit : null; + }), + ); +} + +function removeDirectory(pathname: string): Effect.Effect { + return Effect.tryPromise({ + try: () => rm(pathname, { recursive: true, force: true }), + catch: (cause) => cause, + }).pipe(Effect.orDie); +} + +const makeCheckpointStore = Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runGitWithSpawner = (input: RunGitInput) => + runGit(input).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + const resolveHeadCommitWithSpawner = (cwd: string) => + resolveHeadCommit(cwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const hasHeadCommitWithSpawner = (cwd: string) => + hasHeadCommit(cwd).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const resolveCheckpointCommitWithSpawner = (cwd: string, checkpointRef: string) => + resolveCheckpointCommit(cwd, checkpointRef).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => + Effect.gen(function* () { + const normalizedCwd = cwd.trim(); + + return yield* runGitWithSpawner({ + operation: "CheckpointStore.isGitRepository", + cwd: normalizedCwd, + args: ["rev-parse", "--is-inside-work-tree"], + allowNonZeroExit: true, + }).pipe( + Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"), + Effect.catch(() => Effect.succeed(false)), + ); + }); + + const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) => + Effect.gen(function* () { + const operation = "CheckpointStore.captureCheckpoint"; + const normalizedCwd = input.cwd.trim(); + const checkpointRef = input.checkpointRef.trim(); + + yield* Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-")), + catch: (cause) => cause, + }), + (tempDir) => + Effect.gen(function* () { + const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); + const commitEnv: NodeJS.ProcessEnv = { + ...process.env, + GIT_INDEX_FILE: tempIndexPath, + GIT_AUTHOR_NAME: "T3 Code", + GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com", + GIT_COMMITTER_NAME: "T3 Code", + GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com", + }; + + const headExists = yield* hasHeadCommitWithSpawner(normalizedCwd); + if (headExists) { + yield* runGitWithSpawner({ + operation, + cwd: normalizedCwd, + args: ["read-tree", "HEAD"], + env: commitEnv, + }); + } + + yield* runGitWithSpawner({ + operation, + cwd: normalizedCwd, + args: ["add", "-A", "--", "."], + env: commitEnv, + }); + + const writeTreeResult = yield* runGitWithSpawner({ + operation, + cwd: normalizedCwd, + args: ["write-tree"], + env: commitEnv, + }); + const treeOid = writeTreeResult.stdout.trim(); + if (treeOid.length === 0) { + return yield* Effect.fail( + new CheckpointGitCommandError({ + operation, + command: "git write-tree", + cwd: normalizedCwd, + detail: "git write-tree returned an empty tree oid.", + }), + ); + } + + const message = `t3 checkpoint ref=${checkpointRef}`; + const commitTreeResult = yield* runGitWithSpawner({ + operation, + cwd: normalizedCwd, + args: ["commit-tree", treeOid, "-m", message], + env: commitEnv, + }); + const commitOid = commitTreeResult.stdout.trim(); + if (commitOid.length === 0) { + return yield* Effect.fail( + new CheckpointGitCommandError({ + operation, + command: "git commit-tree", + cwd: normalizedCwd, + detail: "git commit-tree returned an empty commit oid.", + }), + ); + } + + yield* runGitWithSpawner({ + operation, + cwd: normalizedCwd, + args: ["update-ref", checkpointRef, commitOid], + }); + }), + removeDirectory, + ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); + }); + + const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) => + Effect.gen(function* () { + const operation = "CheckpointStore.hasCheckpointRef"; + const normalizedCwd = input.cwd.trim(); + const checkpointRef = input.checkpointRef.trim(); + + return yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe( + Effect.map((commit) => commit !== null), + Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), + ); + }); + + const ensureRootCheckpoint: CheckpointStoreShape["ensureRootCheckpoint"] = (input) => + Effect.gen(function* () { + const operation = "CheckpointStore.ensureRootCheckpoint"; + const normalizedCwd = input.cwd.trim(); + const checkpointRef = input.checkpointRef.trim(); + + const existing = yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe( + Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), + ); + if (existing) { + return true; + } + + const headCommit = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe( + Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), + ); + if (!headCommit) { + return false; + } + + yield* runGitWithSpawner({ + operation, + cwd: normalizedCwd, + args: ["update-ref", checkpointRef, headCommit], + }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); + return true; + }); + + const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = (input) => + Effect.gen(function* () { + const operation = "CheckpointStore.restoreCheckpoint"; + const normalizedCwd = input.cwd.trim(); + const checkpointRef = input.checkpointRef.trim(); + + let commitOid = yield* resolveCheckpointCommitWithSpawner(normalizedCwd, checkpointRef).pipe( + Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), + ); + + if (!commitOid && input.fallbackToHead === true) { + commitOid = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe( + Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), + ); + } + + if (!commitOid) { + return false; + } + + yield* runGitWithSpawner({ + operation, + cwd: normalizedCwd, + args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], + }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); + yield* runGitWithSpawner({ + operation, + cwd: normalizedCwd, + args: ["clean", "-fd", "--", "."], + }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); + + const headExists = yield* hasHeadCommitWithSpawner(normalizedCwd).pipe( + Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), + ); + if (headExists) { + yield* runGitWithSpawner({ + operation, + cwd: normalizedCwd, + args: ["reset", "--quiet", "--", "."], + }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); + } + + return true; + }); + + const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = (input) => + Effect.gen(function* () { + const operation = "CheckpointStore.diffCheckpoints"; + const normalizedCwd = input.cwd.trim(); + const fromCheckpointRef = input.fromCheckpointRef.trim(); + const toCheckpointRef = input.toCheckpointRef.trim(); + + let fromCommitOid = yield* resolveCheckpointCommitWithSpawner( + normalizedCwd, + fromCheckpointRef, + ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); + const toCommitOid = yield* resolveCheckpointCommitWithSpawner( + normalizedCwd, + toCheckpointRef, + ).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); + + if (!fromCommitOid && input.fallbackFromToHead === true) { + const headCommit = yield* resolveHeadCommitWithSpawner(normalizedCwd).pipe( + Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), + ); + if (headCommit) { + fromCommitOid = headCommit; + } + } + + if (!fromCommitOid || !toCommitOid) { + return yield* Effect.fail( + new CheckpointGitCommandError({ + operation, + command: "git diff", + cwd: normalizedCwd, + detail: "Checkpoint ref is unavailable for diff operation.", + }), + ); + } + + const result = yield* runGitWithSpawner({ + operation, + cwd: normalizedCwd, + args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], + }).pipe(Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause))); + + return result.stdout; + }); + + const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = (input) => + Effect.gen(function* () { + const operation = "CheckpointStore.deleteCheckpointRefs"; + const normalizedCwd = input.cwd.trim(); + const checkpointRefs = input.checkpointRefs.map((ref) => ref.trim()); + + yield* Effect.forEach( + checkpointRefs, + (checkpointRef) => + runGitWithSpawner({ + operation, + cwd: normalizedCwd, + args: ["update-ref", "-d", checkpointRef], + allowNonZeroExit: true, + }).pipe( + Effect.mapError((cause) => mapFilesystemFailure(operation, normalizedCwd, cause)), + ), + { discard: true }, + ); + }); + + return { + isGitRepository, + captureCheckpoint, + hasCheckpointRef, + ensureRootCheckpoint, + restoreCheckpoint, + diffCheckpoints, + deleteCheckpointRefs, + } satisfies CheckpointStoreShape; +}); + +export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore); diff --git a/apps/server/src/checkpointing/Services/CheckpointCatalog.ts b/apps/server/src/checkpointing/Services/CheckpointCatalog.ts deleted file mode 100644 index b9e00e08a90dc..0000000000000 --- a/apps/server/src/checkpointing/Services/CheckpointCatalog.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * CheckpointCatalog - Metadata repository for checkpoints. - * - * Stores user-facing checkpoint metadata used by list/revert APIs. It does not - * touch the filesystem or hidden Git refs; `CheckpointStore` owns that state. - * It also does not drive provider conversation rollback. - * - * @module CheckpointCatalog - */ -import type { ProviderCheckpoint } from "@t3tools/contracts"; -import { Option, ServiceMap } from "effect"; -import type { Effect } from "effect"; - -import type { CheckpointCatalogError } from "../Errors.ts"; - -export interface UpsertCheckpointInput { - readonly providerSessionId: string; - readonly threadId: string; - readonly turnCount: number; - readonly messageCount: number; - readonly label: string; - readonly preview?: string; - readonly createdAt: string; -} - -export interface ListSessionCheckpointsInput { - readonly providerSessionId: string; -} - -export interface GetCheckpointInput { - readonly providerSessionId: string; - readonly turnCount: number; -} - -export interface DeleteAfterTurnInput { - readonly providerSessionId: string; - readonly maxTurnCount: number; -} - -export interface DeleteAllForSessionInput { - readonly providerSessionId: string; -} - -export interface CheckpointCatalogShape { - /** - * Insert or update one checkpoint metadata row. - */ - readonly upsertCheckpoint: (input: UpsertCheckpointInput) => Effect.Effect; - - /** - * List checkpoints for a provider session. - */ - readonly listCheckpoints: ( - input: ListSessionCheckpointsInput, - ) => Effect.Effect, CheckpointCatalogError>; - - /** - * Read one checkpoint by turn count. - */ - readonly getCheckpoint: ( - input: GetCheckpointInput, - ) => Effect.Effect, CheckpointCatalogError>; - - /** - * Delete checkpoint metadata newer than the provided turn count. - */ - readonly deleteAfterTurn: (input: DeleteAfterTurnInput) => Effect.Effect; - - /** - * Delete all checkpoint metadata for a provider session. - */ - readonly deleteAllForSession: ( - input: DeleteAllForSessionInput, - ) => Effect.Effect; -} - -/** - * CheckpointCatalog - Service tag for checkpoint metadata persistence. - */ -export class CheckpointCatalog extends ServiceMap.Service< - CheckpointCatalog, - CheckpointCatalogShape ->()("checkpointing/CheckpointCatalog") {} diff --git a/apps/server/src/checkpointing/Services/CheckpointService.ts b/apps/server/src/checkpointing/Services/CheckpointService.ts index 4622b1d182639..6a5260eb6c9e7 100644 --- a/apps/server/src/checkpointing/Services/CheckpointService.ts +++ b/apps/server/src/checkpointing/Services/CheckpointService.ts @@ -1,7 +1,7 @@ /** * CheckpointService - Application orchestrator for checkpoint workflows. * - * Coordinates `CheckpointStore` (Git refs), `CheckpointCatalog` (metadata), and + * Coordinates `CheckpointStore` (Git refs), `CheckpointRepository` (metadata), and * provider timeline operations resolved through `ProviderSessionDirectory` and * `ProviderAdapterRegistry` (read/rollback/hasSession on adapters). * @@ -45,7 +45,9 @@ export interface CheckpointServiceShape { /** * Capture the current thread turn into checkpoint refs/metadata. */ - readonly captureCurrentTurn: (input: CaptureCurrentTurnInput) => Effect.Effect; + readonly captureCurrentTurn: ( + input: CaptureCurrentTurnInput, + ) => Effect.Effect; /** * List checkpoints for a provider session. diff --git a/apps/server/src/checkpointing/Services/CheckpointStore.ts b/apps/server/src/checkpointing/Services/CheckpointStore.ts index 81fa60c305f6f..7dece4884468d 100644 --- a/apps/server/src/checkpointing/Services/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Services/CheckpointStore.ts @@ -17,32 +17,30 @@ import type { CheckpointStoreError } from "../Errors.ts"; export interface CaptureCheckpointInput { readonly cwd: string; - readonly threadId: string; - readonly turnCount: number; + readonly checkpointRef: string; } export interface EnsureRootCheckpointInput { readonly cwd: string; - readonly threadId: string; + readonly checkpointRef: string; } export interface RestoreCheckpointInput { readonly cwd: string; - readonly threadId: string; - readonly turnCount: number; + readonly checkpointRef: string; + readonly fallbackToHead?: boolean; } export interface DiffCheckpointsInput { readonly cwd: string; - readonly threadId: string; - readonly fromTurnCount: number; - readonly toTurnCount: number; + readonly fromCheckpointRef: string; + readonly toCheckpointRef: string; + readonly fallbackFromToHead?: boolean; } -export interface PruneAfterTurnInput { +export interface DeleteCheckpointRefsInput { readonly cwd: string; - readonly threadId: string; - readonly maxTurnCount: number; + readonly checkpointRefs: ReadonlyArray; } export interface CheckpointStoreShape { @@ -52,17 +50,21 @@ export interface CheckpointStoreShape { readonly isGitRepository: (cwd: string) => Effect.Effect; /** - * Capture a checkpoint for a thread at a turn count. + * Capture a checkpoint commit and store it at the provided checkpoint ref. */ - readonly captureCheckpoint: (input: CaptureCheckpointInput) => Effect.Effect; + readonly captureCheckpoint: ( + input: CaptureCheckpointInput, + ) => Effect.Effect; /** * Check whether a checkpoint ref exists. */ - readonly hasCheckpoint: (input: RestoreCheckpointInput) => Effect.Effect; + readonly hasCheckpointRef: ( + input: Omit, + ) => Effect.Effect; /** - * Ensure a turn-0 checkpoint exists for a thread. + * Ensure a root checkpoint ref exists. */ readonly ensureRootCheckpoint: ( input: EnsureRootCheckpointInput, @@ -76,20 +78,23 @@ export interface CheckpointStoreShape { ) => Effect.Effect; /** - * Compute patch diff between two checkpoint turn counts. + * Compute patch diff between two checkpoint refs. */ - readonly diffCheckpoints: (input: DiffCheckpointsInput) => Effect.Effect; + readonly diffCheckpoints: ( + input: DiffCheckpointsInput, + ) => Effect.Effect; /** - * Delete checkpoints newer than the provided turn count. + * Delete the provided checkpoint refs. */ - readonly pruneAfterTurn: (input: PruneAfterTurnInput) => Effect.Effect; + readonly deleteCheckpointRefs: ( + input: DeleteCheckpointRefsInput, + ) => Effect.Effect; } /** * CheckpointStore - Service tag for checkpoint persistence and restore operations. */ -export class CheckpointStore extends ServiceMap.Service< - CheckpointStore, - CheckpointStoreShape ->()("checkpointing/CheckpointStore") {} +export class CheckpointStore extends ServiceMap.Service()( + "checkpointing/CheckpointStore", +) {} diff --git a/apps/server/src/git/Errors.ts b/apps/server/src/git/Errors.ts new file mode 100644 index 0000000000000..9e2ee14f72826 --- /dev/null +++ b/apps/server/src/git/Errors.ts @@ -0,0 +1,16 @@ +import { Schema } from "effect"; + +/** + * GitCommandError - Git command execution failed. + */ +export class GitCommandError extends Schema.TaggedErrorClass()("GitCommandError", { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message(): string { + return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; + } +} diff --git a/apps/server/src/git/process.test.ts b/apps/server/src/git/process.test.ts new file mode 100644 index 0000000000000..58242a4e7a561 --- /dev/null +++ b/apps/server/src/git/process.test.ts @@ -0,0 +1,51 @@ +import { NodeServices } from "@effect/platform-node"; +import { it, assert } from "@effect/vitest"; +import { Effect } from "effect"; + +import { GitCommandError } from "./Errors.ts"; +import { runGit } from "./Process.ts"; + +it.effect("runGit executes successful git commands", () => + Effect.gen(function* () { + const result = yield* runGit({ + operation: "GitProcess.test.version", + cwd: process.cwd(), + args: ["--version"], + }); + + assert.equal(result.code, 0); + assert.ok(result.stdout.toLowerCase().includes("git version")); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("runGit can return non-zero exit codes when allowed", () => + Effect.gen(function* () { + const result = yield* runGit({ + operation: "GitProcess.test.allowNonZero", + cwd: process.cwd(), + args: ["rev-parse", "--verify", "__definitely_missing_ref__"], + allowNonZeroExit: true, + }); + + assert.notEqual(result.code, 0); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () => + Effect.gen(function* () { + const result = yield* Effect.result( + runGit({ + operation: "GitProcess.test.failOnNonZero", + cwd: process.cwd(), + args: ["rev-parse", "--verify", "__definitely_missing_ref__"], + }), + ); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.ok(result.failure instanceof GitCommandError); + assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero"); + assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__"); + } + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/git/process.ts b/apps/server/src/git/process.ts new file mode 100644 index 0000000000000..d5ed04de081c9 --- /dev/null +++ b/apps/server/src/git/process.ts @@ -0,0 +1,174 @@ +/** + * Git process helpers - Effect-native git execution with typed errors. + * + * Centralizes child-process git invocation for server modules. This module + * only executes git commands and reports structured failures. + * + * @module GitProcess + */ +import { Effect, Option, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { GitCommandError } from "./Errors.ts"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; + +export interface RunGitInput { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; +} + +export interface GitRunResult { + readonly code: number; + readonly stdout: string; + readonly stderr: string; +} + +function quoteGitCommand(args: ReadonlyArray): string { + return `git ${args.join(" ")}`; +} + +function causeMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function gitError( + input: Pick, + detail: string, + cause?: unknown, +): GitCommandError { + return new GitCommandError({ + operation: input.operation, + command: quoteGitCommand(input.args), + cwd: input.cwd, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function collectOutput( + input: Pick, + stream: Stream.Stream, + maxOutputBytes: number, +): Effect.Effect { + return Effect.gen(function* () { + const decoder = new TextDecoder(); + let bytes = 0; + let text = ""; + + yield* Stream.runForEach(stream, (chunk) => + Effect.gen(function* () { + bytes += chunk.byteLength; + if (bytes > maxOutputBytes) { + return yield* Effect.fail( + gitError( + input, + `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, + ), + ); + } + text += decoder.decode(chunk, { stream: true }); + }), + ); + + text += decoder.decode(); + return text; + }).pipe( + Effect.mapError((cause) => + gitError( + input, + causeMessage(cause, `${quoteGitCommand(input.args)} output stream failed.`), + cause, + ), + ), + ); +} + +export function runGit( + input: RunGitInput, +): Effect.Effect { + const normalizedCwd = input.cwd.trim(); + const commandInput = { + ...input, + cwd: normalizedCwd, + args: [...input.args], + } as const; + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + + const commandEffect = Effect.gen(function* () { + const child = yield* ChildProcess.spawn( + ChildProcess.make("git", commandInput.args, { + cwd: commandInput.cwd, + ...(input.env ? { env: input.env } : {}), + }), + ).pipe( + Effect.mapError((cause) => + gitError( + commandInput, + causeMessage(cause, `Failed to spawn ${quoteGitCommand(commandInput.args)}.`), + cause, + ), + ), + ); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectOutput(commandInput, child.stdout, maxOutputBytes), + collectOutput(commandInput, child.stderr, maxOutputBytes), + child.exitCode.pipe( + Effect.map((value) => Number(value)), + Effect.mapError((cause) => + gitError( + commandInput, + causeMessage( + cause, + `${quoteGitCommand(commandInput.args)} failed to report exit code.`, + ), + cause, + ), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + if (!input.allowNonZeroExit && exitCode !== 0) { + const trimmedStderr = stderr.trim(); + return yield* Effect.fail( + gitError( + commandInput, + trimmedStderr.length > 0 + ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` + : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, + ), + ); + } + + return { + code: exitCode, + stdout, + stderr, + } satisfies GitRunResult; + }); + + return commandEffect.pipe( + Effect.scoped, + Effect.timeoutOption(timeoutMs), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail(gitError(commandInput, `${quoteGitCommand(commandInput.args)} timed out.`)), + onSome: Effect.succeed, + }), + ), + ); +} diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index d6363f6abeb7f..75f23a89e10f2 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -1,5 +1,9 @@ import { Schema, SchemaIssue } from "effect"; +// =============================== +// Core Persistence Errors +// =============================== + export class PersistenceSqlError extends Schema.TaggedErrorClass()( "PersistenceSqlError", { @@ -39,6 +43,46 @@ export class PersistenceSerializationError extends Schema.TaggedErrorClass + new PersistenceSqlError({ + operation, + detail: `Failed to execute ${operation}`, + cause, + }); +} + +export function toPersistenceDecodeError(operation: string) { + return (error: Schema.SchemaError): PersistenceDecodeError => + new PersistenceDecodeError({ + operation, + issue: SchemaIssue.makeFormatterDefault()(error.issue), + cause: error, + }); +} + +export function toPersistenceDecodeCauseError(operation: string) { + return (cause: unknown): PersistenceDecodeError => + new PersistenceDecodeError({ + operation, + issue: `Failed to execute ${operation}`, + cause, + }); +} + +export function toPersistenceSerializationError(operation: string) { + return (cause: unknown): PersistenceSerializationError => + new PersistenceSerializationError({ + operation, + detail: `Failed to execute ${operation}`, + cause, + }); +} + +// =============================== +// Project Repository Errors +// =============================== + export class ProjectValidationError extends Schema.TaggedErrorClass()( "ProjectValidationError", { @@ -76,6 +120,36 @@ export class ProjectNotFoundError extends Schema.TaggedErrorClass()( + "CheckpointRepositoryValidationError", + { + operation: Schema.String, + issue: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint repository validation failed in ${this.operation}: ${this.issue}`; + } +} + +export class CheckpointRepositoryPersistenceError extends Schema.TaggedErrorClass()( + "CheckpointRepositoryPersistenceError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Checkpoint repository persistence error in ${this.operation}: ${this.detail}`; + } +} + export type ProjectRepositoryError = | PersistenceSqlError | PersistenceDecodeError @@ -89,38 +163,6 @@ export type OrchestrationEventRepositoryError = | PersistenceDecodeError | PersistenceSerializationError; -export function toPersistenceSqlError(operation: string) { - return (cause: unknown): PersistenceSqlError => - new PersistenceSqlError({ - operation, - detail: `Failed to execute ${operation}`, - cause, - }); -} - -export function toPersistenceDecodeError(operation: string) { - return (error: Schema.SchemaError): PersistenceDecodeError => - new PersistenceDecodeError({ - operation, - issue: SchemaIssue.makeFormatterDefault()(error.issue), - cause: error, - }); -} - -export function toPersistenceDecodeCauseError(operation: string) { - return (cause: unknown): PersistenceDecodeError => - new PersistenceDecodeError({ - operation, - issue: `Failed to execute ${operation}`, - cause, - }); -} - -export function toPersistenceSerializationError(operation: string) { - return (cause: unknown): PersistenceSerializationError => - new PersistenceSerializationError({ - operation, - detail: `Failed to execute ${operation}`, - cause, - }); -} +export type CheckpointRepositoryError = + | CheckpointRepositoryValidationError + | CheckpointRepositoryPersistenceError; diff --git a/apps/server/src/persistence/Layers/Checkpoints.test.ts b/apps/server/src/persistence/Layers/Checkpoints.test.ts new file mode 100644 index 0000000000000..d13b5c14f8b47 --- /dev/null +++ b/apps/server/src/persistence/Layers/Checkpoints.test.ts @@ -0,0 +1,95 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { it, assert } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import { CheckpointRepository } from "../Services/Checkpoints.ts"; +import { CheckpointRepositoryLive } from "./Checkpoints.ts"; +import { makeSqlitePersistenceLive } from "./Sqlite.ts"; + +const makeTemporaryDirectoryScoped = (prefix: string) => + Effect.acquireRelease( + Effect.promise(() => mkdtemp(path.join(os.tmpdir(), prefix))), + (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })), + ); + +function makeCheckpointRepositoryLayer(dbPath: string) { + return CheckpointRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); +} + +it.effect("CheckpointRepositoryLive upserts, lists, reads, and prunes checkpoint metadata", () => + Effect.gen(function* () { + const stateDir = yield* makeTemporaryDirectoryScoped("t3code-checkpoint-repository-"); + const dbPath = path.join(stateDir, "orchestration.sqlite"); + + yield* Effect.gen(function* () { + const repository = yield* CheckpointRepository; + + yield* repository.upsertCheckpoint({ + providerSessionId: "sess-1", + threadId: "thread-1", + checkpointId: "root", + checkpointRef: "refs/t3/checkpoints/thread-1/turn/0", + turnCount: 0, + messageCount: 0, + label: "Start of conversation", + createdAt: new Date().toISOString(), + }); + + yield* repository.upsertCheckpoint({ + providerSessionId: "sess-1", + threadId: "thread-1", + checkpointId: "turn-1", + checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", + turnCount: 1, + messageCount: 2, + label: "Turn 1", + preview: "hello", + createdAt: new Date().toISOString(), + }); + + yield* repository.upsertCheckpoint({ + providerSessionId: "sess-1", + threadId: "thread-1", + checkpointId: "turn-2", + checkpointRef: "refs/t3/checkpoints/thread-1/turn/2", + turnCount: 2, + messageCount: 4, + label: "Turn 2", + preview: "done", + createdAt: new Date().toISOString(), + }); + + const checkpoints = yield* repository.listCheckpoints({ providerSessionId: "sess-1" }); + assert.equal(checkpoints.length, 3); + assert.equal(checkpoints[0]?.id, "root"); + assert.equal(checkpoints[0]?.isCurrent, false); + assert.equal(checkpoints[2]?.id, "turn-2"); + assert.equal(checkpoints[2]?.isCurrent, true); + + const one = yield* repository.getCheckpoint({ providerSessionId: "sess-1", turnCount: 1 }); + assert.equal(Option.isSome(one), true); + if (Option.isSome(one)) { + assert.equal(one.value.id, "turn-1"); + assert.equal(one.value.isCurrent, false); + assert.equal(one.value.checkpointRef, "refs/t3/checkpoints/thread-1/turn/1"); + } + + yield* repository.deleteAfterTurn({ + providerSessionId: "sess-1", + maxTurnCount: 1, + }); + + const pruned = yield* repository.listCheckpoints({ providerSessionId: "sess-1" }); + assert.equal(pruned.length, 2); + assert.equal(pruned[1]?.turnCount, 1); + assert.equal(pruned[1]?.isCurrent, true); + + yield* repository.deleteAllForSession({ providerSessionId: "sess-1" }); + const empty = yield* repository.listCheckpoints({ providerSessionId: "sess-1" }); + assert.equal(empty.length, 0); + }).pipe(Effect.provide(makeCheckpointRepositoryLayer(dbPath))); + }), +); diff --git a/apps/server/src/persistence/Layers/Checkpoints.ts b/apps/server/src/persistence/Layers/Checkpoints.ts new file mode 100644 index 0000000000000..b9efe5b9b0b3f --- /dev/null +++ b/apps/server/src/persistence/Layers/Checkpoints.ts @@ -0,0 +1,363 @@ +/** + * CheckpointRepositoryLive - SQLite-backed checkpoint metadata repository layer. + * + * Persists and queries checkpoint metadata (`turnCount`, labels, previews, + * message counts) and the associated hidden Git ref for each turn. + * + * This layer does not read/write Git refs and does not perform provider-thread + * rollback. Those responsibilities remain in `CheckpointStore` and + * `CheckpointService`. + * + * @module CheckpointRepositoryLive + */ +import type { ProviderCheckpoint } from "@t3tools/contracts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Option, Schema } from "effect"; + +import { + CheckpointRepositoryPersistenceError, + CheckpointRepositoryValidationError, + type CheckpointRepositoryError, +} from "../Errors.ts"; +import { makeSqlitePersistenceLive } from "./Sqlite.ts"; +import { + CheckpointRepository, + type CheckpointRepositoryEntry, + type CheckpointRepositoryShape, +} from "../Services/Checkpoints.ts"; + +const NonEmptyString = Schema.NonEmptyString; + +const CheckpointRowSchema = Schema.Struct({ + providerSessionId: Schema.String, + threadId: Schema.String, + checkpointId: Schema.String, + checkpointRef: Schema.String, + turnCount: Schema.Number, + messageCount: Schema.Number, + label: Schema.String, + preview: Schema.NullOr(Schema.String), + createdAt: Schema.String, +}); + +const SessionRequestSchema = Schema.Struct({ + providerSessionId: NonEmptyString, +}); + +const UpsertRequestSchema = Schema.Struct({ + providerSessionId: NonEmptyString, + threadId: NonEmptyString, + checkpointId: NonEmptyString, + checkpointRef: NonEmptyString, + turnCount: Schema.Int, + messageCount: Schema.Int, + label: NonEmptyString, + preview: Schema.NullOr(Schema.String), + createdAt: NonEmptyString, +}); + +const GetCheckpointRequestSchema = Schema.Struct({ + providerSessionId: NonEmptyString, + turnCount: Schema.Int, +}); + +const DeleteAfterTurnRequestSchema = Schema.Struct({ + providerSessionId: NonEmptyString, + maxTurnCount: Schema.Int, +}); + +function errorMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function toValidationError(operation: string, cause: unknown): CheckpointRepositoryValidationError { + return new CheckpointRepositoryValidationError({ + operation, + issue: errorMessage(cause, "Invalid checkpoint repository input."), + cause, + }); +} + +function decodeInput( + schema: Schema.Schema, + input: unknown, + operation: string, +): Effect.Effect { + return Schema.decodeUnknownEffect(schema)(input).pipe( + Effect.mapError((cause) => toValidationError(operation, cause)), + ) as Effect.Effect; +} + +function ensureNonNegativeInt( + operation: string, + field: string, + value: number, +): Effect.Effect { + if (value < 0) { + return Effect.fail( + new CheckpointRepositoryValidationError({ + operation, + issue: `${field} must be an integer >= 0.`, + }), + ); + } + return Effect.succeed(value); +} + +function toPersistenceError( + operation: string, + cause: unknown, +): CheckpointRepositoryPersistenceError { + return new CheckpointRepositoryPersistenceError({ + operation, + detail: `Failed to execute ${operation}.`, + cause, + }); +} + +function toCheckpointEntry( + row: Schema.Schema.Type, + isCurrent: boolean, +): CheckpointRepositoryEntry { + return { + id: row.checkpointId, + threadId: row.threadId, + checkpointRef: row.checkpointRef, + turnCount: row.turnCount, + messageCount: row.messageCount, + label: row.label, + ...(row.preview !== null ? { preview: row.preview } : {}), + createdAt: row.createdAt, + isCurrent, + }; +} + +function toProviderCheckpoint(entry: CheckpointRepositoryEntry): ProviderCheckpoint { + return { + id: entry.id, + turnCount: entry.turnCount, + messageCount: entry.messageCount, + label: entry.label, + ...(entry.preview !== undefined ? { preview: entry.preview } : {}), + isCurrent: entry.isCurrent, + }; +} + +const makeCheckpointRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertCheckpointRow = SqlSchema.findOne({ + Request: UpsertRequestSchema, + Result: CheckpointRowSchema, + execute: (request) => + sql` + INSERT INTO provider_checkpoints ( + provider_session_id, + thread_id, + checkpoint_id, + checkpoint_ref, + turn_count, + message_count, + label, + preview, + created_at + ) + VALUES ( + ${request.providerSessionId}, + ${request.threadId}, + ${request.checkpointId}, + ${request.checkpointRef}, + ${request.turnCount}, + ${request.messageCount}, + ${request.label}, + ${request.preview}, + ${request.createdAt} + ) + ON CONFLICT (provider_session_id, turn_count) + DO UPDATE SET + thread_id = excluded.thread_id, + checkpoint_id = excluded.checkpoint_id, + checkpoint_ref = excluded.checkpoint_ref, + message_count = excluded.message_count, + label = excluded.label, + preview = excluded.preview, + created_at = excluded.created_at + RETURNING + provider_session_id AS "providerSessionId", + thread_id AS "threadId", + checkpoint_id AS "checkpointId", + checkpoint_ref AS "checkpointRef", + turn_count AS "turnCount", + message_count AS "messageCount", + label, + preview, + created_at AS "createdAt" + `, + }); + + const listCheckpointRows = SqlSchema.findAll({ + Request: SessionRequestSchema, + Result: CheckpointRowSchema, + execute: ({ providerSessionId }) => + sql` + SELECT + provider_session_id AS "providerSessionId", + thread_id AS "threadId", + checkpoint_id AS "checkpointId", + checkpoint_ref AS "checkpointRef", + turn_count AS "turnCount", + message_count AS "messageCount", + label, + preview, + created_at AS "createdAt" + FROM provider_checkpoints + WHERE provider_session_id = ${providerSessionId} + ORDER BY turn_count ASC + `, + }); + + const listForSession = ( + providerSessionId: string, + ): Effect.Effect, CheckpointRepositoryError> => + listCheckpointRows({ providerSessionId }).pipe( + Effect.mapError((cause) => + toPersistenceError("CheckpointRepository.listCheckpoints:query", cause), + ), + Effect.map((rows) => { + const currentTurnCount = rows[rows.length - 1]?.turnCount; + return rows.map((row) => toCheckpointEntry(row, row.turnCount === currentTurnCount)); + }), + ); + + const upsertCheckpoint: CheckpointRepositoryShape["upsertCheckpoint"] = (input) => + Effect.gen(function* () { + const operation = "CheckpointRepository.upsertCheckpoint"; + const parsed = yield* decodeInput( + UpsertRequestSchema, + { + providerSessionId: input.providerSessionId.trim(), + threadId: input.threadId.trim(), + checkpointId: input.checkpointId.trim(), + checkpointRef: input.checkpointRef.trim(), + turnCount: input.turnCount, + messageCount: input.messageCount, + label: input.label.trim(), + preview: input.preview?.trim() || null, + createdAt: input.createdAt.trim(), + }, + operation, + ); + yield* ensureNonNegativeInt(operation, "turnCount", parsed.turnCount); + yield* ensureNonNegativeInt(operation, "messageCount", parsed.messageCount); + + yield* upsertCheckpointRow(parsed).pipe( + Effect.mapError((cause) => + toPersistenceError("CheckpointRepository.upsertCheckpoint:query", cause), + ), + Effect.asVoid, + ); + }); + + const listCheckpoints: CheckpointRepositoryShape["listCheckpoints"] = (input) => + Effect.gen(function* () { + const parsed = yield* decodeInput( + SessionRequestSchema, + { + providerSessionId: input.providerSessionId.trim(), + }, + "CheckpointRepository.listCheckpoints", + ); + + const entries = yield* listForSession(parsed.providerSessionId); + return entries.map(toProviderCheckpoint); + }); + + const getCheckpoint: CheckpointRepositoryShape["getCheckpoint"] = (input) => + Effect.gen(function* () { + const parsed = yield* decodeInput( + GetCheckpointRequestSchema, + { + providerSessionId: input.providerSessionId.trim(), + turnCount: input.turnCount, + }, + "CheckpointRepository.getCheckpoint", + ); + yield* ensureNonNegativeInt( + "CheckpointRepository.getCheckpoint", + "turnCount", + parsed.turnCount, + ); + + const entries = yield* listForSession(parsed.providerSessionId); + const checkpoint = entries.find((entry) => entry.turnCount === parsed.turnCount); + return Option.fromNullishOr(checkpoint); + }); + + const deleteAfterTurn: CheckpointRepositoryShape["deleteAfterTurn"] = (input) => + Effect.gen(function* () { + const parsed = yield* decodeInput( + DeleteAfterTurnRequestSchema, + { + providerSessionId: input.providerSessionId.trim(), + maxTurnCount: input.maxTurnCount, + }, + "CheckpointRepository.deleteAfterTurn", + ); + yield* ensureNonNegativeInt( + "CheckpointRepository.deleteAfterTurn", + "maxTurnCount", + parsed.maxTurnCount, + ); + + yield* sql` + DELETE FROM provider_checkpoints + WHERE provider_session_id = ${parsed.providerSessionId} + AND turn_count > ${parsed.maxTurnCount} + `.pipe( + Effect.mapError((cause) => + toPersistenceError("CheckpointRepository.deleteAfterTurn:query", cause), + ), + ); + }); + + const deleteAllForSession: CheckpointRepositoryShape["deleteAllForSession"] = (input) => + Effect.gen(function* () { + const parsed = yield* decodeInput( + SessionRequestSchema, + { + providerSessionId: input.providerSessionId.trim(), + }, + "CheckpointRepository.deleteAllForSession", + ); + + yield* sql` + DELETE FROM provider_checkpoints + WHERE provider_session_id = ${parsed.providerSessionId} + `.pipe( + Effect.mapError((cause) => + toPersistenceError("CheckpointRepository.deleteAllForSession:query", cause), + ), + ); + }); + + return { + upsertCheckpoint, + listCheckpoints, + getCheckpoint, + deleteAfterTurn, + deleteAllForSession, + } satisfies CheckpointRepositoryShape; +}); + +export const CheckpointRepositoryLive = Layer.effect( + CheckpointRepository, + makeCheckpointRepository, +); + +export function makeSqliteCheckpointRepositoryLive(dbPath: string) { + return CheckpointRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); +} diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index d289361f91325..46dba5ae2deb1 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -14,6 +14,7 @@ import * as Layer from "effect/Layer"; // Import all migrations statically import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; import Migration0002 from "./Migrations/002_Projects.ts"; +import Migration0003 from "./Migrations/003_Checkpoints.ts"; /** * Migration loader with all migrations defined inline. @@ -28,6 +29,7 @@ import Migration0002 from "./Migrations/002_Projects.ts"; const loader = Migrator.fromRecord({ "1_OrchestrationEvents": Migration0001, "2_Projects": Migration0002, + "3_CheckpointCatalog": Migration0003, }); /** diff --git a/apps/server/src/persistence/Migrations/003_Checkpoints.ts b/apps/server/src/persistence/Migrations/003_Checkpoints.ts new file mode 100644 index 0000000000000..c7dc9ea3c7eaf --- /dev/null +++ b/apps/server/src/persistence/Migrations/003_Checkpoints.ts @@ -0,0 +1,26 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS provider_checkpoints ( + provider_session_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + checkpoint_id TEXT NOT NULL, + checkpoint_ref TEXT NOT NULL, + turn_count INTEGER NOT NULL, + message_count INTEGER NOT NULL, + label TEXT NOT NULL, + preview TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (provider_session_id, turn_count) + ) + `; + + yield* sql` + CREATE INDEX IF NOT EXISTS idx_provider_checkpoints_session_turn + ON provider_checkpoints(provider_session_id, turn_count ASC) + `; +}); diff --git a/apps/server/src/persistence/Services/Checkpoints.ts b/apps/server/src/persistence/Services/Checkpoints.ts new file mode 100644 index 0000000000000..954d1c8b74c04 --- /dev/null +++ b/apps/server/src/persistence/Services/Checkpoints.ts @@ -0,0 +1,95 @@ +/** + * CheckpointRepository - Repository interface for checkpoint metadata persistence. + * + * Stores and queries checkpoint metadata and checkpoint Git refs keyed by + * provider session + turn count. It does not read/write Git refs itself and it + * does not coordinate provider rollback workflows. + * + * @module CheckpointRepository + */ +import type { ProviderCheckpoint } from "@t3tools/contracts"; +import { Option, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { CheckpointRepositoryError } from "../Errors.ts"; + +export interface UpsertCheckpointInput { + readonly providerSessionId: string; + readonly threadId: string; + readonly checkpointId: string; + readonly checkpointRef: string; + readonly turnCount: number; + readonly messageCount: number; + readonly label: string; + readonly preview?: string; + readonly createdAt: string; +} + +export interface CheckpointRepositoryEntry extends ProviderCheckpoint { + readonly threadId: string; + readonly checkpointRef: string; + readonly createdAt: string; +} + +export interface ListSessionCheckpointsInput { + readonly providerSessionId: string; +} + +export interface GetCheckpointInput { + readonly providerSessionId: string; + readonly turnCount: number; +} + +export interface DeleteAfterTurnInput { + readonly providerSessionId: string; + readonly maxTurnCount: number; +} + +export interface DeleteAllForSessionInput { + readonly providerSessionId: string; +} + +export interface CheckpointRepositoryShape { + /** + * Insert or update one checkpoint metadata row. + */ + readonly upsertCheckpoint: ( + input: UpsertCheckpointInput, + ) => Effect.Effect; + + /** + * List user-facing checkpoints for a provider session. + */ + readonly listCheckpoints: ( + input: ListSessionCheckpointsInput, + ) => Effect.Effect, CheckpointRepositoryError>; + + /** + * Read one checkpoint record (including checkpoint ref) by turn count. + */ + readonly getCheckpoint: ( + input: GetCheckpointInput, + ) => Effect.Effect, CheckpointRepositoryError>; + + /** + * Delete checkpoint metadata newer than the provided turn count. + */ + readonly deleteAfterTurn: ( + input: DeleteAfterTurnInput, + ) => Effect.Effect; + + /** + * Delete all checkpoint metadata for a provider session. + */ + readonly deleteAllForSession: ( + input: DeleteAllForSessionInput, + ) => Effect.Effect; +} + +/** + * CheckpointRepository - Service tag for checkpoint metadata persistence. + */ +export class CheckpointRepository extends ServiceMap.Service< + CheckpointRepository, + CheckpointRepositoryShape +>()("persistence/CheckpointRepository") {} diff --git a/apps/server/src/provider/contracts.test.ts b/apps/server/src/provider/contracts.test.ts index 73756e09cedc9..7f216b1c08c9c 100644 --- a/apps/server/src/provider/contracts.test.ts +++ b/apps/server/src/provider/contracts.test.ts @@ -11,14 +11,18 @@ import { type ProviderServiceError, } from "./Errors.ts"; import { - CheckpointValidationError, - type CheckpointCatalogError, + CheckpointGitCommandError, + CheckpointServiceValidationError, type CheckpointServiceError, type CheckpointStoreError, } from "../checkpointing/Errors.ts"; -import { CheckpointCatalog } from "../checkpointing/Services/CheckpointCatalog.ts"; +import { + CheckpointRepositoryValidationError, + type CheckpointRepositoryError, +} from "../persistence/Errors.ts"; import { CheckpointService } from "../checkpointing/Services/CheckpointService.ts"; import { CheckpointStore } from "../checkpointing/Services/CheckpointStore.ts"; +import { CheckpointRepository } from "../persistence/Services/Checkpoints.ts"; import { CodexAdapter } from "./Services/CodexAdapter.ts"; import { ProviderAdapterRegistry } from "./Services/ProviderAdapterRegistry.ts"; import { ProviderService } from "./Services/ProviderService.ts"; @@ -107,20 +111,22 @@ describe("provider/checkpoint service contracts", () => { const checkpointStoreLive: typeof CheckpointStore.Service = { isGitRepository: () => Effect.succeed(false), captureCheckpoint: () => Effect.void, - hasCheckpoint: () => Effect.succeed(false), + hasCheckpointRef: () => Effect.succeed(false), ensureRootCheckpoint: () => Effect.succeed(false), restoreCheckpoint: () => Effect.succeed(false), diffCheckpoints: () => Effect.fail( - new CheckpointValidationError({ + new CheckpointGitCommandError({ operation: "diffCheckpoints", - issue: "not implemented", + command: "git diff", + cwd: "/tmp", + detail: "not implemented", }), ), - pruneAfterTurn: () => Effect.void, + deleteCheckpointRefs: () => Effect.void, }; - const checkpointCatalogLive: typeof CheckpointCatalog.Service = { + const checkpointRepositoryLive: typeof CheckpointRepository.Service = { upsertCheckpoint: () => Effect.void, listCheckpoints: () => Effect.succeed([]), getCheckpoint: () => Effect.succeed(Option.none()), @@ -133,18 +139,21 @@ describe("provider/checkpoint service contracts", () => { captureCurrentTurn: () => Effect.void, listCheckpoints: () => Effect.fail( - new CheckpointValidationError({ operation: "listCheckpoints", issue: "not implemented" }), + new CheckpointServiceValidationError({ + operation: "listCheckpoints", + issue: "not implemented", + }), ), getCheckpointDiff: () => Effect.fail( - new CheckpointValidationError({ + new CheckpointServiceValidationError({ operation: "getCheckpointDiff", issue: "not implemented", }), ), revertToCheckpoint: () => Effect.fail( - new CheckpointValidationError({ + new CheckpointServiceValidationError({ operation: "revertToCheckpoint", issue: "not implemented", }), @@ -174,7 +183,7 @@ describe("provider/checkpoint service contracts", () => { Layer.succeed(ProviderService, providerLive), Layer.succeed(CodexAdapter, codexLive), Layer.succeed(CheckpointStore, checkpointStoreLive), - Layer.succeed(CheckpointCatalog, checkpointCatalogLive), + Layer.succeed(CheckpointRepository, checkpointRepositoryLive), Layer.succeed(CheckpointService, checkpointServiceLive), Layer.succeed(ProviderAdapterRegistry, providerAdapterRegistryLive), Layer.succeed(ProviderSessionDirectory, providerSessionDirectoryLive), @@ -185,7 +194,7 @@ describe("provider/checkpoint service contracts", () => { provider, codex, checkpointStore, - checkpointCatalog, + checkpointRepository, checkpointService, providerAdapterRegistry, providerSessionDirectory, @@ -193,7 +202,7 @@ describe("provider/checkpoint service contracts", () => { runtime.runPromise(Effect.service(ProviderService)), runtime.runPromise(Effect.service(CodexAdapter)), runtime.runPromise(Effect.service(CheckpointStore)), - runtime.runPromise(Effect.service(CheckpointCatalog)), + runtime.runPromise(Effect.service(CheckpointRepository)), runtime.runPromise(Effect.service(CheckpointService)), runtime.runPromise(Effect.service(ProviderAdapterRegistry)), runtime.runPromise(Effect.service(ProviderSessionDirectory)), @@ -202,7 +211,7 @@ describe("provider/checkpoint service contracts", () => { expect(provider).toBe(providerLive); expect(codex).toBe(codexLive); expect(checkpointStore).toBe(checkpointStoreLive); - expect(checkpointCatalog).toBe(checkpointCatalogLive); + expect(checkpointRepository).toBe(checkpointRepositoryLive); expect(checkpointService).toBe(checkpointServiceLive); expect(providerAdapterRegistry).toBe(providerAdapterRegistryLive); expect(providerSessionDirectory).toBe(providerSessionDirectoryLive); @@ -225,12 +234,12 @@ describe("provider/checkpoint errors", () => { expect(fsError.cause).toBe(fsCause); }); - it("accepts checkpoint catalog error channel type", () => { - const error: CheckpointCatalogError = new CheckpointValidationError({ + it("accepts checkpoint repository error channel type", () => { + const error: CheckpointRepositoryError = new CheckpointRepositoryValidationError({ operation: "upsertCheckpoint", issue: "turnCount must be >= 0", }); - expect(error._tag).toBe("CheckpointValidationError"); + expect(error._tag).toBe("CheckpointRepositoryValidationError"); }); }); From 45772ec5404789b56e29f073f97aff3fae4e302a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 16:19:37 -0800 Subject: [PATCH 20/98] new plan --- .plans/12-effect-new.md | 58 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .plans/12-effect-new.md diff --git a/.plans/12-effect-new.md b/.plans/12-effect-new.md new file mode 100644 index 0000000000000..556b6c92b8ef0 --- /dev/null +++ b/.plans/12-effect-new.md @@ -0,0 +1,58 @@ +# Effect Migration Plan (From Current State) + +Current status summary: +- Service contracts, typed errors, and most checkpoint/persistence services exist. +- `ProviderServiceLive` is already native orchestration (not a thin adapter). +- Production server path still uses legacy `ProviderManager`/`FilesystemCheckpointStore`. +- Checkpoint flow now avoids snapshot re-sync and is write-time driven. + +## PR 1: Wire Provider/Checkpoint Effect Stack Into `wsServer` +- Build one runtime layer graph for provider + checkpoint + persistence + orchestration. +- Resolve `ProviderService` from runtime in `wsServer`. +- Replace `ProviderManager` method calls in WS handlers with `ProviderService` calls. +- Forward provider events by subscribing to `ProviderService.subscribeToEvents`. +- Keep WS method/push payloads identical. + +## PR 2: Runtime Composition + Startup Ownership +- Create/centralize `AppLive` composition for server startup. +- Ensure outer runtime provides Node/platform services once. +- Ensure migrations run at startup via scoped/layer startup path. +- Remove ad-hoc service initialization in request-time paths. + +## PR 3: Session Lifecycle Hygiene + Checkpoint Invariants +- Add explicit checkpoint session cleanup on `stopSession` / `stopAll`. +- Remove per-session lock/cwd map leaks. +- Keep strict invariant model: + - root checkpoint created at session initialization before agent modifications + - each completed turn captures filesystem checkpoint and persists metadata + - no after-the-fact metadata rebuild/sync +- Add tests for lifecycle cleanup and invariant-failure surfaces. + +## PR 4: Provider Event Stream Hardening (Without Extra Service Fragmentation) +- Keep `ProviderService` as the public event surface. +- Internally move callback fanout to Effect concurrency primitives (`Queue`/`PubSub`) for ordering/backpressure control. +- Keep API as `subscribeToEvents` unless we explicitly choose stream API later. +- Add tests for ordering and subscriber isolation under load. + +## PR 5: Codex Runtime Split (Scoped Effect Core) +- Extract `CodexAppServerManager` responsibilities into Effect-native layers: + - scoped process lifecycle + - RPC request/response + pending map via `Deferred` + - session registry/state +- Keep `CodexAdapter` contract stable while swapping internals. +- Preserve protocol behavior and timeout semantics. + +## PR 6: Codex Protocol Decode Hardening +- Replace ad-hoc unknown parsing with runtime schema decode. +- Map decode failures to typed tagged errors with `cause` retained. +- Add regression tests for malformed/partial protocol frames. + +## PR 7: Remove Legacy Provider Stack +- Remove `ProviderManager` + legacy checkpoint integration from runtime path. +- Remove `FilesystemCheckpointStore` from active server flow (keep only if explicitly needed for compatibility tooling). +- Update tests to assert only Effect service path is used. + +## PR 8: Final Cleanup + Docs +- Update architecture docs with final layer graph and service boundaries. +- Document error model and recovery semantics. +- Trim dead compatibility code and stale plan references. From a97f24e2e4e6ceb61df28a4b1ed635160de9c6fb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 17:56:35 -0800 Subject: [PATCH 21/98] fix tests --- apps/server/package.json | 1 - .../Layers/CheckpointService.test.ts | 259 ++++++++------- .../checkpointing/Layers/CheckpointService.ts | 52 ++- apps/server/src/orchestration/Layer.test.ts | 124 ++----- .../persistence/Layers/Checkpoints.test.ts | 21 +- .../src/persistence/Layers/Checkpoints.ts | 5 - .../Layers/OrchestrationEvents.test.ts | 112 ++----- .../persistence/Layers/OrchestrationEvents.ts | 5 - .../src/persistence/Layers/Projects.test.ts | 107 +++--- apps/server/src/persistence/Layers/Sqlite.ts | 53 ++- apps/server/src/persistence/Migrations.ts | 2 +- .../src/persistence/SqliteClient.test.ts | 26 ++ apps/server/src/persistence/SqliteClient.ts | 242 ++++++++++++++ .../Layers/ProviderAdapterRegistry.test.ts | 18 +- .../Layers/ProviderAdapterRegistry.ts | 4 - .../provider/Layers/ProviderService.test.ts | 53 ++- .../src/provider/Layers/ProviderService.ts | 12 +- apps/server/src/wsServer.test.ts | 18 +- apps/server/src/wsServer.ts | 305 +++++++++++------- bun.lock | 61 ---- 20 files changed, 840 insertions(+), 640 deletions(-) create mode 100644 apps/server/src/persistence/SqliteClient.test.ts create mode 100644 apps/server/src/persistence/SqliteClient.ts diff --git a/apps/server/package.json b/apps/server/package.json index 5b0ee327fc4ff..4f311dc5817e8 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -18,7 +18,6 @@ }, "dependencies": { "@effect/platform-node": "4.0.0-beta.10", - "@effect/sql-sqlite-node": "4.0.0-beta.10", "effect": "4.0.0-beta.10", "node-pty": "^1.1.0", "open": "^10.1.0", diff --git a/apps/server/src/checkpointing/Layers/CheckpointService.test.ts b/apps/server/src/checkpointing/Layers/CheckpointService.test.ts index e1d3c6be38501..4347a6006c3fc 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointService.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointService.test.ts @@ -1,7 +1,3 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - import type { ProviderApprovalDecision, ProviderEvent, @@ -13,7 +9,7 @@ import type { } from "@t3tools/contracts"; import { NodeServices } from "@effect/platform-node"; import { it, assert } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, FileSystem, Layer, Path } from "effect"; import { runGit as runGitProcess } from "../../git/Process.ts"; import { CheckpointServiceLive } from "./CheckpointService.ts"; @@ -34,14 +30,9 @@ import type { } from "../../provider/Services/ProviderAdapter.ts"; import { ProviderSessionDirectory } from "../../provider/Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "../../provider/Layers/ProviderSessionDirectory.ts"; -import { makeSqliteCheckpointRepositoryLive } from "../../persistence/Layers/Checkpoints.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import { CheckpointRepository } from "../../persistence/Services/Checkpoints.ts"; - -const makeTemporaryDirectoryScoped = (prefix: string) => - Effect.acquireRelease( - Effect.promise(() => mkdtemp(path.join(os.tmpdir(), prefix))), - (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })), - ); +import { CheckpointRepositoryLive } from "../../persistence/Layers/Checkpoints.ts"; const runGit = (cwd: string, args: readonly string[]) => runGitProcess({ @@ -53,23 +44,24 @@ const runGit = (cwd: string, args: readonly string[]) => Effect.provide(NodeServices.layer), ); -function createRepo() { - return Effect.gen(function* () { - const cwd = yield* makeTemporaryDirectoryScoped("t3-checkpoint-service-repo-"); - yield* runGit(cwd, ["init", "--initial-branch=main"]); - yield* runGit(cwd, ["config", "user.email", "test@example.com"]); - yield* runGit(cwd, ["config", "user.name", "Test User"]); - yield* Effect.promise(() => writeFile(path.join(cwd, "README.md"), "v1\n")); - yield* runGit(cwd, ["add", "."]); - yield* runGit(cwd, ["commit", "-m", "Initial"]); - return cwd; - }); -} +const makeGitRepository = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fs.makeTempDirectory(); + yield* runGit(cwd, ["init", "--initial-branch=main"]); + yield* runGit(cwd, ["config", "user.email", "test@example.com"]); + yield* runGit(cwd, ["config", "user.name", "Test User"]); + yield* fs.writeFileString(path.join(cwd, "README.md"), "v1\n"); + yield* runGit(cwd, ["add", "."]); + yield* runGit(cwd, ["commit", "-m", "Initial"]); + return cwd; +}); class InMemoryProviderAdapter implements ProviderAdapterShape { readonly provider = "codex" as const; private active = true; + private readThreadFailure: ProviderAdapterError | undefined; private snapshot: ProviderThreadSnapshot; private readonly session: ProviderSession; @@ -105,6 +97,10 @@ class InMemoryProviderAdapter implements ProviderAdapterShape => @@ -157,7 +153,9 @@ class InMemoryProviderAdapter implements ProviderAdapterShape hasSession - ? Effect.succeed(this.snapshot) + ? this.readThreadFailure + ? Effect.fail(this.readThreadFailure) + : Effect.succeed(this.snapshot) : Effect.fail( new ProviderAdapterSessionNotFoundError({ provider: this.provider, @@ -203,9 +201,7 @@ class InMemoryProviderAdapter implements ProviderAdapterShape) { return Effect.gen(function* () { - const cwd = yield* createRepo(); - const stateDir = yield* makeTemporaryDirectoryScoped("t3-checkpoint-service-state-"); - const dbPath = path.join(stateDir, "orchestration.sqlite"); + const cwd = yield* makeGitRepository; const sessionId = "sess-1"; const adapter = new InMemoryProviderAdapter(sessionId, cwd, turns); @@ -220,9 +216,10 @@ function makeFixture(turns: ReadonlyArray) { const dependencies = Layer.mergeAll( Layer.provide(CheckpointStoreLive, NodeServices.layer), - makeSqliteCheckpointRepositoryLive(dbPath), + Layer.provide(CheckpointRepositoryLive, SqlitePersistenceMemory), Layer.succeed(ProviderAdapterRegistry, registry), ProviderSessionDirectoryLive, + NodeServices.layer, ); const layer = CheckpointServiceLive.pipe(Layer.provideMerge(dependencies)); @@ -236,87 +233,84 @@ function makeFixture(turns: ReadonlyArray) { }); } -it.effect( - "CheckpointServiceLive captures checkpoints, diffs refs, and reverts workspace + turns", - () => - Effect.gen(function* () { - const firstTurn: ProviderThreadTurnSnapshot = { - id: "turn-1", - items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }], - }; - const secondTurn: ProviderThreadTurnSnapshot = { - id: "turn-2", - items: [{ type: "agentMessage", text: "second" }], - }; - - const fixture = yield* makeFixture([]); - - yield* Effect.gen(function* () { - const service = yield* CheckpointService; - const directory = yield* ProviderSessionDirectory; - - yield* directory.upsert({ - sessionId: fixture.sessionId, - provider: "codex", - threadId: "thread-1", - }); - - yield* service.initializeForSession({ - providerSessionId: fixture.sessionId, - cwd: fixture.cwd, - }); - - yield* Effect.promise(() => writeFile(path.join(fixture.cwd, "README.md"), "v2\n")); - fixture.adapter.setTurns([firstTurn]); - - yield* service.captureCurrentTurn({ - providerSessionId: fixture.sessionId, - }); - - const listed = yield* service.listCheckpoints({ sessionId: fixture.sessionId }); - assert.equal(listed.checkpoints.length, 2); - assert.equal(listed.checkpoints[1]?.id, "turn-1"); - assert.equal(listed.checkpoints[1]?.isCurrent, true); - - const diff = yield* service.getCheckpointDiff({ - sessionId: fixture.sessionId, - fromTurnCount: 0, - toTurnCount: 1, - }); - assert.equal(diff.threadId, "thread-1"); - assert.equal(diff.diff.includes("diff --git a/README.md b/README.md"), true); - assert.equal(diff.diff.includes("-v1"), true); - assert.equal(diff.diff.includes("+v2"), true); - - yield* Effect.promise(() => writeFile(path.join(fixture.cwd, "README.md"), "v3\n")); - fixture.adapter.setTurns([firstTurn, secondTurn]); - - yield* service.captureCurrentTurn({ - providerSessionId: fixture.sessionId, - }); - - const beforeRevert = yield* Effect.promise(() => - readFile(path.join(fixture.cwd, "README.md"), "utf8"), - ); - assert.equal(beforeRevert, "v3\n"); - - const reverted = yield* service.revertToCheckpoint({ - sessionId: fixture.sessionId, - turnCount: 1, - }); +it.effect("captures checkpoints, diffs refs, and reverts workspace + turns", () => + Effect.gen(function* () { + const firstTurn: ProviderThreadTurnSnapshot = { + id: "turn-1", + items: [{ type: "userMessage", content: [{ type: "text", text: "first" }] }], + }; + const secondTurn: ProviderThreadTurnSnapshot = { + id: "turn-2", + items: [{ type: "agentMessage", text: "second" }], + }; - const afterRevert = yield* Effect.promise(() => - readFile(path.join(fixture.cwd, "README.md"), "utf8"), - ); - assert.equal(afterRevert, "v2\n"); - assert.equal(reverted.turnCount, 1); - assert.equal(reverted.rolledBackTurns, 1); - assert.equal(reverted.checkpoints.length, 2); - assert.equal(reverted.checkpoints[1]?.id, "turn-1"); - assert.equal(reverted.checkpoints[1]?.isCurrent, true); - assert.equal(fixture.adapter.getTurns().length, 1); - }).pipe(Effect.provide(fixture.layer)); - }), + const fixture = yield* makeFixture([]); + + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const service = yield* CheckpointService; + const directory = yield* ProviderSessionDirectory; + + yield* directory.upsert({ + sessionId: fixture.sessionId, + provider: "codex", + threadId: "thread-1", + }); + + yield* service.initializeForSession({ + providerSessionId: fixture.sessionId, + cwd: fixture.cwd, + }); + + yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v2\n"); + fixture.adapter.setTurns([firstTurn]); + + yield* service.captureCurrentTurn({ + providerSessionId: fixture.sessionId, + }); + + const listed = yield* service.listCheckpoints({ sessionId: fixture.sessionId }); + assert.equal(listed.checkpoints.length, 2); + assert.equal(listed.checkpoints[1]?.id, "turn-1"); + assert.equal(listed.checkpoints[1]?.isCurrent, true); + + const diff = yield* service.getCheckpointDiff({ + sessionId: fixture.sessionId, + fromTurnCount: 0, + toTurnCount: 1, + }); + assert.equal(diff.threadId, "thread-1"); + assert.equal(diff.diff.includes("diff --git a/README.md b/README.md"), true); + assert.equal(diff.diff.includes("-v1"), true); + assert.equal(diff.diff.includes("+v2"), true); + + yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v3\n"); + fixture.adapter.setTurns([firstTurn, secondTurn]); + + yield* service.captureCurrentTurn({ + providerSessionId: fixture.sessionId, + }); + + const beforeRevert = yield* fs.readFileString(path.join(fixture.cwd, "README.md")); + assert.equal(beforeRevert, "v3\n"); + + const reverted = yield* service.revertToCheckpoint({ + sessionId: fixture.sessionId, + turnCount: 1, + }); + + const afterRevert = yield* fs.readFileString(path.join(fixture.cwd, "README.md")); + assert.equal(afterRevert, "v2\n"); + assert.equal(reverted.turnCount, 1); + assert.equal(reverted.rolledBackTurns, 1); + assert.equal(reverted.checkpoints.length, 2); + assert.equal(reverted.checkpoints[1]?.id, "turn-1"); + assert.equal(reverted.checkpoints[1]?.isCurrent, true); + assert.equal(fixture.adapter.getTurns().length, 1); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect( @@ -338,7 +332,7 @@ it.effect( assert.equal(result.failure._tag, "CheckpointSessionNotFoundError"); } }).pipe(Effect.provide(fixture.layer)); - }), + }).pipe(Effect.provide(NodeServices.layer)), ); it.effect( @@ -353,6 +347,8 @@ it.effect( const fixture = yield* makeFixture([]); yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const service = yield* CheckpointService; const store = yield* CheckpointStore; const checkpointRepository = yield* CheckpointRepository; @@ -369,7 +365,7 @@ it.effect( cwd: fixture.cwd, }); - yield* Effect.promise(() => writeFile(path.join(fixture.cwd, "README.md"), "v2\n")); + yield* fs.writeFileString(path.join(fixture.cwd, "README.md"), "v2\n"); fixture.adapter.setTurns([firstTurn]); yield* service.captureCurrentTurn({ providerSessionId: fixture.sessionId, @@ -400,5 +396,44 @@ it.effect( assert.equal(result.failure._tag, "CheckpointUnavailableError"); } }).pipe(Effect.provide(fixture.layer)); - }), + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("CheckpointServiceLive initializes root checkpoints without requiring thread/read", () => + Effect.gen(function* () { + const fixture = yield* makeFixture([]); + + yield* Effect.gen(function* () { + const service = yield* CheckpointService; + const checkpointRepository = yield* CheckpointRepository; + const directory = yield* ProviderSessionDirectory; + + yield* directory.upsert({ + sessionId: fixture.sessionId, + provider: "codex", + threadId: "thread-1", + }); + fixture.adapter.setReadThreadFailure( + new ProviderAdapterValidationError({ + provider: "codex", + operation: "readThread", + issue: "thread/read is temporarily unavailable", + }), + ); + + yield* service.initializeForSession({ + providerSessionId: fixture.sessionId, + cwd: fixture.cwd, + }); + + const root = yield* checkpointRepository.getCheckpoint({ + providerSessionId: fixture.sessionId, + turnCount: 0, + }); + assert.equal(root._tag, "Some"); + if (root._tag === "Some") { + assert.equal(root.value.threadId, "thread-1"); + } + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/checkpointing/Layers/CheckpointService.ts b/apps/server/src/checkpointing/Layers/CheckpointService.ts index 0677d7615cf03..2c54cb3c547f2 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointService.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointService.ts @@ -441,6 +441,43 @@ const makeCheckpointService = Effect.gen(function* () { ), ); + const resolveThreadIdForSession = ( + sessionId: string, + operation: string, + ): Effect.Effect => + directory.getThreadId(sessionId).pipe( + Effect.mapError( + (cause) => + new CheckpointSessionNotFoundError({ + sessionId, + cause, + }), + ), + Effect.flatMap((threadId) => + Option.match(threadId, { + onSome: (value) => { + const normalizedThreadId = value.trim(); + if (normalizedThreadId.length > 0) { + return Effect.succeed(normalizedThreadId); + } + return Effect.fail( + invariantError( + operation, + `Resolved empty thread id for session ${sessionId}.`, + ), + ); + }, + onNone: () => + Effect.fail( + invariantError( + operation, + `Missing thread id in session directory for session ${sessionId}.`, + ), + ), + }), + ), + ); + const rollbackThreadSnapshot = ( sessionId: string, adapter: ProviderAdapterShape, @@ -633,19 +670,10 @@ const makeCheckpointService = Effect.gen(function* () { Effect.gen(function* () { const adapter = yield* resolveAdapterForSession(input.providerSessionId); yield* ensureSessionIsActive(input.providerSessionId, adapter); - const snapshot = yield* readThreadSnapshot( + const threadId = yield* resolveThreadIdForSession( input.providerSessionId, - adapter, - "CheckpointService.initializeForSession:readThread", + "CheckpointService.initializeForSession", ); - if (snapshot.turns.length !== 0) { - return yield* Effect.fail( - invariantError( - "CheckpointService.initializeForSession", - `Expected empty thread snapshot during initialization, received ${snapshot.turns.length} turn(s).`, - ), - ); - } const supportsGit = yield* store.isGitRepository(input.cwd); if (!supportsGit) { @@ -686,7 +714,7 @@ const makeCheckpointService = Effect.gen(function* () { yield* captureAndPersistCheckpoint({ providerSessionId: input.providerSessionId, cwd: input.cwd, - threadId: snapshot.threadId, + threadId, checkpoint: rootCheckpoint(), }); yield* setTrackedCheckpointCwd(input.providerSessionId, input.cwd); diff --git a/apps/server/src/orchestration/Layer.test.ts b/apps/server/src/orchestration/Layer.test.ts index fb383ab32253f..27f29b8e76d4e 100644 --- a/apps/server/src/orchestration/Layer.test.ts +++ b/apps/server/src/orchestration/Layer.test.ts @@ -1,40 +1,21 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - import type { OrchestrationEvent } from "@t3tools/contracts"; import { Effect, Layer, ManagedRuntime } from "effect"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { OrchestrationEventRepository, type OrchestrationEventRepositoryShape, } from "../persistence/Services/OrchestrationEvents.ts"; import { PersistenceSqlError } from "../persistence/Errors.ts"; -import { makeSqlitePersistenceLive } from "../persistence/Layers/Sqlite.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { OrchestrationEngineLive } from "./Layer.ts"; import { OrchestrationEngineService } from "./Service.ts"; import { OrchestrationEventRepositoryLive } from "../persistence/Layers/OrchestrationEvents.ts"; -const tempDirs: string[] = []; - -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -export async function createOrchestrationSystem(stateDir: string) { - const dbPath = path.join(stateDir, "orchestration.sqlite"); +export async function createOrchestrationSystem() { const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationEventRepositoryLive), - Layer.provide(makeSqlitePersistenceLive(dbPath)), + Layer.provide(SqlitePersistenceMemory), ); const runtime = ManagedRuntime.make(orchestrationLayer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); @@ -46,16 +27,15 @@ export async function createOrchestrationSystem(stateDir: string) { } describe("OrchestrationEngine", () => { - it("replays to the same deterministic snapshot", async () => { - const stateDir = makeTempDir("t3code-orchestration-"); + it("returns deterministic snapshots for repeated reads", async () => { const createdAt = new Date().toISOString(); const projectId = "project-1"; const threadId = "thread-1"; - const firstSystem = await createOrchestrationSystem(stateDir); - const engineA = firstSystem.engine; - await firstSystem.run( - engineA.dispatch({ + const system = await createOrchestrationSystem(); + const engine = system.engine; + await system.run( + engine.dispatch({ type: "thread.create", commandId: "cmd-1", threadId, @@ -67,8 +47,8 @@ describe("OrchestrationEngine", () => { createdAt, }), ); - await firstSystem.run( - engineA.dispatch({ + await system.run( + engine.dispatch({ type: "message.send", commandId: "cmd-3", threadId, @@ -79,19 +59,14 @@ describe("OrchestrationEngine", () => { createdAt, }), ); - const snapshotA = await firstSystem.run(engineA.getSnapshot()); - await firstSystem.dispose(); - - const secondSystem = await createOrchestrationSystem(stateDir); - const engineB = secondSystem.engine; - const snapshotB = await secondSystem.run(engineB.getSnapshot()); + const snapshotA = await system.run(engine.getSnapshot()); + const snapshotB = await system.run(engine.getSnapshot()); expect(snapshotB).toEqual(snapshotA); - await secondSystem.dispose(); + await system.dispose(); }); it("fans out read-model updates to subscribers", async () => { - const stateDir = makeTempDir("t3code-orchestration-fanout-"); - const system = await createOrchestrationSystem(stateDir); + const system = await createOrchestrationSystem(); const engine = system.engine; const updates: number[] = []; const unsubscribe = await system.run( @@ -118,8 +93,7 @@ describe("OrchestrationEngine", () => { }); it("replays append-only events from sequence", async () => { - const stateDir = makeTempDir("t3code-orchestration-replay-"); - const system = await createOrchestrationSystem(stateDir); + const system = await createOrchestrationSystem(); const engine = system.engine; await system.run( engine.dispatch({ @@ -150,11 +124,10 @@ describe("OrchestrationEngine", () => { }); it("stores completed turn summaries even when no files changed", async () => { - const stateDir = makeTempDir("t3code-orchestration-turn-diff-"); - const firstSystem = await createOrchestrationSystem(stateDir); - const engine = firstSystem.engine; + const system = await createOrchestrationSystem(); + const engine = system.engine; const completedAt = new Date().toISOString(); - await firstSystem.run( + await system.run( engine.dispatch({ type: "thread.create", commandId: "cmd-thread-turn-diff", @@ -167,7 +140,7 @@ describe("OrchestrationEngine", () => { createdAt: completedAt, }), ); - await firstSystem.run( + await system.run( engine.dispatch({ type: "thread.turnDiff.complete", commandId: "cmd-turn-diff-complete", @@ -181,7 +154,7 @@ describe("OrchestrationEngine", () => { }), ); - const firstThread = (await firstSystem.run(engine.getSnapshot())).threads.find( + const firstThread = (await system.run(engine.getSnapshot())).threads.find( (thread) => thread.id === "thread-turn-diff", ); expect(firstThread?.turnDiffSummaries).toEqual([ @@ -193,31 +166,14 @@ describe("OrchestrationEngine", () => { checkpointTurnCount: 1, }, ]); - await firstSystem.dispose(); - - const secondSystem = await createOrchestrationSystem(stateDir); - const restartedEngine = secondSystem.engine; - const restartedThread = (await secondSystem.run(restartedEngine.getSnapshot())).threads.find( - (thread) => thread.id === "thread-turn-diff", - ); - expect(restartedThread?.turnDiffSummaries).toEqual([ - { - turnId: "turn-1", - completedAt, - status: "completed", - files: [], - checkpointTurnCount: 1, - }, - ]); - await secondSystem.dispose(); + await system.dispose(); }); it("reverts thread messages and turn summaries to a checkpoint", async () => { - const stateDir = makeTempDir("t3code-orchestration-revert-"); - const firstSystem = await createOrchestrationSystem(stateDir); - const engine = firstSystem.engine; + const system = await createOrchestrationSystem(); + const engine = system.engine; const createdAt = new Date().toISOString(); - await firstSystem.run( + await system.run( engine.dispatch({ type: "thread.create", commandId: "cmd-thread-revert", @@ -230,7 +186,7 @@ describe("OrchestrationEngine", () => { createdAt, }), ); - await firstSystem.run( + await system.run( engine.dispatch({ type: "message.send", commandId: "cmd-msg-1-user", @@ -242,7 +198,7 @@ describe("OrchestrationEngine", () => { createdAt, }), ); - await firstSystem.run( + await system.run( engine.dispatch({ type: "message.send", commandId: "cmd-msg-1-assistant", @@ -254,7 +210,7 @@ describe("OrchestrationEngine", () => { createdAt, }), ); - await firstSystem.run( + await system.run( engine.dispatch({ type: "thread.turnDiff.complete", commandId: "cmd-turn-1-complete", @@ -270,7 +226,7 @@ describe("OrchestrationEngine", () => { ); const createdAtSecond = new Date(Date.now() + 1_000).toISOString(); - await firstSystem.run( + await system.run( engine.dispatch({ type: "message.send", commandId: "cmd-msg-2-user", @@ -282,7 +238,7 @@ describe("OrchestrationEngine", () => { createdAt: createdAtSecond, }), ); - await firstSystem.run( + await system.run( engine.dispatch({ type: "message.send", commandId: "cmd-msg-2-assistant", @@ -294,7 +250,7 @@ describe("OrchestrationEngine", () => { createdAt: createdAtSecond, }), ); - await firstSystem.run( + await system.run( engine.dispatch({ type: "thread.turnDiff.complete", commandId: "cmd-turn-2-complete", @@ -309,7 +265,7 @@ describe("OrchestrationEngine", () => { }), ); - await firstSystem.run( + await system.run( engine.dispatch({ type: "thread.revert", commandId: "cmd-thread-revert-apply", @@ -320,25 +276,13 @@ describe("OrchestrationEngine", () => { }), ); - const thread = (await firstSystem.run(engine.getSnapshot())).threads.find( + const thread = (await system.run(engine.getSnapshot())).threads.find( (entry) => entry.id === "thread-revert", ); expect(thread?.messages.map((message) => message.id)).toEqual(["user-1", "assistant:turn-1"]); expect(thread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); expect(thread?.latestTurnId).toBe("turn-1"); - await firstSystem.dispose(); - - const secondSystem = await createOrchestrationSystem(stateDir); - const restarted = secondSystem.engine; - const restartedThread = (await secondSystem.run(restarted.getSnapshot())).threads.find( - (entry) => entry.id === "thread-revert", - ); - expect(restartedThread?.messages.map((message) => message.id)).toEqual([ - "user-1", - "assistant:turn-1", - ]); - expect(restartedThread?.turnDiffSummaries.map((summary) => summary.turnId)).toEqual(["turn-1"]); - await secondSystem.dispose(); + await system.dispose(); }); it("allows stop to be called multiple times", async () => { diff --git a/apps/server/src/persistence/Layers/Checkpoints.test.ts b/apps/server/src/persistence/Layers/Checkpoints.test.ts index d13b5c14f8b47..437bf734733c0 100644 --- a/apps/server/src/persistence/Layers/Checkpoints.test.ts +++ b/apps/server/src/persistence/Layers/Checkpoints.test.ts @@ -1,29 +1,16 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; - import { it, assert } from "@effect/vitest"; import { Effect, Layer, Option } from "effect"; import { CheckpointRepository } from "../Services/Checkpoints.ts"; import { CheckpointRepositoryLive } from "./Checkpoints.ts"; -import { makeSqlitePersistenceLive } from "./Sqlite.ts"; - -const makeTemporaryDirectoryScoped = (prefix: string) => - Effect.acquireRelease( - Effect.promise(() => mkdtemp(path.join(os.tmpdir(), prefix))), - (directory) => Effect.promise(() => rm(directory, { recursive: true, force: true })), - ); +import { SqlitePersistenceMemory } from "./Sqlite.ts"; -function makeCheckpointRepositoryLayer(dbPath: string) { - return CheckpointRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); +function makeCheckpointRepositoryLayer() { + return CheckpointRepositoryLive.pipe(Layer.provide(SqlitePersistenceMemory)); } it.effect("CheckpointRepositoryLive upserts, lists, reads, and prunes checkpoint metadata", () => Effect.gen(function* () { - const stateDir = yield* makeTemporaryDirectoryScoped("t3code-checkpoint-repository-"); - const dbPath = path.join(stateDir, "orchestration.sqlite"); - yield* Effect.gen(function* () { const repository = yield* CheckpointRepository; @@ -90,6 +77,6 @@ it.effect("CheckpointRepositoryLive upserts, lists, reads, and prunes checkpoint yield* repository.deleteAllForSession({ providerSessionId: "sess-1" }); const empty = yield* repository.listCheckpoints({ providerSessionId: "sess-1" }); assert.equal(empty.length, 0); - }).pipe(Effect.provide(makeCheckpointRepositoryLayer(dbPath))); + }).pipe(Effect.provide(makeCheckpointRepositoryLayer())); }), ); diff --git a/apps/server/src/persistence/Layers/Checkpoints.ts b/apps/server/src/persistence/Layers/Checkpoints.ts index b9efe5b9b0b3f..c6228c80974b9 100644 --- a/apps/server/src/persistence/Layers/Checkpoints.ts +++ b/apps/server/src/persistence/Layers/Checkpoints.ts @@ -20,7 +20,6 @@ import { CheckpointRepositoryValidationError, type CheckpointRepositoryError, } from "../Errors.ts"; -import { makeSqlitePersistenceLive } from "./Sqlite.ts"; import { CheckpointRepository, type CheckpointRepositoryEntry, @@ -357,7 +356,3 @@ export const CheckpointRepositoryLive = Layer.effect( CheckpointRepository, makeCheckpointRepository, ); - -export function makeSqliteCheckpointRepositoryLive(dbPath: string) { - return CheckpointRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); -} diff --git a/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts index 6b11a973e2358..dddba6a727d9b 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.test.ts @@ -1,39 +1,25 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -import { ManagedRuntime, Effect } from "effect"; -import { afterEach, describe, expect, it } from "vitest"; +import { Effect, Layer } from "effect"; +import { assert, it } from "@effect/vitest"; import { OrchestrationEventRepository } from "../Services/OrchestrationEvents.ts"; -import { makeSqliteOrchestrationEventRepositoryLive } from "./OrchestrationEvents.ts"; - -const tempDirs: string[] = []; - -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("OrchestrationEventRepository", () => { - it("persists and replays events across restarts", async () => { - const stateDir = makeTempDir("t3code-event-store-"); - const dbPath = path.join(stateDir, "orchestration.sqlite"); - - const createdAt = new Date().toISOString(); - - const firstRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); - const first = await firstRuntime.runPromise(Effect.service(OrchestrationEventRepository)); - const saved = await firstRuntime.runPromise( - first.append({ +import { SqlitePersistenceMemory } from "./Sqlite.ts"; +import { OrchestrationEventRepositoryLive } from "./OrchestrationEvents.ts"; +import { NodeServices } from "@effect/platform-node"; + +const layer = it.layer( + Layer.mergeAll( + Layer.provide(OrchestrationEventRepositoryLive, SqlitePersistenceMemory), + NodeServices.layer, + ), +); + +layer("OrchestrationEventRepository", (it) => { + it.effect("stores and replays events", () => + Effect.gen(function* () { + const createdAt = new Date().toISOString(); + + const repository = yield* OrchestrationEventRepository; + const saved = yield* repository.append({ eventId: "event-1", type: "thread.created", aggregateType: "thread", @@ -41,57 +27,11 @@ describe("OrchestrationEventRepository", () => { occurredAt: createdAt, commandId: "cmd-1", payload: { id: "thread-1", projectId: "project-1", title: "demo" }, - }), - ); - expect(saved.sequence).toBe(1); - await firstRuntime.dispose(); - - const secondRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); - const second = await secondRuntime.runPromise(Effect.service(OrchestrationEventRepository)); - const replayed = await secondRuntime.runPromise(second.readFromSequence(0)); - expect(replayed).toEqual([saved]); - await secondRuntime.dispose(); - }); - - it("creates and reuses the migrator tracking table", async () => { - const stateDir = makeTempDir("t3code-event-store-migrations-"); - const dbPath = path.join(stateDir, "orchestration.sqlite"); - - const firstRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); - const first = await firstRuntime.runPromise(Effect.service(OrchestrationEventRepository)); - await firstRuntime.runPromise(first.readAll()); - await firstRuntime.dispose(); - - const secondRuntime = ManagedRuntime.make(makeSqliteOrchestrationEventRepositoryLive(dbPath)); - const second = await secondRuntime.runPromise(Effect.service(OrchestrationEventRepository)); - await secondRuntime.runPromise(second.readAll()); - await secondRuntime.dispose(); - - const db = new DatabaseSync(dbPath, { readOnly: true }); - const tableRow = db - .prepare( - ` - SELECT name - FROM sqlite_master - WHERE type = 'table' AND name IN ('effect_sql_migrations', 'orchestration_sql_migrations') - ORDER BY name ASC - `, - ) - .get() as { name: string } | undefined; - expect(tableRow?.name).toBeDefined(); - expect(tableRow).toBeDefined(); - if (!tableRow) return; + }); + assert.equal(saved.sequence, 1); - const migrationRows = db - .prepare( - ` - SELECT * - FROM ${tableRow.name} - `, - ) - .all() as Array>; - expect(migrationRows.length).toBeGreaterThanOrEqual(1); - expect(Object.values(migrationRows[0] ?? {}).length).toBeGreaterThan(0); - db.close(); - }); + const replayed = yield* repository.readFromSequence(0); + assert.deepEqual(replayed, [saved]); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/OrchestrationEvents.ts b/apps/server/src/persistence/Layers/OrchestrationEvents.ts index bd9f0bd7c39af..4da8c8c06323e 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEvents.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEvents.ts @@ -11,7 +11,6 @@ import { toPersistenceSqlError, type OrchestrationEventRepositoryError, } from "../Errors.ts"; -import { makeSqlitePersistenceLive } from "./Sqlite.ts"; import { OrchestrationEventRepository, type OrchestrationEventRepositoryShape, @@ -177,7 +176,3 @@ export const OrchestrationEventRepositoryLive = Layer.effect( OrchestrationEventRepository, makeRepository, ); - -export function makeSqliteOrchestrationEventRepositoryLive(dbPath: string) { - return OrchestrationEventRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); -} diff --git a/apps/server/src/persistence/Layers/Projects.test.ts b/apps/server/src/persistence/Layers/Projects.test.ts index c8ee6215c92f2..0eb5f2689fd32 100644 --- a/apps/server/src/persistence/Layers/Projects.test.ts +++ b/apps/server/src/persistence/Layers/Projects.test.ts @@ -1,83 +1,56 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { Effect, Layer, ManagedRuntime } from "effect"; -import { afterEach, describe, expect, it } from "vitest"; +import { Effect, Layer, FileSystem } from "effect"; +import { assert, it } from "@effect/vitest"; import { ProjectRepository } from "../Services/Projects.ts"; import { ProjectRepositoryLive } from "./Projects.ts"; -import { makeSqlitePersistenceLive } from "./Sqlite.ts"; - -function makeProjectRepositoryTest(dbPath: string) { - return ProjectRepositoryLive.pipe(Layer.provide(makeSqlitePersistenceLive(dbPath))); -} - -const tempDirs: string[] = []; - -function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -describe("ProjectRepository", () => { - it("persists projects and deduplicates adds by cwd", async () => { - const stateDir = makeTempDir("t3code-project-repo-state-"); - const projectDir = makeTempDir("t3code-project-repo-project-"); - const dbPath = path.join(stateDir, "orchestration.sqlite"); - - const firstRuntime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); - const first = await firstRuntime.runPromise(Effect.service(ProjectRepository)); - - const created = await firstRuntime.runPromise(first.add({ cwd: projectDir })); - expect(created.created).toBe(true); - - const duplicate = await firstRuntime.runPromise(first.add({ cwd: projectDir })); - expect(duplicate.created).toBe(false); - expect(duplicate.project.id).toBe(created.project.id); +import { SqlitePersistenceMemory } from "./Sqlite.ts"; +import { NodeServices } from "@effect/platform-node"; - await firstRuntime.dispose(); +const layer = it.layer( + Layer.mergeAll(Layer.provide(ProjectRepositoryLive, SqlitePersistenceMemory), NodeServices.layer), +); - const secondRuntime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); - const second = await secondRuntime.runPromise(Effect.service(ProjectRepository)); - const listed = await secondRuntime.runPromise(second.list()); +layer("ProjectRepository", (it) => { + it.effect("stores projects and deduplicates adds by cwd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const projectDir = yield* fs.makeTempDirectoryScoped(); - expect(listed).toHaveLength(1); - expect(listed[0]?.cwd).toBe(projectDir); + const repository = yield* ProjectRepository; - await secondRuntime.dispose(); - }); + const created = yield* repository.add({ cwd: projectDir }); + assert.equal(created.created, true); - it("prunes missing project paths", async () => { - const stateDir = makeTempDir("t3code-project-repo-prune-state-"); - const existing = makeTempDir("t3code-project-repo-prune-existing-"); - const missing = makeTempDir("t3code-project-repo-prune-missing-"); - const dbPath = path.join(stateDir, "orchestration.sqlite"); + const duplicate = yield* repository.add({ cwd: projectDir }); + assert.equal(duplicate.created, false); + assert.equal(duplicate.project.id, created.project.id); - const runtime = ManagedRuntime.make(makeProjectRepositoryTest(dbPath)); - const repository = await runtime.runPromise(Effect.service(ProjectRepository)); + const listed = yield* repository.list(); + assert.equal(listed.length, 1); + assert.equal(listed[0]?.cwd, projectDir); + }), + ); - const existingProject = await runtime.runPromise(repository.add({ cwd: existing })); - const missingProject = await runtime.runPromise(repository.add({ cwd: missing })); + it.effect("prunes missing project paths", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const existing = yield* fs.makeTempDirectoryScoped(); + const missing = yield* fs.makeTempDirectory(); - expect(existingProject.created).toBe(true); - expect(missingProject.created).toBe(true); + const repository = yield* ProjectRepository; - fs.rmSync(missing, { recursive: true, force: true }); + const existingProject = yield* repository.add({ cwd: existing }); + const missingProject = yield* repository.add({ cwd: missing }); - await runtime.runPromise(repository.pruneMissing()); - const listed = await runtime.runPromise(repository.list()); + assert.isTrue(existingProject.created); + assert.isTrue(missingProject.created); - expect(listed).toHaveLength(1); - expect(listed[0]?.cwd).toBe(existing); + yield* fs.remove(missing, { recursive: true, force: true }); - await runtime.dispose(); - }); + yield* repository.pruneMissing(); + const listed = yield* repository.list(); + assert.equal(listed.length, 1); + assert.equal(listed[0]?.cwd, existing); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 53a8b7d558291..7a700c878d460 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -1,41 +1,24 @@ -import fs from "node:fs"; -import path from "node:path"; - -import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqliteClient from "@effect/sql-sqlite-node/SqliteClient"; -import { Effect, Layer, ServiceMap } from "effect"; +import { Effect, Layer, FileSystem, Path } from "effect"; import { runMigrations } from "../Migrations.ts"; +import * as SqliteClient from "../SqliteClient.ts"; -export interface PersistenceConfigShape { - readonly dbPath: string; -} - -export class PersistenceConfig extends ServiceMap.Service< - PersistenceConfig, - PersistenceConfigShape ->()("persistence/Config") {} +const setup = Layer.effectDiscard( + Effect.gen(function* () { + const sql = yield* SqliteClient.SqliteClient; + yield* sql`PRAGMA journal_mode = WAL;`; + yield* sql`PRAGMA foreign_keys = ON;`; + yield* runMigrations; + }), +); -export function makeSqlitePersistenceLive(dbPath: string) { - const SqliteClientLive = Layer.unwrap( - Effect.sync(() => { - fs.mkdirSync(path.dirname(dbPath), { recursive: true }); - return SqliteClient.layer({ filename: dbPath }); - }), - ); +export const makeSqlitePersistenceLive = (dbPath: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); - return Layer.effect( - SqlClient.SqlClient, - Effect.gen(function* () { - const sql = yield* SqliteClient.SqliteClient; - yield* sql`PRAGMA journal_mode = WAL;`; - yield* sql`PRAGMA foreign_keys = ON;`; - yield* runMigrations; - return sql; - }), - ).pipe(Layer.provide(SqliteClientLive)); -} + return Layer.provideMerge(setup, SqliteClient.layer({ filename: dbPath })); + }).pipe(Layer.unwrap); -export const SqlitePersistenceLive = Layer.unwrap( - Effect.map(Effect.service(PersistenceConfig), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)), -); +export const SqlitePersistenceMemory = Layer.provideMerge(setup, SqliteClient.layerMemory()); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 46dba5ae2deb1..f8db8ee1be395 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -59,7 +59,7 @@ export const runMigrations = run({ loader }); * @example * ```typescript * import { MigrationsLive } from "@acme/db/Migrations" - * import { SqliteClient } from "@effect/sql-sqlite-node" + * import * as SqliteClient from "@acme/db/SqliteClient" * * // Migrations run automatically when SqliteClient is provided * const AppLayer = MigrationsLive.pipe( diff --git a/apps/server/src/persistence/SqliteClient.test.ts b/apps/server/src/persistence/SqliteClient.test.ts new file mode 100644 index 0000000000000..68902626cea3e --- /dev/null +++ b/apps/server/src/persistence/SqliteClient.test.ts @@ -0,0 +1,26 @@ +import { assert, it } from "@effect/vitest"; +import { Effect } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as SqliteClient from "./SqliteClient.ts"; + +it.effect("runs prepared queries and returns positional values", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`; + yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`; + + const rows = yield* sql<{ readonly id: number; readonly name: string }>` + SELECT id, name FROM entries ORDER BY id + `; + assert.equal(rows.length, 2); + assert.equal(rows[0]?.name, "alpha"); + assert.equal(rows[1]?.name, "beta"); + + const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values; + assert.equal(values.length, 2); + assert.equal(values[0]?.[1], "alpha"); + assert.equal(values[1]?.[1], "beta"); + }).pipe(Effect.provide(SqliteClient.layerMemory())), +); diff --git a/apps/server/src/persistence/SqliteClient.ts b/apps/server/src/persistence/SqliteClient.ts new file mode 100644 index 0000000000000..f1c88e57a9d72 --- /dev/null +++ b/apps/server/src/persistence/SqliteClient.ts @@ -0,0 +1,242 @@ +/** + * Port of `@effect/sql-sqlite-node` that uses the native `node:sqlite` + * bindings instead of `better-sqlite3`. + */ +import { DatabaseSync, type StatementSync } from "node:sqlite"; + +import * as Cache from "effect/Cache"; +import * as Config from "effect/Config"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import { identity } from "effect/Function"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as ServiceMap from "effect/ServiceMap"; +import * as Stream from "effect/Stream"; +import * as Reactivity from "effect/unstable/reactivity/Reactivity"; +import * as Client from "effect/unstable/sql/SqlClient"; +import type { Connection } from "effect/unstable/sql/SqlConnection"; +import { SqlError } from "effect/unstable/sql/SqlError"; +import * as Statement from "effect/unstable/sql/Statement"; + +const ATTR_DB_SYSTEM_NAME = "db.system.name"; + +export const TypeId: TypeId = "~local/sqlite-node/SqliteClient"; + +export type TypeId = "~local/sqlite-node/SqliteClient"; + +export const SqliteClient = ServiceMap.Service("@local/sqlite-node/SqliteClient"); + +export interface SqliteClientConfig { + readonly filename: string; + readonly readonly?: boolean | undefined; + readonly allowExtension?: boolean | undefined; + readonly prepareCacheSize?: number | undefined; + readonly prepareCacheTTL?: Duration.Input | undefined; + readonly disableWAL?: boolean | undefined; + readonly spanAttributes?: Record | undefined; + readonly transformResultNames?: ((str: string) => string) | undefined; + readonly transformQueryNames?: ((str: string) => string) | undefined; +} + +export interface SqliteMemoryClientConfig extends Omit< + SqliteClientConfig, + "filename" | "readonly" +> {} + +const makeWithDatabase = ( + options: SqliteClientConfig, + openDatabase: () => DatabaseSync, +): Effect.Effect => + Effect.gen(function* () { + const compiler = Statement.makeCompilerSqlite(options.transformQueryNames); + const transformRows = options.transformResultNames + ? Statement.defaultTransforms(options.transformResultNames).array + : undefined; + + const makeConnection = Effect.gen(function* () { + const scope = yield* Effect.scope; + const db = openDatabase(); + yield* Scope.addFinalizer( + scope, + Effect.sync(() => db.close()), + ); + + if (options.disableWAL !== true) { + db.exec("PRAGMA journal_mode = WAL"); + } + + const statementReaderCache = new WeakMap(); + const hasRows = (statement: StatementSync): boolean => { + const cached = statementReaderCache.get(statement); + if (cached !== undefined) { + return cached; + } + const value = statement.columns().length > 0; + statementReaderCache.set(statement, value); + return value; + }; + + const prepareCache = yield* Cache.make({ + capacity: options.prepareCacheSize ?? 200, + timeToLive: options.prepareCacheTTL ?? Duration.minutes(10), + lookup: (sql: string) => + Effect.try({ + try: () => db.prepare(sql), + catch: (cause) => new SqlError({ cause, message: "Failed to prepare statement" }), + }), + }); + + const runStatement = ( + statement: StatementSync, + params: ReadonlyArray, + raw: boolean, + ) => + Effect.withFiber, SqlError>((fiber) => { + statement.setReadBigInts(Boolean(ServiceMap.get(fiber.services, Client.SafeIntegers))); + try { + if (hasRows(statement)) { + return Effect.succeed(statement.all(...(params as any))); + } + const result = statement.run(...(params as any)); + return Effect.succeed(raw ? (result as unknown as ReadonlyArray) : []); + } catch (cause) { + return Effect.fail(new SqlError({ cause, message: "Failed to execute statement" })); + } + }); + + const run = (sql: string, params: ReadonlyArray, raw = false) => + Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw)); + + const runValues = (sql: string, params: ReadonlyArray) => + Effect.acquireUseRelease( + Cache.get(prepareCache, sql), + (statement) => + Effect.try({ + try: () => { + if (hasRows(statement)) { + statement.setReturnArrays(true); + // Safe to cast to array after we've setReturnArrays(true) + return statement.all(...(params as any)) as unknown as ReadonlyArray< + ReadonlyArray + >; + } + statement.run(...(params as any)); + return []; + }, + catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }), + }), + (statement) => + Effect.sync(() => { + if (hasRows(statement)) { + statement.setReturnArrays(false); + } + }), + ); + + return identity({ + execute(sql, params, rowTransform) { + return rowTransform ? Effect.map(run(sql, params), rowTransform) : run(sql, params); + }, + executeRaw(sql, params) { + return run(sql, params, true); + }, + executeValues(sql, params) { + return runValues(sql, params); + }, + executeUnprepared(sql, params, rowTransform) { + const effect = runStatement(db.prepare(sql), params ?? [], false); + return rowTransform ? Effect.map(effect, rowTransform) : effect; + }, + executeStream(_sql, _params) { + return Stream.die("executeStream not implemented"); + }, + }); + }); + + const semaphore = yield* Semaphore.make(1); + const connection = yield* makeConnection; + + const acquirer = semaphore.withPermits(1)(Effect.succeed(connection)); + const transactionAcquirer = Effect.uninterruptibleMask((restore) => { + const fiber = Fiber.getCurrent()!; + const scope = ServiceMap.getUnsafe(fiber.services, Scope.Scope); + return Effect.as( + Effect.tap(restore(semaphore.take(1)), () => + Scope.addFinalizer(scope, semaphore.release(1)), + ), + connection, + ); + }); + + return yield* Client.make({ + acquirer, + compiler, + transactionAcquirer, + spanAttributes: [ + ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []), + [ATTR_DB_SYSTEM_NAME, "sqlite"], + ], + transformRows, + }); + }); + +const make = ( + options: SqliteClientConfig, +): Effect.Effect => + makeWithDatabase( + options, + () => + new DatabaseSync(options.filename, { + readOnly: options.readonly ?? false, + allowExtension: options.allowExtension ?? false, + }), + ); + +const makeMemory = ( + config: SqliteMemoryClientConfig = {}, +): Effect.Effect => + makeWithDatabase( + { + ...config, + filename: ":memory:", + readonly: false, + disableWAL: config.disableWAL ?? true, + }, + () => { + const database = new DatabaseSync(":memory:", { + allowExtension: config.allowExtension ?? false, + }); + return database; + }, + ); + +export const layerConfig = ( + config: Config.Wrap, +): Layer.Layer => + Layer.effectServices( + Config.unwrap(config) + .asEffect() + .pipe( + Effect.flatMap(make), + Effect.map((client) => + ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), + ), + ), + ).pipe(Layer.provide(Reactivity.layer)); + +export const layer = (config: SqliteClientConfig): Layer.Layer => + Layer.effectServices( + Effect.map(make(config), (client) => + ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), + ), + ).pipe(Layer.provide(Reactivity.layer)); + +export const layerMemory = (config: SqliteMemoryClientConfig = {}): Layer.Layer => + Layer.effectServices( + Effect.map(makeMemory(config), (client) => + ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), + ), + ).pipe(Layer.provide(Reactivity.layer)); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 8384a212823f0..25d8cf07ac176 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -6,8 +6,9 @@ import { Effect, Layer } from "effect"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; -import { makeProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; +import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; +import { NodeServices } from "@effect/platform-node"; const fakeCodexAdapter: CodexAdapterShape = { provider: "codex", @@ -25,13 +26,14 @@ const fakeCodexAdapter: CodexAdapterShape = { }; const layer = it.layer( - makeProviderAdapterRegistryLive().pipe( - Layer.provide(Layer.succeed(CodexAdapter, fakeCodexAdapter)), + Layer.mergeAll( + Layer.provide(ProviderAdapterRegistryLive, Layer.succeed(CodexAdapter, fakeCodexAdapter)), + NodeServices.layer, ), ); layer("ProviderAdapterRegistryLive", (it) => { - it("resolves a registered provider adapter", () => + it.effect("resolves a registered provider adapter", () => Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; const adapter = yield* registry.getByProvider("codex"); @@ -39,12 +41,14 @@ layer("ProviderAdapterRegistryLive", (it) => { const providers = yield* registry.listProviders(); assert.deepEqual(providers, ["codex"]); - })); + }), + ); - it("fails with ProviderUnsupportedError for unknown providers", () => + it.effect("fails with ProviderUnsupportedError for unknown providers", () => Effect.gen(function* () { const registry = yield* ProviderAdapterRegistry; const adapter = yield* registry.getByProvider("unknown" as ProviderKind).pipe(Effect.result); assertFailure(adapter, new ProviderUnsupportedError({ provider: "unknown" })); - })); + }), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index ace64737ca35b..e6a600327cce5 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -51,7 +51,3 @@ export const ProviderAdapterRegistryLive = Layer.effect( ProviderAdapterRegistry, makeProviderAdapterRegistry(), ); - -export function makeProviderAdapterRegistryLive(options?: ProviderAdapterRegistryLiveOptions) { - return Layer.effect(ProviderAdapterRegistry, makeProviderAdapterRegistry(options)); -} diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 726c190620e11..2d7ecb4e2eb36 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -31,6 +31,7 @@ import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts" import { ProviderService } from "../Services/ProviderService.ts"; import { ProviderServiceLive } from "./ProviderService.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; +import { NodeServices } from "@effect/platform-node"; function makeFakeCodexAdapter() { const sessions = new Map(); @@ -253,15 +254,17 @@ function makeProviderServiceLayer() { listProviders: () => Effect.succeed(["codex"]), }; + const checkpointLayer = Layer.succeed(CheckpointService, checkpoint.service); + const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const layer = it.layer( - ProviderServiceLive.pipe( - Layer.provide( - Layer.mergeAll( - Layer.succeed(ProviderAdapterRegistry, registry), - ProviderSessionDirectoryLive, - Layer.succeed(CheckpointService, checkpoint.service), - ), + Layer.mergeAll( + ProviderServiceLive.pipe( + Layer.provide(providerAdapterLayer), + Layer.provide(ProviderSessionDirectoryLive), + Layer.provide(checkpointLayer), ), + NodeServices.layer, ), ); @@ -424,4 +427,40 @@ validation.layer("ProviderServiceLive validation", (it) => { ); }), ); + + it.effect("fails startSession when adapter returns no threadId", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + validation.codex.startSession.mockImplementationOnce((input: ProviderSessionStartInput) => + Effect.sync(() => { + const now = new Date().toISOString(); + return { + sessionId: "sess-missing-thread", + provider: "codex", + status: "ready", + cwd: input.cwd ?? process.cwd(), + createdAt: now, + updatedAt: now, + } satisfies ProviderSession; + }), + ); + + const failure = yield* Effect.result( + provider.startSession({ + provider: "codex", + cwd: "/tmp/project", + }), + ); + + assertFailure( + failure, + new ProviderValidationError({ + operation: "ProviderService.startSession", + issue: + "Provider 'codex' returned a session without threadId. threadId is required for checkpoint initialization.", + }), + ); + }), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 77d1d0f9afc0e..f445d157d4170 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -161,10 +161,20 @@ const makeProviderService = Effect.gen(function* () { ); } + const threadId = session.threadId?.trim(); + if (!threadId) { + return yield* Effect.fail( + toValidationError( + "ProviderService.startSession", + `Provider '${adapter.provider}' returned a session without threadId. threadId is required for checkpoint initialization.`, + ), + ); + } + yield* directory.upsert({ sessionId: session.sessionId, provider: session.provider, - ...(session.threadId !== undefined ? { threadId: session.threadId } : {}), + threadId, }); const checkpointCwd = session.cwd ?? input.cwd ?? process.cwd(); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index ade1641154361..d38d004e48bbe 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -30,7 +30,9 @@ import type { } from "@t3tools/contracts"; import type { TerminalManager } from "./terminalManager"; import { ProjectRepositoryLive } from "./persistence/Layers/Projects"; -import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; +import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; +import { NodeServices } from "@effect/platform-node"; +import { SqlClient } from "effect/unstable/sql"; interface PendingMessages { queue: unknown[]; @@ -237,6 +239,7 @@ describe("WebSocket Server", () => { function createTestServer( options: { + persistenceLayer?: Layer.Layer; cwd?: string; devUrl?: string; authToken?: string; @@ -253,6 +256,7 @@ describe("WebSocket Server", () => { port: 0, cwd: options.cwd ?? "/test/project", stateDir, + persistenceLayer: options.persistenceLayer ?? SqlitePersistenceMemory, ...(options.devUrl ? { devUrl: options.devUrl } : {}), ...(options.authToken ? { authToken: options.authToken } : {}), ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), @@ -1033,11 +1037,11 @@ describe("WebSocket Server", () => { const stateDir = makeTempDir("t3code-ws-prune-state-"); const existing = makeTempDir("t3code-ws-existing-project-"); const missing = makeTempDir("t3code-ws-missing-project-"); + const dbPath = path.join(stateDir, "test-prune.sqlite"); + const persistenceLayer = Layer.provide(makeSqlitePersistenceLive(dbPath), NodeServices.layer); const projectRuntime = ManagedRuntime.make( - ProjectRepositoryLive.pipe( - Layer.provide(makeSqlitePersistenceLive(path.join(stateDir, "orchestration.sqlite"))), - ), + ProjectRepositoryLive.pipe(Layer.provide(persistenceLayer)), ); const projectRepository = await projectRuntime.runPromise(Effect.service(ProjectRepository)); await projectRuntime.runPromise(projectRepository.add({ cwd: existing })); @@ -1045,7 +1049,11 @@ describe("WebSocket Server", () => { await projectRuntime.dispose(); fs.rmSync(missing, { recursive: true, force: true }); - server = createTestServer({ stateDir, cwd: "/test" }); + server = createTestServer({ + stateDir, + cwd: "/test", + persistenceLayer, + }); await server.start(); const addr = server.httpServer.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index d9fde1cb73580..8d33223d22128 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -18,11 +18,11 @@ import { type WsResponse, wsRequestSchema, } from "@t3tools/contracts"; +import { NodeServices } from "@effect/platform-node"; import { Effect, Layer, ManagedRuntime } from "effect"; import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; -import { ProviderManager } from "./providerManager"; import { GitManager } from "./gitManager"; import { checkoutGitBranch, @@ -43,6 +43,17 @@ import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects"; import assert from "node:assert"; import { OrchestrationEventRepositoryLive } from "./persistence/Layers/OrchestrationEvents"; +import { CodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; +import { ProviderServiceLive } from "./provider/Layers/ProviderService"; +import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; +import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; +import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; +import { CheckpointServiceLive } from "./checkpointing/Layers/CheckpointService"; +import { CheckpointRepositoryLive } from "./persistence/Layers/Checkpoints"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlError from "effect/unstable/sql/SqlError"; +import * as Migrator from "effect/unstable/sql/Migrator"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -64,6 +75,9 @@ export interface ServerOptions { host?: string | undefined; cwd: string; stateDir?: string | undefined; + persistenceLayer?: + | Layer.Layer + | undefined; staticDir?: string | undefined; devUrl?: string | undefined; logWebSocketEvents?: boolean | undefined; @@ -121,6 +135,7 @@ export function createServer(options: ServerOptions) { host, cwd, stateDir, + persistenceLayer: customPersistenceLayer, staticDir, devUrl, logWebSocketEvents: explicitLogWsEvents, @@ -128,15 +143,15 @@ export function createServer(options: ServerOptions) { terminalManager = new TerminalManager(), authToken, } = options; - const providerManager = new ProviderManager(); const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata"); let effectRuntime: ManagedRuntime.ManagedRuntime< - ProjectRepository | OrchestrationEngineService, + ProjectRepository | OrchestrationEngineService | ProviderService, unknown > | null = null; let projectRepository: ProjectRepositoryShape | null = null; let orchestrationEngine: OrchestrationEngineShape | null = null; + let providerService: ProviderServiceShape | null = null; const clients = new Set(); const logger = createLogger("ws"); const logWebSocketEvents = @@ -144,6 +159,7 @@ export function createServer(options: ServerOptions) { let keybindingsConfig = loadResolvedKeybindingsConfig(logger); let unsubscribeReadModel = noop; let unsubscribeDomainEvents = noop; + let unsubscribeProviderEvents = noop; function logOutgoingPush(push: WsPush, recipients: number) { if (!logWebSocketEvents) return; @@ -169,8 +185,16 @@ export function createServer(options: ServerOptions) { return effectRuntime; }; + const getProviderService = () => { + assert(providerService, "Provider service is not started"); + return providerService; + }; + async function attachOrchestrationSubscriptions( - runtime: ManagedRuntime.ManagedRuntime, + runtime: ManagedRuntime.ManagedRuntime< + ProjectRepository | OrchestrationEngineService | ProviderService, + unknown + >, orchestrationEngine: OrchestrationEngineShape, ) { unsubscribeReadModel = await runtime.runPromise( @@ -215,114 +239,121 @@ export function createServer(options: ServerOptions) { ); } - // Forward provider events to all connected WebSocket clients - providerManager.on("event", (event) => { - void (async () => { - const liveOrchestrationEngine = orchestrationEngine; - const runtime = effectRuntime; - if (!liveOrchestrationEngine || !runtime) { - return; - } - const snapshot = await runtime.runPromise(liveOrchestrationEngine.getSnapshot()); - const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId); - if (!thread) return; - const now = event.createdAt; - if (event.method === "turn/started" || event.method === "turn/completed") { - await runtime.runPromise( - liveOrchestrationEngine.dispatch({ - type: "thread.session", - commandId: crypto.randomUUID(), - threadId: thread.id, - session: { - sessionId: event.sessionId, - provider: event.provider, - status: event.method === "turn/started" ? "running" : "ready", - threadId: thread.id, - activeTurnId: event.method === "turn/started" ? (event.turnId ?? null) : null, - createdAt: thread.session?.createdAt ?? now, - updatedAt: now, - lastError: null, - }, - createdAt: now, - }), - ); - } - if (event.textDelta && event.textDelta.length > 0) { - const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; - await runtime.runPromise( - liveOrchestrationEngine.dispatch({ - type: "message.send", - commandId: crypto.randomUUID(), - threadId: thread.id, - messageId: assistantMessageId, - role: "assistant", - text: event.textDelta, - streaming: true, - createdAt: now, - }), - ); - } - if (event.method === "turn/completed") { - const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; - await runtime.runPromise( - liveOrchestrationEngine.dispatch({ - type: "message.send", - commandId: crypto.randomUUID(), - threadId: thread.id, - messageId: assistantMessageId, - role: "assistant", - text: "", - streaming: false, - createdAt: now, - }), - ); - } - if (event.method === "checkpoint/captured") { - const payload = asObject(event.payload); - const turnId = event.turnId ?? asString(payload?.turnId); - if (turnId) { - await runtime.runPromise( - liveOrchestrationEngine.dispatch({ - type: "thread.turnDiff.complete", - commandId: crypto.randomUUID(), - threadId: thread.id, - turnId, - completedAt: now, - ...(asString(payload?.status) !== null - ? { status: asString(payload?.status) ?? undefined } - : {}), - files: [], - assistantMessageId: `assistant:${turnId}`, - ...(asNumber(payload?.turnCount) !== null - ? { checkpointTurnCount: asNumber(payload?.turnCount) ?? undefined } - : {}), - createdAt: now, - }), + async function attachProviderSubscriptions( + runtime: ManagedRuntime.ManagedRuntime< + ProjectRepository | OrchestrationEngineService | ProviderService, + unknown + >, + providerService: ProviderServiceShape, + orchestrationEngine: OrchestrationEngineShape, + ) { + unsubscribeProviderEvents = await runtime.runPromise( + providerService.subscribeToEvents((event) => { + void (async () => { + const snapshot = await runtime.runPromise(orchestrationEngine.getSnapshot()); + const thread = snapshot.threads.find( + (entry) => entry.session?.sessionId === event.sessionId, ); - } - } - if (event.kind === "error" && event.message) { - await runtime.runPromise( - liveOrchestrationEngine.dispatch({ - type: "thread.session", - commandId: crypto.randomUUID(), - threadId: thread.id, - session: { - sessionId: event.sessionId, - provider: event.provider, - status: "error", - threadId: thread.id, - activeTurnId: event.turnId ?? null, - createdAt: thread.session?.createdAt ?? now, - updatedAt: now, - lastError: event.message, - }, - createdAt: now, - }), - ); - } - })().catch(() => undefined); - }); + if (!thread) return; + const now = event.createdAt; + if (event.method === "turn/started" || event.method === "turn/completed") { + await runtime.runPromise( + orchestrationEngine.dispatch({ + type: "thread.session", + commandId: crypto.randomUUID(), + threadId: thread.id, + session: { + sessionId: event.sessionId, + provider: event.provider, + status: event.method === "turn/started" ? "running" : "ready", + threadId: thread.id, + activeTurnId: event.method === "turn/started" ? (event.turnId ?? null) : null, + createdAt: thread.session?.createdAt ?? now, + updatedAt: now, + lastError: null, + }, + createdAt: now, + }), + ); + } + if (event.textDelta && event.textDelta.length > 0) { + const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; + await runtime.runPromise( + orchestrationEngine.dispatch({ + type: "message.send", + commandId: crypto.randomUUID(), + threadId: thread.id, + messageId: assistantMessageId, + role: "assistant", + text: event.textDelta, + streaming: true, + createdAt: now, + }), + ); + } + if (event.method === "turn/completed") { + const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; + await runtime.runPromise( + orchestrationEngine.dispatch({ + type: "message.send", + commandId: crypto.randomUUID(), + threadId: thread.id, + messageId: assistantMessageId, + role: "assistant", + text: "", + streaming: false, + createdAt: now, + }), + ); + } + if (event.method === "checkpoint/captured") { + const payload = asObject(event.payload); + const turnId = event.turnId ?? asString(payload?.turnId); + if (turnId) { + await runtime.runPromise( + orchestrationEngine.dispatch({ + type: "thread.turnDiff.complete", + commandId: crypto.randomUUID(), + threadId: thread.id, + turnId, + completedAt: now, + ...(asString(payload?.status) !== null + ? { status: asString(payload?.status) ?? undefined } + : {}), + files: [], + assistantMessageId: `assistant:${turnId}`, + ...(asNumber(payload?.turnCount) !== null + ? { checkpointTurnCount: asNumber(payload?.turnCount) ?? undefined } + : {}), + createdAt: now, + }), + ); + } + } + if (event.kind === "error" && event.message) { + await runtime.runPromise( + orchestrationEngine.dispatch({ + type: "thread.session", + commandId: crypto.randomUUID(), + threadId: thread.id, + session: { + sessionId: event.sessionId, + provider: event.provider, + status: "error", + threadId: thread.id, + activeTurnId: event.turnId ?? null, + createdAt: thread.session?.createdAt ?? now, + updatedAt: now, + lastError: event.message, + }, + createdAt: now, + }), + ); + } + })().catch(() => undefined); + }), + ); + } const onTerminalEvent = (event: TerminalEvent) => { const push: WsPush = { @@ -504,33 +535,36 @@ export function createServer(options: ServerOptions) { const orchestrationEngine = getOrchestrationEngine(); const projectRepository = getProjectRepository(); const effectRuntime = getEffectRuntime(); + const providerService = getProviderService(); switch (request.method) { case WS_METHODS.providersStartSession: - return providerManager.startSession(request.params as never); + return effectRuntime.runPromise(providerService.startSession(request.params as never)); case WS_METHODS.providersSendTurn: - return providerManager.sendTurn(request.params as never); + return effectRuntime.runPromise(providerService.sendTurn(request.params as never)); case WS_METHODS.providersInterruptTurn: - return providerManager.interruptTurn(request.params as never); + return effectRuntime.runPromise(providerService.interruptTurn(request.params as never)); case WS_METHODS.providersRespondToRequest: - return providerManager.respondToRequest(request.params as never); + return effectRuntime.runPromise(providerService.respondToRequest(request.params as never)); case WS_METHODS.providersStopSession: { - providerManager.stopSession(request.params as never); + await effectRuntime.runPromise(providerService.stopSession(request.params as never)); return undefined; } case WS_METHODS.providersListCheckpoints: - return providerManager.listCheckpoints(request.params as never); + return effectRuntime.runPromise(providerService.listCheckpoints(request.params as never)); case WS_METHODS.providersGetCheckpointDiff: - return providerManager.getCheckpointDiff(request.params as never); + return effectRuntime.runPromise(providerService.getCheckpointDiff(request.params as never)); case WS_METHODS.providersRevertToCheckpoint: { - const result = await providerManager.revertToCheckpoint(request.params as never); + const result = await effectRuntime.runPromise( + providerService.revertToCheckpoint(request.params as never), + ); const params = request.params as { sessionId?: string }; const sessionId = params.sessionId; if (typeof sessionId === "string") { @@ -735,25 +769,42 @@ export function createServer(options: ServerOptions) { async function createEffectRuntime() { const dbPath = path.join(resolvedStateDir, "orchestration.sqlite"); - const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const persistenceLayer = customPersistenceLayer ?? makeSqlitePersistenceLive(dbPath); const orchestrationLayer = Layer.provide( OrchestrationEngineLive, OrchestrationEventRepositoryLive, ); - const layer = Layer.mergeAll(orchestrationLayer, ProjectRepositoryLive).pipe( + const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(CodexAdapterLive)); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); + const checkpointDependenciesLayer = Layer.mergeAll( + checkpointStoreLayer, + CheckpointRepositoryLive, + adapterRegistryLayer, + ProviderSessionDirectoryLive, + ); + const checkpointServiceLayer = CheckpointServiceLive.pipe( + Layer.provideMerge(checkpointDependenciesLayer), + ); + const providerLayer = ProviderServiceLive.pipe(Layer.provideMerge(checkpointServiceLayer)); + + const layer = Layer.mergeAll(orchestrationLayer, ProjectRepositoryLive, providerLayer).pipe( Layer.provide(persistenceLayer), + Layer.provideMerge(NodeServices.layer), ); const runtime = ManagedRuntime.make(layer); try { - const [nextOrchestrationEngine, repository] = await Promise.all([ + const [nextOrchestrationEngine, repository, nextProviderService] = await Promise.all([ runtime.runPromise(Effect.service(OrchestrationEngineService)), runtime.runPromise(Effect.service(ProjectRepository)), + runtime.runPromise(Effect.service(ProviderService)), ]); orchestrationEngine = nextOrchestrationEngine; projectRepository = repository; + providerService = nextProviderService; await runtime.runPromise(repository.pruneMissing()); await attachOrchestrationSubscriptions(runtime, nextOrchestrationEngine); + await attachProviderSubscriptions(runtime, nextProviderService, nextOrchestrationEngine); } catch (error) { await runtime.dispose().catch(() => undefined); throw error; @@ -765,18 +816,26 @@ export function createServer(options: ServerOptions) { async function disposeEffectRuntime() { unsubscribeReadModel(); unsubscribeDomainEvents(); + unsubscribeProviderEvents(); unsubscribeReadModel = noop; unsubscribeDomainEvents = noop; + unsubscribeProviderEvents = noop; const runtime = effectRuntime; + const liveProviderService = providerService; effectRuntime = null; orchestrationEngine = null; projectRepository = null; + providerService = null; if (!runtime) { return; } + if (liveProviderService) { + await runtime.runPromise(liveProviderService.stopAll()).catch(() => undefined); + } + await runtime.dispose(); } @@ -804,8 +863,6 @@ export function createServer(options: ServerOptions) { async function stop(): Promise { await disposeEffectRuntime(); terminalManager.off("event", onTerminalEvent); - providerManager.stopAll(); - providerManager.dispose(); terminalManager.dispose(); for (const client of clients) { diff --git a/bun.lock b/bun.lock index 0292e127a49bf..2438919d85f76 100644 --- a/bun.lock +++ b/bun.lock @@ -35,7 +35,6 @@ }, "dependencies": { "@effect/platform-node": "4.0.0-beta.10", - "@effect/sql-sqlite-node": "4.0.0-beta.10", "effect": "4.0.0-beta.10", "node-pty": "^1.1.0", "open": "^10.1.0", @@ -154,8 +153,6 @@ "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.10", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.10" } }, "sha512-gBSeOG2bfchXFdgdi5oipHKynOzSd7BlzTy5QcjFOoCUTtLP9igqohPB9hTkV3a6seWE2JJqsgsQ79VuLb7fAg=="], - "@effect/sql-sqlite-node": ["@effect/sql-sqlite-node@4.0.0-beta.10", "", { "dependencies": { "better-sqlite3": "^12.6.2" }, "peerDependencies": { "effect": "^4.0.0-beta.10" } }, "sha512-oQl+gF31HaVM9R8YEpf96CIJypP1Roa5z1VPj8s7ZP+3aCtRVor1K+eMxYNAb8ph7R68Tz0LpmVkefq0mEI3CA=="], - "@effect/vitest": ["@effect/vitest@4.0.0-beta.10", "", { "peerDependencies": { "effect": "^4.0.0-beta.10", "vitest": "^3.0.0" } }, "sha512-WmE8vCNw+eDoAq9CwwymrAdIBw1f+8hDP3AUF7jyC7/Cj/kyAYOFwkbtKmDXIFaTlf7G/2fmulEXeCO5W8j0kQ=="], "@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=="], @@ -610,28 +607,18 @@ "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=="], @@ -662,8 +649,6 @@ "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=="], @@ -698,8 +683,6 @@ "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=="], @@ -774,8 +757,6 @@ "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=="], @@ -788,8 +769,6 @@ "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=="], @@ -798,8 +777,6 @@ "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=="], @@ -818,8 +795,6 @@ "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=="], @@ -868,14 +843,10 @@ "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@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], @@ -1082,8 +1053,6 @@ "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=="], @@ -1098,10 +1067,6 @@ "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=="], @@ -1150,8 +1115,6 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - "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=="], @@ -1168,8 +1131,6 @@ "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=="], @@ -1178,8 +1139,6 @@ "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=="], @@ -1234,8 +1193,6 @@ "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=="], @@ -1254,10 +1211,6 @@ "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=="], @@ -1278,14 +1231,10 @@ "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=="], @@ -1306,10 +1255,6 @@ "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=="], @@ -1346,8 +1291,6 @@ "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=="], @@ -1400,8 +1343,6 @@ "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@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], @@ -1514,8 +1455,6 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], - "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], - "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], From 4951386d0d264c8923726df854c45dfe81f41931 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 22 Feb 2026 23:40:52 -0800 Subject: [PATCH 22/98] back in a pretty good state --- .../13-provider-service-integration-tests.md | 110 +++++ .../TestProviderAdapter.integration.ts | 310 +++++++++++++ .../integration/fixtures/providerRuntime.ts | 130 ++++++ .../providerService.integration.test.ts | 361 +++++++++++++++ apps/server/package.json | 2 +- .../{bundle-client.mjs => bundle-client.ts} | 10 +- .../Layers/CheckpointService.test.ts | 7 +- .../checkpointing/Layers/CheckpointService.ts | 22 + .../Services/CheckpointService.ts | 10 + apps/server/src/orchestration/Layer.ts | 13 + apps/server/src/orchestration/Service.ts | 12 +- apps/server/src/orchestration/reducer.test.ts | 1 + apps/server/src/orchestration/reducer.ts | 52 ++- .../src/provider/Layers/CodexAdapter.test.ts | 18 +- .../src/provider/Layers/CodexAdapter.ts | 342 +++++++++++++- .../provider/Layers/EventNdjsonLogger.test.ts | 37 ++ .../src/provider/Layers/EventNdjsonLogger.ts | 66 +++ .../Layers/ProviderAdapterRegistry.test.ts | 4 +- .../provider/Layers/ProviderService.test.ts | 161 +++++-- .../src/provider/Layers/ProviderService.ts | 214 +++++---- .../src/provider/Services/ProviderAdapter.ts | 9 +- .../src/provider/Services/ProviderService.ts | 10 +- apps/server/src/provider/contracts.test.ts | 245 ---------- apps/server/src/wsServer.test.ts | 3 +- apps/server/src/wsServer.ts | 436 ++++++++++++------ apps/server/tsconfig.json | 2 +- apps/web/src/components/ChatView.tsx | 25 +- apps/web/src/routes/__root.tsx | 54 ++- apps/web/src/session-logic.test.ts | 90 ++++ apps/web/src/session-logic.ts | 63 ++- apps/web/src/store.ts | 1 + apps/web/src/threadFactory.ts | 1 + apps/web/src/types.ts | 2 + apps/web/src/worktreeCleanup.test.ts | 1 + apps/web/src/wsNativeApi.ts | 10 - packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 1 - packages/contracts/src/orchestration.ts | 42 +- packages/contracts/src/providerRuntime.ts | 217 +++++++++ 39 files changed, 2475 insertions(+), 620 deletions(-) create mode 100644 .plans/13-provider-service-integration-tests.md create mode 100644 apps/server/integration/TestProviderAdapter.integration.ts create mode 100644 apps/server/integration/fixtures/providerRuntime.ts create mode 100644 apps/server/integration/providerService.integration.test.ts rename apps/server/scripts/{bundle-client.mjs => bundle-client.ts} (59%) create mode 100644 apps/server/src/provider/Layers/EventNdjsonLogger.test.ts create mode 100644 apps/server/src/provider/Layers/EventNdjsonLogger.ts delete mode 100644 apps/server/src/provider/contracts.test.ts create mode 100644 apps/web/src/session-logic.test.ts create mode 100644 packages/contracts/src/providerRuntime.ts diff --git a/.plans/13-provider-service-integration-tests.md b/.plans/13-provider-service-integration-tests.md new file mode 100644 index 0000000000000..0cc916429d634 --- /dev/null +++ b/.plans/13-provider-service-integration-tests.md @@ -0,0 +1,110 @@ +# ProviderService Integration Test Plan + +Goal: +- Validate end-to-end `ProviderService` behavior with real layers: + - `ProviderServiceLive` + - `CheckpointServiceLive` + - `CheckpointStoreLive` + - `CheckpointRepositoryLive` (sqlite in-memory) + - `ProviderSessionDirectoryLive` +- Only fake the adapter event source (deterministic Codex-like stream). +- Avoid mocking checkpointing/persistence orchestration logic. + +## Test Harness + +Build a deterministic `TestProviderAdapterLive` in `apps/server/src/provider/Layers/TestProviderAdapter.integration.ts`: +- Service contract: `ProviderAdapterShape`. +- Internal state: + - session registry (session + cwd + threadId) + - thread snapshot store (`threadId`, `turns`) + - event subscribers +- Behavior: + - `startSession`: creates session with threadId. + - `sendTurn`: appends a deterministic turn snapshot and emits ordered events: + - `turn/started` + - `item/started` / `item/completed` (tool + approval variants depending on scenario) + - `item/agentMessage/delta` chunks + - `turn/completed` + - optional "mutator" callback per turn to change workspace files before completion. + - `readThread`, `rollbackThread`, `stopSession`, `stopAll`. + +Use real git-backed temporary workspaces in integration tests: +- initialize repo with baseline commit +- run provider turn in workspace +- assert checkpoint diffs against real git refs + +## Core Integration Specs + +1) `startSession` initializes checkpoint root exactly once +- Arrange: + - start provider session in git repo. +- Assert: + - `provider_checkpoints` contains root row (turn 0). + - checkpoint ref exists in git. + - second `startSession` for new session creates a new independent root. + +2) Turn without filesystem change +- Arrange: + - emit normal turn events, no file mutation. +- Assert: + - provider subscribers receive: + - `turn/started` + - `turn/completed` + - synthetic `checkpoint/captured` + - `listCheckpoints` returns root + turn 1. + - `getCheckpointDiff(0 -> 1)` returns empty/no-op diff. + +3) Turn with filesystem change +- Arrange: + - mutate `README.md` during turn. +- Assert: + - `listCheckpoints` returns root + turn 1. + - `getCheckpointDiff(0 -> 1)` contains file path and hunk. + - persisted checkpoint metadata includes non-empty `checkpointRef`. + +4) Multi-turn sequencing and checkpoint monotonicity +- Arrange: + - turn 1: no file change + - turn 2: file change + - turn 3: file change +- Assert: + - turn counts are monotonic and contiguous in DB (0,1,2,3). + - latest checkpoint is marked current. + - diffs for adjacent turns map to expected filesystem deltas. + +5) Revert to checkpoint +- Arrange: + - execute 3 turns with at least one file-changing turn. + - call `revertToCheckpoint(turnCount=1)`. +- Assert: + - workspace content matches turn 1 state. + - adapter `rollbackThread` called with `numTurns=2`. + - DB rows for turns >1 are removed. + - later refs are deleted from git. + +6) Capture failure surface +- Arrange: + - adapter emits `turn/completed`, but file mutation leaves invalid repo state or store capture fails. +- Assert: + - `ProviderService` emits `checkpoint/captureError`. + - no partial metadata/ref divergence is left behind. + +## WebSocket Coverage (Thin Integration) + +Add one ws server integration spec: +- Subscribe to `providers.event`. +- Run a deterministic provider turn through ws methods. +- Assert push stream includes: + - `turn/started`, tool events, `turn/completed`, `checkpoint/captured`. +- Assert orchestration projection still updates assistant message and turn diff summary. + +## Proposed PR Split + +PR A: +- Test adapter harness + shared integration fixtures (repo setup, runtime/layer setup). + +PR B: +- Core ProviderService integration specs (cases 1-4). + +PR C: +- Revert + failure-path specs (cases 5-6) + ws thin integration spec. diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts new file mode 100644 index 0000000000000..b9828f881c134 --- /dev/null +++ b/apps/server/integration/TestProviderAdapter.integration.ts @@ -0,0 +1,310 @@ +import { randomUUID } from "node:crypto"; + +import type { + ProviderRuntimeEvent, + ProviderSession, + ProviderTurnStartResult, +} from "@t3tools/contracts"; +import { Effect, Queue, Stream } from "effect"; + +import { + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../src/provider/Errors.ts"; +import type { + ProviderAdapterShape, + ProviderThreadSnapshot, + ProviderThreadTurnSnapshot, +} from "../src/provider/Services/ProviderAdapter.ts"; + +export interface TestTurnResponse { + readonly events: ReadonlyArray; + readonly mutateWorkspace?: (input: { + readonly cwd: string; + readonly turnCount: number; + }) => Effect.Effect; +} + +interface SessionState { + readonly session: ProviderSession; + snapshot: ProviderThreadSnapshot; + turnCount: number; + readonly queuedResponses: Array; + readonly rollbackCalls: Array; +} + +export interface TestProviderAdapterHarness { + readonly adapter: ProviderAdapterShape; + readonly queueTurnResponse: ( + sessionId: string, + response: TestTurnResponse, + ) => Effect.Effect; + readonly getRollbackCalls: (sessionId: string) => ReadonlyArray; +} + +const PROVIDER = "codex" as const; + +function nowIso(): string { + return new Date().toISOString(); +} + +function sessionNotFound(sessionId: string): ProviderAdapterSessionNotFoundError { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + sessionId, + }); +} + +function missingSessionEffect(sessionId: string): Effect.Effect { + return Effect.fail(sessionNotFound(sessionId)); +} + +export const makeTestProviderAdapterHarness = Effect.gen(function* () { + const runtimeEvents = yield* Queue.unbounded(); + let sessionCount = 0; + const sessions = new Map(); + + const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event); + + const startSession: ProviderAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }), + ); + } + + sessionCount += 1; + const sessionId = `test-session-${sessionCount}`; + const threadId = `test-thread-${sessionCount}`; + const createdAt = nowIso(); + + const session: ProviderSession = { + sessionId, + provider: PROVIDER, + status: "ready", + threadId, + cwd: input.cwd, + createdAt, + updatedAt: createdAt, + }; + + sessions.set(sessionId, { + session, + snapshot: { + threadId, + turns: [], + }, + turnCount: 0, + queuedResponses: [], + rollbackCalls: [], + }); + + return session; + }); + + const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const state = sessions.get(input.sessionId); + if (!state) { + return yield* missingSessionEffect(input.sessionId); + } + + state.turnCount += 1; + const turnCount = state.turnCount; + const turnId = `turn-${turnCount}`; + + const response = state.queuedResponses.shift(); + if (!response) { + return yield* Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `No queued turn response for session ${input.sessionId}.`, + }), + ); + } + + const assistantDeltas: string[] = []; + let hasCompletedEvent = false; + for (const fixtureEvent of response.events) { + const rawEvent: Record = { + ...(fixtureEvent as Record), + eventId: randomUUID(), + provider: PROVIDER, + sessionId: input.sessionId, + createdAt: nowIso(), + }; + if (Object.hasOwn(rawEvent, "threadId")) { + rawEvent.threadId = state.snapshot.threadId; + } + if (Object.hasOwn(rawEvent, "turnId")) { + rawEvent.turnId = turnId; + } + + const runtimeEvent = rawEvent as ProviderRuntimeEvent; + if (runtimeEvent.type === "message.delta") { + assistantDeltas.push(runtimeEvent.delta); + } + if (runtimeEvent.type === "turn.completed") { + hasCompletedEvent = true; + } + + yield* emit(runtimeEvent); + } + + if (response.mutateWorkspace && state.session.cwd) { + yield* response.mutateWorkspace({ cwd: state.session.cwd!, turnCount }); + } + + const userItem = { + type: "userMessage", + content: [{ type: "text", text: input.input }], + } as const; + const assistantText = assistantDeltas.join(""); + const nextItems: Array = + assistantText.length > 0 + ? [userItem, { type: "agentMessage", text: assistantText }] + : [userItem]; + + const nextTurn: ProviderThreadTurnSnapshot = { + id: turnId, + items: nextItems, + }; + + state.snapshot = { + threadId: state.snapshot.threadId, + turns: [...state.snapshot.turns, nextTurn], + }; + + if (!hasCompletedEvent) { + yield* emit({ + type: "turn.completed", + eventId: randomUUID(), + provider: PROVIDER, + sessionId: input.sessionId, + createdAt: nowIso(), + threadId: state.snapshot.threadId, + turnId, + status: "completed", + }); + } + + return { + threadId: state.snapshot.threadId, + turnId, + } satisfies ProviderTurnStartResult; + }); + + const interruptTurn: ProviderAdapterShape["interruptTurn"] = ( + sessionId, + _turnId, + ) => (sessions.has(sessionId) ? Effect.void : missingSessionEffect(sessionId)); + + const respondToRequest: ProviderAdapterShape["respondToRequest"] = ( + sessionId, + _requestId, + _decision, + ) => (sessions.has(sessionId) ? Effect.void : missingSessionEffect(sessionId)); + + const stopSession: ProviderAdapterShape["stopSession"] = (sessionId) => + Effect.sync(() => { + sessions.delete(sessionId); + }); + + const listSessions: ProviderAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (state) => state.session)); + + const hasSession: ProviderAdapterShape["hasSession"] = (sessionId) => + Effect.succeed(sessions.has(sessionId)); + + const readThread: ProviderAdapterShape["readThread"] = (sessionId) => { + const state = sessions.get(sessionId); + if (!state) { + return missingSessionEffect(sessionId); + } + return Effect.succeed(state.snapshot); + }; + + const rollbackThread: ProviderAdapterShape["rollbackThread"] = ( + sessionId, + numTurns, + ) => { + const state = sessions.get(sessionId); + if (!state) { + return missingSessionEffect(sessionId); + } + if (!Number.isInteger(numTurns) || numTurns < 0 || numTurns > state.snapshot.turns.length) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer between 0 and current turn count.", + }), + ); + } + + return Effect.sync(() => { + state.rollbackCalls.push(numTurns); + state.snapshot = { + threadId: state.snapshot.threadId, + turns: state.snapshot.turns.slice(0, state.snapshot.turns.length - numTurns), + }; + state.turnCount = state.snapshot.turns.length; + return state.snapshot; + }); + }; + + const stopAll: ProviderAdapterShape["stopAll"] = () => + Effect.sync(() => { + sessions.clear(); + }); + + const adapter: ProviderAdapterShape = { + provider: PROVIDER, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + streamEvents: Stream.fromQueue(runtimeEvents), + }; + + const queueTurnResponse = ( + sessionId: string, + response: TestTurnResponse, + ): Effect.Effect => + Effect.sync(() => sessions.get(sessionId)).pipe( + Effect.flatMap((state) => + state + ? Effect.sync(() => { + state.queuedResponses.push(response); + }) + : Effect.fail(sessionNotFound(sessionId)), + ), + ); + + const getRollbackCalls = (sessionId: string): ReadonlyArray => { + const state = sessions.get(sessionId); + if (!state) { + return []; + } + return [...state.rollbackCalls]; + }; + + return { + adapter, + queueTurnResponse, + getRollbackCalls, + } satisfies TestProviderAdapterHarness; +}); diff --git a/apps/server/integration/fixtures/providerRuntime.ts b/apps/server/integration/fixtures/providerRuntime.ts new file mode 100644 index 0000000000000..6ea6587da70c3 --- /dev/null +++ b/apps/server/integration/fixtures/providerRuntime.ts @@ -0,0 +1,130 @@ +import type { ProviderRuntimeEvent } from "@t3tools/contracts"; + +const PROVIDER = "codex" as const; +const SESSION_ID = "fixture-session"; +const THREAD_ID = "fixture-thread"; +const TURN_ID = "fixture-turn"; + +function baseEvent(eventId: string, createdAt: string): Pick< + ProviderRuntimeEvent, + "eventId" | "provider" | "sessionId" | "createdAt" +> { + return { + eventId, + provider: PROVIDER, + sessionId: SESSION_ID, + createdAt, + }; +} + +export const codexTurnTextFixture = [ + { + type: "turn.started", + ...baseEvent("evt-1", "2026-02-23T00:00:00.000Z"), + threadId: THREAD_ID, + turnId: TURN_ID, + }, + { + type: "message.delta", + ...baseEvent("evt-2", "2026-02-23T00:00:00.100Z"), + threadId: THREAD_ID, + turnId: TURN_ID, + delta: "I will make a small update.\n", + }, + { + type: "message.delta", + ...baseEvent("evt-3", "2026-02-23T00:00:00.200Z"), + threadId: THREAD_ID, + turnId: TURN_ID, + delta: "Done.\n", + }, + { + type: "turn.completed", + ...baseEvent("evt-4", "2026-02-23T00:00:00.300Z"), + threadId: THREAD_ID, + turnId: TURN_ID, + status: "completed", + }, +] satisfies ReadonlyArray; + +export const codexTurnToolFixture = [ + { + type: "turn.started", + ...baseEvent("evt-11", "2026-02-23T00:01:00.000Z"), + threadId: THREAD_ID, + turnId: TURN_ID, + }, + { + type: "tool.started", + ...baseEvent("evt-12", "2026-02-23T00:01:00.100Z"), + threadId: THREAD_ID, + turnId: TURN_ID, + toolKind: "command", + title: "Command run", + detail: "echo integration", + }, + { + type: "tool.completed", + ...baseEvent("evt-13", "2026-02-23T00:01:00.200Z"), + threadId: THREAD_ID, + turnId: TURN_ID, + toolKind: "command", + title: "Command run", + detail: "echo integration", + }, + { + type: "message.delta", + ...baseEvent("evt-14", "2026-02-23T00:01:00.300Z"), + threadId: THREAD_ID, + turnId: TURN_ID, + delta: "Applied the requested edit.\n", + }, + { + type: "turn.completed", + ...baseEvent("evt-15", "2026-02-23T00:01:00.400Z"), + threadId: THREAD_ID, + turnId: TURN_ID, + status: "completed", + }, +] satisfies ReadonlyArray; + +export const codexTurnApprovalFixture = [ + { + type: "turn.started", + ...baseEvent("evt-21", "2026-02-23T00:02:00.000Z"), + threadId: THREAD_ID, + turnId: TURN_ID, + }, + { + type: "approval.requested", + ...baseEvent("evt-22", "2026-02-23T00:02:00.100Z"), + threadId: THREAD_ID, + turnId: TURN_ID, + requestId: "req-1", + requestKind: "command", + detail: "Please approve command", + }, + { + type: "approval.resolved", + ...baseEvent("evt-23", "2026-02-23T00:02:00.200Z"), + threadId: THREAD_ID, + turnId: TURN_ID, + requestId: "req-1", + requestKind: "command", + decision: "accept", + }, + { + type: "message.delta", + ...baseEvent("evt-24", "2026-02-23T00:02:00.300Z"), + threadId: THREAD_ID, + turnId: TURN_ID, + delta: "Approval received and command executed.\n", + }, + { + type: "turn.completed", + ...baseEvent("evt-25", "2026-02-23T00:02:00.400Z"), + threadId: THREAD_ID, + turnId: TURN_ID, + status: "completed", + }, +] satisfies ReadonlyArray; diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts new file mode 100644 index 0000000000000..af4326bc6acd9 --- /dev/null +++ b/apps/server/integration/providerService.integration.test.ts @@ -0,0 +1,361 @@ +import type { ProviderRuntimeEvent } from "@t3tools/contracts"; +import { NodeServices } from "@effect/platform-node"; +import { it, assert } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Path, Queue, Stream } from "effect"; + +import { runGit as runGitProcess } from "../src/git/Process.ts"; +import { CheckpointServiceLive } from "../src/checkpointing/Layers/CheckpointService.ts"; +import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; +import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; +import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; +import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import { ProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; +import { + ProviderService, + type ProviderServiceShape, +} from "../src/provider/Services/ProviderService.ts"; +import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; +import { CheckpointRepositoryLive } from "../src/persistence/Layers/Checkpoints.ts"; + +import { + makeTestProviderAdapterHarness, + type TestProviderAdapterHarness, + type TestTurnResponse, +} from "./TestProviderAdapter.integration.ts"; +import { + codexTurnApprovalFixture, + codexTurnToolFixture, + codexTurnTextFixture, +} from "./fixtures/providerRuntime.ts"; + +const runGit = (cwd: string, args: ReadonlyArray, allowNonZeroExit = false) => + runGitProcess({ + operation: "providerService.integration.git", + cwd, + args, + allowNonZeroExit, + }).pipe(Effect.provide(NodeServices.layer)); + +const gitRefExists = (cwd: string, ref: string) => + runGit(cwd, ["show-ref", "--verify", "--quiet", ref], true).pipe( + Effect.map((result) => result.code === 0), + ); + +const checkpointRefForTurn = (threadId: string, turnCount: number) => + `refs/t3/checkpoints/${Buffer.from(threadId, "utf8").toString("base64url")}/turn/${turnCount}`; + +const makeGitRepository = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const cwd = yield* fs.makeTempDirectory(); + + yield* runGit(cwd, ["init", "--initial-branch=main"]); + yield* runGit(cwd, ["config", "user.email", "test@example.com"]); + yield* runGit(cwd, ["config", "user.name", "Test User"]); + yield* fs.writeFileString(pathService.join(cwd, "README.md"), "v1\n"); + yield* runGit(cwd, ["add", "."]); + yield* runGit(cwd, ["commit", "-m", "Initial"]); + + return cwd; +}).pipe(Effect.provide(NodeServices.layer)); + +interface IntegrationFixture { + readonly cwd: string; + readonly harness: TestProviderAdapterHarness; + readonly layer: Layer.Layer; +} + +const makeIntegrationFixture = Effect.gen(function* () { + const cwd = yield* makeGitRepository; + const harness = yield* makeTestProviderAdapterHarness; + + const registry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "codex" + ? Effect.succeed(harness.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex"]), + }; + + const shared = Layer.mergeAll( + ProviderSessionDirectoryLive, + Layer.succeed(ProviderAdapterRegistry, registry), + Layer.provide(CheckpointStoreLive, NodeServices.layer), + Layer.provide(CheckpointRepositoryLive, SqlitePersistenceMemory), + ); + + const checkpointLayer = CheckpointServiceLive.pipe(Layer.provide(shared)); + + const layer = ProviderServiceLive.pipe( + Layer.provide(Layer.mergeAll(shared, checkpointLayer)), + Layer.merge(NodeServices.layer), + ); + + return { + cwd, + harness, + layer, + } satisfies IntegrationFixture; +}); + +const collectEventsDuring = ( + stream: Stream.Stream, + count: number, + action: Effect.Effect, +) => + Effect.gen(function* () { + const queue = yield* Queue.unbounded(); + yield* Stream.runForEach(stream, (event) => Queue.offer(queue, event).pipe(Effect.asVoid)).pipe( + Effect.forkScoped, + ); + + yield* action; + + return yield* Effect.forEach( + Array.from({ length: count }, () => undefined), + () => Queue.take(queue), + { discard: false }, + ); + }); + +const runTurn = (input: { + readonly provider: ProviderServiceShape; + readonly harness: TestProviderAdapterHarness; + readonly sessionId: string; + readonly userText: string; + readonly response: TestTurnResponse; +}) => + Effect.gen(function* () { + yield* input.harness.queueTurnResponse(input.sessionId, input.response); + + return yield* collectEventsDuring( + input.provider.streamEvents, + input.response.events.length + 1, + input.provider.sendTurn({ + sessionId: input.sessionId, + input: input.userText, + attachments: [], + }), + ); + }); + +it.effect("replays typed runtime fixture and emits checkpoint.captured", () => + Effect.gen(function* () { + const fixture = yield* makeIntegrationFixture; + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const session = yield* provider.startSession({ + provider: "codex", + cwd: fixture.cwd, + }); + const threadId = session.threadId ?? ""; + assert.equal(threadId.length > 0, true); + + const observedEvents = yield* runTurn({ + provider, + harness: fixture.harness, + sessionId: session.sessionId, + userText: "hello", + response: { events: codexTurnTextFixture }, + }); + + assert.deepEqual( + observedEvents.map((event) => event.type), + [...codexTurnTextFixture.map((event) => event.type), "checkpoint.captured"], + ); + + const checkpoints = yield* provider.listCheckpoints({ sessionId: session.sessionId }); + assert.deepEqual( + checkpoints.checkpoints.map((checkpoint) => checkpoint.turnCount), + [0, 1], + ); + + const diff = yield* provider.getCheckpointDiff({ + sessionId: session.sessionId, + fromTurnCount: 0, + toTurnCount: 1, + }); + assert.equal(diff.diff.trim(), ""); + + const turnOneRefExists = yield* gitRefExists(fixture.cwd, checkpointRefForTurn(threadId, 1)); + assert.equal(turnOneRefExists, true); + }).pipe(Effect.provide(fixture.layer)); + }), +); + +it.effect("captures file-changing fixture turn and produces a non-empty diff", () => + Effect.gen(function* () { + const fixture = yield* makeIntegrationFixture; + const { join } = yield* Path.Path; + const { writeFileString } = yield* FileSystem.FileSystem; + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const session = yield* provider.startSession({ + provider: "codex", + cwd: fixture.cwd, + }); + const threadId = session.threadId ?? ""; + assert.equal(threadId.length > 0, true); + + const observedEvents = yield* runTurn({ + provider, + harness: fixture.harness, + sessionId: session.sessionId, + userText: "make a small change", + response: { + events: codexTurnToolFixture, + mutateWorkspace: ({ cwd }) => + writeFileString(join(cwd, "README.md"), "v2\n").pipe(Effect.asVoid, Effect.ignore), + }, + }); + + assert.deepEqual( + observedEvents.map((event) => event.type), + [...codexTurnToolFixture.map((event) => event.type), "checkpoint.captured"], + ); + + const diff = yield* provider.getCheckpointDiff({ + sessionId: session.sessionId, + fromTurnCount: 0, + toTurnCount: 1, + }); + assert.equal(diff.diff.includes("README.md"), true); + + const turnOneRefExists = yield* gitRefExists(fixture.cwd, checkpointRefForTurn(threadId, 1)); + assert.equal(turnOneRefExists, true); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("runs multi-turn tool/approval flow with contiguous checkpoints", () => + Effect.gen(function* () { + const fixture = yield* makeIntegrationFixture; + const { join } = yield* Path.Path; + const { writeFileString } = yield* FileSystem.FileSystem; + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const session = yield* provider.startSession({ + provider: "codex", + cwd: fixture.cwd, + }); + const threadId = session.threadId ?? ""; + assert.equal(threadId.length > 0, true); + + const firstTurnEvents = yield* runTurn({ + provider, + harness: fixture.harness, + sessionId: session.sessionId, + userText: "turn 1", + response: { + events: codexTurnToolFixture, + mutateWorkspace: ({ cwd }) => + writeFileString(join(cwd, "README.md"), "v2\n").pipe(Effect.asVoid, Effect.ignore), + }, + }); + assert.deepEqual( + firstTurnEvents.map((event) => event.type), + [...codexTurnToolFixture.map((event) => event.type), "checkpoint.captured"], + ); + + const secondTurnEvents = yield* runTurn({ + provider, + harness: fixture.harness, + sessionId: session.sessionId, + userText: "turn 2 approval", + response: { + events: codexTurnApprovalFixture, + mutateWorkspace: ({ cwd }) => + writeFileString(join(cwd, "README.md"), "v3\n").pipe(Effect.asVoid, Effect.ignore), + }, + }); + assert.deepEqual( + secondTurnEvents.map((event) => event.type), + [...codexTurnApprovalFixture.map((event) => event.type), "checkpoint.captured"], + ); + + const turnStateBeforeRevert = yield* provider.listCheckpoints({ + sessionId: session.sessionId, + }); + assert.deepEqual( + turnStateBeforeRevert.checkpoints.map((checkpoint) => checkpoint.turnCount), + [0, 1, 2], + ); + const current = + turnStateBeforeRevert.checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? + turnStateBeforeRevert.checkpoints[turnStateBeforeRevert.checkpoints.length - 1]; + assert.equal(current?.turnCount, 2); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("reverts to turn 1 and prunes later checkpoint refs", () => + Effect.gen(function* () { + const fixture = yield* makeIntegrationFixture; + const { join } = yield* Path.Path; + const { writeFileString, readFileString } = yield* FileSystem.FileSystem; + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + const session = yield* provider.startSession({ + provider: "codex", + cwd: fixture.cwd, + }); + const threadId = session.threadId ?? ""; + assert.equal(threadId.length > 0, true); + + yield* runTurn({ + provider, + harness: fixture.harness, + sessionId: session.sessionId, + userText: "turn 1", + response: { + events: codexTurnToolFixture, + mutateWorkspace: ({ cwd }) => + writeFileString(join(cwd, "README.md"), "v2\n").pipe(Effect.asVoid, Effect.ignore), + }, + }); + + yield* runTurn({ + provider, + harness: fixture.harness, + sessionId: session.sessionId, + userText: "turn 2 approval", + response: { + events: codexTurnApprovalFixture, + mutateWorkspace: ({ cwd }) => + writeFileString(join(cwd, "README.md"), "v3\n").pipe(Effect.asVoid, Effect.ignore), + }, + }); + + const turnTwoRef = checkpointRefForTurn(threadId, 2); + const turnTwoRefBefore = yield* gitRefExists(fixture.cwd, turnTwoRef); + assert.equal(turnTwoRefBefore, true); + + const revert = yield* provider.revertToCheckpoint({ + sessionId: session.sessionId, + turnCount: 1, + }); + assert.equal(revert.rolledBackTurns, 1); + + const rollbackCalls = fixture.harness.getRollbackCalls(session.sessionId); + assert.deepEqual(rollbackCalls, [1]); + + const readme = yield* readFileString(join(fixture.cwd, "README.md")); + assert.equal(readme, "v2\n"); + + const afterRevertCheckpoints = yield* provider.listCheckpoints({ + sessionId: session.sessionId, + }); + assert.deepEqual( + afterRevertCheckpoints.checkpoints.map((checkpoint) => checkpoint.turnCount), + [0, 1], + ); + + const turnTwoRefAfter = yield* gitRefExists(fixture.cwd, turnTwoRef); + assert.equal(turnTwoRefAfter, false); + }).pipe(Effect.provide(fixture.layer)); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/package.json b/apps/server/package.json index 4f311dc5817e8..709ef2ba8ad5a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -11,7 +11,7 @@ "main": "./dist/index.mjs", "scripts": { "dev": "VITE_DEV_SERVER_URL=${VITE_DEV_SERVER_URL:-http://localhost:5173} bun run src/index.ts", - "build": "tsdown && node scripts/bundle-client.mjs", + "build": "tsdown && node scripts/bundle-client.ts", "start": "node dist/index.mjs", "typecheck": "tsc --noEmit", "test": "vitest run" diff --git a/apps/server/scripts/bundle-client.mjs b/apps/server/scripts/bundle-client.ts similarity index 59% rename from apps/server/scripts/bundle-client.mjs rename to apps/server/scripts/bundle-client.ts index 33b44cd5e75e9..85d5a9aebbdab 100644 --- a/apps/server/scripts/bundle-client.mjs +++ b/apps/server/scripts/bundle-client.ts @@ -2,13 +2,11 @@ * Copies the built web app into dist/client/ so the published npm package * includes the web UI. This runs as a post-build step. */ -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; +import * as fs from "node:fs"; +import * as path from "node:path"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const webDist = path.resolve(__dirname, "../../web/dist"); -const target = path.resolve(__dirname, "../dist/client"); +const webDist = path.resolve(import.meta.dirname, "../../web/dist"); +const target = path.resolve(import.meta.dirname, "../dist/client"); if (!fs.existsSync(webDist)) { console.log( diff --git a/apps/server/src/checkpointing/Layers/CheckpointService.test.ts b/apps/server/src/checkpointing/Layers/CheckpointService.test.ts index 4347a6006c3fc..7aa4f4ec09993 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointService.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointService.test.ts @@ -1,6 +1,5 @@ import type { ProviderApprovalDecision, - ProviderEvent, ProviderKind, ProviderSendTurnInput, ProviderSession, @@ -9,7 +8,7 @@ import type { } from "@t3tools/contracts"; import { NodeServices } from "@effect/platform-node"; import { it, assert } from "@effect/vitest"; -import { Effect, FileSystem, Layer, Path } from "effect"; +import { Effect, FileSystem, Layer, Path, Stream } from "effect"; import { runGit as runGitProcess } from "../../git/Process.ts"; import { CheckpointServiceLive } from "./CheckpointService.ts"; @@ -194,9 +193,7 @@ class InMemoryProviderAdapter implements ProviderAdapterShape void, - ): Effect.Effect<() => void, ProviderAdapterError> => Effect.succeed(() => undefined); + readonly streamEvents = Stream.empty; } function makeFixture(turns: ReadonlyArray) { diff --git a/apps/server/src/checkpointing/Layers/CheckpointService.ts b/apps/server/src/checkpointing/Layers/CheckpointService.ts index 2c54cb3c547f2..c0841ddc2fd4f 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointService.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointService.ts @@ -377,6 +377,16 @@ const makeCheckpointService = Effect.gen(function* () { return next; }); + const clearSessionSemaphore = (sessionId: string) => + Ref.update(sessionSemaphoresRef, (current) => { + if (!current.has(sessionId)) { + return current; + } + const next = new Map(current); + next.delete(sessionId); + return next; + }); + const getTrackedCheckpointCwd = (sessionId: string) => Ref.get(sessionCheckpointCwdsRef).pipe( Effect.map((current) => Option.fromNullishOr(current.get(sessionId))), @@ -1024,12 +1034,24 @@ const makeCheckpointService = Effect.gen(function* () { ), ); + const releaseSession: CheckpointServiceShape["releaseSession"] = (input) => + Effect.gen(function* () { + const providerSessionId = yield* normalizeNonEmpty( + input.providerSessionId, + "CheckpointService.releaseSession", + "providerSessionId", + ); + yield* clearTrackedCheckpointCwd(providerSessionId); + yield* clearSessionSemaphore(providerSessionId); + }); + return { initializeForSession, captureCurrentTurn, listCheckpoints, getCheckpointDiff, revertToCheckpoint, + releaseSession, } satisfies CheckpointServiceShape; }); diff --git a/apps/server/src/checkpointing/Services/CheckpointService.ts b/apps/server/src/checkpointing/Services/CheckpointService.ts index 6a5260eb6c9e7..9d782b52d85a6 100644 --- a/apps/server/src/checkpointing/Services/CheckpointService.ts +++ b/apps/server/src/checkpointing/Services/CheckpointService.ts @@ -69,6 +69,16 @@ export interface CheckpointServiceShape { readonly revertToCheckpoint: ( input: ProviderRevertToCheckpointInput, ) => Effect.Effect; + + /** + * Release in-memory checkpoint session state after session shutdown. + * + * This only clears local coordination state (locks/cwd tracking). It does not + * delete durable checkpoint metadata or git refs. + */ + readonly releaseSession: (input: { + readonly providerSessionId: string; + }) => Effect.Effect; } /** diff --git a/apps/server/src/orchestration/Layer.ts b/apps/server/src/orchestration/Layer.ts index 1edacd925c0f3..6847c693d3302 100644 --- a/apps/server/src/orchestration/Layer.ts +++ b/apps/server/src/orchestration/Layer.ts @@ -143,6 +143,19 @@ function mapCommandToEvent(command: OrchestrationCommand): Omit Effect.Effect; + readonly getSnapshot: () => Effect.Effect; /** * Replay persisted orchestration events from an exclusive sequence cursor. @@ -37,7 +37,7 @@ export interface OrchestrationEngineShape { */ readonly replayEvents: ( fromSequenceExclusive: number, - ) => Effect.Effect; + ) => Effect.Effect; /** * Validate and dispatch an unknown command payload. @@ -49,7 +49,7 @@ export interface OrchestrationEngineShape { */ readonly dispatchUnknown: ( command: unknown, - ) => Effect.Effect<{ sequence: number }, OrchestrationEngineError>; + ) => Effect.Effect<{ sequence: number }, OrchestrationEngineError, never>; /** * Dispatch a validated orchestration command. @@ -59,7 +59,7 @@ export interface OrchestrationEngineShape { */ readonly dispatch: ( command: OrchestrationCommand, - ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError>; + ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>; /** * Subscribe to read model updates. @@ -69,7 +69,7 @@ export interface OrchestrationEngineShape { */ readonly subscribeToReadModel: ( callback: (snapshot: OrchestrationReadModel) => void, - ) => Effect.Effect<() => void>; + ) => Effect.Effect<() => void, never, never>; /** * Subscribe to domain event fan-out. @@ -79,7 +79,7 @@ export interface OrchestrationEngineShape { */ readonly subscribeToDomainEvents: ( callback: (event: OrchestrationEvent) => void, - ) => Effect.Effect<() => void>; + ) => Effect.Effect<() => void, never, never>; } /** diff --git a/apps/server/src/orchestration/reducer.test.ts b/apps/server/src/orchestration/reducer.test.ts index ac887e8dd925d..bf3cc633d72ea 100644 --- a/apps/server/src/orchestration/reducer.test.ts +++ b/apps/server/src/orchestration/reducer.test.ts @@ -46,6 +46,7 @@ describe("orchestration reducer", () => { latestTurnDurationMs: null, messages: [], session: null, + activities: [], turnDiffSummaries: [], error: null, }, diff --git a/apps/server/src/orchestration/reducer.ts b/apps/server/src/orchestration/reducer.ts index 5ba4334e0fc7c..40320779c1f6a 100644 --- a/apps/server/src/orchestration/reducer.ts +++ b/apps/server/src/orchestration/reducer.ts @@ -7,6 +7,7 @@ import type { OrchestrationThread, } from "@t3tools/contracts"; import { + OrchestrationThreadActivitySchema, OrchestrationGitReadModelSchema, OrchestrationMessageSchema, OrchestrationSessionSchema, @@ -89,6 +90,11 @@ const ThreadRevertedPayloadSchema = Schema.Struct({ messageCount: Schema.Number, }); +const ThreadActivityAppendedPayloadSchema = Schema.Struct({ + threadId: Schema.String, + activity: OrchestrationThreadActivitySchema, +}); + export function createEmptyReadModel(nowIso: string): OrchestrationReadModel { return { sequence: 0, @@ -135,6 +141,7 @@ export function reduceEvent( messages: [], session: null, turnDiffSummaries: [], + activities: [], error: null, }, event.type, @@ -214,11 +221,13 @@ export function reduceEvent( ? message.text : entry.text, streaming: message.streaming, - createdAt: message.createdAt, + createdAt: entry.createdAt, } : entry, ) - : [...targetThread.messages, message]; + : !message.streaming && message.text.length === 0 + ? targetThread.messages + : [...targetThread.messages, message]; return { ...nextBase, @@ -334,6 +343,16 @@ export function reduceEvent( .toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); const latestSummary = turnDiffSummaries.length > 0 ? turnDiffSummaries[turnDiffSummaries.length - 1] : null; + const activities = thread.activities.filter((activity) => { + if (!activity.turnId) { + return true; + } + const checkpointTurnCount = inferredTurnCountByTurnId.get(activity.turnId); + if (checkpointTurnCount === undefined) { + return true; + } + return checkpointTurnCount <= targetTurnCount; + }); return { ...nextBase, @@ -347,6 +366,35 @@ export function reduceEvent( latestTurnStartedAt: null, latestTurnCompletedAt: latestSummary?.completedAt ?? null, latestTurnDurationMs: null, + activities, + updatedAt: event.occurredAt, + }), + }; + }), + ); + case "thread.activity-appended": + return decodeForEvent( + ThreadActivityAppendedPayloadSchema, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + const activities = [ + ...thread.activities.filter((activity) => activity.id !== payload.activity.id), + payload.activity, + ] + .toSorted((left, right) => left.createdAt.localeCompare(right.createdAt)) + .slice(-500); + + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + activities, updatedAt: event.occurredAt, }), }; diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3e12a2a3e7c53..632fd0a922126 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -9,7 +9,7 @@ import type { import { afterAll, assert, it, vi } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; -import { Effect } from "effect"; +import { Effect, Fiber, Stream } from "effect"; import { CodexAppServerManager } from "../../codexAppServerManager.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; @@ -170,11 +170,15 @@ const lifecycleManager = new FakeCodexManager(); const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager })); lifecycleLayer("CodexAdapterLive lifecycle", (it) => { - it.effect("subscribes and unsubscribes from manager events", () => + it.effect("emits canonical events on stream and supports consumer interruption", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; - const onEvent = vi.fn((_event: ProviderEvent) => undefined); - const unsubscribe = yield* adapter.subscribeToEvents(onEvent); + const onEvent = vi.fn(() => undefined); + const consumer = yield* Stream.runForEach(adapter.streamEvents, () => + Effect.sync(() => { + onEvent(); + }), + ).pipe(Effect.forkChild); const event: ProviderEvent = { id: "evt-1", @@ -183,14 +187,16 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { sessionId: "sess-1", createdAt: new Date().toISOString(), method: "turn/started", + turnId: "turn-1", }; lifecycleManager.emit("event", event); + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); assert.equal(onEvent.mock.calls.length, 1); - assert.equal(onEvent.mock.calls[0]?.[0]?.method, "turn/started"); - unsubscribe(); + yield* Fiber.interrupt(consumer); lifecycleManager.emit("event", event); + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); assert.equal(onEvent.mock.calls.length, 1); assert.equal(lifecycleManager.stopAllImpl.mock.calls.length, 0); }), diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 2a3307a9626a9..d8055a54460a0 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -6,8 +6,13 @@ * * @module CodexAdapterLive */ -import type { ProviderEvent } from "@t3tools/contracts"; -import { Effect, Layer } from "effect"; +import type { + ProviderEvent, + ProviderRuntimeEvent, + ProviderRuntimeTurnStatus, + ProviderRuntimeToolKind, +} from "@t3tools/contracts"; +import { Effect, Layer, Queue, Stream } from "effect"; import { ProviderAdapterProcessError, @@ -19,12 +24,14 @@ import { } from "../Errors.ts"; import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { CodexAppServerManager } from "../../codexAppServerManager.ts"; +import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "codex" as const; export interface CodexAdapterLiveOptions { readonly manager?: CodexAppServerManager; readonly makeManager?: () => CodexAppServerManager; + readonly nativeEventLogPath?: string; } function toMessage(cause: unknown, fallback: string): string { @@ -69,8 +76,309 @@ function toRequestError(sessionId: string, method: string, cause: unknown): Prov }); } +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function toTurnStatus(value: unknown): ProviderRuntimeTurnStatus | undefined { + switch (value) { + case "completed": + case "failed": + case "cancelled": + case "interrupted": + return value; + default: + return undefined; + } +} + +function normalizeItemType(raw: unknown): string { + const type = asString(raw); + if (!type) return "item"; + return type + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/[._/-]/g, " ") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + +function shouldDropItemType(type: string): boolean { + if (type.includes("preamble") || type.includes("reasoning") || type.includes("thought")) { + return true; + } + return type === "work" || type.startsWith("work "); +} + +function toolMeta(type: string): + | { + readonly toolKind: ProviderRuntimeToolKind; + readonly title: string; + } + | undefined { + if (type.includes("command")) { + return { toolKind: "command", title: "Command run" }; + } + if (type.includes("file change")) { + return { toolKind: "file-change", title: "File change" }; + } + if (type.includes("tool")) { + return { toolKind: "other", title: "Tool call" }; + } + return undefined; +} + +function itemDetail( + item: Record, + payload: Record, +): string | undefined { + const nestedResult = asObject(item.result); + const candidates = [ + asString(item.command), + asString(item.title), + asString(item.summary), + asString(item.text), + asString(item.path), + asString(item.prompt), + asString(nestedResult?.command), + asString(payload.command), + asString(payload.message), + asString(payload.prompt), + ]; + for (const candidate of candidates) { + if (!candidate) continue; + const trimmed = candidate.trim(); + if (trimmed.length === 0) continue; + return trimmed; + } + return undefined; +} + +function mapToolEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined { + if (event.method !== "item/started" && event.method !== "item/completed") { + return undefined; + } + const payload = asObject(event.payload); + const item = asObject(payload?.item); + if (!item) { + return undefined; + } + const normalizedType = normalizeItemType(item.type ?? item.kind); + if (shouldDropItemType(normalizedType)) { + return undefined; + } + const meta = toolMeta(normalizedType); + if (!meta) { + return undefined; + } + + const eventBase = { + eventId: event.id, + provider: event.provider, + sessionId: event.sessionId, + createdAt: event.createdAt, + ...(event.threadId ? { threadId: event.threadId } : {}), + ...(event.turnId ? { turnId: event.turnId } : {}), + ...(event.itemId ? { itemId: event.itemId } : {}), + toolKind: meta.toolKind, + title: meta.title, + ...(payload ? { detail: itemDetail(item, payload) } : {}), + } as const; + + if (event.method === "item/started") { + return { + type: "tool.started", + ...eventBase, + }; + } + + return { + type: "tool.completed", + ...eventBase, + }; +} + +function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray { + const payload = asObject(event.payload); + const turn = asObject(payload?.turn); + + if (event.kind === "error") { + if (!event.message) { + return []; + } + return [ + { + type: "runtime.error", + eventId: event.id, + provider: event.provider, + sessionId: event.sessionId, + createdAt: event.createdAt, + ...(event.threadId ? { threadId: event.threadId } : {}), + ...(event.turnId ? { turnId: event.turnId } : {}), + ...(event.itemId ? { itemId: event.itemId } : {}), + message: event.message, + }, + ]; + } + + if (event.kind === "request" && event.requestId && event.requestKind) { + const detail = + asString(payload?.command) ?? asString(payload?.reason) ?? asString(payload?.prompt); + return [ + { + type: "approval.requested", + eventId: event.id, + provider: event.provider, + sessionId: event.sessionId, + createdAt: event.createdAt, + ...(event.threadId ? { threadId: event.threadId } : {}), + ...(event.turnId ? { turnId: event.turnId } : {}), + ...(event.itemId ? { itemId: event.itemId } : {}), + requestId: event.requestId, + requestKind: event.requestKind, + ...(detail ? { detail } : {}), + }, + ]; + } + + if (event.method === "item/requestApproval/decision" && event.requestId) { + const decision = asString(payload?.decision); + return [ + { + type: "approval.resolved", + eventId: event.id, + provider: event.provider, + sessionId: event.sessionId, + createdAt: event.createdAt, + ...(event.threadId ? { threadId: event.threadId } : {}), + ...(event.turnId ? { turnId: event.turnId } : {}), + ...(event.itemId ? { itemId: event.itemId } : {}), + requestId: event.requestId, + ...(event.requestKind ? { requestKind: event.requestKind } : {}), + ...(decision ? { decision } : {}), + }, + ]; + } + + const tool = mapToolEvent(event); + if (tool) { + return [tool]; + } + + if (event.method === "session/started") { + return [ + { + type: "session.started", + eventId: event.id, + provider: event.provider, + sessionId: event.sessionId, + createdAt: event.createdAt, + ...(event.threadId ? { threadId: event.threadId } : {}), + ...(event.message ? { message: event.message } : {}), + }, + ]; + } + + if (event.method === "session/exited") { + return [ + { + type: "session.exited", + eventId: event.id, + provider: event.provider, + sessionId: event.sessionId, + createdAt: event.createdAt, + ...(event.threadId ? { threadId: event.threadId } : {}), + ...(event.message ? { message: event.message } : {}), + }, + ]; + } + + if (event.method === "thread/started") { + const threadId = event.threadId ?? asString(asObject(payload?.thread)?.id); + if (!threadId) { + return []; + } + return [ + { + type: "thread.started", + eventId: event.id, + provider: event.provider, + sessionId: event.sessionId, + createdAt: event.createdAt, + threadId, + }, + ]; + } + + if (event.method === "turn/started") { + const turnId = event.turnId ?? asString(turn?.id); + if (!turnId) { + return []; + } + return [ + { + type: "turn.started", + eventId: event.id, + provider: event.provider, + sessionId: event.sessionId, + createdAt: event.createdAt, + ...(event.threadId ? { threadId: event.threadId } : {}), + turnId, + }, + ]; + } + + if (event.method === "turn/completed") { + return [ + { + type: "turn.completed", + eventId: event.id, + provider: event.provider, + sessionId: event.sessionId, + createdAt: event.createdAt, + ...(event.threadId ? { threadId: event.threadId } : {}), + ...(event.turnId ? { turnId: event.turnId } : {}), + ...(toTurnStatus(turn?.status) ? { status: toTurnStatus(turn?.status) } : {}), + ...(asString(asObject(turn?.error)?.message) + ? { errorMessage: asString(asObject(turn?.error)?.message) } + : {}), + }, + ]; + } + + if (event.method === "item/agentMessage/delta" && event.textDelta && event.textDelta.length > 0) { + return [ + { + type: "message.delta", + eventId: event.id, + provider: event.provider, + sessionId: event.sessionId, + createdAt: event.createdAt, + ...(event.threadId ? { threadId: event.threadId } : {}), + ...(event.turnId ? { turnId: event.turnId } : {}), + ...(event.itemId ? { itemId: event.itemId } : {}), + delta: event.textDelta, + }, + ]; + } + + return []; +} + const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => Effect.gen(function* () { + const nativeEventLogger = + options?.nativeEventLogPath !== undefined + ? makeEventNdjsonLogger(options.nativeEventLogPath) + : undefined; + const manager = yield* Effect.acquireRelease( Effect.sync(() => { if (options?.manager) { @@ -175,20 +483,28 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => manager.stopAll(); }); - const subscribeToEvents: CodexAdapterShape["subscribeToEvents"] = (callback) => + const runtimeEventQueue = yield* Queue.unbounded(); + + yield* Effect.acquireRelease( Effect.sync(() => { const listener = (event: ProviderEvent) => { - try { - callback(event); - } catch { - // Adapter subscribers must not destabilize provider event processing. - } + nativeEventLogger?.write({ + observedAt: new Date().toISOString(), + event, + }); + Queue.offerAllUnsafe(runtimeEventQueue, mapToRuntimeEvents(event)); }; manager.on("event", listener); - return () => { - manager.off("event", listener); - }; - }); + return listener; + }), + (listener) => + Effect.gen(function* () { + yield* Effect.sync(() => { + manager.off("event", listener); + }); + yield* Queue.shutdown(runtimeEventQueue); + }), + ); return { provider: PROVIDER, @@ -202,7 +518,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => listSessions, hasSession, stopAll, - subscribeToEvents, + streamEvents: Stream.fromQueue(runtimeEventQueue), } satisfies CodexAdapterShape; }); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts new file mode 100644 index 0000000000000..73e110295f333 --- /dev/null +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.test.ts @@ -0,0 +1,37 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +describe("EventNdjsonLogger", () => { + it("writes NDJSON records to disk", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); + const filePath = path.join(tempDir, "provider-native.ndjson"); + + try { + const logger = makeEventNdjsonLogger(filePath); + expect(logger).toBeDefined(); + if (!logger) { + return; + } + + logger.write({ type: "native", payload: { id: "evt-1" } }); + logger.write({ type: "canonical", payload: { id: "evt-2" } }); + logger.close(); + + const raw = fs.readFileSync(filePath, "utf8").trim(); + const lines = raw.split("\n"); + expect(lines.length).toBe(2); + + const first = JSON.parse(lines[0] ?? "{}"); + const second = JSON.parse(lines[1] ?? "{}"); + expect(first).toEqual({ type: "native", payload: { id: "evt-1" } }); + expect(second).toEqual({ type: "canonical", payload: { id: "evt-2" } }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/provider/Layers/EventNdjsonLogger.ts b/apps/server/src/provider/Layers/EventNdjsonLogger.ts new file mode 100644 index 0000000000000..b842f076cb5d7 --- /dev/null +++ b/apps/server/src/provider/Layers/EventNdjsonLogger.ts @@ -0,0 +1,66 @@ +/** + * Provider event NDJSON logger helper. + * + * Best-effort writer for observability logs. Failures are downgraded to warnings + * so provider runtime behavior is unaffected. + */ +import fs from "node:fs"; +import path from "node:path"; + +import { createLogger } from "../../logger.ts"; + +const logger = createLogger("provider-observability"); + +export interface EventNdjsonLogger { + readonly filePath: string; + write: (record: unknown) => void; + close: () => void; +} + +function stringifyRecord(record: unknown): string | undefined { + try { + return JSON.stringify(record); + } catch (error) { + logger.warn("failed to serialize provider event log record", { error }); + return undefined; + } +} + +export function makeEventNdjsonLogger(filePath: string): EventNdjsonLogger | undefined { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.appendFileSync(filePath, "", { encoding: "utf8", flag: "a" }); + } catch (error) { + logger.warn("failed to create provider event log directory", { filePath, error }); + return undefined; + } + + try { + let broken = false; + + return { + filePath, + write(record: unknown) { + if (broken) { + return; + } + const serialized = stringifyRecord(record); + if (!serialized) { + return; + } + try { + fs.appendFileSync(filePath, `${serialized}\n`, { encoding: "utf8", flag: "a" }); + } catch (error) { + broken = true; + logger.warn("provider event log write failed", { filePath, error }); + } + }, + close() { + // no-op for sync append logger + }, + }; + } catch (error) { + logger.warn("failed to create provider event log file", { filePath, error }); + return undefined; + } +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 25d8cf07ac176..48c1a76538b68 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -2,7 +2,7 @@ import type { ProviderKind } from "@t3tools/contracts"; import { it, assert, vi } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, Stream } from "effect"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; @@ -22,7 +22,7 @@ const fakeCodexAdapter: CodexAdapterShape = { readThread: vi.fn(), rollbackThread: vi.fn(), stopAll: vi.fn(), - subscribeToEvents: vi.fn(), + streamEvents: Stream.empty, }; const layer = it.layer( diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 2d7ecb4e2eb36..46182941b8c01 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1,11 +1,11 @@ import type { ProviderApprovalDecision, - ProviderEvent, ProviderGetCheckpointDiffInput, ProviderGetCheckpointDiffResult, ProviderListCheckpointsInput, ProviderListCheckpointsResult, ProviderRevertToCheckpointInput, + ProviderRuntimeEvent, ProviderRevertToCheckpointResult, ProviderSendTurnInput, ProviderSession, @@ -16,7 +16,7 @@ import { providerSessionStartInputSchema } from "@t3tools/contracts"; import { it, assert, vi } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; -import { Effect, Layer } from "effect"; +import { Effect, Fiber, Layer, PubSub, Ref, Stream } from "effect"; import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts"; import { @@ -35,7 +35,7 @@ import { NodeServices } from "@effect/platform-node"; function makeFakeCodexAdapter() { const sessions = new Map(); - const eventListeners = new Set<(event: ProviderEvent) => void>(); + const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSession = 1; const startSession = vi.fn((input: ProviderSessionStartInput) => @@ -127,16 +127,6 @@ function makeFakeCodexAdapter() { }), ); - const subscribeToEvents = vi.fn( - (callback: (event: ProviderEvent) => void): Effect.Effect<() => void, ProviderAdapterError> => - Effect.sync(() => { - eventListeners.add(callback); - return () => { - eventListeners.delete(callback); - }; - }), - ); - const adapter: ProviderAdapterShape = { provider: "codex", startSession, @@ -149,13 +139,11 @@ function makeFakeCodexAdapter() { readThread, rollbackThread, stopAll, - subscribeToEvents, + streamEvents: Stream.fromPubSub(runtimeEventPubSub), }; - const emit = (event: ProviderEvent): void => { - for (const listener of eventListeners) { - listener(event); - } + const emit = (event: ProviderRuntimeEvent): void => { + Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); }; return { @@ -171,7 +159,6 @@ function makeFakeCodexAdapter() { readThread, rollbackThread, stopAll, - subscribeToEvents, }; } @@ -224,6 +211,7 @@ function makeCheckpointServiceDouble() { ], }), ); + const releaseSession = vi.fn((_input: { providerSessionId: string }) => Effect.void); const service: typeof CheckpointService.Service = { initializeForSession, @@ -231,6 +219,7 @@ function makeCheckpointServiceDouble() { listCheckpoints, getCheckpointDiff, revertToCheckpoint, + releaseSession, }; return { @@ -240,6 +229,7 @@ function makeCheckpointServiceDouble() { listCheckpoints, getCheckpointDiff, revertToCheckpoint, + releaseSession, }; } @@ -334,6 +324,9 @@ routing.layer("ProviderServiceLive routing", (it) => { ]); yield* provider.stopSession({ sessionId: session.sessionId }); + assert.deepEqual(routing.checkpoint.releaseSession.mock.calls, [ + [{ providerSessionId: session.sessionId }], + ]); const sendAfterStop = yield* Effect.result( provider.sendTurn({ sessionId: session.sessionId, @@ -347,6 +340,29 @@ routing.layer("ProviderServiceLive routing", (it) => { ); }), ); + + it.effect("releases checkpoint session state when stopping all sessions", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const first = yield* provider.startSession({ + provider: "codex", + }); + const second = yield* provider.startSession({ + provider: "codex", + }); + + yield* provider.stopAll(); + + assert.deepEqual(routing.checkpoint.releaseSession.mock.calls.slice(-2), [ + [{ providerSessionId: first.sessionId }], + [{ providerSessionId: second.sessionId }], + ]); + + const remaining = yield* provider.listSessions(); + assert.equal(remaining.length, 0); + }), + ); }); const fanout = makeProviderServiceLayer(); @@ -358,40 +374,111 @@ fanout.layer("ProviderServiceLive fanout", (it) => { provider: "codex", }); - const onEvent = vi.fn((_event: ProviderEvent) => undefined); - const unsubscribe = yield* provider.subscribeToEvents(onEvent); + const eventsRef = yield* Ref.make>([]); + const consumer = yield* Stream.runForEach(provider.streamEvents, (event) => + Ref.update(eventsRef, (current) => [...current, event]), + ).pipe( + Effect.forkChild, + ); + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); - const completedEvent: ProviderEvent = { - id: "evt-1", - kind: "notification", + const completedEvent: ProviderRuntimeEvent = { + type: "turn.completed", + eventId: "evt-1", provider: "codex", sessionId: session.sessionId, createdAt: new Date().toISOString(), - method: "turn/completed", threadId: "thread-1", turnId: "turn-1", - payload: { - turn: { - status: "completed", - }, - }, + status: "completed", }; fanout.codex.emit(completedEvent); yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); - assert.equal(onEvent.mock.calls.length, 1); - assert.equal(onEvent.mock.calls[0]?.[0]?.method, "turn/completed"); + const events = yield* Ref.get(eventsRef); + yield* Fiber.interrupt(consumer); + + assert.equal(events.length >= 2, true); + assert.equal(events[0]?.type, "turn.completed"); + assert.equal(events[1]?.type, "checkpoint.captured"); assert.deepEqual(fanout.checkpoint.captureCurrentTurn.mock.calls, [ [{ providerSessionId: session.sessionId, turnId: "turn-1", status: "completed" }], ]); + }), + ); - unsubscribe(); - fanout.codex.emit({ - ...completedEvent, - id: "evt-2", + it.effect("keeps subscriber delivery ordered and isolates failing subscribers", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + const session = yield* provider.startSession({ + provider: "codex", }); - assert.equal(onEvent.mock.calls.length, 1); + fanout.checkpoint.captureCurrentTurn.mockClear(); + + const receivedByHealthy: string[] = []; + const healthyFiber = yield* Stream.take(provider.streamEvents, 3).pipe( + Stream.runForEach((event) => + Effect.sync(() => { + receivedByHealthy.push(event.eventId); + }), + ), + Effect.forkChild, + ); + const failingFiber = yield* Stream.take(provider.streamEvents, 1).pipe( + Stream.runForEach(() => Effect.fail("listener crash")), + Effect.forkChild, + ); + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); + + const events: ReadonlyArray = [ + { + type: "tool.completed", + eventId: "evt-ordered-1", + provider: "codex", + sessionId: session.sessionId, + createdAt: new Date().toISOString(), + threadId: "thread-1", + turnId: "turn-1", + toolKind: "command", + title: "Command run", + detail: "echo one", + }, + { + type: "message.delta", + eventId: "evt-ordered-2", + provider: "codex", + sessionId: session.sessionId, + createdAt: new Date().toISOString(), + threadId: "thread-1", + turnId: "turn-1", + delta: "hello", + }, + { + type: "turn.completed", + eventId: "evt-ordered-3", + provider: "codex", + sessionId: session.sessionId, + createdAt: new Date().toISOString(), + threadId: "thread-1", + turnId: "turn-1", + status: "completed", + }, + ]; + + for (const event of events) { + fanout.codex.emit(event); + } + const failingResult = yield* Effect.result(Fiber.join(failingFiber)); + assert.equal(failingResult._tag, "Failure"); + yield* Fiber.join(healthyFiber); + + assert.deepEqual(receivedByHealthy.slice(0, 3), [ + "evt-ordered-1", + "evt-ordered-2", + "evt-ordered-3", + ]); + assert.equal(receivedByHealthy.length, 3); }), ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index f445d157d4170..e1f7d52d694e8 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -12,7 +12,6 @@ */ import { randomUUID } from "node:crypto"; -import type { ProviderEvent } from "@t3tools/contracts"; import { providerGetCheckpointDiffInputSchema, providerInterruptTurnInputSchema, @@ -22,46 +21,54 @@ import { providerSendTurnInputSchema, providerSessionStartInputSchema, providerStopSessionInputSchema, + type ProviderRuntimeEvent, + type ProviderRuntimeTurnCompletedEvent, } from "@t3tools/contracts"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, PubSub, Queue, Stream } from "effect"; import { CheckpointService } from "../../checkpointing/Services/CheckpointService.ts"; import { ProviderValidationError } from "../Errors.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderService, type ProviderServiceShape } from "../Services/ProviderService.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; +import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; -function asObject(value: unknown): Record | undefined { - if (!value || typeof value !== "object") { - return undefined; - } - return value as Record; -} - -function asString(value: unknown): string | undefined { - return typeof value === "string" ? value : undefined; -} - -function readTurnCompletionStatus(event: ProviderEvent): string | undefined { - const payload = asObject(event.payload); - const turn = asObject(payload?.turn); - return asString(turn?.status); +export interface ProviderServiceLiveOptions { + readonly canonicalEventLogPath?: string; } function toCheckpointCaptureErrorEvent( - event: ProviderEvent, + event: ProviderRuntimeTurnCompletedEvent, error: { readonly message: string }, -): ProviderEvent { +): ProviderRuntimeEvent { return { - id: randomUUID(), - kind: "error", + type: "runtime.error", + eventId: randomUUID(), provider: event.provider, sessionId: event.sessionId, createdAt: new Date().toISOString(), - method: "checkpoint/captureError", - message: error.message, ...(event.threadId !== undefined ? { threadId: event.threadId } : {}), ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), + message: error.message, + }; +} + +function toCheckpointCapturedEvent(input: { + readonly event: ProviderRuntimeTurnCompletedEvent; + readonly threadId: string; + readonly turnCount: number; +}): ProviderRuntimeEvent { + const { event, threadId, turnCount } = input; + return { + type: "checkpoint.captured", + eventId: randomUUID(), + provider: event.provider, + sessionId: event.sessionId, + createdAt: new Date().toISOString(), + threadId, + ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), + turnCount, + ...(event.status !== undefined ? { status: event.status } : {}), }; } @@ -77,58 +84,80 @@ function toValidationError( }); } -const makeProviderService = Effect.gen(function* () { - const registry = yield* ProviderAdapterRegistry; - const directory = yield* ProviderSessionDirectory; - const checkpointService = yield* CheckpointService; - - const eventSubscribers = new Set<(event: ProviderEvent) => void>(); - const notifySubscribers = (event: ProviderEvent): void => { - for (const callback of eventSubscribers) { - try { - callback(event); - } catch { - // Subscriber callbacks must not destabilize provider event fanout. - } - } - }; +const makeProviderService = (options?: ProviderServiceLiveOptions) => + Effect.gen(function* () { + const canonicalEventLogger = + options?.canonicalEventLogPath !== undefined + ? makeEventNdjsonLogger(options.canonicalEventLogPath) + : undefined; + + const registry = yield* ProviderAdapterRegistry; + const directory = yield* ProviderSessionDirectory; + const checkpointService = yield* CheckpointService; + + const runtimeEventQueue = yield* Queue.unbounded(); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => + Effect.sync(() => { + canonicalEventLogger?.write({ + observedAt: new Date().toISOString(), + event, + }); + }).pipe( + Effect.flatMap(() => PubSub.publish(runtimeEventPubSub, event)), + Effect.asVoid, + ); - const providers = yield* registry.listProviders(); - const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider)); + const providers = yield* registry.listProviders(); + const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider)); + + const onTurnCompleted = (event: ProviderRuntimeTurnCompletedEvent): Effect.Effect => + checkpointService + .captureCurrentTurn({ + providerSessionId: event.sessionId, + ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), + ...(event.status !== undefined ? { status: event.status } : {}), + }) + .pipe( + Effect.flatMap(() => checkpointService.listCheckpoints({ sessionId: event.sessionId })), + Effect.flatMap((result) => { + const currentCheckpoint = + result.checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? + result.checkpoints[result.checkpoints.length - 1]; + if (!currentCheckpoint) { + return Effect.void; + } + + return publishRuntimeEvent( + toCheckpointCapturedEvent({ + event, + threadId: result.threadId, + turnCount: currentCheckpoint.turnCount, + }), + ); + }), + Effect.catch((error) => publishRuntimeEvent(toCheckpointCaptureErrorEvent(event, error))), + ); - yield* Effect.acquireRelease( - Effect.forEach(adapters, (adapter) => - adapter.subscribeToEvents((event) => { - notifySubscribers(event); - if (event.kind !== "notification" || event.method !== "turn/completed") { - return; + const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => + publishRuntimeEvent(event).pipe( + Effect.flatMap(() => { + if (event.type !== "turn.completed") { + return Effect.void; } - - const status = readTurnCompletionStatus(event); - const captureInput = { - providerSessionId: event.sessionId, - ...(event.turnId !== undefined ? { turnId: event.turnId } : {}), - ...(status !== undefined ? { status } : {}), - }; - - Effect.runFork( - checkpointService.captureCurrentTurn(captureInput).pipe( - Effect.catch((error) => - Effect.sync(() => { - notifySubscribers(toCheckpointCaptureErrorEvent(event, error)); - }), - ), - ), - ); + return onTurnCompleted(event); }), - ), - (unsubscribeAll) => - Effect.forEach(unsubscribeAll, (unsubscribe) => - Effect.sync(() => { - unsubscribe(); - }), - ).pipe(Effect.asVoid), - ); + ); + + const worker = Effect.forever(Queue.take(runtimeEventQueue).pipe(Effect.flatMap(processRuntimeEvent))); + yield* Effect.forkScoped(worker); + + yield* Effect.forEach(adapters, (adapter) => + Stream.runForEach(adapter.streamEvents, (event) => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid), + ).pipe(Effect.forkScoped), + ).pipe(Effect.asVoid); const adapterForSession = (sessionId: string) => directory @@ -252,6 +281,7 @@ const makeProviderService = Effect.gen(function* () { const input = parsed.data; const adapter = yield* adapterForSession(input.sessionId); yield* adapter.stopSession(input.sessionId); + yield* checkpointService.releaseSession({ providerSessionId: input.sessionId }); yield* directory.remove(input.sessionId); }); @@ -316,34 +346,34 @@ const makeProviderService = Effect.gen(function* () { const stopAll: ProviderServiceShape["stopAll"] = () => Effect.gen(function* () { - yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); const sessionIds = yield* directory.listSessionIds(); - yield* Effect.forEach(sessionIds, (sessionId) => directory.remove(sessionId)).pipe( - Effect.asVoid, - ); - }); - - const subscribeToEvents: ProviderServiceShape["subscribeToEvents"] = (callback) => - Effect.sync(() => { - eventSubscribers.add(callback); - return () => { - eventSubscribers.delete(callback); - }; + yield* Effect.forEach(adapters, (adapter) => adapter.stopAll()).pipe(Effect.asVoid); + yield* Effect.forEach( + sessionIds, + (sessionId) => + checkpointService.releaseSession({ providerSessionId: sessionId }).pipe( + Effect.flatMap(() => directory.remove(sessionId)), + ), + ).pipe(Effect.asVoid); }); - return { - startSession, - sendTurn, - interruptTurn, + return { + startSession, + sendTurn, + interruptTurn, respondToRequest, stopSession, listSessions, listCheckpoints, getCheckpointDiff, revertToCheckpoint, - stopAll, - subscribeToEvents, - } satisfies ProviderServiceShape; -}); + stopAll, + streamEvents: Stream.fromPubSub(runtimeEventPubSub), + } satisfies ProviderServiceShape; + }); -export const ProviderServiceLive = Layer.effect(ProviderService, makeProviderService); +export const ProviderServiceLive = Layer.effect(ProviderService, makeProviderService()); + +export function makeProviderServiceLive(options?: ProviderServiceLiveOptions) { + return Layer.effect(ProviderService, makeProviderService(options)); +} diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 21b6d8c3dde24..bff88ca30f350 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -9,14 +9,15 @@ */ import type { ProviderApprovalDecision, - ProviderEvent, ProviderKind, + ProviderRuntimeEvent, ProviderSendTurnInput, ProviderSession, ProviderSessionStartInput, ProviderTurnStartResult, } from "@t3tools/contracts"; import type { Effect } from "effect"; +import type { Stream } from "effect"; export interface ProviderThreadTurnSnapshot { readonly id: string; @@ -96,9 +97,7 @@ export interface ProviderAdapterShape { readonly stopAll: () => Effect.Effect; /** - * Subscribe to provider events emitted by this adapter. + * Canonical runtime event stream emitted by this adapter. */ - readonly subscribeToEvents: ( - callback: (event: ProviderEvent) => void, - ) => Effect.Effect<() => void, TError>; + readonly streamEvents: Stream.Stream; } diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 6d546bee6e516..3d5798b93bf2d 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -12,7 +12,6 @@ * @module ProviderService */ import type { - ProviderEvent, ProviderGetCheckpointDiffInput, ProviderGetCheckpointDiffResult, ProviderInterruptTurnInput, @@ -26,9 +25,10 @@ import type { ProviderSessionStartInput, ProviderStopSessionInput, ProviderTurnStartResult, + ProviderRuntimeEvent, } from "@t3tools/contracts"; import { ServiceMap } from "effect"; -import type { Effect } from "effect"; +import type { Effect, Stream } from "effect"; import type { ProviderServiceError } from "../Errors.ts"; @@ -100,13 +100,11 @@ export interface ProviderServiceShape { readonly stopAll: () => Effect.Effect; /** - * Subscribe to provider event stream. + * Canonical provider runtime event stream. * * Fan-out is owned by ProviderService (not by a standalone event-bus service). */ - readonly subscribeToEvents: ( - callback: (event: ProviderEvent) => void, - ) => Effect.Effect<() => void, ProviderServiceError>; + readonly streamEvents: Stream.Stream; } /** diff --git a/apps/server/src/provider/contracts.test.ts b/apps/server/src/provider/contracts.test.ts deleted file mode 100644 index 7f216b1c08c9c..0000000000000 --- a/apps/server/src/provider/contracts.test.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { Effect, Layer, ManagedRuntime, Option } from "effect"; - -import { - ProviderAdapterValidationError, - ProviderFilesystemError, - ProviderSessionNotFoundError, - ProviderUnsupportedError, - type ProviderAdapterError, - type ProviderServiceError, -} from "./Errors.ts"; -import { - CheckpointGitCommandError, - CheckpointServiceValidationError, - type CheckpointServiceError, - type CheckpointStoreError, -} from "../checkpointing/Errors.ts"; -import { - CheckpointRepositoryValidationError, - type CheckpointRepositoryError, -} from "../persistence/Errors.ts"; -import { CheckpointService } from "../checkpointing/Services/CheckpointService.ts"; -import { CheckpointStore } from "../checkpointing/Services/CheckpointStore.ts"; -import { CheckpointRepository } from "../persistence/Services/Checkpoints.ts"; -import { CodexAdapter } from "./Services/CodexAdapter.ts"; -import { ProviderAdapterRegistry } from "./Services/ProviderAdapterRegistry.ts"; -import { ProviderService } from "./Services/ProviderService.ts"; -import { ProviderSessionDirectory } from "./Services/ProviderSessionDirectory.ts"; - -describe("provider/checkpoint service contracts", () => { - it("resolves all service tags from ServiceMap", async () => { - const providerLive: typeof ProviderService.Service = { - startSession: () => - Effect.fail(new ProviderUnsupportedError({ provider: "x" })), - sendTurn: () => - Effect.fail(new ProviderUnsupportedError({ provider: "x" })), - interruptTurn: () => - Effect.fail(new ProviderUnsupportedError({ provider: "x" })), - respondToRequest: () => - Effect.fail(new ProviderUnsupportedError({ provider: "x" })), - stopSession: () => Effect.void, - listSessions: () => Effect.succeed([]), - listCheckpoints: () => - Effect.fail(new ProviderUnsupportedError({ provider: "x" })), - getCheckpointDiff: () => - Effect.fail(new ProviderUnsupportedError({ provider: "x" })), - revertToCheckpoint: () => - Effect.fail(new ProviderUnsupportedError({ provider: "x" })), - stopAll: () => Effect.void, - subscribeToEvents: () => Effect.succeed(() => undefined), - }; - - const codexLive: typeof CodexAdapter.Service = { - provider: "codex", - startSession: () => - Effect.fail( - new ProviderAdapterValidationError({ - provider: "codex", - operation: "startSession", - issue: "not implemented", - }), - ), - sendTurn: () => - Effect.fail( - new ProviderAdapterValidationError({ - provider: "codex", - operation: "sendTurn", - issue: "not implemented", - }), - ), - interruptTurn: () => - Effect.fail( - new ProviderAdapterValidationError({ - provider: "codex", - operation: "interruptTurn", - issue: "not implemented", - }), - ), - readThread: () => - Effect.fail( - new ProviderAdapterValidationError({ - provider: "codex", - operation: "readThread", - issue: "not implemented", - }), - ), - rollbackThread: () => - Effect.fail( - new ProviderAdapterValidationError({ - provider: "codex", - operation: "rollbackThread", - issue: "not implemented", - }), - ), - respondToRequest: () => - Effect.fail( - new ProviderAdapterValidationError({ - provider: "codex", - operation: "respondToRequest", - issue: "not implemented", - }), - ), - stopSession: () => Effect.void, - listSessions: () => Effect.succeed([]), - hasSession: () => Effect.succeed(false), - stopAll: () => Effect.void, - subscribeToEvents: () => Effect.succeed(() => undefined), - }; - - const checkpointStoreLive: typeof CheckpointStore.Service = { - isGitRepository: () => Effect.succeed(false), - captureCheckpoint: () => Effect.void, - hasCheckpointRef: () => Effect.succeed(false), - ensureRootCheckpoint: () => Effect.succeed(false), - restoreCheckpoint: () => Effect.succeed(false), - diffCheckpoints: () => - Effect.fail( - new CheckpointGitCommandError({ - operation: "diffCheckpoints", - command: "git diff", - cwd: "/tmp", - detail: "not implemented", - }), - ), - deleteCheckpointRefs: () => Effect.void, - }; - - const checkpointRepositoryLive: typeof CheckpointRepository.Service = { - upsertCheckpoint: () => Effect.void, - listCheckpoints: () => Effect.succeed([]), - getCheckpoint: () => Effect.succeed(Option.none()), - deleteAfterTurn: () => Effect.void, - deleteAllForSession: () => Effect.void, - }; - - const checkpointServiceLive: typeof CheckpointService.Service = { - initializeForSession: () => Effect.void, - captureCurrentTurn: () => Effect.void, - listCheckpoints: () => - Effect.fail( - new CheckpointServiceValidationError({ - operation: "listCheckpoints", - issue: "not implemented", - }), - ), - getCheckpointDiff: () => - Effect.fail( - new CheckpointServiceValidationError({ - operation: "getCheckpointDiff", - issue: "not implemented", - }), - ), - revertToCheckpoint: () => - Effect.fail( - new CheckpointServiceValidationError({ - operation: "revertToCheckpoint", - issue: "not implemented", - }), - ), - }; - - const providerAdapterRegistryLive: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(codexLive) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; - - const providerSessionDirectoryLive: typeof ProviderSessionDirectory.Service = { - upsert: () => Effect.void, - getProvider: (sessionId) => - Effect.fail(new ProviderSessionNotFoundError({ sessionId })), - getThreadId: (sessionId) => - Effect.fail(new ProviderSessionNotFoundError({ sessionId })), - remove: () => Effect.void, - listSessionIds: () => Effect.succeed([]), - }; - - const runtime = ManagedRuntime.make( - Layer.mergeAll( - Layer.succeed(ProviderService, providerLive), - Layer.succeed(CodexAdapter, codexLive), - Layer.succeed(CheckpointStore, checkpointStoreLive), - Layer.succeed(CheckpointRepository, checkpointRepositoryLive), - Layer.succeed(CheckpointService, checkpointServiceLive), - Layer.succeed(ProviderAdapterRegistry, providerAdapterRegistryLive), - Layer.succeed(ProviderSessionDirectory, providerSessionDirectoryLive), - ), - ); - - const [ - provider, - codex, - checkpointStore, - checkpointRepository, - checkpointService, - providerAdapterRegistry, - providerSessionDirectory, - ] = await Promise.all([ - runtime.runPromise(Effect.service(ProviderService)), - runtime.runPromise(Effect.service(CodexAdapter)), - runtime.runPromise(Effect.service(CheckpointStore)), - runtime.runPromise(Effect.service(CheckpointRepository)), - runtime.runPromise(Effect.service(CheckpointService)), - runtime.runPromise(Effect.service(ProviderAdapterRegistry)), - runtime.runPromise(Effect.service(ProviderSessionDirectory)), - ]); - - expect(provider).toBe(providerLive); - expect(codex).toBe(codexLive); - expect(checkpointStore).toBe(checkpointStoreLive); - expect(checkpointRepository).toBe(checkpointRepositoryLive); - expect(checkpointService).toBe(checkpointServiceLive); - expect(providerAdapterRegistry).toBe(providerAdapterRegistryLive); - expect(providerSessionDirectory).toBe(providerSessionDirectoryLive); - - await runtime.dispose(); - }); -}); - -describe("provider/checkpoint errors", () => { - it("preserves cause and message on tagged errors", () => { - const fsCause = new Error("filesystem failed"); - const fsError = new ProviderFilesystemError({ - sessionId: "sess-1", - detail: "capture failed", - cause: fsCause, - }); - - expect(fsError._tag).toBe("ProviderFilesystemError"); - expect(fsError.message).toContain("sess-1"); - expect(fsError.cause).toBe(fsCause); - }); - - it("accepts checkpoint repository error channel type", () => { - const error: CheckpointRepositoryError = new CheckpointRepositoryValidationError({ - operation: "upsertCheckpoint", - issue: "turnCount must be >= 0", - }); - - expect(error._tag).toBe("CheckpointRepositoryValidationError"); - }); -}); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index d38d004e48bbe..b00dbbe9e95d8 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -628,8 +628,7 @@ describe("WebSocket Server", () => { const ws = await connectWs(port); connections.push(ws); - // Consume welcome + initial orchestration snapshot pushes - await waitForMessage(ws); + // Consume welcome push await waitForMessage(ws); const response = await sendRequest(ws, "nonexistent.method"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 8d33223d22128..0b1dd45d9f902 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -11,7 +11,7 @@ import { ORCHESTRATION_WS_METHODS, WS_CHANNELS, WS_METHODS, - type OrchestrationReadModelPush, + type ProviderRuntimeEvent, type TerminalEvent, type WsPush, type WsRequest, @@ -19,7 +19,7 @@ import { wsRequestSchema, } from "@t3tools/contracts"; import { NodeServices } from "@effect/platform-node"; -import { Effect, Layer, ManagedRuntime } from "effect"; +import { Effect, Exit, Layer, ManagedRuntime, Queue, Scope, Stream } from "effect"; import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; @@ -43,14 +43,18 @@ import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; import { ProjectRepository, type ProjectRepositoryShape } from "./persistence/Services/Projects"; import assert from "node:assert"; import { OrchestrationEventRepositoryLive } from "./persistence/Layers/OrchestrationEvents"; -import { CodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; -import { ProviderServiceLive } from "./provider/Layers/ProviderService"; +import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; import { CheckpointServiceLive } from "./checkpointing/Layers/CheckpointService"; import { CheckpointRepositoryLive } from "./persistence/Layers/Checkpoints"; +import { + makeEventNdjsonLogger, + type EventNdjsonLogger, +} from "./provider/Layers/EventNdjsonLogger"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlError from "effect/unstable/sql/SqlError"; import * as Migrator from "effect/unstable/sql/Migrator"; @@ -114,17 +118,90 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined { return undefined; } -function asObject(value: unknown): Record | null { - if (!value || typeof value !== "object") return null; - return value as Record; +const providerTurnKey = (sessionId: string, turnId: string) => `${sessionId}:${turnId}`; + +type ActivityTone = "thinking" | "tool" | "info" | "error"; + +interface ThreadActivityInput { + readonly id: string; + readonly createdAt: string; + readonly label: string; + readonly detail?: string; + readonly tone: ActivityTone; + readonly turnId?: string; + readonly requestId?: string; + readonly requestKind?: "command" | "file-change"; } -function asString(value: unknown): string | null { - return typeof value === "string" ? value : null; +function truncateDetail(value: string, limit = 180): string { + return value.length > limit ? `${value.slice(0, limit - 1)}...` : value; } -function asNumber(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; +function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray { + switch (event.type) { + case "approval.requested": + return [ + { + id: event.eventId, + createdAt: event.createdAt, + label: + event.requestKind === "command" + ? "Command approval requested" + : "File-change approval requested", + ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), + tone: "tool", + ...(event.turnId ? { turnId: event.turnId } : {}), + requestId: event.requestId, + requestKind: event.requestKind, + }, + ]; + case "approval.resolved": + return [ + { + id: event.eventId, + createdAt: event.createdAt, + label: "Approval resolved", + tone: "info", + ...(event.turnId ? { turnId: event.turnId } : {}), + requestId: event.requestId, + ...(event.requestKind ? { requestKind: event.requestKind } : {}), + }, + ]; + case "runtime.error": + return [ + { + id: event.eventId, + createdAt: event.createdAt, + label: "Runtime error", + detail: truncateDetail(event.message), + tone: "error", + ...(event.turnId ? { turnId: event.turnId } : {}), + }, + ]; + case "checkpoint.captured": + return [ + { + id: event.eventId, + createdAt: event.createdAt, + label: "Checkpoint captured", + tone: "info", + ...(event.turnId ? { turnId: event.turnId } : {}), + }, + ]; + case "tool.completed": + return [ + { + id: event.eventId, + createdAt: event.createdAt, + label: `${event.title} complete`, + ...(event.detail ? { detail: truncateDetail(event.detail) } : {}), + tone: "tool", + ...(event.turnId ? { turnId: event.turnId } : {}), + }, + ]; + default: + return []; + } } const noop = () => {}; @@ -157,7 +234,8 @@ export function createServer(options: ServerOptions) { const logWebSocketEvents = explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); let keybindingsConfig = loadResolvedKeybindingsConfig(logger); - let unsubscribeReadModel = noop; + let orchestrationDomainEventLogger: EventNdjsonLogger | undefined; + let orchestrationCommandLogger: EventNdjsonLogger | undefined; let unsubscribeDomainEvents = noop; let unsubscribeProviderEvents = noop; @@ -197,30 +275,12 @@ export function createServer(options: ServerOptions) { >, orchestrationEngine: OrchestrationEngineShape, ) { - unsubscribeReadModel = await runtime.runPromise( - orchestrationEngine.subscribeToReadModel((snapshot) => { - const push: WsPush = { - type: "push", - channel: ORCHESTRATION_WS_CHANNELS.readModel, - data: { - sequence: snapshot.sequence, - snapshot, - } satisfies OrchestrationReadModelPush, - }; - const message = JSON.stringify(push); - let recipients = 0; - for (const client of clients) { - if (client.readyState === client.OPEN) { - client.send(message); - recipients += 1; - } - } - logOutgoingPush(push, recipients); - }), - ); - unsubscribeDomainEvents = await runtime.runPromise( orchestrationEngine.subscribeToDomainEvents((event) => { + orchestrationDomainEventLogger?.write({ + observedAt: new Date().toISOString(), + event, + }); const push: WsPush = { type: "push", channel: ORCHESTRATION_WS_CHANNELS.domainEvent, @@ -247,53 +307,96 @@ export function createServer(options: ServerOptions) { providerService: ProviderServiceShape, orchestrationEngine: OrchestrationEngineShape, ) { - unsubscribeProviderEvents = await runtime.runPromise( - providerService.subscribeToEvents((event) => { - void (async () => { - const snapshot = await runtime.runPromise(orchestrationEngine.getSnapshot()); - const thread = snapshot.threads.find( - (entry) => entry.session?.sessionId === event.sessionId, - ); - if (!thread) return; - const now = event.createdAt; - if (event.method === "turn/started" || event.method === "turn/completed") { - await runtime.runPromise( - orchestrationEngine.dispatch({ - type: "thread.session", - commandId: crypto.randomUUID(), - threadId: thread.id, - session: { - sessionId: event.sessionId, - provider: event.provider, - status: event.method === "turn/started" ? "running" : "ready", - threadId: thread.id, - activeTurnId: event.method === "turn/started" ? (event.turnId ?? null) : null, - createdAt: thread.session?.createdAt ?? now, - updatedAt: now, - lastError: null, - }, - createdAt: now, - }), - ); - } - if (event.textDelta && event.textDelta.length > 0) { - const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; - await runtime.runPromise( - orchestrationEngine.dispatch({ - type: "message.send", - commandId: crypto.randomUUID(), - threadId: thread.id, - messageId: assistantMessageId, - role: "assistant", - text: event.textDelta, - streaming: true, - createdAt: now, - }), - ); + const turnMessageIdsByTurnKey = new Map>(); + const latestMessageIdByTurnKey = new Map(); + + const rememberAssistantMessageId = (sessionId: string, turnId: string, messageId: string) => { + const key = providerTurnKey(sessionId, turnId); + const existingIds = turnMessageIdsByTurnKey.get(key); + if (existingIds) { + existingIds.add(messageId); + } else { + turnMessageIdsByTurnKey.set(key, new Set([messageId])); + } + latestMessageIdByTurnKey.set(key, messageId); + }; + + const getAssistantMessageIdsForTurn = (sessionId: string, turnId: string) => { + return turnMessageIdsByTurnKey.get(providerTurnKey(sessionId, turnId)) ?? new Set(); + }; + + const clearAssistantMessageIdsForTurn = (sessionId: string, turnId: string) => { + turnMessageIdsByTurnKey.delete(providerTurnKey(sessionId, turnId)); + }; + + const getLatestAssistantMessageIdForTurn = (sessionId: string, turnId: string) => { + return latestMessageIdByTurnKey.get(providerTurnKey(sessionId, turnId)); + }; + + const clearTurnStateForSession = (sessionId: string) => { + const prefix = `${sessionId}:`; + for (const key of turnMessageIdsByTurnKey.keys()) { + if (key.startsWith(prefix)) { + turnMessageIdsByTurnKey.delete(key); + } + } + for (const key of latestMessageIdByTurnKey.keys()) { + if (key.startsWith(prefix)) { + latestMessageIdByTurnKey.delete(key); + } + } + }; + + const processEvent = (event: ProviderRuntimeEvent) => + Effect.gen(function* () { + const snapshot = yield* orchestrationEngine.getSnapshot(); + const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId); + if (!thread) return; + + const now = event.createdAt; + if (event.type === "turn.started" || event.type === "turn.completed") { + yield* orchestrationEngine.dispatch({ + type: "thread.session", + commandId: crypto.randomUUID(), + threadId: thread.id, + session: { + sessionId: event.sessionId, + provider: event.provider, + status: event.type === "turn.started" ? "running" : "ready", + threadId: thread.id, + activeTurnId: event.type === "turn.started" ? event.turnId : null, + createdAt: thread.session?.createdAt ?? now, + updatedAt: now, + lastError: + event.type === "turn.completed" && event.status === "failed" + ? (event.errorMessage ?? null) + : null, + }, + createdAt: now, + }); + } + + if (event.type === "message.delta" && event.delta.length > 0) { + const assistantMessageId = `assistant:${event.itemId ?? event.turnId ?? event.sessionId}`; + if (event.turnId) { + rememberAssistantMessageId(event.sessionId, event.turnId, assistantMessageId); } - if (event.method === "turn/completed") { - const assistantMessageId = `assistant:${event.turnId ?? event.itemId ?? event.sessionId}`; - await runtime.runPromise( + yield* orchestrationEngine.dispatch({ + type: "message.send", + commandId: crypto.randomUUID(), + threadId: thread.id, + messageId: assistantMessageId, + role: "assistant", + text: event.delta, + streaming: true, + createdAt: now, + }); + } + + if (event.type === "turn.completed") { + if (event.turnId) { + const assistantMessageIds = getAssistantMessageIdsForTurn(event.sessionId, event.turnId); + yield* Effect.forEach(assistantMessageIds, (assistantMessageId) => orchestrationEngine.dispatch({ type: "message.send", commandId: crypto.randomUUID(), @@ -304,55 +407,85 @@ export function createServer(options: ServerOptions) { streaming: false, createdAt: now, }), - ); - } - if (event.method === "checkpoint/captured") { - const payload = asObject(event.payload); - const turnId = event.turnId ?? asString(payload?.turnId); - if (turnId) { - await runtime.runPromise( - orchestrationEngine.dispatch({ - type: "thread.turnDiff.complete", - commandId: crypto.randomUUID(), - threadId: thread.id, - turnId, - completedAt: now, - ...(asString(payload?.status) !== null - ? { status: asString(payload?.status) ?? undefined } - : {}), - files: [], - assistantMessageId: `assistant:${turnId}`, - ...(asNumber(payload?.turnCount) !== null - ? { checkpointTurnCount: asNumber(payload?.turnCount) ?? undefined } - : {}), - createdAt: now, - }), - ); - } - } - if (event.kind === "error" && event.message) { - await runtime.runPromise( - orchestrationEngine.dispatch({ - type: "thread.session", - commandId: crypto.randomUUID(), - threadId: thread.id, - session: { - sessionId: event.sessionId, - provider: event.provider, - status: "error", - threadId: thread.id, - activeTurnId: event.turnId ?? null, - createdAt: thread.session?.createdAt ?? now, - updatedAt: now, - lastError: event.message, - }, - createdAt: now, - }), - ); + ).pipe(Effect.asVoid); + clearAssistantMessageIdsForTurn(event.sessionId, event.turnId); } - })().catch(() => undefined); - }), + } + + if (event.type === "session.exited") { + clearTurnStateForSession(event.sessionId); + } + + if (event.type === "checkpoint.captured" && event.turnId) { + const assistantMessageId = + getLatestAssistantMessageIdForTurn(event.sessionId, event.turnId) ?? + `assistant:${event.turnId}`; + yield* orchestrationEngine.dispatch({ + type: "thread.turnDiff.complete", + commandId: crypto.randomUUID(), + threadId: thread.id, + turnId: event.turnId, + completedAt: now, + ...(event.status ? { status: event.status } : {}), + files: [], + assistantMessageId, + checkpointTurnCount: event.turnCount, + createdAt: now, + }); + } + + if (event.type === "runtime.error") { + yield* orchestrationEngine.dispatch({ + type: "thread.session", + commandId: crypto.randomUUID(), + threadId: thread.id, + session: { + sessionId: event.sessionId, + provider: event.provider, + status: "error", + threadId: thread.id, + activeTurnId: event.turnId ?? null, + createdAt: thread.session?.createdAt ?? now, + updatedAt: now, + lastError: event.message, + }, + createdAt: now, + }); + } + + const activities = runtimeEventToActivities(event); + yield* Effect.forEach(activities, (activity) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId: crypto.randomUUID(), + threadId: thread.id, + activity, + createdAt: activity.createdAt, + }), + ).pipe(Effect.asVoid); + }); + + const subscriptionScope = await runtime.runPromise(Scope.make("sequential")); + await runtime.runPromise( + Effect.gen(function* () { + const providerEventQueue = yield* Queue.unbounded(); + + yield* Effect.addFinalizer(() => Queue.shutdown(providerEventQueue).pipe(Effect.asVoid)); + + yield* Effect.forkScoped( + Effect.forever(Queue.take(providerEventQueue).pipe(Effect.flatMap(processEvent))), + ); + yield* Effect.forkScoped( + Stream.runForEach(providerService.streamEvents, (event) => + Queue.offer(providerEventQueue, event).pipe(Effect.asVoid), + ), + ); + }).pipe(Scope.provide(subscriptionScope)), ); + + unsubscribeProviderEvents = () => { + runtime.runFork(Scope.close(subscriptionScope, Exit.void)); + }; } const onTerminalEvent = (event: TerminalEvent) => { @@ -456,8 +589,6 @@ export function createServer(options: ServerOptions) { wss.on("connection", (ws) => { clients.add(ws); - const orchestrationEngine = getOrchestrationEngine(); - const runtime = getEffectRuntime(); // Send welcome message with project info const segments = cwd.split(/[/\\]/).filter(Boolean); @@ -471,25 +602,6 @@ export function createServer(options: ServerOptions) { logOutgoingPush(welcome, 1); ws.send(JSON.stringify(welcome)); - void runtime - .runPromise(orchestrationEngine.getSnapshot()) - .then((snapshot) => { - if (ws.readyState !== ws.OPEN) { - return; - } - const snapshotPush: WsPush = { - type: "push", - channel: ORCHESTRATION_WS_CHANNELS.readModel, - data: { - sequence: snapshot.sequence, - snapshot, - } satisfies OrchestrationReadModelPush, - }; - logOutgoingPush(snapshotPush, 1); - ws.send(JSON.stringify(snapshotPush)); - }) - .catch(() => undefined); - ws.on("message", (raw) => { void handleMessage(ws, raw); }); @@ -745,6 +857,11 @@ export function createServer(options: ServerOptions) { return effectRuntime.runPromise(orchestrationEngine.getSnapshot()); case ORCHESTRATION_WS_METHODS.dispatchCommand: + orchestrationCommandLogger?.write({ + observedAt: new Date().toISOString(), + requestId: request.id, + command: request.params, + }); return effectRuntime.runPromise(orchestrationEngine.dispatchUnknown(request.params)); case ORCHESTRATION_WS_METHODS.replayEvents: @@ -769,12 +886,23 @@ export function createServer(options: ServerOptions) { async function createEffectRuntime() { const dbPath = path.join(resolvedStateDir, "orchestration.sqlite"); + const providerLogsDir = path.join(resolvedStateDir, "logs", "providers"); + const orchestrationLogsDir = path.join(resolvedStateDir, "logs", "orchestration"); + orchestrationDomainEventLogger = makeEventNdjsonLogger( + path.join(orchestrationLogsDir, "orchestration-domain.ndjson"), + ); + orchestrationCommandLogger = makeEventNdjsonLogger( + path.join(orchestrationLogsDir, "orchestration-command.ndjson"), + ); const persistenceLayer = customPersistenceLayer ?? makeSqlitePersistenceLive(dbPath); const orchestrationLayer = Layer.provide( OrchestrationEngineLive, OrchestrationEventRepositoryLive, ); - const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(CodexAdapterLive)); + const codexAdapterLayer = makeCodexAdapterLive({ + nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), + }); + const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(codexAdapterLayer)); const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); const checkpointDependenciesLayer = Layer.mergeAll( checkpointStoreLayer, @@ -785,7 +913,9 @@ export function createServer(options: ServerOptions) { const checkpointServiceLayer = CheckpointServiceLive.pipe( Layer.provideMerge(checkpointDependenciesLayer), ); - const providerLayer = ProviderServiceLive.pipe(Layer.provideMerge(checkpointServiceLayer)); + const providerLayer = makeProviderServiceLive({ + canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"), + }).pipe(Layer.provideMerge(checkpointServiceLayer)); const layer = Layer.mergeAll(orchestrationLayer, ProjectRepositoryLive, providerLayer).pipe( Layer.provide(persistenceLayer), @@ -814,12 +944,14 @@ export function createServer(options: ServerOptions) { } async function disposeEffectRuntime() { - unsubscribeReadModel(); unsubscribeDomainEvents(); unsubscribeProviderEvents(); - unsubscribeReadModel = noop; unsubscribeDomainEvents = noop; unsubscribeProviderEvents = noop; + orchestrationDomainEventLogger?.close(); + orchestrationDomainEventLogger = undefined; + orchestrationCommandLogger?.close(); + orchestrationCommandLogger = undefined; const runtime = effectRuntime; const liveProviderService = providerService; diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 776a47cce0410..122fd028672c3 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -7,5 +7,5 @@ "noEmit": true, "allowImportingTsExtensions": true }, - "include": ["src", "tsdown.config.ts"] + "include": ["src", "integration", "tsdown.config.ts", "scripts"] } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0a1423863ea33..8c68aa3a82108 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -432,20 +432,35 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const phase = derivePhase(activeThread?.session ?? null); const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; + const activeTurnId = activeThread?.session?.activeTurnId ?? undefined; const nowIso = new Date(nowTick).toISOString(); - const workLogEntries = useMemo(() => deriveWorkLogEntries([], undefined), []); + const threadActivities = activeThread?.activities ?? []; + const workLogEntries = useMemo( + () => deriveWorkLogEntries(threadActivities, undefined), + [threadActivities], + ); const latestTurnWorkEntries = useMemo( - () => deriveWorkLogEntries([], activeThread?.latestTurnId), - [activeThread?.latestTurnId], + () => deriveWorkLogEntries(threadActivities, activeTurnId ?? activeThread?.latestTurnId), + [activeThread?.latestTurnId, activeTurnId, threadActivities], + ); + const pendingApprovals = useMemo( + () => derivePendingApprovals(threadActivities), + [threadActivities], ); - const pendingApprovals = useMemo(() => derivePendingApprovals([]), []); - const assistantCompletionByItemId = useMemo(() => new Map(), []); const timelineEntries = useMemo( () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), [activeThread?.messages, workLogEntries], ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); + const assistantCompletionByItemId = useMemo(() => { + const completionByMessageId = new Map(); + for (const summary of turnDiffSummaries) { + if (!summary.assistantMessageId) continue; + completionByMessageId.set(summary.assistantMessageId, summary.completedAt); + } + return completionByMessageId; + }, [turnDiffSummaries]); const turnDiffSummaryByAssistantMessageId = useMemo(() => { const byMessageId = new Map(); for (const summary of turnDiffSummaries) { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 8a6ee0752bc72..041c57fdfecae 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -126,17 +126,59 @@ function EventRouter() { useEffect(() => { if (!api) return; - void Promise.all([api.projects.list(), api.orchestration.getSnapshot()]) - .then(([projects, snapshot]) => { + let disposed = false; + let latestSequence = 0; + let syncing = false; + let pending = false; + + const flushSnapshotSync = async (): Promise => { + const snapshot = await api.orchestration.getSnapshot(); + if (disposed) return; + latestSequence = Math.max(latestSequence, snapshot.sequence); + dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); + if (pending) { + pending = false; + await flushSnapshotSync(); + } + }; + + const syncSnapshot = async () => { + if (syncing) { + pending = true; + return; + } + syncing = true; + try { + pending = false; + await flushSnapshotSync(); + } catch { + // Keep prior state and wait for next domain event to trigger a resync. + } finally { + syncing = false; + } + }; + + void Promise.all([api.projects.list(), syncSnapshot()]) + .then(([projects]) => { + if (disposed) return; dispatch({ type: "SYNC_PROJECTS", projects }); - dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); }) .catch(() => undefined); - const unsubReadModel = api.orchestration.onReadModel((snapshot) => { - dispatch({ type: "SYNC_SERVER_READ_MODEL", readModel: snapshot }); + + const unsubDomainEvent = api.orchestration.onDomainEvent((event) => { + if (event.sequence <= latestSequence) { + return; + } + latestSequence = event.sequence; + void syncSnapshot(); + }); + const unsubWelcome = onServerWelcome(() => { + void syncSnapshot(); }); return () => { - unsubReadModel(); + disposed = true; + unsubDomainEvent(); + unsubWelcome(); }; }, [api, dispatch]); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts new file mode 100644 index 0000000000000..c154a76644c9a --- /dev/null +++ b/apps/web/src/session-logic.test.ts @@ -0,0 +1,90 @@ +import type { OrchestrationThreadActivity } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; + +import { derivePendingApprovals, deriveWorkLogEntries } from "./session-logic"; + +function makeActivity( + overrides: Partial, +): OrchestrationThreadActivity { + return { + id: overrides.id ?? crypto.randomUUID(), + createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z", + label: overrides.label ?? "Tool call", + tone: overrides.tone ?? "tool", + ...(overrides.detail !== undefined ? { detail: overrides.detail } : {}), + ...(overrides.turnId !== undefined ? { turnId: overrides.turnId } : {}), + ...(overrides.requestId !== undefined ? { requestId: overrides.requestId } : {}), + ...(overrides.requestKind !== undefined ? { requestKind: overrides.requestKind } : {}), + }; +} + +describe("derivePendingApprovals", () => { + it("tracks open approvals and removes resolved ones", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open", + createdAt: "2026-02-23T00:00:01.000Z", + label: "Command approval requested", + detail: "bun run lint", + tone: "tool", + requestId: "req-1", + requestKind: "command", + }), + makeActivity({ + id: "approval-close", + createdAt: "2026-02-23T00:00:02.000Z", + label: "Approval resolved", + tone: "info", + requestId: "req-2", + }), + makeActivity({ + id: "approval-closed-request", + createdAt: "2026-02-23T00:00:01.500Z", + label: "File-change approval requested", + tone: "tool", + requestId: "req-2", + requestKind: "file-change", + }), + ]; + + expect(derivePendingApprovals(activities)).toEqual([ + { + requestId: "req-1", + requestKind: "command", + createdAt: "2026-02-23T00:00:01.000Z", + detail: "bun run lint", + }, + ]); + }); +}); + +describe("deriveWorkLogEntries", () => { + it("returns entries in chronological order", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "tool-complete", + createdAt: "2026-02-23T00:00:03.000Z", + label: "Tool call complete", + }), + makeActivity({ + id: "tool-start", + createdAt: "2026-02-23T00:00:02.000Z", + label: "Tool call", + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries.map((entry) => entry.id)).toEqual(["tool-start", "tool-complete"]); + }); + + it("filters by turn id when provided", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ id: "turn-1", turnId: "turn-1", label: "Tool call" }), + makeActivity({ id: "turn-2", turnId: "turn-2", label: "Tool call complete" }), + makeActivity({ id: "no-turn", label: "Checkpoint captured", tone: "info" }), + ]; + + const entries = deriveWorkLogEntries(activities, "turn-2"); + expect(entries.map((entry) => entry.id)).toEqual(["turn-2"]); + }); +}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index f8a5592be86ea..6ccfb21223ccf 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,4 +1,8 @@ -import type { ProviderKind, ProviderSession } from "@t3tools/contracts"; +import type { + OrchestrationThreadActivity, + ProviderKind, + ProviderSession, +} from "@t3tools/contracts"; import type { ChatMessage, SessionPhase, TurnDiffFileChange, TurnDiffSummary } from "./types"; @@ -70,15 +74,56 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str return formatDuration(endedAt - startedAt); } -export function derivePendingApprovals(_events: unknown[]): PendingApproval[] { - return []; +export function derivePendingApprovals( + activities: ReadonlyArray, +): PendingApproval[] { + const openByRequestId = new Map(); + const ordered = [...activities].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ); + + for (const activity of ordered) { + if (activity.requestId && activity.requestKind) { + openByRequestId.set(activity.requestId, { + requestId: activity.requestId, + requestKind: activity.requestKind, + createdAt: activity.createdAt, + ...(activity.detail ? { detail: activity.detail } : {}), + }); + continue; + } + + if (activity.requestId && activity.label === "Approval resolved") { + openByRequestId.delete(activity.requestId); + } + } + + return [...openByRequestId.values()].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ); } export function deriveWorkLogEntries( - _events: unknown[], - _latestTurnId: string | undefined, + activities: ReadonlyArray, + latestTurnId: string | undefined, ): WorkLogEntry[] { - return []; + const ordered = [...activities].toSorted((left, right) => + left.createdAt.localeCompare(right.createdAt), + ); + return ordered + .filter((activity) => (latestTurnId ? activity.turnId === latestTurnId : true)) + .map((activity) => { + const entry: WorkLogEntry = { + id: activity.id, + createdAt: activity.createdAt, + label: activity.label, + tone: activity.tone, + }; + if (activity.detail) { + entry.detail = activity.detail; + } + return entry; + }); } export function deriveTimelineEntries( @@ -97,7 +142,7 @@ export function deriveTimelineEntries( createdAt: entry.createdAt, entry, })); - return [...messageRows, ...workRows].sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + return [...messageRows, ...workRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt)); } export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileChange[] { @@ -116,7 +161,7 @@ export function deriveTurnDiffFilesFromUnifiedDiff(diff: string): TurnDiffFileCh export function inferCheckpointTurnCountByTurnId( summaries: TurnDiffSummary[], ): Record { - const sorted = [...summaries].sort((a, b) => a.completedAt.localeCompare(b.completedAt)); + const sorted = [...summaries].toSorted((a, b) => a.completedAt.localeCompare(b.completedAt)); const result: Record = {}; for (let index = 0; index < sorted.length; index += 1) { const summary = sorted[index]; @@ -126,7 +171,7 @@ export function inferCheckpointTurnCountByTurnId( return result; } -export function deriveTurnDiffSummaries(_events: unknown[]): TurnDiffSummary[] { +export function deriveTurnDiffSummaries(_events: ReadonlyArray): TurnDiffSummary[] { return []; } diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 46de08875d1f5..faf2cb3c9b519 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -398,6 +398,7 @@ export function reducer(state: AppState, action: Action): AppState { ...summary, files: summary.files.map((file) => ({ ...file })), })), + activities: thread.activities.map((activity) => ({ ...activity })), }); }); return { diff --git a/apps/web/src/threadFactory.ts b/apps/web/src/threadFactory.ts index 01a638140b98c..d5a2b0e5df155 100644 --- a/apps/web/src/threadFactory.ts +++ b/apps/web/src/threadFactory.ts @@ -33,6 +33,7 @@ export function createThread(projectId: string, options: CreateThreadOptions = { session: null, messages: [], turnDiffSummaries: [], + activities: [], error: null, createdAt, branch: options.branch ?? null, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 88188e426da5f..b7fb449e6759d 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,4 +1,5 @@ import type { + OrchestrationThreadActivity, ProjectScript as ContractProjectScript, ProviderSession, } from "@t3tools/contracts"; @@ -86,4 +87,5 @@ export interface Thread { branch: string | null; worktreePath: string | null; turnDiffSummaries: TurnDiffSummary[]; + activities: OrchestrationThreadActivity[]; } diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 423b86d3fc7d1..a00ab258fec9e 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -25,6 +25,7 @@ function makeThread(overrides: Partial = {}): Thread { session: null, messages: [], turnDiffSummaries: [], + activities: [], error: null, createdAt: "2026-02-13T00:00:00.000Z", branch: null, diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 7a52f75365bb7..d40439c888b5b 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -4,8 +4,6 @@ import { type NativeApi, WS_CHANNELS, WS_METHODS, - type OrchestrationEvent, - type OrchestrationReadModel, type WsWelcomePayload, } from "@t3tools/contracts"; @@ -161,14 +159,6 @@ export function createWsNativeApi(): NativeApi { transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command), replayEvents: (fromSequenceExclusive) => transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), - onReadModel: (callback) => - transport.subscribe( - ORCHESTRATION_WS_CHANNELS.readModel, - (data: unknown) => { - const payload = data as { snapshot: OrchestrationReadModel }; - callback(payload.snapshot); - }, - ), onDomainEvent: (callback) => transport.subscribe( ORCHESTRATION_WS_CHANNELS.domainEvent, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index fea1328075006..252cf2e3e2a30 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -3,6 +3,7 @@ export * from "./ipc"; export * from "./agent"; export * from "./terminal"; export * from "./provider"; +export * from "./providerRuntime"; export * from "./model"; export * from "./ws"; export * from "./keybindings"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b3649d0907f84..8f199dda1429e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -146,7 +146,6 @@ export interface NativeApi { getSnapshot: () => Promise; dispatchCommand: (command: OrchestrationCommand) => Promise<{ sequence: number }>; replayEvents: (fromSequenceExclusive: number) => Promise; - onReadModel: (callback: (snapshot: OrchestrationReadModel) => void) => () => void; onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void; }; } diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index ce12299721b7c..f2851d880f90d 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -7,7 +7,6 @@ export const ORCHESTRATION_WS_METHODS = { } as const; export const ORCHESTRATION_WS_CHANNELS = { - readModel: "orchestration.readModel", domainEvent: "orchestration.domainEvent", } as const; @@ -59,6 +58,30 @@ export type OrchestrationTurnDiffSummary = Schema.Schema.Type< typeof OrchestrationTurnDiffSummarySchema >; +export const OrchestrationThreadActivityToneSchema = Schema.Literals([ + "thinking", + "tool", + "info", + "error", +]); + +export type OrchestrationThreadActivityTone = Schema.Schema.Type< + typeof OrchestrationThreadActivityToneSchema +>; + +export const OrchestrationThreadActivitySchema = Schema.Struct({ + id: IdSchema, + createdAt: IsoDateTimeSchema, + label: Schema.String, + detail: Schema.optional(Schema.String), + tone: OrchestrationThreadActivityToneSchema, + turnId: Schema.optional(Schema.String), + requestId: Schema.optional(Schema.String), + requestKind: Schema.optional(Schema.Literals(["command", "file-change"])), +}); + +export type OrchestrationThreadActivity = Schema.Schema.Type; + export const OrchestrationThreadSchema = Schema.Struct({ id: IdSchema, projectId: IdSchema, @@ -75,6 +98,7 @@ export const OrchestrationThreadSchema = Schema.Struct({ messages: Schema.Array(OrchestrationMessageSchema), session: Schema.NullOr(OrchestrationSessionSchema), turnDiffSummaries: Schema.Array(OrchestrationTurnDiffSummarySchema), + activities: Schema.Array(OrchestrationThreadActivitySchema), error: Schema.NullOr(Schema.String), }); @@ -182,6 +206,14 @@ export const RevertThreadCommandSchema = Schema.Struct({ createdAt: IsoDateTimeSchema, }); +export const AppendThreadActivityCommandSchema = Schema.Struct({ + type: Schema.Literal("thread.activity.append"), + commandId: IdSchema, + threadId: IdSchema, + activity: OrchestrationThreadActivitySchema, + createdAt: IsoDateTimeSchema, +}); + export const OrchestrationCommandSchema = Schema.Union([ CreateThreadCommandSchema, DeleteThreadCommandSchema, @@ -190,6 +222,7 @@ export const OrchestrationCommandSchema = Schema.Union([ SetThreadSessionSchema, UpsertGitReadModelCommandSchema, CompleteThreadTurnDiffCommandSchema, + AppendThreadActivityCommandSchema, RevertThreadCommandSchema,] ); @@ -207,10 +240,3 @@ export const OrchestrationEventSchema = Schema.Struct({ }); export type OrchestrationEvent = Schema.Schema.Type; - -export const OrchestrationReadModelPushSchema = Schema.Struct({ - sequence: Schema.Number, - snapshot: OrchestrationReadModelSchema, -}); - -export type OrchestrationReadModelPush = Schema.Schema.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts new file mode 100644 index 0000000000000..95e9b97e11de5 --- /dev/null +++ b/packages/contracts/src/providerRuntime.ts @@ -0,0 +1,217 @@ +import { Schema } from "effect"; + +const IsoDateTimeSchema = Schema.String; +const IdSchema = Schema.String; + +export const ProviderRuntimeProviderSchema = Schema.Literals(["codex", "claudeCode"]); +export type ProviderRuntimeProvider = Schema.Schema.Type; + +export const ProviderRuntimeApprovalKindSchema = Schema.Literals(["command", "file-change"]); +export type ProviderRuntimeApprovalKind = Schema.Schema.Type< + typeof ProviderRuntimeApprovalKindSchema +>; + +export const ProviderRuntimeToolKindSchema = Schema.Literals(["command", "file-change", "other"]); +export type ProviderRuntimeToolKind = Schema.Schema.Type; + +export const ProviderRuntimeTurnStatusSchema = Schema.Literals([ + "completed", + "failed", + "cancelled", + "interrupted", +]); +export type ProviderRuntimeTurnStatus = Schema.Schema.Type; + +export const ProviderRuntimeSessionStartedEventSchema = Schema.Struct({ + type: Schema.Literal("session.started"), + eventId: IdSchema, + provider: ProviderRuntimeProviderSchema, + sessionId: IdSchema, + createdAt: IsoDateTimeSchema, + threadId: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), +}); + +export const ProviderRuntimeSessionExitedEventSchema = Schema.Struct({ + type: Schema.Literal("session.exited"), + eventId: IdSchema, + provider: ProviderRuntimeProviderSchema, + sessionId: IdSchema, + createdAt: IsoDateTimeSchema, + threadId: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), +}); + +export const ProviderRuntimeThreadStartedEventSchema = Schema.Struct({ + type: Schema.Literal("thread.started"), + eventId: IdSchema, + provider: ProviderRuntimeProviderSchema, + sessionId: IdSchema, + createdAt: IsoDateTimeSchema, + threadId: IdSchema, +}); + +export const ProviderRuntimeTurnStartedEventSchema = Schema.Struct({ + type: Schema.Literal("turn.started"), + eventId: IdSchema, + provider: ProviderRuntimeProviderSchema, + sessionId: IdSchema, + createdAt: IsoDateTimeSchema, + threadId: Schema.optional(Schema.String), + turnId: IdSchema, +}); + +export const ProviderRuntimeTurnCompletedEventSchema = Schema.Struct({ + type: Schema.Literal("turn.completed"), + eventId: IdSchema, + provider: ProviderRuntimeProviderSchema, + sessionId: IdSchema, + createdAt: IsoDateTimeSchema, + threadId: Schema.optional(Schema.String), + turnId: Schema.optional(Schema.String), + status: Schema.optional(ProviderRuntimeTurnStatusSchema), + errorMessage: Schema.optional(Schema.String), +}); + +export const ProviderRuntimeMessageDeltaEventSchema = Schema.Struct({ + type: Schema.Literal("message.delta"), + eventId: IdSchema, + provider: ProviderRuntimeProviderSchema, + sessionId: IdSchema, + createdAt: IsoDateTimeSchema, + threadId: Schema.optional(Schema.String), + turnId: Schema.optional(Schema.String), + itemId: Schema.optional(Schema.String), + delta: Schema.String, +}); + +export const ProviderRuntimeToolStartedEventSchema = Schema.Struct({ + type: Schema.Literal("tool.started"), + eventId: IdSchema, + provider: ProviderRuntimeProviderSchema, + sessionId: IdSchema, + createdAt: IsoDateTimeSchema, + threadId: Schema.optional(Schema.String), + turnId: Schema.optional(Schema.String), + itemId: Schema.optional(Schema.String), + toolKind: ProviderRuntimeToolKindSchema, + title: Schema.String, + detail: Schema.optional(Schema.String), +}); + +export const ProviderRuntimeToolCompletedEventSchema = Schema.Struct({ + type: Schema.Literal("tool.completed"), + eventId: IdSchema, + provider: ProviderRuntimeProviderSchema, + sessionId: IdSchema, + createdAt: IsoDateTimeSchema, + threadId: Schema.optional(Schema.String), + turnId: Schema.optional(Schema.String), + itemId: Schema.optional(Schema.String), + toolKind: ProviderRuntimeToolKindSchema, + title: Schema.String, + detail: Schema.optional(Schema.String), +}); + +export const ProviderRuntimeApprovalRequestedEventSchema = Schema.Struct({ + type: Schema.Literal("approval.requested"), + eventId: IdSchema, + provider: ProviderRuntimeProviderSchema, + sessionId: IdSchema, + createdAt: IsoDateTimeSchema, + threadId: Schema.optional(Schema.String), + turnId: Schema.optional(Schema.String), + itemId: Schema.optional(Schema.String), + requestId: IdSchema, + requestKind: ProviderRuntimeApprovalKindSchema, + detail: Schema.optional(Schema.String), +}); + +export const ProviderRuntimeApprovalResolvedEventSchema = Schema.Struct({ + type: Schema.Literal("approval.resolved"), + eventId: IdSchema, + provider: ProviderRuntimeProviderSchema, + sessionId: IdSchema, + createdAt: IsoDateTimeSchema, + threadId: Schema.optional(Schema.String), + turnId: Schema.optional(Schema.String), + itemId: Schema.optional(Schema.String), + requestId: IdSchema, + requestKind: Schema.optional(ProviderRuntimeApprovalKindSchema), + decision: Schema.optional(Schema.String), +}); + +export const ProviderRuntimeCheckpointCapturedEventSchema = Schema.Struct({ + type: Schema.Literal("checkpoint.captured"), + eventId: IdSchema, + provider: ProviderRuntimeProviderSchema, + sessionId: IdSchema, + createdAt: IsoDateTimeSchema, + threadId: IdSchema, + turnId: Schema.optional(Schema.String), + turnCount: Schema.Number, + status: Schema.optional(ProviderRuntimeTurnStatusSchema), +}); + +export const ProviderRuntimeErrorEventSchema = Schema.Struct({ + type: Schema.Literal("runtime.error"), + eventId: IdSchema, + provider: ProviderRuntimeProviderSchema, + sessionId: IdSchema, + createdAt: IsoDateTimeSchema, + threadId: Schema.optional(Schema.String), + turnId: Schema.optional(Schema.String), + itemId: Schema.optional(Schema.String), + message: Schema.String, +}); + +export const ProviderRuntimeEventSchema = Schema.Union([ + ProviderRuntimeSessionStartedEventSchema, + ProviderRuntimeSessionExitedEventSchema, + ProviderRuntimeThreadStartedEventSchema, + ProviderRuntimeTurnStartedEventSchema, + ProviderRuntimeTurnCompletedEventSchema, + ProviderRuntimeMessageDeltaEventSchema, + ProviderRuntimeToolStartedEventSchema, + ProviderRuntimeToolCompletedEventSchema, + ProviderRuntimeApprovalRequestedEventSchema, + ProviderRuntimeApprovalResolvedEventSchema, + ProviderRuntimeCheckpointCapturedEventSchema, + ProviderRuntimeErrorEventSchema, +]); + +export type ProviderRuntimeSessionStartedEvent = Schema.Schema.Type< + typeof ProviderRuntimeSessionStartedEventSchema +>; +export type ProviderRuntimeSessionExitedEvent = Schema.Schema.Type< + typeof ProviderRuntimeSessionExitedEventSchema +>; +export type ProviderRuntimeThreadStartedEvent = Schema.Schema.Type< + typeof ProviderRuntimeThreadStartedEventSchema +>; +export type ProviderRuntimeTurnStartedEvent = Schema.Schema.Type< + typeof ProviderRuntimeTurnStartedEventSchema +>; +export type ProviderRuntimeTurnCompletedEvent = Schema.Schema.Type< + typeof ProviderRuntimeTurnCompletedEventSchema +>; +export type ProviderRuntimeMessageDeltaEvent = Schema.Schema.Type< + typeof ProviderRuntimeMessageDeltaEventSchema +>; +export type ProviderRuntimeToolStartedEvent = Schema.Schema.Type< + typeof ProviderRuntimeToolStartedEventSchema +>; +export type ProviderRuntimeToolCompletedEvent = Schema.Schema.Type< + typeof ProviderRuntimeToolCompletedEventSchema +>; +export type ProviderRuntimeApprovalRequestedEvent = Schema.Schema.Type< + typeof ProviderRuntimeApprovalRequestedEventSchema +>; +export type ProviderRuntimeApprovalResolvedEvent = Schema.Schema.Type< + typeof ProviderRuntimeApprovalResolvedEventSchema +>; +export type ProviderRuntimeCheckpointCapturedEvent = Schema.Schema.Type< + typeof ProviderRuntimeCheckpointCapturedEventSchema +>; +export type ProviderRuntimeErrorEvent = Schema.Schema.Type; +export type ProviderRuntimeEvent = Schema.Schema.Type; From d14705516ca9eb1294214eeb4a16f3c2abba5778 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 23 Feb 2026 01:23:39 -0800 Subject: [PATCH 23/98] wip --- apps/server/package.json | 1 + apps/server/src/checkpointing/Diffs.test.ts | 68 +++++ apps/server/src/checkpointing/Diffs.ts | 25 ++ apps/server/src/orchestration/Layer.test.ts | 69 +++-- apps/server/src/orchestration/Layer.ts | 53 +--- apps/server/src/orchestration/Service.ts | 21 +- apps/server/src/orchestration/reducer.test.ts | 160 ++++++++++++ apps/server/src/orchestration/reducer.ts | 54 ++++ .../src/provider/Layers/CodexAdapter.test.ts | 39 +++ .../src/provider/Layers/CodexAdapter.ts | 38 +++ apps/server/src/wsServer.ts | 111 ++++++-- apps/web/src/components/ChatView.tsx | 239 ++++-------------- apps/web/src/components/DiffPanel.tsx | 45 +++- apps/web/src/hooks/useTurnDiffSummaries.ts | 2 +- apps/web/src/session-logic.test.ts | 54 +++- apps/web/src/session-logic.ts | 44 +++- apps/web/src/types.ts | 1 + bun.lock | 1 + packages/contracts/src/orchestration.ts | 1 + packages/contracts/src/providerRuntime.ts | 15 ++ 20 files changed, 710 insertions(+), 331 deletions(-) create mode 100644 apps/server/src/checkpointing/Diffs.test.ts create mode 100644 apps/server/src/checkpointing/Diffs.ts diff --git a/apps/server/package.json b/apps/server/package.json index 709ef2ba8ad5a..e05937f2b3856 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@effect/platform-node": "4.0.0-beta.10", + "@pierre/diffs": "^1.1.0-beta.16", "effect": "4.0.0-beta.10", "node-pty": "^1.1.0", "open": "^10.1.0", diff --git a/apps/server/src/checkpointing/Diffs.test.ts b/apps/server/src/checkpointing/Diffs.test.ts new file mode 100644 index 0000000000000..e81e509364218 --- /dev/null +++ b/apps/server/src/checkpointing/Diffs.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { parseTurnDiffFilesFromUnifiedDiff } from "./Diffs.ts"; + +describe("parseTurnDiffFilesFromUnifiedDiff", () => { + it("returns empty list for empty diff", () => { + expect(parseTurnDiffFilesFromUnifiedDiff("")).toEqual([]); + }); + + it("parses per-file additions and deletions", () => { + const diff = [ + "diff --git a/a.txt b/a.txt", + "index 1111111..2222222 100644", + "--- a/a.txt", + "+++ b/a.txt", + "@@ -1,2 +1,3 @@", + " one", + "-two", + "+two updated", + "+three", + "diff --git a/src/b.ts b/src/b.ts", + "index 3333333..4444444 100644", + "--- a/src/b.ts", + "+++ b/src/b.ts", + "@@ -3,2 +3,0 @@", + "-old", + "-stale", + "", + ].join("\n"); + + expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([ + { path: "a.txt", additions: 2, deletions: 1 }, + { path: "src/b.ts", additions: 0, deletions: 2 }, + ]); + }); + + it("parses rename-only diffs with zero line changes", () => { + const diff = [ + "diff --git a/src/old.ts b/src/new.ts", + "similarity index 100%", + "rename from src/old.ts", + "rename to src/new.ts", + "", + ].join("\n"); + + expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([ + { path: "src/new.ts", additions: 0, deletions: 0 }, + ]); + }); + + it("normalizes CRLF input before parsing", () => { + const diff = [ + "diff --git a/a.txt b/a.txt", + "index 1111111..2222222 100644", + "--- a/a.txt", + "+++ b/a.txt", + "@@ -1 +1,2 @@", + "-one", + "+one updated", + "+two", + "", + ].join("\r\n"); + + expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([ + { path: "a.txt", additions: 2, deletions: 1 }, + ]); + }); +}); diff --git a/apps/server/src/checkpointing/Diffs.ts b/apps/server/src/checkpointing/Diffs.ts new file mode 100644 index 0000000000000..1db56411051bc --- /dev/null +++ b/apps/server/src/checkpointing/Diffs.ts @@ -0,0 +1,25 @@ +import { parsePatchFiles } from "@pierre/diffs"; + +export interface TurnDiffFileSummary { + readonly path: string; + readonly additions: number; + readonly deletions: number; +} + +export function parseTurnDiffFilesFromUnifiedDiff(diff: string): ReadonlyArray { + const normalized = diff.replace(/\r\n/g, "\n").trim(); + if (normalized.length === 0) { + return []; + } + + const parsedPatches = parsePatchFiles(normalized); + const files = parsedPatches.flatMap((patch) => + patch.files.map((file) => ({ + path: file.name, + additions: file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0), + deletions: file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0), + })), + ); + + return files.toSorted((left, right) => left.path.localeCompare(right.path)); +} diff --git a/apps/server/src/orchestration/Layer.test.ts b/apps/server/src/orchestration/Layer.test.ts index 27f29b8e76d4e..e82811c120566 100644 --- a/apps/server/src/orchestration/Layer.test.ts +++ b/apps/server/src/orchestration/Layer.test.ts @@ -1,5 +1,5 @@ import type { OrchestrationEvent } from "@t3tools/contracts"; -import { Effect, Layer, ManagedRuntime } from "effect"; +import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"; import { describe, expect, it } from "vitest"; import { @@ -65,33 +65,6 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); - it("fans out read-model updates to subscribers", async () => { - const system = await createOrchestrationSystem(); - const engine = system.engine; - const updates: number[] = []; - const unsubscribe = await system.run( - engine.subscribeToReadModel((snapshot) => { - updates.push(snapshot.sequence); - }), - ); - await system.run( - engine.dispatch({ - type: "thread.create", - commandId: "cmd-thread", - threadId: "thread-2", - projectId: "project-2", - title: "fanout", - model: "gpt-5-codex", - branch: null, - worktreePath: null, - createdAt: new Date().toISOString(), - }), - ); - unsubscribe(); - expect(updates.length).toBeGreaterThan(0); - await system.dispose(); - }); - it("replays append-only events from sequence", async () => { const system = await createOrchestrationSystem(); const engine = system.engine; @@ -123,6 +96,46 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + it("streams persisted domain events in order", async () => { + const system = await createOrchestrationSystem(); + const engine = system.engine; + const eventTypes: string[] = []; + await system.run( + Effect.gen(function* () { + const eventQueue = yield* Queue.unbounded(); + yield* Effect.forkScoped( + Stream.take(engine.streamDomainEvents, 2).pipe( + Stream.runForEach((event) => Queue.offer(eventQueue, event).pipe(Effect.asVoid)), + ), + ); + yield* Effect.sleep("10 millis"); + const createdAt = new Date().toISOString(); + yield* engine.dispatch({ + type: "thread.create", + commandId: "cmd-stream-domain-a", + threadId: "thread-stream-domain", + projectId: "project-stream-domain", + title: "domain-stream", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.meta.update", + commandId: "cmd-stream-domain-b", + threadId: "thread-stream-domain", + title: "domain-stream-updated", + createdAt, + }); + eventTypes.push((yield* Queue.take(eventQueue)).type); + eventTypes.push((yield* Queue.take(eventQueue)).type); + }).pipe(Effect.scoped), + ); + expect(eventTypes).toEqual(["thread.created", "thread.meta-updated"]); + await system.dispose(); + }); + it("stores completed turn summaries even when no files changed", async () => { const system = await createOrchestrationSystem(); const engine = system.engine; diff --git a/apps/server/src/orchestration/Layer.ts b/apps/server/src/orchestration/Layer.ts index 6847c693d3302..c03029b93ae7a 100644 --- a/apps/server/src/orchestration/Layer.ts +++ b/apps/server/src/orchestration/Layer.ts @@ -4,12 +4,11 @@ import type { OrchestrationReadModel, } from "@t3tools/contracts"; import { OrchestrationCommandSchema } from "@t3tools/contracts"; -import { Deferred, Effect, Layer, PubSub, Queue, Schema } from "effect"; +import { Deferred, Effect, Layer, PubSub, Queue, Schema, Stream } from "effect"; import { createLogger } from "../logger.ts"; import { OrchestrationEventRepository } from "../persistence/Services/OrchestrationEvents.ts"; import { - toListenerCallbackError, toOrchestrationCommandDecodeError, toOrchestrationJsonParseError, type OrchestrationDispatchError, @@ -182,42 +181,14 @@ const makeOrchestrationEngine = Effect.gen(function* () { let readModel = createEmptyReadModel(new Date().toISOString()); const commandQueue = yield* Queue.unbounded(); - const readModelPubSub = yield* PubSub.unbounded(); const eventPubSub = yield* PubSub.unbounded(); - const readModelListeners = new Set<(snapshot: OrchestrationReadModel) => void>(); - const domainEventListeners = new Set<(event: OrchestrationEvent) => void>(); - - const notifyReadModelListeners = (snapshot: OrchestrationReadModel) => - Effect.forEach(readModelListeners, (listener) => - Effect.try({ - try: () => listener(snapshot), - catch: toListenerCallbackError("read-model"), - }), - ).pipe(Effect.asVoid); - - const notifyDomainEventListeners = (event: OrchestrationEvent) => - Effect.forEach(domainEventListeners, (listener) => - Effect.try({ - try: () => listener(event), - catch: toListenerCallbackError("domain-event"), - }), - ).pipe(Effect.asVoid); - const processEnvelope = (envelope: CommandEnvelope): Effect.Effect => Effect.gen(function* () { const eventBase = mapCommandToEvent(envelope.command); const savedEvent = yield* eventStore.append(eventBase); readModel = yield* reduceEvent(readModel, savedEvent); - - const snapshot = readModel; - yield* Effect.all([ - PubSub.publish(eventPubSub, savedEvent), - PubSub.publish(readModelPubSub, snapshot), - ]); - - yield* notifyDomainEventListeners(savedEvent); - yield* notifyReadModelListeners(snapshot); + yield* PubSub.publish(eventPubSub, savedEvent); yield* Deferred.succeed(envelope.result, { sequence: savedEvent.sequence }); }).pipe(Effect.catch((error) => Deferred.fail(envelope.result, error).pipe(Effect.asVoid))); @@ -269,29 +240,15 @@ const makeOrchestrationEngine = Effect.gen(function* () { return yield* dispatch(decoded); }); - const subscribeToReadModel: OrchestrationEngineShape["subscribeToReadModel"] = (callback) => - Effect.sync(() => { - readModelListeners.add(callback); - return () => { - readModelListeners.delete(callback); - }; - }); - - const subscribeToDomainEvents: OrchestrationEngineShape["subscribeToDomainEvents"] = (callback) => - Effect.sync(() => { - domainEventListeners.add(callback); - return () => { - domainEventListeners.delete(callback); - }; - }); + const streamDomainEvents: OrchestrationEngineShape["streamDomainEvents"] = + Stream.fromPubSub(eventPubSub); return { getSnapshot, replayEvents, dispatchUnknown, dispatch, - subscribeToReadModel, - subscribeToDomainEvents, + streamDomainEvents, } satisfies OrchestrationEngineShape; }); diff --git a/apps/server/src/orchestration/Service.ts b/apps/server/src/orchestration/Service.ts index 692c6ca499826..bffd33e50823b 100644 --- a/apps/server/src/orchestration/Service.ts +++ b/apps/server/src/orchestration/Service.ts @@ -16,7 +16,7 @@ import type { OrchestrationReadModel, } from "@t3tools/contracts"; import { ServiceMap } from "effect"; -import type { Effect } from "effect"; +import type { Effect, Stream } from "effect"; import type { OrchestrationDispatchError, OrchestrationEngineError } from "./Errors.ts"; import type { OrchestrationEventRepositoryError } from "../persistence/Errors.ts"; @@ -62,24 +62,9 @@ export interface OrchestrationEngineShape { ) => Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never>; /** - * Subscribe to read model updates. - * - * @param callback - Handler invoked on every read model update. - * @returns Effect containing an unsubscribe function. - */ - readonly subscribeToReadModel: ( - callback: (snapshot: OrchestrationReadModel) => void, - ) => Effect.Effect<() => void, never, never>; - - /** - * Subscribe to domain event fan-out. - * - * @param callback - Handler invoked for every persisted domain event. - * @returns Effect containing an unsubscribe function. + * Stream persisted domain events in dispatch order. */ - readonly subscribeToDomainEvents: ( - callback: (event: OrchestrationEvent) => void, - ) => Effect.Effect<() => void, never, never>; + readonly streamDomainEvents: Stream.Stream; } /** diff --git a/apps/server/src/orchestration/reducer.test.ts b/apps/server/src/orchestration/reducer.test.ts index bf3cc633d72ea..97cb5a9d14afe 100644 --- a/apps/server/src/orchestration/reducer.test.ts +++ b/apps/server/src/orchestration/reducer.test.ts @@ -95,4 +95,164 @@ describe("orchestration reducer", () => { expect(next.updatedAt).toBe("2026-01-01T00:00:00.000Z"); expect(next.threads).toEqual([]); }); + + it("tracks latest turn timing from session lifecycle events", async () => { + const createdAt = "2026-02-23T08:00:00.000Z"; + const startedAt = "2026-02-23T08:00:05.000Z"; + const completedAt = "2026-02-23T08:00:11.250Z"; + const model = createEmptyReadModel(createdAt); + + const createdEvent: OrchestrationEvent = { + sequence: 1, + eventId: "thread-created", + type: "thread.created", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + id: "thread-1", + projectId: "project-1", + title: "demo", + model: "gpt-5.3-codex", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }; + + const runningSessionEvent: OrchestrationEvent = { + sequence: 2, + eventId: "session-running", + type: "thread.session-set", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: startedAt, + commandId: "cmd-running", + payload: { + threadId: "thread-1", + session: { + sessionId: "session-1", + status: "running", + provider: "codex", + threadId: "thread-1", + activeTurnId: "turn-1", + createdAt, + updatedAt: startedAt, + lastError: null, + }, + }, + }; + + const readySessionEvent: OrchestrationEvent = { + sequence: 3, + eventId: "session-ready", + type: "thread.session-set", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: completedAt, + commandId: "cmd-ready", + payload: { + threadId: "thread-1", + session: { + sessionId: "session-1", + status: "ready", + provider: "codex", + threadId: "thread-1", + activeTurnId: null, + createdAt, + updatedAt: completedAt, + lastError: null, + }, + }, + }; + + const afterCreate = await Effect.runPromise(reduceEvent(model, createdEvent)); + const afterRunning = await Effect.runPromise(reduceEvent(afterCreate, runningSessionEvent)); + const afterReady = await Effect.runPromise(reduceEvent(afterRunning, readySessionEvent)); + const thread = afterReady.threads[0]; + + expect(thread?.latestTurnId).toBe("turn-1"); + expect(thread?.latestTurnStartedAt).toBe(startedAt); + expect(thread?.latestTurnCompletedAt).toBe(completedAt); + expect(thread?.latestTurnDurationMs).toBe(6250); + }); + + it("marks assistant messages completed using non-streaming updates", async () => { + const createdAt = "2026-02-23T09:00:00.000Z"; + const deltaAt = "2026-02-23T09:00:01.000Z"; + const completeAt = "2026-02-23T09:00:03.500Z"; + const model = createEmptyReadModel(createdAt); + + const createdEvent: OrchestrationEvent = { + sequence: 1, + eventId: "thread-created", + type: "thread.created", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + id: "thread-1", + projectId: "project-1", + title: "demo", + model: "gpt-5.3-codex", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }; + + const deltaEvent: OrchestrationEvent = { + sequence: 2, + eventId: "assistant-delta", + type: "message.sent", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: deltaAt, + commandId: "cmd-delta", + payload: { + id: "assistant:msg-1", + role: "assistant", + text: "hello", + threadId: "thread-1", + createdAt: deltaAt, + streaming: true, + }, + }; + + const completionEvent: OrchestrationEvent = { + sequence: 3, + eventId: "assistant-complete", + type: "message.sent", + aggregateType: "thread", + aggregateId: "thread-1", + occurredAt: completeAt, + commandId: "cmd-complete", + payload: { + id: "assistant:msg-1", + role: "assistant", + text: "", + threadId: "thread-1", + createdAt: completeAt, + streaming: false, + }, + }; + + const afterCreate = await Effect.runPromise(reduceEvent(model, createdEvent)); + const afterDelta = await Effect.runPromise(reduceEvent(afterCreate, deltaEvent)); + const afterComplete = await Effect.runPromise(reduceEvent(afterDelta, completionEvent)); + const message = afterComplete.threads[0]?.messages[0]; + + expect(message).toEqual({ + id: "assistant:msg-1", + role: "assistant", + text: "hello", + createdAt: deltaAt, + completedAt: completeAt, + streaming: false, + }); + }); }); diff --git a/apps/server/src/orchestration/reducer.ts b/apps/server/src/orchestration/reducer.ts index 40320779c1f6a..bf643572eebf5 100644 --- a/apps/server/src/orchestration/reducer.ts +++ b/apps/server/src/orchestration/reducer.ts @@ -204,6 +204,7 @@ export function reduceEvent( role: payload.role, text: payload.text, createdAt: payload.createdAt, + ...(payload.streaming ? {} : { completedAt: event.occurredAt }), streaming: payload.streaming, }, event.type, @@ -222,6 +223,9 @@ export function reduceEvent( : entry.text, streaming: message.streaming, createdAt: entry.createdAt, + ...(message.streaming + ? { completedAt: undefined } + : { completedAt: message.completedAt ?? event.occurredAt }), } : entry, ) @@ -260,12 +264,51 @@ export function reduceEvent( "session", ); + const sessionUpdatedAt = session.updatedAt; + const turnTimingPatch: ThreadPatch = + session.status === "running" && session.activeTurnId + ? (() => { + const isSameTurn = thread.latestTurnId === session.activeTurnId; + return { + latestTurnId: session.activeTurnId, + latestTurnStartedAt: + isSameTurn && thread.latestTurnStartedAt + ? thread.latestTurnStartedAt + : sessionUpdatedAt, + ...(isSameTurn + ? {} + : { + latestTurnCompletedAt: null, + latestTurnDurationMs: null, + }), + }; + })() + : session.activeTurnId === null && thread.latestTurnId && thread.latestTurnStartedAt + ? (() => { + const latestTurnCompletedAt = thread.latestTurnCompletedAt ?? sessionUpdatedAt; + const startedAtMs = Date.parse(thread.latestTurnStartedAt); + const completedAtMs = Date.parse(latestTurnCompletedAt); + const latestTurnDurationMs = + !Number.isNaN(startedAtMs) && + !Number.isNaN(completedAtMs) && + completedAtMs >= startedAtMs + ? completedAtMs - startedAtMs + : thread.latestTurnDurationMs; + + return { + latestTurnCompletedAt, + latestTurnDurationMs, + }; + })() + : {}; + return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { session, updatedAt: event.occurredAt, error: session.lastError, + ...turnTimingPatch, }), }; }); @@ -303,12 +346,23 @@ export function reduceEvent( ...thread.turnDiffSummaries.filter((summary) => summary.turnId !== payload.turnId), nextSummary, ].toSorted((left, right) => left.completedAt.localeCompare(right.completedAt)); + const startedAtMs = thread.latestTurnStartedAt + ? Date.parse(thread.latestTurnStartedAt) + : Number.NaN; + const completedAtMs = Date.parse(nextSummary.completedAt); + const latestTurnDurationMs = + !Number.isNaN(startedAtMs) && + !Number.isNaN(completedAtMs) && + completedAtMs >= startedAtMs + ? completedAtMs - startedAtMs + : thread.latestTurnDurationMs; return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { latestTurnId: payload.turnId, latestTurnCompletedAt: nextSummary.completedAt, + latestTurnDurationMs, turnDiffSummaries, updatedAt: event.occurredAt, }), diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 632fd0a922126..c8c6bb334e502 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -170,6 +170,45 @@ const lifecycleManager = new FakeCodexManager(); const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager })); lifecycleLayer("CodexAdapterLive lifecycle", (it) => { + it.effect("maps completed agent message items to canonical message.completed events", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + const event: ProviderEvent = { + id: "evt-msg-complete", + kind: "notification", + provider: "codex", + sessionId: "sess-1", + createdAt: new Date().toISOString(), + method: "item/completed", + threadId: "thread-1", + turnId: "turn-1", + itemId: "msg_1", + payload: { + item: { + type: "agentMessage", + id: "msg_1", + }, + }, + }; + + lifecycleManager.emit("event", event); + const firstEvent = yield* Fiber.join(firstEventFiber); + + assert.equal(firstEvent._tag, "Some"); + if (firstEvent._tag !== "Some") { + return; + } + assert.equal(firstEvent.value.type, "message.completed"); + if (firstEvent.value.type !== "message.completed") { + return; + } + assert.equal(firstEvent.value.itemId, "msg_1"); + assert.equal(firstEvent.value.turnId, "turn-1"); + }), + ); + it.effect("emits canonical events on stream and supports consumer interruption", () => Effect.gen(function* () { const adapter = yield* CodexAdapter; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index d8055a54460a0..c3e88d112becc 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -161,6 +161,39 @@ function itemDetail( return undefined; } +function mapMessageCompletedEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined { + if (event.method !== "item/completed") { + return undefined; + } + + const payload = asObject(event.payload); + const item = asObject(payload?.item); + if (!item) { + return undefined; + } + + const normalizedType = normalizeItemType(item.type ?? item.kind); + if (!normalizedType.includes("agent message")) { + return undefined; + } + + const itemId = event.itemId ?? asString(item.id); + if (!itemId) { + return undefined; + } + + return { + type: "message.completed", + eventId: event.id, + provider: event.provider, + sessionId: event.sessionId, + createdAt: event.createdAt, + itemId, + ...(event.threadId ? { threadId: event.threadId } : {}), + ...(event.turnId ? { turnId: event.turnId } : {}), + }; +} + function mapToolEvent(event: ProviderEvent): ProviderRuntimeEvent | undefined { if (event.method !== "item/started" && event.method !== "item/completed") { return undefined; @@ -267,6 +300,11 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray, orchestrationEngine: OrchestrationEngineShape, ) { - unsubscribeDomainEvents = await runtime.runPromise( - orchestrationEngine.subscribeToDomainEvents((event) => { - orchestrationDomainEventLogger?.write({ - observedAt: new Date().toISOString(), - event, - }); - const push: WsPush = { - type: "push", - channel: ORCHESTRATION_WS_CHANNELS.domainEvent, - 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; - } - } - logOutgoingPush(push, recipients); - }), + const subscriptionScope = await runtime.runPromise(Scope.make("sequential")); + await runtime.runPromise( + Effect.gen(function* () { + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => + Effect.sync(() => { + orchestrationDomainEventLogger?.write({ + observedAt: new Date().toISOString(), + event, + }); + const push: WsPush = { + type: "push", + channel: ORCHESTRATION_WS_CHANNELS.domainEvent, + 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; + } + } + logOutgoingPush(push, recipients); + }), + ), + ); + }).pipe(Scope.provide(subscriptionScope)), ); + + unsubscribeDomainEvents = () => { + runtime.runFork(Scope.close(subscriptionScope, Exit.void)); + }; } async function attachProviderSubscriptions( @@ -350,7 +359,9 @@ export function createServer(options: ServerOptions) { const processEvent = (event: ProviderRuntimeEvent) => Effect.gen(function* () { const snapshot = yield* orchestrationEngine.getSnapshot(); - const thread = snapshot.threads.find((entry) => entry.session?.sessionId === event.sessionId); + const thread = snapshot.threads.find( + (entry) => entry.session?.sessionId === event.sessionId, + ); if (!thread) return; const now = event.createdAt; @@ -393,9 +404,29 @@ export function createServer(options: ServerOptions) { }); } + if (event.type === "message.completed") { + const assistantMessageId = `assistant:${event.itemId}`; + if (event.turnId) { + rememberAssistantMessageId(event.sessionId, event.turnId, assistantMessageId); + } + yield* orchestrationEngine.dispatch({ + type: "message.send", + commandId: crypto.randomUUID(), + threadId: thread.id, + messageId: assistantMessageId, + role: "assistant", + text: "", + streaming: false, + createdAt: now, + }); + } + if (event.type === "turn.completed") { if (event.turnId) { - const assistantMessageIds = getAssistantMessageIdsForTurn(event.sessionId, event.turnId); + const assistantMessageIds = getAssistantMessageIdsForTurn( + event.sessionId, + event.turnId, + ); yield* Effect.forEach(assistantMessageIds, (assistantMessageId) => orchestrationEngine.dispatch({ type: "message.send", @@ -417,6 +448,30 @@ export function createServer(options: ServerOptions) { } if (event.type === "checkpoint.captured" && event.turnId) { + const files: ReadonlyArray = yield* providerService + .getCheckpointDiff({ + sessionId: event.sessionId, + fromTurnCount: Math.max(0, event.turnCount - 1), + toTurnCount: event.turnCount, + }) + .pipe( + Effect.map((result) => parseTurnDiffFilesFromUnifiedDiff(result.diff)), + Effect.catch((error) => + Effect.sync(() => { + logger.warn("failed to derive checkpoint file summary", { + sessionId: event.sessionId, + turnId: event.turnId, + turnCount: event.turnCount, + error: + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "unknown error", + }); + }).pipe(Effect.as([] as ReadonlyArray)), + ), + ); const assistantMessageId = getLatestAssistantMessageIdForTurn(event.sessionId, event.turnId) ?? `assistant:${event.turnId}`; @@ -427,7 +482,7 @@ export function createServer(options: ServerOptions) { turnId: event.turnId, completedAt: now, ...(event.status ? { status: event.status } : {}), - files: [], + files, assistantMessageId, checkpointTurnCount: event.turnCount, createdAt: now, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8c68aa3a82108..c308809c55710 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,12 +26,11 @@ import { useRef, useState, } from "react"; -import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; -import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; @@ -55,7 +54,6 @@ import { import { derivePendingApprovals, derivePhase, - deriveTurnDiffFilesFromUnifiedDiff, deriveTimelineEntries, type PendingApproval, deriveWorkLogEntries, @@ -70,7 +68,6 @@ import { DEFAULT_THREAD_TERMINAL_ID, MAX_THREAD_TERMINAL_COUNT, type ChatImageAttachment, - type TurnDiffFileChange, type TurnDiffSummary, } from "../types"; import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; @@ -186,33 +183,6 @@ interface ExpandedImagePreview { name: string; } -type TurnCheckpointFilesState = - | { - status: "pending" | "loading"; - files: TurnDiffFileChange[]; - errorMessage?: undefined; - } - | { - status: "error"; - files: TurnDiffFileChange[]; - errorMessage: string; - } - | { - status: "ready"; - files: TurnDiffFileChange[]; - errorMessage?: undefined; - }; - -function checkpointErrorMessage(error: unknown): string { - if (error instanceof Error && error.message.trim().length > 0) { - return error.message; - } - if (typeof error === "string" && error.trim().length > 0) { - return error; - } - return "Checkpoint diff is unavailable."; -} - type ComposerCommandItem = | { id: string; @@ -432,17 +402,21 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const phase = derivePhase(activeThread?.session ?? null); const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; - const activeTurnId = activeThread?.session?.activeTurnId ?? undefined; const nowIso = new Date(nowTick).toISOString(); const threadActivities = activeThread?.activities ?? []; const workLogEntries = useMemo( () => deriveWorkLogEntries(threadActivities, undefined), [threadActivities], ); - const latestTurnWorkEntries = useMemo( - () => deriveWorkLogEntries(threadActivities, activeTurnId ?? activeThread?.latestTurnId), - [activeThread?.latestTurnId, activeTurnId, threadActivities], - ); + const latestTurnHasToolActivity = useMemo(() => { + const latestTurnId = activeThread?.latestTurnId; + if (!latestTurnId) { + return false; + } + return threadActivities.some( + (activity) => activity.turnId === latestTurnId && activity.tone === "tool", + ); + }, [activeThread?.latestTurnId, threadActivities]); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], @@ -453,14 +427,6 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); - const assistantCompletionByItemId = useMemo(() => { - const completionByMessageId = new Map(); - for (const summary of turnDiffSummaries) { - if (!summary.assistantMessageId) continue; - completionByMessageId.set(summary.assistantMessageId, summary.completedAt); - } - return completionByMessageId; - }, [turnDiffSummaries]); const turnDiffSummaryByAssistantMessageId = useMemo(() => { const byMessageId = new Map(); for (const summary of turnDiffSummaries) { @@ -469,69 +435,6 @@ export default function ChatView({ threadId }: ChatViewProps) { } return byMessageId; }, [turnDiffSummaries]); - const checkpointDiffInputsByTurn = useMemo( - () => - turnDiffSummaries - .filter((summary) => summary.assistantMessageId) - .map((summary) => { - const checkpointTurnCount = - summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; - return { - turnId: summary.turnId, - checkpointTurnCount: - typeof checkpointTurnCount === "number" ? checkpointTurnCount : null, - }; - }), - [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], - ); - const checkpointDiffQueriesByTurn = useQueries({ - queries: checkpointDiffInputsByTurn.map((input) => - checkpointDiffQueryOptions(api, { - sessionId: activeSessionId, - threadRuntimeId: activeThreadRuntimeId, - fromTurnCount: - typeof input.checkpointTurnCount === "number" - ? Math.max(0, input.checkpointTurnCount - 1) - : null, - toTurnCount: input.checkpointTurnCount, - cacheScope: `turn:${input.turnId}`, - }), - ), - }); - const turnCheckpointFilesByTurnId = useMemo(() => { - const byTurnId = new Map(); - for (let index = 0; index < checkpointDiffInputsByTurn.length; index += 1) { - const input = checkpointDiffInputsByTurn[index]; - const query = checkpointDiffQueriesByTurn[index]; - if (!input || !query) continue; - - if (!activeSessionId || typeof input.checkpointTurnCount !== "number") { - byTurnId.set(input.turnId, { status: "pending", files: [] }); - continue; - } - - const diff = query.data?.diff; - if (typeof diff === "string") { - byTurnId.set(input.turnId, { - status: "ready", - files: deriveTurnDiffFilesFromUnifiedDiff(diff), - }); - continue; - } - - if (query.isError) { - byTurnId.set(input.turnId, { - status: "error", - files: [], - errorMessage: checkpointErrorMessage(query.error), - }); - continue; - } - - byTurnId.set(input.turnId, { status: "loading", files: [] }); - } - return byTurnId; - }, [activeSessionId, checkpointDiffInputsByTurn, checkpointDiffQueriesByTurn]); const revertTurnCountByUserMessageId = useMemo(() => { const byUserMessageId = new Map(); for (let index = 0; index < timelineEntries.length; index += 1) { @@ -568,9 +471,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const completionSummary = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; - if (!latestTurnWorkEntries.some((entry) => entry.tone === "tool")) { - return null; - } + if (!latestTurnHasToolActivity) return null; if ( typeof activeThread.latestTurnDurationMs === "number" && @@ -589,7 +490,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThread?.latestTurnStartedAt, activeThread?.latestTurnCompletedAt, activeThread?.latestTurnDurationMs, - latestTurnWorkEntries, + latestTurnHasToolActivity, ]); const completionDividerBeforeEntryId = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; @@ -2039,9 +1940,7 @@ export default function ChatView({ threadId }: ChatViewProps) { timelineEntries={timelineEntries} completionDividerBeforeEntryId={completionDividerBeforeEntryId} completionSummary={completionSummary} - assistantCompletionByItemId={assistantCompletionByItemId} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} - turnCheckpointFilesByTurnId={turnCheckpointFilesByTurnId} nowIso={nowIso} expandedWorkGroups={expandedWorkGroups} onToggleWorkGroup={onToggleWorkGroup} @@ -2505,9 +2404,7 @@ interface MessagesTimelineProps { timelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; completionSummary: string | null; - assistantCompletionByItemId: Map; turnDiffSummaryByAssistantMessageId: Map; - turnCheckpointFilesByTurnId: Map; nowIso: string; expandedWorkGroups: Record; onToggleWorkGroup: (groupId: string) => void; @@ -2542,9 +2439,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ timelineEntries, completionDividerBeforeEntryId, completionSummary, - assistantCompletionByItemId, turnDiffSummaryByAssistantMessageId, - turnCheckpointFilesByTurnId, nowIso, expandedWorkGroups, onToggleWorkGroup, @@ -2793,10 +2688,8 @@ const MessagesTimeline = memo(function MessagesTimeline({ row.message.id, ); if (!turnSummary) return null; - const checkpointFilesState = turnCheckpointFilesByTurnId.get( - turnSummary.turnId, - ); - const checkpointFiles = checkpointFilesState?.files ?? []; + const checkpointFiles = turnSummary.files; + if (checkpointFiles.length === 0) return null; const summaryStat = checkpointFiles.reduce( (acc, file) => { if ( @@ -2812,10 +2705,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ }, { additions: 0, deletions: 0 }, ); - const changedFileCountLabel = - checkpointFilesState?.status === "ready" - ? String(checkpointFiles.length) - : "..."; + const changedFileCountLabel = String(checkpointFiles.length); return (
@@ -2843,66 +2733,40 @@ const MessagesTimeline = memo(function MessagesTimeline({ View diff
- {!checkpointFilesState || - checkpointFilesState.status === "pending" ? ( -

- Waiting for checkpoint metadata... -

- ) : checkpointFilesState?.status === "loading" ? ( -

- Loading checkpoint diff... -

- ) : checkpointFilesState?.status === "error" ? ( -

- {checkpointFilesState.errorMessage} -

- ) : checkpointFiles.length > 0 ? ( -
- {checkpointFiles.map((file) => ( - - ))} -
- ) : ( -

- No changed files. -

- )} +
+ {checkpointFiles.map((file) => ( + + ))} +
); })()} @@ -2911,10 +2775,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ row.message.createdAt, row.message.streaming ? formatElapsed(row.message.createdAt, nowIso) - : formatElapsed( - row.message.createdAt, - assistantCompletionByItemId.get(row.message.id), - ), + : formatElapsed(row.message.createdAt, row.message.completedAt), )}

diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 4b86720910822..3a25f678132fe 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -104,14 +104,28 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const activeSessionId = activeThread?.session?.sessionId ?? null; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); + const orderedTurnDiffSummaries = useMemo( + () => + [...turnDiffSummaries].toSorted((left, right) => { + const leftTurnCount = + left.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[left.turnId] ?? 0; + const rightTurnCount = + right.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[right.turnId] ?? 0; + if (leftTurnCount !== rightTurnCount) { + return rightTurnCount - leftTurnCount; + } + return right.completedAt.localeCompare(left.completedAt); + }), + [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], + ); const selectedTurnId = diffSearch.diffTurnId ?? null; const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; const selectedTurn = selectedTurnId === null ? undefined - : (turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? - turnDiffSummaries[0]); + : (orderedTurnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? + orderedTurnDiffSummaries[0]); const selectedCheckpointTurnCount = selectedTurn && (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); @@ -126,7 +140,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { [selectedCheckpointTurnCount], ); const conversationCheckpointTurnCount = useMemo(() => { - const turnCounts = turnDiffSummaries + const turnCounts = orderedTurnDiffSummaries .map( (summary) => summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId], @@ -137,7 +151,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { } const latest = Math.max(...turnCounts); return latest > 0 ? latest : undefined; - }, [inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); + }, [inferredCheckpointTurnCountByTurnId, orderedTurnDiffSummaries]); const conversationCheckpointRange = useMemo( () => !selectedTurn && typeof conversationCheckpointTurnCount === "number" @@ -152,11 +166,11 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ? selectedCheckpointRange : conversationCheckpointRange; const conversationCacheScope = useMemo(() => { - if (selectedTurn || turnDiffSummaries.length === 0) { + if (selectedTurn || orderedTurnDiffSummaries.length === 0) { return null; } - return `conversation:${turnDiffSummaries.map((summary) => summary.turnId).join(",")}`; - }, [selectedTurn, turnDiffSummaries]); + return `conversation:${orderedTurnDiffSummaries.map((summary) => summary.turnId).join(",")}`; + }, [orderedTurnDiffSummaries, selectedTurn]); const activeCheckpointDiffQuery = useQuery( checkpointDiffQueryOptions(api, { sessionId: activeSessionId, @@ -181,6 +195,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { : null; const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; + const hasResolvedPatch = typeof selectedPatch === "string"; + const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; const renderablePatch = useMemo(() => getRenderablePatch(selectedPatch), [selectedPatch]); const renderableFiles = useMemo(() => { if (!renderablePatch || renderablePatch.kind !== "files") { @@ -275,7 +291,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { useEffect(() => { updateTurnStripScrollState(); - }, [turnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); + }, [orderedTurnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); useEffect(() => { const element = turnStripRef.current; @@ -358,7 +374,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
All turns
- {turnDiffSummaries.map((summary, index) => ( + {orderedTurnDiffSummaries.map((summary) => (
diff --git a/apps/web/src/lib/providerReactQuery.test.ts b/apps/web/src/lib/providerReactQuery.test.ts index 91edd765677fc..96e12f0a1cfe3 100644 --- a/apps/web/src/lib/providerReactQuery.test.ts +++ b/apps/web/src/lib/providerReactQuery.test.ts @@ -28,9 +28,11 @@ describe("providerQueryKeys.checkpointDiff", () => { describe("checkpointDiffQueryOptions", () => { it("forwards checkpoint range to the provider API", async () => { const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); + const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); const api = { orchestration: { getTurnDiff, + getFullThreadDiff, }, } as unknown as NativeApi; @@ -49,6 +51,34 @@ describe("checkpointDiffQueryOptions", () => { fromTurnCount: 3, toTurnCount: 4, }); + expect(getFullThreadDiff).not.toHaveBeenCalled(); + }); + + it("uses explicit full thread diff API when range starts from zero", async () => { + const getTurnDiff = vi.fn().mockResolvedValue({ diff: "patch" }); + const getFullThreadDiff = vi.fn().mockResolvedValue({ diff: "patch" }); + const api = { + orchestration: { + getTurnDiff, + getFullThreadDiff, + }, + } as unknown as NativeApi; + + const options = checkpointDiffQueryOptions(api, { + threadId: "thread-id", + fromTurnCount: 0, + toTurnCount: 2, + cacheScope: "thread:all", + }); + + const queryClient = new QueryClient(); + await queryClient.fetchQuery(options); + + expect(getFullThreadDiff).toHaveBeenCalledWith({ + threadId: "thread-id", + toTurnCount: 2, + }); + expect(getTurnDiff).not.toHaveBeenCalled(); }); it("retries checkpoint-not-ready errors longer than generic failures", () => { diff --git a/apps/web/src/lib/providerReactQuery.ts b/apps/web/src/lib/providerReactQuery.ts index bb69cc9e56a45..a457ca4e8c918 100644 --- a/apps/web/src/lib/providerReactQuery.ts +++ b/apps/web/src/lib/providerReactQuery.ts @@ -64,6 +64,12 @@ export function checkpointDiffQueryOptions( if (typeof fromTurnCount !== "number" || typeof toTurnCount !== "number") { throw new Error("Checkpoint diff range is invalid."); } + if (fromTurnCount === 0) { + return api.orchestration.getFullThreadDiff({ + threadId: asThreadId(input.threadId), + toTurnCount, + }); + } return api.orchestration.getTurnDiff({ threadId: asThreadId(input.threadId), fromTurnCount, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index eb9d06cbc38d7..eb2de5aca0472 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -3,18 +3,16 @@ import { createRootRouteWithContext, type ErrorComponentProps, } from "@tanstack/react-router"; -import { useEffect, useRef } from "react"; -import { QueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { QueryClient, useQueryClient } 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 { DEFAULT_MODEL } from "../model-logic"; import { useStore } from "../store"; -import { newCommandId, newProjectId, newThreadId } from "../lib/orchestrationIds"; import { onServerWelcome } from "../wsNativeApi"; +import { providerQueryKeys } from "../lib/providerReactQuery"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -42,7 +40,6 @@ function RootRouteView() { - @@ -124,6 +121,7 @@ function errorDetails(error: unknown): string { function EventRouter() { const api = useNativeApi(); const { dispatch } = useStore(); + const queryClient = useQueryClient(); useEffect(() => { if (!api) return; @@ -166,6 +164,9 @@ function EventRouter() { return; } latestSequence = event.sequence; + if (event.type === "thread.turn-diff-completed" || event.type === "thread.reverted") { + void queryClient.invalidateQueries({ queryKey: providerQueryKeys.all }); + } void syncSnapshot(); }); const unsubWelcome = onServerWelcome(() => { @@ -176,63 +177,7 @@ function EventRouter() { unsubDomainEvent(); unsubWelcome(); }; - }, [api, dispatch]); - - return null; -} - -function AutoProjectBootstrap() { - const api = useNativeApi(); - const { state, dispatch } = useStore(); - const bootstrappedRef = useRef(false); - - useEffect(() => { - if (!api) return; - // Browser mode auto-adds the current cwd project via server welcome. - // Desktop skips this because projects are already loaded from the repository. - 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; - const now = new Date().toISOString(); - const projectId = newProjectId(); - const threadId = newThreadId(); - void api.orchestration - .dispatchCommand({ - type: "project.create", - commandId: newCommandId(), - projectId, - title: payload.projectName, - workspaceRoot: payload.cwd, - defaultModel: DEFAULT_MODEL, - createdAt: now, - }) - .then(() => - api.orchestration.dispatchCommand({ - type: "thread.create", - commandId: newCommandId(), - threadId, - projectId, - title: "New thread", - model: DEFAULT_MODEL, - branch: null, - worktreePath: null, - createdAt: now, - }), - ) - .catch(() => undefined); - }); - }, [api, state.projects, dispatch]); + }, [api, dispatch, queryClient]); return null; } diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts new file mode 100644 index 0000000000000..1197faa6ea191 --- /dev/null +++ b/apps/web/src/wsNativeApi.test.ts @@ -0,0 +1,53 @@ +import { CommandId, ORCHESTRATION_WS_METHODS, ProjectId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vitest"; + +const requestMock = vi.fn<(...args: Array) => Promise>(); +const subscribeMock = vi.fn<(...args: Array) => () => void>(() => () => undefined); + +vi.mock("./wsTransport", () => { + return { + WsTransport: class MockWsTransport { + request = requestMock; + subscribe = subscribeMock; + }, + }; +}); + +describe("wsNativeApi", () => { + it("wraps orchestration dispatch commands in the command envelope", async () => { + requestMock.mockResolvedValue(undefined); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + const command = { + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-1"), + projectId: ProjectId.makeUnsafe("project-1"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModel: "gpt-5-codex", + createdAt: "2026-02-24T00:00:00.000Z", + } as const; + await api.orchestration.dispatchCommand(command); + + expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.dispatchCommand, { + command, + }); + }); + + it("forwards full-thread diff requests to the orchestration websocket method", async () => { + requestMock.mockResolvedValue({ diff: "patch" }); + const { createWsNativeApi } = await import("./wsNativeApi"); + + const api = createWsNativeApi(); + await api.orchestration.getFullThreadDiff({ + threadId: ThreadId.makeUnsafe("thread-1"), + toTurnCount: 1, + }); + + expect(requestMock).toHaveBeenCalledWith(ORCHESTRATION_WS_METHODS.getFullThreadDiff, { + threadId: "thread-1", + toTurnCount: 1, + }); + }); +}); diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 88a11e1424077..88ab2f3089b37 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -128,8 +128,10 @@ export function createWsNativeApi(): NativeApi { orchestration: { getSnapshot: () => transport.request(ORCHESTRATION_WS_METHODS.getSnapshot), dispatchCommand: (command) => - transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, command), + transport.request(ORCHESTRATION_WS_METHODS.dispatchCommand, { command }), getTurnDiff: (input) => transport.request(ORCHESTRATION_WS_METHODS.getTurnDiff, input), + getFullThreadDiff: (input) => + transport.request(ORCHESTRATION_WS_METHODS.getFullThreadDiff, input), replayEvents: (fromSequenceExclusive) => transport.request(ORCHESTRATION_WS_METHODS.replayEvents, { fromSequenceExclusive }), onDomainEvent: (callback) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 154b7adab4fea..ce1c0305a54a9 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -28,6 +28,8 @@ import type { import type { ServerUpsertKeybindingInput, ServerUpsertKeybindingResult } from "./server"; import type { ClientOrchestrationCommand, + OrchestrationGetFullThreadDiffInput, + OrchestrationGetFullThreadDiffResult, OrchestrationGetTurnDiffInput, OrchestrationGetTurnDiffResult, OrchestrationEvent, @@ -95,6 +97,9 @@ export interface NativeApi { getSnapshot: () => Promise; dispatchCommand: (command: ClientOrchestrationCommand) => Promise<{ sequence: number }>; getTurnDiff: (input: OrchestrationGetTurnDiffInput) => Promise; + getFullThreadDiff: ( + input: OrchestrationGetFullThreadDiffInput, + ) => Promise; replayEvents: (fromSequenceExclusive: number) => Promise; onDomainEvent: (callback: (event: OrchestrationEvent) => void) => () => void; }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b8012890a999f..8a84021798506 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -4,6 +4,7 @@ export const ORCHESTRATION_WS_METHODS = { getSnapshot: "orchestration.getSnapshot", dispatchCommand: "orchestration.dispatchCommand", getTurnDiff: "orchestration.getTurnDiff", + getFullThreadDiff: "orchestration.getFullThreadDiff", replayEvents: "orchestration.replayEvents", } as const; @@ -837,6 +838,15 @@ export type OrchestrationGetTurnDiffInput = typeof OrchestrationGetTurnDiffInput export const OrchestrationGetTurnDiffResult = ThreadTurnDiff; export type OrchestrationGetTurnDiffResult = typeof OrchestrationGetTurnDiffResult.Type; +export const OrchestrationGetFullThreadDiffInput = Schema.Struct({ + threadId: ThreadId, + toTurnCount: NonNegativeInt, +}); +export type OrchestrationGetFullThreadDiffInput = typeof OrchestrationGetFullThreadDiffInput.Type; + +export const OrchestrationGetFullThreadDiffResult = ThreadTurnDiff; +export type OrchestrationGetFullThreadDiffResult = typeof OrchestrationGetFullThreadDiffResult.Type; + export const OrchestrationReplayEventsInput = Schema.Struct({ fromSequenceExclusive: NonNegativeInt, }); @@ -858,6 +868,10 @@ export const OrchestrationRpcSchemas = { input: OrchestrationGetTurnDiffInput, output: OrchestrationGetTurnDiffResult, }, + getFullThreadDiff: { + input: OrchestrationGetFullThreadDiffInput, + output: OrchestrationGetFullThreadDiffResult, + }, replayEvents: { input: OrchestrationReplayEventsInput, output: OrchestrationReplayEventsResult, diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 5f5d26b866ff2..57dbf8899f220 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -2,6 +2,7 @@ import { Schema } from "effect"; import { ClientOrchestrationCommand, + OrchestrationGetFullThreadDiffInput, ORCHESTRATION_WS_METHODS, OrchestrationGetSnapshotInput, OrchestrationGetTurnDiffInput, @@ -80,6 +81,7 @@ export const WebSocketRequestBody = Schema.TaggedUnion({ [ORCHESTRATION_WS_METHODS.dispatchCommand]: { command: ClientOrchestrationCommand }, [ORCHESTRATION_WS_METHODS.getSnapshot]: OrchestrationGetSnapshotInput.fields, [ORCHESTRATION_WS_METHODS.getTurnDiff]: OrchestrationGetTurnDiffInput.fields, + [ORCHESTRATION_WS_METHODS.getFullThreadDiff]: OrchestrationGetFullThreadDiffInput.fields, [ORCHESTRATION_WS_METHODS.replayEvents]: OrchestrationReplayEventsInput.fields, // Project Search From 77b14483d63793056296e227e6d25cce612b46d1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 11:35:33 -0800 Subject: [PATCH 34/98] terminal plan v1 --- .plans/terminal-runtime-state-plan.md | 154 ++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 .plans/terminal-runtime-state-plan.md diff --git a/.plans/terminal-runtime-state-plan.md b/.plans/terminal-runtime-state-plan.md new file mode 100644 index 0000000000000..0b90488b06920 --- /dev/null +++ b/.plans/terminal-runtime-state-plan.md @@ -0,0 +1,154 @@ +# Terminal Runtime And State Plan + +## Summary + +Terminals appear to "hang" because PTY processes are exiting immediately at startup in the current runtime. + +Repro in this branch: + +- `node` + `node-pty`: interactive shell stays alive and accepts writes. +- `bun` + `node-pty`: shell exits immediately (`exitCode: 0`, `signal: 1`), so the UI opens a terminal pane but no live process remains. + +This means the current breakage is primarily a runtime compatibility issue, plus missing reconciliation between desired terminal state and observed process state. + +## Root Cause (Current) + +1. Server dev runtime uses Bun (`apps/server/package.json` `dev` script). +2. Terminal PTY implementation depends on `node-pty`. +3. Under Bun, spawned PTY shells exit immediately. +4. Frontend still mounts terminals, so UX looks like startup hang/blank terminal. + +## Design Goals + +1. Do not store full terminal output in SQLite. +2. Preserve terminal topology and intent across reconnects/restarts: + - open/split/new/close + - active terminal/group + - cwd/env profile +3. Keep runtime truth accurate even when processes change outside the app. +4. Support deterministic recovery and idempotent command handling. + +## Proposed Architecture + +### 1) Runtime Boundary + +- Run terminal PTY host on Node runtime only. +- If main server remains Bun, isolate PTY behind a Node sidecar process (JSON-RPC/IPC). +- Treat PTY host as volatile runtime, not source of durable truth. + +### 2) Event-Sourced In SQLite (Control Plane Only) + +Persist only control/state events, not output payloads. + +Recommended event families: + +- `terminal.layout.updated` + - terminal ids, split groups, active terminal id/group id +- `terminal.open.requested` + - threadId, terminalId, cwd, envHash/envProfileId, cols, rows +- `terminal.close.requested` + - terminalId, deleteHistory +- `terminal.resize.requested` + - cols, rows +- `terminal.clear.requested` +- `terminal.input.sent` + - input metadata only (`byteLength`, optional hash, optional commandBoundary flag), no raw payload +- `terminal.runtime.started` + - pid, startedAt, runtime instance id +- `terminal.runtime.exited` + - pid, exitCode, exitSignal, observedAt, reason (`normal|killed|external|crash|unknown`) +- `terminal.runtime.error` + - normalized error code/message +- `terminal.output.segment.recorded` + - segment file id + byte offsets only + +Use idempotency keys/command ids for each client command so reconnect retries are safe. + +### 3) Output Storage In Log Files (Data Plane) + +- Store raw output only in append-only files (per thread+terminal). +- Rotate into segments (size-based and/or time-based). +- Keep lightweight SQLite index rows: + - `segment_id`, `thread_id`, `terminal_id`, `start_offset`, `end_offset`, `created_at`, checksum. +- Optional retention policy (max bytes / days / segment count per terminal). + +This keeps SQLite small while preserving replay capability. + +### 4) Reconciliation Loop (Runtime Truth) + +Do not treat client dispatch as sole truth. + +Maintain dual state: + +- Desired state: from event stream/projection. +- Observed runtime state: from PTY host watchers (process exit, health checks, poll). + +On mismatch, emit runtime observation events: + +- process missing but desired running -> `terminal.runtime.exited` with reason `external|unknown` +- process exists but projection says closed -> terminate process and emit correction event + +This makes out-of-band kills/restarts explicit and recoverable. + +### 5) Startup/Recovery Flow + +1. Rebuild terminal projection from SQLite control events. +2. Load terminal layout/topology immediately for UI. +3. Reconcile each desired running terminal with runtime: + - if process alive: attach stream + tail recent output from logs + - if process dead: mark exited; auto-restart only if policy says so +4. Resume incremental output from last persisted offset. + +## Data Model Additions + +Add dedicated tables (or equivalent repos): + +- `terminal_control_events` (or reuse orchestration_events with terminal aggregate kind) +- `terminal_output_segments` (index only, no raw output) +- `terminal_runtime_snapshot` (latest observed runtime state cache) + +Avoid embedding output blobs in projection snapshots. + +## Implementation Plan + +### Phase 0: Stop the bleeding + +1. Run PTY on Node runtime in all dev/prod paths (or ship Node sidecar now). +2. Add startup diagnostics: runtime, PTY spawn result, immediate-exit telemetry. +3. Surface clear UI error when PTY host unavailable. + +### Phase 1: Durable control events + +1. Introduce terminal aggregate + commands/events. +2. Persist layout and terminal lifecycle intent events. +3. Build projection for terminal UI state from events. + +### Phase 2: File-backed output + index + +1. Implement segmented append-only output logger. +2. Persist only segment metadata/offsets in SQLite. +3. Update terminal history restore to tail from segments (bounded bytes/lines). + +### Phase 3: Runtime reconciliation + +1. Add process observer + periodic verifier. +2. Emit runtime observation events on drift. +3. Add recovery tests for out-of-band process termination. + +### Phase 4: Hardening + +1. Backpressure and truncation policies for high-volume output. +2. Crash-safe fsync/flush strategy for segment writes. +3. Retention and compaction jobs. + +## Testing Strategy + +Backend tests should cover: + +1. Rebuild projection from control events (open/split/close/resize/active terminal). +2. Runtime reconciliation when process is killed externally. +3. Recovery after restart with existing segment files. +4. Idempotent command handling on duplicate dispatches. +5. Segment index consistency and retention behavior. + +Use real runtime-layer tests for terminal adapter behavior where possible; only mock external process/service boundaries. From b2f29834553747157b9eba6a5fa9de1b84693dce Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 13:46:30 -0800 Subject: [PATCH 35/98] effect cli --- apps/server/src/codexAppServerManager.test.ts | 15 +- apps/server/src/index.ts | 194 ++---------- apps/server/src/keybindings.ts | 2 +- apps/server/src/main.test.ts | 131 ++++++++ apps/server/src/main.ts | 284 ++++++++++++++++++ apps/server/src/open.ts | 93 ++++++ apps/server/src/orchestration/Layer.test.ts | 131 ++++---- .../Layers/CheckpointReactor.test.ts | 4 +- .../Layers/ProjectionSnapshotQuery.test.ts | 49 ++- .../Layers/ProviderCommandReactor.test.ts | 10 +- .../Layers/ProviderRuntimeIngestion.test.ts | 2 +- .../orchestration/commandInvariants.test.ts | 67 +++-- .../decider.projectScripts.test.ts | 59 ++-- .../src/orchestration/projector.test.ts | 55 ++-- .../src/provider/Layers/CodexAdapter.test.ts | 80 ++--- .../provider/Layers/ProviderService.test.ts | 58 ++-- apps/server/src/wsServer.test.ts | 97 ++++-- apps/server/src/wsServer.ts | 76 ++--- apps/server/tsconfig.json | 3 +- packages/contracts/src/git.ts | 2 +- packages/contracts/src/keybindings.test.ts | 4 +- packages/contracts/src/keybindings.ts | 13 +- scripts/dev-runner.mjs | 9 + 23 files changed, 952 insertions(+), 486 deletions(-) create mode 100644 apps/server/src/main.test.ts create mode 100644 apps/server/src/main.ts create mode 100644 apps/server/src/open.ts diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index afb23d48a768f..f0af874337229 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { ProviderSessionId } from "@t3tools/contracts"; import { CodexAppServerManager, @@ -7,6 +8,8 @@ import { normalizeCodexModelSlug, } from "./codexAppServerManager"; +const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); + function createSendTurnHarness() { const manager = new CodexAppServerManager(); const context = { @@ -183,7 +186,7 @@ describe("sendTurn", () => { createSendTurnHarness(); const result = await manager.sendTurn({ - sessionId: "sess_1", + sessionId: asSessionId("sess_1"), input: "Inspect this image", attachments: [ { @@ -229,7 +232,7 @@ describe("sendTurn", () => { const { manager, context, sendRequest } = createSendTurnHarness(); await manager.sendTurn({ - sessionId: "sess_1", + sessionId: asSessionId("sess_1"), attachments: [ { type: "image", @@ -257,7 +260,7 @@ describe("sendTurn", () => { await expect( manager.sendTurn({ - sessionId: "sess_1", + sessionId: asSessionId("sess_1"), }), ).rejects.toThrow("Turn input must include text or attachments."); }); @@ -278,7 +281,7 @@ describe("thread checkpoint control", () => { }, }); - const result = await manager.readThread("sess_1"); + const result = await manager.readThread(asSessionId("sess_1")); expect(requireSession).toHaveBeenCalledWith("sess_1"); expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { @@ -308,7 +311,7 @@ describe("thread checkpoint control", () => { ], }); - const result = await manager.readThread("sess_1"); + const result = await manager.readThread(asSessionId("sess_1")); expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { threadId: "thread_1", @@ -334,7 +337,7 @@ describe("thread checkpoint control", () => { }, }); - const result = await manager.rollbackThread("sess_1", 2); + const result = await manager.rollbackThread(asSessionId("sess_1"), 2); expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", { threadId: "thread_1", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index c2ce1b3449454..f23154e7235e2 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,177 +1,17 @@ -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { fixPath } from "./fixPath"; -import { createLogger } from "./logger"; -import { createServer } from "./wsServer"; - -fixPath(); - -const DEFAULT_PORT = 3773; -// Dummy comment for a no-op change request. -// Additional dummy comment requested in chat. -const cwd = process.cwd(); -const logger = createLogger("server"); - -type RuntimeMode = "web" | "desktop"; - -function parseBooleanEnv(value: string | undefined): boolean | undefined { - if (value === undefined) return undefined; - const normalized = value.trim().toLowerCase(); - if (["1", "true", "yes", "on"].includes(normalized)) return true; - if (["0", "false", "no", "off"].includes(normalized)) return false; - return undefined; -} - -function parsePort(value: string | undefined): number | undefined { - if (!value) return undefined; - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { - throw new Error(`Invalid T3CODE_PORT: ${value}`); - } - return parsed; -} - -function expandHomePath(input: string): string { - if (input === "~") return os.homedir(); - if (input.startsWith("~/")) { - return path.join(os.homedir(), input.slice(2)); - } - return input; -} - -function resolveStateDir(raw: string | undefined): string { - if (!raw || raw.trim().length === 0) { - return path.join(os.homedir(), ".t3", "userdata"); - } - return path.resolve(expandHomePath(raw.trim())); -} - -async function findAvailablePort(preferred: number): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer(); - server.listen(preferred, () => { - server.close(() => resolve(preferred)); - }); - server.on("error", () => { - // Preferred port busy — let the OS pick one - const fallback = net.createServer(); - fallback.listen(0, () => { - const addr = fallback.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - fallback.close(() => { - if (port > 0) resolve(port); - else reject(new Error("Could not find an available port.")); - }); - }); - fallback.on("error", reject); - }); - }); -} - -function resolveStaticDir(): string | undefined { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - - // Check for bundled client (npm publish / npx scenario): - // dist/client/ lives alongside dist/index.js - const bundledClient = path.resolve(__dirname, "client"); - try { - const stat = fs.statSync(path.join(bundledClient, "index.html")); - if (stat.isFile()) return bundledClient; - } catch { - // Not bundled — check monorepo layout - } - - // Monorepo layout: apps/server/dist/index.js → apps/web/dist/ - const monorepoClient = path.resolve(__dirname, "../../web/dist"); - try { - const stat = fs.statSync(path.join(monorepoClient, "index.html")); - if (stat.isFile()) return monorepoClient; - } catch { - // Not found — probably dev mode, Vite will serve - } - - return undefined; -} - -async function main() { - const mode: RuntimeMode = process.env.T3CODE_MODE === "desktop" ? "desktop" : "web"; - const requestedPort = parsePort(process.env.T3CODE_PORT); - const port = - requestedPort ?? (mode === "desktop" ? DEFAULT_PORT : await findAvailablePort(DEFAULT_PORT)); - const stateDir = resolveStateDir(process.env.T3CODE_STATE_DIR); - const devUrl = process.env.VITE_DEV_SERVER_URL; - const noBrowser = parseBooleanEnv(process.env.T3CODE_NO_BROWSER) ?? mode === "desktop"; - const authToken = process.env.T3CODE_AUTH_TOKEN; - const staticDir = devUrl ? undefined : resolveStaticDir(); - - if (!devUrl && !staticDir) { - logger.warn("web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", { - hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.", - }); - } - - const server = createServer({ - port, - host: mode === "desktop" ? "127.0.0.1" : undefined, - cwd, - autoBootstrapProjectFromCwd: mode === "web", - stateDir, - staticDir, - devUrl, - authToken, - }); - await server.start(); - - const url = `http://localhost:${port}`; - logger.info("T3 Code running", { - url, - cwd, - mode, - stateDir, - authEnabled: Boolean(authToken), - }); - - // Open browser (dynamic import because `open` is ESM-only) - if (!noBrowser) { - try { - const open = await import("open"); - const target = devUrl ?? url; - await open.default(target); - } catch { - logger.info("browser auto-open unavailable", { - hint: `Open ${devUrl ?? url} in your browser.`, - }); - } - } - - // Graceful shutdown - let shuttingDown = false; - async function shutdown() { - if (shuttingDown) return; - shuttingDown = true; - logger.info("shutting down"); - try { - await server.stop(); - process.exit(0); - } catch (error) { - logger.error("failed to stop server cleanly", { error }); - process.exit(1); - } - } - - process.on("SIGINT", () => { - void shutdown(); - }); - process.on("SIGTERM", () => { - void shutdown(); - }); -} - -main().catch((err) => { - logger.error("failed to start T3 Code", { error: err }); - process.exit(1); -}); +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { CliConfigLive, makeCliCommand } from "./main"; +import { OpenLive } from "./open"; +import { Command } from "effect/unstable/cli"; +import { version } from "../package.json" with { type: "json" }; +import { ServerLive } from "./wsServer"; + +const RuntimeLayer = Layer.mergeAll(CliConfigLive, ServerLive, OpenLive, NodeServices.layer); + +Command.run(makeCliCommand(), { version }).pipe( + Effect.provide(RuntimeLayer), + NodeRuntime.runMain, +); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 428662a7498cd..70f43dfeb2777 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -326,7 +326,7 @@ function loadCustomKeybindingsConfig( const sanitized: Mutable = []; let invalidEntries = 0; for (const entry of parsed) { - const result = Schema.decodeUnknownExit(KeybindingRule)(entry); + const result = Schema.decodeExit(KeybindingRule)(entry); if (result._tag === "Success") { if (!compileResolvedKeybindingRule(result.value)) { invalidEntries += 1; diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts new file mode 100644 index 0000000000000..e98612e848995 --- /dev/null +++ b/apps/server/src/main.test.ts @@ -0,0 +1,131 @@ +import { NodeServices } from "@effect/platform-node"; +import { assert, it, vi } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Command from "effect/unstable/cli/Command"; +import { beforeEach } from "vitest"; + +import { CliConfig, makeCliCommand, type CliConfigShape } from "./main"; +import { Open, type OpenShape } from "./open"; +import { Server, type ServerShape, type ServerOptions } from "./wsServer"; + +const start = vi.fn(async () => undefined); +const stop = vi.fn(async () => undefined); +const createServer = vi.fn((_: ServerOptions) => ({ + start, + stop, + httpServer: undefined as never, +})); +const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred)); + +const testLayer = Layer.mergeAll( + Layer.succeed(CliConfig, { + cwd: "/tmp/t3-test-workspace", + fixPath: Effect.void, + findAvailablePort, + resolveStaticDir: () => undefined, + } satisfies CliConfigShape), + Layer.succeed(Server, { + createServer, + } satisfies ServerShape), + Layer.succeed(Open, { + openBrowser: (_target: string) => Effect.void, + openInEditor: () => Effect.void, + } satisfies OpenShape), + NodeServices.layer, +); + +const runCli = ( + args: ReadonlyArray, + env: Record = { T3CODE_NO_BROWSER: "true" }, +) => + Command.runWith(makeCliCommand(), { version: "0.0.0-test" })(args).pipe( + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), + ); + +beforeEach(() => { + vi.clearAllMocks(); + start.mockImplementation(async () => undefined); + stop.mockImplementation(async () => undefined); + createServer.mockImplementation((_: ServerOptions) => ({ + start, + stop, + httpServer: undefined as never, + })); + findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred)); +}); + +it.layer(testLayer)("server cli", (it) => { + it.effect("parses --port and --token and wires scoped start/stop", () => + Effect.gen(function* () { + yield* runCli(["--port", "4010", "--token", "secret"]); + + assert.equal(createServer.mock.calls.length, 1); + const options = createServer.mock.calls[0]?.[0]; + assert.equal(options?.port, 4010); + assert.equal(options?.authToken, "secret"); + assert.equal(start.mock.calls.length, 1); + assert.equal(stop.mock.calls.length, 1); + }), + ); + + it.effect("uses env fallbacks when flags are not provided", () => + Effect.gen(function* () { + yield* runCli([], { + T3CODE_NO_BROWSER: "true", + T3CODE_PORT: "4999", + T3CODE_AUTH_TOKEN: "env-token", + }); + + assert.equal(createServer.mock.calls.length, 1); + const options = createServer.mock.calls[0]?.[0]; + assert.equal(options?.port, 4999); + assert.equal(options?.authToken, "env-token"); + assert.equal(findAvailablePort.mock.calls.length, 0); + }), + ); + + it.effect("uses dynamic port discovery in web mode when port is omitted", () => + Effect.gen(function* () { + findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444)); + yield* runCli([]); + + assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); + assert.equal(createServer.mock.calls.length, 1); + const options = createServer.mock.calls[0]?.[0]; + assert.equal(options?.port, 5444); + assert.equal(options?.host, undefined); + }), + ); + + it.effect("uses fixed localhost defaults in desktop mode", () => + Effect.gen(function* () { + yield* runCli([], { + T3CODE_MODE: "desktop", + T3CODE_NO_BROWSER: "true", + }); + + assert.equal(findAvailablePort.mock.calls.length, 0); + assert.equal(createServer.mock.calls.length, 1); + const options = createServer.mock.calls[0]?.[0]; + assert.equal(options?.port, 3773); + assert.equal(options?.host, "127.0.0.1"); + }), + ); + + it.effect("fails for out-of-range --port values", () => + Effect.gen(function* () { + const result = yield* Effect.result(runCli(["--port", "70000"])); + + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal( + result.failure.message, + "Invalid --port: 70000. Expected an integer between 1 and 65535.", + ); + } + assert.equal(createServer.mock.calls.length, 0); + }), + ); +}); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts new file mode 100644 index 0000000000000..cb74206a1be15 --- /dev/null +++ b/apps/server/src/main.ts @@ -0,0 +1,284 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Config from "effect/Config"; +import { ServiceMap } from "effect"; +import * as Command from "effect/unstable/cli/Command"; +import * as Flag from "effect/unstable/cli/Flag"; + +import { fixPath } from "./fixPath"; +import { Open } from "./open"; +import { Server } from "./wsServer"; + +const DEFAULT_PORT = 3773; + +type RuntimeMode = "web" | "desktop"; + +export class StartupError extends Data.TaggedError("StartupError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +interface CliInput { + readonly port: Option.Option; + readonly token: Option.Option; +} + +interface ResolvedCliConfig { + readonly port: number; + readonly mode: RuntimeMode; + readonly cwd: string; + readonly stateDir: string; + readonly devUrl: URL | undefined; + readonly noBrowser: boolean; + readonly authToken: string | undefined; + readonly staticDir: string | undefined; +} + +export interface CliConfigShape { + readonly cwd: string; + readonly fixPath: Effect.Effect; + readonly findAvailablePort: (preferred: number) => Effect.Effect; + readonly resolveStaticDir: () => string | undefined; +} + +export class CliConfig extends ServiceMap.Service()( + "server/CliConfig", +) {} + +const CliEnvConfig = Config.all({ + mode: Config.string("T3CODE_MODE").pipe( + Config.option, + Config.map( + Option.match({ + onNone: () => "web" as const, + onSome: (value) => (value === "desktop" ? "desktop" : "web"), + }), + ), + ), + port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), + stateDir: Config.string("T3CODE_STATE_DIR").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), + noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + authToken: Config.string("T3CODE_AUTH_TOKEN").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), +}); + +function assertValidPort(value: number, source: string): Effect.Effect { + if (Number.isInteger(value) && value >= 1 && value <= 65535) { + return Effect.succeed(value); + } + return Effect.fail( + new StartupError({ + message: `Invalid ${source}: ${value}. Expected an integer between 1 and 65535.`, + }), + ); +} + +function expandHomePath(input: string): string { + if (input === "~") return os.homedir(); + if (input.startsWith("~/")) { + return path.join(os.homedir(), input.slice(2)); + } + return input; +} + +function resolveStateDir(raw: string | undefined): string { + if (!raw || raw.trim().length === 0) { + return path.join(os.homedir(), ".t3", "userdata"); + } + return path.resolve(expandHomePath(raw.trim())); +} + +async function findAvailablePort(preferred: number): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(preferred, () => { + server.close(() => resolve(preferred)); + }); + server.on("error", () => { + const fallback = net.createServer(); + fallback.listen(0, () => { + const addr = fallback.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + fallback.close(() => { + if (port > 0) resolve(port); + else reject(new Error("Could not find an available port.")); + }); + }); + fallback.on("error", reject); + }); + }); +} + +function resolveStaticDir(): string | undefined { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + + const bundledClient = path.resolve(__dirname, "client"); + try { + const stat = fs.statSync(path.join(bundledClient, "index.html")); + if (stat.isFile()) return bundledClient; + } catch { + // Not bundled — check monorepo layout. + } + + const monorepoClient = path.resolve(__dirname, "../../web/dist"); + try { + const stat = fs.statSync(path.join(monorepoClient, "index.html")); + if (stat.isFile()) return monorepoClient; + } catch { + // Not found — probably dev mode. + } + + return undefined; +} + +function resolveCliConfig( + input: CliInput, +): Effect.Effect { + return Effect.gen(function* () { + const cliConfig = yield* CliConfig; + const env = yield* CliEnvConfig.asEffect().pipe( + Effect.mapError( + (cause) => + new StartupError({ + message: "Failed to read environment configuration", + cause, + }), + ), + ); + const mode: RuntimeMode = env.mode === "desktop" ? "desktop" : "web"; + + const cliPort = Option.getOrUndefined(input.port); + const requestedPort = + cliPort === undefined ? env.port : yield* assertValidPort(cliPort, "--port"); + + const port = + requestedPort ?? + (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT)); + const stateDir = resolveStateDir(env.stateDir); + const devUrl = env.devUrl; + const noBrowser = env.noBrowser ?? mode === "desktop"; + const authToken = Option.getOrUndefined(input.token) ?? env.authToken; + const staticDir = devUrl ? undefined : cliConfig.resolveStaticDir(); + + return { + mode, + port, + stateDir, + devUrl, + noBrowser, + authToken, + staticDir, + cwd: cliConfig.cwd, + }; + }); +} + +const makeServerProgram = Effect.fn(function* (input: CliInput) { + const cliConfig = yield* CliConfig; + const serverDeps = yield* Server; + const openDeps = yield* Open; + yield* cliConfig.fixPath; + const config = yield* resolveCliConfig(input); + + if (!config.devUrl && !config.staticDir) { + yield* Effect.logWarning("web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", { + hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.", + }); + } + + yield* Effect.acquireRelease( + Effect.tryPromise({ + try: async () => { + const server = serverDeps.createServer({ + port: config.port, + host: config.mode === "desktop" ? "127.0.0.1" : undefined, + cwd: config.cwd, + autoBootstrapProjectFromCwd: config.mode === "web", + stateDir: config.stateDir, + staticDir: config.staticDir, + devUrl: config.devUrl?.href, + authToken: config.authToken, + }); + await server.start(); + return server; + }, + catch: (cause) => new StartupError({ message: "Failed to start server", cause }), + }), + (server) => + Effect.tryPromise({ + try: () => server.stop(), + catch: (cause) => new StartupError({ message: "Failed to stop server", cause }), + }).pipe(Effect.catch((error) => Effect.logError("failed to stop server cleanly", { error }))), + ); + + const url = `http://localhost:${config.port}`; + yield* Effect.logInfo("T3 Code running", { + url, + cwd: config.cwd, + mode: config.mode, + stateDir: config.stateDir, + authEnabled: Boolean(config.authToken), + }); + + if (!config.noBrowser) { + const target = config.devUrl ?? url; + yield* openDeps.openBrowser(target.toString()).pipe( + Effect.catch(() => + Effect.logInfo("browser auto-open unavailable", { + hint: `Open ${target} in your browser.`, + }), + ), + ); + } +}, Effect.scoped); + +export function makeCliCommand() { + const portFlag = Flag.integer("port").pipe( + Flag.withDescription("Port for the HTTP/WebSocket server."), + Flag.optional, + ); + const tokenFlag = Flag.string("token").pipe( + Flag.withDescription("Auth token required for WebSocket connections."), + Flag.optional, + ); + + return Command.make("t3", { + port: portFlag, + token: tokenFlag, + }).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withHandler((input) => makeServerProgram(input)), + ); +} + +export const CliConfigLive = Layer.succeed(CliConfig, { + cwd: process.cwd(), + fixPath: Effect.sync(fixPath), + findAvailablePort: (preferred) => + Effect.tryPromise({ + try: () => findAvailablePort(preferred), + catch: (cause) => + new StartupError({ + message: "Failed to discover an available port", + cause, + }), + }), + resolveStaticDir, +} satisfies CliConfigShape); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts new file mode 100644 index 0000000000000..723d656eb6217 --- /dev/null +++ b/apps/server/src/open.ts @@ -0,0 +1,93 @@ +import { spawn } from "node:child_process"; + +import { EDITORS, type EditorId } from "@t3tools/contracts"; +import { ServiceMap, Schema, Effect, Layer } from "effect"; + +export class OpenError extends Schema.TaggedErrorClass()("OpenError", { + message: Schema.String, + cause: Schema.optional(Schema.Defect), +}) {} + +export interface OpenInEditorInput { + readonly cwd: string; + readonly editor: EditorId; +} + +interface EditorLaunch { + readonly command: string; + readonly args: ReadonlyArray; +} + +export interface OpenShape { + readonly openBrowser: (target: string) => Effect.Effect; + readonly openInEditor: (input: OpenInEditorInput) => Effect.Effect; +} + +export class Open extends ServiceMap.Service()("server/Open") {} + +export function resolveEditorLaunch( + input: OpenInEditorInput, + platform: NodeJS.Platform = process.platform, +): EditorLaunch { + const editorDef = EDITORS.find((editor) => editor.id === input.editor); + if (!editorDef) { + throw new OpenError({ message: `Unknown editor: ${input.editor}` }); + } + + if (editorDef.command) { + return { command: editorDef.command, args: [input.cwd] }; + } + + if (editorDef.id !== "file-manager") { + throw new OpenError({ message: `Unsupported editor: ${input.editor}` }); + } + + switch (platform) { + case "darwin": + return { command: "open", args: [input.cwd] }; + case "win32": + return { command: "explorer", args: [input.cwd] }; + default: + return { command: "xdg-open", args: [input.cwd] }; + } +} + +const OpenDefault: OpenShape = { + openBrowser: (target) => + Effect.tryPromise({ + try: async () => { + const open = await import("open"); + await open.default(target); + }, + catch: (cause) => + new OpenError({ + message: "Browser auto-open failed", + cause, + }), + }), + openInEditor: (input) => + Effect.try({ + try: () => { + const launch = resolveEditorLaunch(input); + const child = spawn(launch.command, [...launch.args], { + detached: true, + stdio: "ignore", + }); + child.on("error", () => { + // Best-effort behavior for detached editor launches. + }); + child.unref(); + }, + catch: (cause) => + cause instanceof OpenError + ? cause + : new OpenError({ + message: "Failed to open editor", + cause, + }), + }), +}; + +export const OpenLive = Layer.succeed(Open, OpenDefault); + +export const OpenDefaultService = OpenDefault; diff --git a/apps/server/src/orchestration/Layer.test.ts b/apps/server/src/orchestration/Layer.test.ts index ac9df75cce21b..242f2c8661eae 100644 --- a/apps/server/src/orchestration/Layer.test.ts +++ b/apps/server/src/orchestration/Layer.test.ts @@ -1,4 +1,12 @@ -import type { OrchestrationEvent } from "@t3tools/contracts"; +import { + CheckpointRef, + CommandId, + MessageId, + ProjectId, + ThreadId, + TurnId, + type OrchestrationEvent, +} from "@t3tools/contracts"; import { Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"; import { describe, expect, it } from "vitest"; @@ -14,6 +22,13 @@ import { OrchestrationEngineLive } from "./Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./Layers/ProjectionPipeline.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; +const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); +const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); +const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); + async function createOrchestrationSystem() { const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -43,8 +58,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "project.create", - commandId: "cmd-project-1-create", - projectId: "project-1", + commandId: asCommandId("cmd-project-1-create"), + projectId: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", defaultModel: "gpt-5-codex", @@ -54,9 +69,9 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.create", - commandId: "cmd-thread-1-create", - threadId: "thread-1", - projectId: "project-1", + commandId: asCommandId("cmd-thread-1-create"), + threadId: asThreadId("thread-1"), + projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", branch: null, @@ -67,10 +82,10 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.turn.start", - commandId: "cmd-turn-start-1", - threadId: "thread-1", + commandId: asCommandId("cmd-turn-start-1"), + threadId: asThreadId("thread-1"), message: { - messageId: "msg-1", + messageId: asMessageId("msg-1"), role: "user", text: "hello", attachments: [], @@ -93,8 +108,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "project.create", - commandId: "cmd-project-replay-create", - projectId: "project-replay", + commandId: asCommandId("cmd-project-replay-create"), + projectId: asProjectId("project-replay"), title: "Replay Project", workspaceRoot: "/tmp/project-replay", defaultModel: "gpt-5-codex", @@ -104,9 +119,9 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.create", - commandId: "cmd-thread-replay-create", - threadId: "thread-replay", - projectId: "project-replay", + commandId: asCommandId("cmd-thread-replay-create"), + threadId: asThreadId("thread-replay"), + projectId: asProjectId("project-replay"), title: "replay", model: "gpt-5-codex", branch: null, @@ -117,8 +132,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.delete", - commandId: "cmd-thread-replay-delete", - threadId: "thread-replay", + commandId: asCommandId("cmd-thread-replay-delete"), + threadId: asThreadId("thread-replay"), }), ); @@ -143,8 +158,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "project.create", - commandId: "cmd-project-stream-create", - projectId: "project-stream", + commandId: asCommandId("cmd-project-stream-create"), + projectId: asProjectId("project-stream"), title: "Stream Project", workspaceRoot: "/tmp/project-stream", defaultModel: "gpt-5-codex", @@ -164,9 +179,9 @@ describe("OrchestrationEngine", () => { yield* Effect.sleep("10 millis"); yield* engine.dispatch({ type: "thread.create", - commandId: "cmd-stream-thread-create", - threadId: "thread-stream", - projectId: "project-stream", + commandId: asCommandId("cmd-stream-thread-create"), + threadId: asThreadId("thread-stream"), + projectId: asProjectId("project-stream"), title: "domain-stream", model: "gpt-5-codex", branch: null, @@ -175,8 +190,8 @@ describe("OrchestrationEngine", () => { }); yield* engine.dispatch({ type: "thread.meta.update", - commandId: "cmd-stream-thread-update", - threadId: "thread-stream", + commandId: asCommandId("cmd-stream-thread-update"), + threadId: asThreadId("thread-stream"), title: "domain-stream-updated", }); eventTypes.push((yield* Queue.take(eventQueue)).type); @@ -196,8 +211,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "project.create", - commandId: "cmd-project-turn-diff-create", - projectId: "project-turn-diff", + commandId: asCommandId("cmd-project-turn-diff-create"), + projectId: asProjectId("project-turn-diff"), title: "Turn Diff Project", workspaceRoot: "/tmp/project-turn-diff", defaultModel: "gpt-5-codex", @@ -207,9 +222,9 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.create", - commandId: "cmd-thread-turn-diff-create", - threadId: "thread-turn-diff", - projectId: "project-turn-diff", + commandId: asCommandId("cmd-thread-turn-diff-create"), + threadId: asThreadId("thread-turn-diff"), + projectId: asProjectId("project-turn-diff"), title: "Turn diff thread", model: "gpt-5-codex", branch: null, @@ -220,11 +235,11 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.turn.diff.complete", - commandId: "cmd-turn-diff-complete", - threadId: "thread-turn-diff", - turnId: "turn-1", + commandId: asCommandId("cmd-turn-diff-complete"), + threadId: asThreadId("thread-turn-diff"), + turnId: asTurnId("turn-1"), completedAt: createdAt, - checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1", + checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"), status: "ready", files: [], checkpointTurnCount: 1, @@ -237,9 +252,9 @@ describe("OrchestrationEngine", () => { ); expect(thread?.checkpoints).toEqual([ { - turnId: "turn-1", + turnId: asTurnId("turn-1"), checkpointTurnCount: 1, - checkpointRef: "refs/t3/checkpoints/thread-turn-diff/turn/1", + checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"), status: "ready", files: [], assistantMessageId: null, @@ -250,13 +265,17 @@ describe("OrchestrationEngine", () => { }); it("keeps processing queued commands after a storage failure", async () => { - const events: OrchestrationEvent[] = []; + type StoredEvent = + ReturnType extends Effect.Effect + ? A + : never; + const events: StoredEvent[] = []; let nextSequence = 1; let shouldFailFirstAppend = true; const flakyStore: OrchestrationEventStoreShape = { append(event) { - if (shouldFailFirstAppend && event.commandId === "cmd-flaky-1") { + if (shouldFailFirstAppend && event.commandId === asCommandId("cmd-flaky-1")) { shouldFailFirstAppend = false; return Effect.fail( new PersistenceSqlError({ @@ -268,7 +287,7 @@ describe("OrchestrationEngine", () => { const savedEvent = { ...event, sequence: nextSequence, - } satisfies OrchestrationEvent; + } as StoredEvent; nextSequence += 1; events.push(savedEvent); return Effect.succeed(savedEvent); @@ -295,8 +314,8 @@ describe("OrchestrationEngine", () => { await runtime.runPromise( engine.dispatch({ type: "project.create", - commandId: "cmd-project-flaky-create", - projectId: "project-flaky", + commandId: asCommandId("cmd-project-flaky-create"), + projectId: asProjectId("project-flaky"), title: "Flaky Project", workspaceRoot: "/tmp/project-flaky", defaultModel: "gpt-5-codex", @@ -308,9 +327,9 @@ describe("OrchestrationEngine", () => { runtime.runPromise( engine.dispatch({ type: "thread.create", - commandId: "cmd-flaky-1", - threadId: "thread-flaky-fail", - projectId: "project-flaky", + commandId: asCommandId("cmd-flaky-1"), + threadId: asThreadId("thread-flaky-fail"), + projectId: asProjectId("project-flaky"), title: "flaky-fail", model: "gpt-5-codex", branch: null, @@ -323,9 +342,9 @@ describe("OrchestrationEngine", () => { const result = await runtime.runPromise( engine.dispatch({ type: "thread.create", - commandId: "cmd-flaky-2", - threadId: "thread-flaky-ok", - projectId: "project-flaky", + commandId: asCommandId("cmd-flaky-2"), + threadId: asThreadId("thread-flaky-ok"), + projectId: asProjectId("project-flaky"), title: "flaky-ok", model: "gpt-5-codex", branch: null, @@ -347,10 +366,10 @@ describe("OrchestrationEngine", () => { system.run( engine.dispatch({ type: "thread.turn.start", - commandId: "cmd-invariant-missing-thread", - threadId: "thread-missing", + commandId: asCommandId("cmd-invariant-missing-thread"), + threadId: asThreadId("thread-missing"), message: { - messageId: "msg-missing", + messageId: asMessageId("msg-missing"), role: "user", text: "hello", attachments: [], @@ -371,8 +390,8 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "project.create", - commandId: "cmd-project-duplicate-create", - projectId: "project-duplicate", + commandId: asCommandId("cmd-project-duplicate-create"), + projectId: asProjectId("project-duplicate"), title: "Duplicate Project", workspaceRoot: "/tmp/project-duplicate", defaultModel: "gpt-5-codex", @@ -383,9 +402,9 @@ describe("OrchestrationEngine", () => { await system.run( engine.dispatch({ type: "thread.create", - commandId: "cmd-thread-duplicate-1", - threadId: "thread-duplicate", - projectId: "project-duplicate", + commandId: asCommandId("cmd-thread-duplicate-1"), + threadId: asThreadId("thread-duplicate"), + projectId: asProjectId("project-duplicate"), title: "duplicate", model: "gpt-5-codex", branch: null, @@ -398,9 +417,9 @@ describe("OrchestrationEngine", () => { system.run( engine.dispatch({ type: "thread.create", - commandId: "cmd-thread-duplicate-2", - threadId: "thread-duplicate", - projectId: "project-duplicate", + commandId: asCommandId("cmd-thread-duplicate-2"), + threadId: asThreadId("thread-duplicate"), + projectId: asProjectId("project-duplicate"), title: "duplicate", model: "gpt-5-codex", branch: null, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 338a64e874c56..da8fc58c39ea4 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -41,6 +41,8 @@ const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asProviderThreadId = (value: string): ProviderThreadId => + ProviderThreadId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); function checkpointRefForTurn(threadId: ThreadId, turnCount: number): CheckpointRef { @@ -62,7 +64,7 @@ function createProviderServiceHarness(cwd: string, hasSession = true) { sessionId: asSessionId("sess-1"), provider: "codex", status: "ready", - threadId: asThreadId("thread-1"), + threadId: asProviderThreadId("provider-thread-1"), cwd, createdAt: now, updatedAt: now, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 3f57264c31879..867d932e35072 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1,3 +1,13 @@ +import { + CheckpointRef, + EventId, + MessageId, + ProjectId, + ProviderSessionId, + ProviderThreadId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -7,6 +17,15 @@ import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; +const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); +const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); +const asEventId = (value: string): EventId => EventId.makeUnsafe(value); +const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); +const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); + const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), ); @@ -185,7 +204,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { assert.equal(snapshot.updatedAt, "2026-02-24T00:00:09.000Z"); assert.deepEqual(snapshot.projects, [ { - id: "project-1", + id: asProjectId("project-1"), title: "Project 1", workspaceRoot: "/tmp/project-1", defaultModel: "gpt-5-codex", @@ -205,22 +224,22 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ]); assert.deepEqual(snapshot.threads, [ { - id: "thread-1", - projectId: "project-1", + id: asThreadId("thread-1"), + projectId: asProjectId("project-1"), title: "Thread 1", model: "gpt-5-codex", branch: null, worktreePath: null, - latestTurnId: "turn-1", + latestTurnId: asTurnId("turn-1"), createdAt: "2026-02-24T00:00:02.000Z", updatedAt: "2026-02-24T00:00:03.000Z", deletedAt: null, messages: [ { - id: "message-1", + id: asMessageId("message-1"), role: "assistant", text: "hello from projection", - turnId: "turn-1", + turnId: asTurnId("turn-1"), streaming: false, createdAt: "2026-02-24T00:00:04.000Z", updatedAt: "2026-02-24T00:00:05.000Z", @@ -228,33 +247,33 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ], activities: [ { - id: "activity-1", + id: asEventId("activity-1"), tone: "info", kind: "runtime.note", summary: "provider started", payload: { stage: "start" }, - turnId: "turn-1", + turnId: asTurnId("turn-1"), createdAt: "2026-02-24T00:00:06.000Z", }, ], checkpoints: [ { - turnId: "turn-1", + turnId: asTurnId("turn-1"), checkpointTurnCount: 1, - checkpointRef: "checkpoint-1", + checkpointRef: asCheckpointRef("checkpoint-1"), status: "ready", files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }], - assistantMessageId: "message-1", + assistantMessageId: asMessageId("message-1"), completedAt: "2026-02-24T00:00:08.000Z", }, ], session: { - threadId: "thread-1", + threadId: asThreadId("thread-1"), status: "running", providerName: "codex", - providerSessionId: "provider-session-1", - providerThreadId: "provider-thread-1", - activeTurnId: "turn-1", + providerSessionId: asProviderSessionId("provider-session-1"), + providerThreadId: asProviderThreadId("provider-thread-1"), + activeTurnId: asTurnId("turn-1"), lastError: null, updatedAt: "2026-02-24T00:00:07.000Z", }, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index b35cc22af7790..95265cfe5a575 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2,11 +2,13 @@ import type { ProviderRuntimeEvent } from "@t3tools/contracts"; import { ApprovalRequestId, CommandId, + MessageId, ProjectId, ProviderSessionId, ProviderThreadId, ProviderTurnId, ThreadId, + TurnId, } from "@t3tools/contracts"; import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -30,6 +32,8 @@ const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); +const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); async function waitFor( predicate: () => boolean, @@ -166,7 +170,7 @@ describe("ProviderCommandReactor", () => { commandId: asCommandId("cmd-turn-start-1"), threadId: asThreadId("thread-1"), message: { - messageId: "user-message-1", + messageId: asMessageId("user-message-1"), role: "user", text: "hello reactor", attachments: [], @@ -198,7 +202,7 @@ describe("ProviderCommandReactor", () => { providerName: "codex", providerSessionId: asSessionId("sess-1"), providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-1"), - activeTurnId: "turn-1", + activeTurnId: asTurnId("turn-1"), lastError: null, updatedAt: now, }, @@ -211,7 +215,7 @@ describe("ProviderCommandReactor", () => { type: "thread.turn.interrupt", commandId: asCommandId("cmd-turn-interrupt"), threadId: asThreadId("thread-1"), - turnId: "turn-1", + turnId: asTurnId("turn-1"), createdAt: now, }), ); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index dcf2c18e256e0..9d2f3bf80eb0c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -313,7 +313,7 @@ describe("ProviderRuntimeIngestion", () => { sessionId: asSessionId("sess-1"), createdAt: now, turnId: asProviderTurnId("turn-9"), - toolKind: "read", + toolKind: "other", title: "Read file", detail: "/tmp/file.ts", }); diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 87fbe951813ee..d02f80e300745 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { OrchestrationCommand, OrchestrationReadModel } from "@t3tools/contracts"; +import { + MessageId, + CommandId, + ProjectId, + ThreadId, + type OrchestrationCommand, + type OrchestrationReadModel, +} from "@t3tools/contracts"; import { Effect } from "effect"; import { @@ -17,7 +24,7 @@ const readModel: OrchestrationReadModel = { updatedAt: now, projects: [ { - id: "project-a", + id: ProjectId.makeUnsafe("project-a"), title: "Project A", workspaceRoot: "/tmp/project-a", defaultModel: "gpt-5-codex", @@ -27,7 +34,7 @@ const readModel: OrchestrationReadModel = { deletedAt: null, }, { - id: "project-b", + id: ProjectId.makeUnsafe("project-b"), title: "Project B", workspaceRoot: "/tmp/project-b", defaultModel: "gpt-5-codex", @@ -39,8 +46,8 @@ const readModel: OrchestrationReadModel = { ], threads: [ { - id: "thread-1", - projectId: "project-a", + id: ThreadId.makeUnsafe("thread-1"), + projectId: ProjectId.makeUnsafe("project-a"), title: "Thread A", model: "gpt-5-codex", branch: null, @@ -52,10 +59,11 @@ const readModel: OrchestrationReadModel = { session: null, activities: [], checkpoints: [], + deletedAt: null, }, { - id: "thread-2", - projectId: "project-b", + id: ThreadId.makeUnsafe("thread-2"), + projectId: ProjectId.makeUnsafe("project-b"), title: "Thread B", model: "gpt-5-codex", branch: null, @@ -67,16 +75,17 @@ const readModel: OrchestrationReadModel = { session: null, activities: [], checkpoints: [], + deletedAt: null, }, ], }; const messageSendCommand: OrchestrationCommand = { type: "thread.turn.start", - commandId: "cmd-1", - threadId: "thread-1", + commandId: CommandId.makeUnsafe("cmd-1"), + threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: "msg-1", + messageId: MessageId.makeUnsafe("msg-1"), role: "user", text: "hello", attachments: [], @@ -86,11 +95,13 @@ const messageSendCommand: OrchestrationCommand = { describe("commandInvariants", () => { it("finds threads by id and project", () => { - expect(findThreadById(readModel, "thread-1")?.projectId).toBe("project-a"); - expect(findThreadById(readModel, "missing")).toBeUndefined(); - expect(listThreadsByProjectId(readModel, "project-b").map((thread) => thread.id)).toEqual([ - "thread-2", - ]); + expect(findThreadById(readModel, ThreadId.makeUnsafe("thread-1"))?.projectId).toBe("project-a"); + expect(findThreadById(readModel, ThreadId.makeUnsafe("missing"))).toBeUndefined(); + expect( + listThreadsByProjectId(readModel, ProjectId.makeUnsafe("project-b")).map( + (thread) => thread.id, + ), + ).toEqual([ThreadId.makeUnsafe("thread-2")]); }); it("requires existing thread", async () => { @@ -98,17 +109,17 @@ describe("commandInvariants", () => { requireThread({ readModel, command: messageSendCommand, - threadId: "thread-1", + threadId: ThreadId.makeUnsafe("thread-1"), }), ); - expect(thread.id).toBe("thread-1"); + expect(thread.id).toBe(ThreadId.makeUnsafe("thread-1")); await expect( Effect.runPromise( requireThread({ readModel, command: messageSendCommand, - threadId: "missing", + threadId: ThreadId.makeUnsafe("missing"), }), ), ).rejects.toThrow("does not exist"); @@ -120,16 +131,16 @@ describe("commandInvariants", () => { readModel, command: { type: "thread.create", - commandId: "cmd-2", - threadId: "thread-3", - projectId: "project-a", + commandId: CommandId.makeUnsafe("cmd-2"), + threadId: ThreadId.makeUnsafe("thread-3"), + projectId: ProjectId.makeUnsafe("project-a"), title: "new", model: "gpt-5-codex", branch: null, worktreePath: null, createdAt: now, }, - threadId: "thread-3", + threadId: ThreadId.makeUnsafe("thread-3"), }), ); @@ -139,16 +150,16 @@ describe("commandInvariants", () => { readModel, command: { type: "thread.create", - commandId: "cmd-3", - threadId: "thread-1", - projectId: "project-a", + commandId: CommandId.makeUnsafe("cmd-3"), + threadId: ThreadId.makeUnsafe("thread-1"), + projectId: ProjectId.makeUnsafe("project-a"), title: "dup", model: "gpt-5-codex", branch: null, worktreePath: null, createdAt: now, }, - threadId: "thread-1", + threadId: ThreadId.makeUnsafe("thread-1"), }), ), ).rejects.toThrow("already exists"); @@ -157,7 +168,7 @@ describe("commandInvariants", () => { it("requires non-negative integers", async () => { await Effect.runPromise( requireNonNegativeInteger({ - commandType: "thread.revert", + commandType: "thread.checkpoint.revert", field: "turnCount", value: 0, }), @@ -166,7 +177,7 @@ describe("commandInvariants", () => { await expect( Effect.runPromise( requireNonNegativeInteger({ - commandType: "thread.revert", + commandType: "thread.checkpoint.revert", field: "turnCount", value: -1, }), diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 65207dab7aaa5..c56d427fb126e 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -1,9 +1,16 @@ +import { CommandId, EventId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { Effect } from "effect"; import { decideOrchestrationCommand } from "./decider.ts"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; +const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); +const asEventId = (value: string): EventId => EventId.makeUnsafe(value); +const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); + describe("decider project scripts", () => { it("emits empty scripts on project.create", async () => { const now = new Date().toISOString(); @@ -13,8 +20,8 @@ describe("decider project scripts", () => { decideOrchestrationCommand({ command: { type: "project.create", - commandId: "cmd-project-create-scripts", - projectId: "project-scripts", + commandId: asCommandId("cmd-project-create-scripts"), + projectId: asProjectId("project-scripts"), title: "Scripts", workspaceRoot: "/tmp/scripts", createdAt: now, @@ -34,17 +41,17 @@ describe("decider project scripts", () => { const readModel = await Effect.runPromise( projectEvent(initial, { sequence: 1, - eventId: "evt-project-create-scripts", - aggregateType: "project", - aggregateId: "project-scripts", + eventId: asEventId("evt-project-create-scripts"), + aggregateKind: "project", + aggregateId: asProjectId("project-scripts"), type: "project.created", occurredAt: now, - commandId: "cmd-project-create-scripts", + commandId: asCommandId("cmd-project-create-scripts"), causationEventId: null, - correlationId: "cmd-project-create-scripts", + correlationId: asCommandId("cmd-project-create-scripts"), metadata: {}, payload: { - projectId: "project-scripts", + projectId: asProjectId("project-scripts"), title: "Scripts", workspaceRoot: "/tmp/scripts", defaultModel: null, @@ -69,8 +76,8 @@ describe("decider project scripts", () => { decideOrchestrationCommand({ command: { type: "project.meta.update", - commandId: "cmd-project-update-scripts", - projectId: "project-scripts", + commandId: asCommandId("cmd-project-update-scripts"), + projectId: asProjectId("project-scripts"), scripts: Array.from(scripts), }, readModel, @@ -88,17 +95,17 @@ describe("decider project scripts", () => { const withProject = await Effect.runPromise( projectEvent(initial, { sequence: 1, - eventId: "evt-project-create", - aggregateType: "project", - aggregateId: "project-1", + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-1"), type: "project.created", occurredAt: now, - commandId: "cmd-project-create", + commandId: asCommandId("cmd-project-create"), causationEventId: null, - correlationId: "cmd-project-create", + correlationId: asCommandId("cmd-project-create"), metadata: {}, payload: { - projectId: "project-1", + projectId: asProjectId("project-1"), title: "Project", workspaceRoot: "/tmp/project", defaultModel: null, @@ -111,18 +118,18 @@ describe("decider project scripts", () => { const readModel = await Effect.runPromise( projectEvent(withProject, { sequence: 2, - eventId: "evt-thread-create", - aggregateType: "thread", - aggregateId: "thread-1", + eventId: asEventId("evt-thread-create"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-1"), type: "thread.created", occurredAt: now, - commandId: "cmd-thread-create", + commandId: asCommandId("cmd-thread-create"), causationEventId: null, - correlationId: "cmd-thread-create", + correlationId: asCommandId("cmd-thread-create"), metadata: {}, payload: { - threadId: "thread-1", - projectId: "project-1", + threadId: asThreadId("thread-1"), + projectId: asProjectId("project-1"), title: "Thread", model: "gpt-5-codex", branch: null, @@ -137,10 +144,10 @@ describe("decider project scripts", () => { decideOrchestrationCommand({ command: { type: "thread.turn.start", - commandId: "cmd-turn-start", - threadId: "thread-1", + commandId: asCommandId("cmd-turn-start"), + threadId: asThreadId("thread-1"), message: { - messageId: "message-user-1", + messageId: asMessageId("message-user-1"), role: "user", text: "hello", attachments: [], diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 45802c2b98d3c..798aa71002404 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -1,4 +1,4 @@ -import type { OrchestrationEvent } from "@t3tools/contracts"; +import { CommandId, EventId, ProjectId, ThreadId, type OrchestrationEvent } from "@t3tools/contracts"; import { Effect } from "effect"; import { describe, expect, it } from "vitest"; @@ -8,24 +8,27 @@ function makeEvent(input: { sequence: number; type: OrchestrationEvent["type"]; occurredAt: string; - aggregateType: OrchestrationEvent["aggregateType"]; + aggregateKind: OrchestrationEvent["aggregateKind"]; aggregateId: string; commandId: string | null; payload: unknown; }): OrchestrationEvent { return { sequence: input.sequence, - eventId: `event-${input.sequence}`, + eventId: EventId.makeUnsafe(`event-${input.sequence}`), type: input.type, - aggregateType: input.aggregateType, - aggregateId: input.aggregateId as OrchestrationEvent["aggregateId"], + aggregateKind: input.aggregateKind, + aggregateId: + input.aggregateKind === "project" + ? ProjectId.makeUnsafe(input.aggregateId) + : ThreadId.makeUnsafe(input.aggregateId), occurredAt: input.occurredAt, - commandId: input.commandId as OrchestrationEvent["commandId"], + commandId: input.commandId === null ? null : CommandId.makeUnsafe(input.commandId), causationEventId: null, correlationId: null, metadata: {}, - payload: input.payload, - }; + payload: input.payload as never, + } as OrchestrationEvent; } describe("orchestration projector", () => { @@ -39,7 +42,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 1, type: "thread.created", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: now, commandId: "cmd-thread-create", @@ -89,7 +92,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 1, type: "thread.created", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: now, commandId: "cmd-invalid", @@ -119,7 +122,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 7, type: "thread.turn-start-requested", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-01-01T00:00:00.000Z", commandId: "cmd-unhandled", @@ -148,7 +151,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 1, type: "thread.created", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: createdAt, commandId: "cmd-create", @@ -172,7 +175,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 2, type: "thread.session-set", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: startedAt, commandId: "cmd-running", @@ -210,7 +213,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 1, type: "thread.created", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: createdAt, commandId: "cmd-create", @@ -234,7 +237,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 2, type: "thread.message-sent", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: deltaAt, commandId: "cmd-delta", @@ -258,7 +261,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 3, type: "thread.message-sent", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: completeAt, commandId: "cmd-complete", @@ -293,7 +296,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 1, type: "thread.created", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: createdAt, commandId: "cmd-create", @@ -315,7 +318,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 2, type: "thread.message-sent", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:01.000Z", commandId: "cmd-user-1", @@ -333,7 +336,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 3, type: "thread.message-sent", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:02.000Z", commandId: "cmd-assistant-1", @@ -351,7 +354,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 4, type: "thread.turn-diff-completed", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:02.500Z", commandId: "cmd-turn-1-complete", @@ -369,7 +372,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 5, type: "thread.activity-appended", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:02.750Z", commandId: "cmd-activity-1", @@ -389,7 +392,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 6, type: "thread.message-sent", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:03.000Z", commandId: "cmd-user-2", @@ -407,7 +410,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 7, type: "thread.message-sent", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:04.000Z", commandId: "cmd-assistant-2", @@ -425,7 +428,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 8, type: "thread.turn-diff-completed", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:04.500Z", commandId: "cmd-turn-2-complete", @@ -443,7 +446,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 9, type: "thread.activity-appended", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:04.750Z", commandId: "cmd-activity-2", @@ -463,7 +466,7 @@ describe("orchestration projector", () => { makeEvent({ sequence: 10, type: "thread.reverted", - aggregateType: "thread", + aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-02-23T10:00:05.000Z", commandId: "cmd-revert", diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index c8c6bb334e502..f6a9a108a2528 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -1,10 +1,16 @@ -import type { - ProviderApprovalDecision, - ProviderEvent, - ProviderSendTurnInput, - ProviderSession, - ProviderSessionStartInput, - ProviderTurnStartResult, +import { + ApprovalRequestId, + EventId, + ProviderItemId, + type ProviderApprovalDecision, + type ProviderEvent, + type ProviderSendTurnInput, + ProviderSessionId, + type ProviderSession, + type ProviderSessionStartInput, + ProviderThreadId, + ProviderTurnId, + type ProviderTurnStartResult, } from "@t3tools/contracts"; import { afterAll, assert, it, vi } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; @@ -16,15 +22,21 @@ import { ProviderAdapterValidationError } from "../Errors.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { makeCodexAdapterLive } from "./CodexAdapter.ts"; +const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); +const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); +const asEventId = (value: string): EventId => EventId.makeUnsafe(value); +const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); + class FakeCodexManager extends CodexAppServerManager { public startSessionImpl = vi.fn( async (input: ProviderSessionStartInput): Promise => { const now = new Date().toISOString(); return { - sessionId: "sess-1", + sessionId: asSessionId("sess-1"), provider: "codex", status: "ready", - threadId: "thread-1", + threadId: asThreadId("thread-1"), cwd: input.cwd, createdAt: now, updatedAt: now, @@ -34,29 +46,29 @@ class FakeCodexManager extends CodexAppServerManager { public sendTurnImpl = vi.fn( async (_input: ProviderSendTurnInput): Promise => ({ - threadId: "thread-1", - turnId: "turn-1", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), }), ); public interruptTurnImpl = vi.fn( - async (_sessionId: string, _turnId?: string): Promise => undefined, + async (_sessionId: ProviderSessionId, _turnId?: ProviderTurnId): Promise => undefined, ); - public readThreadImpl = vi.fn(async (_sessionId: string) => ({ - threadId: "thread-1", + public readThreadImpl = vi.fn(async (_sessionId: ProviderSessionId) => ({ + threadId: asThreadId("thread-1"), turns: [], })); - public rollbackThreadImpl = vi.fn(async (_sessionId: string, _numTurns: number) => ({ - threadId: "thread-1", + public rollbackThreadImpl = vi.fn(async (_sessionId: ProviderSessionId, _numTurns: number) => ({ + threadId: asThreadId("thread-1"), turns: [], })); public respondToRequestImpl = vi.fn( async ( - _sessionId: string, - _requestId: string, + _sessionId: ProviderSessionId, + _requestId: ApprovalRequestId, _decision: ProviderApprovalDecision, ): Promise => undefined, ); @@ -71,33 +83,33 @@ class FakeCodexManager extends CodexAppServerManager { return this.sendTurnImpl(input); } - override interruptTurn(sessionId: string, turnId?: string): Promise { + override interruptTurn(sessionId: ProviderSessionId, turnId?: ProviderTurnId): Promise { return this.interruptTurnImpl(sessionId, turnId); } - override readThread(sessionId: string) { + override readThread(sessionId: ProviderSessionId) { return this.readThreadImpl(sessionId); } - override rollbackThread(sessionId: string, numTurns: number) { + override rollbackThread(sessionId: ProviderSessionId, numTurns: number) { return this.rollbackThreadImpl(sessionId, numTurns); } override respondToRequest( - sessionId: string, - requestId: string, + sessionId: ProviderSessionId, + requestId: ApprovalRequestId, decision: ProviderApprovalDecision, ): Promise { return this.respondToRequestImpl(sessionId, requestId, decision); } - override stopSession(_sessionId: string): void {} + override stopSession(_sessionId: ProviderSessionId): void {} override listSessions(): ProviderSession[] { return []; } - override hasSession(_sessionId: string): boolean { + override hasSession(_sessionId: ProviderSessionId): boolean { return false; } @@ -144,7 +156,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { const adapter = yield* CodexAdapter; const result = yield* adapter .sendTurn({ - sessionId: "sess-missing", + sessionId: asSessionId("sess-missing"), input: "hello", attachments: [], }) @@ -176,15 +188,15 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); const event: ProviderEvent = { - id: "evt-msg-complete", + id: asEventId("evt-msg-complete"), kind: "notification", provider: "codex", - sessionId: "sess-1", + sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), method: "item/completed", - threadId: "thread-1", - turnId: "turn-1", - itemId: "msg_1", + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + itemId: asItemId("msg_1"), payload: { item: { type: "agentMessage", @@ -220,13 +232,13 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { ).pipe(Effect.forkChild); const event: ProviderEvent = { - id: "evt-1", + id: asEventId("evt-1"), kind: "notification", provider: "codex", - sessionId: "sess-1", + sessionId: asSessionId("sess-1"), createdAt: new Date().toISOString(), method: "turn/started", - turnId: "turn-1", + turnId: asTurnId("turn-1"), }; lifecycleManager.emit("event", event); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 5e97f3538f8ec..8b63284967544 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -45,17 +45,6 @@ import { SqlitePersistenceMemory, } from "../../persistence/Layers/Sqlite.ts"; -function decodeIssueMessage(cause: unknown): string { - if (cause instanceof Error && cause.message.length > 0) { - return cause.message; - } - try { - return JSON.stringify(cause); - } catch { - return "Invalid provider input."; - } -} - const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); @@ -526,11 +515,18 @@ routing.layer("ProviderServiceLive routing", (it) => { const payload = runningRuntime.value.runtimePayload; assert.equal(payload !== null && typeof payload === "object", true); if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { - assert.equal(payload.cwd, process.cwd()); - assert.equal(payload.model, null); - assert.equal(payload.activeTurnId, "turn-1"); - assert.equal(payload.lastError, null); - assert.equal(payload.lastRuntimeEvent, "provider.sendTurn"); + const runtimePayload = payload as { + cwd: string; + model: string | null; + activeTurnId: string | null; + lastError: string | null; + lastRuntimeEvent: string | null; + }; + assert.equal(runtimePayload.cwd, process.cwd()); + assert.equal(runtimePayload.model, null); + assert.equal(runtimePayload.activeTurnId, "turn-1"); + assert.equal(runtimePayload.lastError, null); + assert.equal(runtimePayload.lastRuntimeEvent, "provider.sendTurn"); } } @@ -669,32 +665,22 @@ validation.layer("ProviderServiceLive validation", (it) => { Effect.gen(function* () { const provider = yield* ProviderService; - let parseCause: unknown; - let decodeFailed = false; - try { - Schema.decodeUnknownSync(ProviderSessionStartInput)({ - provider: "invalid-provider", - }); - } catch (cause) { - parseCause = cause; - decodeFailed = true; - } - assert.equal(decodeFailed, true); - const failure = yield* Effect.result( provider.startSession({ provider: "invalid-provider", } as never), ); - assertFailure( - failure, - new ProviderValidationError({ - operation: "ProviderService.startSession", - issue: decodeIssueMessage(parseCause), - cause: parseCause, - }), - ); + assert.equal(failure._tag, "Failure"); + if (failure._tag !== "Failure") { + return; + } + assert.equal(failure.failure._tag, "ProviderValidationError"); + if (failure.failure._tag !== "ProviderValidationError") { + return; + } + assert.equal(failure.failure.operation, "ProviderService.startSession"); + assert.equal(failure.failure.issue.includes("invalid-provider"), true); }), ); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 75c02ca4079de..b2f2eba5b838e 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -10,15 +10,20 @@ import WebSocket from "ws"; import { DEFAULT_TERMINAL_ID, + EventId, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, + ProviderItemId, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, WS_CHANNELS, WS_METHODS, + type WebSocketResponse, type ProviderRuntimeEvent, type KeybindingsConfig, type ResolvedKeybindingsConfig, type WsPush, - type WsResponse, } from "@t3tools/contracts"; import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; import type { @@ -34,6 +39,7 @@ import type { TerminalManager } from "./terminalManager"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; import { SqlClient } from "effect/unstable/sql"; import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; +import type { OpenShape } from "./open"; interface PendingMessages { queue: unknown[]; @@ -42,6 +48,12 @@ interface PendingMessages { const pendingBySocket = new WeakMap(); +const asEventId = (value: string): EventId => EventId.makeUnsafe(value); +const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); +const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); +const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); + class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> { private readonly sessions = new Map(); @@ -187,7 +199,19 @@ function waitForMessage(ws: WebSocket): Promise { }); } -async function sendRequest(ws: WebSocket, method: string, params?: unknown): Promise { +function asWebSocketResponse(message: unknown): WebSocketResponse | null { + if (typeof message !== "object" || message === null) return null; + if (!("id" in message)) return null; + const id = (message as { id?: unknown }).id; + if (typeof id !== "string") return null; + return message as WebSocketResponse; +} + +async function sendRequest( + ws: WebSocket, + method: string, + params?: unknown, +): Promise { const id = crypto.randomUUID(); const body = method === ORCHESTRATION_WS_METHODS.dispatchCommand @@ -200,12 +224,15 @@ async function sendRequest(ws: WebSocket, method: string, params?: unknown): Pro // Wait for response with matching id while (true) { - const parsed = (await waitForMessage(ws)) as Record; + const parsed = asWebSocketResponse(await waitForMessage(ws)); + if (!parsed) { + continue; + } if (parsed.id === id) { - return parsed as WsResponse; + return parsed; } if (parsed.id === "unknown") { - return parsed as WsResponse; + return parsed; } } } @@ -233,7 +260,7 @@ async function waitForPush( } function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { - const resolved: ResolvedKeybindingsConfig = []; + const resolved: Array = []; for (const binding of bindings) { const compiled = compileResolvedKeybindingRule(binding); if (!compiled) { @@ -278,6 +305,7 @@ describe("WebSocket Server", () => { authToken?: string; stateDir?: string; providerLayer?: Layer.Layer; + open?: OpenShape; gitManager?: { status: (input: { cwd: string }) => Promise; runStackedAction: (input: { cwd: string; action: string }) => Promise; @@ -297,6 +325,7 @@ describe("WebSocket Server", () => { ...(options.devUrl ? { devUrl: options.devUrl } : {}), ...(options.authToken ? { authToken: options.authToken } : {}), ...(options.providerLayer ? { providerLayer: options.providerLayer } : {}), + ...(options.open ? { open: options.open } : {}), ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), }); @@ -420,6 +449,33 @@ describe("WebSocket Server", () => { }); }); + it("routes shell.openInEditor through the injected open service", async () => { + const openCalls: Array<{ cwd: string; editor: string }> = []; + const openService: OpenShape = { + openBrowser: () => Effect.void, + openInEditor: (input) => { + openCalls.push({ cwd: input.cwd, editor: input.editor }); + return Effect.void; + }, + }; + + server = createTestServer({ cwd: "/my/workspace", open: openService }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.shellOpenInEditor, { + cwd: "/my/workspace", + editor: "cursor", + }); + expect(response.error).toBeUndefined(); + expect(openCalls).toEqual([{ cwd: "/my/workspace", editor: "cursor" }]); + }); + it("reads keybindings from ~/.t3/keybindings.json", async () => { const fakeHome = makeTempDir("t3code-home-"); const configDir = path.join(fakeHome, ".t3"); @@ -787,7 +843,7 @@ describe("WebSocket Server", () => { it("keeps orchestration domain push behavior for provider runtime events", async () => { const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); - const sessionId = "sess-test"; + const sessionId = asProviderSessionId("sess-test"); const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); }; @@ -798,11 +854,15 @@ describe("WebSocket Server", () => { sessionId, provider: "codex", status: "ready", - threadId: "provider-thread-1", + threadId: asProviderThreadId("provider-thread-1"), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }), - sendTurn: () => Effect.succeed({ threadId: "provider-thread-1", turnId: "provider-turn-1" }), + sendTurn: () => + Effect.succeed({ + threadId: asProviderThreadId("provider-thread-1"), + turnId: asProviderTurnId("provider-turn-1"), + }), interruptTurn: () => unsupported(), respondToRequest: () => unsupported(), stopSession: () => unsupported(), @@ -870,12 +930,12 @@ describe("WebSocket Server", () => { emitRuntimeEvent({ type: "message.delta", - eventId: "evt-ws-runtime-message-delta", + eventId: asEventId("evt-ws-runtime-message-delta"), provider: "codex", sessionId, createdAt: new Date().toISOString(), - turnId: "turn-1", - itemId: "item-1", + turnId: asProviderTurnId("turn-1"), + itemId: asProviderItemId("item-1"), delta: "hello from runtime", }); @@ -1015,15 +1075,18 @@ describe("WebSocket Server", () => { // Send garbage ws.send("not json at all"); - let response: WsResponse | null = null; + let response: WebSocketResponse | null = null; for (let attempt = 0; attempt < 5; attempt += 1) { - const message = (await waitForMessage(ws)) as Record; - if (typeof message.id === "string" && message.id === "unknown") { - response = message as WsResponse; + const message = asWebSocketResponse(await waitForMessage(ws)); + if (!message) { + continue; + } + if (message.id === "unknown") { + response = message; break; } if (message.error) { - response = message as WsResponse; + response = message; break; } } diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index bb2bb8aeb1ad8..b45b266714c45 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -1,4 +1,3 @@ -import { spawn } from "node:child_process"; import fs from "node:fs"; import http from "node:http"; import os from "node:os"; @@ -7,7 +6,6 @@ import type { Duplex } from "node:stream"; import { CheckpointRef, - EDITORS, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, TerminalEvent, @@ -18,7 +16,7 @@ import { WsResponse, } from "@t3tools/contracts"; import { NodeServices } from "@effect/platform-node"; -import { Effect, Exit, Layer, ManagedRuntime, Schema, Scope, Stream } from "effect"; +import { Effect, Exit, Layer, ManagedRuntime, Schema, Scope, ServiceMap, Stream } from "effect"; import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; @@ -62,12 +60,16 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; -import { CheckpointStore, type CheckpointStoreShape } from "./checkpointing/Services/CheckpointStore"; +import { + CheckpointStore, + type CheckpointStoreShape, +} from "./checkpointing/Services/CheckpointStore"; import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlError from "effect/unstable/sql/SqlError"; import * as Migrator from "effect/unstable/sql/Migrator"; import { clamp } from "effect/Number"; +import { OpenDefaultService, type OpenShape } from "./open"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -100,8 +102,25 @@ export interface ServerOptions { terminalManager?: TerminalManager | undefined; providerLayer?: Layer.Layer | undefined; authToken?: string | undefined; + open?: OpenShape | undefined; +} + +export interface ServerRuntime { + readonly start: () => Promise; + readonly stop: () => Promise; + readonly httpServer: http.Server; } +export interface ServerShape { + readonly createServer: (options: ServerOptions) => ServerRuntime; +} + +export class Server extends ServiceMap.Service()("server/Server") {} + +export const ServerLive = Layer.succeed(Server, { + createServer, +} satisfies ServerShape); + const isServerNotRunningError = (error: unknown): boolean => { if (!(error instanceof Error)) return false; const maybeCode = (error as NodeJS.ErrnoException).code; @@ -162,9 +181,7 @@ function websocketRawToString(raw: unknown): string | null { return null; } -function stripRequestTag( - body: T, -): Omit { +function stripRequestTag(body: T): Omit { const { _tag: _ignoredTag, ...rest } = body; return rest; } @@ -185,7 +202,7 @@ type EffectRuntime = ManagedRuntime.ManagedRuntime< unknown >; -export function createServer(options: ServerOptions) { +export function createServer(options: ServerOptions): ServerRuntime { const { port, host, @@ -200,6 +217,7 @@ export function createServer(options: ServerOptions) { terminalManager = new TerminalManager(), providerLayer: customProviderLayer, authToken, + open: openService = OpenDefaultService, } = options; const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata"); @@ -626,42 +644,7 @@ export function createServer(options: ServerOptions) { return searchWorkspaceEntries(stripRequestTag(request.body)); case WS_METHODS.shellOpenInEditor: { - const body = request.body; - const editorDef = EDITORS.find((e) => e.id === body.editor); - if (!editorDef) throw new Error(`Unknown editor: ${body.editor}`); - - let command: string; - let args: string[]; - - if (editorDef.command) { - command = editorDef.command; - args = [body.cwd]; - } else if (editorDef.id === "file-manager") { - // Use platform-specific file manager command - switch (process.platform) { - case "darwin": - command = "open"; - break; - case "win32": - command = "explorer"; - break; - default: - command = "xdg-open"; - break; - } - args = [body.cwd]; - } else { - return undefined; - } - - const child = spawn(command, args, { - detached: true, - stdio: "ignore", - }); - child.on("error", () => { - /* ignore spawn failures for detached editors */ - }); - child.unref(); + await Effect.runPromise(openService.openInEditor(stripRequestTag(request.body))); return undefined; } @@ -763,10 +746,7 @@ export function createServer(options: ServerOptions) { ); return makeProviderServiceLive({ canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"), - }).pipe( - Layer.provide(adapterRegistryLayer), - Layer.provide(providerSessionDirectoryLayer), - ); + }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); })(); const runtimeServicesLayer = Layer.mergeAll( diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 23300dbb118c2..b99ca864e7780 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -12,6 +12,5 @@ } ] }, - "include": ["src", "tsdown.config.ts", "scripts", "integration"], - "exclude": ["src/**/*.test.ts"] + "include": ["src", "tsdown.config.ts", "scripts", "integration"] } diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 503d928a06527..3d36967bdd5a1 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -68,7 +68,7 @@ export class GitRemoveWorktreeInput extends Schema.Class )({ cwd: TrimmedNonEmptyString, path: TrimmedNonEmptyString, - force: Schema.Boolean.pipe(Schema.NullOr), + force: Schema.optional(Schema.Boolean), }) {} export class GitCreateBranchInput extends Schema.Class( diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 212514b197564..50c1075c1623d 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -12,9 +12,7 @@ const decode = (schema: S, input: unknown): Schema.Schema. Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type; const decodeResolvedRuleStrict = (input: unknown) => - Schema.decodeUnknownSync(ResolvedKeybindingRule as never)(input, { - onExcessProperty: "error", - }); + Schema.decodeUnknownSync(ResolvedKeybindingRule as never)(input); describe("keybindings contracts", () => { it("parses keybinding rules", () => { diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index fa71cfbd7469e..9768e4f90aced 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -70,11 +70,14 @@ export type KeybindingWhenNode = export class ResolvedKeybindingRule extends Schema.Class( "ResolvedKeybindingRule", -)({ - command: KeybindingCommand, - shortcut: KeybindingShortcut, - whenAst: Schema.optional(KeybindingWhenNode), -}) {} +)( + { + command: KeybindingCommand, + shortcut: KeybindingShortcut, + whenAst: Schema.optional(KeybindingWhenNode), + }, + { parseOptions: { onExcessProperty: "error" } }, +) {} export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( Schema.isMaxLength(256), diff --git a/scripts/dev-runner.mjs b/scripts/dev-runner.mjs index 89b9ef3c26cec..dce79715c9740 100644 --- a/scripts/dev-runner.mjs +++ b/scripts/dev-runner.mjs @@ -84,6 +84,15 @@ function main() { VITE_WS_URL: `ws://localhost:${serverPort}`, VITE_DEV_SERVER_URL: `http://localhost:${webPort}`, }; + + if (mode === "dev" || mode === "dev:server" || mode === "dev:web") { + // Running server/web in browser mode should not inherit desktop launcher state. + env.T3CODE_MODE = "web"; + delete env.T3CODE_NO_BROWSER; + delete env.T3CODE_AUTH_TOKEN; + delete env.T3CODE_DESKTOP_WS_URL; + } + if (mode === "dev" && !env.T3CODE_LOG_WS_EVENTS) { env.T3CODE_LOG_WS_EVENTS = "1"; } From ffac46e425785e1b4d2d7f5017a46cfdfcaaca38 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 13:50:24 -0800 Subject: [PATCH 36/98] fix --- apps/web/src/lib/gitReactQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 9a75c9520b4e4..7a0becf044bf5 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -179,7 +179,7 @@ export function gitRemoveWorktreeMutationOptions(input: { if (!input.api) { throw new Error("Git worktree removal is unavailable."); } - return input.api.git.removeWorktree({ cwd, path, force: force ?? null }); + return input.api.git.removeWorktree({ cwd, path, force }); }, onSettled: async () => { await invalidateGitQueries(input.queryClient); From 18b6886b24bbbc8d0c09c5b6d39583afb0c34591 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 13:54:47 -0800 Subject: [PATCH 37/98] setup node in ci --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e2cc265ccdab..641c40fb40f34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,12 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: 1.3.9 + bun-version-file: package.json + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: package.json - name: Cache Bun and Turbo uses: actions/cache@v4 From 68c8eaa6198d766a675eddfb5600482df885ea77 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 13:58:28 -0800 Subject: [PATCH 38/98] keepalive --- apps/server/src/main.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index cb74206a1be15..6a16de73382e1 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -247,6 +247,8 @@ const makeServerProgram = Effect.fn(function* (input: CliInput) { ), ); } + + return yield* Effect.never; }, Effect.scoped); export function makeCliCommand() { From 1be263eab711f3cb2276691d3a3d73b8afc185ad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 13:59:02 -0800 Subject: [PATCH 39/98] nit --- apps/server/src/main.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 6a16de73382e1..7141a2977b58a 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -277,10 +277,7 @@ export const CliConfigLive = Layer.succeed(CliConfig, { Effect.tryPromise({ try: () => findAvailablePort(preferred), catch: (cause) => - new StartupError({ - message: "Failed to discover an available port", - cause, - }), + new StartupError({ message: "Failed to discover an available port", cause }), }), resolveStaticDir, } satisfies CliConfigShape); From 6c4547af0625f9dee717a8bf3e39474dd37362fe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 14:13:26 -0800 Subject: [PATCH 40/98] kewl --- apps/server/src/index.ts | 10 +- apps/server/src/main.test.ts | 2 +- apps/server/src/main.ts | 202 ++++++++++++++++------------------- 3 files changed, 98 insertions(+), 116 deletions(-) diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f23154e7235e2..b8985c9b282a7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -9,9 +9,9 @@ import { Command } from "effect/unstable/cli"; import { version } from "../package.json" with { type: "json" }; import { ServerLive } from "./wsServer"; -const RuntimeLayer = Layer.mergeAll(CliConfigLive, ServerLive, OpenLive, NodeServices.layer); - -Command.run(makeCliCommand(), { version }).pipe( - Effect.provide(RuntimeLayer), - NodeRuntime.runMain, +const RuntimeLayer = Layer.provideMerge( + Layer.mergeAll(CliConfigLive, ServerLive, OpenLive), + NodeServices.layer, ); + +Command.run(makeCliCommand(), { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain); diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index e98612e848995..85469ffb0f5c5 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -24,7 +24,7 @@ const testLayer = Layer.mergeAll( cwd: "/tmp/t3-test-workspace", fixPath: Effect.void, findAvailablePort, - resolveStaticDir: () => undefined, + resolveStaticDir: () => Effect.undefined, } satisfies CliConfigShape), Layer.succeed(Server, { createServer, diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 7141a2977b58a..39668fd7ff1ef 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,17 +1,19 @@ -import fs from "node:fs"; import net from "node:net"; import os from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import * as Data from "effect/Data"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Config from "effect/Config"; -import { ServiceMap } from "effect"; -import * as Command from "effect/unstable/cli/Command"; -import * as Flag from "effect/unstable/cli/Flag"; +import { + ServiceMap, + Data, + Effect, + Layer, + Option, + Config, + Path, + Schema, + FileSystem, + PlatformError, +} from "effect"; +import { Command, Flag } from "effect/unstable/cli"; import { fixPath } from "./fixPath"; import { Open } from "./open"; @@ -31,22 +33,11 @@ interface CliInput { readonly token: Option.Option; } -interface ResolvedCliConfig { - readonly port: number; - readonly mode: RuntimeMode; - readonly cwd: string; - readonly stateDir: string; - readonly devUrl: URL | undefined; - readonly noBrowser: boolean; - readonly authToken: string | undefined; - readonly staticDir: string | undefined; -} - export interface CliConfigShape { readonly cwd: string; readonly fixPath: Effect.Effect; readonly findAvailablePort: (preferred: number) => Effect.Effect; - readonly resolveStaticDir: () => string | undefined; + readonly resolveStaticDir: () => Effect.Effect; } export class CliConfig extends ServiceMap.Service()( @@ -79,31 +70,22 @@ const CliEnvConfig = Config.all({ ), }); -function assertValidPort(value: number, source: string): Effect.Effect { - if (Number.isInteger(value) && value >= 1 && value <= 65535) { - return Effect.succeed(value); - } - return Effect.fail( - new StartupError({ - message: `Invalid ${source}: ${value}. Expected an integer between 1 and 65535.`, - }), - ); -} - -function expandHomePath(input: string): string { +const expandHomePath = Effect.fn(function* (input: string) { + const { join } = yield* Path.Path; if (input === "~") return os.homedir(); if (input.startsWith("~/")) { - return path.join(os.homedir(), input.slice(2)); + return join(os.homedir(), input.slice(2)); } return input; -} +}); -function resolveStateDir(raw: string | undefined): string { +const resolveStateDir = Effect.fn(function* (raw: string | undefined) { + const { join, resolve } = yield* Path.Path; if (!raw || raw.trim().length === 0) { - return path.join(os.homedir(), ".t3", "userdata"); + return join(os.homedir(), ".t3", "userdata"); } - return path.resolve(expandHomePath(raw.trim())); -} + return resolve(yield* expandHomePath(raw.trim())); +}); async function findAvailablePort(preferred: number): Promise { return new Promise((resolve, reject) => { @@ -126,69 +108,57 @@ async function findAvailablePort(preferred: number): Promise { }); } -function resolveStaticDir(): string | undefined { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - - const bundledClient = path.resolve(__dirname, "client"); - try { - const stat = fs.statSync(path.join(bundledClient, "index.html")); - if (stat.isFile()) return bundledClient; - } catch { - // Not bundled — check monorepo layout. - } - - const monorepoClient = path.resolve(__dirname, "../../web/dist"); - try { - const stat = fs.statSync(path.join(monorepoClient, "index.html")); - if (stat.isFile()) return monorepoClient; - } catch { - // Not found — probably dev mode. - } +const resolveStaticDir = Effect.fn(function* () { + const { join, resolve } = yield* Path.Path; + const { stat } = yield* FileSystem.FileSystem; + const bundledClient = resolve(join(import.meta.dirname, "client")); + const bundledStat = yield* stat(join(bundledClient, "index.html")).pipe( + Effect.orElseSucceed(() => undefined), + ); + if (bundledStat) return bundledClient; + const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist")); + const monorepoStat = yield* stat(join(monorepoClient, "index.html")).pipe( + Effect.orElseSucceed(() => undefined), + ); + if (monorepoStat) return monorepoClient; return undefined; -} - -function resolveCliConfig( - input: CliInput, -): Effect.Effect { - return Effect.gen(function* () { - const cliConfig = yield* CliConfig; - const env = yield* CliEnvConfig.asEffect().pipe( - Effect.mapError( - (cause) => - new StartupError({ - message: "Failed to read environment configuration", - cause, - }), - ), - ); - const mode: RuntimeMode = env.mode === "desktop" ? "desktop" : "web"; +}); - const cliPort = Option.getOrUndefined(input.port); - const requestedPort = - cliPort === undefined ? env.port : yield* assertValidPort(cliPort, "--port"); +const resolveCliConfig = Effect.fn(function* (input: CliInput) { + const cliConfig = yield* CliConfig; + const env = yield* CliEnvConfig.asEffect().pipe( + Effect.mapError( + (cause) => + new StartupError({ + message: "Failed to read environment configuration", + cause, + }), + ), + ); + const mode: RuntimeMode = env.mode === "desktop" ? "desktop" : "web"; - const port = - requestedPort ?? - (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT)); - const stateDir = resolveStateDir(env.stateDir); - const devUrl = env.devUrl; - const noBrowser = env.noBrowser ?? mode === "desktop"; - const authToken = Option.getOrUndefined(input.token) ?? env.authToken; - const staticDir = devUrl ? undefined : cliConfig.resolveStaticDir(); + const cliPort = Option.getOrUndefined(input.port); + const port = + (cliPort === undefined ? env.port : cliPort) ?? + (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT)); + const stateDir = yield* resolveStateDir(env.stateDir); + const devUrl = env.devUrl; + const noBrowser = env.noBrowser ?? mode === "desktop"; + const authToken = Option.getOrUndefined(input.token) ?? env.authToken; + const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir(); - return { - mode, - port, - stateDir, - devUrl, - noBrowser, - authToken, - staticDir, - cwd: cliConfig.cwd, - }; - }); -} + return { + mode, + port, + stateDir, + devUrl, + noBrowser, + authToken, + staticDir, + cwd: cliConfig.cwd, + }; +}); const makeServerProgram = Effect.fn(function* (input: CliInput) { const cliConfig = yield* CliConfig; @@ -253,6 +223,7 @@ const makeServerProgram = Effect.fn(function* (input: CliInput) { export function makeCliCommand() { const portFlag = Flag.integer("port").pipe( + Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), Flag.withDescription("Port for the HTTP/WebSocket server."), Flag.optional, ); @@ -270,14 +241,25 @@ export function makeCliCommand() { ); } -export const CliConfigLive = Layer.succeed(CliConfig, { - cwd: process.cwd(), - fixPath: Effect.sync(fixPath), - findAvailablePort: (preferred) => - Effect.tryPromise({ - try: () => findAvailablePort(preferred), - catch: (cause) => - new StartupError({ message: "Failed to discover an available port", cause }), - }), - resolveStaticDir, -} satisfies CliConfigShape); +export const CliConfigLive = Layer.effect( + CliConfig, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return { + cwd: process.cwd(), + fixPath: Effect.sync(fixPath), + findAvailablePort: (preferred) => + Effect.tryPromise({ + try: () => findAvailablePort(preferred), + catch: (cause) => + new StartupError({ message: "Failed to discover an available port", cause }), + }), + resolveStaticDir: () => + resolveStaticDir().pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + } satisfies CliConfigShape; + }), +); From 176e2f5f612903b3ace69264cf7ca555a2a19774 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 14:16:29 -0800 Subject: [PATCH 41/98] nice --- apps/server/src/main.ts | 42 ++++++++++++++++------------------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 39668fd7ff1ef..077934ad602f6 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,18 +1,7 @@ import net from "node:net"; import os from "node:os"; -import { - ServiceMap, - Data, - Effect, - Layer, - Option, - Config, - Path, - Schema, - FileSystem, - PlatformError, -} from "effect"; +import { ServiceMap, Data, Effect, Layer, Option, Config, Path, Schema, FileSystem } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { fixPath } from "./fixPath"; @@ -37,7 +26,7 @@ export interface CliConfigShape { readonly cwd: string; readonly fixPath: Effect.Effect; readonly findAvailablePort: (preferred: number) => Effect.Effect; - readonly resolveStaticDir: () => Effect.Effect; + readonly resolveStaticDir: () => Effect.Effect; } export class CliConfig extends ServiceMap.Service()( @@ -87,11 +76,11 @@ const resolveStateDir = Effect.fn(function* (raw: string | undefined) { return resolve(yield* expandHomePath(raw.trim())); }); -async function findAvailablePort(preferred: number): Promise { - return new Promise((resolve, reject) => { +const findAvailablePort = (preferred: number) => + Effect.callback((resume) => { const server = net.createServer(); server.listen(preferred, () => { - server.close(() => resolve(preferred)); + server.close(() => resume(Effect.succeed(preferred))); }); server.on("error", () => { const fallback = net.createServer(); @@ -99,14 +88,20 @@ async function findAvailablePort(preferred: number): Promise { const addr = fallback.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; fallback.close(() => { - if (port > 0) resolve(port); - else reject(new Error("Could not find an available port.")); + resume( + port > 0 + ? Effect.succeed(port) + : Effect.fail(new StartupError({ message: "Could not find an available port." })), + ); }); }); - fallback.on("error", reject); + fallback.on("error", (cause) => { + resume( + Effect.fail(new StartupError({ message: "Failed to find an available port", cause })), + ); + }); }); }); -} const resolveStaticDir = Effect.fn(function* () { const { join, resolve } = yield* Path.Path; @@ -249,12 +244,7 @@ export const CliConfigLive = Layer.effect( return { cwd: process.cwd(), fixPath: Effect.sync(fixPath), - findAvailablePort: (preferred) => - Effect.tryPromise({ - try: () => findAvailablePort(preferred), - catch: (cause) => - new StartupError({ message: "Failed to discover an available port", cause }), - }), + findAvailablePort, resolveStaticDir: () => resolveStaticDir().pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), From 8ea823dc25886890a22a9cb6448bf037ead26011 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 14:17:52 -0800 Subject: [PATCH 42/98] niceee --- apps/server/src/main.test.ts | 2 +- apps/server/src/main.ts | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 85469ffb0f5c5..c16fe9aabe999 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -24,7 +24,7 @@ const testLayer = Layer.mergeAll( cwd: "/tmp/t3-test-workspace", fixPath: Effect.void, findAvailablePort, - resolveStaticDir: () => Effect.undefined, + resolveStaticDir: Effect.undefined, } satisfies CliConfigShape), Layer.succeed(Server, { createServer, diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 077934ad602f6..535b71d3f3285 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -26,7 +26,7 @@ export interface CliConfigShape { readonly cwd: string; readonly fixPath: Effect.Effect; readonly findAvailablePort: (preferred: number) => Effect.Effect; - readonly resolveStaticDir: () => Effect.Effect; + readonly resolveStaticDir: Effect.Effect; } export class CliConfig extends ServiceMap.Service()( @@ -141,7 +141,7 @@ const resolveCliConfig = Effect.fn(function* (input: CliInput) { const devUrl = env.devUrl; const noBrowser = env.noBrowser ?? mode === "desktop"; const authToken = Option.getOrUndefined(input.token) ?? env.authToken; - const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir(); + const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; return { mode, @@ -245,11 +245,10 @@ export const CliConfigLive = Layer.effect( cwd: process.cwd(), fixPath: Effect.sync(fixPath), findAvailablePort, - resolveStaticDir: () => - resolveStaticDir().pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), + resolveStaticDir: resolveStaticDir().pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), } satisfies CliConfigShape; }), ); From 0407e689dd89c1e1f0dab4bd18459ce33bfd79f0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 14:34:50 -0800 Subject: [PATCH 43/98] fix test --- apps/server/src/main.test.ts | 16 +--------------- apps/server/src/main.ts | 6 +++--- apps/server/src/wsServer.ts | 2 ++ 3 files changed, 6 insertions(+), 18 deletions(-) diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index c16fe9aabe999..6f1e45bcca136 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -28,6 +28,7 @@ const testLayer = Layer.mergeAll( } satisfies CliConfigShape), Layer.succeed(Server, { createServer, + stopSignal: Effect.never, } satisfies ServerShape), Layer.succeed(Open, { openBrowser: (_target: string) => Effect.void, @@ -113,19 +114,4 @@ it.layer(testLayer)("server cli", (it) => { assert.equal(options?.host, "127.0.0.1"); }), ); - - it.effect("fails for out-of-range --port values", () => - Effect.gen(function* () { - const result = yield* Effect.result(runCli(["--port", "70000"])); - - assert.equal(result._tag, "Failure"); - if (result._tag === "Failure") { - assert.equal( - result.failure.message, - "Invalid --port: 70000. Expected an integer between 1 and 65535.", - ); - } - assert.equal(createServer.mock.calls.length, 0); - }), - ); }); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 535b71d3f3285..5e87a7a912692 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -157,7 +157,7 @@ const resolveCliConfig = Effect.fn(function* (input: CliInput) { const makeServerProgram = Effect.fn(function* (input: CliInput) { const cliConfig = yield* CliConfig; - const serverDeps = yield* Server; + const { createServer, stopSignal } = yield* Server; const openDeps = yield* Open; yield* cliConfig.fixPath; const config = yield* resolveCliConfig(input); @@ -171,7 +171,7 @@ const makeServerProgram = Effect.fn(function* (input: CliInput) { yield* Effect.acquireRelease( Effect.tryPromise({ try: async () => { - const server = serverDeps.createServer({ + const server = createServer({ port: config.port, host: config.mode === "desktop" ? "127.0.0.1" : undefined, cwd: config.cwd, @@ -213,7 +213,7 @@ const makeServerProgram = Effect.fn(function* (input: CliInput) { ); } - return yield* Effect.never; + return yield* stopSignal; }, Effect.scoped); export function makeCliCommand() { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index b45b266714c45..5be76b1cd9547 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -113,12 +113,14 @@ export interface ServerRuntime { export interface ServerShape { readonly createServer: (options: ServerOptions) => ServerRuntime; + readonly stopSignal: Effect.Effect; } export class Server extends ServiceMap.Service()("server/Server") {} export const ServerLive = Layer.succeed(Server, { createServer, + stopSignal: Effect.never, } satisfies ServerShape); const isServerNotRunningError = (error: unknown): boolean => { From af53ae8b4845852a320f108dda30b32afc3c4029 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 14:43:28 -0800 Subject: [PATCH 44/98] fix flaky tests --- apps/server/src/main.test.ts | 13 +++++++- .../src/provider/Layers/CodexAdapter.test.ts | 32 ------------------- 2 files changed, 12 insertions(+), 33 deletions(-) diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 6f1e45bcca136..0890be8addc9e 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -28,7 +28,7 @@ const testLayer = Layer.mergeAll( } satisfies CliConfigShape), Layer.succeed(Server, { createServer, - stopSignal: Effect.never, + stopSignal: Effect.void, } satisfies ServerShape), Layer.succeed(Open, { openBrowser: (_target: string) => Effect.void, @@ -114,4 +114,15 @@ it.layer(testLayer)("server cli", (it) => { assert.equal(options?.host, "127.0.0.1"); }), ); + + it.effect("does not start server for out-of-range --port values", () => + Effect.gen(function* () { + yield* runCli(["--port", "70000"]); + + // effect/unstable/cli renders help/errors for parse failures and returns success. + assert.equal(createServer.mock.calls.length, 0); + assert.equal(start.mock.calls.length, 0); + assert.equal(stop.mock.calls.length, 0); + }), + ); }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index f6a9a108a2528..87f33fad57fc1 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -220,38 +220,6 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => { assert.equal(firstEvent.value.turnId, "turn-1"); }), ); - - it.effect("emits canonical events on stream and supports consumer interruption", () => - Effect.gen(function* () { - const adapter = yield* CodexAdapter; - const onEvent = vi.fn(() => undefined); - const consumer = yield* Stream.runForEach(adapter.streamEvents, () => - Effect.sync(() => { - onEvent(); - }), - ).pipe(Effect.forkChild); - - const event: ProviderEvent = { - id: asEventId("evt-1"), - kind: "notification", - provider: "codex", - sessionId: asSessionId("sess-1"), - createdAt: new Date().toISOString(), - method: "turn/started", - turnId: asTurnId("turn-1"), - }; - - lifecycleManager.emit("event", event); - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); - assert.equal(onEvent.mock.calls.length, 1); - - yield* Fiber.interrupt(consumer); - lifecycleManager.emit("event", event); - yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 0))); - assert.equal(onEvent.mock.calls.length, 1); - assert.equal(lifecycleManager.stopAllImpl.mock.calls.length, 0); - }), - ); }); afterAll(() => { From d102702d9556fac36379e13e7a89d21f230ebcb6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 15:03:21 -0800 Subject: [PATCH 45/98] cross platform expandHomePath --- apps/server/src/main.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 5e87a7a912692..4e6e840bcce43 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -60,10 +60,10 @@ const CliEnvConfig = Config.all({ }); const expandHomePath = Effect.fn(function* (input: string) { - const { join } = yield* Path.Path; + const { join, sep } = yield* Path.Path; if (input === "~") return os.homedir(); - if (input.startsWith("~/")) { - return join(os.homedir(), input.slice(2)); + if (input.startsWith(`~${sep}`)) { + return join(os.homedir(), input.slice(sep.length)); } return input; }); From 3379feed8d37d82ba2415e39e8d50958ee680719 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 15:27:41 -0800 Subject: [PATCH 46/98] wal --- apps/server/src/persistence/Layers/Sqlite.ts | 6 +----- apps/server/src/persistence/NodeSqliteClient.ts | 6 ------ 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index b12d4a9b73c5d..cf7e40efa5e96 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -5,7 +5,6 @@ import { runMigrations } from "../Migrations.ts"; type RuntimeSqliteLayerConfig = { readonly filename: string; - readonly disableWAL?: boolean | undefined; }; type Loader = { @@ -46,8 +45,5 @@ export const makeSqlitePersistenceLive = (dbPath: string) => export const SqlitePersistenceMemory = Layer.provideMerge( setup, - makeRuntimeSqliteLayer({ - filename: ":memory:", - disableWAL: true, - }), + makeRuntimeSqliteLayer({ filename: ":memory:" }), ); diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts index f1c88e57a9d72..cdfcee4e98632 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -35,7 +35,6 @@ export interface SqliteClientConfig { readonly allowExtension?: boolean | undefined; readonly prepareCacheSize?: number | undefined; readonly prepareCacheTTL?: Duration.Input | undefined; - readonly disableWAL?: boolean | undefined; readonly spanAttributes?: Record | undefined; readonly transformResultNames?: ((str: string) => string) | undefined; readonly transformQueryNames?: ((str: string) => string) | undefined; @@ -64,10 +63,6 @@ const makeWithDatabase = ( Effect.sync(() => db.close()), ); - if (options.disableWAL !== true) { - db.exec("PRAGMA journal_mode = WAL"); - } - const statementReaderCache = new WeakMap(); const hasRows = (statement: StatementSync): boolean => { const cached = statementReaderCache.get(statement); @@ -203,7 +198,6 @@ const makeMemory = ( ...config, filename: ":memory:", readonly: false, - disableWAL: config.disableWAL ?? true, }, () => { const database = new DatabaseSync(":memory:", { From 84df96e6b3218275cf542be79682c5bc3cc22981 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 17:18:49 -0800 Subject: [PATCH 47/98] nice refactor --- apps/server/src/main.test.ts | 37 +- apps/server/src/main.ts | 50 +- apps/server/src/wsServer.test.ts | 218 +++++---- apps/server/src/wsServer.ts | 813 ++++++++++++++----------------- 4 files changed, 522 insertions(+), 596 deletions(-) diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 0890be8addc9e..a75dbb593769e 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -1,3 +1,4 @@ +import * as Http from "node:http"; import { NodeServices } from "@effect/platform-node"; import { assert, it, vi } from "@effect/vitest"; import * as ConfigProvider from "effect/ConfigProvider"; @@ -10,13 +11,17 @@ import { CliConfig, makeCliCommand, type CliConfigShape } from "./main"; import { Open, type OpenShape } from "./open"; import { Server, type ServerShape, type ServerOptions } from "./wsServer"; -const start = vi.fn(async () => undefined); -const stop = vi.fn(async () => undefined); -const createServer = vi.fn((_: ServerOptions) => ({ - start, - stop, - httpServer: undefined as never, -})); +const start = vi.fn(() => undefined); +const stop = vi.fn(() => undefined); +const createServer = vi.fn((_: ServerOptions) => + Effect.acquireRelease( + Effect.sync(() => { + start(); + return {} as unknown as Http.Server; + }), + () => Effect.sync(() => stop()), + ), +); const findAvailablePort = vi.fn((preferred: number) => Effect.succeed(preferred)); const testLayer = Layer.mergeAll( @@ -47,13 +52,17 @@ const runCli = ( beforeEach(() => { vi.clearAllMocks(); - start.mockImplementation(async () => undefined); - stop.mockImplementation(async () => undefined); - createServer.mockImplementation((_: ServerOptions) => ({ - start, - stop, - httpServer: undefined as never, - })); + start.mockImplementation(() => undefined); + stop.mockImplementation(() => undefined); + createServer.mockImplementation((_: ServerOptions) => + Effect.acquireRelease( + Effect.sync(() => { + start(); + return {} as unknown as Http.Server; + }), + () => Effect.sync(() => stop()), + ), + ); findAvailablePort.mockImplementation((preferred: number) => Effect.succeed(preferred)); }); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 4e6e840bcce43..26d1d70beb210 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -6,7 +6,12 @@ import { Command, Flag } from "effect/unstable/cli"; import { fixPath } from "./fixPath"; import { Open } from "./open"; -import { Server } from "./wsServer"; +import { + makeServerPersistenceLayer, + makeServerProviderLayer, + makeServerRuntimeServicesLayer, + Server, +} from "./wsServer"; const DEFAULT_PORT = 3773; @@ -168,29 +173,26 @@ const makeServerProgram = Effect.fn(function* (input: CliInput) { }); } - yield* Effect.acquireRelease( - Effect.tryPromise({ - try: async () => { - const server = createServer({ - port: config.port, - host: config.mode === "desktop" ? "127.0.0.1" : undefined, - cwd: config.cwd, - autoBootstrapProjectFromCwd: config.mode === "web", - stateDir: config.stateDir, - staticDir: config.staticDir, - devUrl: config.devUrl?.href, - authToken: config.authToken, - }); - await server.start(); - return server; - }, - catch: (cause) => new StartupError({ message: "Failed to start server", cause }), - }), - (server) => - Effect.tryPromise({ - try: () => server.stop(), - catch: (cause) => new StartupError({ message: "Failed to stop server", cause }), - }).pipe(Effect.catch((error) => Effect.logError("failed to stop server cleanly", { error }))), + const persistenceLayer = makeServerPersistenceLayer(config.stateDir); + const providerLayer = makeServerProviderLayer(config.stateDir); + const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); + const serverRuntimeLayer = Layer.merge( + makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), + infrastructureLayer, + ); + + yield* createServer({ + port: config.port, + host: config.mode === "desktop" ? "127.0.0.1" : undefined, + cwd: config.cwd, + autoBootstrapProjectFromCwd: config.mode === "web", + stateDir: config.stateDir, + staticDir: config.staticDir, + devUrl: config.devUrl?.href, + authToken: config.authToken, + }).pipe( + Effect.provide(serverRuntimeLayer), + Effect.mapError((cause) => new StartupError({ message: "Failed to start server", cause })), ); const url = `http://localhost:${config.port}`; diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index b2f2eba5b838e..ab92b43532554 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -1,11 +1,12 @@ +import * as Http from "node:http"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { EventEmitter } from "node:events"; -import { Effect, Layer, PubSub, Stream } from "effect"; +import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect"; import { describe, expect, it, afterEach, vi } from "vitest"; -import { createServer } from "./wsServer"; +import { createServer, makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./wsServer"; import WebSocket from "ws"; import { @@ -49,7 +50,8 @@ interface PendingMessages { const pendingBySocket = new WeakMap(); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asProviderSessionId = (value: string): ProviderSessionId => + ProviderSessionId.makeUnsafe(value); const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); @@ -286,7 +288,8 @@ function mergeWithDefaultsForTest(custom: KeybindingsConfig): ResolvedKeybinding } describe("WebSocket Server", () => { - let server: ReturnType | null = null; + let server: Http.Server | null = null; + let serverScope: Scope.Closeable | null = null; const connections: WebSocket[] = []; const tempDirs: string[] = []; @@ -296,7 +299,7 @@ describe("WebSocket Server", () => { return dir; } - function createTestServer( + async function createTestServer( options: { persistenceLayer?: Layer.Layer; cwd?: string; @@ -312,23 +315,53 @@ describe("WebSocket Server", () => { }; terminalManager?: TerminalManager; } = {}, - ): ReturnType { + ): Promise { + if (serverScope) { + throw new Error("Test server is already running"); + } + const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); - return createServer({ - port: 0, - cwd: options.cwd ?? "/test/project", - stateDir, - persistenceLayer: options.persistenceLayer ?? SqlitePersistenceMemory, - ...(options.autoBootstrapProjectFromCwd !== undefined - ? { autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd } - : {}), - ...(options.devUrl ? { devUrl: options.devUrl } : {}), - ...(options.authToken ? { authToken: options.authToken } : {}), - ...(options.providerLayer ? { providerLayer: options.providerLayer } : {}), - ...(options.open ? { open: options.open } : {}), - ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), - ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), - }); + const scope = await Effect.runPromise(Scope.make("sequential")); + const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; + const providerLayer = options.providerLayer ?? makeServerProviderLayer(stateDir); + const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); + const runtimeLayer = Layer.merge( + makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), + infrastructureLayer, + ); + const runtimeServices = await Effect.runPromise( + Layer.build(runtimeLayer).pipe(Scope.provide(scope)), + ); + + try { + const runtime = await Effect.runPromise( + createServer({ + port: 0, + cwd: options.cwd ?? "/test/project", + stateDir, + ...(options.autoBootstrapProjectFromCwd !== undefined + ? { autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd } + : {}), + ...(options.devUrl ? { devUrl: options.devUrl } : {}), + ...(options.authToken ? { authToken: options.authToken } : {}), + ...(options.open ? { open: options.open } : {}), + ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), + ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), + }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)), + ); + serverScope = scope; + return runtime; + } catch (error) { + await Effect.runPromise(Scope.close(scope, Exit.void)); + throw error; + } + } + + async function closeTestServer() { + if (!serverScope) return; + const scope = serverScope; + serverScope = null; + await Effect.runPromise(Scope.close(scope, Exit.void)); } afterEach(async () => { @@ -336,9 +369,7 @@ describe("WebSocket Server", () => { ws.close(); } connections.length = 0; - if (server) { - await server.stop(); - } + await closeTestServer(); server = null; for (const dir of tempDirs.splice(0, tempDirs.length)) { fs.rmSync(dir, { recursive: true, force: true }); @@ -347,10 +378,9 @@ describe("WebSocket Server", () => { }); it("sends welcome message on connect", async () => { - server = createTestServer({ cwd: "/test/project" }); + server = await createTestServer({ cwd: "/test/project" }); // Get the actual port after listen - await server.start(); - const addr = server.httpServer.address(); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -367,12 +397,11 @@ describe("WebSocket Server", () => { }); it("bootstraps the cwd project on startup when enabled", async () => { - server = createTestServer({ + server = await createTestServer({ cwd: "/test/bootstrap-workspace", autoBootstrapProjectFromCwd: true, }); - await server.start(); - const addr = server.httpServer.address(); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -402,12 +431,11 @@ describe("WebSocket Server", () => { // Keep test output clean while verifying websocket logs. }); - server = createTestServer({ + server = await createTestServer({ cwd: "/test/project", devUrl: "http://localhost:5173", }); - await server.start(); - const addr = server.httpServer.address(); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -430,9 +458,8 @@ describe("WebSocket Server", () => { it("responds to server.getConfig", async () => { const fakeHome = makeTempDir("t3code-home-"); vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - server = createTestServer({ cwd: "/my/workspace" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/my/workspace" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -459,9 +486,8 @@ describe("WebSocket Server", () => { }, }; - server = createTestServer({ cwd: "/my/workspace", open: openService }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/my/workspace", open: openService }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -491,9 +517,8 @@ describe("WebSocket Server", () => { ); vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - server = createTestServer({ cwd: "/my/workspace" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/my/workspace" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -528,9 +553,8 @@ describe("WebSocket Server", () => { ); vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - server = createTestServer({ cwd: "/my/workspace" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/my/workspace" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -563,9 +587,8 @@ describe("WebSocket Server", () => { ); vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - server = createTestServer({ cwd: "/my/workspace" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/my/workspace" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -598,9 +621,8 @@ describe("WebSocket Server", () => { ); vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - server = createTestServer({ cwd: "/my/workspace" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/my/workspace" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -637,9 +659,8 @@ describe("WebSocket Server", () => { ); vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - server = createTestServer({ cwd: "/my/workspace" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/my/workspace" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -690,9 +711,8 @@ describe("WebSocket Server", () => { ); vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - server = createTestServer({ cwd: "/my/workspace" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/my/workspace" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -721,9 +741,8 @@ describe("WebSocket Server", () => { fs.writeFileSync(path.join(configDir, "keybindings.json"), "{not-json", "utf8"); vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - server = createTestServer({ cwd: "/my/workspace" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/my/workspace" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -745,9 +764,8 @@ describe("WebSocket Server", () => { }); it("returns error for unknown methods", async () => { - server = createTestServer({ cwd: "/test" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -762,9 +780,8 @@ describe("WebSocket Server", () => { }); it("returns error when requesting turn diff for unknown thread", async () => { - server = createTestServer({ cwd: "/test" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -781,9 +798,8 @@ describe("WebSocket Server", () => { }); it("returns error when requesting full thread diff for unknown thread", async () => { - server = createTestServer({ cwd: "/test" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -799,9 +815,8 @@ describe("WebSocket Server", () => { }); it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => { - server = createTestServer({ cwd: "/test" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -873,12 +888,11 @@ describe("WebSocket Server", () => { }; const providerLayer = Layer.succeed(ProviderService, providerService); - server = createTestServer({ + server = await createTestServer({ cwd: "/test", providerLayer, }); - await server.start(); - const addr = server.httpServer.address(); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -941,7 +955,9 @@ describe("WebSocket Server", () => { const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } }; - return event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1"; + return ( + event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1" + ); }); const domainEvent = domainPush.data as { @@ -956,12 +972,11 @@ describe("WebSocket Server", () => { it("routes terminal RPC methods and broadcasts terminal events", async () => { const cwd = makeTempDir("t3code-ws-terminal-cwd-"); const terminalManager = new MockTerminalManager(); - server = createTestServer({ + server = await createTestServer({ cwd: "/test", terminalManager: terminalManager as unknown as TerminalManager, }); - await server.start(); - const addr = server.httpServer.address(); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -1027,24 +1042,22 @@ describe("WebSocket Server", () => { it("detaches terminal event listener on stop for injected manager", async () => { const terminalManager = new MockTerminalManager(); - server = createTestServer({ + server = await createTestServer({ cwd: "/test", terminalManager: terminalManager as unknown as TerminalManager, }); - await server.start(); expect(terminalManager.listenerCount("event")).toBe(1); - await server.stop(); + await closeTestServer(); server = null; expect(terminalManager.listenerCount("event")).toBe(0); }); it("returns validation errors for invalid terminal open params", async () => { - server = createTestServer({ cwd: "/test" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -1061,9 +1074,8 @@ describe("WebSocket Server", () => { }); it("handles invalid JSON gracefully", async () => { - server = createTestServer({ cwd: "/test" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -1096,9 +1108,8 @@ describe("WebSocket Server", () => { }); it("returns errors for removed projects CRUD methods", async () => { - server = createTestServer({ cwd: "/test" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -1134,9 +1145,8 @@ describe("WebSocket Server", () => { fs.mkdirSync(path.join(workspace, ".git"), { recursive: true }); fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); - server = createTestServer({ cwd: "/test" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -1161,9 +1171,8 @@ describe("WebSocket Server", () => { it("supports git methods over websocket", async () => { const repoCwd = makeTempDir("t3code-ws-git-project-"); - server = createTestServer({ cwd: "/test" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -1209,9 +1218,8 @@ describe("WebSocket Server", () => { runStackedAction: vi.fn(), }; - server = createTestServer({ cwd: "/test", gitManager }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/test", gitManager }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -1232,9 +1240,8 @@ describe("WebSocket Server", () => { runStackedAction: vi.fn().mockRejectedValue(new Error("Cannot push from detached HEAD.")), }; - server = createTestServer({ cwd: "/test", gitManager }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/test", gitManager }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; const ws = await connectWs(port); @@ -1254,9 +1261,8 @@ describe("WebSocket Server", () => { }); it("rejects websocket connections without a valid auth token", async () => { - server = createTestServer({ cwd: "/test", authToken: "secret-token" }); - await server.start(); - const addr = server.httpServer.address(); + server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); + const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 5be76b1cd9547..79deff0cf3a1b 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -12,11 +12,12 @@ import { WS_CHANNELS, WS_METHODS, WebSocketRequest, + WebSocketResponse, WsPush, WsResponse, } from "@t3tools/contracts"; -import { NodeServices } from "@effect/platform-node"; -import { Effect, Exit, Layer, ManagedRuntime, Schema, Scope, ServiceMap, Stream } from "effect"; +import { NodeHttpServer, NodeServices } from "@effect/platform-node"; +import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream } from "effect"; import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; @@ -40,34 +41,21 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; -import { - OrchestrationEngineService, - type OrchestrationEngineShape, -} from "./orchestration/Services/OrchestrationEngine"; -import { - ProjectionSnapshotQuery, - type ProjectionSnapshotQueryShape, -} from "./orchestration/Services/ProjectionSnapshotQuery"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; -import assert from "node:assert"; import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; -import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; +import { ProviderService } from "./provider/Services/ProviderService"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; -import { - CheckpointStore, - type CheckpointStoreShape, -} from "./checkpointing/Services/CheckpointStore"; -import { makeEventNdjsonLogger, type EventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; +import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as SqlError from "effect/unstable/sql/SqlError"; -import * as Migrator from "effect/unstable/sql/Migrator"; import { clamp } from "effect/Number"; import { OpenDefaultService, type OpenShape } from "./open"; @@ -92,37 +80,24 @@ export interface ServerOptions { cwd: string; autoBootstrapProjectFromCwd?: boolean | undefined; stateDir?: string | undefined; - persistenceLayer?: - | Layer.Layer - | undefined; staticDir?: string | undefined; devUrl?: string | undefined; logWebSocketEvents?: boolean | undefined; gitManager?: GitManager | undefined; terminalManager?: TerminalManager | undefined; - providerLayer?: Layer.Layer | undefined; authToken?: string | undefined; open?: OpenShape | undefined; } -export interface ServerRuntime { - readonly start: () => Promise; - readonly stop: () => Promise; - readonly httpServer: http.Server; -} - export interface ServerShape { - readonly createServer: (options: ServerOptions) => ServerRuntime; + readonly createServer: ( + options: ServerOptions, + ) => Effect.Effect; readonly stopSignal: Effect.Effect; } export class Server extends ServiceMap.Service()("server/Server") {} -export const ServerLive = Layer.succeed(Server, { - createServer, - stopSignal: Effect.never, -} satisfies ServerShape); - const isServerNotRunningError = (error: unknown): boolean => { if (!(error instanceof Error)) return false; const maybeCode = (error as NodeJS.ErrnoException).code; @@ -195,47 +170,104 @@ function checkpointRefForThreadTurn(threadId: string, turnCount: number): Checkp return CheckpointRef.makeUnsafe(`${CHECKPOINT_REFS_PREFIX}/${encodedThreadId}/turn/${turnCount}`); } -type EffectRuntime = ManagedRuntime.ManagedRuntime< +export type ServerCoreRuntimeServices = | OrchestrationEngineService | ProjectionSnapshotQuery - | ProviderService | CheckpointStore - | OrchestrationReactor, - unknown ->; + | OrchestrationReactor; + +export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService; -export function createServer(options: ServerOptions): ServerRuntime { +export function makeServerPersistenceLayer(stateDir: string) { + const dbPath = path.join(stateDir, "orchestration.sqlite"); + return makeSqlitePersistenceLive(dbPath); +} + +export function makeServerProviderLayer( + stateDir: string, +): Layer.Layer { + const providerLogsDir = path.join(stateDir, "logs", "providers"); + const codexAdapterLayer = makeCodexAdapterLive({ + nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), + }); + const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(codexAdapterLayer)); + const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntimeRepositoryLive), + ); + return makeProviderServiceLive({ + canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"), + }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); +} + +export function makeServerRuntimeServicesLayer(): Layer.Layer< + ServerCoreRuntimeServices, + unknown, + SqlClient.SqlClient | ProviderService +> { + const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionPipelineLive), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + ); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); + + const runtimeServicesLayer = Layer.mergeAll( + orchestrationLayer, + OrchestrationProjectionSnapshotQueryLive, + checkpointStoreLayer, + ); + const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const checkpointReactorLayer = CheckpointReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const orchestrationReactorLayer = OrchestrationReactorLive.pipe( + Layer.provideMerge(runtimeIngestionLayer), + Layer.provideMerge(providerCommandReactorLayer), + Layer.provideMerge(checkpointReactorLayer), + ); + + return orchestrationReactorLayer; +} + +export class ServerLifecycleError extends Schema.TaggedErrorClass()( + "ServerLifecycleError", + { + operation: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + +class RouteRequestError extends Schema.TaggedErrorClass()("RouteRequestError", { + message: Schema.String, +}) {} + +export const createServer = Effect.fn(function* ( + options: ServerOptions, +): Effect.fn.Return { const { port, host, cwd, autoBootstrapProjectFromCwd = false, - stateDir, - persistenceLayer: customPersistenceLayer, staticDir, devUrl, logWebSocketEvents: explicitLogWsEvents, gitManager = new GitManager(), terminalManager = new TerminalManager(), - providerLayer: customProviderLayer, authToken, open: openService = OpenDefaultService, } = options; - const resolvedStateDir = stateDir ?? path.join(os.homedir(), ".t3", "userdata"); - let effectRuntime: EffectRuntime | null = null; - let orchestrationEngine: OrchestrationEngineShape | null = null; - let projectionSnapshotQuery: ProjectionSnapshotQueryShape | null = null; - let providerService: ProviderServiceShape | null = null; - let checkpointStore: CheckpointStoreShape | null = null; const clients = new Set(); const logger = createLogger("ws"); const logWebSocketEvents = explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); let keybindingsConfig = loadResolvedKeybindingsConfig(logger); - let orchestrationDomainEventLogger: EventNdjsonLogger | undefined; - let orchestrationCommandLogger: EventNdjsonLogger | undefined; - let subscriptionsScope: Scope.Closeable | null = null; function logOutgoingPush(push: WsPush, recipients: number) { if (!logWebSocketEvents) return; @@ -246,86 +278,6 @@ export function createServer(options: ServerOptions): ServerRuntime { }); } - const getOrchestrationEngine = () => { - assert(orchestrationEngine, "Orchestration engine is not started"); - return orchestrationEngine; - }; - - const getEffectRuntime = () => { - assert(effectRuntime, "Effect runtime is not started"); - return effectRuntime; - }; - - const getProjectionSnapshotQuery = () => { - assert(projectionSnapshotQuery, "Projection snapshot query is not started"); - return projectionSnapshotQuery; - }; - - const getCheckpointStore = () => { - assert(checkpointStore, "Checkpoint store is not started"); - return checkpointStore; - }; - - async function bootstrapProjectForCwd() { - if (!autoBootstrapProjectFromCwd) { - return; - } - - const runtime = getEffectRuntime(); - const snapshotQuery = getProjectionSnapshotQuery(); - const engine = getOrchestrationEngine(); - const snapshot = await runtime.runPromise(snapshotQuery.getSnapshot()); - const existing = snapshot.projects.find((project) => project.workspaceRoot === cwd); - if (existing) { - return; - } - - const createdAt = new Date().toISOString(); - const projectName = path.basename(cwd) || "project"; - await runtime.runPromise( - engine.dispatchUnknownCommand({ - type: "project.create", - commandId: crypto.randomUUID(), - projectId: crypto.randomUUID(), - title: projectName, - workspaceRoot: cwd, - defaultModel: "gpt-5-codex", - createdAt, - }), - ); - } - - const attachSubscriptionss = Effect.gen(function* () { - const orchestrationEngine = yield* OrchestrationEngineService; - const orchestrationReactor = yield* OrchestrationReactor; - subscriptionsScope = yield* Scope.make("sequential"); - - yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => - Effect.sync(() => { - orchestrationDomainEventLogger?.write({ - observedAt: new Date().toISOString(), - event, - }); - const push: WsPush = { - type: "push", - channel: ORCHESTRATION_WS_CHANNELS.domainEvent, - 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; - } - } - logOutgoingPush(push, recipients); - }), - ).pipe(Effect.forkIn(subscriptionsScope)); - - yield* orchestrationReactor.start.pipe(Scope.provide(subscriptionsScope)); - }); - const onTerminalEvent = (event: TerminalEvent) => { const push: WsPush = { type: "push", @@ -342,7 +294,6 @@ export function createServer(options: ServerOptions): ServerRuntime { } logOutgoingPush(push, recipients); }; - terminalManager.on("event", onTerminalEvent); // HTTP server — serves static files or redirects to Vite dev server const httpServer = http.createServer((req, res) => { @@ -403,104 +354,85 @@ export function createServer(options: ServerOptions): ServerRuntime { // WebSocket server — upgrades from the HTTP server const wss = new WebSocketServer({ noServer: true }); - httpServer.on("upgrade", (request, socket, head) => { - if (authToken) { - let providedToken: string | null = null; - try { - const url = new URL(request.url ?? "/", `http://localhost:${port}`); - providedToken = url.searchParams.get("token"); - } catch { - rejectUpgrade(socket, 400, "Invalid WebSocket URL"); - return; - } - - if (providedToken !== authToken) { - rejectUpgrade(socket, 401, "Unauthorized WebSocket connection"); - return; + const closeWebSocketServer = Effect.callback((resume) => { + wss.close((error) => { + if (error && !isServerNotRunningError(error)) { + resume( + Effect.fail( + new ServerLifecycleError({ operation: "closeWebSocketServer", cause: error }), + ), + ); + } else { + resume(Effect.void); } - } - - wss.handleUpgrade(request, socket, head, (ws) => { - wss.emit("connection", ws, request); }); }); - 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, - data: { cwd, projectName }, - }; - logOutgoingPush(welcome, 1); - ws.send(JSON.stringify(welcome)); + const closeAllClients = () => { + for (const client of clients) { + client.close(); + } + clients.clear(); + }; - ws.on("message", (raw) => { - void handleMessage(ws, raw); - }); + const listenOptions = host ? { host, port } : { port }; - ws.on("close", () => { - clients.delete(ws); - }); + const orchestrationEngine = yield* OrchestrationEngineService; + const projectionReadModelQuery = yield* ProjectionSnapshotQuery; + const liveCheckpointStore = yield* CheckpointStore; + const liveProviderService = yield* ProviderService; + const orchestrationReactor = yield* OrchestrationReactor; - ws.on("error", () => { - clients.delete(ws); - }); - }); + const subscriptionsScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => + Scope.close(subscriptionsScope, Exit.void).pipe(Effect.orElseSucceed(() => undefined)), + ); - async function handleMessage(ws: WebSocket, raw: unknown) { - const messageText = websocketRawToString(raw); - if (messageText === null) { - const errorResponse: WsResponse = { - id: "unknown", - error: { message: "Invalid request format" }, + yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => + Effect.sync(() => { + const push: WsPush = { + type: "push", + channel: ORCHESTRATION_WS_CHANNELS.domainEvent, + data: event, }; - ws.send(JSON.stringify(errorResponse)); - return; - } + const message = JSON.stringify(push); + let recipients = 0; + for (const client of clients) { + if (client.readyState === client.OPEN) { + client.send(message); + recipients += 1; + } + } + logOutgoingPush(push, recipients); + }), + ).pipe(Effect.forkIn(subscriptionsScope)); - let request: WebSocketRequest; - try { - request = Schema.decodeUnknownSync(Schema.fromJsonString(WebSocketRequest))(messageText); - } catch { - const errorResponse: WsResponse = { - id: "unknown", - error: { message: "Invalid request format" }, - }; - ws.send(JSON.stringify(errorResponse)); - return; - } + yield* orchestrationReactor.start.pipe(Scope.provide(subscriptionsScope)); - try { - const result = await routeRequest(request); - const response: WsResponse = { id: request.id, result }; - ws.send(JSON.stringify(response)); - } catch (err) { - const message = err instanceof Error ? err.message : "Unknown server error"; - const response: WsResponse = { - id: request.id, - error: { message }, - }; - ws.send(JSON.stringify(response)); + if (autoBootstrapProjectFromCwd) { + const snapshot = yield* projectionReadModelQuery.getSnapshot(); + const existing = snapshot.projects.find((project) => project.workspaceRoot === cwd); + if (!existing) { + const createdAt = new Date().toISOString(); + const projectName = path.basename(cwd) || "project"; + yield* orchestrationEngine.dispatchUnknownCommand({ + type: "project.create", + commandId: crypto.randomUUID(), + projectId: crypto.randomUUID(), + title: projectName, + workspaceRoot: cwd, + defaultModel: "gpt-5-codex", + createdAt, + }); } } - async function routeRequest(request: WebSocketRequest): Promise { - const orchestrationEngine = getOrchestrationEngine(); - const projectionReadModelQuery = getProjectionSnapshotQuery(); - const effectRuntime = getEffectRuntime(); - const liveCheckpointStore = getCheckpointStore(); - - const computeTurnDiff = async (input: { + const routeRequest = Effect.fn(function* (request: WebSocketRequest) { + const computeTurnDiff = Effect.fn(function* (input: { readonly threadId: string; readonly fromTurnCount: number; readonly toTurnCount: number; - }) => { + }) { if (input.fromTurnCount > input.toTurnCount) { throw new Error( `Invalid turn diff range for thread '${input.threadId}': from ${input.fromTurnCount} exceeds to ${input.toTurnCount}.`, @@ -516,7 +448,7 @@ export function createServer(options: ServerOptions): ServerRuntime { }; } - const snapshot = await effectRuntime.runPromise(projectionReadModelQuery.getSnapshot()); + const snapshot = yield* projectionReadModelQuery.getSnapshot(); const thread = snapshot.threads.find((entry) => entry.id === input.threadId); if (!thread) { throw new Error(`Thread '${input.threadId}' not found.`); @@ -562,20 +494,19 @@ export function createServer(options: ServerOptions): ServerRuntime { ); } - const [fromExists, toExists] = await Promise.all([ - effectRuntime.runPromise( + const [fromExists, toExists] = yield* Effect.all( + [ liveCheckpointStore.hasCheckpointRef({ cwd: workspaceCwd, checkpointRef: fromCheckpointRef, }), - ), - effectRuntime.runPromise( liveCheckpointStore.hasCheckpointRef({ cwd: workspaceCwd, checkpointRef: toCheckpointRef, }), - ), - ]); + ], + { concurrency: "unbounded" }, + ); if (!fromExists) { throw new Error( `Filesystem checkpoint is unavailable for turn ${input.fromTurnCount} in thread ${input.threadId}.`, @@ -587,304 +518,282 @@ export function createServer(options: ServerOptions): ServerRuntime { ); } - const diff = await effectRuntime.runPromise( - liveCheckpointStore.diffCheckpoints({ - cwd: workspaceCwd, - fromCheckpointRef, - toCheckpointRef, - fallbackFromToHead: false, - }), - ); + const diff = yield* liveCheckpointStore.diffCheckpoints({ + cwd: workspaceCwd, + fromCheckpointRef, + toCheckpointRef, + fallbackFromToHead: false, + }); return { threadId: input.threadId, fromTurnCount: input.fromTurnCount, toTurnCount: input.toTurnCount, diff, }; - }; + }); switch (request.body._tag) { - case ORCHESTRATION_WS_METHODS.getSnapshot: { - return effectRuntime.runPromise(projectionReadModelQuery.getSnapshot()); - } + case ORCHESTRATION_WS_METHODS.getSnapshot: + return yield* projectionReadModelQuery.getSnapshot(); case ORCHESTRATION_WS_METHODS.dispatchCommand: { - orchestrationCommandLogger?.write({ - observedAt: new Date().toISOString(), - command: request.body.command, - }); - return effectRuntime.runPromise(orchestrationEngine.dispatch(request.body.command)); + const { command } = request.body; + return yield* orchestrationEngine.dispatch(command); } case ORCHESTRATION_WS_METHODS.getTurnDiff: { const body = stripRequestTag(request.body); - return computeTurnDiff(body); + return yield* computeTurnDiff(body); } case ORCHESTRATION_WS_METHODS.getFullThreadDiff: { const body = stripRequestTag(request.body); - return computeTurnDiff({ + return yield* computeTurnDiff({ threadId: body.threadId, fromTurnCount: 0, toTurnCount: body.toTurnCount, }); } - case ORCHESTRATION_WS_METHODS.replayEvents: - return effectRuntime.runPromise( - Stream.runCollect( - orchestrationEngine.readEvents( - clamp(request.body.fromSequenceExclusive, { - maximum: Number.MAX_SAFE_INTEGER, - minimum: 0, - }), - ), - ).pipe(Effect.map((events) => Array.from(events))), - ); + case ORCHESTRATION_WS_METHODS.replayEvents: { + const { fromSequenceExclusive } = request.body; + return yield* Stream.runCollect( + orchestrationEngine.readEvents( + clamp(fromSequenceExclusive, { + maximum: Number.MAX_SAFE_INTEGER, + minimum: 0, + }), + ), + ).pipe(Effect.map((events) => Array.from(events))); + } - case WS_METHODS.projectsSearchEntries: - return searchWorkspaceEntries(stripRequestTag(request.body)); + case WS_METHODS.projectsSearchEntries: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => searchWorkspaceEntries(body)); + } case WS_METHODS.shellOpenInEditor: { - await Effect.runPromise(openService.openInEditor(stripRequestTag(request.body))); - return undefined; + const body = stripRequestTag(request.body); + return yield* openService.openInEditor(body); } - case WS_METHODS.gitStatus: - return gitManager.status(stripRequestTag(request.body)); + case WS_METHODS.gitStatus: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => gitManager.status(body)); + } - case WS_METHODS.gitPull: - return pullGitBranch(stripRequestTag(request.body)); + case WS_METHODS.gitPull: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => pullGitBranch(body)); + } - case WS_METHODS.gitRunStackedAction: - return gitManager.runStackedAction(stripRequestTag(request.body)); - case WS_METHODS.gitListBranches: - return listGitBranches(stripRequestTag(request.body)); + case WS_METHODS.gitRunStackedAction: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => gitManager.runStackedAction(body)); + } - case WS_METHODS.gitCreateWorktree: - return createGitWorktree(stripRequestTag(request.body)); + case WS_METHODS.gitListBranches: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => listGitBranches(body)); + } - case WS_METHODS.gitRemoveWorktree: - return removeGitWorktree(stripRequestTag(request.body)); + case WS_METHODS.gitCreateWorktree: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => createGitWorktree(body)); + } - case WS_METHODS.gitCreateBranch: - return createGitBranch(stripRequestTag(request.body)); + case WS_METHODS.gitRemoveWorktree: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => removeGitWorktree(body)); + } - case WS_METHODS.gitCheckout: - return checkoutGitBranch(stripRequestTag(request.body)); + case WS_METHODS.gitCreateBranch: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => createGitBranch(body)); + } - case WS_METHODS.gitInit: - return initGitRepo(stripRequestTag(request.body)); + case WS_METHODS.gitCheckout: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => checkoutGitBranch(body)); + } - case WS_METHODS.terminalOpen: - return terminalManager.open(stripRequestTag(request.body)); + case WS_METHODS.gitInit: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => initGitRepo(body)); + } - case WS_METHODS.terminalWrite: - await terminalManager.write(stripRequestTag(request.body)); - return undefined; + case WS_METHODS.terminalOpen: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => terminalManager.open(body)); + } - case WS_METHODS.terminalResize: - await terminalManager.resize(stripRequestTag(request.body)); - return undefined; + case WS_METHODS.terminalWrite: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => terminalManager.write(body)); + } - case WS_METHODS.terminalClear: - await terminalManager.clear(stripRequestTag(request.body)); - return undefined; + case WS_METHODS.terminalResize: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => terminalManager.resize(body)); + } + + case WS_METHODS.terminalClear: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => terminalManager.clear(body)); + } - case WS_METHODS.terminalRestart: - return terminalManager.restart(stripRequestTag(request.body)); + case WS_METHODS.terminalRestart: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => terminalManager.restart(body)); + } - case WS_METHODS.terminalClose: - await terminalManager.close(stripRequestTag(request.body)); - return undefined; + case WS_METHODS.terminalClose: { + const body = stripRequestTag(request.body); + return yield* Effect.promise(() => terminalManager.close(body)); + } case WS_METHODS.serverGetConfig: - return { - cwd, - keybindings: keybindingsConfig, - }; + return { cwd, keybindings: keybindingsConfig }; - case WS_METHODS.serverUpsertKeybinding: - keybindingsConfig = upsertKeybindingRule(logger, stripRequestTag(request.body)); - return { - keybindings: keybindingsConfig, - }; + case WS_METHODS.serverUpsertKeybinding: { + const body = stripRequestTag(request.body); + keybindingsConfig = upsertKeybindingRule(logger, body); + return { keybindings: keybindingsConfig }; + } default: { const _exhaustiveCheck: never = request.body; - throw new Error(`Unknown method: ${String(_exhaustiveCheck)}`); - } - } - } - - async function createEffectRuntime() { - const dbPath = path.join(resolvedStateDir, "orchestration.sqlite"); - const providerLogsDir = path.join(resolvedStateDir, "logs", "providers"); - const orchestrationLogsDir = path.join(resolvedStateDir, "logs", "orchestration"); - orchestrationDomainEventLogger = makeEventNdjsonLogger( - path.join(orchestrationLogsDir, "orchestration-domain.ndjson"), - ); - orchestrationCommandLogger = makeEventNdjsonLogger( - path.join(orchestrationLogsDir, "orchestration-command.ndjson"), - ); - const persistenceLayer = customPersistenceLayer ?? makeSqlitePersistenceLive(dbPath); - const orchestrationLayer = OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionPipelineLive), - Layer.provide(OrchestrationEventStoreLive), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), - ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); - const providerLayer = - customProviderLayer ?? - (() => { - const codexAdapterLayer = makeCodexAdapterLive({ - nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), + return yield* new RouteRequestError({ + message: `Unknown method: ${String(_exhaustiveCheck)}`, }); - const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( - Layer.provide(codexAdapterLayer), - ); - const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), - ); - return makeProviderServiceLive({ - canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"), - }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); - })(); - - const runtimeServicesLayer = Layer.mergeAll( - orchestrationLayer, - OrchestrationProjectionSnapshotQueryLive, - checkpointStoreLayer, - providerLayer, - ); - const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const checkpointReactorLayer = CheckpointReactorLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const orchestrationReactorLayer = OrchestrationReactorLive.pipe( - Layer.provideMerge(runtimeIngestionLayer), - Layer.provideMerge(providerCommandReactorLayer), - Layer.provideMerge(checkpointReactorLayer), - ); - const layer = orchestrationReactorLayer.pipe( - Layer.provide(persistenceLayer), - Layer.provideMerge(NodeServices.layer), - ); - const runtime = ManagedRuntime.make(layer); - - try { - const [ - nextOrchestrationEngine, - nextProjectionSnapshotQuery, - nextCheckpointStore, - nextProviderService, - ] = await Promise.all([ - runtime.runPromise(Effect.service(OrchestrationEngineService)), - runtime.runPromise(Effect.service(ProjectionSnapshotQuery)), - runtime.runPromise(Effect.service(CheckpointStore)), - runtime.runPromise(Effect.service(ProviderService)), - ]); - orchestrationEngine = nextOrchestrationEngine; - projectionSnapshotQuery = nextProjectionSnapshotQuery; - checkpointStore = nextCheckpointStore; - providerService = nextProviderService; - await runtime.runPromise(attachSubscriptionss); - } catch (error) { - await runtime.dispose().catch(() => undefined); - throw error; + } } + }); - return runtime; - } + const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) { + const encodeResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse)); - async function disposeEffectRuntime() { - if (subscriptionsScope && effectRuntime) { - await effectRuntime.runPromise(Scope.close(subscriptionsScope, Exit.void)); - subscriptionsScope = null; + const messageText = websocketRawToString(raw); + if (messageText === null) { + const errorResponse = yield* encodeResponse( + new WebSocketResponse({ + id: "unknown", + error: { message: "Invalid request format: Failed to read message" }, + }), + ); + ws.send(errorResponse); + return; } - orchestrationDomainEventLogger?.close(); - orchestrationDomainEventLogger = undefined; - orchestrationCommandLogger?.close(); - orchestrationCommandLogger = undefined; - - const runtime = effectRuntime; - const liveProviderService = providerService; - effectRuntime = null; - orchestrationEngine = null; - projectionSnapshotQuery = null; - checkpointStore = null; - providerService = null; - - if (!runtime) { + + const request = Schema.decodeExit(Schema.fromJsonString(WebSocketRequest))(messageText); + if (request._tag === "Failure") { + const errorResponse = yield* encodeResponse( + new WebSocketResponse({ + id: "unknown", + error: { message: `Invalid request format: ${Cause.pretty(request.cause)}` }, + }), + ); + ws.send(errorResponse); return; } - if (liveProviderService) { - await runtime.runPromise(liveProviderService.stopAll()).catch(() => undefined); + const result = yield* Effect.exit(routeRequest(request.value)); + if (result._tag === "Failure") { + const errorResponse = yield* encodeResponse( + new WebSocketResponse({ + id: request.value.id, + error: { message: Cause.pretty(result.cause) }, + }), + ); + ws.send(errorResponse); + return; } - await runtime.dispose(); - } + const response = yield* encodeResponse( + new WebSocketResponse({ + id: request.value.id, + result: result.value, + }), + ); + ws.send(response); + }); - async function start() { - effectRuntime = await createEffectRuntime(); - await bootstrapProjectForCwd(); + httpServer.on("upgrade", (request, socket, head) => { + if (authToken) { + let providedToken: string | null = null; + try { + const url = new URL(request.url ?? "/", `http://localhost:${port}`); + providedToken = url.searchParams.get("token"); + } catch { + rejectUpgrade(socket, 400, "Invalid WebSocket URL"); + return; + } - return new Promise((resolve, reject) => { - const onError = (error: Error) => { - httpServer.off("error", onError); - void disposeEffectRuntime().finally(() => reject(error)); - }; - httpServer.once("error", onError); - const onListening = () => { - httpServer.off("error", onError); - resolve(); - }; - if (host) { - httpServer.listen(port, host, onListening); + if (providedToken !== authToken) { + rejectUpgrade(socket, 401, "Unauthorized WebSocket connection"); return; } - httpServer.listen(port, onListening); + } + + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit("connection", ws, request); }); - } + }); - async function stop(): Promise { - await disposeEffectRuntime(); - terminalManager.off("event", onTerminalEvent); - terminalManager.dispose(); + wss.on("connection", (ws) => { + clients.add(ws); - for (const client of clients) { - client.close(); - } - clients.clear(); + const segments = cwd.split(/[/\\]/).filter(Boolean); + const projectName = segments[segments.length - 1] ?? "project"; - const closeWebSocketServer = new Promise((resolve, reject) => { - wss.close((error) => { - if (error && !isServerNotRunningError(error)) { - reject(error); - return; - } - resolve(); - }); + const welcome: WsPush = { + type: "push", + channel: WS_CHANNELS.serverWelcome, + data: { cwd, projectName }, + }; + logOutgoingPush(welcome, 1); + ws.send(JSON.stringify(welcome)); + + ws.on("message", (raw) => { + void Effect.runPromise(handleMessage(ws, raw)); }); - const closeHttpServer = new Promise((resolve, reject) => { - httpServer.close((error) => { - if (error && !isServerNotRunningError(error)) { - reject(error); - return; - } - resolve(); - }); + ws.on("close", () => { + clients.delete(ws); }); - await Promise.all([closeWebSocketServer, closeHttpServer]); - } + ws.on("error", () => { + clients.delete(ws); + }); + }); - return { start, stop, httpServer }; -} + yield* Effect.addFinalizer(() => + Effect.catch(liveProviderService.stopAll(), (cause) => + Effect.logWarning("failed to stop provider service", { cause }), + ), + ); + + terminalManager.on("event", onTerminalEvent); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + terminalManager.off("event", onTerminalEvent); + terminalManager.dispose(); + }), + ); + + yield* NodeHttpServer.make(() => httpServer, listenOptions); + + yield* Effect.addFinalizer(() => + Effect.all([ + Effect.sync(() => closeAllClients()), + closeWebSocketServer.pipe(Effect.orElseSucceed(() => undefined)), + ]), + ); + + return httpServer; +}); + +export const ServerLive = Layer.succeed(Server, { + createServer, + stopSignal: Effect.never, +} satisfies ServerShape); From 69d5caa0f94522b5c90285aa752d9410f756a86e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 18:25:26 -0800 Subject: [PATCH 48/98] alright --- apps/server/src/config.ts | 87 ++++++ apps/server/src/fixPath.ts | 18 -- apps/server/src/index.ts | 12 +- apps/server/src/main.test.ts | 37 ++- apps/server/src/main.ts | 307 ++++++++----------- apps/server/src/open.ts | 61 ++-- apps/server/src/os-jank.ts | 39 +++ apps/server/src/persistence/Layers/Sqlite.ts | 8 + apps/server/src/serverLayers.ts | 83 +++++ apps/server/src/wsServer.test.ts | 42 ++- apps/server/src/wsServer.ts | 117 +------ 11 files changed, 436 insertions(+), 375 deletions(-) create mode 100644 apps/server/src/config.ts delete mode 100644 apps/server/src/fixPath.ts create mode 100644 apps/server/src/os-jank.ts create mode 100644 apps/server/src/serverLayers.ts diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts new file mode 100644 index 0000000000000..d23553c9c832a --- /dev/null +++ b/apps/server/src/config.ts @@ -0,0 +1,87 @@ +import * as Net from "node:net"; +import { Data, Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; + +export const DEFAULT_PORT = 3773; + +export type RuntimeMode = "web" | "desktop"; + +export interface ServerConfigShape { + readonly mode: RuntimeMode; + readonly port: number; + readonly host: string | undefined; + readonly cwd: string; + readonly stateDir: string; + readonly staticDir: string | undefined; + readonly devUrl: URL | undefined; + readonly noBrowser: boolean; + readonly authToken: string | undefined; +} + +export class ServerConfig extends ServiceMap.Service()( + "server/ServerConfig", +) {} + +// Helpers + +export class NetError extends Data.TaggedError("NetError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export interface NetServiceShape { + readonly findAvailablePort: (preferred: number) => Effect.Effect; +} + +export class NetService extends ServiceMap.Service()( + "server/NetService", +) { + static readonly layer = Layer.succeed(NetService, { + findAvailablePort: (preferred) => + Effect.callback((resume) => { + const server = Net.createServer(); + server.listen(preferred, () => { + server.close(() => resume(Effect.succeed(preferred))); + }); + server.on("error", () => { + const fallback = Net.createServer(); + fallback.listen(0, () => { + const addr = fallback.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + fallback.close(() => { + resume( + port > 0 + ? Effect.succeed(port) + : Effect.fail(new NetError({ message: "Could not find an available port." })), + ); + }); + }); + fallback.on("error", (cause) => { + resume( + Effect.fail(new NetError({ message: "Failed to find an available port", cause })), + ); + }); + }); + }), + }); +} + +export const resolveStaticDir = Effect.fn(function* () { + const { join, resolve } = yield* Path.Path; + const { stat } = yield* FileSystem.FileSystem; + const bundledClient = resolve(join(import.meta.dirname, "client")); + const bundledStat = yield* stat(join(bundledClient, "index.html")).pipe( + Effect.orElseSucceed(() => undefined), + ); + if (bundledStat) { + return bundledClient; + } + + const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist")); + const monorepoStat = yield* stat(join(monorepoClient, "index.html")).pipe( + Effect.orElseSucceed(() => undefined), + ); + if (monorepoStat) { + return monorepoClient; + } + return undefined; +}); diff --git a/apps/server/src/fixPath.ts b/apps/server/src/fixPath.ts deleted file mode 100644 index be7ea9447c4dd..0000000000000 --- a/apps/server/src/fixPath.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { execFileSync } from "node:child_process"; - -export function fixPath(): void { - if (process.platform !== "darwin") return; - - try { - const shell = process.env.SHELL ?? "/bin/zsh"; - const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], { - encoding: "utf8", - timeout: 5000, - }); - if (result) { - process.env.PATH = result; - } - } catch { - // Silently ignore — keep default PATH - } -} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index b8985c9b282a7..711bedff37e0d 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -3,15 +3,19 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { CliConfigLive, makeCliCommand } from "./main"; +import { CliConfig, makeCliCommand } from "./main"; import { OpenLive } from "./open"; import { Command } from "effect/unstable/cli"; import { version } from "../package.json" with { type: "json" }; import { ServerLive } from "./wsServer"; +import { NetService } from "./config"; -const RuntimeLayer = Layer.provideMerge( - Layer.mergeAll(CliConfigLive, ServerLive, OpenLive), - NodeServices.layer, +const RuntimeLayer = Layer.empty.pipe( + Layer.provideMerge(CliConfig.layer), + Layer.provideMerge(ServerLive), + Layer.provideMerge(OpenLive), + Layer.provideMerge(NetService.layer), + Layer.provideMerge(NodeServices.layer), ); Command.run(makeCliCommand(), { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain); diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index a75dbb593769e..836c97ea7d2d2 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -8,14 +8,17 @@ import * as Command from "effect/unstable/cli/Command"; import { beforeEach } from "vitest"; import { CliConfig, makeCliCommand, type CliConfigShape } from "./main"; +import { NetService, ServerConfig, type ServerConfigShape } from "./config"; import { Open, type OpenShape } from "./open"; import { Server, type ServerShape, type ServerOptions } from "./wsServer"; const start = vi.fn(() => undefined); const stop = vi.fn(() => undefined); -const createServer = vi.fn((_: ServerOptions) => +let resolvedConfig: ServerConfigShape | null = null; +const createServer = vi.fn((_: ServerOptions | undefined) => Effect.acquireRelease( - Effect.sync(() => { + Effect.gen(function* () { + resolvedConfig = yield* ServerConfig; start(); return {} as unknown as Http.Server; }), @@ -28,9 +31,11 @@ const testLayer = Layer.mergeAll( Layer.succeed(CliConfig, { cwd: "/tmp/t3-test-workspace", fixPath: Effect.void, - findAvailablePort, resolveStaticDir: Effect.undefined, } satisfies CliConfigShape), + Layer.succeed(NetService, { + findAvailablePort, + }), Layer.succeed(Server, { createServer, stopSignal: Effect.void, @@ -52,11 +57,13 @@ const runCli = ( beforeEach(() => { vi.clearAllMocks(); + resolvedConfig = null; start.mockImplementation(() => undefined); stop.mockImplementation(() => undefined); - createServer.mockImplementation((_: ServerOptions) => + createServer.mockImplementation((_: ServerOptions | undefined) => Effect.acquireRelease( - Effect.sync(() => { + Effect.gen(function* () { + resolvedConfig = yield* ServerConfig; start(); return {} as unknown as Http.Server; }), @@ -72,9 +79,8 @@ it.layer(testLayer)("server cli", (it) => { yield* runCli(["--port", "4010", "--token", "secret"]); assert.equal(createServer.mock.calls.length, 1); - const options = createServer.mock.calls[0]?.[0]; - assert.equal(options?.port, 4010); - assert.equal(options?.authToken, "secret"); + assert.equal(resolvedConfig?.port, 4010); + assert.equal(resolvedConfig?.authToken, "secret"); assert.equal(start.mock.calls.length, 1); assert.equal(stop.mock.calls.length, 1); }), @@ -89,9 +95,8 @@ it.layer(testLayer)("server cli", (it) => { }); assert.equal(createServer.mock.calls.length, 1); - const options = createServer.mock.calls[0]?.[0]; - assert.equal(options?.port, 4999); - assert.equal(options?.authToken, "env-token"); + assert.equal(resolvedConfig?.port, 4999); + assert.equal(resolvedConfig?.authToken, "env-token"); assert.equal(findAvailablePort.mock.calls.length, 0); }), ); @@ -103,9 +108,8 @@ it.layer(testLayer)("server cli", (it) => { assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); assert.equal(createServer.mock.calls.length, 1); - const options = createServer.mock.calls[0]?.[0]; - assert.equal(options?.port, 5444); - assert.equal(options?.host, undefined); + assert.equal(resolvedConfig?.port, 5444); + assert.equal(resolvedConfig?.mode, "web"); }), ); @@ -118,9 +122,8 @@ it.layer(testLayer)("server cli", (it) => { assert.equal(findAvailablePort.mock.calls.length, 0); assert.equal(createServer.mock.calls.length, 1); - const options = createServer.mock.calls[0]?.[0]; - assert.equal(options?.port, 3773); - assert.equal(options?.host, "127.0.0.1"); + assert.equal(resolvedConfig?.port, 3773); + assert.equal(resolvedConfig?.mode, "desktop"); }), ); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 26d1d70beb210..661ccae1919f8 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -1,21 +1,19 @@ -import net from "node:net"; -import os from "node:os"; - -import { ServiceMap, Data, Effect, Layer, Option, Config, Path, Schema, FileSystem } from "effect"; +import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; import { Command, Flag } from "effect/unstable/cli"; -import { fixPath } from "./fixPath"; -import { Open } from "./open"; import { - makeServerPersistenceLayer, - makeServerProviderLayer, - makeServerRuntimeServicesLayer, - Server, -} from "./wsServer"; - -const DEFAULT_PORT = 3773; - -type RuntimeMode = "web" | "desktop"; + DEFAULT_PORT, + NetService, + resolveStaticDir, + ServerConfig, + type RuntimeMode, + type ServerConfigShape, +} from "./config"; +import { fixPath, resolveStateDir } from "./os-jank"; +import { Open } from "./open"; +import * as SqlitePersistence from "./persistence/Layers/Sqlite"; +import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; +import { Server } from "./wsServer"; export class StartupError extends Data.TaggedError("StartupError")<{ readonly message: string; @@ -29,21 +27,34 @@ interface CliInput { export interface CliConfigShape { readonly cwd: string; - readonly fixPath: Effect.Effect; - readonly findAvailablePort: (preferred: number) => Effect.Effect; + readonly fixPath: Effect.Effect; readonly resolveStaticDir: Effect.Effect; } -export class CliConfig extends ServiceMap.Service()( - "server/CliConfig", -) {} +export class CliConfig extends ServiceMap.Service()("server/CliConfig") { + static readonly layer = Layer.effect( + CliConfig, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return { + cwd: process.cwd(), + fixPath: Effect.sync(fixPath), + resolveStaticDir: resolveStaticDir().pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + } satisfies CliConfigShape; + }), + ); +} const CliEnvConfig = Config.all({ mode: Config.string("T3CODE_MODE").pipe( Config.option, Config.map( - Option.match({ - onNone: () => "web" as const, + Option.match({ + onNone: () => "web", onSome: (value) => (value === "desktop" ? "desktop" : "web"), }), ), @@ -64,159 +75,102 @@ const CliEnvConfig = Config.all({ ), }); -const expandHomePath = Effect.fn(function* (input: string) { - const { join, sep } = yield* Path.Path; - if (input === "~") return os.homedir(); - if (input.startsWith(`~${sep}`)) { - return join(os.homedir(), input.slice(sep.length)); - } - return input; -}); - -const resolveStateDir = Effect.fn(function* (raw: string | undefined) { - const { join, resolve } = yield* Path.Path; - if (!raw || raw.trim().length === 0) { - return join(os.homedir(), ".t3", "userdata"); - } - return resolve(yield* expandHomePath(raw.trim())); -}); - -const findAvailablePort = (preferred: number) => - Effect.callback((resume) => { - const server = net.createServer(); - server.listen(preferred, () => { - server.close(() => resume(Effect.succeed(preferred))); - }); - server.on("error", () => { - const fallback = net.createServer(); - fallback.listen(0, () => { - const addr = fallback.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - fallback.close(() => { - resume( - port > 0 - ? Effect.succeed(port) - : Effect.fail(new StartupError({ message: "Could not find an available port." })), - ); - }); +const ServerConfigLive = (input: CliInput) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const cliConfig = yield* CliConfig; + const { findAvailablePort } = yield* NetService; + const env = yield* CliEnvConfig.asEffect().pipe( + Effect.mapError( + (cause) => + new StartupError({ message: "Failed to read environment configuration", cause }), + ), + ); + + const port = yield* Option.match(input.port, { + onSome: (value) => Effect.succeed(value), + onNone: () => { + if (env.port) { + return Effect.succeed(env.port); + } + if (env.mode === "desktop") { + return Effect.succeed(DEFAULT_PORT); + } + return findAvailablePort(DEFAULT_PORT); + }, }); - fallback.on("error", (cause) => { - resume( - Effect.fail(new StartupError({ message: "Failed to find an available port", cause })), - ); - }); - }); - }); - -const resolveStaticDir = Effect.fn(function* () { - const { join, resolve } = yield* Path.Path; - const { stat } = yield* FileSystem.FileSystem; - const bundledClient = resolve(join(import.meta.dirname, "client")); - const bundledStat = yield* stat(join(bundledClient, "index.html")).pipe( - Effect.orElseSucceed(() => undefined), - ); - if (bundledStat) return bundledClient; - - const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist")); - const monorepoStat = yield* stat(join(monorepoClient, "index.html")).pipe( - Effect.orElseSucceed(() => undefined), + const stateDir = yield* resolveStateDir(env.stateDir); + const devUrl = env.devUrl; + const noBrowser = env.noBrowser ?? env.mode === "desktop"; + const authToken = Option.getOrUndefined(input.token) ?? env.authToken; + const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; + const host = env.mode === "desktop" ? "127.0.0.1" : undefined; + + return { + mode: env.mode, + port, + cwd: cliConfig.cwd, + host, + stateDir, + staticDir, + devUrl, + noBrowser, + authToken, + } satisfies ServerConfigShape; + }), ); - if (monorepoStat) return monorepoClient; - return undefined; -}); -const resolveCliConfig = Effect.fn(function* (input: CliInput) { - const cliConfig = yield* CliConfig; - const env = yield* CliEnvConfig.asEffect().pipe( - Effect.mapError( - (cause) => - new StartupError({ - message: "Failed to read environment configuration", - cause, - }), - ), +const LayerLive = (input: CliInput) => + Layer.empty.pipe( + Layer.provideMerge(makeServerRuntimeServicesLayer()), + Layer.provideMerge(makeServerProviderLayer()), + Layer.provideMerge(SqlitePersistence.layerConfig), + Layer.provideMerge(ServerConfigLive(input)), ); - const mode: RuntimeMode = env.mode === "desktop" ? "desktop" : "web"; - - const cliPort = Option.getOrUndefined(input.port); - const port = - (cliPort === undefined ? env.port : cliPort) ?? - (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT)); - const stateDir = yield* resolveStateDir(env.stateDir); - const devUrl = env.devUrl; - const noBrowser = env.noBrowser ?? mode === "desktop"; - const authToken = Option.getOrUndefined(input.token) ?? env.authToken; - const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; - return { - mode, - port, - stateDir, - devUrl, - noBrowser, - authToken, - staticDir, - cwd: cliConfig.cwd, - }; -}); - -const makeServerProgram = Effect.fn(function* (input: CliInput) { - const cliConfig = yield* CliConfig; - const { createServer, stopSignal } = yield* Server; - const openDeps = yield* Open; - yield* cliConfig.fixPath; - const config = yield* resolveCliConfig(input); - - if (!config.devUrl && !config.staticDir) { - yield* Effect.logWarning("web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", { - hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.", +const makeServerProgram = (input: CliInput) => + Effect.gen(function* () { + const cliConfig = yield* CliConfig; + const { createServer, stopSignal } = yield* Server; + const openDeps = yield* Open; + yield* cliConfig.fixPath; + + const config = yield* ServerConfig; + + if (!config.devUrl && !config.staticDir) { + yield* Effect.logWarning( + "web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", + { + hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.", + }, + ); + } + + yield* createServer(); + + const url = `http://localhost:${config.port}`; + yield* Effect.logInfo("T3 Code running", { + url, + cwd: config.cwd, + mode: config.mode, + stateDir: config.stateDir, + authEnabled: Boolean(config.authToken), }); - } - const persistenceLayer = makeServerPersistenceLayer(config.stateDir); - const providerLayer = makeServerProviderLayer(config.stateDir); - const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); - const serverRuntimeLayer = Layer.merge( - makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), - infrastructureLayer, - ); - - yield* createServer({ - port: config.port, - host: config.mode === "desktop" ? "127.0.0.1" : undefined, - cwd: config.cwd, - autoBootstrapProjectFromCwd: config.mode === "web", - stateDir: config.stateDir, - staticDir: config.staticDir, - devUrl: config.devUrl?.href, - authToken: config.authToken, - }).pipe( - Effect.provide(serverRuntimeLayer), - Effect.mapError((cause) => new StartupError({ message: "Failed to start server", cause })), - ); + if (!config.noBrowser) { + const target = config.devUrl?.toString() ?? url; + yield* openDeps.openBrowser(target).pipe( + Effect.catch(() => + Effect.logInfo("browser auto-open unavailable", { + hint: `Open ${target} in your browser.`, + }), + ), + ); + } - const url = `http://localhost:${config.port}`; - yield* Effect.logInfo("T3 Code running", { - url, - cwd: config.cwd, - mode: config.mode, - stateDir: config.stateDir, - authEnabled: Boolean(config.authToken), - }); - - if (!config.noBrowser) { - const target = config.devUrl ?? url; - yield* openDeps.openBrowser(target.toString()).pipe( - Effect.catch(() => - Effect.logInfo("browser auto-open unavailable", { - hint: `Open ${target} in your browser.`, - }), - ), - ); - } - - return yield* stopSignal; -}, Effect.scoped); + return yield* stopSignal; + }).pipe(Effect.provide(LayerLive(input))); export function makeCliCommand() { const portFlag = Flag.integer("port").pipe( @@ -234,23 +188,6 @@ export function makeCliCommand() { token: tokenFlag, }).pipe( Command.withDescription("Run the T3 Code server."), - Command.withHandler((input) => makeServerProgram(input)), + Command.withHandler((input) => Effect.scoped(makeServerProgram(input))), ); } - -export const CliConfigLive = Layer.effect( - CliConfig, - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - return { - cwd: process.cwd(), - fixPath: Effect.sync(fixPath), - findAvailablePort, - resolveStaticDir: resolveStaticDir().pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - } satisfies CliConfigShape; - }), -); diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 723d656eb6217..91b63cf04478f 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { EDITORS, type EditorId } from "@t3tools/contracts"; import { ServiceMap, Schema, Effect, Layer } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; export class OpenError extends Schema.TaggedErrorClass()("OpenError", { message: Schema.String, @@ -25,7 +26,7 @@ export interface OpenShape { export class Open extends ServiceMap.Service()("server/Open") {} -export function resolveEditorLaunch( +function resolveEditorLaunch( input: OpenInEditorInput, platform: NodeJS.Platform = process.platform, ): EditorLaunch { @@ -52,42 +53,26 @@ export function resolveEditorLaunch( } } -const OpenDefault: OpenShape = { - openBrowser: (target) => - Effect.tryPromise({ - try: async () => { - const open = await import("open"); - await open.default(target); - }, - catch: (cause) => - new OpenError({ - message: "Browser auto-open failed", - cause, - }), - }), - openInEditor: (input) => - Effect.try({ - try: () => { - const launch = resolveEditorLaunch(input); - const child = spawn(launch.command, [...launch.args], { - detached: true, - stdio: "ignore", - }); - child.on("error", () => { - // Best-effort behavior for detached editor launches. - }); - child.unref(); - }, - catch: (cause) => - cause instanceof OpenError - ? cause - : new OpenError({ - message: "Failed to open editor", - cause, - }), - }), -}; +const make = Effect.gen(function* () { + const open = yield* Effect.promise(() => import("open")); + const { spawn } = yield* ChildProcessSpawner.ChildProcessSpawner; -export const OpenLive = Layer.succeed(Open, OpenDefault); + return { + openBrowser: (target) => + Effect.tryPromise({ + try: () => open.default(target), + catch: (cause) => new OpenError({ message: "Browser auto-open failed", cause }), + }), + openInEditor: (input) => + Effect.gen(function* () { + const launch = resolveEditorLaunch(input); + const child = yield* spawn(ChildProcess.make(launch.command, launch.args)); + yield* Effect.forkDetach(child.exitCode); + }).pipe( + Effect.catch((error) => Effect.logError("Failed to open editor", { cause: error })), + Effect.scoped, + ), + } satisfies OpenShape; +}); -export const OpenDefaultService = OpenDefault; +export const OpenLive = Layer.effect(Open, make); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts new file mode 100644 index 0000000000000..3f5e2129da06f --- /dev/null +++ b/apps/server/src/os-jank.ts @@ -0,0 +1,39 @@ +import * as OS from "node:os"; +import { Effect, Path } from "effect"; +import { execFileSync } from "node:child_process"; + +export function fixPath(): void { + if (process.platform !== "darwin") return; + + try { + const shell = process.env.SHELL ?? "/bin/zsh"; + const result = execFileSync(shell, ["-ilc", "echo -n $PATH"], { + encoding: "utf8", + timeout: 5000, + }); + if (result) { + process.env.PATH = result; + } + } catch { + // Silently ignore — keep default PATH + } +} + +export const expandHomePath = Effect.fn(function* (input: string) { + const { join, sep } = yield* Path.Path; + if (input === "~") { + return OS.homedir(); + } + if (input.startsWith(`~${sep}`)) { + return join(OS.homedir(), input.slice(sep.length)); + } + return input; +}); + +export const resolveStateDir = Effect.fn(function* (raw: string | undefined) { + const { join, resolve } = yield* Path.Path; + if (!raw || raw.trim().length === 0) { + return join(OS.homedir(), ".t3", "userdata"); + } + return resolve(yield* expandHomePath(raw.trim())); +}); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index cf7e40efa5e96..c2396a247cf34 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -2,6 +2,7 @@ import { Effect, Layer, FileSystem, Path } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; +import { ServerConfig } from "../../config.ts"; type RuntimeSqliteLayerConfig = { readonly filename: string; @@ -47,3 +48,10 @@ export const SqlitePersistenceMemory = Layer.provideMerge( setup, makeRuntimeSqliteLayer({ filename: ":memory:" }), ); + +export const layerConfig = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig; + const { join } = yield* Path.Path; + const dbPath = join(stateDir, "orchestration.sqlite"); + return makeSqlitePersistenceLive(dbPath); +}).pipe(Layer.unwrap); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts new file mode 100644 index 0000000000000..189dea33a1577 --- /dev/null +++ b/apps/server/src/serverLayers.ts @@ -0,0 +1,83 @@ +import path from "node:path"; + +import { NodeServices } from "@effect/platform-node"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; +import { ServerConfig } from "./config"; +import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; +import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; +import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; +import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; +import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; +import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; +import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; +import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; +import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; +import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; +import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; +import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; +import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; +import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; +import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; +import { ProviderService } from "./provider/Services/ProviderService"; + +export function makeServerProviderLayer(): Layer.Layer< + ProviderService, + unknown, + SqlClient.SqlClient | ServerConfig +> { + return Effect.gen(function* () { + const { stateDir } = yield* ServerConfig; + const providerLogsDir = path.join(stateDir, "logs", "providers"); + const codexAdapterLayer = makeCodexAdapterLive({ + nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), + }); + const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(codexAdapterLayer)); + const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntimeRepositoryLive), + ); + return makeProviderServiceLive({ + canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"), + }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); + }).pipe(Layer.unwrap); +} + +export function makeServerRuntimeServicesLayer(): Layer.Layer< + OrchestrationEngineService | ProjectionSnapshotQuery | CheckpointStore | OrchestrationReactor, + unknown, + SqlClient.SqlClient | ProviderService +> { + const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionPipelineLive), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + ); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); + + const runtimeServicesLayer = Layer.mergeAll( + orchestrationLayer, + OrchestrationProjectionSnapshotQueryLive, + checkpointStoreLayer, + ); + const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const checkpointReactorLayer = CheckpointReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const orchestrationReactorLayer = OrchestrationReactorLive.pipe( + Layer.provideMerge(runtimeIngestionLayer), + Layer.provideMerge(providerCommandReactorLayer), + Layer.provideMerge(checkpointReactorLayer), + ); + + return orchestrationReactorLayer; +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index ab92b43532554..eeaf8b1bf80f6 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -6,8 +6,10 @@ import { EventEmitter } from "node:events"; import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect"; import { describe, expect, it, afterEach, vi } from "vitest"; -import { createServer, makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./wsServer"; +import { createServer } from "./wsServer"; import WebSocket from "ws"; +import { ServerConfig, type ServerConfigShape } from "./config"; +import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; import { DEFAULT_TERMINAL_ID, @@ -40,7 +42,7 @@ import type { TerminalManager } from "./terminalManager"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; import { SqlClient } from "effect/unstable/sql"; import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; -import type { OpenShape } from "./open"; +import { Open, type OpenShape } from "./open"; interface PendingMessages { queue: unknown[]; @@ -56,6 +58,11 @@ const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); +const defaultOpenService: OpenShape = { + openBrowser: () => Effect.void, + openInEditor: () => Effect.void, +}; + class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> { private readonly sessions = new Map(); @@ -323,28 +330,37 @@ describe("WebSocket Server", () => { const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); const scope = await Effect.runPromise(Scope.make("sequential")); const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; - const providerLayer = options.providerLayer ?? makeServerProviderLayer(stateDir); + const providerLayer = options.providerLayer ?? makeServerProviderLayer(); + const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); + const serverConfigLayer = Layer.succeed(ServerConfig, { + mode: "web", + port: 0, + host: undefined, + cwd: options.cwd ?? "/test/project", + stateDir, + staticDir: undefined, + devUrl: options.devUrl ? new URL(options.devUrl) : undefined, + noBrowser: true, + authToken: options.authToken, + } satisfies ServerConfigShape); const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); const runtimeLayer = Layer.merge( makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), infrastructureLayer, ); + const dependenciesLayer = Layer.empty.pipe( + Layer.provideMerge(runtimeLayer), + Layer.provideMerge(openLayer), + Layer.provideMerge(serverConfigLayer), + ); const runtimeServices = await Effect.runPromise( - Layer.build(runtimeLayer).pipe(Scope.provide(scope)), + Layer.build(dependenciesLayer).pipe(Scope.provide(scope)), ); try { const runtime = await Effect.runPromise( createServer({ - port: 0, - cwd: options.cwd ?? "/test/project", - stateDir, - ...(options.autoBootstrapProjectFromCwd !== undefined - ? { autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd } - : {}), - ...(options.devUrl ? { devUrl: options.devUrl } : {}), - ...(options.authToken ? { authToken: options.authToken } : {}), - ...(options.open ? { open: options.open } : {}), + autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, ...(options.gitManager ? { gitManager: options.gitManager as never } : {}), ...(options.terminalManager ? { terminalManager: options.terminalManager } : {}), }).pipe(Effect.provide(runtimeServices), Scope.provide(scope)), diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 79deff0cf3a1b..d4afd8e714432 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -1,6 +1,5 @@ import fs from "node:fs"; import http from "node:http"; -import os from "node:os"; import path from "node:path"; import type { Duplex } from "node:stream"; @@ -16,7 +15,7 @@ import { WsPush, WsResponse, } from "@t3tools/contracts"; -import { NodeHttpServer, NodeServices } from "@effect/platform-node"; +import { NodeHttpServer } from "@effect/platform-node"; import { Cause, Effect, Exit, Layer, Schema, Scope, ServiceMap, Stream } from "effect"; import { WebSocketServer, type WebSocket } from "ws"; @@ -34,30 +33,14 @@ import { import { TerminalManager } from "./terminalManager"; import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; -import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; -import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; -import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; -import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; -import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; -import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; -import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; -import { makeSqlitePersistenceLive } from "./persistence/Layers/Sqlite"; -import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; -import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; -import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; -import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; -import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; -import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; import { ProviderService } from "./provider/Services/ProviderService"; -import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; -import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; import { CheckpointStore } from "./checkpointing/Services/CheckpointStore"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; import { clamp } from "effect/Number"; -import { OpenDefaultService, type OpenShape } from "./open"; +import { Open } from "./open"; +import { ServerConfig } from "./config"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -75,24 +58,16 @@ const MIME_TYPES: Record = { }; export interface ServerOptions { - port: number; - host?: string | undefined; - cwd: string; autoBootstrapProjectFromCwd?: boolean | undefined; - stateDir?: string | undefined; - staticDir?: string | undefined; - devUrl?: string | undefined; logWebSocketEvents?: boolean | undefined; gitManager?: GitManager | undefined; terminalManager?: TerminalManager | undefined; - authToken?: string | undefined; - open?: OpenShape | undefined; } export interface ServerShape { readonly createServer: ( - options: ServerOptions, - ) => Effect.Effect; + options?: ServerOptions, + ) => Effect.Effect; readonly stopSignal: Effect.Effect; } @@ -174,66 +149,11 @@ export type ServerCoreRuntimeServices = | OrchestrationEngineService | ProjectionSnapshotQuery | CheckpointStore - | OrchestrationReactor; + | OrchestrationReactor + | Open; export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService; -export function makeServerPersistenceLayer(stateDir: string) { - const dbPath = path.join(stateDir, "orchestration.sqlite"); - return makeSqlitePersistenceLive(dbPath); -} - -export function makeServerProviderLayer( - stateDir: string, -): Layer.Layer { - const providerLogsDir = path.join(stateDir, "logs", "providers"); - const codexAdapterLayer = makeCodexAdapterLive({ - nativeEventLogPath: path.join(providerLogsDir, "provider-native.ndjson"), - }); - const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(Layer.provide(codexAdapterLayer)); - const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(ProviderSessionRuntimeRepositoryLive), - ); - return makeProviderServiceLive({ - canonicalEventLogPath: path.join(providerLogsDir, "provider-canonical.ndjson"), - }).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); -} - -export function makeServerRuntimeServicesLayer(): Layer.Layer< - ServerCoreRuntimeServices, - unknown, - SqlClient.SqlClient | ProviderService -> { - const orchestrationLayer = OrchestrationEngineLive.pipe( - Layer.provide(OrchestrationProjectionPipelineLive), - Layer.provide(OrchestrationEventStoreLive), - Layer.provide(OrchestrationCommandReceiptRepositoryLive), - ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); - - const runtimeServicesLayer = Layer.mergeAll( - orchestrationLayer, - OrchestrationProjectionSnapshotQueryLive, - checkpointStoreLayer, - ); - const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const checkpointReactorLayer = CheckpointReactorLive.pipe( - Layer.provideMerge(runtimeServicesLayer), - ); - const orchestrationReactorLayer = OrchestrationReactorLive.pipe( - Layer.provideMerge(runtimeIngestionLayer), - Layer.provideMerge(providerCommandReactorLayer), - Layer.provideMerge(checkpointReactorLayer), - ); - - return orchestrationReactorLayer; -} - export class ServerLifecycleError extends Schema.TaggedErrorClass()( "ServerLifecycleError", { @@ -246,22 +166,18 @@ class RouteRequestError extends Schema.TaggedErrorClass()("Ro message: Schema.String, }) {} -export const createServer = Effect.fn(function* ( - options: ServerOptions, -): Effect.fn.Return { +export const createServer = Effect.fn(function* (options: ServerOptions = {}): Effect.fn.Return< + http.Server, + unknown, // TODO: This should not be unknown + Scope.Scope | ServerRuntimeServices | ServerConfig +> { + const { port, cwd, staticDir, devUrl, authToken, mode, host } = yield* ServerConfig; const { - port, - host, - cwd, - autoBootstrapProjectFromCwd = false, - staticDir, - devUrl, logWebSocketEvents: explicitLogWsEvents, gitManager = new GitManager(), terminalManager = new TerminalManager(), - authToken, - open: openService = OpenDefaultService, } = options; + const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web"; const clients = new Set(); const logger = createLogger("ws"); @@ -299,7 +215,7 @@ export const createServer = Effect.fn(function* ( const httpServer = http.createServer((req, res) => { // In dev mode, redirect to Vite dev server if (devUrl) { - res.writeHead(302, { Location: devUrl }); + res.writeHead(302, { Location: devUrl.href }); res.end(); return; } @@ -382,6 +298,7 @@ export const createServer = Effect.fn(function* ( const liveCheckpointStore = yield* CheckpointStore; const liveProviderService = yield* ProviderService; const orchestrationReactor = yield* OrchestrationReactor; + const { openInEditor } = yield* Open; const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => @@ -574,7 +491,7 @@ export const createServer = Effect.fn(function* ( case WS_METHODS.shellOpenInEditor: { const body = stripRequestTag(request.body); - return yield* openService.openInEditor(body); + return yield* openInEditor(body); } case WS_METHODS.gitStatus: { From 895ab19a543c8824857e25ff2187156dfbc14250 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 19:01:47 -0800 Subject: [PATCH 49/98] terminal --- .plans/terminal-runtime-state-plan.md | 154 ------------------ apps/server/package.json | 1 + apps/server/src/ptyAdapter.test.ts | 75 +++++---- apps/server/src/ptyAdapter.ts | 153 ++++++++++++++++-- apps/server/src/serverLayers.ts | 20 ++- apps/server/src/terminalManager.test.ts | 8 +- apps/server/src/terminalManager.ts | 67 ++++++-- apps/server/src/wsServer.test.ts | 199 +++++++++++++----------- apps/server/src/wsServer.ts | 29 ++-- apps/server/tsconfig.json | 2 +- bun.lock | 9 ++ 11 files changed, 398 insertions(+), 319 deletions(-) delete mode 100644 .plans/terminal-runtime-state-plan.md diff --git a/.plans/terminal-runtime-state-plan.md b/.plans/terminal-runtime-state-plan.md deleted file mode 100644 index 0b90488b06920..0000000000000 --- a/.plans/terminal-runtime-state-plan.md +++ /dev/null @@ -1,154 +0,0 @@ -# Terminal Runtime And State Plan - -## Summary - -Terminals appear to "hang" because PTY processes are exiting immediately at startup in the current runtime. - -Repro in this branch: - -- `node` + `node-pty`: interactive shell stays alive and accepts writes. -- `bun` + `node-pty`: shell exits immediately (`exitCode: 0`, `signal: 1`), so the UI opens a terminal pane but no live process remains. - -This means the current breakage is primarily a runtime compatibility issue, plus missing reconciliation between desired terminal state and observed process state. - -## Root Cause (Current) - -1. Server dev runtime uses Bun (`apps/server/package.json` `dev` script). -2. Terminal PTY implementation depends on `node-pty`. -3. Under Bun, spawned PTY shells exit immediately. -4. Frontend still mounts terminals, so UX looks like startup hang/blank terminal. - -## Design Goals - -1. Do not store full terminal output in SQLite. -2. Preserve terminal topology and intent across reconnects/restarts: - - open/split/new/close - - active terminal/group - - cwd/env profile -3. Keep runtime truth accurate even when processes change outside the app. -4. Support deterministic recovery and idempotent command handling. - -## Proposed Architecture - -### 1) Runtime Boundary - -- Run terminal PTY host on Node runtime only. -- If main server remains Bun, isolate PTY behind a Node sidecar process (JSON-RPC/IPC). -- Treat PTY host as volatile runtime, not source of durable truth. - -### 2) Event-Sourced In SQLite (Control Plane Only) - -Persist only control/state events, not output payloads. - -Recommended event families: - -- `terminal.layout.updated` - - terminal ids, split groups, active terminal id/group id -- `terminal.open.requested` - - threadId, terminalId, cwd, envHash/envProfileId, cols, rows -- `terminal.close.requested` - - terminalId, deleteHistory -- `terminal.resize.requested` - - cols, rows -- `terminal.clear.requested` -- `terminal.input.sent` - - input metadata only (`byteLength`, optional hash, optional commandBoundary flag), no raw payload -- `terminal.runtime.started` - - pid, startedAt, runtime instance id -- `terminal.runtime.exited` - - pid, exitCode, exitSignal, observedAt, reason (`normal|killed|external|crash|unknown`) -- `terminal.runtime.error` - - normalized error code/message -- `terminal.output.segment.recorded` - - segment file id + byte offsets only - -Use idempotency keys/command ids for each client command so reconnect retries are safe. - -### 3) Output Storage In Log Files (Data Plane) - -- Store raw output only in append-only files (per thread+terminal). -- Rotate into segments (size-based and/or time-based). -- Keep lightweight SQLite index rows: - - `segment_id`, `thread_id`, `terminal_id`, `start_offset`, `end_offset`, `created_at`, checksum. -- Optional retention policy (max bytes / days / segment count per terminal). - -This keeps SQLite small while preserving replay capability. - -### 4) Reconciliation Loop (Runtime Truth) - -Do not treat client dispatch as sole truth. - -Maintain dual state: - -- Desired state: from event stream/projection. -- Observed runtime state: from PTY host watchers (process exit, health checks, poll). - -On mismatch, emit runtime observation events: - -- process missing but desired running -> `terminal.runtime.exited` with reason `external|unknown` -- process exists but projection says closed -> terminate process and emit correction event - -This makes out-of-band kills/restarts explicit and recoverable. - -### 5) Startup/Recovery Flow - -1. Rebuild terminal projection from SQLite control events. -2. Load terminal layout/topology immediately for UI. -3. Reconcile each desired running terminal with runtime: - - if process alive: attach stream + tail recent output from logs - - if process dead: mark exited; auto-restart only if policy says so -4. Resume incremental output from last persisted offset. - -## Data Model Additions - -Add dedicated tables (or equivalent repos): - -- `terminal_control_events` (or reuse orchestration_events with terminal aggregate kind) -- `terminal_output_segments` (index only, no raw output) -- `terminal_runtime_snapshot` (latest observed runtime state cache) - -Avoid embedding output blobs in projection snapshots. - -## Implementation Plan - -### Phase 0: Stop the bleeding - -1. Run PTY on Node runtime in all dev/prod paths (or ship Node sidecar now). -2. Add startup diagnostics: runtime, PTY spawn result, immediate-exit telemetry. -3. Surface clear UI error when PTY host unavailable. - -### Phase 1: Durable control events - -1. Introduce terminal aggregate + commands/events. -2. Persist layout and terminal lifecycle intent events. -3. Build projection for terminal UI state from events. - -### Phase 2: File-backed output + index - -1. Implement segmented append-only output logger. -2. Persist only segment metadata/offsets in SQLite. -3. Update terminal history restore to tail from segments (bounded bytes/lines). - -### Phase 3: Runtime reconciliation - -1. Add process observer + periodic verifier. -2. Emit runtime observation events on drift. -3. Add recovery tests for out-of-band process termination. - -### Phase 4: Hardening - -1. Backpressure and truncation policies for high-volume output. -2. Crash-safe fsync/flush strategy for segment writes. -3. Retention and compaction jobs. - -## Testing Strategy - -Backend tests should cover: - -1. Rebuild projection from control events (open/split/close/resize/active terminal). -2. Runtime reconciliation when process is killed externally. -3. Recovery after restart with existing segment files. -4. Idempotent command handling on duplicate dispatches. -5. Segment index consistency and retention behavior. - -Use real runtime-layer tests for terminal adapter behavior where possible; only mock external process/service boundaries. diff --git a/apps/server/package.json b/apps/server/package.json index 89d53aa3ccc88..4978cbcd075a3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -31,6 +31,7 @@ "@effect/vitest": "4.0.0-beta.10", "@t3tools/contracts": "workspace:*", "@t3tools/web": "workspace:*", + "@types/bun": "^1.3.9", "@types/node": "^22.10.2", "@types/ws": "^8.5.13", "tsdown": "^0.20.3", diff --git a/apps/server/src/ptyAdapter.test.ts b/apps/server/src/ptyAdapter.test.ts index 5fd37292fe2fa..25f30d6fe3abb 100644 --- a/apps/server/src/ptyAdapter.test.ts +++ b/apps/server/src/ptyAdapter.test.ts @@ -2,7 +2,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { assert, it, vi } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { afterEach } from "vitest"; import { ensureNodePtySpawnHelperExecutable } from "./ptyAdapter"; @@ -10,45 +12,54 @@ function fileMode(filePath: string): number { return fs.statSync(filePath).mode & 0o777; } -describe("ensureNodePtySpawnHelperExecutable", () => { - const tempDirs: string[] = []; +const tempDirs: string[] = []; - afterEach(() => { - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); +afterEach(() => { + vi.unstubAllGlobals(); + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +const helperLayer = it.layer(Layer.empty); - it("adds executable bits when helper exists but is not executable", () => { - if (process.platform === "win32") return; +helperLayer("ensureNodePtySpawnHelperExecutable", (it) => { + it.effect("adds executable bits when helper exists but is not executable", () => + Effect.sync(() => { + if (process.platform === "win32") return; - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); - tempDirs.push(dir); - const helperPath = path.join(dir, "spawn-helper"); - fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); - fs.chmodSync(helperPath, 0o644); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); + tempDirs.push(dir); + const helperPath = path.join(dir, "spawn-helper"); + fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(helperPath, 0o644); - ensureNodePtySpawnHelperExecutable(helperPath); + ensureNodePtySpawnHelperExecutable(helperPath); - expect(fileMode(helperPath) & 0o111).toBe(0o111); - }); + assert.equal(fileMode(helperPath) & 0o111, 0o111); + }), + ); - it("keeps executable helper as executable", () => { - if (process.platform === "win32") return; + it.effect("keeps executable helper as executable", () => + Effect.sync(() => { + if (process.platform === "win32") return; - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); - tempDirs.push(dir); - const helperPath = path.join(dir, "spawn-helper"); - fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); - fs.chmodSync(helperPath, 0o755); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pty-helper-test-")); + tempDirs.push(dir); + const helperPath = path.join(dir, "spawn-helper"); + fs.writeFileSync(helperPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(helperPath, 0o755); - ensureNodePtySpawnHelperExecutable(helperPath); + ensureNodePtySpawnHelperExecutable(helperPath); - expect(fileMode(helperPath) & 0o111).toBe(0o111); - }); + assert.equal(fileMode(helperPath) & 0o111, 0o111); + }), + ); - it("does not throw when helper path is missing", () => { - const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`); - expect(() => ensureNodePtySpawnHelperExecutable(missing)).not.toThrow(); - }); + it.effect("does not throw when helper path is missing", () => + Effect.sync(() => { + const missing = path.join(os.tmpdir(), `does-not-exist-${Date.now()}`); + ensureNodePtySpawnHelperExecutable(missing); + }), + ); }); diff --git a/apps/server/src/ptyAdapter.ts b/apps/server/src/ptyAdapter.ts index 60061e04c88d3..0e655f484e5de 100644 --- a/apps/server/src/ptyAdapter.ts +++ b/apps/server/src/ptyAdapter.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; -import * as nodePty from "node-pty"; +import { Effect, Layer, ServiceMap } from "effect"; export interface PtyExitEvent { exitCode: number; @@ -27,10 +27,17 @@ export interface PtySpawnInput { env: NodeJS.ProcessEnv; } -export interface PtyAdapter { +export interface PtyAdapterShape { spawn(input: PtySpawnInput): PtyProcess; } +export class PtyAdapter extends ServiceMap.Service()( + "terminal/PtyAdapter", +) { + static readonly layer = () => + typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive; +} + const requireForNodePty = createRequire(import.meta.url); let didEnsureSpawnHelperExecutable = false; @@ -78,7 +85,7 @@ export function ensureNodePtySpawnHelperExecutable(explicitPath?: string): void } class NodePtyProcess implements PtyProcess { - constructor(private readonly process: nodePty.IPty) {} + constructor(private readonly process: import("node-pty").IPty) {} get pid(): number { return this.process.pid; @@ -116,16 +123,134 @@ class NodePtyProcess implements PtyProcess { } } -export class NodePtyAdapter implements PtyAdapter { - spawn(input: PtySpawnInput): PtyProcess { - ensureNodePtySpawnHelperExecutable(); - const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { - cwd: input.cwd, - cols: input.cols, - rows: input.rows, - env: input.env, - name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", - }); - return new NodePtyProcess(ptyProcess); +class BunPtyProcess implements PtyProcess { + private readonly dataListeners = new Set<(data: string) => void>(); + private readonly exitListeners = new Set<(event: PtyExitEvent) => void>(); + private readonly decoder = new TextDecoder(); + private didExit = false; + + constructor(private readonly process: Bun.Subprocess) { + void this.process.exited + .then((exitCode) => { + this.emitExit({ + exitCode: Number.isInteger(exitCode) ? exitCode : 0, + signal: typeof this.process.signalCode === "number" ? this.process.signalCode : null, + }); + }) + .catch(() => { + this.emitExit({ exitCode: 1, signal: null }); + }); + } + + get pid(): number { + return this.process.pid; + } + + write(data: string): void { + if (!this.process.terminal) { + throw new Error("Bun PTY terminal handle is unavailable"); + } + this.process.terminal.write(data); + } + + resize(cols: number, rows: number): void { + if (!this.process.terminal?.resize) { + throw new Error("Bun PTY resize is unavailable"); + } + this.process.terminal.resize(cols, rows); + } + + kill(signal?: string): void { + if (!signal) { + this.process.kill(); + return; + } + this.process.kill(signal as NodeJS.Signals); + } + + onData(callback: (data: string) => void): () => void { + this.dataListeners.add(callback); + return () => { + this.dataListeners.delete(callback); + }; + } + + onExit(callback: (event: PtyExitEvent) => void): () => void { + this.exitListeners.add(callback); + return () => { + this.exitListeners.delete(callback); + }; + } + + emitData(data: Uint8Array): void { + if (this.didExit) return; + const text = this.decoder.decode(data, { stream: true }); + if (text.length === 0) return; + for (const listener of this.dataListeners) { + listener(text); + } + } + + private emitExit(event: PtyExitEvent): void { + if (this.didExit) return; + this.didExit = true; + + const remainder = this.decoder.decode(); + if (remainder.length > 0) { + for (const listener of this.dataListeners) { + listener(remainder); + } + } + + for (const listener of this.exitListeners) { + listener(event); + } } } +export const NodePtyAdapterLive = Layer.effect( + PtyAdapter, + Effect.gen(function* () { + const nodePty = yield* Effect.promise(() => import("node-pty")); + return { + spawn(input: PtySpawnInput): PtyProcess { + ensureNodePtySpawnHelperExecutable(); + const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { + cwd: input.cwd, + cols: input.cols, + rows: input.rows, + env: input.env, + name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color", + }); + return new NodePtyProcess(ptyProcess); + }, + }; + }), +); + +export const BunPtyAdapterLive = Layer.effect( + PtyAdapter, + Effect.gen(function* () { + if (process.platform === "win32") { + return yield* Effect.die("Bun PTY terminal support is unavailable on Windows."); + } + return { + spawn(input: PtySpawnInput): PtyProcess { + let processHandle: BunPtyProcess | null = null; + const command = [input.shell, ...(input.args ?? [])]; + const subprocess = Bun.spawn(command, { + cwd: input.cwd, + env: input.env, + terminal: { + cols: input.cols, + rows: input.rows, + data: (_terminal, data) => { + processHandle?.emitData(data); + }, + }, + }); + processHandle = new BunPtyProcess(subprocess); + return processHandle; + }, + }; + }), +); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 189dea33a1577..e3e5e60b85431 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -26,6 +26,9 @@ import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; import { ProviderService } from "./provider/Services/ProviderService"; +import { makeTerminalManagerLive, TerminalManager } from "./terminalManager"; +import { PtyAdapter } from "./ptyAdapter"; + export function makeServerProviderLayer(): Layer.Layer< ProviderService, unknown, @@ -48,9 +51,13 @@ export function makeServerProviderLayer(): Layer.Layer< } export function makeServerRuntimeServicesLayer(): Layer.Layer< - OrchestrationEngineService | ProjectionSnapshotQuery | CheckpointStore | OrchestrationReactor, + | OrchestrationEngineService + | ProjectionSnapshotQuery + | CheckpointStore + | OrchestrationReactor + | TerminalManager, unknown, - SqlClient.SqlClient | ProviderService + SqlClient.SqlClient | ProviderService | ServerConfig > { const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -79,5 +86,12 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< Layer.provideMerge(checkpointReactorLayer), ); - return orchestrationReactorLayer; + const terminalLayer = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig; + return makeTerminalManagerLive({ + logsDir: path.join(stateDir, "logs", "terminals"), + }).pipe(Layer.provide(PtyAdapter.layer())); + }).pipe(Layer.unwrap); + + return Layer.merge(orchestrationReactorLayer, terminalLayer); } diff --git a/apps/server/src/terminalManager.test.ts b/apps/server/src/terminalManager.test.ts index c98b0b3a782b4..861215d922005 100644 --- a/apps/server/src/terminalManager.test.ts +++ b/apps/server/src/terminalManager.test.ts @@ -9,8 +9,8 @@ import { } from "@t3tools/contracts"; import { afterEach, describe, expect, it } from "vitest"; -import type { PtyAdapter, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; -import { TerminalManager } from "./terminalManager"; +import type { PtyAdapterShape, PtyExitEvent, PtyProcess, PtySpawnInput } from "./ptyAdapter"; +import { TerminalManagerRuntime } from "./terminalManager"; class FakePtyProcess implements PtyProcess { readonly writes: string[] = []; @@ -60,7 +60,7 @@ class FakePtyProcess implements PtyProcess { } } -class FakePtyAdapter implements PtyAdapter { +class FakePtyAdapter implements PtyAdapterShape { readonly spawnInputs: PtySpawnInput[] = []; readonly processes: FakePtyProcess[] = []; readonly spawnFailures: Error[] = []; @@ -150,7 +150,7 @@ describe("TerminalManager", () => { const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-terminal-")); tempDirs.push(logsDir); const ptyAdapter = new FakePtyAdapter(); - const manager = new TerminalManager({ + const manager = new TerminalManagerRuntime({ logsDir, ptyAdapter, historyLineLimit, diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts index a9e6533141a9b..a79722cc7cc34 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminalManager.ts @@ -13,10 +13,10 @@ import { type TerminalSessionSnapshot, type TerminalSessionStatus, } from "@t3tools/contracts"; -import { Schema } from "effect"; +import { Effect, Layer, Schema, ServiceMap } from "effect"; import { createLogger } from "./logger"; -import { NodePtyAdapter, type PtyAdapter, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; +import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "./ptyAdapter"; import { runProcess } from "./processRunner"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; @@ -41,12 +41,27 @@ export interface TerminalManagerEvents { export interface TerminalManagerOptions { logsDir?: string; historyLineLimit?: number; - ptyAdapter?: PtyAdapter; + ptyAdapter: PtyAdapterShape; shellResolver?: () => string; subprocessChecker?: TerminalSubprocessChecker; subprocessPollIntervalMs?: number; } +export interface TerminalManagerShape { + readonly open: (input: TerminalOpenInput) => Effect.Effect; + readonly write: (input: TerminalWriteInput) => Effect.Effect; + readonly resize: (input: TerminalResizeInput) => Effect.Effect; + readonly clear: (input: TerminalClearInput) => Effect.Effect; + readonly restart: (input: TerminalOpenInput) => Effect.Effect; + readonly close: (input: TerminalCloseInput) => Effect.Effect; + readonly subscribe: (listener: (event: TerminalEvent) => void) => Effect.Effect<() => void>; + readonly dispose: () => Effect.Effect; +} + +export class TerminalManager extends ServiceMap.Service()( + "terminal/TerminalManager", +) {} + interface TerminalSessionState { threadId: string; terminalId: string; @@ -73,8 +88,8 @@ interface ShellCandidate { interface TerminalStartInput extends TerminalOpenInput { cols: number; - rows: number -}; + rows: number; +} function defaultShellResolver(): string { if (process.platform === "win32") { @@ -300,14 +315,14 @@ function normalizedRuntimeEnv( if (!env) return null; const entries = Object.entries(env); if (entries.length === 0) return null; - return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right))); + return Object.fromEntries(entries.toSorted(([left], [right]) => left.localeCompare(right))); } -export class TerminalManager extends EventEmitter { +export class TerminalManagerRuntime extends EventEmitter { private readonly sessions = new Map(); private readonly logsDir: string; private readonly historyLineLimit: number; - private readonly ptyAdapter: PtyAdapter; + private readonly ptyAdapter: PtyAdapterShape; private readonly shellResolver: () => string; private readonly persistQueues = new Map>(); private readonly persistTimers = new Map>(); @@ -320,11 +335,11 @@ export class TerminalManager extends EventEmitter { private subprocessPollInFlight = false; private readonly logger = createLogger("terminal"); - constructor(options: TerminalManagerOptions = {}) { + constructor(options: TerminalManagerOptions) { super(); this.logsDir = options.logsDir ?? path.resolve(process.cwd(), ".logs", "terminals"); this.historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT; - this.ptyAdapter = options.ptyAdapter ?? new NodePtyAdapter(); + this.ptyAdapter = options.ptyAdapter; this.shellResolver = options.shellResolver ?? defaultShellResolver; this.persistDebounceMs = DEFAULT_PERSIST_DEBOUNCE_MS; this.subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker; @@ -1046,3 +1061,35 @@ export class TerminalManager extends EventEmitter { } } } + +export function makeTerminalManagerLive( + options: Omit = {}, +): Layer.Layer { + return Layer.effect( + TerminalManager, + Effect.gen(function* () { + const ptyAdapter = yield* PtyAdapter; + const runtime = new TerminalManagerRuntime({ ...options, ptyAdapter }); + + return { + open: (input) => Effect.promise(() => runtime.open(input)), + write: (input) => Effect.promise(() => runtime.write(input)), + resize: (input) => Effect.promise(() => runtime.resize(input)), + clear: (input) => Effect.promise(() => runtime.clear(input)), + restart: (input) => Effect.promise(() => runtime.restart(input)), + close: (input) => Effect.promise(() => runtime.close(input)), + subscribe: (listener) => + Effect.sync(() => { + runtime.on("event", listener); + return () => { + runtime.off("event", listener); + }; + }), + dispose: () => + Effect.sync(() => { + runtime.dispose(); + }), + } satisfies TerminalManagerShape; + }), + ); +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index eeaf8b1bf80f6..4a4a2bee1e426 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -2,7 +2,6 @@ import * as Http from "node:http"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { EventEmitter } from "node:events"; import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect"; import { describe, expect, it, afterEach, vi } from "vitest"; @@ -38,7 +37,7 @@ import type { TerminalSessionSnapshot, TerminalWriteInput, } from "@t3tools/contracts"; -import type { TerminalManager } from "./terminalManager"; +import type { TerminalManagerShape } from "./terminalManager"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; import { SqlClient } from "effect/unstable/sql"; import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; @@ -63,111 +62,135 @@ const defaultOpenService: OpenShape = { openInEditor: () => Effect.void, }; -class MockTerminalManager extends EventEmitter<{ event: [event: TerminalEvent] }> { +class MockTerminalManager implements TerminalManagerShape { private readonly sessions = new Map(); + private readonly listeners = new Set<(event: TerminalEvent) => void>(); private key(threadId: string, terminalId: string): string { return `${threadId}\u0000${terminalId}`; } - async open(input: TerminalOpenInput): Promise { - const now = new Date().toISOString(); - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; - const snapshot: TerminalSessionSnapshot = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - status: "running", - pid: 4242, - history: "", - exitCode: null, - exitSignal: null, - updatedAt: now, - }; - this.sessions.set(this.key(input.threadId, terminalId), snapshot); - queueMicrotask(() => { - this.emit("event", { - type: "started", + emitEvent(event: TerminalEvent): void { + for (const listener of this.listeners) { + listener(event); + } + } + + subscriptionCount(): number { + return this.listeners.size; + } + + readonly open: TerminalManagerShape["open"] = (input: TerminalOpenInput) => + Effect.sync(() => { + const now = new Date().toISOString(); + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + const snapshot: TerminalSessionSnapshot = { threadId: input.threadId, terminalId, - createdAt: now, - snapshot, + cwd: input.cwd, + status: "running", + pid: 4242, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: now, + }; + this.sessions.set(this.key(input.threadId, terminalId), snapshot); + queueMicrotask(() => { + this.emitEvent({ + type: "started", + threadId: input.threadId, + terminalId, + createdAt: now, + snapshot, + }); }); + return snapshot; }); - return snapshot; - } - async write(input: TerminalWriteInput): Promise { - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; - const existing = this.sessions.get(this.key(input.threadId, terminalId)); - if (!existing) { - throw new Error(`Unknown terminal thread: ${input.threadId}`); - } - queueMicrotask(() => { - this.emit("event", { - type: "output", - threadId: input.threadId, - terminalId, - createdAt: new Date().toISOString(), - data: input.data, + readonly write: TerminalManagerShape["write"] = (input: TerminalWriteInput) => + Effect.sync(() => { + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + const existing = this.sessions.get(this.key(input.threadId, terminalId)); + if (!existing) { + throw new Error(`Unknown terminal thread: ${input.threadId}`); + } + queueMicrotask(() => { + this.emitEvent({ + type: "output", + threadId: input.threadId, + terminalId, + createdAt: new Date().toISOString(), + data: input.data, + }); }); }); - } - async resize(_input: TerminalResizeInput): Promise {} + readonly resize: TerminalManagerShape["resize"] = (_input: TerminalResizeInput) => Effect.void; - async clear(input: TerminalClearInput): Promise { - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; - queueMicrotask(() => { - this.emit("event", { - type: "cleared", - threadId: input.threadId, - terminalId, - createdAt: new Date().toISOString(), + readonly clear: TerminalManagerShape["clear"] = (input: TerminalClearInput) => + Effect.sync(() => { + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + queueMicrotask(() => { + this.emitEvent({ + type: "cleared", + threadId: input.threadId, + terminalId, + createdAt: new Date().toISOString(), + }); }); }); - } - async restart(input: TerminalOpenInput): Promise { - const now = new Date().toISOString(); - const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; - const snapshot: TerminalSessionSnapshot = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - status: "running", - pid: 5252, - history: "", - exitCode: null, - exitSignal: null, - updatedAt: now, - }; - this.sessions.set(this.key(input.threadId, terminalId), snapshot); - queueMicrotask(() => { - this.emit("event", { - type: "restarted", + readonly restart: TerminalManagerShape["restart"] = (input: TerminalOpenInput) => + Effect.sync(() => { + const now = new Date().toISOString(); + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + const snapshot: TerminalSessionSnapshot = { threadId: input.threadId, terminalId, - createdAt: now, - snapshot, + cwd: input.cwd, + status: "running", + pid: 5252, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: now, + }; + this.sessions.set(this.key(input.threadId, terminalId), snapshot); + queueMicrotask(() => { + this.emitEvent({ + type: "restarted", + threadId: input.threadId, + terminalId, + createdAt: now, + snapshot, + }); }); + return snapshot; }); - return snapshot; - } - async close(input: TerminalCloseInput): Promise { - if (input.terminalId) { - this.sessions.delete(this.key(input.threadId, input.terminalId)); - return; - } - for (const key of [...this.sessions.keys()]) { - if (key.startsWith(`${input.threadId}\u0000`)) { - this.sessions.delete(key); + readonly close: TerminalManagerShape["close"] = (input: TerminalCloseInput) => + Effect.sync(() => { + if (input.terminalId) { + this.sessions.delete(this.key(input.threadId, input.terminalId)); + return; } - } - } + for (const key of [...this.sessions.keys()]) { + if (key.startsWith(`${input.threadId}\u0000`)) { + this.sessions.delete(key); + } + } + }); + + readonly subscribe: TerminalManagerShape["subscribe"] = (listener) => + Effect.sync(() => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }); - dispose(): void {} + readonly dispose: TerminalManagerShape["dispose"] = () => Effect.void; } function connectWs(port: number, token?: string): Promise { @@ -320,7 +343,7 @@ describe("WebSocket Server", () => { status: (input: { cwd: string }) => Promise; runStackedAction: (input: { cwd: string; action: string }) => Promise; }; - terminalManager?: TerminalManager; + terminalManager?: TerminalManagerShape; } = {}, ): Promise { if (serverScope) { @@ -990,7 +1013,7 @@ describe("WebSocket Server", () => { const terminalManager = new MockTerminalManager(); server = await createTestServer({ cwd: "/test", - terminalManager: terminalManager as unknown as TerminalManager, + terminalManager, }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1048,7 +1071,7 @@ describe("WebSocket Server", () => { createdAt: new Date().toISOString(), data: "manual test output\n", }; - terminalManager.emit("event", manualEvent); + terminalManager.emitEvent(manualEvent); const push = (await waitForMessage(ws)) as WsPush; expect(push.type).toBe("push"); @@ -1060,15 +1083,15 @@ describe("WebSocket Server", () => { const terminalManager = new MockTerminalManager(); server = await createTestServer({ cwd: "/test", - terminalManager: terminalManager as unknown as TerminalManager, + terminalManager, }); - expect(terminalManager.listenerCount("event")).toBe(1); + expect(terminalManager.subscriptionCount()).toBe(1); await closeTestServer(); server = null; - expect(terminalManager.listenerCount("event")).toBe(0); + expect(terminalManager.subscriptionCount()).toBe(0); }); it("returns validation errors for invalid terminal open params", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index d4afd8e714432..9ff5d0c84a7e7 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -30,7 +30,7 @@ import { pullGitBranch, removeGitWorktree, } from "./git"; -import { TerminalManager } from "./terminalManager"; +import { TerminalManager, type TerminalManagerShape } from "./terminalManager"; import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; @@ -61,7 +61,7 @@ export interface ServerOptions { autoBootstrapProjectFromCwd?: boolean | undefined; logWebSocketEvents?: boolean | undefined; gitManager?: GitManager | undefined; - terminalManager?: TerminalManager | undefined; + terminalManager?: TerminalManagerShape | undefined; } export interface ServerShape { @@ -152,7 +152,7 @@ export type ServerCoreRuntimeServices = | OrchestrationReactor | Open; -export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService; +export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService | TerminalManager; export class ServerLifecycleError extends Schema.TaggedErrorClass()( "ServerLifecycleError", @@ -172,10 +172,11 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E Scope.Scope | ServerRuntimeServices | ServerConfig > { const { port, cwd, staticDir, devUrl, authToken, mode, host } = yield* ServerConfig; + const defaultTerminalManager = yield* TerminalManager; const { logWebSocketEvents: explicitLogWsEvents, gitManager = new GitManager(), - terminalManager = new TerminalManager(), + terminalManager = defaultTerminalManager, } = options; const autoBootstrapProjectFromCwd = options.autoBootstrapProjectFromCwd ?? mode === "web"; @@ -541,32 +542,32 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E case WS_METHODS.terminalOpen: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => terminalManager.open(body)); + return yield* terminalManager.open(body); } case WS_METHODS.terminalWrite: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => terminalManager.write(body)); + return yield* terminalManager.write(body); } case WS_METHODS.terminalResize: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => terminalManager.resize(body)); + return yield* terminalManager.resize(body); } case WS_METHODS.terminalClear: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => terminalManager.clear(body)); + return yield* terminalManager.clear(body); } case WS_METHODS.terminalRestart: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => terminalManager.restart(body)); + return yield* terminalManager.restart(body); } case WS_METHODS.terminalClose: { const body = stripRequestTag(request.body); - return yield* Effect.promise(() => terminalManager.close(body)); + return yield* terminalManager.close(body); } case WS_METHODS.serverGetConfig: @@ -690,13 +691,15 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E ), ); - terminalManager.on("event", onTerminalEvent); + const unsubscribeTerminalEvents = yield* terminalManager.subscribe(onTerminalEvent); yield* Effect.addFinalizer(() => Effect.sync(() => { - terminalManager.off("event", onTerminalEvent); - terminalManager.dispose(); + unsubscribeTerminalEvents(); }), ); + yield* Effect.addFinalizer(() => + terminalManager.dispose().pipe(Effect.orElseSucceed(() => undefined)), + ); yield* NodeHttpServer.make(() => httpServer, listenOptions); diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index b99ca864e7780..db897c75cadad 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, - "types": ["node"], + "types": ["node", "bun"], "lib": ["ES2023", "esnext.disposable"], "noEmit": true, "allowImportingTsExtensions": true, diff --git a/bun.lock b/bun.lock index 6e316d6e4ee10..6c77bf45e5917 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "@effect/vitest": "4.0.0-beta.10", "@t3tools/contracts": "workspace:*", "@t3tools/web": "workspace:*", + "@types/bun": "^1.3.9", "@types/node": "^22.10.2", "@types/ws": "^8.5.13", "tsdown": "^0.20.3", @@ -527,6 +528,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -629,6 +632,8 @@ "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], @@ -1451,6 +1456,8 @@ "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + "bun-types/@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], @@ -1501,6 +1508,8 @@ "@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=="], + "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "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=="], From 195ddac9e9701687d2cd42ac9e6ac9233e214f5b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 19:28:45 -0800 Subject: [PATCH 50/98] remote support --- REMOTE.md | 65 +++++++++++++++++++ apps/server/src/index.ts | 4 +- apps/server/src/main.test.ts | 110 ++++++++++++++++++++++++++++++-- apps/server/src/main.ts | 119 ++++++++++++++++++++++++++--------- 4 files changed, 260 insertions(+), 38 deletions(-) create mode 100644 REMOTE.md diff --git a/REMOTE.md b/REMOTE.md new file mode 100644 index 0000000000000..8bbd481deae86 --- /dev/null +++ b/REMOTE.md @@ -0,0 +1,65 @@ +# Remote Access Setup + +Use this when you want to open T3 Code from another device (phone, tablet, another laptop). + +## CLI ↔ Env option map + +The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables: + +| CLI flag | Env var | Notes | +| ----------------------- | --------------------- | ---------------------------------- | +| `--mode ` | `T3CODE_MODE` | Runtime mode. | +| `--port ` | `T3CODE_PORT` | HTTP/WebSocket port. | +| `--host
` | `T3CODE_HOST` | Bind interface/address. | +| `--state-dir ` | `T3CODE_STATE_DIR` | State directory. | +| `--dev-url ` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | +| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | +| `--auth-token ` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. | + +> TIP: Use the `--help` flag to see all available options and their descriptions. + +## Security First + +- Always set `--auth-token` before exposing the server outside localhost. +- Treat the token like a password. +- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed. + +## 1) Build + run server for remote access + +Remote access should use the built web app (not local Vite redirect mode). + +```bash +bun run build +TOKEN="$(openssl rand -hex 24)" +bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser +``` + +Then open on your phone: + +`http://:3773` + +Example: + +`http://192.168.1.42:3773` + +Notes: + +- `--host 0.0.0.0` listens on all IPv4 interfaces. +- `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions. +- Ensure your OS firewall allows inbound TCP on the selected port. + +## 2) Tailnet / Tailscale access + +If you use Tailscale, you can bind directly to your Tailnet address. + +```bash +TAILNET_IP="$(tailscale ip -4)" +TOKEN="$(openssl rand -hex 24)" +bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser +``` + +Open from any device in your tailnet: + +`http://:3773` + +You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure. diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 711bedff37e0d..2f3ca764bae0e 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -3,7 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { CliConfig, makeCliCommand } from "./main"; +import { CliConfig, t3Cli } from "./main"; import { OpenLive } from "./open"; import { Command } from "effect/unstable/cli"; import { version } from "../package.json" with { type: "json" }; @@ -18,4 +18,4 @@ const RuntimeLayer = Layer.empty.pipe( Layer.provideMerge(NodeServices.layer), ); -Command.run(makeCliCommand(), { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain); +Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain); diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 836c97ea7d2d2..e14141fb7dead 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -7,7 +7,7 @@ import * as Layer from "effect/Layer"; import * as Command from "effect/unstable/cli/Command"; import { beforeEach } from "vitest"; -import { CliConfig, makeCliCommand, type CliConfigShape } from "./main"; +import { CliConfig, t3Cli, type CliConfigShape } from "./main"; import { NetService, ServerConfig, type ServerConfigShape } from "./config"; import { Open, type OpenShape } from "./open"; import { Server, type ServerShape, type ServerOptions } from "./wsServer"; @@ -51,7 +51,7 @@ const runCli = ( args: ReadonlyArray, env: Record = { T3CODE_NO_BROWSER: "true" }, ) => - Command.runWith(makeCliCommand(), { version: "0.0.0-test" })(args).pipe( + Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe( Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), ); @@ -74,33 +74,97 @@ beforeEach(() => { }); it.layer(testLayer)("server cli", (it) => { - it.effect("parses --port and --token and wires scoped start/stop", () => + it.effect("parses all CLI flags and wires scoped start/stop", () => Effect.gen(function* () { - yield* runCli(["--port", "4010", "--token", "secret"]); + yield* runCli([ + "--mode", + "desktop", + "--port", + "4010", + "--host", + "0.0.0.0", + "--state-dir", + "/tmp/t3-cli-state", + "--dev-url", + "http://127.0.0.1:5173", + "--no-browser", + "--auth-token", + "auth-secret", + ]); assert.equal(createServer.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "desktop"); assert.equal(resolvedConfig?.port, 4010); - assert.equal(resolvedConfig?.authToken, "secret"); + assert.equal(resolvedConfig?.host, "0.0.0.0"); + assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-state"); + assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); + assert.equal(resolvedConfig?.noBrowser, true); + assert.equal(resolvedConfig?.authToken, "auth-secret"); assert.equal(start.mock.calls.length, 1); assert.equal(stop.mock.calls.length, 1); }), ); + it.effect("supports --token as an alias for --auth-token", () => + Effect.gen(function* () { + yield* runCli(["--token", "token-secret"]); + + assert.equal(createServer.mock.calls.length, 1); + assert.equal(resolvedConfig?.authToken, "token-secret"); + }), + ); + it.effect("uses env fallbacks when flags are not provided", () => Effect.gen(function* () { yield* runCli([], { - T3CODE_NO_BROWSER: "true", + T3CODE_MODE: "desktop", T3CODE_PORT: "4999", + T3CODE_HOST: "100.88.10.4", + T3CODE_STATE_DIR: "/tmp/t3-env-state", + VITE_DEV_SERVER_URL: "http://localhost:5173", + T3CODE_NO_BROWSER: "true", T3CODE_AUTH_TOKEN: "env-token", }); assert.equal(createServer.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "desktop"); assert.equal(resolvedConfig?.port, 4999); + assert.equal(resolvedConfig?.host, "100.88.10.4"); + assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-state"); + assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/"); + assert.equal(resolvedConfig?.noBrowser, true); assert.equal(resolvedConfig?.authToken, "env-token"); assert.equal(findAvailablePort.mock.calls.length, 0); }), ); + it.effect("prefers --mode over T3CODE_MODE", () => + Effect.gen(function* () { + findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666)); + yield* runCli(["--mode", "web"], { + T3CODE_MODE: "desktop", + T3CODE_NO_BROWSER: "true", + }); + + assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); + assert.equal(createServer.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "web"); + assert.equal(resolvedConfig?.port, 4666); + assert.equal(resolvedConfig?.host, undefined); + }), + ); + + it.effect("prefers --no-browser over T3CODE_NO_BROWSER", () => + Effect.gen(function* () { + yield* runCli(["--no-browser"], { + T3CODE_NO_BROWSER: "false", + }); + + assert.equal(createServer.mock.calls.length, 1); + assert.equal(resolvedConfig?.noBrowser, true); + }), + ); + it.effect("uses dynamic port discovery in web mode when port is omitted", () => Effect.gen(function* () { findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444)); @@ -123,7 +187,41 @@ it.layer(testLayer)("server cli", (it) => { assert.equal(findAvailablePort.mock.calls.length, 0); assert.equal(createServer.mock.calls.length, 1); assert.equal(resolvedConfig?.port, 3773); + assert.equal(resolvedConfig?.host, "127.0.0.1"); + assert.equal(resolvedConfig?.mode, "desktop"); + }), + ); + + it.effect("allows overriding desktop host with --host", () => + Effect.gen(function* () { + yield* runCli(["--host", "0.0.0.0"], { + T3CODE_MODE: "desktop", + T3CODE_NO_BROWSER: "true", + }); + + assert.equal(createServer.mock.calls.length, 1); assert.equal(resolvedConfig?.mode, "desktop"); + assert.equal(resolvedConfig?.host, "0.0.0.0"); + }), + ); + + it.effect("does not start server for invalid --mode values", () => + Effect.gen(function* () { + yield* runCli(["--mode", "invalid"]); + + assert.equal(createServer.mock.calls.length, 0); + assert.equal(start.mock.calls.length, 0); + assert.equal(stop.mock.calls.length, 0); + }), + ); + + it.effect("does not start server for invalid --dev-url values", () => + Effect.gen(function* () { + yield* runCli(["--dev-url", "not-a-url"]).pipe(Effect.catch(() => Effect.void)); + + assert.equal(createServer.mock.calls.length, 0); + assert.equal(start.mock.calls.length, 0); + assert.equal(stop.mock.calls.length, 0); }), ); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 661ccae1919f8..4d0691ce05bee 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -21,8 +21,13 @@ export class StartupError extends Data.TaggedError("StartupError")<{ }> {} interface CliInput { + readonly mode: Option.Option; readonly port: Option.Option; - readonly token: Option.Option; + readonly host: Option.Option; + readonly stateDir: Option.Option; + readonly devUrl: Option.Option; + readonly noBrowser: Option.Option; + readonly authToken: Option.Option; } export interface CliConfigShape { @@ -60,6 +65,7 @@ const CliEnvConfig = Config.all({ ), ), port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), + host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), stateDir: Config.string("T3CODE_STATE_DIR").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -88,27 +94,39 @@ const ServerConfigLive = (input: CliInput) => ), ); + const mode = Option.getOrElse(input.mode, () => env.mode); + const port = yield* Option.match(input.port, { onSome: (value) => Effect.succeed(value), onNone: () => { if (env.port) { return Effect.succeed(env.port); } - if (env.mode === "desktop") { + if (mode === "desktop") { return Effect.succeed(DEFAULT_PORT); } return findAvailablePort(DEFAULT_PORT); }, }); - const stateDir = yield* resolveStateDir(env.stateDir); - const devUrl = env.devUrl; - const noBrowser = env.noBrowser ?? env.mode === "desktop"; - const authToken = Option.getOrUndefined(input.token) ?? env.authToken; + const stateDir = yield* resolveStateDir( + Option.getOrUndefined(input.stateDir) ?? env.stateDir, + ); + const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); + const noBrowser = Option.match(input.noBrowser, { + // effect/cli boolean flags parse to `false` when absent; in that case + // we still want env/mode fallbacks to apply. + onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")), + onNone: () => env.noBrowser ?? mode === "desktop", + }); + const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; - const host = env.mode === "desktop" ? "127.0.0.1" : undefined; + const host = + Option.getOrUndefined(input.host) ?? + env.host ?? + (mode === "desktop" ? "127.0.0.1" : undefined); return { - mode: env.mode, + mode, port, cwd: cliConfig.cwd, host, @@ -129,6 +147,12 @@ const LayerLive = (input: CliInput) => Layer.provideMerge(ServerConfigLive(input)), ); +const isWildcardHost = (host: string | undefined): boolean => + host === "0.0.0.0" || host === "::" || host === "[::]"; + +const formatHostForUrl = (host: string): string => + host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + const makeServerProgram = (input: CliInput) => Effect.gen(function* () { const cliConfig = yield* CliConfig; @@ -149,9 +173,15 @@ const makeServerProgram = (input: CliInput) => yield* createServer(); - const url = `http://localhost:${config.port}`; + const localUrl = `http://localhost:${config.port}`; + const bindUrl = + config.host && !isWildcardHost(config.host) + ? `http://${formatHostForUrl(config.host)}:${config.port}` + : localUrl; yield* Effect.logInfo("T3 Code running", { - url, + url: bindUrl, + localUrl, + bindHost: config.host ?? "default", cwd: config.cwd, mode: config.mode, stateDir: config.stateDir, @@ -159,7 +189,7 @@ const makeServerProgram = (input: CliInput) => }); if (!config.noBrowser) { - const target = config.devUrl?.toString() ?? url; + const target = config.devUrl?.toString() ?? bindUrl; yield* openDeps.openBrowser(target).pipe( Effect.catch(() => Effect.logInfo("browser auto-open unavailable", { @@ -172,22 +202,51 @@ const makeServerProgram = (input: CliInput) => return yield* stopSignal; }).pipe(Effect.provide(LayerLive(input))); -export function makeCliCommand() { - const portFlag = Flag.integer("port").pipe( - Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), - Flag.withDescription("Port for the HTTP/WebSocket server."), - Flag.optional, - ); - const tokenFlag = Flag.string("token").pipe( - Flag.withDescription("Auth token required for WebSocket connections."), - Flag.optional, - ); - - return Command.make("t3", { - port: portFlag, - token: tokenFlag, - }).pipe( - Command.withDescription("Run the T3 Code server."), - Command.withHandler((input) => Effect.scoped(makeServerProgram(input))), - ); -} +/** + * These flags mirrors the environment variables and the config shape. + */ + +const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( + Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), + Flag.optional, +); +const portFlag = Flag.integer("port").pipe( + Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), + Flag.withDescription("Port for the HTTP/WebSocket server."), + Flag.optional, +); +const hostFlag = Flag.string("host").pipe( + Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), + Flag.optional, +); +const stateDirFlag = Flag.string("state-dir").pipe( + Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."), + Flag.optional, +); +const devUrlFlag = Flag.string("dev-url").pipe( + Flag.withSchema(Schema.URLFromString), + Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), + Flag.optional, +); +const noBrowserFlag = Flag.boolean("no-browser").pipe( + Flag.withDescription("Disable automatic browser opening."), + Flag.optional, +); +const authTokenFlag = Flag.string("auth-token").pipe( + Flag.withDescription("Auth token required for WebSocket connections."), + Flag.withAlias("token"), + Flag.optional, +); + +export const t3Cli = Command.make("t3", { + mode: modeFlag, + port: portFlag, + host: hostFlag, + stateDir: stateDirFlag, + devUrl: devUrlFlag, + noBrowser: noBrowserFlag, + authToken: authTokenFlag, +}).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withHandler((input) => Effect.scoped(makeServerProgram(input))), +); From 49787ed6095c56baeb0866954a09af6af5049ec6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 20:15:40 -0800 Subject: [PATCH 51/98] fix checkpoint path --- .../Layers/CheckpointReactor.test.ts | 55 +++++++++++++++- .../orchestration/Layers/CheckpointReactor.ts | 66 ++++++++++++++++--- .../Layers/ProviderCommandReactor.test.ts | 4 ++ .../Layers/ProviderCommandReactor.ts | 33 ++++++++-- 4 files changed, 140 insertions(+), 18 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index da8fc58c39ea4..caa4d1f8d4a6e 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -220,6 +220,8 @@ describe("CheckpointReactor", () => { async function createHarness(options?: { readonly hasSession?: boolean; readonly seedFilesystemCheckpoints?: boolean; + readonly projectWorkspaceRoot?: string; + readonly threadWorktreePath?: string | null; }) { const cwd = createGitRepository(); tempDirs.push(cwd); @@ -252,7 +254,7 @@ describe("CheckpointReactor", () => { commandId: asCommandId("cmd-project-create"), projectId: asProjectId("project-1"), title: "Test Project", - workspaceRoot: "/tmp/test-project", + workspaceRoot: options?.projectWorkspaceRoot ?? cwd, defaultModel: "gpt-5-codex", createdAt, }), @@ -266,7 +268,7 @@ describe("CheckpointReactor", () => { title: "Thread", model: "gpt-5-codex", branch: null, - worktreePath: cwd, + worktreePath: options?.threadWorktreePath ?? cwd, createdAt, }), ); @@ -363,10 +365,11 @@ describe("CheckpointReactor", () => { ).toBe("v2\n"); }); - it("captures pre-turn baseline from thread.turn.start domain flow before runtime turn events", async () => { + it("captures pre-turn baseline from project workspace root when thread worktree is unset", async () => { const harness = await createHarness({ hasSession: false, seedFilesystemCheckpoints: false, + threadWorktreePath: null, }); await Effect.runPromise( @@ -390,6 +393,52 @@ describe("CheckpointReactor", () => { ).toBe("v1\n"); }); + it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => { + const harness = await createHarness({ + hasSession: false, + seedFilesystemCheckpoints: false, + threadWorktreePath: null, + }); + const createdAt = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: asCommandId("cmd-session-set-missing-provider-cwd"), + threadId: asThreadId("thread-1"), + session: { + threadId: asThreadId("thread-1"), + status: "running", + providerName: "codex", + providerSessionId: asSessionId("sess-missing"), + providerThreadId: ProviderThreadId.makeUnsafe("provider-thread-missing"), + activeTurnId: asTurnId("turn-missing-cwd"), + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }), + ); + + fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"), + provider: "codex", + sessionId: asSessionId("sess-missing"), + createdAt: new Date().toISOString(), + threadId: ProviderThreadId.makeUnsafe("provider-thread-missing"), + turnId: asTurnId("turn-missing-cwd"), + status: "completed", + }); + + await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); + expect(gitRefExists(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1))).toBe(true); + expect( + gitShowFileAtRef(harness.cwd, checkpointRefForTurn(asThreadId("thread-1"), 1), "README.md"), + ).toBe("v2\n"); + }); + it("maps runtime checkpoint.captured into thread.turn.diff.complete when missing in read model", async () => { const harness = await createHarness(); const createdAt = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 974670dd3ec64..eb8b5d71342e8 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -46,6 +46,27 @@ function trimToNonEmpty(value: string | null | undefined): string | undefined { return normalized && normalized.length > 0 ? normalized : undefined; } +function resolveThreadWorkspaceCwd(input: { + readonly thread: { + readonly projectId: string; + readonly worktreePath: string | null; + }; + readonly projects: ReadonlyArray<{ + readonly id: string; + readonly workspaceRoot: string; + }>; +}): string | undefined { + const worktreeCwd = trimToNonEmpty(input.thread.worktreePath); + if (worktreeCwd) { + return worktreeCwd; + } + + const workspaceRoot = input.projects.find( + (project) => project.id === input.thread.projectId, + )?.workspaceRoot; + return trimToNonEmpty(workspaceRoot); +} + function checkpointStatusFromRuntime(status: string | undefined): "ready" | "missing" | "error" { switch (status) { case "failed": @@ -133,6 +154,7 @@ const make = Effect.gen(function* () { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find((entry) => entry.id === threadId); const projectedSessionId = thread?.session?.providerSessionId ?? null; + const projectedProviderThreadId = trimToNonEmpty(thread?.session?.providerThreadId); const sessions = yield* providerService.listSessions(); @@ -154,8 +176,17 @@ const make = Effect.gen(function* () { } } - const matchedSession = sessions.find((session) => session.threadId?.trim() === threadId.trim()); - return findSessionWithCwd(matchedSession); + if (projectedProviderThreadId) { + const matchedSession = sessions.find( + (session) => session.threadId?.trim() === projectedProviderThreadId, + ); + const fromProviderThread = findSessionWithCwd(matchedSession); + if (Option.isSome(fromProviderThread)) { + return fromProviderThread; + } + } + + return Option.none(); }); const captureCheckpointFromTurnCompletion = Effect.fnUntraced(function* ( @@ -179,7 +210,16 @@ const make = Effect.gen(function* () { } const sessionRuntime = yield* resolveSessionRuntimeForThread(thread.id); - if (Option.isNone(sessionRuntime)) { + const checkpointCwd = + Option.match(sessionRuntime, { + onNone: () => undefined, + onSome: (runtime) => runtime.cwd, + }) ?? + resolveThreadWorkspaceCwd({ + thread, + projects: readModel.projects, + }); + if (!checkpointCwd) { yield* Effect.logWarning("checkpoint capture skipped: no active provider session cwd", { threadId: thread.id, turnId, @@ -197,7 +237,7 @@ const make = Effect.gen(function* () { const targetCheckpointRef = checkpointRefForThreadTurn(thread.id, nextTurnCount); const fromCheckpointExists = yield* checkpointStore.hasCheckpointRef({ - cwd: sessionRuntime.value.cwd, + cwd: checkpointCwd, checkpointRef: fromCheckpointRef, }); if (!fromCheckpointExists) { @@ -209,13 +249,13 @@ const make = Effect.gen(function* () { } yield* checkpointStore.captureCheckpoint({ - cwd: sessionRuntime.value.cwd, + cwd: checkpointCwd, checkpointRef: targetCheckpointRef, }); const files = yield* checkpointStore .diffCheckpoints({ - cwd: sessionRuntime.value.cwd, + cwd: checkpointCwd, fromCheckpointRef, toCheckpointRef: targetCheckpointRef, fallbackFromToHead: false, @@ -296,9 +336,12 @@ const make = Effect.gen(function* () { return; } - const checkpointCwdFromThread = trimToNonEmpty(thread.worktreePath); + const checkpointCwdFromThreadOrProject = resolveThreadWorkspaceCwd({ + thread, + projects: readModel.projects, + }); const checkpointCwd = - checkpointCwdFromThread ?? + checkpointCwdFromThreadOrProject ?? Option.match(yield* resolveSessionRuntimeForThread(thread.id), { onNone: () => undefined, onSome: (runtime) => runtime.cwd, @@ -349,9 +392,12 @@ const make = Effect.gen(function* () { return; } - const checkpointCwdFromThread = trimToNonEmpty(thread.worktreePath); + const checkpointCwdFromThreadOrProject = resolveThreadWorkspaceCwd({ + thread, + projects: readModel.projects, + }); const checkpointCwd = - checkpointCwdFromThread ?? + checkpointCwdFromThreadOrProject ?? Option.match(yield* resolveSessionRuntimeForThread(threadId), { onNone: () => undefined, onSome: (runtime) => runtime.cwd, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 95265cfe5a575..d8c58df8531d3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -181,6 +181,10 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[0]).toMatchObject({ + cwd: "/tmp/provider-project", + model: "gpt-5-codex", + }); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const thread = readModel.threads.find((entry) => entry.id === asThreadId("thread-1")); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 0dbaa514e53e3..ae10b413798e2 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -5,7 +5,6 @@ import { type OrchestrationEvent, type ProviderKind, type OrchestrationSession, - type ProviderSessionId, type ThreadId, type TurnId, } from "@t3tools/contracts"; @@ -34,6 +33,27 @@ function trimToNonEmptyOrUndefined(value: string | undefined): string | undefine return normalized && normalized.length > 0 ? normalized : undefined; } +function resolveEffectiveThreadCwd(input: { + readonly thread: { + readonly projectId: string; + readonly worktreePath: string | null; + }; + readonly projects: ReadonlyArray<{ + readonly id: string; + readonly workspaceRoot: string; + }>; +}): string | undefined { + const worktreeCwd = trimToNonEmptyOrUndefined(input.thread.worktreePath ?? undefined); + if (worktreeCwd) { + return worktreeCwd; + } + + const workspaceRoot = input.projects.find( + (project) => project.id === input.thread.projectId, + )?.workspaceRoot; + return trimToNonEmptyOrUndefined(workspaceRoot); +} + function mapProviderSessionStatusToOrchestrationStatus( status: "connecting" | "ready" | "running" | "error" | "closed", ): OrchestrationSession["status"] { @@ -115,7 +135,8 @@ const make = Effect.gen(function* () { threadId: ThreadId, createdAt: string, ) { - const thread = yield* resolveThread(threadId); + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find((entry) => entry.id === threadId); if (!thread) { return yield* Effect.die(new Error(`Thread '${threadId}' was not found in read model.`)); } @@ -129,12 +150,14 @@ const make = Effect.gen(function* () { thread.session?.providerName === "codex" || thread.session?.providerName === "claudeCode" ? thread.session.providerName : undefined; + const effectiveCwd = resolveEffectiveThreadCwd({ + thread, + projects: readModel.projects, + }); const startedSession = yield* providerService.startSession({ ...(preferredProvider ? { provider: preferredProvider } : {}), - ...(trimToNonEmptyOrUndefined(thread.worktreePath ?? undefined) - ? { cwd: trimToNonEmptyOrUndefined(thread.worktreePath ?? undefined) } - : {}), + ...(effectiveCwd ? { cwd: effectiveCwd } : {}), ...(trimToNonEmptyOrUndefined(thread.model) ? { model: thread.model } : {}), }); From 31f7a7ba90309e81935f3e3ec483a39825d754cf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 21:48:41 -0800 Subject: [PATCH 52/98] Improve chat layout responsiveness and adopt new sidebar primitives - Refactor chat, header, and composer spacing/overflow for mobile-friendly behavior - Replace custom sidebar structure with reusable collapsible/sidebar UI components - Tighten compact controls with responsive labels, triggers, and accessibility tweaks --- apps/web/src/components/ChatMarkdown.tsx | 2 +- apps/web/src/components/ChatView.tsx | 70 +- apps/web/src/components/GitActionsControl.tsx | 6 +- .../src/components/ProjectScriptsControl.tsx | 8 +- apps/web/src/components/Sidebar.tsx | 419 ++++++----- apps/web/src/components/ui/collapsible.tsx | 45 ++ apps/web/src/components/ui/sidebar.tsx | 700 ++++++++++++++++++ apps/web/src/components/ui/tooltip.tsx | 59 ++ apps/web/src/index.css | 15 + apps/web/src/routes/_chat.index.tsx | 10 + apps/web/src/routes/_chat.tsx | 24 +- 11 files changed, 1128 insertions(+), 230 deletions(-) create mode 100644 apps/web/src/components/ui/collapsible.tsx create mode 100644 apps/web/src/components/ui/sidebar.tsx create mode 100644 apps/web/src/components/ui/tooltip.tsx diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e16d09e2a3705..e8e2b9d85c8ae 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -16,7 +16,7 @@ const markdownComponents: Components = { function ChatMarkdown({ text }: ChatMarkdownProps) { return ( -
+
+ {!isElectron && ( +
+
+ + Threads +
+
+ )} {isElectron && (
No active thread @@ -1681,10 +1690,13 @@ export default function ChatView({ threadId }: ChatViewProps) { } return ( -
+
{/* Top bar */}
{/* Input bar */} -
-
+
+
{/* Textarea area */} -
+
{composerMenuOpen && (
{/* Bottom toolbar */} -
-
+
+
{/* Model picker */} {/* Divider */} - + {/* Reasoning effort */} {/* Divider */} - + {/* Runtime mode toggle */}
{/* Right side: send / stop button */} -
+
{phase === "running" ? ( - + }>
-
- {THEME_OPTIONS.map((option) => { - const selected = theme === option.value; - return ( - - ); - })} -
+
+
+

Appearance

+

+ Choose how T3 Code handles light and dark mode. +

+
-

- Active theme: {resolvedTheme} -

-
+
+ {THEME_OPTIONS.map((option) => { + const selected = theme === option.value; + return ( + + ); + })} +
-
-
-

Codex App Server

-

- These overrides apply to new sessions and let you use a non-default Codex install. +

+ Active theme: {resolvedTheme}

-
- -
- +
- - -
-

- Binary source: {codexBinaryPath || "PATH"} +

+
+

Codex App Server

+

+ These overrides apply to new sessions and let you use a non-default Codex install.

-
-
- -
-
-

Runtime Mode

-

- Select the default execution policy for this client. -

-
+
+ -
- {RUNTIME_MODE_OPTIONS.map((option) => { - const selected = state.runtimeMode === option.value; - return ( - - ); - })} -
-
+ Reset codex overrides + +
+
+ -
-
-

Safety

-

- Additional guardrails for destructive local actions. -

-
+
+
+

Runtime Mode

+

+ Select the default execution policy for this client. +

+
+ +
+ {RUNTIME_MODE_OPTIONS.map((option) => { + const selected = state.runtimeMode === option.value; + return ( + + ); + })} +
+
-
-
-

Confirm thread deletion

-

- Ask for confirmation before deleting a thread and its chat history. +

+
+

Safety

+

+ Additional guardrails for destructive local actions.

- - updateSettings({ - confirmThreadDelete: Boolean(checked), - }) - } - aria-label="Confirm thread deletion" - /> -
- {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( -
- + aria-label="Confirm thread deletion" + />
- ) : null} -
+ + {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( +
+ +
+ ) : null} + +
- + ); } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 38cf5bcccd4f5..0c535c68fa0df 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -33,11 +33,9 @@ function ChatRouteLayout() { > - - - - - + + + ); } diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index c19f2618fe3f9..bf2691fbde181 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -8,10 +8,7 @@ import { useReducer, } from "react"; -import { - type OrchestrationReadModel, - type OrchestrationSessionStatus, -} from "@t3tools/contracts"; +import { type OrchestrationReadModel, type OrchestrationSessionStatus } from "@t3tools/contracts"; import { DEFAULT_MODEL, resolveModelSlug } from "./model-logic"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, @@ -30,8 +27,7 @@ import { type Action = | { type: "SYNC_SERVER_READ_MODEL"; readModel: OrchestrationReadModel } - | { type: "SET_PROJECT_SCRIPTS"; projectId: string; scripts: ProjectScript[] } - | { type: "SET_THREADS_HYDRATED"; hydrated: boolean } + | { type: "MARK_THREAD_VISITED"; threadId: string; visitedAt?: string } | { type: "TOGGLE_PROJECT"; projectId: string } | { type: "TOGGLE_THREAD_TERMINAL"; threadId: string } | { type: "SET_THREAD_TERMINAL_OPEN"; threadId: string; open: boolean } @@ -137,10 +133,6 @@ function updateThread( return threads.map((t) => (t.id === threadId ? updater(t) : t)); } -function cloneProjectScripts(scripts: ReadonlyArray): ProjectScript[] { - return scripts.map((script) => ({ ...script })); -} - function mapProjectsFromReadModel( incoming: OrchestrationReadModel["projects"], previous: Project[], @@ -159,7 +151,7 @@ function mapProjectsFromReadModel( (persistedExpandedProjectCwds.size > 0 ? persistedExpandedProjectCwds.has(project.workspaceRoot) : true), - scripts: cloneProjectScripts(project.scripts), + scripts: project.scripts.map((script) => ({ ...script })), }; }); } @@ -187,7 +179,6 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { return providerName === "claudeCode" ? "claudeCode" : "codex"; } - 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); @@ -208,7 +199,6 @@ function normalizeRunningTerminalIds( .filter((id) => id.length > 0 && validTerminalIdSet.has(id)); } - function normalizeTerminalGroupIds(terminalIds: string[]): string[] { return [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; } @@ -374,7 +364,6 @@ function closeThreadTerminal(thread: Thread, terminalId: string): Thread { }); } - // ── Reducer ────────────────────────────────────────────────────────── export function reducer(state: AppState, action: Action): AppState { @@ -384,94 +373,100 @@ export function reducer(state: AppState, action: Action): AppState { action.readModel.projects.filter((project) => project.deletedAt === null), state.projects, ); - const existingThreadById = new Map(state.threads.map((thread) => [thread.id, thread] as const)); + const existingThreadById = new Map( + state.threads.map((thread) => [thread.id, thread] as const), + ); const threads = action.readModel.threads .filter((thread) => thread.deletedAt === null) .map((thread) => { - const existing = existingThreadById.get(thread.id); - const latestTurnStartedAt = - thread.latestTurnId === null - ? undefined - : thread.messages.find( - (message) => message.turnId === thread.latestTurnId && message.role === "user", - )?.createdAt; - const latestTurnCompletedAt = thread.checkpoints.find( - (checkpoint) => checkpoint.turnId === thread.latestTurnId, - )?.completedAt; - const latestTurnDurationMs = - latestTurnStartedAt && latestTurnCompletedAt - ? Math.max(0, Date.parse(latestTurnCompletedAt) - Date.parse(latestTurnStartedAt)) - : undefined; - - return normalizeThreadTerminals({ - id: thread.id, - codexThreadId: thread.session?.providerThreadId ?? null, - projectId: thread.projectId, - title: thread.title, - model: resolveModelSlug(thread.model), - terminalOpen: existing?.terminalOpen ?? false, - terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: existing?.runningTerminalIds ?? [], - activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: existing?.terminalGroups ?? [ - { id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, terminalIds: [DEFAULT_THREAD_TERMINAL_ID] }, - ], - activeTerminalGroupId: existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, - session: thread.session - ? { - sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`, - provider: toLegacyProvider(thread.session.providerName), - status: toLegacySessionStatus(thread.session.status), - orchestrationStatus: thread.session.status, - threadId: thread.session.providerThreadId, - activeTurnId: thread.session.activeTurnId ?? undefined, - createdAt: thread.session.updatedAt, - updatedAt: thread.session.updatedAt, - ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), - } - : null, - messages: thread.messages.map((message) => { - const attachments = message.attachments?.map((attachment, index) => ({ - type: "image" as const, - id: `${message.id}:${index}`, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - previewUrl: attachment.dataUrl, - })); - const normalizedMessage: ChatMessage = { - id: message.id, - role: message.role, - text: message.text, - createdAt: message.createdAt, - streaming: message.streaming, - ...(message.streaming ? {} : { completedAt: message.updatedAt }), - ...(attachments && attachments.length > 0 ? { attachments } : {}), - }; - return normalizedMessage; - }), - error: thread.session?.lastError ?? null, - createdAt: thread.createdAt, - latestTurnId: thread.latestTurnId ?? undefined, - latestTurnStartedAt, - latestTurnCompletedAt, - latestTurnDurationMs, - lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, - branch: thread.branch, - worktreePath: thread.worktreePath, - turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ - turnId: checkpoint.turnId, - completedAt: checkpoint.completedAt, - status: checkpoint.status, - assistantMessageId: checkpoint.assistantMessageId ?? undefined, - checkpointTurnCount: checkpoint.checkpointTurnCount, - checkpointRef: checkpoint.checkpointRef, - files: checkpoint.files.map((file) => ({ ...file })), - })), - activities: thread.activities.map((activity) => ({ ...activity })), + const existing = existingThreadById.get(thread.id); + const latestTurnStartedAt = + thread.latestTurnId === null + ? undefined + : thread.messages.find( + (message) => message.turnId === thread.latestTurnId && message.role === "user", + )?.createdAt; + const latestTurnCompletedAt = thread.checkpoints.find( + (checkpoint) => checkpoint.turnId === thread.latestTurnId, + )?.completedAt; + const latestTurnDurationMs = + latestTurnStartedAt && latestTurnCompletedAt + ? Math.max(0, Date.parse(latestTurnCompletedAt) - Date.parse(latestTurnStartedAt)) + : undefined; + + return normalizeThreadTerminals({ + id: thread.id, + codexThreadId: thread.session?.providerThreadId ?? null, + projectId: thread.projectId, + title: thread.title, + model: resolveModelSlug(thread.model), + terminalOpen: existing?.terminalOpen ?? false, + terminalHeight: existing?.terminalHeight ?? DEFAULT_THREAD_TERMINAL_HEIGHT, + terminalIds: existing?.terminalIds ?? [DEFAULT_THREAD_TERMINAL_ID], + runningTerminalIds: existing?.runningTerminalIds ?? [], + activeTerminalId: existing?.activeTerminalId ?? DEFAULT_THREAD_TERMINAL_ID, + terminalGroups: existing?.terminalGroups ?? [ + { + id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, + terminalIds: [DEFAULT_THREAD_TERMINAL_ID], + }, + ], + activeTerminalGroupId: + existing?.activeTerminalGroupId ?? `group-${DEFAULT_THREAD_TERMINAL_ID}`, + session: thread.session + ? { + sessionId: thread.session.providerSessionId ?? `thread:${thread.id}`, + provider: toLegacyProvider(thread.session.providerName), + status: toLegacySessionStatus(thread.session.status), + orchestrationStatus: thread.session.status, + threadId: thread.session.providerThreadId, + activeTurnId: thread.session.activeTurnId ?? undefined, + createdAt: thread.session.updatedAt, + updatedAt: thread.session.updatedAt, + ...(thread.session.lastError ? { lastError: thread.session.lastError } : {}), + } + : null, + messages: thread.messages.map((message) => { + const attachments = message.attachments?.map((attachment, index) => ({ + type: "image" as const, + id: `${message.id}:${index}`, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + previewUrl: attachment.dataUrl, + })); + const normalizedMessage: ChatMessage = { + id: message.id, + role: message.role, + text: message.text, + createdAt: message.createdAt, + streaming: message.streaming, + ...(message.streaming ? {} : { completedAt: message.updatedAt }), + ...(attachments && attachments.length > 0 ? { attachments } : {}), + }; + return normalizedMessage; + }), + error: thread.session?.lastError ?? null, + createdAt: thread.createdAt, + latestTurnId: thread.latestTurnId ?? undefined, + latestTurnStartedAt, + latestTurnCompletedAt, + latestTurnDurationMs, + lastVisitedAt: existing?.lastVisitedAt ?? thread.updatedAt, + branch: thread.branch, + worktreePath: thread.worktreePath, + turnDiffSummaries: thread.checkpoints.map((checkpoint) => ({ + turnId: checkpoint.turnId, + completedAt: checkpoint.completedAt, + status: checkpoint.status, + assistantMessageId: checkpoint.assistantMessageId ?? undefined, + checkpointTurnCount: checkpoint.checkpointTurnCount, + checkpointRef: checkpoint.checkpointRef, + files: checkpoint.files.map((file) => ({ ...file })), + })), + activities: thread.activities.map((activity) => ({ ...activity })), + }); }); - }); return { ...state, projects, @@ -480,24 +475,27 @@ export function reducer(state: AppState, action: Action): AppState { }; } - case "SET_PROJECT_SCRIPTS": - return { - ...state, - projects: state.projects.map((project) => - project.id === action.projectId - ? { ...project, scripts: cloneProjectScripts(action.scripts) } - : project, - ), - }; - - case "SET_THREADS_HYDRATED": - if (state.threadsHydrated === action.hydrated) { - return state; - } + case "MARK_THREAD_VISITED": { + const visitedAt = action.visitedAt ?? new Date().toISOString(); + const visitedAtMs = Date.parse(visitedAt); return { ...state, - threadsHydrated: action.hydrated, + threads: updateThread(state.threads, action.threadId, (thread) => { + const previousVisitedAtMs = thread.lastVisitedAt ? Date.parse(thread.lastVisitedAt) : NaN; + if ( + Number.isFinite(previousVisitedAtMs) && + Number.isFinite(visitedAtMs) && + previousVisitedAtMs >= visitedAtMs + ) { + return thread; + } + return { + ...thread, + lastVisitedAt: visitedAt, + }; + }), }; + } case "TOGGLE_PROJECT": return { @@ -540,10 +538,7 @@ export function reducer(state: AppState, action: Action): AppState { threads: updateThread(state.threads, action.threadId, (thread) => { const normalizedThread = normalizeThreadTerminals(thread); const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId); - if ( - isNewTerminal && - normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT - ) { + if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { return normalizedThread; } const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) @@ -613,10 +608,7 @@ export function reducer(state: AppState, action: Action): AppState { threads: updateThread(state.threads, action.threadId, (thread) => { const normalizedThread = normalizeThreadTerminals(thread); const isNewTerminal = !normalizedThread.terminalIds.includes(action.terminalId); - if ( - isNewTerminal && - normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT - ) { + if (isNewTerminal && normalizedThread.terminalIds.length >= MAX_THREAD_TERMINAL_COUNT) { return normalizedThread; } const terminalIds = normalizedThread.terminalIds.includes(action.terminalId) From 98706aebff68aff938c3c9004dccfac4a02970eb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 25 Feb 2026 10:09:06 -0800 Subject: [PATCH 55/98] Refine Electron header drag regions and diff panel layout - Restructure Chat, DiffPanel, and Sidebar headers for cleaner Electron drag/no-drag behavior - Keep turn-strip controls interactive while preserving draggable titlebar regions - Lower inline diff panel minimum width from 28rem to 26rem for tighter layouts --- apps/web/src/components/ChatView.tsx | 2 +- apps/web/src/components/DiffPanel.tsx | 214 ++++++++++++------------ apps/web/src/components/Sidebar.tsx | 80 +++++---- apps/web/src/routes/_chat.$threadId.tsx | 2 +- 4 files changed, 160 insertions(+), 138 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8272223ae3477..ab3248639b0f7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1698,7 +1698,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
-
-
+
+ {canScrollTurnStripLeft && ( +
+ )} + {canScrollTurnStripRight && ( +
+ )} + + +
- -
+ {orderedTurnDiffSummaries.map((summary) => ( - {orderedTurnDiffSummaries.map((summary) => ( - - ))} -
-
- { - const next = value[0]; - if (next === "stacked" || next === "split") { - setDiffRenderMode(next); - } - }} - > - - - - - - - + ))}
+ { + const next = value[0]; + if (next === "stacked" || next === "split") { + setDiffRenderMode(next); + } + }} + > + + + + + + + + + ); + const headerRowClassName = cn( + "flex items-center justify-between gap-2 px-4", + shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12", + ); + + return ( +
+ {shouldUseDragRegion ? ( +
{headerRow}
+ ) : ( +
+
{headerRow}
+
+ )} {!activeThread ? (
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 566b386914b62..7bed62ff2096a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -467,40 +467,56 @@ export default function Sidebar() { }; }, [handleNewThread, keybindings, routeThreadId, state.projects, state.threads]); + const onCreateThreadClick = () => { + if (state.projects.length === 0) { + setAddingProject(true); + return; + } + const firstProject = state.projects[0]; + if (firstProject) void handleNewThread(firstProject.id); + }; + + const wordmark = ( +
+ +
+ + + Code + + + {APP_STAGE_LABEL} + +
+
+ ); + + const newThreadButton = ( + + ); + return ( <> - -
- -
- - - Code - - - {APP_STAGE_LABEL} - -
-
- - -
+ {isElectron ? ( + <> + + {wordmark} + +
{newThreadButton}
+ + ) : ( + + {wordmark} + {newThreadButton} + + )} diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 1cebff4b3b877..5c23d14f17efe 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -12,7 +12,7 @@ const DiffPanel = lazy(() => import("../components/DiffPanel")); const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; const DIFF_INLINE_SIDEBAR_WIDTH_STORAGE_KEY = "chat_diff_sidebar_width"; const DIFF_INLINE_DEFAULT_WIDTH = "clamp(28rem,48vw,44rem)"; -const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 28 * 16; +const DIFF_INLINE_SIDEBAR_MIN_WIDTH = 26 * 16; const DiffPanelSheet = (props: { children: ReactNode; From c3672a44f0414e85254b4ba6bef5b701994882db Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 25 Feb 2026 12:28:18 -0800 Subject: [PATCH 56/98] keybindings --- apps/desktop/scripts/dev-electron.mjs | 5 +- apps/desktop/scripts/electron-launcher.mjs | 53 +- apps/desktop/src/main.ts | 7 +- .../integration/fixtures/providerRuntime.ts | 8 +- .../orchestrationEngine.integration.test.ts | 85 +- .../providerService.integration.test.ts | 2 +- apps/server/src/checkpointing/Diffs.ts | 4 +- apps/server/src/codexAppServerManager.ts | 19 +- apps/server/src/config.ts | 1 + apps/server/src/keybindings.test.ts | 475 +- apps/server/src/keybindings.ts | 370 +- apps/server/src/main.ts | 3 + apps/server/src/open.test.ts | 28 +- .../Layers/CheckpointReactor.test.ts | 13 +- .../orchestration/Layers/CheckpointReactor.ts | 31 +- .../Layers/OrchestrationReactor.ts | 5 +- .../Layers/ProjectionPipeline.test.ts | 5 +- .../Layers/ProjectionPipeline.ts | 19 +- .../Layers/ProjectionSnapshotQuery.test.ts | 3 +- .../Layers/ProjectionSnapshotQuery.ts | 233 +- .../Layers/ProviderCommandReactor.test.ts | 8 +- .../Layers/ProviderCommandReactor.ts | 4 +- .../Layers/ProviderRuntimeIngestion.ts | 12 +- apps/server/src/orchestration/Schemas.ts | 3 +- .../Services/ProjectionPipeline.ts | 5 +- .../src/orchestration/commandInvariants.ts | 60 +- apps/server/src/orchestration/decider.ts | 3 +- .../src/orchestration/projector.test.ts | 24 +- apps/server/src/orchestration/projector.ts | 31 +- .../Layers/OrchestrationEventStore.ts | 5 +- .../Layers/ProjectionCheckpoints.ts | 5 +- .../Layers/ProjectionThreadActivities.ts | 4 +- .../persistence/Layers/ProviderSessions.ts | 4 +- .../provider/Layers/ProviderService.test.ts | 200 +- .../Layers/ProviderSessionDirectory.test.ts | 11 +- .../Services/ProviderSessionDirectory.ts | 1 - apps/server/src/serverLayers.ts | 8 +- .../src/workspaceEntries.chunking.test.ts | 15 +- apps/server/src/workspaceEntries.test.ts | 48 +- apps/server/src/wsServer.test.ts | 1 + apps/server/src/wsServer.ts | 18 +- apps/web/src/components/BranchToolbar.tsx | 10 +- apps/web/src/components/ChatView.tsx | 43 +- apps/web/src/components/DiffPanel.tsx | 5 +- .../src/components/DiffWorkerPoolProvider.tsx | 4 +- .../src/components/ThreadTerminalDrawer.tsx | 8 +- apps/web/src/components/ui/collapsible.tsx | 10 +- apps/web/src/components/ui/sidebar.tsx | 13 +- apps/web/src/keybindings.test.ts | 4 +- apps/web/src/lib/providerReactQuery.test.ts | 10 +- apps/web/src/markdown-links.test.ts | 6 +- apps/web/src/markdown-links.ts | 3 +- apps/web/src/projectScripts.ts | 28 +- apps/web/src/routeTree.gen.ts | 140 +- apps/web/src/session-logic.ts | 9 +- .../vscode-icons-language-associations.json | 482 +- apps/web/src/vscode-icons-manifest.json | 8352 ++++++++++++++++- bun.lock | 1 + packages/contracts/package.json | 1 + packages/contracts/src/keybindings.test.ts | 108 +- packages/contracts/src/keybindings.ts | 40 +- packages/contracts/src/providerRuntime.ts | 12 +- packages/contracts/src/terminal.ts | 7 +- packages/contracts/src/ws.ts | 1 - scripts/build-desktop-dmg.mjs | 9 +- 65 files changed, 10082 insertions(+), 1063 deletions(-) diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index 03ac96b5a070e..c1dfad2a390ed 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -46,10 +46,7 @@ async function shutdown(exitCode) { shuttingDown = true; try { - await Promise.race([ - monitor.destroy(), - new Promise((resolve) => setTimeout(resolve, 1_500)), - ]); + await Promise.race([monitor.destroy(), new Promise((resolve) => setTimeout(resolve, 1_500))]); } catch { // Best effort only; fallback process kill below handles stubborn children. } diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 2a6d685dea203..9d7c522781a93 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -25,34 +25,22 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); export const desktopDir = resolve(__dirname, ".."); function setPlistString(plistPath, key, value) { - const replaceResult = spawnSync( - "plutil", - ["-replace", key, "-string", value, plistPath], - { - encoding: "utf8", - }, - ); + const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], { + encoding: "utf8", + }); if (replaceResult.status === 0) { return; } - const insertResult = spawnSync( - "plutil", - ["-insert", key, "-string", value, plistPath], - { - encoding: "utf8", - }, - ); + const insertResult = spawnSync("plutil", ["-insert", key, "-string", value, plistPath], { + encoding: "utf8", + }); if (insertResult.status === 0) { return; } - const details = [replaceResult.stderr, insertResult.stderr] - .filter(Boolean) - .join("\n"); - throw new Error( - `Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim(), - ); + const details = [replaceResult.stderr, insertResult.stderr].filter(Boolean).join("\n"); + throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim()); } function patchMainBundleInfoPlist(appBundlePath, iconPath) { @@ -81,28 +69,16 @@ function patchHelperBundleInfoPlists(appBundlePath) { continue; } - const helperPlistPath = join( - frameworksDir, - entry.name, - "Contents", - "Info.plist", - ); + const helperPlistPath = join(frameworksDir, entry.name, "Contents", "Info.plist"); if (!existsSync(helperPlistPath)) { continue; } - const suffix = entry.name - .replace("Electron Helper", "") - .replace(".app", "") - .trim(); + const suffix = entry.name.replace("Electron Helper", "").replace(".app", "").trim(); const helperName = suffix ? `${APP_DISPLAY_NAME} Helper ${suffix}` : `${APP_DISPLAY_NAME} Helper`; - const helperIdSuffix = suffix - .replace(/[()]/g, "") - .trim() - .toLowerCase() - .replace(/\s+/g, "-"); + const helperIdSuffix = suffix.replace(/[()]/g, "").trim().toLowerCase().replace(/\s+/g, "-"); const helperBundleId = helperIdSuffix ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}` : `${APP_BUNDLE_ID}.helper`; @@ -125,12 +101,7 @@ function buildMacLauncher(electronBinaryPath) { const sourceAppBundlePath = resolve(electronBinaryPath, "../../.."); const runtimeDir = join(desktopDir, ".electron-runtime"); const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); - const targetBinaryPath = join( - targetAppBundlePath, - "Contents", - "MacOS", - "Electron", - ); + const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron"); const iconPath = join(desktopDir, "resources", "icon.icns"); const metadataPath = join(runtimeDir, "metadata.json"); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 792e78811ecf8..c4af043591bce 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -126,7 +126,9 @@ function registerDesktopProtocol(): void { const staticRoot = resolveDesktopStaticDir(); if (!staticRoot) { - throw new Error("Desktop static bundle missing. Build apps/server (with bundled client) first."); + throw new Error( + "Desktop static bundle missing. Build apps/server (with bundled client) first.", + ); } const staticRootResolved = path.resolve(staticRoot); @@ -160,7 +162,8 @@ function registerDesktopProtocol(): void { } function dispatchMenuAction(action: string): void { - const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; + const existingWindow = + BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; const targetWindow = existingWindow ?? createWindow(); if (!existingWindow) { mainWindow = targetWindow; diff --git a/apps/server/integration/fixtures/providerRuntime.ts b/apps/server/integration/fixtures/providerRuntime.ts index e33e1336e840d..c846fcb704b29 100644 --- a/apps/server/integration/fixtures/providerRuntime.ts +++ b/apps/server/integration/fixtures/providerRuntime.ts @@ -13,10 +13,10 @@ const THREAD_ID = ProviderThreadId.makeUnsafe("fixture-thread"); const TURN_ID = ProviderTurnId.makeUnsafe("fixture-turn"); const REQUEST_ID = ApprovalRequestId.makeUnsafe("req-1"); -function baseEvent(eventId: string, createdAt: string): Pick< - ProviderRuntimeEvent, - "eventId" | "provider" | "sessionId" | "createdAt" -> { +function baseEvent( + eventId: string, + createdAt: string, +): Pick { return { eventId: EventId.makeUnsafe(eventId), provider: PROVIDER, diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 75a2811ecbd99..a2d8aeff4a629 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -29,10 +29,12 @@ const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); -const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asProviderSessionId = (value: string): ProviderSessionId => + ProviderSessionId.makeUnsafe(value); const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); -const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); +const asApprovalRequestId = (value: string): ApprovalRequestId => + ApprovalRequestId.makeUnsafe(value); const PROJECT_ID = asProjectId("project-1"); const THREAD_ID = asThreadId("thread-1"); @@ -45,7 +47,8 @@ function nowIso() { return new Date().toISOString(); } -const sleep = (ms: number) => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); +const sleep = (ms: number) => + Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); function waitForSync
( read: () => A, @@ -78,9 +81,7 @@ function runtimeBase(eventId: string, createdAt: string) { }; } -function withHarness( - use: (harness: OrchestrationIntegrationHarness) => Effect.Effect, -) { +function withHarness(use: (harness: OrchestrationIntegrationHarness) => Effect.Effect) { return Effect.acquireUseRelease( makeOrchestrationIntegrationHarness, use, @@ -176,7 +177,9 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + THREAD_ID, (entry) => entry.session?.status === "ready" && - entry.messages.some((message) => message.role === "assistant" && message.streaming === false) && + entry.messages.some( + (message) => message.role === "assistant" && message.streaming === false, + ) && entry.checkpoints.length === 1, ); assert.equal(thread.checkpoints[0]?.status, "ready"); @@ -317,7 +320,10 @@ it.live("runs multi-turn file edits and persists checkpoint diffs", () => const secondCheckpoint = secondTurnThread.checkpoints.find( (checkpoint) => checkpoint.checkpointTurnCount === 2, ); - assert.equal(secondCheckpoint?.files.some((file) => file.path === "README.md"), true); + assert.equal( + secondCheckpoint?.files.some((file) => file.path === "README.md"), + true, + ); const checkpointRows = yield* harness.checkpointRepository.listByThreadId({ threadId: THREAD_ID, @@ -394,9 +400,8 @@ it.live("tracks approval requests and resolves pending approvals on user respons text: "Run command needing approval", }); - const thread = yield* harness.waitForThread( - THREAD_ID, - (entry) => entry.activities.some((activity) => activity.kind === "approval.requested"), + const thread = yield* harness.waitForThread(THREAD_ID, (entry) => + entry.activities.some((activity) => activity.kind === "approval.requested"), ); assert.equal( thread.activities.some((activity) => activity.kind === "approval.requested"), @@ -687,39 +692,39 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git ), ); -it.live("appends checkpoint.revert.failed activity when revert is requested without an active session", () => - withHarness((harness) => - Effect.gen(function* () { - yield* seedProjectAndThread(harness); - - yield* harness.engine.dispatch({ - type: "thread.checkpoint.revert", - commandId: asCommandId("cmd-checkpoint-revert-no-session"), - threadId: THREAD_ID, - turnCount: 0, - createdAt: nowIso(), - }); - - const thread = yield* harness.waitForThread( - THREAD_ID, - (entry) => +it.live( + "appends checkpoint.revert.failed activity when revert is requested without an active session", + () => + withHarness((harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.engine.dispatch({ + type: "thread.checkpoint.revert", + commandId: asCommandId("cmd-checkpoint-revert-no-session"), + threadId: THREAD_ID, + turnCount: 0, + createdAt: nowIso(), + }); + + const thread = yield* harness.waitForThread(THREAD_ID, (entry) => entry.activities.some( (activity) => activity.kind === "checkpoint.revert.failed" && typeof activity.payload === "object" && activity.payload !== null, ), - ); - const failureActivity = thread.activities.find( - (activity) => activity.kind === "checkpoint.revert.failed", - ); - assert.equal(failureActivity !== undefined, true); - assert.equal( - String((failureActivity?.payload as { readonly detail?: string } | undefined)?.detail).includes( - "No active provider session", - ), - true, - ); - }), - ), + ); + const failureActivity = thread.activities.find( + (activity) => activity.kind === "checkpoint.revert.failed", + ); + assert.equal(failureActivity !== undefined, true); + assert.equal( + String( + (failureActivity?.payload as { readonly detail?: string } | undefined)?.detail, + ).includes("No active provider session"), + true, + ); + }), + ), ); diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index e3337094e93ae..353b7d984ceca 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -127,7 +127,7 @@ it.effect("replays typed runtime fixture events", () => harness: fixture.harness, sessionId: session.sessionId, userText: "hello", - response: { events: codexTurnTextFixture }, + response: { events: codexTurnTextFixture }, }); assert.deepEqual( diff --git a/apps/server/src/checkpointing/Diffs.ts b/apps/server/src/checkpointing/Diffs.ts index 1db56411051bc..c2f867b96e0e6 100644 --- a/apps/server/src/checkpointing/Diffs.ts +++ b/apps/server/src/checkpointing/Diffs.ts @@ -6,7 +6,9 @@ export interface TurnDiffFileSummary { readonly deletions: number; } -export function parseTurnDiffFilesFromUnifiedDiff(diff: string): ReadonlyArray { +export function parseTurnDiffFilesFromUnifiedDiff( + diff: string, +): ReadonlyArray { const normalized = diff.replace(/\r\n/g, "\n").trim(); if (normalized.length === 0) { return []; diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 3bb317804562e..b2ab09d6186c5 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -383,7 +383,10 @@ export class CodexAppServerManager extends EventEmitter { + async rollbackThread( + sessionId: ProviderSessionId, + numTurns: number, + ): Promise { const context = this.requireSession(sessionId); const threadId = context.session.threadId; if (!threadId) { @@ -822,19 +825,18 @@ export class CodexAppServerManager extends EventEmitter { const turn = this.readObject(turnValue); - const turnIdRaw = - this.readString(turn, "id") ?? - `${threadIdRaw}:turn:${index + 1}`; + const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`; const turnId = ProviderTurnId.makeUnsafe(turnIdRaw); const items = this.readArray(turn, "items") ?? []; return { @@ -900,8 +902,7 @@ export class CodexAppServerManager extends EventEmitter { - const tempDirs: string[] = []; - const logger = { - warn: vi.fn(), - }; - - function makeTempDir(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; - } - - afterEach(() => { - vi.restoreAllMocks(); - logger.warn.mockReset(); - for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); - } +const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); +const makeKeybindingsLayer = () => + KeybindingsLive.pipe( + Layer.provideMerge( + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { join } = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-server-config-test-" }); + const configPath = join(dir, "keybindings.json"); + return { keybindingsConfigPath: configPath } as ServerConfigShape; + }), + ), + ), + ); + +const toDetailResult = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError((error) => error.detail), + Effect.result, + ); + +const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRule[]) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)( + rules.map((rule) => KeybindingRule.makeUnsafe(rule)), + ); + yield* fileSystem.writeFileString(configPath, encoded); }); - it("parses shortcuts including plus key", () => { - assert.deepEqual(parseKeybindingShortcut("mod+j"), { - key: "j", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }); - assert.deepEqual(parseKeybindingShortcut("mod++"), { - key: "+", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }); +const readKeybindingsConfig = (configPath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const rawConfig = yield* fileSystem.readFileString(configPath); + return yield* Schema.decodeUnknownEffect(KeybindingsConfigJson)(rawConfig); }); - it("compiles valid rule with parsed when AST", () => { - const compiled = compileResolvedKeybindingRule({ - key: "mod+d", - command: "terminal.split", - when: "terminalOpen && !terminalFocus", - }); - - assert.deepEqual(compiled, { - command: "terminal.split", - shortcut: { - key: "d", +it.layer(NodeServices.layer)("keybindings", (it) => { + it.effect("parses shortcuts including plus key", () => + Effect.sync(() => { + assert.deepEqual(parseKeybindingShortcut("mod+j"), { + key: "j", metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, modKey: true, - }, - whenAst: { - type: "and", - left: { type: "identifier", name: "terminalOpen" }, - right: { - type: "not", - node: { type: "identifier", name: "terminalFocus" }, - }, - }, - }); - }); - - it("rejects invalid rules", () => { - assert.isNull( - compileResolvedKeybindingRule({ - key: "mod+shift+d+o", - command: "terminal.new", - }), - ); + }); + assert.deepEqual(parseKeybindingShortcut("mod++"), { + key: "+", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }); + }), + ); - assert.isNull( - compileResolvedKeybindingRule({ + it.effect("compiles valid rule with parsed when AST", () => + Effect.sync(() => { + const compiled = compileResolvedKeybindingRule({ key: "mod+d", command: "terminal.split", - when: "terminalFocus && (", - }), - ); + when: "terminalOpen && !terminalFocus", + }); - assert.isNull( - compileResolvedKeybindingRule({ - key: "mod+d", + assert.deepEqual(compiled, { command: "terminal.split", - when: `${"!".repeat(300)}terminalFocus`, - }), - ); - }); + shortcut: { + key: "d", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "and", + left: { type: "identifier", name: "terminalOpen" }, + right: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + }); + }), + ); - it("upserts custom keybindings to ~/.t3/keybindings.json", () => { - const fakeHome = makeTempDir("t3code-keybindings-upsert-"); - const configDir = path.join(fakeHome, ".t3"); - fs.mkdirSync(configDir, { recursive: true }); - const configPath = path.join(configDir, "keybindings.json"); - fs.writeFileSync( - configPath, - JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), - "utf8", - ); - vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - - const resolved = upsertKeybindingRule(logger, { - key: "mod+shift+r", - command: "script.run-tests.run", - }); - const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ - key: string; - command: string; - }>; - - expect(persisted).toEqual([ - { key: "mod+j", command: "terminal.toggle" }, - { key: "mod+shift+r", command: "script.run-tests.run" }, - ]); - expect(resolved.some((entry) => entry.command === "script.run-tests.run")).toBe(true); - }); + it.effect("rejects invalid rules", () => + Effect.sync(() => { + assert.isNull( + compileResolvedKeybindingRule({ + key: "mod+shift+d+o", + command: "terminal.new", + }), + ); - it("replaces existing custom keybinding for the same command", () => { - const fakeHome = makeTempDir("t3code-keybindings-upsert-replace-"); - const configDir = path.join(fakeHome, ".t3"); - fs.mkdirSync(configDir, { recursive: true }); - const configPath = path.join(configDir, "keybindings.json"); - fs.writeFileSync( - configPath, - JSON.stringify([{ key: "mod+r", command: "script.run-tests.run" }]), - "utf8", - ); - vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - - upsertKeybindingRule(logger, { - key: "mod+shift+r", - command: "script.run-tests.run", - }); - - const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ - key: string; - command: string; - }>; - expect(persisted).toEqual([{ key: "mod+shift+r", command: "script.run-tests.run" }]); - }); + assert.isNull( + compileResolvedKeybindingRule({ + key: "mod+d", + command: "terminal.split", + when: "terminalFocus && (", + }), + ); - it("refuses to overwrite malformed keybindings config", () => { - const fakeHome = makeTempDir("t3code-keybindings-malformed-"); - const configDir = path.join(fakeHome, ".t3"); - fs.mkdirSync(configDir, { recursive: true }); - const configPath = path.join(configDir, "keybindings.json"); - fs.writeFileSync(configPath, "{ not-json", "utf8"); - vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - - expect(() => - upsertKeybindingRule(logger, { - key: "mod+shift+r", - command: "script.run-tests.run", - }), - ).toThrow(/Unable to parse keybindings config/); - - expect(fs.readFileSync(configPath, "utf8")).toBe("{ not-json"); - }); + assert.isNull( + compileResolvedKeybindingRule({ + key: "mod+d", + command: "terminal.split", + when: `${"!".repeat(300)}terminalFocus`, + }), + ); + }), + ); - it("reports non-array config parse errors without duplicate prefix", () => { - const fakeHome = makeTempDir("t3code-keybindings-non-array-"); - const configDir = path.join(fakeHome, ".t3"); - fs.mkdirSync(configDir, { recursive: true }); - const configPath = path.join(configDir, "keybindings.json"); - fs.writeFileSync(configPath, JSON.stringify({ key: "mod+j", command: "terminal.toggle" }), "utf8"); - vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - - expect(() => - upsertKeybindingRule(logger, { - key: "mod+shift+r", - command: "script.run-tests.run", - }), - ).toThrow(new RegExp(`Unable to parse keybindings config at ${configPath}: expected JSON array`)); - - expect(() => - upsertKeybindingRule(logger, { - key: "mod+shift+r", - command: "script.run-tests.run", - }), - ).not.toThrow( - new RegExp(`Unable to parse keybindings config at ${configPath}: Unable to parse keybindings config at`), - ); - }); + it.effect("upserts custom keybindings to configured path", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+j", command: "terminal.toggle" }, + ]); - it("cleans up temp files when atomic keybinding write fails", () => { - const fakeHome = makeTempDir("t3code-keybindings-atomic-"); - const configDir = path.join(fakeHome, ".t3"); - fs.mkdirSync(configDir, { recursive: true }); - const configPath = path.join(configDir, "keybindings.json"); - fs.writeFileSync(configPath, JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), "utf8"); - vi.spyOn(os, "homedir").mockReturnValue(fakeHome); - vi.spyOn(fs, "renameSync").mockImplementation(() => { - throw new Error("rename failed"); - }); - - expect(() => - upsertKeybindingRule(logger, { - key: "mod+shift+r", - command: "script.run-tests.run", - }), - ).toThrow(/rename failed/); - - expect(fs.readFileSync(configPath, "utf8")).toBe( - JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), - ); - const tempFiles = fs - .readdirSync(configDir) - .filter((file) => file.includes("keybindings.json.") && file.endsWith(".tmp")); - expect(tempFiles).toEqual([]); - }); + const resolved = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + + assert.deepEqual(persistedView, [ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]); + assert.isTrue(resolved.some((entry) => entry.command === "script.run-tests.run")); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("replaces existing custom keybinding for the same command", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+r", command: "script.run-tests.run" }, + ]); + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [{ key: "mod+shift+r", command: "script.run-tests.run" }]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("refuses to overwrite malformed keybindings config", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { keybindingsConfigPath } = yield* ServerConfig; + yield* fs.writeFileString(keybindingsConfigPath, "{ not-json"); + + const result = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + }).pipe(toDetailResult); + assertFailure(result, "expected JSON array"); + + const persistedRaw = yield* fs.readFileString(keybindingsConfigPath); + assert.equal(persistedRaw, "{ not-json"); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("reports non-array config parse errors without duplicate prefix", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { keybindingsConfigPath } = yield* ServerConfig; + yield* fs.writeFileString( + keybindingsConfigPath, + '{"key":"mod+j","command":"terminal.toggle"}', + ); + + const firstResult = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + }).pipe(toDetailResult); + assertFailure(firstResult, "expected JSON array"); + + const secondResult = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + }).pipe(toDetailResult); + assertFailure(secondResult, "expected JSON array"); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("fails when config directory is not writable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { keybindingsConfigPath } = yield* ServerConfig; + const { dirname } = yield* Path.Path; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+j", command: "terminal.toggle" }, + ]); + yield* fs.chmod(dirname(keybindingsConfigPath), 0o500); + + const result = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + }).pipe(toDetailResult); + assertFailure(result, "failed to write keybindings config"); + + yield* fs.chmod(dirname(keybindingsConfigPath), 0o700); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [{ key: "mod+j", command: "terminal.toggle" }]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("caches loaded resolved config across repeated reads", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+j", command: "terminal.toggle" }, + ]); + + const [first, second] = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + const firstLoad = yield* keybindings.loadResolvedKeybindingsConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+x", command: "script.setup.run" }, + ]); + const secondLoad = yield* keybindings.loadResolvedKeybindingsConfig; + return [firstLoad, secondLoad] as const; + }); + + assert.deepEqual(first, second); + assert.isTrue(second.some((entry) => entry.command === "terminal.toggle")); + assert.isFalse(second.some((entry) => entry.command === "script.setup.run")); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("updates cached resolved config after upsert", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { keybindingsConfigPath } = yield* ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+j", command: "terminal.toggle" }, + ]); + + const loadedAfterUpsert = yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + yield* keybindings.loadResolvedKeybindingsConfig; + yield* keybindings.upsertKeybindingRule({ + key: "mod+shift+r", + command: "script.run-tests.run", + }); + yield* fs.writeFileString(keybindingsConfigPath, "{ not-json"); + return yield* keybindings.loadResolvedKeybindingsConfig; + }); + + assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "script.run-tests.run")); + assert.isTrue(loadedAfterUpsert.some((entry) => entry.command === "terminal.toggle")); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); }); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 70f43dfeb2777..9942b5c612744 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -1,20 +1,42 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - import { KeybindingRule, KeybindingsConfig, KeybindingShortcut, KeybindingWhenNode, + MAX_KEYBINDINGS_COUNT, + MAX_WHEN_EXPRESSION_DEPTH, ResolvedKeybindingRule, ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { Mutable } from "effect/Types"; -import { Schema } from "effect"; - -interface KeybindingsLogger { - warn(message: string, context?: Record): void; +import { + Array, + Cause, + Effect, + FileSystem, + Path, + Layer, + Option, + Predicate, + Ref, + Schema, + SchemaIssue, + SchemaTransformation, + ServiceMap, +} from "effect"; +import { ServerConfig } from "./config"; + +export class KeybindingsConfigError extends Schema.TaggedErrorClass()( + "KeybindingsConfigParseError", + { + configPath: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`; + } } type WhenToken = @@ -25,8 +47,6 @@ type WhenToken = | { type: "lparen" } | { type: "rparen" }; -const MAX_WHEN_EXPRESSION_DEPTH = 256; - export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, @@ -44,6 +64,7 @@ function normalizeKeyToken(token: string): string { return token; } +/** @internal - Exported for testing */ export function parseKeybindingShortcut(value: string): KeybindingShortcut | null { const rawTokens = value .toLowerCase() @@ -157,7 +178,7 @@ function tokenizeWhenExpression(expression: string): WhenToken[] | null { return tokens; } -export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { +function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { const tokens = tokenizeWhenExpression(expression); if (!tokens || tokens.length === 0) return null; let index = 0; @@ -242,6 +263,7 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe return ast; } +/** @internal - Exported for testing */ export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null { const key = rule.key.trim(); if (key.length === 0) return null; @@ -270,113 +292,85 @@ export function compileResolvedKeybindingsConfig( ): ResolvedKeybindingsConfig { const compiled: Mutable = []; for (const rule of config) { - const resolved = compileResolvedKeybindingRule(rule); - if (!resolved) continue; - compiled.push(resolved); + const result = Schema.decodeExit(ResolvedKeybindingFromConfig)(rule); + if (result._tag === "Success") { + compiled.push(result.value); + } } return compiled; } -function compileDefaultKeybindings(): ResolvedKeybindingsConfig { - const resolved: Mutable = []; - for (const rule of DEFAULT_KEYBINDINGS) { - const compiled = compileResolvedKeybindingRule(rule); - if (!compiled) { - throw new Error(`Invalid default keybinding: ${rule.command} (${rule.key})`); - } - resolved.push(compiled); - } - return resolved; +export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( + Schema.decodeTo( + Schema.toType(ResolvedKeybindingRule), + SchemaTransformation.transformOrFail({ + decode: (rule) => + Effect.succeed(compileResolvedKeybindingRule(rule)).pipe( + Effect.filterOrFail( + Predicate.isNotNull, + () => + new SchemaIssue.InvalidValue(Option.some(rule), { + title: "Invalid keybinding rule", + }), + ), + Effect.map((resolved) => ResolvedKeybindingRule.makeUnsafe(resolved)), + ), + + encode: (resolved) => + Effect.gen(function* () { + const key = encodeShortcut(resolved.shortcut); + if (!key) { + return yield* Effect.fail( + new SchemaIssue.InvalidValue(Option.some(resolved), { + title: "Resolved shortcut cannot be encoded to key string", + }), + ); + } + + const when = resolved.whenAst ? encodeWhenAst(resolved.whenAst) : undefined; + return { + key, + command: resolved.command, + when, + }; + }), + }), + ), +); + +export const ResolvedKeybindingsFromConfig = Schema.Array(ResolvedKeybindingFromConfig).check( + Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), +); + +function encodeShortcut(shortcut: KeybindingShortcut): string | null { + const modifiers: string[] = []; + if (shortcut.modKey) modifiers.push("mod"); + if (shortcut.metaKey) modifiers.push("meta"); + if (shortcut.ctrlKey) modifiers.push("ctrl"); + if (shortcut.altKey) modifiers.push("alt"); + if (shortcut.shiftKey) modifiers.push("shift"); + if (!shortcut.key || shortcut.key.includes("+")) return null; + const key = shortcut.key === " " ? "space" : shortcut.key; + return [...modifiers, key].join("+"); } -const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); -const KEYBINDINGS_CONFIG_PATH = path.join(".t3", "keybindings.json"); -const MAX_KEYBINDINGS = 256; - -class KeybindingsConfigParseError extends Error { - constructor(configPath: string, detail: string, options?: { cause?: unknown }) { - super(`Unable to parse keybindings config at ${configPath}: ${detail}`, options); +function encodeWhenAst(node: KeybindingWhenNode): string { + switch (node.type) { + case "identifier": + return node.name; + case "not": + return `!(${encodeWhenAst(node.node)})`; + case "and": + return `(${encodeWhenAst(node.left)} && ${encodeWhenAst(node.right)})`; + case "or": + return `(${encodeWhenAst(node.left)} || ${encodeWhenAst(node.right)})`; } } -function resolveKeybindingsConfigPath(): string { - return path.join(os.homedir(), KEYBINDINGS_CONFIG_PATH); -} +const DEFAULT_RESOLVED_KEYBINDINGS = compileResolvedKeybindingsConfig(DEFAULT_KEYBINDINGS); -function loadCustomKeybindingsConfig( - logger: KeybindingsLogger, - options?: { - throwOnUnreadableConfig?: boolean; - }, -): KeybindingsConfig { - const configPath = resolveKeybindingsConfigPath(); - try { - const raw = fs.readFileSync(configPath, "utf8"); - const parsed = JSON.parse(raw) as unknown; - if (!Array.isArray(parsed)) { - if (options?.throwOnUnreadableConfig) { - throw new KeybindingsConfigParseError(configPath, "expected JSON array"); - } - logger.warn("ignoring keybindings config with unsupported format; expected array", { - path: configPath, - }); - return []; - } - - const sanitized: Mutable = []; - let invalidEntries = 0; - for (const entry of parsed) { - const result = Schema.decodeExit(KeybindingRule)(entry); - if (result._tag === "Success") { - if (!compileResolvedKeybindingRule(result.value)) { - invalidEntries += 1; - continue; - } - sanitized.push(result.value); - continue; - } - invalidEntries += 1; - } - if (invalidEntries > 0) { - logger.warn("ignoring invalid keybinding entries", { - path: configPath, - invalidEntries, - totalEntries: parsed.length, - }); - } - return sanitized; - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - return []; - } - if (options?.throwOnUnreadableConfig) { - if (error instanceof KeybindingsConfigParseError) { - throw error; - } - throw new KeybindingsConfigParseError( - configPath, - error instanceof Error ? error.message : String(error), - { cause: error }, - ); - } - logger.warn("ignoring malformed keybindings config", { - path: configPath, - error: error instanceof Error ? error.message : String(error), - }); - return []; - } -} - -function writeConfigAtomically(configPath: string, config: KeybindingsConfig): void { - const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`; - try { - fs.writeFileSync(tempPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); - fs.renameSync(tempPath, configPath); - } catch (error) { - fs.rmSync(tempPath, { force: true }); - throw error; - } -} +const RawKeybindingsEntries = Schema.fromJsonString(Schema.Array(Schema.Unknown)); +const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { if (custom.length === 0) { @@ -389,55 +383,133 @@ function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): Resolve ); const merged = [...retainedDefaults, ...custom]; - if (merged.length <= MAX_KEYBINDINGS) { + if (merged.length <= MAX_KEYBINDINGS_COUNT) { return merged; } // Keep the latest rules when the config exceeds max size; later rules have higher precedence. - return merged.slice(-MAX_KEYBINDINGS); + return merged.slice(-MAX_KEYBINDINGS_COUNT); } -export function loadResolvedKeybindingsConfig( - logger: KeybindingsLogger, -): ResolvedKeybindingsConfig { - const customConfig = loadCustomKeybindingsConfig(logger); - const compiledCustomConfig = compileResolvedKeybindingsConfig(customConfig); - const overriddenCommands = new Set(compiledCustomConfig.map((entry) => entry.command)); - const mergedBeforeCapLength = - DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) - .length + compiledCustomConfig.length; - const merged = mergeWithDefaultKeybindings(compiledCustomConfig); - if (mergedBeforeCapLength > MAX_KEYBINDINGS) { - logger.warn("truncating merged keybindings config to max entries", { - path: resolveKeybindingsConfigPath(), - maxEntries: MAX_KEYBINDINGS, - }); - } - return merged; +export interface KeybindingsShape { + readonly loadResolvedKeybindingsConfig: Effect.Effect< + readonly ResolvedKeybindingRule[], + KeybindingsConfigError + >; + readonly upsertKeybindingRule: ( + rule: KeybindingRule, + ) => Effect.Effect; } -export function upsertKeybindingRule( - logger: KeybindingsLogger, - rule: KeybindingRule, -): ResolvedKeybindingsConfig { - const configPath = resolveKeybindingsConfigPath(); - const customConfig = loadCustomKeybindingsConfig(logger, { - throwOnUnreadableConfig: true, +export class Keybindings extends ServiceMap.Service()( + "server/Keybindings", +) {} + +const makeKeybindings = Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cachedResolvedConfig = yield* Ref.make(null); + + const loadCustomKeybindingsConfig = Effect.fn(function* (): Effect.fn.Return< + readonly KeybindingRule[], + KeybindingsConfigError + > { + const rawConfig = yield* fs.readFileString(keybindingsConfigPath).pipe( + Effect.flatMap(Schema.decodeEffect(RawKeybindingsEntries)), + Effect.mapError( + (cause) => + new KeybindingsConfigError({ + configPath: keybindingsConfigPath, + detail: "expected JSON array", + cause, + }), + ), + ); + + return yield* Effect.forEach(rawConfig, (entry) => + Effect.gen(function* () { + const decodedRule = Schema.decodeUnknownExit(KeybindingRule)(entry); + if (decodedRule._tag === "Failure") { + yield* Effect.logWarning("ignoring invalid keybinding entry", { + path: keybindingsConfigPath, + entry, + error: Cause.pretty(decodedRule.cause), + }); + return null; + } + const resolved = Schema.decodeExit(ResolvedKeybindingFromConfig)(decodedRule.value); + if (resolved._tag === "Failure") { + yield* Effect.logWarning("ignoring invalid keybinding entry", { + path: keybindingsConfigPath, + entry, + error: Cause.pretty(resolved.cause), + }); + return null; + } + return decodedRule.value; + }), + ).pipe(Effect.map(Array.filter(Predicate.isNotNull))); }); - const nextConfig = [...customConfig.filter((entry) => entry.command !== rule.command), rule]; - const cappedConfig = - nextConfig.length > MAX_KEYBINDINGS ? nextConfig.slice(-MAX_KEYBINDINGS) : nextConfig; - - if (nextConfig.length > MAX_KEYBINDINGS) { - logger.warn("truncating keybindings config to max entries", { - path: configPath, - maxEntries: MAX_KEYBINDINGS, - }); - } - fs.mkdirSync(path.dirname(configPath), { recursive: true }); - writeConfigAtomically(configPath, cappedConfig); + const writeConfigAtomically = (rules: readonly KeybindingRule[]) => { + const tempPath = `${keybindingsConfigPath}.${process.pid}.${Date.now()}.tmp`; + + return Schema.encodeEffect(KeybindingsConfigJson)( + rules.map((rule) => KeybindingRule.makeUnsafe(rule)), + ).pipe( + Effect.tap(() => fs.makeDirectory(path.dirname(keybindingsConfigPath), { recursive: true })), + Effect.tap((encoded) => fs.writeFileString(tempPath, encoded)), + Effect.flatMap(() => fs.rename(tempPath, keybindingsConfigPath)), + Effect.mapError( + (cause) => + new KeybindingsConfigError({ + configPath: keybindingsConfigPath, + detail: "failed to write keybindings config", + cause, + }), + ), + ); + }; - const compiledCustomConfig = compileResolvedKeybindingsConfig(cappedConfig); - return mergeWithDefaultKeybindings(compiledCustomConfig); -} + const loadResolvedFromCacheOrDisk = Effect.gen(function* () { + const cached = yield* Ref.get(cachedResolvedConfig); + if (cached) return cached; + const resolved = yield* loadCustomKeybindingsConfig().pipe( + Effect.map(compileResolvedKeybindingsConfig), + Effect.map(mergeWithDefaultKeybindings), + ); + yield* Ref.set(cachedResolvedConfig, resolved); + return resolved; + }); + + return { + loadResolvedKeybindingsConfig: loadResolvedFromCacheOrDisk, + upsertKeybindingRule: (rule) => + Effect.gen(function* () { + const customConfig = yield* loadCustomKeybindingsConfig(); + const nextConfig = [ + ...customConfig.filter((entry) => entry.command !== rule.command), + rule, + ]; + const cappedConfig = + nextConfig.length > MAX_KEYBINDINGS_COUNT + ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) + : nextConfig; + if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { + yield* Effect.logWarning("truncating keybindings config to max entries", { + path: keybindingsConfigPath, + maxEntries: MAX_KEYBINDINGS_COUNT, + }); + } + yield* writeConfigAtomically(cappedConfig); + const nextResolved = mergeWithDefaultKeybindings( + compileResolvedKeybindingsConfig(cappedConfig), + ); + yield* Ref.set(cachedResolvedConfig, nextResolved); + return nextResolved; + }), + } satisfies KeybindingsShape; +}); + +export const KeybindingsLive = Layer.effect(Keybindings, makeKeybindings); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 4d0691ce05bee..0dd164cb99478 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -120,6 +120,8 @@ const ServerConfigLive = (input: CliInput) => }); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; + const { join } = yield* Path.Path; + const keybindingsConfigPath = join(stateDir, "keybindings.json"); const host = Option.getOrUndefined(input.host) ?? env.host ?? @@ -129,6 +131,7 @@ const ServerConfigLive = (input: CliInput) => mode, port, cwd: cliConfig.cwd, + keybindingsConfigPath, host, stateDir, staticDir, diff --git a/apps/server/src/open.test.ts b/apps/server/src/open.test.ts index f4b88d052a329..c3d521eb90a6e 100644 --- a/apps/server/src/open.test.ts +++ b/apps/server/src/open.test.ts @@ -11,18 +11,16 @@ describe("resolveEditorLaunch", () => { }); it("maps file-manager editor to OS open commands", () => { - expect(resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "file-manager" }, "darwin")).toEqual( - { - command: "open", - args: ["/tmp/workspace"], - }, - ); - expect(resolveEditorLaunch({ cwd: "C:\\workspace", editor: "file-manager" }, "win32")).toEqual( - { - command: "explorer", - args: ["C:\\workspace"], - }, - ); + expect( + resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "file-manager" }, "darwin"), + ).toEqual({ + command: "open", + args: ["/tmp/workspace"], + }); + expect(resolveEditorLaunch({ cwd: "C:\\workspace", editor: "file-manager" }, "win32")).toEqual({ + command: "explorer", + args: ["C:\\workspace"], + }); expect(resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "file-manager" }, "linux")).toEqual( { command: "xdg-open", @@ -40,8 +38,8 @@ describe("launchDetached", () => { }); it("rejects when command does not exist", async () => { - await expect( - launchDetached(`t3code-no-such-command-${Date.now()}`, []), - ).rejects.toBeInstanceOf(Error); + await expect(launchDetached(`t3code-no-such-command-${Date.now()}`, [])).rejects.toBeInstanceOf( + Error, + ); }); }); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index caa4d1f8d4a6e..5985a2219c834 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -41,8 +41,7 @@ const asCommandId = (value: string): CommandId => CommandId.makeUnsafe(value); const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => - ProviderThreadId.makeUnsafe(value); +const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); function checkpointRefForTurn(threadId: ThreadId, turnCount: number): CheckpointRef { @@ -474,11 +473,12 @@ describe("CheckpointReactor", () => { status: "completed", }); - const thread = await waitForThread( - harness.engine, - (entry) => entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3), + const thread = await waitForThread(harness.engine, (entry) => + entry.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3), + ); + expect(thread.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe( + true, ); - expect(thread.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe(true); }); it("executes provider revert and emits thread.reverted for checkpoint revert requests", async () => { @@ -581,5 +581,4 @@ describe("CheckpointReactor", () => { ); expect(harness.provider.rollbackConversation).not.toHaveBeenCalled(); }); - }); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index eb8b5d71342e8..2be629d7c5392 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -347,13 +347,10 @@ const make = Effect.gen(function* () { onSome: (runtime) => runtime.cwd, }); if (!checkpointCwd) { - yield* Effect.logWarning( - "checkpoint pre-turn capture skipped: no workspace cwd", - { - threadId: thread.id, - turnId, - }, - ); + yield* Effect.logWarning("checkpoint pre-turn capture skipped: no workspace cwd", { + threadId: thread.id, + turnId, + }); return; } @@ -377,10 +374,17 @@ const make = Effect.gen(function* () { }); const ensurePreTurnBaselineFromDomainTurnStart = Effect.fnUntraced(function* ( - event: Extract, + event: Extract< + OrchestrationEvent, + { type: "thread.turn-start-requested" | "thread.message-sent" } + >, ) { if (event.type === "thread.message-sent") { - if (event.payload.role !== "user" || event.payload.streaming || event.payload.turnId !== null) { + if ( + event.payload.role !== "user" || + event.payload.streaming || + event.payload.turnId !== null + ) { return; } } @@ -403,12 +407,9 @@ const make = Effect.gen(function* () { onSome: (runtime) => runtime.cwd, }); if (!checkpointCwd) { - yield* Effect.logWarning( - "checkpoint pre-turn capture skipped: no workspace cwd", - { - threadId, - }, - ); + yield* Effect.logWarning("checkpoint pre-turn capture skipped: no workspace cwd", { + threadId, + }); return; } diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index d9719c428c521..1e498885a05f8 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -24,4 +24,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { } satisfies OrchestrationReactorShape; }); -export const OrchestrationReactorLive = Layer.effect(OrchestrationReactor, makeOrchestrationReactor); +export const OrchestrationReactorLive = Layer.effect( + OrchestrationReactor, + makeOrchestrationReactor, +); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 03b2a6d2c69d9..ce775b32fc2d6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -479,7 +479,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { defaultModel: "gpt-5", }); - const projectRows = yield* sql<{ readonly scriptsJson: string; readonly defaultModel: string }>` + const projectRows = yield* sql<{ + readonly scriptsJson: string; + readonly defaultModel: string; + }>` SELECT scripts_json AS "scriptsJson", default_model AS "defaultModel" diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 351ec9bba4ce2..b890adfbabd7f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,7 +1,4 @@ -import { - ApprovalRequestId, - type OrchestrationEvent, -} from "@t3tools/contracts"; +import { ApprovalRequestId, type OrchestrationEvent } from "@t3tools/contracts"; import { Effect, Layer, Option, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -119,8 +116,7 @@ function retainProjectionMessagesAfterRevert( if (missingAssistantCount > 0) { const fallbackAssistantMessages = messages .filter( - (message) => - message.role === "assistant" && !retainedMessageIds.has(message.messageId), + (message) => message.role === "assistant" && !retainedMessageIds.has(message.messageId), ) .toSorted( (left, right) => @@ -142,9 +138,7 @@ function retainProjectionActivitiesAfterRevert( turnCount: number, ): ReadonlyArray { const retainedTurnIds = new Set( - turns - .filter((turn) => turn.turnCount <= turnCount) - .map((turn) => turn.turnId), + turns.filter((turn) => turn.turnCount <= turnCount).map((turn) => turn.turnId), ); return activities.filter( (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId), @@ -354,7 +348,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ threadId: event.payload.threadId, }); - const existingMessage = existingRows.find((row) => row.messageId === event.payload.messageId); + const existingMessage = existingRows.find( + (row) => row.messageId === event.payload.messageId, + ); const nextText = existingMessage && event.payload.streaming ? `${existingMessage.text}${event.payload.text}` @@ -496,7 +492,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { yield* projectionThreadTurnRepository.upsert({ ...existingTurn.value, status: existingTurn.value.status === "completed" ? "completed" : "running", - userMessageId: existingTurn.value.userMessageId ?? (pendingTurnStart?.messageId ?? null), + userMessageId: + existingTurn.value.userMessageId ?? pendingTurnStart?.messageId ?? null, startedAt: existingTurn.value.startedAt, }); } else { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 867d932e35072..3e55887e20bdd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -23,7 +23,8 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); -const asProviderSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); +const asProviderSessionId = (value: string): ProviderSessionId => + ProviderSessionId.makeUnsafe(value); const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); const projectionSnapshotLayer = it.layer( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index d2817933f2604..d9d09c30ffaf9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -245,10 +245,18 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { }); const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => - sql.withTransaction( - Effect.gen(function* () { - const [projectRows, threadRows, messageRows, activityRows, sessionRows, checkpointRows, stateRows] = - yield* Effect.all([ + sql + .withTransaction( + Effect.gen(function* () { + const [ + projectRows, + threadRows, + messageRows, + activityRows, + sessionRows, + checkpointRows, + stateRows, + ] = yield* Effect.all([ listProjectRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -307,122 +315,127 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ]); - const messagesByThread = new Map>(); - const activitiesByThread = new Map>(); - const checkpointsByThread = new Map>(); - const sessionsByThread = new Map(); + const messagesByThread = new Map>(); + const activitiesByThread = new Map>(); + const checkpointsByThread = new Map>(); + const sessionsByThread = new Map(); - let updatedAt: string | null = null; + let updatedAt: string | null = null; - for (const row of projectRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of threadRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } - for (const row of stateRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - } + for (const row of projectRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of threadRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } + for (const row of stateRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + } - for (const row of messageRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - const threadMessages = messagesByThread.get(row.threadId) ?? []; - threadMessages.push({ - id: row.messageId, - role: row.role, - text: row.text, - turnId: row.turnId, - streaming: row.isStreaming === 1, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }); - messagesByThread.set(row.threadId, threadMessages); - } + for (const row of messageRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + const threadMessages = messagesByThread.get(row.threadId) ?? []; + threadMessages.push({ + id: row.messageId, + role: row.role, + text: row.text, + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + messagesByThread.set(row.threadId, threadMessages); + } - for (const row of activityRows) { - updatedAt = maxIso(updatedAt, row.createdAt); - const threadActivities = activitiesByThread.get(row.threadId) ?? []; - threadActivities.push({ - id: row.activityId, - tone: row.tone, - kind: row.kind, - summary: row.summary, - payload: row.payload, - turnId: row.turnId, - createdAt: row.createdAt, - }); - activitiesByThread.set(row.threadId, threadActivities); - } + for (const row of activityRows) { + updatedAt = maxIso(updatedAt, row.createdAt); + const threadActivities = activitiesByThread.get(row.threadId) ?? []; + threadActivities.push({ + id: row.activityId, + tone: row.tone, + kind: row.kind, + summary: row.summary, + payload: row.payload, + turnId: row.turnId, + createdAt: row.createdAt, + }); + activitiesByThread.set(row.threadId, threadActivities); + } - for (const row of checkpointRows) { - updatedAt = maxIso(updatedAt, row.completedAt); - const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? []; - threadCheckpoints.push({ - turnId: row.turnId, - checkpointTurnCount: row.checkpointTurnCount, - checkpointRef: row.checkpointRef, - status: row.status, - files: row.files, - assistantMessageId: row.assistantMessageId, - completedAt: row.completedAt, - }); - checkpointsByThread.set(row.threadId, threadCheckpoints); - } + for (const row of checkpointRows) { + updatedAt = maxIso(updatedAt, row.completedAt); + const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? []; + threadCheckpoints.push({ + turnId: row.turnId, + checkpointTurnCount: row.checkpointTurnCount, + checkpointRef: row.checkpointRef, + status: row.status, + files: row.files, + assistantMessageId: row.assistantMessageId, + completedAt: row.completedAt, + }); + checkpointsByThread.set(row.threadId, threadCheckpoints); + } - for (const row of sessionRows) { - updatedAt = maxIso(updatedAt, row.updatedAt); - sessionsByThread.set(row.threadId, { - threadId: row.threadId, - status: row.status, - providerName: row.providerName, - providerSessionId: row.providerSessionId, - providerThreadId: row.providerThreadId, - activeTurnId: row.activeTurnId, - lastError: row.lastError, - updatedAt: row.updatedAt, - }); - } + for (const row of sessionRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + sessionsByThread.set(row.threadId, { + threadId: row.threadId, + status: row.status, + providerName: row.providerName, + providerSessionId: row.providerSessionId, + providerThreadId: row.providerThreadId, + activeTurnId: row.activeTurnId, + lastError: row.lastError, + updatedAt: row.updatedAt, + }); + } - const projects: Array = projectRows.map((row) => ({ - id: row.projectId, - title: row.title, - workspaceRoot: row.workspaceRoot, - defaultModel: row.defaultModel, - scripts: row.scripts, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - })); + const projects: Array = projectRows.map((row) => ({ + id: row.projectId, + title: row.title, + workspaceRoot: row.workspaceRoot, + defaultModel: row.defaultModel, + scripts: row.scripts, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + })); - const threads: Array = threadRows.map((row) => ({ - id: row.threadId, - projectId: row.projectId, - title: row.title, - model: row.model, - branch: row.branch, - worktreePath: row.worktreePath, - latestTurnId: row.latestTurnId, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - deletedAt: row.deletedAt, - messages: messagesByThread.get(row.threadId) ?? [], - activities: activitiesByThread.get(row.threadId) ?? [], - checkpoints: checkpointsByThread.get(row.threadId) ?? [], - session: sessionsByThread.get(row.threadId) ?? null, - })); + const threads: Array = threadRows.map((row) => ({ + id: row.threadId, + projectId: row.projectId, + title: row.title, + model: row.model, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurnId: row.latestTurnId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deletedAt: row.deletedAt, + messages: messagesByThread.get(row.threadId) ?? [], + activities: activitiesByThread.get(row.threadId) ?? [], + checkpoints: checkpointsByThread.get(row.threadId) ?? [], + session: sessionsByThread.get(row.threadId) ?? null, + })); - const snapshot = { - snapshotSequence: computeSnapshotSequence(stateRows), - projects, - threads, - updatedAt: updatedAt ?? new Date(0).toISOString(), - }; + const snapshot = { + snapshotSequence: computeSnapshotSequence(stateRows), + projects, + threads, + updatedAt: updatedAt ?? new Date(0).toISOString(), + }; - return yield* decodeReadModel(snapshot).pipe( - Effect.mapError(toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel")), - ); - }), - ).pipe(Effect.mapError(toProjectionRepositoryError("ProjectionSnapshotQuery.getSnapshot:query"))); + return yield* decodeReadModel(snapshot).pipe( + Effect.mapError( + toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), + ), + ); + }), + ) + .pipe( + Effect.mapError(toProjectionRepositoryError("ProjectionSnapshotQuery.getSnapshot:query")), + ); return { getSnapshot, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d8c58df8531d3..0b65741a3f2cf 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -31,14 +31,12 @@ const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); -const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); +const asApprovalRequestId = (value: string): ApprovalRequestId => + ApprovalRequestId.makeUnsafe(value); const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); -async function waitFor( - predicate: () => boolean, - timeoutMs = 2000, -): Promise { +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { const deadline = Date.now() + timeoutMs; const poll = async (): Promise => { if (predicate()) { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ae10b413798e2..90baa6c5f06c8 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -344,7 +344,9 @@ const make = Effect.gen(function* () { const queue = yield* Queue.unbounded(); yield* Effect.addFinalizer(() => Queue.shutdown(queue).pipe(Effect.asVoid)); - yield* Effect.forkScoped(Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processDomainEvent)))); + yield* Effect.forkScoped( + Effect.forever(Queue.take(queue).pipe(Effect.flatMap(processDomainEvent))), + ); yield* Effect.forkScoped( Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 5b622b442e5cd..a3ab7e7c78687 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -29,7 +29,9 @@ function truncateDetail(value: string, limit = 180): string { return value.length > limit ? `${value.slice(0, limit - 1)}...` : value; } -function runtimeEventToActivities(event: ProviderRuntimeEvent): ReadonlyArray { +function runtimeEventToActivities( + event: ProviderRuntimeEvent, +): ReadonlyArray { switch (event.type) { case "approval.requested": { return [ @@ -184,14 +186,16 @@ const make = Effect.gen(function* () { event.type === "turn.started" || event.type === "turn.completed" ) { - const activeTurnId = event.type === "turn.started" ? (toTurnId(event.turnId) ?? null) : null; + const activeTurnId = + event.type === "turn.started" ? (toTurnId(event.turnId) ?? null) : null; const providerThreadIdFromEvent = event.type === "thread.started" ? ProviderThreadId.makeUnsafe(event.threadId) : event.threadId !== undefined ? ProviderThreadId.makeUnsafe(event.threadId) : null; - const providerThreadId = providerThreadIdFromEvent ?? (thread.session?.providerThreadId ?? null); + const providerThreadId = + providerThreadIdFromEvent ?? thread.session?.providerThreadId ?? null; const status = event.type === "turn.started" ? "running" @@ -199,7 +203,7 @@ const make = Effect.gen(function* () { ? "stopped" : event.type === "turn.completed" && event.status === "failed" ? "error" - : "ready"; + : "ready"; const lastError = event.type === "turn.completed" && event.status === "failed" ? (event.errorMessage ?? thread.session?.lastError ?? "Turn failed") diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 8b67722e026cd..03a5e35057f22 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -33,7 +33,8 @@ export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; export const ThreadActivityAppendedPayload = ContractsThreadActivityAppendedPayloadSchema; export const ThreadTurnStartRequestedPayload = ContractsThreadTurnStartRequestedPayloadSchema; -export const ThreadTurnInterruptRequestedPayload = ContractsThreadTurnInterruptRequestedPayloadSchema; +export const ThreadTurnInterruptRequestedPayload = + ContractsThreadTurnInterruptRequestedPayloadSchema; export const ThreadApprovalResponseRequestedPayload = ContractsThreadApprovalResponseRequestedPayloadSchema; export const ThreadCheckpointRevertRequestedPayload = diff --git a/apps/server/src/orchestration/Services/ProjectionPipeline.ts b/apps/server/src/orchestration/Services/ProjectionPipeline.ts index 8f8d649c1354f..9457aaf8d9b3c 100644 --- a/apps/server/src/orchestration/Services/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Services/ProjectionPipeline.ts @@ -2,7 +2,10 @@ import type { OrchestrationEvent } from "@t3tools/contracts"; import { ServiceMap } from "effect"; import type { Effect } from "effect"; -import type { OrchestrationEventStoreError, ProjectionRepositoryError } from "../../persistence/Errors.ts"; +import type { + OrchestrationEventStoreError, + ProjectionRepositoryError, +} from "../../persistence/Errors.ts"; export type OrchestrationProjectionPipelineError = | OrchestrationEventStoreError diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index ca615b8cdb6d8..6acb9c82ba54b 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -38,13 +38,11 @@ export function listThreadsByProjectId( return readModel.threads.filter((thread) => thread.projectId === projectId); } -export function requireProject( - input: { - readonly readModel: OrchestrationReadModel; - readonly command: OrchestrationCommand; - readonly projectId: ProjectId; - }, -): Effect.Effect { +export function requireProject(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly projectId: ProjectId; +}): Effect.Effect { const project = findProjectById(input.readModel, input.projectId); if (project) { return Effect.succeed(project); @@ -57,13 +55,11 @@ export function requireProject( ); } -export function requireProjectAbsent( - input: { - readonly readModel: OrchestrationReadModel; - readonly command: OrchestrationCommand; - readonly projectId: ProjectId; - }, -): Effect.Effect { +export function requireProjectAbsent(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly projectId: ProjectId; +}): Effect.Effect { if (!findProjectById(input.readModel, input.projectId)) { return Effect.void; } @@ -75,13 +71,11 @@ export function requireProjectAbsent( ); } -export function requireThread( - input: { - readonly readModel: OrchestrationReadModel; - readonly command: OrchestrationCommand; - readonly threadId: ThreadId; - }, -): Effect.Effect { +export function requireThread(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly threadId: ThreadId; +}): Effect.Effect { const thread = findThreadById(input.readModel, input.threadId); if (thread) { return Effect.succeed(thread); @@ -94,13 +88,11 @@ export function requireThread( ); } -export function requireThreadAbsent( - input: { - readonly readModel: OrchestrationReadModel; - readonly command: OrchestrationCommand; - readonly threadId: ThreadId; - }, -): Effect.Effect { +export function requireThreadAbsent(input: { + readonly readModel: OrchestrationReadModel; + readonly command: OrchestrationCommand; + readonly threadId: ThreadId; +}): Effect.Effect { if (!findThreadById(input.readModel, input.threadId)) { return Effect.void; } @@ -112,13 +104,11 @@ export function requireThreadAbsent( ); } -export function requireNonNegativeInteger( - input: { - readonly commandType: OrchestrationCommand["type"]; - readonly field: string; - readonly value: number; - }, -): Effect.Effect { +export function requireNonNegativeInteger(input: { + readonly commandType: OrchestrationCommand["type"]; + readonly field: string; + readonly value: number; +}): Effect.Effect { if (Number.isInteger(input.value) && input.value >= 0) { return Effect.void; } diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index d29258c153d95..b716543d3d51b 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -491,7 +491,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command.activity.payload !== null && "requestId" in command.activity.payload && typeof (command.activity.payload as { requestId?: unknown }).requestId === "string" - ? ((command.activity.payload as { requestId: string }).requestId as OrchestrationEvent["metadata"]["requestId"]) + ? ((command.activity.payload as { requestId: string }) + .requestId as OrchestrationEvent["metadata"]["requestId"]) : undefined; return { ...withEventBase({ diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 798aa71002404..e03c260db817a 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -1,4 +1,10 @@ -import { CommandId, EventId, ProjectId, ThreadId, type OrchestrationEvent } from "@t3tools/contracts"; +import { + CommandId, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, +} from "@t3tools/contracts"; import { Effect } from "effect"; import { describe, expect, it } from "vitest"; @@ -484,13 +490,15 @@ describe("orchestration projector", () => { ); const thread = afterRevert.threads[0]; - expect(thread?.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual([ - { role: "user", text: "First edit" }, - { role: "assistant", text: "Updated README to v2.\n" }, - ]); - expect(thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId }))).toEqual([ - { id: "activity-1", turnId: "turn-1" }, - ]); + expect(thread?.messages.map((message) => ({ role: message.role, text: message.text }))).toEqual( + [ + { role: "user", text: "First edit" }, + { role: "assistant", text: "Updated README to v2.\n" }, + ], + ); + expect( + thread?.activities.map((activity) => ({ id: activity.id, turnId: activity.turnId })), + ).toEqual([{ id: "activity-1", turnId: "turn-1" }]); expect(thread?.checkpoints.map((checkpoint) => checkpoint.checkpointTurnCount)).toEqual([1]); expect(thread?.latestTurnId).toBe("turn-1"); }); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 1b6d4d6cf75f9..6a3f8fe9c12ba 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,8 +1,4 @@ -import type { - OrchestrationEvent, - OrchestrationReadModel, - ThreadId, -} from "@t3tools/contracts"; +import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; import { OrchestrationCheckpointSummary, OrchestrationMessage, @@ -147,19 +143,16 @@ export function projectEvent( return { ...nextBase, projects: existing - ? nextBase.projects.map((entry) => (entry.id === payload.projectId ? nextProject : entry)) + ? nextBase.projects.map((entry) => + entry.id === payload.projectId ? nextProject : entry, + ) : [...nextBase.projects, nextProject], }; }), ); case "project.meta-updated": - return decodeForEvent( - ProjectMetaUpdatedPayload, - event.payload, - event.type, - "payload", - ).pipe( + return decodeForEvent(ProjectMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ ...nextBase, projects: nextBase.projects.map((project) => @@ -247,12 +240,7 @@ export function projectEvent( ); case "thread.meta-updated": - return decodeForEvent( - ThreadMetaUpdatedPayload, - event.payload, - event.type, - "payload", - ).pipe( + return decodeForEvent(ThreadMetaUpdatedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { @@ -415,13 +403,10 @@ export function projectEvent( retainedTurnIds, payload.turnCount, ); - const activities = retainThreadActivitiesAfterRevert( - thread.activities, - retainedTurnIds, - ); + const activities = retainThreadActivitiesAfterRevert(thread.activities, retainedTurnIds); const latestTurnId = - checkpoints.length > 0 ? checkpoints[checkpoints.length - 1]?.turnId ?? null : null; + checkpoints.length > 0 ? (checkpoints[checkpoints.length - 1]?.turnId ?? null) : null; return { ...nextBase, diff --git a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts index ad36142afbf9b..6f166417b5c9b 100644 --- a/apps/server/src/persistence/Layers/OrchestrationEventStore.ts +++ b/apps/server/src/persistence/Layers/OrchestrationEventStore.ts @@ -88,10 +88,7 @@ function inferActorKind( return "client"; } -function toPersistenceSqlOrDecodeError( - sqlOperation: string, - decodeOperation: string, -) { +function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown): OrchestrationEventStoreError => Schema.isSchemaError(cause) ? toPersistenceDecodeError(decodeOperation)(cause) diff --git a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts index a8d65914bb7b8..0a36aba76caf5 100644 --- a/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts +++ b/apps/server/src/persistence/Layers/ProjectionCheckpoints.ts @@ -3,10 +3,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { Effect, Layer, Option, Schema, Struct } from "effect"; -import { - toPersistenceDecodeError, - toPersistenceSqlError, -} from "../Errors.ts"; +import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; import { DeleteByThreadIdInput, GetByThreadAndTurnCountInput, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index 21cf24cd88140..c2285f95de767 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -111,7 +111,9 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { "ProjectionThreadActivityRepository.listByThreadId:decodeRows", ), ), - Effect.map((rows) => rows as ReadonlyArray>), + Effect.map( + (rows) => rows as ReadonlyArray>, + ), ); const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => diff --git a/apps/server/src/persistence/Layers/ProviderSessions.ts b/apps/server/src/persistence/Layers/ProviderSessions.ts index c31a6a53fa5f9..46f72da57454c 100644 --- a/apps/server/src/persistence/Layers/ProviderSessions.ts +++ b/apps/server/src/persistence/Layers/ProviderSessions.ts @@ -72,9 +72,7 @@ function toPersistenceError( }); } -function toEntry( - row: Schema.Schema.Type, -): ProviderSessionEntry { +function toEntry(row: Schema.Schema.Type): ProviderSessionEntry { return { sessionId: row.sessionId, provider: row.provider, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 8b63284967544..5d47828922c64 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -129,7 +129,10 @@ function makeFakeCodexAdapter() { ( _sessionId: ProviderSessionId, ): Effect.Effect< - { threadId: ProviderThreadId; turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }> }, + { + threadId: ProviderThreadId; + turns: ReadonlyArray<{ id: ProviderTurnId; items: readonly [] }>; + }, ProviderAdapterError > => Effect.succeed({ @@ -290,101 +293,103 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( }).pipe(Effect.provide(NodeServices.layer)), ); -it.effect("ProviderServiceLive restores rollback routing after restart using persisted thread mapping", () => - Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); - const persistenceLayer = makeSqlitePersistenceLive(dbPath); - const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( - Layer.provide(persistenceLayer), - ); +it.effect( + "ProviderServiceLive restores rollback routing after restart using persisted thread mapping", + () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-restart-")); + const dbPath = path.join(tempDir, "orchestration.sqlite"); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); - const firstCodex = makeFakeCodexAdapter(); - const firstRegistry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(firstCodex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; + const firstCodex = makeFakeCodexAdapter(); + const firstRegistry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "codex" + ? Effect.succeed(firstCodex.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex"]), + }; - const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(runtimeRepositoryLayer), - ); - const firstProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), - Layer.provide(firstDirectoryLayer), - ); + const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const firstProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), + Layer.provide(firstDirectoryLayer), + ); - const startedSession = yield* Effect.gen(function* () { - const provider = yield* ProviderService; - return yield* provider.startSession({ - provider: "codex", - cwd: "/tmp/project", - }); - }).pipe(Effect.provide(firstProviderLayer)); + const startedSession = yield* Effect.gen(function* () { + const provider = yield* ProviderService; + return yield* provider.startSession({ + provider: "codex", + cwd: "/tmp/project", + }); + }).pipe(Effect.provide(firstProviderLayer)); - yield* Effect.gen(function* () { - const provider = yield* ProviderService; - yield* provider.stopAll(); - }).pipe(Effect.provide(firstProviderLayer)); + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.stopAll(); + }).pipe(Effect.provide(firstProviderLayer)); - const persistedAfterStopAll = yield* Effect.gen(function* () { - const repository = yield* ProviderSessionRuntimeRepository; - return yield* repository.getBySessionId({ - providerSessionId: startedSession.sessionId, - }); - }).pipe(Effect.provide(runtimeRepositoryLayer)); - assert.equal(Option.isSome(persistedAfterStopAll), true); - if (Option.isSome(persistedAfterStopAll)) { - assert.equal(persistedAfterStopAll.value.status, "stopped"); - assert.deepEqual(persistedAfterStopAll.value.resumeCursor, { - resumeThreadId: startedSession.threadId, - }); - } + const persistedAfterStopAll = yield* Effect.gen(function* () { + const repository = yield* ProviderSessionRuntimeRepository; + return yield* repository.getBySessionId({ + providerSessionId: startedSession.sessionId, + }); + }).pipe(Effect.provide(runtimeRepositoryLayer)); + assert.equal(Option.isSome(persistedAfterStopAll), true); + if (Option.isSome(persistedAfterStopAll)) { + assert.equal(persistedAfterStopAll.value.status, "stopped"); + assert.deepEqual(persistedAfterStopAll.value.resumeCursor, { + resumeThreadId: startedSession.threadId, + }); + } - const secondCodex = makeFakeCodexAdapter(); - const secondRegistry: typeof ProviderAdapterRegistry.Service = { - getByProvider: (provider) => - provider === "codex" - ? Effect.succeed(secondCodex.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex"]), - }; - const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( - Layer.provide(runtimeRepositoryLayer), - ); - const secondProviderLayer = makeProviderServiceLive().pipe( - Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), - Layer.provide(secondDirectoryLayer), - ); + const secondCodex = makeFakeCodexAdapter(); + const secondRegistry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "codex" + ? Effect.succeed(secondCodex.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex"]), + }; + const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const secondProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), + Layer.provide(secondDirectoryLayer), + ); - secondCodex.startSession.mockClear(); - secondCodex.rollbackThread.mockClear(); + secondCodex.startSession.mockClear(); + secondCodex.rollbackThread.mockClear(); - yield* Effect.gen(function* () { - const provider = yield* ProviderService; - yield* provider.rollbackConversation({ - sessionId: startedSession.sessionId, - numTurns: 1, - }); - }).pipe(Effect.provide(secondProviderLayer)); + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.rollbackConversation({ + sessionId: startedSession.sessionId, + numTurns: 1, + }); + }).pipe(Effect.provide(secondProviderLayer)); - assert.deepEqual(secondCodex.startSession.mock.calls, [ - [ - { - provider: "codex", - resumeThreadId: startedSession.threadId, - }, - ], - ]); - assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); - const rollbackCall = secondCodex.rollbackThread.mock.calls[0]; - assert.equal(typeof rollbackCall?.[0], "string"); - assert.equal(rollbackCall?.[1], 1); + assert.deepEqual(secondCodex.startSession.mock.calls, [ + [ + { + provider: "codex", + resumeThreadId: startedSession.threadId, + }, + ], + ]); + assert.equal(secondCodex.rollbackThread.mock.calls.length, 1); + const rollbackCall = secondCodex.rollbackThread.mock.calls[0]; + assert.equal(typeof rollbackCall?.[0], "string"); + assert.equal(rollbackCall?.[1], 1); - fs.rmSync(tempDir, { recursive: true, force: true }); - }).pipe(Effect.provide(NodeServices.layer)), + fs.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), ); routing.layer("ProviderServiceLive routing", (it) => { @@ -574,7 +579,10 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const events = yield* Ref.get(eventsRef); yield* Fiber.interrupt(consumer); - assert.equal(events.some((entry) => entry.type === "turn.completed"), true); + assert.equal( + events.some((entry) => entry.type === "turn.completed"), + true, + ); }), ); @@ -586,11 +594,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { }); const receivedByHealthy: string[] = []; - const expectedEventIds = new Set([ - "evt-ordered-1", - "evt-ordered-2", - "evt-ordered-3", - ]); + const expectedEventIds = new Set(["evt-ordered-1", "evt-ordered-2", "evt-ordered-3"]); const healthyFiber = yield* Stream.take(provider.streamEvents, 3).pipe( Stream.runForEach((event) => Effect.sync(() => { @@ -649,11 +653,7 @@ fanout.layer("ProviderServiceLive fanout", (it) => { assert.deepEqual( receivedByHealthy.filter((eventId) => expectedEventIds.has(eventId)).slice(0, 3), - [ - "evt-ordered-1", - "evt-ordered-2", - "evt-ordered-3", - ], + ["evt-ordered-1", "evt-ordered-2", "evt-ordered-3"], ); }), ); @@ -712,9 +712,9 @@ validation.layer("ProviderServiceLive validation", (it) => { assertFailure( failure, new ProviderValidationError({ - operation: "ProviderService.startSession", - issue: "Provider 'codex' returned a session without threadId.", - }), + operation: "ProviderService.startSession", + issue: "Provider 'codex' returned a session without threadId.", + }), ); }), ); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 1df3f050312f9..fc807f84af92e 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -3,17 +3,16 @@ import os from "node:os"; import path from "node:path"; import { NodeServices } from "@effect/platform-node"; -import { - ProviderSessionId, - ProviderThreadId, - ThreadId, -} from "@t3tools/contracts"; +import { ProviderSessionId, ProviderThreadId, ThreadId } from "@t3tools/contracts"; import { it, assert } from "@effect/vitest"; import { assertFailure, assertSome } from "@effect/vitest/utils"; import { Effect, Layer, Option } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { + makeSqlitePersistenceLive, + SqlitePersistenceMemory, +} from "../../persistence/Layers/Sqlite.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionNotFoundError, ProviderValidationError } from "../Errors.ts"; diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index 6d2a36c5f6e68..b3b68f48971a1 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -78,7 +78,6 @@ export interface ProviderSessionDirectoryShape { ReadonlyArray, ProviderSessionDirectoryPersistenceError >; - } /** diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index e3e5e60b85431..314da061a0f94 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -28,6 +28,7 @@ import { ProviderService } from "./provider/Services/ProviderService"; import { makeTerminalManagerLive, TerminalManager } from "./terminalManager"; import { PtyAdapter } from "./ptyAdapter"; +import { Keybindings, KeybindingsLive } from "./keybindings"; export function makeServerProviderLayer(): Layer.Layer< ProviderService, @@ -55,7 +56,8 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< | ProjectionSnapshotQuery | CheckpointStore | OrchestrationReactor - | TerminalManager, + | TerminalManager + | Keybindings, unknown, SqlClient.SqlClient | ProviderService | ServerConfig > { @@ -93,5 +95,7 @@ export function makeServerRuntimeServicesLayer(): Layer.Layer< }).pipe(Layer.provide(PtyAdapter.layer())); }).pipe(Layer.unwrap); - return Layer.merge(orchestrationReactorLayer, terminalLayer); + const keybindingsLayer = KeybindingsLive.pipe(Layer.provide(NodeServices.layer)); + + return Layer.mergeAll(orchestrationReactorLayer, terminalLayer, keybindingsLayer); } diff --git a/apps/server/src/workspaceEntries.chunking.test.ts b/apps/server/src/workspaceEntries.chunking.test.ts index 6644d608015d2..f978c5233ecca 100644 --- a/apps/server/src/workspaceEntries.chunking.test.ts +++ b/apps/server/src/workspaceEntries.chunking.test.ts @@ -2,13 +2,14 @@ import { assert, beforeEach, describe, it, vi } from "vitest"; import type { ProcessRunOptions, ProcessRunResult } from "./processRunner"; const { runProcessMock } = vi.hoisted(() => ({ - runProcessMock: vi.fn< - ( - command: string, - args: readonly string[], - options?: ProcessRunOptions, - ) => Promise - >(), + runProcessMock: + vi.fn< + ( + command: string, + args: readonly string[], + options?: ProcessRunOptions, + ) => Promise + >(), })); vi.mock("./processRunner", () => ({ diff --git a/apps/server/src/workspaceEntries.test.ts b/apps/server/src/workspaceEntries.test.ts index e3660609dfeda..ca8435336f0d2 100644 --- a/apps/server/src/workspaceEntries.test.ts +++ b/apps/server/src/workspaceEntries.test.ts @@ -124,15 +124,15 @@ describe("searchWorkspaceEntries", () => { let rootReadCount = 0; const originalReaddir = fsPromises.readdir.bind(fsPromises); - vi.spyOn(fsPromises, "readdir").mockImplementation( - (async (...args: Parameters) => { - if (args[0] === cwd) { - rootReadCount += 1; - await new Promise((resolve) => setTimeout(resolve, 20)); - } - return originalReaddir(...args); - }) as typeof fsPromises.readdir, - ); + vi.spyOn(fsPromises, "readdir").mockImplementation((async ( + ...args: Parameters + ) => { + if (args[0] === cwd) { + rootReadCount += 1; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return originalReaddir(...args); + }) as typeof fsPromises.readdir); await Promise.all([ searchWorkspaceEntries({ cwd, query: "", limit: 100 }), @@ -152,22 +152,22 @@ describe("searchWorkspaceEntries", () => { let activeReads = 0; let peakReads = 0; const originalReaddir = fsPromises.readdir.bind(fsPromises); - vi.spyOn(fsPromises, "readdir").mockImplementation( - (async (...args: Parameters) => { - const target = args[0]; - if (typeof target === "string" && target.startsWith(cwd)) { - activeReads += 1; - peakReads = Math.max(peakReads, activeReads); - await new Promise((resolve) => setTimeout(resolve, 4)); - try { - return await originalReaddir(...args); - } finally { - activeReads -= 1; - } + vi.spyOn(fsPromises, "readdir").mockImplementation((async ( + ...args: Parameters + ) => { + const target = args[0]; + if (typeof target === "string" && target.startsWith(cwd)) { + activeReads += 1; + peakReads = Math.max(peakReads, activeReads); + await new Promise((resolve) => setTimeout(resolve, 4)); + try { + return await originalReaddir(...args); + } finally { + activeReads -= 1; } - return originalReaddir(...args); - }) as typeof fsPromises.readdir, - ); + } + return originalReaddir(...args); + }) as typeof fsPromises.readdir); await searchWorkspaceEntries({ cwd, query: "", limit: 200 }); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 4a4a2bee1e426..43e2284470e4b 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -360,6 +360,7 @@ describe("WebSocket Server", () => { port: 0, host: undefined, cwd: options.cwd ?? "/test/project", + keybindingsConfigPath: path.join(stateDir, "keybindings.json"), stateDir, staticDir: undefined, devUrl: options.devUrl ? new URL(options.devUrl) : undefined, diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 9ff5d0c84a7e7..c9b3f1795b25b 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -31,7 +31,7 @@ import { removeGitWorktree, } from "./git"; import { TerminalManager, type TerminalManagerShape } from "./terminalManager"; -import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; +import { Keybindings } from "./keybindings"; import { searchWorkspaceEntries } from "./workspaceEntries"; import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; @@ -150,9 +150,13 @@ export type ServerCoreRuntimeServices = | ProjectionSnapshotQuery | CheckpointStore | OrchestrationReactor - | Open; + | ProviderService; -export type ServerRuntimeServices = ServerCoreRuntimeServices | ProviderService | TerminalManager; +export type ServerRuntimeServices = + | ServerCoreRuntimeServices + | TerminalManager + | Keybindings + | Open; export class ServerLifecycleError extends Schema.TaggedErrorClass()( "ServerLifecycleError", @@ -171,8 +175,10 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E unknown, // TODO: This should not be unknown Scope.Scope | ServerRuntimeServices | ServerConfig > { - const { port, cwd, staticDir, devUrl, authToken, mode, host } = yield* ServerConfig; + const serverConfig = yield* ServerConfig; + const { port, cwd, staticDir, devUrl, authToken, mode, host } = serverConfig; const defaultTerminalManager = yield* TerminalManager; + const keybindingsManager = yield* Keybindings; const { logWebSocketEvents: explicitLogWsEvents, gitManager = new GitManager(), @@ -184,7 +190,6 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E const logger = createLogger("ws"); const logWebSocketEvents = explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); - let keybindingsConfig = loadResolvedKeybindingsConfig(logger); function logOutgoingPush(push: WsPush, recipients: number) { if (!logWebSocketEvents) return; @@ -571,11 +576,12 @@ export const createServer = Effect.fn(function* (options: ServerOptions = {}): E } case WS_METHODS.serverGetConfig: + const keybindingsConfig = yield* keybindingsManager.loadResolvedKeybindingsConfig; return { cwd, keybindings: keybindingsConfig }; case WS_METHODS.serverUpsertKeybinding: { const body = stripRequestTag(request.body); - keybindingsConfig = upsertKeybindingRule(logger, body); + const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); return { keybindings: keybindingsConfig }; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 60415140eb818..5306ef1cc9b5b 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -108,7 +108,15 @@ export default function BranchToolbar({ branch: syncedBranch, worktreePath: null, }); - }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, envMode, dispatch, api]); + }, [ + activeThreadId, + activeWorktreePath, + activeThreadBranch, + queryBranches, + envMode, + dispatch, + api, + ]); useEffect(() => { if (isBranchMenuOpen) return; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab3248639b0f7..e1ba97830181e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,6 +1,7 @@ import { EDITORS, type EditorId, + type KeybindingCommand, type NativeApi, type ProjectEntry, type ProjectScript, @@ -10,20 +11,7 @@ import { type ResolvedKeybindingsConfig, type ProviderApprovalDecision, } from "@t3tools/contracts"; -import { - type ClipboardEvent, - type DragEvent, - type FormEvent, - type KeyboardEvent, - memo, - type RefObject, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; @@ -298,7 +286,7 @@ const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { triggerKind: ComposerTriggerKind | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; - commandInputRef: RefObject; + commandInputRef: React.RefObject; }) { return ( { if (!api) return; @@ -1250,7 +1238,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }; - const onComposerPaste = (event: ClipboardEvent) => { + const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); if (files.length === 0) { return; @@ -1263,7 +1251,7 @@ export default function ChatView({ threadId }: ChatViewProps) { addComposerImages(imageFiles); }; - const onComposerDragEnter = (event: DragEvent) => { + const onComposerDragEnter = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) { return; } @@ -1272,7 +1260,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setIsDragOverComposer(true); }; - const onComposerDragOver = (event: DragEvent) => { + const onComposerDragOver = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) { return; } @@ -1281,7 +1269,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setIsDragOverComposer(true); }; - const onComposerDragLeave = (event: DragEvent) => { + const onComposerDragLeave = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) { return; } @@ -1296,7 +1284,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } }; - const onComposerDrop = (event: DragEvent) => { + const onComposerDrop = (event: React.DragEvent) => { if (!event.dataTransfer.types.includes("Files")) { return; } @@ -1350,7 +1338,7 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThread, api, isConnecting, isRevertingCheckpoint, isSending, phase, setThreadError], ); - const onSend = async (e: FormEvent) => { + const onSend = async (e: React.SubmitEvent | React.KeyboardEvent) => { e.preventDefault(); if (!api || !activeThread || isSending || isConnecting) return; const trimmed = prompt.trim(); @@ -1594,7 +1582,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerCursor(nextCursor); }, []); - const onKeyDown = (e: KeyboardEvent) => { + const onKeyDown = (e: React.KeyboardEvent) => { if (composerMenuOpen && composerMenuItems.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); @@ -1618,7 +1606,7 @@ export default function ChatView({ threadId }: ChatViewProps) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); - void onSend(e as unknown as FormEvent); + void onSend(e); } }; const onToggleWorkGroup = useCallback((groupId: string) => { @@ -2086,7 +2074,10 @@ const ChatHeader = memo(function ChatHeader({
-

+

{activeThreadTitle}

{activeProjectName && ( @@ -2216,7 +2207,7 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ interface MessagesTimelineProps { hasMessages: boolean; isWorking: boolean; - scrollContainerRef: RefObject; + scrollContainerRef: React.RefObject; timelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; completionSummary: string | null; diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index c20de6f2c7e24..cc5bd4a6e1310 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -335,10 +335,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { > -
+
+
+ +

+ Opens in your preferred editor selection. +

+ {openKeybindingsError ? ( +

{openKeybindingsError}

+ ) : null} +
+ +

Safety

diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 774cf5d37c8a5..6e52f8b81d757 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -4,6 +4,7 @@ import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; export const ServerConfig = Schema.Struct({ cwd: TrimmedNonEmptyString, + keybindingsConfigPath: TrimmedNonEmptyString, keybindings: ResolvedKeybindingsConfig, }); export type ServerConfig = typeof ServerConfig.Type; From ad9e36a4ffab9bbfd1408d538a68f2c12257bd76 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Feb 2026 11:58:51 -0800 Subject: [PATCH 98/98] fix bunpty lazy --- apps/server/src/terminal/Layers/BunPTY.ts | 33 ++++++++++++----------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/Layers/BunPTY.ts index 84a116d32156e..48b6492b195bc 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/Layers/BunPTY.ts @@ -93,23 +93,24 @@ export const BunPtyAdapterLive = Layer.effect( return yield* Effect.die("Bun PTY terminal support is unavailable on Windows."); } return { - spawn: (input) => { - let processHandle: BunPtyProcess | null = null; - const command = [input.shell, ...(input.args ?? [])]; - const subprocess = Bun.spawn(command, { - cwd: input.cwd, - env: input.env, - terminal: { - cols: input.cols, - rows: input.rows, - data: (_terminal, data) => { - processHandle?.emitData(data); + spawn: (input) => + Effect.sync(() => { + let processHandle: BunPtyProcess | null = null; + const command = [input.shell, ...(input.args ?? [])]; + const subprocess = Bun.spawn(command, { + cwd: input.cwd, + env: input.env, + terminal: { + cols: input.cols, + rows: input.rows, + data: (_terminal, data) => { + processHandle?.emitData(data); + }, }, - }, - }); - processHandle = new BunPtyProcess(subprocess); - return Effect.succeed(processHandle); - }, + }); + processHandle = new BunPtyProcess(subprocess); + return processHandle; + }), } satisfies PtyAdapterShape; }), );