From a88ad899723eb2905f47b779d66564426d97589b Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Tue, 14 Oct 2025 12:18:29 +0900 Subject: [PATCH 01/21] fix: disable tool approve for old claude code version --- src/server/hono/route.ts | 52 +++++++++---------- .../service/claude-code/ClaudeCodeExecutor.ts | 6 +-- .../claude-code/ClaudeCodeTaskController.ts | 45 +++++++--------- src/server/service/events/EventBus.ts | 10 ++-- 4 files changed, 53 insertions(+), 60 deletions(-) diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index c9425c3..14e48de 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -167,7 +167,7 @@ export const routes = (app: HonoAppType) => { "query", z.object({ basePath: z.string().optional().default("/"), - }) + }), ), async (c) => { const { projectId } = c.req.param(); @@ -182,14 +182,14 @@ export const routes = (app: HonoAppType) => { try { const result = await getFileCompletion( project.meta.projectPath, - basePath + basePath, ); return c.json(result); } catch (error) { console.error("File completion error:", error); return c.json({ error: "Failed to get file completion" }, 500); } - } + }, ) .get("/projects/:projectId/claude-commands", async (c) => { @@ -202,18 +202,18 @@ export const routes = (app: HonoAppType) => { }).then((dirents) => dirents .filter((d) => d.isFile() && d.name.endsWith(".md")) - .map((d) => d.name.replace(/\.md$/, "")) + .map((d) => d.name.replace(/\.md$/, "")), ), project.meta.projectPath !== null ? readdir( resolve(project.meta.projectPath, ".claude", "commands"), { withFileTypes: true, - } + }, ).then((dirents) => dirents .filter((d) => d.isFile() && d.name.endsWith(".md")) - .map((d) => d.name.replace(/\.md$/, "")) + .map((d) => d.name.replace(/\.md$/, "")), ) : [], ]); @@ -274,7 +274,7 @@ export const routes = (app: HonoAppType) => { z.object({ fromRef: z.string().min(1, "fromRef is required"), toRef: z.string().min(1, "toRef is required"), - }) + }), ), async (c) => { const { projectId } = c.req.param(); @@ -289,7 +289,7 @@ export const routes = (app: HonoAppType) => { const result = await getDiff( project.meta.projectPath, fromRef, - toRef + toRef, ); return c.json(result); } catch (error) { @@ -299,7 +299,7 @@ export const routes = (app: HonoAppType) => { } return c.json({ error: "Failed to get diff" }, 500); } - } + }, ) .get("/mcp/list", async (c) => { @@ -313,7 +313,7 @@ export const routes = (app: HonoAppType) => { "json", z.object({ message: z.string(), - }) + }), ), async (c) => { const { projectId } = c.req.param(); @@ -325,13 +325,13 @@ export const routes = (app: HonoAppType) => { } const task = await getTaskController( - c.get("config") + c.get("config"), ).startOrContinueTask( { projectId, cwd: project.meta.projectPath, }, - message + message, ); return c.json({ @@ -339,7 +339,7 @@ export const routes = (app: HonoAppType) => { sessionId: task.sessionId, userMessageId: task.userMessageId, }); - } + }, ) .post( @@ -348,7 +348,7 @@ export const routes = (app: HonoAppType) => { "json", z.object({ resumeMessage: z.string(), - }) + }), ), async (c) => { const { projectId, sessionId } = c.req.param(); @@ -360,14 +360,14 @@ export const routes = (app: HonoAppType) => { } const task = await getTaskController( - c.get("config") + c.get("config"), ).startOrContinueTask( { projectId, sessionId, cwd: project.meta.projectPath, }, - resumeMessage + resumeMessage, ); return c.json({ @@ -375,7 +375,7 @@ export const routes = (app: HonoAppType) => { sessionId: task.sessionId, userMessageId: task.userMessageId, }); - } + }, ) .get("/tasks/alive", async (c) => { @@ -386,7 +386,7 @@ export const routes = (app: HonoAppType) => { status: task.status, sessionId: task.sessionId, userMessageId: task.userMessageId, - }) + }), ), }); }) @@ -398,7 +398,7 @@ export const routes = (app: HonoAppType) => { const { sessionId } = c.req.valid("json"); getTaskController(c.get("config")).abortTask(sessionId); return c.json({ message: "Task aborted" }); - } + }, ) .post( @@ -408,15 +408,15 @@ export const routes = (app: HonoAppType) => { z.object({ permissionRequestId: z.string(), decision: z.enum(["allow", "deny"]), - }) + }), ), async (c) => { const permissionResponse = c.req.valid("json"); getTaskController(c.get("config")).respondToPermissionRequest( - permissionResponse + permissionResponse, ); return c.json({ message: "Permission response received" }); - } + }, ) .get("/sse", async (c) => { @@ -426,7 +426,7 @@ export const routes = (app: HonoAppType) => { const stream = writeTypeSafeSSE(rawStream); const onSessionListChanged = ( - event: InternalEventDeclaration["sessionListChanged"] + event: InternalEventDeclaration["sessionListChanged"], ) => { stream.writeSSE("sessionListChanged", { projectId: event.projectId, @@ -434,7 +434,7 @@ export const routes = (app: HonoAppType) => { }; const onSessionChanged = ( - event: InternalEventDeclaration["sessionChanged"] + event: InternalEventDeclaration["sessionChanged"], ) => { stream.writeSSE("sessionChanged", { projectId: event.projectId, @@ -443,7 +443,7 @@ export const routes = (app: HonoAppType) => { }; const onTaskChanged = ( - event: InternalEventDeclaration["taskChanged"] + event: InternalEventDeclaration["taskChanged"], ) => { stream.writeSSE("taskChanged", { aliveTasks: event.aliveTasks, @@ -467,7 +467,7 @@ export const routes = (app: HonoAppType) => { }, async (err) => { console.error("Streaming error:", err); - } + }, ); }) ); diff --git a/src/server/service/claude-code/ClaudeCodeExecutor.ts b/src/server/service/claude-code/ClaudeCodeExecutor.ts index 691117e..d3c1678 100644 --- a/src/server/service/claude-code/ClaudeCodeExecutor.ts +++ b/src/server/service/claude-code/ClaudeCodeExecutor.ts @@ -19,7 +19,7 @@ export class ClaudeCodeExecutor { ? resolve(executablePath) : execSync("which claude", {}).toString().trim(); this.claudeCodeVersion = ClaudeCodeVersion.fromCLIString( - execSync(`${this.pathToClaudeCodeExecutable} --version`, {}).toString() + execSync(`${this.pathToClaudeCodeExecutable} --version`, {}).toString(), ); } @@ -27,11 +27,11 @@ export class ClaudeCodeExecutor { return { enableToolApproval: this.claudeCodeVersion?.greaterThanOrEqual( - new ClaudeCodeVersion({ major: 1, minor: 0, patch: 82 }) + new ClaudeCodeVersion({ major: 1, minor: 0, patch: 82 }), ) ?? false, extractUuidFromSDKMessage: this.claudeCodeVersion?.greaterThanOrEqual( - new ClaudeCodeVersion({ major: 1, minor: 0, patch: 86 }) + new ClaudeCodeVersion({ major: 1, minor: 0, patch: 86 }), ) ?? false, }; } diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts index 2f859cf..a097f6b 100644 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts @@ -46,7 +46,7 @@ export class ClaudeCodeTaskController { return async ( toolName: string, toolInput: Record, - _options: { signal: AbortSignal } + _options: { signal: AbortSignal }, ) => { // If not in default mode, use the configured permission mode behavior if (this.config.permissionMode !== "default") { @@ -81,7 +81,7 @@ export class ClaudeCodeTaskController { // Store the request this.pendingPermissionRequests.set( permissionRequest.id, - permissionRequest + permissionRequest, ); // Emit event to notify UI @@ -92,7 +92,7 @@ export class ClaudeCodeTaskController { // Wait for user response with timeout const response = await this.waitForPermissionResponse( permissionRequest.id, - 60000 + 60000, ); // 60 second timeout if (response) { @@ -120,7 +120,7 @@ export class ClaudeCodeTaskController { private async waitForPermissionResponse( permissionRequestId: string, - timeoutMs: number + timeoutMs: number, ): Promise { return new Promise((resolve) => { const checkResponse = () => { @@ -153,7 +153,7 @@ export class ClaudeCodeTaskController { public get aliveTasks() { return this.tasks.filter( - (task) => task.status === "running" || task.status === "paused" + (task) => task.status === "running" || task.status === "paused", ); } @@ -163,10 +163,10 @@ export class ClaudeCodeTaskController { projectId: string; sessionId?: string; }, - message: string + message: string, ): Promise { const existingTask = this.aliveTasks.find( - (task) => task.sessionId === currentSession.sessionId + (task) => task.sessionId === currentSession.sessionId, ); if (existingTask) { @@ -190,7 +190,7 @@ export class ClaudeCodeTaskController { projectId: string; sessionId?: string; }, - message: string + message: string, ) { const { generateMessages, @@ -221,7 +221,7 @@ export class ClaudeCodeTaskController { (resolve, reject) => { aliveTaskResolve = resolve; aliveTaskReject = reject; - } + }, ); let resolved = false; @@ -240,10 +240,10 @@ export class ClaudeCodeTaskController { permissionMode: this.config.permissionMode, canUseTool: this.createCanUseToolCallback( task.id, - task.baseSessionId + task.baseSessionId, ), abortController: abortController, - } + }, )) { currentTask ??= this.aliveTasks.find((t) => t.id === task.id); @@ -257,13 +257,9 @@ export class ClaudeCodeTaskController { // 初回の system message だとまだ history ファイルが作成されていないので if (message.type === "user" || message.type === "assistant") { // 本来は message.uuid の存在チェックをしたいが、古いバージョンでは存在しないことがある - console.log( - "[DEBUG startTask] 9. Processing user/assistant message" - ); - if (!resolved) { console.log( - "[DEBUG startTask] 10. Resolving task for first time" + "[DEBUG startTask] 10. Resolving task for first time", ); const runningTask: RunningClaudeCodeTask = { @@ -283,12 +279,12 @@ export class ClaudeCodeTaskController { }; this.tasks.push(runningTask); console.log( - "[DEBUG startTask] 11. About to call aliveTaskResolve" + "[DEBUG startTask] 11. About to call aliveTaskResolve", ); aliveTaskResolve(runningTask); resolved = true; console.log( - "[DEBUG startTask] 12. aliveTaskResolve called, resolved=true" + "[DEBUG startTask] 12. aliveTaskResolve called, resolved=true", ); } @@ -298,13 +294,10 @@ export class ClaudeCodeTaskController { await Promise.all( task.onMessageHandlers.map(async (onMessageHandler) => { await onMessageHandler(message); - }) + }), ); if (currentTask !== undefined && message.type === "result") { - console.log( - "[DEBUG startTask] 15. Result message received, pausing task" - ); this.upsertExistingTask({ ...currentTask, status: "paused", @@ -318,12 +311,12 @@ export class ClaudeCodeTaskController { if (updatedTask === undefined) { console.log( - "[DEBUG startTask] 17. ERROR: Task not found in aliveTasks" + "[DEBUG startTask] 17. ERROR: Task not found in aliveTasks", ); const error = new Error( `illegal state: task is not running, task: ${JSON.stringify( - updatedTask - )}` + updatedTask, + )}`, ); aliveTaskReject(error); throw error; @@ -336,7 +329,7 @@ export class ClaudeCodeTaskController { } catch (error) { if (!resolved) { console.log( - "[DEBUG startTask] 20. Rejecting task (not yet resolved)" + "[DEBUG startTask] 20. Rejecting task (not yet resolved)", ); aliveTaskReject(error); resolved = true; diff --git a/src/server/service/events/EventBus.ts b/src/server/service/events/EventBus.ts index c9deac7..42fa9e4 100644 --- a/src/server/service/events/EventBus.ts +++ b/src/server/service/events/EventBus.ts @@ -10,7 +10,7 @@ class EventBus { public emit( event: EventName, - data: InternalEventDeclaration[EventName] + data: InternalEventDeclaration[EventName], ): void { this.emitter.emit(event, { ...data, @@ -20,8 +20,8 @@ class EventBus { public on( event: EventName, listener: ( - data: InternalEventDeclaration[EventName] - ) => void | Promise + data: InternalEventDeclaration[EventName], + ) => void | Promise, ): void { this.emitter.on(event, listener); } @@ -29,8 +29,8 @@ class EventBus { public off( event: EventName, listener: ( - data: InternalEventDeclaration[EventName] - ) => void | Promise + data: InternalEventDeclaration[EventName], + ) => void | Promise, ): void { this.emitter.off(event, listener); } From c7d89d47cd24233c554646a862178e5091522f48 Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Tue, 14 Oct 2025 23:13:23 +0900 Subject: [PATCH 02/21] perf: added cache for project, session --- src/app/api/[[...route]]/route.ts | 2 +- src/server/hono/initialize.ts | 41 ++++++ src/server/hono/route.ts | 52 +++---- src/server/lib/storage/FileCacheStorage.ts | 82 +++++++++++ .../lib/storage/InMemoryCacheStorage.ts | 21 +++ .../claude-code/ClaudeCodeTaskController.ts | 8 +- src/server/service/events/EventBus.ts | 10 +- .../service/events/adaptInternalEventToSSE.ts | 4 +- src/server/service/events/fileWatcher.ts | 22 +-- src/server/service/parseCommandXml.ts | 33 +++-- src/server/service/paths.ts | 6 + .../service/project/ProjectRepository.ts | 75 ++++++++++ src/server/service/project/getProject.ts | 24 ---- src/server/service/project/getProjectMeta.ts | 85 ------------ src/server/service/project/getProjects.ts | 52 ------- .../service/project/projectMetaStorage.ts | 109 +++++++++++++++ src/server/service/schema.ts | 15 ++ .../service/session/SessionRepository.ts | 69 +++++++++ src/server/service/session/getSession.ts | 31 ----- src/server/service/session/getSessionMeta.ts | 101 -------------- src/server/service/session/getSessions.ts | 43 ------ src/server/service/session/id.ts | 11 ++ .../service/session/sessionMetaStorage.ts | 131 ++++++++++++++++++ src/server/service/types.ts | 16 +-- 24 files changed, 618 insertions(+), 425 deletions(-) create mode 100644 src/server/hono/initialize.ts create mode 100644 src/server/lib/storage/FileCacheStorage.ts create mode 100644 src/server/lib/storage/InMemoryCacheStorage.ts create mode 100644 src/server/service/project/ProjectRepository.ts delete mode 100644 src/server/service/project/getProject.ts delete mode 100644 src/server/service/project/getProjectMeta.ts delete mode 100644 src/server/service/project/getProjects.ts create mode 100644 src/server/service/project/projectMetaStorage.ts create mode 100644 src/server/service/schema.ts create mode 100644 src/server/service/session/SessionRepository.ts delete mode 100644 src/server/service/session/getSession.ts delete mode 100644 src/server/service/session/getSessionMeta.ts delete mode 100644 src/server/service/session/getSessions.ts create mode 100644 src/server/service/session/id.ts create mode 100644 src/server/service/session/sessionMetaStorage.ts diff --git a/src/app/api/[[...route]]/route.ts b/src/app/api/[[...route]]/route.ts index 63e2ac0..76f4e5e 100644 --- a/src/app/api/[[...route]]/route.ts +++ b/src/app/api/[[...route]]/route.ts @@ -2,7 +2,7 @@ import { handle } from "hono/vercel"; import { honoApp } from "../../../server/hono/app"; import { routes } from "../../../server/hono/route"; -routes(honoApp); +await routes(honoApp); export const GET = handle(honoApp); export const POST = handle(honoApp); diff --git a/src/server/hono/initialize.ts b/src/server/hono/initialize.ts new file mode 100644 index 0000000..d0d79a0 --- /dev/null +++ b/src/server/hono/initialize.ts @@ -0,0 +1,41 @@ +import { eventBus } from "../service/events/EventBus"; +import { fileWatcher } from "../service/events/fileWatcher"; +import type { ProjectRepository } from "../service/project/ProjectRepository"; +import { projectMetaStorage } from "../service/project/projectMetaStorage"; +import type { SessionRepository } from "../service/session/SessionRepository"; +import { sessionMetaStorage } from "../service/session/sessionMetaStorage"; + +export const initialize = async (deps: { + sessionRepository: SessionRepository; + projectRepository: ProjectRepository; +}): Promise => { + fileWatcher.startWatching(); + + setInterval(() => { + eventBus.emit("heartbeat", {}); + }, 10 * 1000); + + eventBus.on("sessionChanged", (event) => { + projectMetaStorage.invalidateProject(event.projectId); + sessionMetaStorage.invalidateSession(event.projectId, event.sessionId); + }); + + try { + console.log("Initializing projects cache"); + const { projects } = await deps.projectRepository.getProjects(); + console.log(`${projects.length} projects cache initialized`); + + console.log("Initializing sessions cache"); + const results = await Promise.all( + projects.map((project) => deps.sessionRepository.getSessions(project.id)) + ); + console.log( + `${results.reduce( + (s, { sessions }) => s + sessions.length, + 0 + )} sessions cache initialized` + ); + } catch { + // do nothing + } +}; diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index 14e48de..e033cc9 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -9,8 +9,7 @@ import { env } from "../lib/env"; import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; import type { SerializableAliveTask } from "../service/claude-code/types"; import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE"; -import { getEventBus } from "../service/events/EventBus"; -import { getFileWatcher } from "../service/events/fileWatcher"; +import { eventBus } from "../service/events/EventBus"; import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration"; import { writeTypeSafeSSE } from "../service/events/typeSafeSSE"; import { getFileCompletion } from "../service/file-completion/getFileCompletion"; @@ -19,14 +18,13 @@ import { getCommits } from "../service/git/getCommits"; import { getDiff } from "../service/git/getDiff"; import { getMcpList } from "../service/mcp/getMcpList"; import { claudeCommandsDirPath } from "../service/paths"; -import { getProject } from "../service/project/getProject"; -import { getProjects } from "../service/project/getProjects"; -import { getSession } from "../service/session/getSession"; -import { getSessions } from "../service/session/getSessions"; +import { ProjectRepository } from "../service/project/ProjectRepository"; +import { SessionRepository } from "../service/session/SessionRepository"; import type { HonoAppType } from "./app"; +import { initialize } from "./initialize"; import { configMiddleware } from "./middleware/config.middleware"; -export const routes = (app: HonoAppType) => { +export const routes = async (app: HonoAppType) => { let taskController: ClaudeCodeTaskController | null = null; const getTaskController = (config: Config) => { if (!taskController) { @@ -37,15 +35,14 @@ export const routes = (app: HonoAppType) => { return taskController; }; - const fileWatcher = getFileWatcher(); - const eventBus = getEventBus(); + const sessionRepository = new SessionRepository(); + const projectRepository = new ProjectRepository(); if (env.get("NEXT_PHASE") !== "phase-production-build") { - fileWatcher.startWatching(); - - setInterval(() => { - eventBus.emit("heartbeat", {}); - }, 10 * 1000); + await initialize({ + sessionRepository, + projectRepository, + }); } return ( @@ -71,7 +68,7 @@ export const routes = (app: HonoAppType) => { }) .get("/projects", async (c) => { - const { projects } = await getProjects(); + const { projects } = await projectRepository.getProjects(); return c.json({ projects }); }) @@ -79,8 +76,8 @@ export const routes = (app: HonoAppType) => { const { projectId } = c.req.param(); const [{ project }, { sessions }] = await Promise.all([ - getProject(projectId), - getSessions(projectId).then(({ sessions }) => { + projectRepository.getProject(projectId), + sessionRepository.getSessions(projectId).then(({ sessions }) => { let filteredSessions = sessions; // Filter sessions based on hideNoUserMessageSession setting @@ -157,7 +154,10 @@ export const routes = (app: HonoAppType) => { .get("/projects/:projectId/sessions/:sessionId", async (c) => { const { projectId, sessionId } = c.req.param(); - const { session } = await getSession(projectId, sessionId); + const { session } = await sessionRepository.getSession( + projectId, + sessionId, + ); return c.json({ session }); }) @@ -173,7 +173,7 @@ export const routes = (app: HonoAppType) => { const { projectId } = c.req.param(); const { basePath } = c.req.valid("query"); - const { project } = await getProject(projectId); + const { project } = await projectRepository.getProject(projectId); if (project.meta.projectPath === null) { return c.json({ error: "Project path not found" }, 400); @@ -194,7 +194,7 @@ export const routes = (app: HonoAppType) => { .get("/projects/:projectId/claude-commands", async (c) => { const { projectId } = c.req.param(); - const { project } = await getProject(projectId); + const { project } = await projectRepository.getProject(projectId); const [globalCommands, projectCommands] = await Promise.allSettled([ readdir(claudeCommandsDirPath, { @@ -229,7 +229,7 @@ export const routes = (app: HonoAppType) => { .get("/projects/:projectId/git/branches", async (c) => { const { projectId } = c.req.param(); - const { project } = await getProject(projectId); + const { project } = await projectRepository.getProject(projectId); if (project.meta.projectPath === null) { return c.json({ error: "Project path not found" }, 400); @@ -249,7 +249,7 @@ export const routes = (app: HonoAppType) => { .get("/projects/:projectId/git/commits", async (c) => { const { projectId } = c.req.param(); - const { project } = await getProject(projectId); + const { project } = await projectRepository.getProject(projectId); if (project.meta.projectPath === null) { return c.json({ error: "Project path not found" }, 400); @@ -279,7 +279,7 @@ export const routes = (app: HonoAppType) => { async (c) => { const { projectId } = c.req.param(); const { fromRef, toRef } = c.req.valid("json"); - const { project } = await getProject(projectId); + const { project } = await projectRepository.getProject(projectId); if (project.meta.projectPath === null) { return c.json({ error: "Project path not found" }, 400); @@ -318,7 +318,7 @@ export const routes = (app: HonoAppType) => { async (c) => { const { projectId } = c.req.param(); const { message } = c.req.valid("json"); - const { project } = await getProject(projectId); + const { project } = await projectRepository.getProject(projectId); if (project.meta.projectPath === null) { return c.json({ error: "Project path not found" }, 400); @@ -353,7 +353,7 @@ export const routes = (app: HonoAppType) => { async (c) => { const { projectId, sessionId } = c.req.param(); const { resumeMessage } = c.req.valid("json"); - const { project } = await getProject(projectId); + const { project } = await projectRepository.getProject(projectId); if (project.meta.projectPath === null) { return c.json({ error: "Project path not found" }, 400); @@ -473,4 +473,4 @@ export const routes = (app: HonoAppType) => { ); }; -export type RouteType = ReturnType; +export type RouteType = Awaited>; diff --git a/src/server/lib/storage/FileCacheStorage.ts b/src/server/lib/storage/FileCacheStorage.ts new file mode 100644 index 0000000..34d71fc --- /dev/null +++ b/src/server/lib/storage/FileCacheStorage.ts @@ -0,0 +1,82 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { z } from "zod"; +import { claudeCodeViewerCacheDirPath } from "../../service/paths"; + +const saveSchema = z.array(z.tuple([z.string(), z.unknown()])); + +export class FileCacheStorage { + private storage = new Map(); + + private constructor(private readonly key: string) {} + + public static load( + key: string, + schema: z.ZodType + ) { + const instance = new FileCacheStorage(key); + + if (!existsSync(claudeCodeViewerCacheDirPath)) { + mkdirSync(claudeCodeViewerCacheDirPath, { recursive: true }); + } + + if (!existsSync(instance.cacheFilePath)) { + writeFileSync(instance.cacheFilePath, "[]"); + } else { + const content = readFileSync(instance.cacheFilePath, "utf-8"); + const parsed = saveSchema.safeParse(JSON.parse(content)); + + if (!parsed.success) { + writeFileSync(instance.cacheFilePath, "[]"); + } else { + for (const [key, value] of parsed.data) { + const parsedValue = schema.safeParse(value); + if (!parsedValue.success) { + continue; + } + + instance.storage.set(key, parsedValue.data); + } + } + } + + return instance; + } + + private get cacheFilePath() { + return resolve(claudeCodeViewerCacheDirPath, `${this.key}.json`); + } + + private asSaveFormat() { + return JSON.stringify(Array.from(this.storage.entries())); + } + + private async syncToFile() { + await writeFile(this.cacheFilePath, this.asSaveFormat()); + } + + public get(key: string) { + return this.storage.get(key); + } + + public save(key: string, value: T) { + const previous = this.asSaveFormat(); + this.storage.set(key, value); + + if (previous === this.asSaveFormat()) { + return; + } + + void this.syncToFile(); + } + + public invalidate(key: string) { + if (!this.storage.has(key)) { + return; + } + + this.storage.delete(key); + void this.syncToFile(); + } +} diff --git a/src/server/lib/storage/InMemoryCacheStorage.ts b/src/server/lib/storage/InMemoryCacheStorage.ts new file mode 100644 index 0000000..5433d07 --- /dev/null +++ b/src/server/lib/storage/InMemoryCacheStorage.ts @@ -0,0 +1,21 @@ +export class InMemoryCacheStorage { + private storage = new Map(); + + public constructor() {} + + public get(key: string) { + return this.storage.get(key); + } + + public save(key: string, value: T) { + this.storage.set(key, value); + } + + public invalidate(key: string) { + if (!this.storage.has(key)) { + return; + } + + this.storage.delete(key); + } +} diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts index a097f6b..765f026 100644 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts @@ -1,7 +1,7 @@ import prexit from "prexit"; import { ulid } from "ulid"; import type { Config } from "../../config/config"; -import { getEventBus, type IEventBus } from "../events/EventBus"; +import { eventBus } from "../events/EventBus"; import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor"; import { createMessageGenerator } from "./createMessageGenerator"; import type { @@ -16,14 +16,12 @@ import type { export class ClaudeCodeTaskController { private claudeCode: ClaudeCodeExecutor; private tasks: ClaudeCodeTask[] = []; - private eventBus: IEventBus; private config: Config; private pendingPermissionRequests: Map = new Map(); private permissionResponses: Map = new Map(); constructor(config: Config) { this.claudeCode = new ClaudeCodeExecutor(); - this.eventBus = getEventBus(); this.config = config; prexit(() => { @@ -85,7 +83,7 @@ export class ClaudeCodeTaskController { ); // Emit event to notify UI - this.eventBus.emit("permissionRequested", { + eventBus.emit("permissionRequested", { permissionRequest, }); @@ -389,7 +387,7 @@ export class ClaudeCodeTaskController { } if (task.status === "paused" || task.status === "running") { - this.eventBus.emit("taskChanged", { + eventBus.emit("taskChanged", { aliveTasks: this.aliveTasks, changed: task, }); diff --git a/src/server/service/events/EventBus.ts b/src/server/service/events/EventBus.ts index 42fa9e4..9bc0a2b 100644 --- a/src/server/service/events/EventBus.ts +++ b/src/server/service/events/EventBus.ts @@ -36,12 +36,4 @@ class EventBus { } } -// singleton -let eventBus: EventBus | null = null; - -export const getEventBus = () => { - eventBus ??= new EventBus(); - return eventBus; -}; - -export type IEventBus = ReturnType; +export const eventBus = new EventBus(); diff --git a/src/server/service/events/adaptInternalEventToSSE.ts b/src/server/service/events/adaptInternalEventToSSE.ts index e4bf7c8..9e53c41 100644 --- a/src/server/service/events/adaptInternalEventToSSE.ts +++ b/src/server/service/events/adaptInternalEventToSSE.ts @@ -1,5 +1,5 @@ import type { SSEStreamingApi } from "hono/streaming"; -import { getEventBus } from "./EventBus"; +import { eventBus } from "./EventBus"; import type { InternalEventDeclaration } from "./InternalEventDeclaration"; import { writeTypeSafeSSE } from "./typeSafeSSE"; @@ -14,8 +14,6 @@ export const adaptInternalEventToSSE = ( console.log("SSE connection started"); - const eventBus = getEventBus(); - const stream = writeTypeSafeSSE(rawStream); const abortController = new AbortController(); diff --git a/src/server/service/events/fileWatcher.ts b/src/server/service/events/fileWatcher.ts index 290de2c..0871678 100644 --- a/src/server/service/events/fileWatcher.ts +++ b/src/server/service/events/fileWatcher.ts @@ -1,7 +1,7 @@ import { type FSWatcher, watch } from "node:fs"; import z from "zod"; import { claudeProjectsDirPath } from "../paths"; -import { getEventBus, type IEventBus } from "./EventBus"; +import { eventBus } from "./EventBus"; const fileRegExp = /(?.*?)\/(?.*?)\.jsonl/; const fileRegExpGroupSchema = z.object({ @@ -13,11 +13,6 @@ export class FileWatcherService { private isWatching = false; private watcher: FSWatcher | null = null; private projectWatchers: Map = new Map(); - private eventBus: IEventBus; - - constructor() { - this.eventBus = getEventBus(); - } public startWatching(): void { if (this.isWatching) return; @@ -42,13 +37,13 @@ export class FileWatcherService { if (eventType === "change") { // セッションファイルの中身が変更されている - this.eventBus.emit("sessionChanged", { + eventBus.emit("sessionChanged", { projectId, sessionId, }); } else if (eventType === "rename") { // セッションファイルの追加/削除 - this.eventBus.emit("sessionListChanged", { + eventBus.emit("sessionListChanged", { projectId, }); } else { @@ -75,13 +70,4 @@ export class FileWatcherService { } } -// シングルトンインスタンス -let watcherInstance: FileWatcherService | null = null; - -export const getFileWatcher = (): FileWatcherService => { - if (!watcherInstance) { - console.log("Creating new FileWatcher instance"); - watcherInstance = new FileWatcherService(); - } - return watcherInstance; -}; +export const fileWatcher = new FileWatcherService(); diff --git a/src/server/service/parseCommandXml.ts b/src/server/service/parseCommandXml.ts index 18a436c..fa7dca5 100644 --- a/src/server/service/parseCommandXml.ts +++ b/src/server/service/parseCommandXml.ts @@ -7,21 +7,24 @@ const matchSchema = z.object({ content: z.string(), }); -export type ParsedCommand = - | { - kind: "command"; - commandName: string; - commandArgs?: string; - commandMessage?: string; - } - | { - kind: "local-command"; - stdout: string; - } - | { - kind: "text"; - content: string; - }; +export const parsedCommandSchema = z.union([ + z.object({ + kind: z.literal("command"), + commandName: z.string(), + commandArgs: z.string().optional(), + commandMessage: z.string().optional(), + }), + z.object({ + kind: z.literal("local-command"), + stdout: z.string(), + }), + z.object({ + kind: z.literal("text"), + content: z.string(), + }), +]); + +export type ParsedCommand = z.infer; export const parseCommandXml = (content: string): ParsedCommand => { const matches = Array.from(content.matchAll(regExp)) diff --git a/src/server/service/paths.ts b/src/server/service/paths.ts index 0344968..4a89509 100644 --- a/src/server/service/paths.ts +++ b/src/server/service/paths.ts @@ -18,3 +18,9 @@ export const claudeCommandsDirPath = resolve( globalClaudeDirectoryPath, "commands", ); + +export const claudeCodeViewerCacheDirPath = resolve( + homedir(), + ".claude-code-viewer", + "cache", +); diff --git a/src/server/service/project/ProjectRepository.ts b/src/server/service/project/ProjectRepository.ts new file mode 100644 index 0000000..d6d7055 --- /dev/null +++ b/src/server/service/project/ProjectRepository.ts @@ -0,0 +1,75 @@ +import { existsSync } from "node:fs"; +import { access, constants, readdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { claudeProjectsDirPath } from "../paths"; +import type { Project } from "../types"; +import { decodeProjectId, encodeProjectId } from "./id"; +import { projectMetaStorage } from "./projectMetaStorage"; + +export class ProjectRepository { + public async getProject(projectId: string): Promise<{ project: Project }> { + const fullPath = decodeProjectId(projectId); + if (!existsSync(fullPath)) { + throw new Error("Project not found"); + } + + const meta = await projectMetaStorage.getProjectMeta(projectId); + + return { + project: { + id: projectId, + claudeProjectPath: fullPath, + meta, + }, + }; + } + + public async getProjects(): Promise<{ projects: Project[] }> { + try { + // Check if the claude projects directory exists + await access(claudeProjectsDirPath, constants.F_OK); + } catch (_error) { + // Directory doesn't exist, return empty array + console.warn( + `Claude projects directory not found at ${claudeProjectsDirPath}`, + ); + return { projects: [] }; + } + + try { + const dirents = await readdir(claudeProjectsDirPath, { + withFileTypes: true, + }); + const projects = await Promise.all( + dirents + .filter((d) => d.isDirectory()) + .map(async (d) => { + const fullPath = resolve(claudeProjectsDirPath, d.name); + const id = encodeProjectId(fullPath); + + return { + id, + claudeProjectPath: fullPath, + meta: await projectMetaStorage.getProjectMeta(id), + }; + }), + ); + + return { + projects: projects.sort((a, b) => { + return ( + (b.meta.lastModifiedAt + ? new Date(b.meta.lastModifiedAt).getTime() + : 0) - + (a.meta.lastModifiedAt + ? new Date(a.meta.lastModifiedAt).getTime() + : 0) + ); + }), + }; + } catch (error) { + console.error("Error reading projects:", error); + return { projects: [] }; + } + } +} diff --git a/src/server/service/project/getProject.ts b/src/server/service/project/getProject.ts deleted file mode 100644 index 4af646d..0000000 --- a/src/server/service/project/getProject.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { existsSync } from "node:fs"; - -import type { Project } from "../types"; -import { getProjectMeta } from "./getProjectMeta"; -import { decodeProjectId } from "./id"; - -export const getProject = async ( - projectId: string, -): Promise<{ project: Project }> => { - const fullPath = decodeProjectId(projectId); - if (!existsSync(fullPath)) { - throw new Error("Project not found"); - } - - const meta = await getProjectMeta(fullPath); - - return { - project: { - id: projectId, - claudeProjectPath: fullPath, - meta, - }, - }; -}; diff --git a/src/server/service/project/getProjectMeta.ts b/src/server/service/project/getProjectMeta.ts deleted file mode 100644 index 7867f6d..0000000 --- a/src/server/service/project/getProjectMeta.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { statSync } from "node:fs"; -import { readdir, readFile } from "node:fs/promises"; -import { basename, resolve } from "node:path"; - -import { parseJsonl } from "../parseJsonl"; -import type { ProjectMeta } from "../types"; - -const projectPathCache = new Map(); - -const extractProjectPathFromJsonl = async (filePath: string) => { - const cached = projectPathCache.get(filePath); - if (cached !== undefined) { - return cached; - } - - const content = await readFile(filePath, "utf-8"); - const lines = content.split("\n"); - - let cwd: string | null = null; - - for (const line of lines) { - const conversation = parseJsonl(line).at(0); - - if ( - conversation === undefined || - conversation.type === "summary" || - conversation.type === "x-error" - ) { - continue; - } - - cwd = conversation.cwd; - - break; - } - - if (cwd !== null) { - projectPathCache.set(filePath, cwd); - } - - return cwd; -}; - -export const getProjectMeta = async ( - claudeProjectPath: string, -): Promise => { - const dirents = await readdir(claudeProjectPath, { withFileTypes: true }); - const files = dirents - .filter((d) => d.isFile() && d.name.endsWith(".jsonl")) - .map( - (d) => - ({ - fullPath: resolve(claudeProjectPath, d.name), - stats: statSync(resolve(claudeProjectPath, d.name)), - }) as const, - ) - .sort((a, b) => { - return a.stats.ctime.getTime() - b.stats.ctime.getTime(); - }); - - const lastModifiedUnixTime = files.at(-1)?.stats.ctime.getTime(); - - let projectPath: string | null = null; - - for (const file of files) { - projectPath = await extractProjectPathFromJsonl(file.fullPath); - - if (projectPath === null) { - continue; - } - - break; - } - - const projectMeta: ProjectMeta = { - projectName: projectPath ? basename(projectPath) : null, - projectPath, - lastModifiedAt: lastModifiedUnixTime - ? new Date(lastModifiedUnixTime) - : null, - sessionCount: files.length, - }; - - return projectMeta; -}; diff --git a/src/server/service/project/getProjects.ts b/src/server/service/project/getProjects.ts deleted file mode 100644 index 9396033..0000000 --- a/src/server/service/project/getProjects.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { constants } from "node:fs"; -import { access, readdir } from "node:fs/promises"; -import { resolve } from "node:path"; -import { claudeProjectsDirPath } from "../paths"; -import type { Project } from "../types"; -import { getProjectMeta } from "./getProjectMeta"; -import { encodeProjectId } from "./id"; - -export const getProjects = async (): Promise<{ projects: Project[] }> => { - try { - // Check if the claude projects directory exists - await access(claudeProjectsDirPath, constants.F_OK); - } catch (_error) { - // Directory doesn't exist, return empty array - console.warn( - `Claude projects directory not found at ${claudeProjectsDirPath}`, - ); - return { projects: [] }; - } - - try { - const dirents = await readdir(claudeProjectsDirPath, { - withFileTypes: true, - }); - const projects = await Promise.all( - dirents - .filter((d) => d.isDirectory()) - .map(async (d) => { - const fullPath = resolve(claudeProjectsDirPath, d.name); - const id = encodeProjectId(fullPath); - - return { - id, - claudeProjectPath: fullPath, - meta: await getProjectMeta(fullPath), - }; - }), - ); - - return { - projects: projects.sort((a, b) => { - return ( - (b.meta.lastModifiedAt?.getTime() ?? 0) - - (a.meta.lastModifiedAt?.getTime() ?? 0) - ); - }), - }; - } catch (error) { - console.error("Error reading projects:", error); - return { projects: [] }; - } -}; diff --git a/src/server/service/project/projectMetaStorage.ts b/src/server/service/project/projectMetaStorage.ts new file mode 100644 index 0000000..63d616f --- /dev/null +++ b/src/server/service/project/projectMetaStorage.ts @@ -0,0 +1,109 @@ +import { statSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { z } from "zod"; +import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; +import { parseJsonl } from "../parseJsonl"; +import type { ProjectMeta } from "../types"; +import { decodeProjectId } from "./id"; +import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; + +class ProjectMetaStorage { + private projectPathCache = FileCacheStorage.load( + "project-path-cache", + z.string().nullable() + ); + private projectMetaCache = new InMemoryCacheStorage(); + + public async getProjectMeta(projectId: string): Promise { + const cached = this.projectMetaCache.get(projectId); + if (cached !== undefined) { + return cached; + } + + const claudeProjectPath = decodeProjectId(projectId); + + const dirents = await readdir(claudeProjectPath, { withFileTypes: true }); + const files = dirents + .filter((d) => d.isFile() && d.name.endsWith(".jsonl")) + .map( + (d) => + ({ + fullPath: resolve(claudeProjectPath, d.name), + stats: statSync(resolve(claudeProjectPath, d.name)), + } as const) + ) + .sort((a, b) => { + return a.stats.mtime.getTime() - b.stats.mtime.getTime(); + }); + + const lastModifiedUnixTime = files.at(-1)?.stats.mtime.getTime(); + + let projectPath: string | null = null; + + for (const file of files) { + projectPath = await this.extractProjectPathFromJsonl(file.fullPath); + + if (projectPath === null) { + continue; + } + + break; + } + + const projectMeta: ProjectMeta = { + projectName: projectPath ? basename(projectPath) : null, + projectPath, + lastModifiedAt: lastModifiedUnixTime + ? new Date(lastModifiedUnixTime).toISOString() + : null, + sessionCount: files.length, + }; + + this.projectMetaCache.save(projectId, projectMeta); + + return projectMeta; + } + + public invalidateProject(projectId: string) { + this.projectMetaCache.invalidate(projectId); + } + + private async extractProjectPathFromJsonl( + filePath: string + ): Promise { + const cached = this.projectPathCache.get(filePath); + if (cached !== undefined) { + return cached; + } + + const content = await readFile(filePath, "utf-8"); + const lines = content.split("\n"); + + let cwd: string | null = null; + + for (const line of lines) { + const conversation = parseJsonl(line).at(0); + + if ( + conversation === undefined || + conversation.type === "summary" || + conversation.type === "x-error" + ) { + continue; + } + + cwd = conversation.cwd; + + break; + } + + if (cwd !== null) { + this.projectPathCache.save(filePath, cwd); + } + + return cwd; + } +} + +export const projectMetaStorage = new ProjectMetaStorage(); diff --git a/src/server/service/schema.ts b/src/server/service/schema.ts new file mode 100644 index 0000000..3060cf5 --- /dev/null +++ b/src/server/service/schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { parsedCommandSchema } from "./parseCommandXml"; + +export const projectMetaSchema = z.object({ + projectName: z.string().nullable(), + projectPath: z.string().nullable(), + lastModifiedAt: z.string().nullable(), + sessionCount: z.number(), +}); + +export const sessionMetaSchema = z.object({ + messageCount: z.number(), + firstCommand: parsedCommandSchema.nullable(), + lastModifiedAt: z.string().nullable(), +}); diff --git a/src/server/service/session/SessionRepository.ts b/src/server/service/session/SessionRepository.ts new file mode 100644 index 0000000..7242025 --- /dev/null +++ b/src/server/service/session/SessionRepository.ts @@ -0,0 +1,69 @@ +import { readdir, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { parseJsonl } from "../parseJsonl"; +import { decodeProjectId } from "../project/id"; +import type { Session, SessionDetail } from "../types"; +import { decodeSessionId, encodeSessionId } from "./id"; +import { sessionMetaStorage } from "./sessionMetaStorage"; + +const getTime = (date: string | null) => { + if (date === null) return 0; + return new Date(date).getTime(); +}; + +export class SessionRepository { + public async getSession( + projectId: string, + sessionId: string, + ): Promise<{ + session: SessionDetail; + }> { + const sessionPath = decodeSessionId(projectId, sessionId); + const content = await readFile(sessionPath, "utf-8"); + + const conversations = parseJsonl(content); + + const sessionDetail: SessionDetail = { + id: sessionId, + jsonlFilePath: sessionPath, + meta: await sessionMetaStorage.getSessionMeta(projectId, sessionId), + conversations, + }; + + return { + session: sessionDetail, + }; + } + + public async getSessions( + projectId: string, + ): Promise<{ sessions: Session[] }> { + try { + const claudeProjectPath = decodeProjectId(projectId); + const dirents = await readdir(claudeProjectPath, { withFileTypes: true }); + const sessions = await Promise.all( + dirents + .filter((d) => d.isFile() && d.name.endsWith(".jsonl")) + .map(async (d) => ({ + id: encodeSessionId(resolve(claudeProjectPath, d.name)), + jsonlFilePath: resolve(claudeProjectPath, d.name), + meta: await sessionMetaStorage.getSessionMeta( + projectId, + encodeSessionId(resolve(claudeProjectPath, d.name)), + ), + })), + ); + + return { + sessions: sessions.sort((a, b) => { + return ( + getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt) + ); + }), + }; + } catch (error) { + console.warn(`Failed to read sessions for project ${projectId}:`, error); + return { sessions: [] }; + } + } +} diff --git a/src/server/service/session/getSession.ts b/src/server/service/session/getSession.ts deleted file mode 100644 index 7c7e612..0000000 --- a/src/server/service/session/getSession.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { parseJsonl } from "../parseJsonl"; -import { decodeProjectId } from "../project/id"; -import type { SessionDetail } from "../types"; -import { getSessionMeta } from "./getSessionMeta"; - -export const getSession = async ( - projectId: string, - sessionId: string, -): Promise<{ - session: SessionDetail; -}> => { - const projectPath = decodeProjectId(projectId); - const sessionPath = resolve(projectPath, `${sessionId}.jsonl`); - - const content = await readFile(sessionPath, "utf-8"); - - const conversations = parseJsonl(content); - - const sessionDetail: SessionDetail = { - id: sessionId, - jsonlFilePath: sessionPath, - meta: await getSessionMeta(sessionPath), - conversations, - }; - - return { - session: sessionDetail, - }; -}; diff --git a/src/server/service/session/getSessionMeta.ts b/src/server/service/session/getSessionMeta.ts deleted file mode 100644 index 2ca9aac..0000000 --- a/src/server/service/session/getSessionMeta.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { statSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { type ParsedCommand, parseCommandXml } from "../parseCommandXml"; -import { parseJsonl } from "../parseJsonl"; -import type { SessionMeta } from "../types"; - -const firstCommandCache = new Map(); - -const ignoreCommands = [ - "/clear", - "/login", - "/logout", - "/exit", - "/mcp", - "/memory", -]; - -const getFirstCommand = ( - jsonlFilePath: string, - lines: string[], -): ParsedCommand | null => { - const cached = firstCommandCache.get(jsonlFilePath); - if (cached !== undefined) { - return cached; - } - - let firstCommand: ParsedCommand | null = null; - - for (const line of lines) { - const conversation = parseJsonl(line).at(0); - - if (conversation === undefined || conversation.type !== "user") { - continue; - } - - const firstUserText = - conversation === null - ? null - : typeof conversation.message.content === "string" - ? conversation.message.content - : (() => { - const firstContent = conversation.message.content.at(0); - if (firstContent === undefined) return null; - if (typeof firstContent === "string") return firstContent; - if (firstContent.type === "text") return firstContent.text; - return null; - })(); - - if (firstUserText === null) { - continue; - } - - if ( - firstUserText === - "Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to." - ) { - continue; - } - - const command = parseCommandXml(firstUserText); - if (command.kind === "local-command") { - continue; - } - - if ( - command.kind === "command" && - ignoreCommands.includes(command.commandName) - ) { - continue; - } - - firstCommand = command; - break; - } - - if (firstCommand !== null) { - firstCommandCache.set(jsonlFilePath, firstCommand); - } - - return firstCommand; -}; - -export const getSessionMeta = async ( - jsonlFilePath: string, -): Promise => { - const stats = statSync(jsonlFilePath); - const lastModifiedUnixTime = stats.ctime.getTime(); - - const content = await readFile(jsonlFilePath, "utf-8"); - const lines = content.split("\n"); - - const sessionMeta: SessionMeta = { - messageCount: lines.length, - firstCommand: getFirstCommand(jsonlFilePath, lines), - lastModifiedAt: lastModifiedUnixTime - ? new Date(lastModifiedUnixTime).toISOString() - : null, - }; - - return sessionMeta; -}; diff --git a/src/server/service/session/getSessions.ts b/src/server/service/session/getSessions.ts deleted file mode 100644 index 5927429..0000000 --- a/src/server/service/session/getSessions.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { readdir } from "node:fs/promises"; -import { basename, extname, resolve } from "node:path"; - -import { decodeProjectId } from "../project/id"; -import type { Session } from "../types"; -import { getSessionMeta } from "./getSessionMeta"; - -const getTime = (date: string | null) => { - if (date === null) return 0; - return new Date(date).getTime(); -}; - -export const getSessions = async ( - projectId: string, -): Promise<{ sessions: Session[] }> => { - const claudeProjectPath = decodeProjectId(projectId); - - try { - const dirents = await readdir(claudeProjectPath, { withFileTypes: true }); - const sessions = await Promise.all( - dirents - .filter((d) => d.isFile() && d.name.endsWith(".jsonl")) - .map(async (d): Promise => { - const fullPath = resolve(claudeProjectPath, d.name); - - return { - id: basename(fullPath, extname(fullPath)), - jsonlFilePath: fullPath, - meta: await getSessionMeta(fullPath), - }; - }), - ); - - return { - sessions: sessions.sort((a, b) => { - return getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt); - }), - }; - } catch (error) { - console.warn(`Failed to read sessions for project ${projectId}:`, error); - return { sessions: [] }; - } -}; diff --git a/src/server/service/session/id.ts b/src/server/service/session/id.ts new file mode 100644 index 0000000..203fdfe --- /dev/null +++ b/src/server/service/session/id.ts @@ -0,0 +1,11 @@ +import { basename, extname, resolve } from "node:path"; +import { decodeProjectId } from "../project/id"; + +export const encodeSessionId = (jsonlFilePath: string) => { + return basename(jsonlFilePath, extname(jsonlFilePath)); +}; + +export const decodeSessionId = (projectId: string, sessionId: string) => { + const projectPath = decodeProjectId(projectId); + return resolve(projectPath, `${sessionId}.jsonl`); +}; diff --git a/src/server/service/session/sessionMetaStorage.ts b/src/server/service/session/sessionMetaStorage.ts new file mode 100644 index 0000000..b1dc26a --- /dev/null +++ b/src/server/service/session/sessionMetaStorage.ts @@ -0,0 +1,131 @@ +import { statSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; +import { + type ParsedCommand, + parseCommandXml, + parsedCommandSchema, +} from "../parseCommandXml"; +import { parseJsonl } from "../parseJsonl"; +import { sessionMetaSchema } from "../schema"; +import type { SessionMeta } from "../types"; +import { decodeSessionId } from "./id"; +import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; + +const ignoreCommands = [ + "/clear", + "/login", + "/logout", + "/exit", + "/mcp", + "/memory", +]; + +class SessionMetaStorage { + private firstCommandCache = FileCacheStorage.load( + "first-command-cache", + parsedCommandSchema + ); + private sessionMetaCache = new InMemoryCacheStorage(); + + public async getSessionMeta( + projectId: string, + sessionId: string + ): Promise { + const cached = this.sessionMetaCache.get(sessionId); + if (cached !== undefined) { + return cached; + } + + const sessionPath = decodeSessionId(projectId, sessionId); + + const stats = statSync(sessionPath); + const lastModifiedUnixTime = stats.mtime.getTime(); + + const content = await readFile(sessionPath, "utf-8"); + const lines = content.split("\n"); + + const sessionMeta: SessionMeta = { + messageCount: lines.length, + firstCommand: this.getFirstCommand(sessionPath, lines), + lastModifiedAt: lastModifiedUnixTime + ? new Date(lastModifiedUnixTime).toISOString() + : null, + }; + + this.sessionMetaCache.save(sessionId, sessionMeta); + + return sessionMeta; + } + + private getFirstCommand = ( + jsonlFilePath: string, + lines: string[] + ): ParsedCommand | null => { + const cached = this.firstCommandCache.get(jsonlFilePath); + if (cached !== undefined) { + return cached; + } + + let firstCommand: ParsedCommand | null = null; + + for (const line of lines) { + const conversation = parseJsonl(line).at(0); + + if (conversation === undefined || conversation.type !== "user") { + continue; + } + + const firstUserText = + conversation === null + ? null + : typeof conversation.message.content === "string" + ? conversation.message.content + : (() => { + const firstContent = conversation.message.content.at(0); + if (firstContent === undefined) return null; + if (typeof firstContent === "string") return firstContent; + if (firstContent.type === "text") return firstContent.text; + return null; + })(); + + if (firstUserText === null) { + continue; + } + + if ( + firstUserText === + "Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to." + ) { + continue; + } + + const command = parseCommandXml(firstUserText); + if (command.kind === "local-command") { + continue; + } + + if ( + command.kind === "command" && + ignoreCommands.includes(command.commandName) + ) { + continue; + } + + firstCommand = command; + break; + } + + if (firstCommand !== null) { + this.firstCommandCache.save(jsonlFilePath, firstCommand); + } + + return firstCommand; + }; + + public invalidateSession(_projectId: string, sessionId: string) { + this.sessionMetaCache.invalidate(sessionId); + } +} + +export const sessionMetaStorage = new SessionMetaStorage(); diff --git a/src/server/service/types.ts b/src/server/service/types.ts index 19c229d..b5ca5d9 100644 --- a/src/server/service/types.ts +++ b/src/server/service/types.ts @@ -1,5 +1,6 @@ +import type { z } from "zod"; import type { Conversation } from "../../lib/conversation-schema"; -import type { ParsedCommand } from "./parseCommandXml"; +import type { projectMetaSchema, sessionMetaSchema } from "./schema"; export type Project = { id: string; @@ -7,12 +8,7 @@ export type Project = { meta: ProjectMeta; }; -export type ProjectMeta = { - projectName: string | null; - projectPath: string | null; - lastModifiedAt: Date | null; - sessionCount: number; -}; +export type ProjectMeta = z.infer; export type Session = { id: string; @@ -20,11 +16,7 @@ export type Session = { meta: SessionMeta; }; -export type SessionMeta = { - messageCount: number; - firstCommand: ParsedCommand | null; - lastModifiedAt: string | null; -}; +export type SessionMeta = z.infer; export type ErrorJsonl = { type: "x-error"; From 0259e71b4453f7d71bb94a11fbd59a71fb205a7b Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Wed, 15 Oct 2025 01:18:14 +0900 Subject: [PATCH 03/21] feat: improve interactivity by predict sessions --- .vscode/settings.json | 22 +++- .../components/chatForm/useChatMutations.ts | 12 +- src/server/hono/initialize.ts | 6 +- src/server/hono/route.ts | 3 - src/server/lib/storage/FileCacheStorage.ts | 2 +- .../lib/storage/InMemoryCacheStorage.ts | 2 - .../service/claude-code/ClaudeCodeExecutor.ts | 12 +- .../claude-code/ClaudeCodeTaskController.ts | 107 +++++++++++------- .../service/claude-code/ClaudeCodeVersion.ts | 4 + src/server/service/claude-code/types.ts | 5 +- src/server/service/project/id.ts | 6 + .../service/project/projectMetaStorage.ts | 8 +- .../session/PredictSessionsDatabase.ts | 34 ++++++ .../service/session/SessionRepository.ts | 22 +++- .../service/session/sessionMetaStorage.ts | 25 ++-- 15 files changed, 186 insertions(+), 84 deletions(-) create mode 100644 src/server/service/session/PredictSessionsDatabase.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 4e0d5b7..c3b651b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,7 +7,27 @@ "biome.enabled": true, // autofix "editor.formatOnSave": false, - "[typescript][typescriptreact][javascript][javascriptreact][json][jsonc][json][yaml]": { + "[typescript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { "editor.formatOnSave": true, "editor.defaultFormatter": "biomejs.biome" }, diff --git a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts index 88e0b2d..72d49ab 100644 --- a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts +++ b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts @@ -32,13 +32,7 @@ export const useNewChatMutation = ( }, onSuccess: async (response) => { onSuccess?.(); - router.push( - `/projects/${projectId}/sessions/${response.sessionId}` + - response.userMessageId !== - undefined - ? `#message-${response.userMessageId}` - : "", - ); + router.push(`/projects/${projectId}/sessions/${response.sessionId}`); }, }); }; @@ -70,9 +64,7 @@ export const useResumeChatMutation = (projectId: string, sessionId: string) => { }, onSuccess: async (response) => { if (sessionId !== response.sessionId) { - router.push( - `/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`, - ); + router.push(`/projects/${projectId}/sessions/${response.sessionId}`); } }, }); diff --git a/src/server/hono/initialize.ts b/src/server/hono/initialize.ts index d0d79a0..2b32e02 100644 --- a/src/server/hono/initialize.ts +++ b/src/server/hono/initialize.ts @@ -27,13 +27,13 @@ export const initialize = async (deps: { console.log("Initializing sessions cache"); const results = await Promise.all( - projects.map((project) => deps.sessionRepository.getSessions(project.id)) + projects.map((project) => deps.sessionRepository.getSessions(project.id)), ); console.log( `${results.reduce( (s, { sessions }) => s + sessions.length, - 0 - )} sessions cache initialized` + 0, + )} sessions cache initialized`, ); } catch { // do nothing diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index e033cc9..c437935 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -337,7 +337,6 @@ export const routes = async (app: HonoAppType) => { return c.json({ taskId: task.id, sessionId: task.sessionId, - userMessageId: task.userMessageId, }); }, ) @@ -373,7 +372,6 @@ export const routes = async (app: HonoAppType) => { return c.json({ taskId: task.id, sessionId: task.sessionId, - userMessageId: task.userMessageId, }); }, ) @@ -385,7 +383,6 @@ export const routes = async (app: HonoAppType) => { id: task.id, status: task.status, sessionId: task.sessionId, - userMessageId: task.userMessageId, }), ), }); diff --git a/src/server/lib/storage/FileCacheStorage.ts b/src/server/lib/storage/FileCacheStorage.ts index 34d71fc..ed333e9 100644 --- a/src/server/lib/storage/FileCacheStorage.ts +++ b/src/server/lib/storage/FileCacheStorage.ts @@ -13,7 +13,7 @@ export class FileCacheStorage { public static load( key: string, - schema: z.ZodType + schema: z.ZodType, ) { const instance = new FileCacheStorage(key); diff --git a/src/server/lib/storage/InMemoryCacheStorage.ts b/src/server/lib/storage/InMemoryCacheStorage.ts index 5433d07..8f19e5e 100644 --- a/src/server/lib/storage/InMemoryCacheStorage.ts +++ b/src/server/lib/storage/InMemoryCacheStorage.ts @@ -1,8 +1,6 @@ export class InMemoryCacheStorage { private storage = new Map(); - public constructor() {} - public get(key: string) { return this.storage.get(key); } diff --git a/src/server/service/claude-code/ClaudeCodeExecutor.ts b/src/server/service/claude-code/ClaudeCodeExecutor.ts index d3c1678..b9de1b2 100644 --- a/src/server/service/claude-code/ClaudeCodeExecutor.ts +++ b/src/server/service/claude-code/ClaudeCodeExecutor.ts @@ -23,13 +23,17 @@ export class ClaudeCodeExecutor { ); } - public get features() { + public get version() { + return this.claudeCodeVersion?.version; + } + + public get availableFeatures() { return { - enableToolApproval: + canUseTool: this.claudeCodeVersion?.greaterThanOrEqual( new ClaudeCodeVersion({ major: 1, minor: 0, patch: 82 }), ) ?? false, - extractUuidFromSDKMessage: + uuidOnSDKMessage: this.claudeCodeVersion?.greaterThanOrEqual( new ClaudeCodeVersion({ major: 1, minor: 0, patch: 86 }), ) ?? false, @@ -44,7 +48,7 @@ export class ClaudeCodeExecutor { options: { pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable, ...baseOptions, - ...(this.features.enableToolApproval ? { canUseTool } : {}), + ...(this.availableFeatures.canUseTool ? { canUseTool } : {}), }, }); } diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts index 765f026..f7ad96c 100644 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts @@ -2,6 +2,7 @@ import prexit from "prexit"; import { ulid } from "ulid"; import type { Config } from "../../config/config"; import { eventBus } from "../events/EventBus"; +import { predictSessionsDatabase } from "../session/PredictSessionsDatabase"; import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor"; import { createMessageGenerator } from "./createMessageGenerator"; import type { @@ -168,9 +169,20 @@ export class ClaudeCodeTaskController { ); if (existingTask) { + console.log( + `Alive task for session(id=${currentSession.sessionId}) continued.`, + ); const result = await this.continueTask(existingTask, message); return result; } else { + if (currentSession.sessionId === undefined) { + console.log(`New task started.`); + } else { + console.log( + `New task started for existing session(id=${currentSession.sessionId}).`, + ); + } + const result = await this.startTask(currentSession, message); return result; } @@ -188,7 +200,7 @@ export class ClaudeCodeTaskController { projectId: string; sessionId?: string; }, - message: string, + userMessage: string, ) { const { generateMessages, @@ -196,7 +208,7 @@ export class ClaudeCodeTaskController { setFirstMessagePromise, resolveFirstMessage, awaitFirstMessage, - } = createMessageGenerator(message); + } = createMessageGenerator(userMessage); const task: PendingClaudeCodeTask = { status: "pending", @@ -252,43 +264,62 @@ export class ClaudeCodeTaskController { }); } - // 初回の system message だとまだ history ファイルが作成されていないので - if (message.type === "user" || message.type === "assistant") { - // 本来は message.uuid の存在チェックをしたいが、古いバージョンでは存在しないことがある - if (!resolved) { - console.log( - "[DEBUG startTask] 10. Resolving task for first time", - ); - - const runningTask: RunningClaudeCodeTask = { - status: "running", - id: task.id, - projectId: task.projectId, - cwd: task.cwd, - generateMessages: task.generateMessages, - setNextMessage: task.setNextMessage, - resolveFirstMessage: task.resolveFirstMessage, - setFirstMessagePromise: task.setFirstMessagePromise, - awaitFirstMessage: task.awaitFirstMessage, - onMessageHandlers: task.onMessageHandlers, - userMessageId: message.uuid, - sessionId: message.session_id, - abortController: abortController, - }; - this.tasks.push(runningTask); - console.log( - "[DEBUG startTask] 11. About to call aliveTaskResolve", - ); - aliveTaskResolve(runningTask); - resolved = true; - console.log( - "[DEBUG startTask] 12. aliveTaskResolve called, resolved=true", - ); - } - - resolveFirstMessage(); + if ( + message.type === "system" && + message.subtype === "init" && + currentSession.sessionId === undefined + ) { + // because it takes time for the Claude Code file to be updated, simulate the message + predictSessionsDatabase.createPredictSession({ + id: message.session_id, + jsonlFilePath: message.session_id, + conversations: [ + { + type: "user", + message: { + role: "user", + content: userMessage, + }, + isSidechain: false, + userType: "external", + cwd: message.cwd, + sessionId: message.session_id, + version: this.claudeCode.version?.toString() ?? "unknown", + uuid: message.uuid, + timestamp: new Date().toISOString(), + parentUuid: null, + }, + ], + meta: { + firstCommand: null, + lastModifiedAt: new Date().toISOString(), + messageCount: 0, + }, + }); } + if (!resolved) { + const runningTask: RunningClaudeCodeTask = { + status: "running", + id: task.id, + projectId: task.projectId, + cwd: task.cwd, + generateMessages: task.generateMessages, + setNextMessage: task.setNextMessage, + resolveFirstMessage: task.resolveFirstMessage, + setFirstMessagePromise: task.setFirstMessagePromise, + awaitFirstMessage: task.awaitFirstMessage, + onMessageHandlers: task.onMessageHandlers, + sessionId: message.session_id, + abortController: abortController, + }; + this.tasks.push(runningTask); + aliveTaskResolve(runningTask); + resolved = true; + } + + resolveFirstMessage(); + await Promise.all( task.onMessageHandlers.map(async (onMessageHandler) => { await onMessageHandler(message); @@ -302,6 +333,7 @@ export class ClaudeCodeTaskController { }); resolved = true; setFirstMessagePromise(); + predictSessionsDatabase.deletePredictSession(currentTask.sessionId); } } @@ -372,7 +404,6 @@ export class ClaudeCodeTaskController { awaitFirstMessage: task.awaitFirstMessage, onMessageHandlers: task.onMessageHandlers, baseSessionId: task.baseSessionId, - userMessageId: task.userMessageId, }); } diff --git a/src/server/service/claude-code/ClaudeCodeVersion.ts b/src/server/service/claude-code/ClaudeCodeVersion.ts index db3b4f3..9193a7e 100644 --- a/src/server/service/claude-code/ClaudeCodeVersion.ts +++ b/src/server/service/claude-code/ClaudeCodeVersion.ts @@ -43,6 +43,10 @@ export class ClaudeCodeVersion { return this.version.patch; } + public toString() { + return `${this.major}.${this.minor}.${this.patch}`; + } + public equals(other: ClaudeCodeVersion) { return ( this.version.major === other.version.major && diff --git a/src/server/service/claude-code/types.ts b/src/server/service/claude-code/types.ts index aee0526..c920d14 100644 --- a/src/server/service/claude-code/types.ts +++ b/src/server/service/claude-code/types.ts @@ -20,21 +20,18 @@ export type PendingClaudeCodeTask = BaseClaudeCodeTask & { export type RunningClaudeCodeTask = BaseClaudeCodeTask & { status: "running"; sessionId: string; - userMessageId: string | undefined; abortController: AbortController; }; export type PausedClaudeCodeTask = BaseClaudeCodeTask & { status: "paused"; sessionId: string; - userMessageId: string | undefined; abortController: AbortController; }; type CompletedClaudeCodeTask = BaseClaudeCodeTask & { status: "completed"; sessionId: string; - userMessageId: string | undefined; abortController: AbortController; resolveFirstMessage: () => void; }; @@ -56,7 +53,7 @@ export type AliveClaudeCodeTask = RunningClaudeCodeTask | PausedClaudeCodeTask; export type SerializableAliveTask = Pick< AliveClaudeCodeTask, - "id" | "status" | "sessionId" | "userMessageId" + "id" | "status" | "sessionId" >; export type PermissionRequest = { diff --git a/src/server/service/project/id.ts b/src/server/service/project/id.ts index 5b9f64c..f52d713 100644 --- a/src/server/service/project/id.ts +++ b/src/server/service/project/id.ts @@ -1,3 +1,5 @@ +import { dirname } from "node:path"; + export const encodeProjectId = (fullPath: string) => { return Buffer.from(fullPath).toString("base64url"); }; @@ -5,3 +7,7 @@ export const encodeProjectId = (fullPath: string) => { export const decodeProjectId = (id: string) => { return Buffer.from(id, "base64url").toString("utf-8"); }; + +export const encodeProjectIdFromSessionFilePath = (sessionFilePath: string) => { + return encodeProjectId(dirname(sessionFilePath)); +}; diff --git a/src/server/service/project/projectMetaStorage.ts b/src/server/service/project/projectMetaStorage.ts index 63d616f..e12814c 100644 --- a/src/server/service/project/projectMetaStorage.ts +++ b/src/server/service/project/projectMetaStorage.ts @@ -3,15 +3,15 @@ import { readdir, readFile } from "node:fs/promises"; import { basename, resolve } from "node:path"; import { z } from "zod"; import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; +import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; import { parseJsonl } from "../parseJsonl"; import type { ProjectMeta } from "../types"; import { decodeProjectId } from "./id"; -import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; class ProjectMetaStorage { private projectPathCache = FileCacheStorage.load( "project-path-cache", - z.string().nullable() + z.string().nullable(), ); private projectMetaCache = new InMemoryCacheStorage(); @@ -31,7 +31,7 @@ class ProjectMetaStorage { ({ fullPath: resolve(claudeProjectPath, d.name), stats: statSync(resolve(claudeProjectPath, d.name)), - } as const) + }) as const, ) .sort((a, b) => { return a.stats.mtime.getTime() - b.stats.mtime.getTime(); @@ -70,7 +70,7 @@ class ProjectMetaStorage { } private async extractProjectPathFromJsonl( - filePath: string + filePath: string, ): Promise { const cached = this.projectPathCache.get(filePath); if (cached !== undefined) { diff --git a/src/server/service/session/PredictSessionsDatabase.ts b/src/server/service/session/PredictSessionsDatabase.ts new file mode 100644 index 0000000..161b273 --- /dev/null +++ b/src/server/service/session/PredictSessionsDatabase.ts @@ -0,0 +1,34 @@ +import { encodeProjectIdFromSessionFilePath } from "../project/id"; +import type { Session, SessionDetail } from "../types"; + +/** + * For interactively experience, handle sessions not already persisted to the filesystem. + */ +class PredictSessionsDatabase { + private storage = new Map(); + + public getPredictSessions(projectId: string): Session[] { + return Array.from(this.storage.values()).filter( + ({ jsonlFilePath }) => + encodeProjectIdFromSessionFilePath(jsonlFilePath) === projectId, + ); + } + + public getPredictSession(sessionId: string): SessionDetail { + const session = this.storage.get(sessionId); + if (!session) { + throw new Error("Session not found"); + } + return session; + } + + public createPredictSession(session: SessionDetail) { + this.storage.set(session.id, session); + } + + public deletePredictSession(sessionId: string) { + this.storage.delete(sessionId); + } +} + +export const predictSessionsDatabase = new PredictSessionsDatabase(); diff --git a/src/server/service/session/SessionRepository.ts b/src/server/service/session/SessionRepository.ts index 7242025..2fc9c28 100644 --- a/src/server/service/session/SessionRepository.ts +++ b/src/server/service/session/SessionRepository.ts @@ -1,9 +1,11 @@ +import { existsSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { parseJsonl } from "../parseJsonl"; import { decodeProjectId } from "../project/id"; import type { Session, SessionDetail } from "../types"; import { decodeSessionId, encodeSessionId } from "./id"; +import { predictSessionsDatabase } from "./PredictSessionsDatabase"; import { sessionMetaStorage } from "./sessionMetaStorage"; const getTime = (date: string | null) => { @@ -19,6 +21,17 @@ export class SessionRepository { session: SessionDetail; }> { const sessionPath = decodeSessionId(projectId, sessionId); + if (!existsSync(sessionPath)) { + const predictSession = + predictSessionsDatabase.getPredictSession(sessionId); + if (predictSession) { + return { + session: predictSession, + }; + } + + throw new Error("Session not found"); + } const content = await readFile(sessionPath, "utf-8"); const conversations = parseJsonl(content); @@ -53,9 +66,16 @@ export class SessionRepository { ), })), ); + const sessionMap = new Map( + sessions.map((session) => [session.id, session]), + ); + + const predictSessions = predictSessionsDatabase + .getPredictSessions(projectId) + .filter((session) => !sessionMap.has(session.id)); return { - sessions: sessions.sort((a, b) => { + sessions: [...predictSessions, ...sessions].sort((a, b) => { return ( getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt) ); diff --git a/src/server/service/session/sessionMetaStorage.ts b/src/server/service/session/sessionMetaStorage.ts index b1dc26a..975db76 100644 --- a/src/server/service/session/sessionMetaStorage.ts +++ b/src/server/service/session/sessionMetaStorage.ts @@ -1,16 +1,15 @@ import { statSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; +import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; import { type ParsedCommand, parseCommandXml, parsedCommandSchema, } from "../parseCommandXml"; import { parseJsonl } from "../parseJsonl"; -import { sessionMetaSchema } from "../schema"; import type { SessionMeta } from "../types"; import { decodeSessionId } from "./id"; -import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; const ignoreCommands = [ "/clear", @@ -24,13 +23,13 @@ const ignoreCommands = [ class SessionMetaStorage { private firstCommandCache = FileCacheStorage.load( "first-command-cache", - parsedCommandSchema + parsedCommandSchema, ); private sessionMetaCache = new InMemoryCacheStorage(); public async getSessionMeta( projectId: string, - sessionId: string + sessionId: string, ): Promise { const cached = this.sessionMetaCache.get(sessionId); if (cached !== undefined) { @@ -60,7 +59,7 @@ class SessionMetaStorage { private getFirstCommand = ( jsonlFilePath: string, - lines: string[] + lines: string[], ): ParsedCommand | null => { const cached = this.firstCommandCache.get(jsonlFilePath); if (cached !== undefined) { @@ -80,14 +79,14 @@ class SessionMetaStorage { conversation === null ? null : typeof conversation.message.content === "string" - ? conversation.message.content - : (() => { - const firstContent = conversation.message.content.at(0); - if (firstContent === undefined) return null; - if (typeof firstContent === "string") return firstContent; - if (firstContent.type === "text") return firstContent.text; - return null; - })(); + ? conversation.message.content + : (() => { + const firstContent = conversation.message.content.at(0); + if (firstContent === undefined) return null; + if (typeof firstContent === "string") return firstContent; + if (firstContent.type === "text") return firstContent.text; + return null; + })(); if (firstUserText === null) { continue; From d322db543c6ef7e014598806ea24f09860f628d7 Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Wed, 15 Oct 2025 02:25:26 +0900 Subject: [PATCH 04/21] perf: added pagination for session in order to improve large project's performance issue --- .../[projectId]/components/ProjectPage.tsx | 37 +++- .../projects/[projectId]/hooks/useProject.ts | 12 +- src/app/projects/[projectId]/page.tsx | 9 +- .../components/SessionPageContent.tsx | 9 +- .../sessionSidebar/MobileSidebar.tsx | 14 +- .../sessionSidebar/SessionSidebar.tsx | 14 +- .../components/sessionSidebar/SessionsTab.tsx | 39 +++- src/app/projects/components/ProjectList.tsx | 4 +- src/lib/api/queries.ts | 7 +- src/server/hono/initialize.ts | 20 +- src/server/hono/route.ts | 176 +++++++++--------- .../service/claude-code/ClaudeCodeExecutor.ts | 8 +- .../claude-code/ClaudeCodeTaskController.ts | 28 +-- .../service/project/ProjectRepository.ts | 12 +- .../service/project/projectMetaStorage.ts | 5 - src/server/service/schema.ts | 2 - .../session/PredictSessionsDatabase.ts | 8 +- .../service/session/SessionRepository.ts | 95 +++++++--- .../service/session/sessionMetaStorage.ts | 7 - src/server/service/types.ts | 2 + 20 files changed, 316 insertions(+), 192 deletions(-) diff --git a/src/app/projects/[projectId]/components/ProjectPage.tsx b/src/app/projects/[projectId]/components/ProjectPage.tsx index 53a90de..417f0de 100644 --- a/src/app/projects/[projectId]/components/ProjectPage.tsx +++ b/src/app/projects/[projectId]/components/ProjectPage.tsx @@ -25,24 +25,30 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import { projectDetailQuery } from "../../../../lib/api/queries"; import { useConfig } from "../../../hooks/useConfig"; import { useProject } from "../hooks/useProject"; import { firstCommandToTitle } from "../services/firstCommandToTitle"; import { NewChatModal } from "./newChat/NewChatModal"; export const ProjectPageContent = ({ projectId }: { projectId: string }) => { - const { - data: { project, sessions }, - } = useProject(projectId); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + useProject(projectId); const { config } = useConfig(); const queryClient = useQueryClient(); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + // Flatten all pages to get project and sessions + const project = data.pages.at(0)?.project; + const sessions = data.pages.flatMap((page) => page.sessions); + + if (!project) { + throw new Error("Unreachable: Project must be defined."); + } + // biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed useEffect(() => { void queryClient.invalidateQueries({ - queryKey: projectDetailQuery(projectId).queryKey, + queryKey: ["projects", projectId], }); }, [config.hideNoUserMessageSession, config.unifySameTitleSession]); @@ -170,10 +176,8 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {

Last modified:{" "} - {session.meta.lastModifiedAt - ? new Date( - session.meta.lastModifiedAt, - ).toLocaleDateString() + {session.lastModifiedAt + ? new Date(session.lastModifiedAt).toLocaleDateString() : ""}

@@ -195,6 +199,21 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => { ))} )} + + {/* Load More Button */} + {sessions.length > 0 && hasNextPage && ( +

+ +
+ )} diff --git a/src/app/projects/[projectId]/hooks/useProject.ts b/src/app/projects/[projectId]/hooks/useProject.ts index 7ea840d..9036b75 100644 --- a/src/app/projects/[projectId]/hooks/useProject.ts +++ b/src/app/projects/[projectId]/hooks/useProject.ts @@ -1,10 +1,14 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; import { projectDetailQuery } from "../../../../lib/api/queries"; export const useProject = (projectId: string) => { - return useSuspenseQuery({ - queryKey: projectDetailQuery(projectId).queryKey, - queryFn: projectDetailQuery(projectId).queryFn, + return useSuspenseInfiniteQuery({ + queryKey: ["projects", projectId], + queryFn: async ({ pageParam }) => { + return await projectDetailQuery(projectId, pageParam).queryFn(); + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, refetchOnReconnect: true, }); }; diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index ec66435..d8b841b 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -15,9 +15,12 @@ export default async function ProjectPage({ params }: ProjectPageProps) { const queryClient = new QueryClient(); - await queryClient.prefetchQuery({ - queryKey: projectDetailQuery(projectId).queryKey, - queryFn: projectDetailQuery(projectId).queryFn, + await queryClient.prefetchInfiniteQuery({ + queryKey: ["projects", projectId], + queryFn: async ({ pageParam }) => { + return await projectDetailQuery(projectId, pageParam).queryFn(); + }, + initialPageParam: undefined as string | undefined, }); return ( diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx index c55d0ae..3949211 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx @@ -35,7 +35,9 @@ export const SessionPageContent: FC<{ projectId, sessionId, ); - const { data: project } = useProject(projectId); + const { data: projectData } = useProject(projectId); + // biome-ignore lint/style/noNonNullAssertion: useSuspenseInfiniteQuery guarantees at least one page + const project = projectData.pages[0]!.project; const abortTask = useMutation({ mutationFn: async (sessionId: string) => { @@ -111,7 +113,7 @@ export const SessionPageContent: FC<{
- {project?.project.claudeProjectPath && ( + {project?.claudeProjectPath && ( - {project.project.meta.projectPath ?? - project.project.claudeProjectPath} + {project.meta.projectPath ?? project.claudeProjectPath} )} diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx index 2900418..d49066f 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx @@ -24,8 +24,12 @@ export const MobileSidebar: FC = ({ onClose, }) => { const { - data: { sessions }, + data: projectData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, } = useProject(projectId); + const sessions = projectData.pages.flatMap((page) => page.sessions); const [activeTab, setActiveTab] = useState<"sessions" | "mcp" | "settings">( "sessions", ); @@ -71,9 +75,15 @@ export const MobileSidebar: FC = ({ case "sessions": return ( ({ + ...session, + lastModifiedAt: new Date(session.lastModifiedAt), + }))} currentSessionId={currentSessionId} projectId={projectId} + hasNextPage={hasNextPage} + isFetchingNextPage={isFetchingNextPage} + onLoadMore={() => fetchNextPage()} /> ); case "mcp": diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx index cb926a5..5b4a9f0 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx @@ -30,8 +30,12 @@ export const SessionSidebar: FC<{ }) => { const router = useRouter(); const { - data: { sessions }, + data: projectData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, } = useProject(projectId); + const sessions = projectData.pages.flatMap((page) => page.sessions); const [activeTab, setActiveTab] = useState<"sessions" | "mcp" | "settings">( "sessions", ); @@ -53,9 +57,15 @@ export const SessionSidebar: FC<{ case "sessions": return ( ({ + ...session, + lastModifiedAt: new Date(session.lastModifiedAt), + }))} currentSessionId={currentSessionId} projectId={projectId} + hasNextPage={hasNextPage} + isFetchingNextPage={isFetchingNextPage} + onLoadMore={() => fetchNextPage()} /> ); case "mcp": diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx index 635ba32..221090b 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx @@ -16,7 +16,17 @@ export const SessionsTab: FC<{ sessions: Session[]; currentSessionId: string; projectId: string; -}> = ({ sessions, currentSessionId, projectId }) => { + hasNextPage?: boolean; + isFetchingNextPage?: boolean; + onLoadMore?: () => void; +}> = ({ + sessions, + currentSessionId, + projectId, + hasNextPage, + isFetchingNextPage, + onLoadMore, +}) => { const aliveTasks = useAtomValue(aliveTasksAtom); // Sort sessions: Running > Paused > Others, then by lastModifiedAt (newest first) @@ -43,12 +53,8 @@ export const SessionsTab: FC<{ } // Then sort by lastModifiedAt (newest first) - const aTime = a.meta.lastModifiedAt - ? new Date(a.meta.lastModifiedAt).getTime() - : 0; - const bTime = b.meta.lastModifiedAt - ? new Date(b.meta.lastModifiedAt).getTime() - : 0; + const aTime = a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0; + const bTime = b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0; return bTime - aTime; }); @@ -121,9 +127,9 @@ export const SessionsTab: FC<{ {session.meta.messageCount}
- {session.meta.lastModifiedAt && ( + {session.lastModifiedAt && ( - {new Date(session.meta.lastModifiedAt).toLocaleDateString( + {new Date(session.lastModifiedAt).toLocaleDateString( "en-US", { month: "short", @@ -137,6 +143,21 @@ export const SessionsTab: FC<{ ); })} + + {/* Load More Button */} + {hasNextPage && onLoadMore && ( +
+ +
+ )} ); diff --git a/src/app/projects/components/ProjectList.tsx b/src/app/projects/components/ProjectList.tsx index 98e5038..eb4fa1b 100644 --- a/src/app/projects/components/ProjectList.tsx +++ b/src/app/projects/components/ProjectList.tsx @@ -49,8 +49,8 @@ export const ProjectList: FC = () => {

Last modified:{" "} - {project.meta.lastModifiedAt - ? new Date(project.meta.lastModifiedAt).toLocaleDateString() + {project.lastModifiedAt + ? new Date(project.lastModifiedAt).toLocaleDateString() : ""}

diff --git a/src/lib/api/queries.ts b/src/lib/api/queries.ts index 297921a..3ee0d2a 100644 --- a/src/lib/api/queries.ts +++ b/src/lib/api/queries.ts @@ -16,12 +16,15 @@ export const projectListQuery = { }, } as const; -export const projectDetailQuery = (projectId: string) => +export const projectDetailQuery = (projectId: string, cursor?: string) => ({ - queryKey: ["projects", projectId], + queryKey: cursor + ? ["projects", projectId, cursor] + : ["projects", projectId], queryFn: async () => { const response = await honoClient.api.projects[":projectId"].$get({ param: { projectId }, + query: { cursor }, }); if (!response.ok) { diff --git a/src/server/hono/initialize.ts b/src/server/hono/initialize.ts index 2b32e02..5e992b2 100644 --- a/src/server/hono/initialize.ts +++ b/src/server/hono/initialize.ts @@ -1,5 +1,8 @@ +import prexit from "prexit"; +import { claudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; import { eventBus } from "../service/events/EventBus"; import { fileWatcher } from "../service/events/fileWatcher"; +import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration"; import type { ProjectRepository } from "../service/project/ProjectRepository"; import { projectMetaStorage } from "../service/project/projectMetaStorage"; import type { SessionRepository } from "../service/session/SessionRepository"; @@ -11,14 +14,18 @@ export const initialize = async (deps: { }): Promise => { fileWatcher.startWatching(); - setInterval(() => { + const intervalId = setInterval(() => { eventBus.emit("heartbeat", {}); }, 10 * 1000); - eventBus.on("sessionChanged", (event) => { + const onSessionChanged = ( + event: InternalEventDeclaration["sessionChanged"], + ) => { projectMetaStorage.invalidateProject(event.projectId); sessionMetaStorage.invalidateSession(event.projectId, event.sessionId); - }); + }; + + eventBus.on("sessionChanged", onSessionChanged); try { console.log("Initializing projects cache"); @@ -38,4 +45,11 @@ export const initialize = async (deps: { } catch { // do nothing } + + prexit(() => { + clearInterval(intervalId); + eventBus.off("sessionChanged", onSessionChanged); + fileWatcher.stop(); + claudeCodeTaskController.abortAllTasks(); + }); }; diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index c437935..bec1951 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -4,9 +4,9 @@ import { zValidator } from "@hono/zod-validator"; import { setCookie } from "hono/cookie"; import { streamSSE } from "hono/streaming"; import { z } from "zod"; -import { type Config, configSchema } from "../config/config"; +import { configSchema } from "../config/config"; import { env } from "../lib/env"; -import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; +import { claudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; import type { SerializableAliveTask } from "../service/claude-code/types"; import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE"; import { eventBus } from "../service/events/EventBus"; @@ -25,16 +25,6 @@ import { initialize } from "./initialize"; import { configMiddleware } from "./middleware/config.middleware"; export const routes = async (app: HonoAppType) => { - let taskController: ClaudeCodeTaskController | null = null; - const getTaskController = (config: Config) => { - if (!taskController) { - taskController = new ClaudeCodeTaskController(config); - } else { - taskController.updateConfig(config); - } - return taskController; - }; - const sessionRepository = new SessionRepository(); const projectRepository = new ProjectRepository(); @@ -49,6 +39,10 @@ export const routes = async (app: HonoAppType) => { app // middleware .use(configMiddleware) + .use(async (c, next) => { + claudeCodeTaskController.updateConfig(c.get("config")); + await next(); + }) // routes .get("/config", async (c) => { @@ -72,85 +66,93 @@ export const routes = async (app: HonoAppType) => { return c.json({ projects }); }) - .get("/projects/:projectId", async (c) => { - const { projectId } = c.req.param(); + .get( + "/projects/:projectId", + zValidator("query", z.object({ cursor: z.string().optional() })), + async (c) => { + const { projectId } = c.req.param(); + const { cursor } = c.req.valid("query"); - const [{ project }, { sessions }] = await Promise.all([ - projectRepository.getProject(projectId), - sessionRepository.getSessions(projectId).then(({ sessions }) => { - let filteredSessions = sessions; + const [{ project }, { sessions, nextCursor }] = await Promise.all([ + projectRepository.getProject(projectId), + sessionRepository + .getSessions(projectId, { cursor }) + .then(({ sessions }) => { + let filteredSessions = sessions; - // Filter sessions based on hideNoUserMessageSession setting - if (c.get("config").hideNoUserMessageSession) { - filteredSessions = filteredSessions.filter((session) => { - return session.meta.firstCommand !== null; - }); - } + // Filter sessions based on hideNoUserMessageSession setting + if (c.get("config").hideNoUserMessageSession) { + filteredSessions = filteredSessions.filter((session) => { + return session.meta.firstCommand !== null; + }); + } - // Unify sessions with same title if unifySameTitleSession is enabled - if (c.get("config").unifySameTitleSession) { - const sessionMap = new Map< - string, - (typeof filteredSessions)[0] - >(); + // Unify sessions with same title if unifySameTitleSession is enabled + if (c.get("config").unifySameTitleSession) { + const sessionMap = new Map< + string, + (typeof filteredSessions)[0] + >(); - for (const session of filteredSessions) { - // Generate title for comparison - const title = - session.meta.firstCommand !== null - ? (() => { - const cmd = session.meta.firstCommand; - switch (cmd.kind) { - case "command": - return cmd.commandArgs === undefined - ? cmd.commandName - : `${cmd.commandName} ${cmd.commandArgs}`; - case "local-command": - return cmd.stdout; - case "text": - return cmd.content; - default: - return session.id; + for (const session of filteredSessions) { + // Generate title for comparison + const title = + session.meta.firstCommand !== null + ? (() => { + const cmd = session.meta.firstCommand; + switch (cmd.kind) { + case "command": + return cmd.commandArgs === undefined + ? cmd.commandName + : `${cmd.commandName} ${cmd.commandArgs}`; + case "local-command": + return cmd.stdout; + case "text": + return cmd.content; + default: + return session.id; + } + })() + : session.id; + + const existingSession = sessionMap.get(title); + if (existingSession) { + // Keep the session with the latest modification date + if ( + session.lastModifiedAt && + existingSession.lastModifiedAt + ) { + if ( + session.lastModifiedAt > + existingSession.lastModifiedAt + ) { + sessionMap.set(title, session); } - })() - : session.id; - - const existingSession = sessionMap.get(title); - if (existingSession) { - // Keep the session with the latest modification date - if ( - session.meta.lastModifiedAt && - existingSession.meta.lastModifiedAt - ) { - if ( - new Date(session.meta.lastModifiedAt) > - new Date(existingSession.meta.lastModifiedAt) - ) { + } else if ( + session.lastModifiedAt && + !existingSession.lastModifiedAt + ) { + sessionMap.set(title, session); + } + // If no modification dates, keep the existing one + } else { sessionMap.set(title, session); } - } else if ( - session.meta.lastModifiedAt && - !existingSession.meta.lastModifiedAt - ) { - sessionMap.set(title, session); } - // If no modification dates, keep the existing one - } else { - sessionMap.set(title, session); + + filteredSessions = Array.from(sessionMap.values()); } - } - filteredSessions = Array.from(sessionMap.values()); - } + return { + sessions: filteredSessions, + nextCursor: sessions.at(-1)?.id, + }; + }), + ] as const); - return { - sessions: filteredSessions, - }; - }), - ] as const); - - return c.json({ project, sessions }); - }) + return c.json({ project, sessions, nextCursor }); + }, + ) .get("/projects/:projectId/sessions/:sessionId", async (c) => { const { projectId, sessionId } = c.req.param(); @@ -324,9 +326,7 @@ export const routes = async (app: HonoAppType) => { return c.json({ error: "Project path not found" }, 400); } - const task = await getTaskController( - c.get("config"), - ).startOrContinueTask( + const task = await claudeCodeTaskController.startOrContinueTask( { projectId, cwd: project.meta.projectPath, @@ -358,9 +358,7 @@ export const routes = async (app: HonoAppType) => { return c.json({ error: "Project path not found" }, 400); } - const task = await getTaskController( - c.get("config"), - ).startOrContinueTask( + const task = await claudeCodeTaskController.startOrContinueTask( { projectId, sessionId, @@ -378,7 +376,7 @@ export const routes = async (app: HonoAppType) => { .get("/tasks/alive", async (c) => { return c.json({ - aliveTasks: getTaskController(c.get("config")).aliveTasks.map( + aliveTasks: claudeCodeTaskController.aliveTasks.map( (task): SerializableAliveTask => ({ id: task.id, status: task.status, @@ -393,7 +391,7 @@ export const routes = async (app: HonoAppType) => { zValidator("json", z.object({ sessionId: z.string() })), async (c) => { const { sessionId } = c.req.valid("json"); - getTaskController(c.get("config")).abortTask(sessionId); + claudeCodeTaskController.abortTask(sessionId); return c.json({ message: "Task aborted" }); }, ) @@ -409,7 +407,7 @@ export const routes = async (app: HonoAppType) => { ), async (c) => { const permissionResponse = c.req.valid("json"); - getTaskController(c.get("config")).respondToPermissionRequest( + claudeCodeTaskController.respondToPermissionRequest( permissionResponse, ); return c.json({ message: "Permission response received" }); diff --git a/src/server/service/claude-code/ClaudeCodeExecutor.ts b/src/server/service/claude-code/ClaudeCodeExecutor.ts index b9de1b2..55285b3 100644 --- a/src/server/service/claude-code/ClaudeCodeExecutor.ts +++ b/src/server/service/claude-code/ClaudeCodeExecutor.ts @@ -41,14 +41,18 @@ export class ClaudeCodeExecutor { } public query(prompt: CCQueryPrompt, options: CCQueryOptions) { - const { canUseTool, ...baseOptions } = options; + const { canUseTool, permissionMode, ...baseOptions } = options; return query({ prompt, options: { pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable, ...baseOptions, - ...(this.availableFeatures.canUseTool ? { canUseTool } : {}), + ...(this.availableFeatures.canUseTool + ? { canUseTool, permissionMode } + : { + permissionMode: "bypassPermissions", + }), }, }); } diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts index f7ad96c..cb630a7 100644 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts @@ -1,4 +1,3 @@ -import prexit from "prexit"; import { ulid } from "ulid"; import type { Config } from "../../config/config"; import { eventBus } from "../events/EventBus"; @@ -14,22 +13,21 @@ import type { RunningClaudeCodeTask, } from "./types"; -export class ClaudeCodeTaskController { +class ClaudeCodeTaskController { private claudeCode: ClaudeCodeExecutor; private tasks: ClaudeCodeTask[] = []; private config: Config; private pendingPermissionRequests: Map = new Map(); private permissionResponses: Map = new Map(); - constructor(config: Config) { + constructor() { this.claudeCode = new ClaudeCodeExecutor(); - this.config = config; - - prexit(() => { - this.aliveTasks.forEach((task) => { - task.abortController.abort(); - }); - }); + this.config = { + hideNoUserMessageSession: false, + unifySameTitleSession: false, + enterKeyBehavior: "shift-enter-send", + permissionMode: "default", + }; } public updateConfig(config: Config) { @@ -292,9 +290,9 @@ export class ClaudeCodeTaskController { ], meta: { firstCommand: null, - lastModifiedAt: new Date().toISOString(), messageCount: 0, }, + lastModifiedAt: new Date(), }); } @@ -407,6 +405,12 @@ export class ClaudeCodeTaskController { }); } + public abortAllTasks() { + for (const task of this.aliveTasks) { + task.abortController.abort(); + } + } + private upsertExistingTask(task: ClaudeCodeTask) { const target = this.tasks.find((t) => t.id === task.id); @@ -425,3 +429,5 @@ export class ClaudeCodeTaskController { } } } + +export const claudeCodeTaskController = new ClaudeCodeTaskController(); diff --git a/src/server/service/project/ProjectRepository.ts b/src/server/service/project/ProjectRepository.ts index d6d7055..df87935 100644 --- a/src/server/service/project/ProjectRepository.ts +++ b/src/server/service/project/ProjectRepository.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { access, constants, readdir } from "node:fs/promises"; import { resolve } from "node:path"; import { claudeProjectsDirPath } from "../paths"; @@ -19,6 +19,7 @@ export class ProjectRepository { project: { id: projectId, claudeProjectPath: fullPath, + lastModifiedAt: statSync(fullPath).mtime, meta, }, }; @@ -50,6 +51,7 @@ export class ProjectRepository { return { id, claudeProjectPath: fullPath, + lastModifiedAt: statSync(fullPath).mtime, meta: await projectMetaStorage.getProjectMeta(id), }; }), @@ -58,12 +60,8 @@ export class ProjectRepository { return { projects: projects.sort((a, b) => { return ( - (b.meta.lastModifiedAt - ? new Date(b.meta.lastModifiedAt).getTime() - : 0) - - (a.meta.lastModifiedAt - ? new Date(a.meta.lastModifiedAt).getTime() - : 0) + (b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0) - + (a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0) ); }), }; diff --git a/src/server/service/project/projectMetaStorage.ts b/src/server/service/project/projectMetaStorage.ts index e12814c..85bd698 100644 --- a/src/server/service/project/projectMetaStorage.ts +++ b/src/server/service/project/projectMetaStorage.ts @@ -37,8 +37,6 @@ class ProjectMetaStorage { return a.stats.mtime.getTime() - b.stats.mtime.getTime(); }); - const lastModifiedUnixTime = files.at(-1)?.stats.mtime.getTime(); - let projectPath: string | null = null; for (const file of files) { @@ -54,9 +52,6 @@ class ProjectMetaStorage { const projectMeta: ProjectMeta = { projectName: projectPath ? basename(projectPath) : null, projectPath, - lastModifiedAt: lastModifiedUnixTime - ? new Date(lastModifiedUnixTime).toISOString() - : null, sessionCount: files.length, }; diff --git a/src/server/service/schema.ts b/src/server/service/schema.ts index 3060cf5..ef2fd37 100644 --- a/src/server/service/schema.ts +++ b/src/server/service/schema.ts @@ -4,12 +4,10 @@ import { parsedCommandSchema } from "./parseCommandXml"; export const projectMetaSchema = z.object({ projectName: z.string().nullable(), projectPath: z.string().nullable(), - lastModifiedAt: z.string().nullable(), sessionCount: z.number(), }); export const sessionMetaSchema = z.object({ messageCount: z.number(), firstCommand: parsedCommandSchema.nullable(), - lastModifiedAt: z.string().nullable(), }); diff --git a/src/server/service/session/PredictSessionsDatabase.ts b/src/server/service/session/PredictSessionsDatabase.ts index 161b273..8dbca71 100644 --- a/src/server/service/session/PredictSessionsDatabase.ts +++ b/src/server/service/session/PredictSessionsDatabase.ts @@ -14,12 +14,8 @@ class PredictSessionsDatabase { ); } - public getPredictSession(sessionId: string): SessionDetail { - const session = this.storage.get(sessionId); - if (!session) { - throw new Error("Session not found"); - } - return session; + public getPredictSession(sessionId: string): SessionDetail | null { + return this.storage.get(sessionId) ?? null; } public createPredictSession(session: SessionDetail) { diff --git a/src/server/service/session/SessionRepository.ts b/src/server/service/session/SessionRepository.ts index 2fc9c28..7374cfd 100644 --- a/src/server/service/session/SessionRepository.ts +++ b/src/server/service/session/SessionRepository.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { parseJsonl } from "../parseJsonl"; @@ -8,11 +8,6 @@ import { decodeSessionId, encodeSessionId } from "./id"; import { predictSessionsDatabase } from "./PredictSessionsDatabase"; import { sessionMetaStorage } from "./sessionMetaStorage"; -const getTime = (date: string | null) => { - if (date === null) return 0; - return new Date(date).getTime(); -}; - export class SessionRepository { public async getSession( projectId: string, @@ -33,14 +28,16 @@ export class SessionRepository { throw new Error("Session not found"); } const content = await readFile(sessionPath, "utf-8"); + const allLines = content.split("\n").filter((line) => line.trim()); - const conversations = parseJsonl(content); + const conversations = parseJsonl(allLines.join("\n")); const sessionDetail: SessionDetail = { id: sessionId, jsonlFilePath: sessionPath, meta: await sessionMetaStorage.getSessionMeta(projectId, sessionId), conversations, + lastModifiedAt: statSync(sessionPath).mtime, }; return { @@ -50,36 +47,88 @@ export class SessionRepository { public async getSessions( projectId: string, + options?: { + maxCount?: number; + cursor?: string; + }, ): Promise<{ sessions: Session[] }> { + const { maxCount = 20, cursor } = options ?? {}; + try { const claudeProjectPath = decodeProjectId(projectId); const dirents = await readdir(claudeProjectPath, { withFileTypes: true }); const sessions = await Promise.all( dirents .filter((d) => d.isFile() && d.name.endsWith(".jsonl")) - .map(async (d) => ({ - id: encodeSessionId(resolve(claudeProjectPath, d.name)), - jsonlFilePath: resolve(claudeProjectPath, d.name), - meta: await sessionMetaStorage.getSessionMeta( - projectId, - encodeSessionId(resolve(claudeProjectPath, d.name)), - ), - })), + .map(async (d) => { + const sessionId = encodeSessionId( + resolve(claudeProjectPath, d.name), + ); + const stats = statSync(resolve(claudeProjectPath, d.name)); + + return { + id: sessionId, + jsonlFilePath: resolve(claudeProjectPath, d.name), + lastModifiedAt: stats.mtime, + }; + }), + ).then((fetched) => + fetched.sort( + (a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(), + ), ); - const sessionMap = new Map( - sessions.map((session) => [session.id, session]), + + const sessionMap = new Map( + sessions.map((session) => [session.id, session] as const), ); + const index = + cursor !== undefined + ? sessions.findIndex((session) => session.id === cursor) + : -1; + + if (index !== -1) { + return { + sessions: await Promise.all( + sessions + .slice(index + 1, Math.min(index + 1 + maxCount, sessions.length)) + .map(async (item) => { + return { + ...item, + meta: await sessionMetaStorage.getSessionMeta( + projectId, + item.id, + ), + }; + }), + ), + }; + } + const predictSessions = predictSessionsDatabase .getPredictSessions(projectId) - .filter((session) => !sessionMap.has(session.id)); + .filter((session) => !sessionMap.has(session.id)) + .sort((a, b) => { + return b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(); + }); return { - sessions: [...predictSessions, ...sessions].sort((a, b) => { - return ( - getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt) - ); - }), + sessions: [ + ...predictSessions, + ...(await Promise.all( + sessions + .slice(0, Math.min(maxCount, sessions.length)) + .map(async (item) => { + return { + ...item, + meta: await sessionMetaStorage.getSessionMeta( + projectId, + item.id, + ), + }; + }), + )), + ], }; } catch (error) { console.warn(`Failed to read sessions for project ${projectId}:`, error); diff --git a/src/server/service/session/sessionMetaStorage.ts b/src/server/service/session/sessionMetaStorage.ts index 975db76..00bbb47 100644 --- a/src/server/service/session/sessionMetaStorage.ts +++ b/src/server/service/session/sessionMetaStorage.ts @@ -1,4 +1,3 @@ -import { statSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; @@ -38,18 +37,12 @@ class SessionMetaStorage { const sessionPath = decodeSessionId(projectId, sessionId); - const stats = statSync(sessionPath); - const lastModifiedUnixTime = stats.mtime.getTime(); - const content = await readFile(sessionPath, "utf-8"); const lines = content.split("\n"); const sessionMeta: SessionMeta = { messageCount: lines.length, firstCommand: this.getFirstCommand(sessionPath, lines), - lastModifiedAt: lastModifiedUnixTime - ? new Date(lastModifiedUnixTime).toISOString() - : null, }; this.sessionMetaCache.save(sessionId, sessionMeta); diff --git a/src/server/service/types.ts b/src/server/service/types.ts index b5ca5d9..2668e30 100644 --- a/src/server/service/types.ts +++ b/src/server/service/types.ts @@ -5,6 +5,7 @@ import type { projectMetaSchema, sessionMetaSchema } from "./schema"; export type Project = { id: string; claudeProjectPath: string; + lastModifiedAt: Date; meta: ProjectMeta; }; @@ -13,6 +14,7 @@ export type ProjectMeta = z.infer; export type Session = { id: string; jsonlFilePath: string; + lastModifiedAt: Date; meta: SessionMeta; }; From f34943c9cc846dc59263005e50380c57c22bbe18 Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Wed, 15 Oct 2025 03:47:09 +0900 Subject: [PATCH 05/21] chore: using refetchQueries instead of invalidateQueries --- src/app/projects/[projectId]/components/ProjectPage.tsx | 2 +- src/components/SettingsControls.tsx | 6 +++--- src/lib/api/queries.ts | 4 +--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/app/projects/[projectId]/components/ProjectPage.tsx b/src/app/projects/[projectId]/components/ProjectPage.tsx index 417f0de..0d4077d 100644 --- a/src/app/projects/[projectId]/components/ProjectPage.tsx +++ b/src/app/projects/[projectId]/components/ProjectPage.tsx @@ -47,7 +47,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => { // biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed useEffect(() => { - void queryClient.invalidateQueries({ + void queryClient.refetchQueries({ queryKey: ["projects", projectId], }); }, [config.hideNoUserMessageSession, config.unifySameTitleSession]); diff --git a/src/components/SettingsControls.tsx b/src/components/SettingsControls.tsx index 415eda2..b9c849d 100644 --- a/src/components/SettingsControls.tsx +++ b/src/components/SettingsControls.tsx @@ -37,13 +37,13 @@ export const SettingsControls: FC = ({ const queryClient = useQueryClient(); const onConfigChanged = useCallback(async () => { - await queryClient.invalidateQueries({ + await queryClient.refetchQueries({ queryKey: configQuery.queryKey, }); - await queryClient.invalidateQueries({ + await queryClient.refetchQueries({ queryKey: projectListQuery.queryKey, }); - void queryClient.invalidateQueries({ + void queryClient.refetchQueries({ queryKey: projectDetailQuery(openingProjectId).queryKey, }); }, [queryClient, openingProjectId]); diff --git a/src/lib/api/queries.ts b/src/lib/api/queries.ts index 3ee0d2a..5d754d5 100644 --- a/src/lib/api/queries.ts +++ b/src/lib/api/queries.ts @@ -18,9 +18,7 @@ export const projectListQuery = { export const projectDetailQuery = (projectId: string, cursor?: string) => ({ - queryKey: cursor - ? ["projects", projectId, cursor] - : ["projects", projectId], + queryKey: ["projects", projectId], queryFn: async () => { const response = await honoClient.api.projects[":projectId"].$get({ param: { projectId }, From 7c05168e4e7710e62ba11855a3e2aafac4eef6a6 Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Wed, 15 Oct 2025 12:59:05 +0900 Subject: [PATCH 06/21] fix: fix bug conversation log syncronization --- scripts/build.sh | 4 +++ src/app/components/SSEEventListeners.tsx | 7 +--- .../projects/[projectId]/hooks/useProject.ts | 5 +-- src/server/hono/route.ts | 13 +++++++- .../claude-code/ClaudeCodeTaskController.ts | 24 +++++++++----- .../events/InternalEventDeclaration.ts | 3 +- src/server/service/project/id.test.ts | 33 +++++++++++++++++++ .../session/PredictSessionsDatabase.ts | 6 +++- .../service/session/SessionRepository.ts | 3 +- src/server/service/session/id.test.ts | 26 +++++++++++++++ src/test-setups/vitest.setup.ts | 3 ++ src/types/sse.ts | 3 +- 12 files changed, 109 insertions(+), 21 deletions(-) create mode 100644 src/server/service/project/id.test.ts create mode 100644 src/server/service/session/id.test.ts create mode 100644 src/test-setups/vitest.setup.ts diff --git a/scripts/build.sh b/scripts/build.sh index bf1ace9..1dd13e1 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -6,6 +6,10 @@ if [ -d "dist/.next" ]; then rm -rf dist/.next fi +if [ -d "dist/standalone" ]; then + rm -rf dist/standalone +fi + pnpm exec next build cp -r public .next/standalone/ cp -r .next/static .next/standalone/.next/ diff --git a/src/app/components/SSEEventListeners.tsx b/src/app/components/SSEEventListeners.tsx index a80be14..77d6fbc 100644 --- a/src/app/components/SSEEventListeners.tsx +++ b/src/app/components/SSEEventListeners.tsx @@ -25,13 +25,8 @@ export const SSEEventListeners: FC = ({ children }) => { }); }); - useServerEventListener("taskChanged", async ({ aliveTasks, changed }) => { + useServerEventListener("taskChanged", async ({ aliveTasks }) => { setAliveTasks(aliveTasks); - - await queryClient.invalidateQueries({ - queryKey: sessionDetailQuery(changed.projectId, changed.sessionId) - .queryKey, - }); }); return <>{children}; diff --git a/src/app/projects/[projectId]/hooks/useProject.ts b/src/app/projects/[projectId]/hooks/useProject.ts index 9036b75..ff1da81 100644 --- a/src/app/projects/[projectId]/hooks/useProject.ts +++ b/src/app/projects/[projectId]/hooks/useProject.ts @@ -3,9 +3,10 @@ import { projectDetailQuery } from "../../../../lib/api/queries"; export const useProject = (projectId: string) => { return useSuspenseInfiniteQuery({ - queryKey: ["projects", projectId], + queryKey: projectDetailQuery(projectId).queryKey, queryFn: async ({ pageParam }) => { - return await projectDetailQuery(projectId, pageParam).queryFn(); + const result = await projectDetailQuery(projectId, pageParam).queryFn(); + return result; }, initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index bec1951..ac7a713 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -442,8 +442,19 @@ export const routes = async (app: HonoAppType) => { ) => { stream.writeSSE("taskChanged", { aliveTasks: event.aliveTasks, - changed: event.changed, + changed: { + status: event.changed.status, + sessionId: event.changed.sessionId, + projectId: event.changed.projectId, + }, }); + + if (event.changed.sessionId !== undefined) { + stream.writeSSE("sessionChanged", { + projectId: event.changed.projectId, + sessionId: event.changed.sessionId, + }); + } }; eventBus.on("sessionListChanged", onSessionListChanged); diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts index cb630a7..2e987d4 100644 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts @@ -1,6 +1,9 @@ +import { resolve } from "node:path"; import { ulid } from "ulid"; import type { Config } from "../../config/config"; import { eventBus } from "../events/EventBus"; +import { parseCommandXml } from "../parseCommandXml"; +import { decodeProjectId } from "../project/id"; import { predictSessionsDatabase } from "../session/PredictSessionsDatabase"; import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor"; import { createMessageGenerator } from "./createMessageGenerator"; @@ -270,7 +273,10 @@ class ClaudeCodeTaskController { // because it takes time for the Claude Code file to be updated, simulate the message predictSessionsDatabase.createPredictSession({ id: message.session_id, - jsonlFilePath: message.session_id, + jsonlFilePath: resolve( + decodeProjectId(currentSession.projectId), + `${message.session_id}.jsonl`, + ), conversations: [ { type: "user", @@ -289,11 +295,15 @@ class ClaudeCodeTaskController { }, ], meta: { - firstCommand: null, + firstCommand: parseCommandXml(userMessage), messageCount: 0, }, lastModifiedAt: new Date(), }); + + eventBus.emit("sessionListChanged", { + projectId: task.projectId, + }); } if (!resolved) { @@ -421,12 +431,10 @@ class ClaudeCodeTaskController { Object.assign(target, task); } - if (task.status === "paused" || task.status === "running") { - eventBus.emit("taskChanged", { - aliveTasks: this.aliveTasks, - changed: task, - }); - } + eventBus.emit("taskChanged", { + aliveTasks: this.aliveTasks, + changed: task, + }); } } diff --git a/src/server/service/events/InternalEventDeclaration.ts b/src/server/service/events/InternalEventDeclaration.ts index 347fe18..d221353 100644 --- a/src/server/service/events/InternalEventDeclaration.ts +++ b/src/server/service/events/InternalEventDeclaration.ts @@ -1,5 +1,6 @@ import type { AliveClaudeCodeTask, + ClaudeCodeTask, PermissionRequest, } from "../claude-code/types"; @@ -18,7 +19,7 @@ export type InternalEventDeclaration = { taskChanged: { aliveTasks: AliveClaudeCodeTask[]; - changed: AliveClaudeCodeTask; + changed: ClaudeCodeTask; }; permissionRequested: { diff --git a/src/server/service/project/id.test.ts b/src/server/service/project/id.test.ts new file mode 100644 index 0000000..b260238 --- /dev/null +++ b/src/server/service/project/id.test.ts @@ -0,0 +1,33 @@ +import { resolve } from "node:path"; +import { + decodeProjectId, + encodeProjectId, + encodeProjectIdFromSessionFilePath, +} from "./id"; + +const sampleProjectPath = + "/path/to/claude-code-project-dir/projects/sample-project"; +const sampleProjectId = + "L3BhdGgvdG8vY2xhdWRlLWNvZGUtcHJvamVjdC1kaXIvcHJvamVjdHMvc2FtcGxlLXByb2plY3Q"; + +describe("encodeProjectId", () => { + it("should encode project id from project path", () => { + expect(encodeProjectId(sampleProjectPath)).toBe(sampleProjectId); + }); +}); + +describe("decodeProjectId", () => { + it("should decode project absolute path from project id", () => { + expect(decodeProjectId(sampleProjectId)).toBe(sampleProjectPath); + }); +}); + +describe("encodeProjectIdFromSessionFilePath", () => { + it("should encode project id from session file path", () => { + expect( + encodeProjectIdFromSessionFilePath( + resolve(sampleProjectPath, "sample-session-id.jsonl"), + ), + ).toBe(sampleProjectId); + }); +}); diff --git a/src/server/service/session/PredictSessionsDatabase.ts b/src/server/service/session/PredictSessionsDatabase.ts index 8dbca71..d26ec5f 100644 --- a/src/server/service/session/PredictSessionsDatabase.ts +++ b/src/server/service/session/PredictSessionsDatabase.ts @@ -7,8 +7,12 @@ import type { Session, SessionDetail } from "../types"; class PredictSessionsDatabase { private storage = new Map(); + private get allPredictSessions() { + return Array.from(this.storage.values()); + } + public getPredictSessions(projectId: string): Session[] { - return Array.from(this.storage.values()).filter( + return this.allPredictSessions.filter( ({ jsonlFilePath }) => encodeProjectIdFromSessionFilePath(jsonlFilePath) === projectId, ); diff --git a/src/server/service/session/SessionRepository.ts b/src/server/service/session/SessionRepository.ts index 7374cfd..6f43cf2 100644 --- a/src/server/service/session/SessionRepository.ts +++ b/src/server/service/session/SessionRepository.ts @@ -19,7 +19,8 @@ export class SessionRepository { if (!existsSync(sessionPath)) { const predictSession = predictSessionsDatabase.getPredictSession(sessionId); - if (predictSession) { + + if (predictSession !== null) { return { session: predictSession, }; diff --git a/src/server/service/session/id.test.ts b/src/server/service/session/id.test.ts new file mode 100644 index 0000000..f79aa35 --- /dev/null +++ b/src/server/service/session/id.test.ts @@ -0,0 +1,26 @@ +import { resolve } from "node:path"; +import { decodeSessionId, encodeSessionId } from "./id"; + +const sampleProjectId = + "L3BhdGgvdG8vY2xhdWRlLWNvZGUtcHJvamVjdC1kaXIvcHJvamVjdHMvc2FtcGxlLXByb2plY3Q"; +const sampleProjectPath = + "/path/to/claude-code-project-dir/projects/sample-project"; +const sampleSessionId = "1af7fc5e-8455-4414-9ccd-011d40f70b2a"; +const sampleSessionFilePath = resolve( + sampleProjectPath, + `${sampleSessionId}.jsonl`, +); + +describe("encodeSessionId", () => { + it("should encode session id from jsonl file path", () => { + expect(encodeSessionId(sampleSessionFilePath)).toBe(sampleSessionId); + }); +}); + +describe("decodeSessionId", () => { + it("should decode session file absolute path from project id and session id", () => { + expect(decodeSessionId(sampleProjectId, sampleSessionId)).toBe( + sampleSessionFilePath, + ); + }); +}); diff --git a/src/test-setups/vitest.setup.ts b/src/test-setups/vitest.setup.ts new file mode 100644 index 0000000..22bd5e6 --- /dev/null +++ b/src/test-setups/vitest.setup.ts @@ -0,0 +1,3 @@ +afterEach(() => { + vi.clearAllMocks(); +}); diff --git a/src/types/sse.ts b/src/types/sse.ts index 5b26e20..d4def0e 100644 --- a/src/types/sse.ts +++ b/src/types/sse.ts @@ -1,5 +1,6 @@ import type { AliveClaudeCodeTask, + ClaudeCodeTask, PermissionRequest, } from "../server/service/claude-code/types"; @@ -21,7 +22,7 @@ export type SSEEventDeclaration = { taskChanged: { aliveTasks: AliveClaudeCodeTask[]; - changed: AliveClaudeCodeTask; + changed: Pick; }; permission_requested: { From 8d592ce89ba8bcb72fa1d3c1656fd591c7069485 Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Tue, 14 Oct 2025 12:18:29 +0900 Subject: [PATCH 07/21] fix: disable tool approve for old claude code version --- .../components/chatForm/useChatMutations.ts | 8 +- src/server/hono/route.ts | 16 ++- .../service/claude-code/ClaudeCodeExecutor.ts | 18 +-- .../claude-code/ClaudeCodeTaskController.ts | 110 +++++++----------- .../service/claude-code/ClaudeCodeVersion.ts | 4 - src/server/service/claude-code/types.ts | 3 + 6 files changed, 66 insertions(+), 93 deletions(-) diff --git a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts index 72d49ab..f77a818 100644 --- a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts +++ b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts @@ -32,7 +32,13 @@ export const useNewChatMutation = ( }, onSuccess: async (response) => { onSuccess?.(); - router.push(`/projects/${projectId}/sessions/${response.sessionId}`); + router.push( + `/projects/${projectId}/sessions/${response.sessionId}` + + response.userMessageId !== + undefined + ? `#message-${response.userMessageId}` + : "", + ); }, }); }; diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index ac7a713..9521f74 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -4,9 +4,9 @@ import { zValidator } from "@hono/zod-validator"; import { setCookie } from "hono/cookie"; import { streamSSE } from "hono/streaming"; import { z } from "zod"; -import { configSchema } from "../config/config"; +import { type Config, configSchema } from "../config/config"; import { env } from "../lib/env"; -import { claudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; +import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; import type { SerializableAliveTask } from "../service/claude-code/types"; import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE"; import { eventBus } from "../service/events/EventBus"; @@ -28,11 +28,15 @@ export const routes = async (app: HonoAppType) => { const sessionRepository = new SessionRepository(); const projectRepository = new ProjectRepository(); + const fileWatcher = getFileWatcher(); + const eventBus = getEventBus(); + if (env.get("NEXT_PHASE") !== "phase-production-build") { - await initialize({ - sessionRepository, - projectRepository, - }); + fileWatcher.startWatching(); + + setInterval(() => { + eventBus.emit("heartbeat", {}); + }, 10 * 1000); } return ( diff --git a/src/server/service/claude-code/ClaudeCodeExecutor.ts b/src/server/service/claude-code/ClaudeCodeExecutor.ts index 55285b3..d3c1678 100644 --- a/src/server/service/claude-code/ClaudeCodeExecutor.ts +++ b/src/server/service/claude-code/ClaudeCodeExecutor.ts @@ -23,17 +23,13 @@ export class ClaudeCodeExecutor { ); } - public get version() { - return this.claudeCodeVersion?.version; - } - - public get availableFeatures() { + public get features() { return { - canUseTool: + enableToolApproval: this.claudeCodeVersion?.greaterThanOrEqual( new ClaudeCodeVersion({ major: 1, minor: 0, patch: 82 }), ) ?? false, - uuidOnSDKMessage: + extractUuidFromSDKMessage: this.claudeCodeVersion?.greaterThanOrEqual( new ClaudeCodeVersion({ major: 1, minor: 0, patch: 86 }), ) ?? false, @@ -41,18 +37,14 @@ export class ClaudeCodeExecutor { } public query(prompt: CCQueryPrompt, options: CCQueryOptions) { - const { canUseTool, permissionMode, ...baseOptions } = options; + const { canUseTool, ...baseOptions } = options; return query({ prompt, options: { pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable, ...baseOptions, - ...(this.availableFeatures.canUseTool - ? { canUseTool, permissionMode } - : { - permissionMode: "bypassPermissions", - }), + ...(this.features.enableToolApproval ? { canUseTool } : {}), }, }); } diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts index 2e987d4..4883b31 100644 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts @@ -1,10 +1,7 @@ -import { resolve } from "node:path"; +import prexit from "prexit"; import { ulid } from "ulid"; import type { Config } from "../../config/config"; -import { eventBus } from "../events/EventBus"; -import { parseCommandXml } from "../parseCommandXml"; -import { decodeProjectId } from "../project/id"; -import { predictSessionsDatabase } from "../session/PredictSessionsDatabase"; +import { getEventBus, type IEventBus } from "../events/EventBus"; import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor"; import { createMessageGenerator } from "./createMessageGenerator"; import type { @@ -16,21 +13,23 @@ import type { RunningClaudeCodeTask, } from "./types"; -class ClaudeCodeTaskController { +export class ClaudeCodeTaskController { private claudeCode: ClaudeCodeExecutor; private tasks: ClaudeCodeTask[] = []; private config: Config; private pendingPermissionRequests: Map = new Map(); private permissionResponses: Map = new Map(); - constructor() { + constructor(config: Config) { this.claudeCode = new ClaudeCodeExecutor(); - this.config = { - hideNoUserMessageSession: false, - unifySameTitleSession: false, - enterKeyBehavior: "shift-enter-send", - permissionMode: "default", - }; + this.eventBus = getEventBus(); + this.config = config; + + prexit(() => { + this.aliveTasks.forEach((task) => { + task.abortController.abort(); + }); + }); } public updateConfig(config: Config) { @@ -170,20 +169,9 @@ class ClaudeCodeTaskController { ); if (existingTask) { - console.log( - `Alive task for session(id=${currentSession.sessionId}) continued.`, - ); const result = await this.continueTask(existingTask, message); return result; } else { - if (currentSession.sessionId === undefined) { - console.log(`New task started.`); - } else { - console.log( - `New task started for existing session(id=${currentSession.sessionId}).`, - ); - } - const result = await this.startTask(currentSession, message); return result; } @@ -265,41 +253,29 @@ class ClaudeCodeTaskController { }); } - if ( - message.type === "system" && - message.subtype === "init" && - currentSession.sessionId === undefined - ) { - // because it takes time for the Claude Code file to be updated, simulate the message - predictSessionsDatabase.createPredictSession({ - id: message.session_id, - jsonlFilePath: resolve( - decodeProjectId(currentSession.projectId), - `${message.session_id}.jsonl`, - ), - conversations: [ - { - type: "user", - message: { - role: "user", - content: userMessage, - }, - isSidechain: false, - userType: "external", - cwd: message.cwd, - sessionId: message.session_id, - version: this.claudeCode.version?.toString() ?? "unknown", - uuid: message.uuid, - timestamp: new Date().toISOString(), - parentUuid: null, - }, - ], - meta: { - firstCommand: parseCommandXml(userMessage), - messageCount: 0, - }, - lastModifiedAt: new Date(), - }); + // 初回の system message だとまだ history ファイルが作成されていないので + if (message.type === "user" || message.type === "assistant") { + // 本来は message.uuid の存在チェックをしたいが、古いバージョンでは存在しないことがある + if (!resolved) { + const runningTask: RunningClaudeCodeTask = { + status: "running", + id: task.id, + projectId: task.projectId, + cwd: task.cwd, + generateMessages: task.generateMessages, + setNextMessage: task.setNextMessage, + resolveFirstMessage: task.resolveFirstMessage, + setFirstMessagePromise: task.setFirstMessagePromise, + awaitFirstMessage: task.awaitFirstMessage, + onMessageHandlers: task.onMessageHandlers, + userMessageId: message.uuid, + sessionId: message.session_id, + abortController: abortController, + }; + this.tasks.push(runningTask); + aliveTaskResolve(runningTask); + resolved = true; + } eventBus.emit("sessionListChanged", { projectId: task.projectId, @@ -415,12 +391,6 @@ class ClaudeCodeTaskController { }); } - public abortAllTasks() { - for (const task of this.aliveTasks) { - task.abortController.abort(); - } - } - private upsertExistingTask(task: ClaudeCodeTask) { const target = this.tasks.find((t) => t.id === task.id); @@ -431,10 +401,12 @@ class ClaudeCodeTaskController { Object.assign(target, task); } - eventBus.emit("taskChanged", { - aliveTasks: this.aliveTasks, - changed: task, - }); + if (task.status === "paused" || task.status === "running") { + this.eventBus.emit("taskChanged", { + aliveTasks: this.aliveTasks, + changed: task, + }); + } } } diff --git a/src/server/service/claude-code/ClaudeCodeVersion.ts b/src/server/service/claude-code/ClaudeCodeVersion.ts index 9193a7e..db3b4f3 100644 --- a/src/server/service/claude-code/ClaudeCodeVersion.ts +++ b/src/server/service/claude-code/ClaudeCodeVersion.ts @@ -43,10 +43,6 @@ export class ClaudeCodeVersion { return this.version.patch; } - public toString() { - return `${this.major}.${this.minor}.${this.patch}`; - } - public equals(other: ClaudeCodeVersion) { return ( this.version.major === other.version.major && diff --git a/src/server/service/claude-code/types.ts b/src/server/service/claude-code/types.ts index c920d14..83889df 100644 --- a/src/server/service/claude-code/types.ts +++ b/src/server/service/claude-code/types.ts @@ -20,18 +20,21 @@ export type PendingClaudeCodeTask = BaseClaudeCodeTask & { export type RunningClaudeCodeTask = BaseClaudeCodeTask & { status: "running"; sessionId: string; + userMessageId: string | undefined; abortController: AbortController; }; export type PausedClaudeCodeTask = BaseClaudeCodeTask & { status: "paused"; sessionId: string; + userMessageId: string | undefined; abortController: AbortController; }; type CompletedClaudeCodeTask = BaseClaudeCodeTask & { status: "completed"; sessionId: string; + userMessageId: string | undefined; abortController: AbortController; resolveFirstMessage: () => void; }; From 94cc1c063063e4f9f6a33e7fc84023940077aea2 Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Wed, 15 Oct 2025 01:18:14 +0900 Subject: [PATCH 08/21] feat: improve interactivity by predict sessions --- .../components/chatForm/useChatMutations.ts | 8 +-- .../service/claude-code/ClaudeCodeExecutor.ts | 12 ++-- .../claude-code/ClaudeCodeTaskController.ts | 71 ++++++++++++------- .../service/claude-code/ClaudeCodeVersion.ts | 4 ++ src/server/service/claude-code/types.ts | 3 - .../session/PredictSessionsDatabase.ts | 14 ++-- .../service/session/SessionRepository.ts | 39 +++++----- 7 files changed, 86 insertions(+), 65 deletions(-) diff --git a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts index f77a818..72d49ab 100644 --- a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts +++ b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts @@ -32,13 +32,7 @@ export const useNewChatMutation = ( }, onSuccess: async (response) => { onSuccess?.(); - router.push( - `/projects/${projectId}/sessions/${response.sessionId}` + - response.userMessageId !== - undefined - ? `#message-${response.userMessageId}` - : "", - ); + router.push(`/projects/${projectId}/sessions/${response.sessionId}`); }, }); }; diff --git a/src/server/service/claude-code/ClaudeCodeExecutor.ts b/src/server/service/claude-code/ClaudeCodeExecutor.ts index d3c1678..b9de1b2 100644 --- a/src/server/service/claude-code/ClaudeCodeExecutor.ts +++ b/src/server/service/claude-code/ClaudeCodeExecutor.ts @@ -23,13 +23,17 @@ export class ClaudeCodeExecutor { ); } - public get features() { + public get version() { + return this.claudeCodeVersion?.version; + } + + public get availableFeatures() { return { - enableToolApproval: + canUseTool: this.claudeCodeVersion?.greaterThanOrEqual( new ClaudeCodeVersion({ major: 1, minor: 0, patch: 82 }), ) ?? false, - extractUuidFromSDKMessage: + uuidOnSDKMessage: this.claudeCodeVersion?.greaterThanOrEqual( new ClaudeCodeVersion({ major: 1, minor: 0, patch: 86 }), ) ?? false, @@ -44,7 +48,7 @@ export class ClaudeCodeExecutor { options: { pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable, ...baseOptions, - ...(this.features.enableToolApproval ? { canUseTool } : {}), + ...(this.availableFeatures.canUseTool ? { canUseTool } : {}), }, }); } diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts index 4883b31..50cbaa0 100644 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts @@ -1,7 +1,8 @@ import prexit from "prexit"; import { ulid } from "ulid"; import type { Config } from "../../config/config"; -import { getEventBus, type IEventBus } from "../events/EventBus"; +import { eventBus } from "../events/EventBus"; +import { predictSessionsDatabase } from "../session/PredictSessionsDatabase"; import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor"; import { createMessageGenerator } from "./createMessageGenerator"; import type { @@ -169,9 +170,20 @@ export class ClaudeCodeTaskController { ); if (existingTask) { + console.log( + `Alive task for session(id=${currentSession.sessionId}) continued.`, + ); const result = await this.continueTask(existingTask, message); return result; } else { + if (currentSession.sessionId === undefined) { + console.log(`New task started.`); + } else { + console.log( + `New task started for existing session(id=${currentSession.sessionId}).`, + ); + } + const result = await this.startTask(currentSession, message); return result; } @@ -253,32 +265,37 @@ export class ClaudeCodeTaskController { }); } - // 初回の system message だとまだ history ファイルが作成されていないので - if (message.type === "user" || message.type === "assistant") { - // 本来は message.uuid の存在チェックをしたいが、古いバージョンでは存在しないことがある - if (!resolved) { - const runningTask: RunningClaudeCodeTask = { - status: "running", - id: task.id, - projectId: task.projectId, - cwd: task.cwd, - generateMessages: task.generateMessages, - setNextMessage: task.setNextMessage, - resolveFirstMessage: task.resolveFirstMessage, - setFirstMessagePromise: task.setFirstMessagePromise, - awaitFirstMessage: task.awaitFirstMessage, - onMessageHandlers: task.onMessageHandlers, - userMessageId: message.uuid, - sessionId: message.session_id, - abortController: abortController, - }; - this.tasks.push(runningTask); - aliveTaskResolve(runningTask); - resolved = true; - } - - eventBus.emit("sessionListChanged", { - projectId: task.projectId, + if ( + message.type === "system" && + message.subtype === "init" && + currentSession.sessionId === undefined + ) { + // because it takes time for the Claude Code file to be updated, simulate the message + predictSessionsDatabase.createPredictSession({ + id: message.session_id, + jsonlFilePath: message.session_id, + conversations: [ + { + type: "user", + message: { + role: "user", + content: userMessage, + }, + isSidechain: false, + userType: "external", + cwd: message.cwd, + sessionId: message.session_id, + version: this.claudeCode.version?.toString() ?? "unknown", + uuid: message.uuid, + timestamp: new Date().toISOString(), + parentUuid: null, + }, + ], + meta: { + firstCommand: null, + lastModifiedAt: new Date().toISOString(), + messageCount: 0, + }, }); } diff --git a/src/server/service/claude-code/ClaudeCodeVersion.ts b/src/server/service/claude-code/ClaudeCodeVersion.ts index db3b4f3..9193a7e 100644 --- a/src/server/service/claude-code/ClaudeCodeVersion.ts +++ b/src/server/service/claude-code/ClaudeCodeVersion.ts @@ -43,6 +43,10 @@ export class ClaudeCodeVersion { return this.version.patch; } + public toString() { + return `${this.major}.${this.minor}.${this.patch}`; + } + public equals(other: ClaudeCodeVersion) { return ( this.version.major === other.version.major && diff --git a/src/server/service/claude-code/types.ts b/src/server/service/claude-code/types.ts index 83889df..c920d14 100644 --- a/src/server/service/claude-code/types.ts +++ b/src/server/service/claude-code/types.ts @@ -20,21 +20,18 @@ export type PendingClaudeCodeTask = BaseClaudeCodeTask & { export type RunningClaudeCodeTask = BaseClaudeCodeTask & { status: "running"; sessionId: string; - userMessageId: string | undefined; abortController: AbortController; }; export type PausedClaudeCodeTask = BaseClaudeCodeTask & { status: "paused"; sessionId: string; - userMessageId: string | undefined; abortController: AbortController; }; type CompletedClaudeCodeTask = BaseClaudeCodeTask & { status: "completed"; sessionId: string; - userMessageId: string | undefined; abortController: AbortController; resolveFirstMessage: () => void; }; diff --git a/src/server/service/session/PredictSessionsDatabase.ts b/src/server/service/session/PredictSessionsDatabase.ts index d26ec5f..161b273 100644 --- a/src/server/service/session/PredictSessionsDatabase.ts +++ b/src/server/service/session/PredictSessionsDatabase.ts @@ -7,19 +7,19 @@ import type { Session, SessionDetail } from "../types"; class PredictSessionsDatabase { private storage = new Map(); - private get allPredictSessions() { - return Array.from(this.storage.values()); - } - public getPredictSessions(projectId: string): Session[] { - return this.allPredictSessions.filter( + return Array.from(this.storage.values()).filter( ({ jsonlFilePath }) => encodeProjectIdFromSessionFilePath(jsonlFilePath) === projectId, ); } - public getPredictSession(sessionId: string): SessionDetail | null { - return this.storage.get(sessionId) ?? null; + public getPredictSession(sessionId: string): SessionDetail { + const session = this.storage.get(sessionId); + if (!session) { + throw new Error("Session not found"); + } + return session; } public createPredictSession(session: SessionDetail) { diff --git a/src/server/service/session/SessionRepository.ts b/src/server/service/session/SessionRepository.ts index 6f43cf2..df9be24 100644 --- a/src/server/service/session/SessionRepository.ts +++ b/src/server/service/session/SessionRepository.ts @@ -1,4 +1,4 @@ -import { existsSync, statSync } from "node:fs"; +import { existsSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { parseJsonl } from "../parseJsonl"; @@ -19,6 +19,15 @@ export class SessionRepository { if (!existsSync(sessionPath)) { const predictSession = predictSessionsDatabase.getPredictSession(sessionId); + if (predictSession) { + return { + session: predictSession, + }; + } + + throw new Error("Session not found"); + } + const content = await readFile(sessionPath, "utf-8"); if (predictSession !== null) { return { @@ -78,6 +87,13 @@ export class SessionRepository { (a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(), ), ); + const sessionMap = new Map( + sessions.map((session) => [session.id, session]), + ); + + const predictSessions = predictSessionsDatabase + .getPredictSessions(projectId) + .filter((session) => !sessionMap.has(session.id)); const sessionMap = new Map( sessions.map((session) => [session.id, session] as const), @@ -114,22 +130,11 @@ export class SessionRepository { }); return { - sessions: [ - ...predictSessions, - ...(await Promise.all( - sessions - .slice(0, Math.min(maxCount, sessions.length)) - .map(async (item) => { - return { - ...item, - meta: await sessionMetaStorage.getSessionMeta( - projectId, - item.id, - ), - }; - }), - )), - ], + sessions: [...predictSessions, ...sessions].sort((a, b) => { + return ( + getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt) + ); + }), }; } catch (error) { console.warn(`Failed to read sessions for project ${projectId}:`, error); From 21070d09ff57de0dbc21d50515e2a943e593d2eb Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Wed, 15 Oct 2025 23:22:27 +0900 Subject: [PATCH 09/21] refactor: add effect-ts and refactor codes --- package.json | 4 + pnpm-lock.yaml | 463 +++++++ src/app/api/[[...route]]/route.ts | 47 +- src/app/components/SSEEventListeners.tsx | 8 +- src/app/components/SyncSessionProcess.tsx | 18 + src/app/layout.tsx | 14 +- .../[projectId]/components/chatForm/index.ts | 5 +- .../components/chatForm/useChatMutations.ts | 53 +- .../components/newChat/NewChat.tsx | 13 +- .../components/SessionPageContent.tsx | 56 +- .../components/resumeChat/ContinueChat.tsx | 46 + .../components/resumeChat/ResumeChat.tsx | 26 +- .../components/sessionSidebar/McpTab.tsx | 10 +- .../sessionSidebar/MobileSidebar.tsx | 2 +- .../sessionSidebar/SessionSidebar.tsx | 2 +- .../components/sessionSidebar/SessionsTab.tsx | 24 +- .../[sessionId]/hooks/useAliveTask.ts | 31 - .../sessions/[sessionId]/hooks/useSession.ts | 12 +- .../[sessionId]/hooks/useSessionProcess.ts | 23 + .../[sessionId]/store/aliveTasksAtom.ts | 4 - .../[sessionId]/store/sessionProcessesAtom.ts | 4 + src/hooks/usePermissionRequests.ts | 2 +- src/lib/api/queries.ts | 33 +- src/lib/controllablePromise.ts | 25 + .../entry/UserEntrySchema.ts | 2 + src/server/hono/initialize.test.ts | 362 ++++++ src/server/hono/initialize.ts | 183 ++- src/server/hono/route.ts | 1078 ++++++++++------- src/server/lib/effect/types.ts | 6 + src/server/lib/env/schema.ts | 1 + src/server/lib/storage/FileCacheStorage.ts | 82 -- .../FileCacheStorage/PersistantService.ts | 64 + .../storage/FileCacheStorage/index.test.ts | 516 ++++++++ .../lib/storage/FileCacheStorage/index.ts | 94 ++ .../lib/storage/InMemoryCacheStorage.ts | 19 - .../service/claude-code/ClaudeCode.test.ts | 94 ++ src/server/service/claude-code/ClaudeCode.ts | 81 ++ .../service/claude-code/ClaudeCodeExecutor.ts | 55 - .../claude-code/ClaudeCodeLifeCycleService.ts | 367 ++++++ .../ClaudeCodePermissionService.ts | 158 +++ .../ClaudeCodeSessionProcessService.ts | 463 +++++++ .../claude-code/ClaudeCodeTaskController.ts | 430 ------- .../service/claude-code/ClaudeCodeVersion.ts | 71 -- .../service/claude-code/MessageGenerator.ts | 83 ++ .../claude-code/createMessageGenerator.ts | 36 +- .../claude-code/models/CCSessionProcess.ts | 108 ++ .../claude-code/models/ClaudeCodeTask.ts | 59 + .../models/ClaudeCodeVersion.test.ts | 84 ++ .../claude-code/models/ClaudeCodeVersion.ts | 47 + src/server/service/claude-code/types.ts | 71 -- src/server/service/events/EventBus.test.ts | 282 +++++ src/server/service/events/EventBus.ts | 104 +- .../events/InternalEventDeclaration.ts | 14 +- .../service/events/adaptInternalEventToSSE.ts | 34 - src/server/service/events/fileWatcher.test.ts | 176 +++ src/server/service/events/fileWatcher.ts | 157 ++- src/server/service/events/typeSafeSSE.test.ts | 248 ++++ src/server/service/events/typeSafeSSE.ts | 53 +- src/server/service/mcp/getMcpList.ts | 6 +- src/server/service/parseJsonl.ts | 4 +- .../project/ProjectMetaService.test.ts | 221 ++++ .../service/project/ProjectMetaService.ts | 154 +++ .../service/project/ProjectRepository.test.ts | 329 +++++ .../service/project/ProjectRepository.ts | 126 +- .../service/project/projectMetaStorage.ts | 104 -- .../session/PredictSessionsDatabase.ts | 34 - .../session/SessionMetaService.test.ts | 247 ++++ .../service/session/SessionMetaService.ts | 185 +++ .../service/session/SessionRepository.test.ts | 604 +++++++++ .../service/session/SessionRepository.ts | 424 +++++-- .../VirtualConversationDatabase.test.ts | 245 ++++ .../session/VirtualConversationDatabase.ts | 116 ++ .../service/session/sessionMetaStorage.ts | 123 -- src/server/service/types.ts | 1 + src/types/session-process.ts | 6 + src/types/sse.ts | 12 +- 76 files changed, 7598 insertions(+), 1950 deletions(-) create mode 100644 src/app/components/SyncSessionProcess.tsx create mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ContinueChat.tsx delete mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/hooks/useAliveTask.ts create mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionProcess.ts delete mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom.ts create mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom.ts create mode 100644 src/lib/controllablePromise.ts create mode 100644 src/server/hono/initialize.test.ts create mode 100644 src/server/lib/effect/types.ts delete mode 100644 src/server/lib/storage/FileCacheStorage.ts create mode 100644 src/server/lib/storage/FileCacheStorage/PersistantService.ts create mode 100644 src/server/lib/storage/FileCacheStorage/index.test.ts create mode 100644 src/server/lib/storage/FileCacheStorage/index.ts delete mode 100644 src/server/lib/storage/InMemoryCacheStorage.ts create mode 100644 src/server/service/claude-code/ClaudeCode.test.ts create mode 100644 src/server/service/claude-code/ClaudeCode.ts delete mode 100644 src/server/service/claude-code/ClaudeCodeExecutor.ts create mode 100644 src/server/service/claude-code/ClaudeCodeLifeCycleService.ts create mode 100644 src/server/service/claude-code/ClaudeCodePermissionService.ts create mode 100644 src/server/service/claude-code/ClaudeCodeSessionProcessService.ts delete mode 100644 src/server/service/claude-code/ClaudeCodeTaskController.ts delete mode 100644 src/server/service/claude-code/ClaudeCodeVersion.ts create mode 100644 src/server/service/claude-code/MessageGenerator.ts create mode 100644 src/server/service/claude-code/models/CCSessionProcess.ts create mode 100644 src/server/service/claude-code/models/ClaudeCodeTask.ts create mode 100644 src/server/service/claude-code/models/ClaudeCodeVersion.test.ts create mode 100644 src/server/service/claude-code/models/ClaudeCodeVersion.ts delete mode 100644 src/server/service/claude-code/types.ts create mode 100644 src/server/service/events/EventBus.test.ts create mode 100644 src/server/service/events/fileWatcher.test.ts create mode 100644 src/server/service/events/typeSafeSSE.test.ts create mode 100644 src/server/service/project/ProjectMetaService.test.ts create mode 100644 src/server/service/project/ProjectMetaService.ts create mode 100644 src/server/service/project/ProjectRepository.test.ts delete mode 100644 src/server/service/project/projectMetaStorage.ts delete mode 100644 src/server/service/session/PredictSessionsDatabase.ts create mode 100644 src/server/service/session/SessionMetaService.test.ts create mode 100644 src/server/service/session/SessionMetaService.ts create mode 100644 src/server/service/session/SessionRepository.test.ts create mode 100644 src/server/service/session/VirtualConversationDatabase.test.ts create mode 100644 src/server/service/session/VirtualConversationDatabase.ts delete mode 100644 src/server/service/session/sessionMetaStorage.ts create mode 100644 src/types/session-process.ts diff --git a/package.json b/package.json index bd2edac..efab3cb 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,8 @@ }, "dependencies": { "@anthropic-ai/claude-code": "^1.0.98", + "@effect/platform": "^0.92.1", + "@effect/platform-node": "^0.98.3", "@hono/zod-validator": "^0.7.2", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", @@ -51,6 +53,8 @@ "@tanstack/react-query": "^5.85.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "effect": "^3.18.4", + "es-toolkit": "^1.40.0", "hono": "^4.9.5", "jotai": "^2.13.1", "lucide-react": "^0.542.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6faec8f..4b275ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@anthropic-ai/claude-code': specifier: ^1.0.98 version: 1.0.128 + '@effect/platform': + specifier: ^0.92.1 + version: 0.92.1(effect@3.18.4) + '@effect/platform-node': + specifier: ^0.98.3 + version: 0.98.3(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4) '@hono/zod-validator': specifier: ^0.7.2 version: 0.7.2(hono@4.9.5)(zod@4.1.5) @@ -47,6 +53,12 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + effect: + specifier: ^3.18.4 + version: 3.18.4 + es-toolkit: + specifier: ^1.40.0 + version: 1.40.0 hono: specifier: ^4.9.5 version: 4.9.5 @@ -225,6 +237,71 @@ packages: conventional-commits-parser: optional: true + '@effect/cluster@0.50.4': + resolution: {integrity: sha512-9uS2pRN4BCguAGqFCLFlQkReXG993UFj/TLtiwaXsacytKhdlGBU5zDDI/TckbM0wUv4g2nZPRRywqU8qnrvjQ==} + peerDependencies: + '@effect/platform': ^0.92.1 + '@effect/rpc': ^0.71.0 + '@effect/sql': ^0.46.0 + '@effect/workflow': ^0.11.3 + effect: ^3.18.4 + + '@effect/experimental@0.56.0': + resolution: {integrity: sha512-ZT9wTUVyDptzdkW4Tfvz5fNzygW9vt5jWcFmKI9SlhZMu9unVJgsBhxWCNYCyfPnxw3n/Z6SEKsqgt8iKQc4MA==} + peerDependencies: + '@effect/platform': ^0.92.0 + effect: ^3.18.0 + ioredis: ^5 + lmdb: ^3 + peerDependenciesMeta: + ioredis: + optional: true + lmdb: + optional: true + + '@effect/platform-node-shared@0.51.4': + resolution: {integrity: sha512-xElU9+cNPa1BnUHAZ3sVVanuuKof8oWQhK7rbyHNqgWM7CZTjv7x9VMDs0X05+1OcTQnnW3E+SrZKIPCfcYlDQ==} + peerDependencies: + '@effect/cluster': ^0.50.3 + '@effect/platform': ^0.92.1 + '@effect/rpc': ^0.71.0 + '@effect/sql': ^0.46.0 + effect: ^3.18.2 + + '@effect/platform-node@0.98.3': + resolution: {integrity: sha512-90eMWmFSVHrUEreICCd2qLPiw7qcaAv9XTx9OJ+LLv7igQgt4qkisRSK0oxAr5hqU9TdUrsgFDohqe7q7h3ZRg==} + peerDependencies: + '@effect/cluster': ^0.50.3 + '@effect/platform': ^0.92.1 + '@effect/rpc': ^0.71.0 + '@effect/sql': ^0.46.0 + effect: ^3.18.1 + + '@effect/platform@0.92.1': + resolution: {integrity: sha512-XXWCBVwyhaKZISN7aM1fv/3fWDGyxr84ObywnUrL8aHvJLoIeskWFAP/fqw3c5MFCrJ3ZV97RWLbv6JiBQugdg==} + peerDependencies: + effect: ^3.18.1 + + '@effect/rpc@0.71.0': + resolution: {integrity: sha512-m6mFX0ShdA+fnYAyamz7SRKF4FepaDB/ZhBri6iue26tBF2LrOFJUWewbwv8/LdLSedkO4eukhsHXuEYortL/w==} + peerDependencies: + '@effect/platform': ^0.92.0 + effect: ^3.18.0 + + '@effect/sql@0.46.0': + resolution: {integrity: sha512-nm9TuTTG7gLmJlIPkf71wA5lXArSvkpm1oYoIF+rhf01wef+1ujz9Mv1SfuzYbzsk7W9+OXUIRMxz/nSlKkiGQ==} + peerDependencies: + '@effect/experimental': ^0.56.0 + '@effect/platform': ^0.92.0 + effect: ^3.18.0 + + '@effect/workflow@0.11.3': + resolution: {integrity: sha512-3uyj0yOc2QRtQVOw6NEJVEMOhN/F7khhnf3QSU+2T3wvuDag9iBUzJFvSls8PgNCO3j/GgeaWzbcXwxqpFQYOQ==} + peerDependencies: + '@effect/platform': ^0.92.1 + '@effect/rpc': ^0.71.0 + effect: ^3.18.1 + '@emnapi/runtime@1.5.0': resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==} @@ -741,6 +818,36 @@ packages: '@jridgewell/trace-mapping@0.3.30': resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@next/env@15.5.2': resolution: {integrity: sha512-Qe06ew4zt12LeO6N7j8/nULSOe3fMXE4dM6xgpBQNvdzyK1sv5y4oAP3bq4LamrvGCZtmRYnW8URFCeX5nFgGg==} @@ -853,6 +960,88 @@ packages: '@octokit/types@14.1.0': resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + '@phun-ky/typeof@1.2.8': resolution: {integrity: sha512-7J6ca1tK0duM2BgVB+CuFMh3idlIVASOP2QvOCbNWDc6JnvjtKa9nufPoJQQ4xrwBonwgT1TIhRRcEtzdVgWsA==} engines: {node: ^20.9.0 || >=22.0.0, npm: '>=10.8.2'} @@ -1342,6 +1531,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1862,6 +2054,11 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + detect-libc@2.0.4: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} @@ -1884,6 +2081,9 @@ packages: resolution: {integrity: sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==} engines: {node: '>=12'} + effect@3.18.4: + resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + emoji-regex@10.5.0: resolution: {integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==} @@ -1897,6 +2097,9 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-toolkit@1.40.0: + resolution: {integrity: sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==} + esbuild@0.25.9: resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} engines: {node: '>=18'} @@ -1956,6 +2159,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-content-type-parse@2.0.1: resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==} @@ -1979,6 +2186,9 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-my-way-ts@0.1.6: + resolution: {integrity: sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -2498,6 +2708,10 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} @@ -2506,6 +2720,11 @@ packages: resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} engines: {node: '>= 0.6'} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@4.0.0: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} @@ -2554,6 +2773,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + + multipasta@0.2.7: + resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -2598,9 +2827,16 @@ packages: sass: optional: true + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -2785,6 +3021,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -3132,6 +3371,10 @@ packages: resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} engines: {node: '>=18.17'} + undici@7.16.0: + resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -3186,6 +3429,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -3291,6 +3538,18 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -3393,6 +3652,75 @@ snapshots: conventional-commits-filter: 5.0.0 conventional-commits-parser: 6.2.0 + '@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)': + dependencies: + '@effect/platform': 0.92.1(effect@3.18.4) + '@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4) + '@effect/sql': 0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4) + '@effect/workflow': 0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4) + effect: 3.18.4 + + '@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)': + dependencies: + '@effect/platform': 0.92.1(effect@3.18.4) + effect: 3.18.4 + uuid: 11.1.0 + + '@effect/platform-node-shared@0.51.4(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)': + dependencies: + '@effect/cluster': 0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4) + '@effect/platform': 0.92.1(effect@3.18.4) + '@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4) + '@effect/sql': 0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4) + '@parcel/watcher': 2.5.1 + effect: 3.18.4 + multipasta: 0.2.7 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform-node@0.98.3(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)': + dependencies: + '@effect/cluster': 0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4) + '@effect/platform': 0.92.1(effect@3.18.4) + '@effect/platform-node-shared': 0.51.4(@effect/cluster@0.50.4(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4) + '@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4) + '@effect/sql': 0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4) + effect: 3.18.4 + mime: 3.0.0 + undici: 7.16.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@effect/platform@0.92.1(effect@3.18.4)': + dependencies: + effect: 3.18.4 + find-my-way-ts: 0.1.6 + msgpackr: 1.11.5 + multipasta: 0.2.7 + + '@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)': + dependencies: + '@effect/platform': 0.92.1(effect@3.18.4) + effect: 3.18.4 + msgpackr: 1.11.5 + + '@effect/sql@0.46.0(@effect/experimental@0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4)': + dependencies: + '@effect/experimental': 0.56.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4) + '@effect/platform': 0.92.1(effect@3.18.4) + effect: 3.18.4 + uuid: 11.1.0 + + '@effect/workflow@0.11.3(@effect/platform@0.92.1(effect@3.18.4))(@effect/rpc@0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4))(effect@3.18.4)': + dependencies: + '@effect/platform': 0.92.1(effect@3.18.4) + '@effect/rpc': 0.71.0(@effect/platform@0.92.1(effect@3.18.4))(effect@3.18.4) + effect: 3.18.4 + '@emnapi/runtime@1.5.0': dependencies: tslib: 2.8.1 @@ -3779,6 +4107,24 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@next/env@15.5.2': {} '@next/swc-darwin-arm64@15.5.2': @@ -3877,6 +4223,66 @@ snapshots: dependencies: '@octokit/openapi-types': 25.1.0 + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + '@phun-ky/typeof@1.2.8': {} '@playwright/test@1.55.0': @@ -4309,6 +4715,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.0.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -4831,6 +5239,8 @@ snapshots: destr@2.0.5: {} + detect-libc@1.0.3: {} + detect-libc@2.0.4: {} detect-node-es@1.1.0: {} @@ -4847,6 +5257,11 @@ snapshots: dotenv@17.2.1: {} + effect@3.18.4: + dependencies: + '@standard-schema/spec': 1.0.0 + fast-check: 3.23.2 + emoji-regex@10.5.0: {} emoji-regex@8.0.0: {} @@ -4858,6 +5273,8 @@ snapshots: es-module-lexer@1.7.0: {} + es-toolkit@1.40.0: {} + esbuild@0.25.9: optionalDependencies: '@esbuild/aix-ppc64': 0.25.9 @@ -4946,6 +5363,10 @@ snapshots: extend@3.0.2: {} + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-content-type-parse@2.0.1: {} fault@1.0.4: @@ -4964,6 +5385,8 @@ snapshots: dependencies: to-regex-range: 5.0.1 + find-my-way-ts@0.1.6: {} + format@0.2.2: {} fs-minipass@2.1.0: @@ -5642,12 +6065,19 @@ snapshots: transitivePeerDependencies: - supports-color + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + mime-db@1.54.0: {} mime-types@3.0.1: dependencies: mime-db: 1.54.0 + mime@3.0.0: {} + mimic-fn@4.0.0: {} mimic-function@5.0.1: {} @@ -5684,6 +6114,24 @@ snapshots: ms@2.1.3: {} + 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 + optional: true + + msgpackr@1.11.5: + optionalDependencies: + msgpackr-extract: 3.0.3 + + multipasta@0.2.7: {} + mute-stream@2.0.0: {} nanoid@3.3.11: {} @@ -5723,8 +6171,15 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-addon-api@7.1.1: {} + node-fetch-native@1.6.7: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.0.4 + optional: true + normalize-path@3.0.0: {} npm-normalize-package-bin@4.0.0: {} @@ -5945,6 +6400,8 @@ snapshots: proxy-from-env@1.1.0: {} + pure-rand@6.1.0: {} + rc9@2.1.2: dependencies: defu: 6.1.4 @@ -6370,6 +6827,8 @@ snapshots: undici@6.21.3: {} + undici@7.16.0: {} + unicorn-magic@0.3.0: {} unified@11.0.5: @@ -6428,6 +6887,8 @@ snapshots: dependencies: react: 19.1.1 + uuid@11.1.0: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -6542,6 +7003,8 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + ws@8.18.3: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.0 diff --git a/src/app/api/[[...route]]/route.ts b/src/app/api/[[...route]]/route.ts index 76f4e5e..12ae66e 100644 --- a/src/app/api/[[...route]]/route.ts +++ b/src/app/api/[[...route]]/route.ts @@ -1,8 +1,53 @@ +import { NodeContext } from "@effect/platform-node"; +import { Effect } from "effect"; import { handle } from "hono/vercel"; import { honoApp } from "../../../server/hono/app"; +import { InitializeService } from "../../../server/hono/initialize"; import { routes } from "../../../server/hono/route"; +import { ClaudeCodeLifeCycleService } from "../../../server/service/claude-code/ClaudeCodeLifeCycleService"; +import { ClaudeCodePermissionService } from "../../../server/service/claude-code/ClaudeCodePermissionService"; +import { ClaudeCodeSessionProcessService } from "../../../server/service/claude-code/ClaudeCodeSessionProcessService"; +import { EventBus } from "../../../server/service/events/EventBus"; +import { FileWatcherService } from "../../../server/service/events/fileWatcher"; +import { ProjectMetaService } from "../../../server/service/project/ProjectMetaService"; +import { ProjectRepository } from "../../../server/service/project/ProjectRepository"; +import { VirtualConversationDatabase } from "../../../server/service/session/PredictSessionsDatabase"; +import { SessionMetaService } from "../../../server/service/session/SessionMetaService"; +import { SessionRepository } from "../../../server/service/session/SessionRepository"; -await routes(honoApp); +const program = routes(honoApp); + +await Effect.runPromise( + program.pipe( + // 依存の浅い順にコンテナに pipe する必要がある + + /** Application */ + Effect.provide(InitializeService.Live), + + /** Domain */ + Effect.provide(ClaudeCodeLifeCycleService.Live), + Effect.provide(ClaudeCodePermissionService.Live), + Effect.provide(ClaudeCodeSessionProcessService.Live), + + // Shared Services + Effect.provide(FileWatcherService.Live), + Effect.provide(EventBus.Live), + + /** Infrastructure */ + + // Repository + Effect.provide(ProjectRepository.Live), + Effect.provide(SessionRepository.Live), + + // StorageService + Effect.provide(ProjectMetaService.Live), + Effect.provide(SessionMetaService.Live), + Effect.provide(VirtualConversationDatabase.Live), + + /** Platform */ + Effect.provide(NodeContext.layer), + ), +); export const GET = handle(honoApp); export const POST = handle(honoApp); diff --git a/src/app/components/SSEEventListeners.tsx b/src/app/components/SSEEventListeners.tsx index 77d6fbc..50bf595 100644 --- a/src/app/components/SSEEventListeners.tsx +++ b/src/app/components/SSEEventListeners.tsx @@ -5,11 +5,11 @@ import { useSetAtom } from "jotai"; import type { FC, PropsWithChildren } from "react"; import { projectDetailQuery, sessionDetailQuery } from "../../lib/api/queries"; import { useServerEventListener } from "../../lib/sse/hook/useServerEventListener"; -import { aliveTasksAtom } from "../projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom"; +import { sessionProcessesAtom } from "../projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom"; export const SSEEventListeners: FC = ({ children }) => { const queryClient = useQueryClient(); - const setAliveTasks = useSetAtom(aliveTasksAtom); + const setSessionProcesses = useSetAtom(sessionProcessesAtom); useServerEventListener("sessionListChanged", async (event) => { // invalidate session list @@ -25,8 +25,8 @@ export const SSEEventListeners: FC = ({ children }) => { }); }); - useServerEventListener("taskChanged", async ({ aliveTasks }) => { - setAliveTasks(aliveTasks); + useServerEventListener("sessionProcessChanged", async ({ processes }) => { + setSessionProcesses(processes); }); return <>{children}; diff --git a/src/app/components/SyncSessionProcess.tsx b/src/app/components/SyncSessionProcess.tsx new file mode 100644 index 0000000..e0ff79a --- /dev/null +++ b/src/app/components/SyncSessionProcess.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useSetAtom } from "jotai"; +import { type FC, type PropsWithChildren, useEffect } from "react"; +import type { PublicSessionProcess } from "../../types/session-process"; +import { sessionProcessesAtom } from "../projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom"; + +export const SyncSessionProcess: FC< + PropsWithChildren<{ initProcesses: PublicSessionProcess[] }> +> = ({ children, initProcesses }) => { + const setSessionProcesses = useSetAtom(sessionProcessesAtom); + + useEffect(() => { + setSessionProcesses(initProcesses); + }, [initProcesses, setSessionProcesses]); + + return <>{children}; +}; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 030d292..f882369 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,8 +7,10 @@ import { SSEProvider } from "../lib/sse/components/SSEProvider"; import { RootErrorBoundary } from "./components/RootErrorBoundary"; import "./globals.css"; +import { honoClient } from "../lib/api/client"; import { configQuery } from "../lib/api/queries"; import { SSEEventListeners } from "./components/SSEEventListeners"; +import { SyncSessionProcess } from "./components/SyncSessionProcess"; export const dynamic = "force-dynamic"; export const fetchCache = "force-no-store"; @@ -40,6 +42,10 @@ export default async function RootLayout({ queryFn: configQuery.queryFn, }); + const initSessionProcesses = await honoClient.api.cc["session-processes"] + .$get({}) + .then((response) => response.json()); + return ( - {children} + + + {children} + + diff --git a/src/app/projects/[projectId]/components/chatForm/index.ts b/src/app/projects/[projectId]/components/chatForm/index.ts index 1b68d06..ca75a3b 100644 --- a/src/app/projects/[projectId]/components/chatForm/index.ts +++ b/src/app/projects/[projectId]/components/chatForm/index.ts @@ -4,4 +4,7 @@ export type { CommandCompletionRef } from "./CommandCompletion"; export { CommandCompletion } from "./CommandCompletion"; export type { FileCompletionRef } from "./FileCompletion"; export { FileCompletion } from "./FileCompletion"; -export { useNewChatMutation, useResumeChatMutation } from "./useChatMutations"; +export { + useContinueSessionProcessMutation, + useCreateSessionProcessMutation, +} from "./useChatMutations"; diff --git a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts index 72d49ab..e124182 100644 --- a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts +++ b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts @@ -2,20 +2,24 @@ import { useMutation } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { honoClient } from "../../../../../lib/api/client"; -export const useNewChatMutation = ( +export const useCreateSessionProcessMutation = ( projectId: string, onSuccess?: () => void, ) => { const router = useRouter(); return useMutation({ - mutationFn: async (options: { message: string }) => { - const response = await honoClient.api.projects[":projectId"][ - "new-session" - ].$post( + mutationFn: async (options: { + message: string; + baseSessionId?: string; + }) => { + const response = await honoClient.api.cc["session-processes"].$post( { - param: { projectId }, - json: { message: options.message }, + json: { + projectId, + baseSessionId: options.baseSessionId, + message: options.message, + }, }, { init: { @@ -32,22 +36,32 @@ export const useNewChatMutation = ( }, onSuccess: async (response) => { onSuccess?.(); - router.push(`/projects/${projectId}/sessions/${response.sessionId}`); + router.push( + `/projects/${projectId}/sessions/${response.sessionProcess.sessionId}`, + ); }, }); }; -export const useResumeChatMutation = (projectId: string, sessionId: string) => { - const router = useRouter(); - +export const useContinueSessionProcessMutation = ( + projectId: string, + baseSessionId: string, +) => { return useMutation({ - mutationFn: async (options: { message: string }) => { - const response = await honoClient.api.projects[":projectId"].sessions[ - ":sessionId" - ].resume.$post( + mutationFn: async (options: { + message: string; + sessionProcessId: string; + }) => { + const response = await honoClient.api.cc["session-processes"][ + ":sessionProcessId" + ].continue.$post( { - param: { projectId, sessionId }, - json: { resumeMessage: options.message }, + param: { sessionProcessId: options.sessionProcessId }, + json: { + projectId: projectId, + baseSessionId: baseSessionId, + continueMessage: options.message, + }, }, { init: { @@ -62,10 +76,5 @@ export const useResumeChatMutation = (projectId: string, sessionId: string) => { return response.json(); }, - onSuccess: async (response) => { - if (sessionId !== response.sessionId) { - router.push(`/projects/${projectId}/sessions/${response.sessionId}`); - } - }, }); }; diff --git a/src/app/projects/[projectId]/components/newChat/NewChat.tsx b/src/app/projects/[projectId]/components/newChat/NewChat.tsx index 62f4e30..6f73a1e 100644 --- a/src/app/projects/[projectId]/components/newChat/NewChat.tsx +++ b/src/app/projects/[projectId]/components/newChat/NewChat.tsx @@ -1,16 +1,19 @@ import type { FC } from "react"; import { useConfig } from "../../../../hooks/useConfig"; -import { ChatInput, useNewChatMutation } from "../chatForm"; +import { ChatInput, useCreateSessionProcessMutation } from "../chatForm"; export const NewChat: FC<{ projectId: string; onSuccess?: () => void; }> = ({ projectId, onSuccess }) => { - const startNewChat = useNewChatMutation(projectId, onSuccess); + const createSessionProcess = useCreateSessionProcessMutation( + projectId, + onSuccess, + ); const { config } = useConfig(); const handleSubmit = async (message: string) => { - await startNewChat.mutateAsync({ message }); + await createSessionProcess.mutateAsync({ message }); }; const getPlaceholder = () => { @@ -25,8 +28,8 @@ export const NewChat: FC<{ { - const response = await honoClient.api.tasks.abort.$post({ - json: { sessionId }, + mutationFn: async (sessionProcessId: string) => { + const response = await honoClient.api.cc["session-processes"][ + ":sessionProcessId" + ].abort.$post({ + param: { sessionProcessId }, + json: { projectId }, }); if (!response.ok) { @@ -52,13 +56,18 @@ export const SessionPageContent: FC<{ return response.json(); }, }); + const sessionProcess = useSessionProcess(); - const { isRunningTask, isPausedTask } = useAliveTask(sessionId); const { currentPermissionRequest, isDialogOpen, onPermissionResponse } = usePermissionRequests(); + const relatedSessionProcess = useMemo( + () => sessionProcess.getSessionProcess(sessionId), + [sessionProcess, sessionId], + ); + // Set up task completion notifications - useTaskNotifications(isRunningTask); + useTaskNotifications(relatedSessionProcess?.status === "running"); const [previousConversationLength, setPreviousConversationLength] = useState(0); @@ -69,7 +78,7 @@ export const SessionPageContent: FC<{ // 自動スクロール処理 useEffect(() => { if ( - (isRunningTask || isPausedTask) && + relatedSessionProcess?.status === "running" && conversations.length !== previousConversationLength ) { setPreviousConversationLength(conversations.length); @@ -81,7 +90,11 @@ export const SessionPageContent: FC<{ }); } } - }, [conversations, isRunningTask, isPausedTask, previousConversationLength]); + }, [ + conversations, + relatedSessionProcess?.status, + previousConversationLength, + ]); return (

@@ -136,7 +149,7 @@ export const SessionPageContent: FC<{
- {isRunningTask && ( + {relatedSessionProcess?.status === "running" && (
@@ -148,7 +161,7 @@ export const SessionPageContent: FC<{ variant="ghost" size="sm" onClick={() => { - abortTask.mutate(sessionId); + abortTask.mutate(relatedSessionProcess.id); }} > @@ -157,7 +170,7 @@ export const SessionPageContent: FC<{
)} - {isPausedTask && ( + {relatedSessionProcess?.status === "paused" && (
@@ -169,7 +182,7 @@ export const SessionPageContent: FC<{ variant="ghost" size="sm" onClick={() => { - abortTask.mutate(sessionId); + abortTask.mutate(relatedSessionProcess.id); }} > @@ -190,7 +203,7 @@ export const SessionPageContent: FC<{ getToolResult={getToolResult} /> - {isRunningTask && ( + {relatedSessionProcess?.status === "running" && (
@@ -207,12 +220,15 @@ export const SessionPageContent: FC<{
)} - + {relatedSessionProcess !== undefined ? ( + + ) : ( + + )}
diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ContinueChat.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ContinueChat.tsx new file mode 100644 index 0000000..bdbf87f --- /dev/null +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ContinueChat.tsx @@ -0,0 +1,46 @@ +import type { FC } from "react"; +import { useConfig } from "../../../../../../hooks/useConfig"; +import { + ChatInput, + useContinueSessionProcessMutation, +} from "../../../../components/chatForm"; + +export const ContinueChat: FC<{ + projectId: string; + sessionId: string; + sessionProcessId: string; +}> = ({ projectId, sessionId, sessionProcessId }) => { + const continueSessionProcess = useContinueSessionProcessMutation( + projectId, + sessionId, + ); + const { config } = useConfig(); + + const handleSubmit = async (message: string) => { + await continueSessionProcess.mutateAsync({ message, sessionProcessId }); + }; + + const getPlaceholder = () => { + const isEnterSend = config?.enterKeyBehavior === "enter-send"; + if (isEnterSend) { + return "Type your message... (Start with / for commands, Enter to send)"; + } + return "Type your message... (Start with / for commands, Shift+Enter to send)"; + }; + + return ( +
+ +
+ ); +}; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx index 0f4397e..b2a788e 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx @@ -2,27 +2,21 @@ import type { FC } from "react"; import { useConfig } from "../../../../../../hooks/useConfig"; import { ChatInput, - useResumeChatMutation, + useCreateSessionProcessMutation, } from "../../../../components/chatForm"; export const ResumeChat: FC<{ projectId: string; sessionId: string; - isPausedTask: boolean; - isRunningTask: boolean; -}> = ({ projectId, sessionId, isPausedTask, isRunningTask }) => { - const resumeChat = useResumeChatMutation(projectId, sessionId); +}> = ({ projectId, sessionId }) => { + const createSessionProcess = useCreateSessionProcessMutation(projectId); const { config } = useConfig(); const handleSubmit = async (message: string) => { - await resumeChat.mutateAsync({ message }); - }; - - const getButtonText = () => { - if (isPausedTask || isRunningTask) { - return "Send"; - } - return "Resume"; + await createSessionProcess.mutateAsync({ + message, + baseSessionId: sessionId, + }); }; const getPlaceholder = () => { @@ -38,10 +32,10 @@ export const ResumeChat: FC<{ { +export const McpTab: FC<{ projectId: string }> = ({ projectId }) => { const queryClient = useQueryClient(); const { @@ -14,12 +14,14 @@ export const McpTab: FC = () => { isLoading, error, } = useQuery({ - queryKey: mcpListQuery.queryKey, - queryFn: mcpListQuery.queryFn, + queryKey: mcpListQuery(projectId).queryKey, + queryFn: mcpListQuery(projectId).queryFn, }); const handleReload = () => { - queryClient.invalidateQueries({ queryKey: mcpListQuery.queryKey }); + queryClient.invalidateQueries({ + queryKey: mcpListQuery(projectId).queryKey, + }); }; return ( diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx index d49066f..dca0e3c 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx @@ -87,7 +87,7 @@ export const MobileSidebar: FC = ({ /> ); case "mcp": - return ; + return ; case "settings": return ; default: diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx index 5b4a9f0..4e08949 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx @@ -69,7 +69,7 @@ export const SessionSidebar: FC<{ /> ); case "mcp": - return ; + return ; case "settings": return ; default: diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx index 221090b..fd99fbd 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx @@ -10,7 +10,7 @@ import { cn } from "@/lib/utils"; import type { Session } from "../../../../../../../server/service/types"; import { NewChatModal } from "../../../../components/newChat/NewChatModal"; import { firstCommandToTitle } from "../../../../services/firstCommandToTitle"; -import { aliveTasksAtom } from "../../store/aliveTasksAtom"; +import { sessionProcessesAtom } from "../../store/sessionProcessesAtom"; export const SessionsTab: FC<{ sessions: Session[]; @@ -27,18 +27,22 @@ export const SessionsTab: FC<{ isFetchingNextPage, onLoadMore, }) => { - const aliveTasks = useAtomValue(aliveTasksAtom); + const sessionProcesses = useAtomValue(sessionProcessesAtom); // Sort sessions: Running > Paused > Others, then by lastModifiedAt (newest first) const sortedSessions = [...sessions].sort((a, b) => { - const aTask = aliveTasks.find((task) => task.sessionId === a.id); - const bTask = aliveTasks.find((task) => task.sessionId === b.id); + const aProcess = sessionProcesses.find( + (process) => process.sessionId === a.id, + ); + const bProcess = sessionProcesses.find( + (process) => process.sessionId === b.id, + ); - const aStatus = aTask?.status; - const bStatus = bTask?.status; + const aStatus = aProcess?.status; + const bStatus = bProcess?.status; // Define priority: running = 0, paused = 1, others = 2 - const getPriority = (status: string | undefined) => { + const getPriority = (status: "paused" | "running" | undefined) => { if (status === "running") return 0; if (status === "paused") return 1; return 2; @@ -86,11 +90,11 @@ export const SessionsTab: FC<{ ? firstCommandToTitle(session.meta.firstCommand) : session.id; - const aliveTask = aliveTasks.find( + const sessionProcess = sessionProcesses.find( (task) => task.sessionId === session.id, ); - const isRunning = aliveTask?.status === "running"; - const isPaused = aliveTask?.status === "paused"; + const isRunning = sessionProcess?.status === "running"; + const isPaused = sessionProcess?.status === "paused"; return ( { - const [aliveTasks, setAliveTasks] = useAtom(aliveTasksAtom); - - useQuery({ - queryKey: aliveTasksQuery.queryKey, - queryFn: async () => { - const { aliveTasks } = await aliveTasksQuery.queryFn(); - setAliveTasks(aliveTasks); - return aliveTasks; - }, - refetchOnReconnect: true, - }); - - const taskInfo = useMemo(() => { - const aliveTask = aliveTasks.find((task) => task.sessionId === sessionId); - - return { - aliveTask: aliveTasks.find((task) => task.sessionId === sessionId), - isRunningTask: aliveTask?.status === "running", - isPausedTask: aliveTask?.status === "paused", - } as const; - }, [aliveTasks, sessionId]); - - return taskInfo; -}; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSession.ts b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSession.ts index 961de3a..88295a2 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSession.ts +++ b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSession.ts @@ -3,9 +3,13 @@ import { useSessionQuery } from "./useSessionQuery"; export const useSession = (projectId: string, sessionId: string) => { const query = useSessionQuery(projectId, sessionId); + const session = query.data?.session; + if (session === undefined || session === null) { + throw new Error("Session not found"); + } const toolResultMap = useMemo(() => { - const entries = query.data.session.conversations.flatMap((conversation) => { + const entries = session.conversations.flatMap((conversation) => { if (conversation.type !== "user") { return []; } @@ -28,7 +32,7 @@ export const useSession = (projectId: string, sessionId: string) => { }); return new Map(entries); - }, [query.data.session.conversations]); + }, [session.conversations]); const getToolResult = useCallback( (toolUseId: string) => { @@ -38,8 +42,8 @@ export const useSession = (projectId: string, sessionId: string) => { ); return { - session: query.data.session, - conversations: query.data.session.conversations, + session, + conversations: session.conversations, getToolResult, }; }; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionProcess.ts b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionProcess.ts new file mode 100644 index 0000000..52203bd --- /dev/null +++ b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionProcess.ts @@ -0,0 +1,23 @@ +import { useAtomValue } from "jotai"; +import { useCallback } from "react"; +import { sessionProcessesAtom } from "../store/sessionProcessesAtom"; + +export const useSessionProcess = () => { + const sessionProcesses = useAtomValue(sessionProcessesAtom); + + const getSessionProcess = useCallback( + (sessionId: string) => { + const targetProcess = sessionProcesses.find( + (process) => process.sessionId === sessionId, + ); + + return targetProcess; + }, + [sessionProcesses], + ); + + return { + sessionProcesses, + getSessionProcess, + }; +}; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom.ts b/src/app/projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom.ts deleted file mode 100644 index 90dd9de..0000000 --- a/src/app/projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { atom } from "jotai"; -import type { SerializableAliveTask } from "../../../../../../server/service/claude-code/types"; - -export const aliveTasksAtom = atom([]); diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom.ts b/src/app/projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom.ts new file mode 100644 index 0000000..7cefa5c --- /dev/null +++ b/src/app/projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom.ts @@ -0,0 +1,4 @@ +import { atom } from "jotai"; +import type { PublicSessionProcess } from "../../../../../../types/session-process"; + +export const sessionProcessesAtom = atom([]); diff --git a/src/hooks/usePermissionRequests.ts b/src/hooks/usePermissionRequests.ts index 17a4cb9..d28f485 100644 --- a/src/hooks/usePermissionRequests.ts +++ b/src/hooks/usePermissionRequests.ts @@ -25,7 +25,7 @@ export const usePermissionRequests = () => { const handlePermissionResponse = useCallback( async (response: PermissionResponse) => { try { - const apiResponse = await honoClient.api.tasks[ + const apiResponse = await honoClient.api.cc[ "permission-response" ].$post({ json: response, diff --git a/src/lib/api/queries.ts b/src/lib/api/queries.ts index 5d754d5..2a75bdc 100644 --- a/src/lib/api/queries.ts +++ b/src/lib/api/queries.ts @@ -74,10 +74,10 @@ export const claudeCommandsQuery = (projectId: string) => }, }) as const; -export const aliveTasksQuery = { - queryKey: ["aliveTasks"], +export const sessionProcessesQuery = { + queryKey: ["sessionProcesses"], queryFn: async () => { - const response = await honoClient.api.tasks.alive.$get({}); + const response = await honoClient.api.cc["session-processes"].$get({}); if (!response.ok) { throw new Error(`Failed to fetch alive tasks: ${response.statusText}`); @@ -123,18 +123,23 @@ export const gitCommitsQuery = (projectId: string) => }, }) as const; -export const mcpListQuery = { - queryKey: ["mcp", "list"], - queryFn: async () => { - const response = await honoClient.api.mcp.list.$get(); +export const mcpListQuery = (projectId: string) => + ({ + queryKey: ["mcp", "list", projectId], + queryFn: async () => { + const response = await honoClient.api.projects[ + ":projectId" + ].mcp.list.$get({ + param: { projectId }, + }); - if (!response.ok) { - throw new Error(`Failed to fetch MCP list: ${response.statusText}`); - } + if (!response.ok) { + throw new Error(`Failed to fetch MCP list: ${response.statusText}`); + } - return await response.json(); - }, -} as const; + return await response.json(); + }, + }) as const; export const fileCompletionQuery = (projectId: string, basePath: string) => ({ @@ -151,7 +156,7 @@ export const fileCompletionQuery = (projectId: string, basePath: string) => throw new Error("Failed to fetch file completion"); } - return response.json(); + return await response.json(); }, }) as const; diff --git a/src/lib/controllablePromise.ts b/src/lib/controllablePromise.ts new file mode 100644 index 0000000..98d44b0 --- /dev/null +++ b/src/lib/controllablePromise.ts @@ -0,0 +1,25 @@ +export type ControllablePromise = { + readonly promise: Promise; + readonly resolve: (value: T) => void; + readonly reject: (reason?: unknown) => void; +}; + +export const controllablePromise = (): ControllablePromise => { + let promiseResolve: ((value: T) => void) | undefined; + let promiseReject: ((reason?: unknown) => void) | undefined; + + const promise = new Promise((resolve, reject) => { + promiseResolve = resolve; + promiseReject = reject; + }); + + if (!promiseResolve || !promiseReject) { + throw new Error("Illegal state: Promise not created"); + } + + return { + promise, + resolve: promiseResolve, + reject: promiseReject, + } as const; +}; diff --git a/src/lib/conversation-schema/entry/UserEntrySchema.ts b/src/lib/conversation-schema/entry/UserEntrySchema.ts index 6cc7b77..99c86c5 100644 --- a/src/lib/conversation-schema/entry/UserEntrySchema.ts +++ b/src/lib/conversation-schema/entry/UserEntrySchema.ts @@ -9,3 +9,5 @@ export const UserEntrySchema = BaseEntrySchema.extend({ // required message: UserMessageSchema, }); + +export type UserEntry = z.infer; diff --git a/src/server/hono/initialize.test.ts b/src/server/hono/initialize.test.ts new file mode 100644 index 0000000..e3515a7 --- /dev/null +++ b/src/server/hono/initialize.test.ts @@ -0,0 +1,362 @@ +import { Effect, Layer, Ref } from "effect"; +import { describe, expect, it, vi } from "vitest"; +import { EventBus } from "../service/events/EventBus"; +import { FileWatcherService } from "../service/events/fileWatcher"; +import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration"; +import { ProjectMetaService } from "../service/project/ProjectMetaService"; +import { ProjectRepository } from "../service/project/ProjectRepository"; +import { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase"; +import { SessionMetaService } from "../service/session/SessionMetaService"; +import { SessionRepository } from "../service/session/SessionRepository"; +import { InitializeService } from "./initialize"; + +describe("InitializeService", () => { + const createMockProjectRepository = ( + projects: Array<{ + id: string; + claudeProjectPath: string; + lastModifiedAt: Date; + meta: { + projectName: string | null; + projectPath: string | null; + sessionCount: number; + }; + }> = [], + ) => + Layer.succeed(ProjectRepository, { + getProjects: () => Effect.succeed({ projects }), + getProject: () => Effect.fail(new Error("Not implemented in mock")), + }); + + const createMockSessionRepository = ( + sessions: Array<{ + id: string; + jsonlFilePath: string; + lastModifiedAt: Date; + meta: { + messageCount: number; + firstCommand: { + kind: "command"; + commandName: string; + commandArgs?: string; + commandMessage?: string; + } | null; + }; + }> = [], + getSessionsCb?: (projectId: string) => void, + ) => + Layer.succeed(SessionRepository, { + getSessions: (projectId: string) => { + if (getSessionsCb) getSessionsCb(projectId); + return Effect.succeed({ sessions }); + }, + getSession: () => Effect.fail(new Error("Not implemented in mock")), + }); + + const createMockProjectMetaService = () => + Layer.succeed(ProjectMetaService, { + getProjectMeta: () => + Effect.succeed({ + projectName: "Test Project", + projectPath: "/path/to/project", + sessionCount: 0, + }), + invalidateProject: () => Effect.void, + }); + + const createMockSessionMetaService = () => + Layer.succeed(SessionMetaService, { + getSessionMeta: () => + Effect.succeed({ + messageCount: 0, + firstCommand: null, + }), + invalidateSession: () => Effect.void, + }); + + const createTestLayer = ( + mockProjectRepositoryLayer: Layer.Layer< + ProjectRepository, + never, + never + > = createMockProjectRepository(), + mockSessionRepositoryLayer: Layer.Layer< + SessionRepository, + never, + never + > = createMockSessionRepository(), + ) => { + // Provide EventBus first since FileWatcherService depends on it + const fileWatcherWithEventBus = FileWatcherService.Live.pipe( + Layer.provide(EventBus.Live), + ); + + // Merge all dependencies + const allDependencies = Layer.mergeAll( + EventBus.Live, + fileWatcherWithEventBus, + mockProjectRepositoryLayer, + mockSessionRepositoryLayer, + createMockProjectMetaService(), + createMockSessionMetaService(), + VirtualConversationDatabase.Live, + ); + + // Provide dependencies to InitializeService.Live and expose all services + return Layer.provide(InitializeService.Live, allDependencies).pipe( + Layer.merge(allDependencies), + ); + }; + + describe("basic initialization process", () => { + it("service initialization succeeds", async () => { + const mockProjectRepositoryLayer = createMockProjectRepository([ + { + id: "project-1", + claudeProjectPath: "/path/to/project-1", + lastModifiedAt: new Date(), + meta: { + projectName: "Project 1", + projectPath: "/path/to/project-1", + sessionCount: 2, + }, + }, + ]); + + const mockSessionRepositoryLayer = createMockSessionRepository([ + { + id: "session-1", + jsonlFilePath: "/path/to/session-1.jsonl", + lastModifiedAt: new Date(), + meta: { + messageCount: 5, + firstCommand: { + kind: "command", + commandName: "test", + }, + }, + }, + { + id: "session-2", + jsonlFilePath: "/path/to/session-2.jsonl", + lastModifiedAt: new Date(), + meta: { + messageCount: 3, + firstCommand: null, + }, + }, + ]); + + const program = Effect.gen(function* () { + const initialize = yield* InitializeService; + return yield* initialize.startInitialization(); + }); + + const testLayer = createTestLayer( + mockProjectRepositoryLayer, + mockSessionRepositoryLayer, + ); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(testLayer)), + ); + + expect(result).toBeUndefined(); + }); + + it("file watcher is started", async () => { + const program = Effect.gen(function* () { + const initialize = yield* InitializeService; + + yield* initialize.startInitialization(); + + // Verify file watcher is started + // (In actual implementation, verify that startWatching is called) + return "file watcher started"; + }); + + const testLayer = createTestLayer(); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(testLayer)), + ); + + expect(result).toBe("file watcher started"); + }); + }); + + describe("event processing", () => { + it("receives sessionChanged event", async () => { + const program = Effect.gen(function* () { + const initialize = yield* InitializeService; + const eventBus = yield* EventBus; + const eventsRef = yield* Ref.make< + Array + >([]); + + // Set up listener for sessionChanged event + yield* eventBus.on("sessionChanged", (event) => { + Effect.runSync(Ref.update(eventsRef, (events) => [...events, event])); + }); + + yield* initialize.startInitialization(); + + // Emit event + yield* eventBus.emit("sessionChanged", { + projectId: "project-1", + sessionId: "session-1", + }); + + // Wait a bit for event to be processed + yield* Effect.sleep("50 millis"); + + const events = yield* Ref.get(eventsRef); + return events; + }); + + const testLayer = createTestLayer(); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(testLayer)), + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + projectId: "project-1", + sessionId: "session-1", + }); + }); + + it("heartbeat event is emitted periodically", async () => { + const program = Effect.gen(function* () { + const initialize = yield* InitializeService; + const eventBus = yield* EventBus; + const heartbeatCountRef = yield* Ref.make(0); + + // Set up listener for heartbeat event + yield* eventBus.on("heartbeat", () => + Effect.runSync(Ref.update(heartbeatCountRef, (count) => count + 1)), + ); + + yield* initialize.startInitialization(); + + // Wait a bit to verify heartbeat is emitted + // (In actual tests, should use mock to shorten time) + yield* Effect.sleep("100 millis"); + + const count = yield* Ref.get(heartbeatCountRef); + return count; + }); + + const testLayer = createTestLayer(); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(testLayer)), + ); + + // heartbeat is emitted immediately once first, then every 10 seconds + // At 100ms, only the first one is emitted + expect(result).toBeGreaterThanOrEqual(1); + }); + }); + + describe("cache initialization", () => { + it("project and session caches are initialized", async () => { + const getProjectsCalled = vi.fn(); + const getSessionsCalled = vi.fn(); + + const mockProjectRepositoryLayer = Layer.succeed(ProjectRepository, { + getProjects: () => { + getProjectsCalled(); + return Effect.succeed({ + projects: [ + { + id: "project-1", + claudeProjectPath: "/path/to/project-1", + lastModifiedAt: new Date(), + meta: { + projectName: "Project 1", + projectPath: "/path/to/project-1", + sessionCount: 2, + }, + }, + ], + }); + }, + getProject: () => Effect.fail(new Error("Not implemented in mock")), + }); + + const mockSessionRepositoryLayer = createMockSessionRepository( + [ + { + id: "session-1", + jsonlFilePath: "/path/to/session-1.jsonl", + lastModifiedAt: new Date(), + meta: { + messageCount: 5, + firstCommand: { + kind: "command", + commandName: "test", + }, + }, + }, + ], + getSessionsCalled, + ); + + const program = Effect.gen(function* () { + const initialize = yield* InitializeService; + yield* initialize.startInitialization(); + }); + + const testLayer = createTestLayer( + mockProjectRepositoryLayer, + mockSessionRepositoryLayer, + ); + + await Effect.runPromise(program.pipe(Effect.provide(testLayer))); + + expect(getProjectsCalled).toHaveBeenCalledTimes(1); + expect(getSessionsCalled).toHaveBeenCalledTimes(1); + expect(getSessionsCalled).toHaveBeenCalledWith("project-1"); + }); + + it("doesn't throw error even if cache initialization fails", async () => { + const mockProjectRepositoryLayer = Layer.succeed(ProjectRepository, { + getProjects: () => Effect.fail(new Error("Failed to get projects")), + getProject: () => Effect.fail(new Error("Not implemented in mock")), + }); + + const program = Effect.gen(function* () { + const initialize = yield* InitializeService; + return yield* initialize.startInitialization(); + }); + + const testLayer = createTestLayer(mockProjectRepositoryLayer); + + // Completes without throwing error + await expect( + Effect.runPromise(program.pipe(Effect.provide(testLayer))), + ).resolves.toBeUndefined(); + }); + }); + + describe("cleanup", () => { + it("resources are cleaned up with stopCleanup", async () => { + const program = Effect.gen(function* () { + const initialize = yield* InitializeService; + yield* initialize.startInitialization(); + yield* initialize.stopCleanup(); + return "cleaned up"; + }); + + const testLayer = createTestLayer(); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(testLayer)), + ); + + expect(result).toBe("cleaned up"); + }); + }); +}); diff --git a/src/server/hono/initialize.ts b/src/server/hono/initialize.ts index 5e992b2..ab11261 100644 --- a/src/server/hono/initialize.ts +++ b/src/server/hono/initialize.ts @@ -1,55 +1,144 @@ -import prexit from "prexit"; -import { claudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; -import { eventBus } from "../service/events/EventBus"; -import { fileWatcher } from "../service/events/fileWatcher"; +import { Context, Effect, Layer, Ref, Schedule } from "effect"; +import { EventBus } from "../service/events/EventBus"; +import { FileWatcherService } from "../service/events/fileWatcher"; import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration"; -import type { ProjectRepository } from "../service/project/ProjectRepository"; -import { projectMetaStorage } from "../service/project/projectMetaStorage"; -import type { SessionRepository } from "../service/session/SessionRepository"; -import { sessionMetaStorage } from "../service/session/sessionMetaStorage"; +import { ProjectMetaService } from "../service/project/ProjectMetaService"; +import { ProjectRepository } from "../service/project/ProjectRepository"; +import { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase"; +import { SessionMetaService } from "../service/session/SessionMetaService"; +import { SessionRepository } from "../service/session/SessionRepository"; -export const initialize = async (deps: { - sessionRepository: SessionRepository; - projectRepository: ProjectRepository; -}): Promise => { - fileWatcher.startWatching(); +interface InitializeServiceInterface { + readonly startInitialization: () => Effect.Effect; + readonly stopCleanup: () => Effect.Effect; +} - const intervalId = setInterval(() => { - eventBus.emit("heartbeat", {}); - }, 10 * 1000); +export class InitializeService extends Context.Tag("InitializeService")< + InitializeService, + InitializeServiceInterface +>() { + static Live = Layer.effect( + this, + Effect.gen(function* () { + const eventBus = yield* EventBus; + const fileWatcher = yield* FileWatcherService; + const projectRepository = yield* ProjectRepository; + const sessionRepository = yield* SessionRepository; + const projectMetaService = yield* ProjectMetaService; + const sessionMetaService = yield* SessionMetaService; + const virtualConversationDatabase = yield* VirtualConversationDatabase; - const onSessionChanged = ( - event: InternalEventDeclaration["sessionChanged"], - ) => { - projectMetaStorage.invalidateProject(event.projectId); - sessionMetaStorage.invalidateSession(event.projectId, event.sessionId); - }; + // 状態管理用の Ref + const listenersRef = yield* Ref.make<{ + sessionProcessChanged?: + | ((event: InternalEventDeclaration["sessionProcessChanged"]) => void) + | null; + sessionChanged?: + | ((event: InternalEventDeclaration["sessionChanged"]) => void) + | null; + }>({}); - eventBus.on("sessionChanged", onSessionChanged); + const startInitialization = (): Effect.Effect => { + return Effect.gen(function* () { + // ファイルウォッチャーを開始 + yield* fileWatcher.startWatching(); - try { - console.log("Initializing projects cache"); - const { projects } = await deps.projectRepository.getProjects(); - console.log(`${projects.length} projects cache initialized`); + // ハートビートを定期的に送信 + const daemon = Effect.repeat( + eventBus.emit("heartbeat", {}), + Schedule.fixed("10 seconds"), + ); - console.log("Initializing sessions cache"); - const results = await Promise.all( - projects.map((project) => deps.sessionRepository.getSessions(project.id)), - ); - console.log( - `${results.reduce( - (s, { sessions }) => s + sessions.length, - 0, - )} sessions cache initialized`, - ); - } catch { - // do nothing - } + console.log("start heartbeat"); + yield* Effect.forkDaemon(daemon); + console.log("after starting heartbeat fork"); - prexit(() => { - clearInterval(intervalId); - eventBus.off("sessionChanged", onSessionChanged); - fileWatcher.stop(); - claudeCodeTaskController.abortAllTasks(); - }); -}; + // sessionChanged イベントのリスナーを登録 + const onSessionChanged = ( + event: InternalEventDeclaration["sessionChanged"], + ) => { + Effect.runFork( + projectMetaService.invalidateProject(event.projectId), + ); + + Effect.runFork( + sessionMetaService.invalidateSession( + event.projectId, + event.sessionId, + ), + ); + }; + + const onSessionProcessChanged = ( + event: InternalEventDeclaration["sessionProcessChanged"], + ) => { + if ( + (event.changed.type === "completed" || + event.changed.type === "paused") && + event.changed.sessionId !== undefined + ) { + Effect.runFork( + virtualConversationDatabase.deleteVirtualConversations( + event.changed.sessionId, + ), + ); + return; + } + }; + + yield* Ref.set(listenersRef, { + sessionChanged: onSessionChanged, + sessionProcessChanged: onSessionProcessChanged, + }); + yield* eventBus.on("sessionChanged", onSessionChanged); + yield* eventBus.on("sessionProcessChanged", onSessionProcessChanged); + + yield* Effect.gen(function* () { + console.log("Initializing projects cache"); + const { projects } = yield* projectRepository.getProjects(); + console.log(`${projects.length} projects cache initialized`); + + console.log("Initializing sessions cache"); + const results = yield* Effect.all( + projects.map((project) => + sessionRepository.getSessions(project.id), + ), + { concurrency: "unbounded" }, + ); + const totalSessions = results.reduce( + (s, { sessions }) => s + sessions.length, + 0, + ); + console.log(`${totalSessions} sessions cache initialized`); + }).pipe( + Effect.catchAll(() => Effect.void), + Effect.withSpan("initialize-cache"), + ); + }).pipe(Effect.withSpan("start-initialization")) as Effect.Effect; + }; + + const stopCleanup = (): Effect.Effect => + Effect.gen(function* () { + const listeners = yield* Ref.get(listenersRef); + if (listeners.sessionChanged) { + yield* eventBus.off("sessionChanged", listeners.sessionChanged); + } + + if (listeners.sessionProcessChanged) { + yield* eventBus.off( + "sessionProcessChanged", + listeners.sessionProcessChanged, + ); + } + + yield* Ref.set(listenersRef, {}); + yield* fileWatcher.stop(); + }); + + return { + startInitialization, + stopCleanup, + } satisfies InitializeServiceInterface; + }), + ); +} diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index 9521f74..3a809ee 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -1,486 +1,692 @@ import { readdir } from "node:fs/promises"; import { resolve } from "node:path"; +import type { CommandExecutor, FileSystem, Path } from "@effect/platform"; import { zValidator } from "@hono/zod-validator"; +import { Effect, Runtime } from "effect"; import { setCookie } from "hono/cookie"; import { streamSSE } from "hono/streaming"; +import prexit from "prexit"; import { z } from "zod"; -import { type Config, configSchema } from "../config/config"; +import type { PublicSessionProcess } from "../../types/session-process"; +import { configSchema } from "../config/config"; import { env } from "../lib/env"; -import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; -import type { SerializableAliveTask } from "../service/claude-code/types"; +import { ClaudeCodeLifeCycleService } from "../service/claude-code/ClaudeCodeLifeCycleService"; +import { ClaudeCodePermissionService } from "../service/claude-code/ClaudeCodePermissionService"; import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE"; -import { eventBus } from "../service/events/EventBus"; +import { EventBus } from "../service/events/EventBus"; import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration"; -import { writeTypeSafeSSE } from "../service/events/typeSafeSSE"; +import { TypeSafeSSE } from "../service/events/typeSafeSSE"; import { getFileCompletion } from "../service/file-completion/getFileCompletion"; import { getBranches } from "../service/git/getBranches"; import { getCommits } from "../service/git/getCommits"; import { getDiff } from "../service/git/getDiff"; import { getMcpList } from "../service/mcp/getMcpList"; import { claudeCommandsDirPath } from "../service/paths"; +import type { ProjectMetaService } from "../service/project/ProjectMetaService"; import { ProjectRepository } from "../service/project/ProjectRepository"; +import type { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase"; +import type { SessionMetaService } from "../service/session/SessionMetaService"; import { SessionRepository } from "../service/session/SessionRepository"; import type { HonoAppType } from "./app"; -import { initialize } from "./initialize"; +import { InitializeService } from "./initialize"; import { configMiddleware } from "./middleware/config.middleware"; -export const routes = async (app: HonoAppType) => { - const sessionRepository = new SessionRepository(); - const projectRepository = new ProjectRepository(); +export const routes = (app: HonoAppType) => + Effect.gen(function* () { + const sessionRepository = yield* SessionRepository; + const projectRepository = yield* ProjectRepository; + const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService; + const claudeCodePermissionService = yield* ClaudeCodePermissionService; + const initializeService = yield* InitializeService; + const eventBus = yield* EventBus; - const fileWatcher = getFileWatcher(); - const eventBus = getEventBus(); + const runtime = yield* Effect.runtime< + | ProjectMetaService + | SessionMetaService + | VirtualConversationDatabase + | FileSystem.FileSystem + | Path.Path + | CommandExecutor.CommandExecutor + >(); - if (env.get("NEXT_PHASE") !== "phase-production-build") { - fileWatcher.startWatching(); + if (env.get("NEXT_PHASE") !== "phase-production-build") { + yield* initializeService.startInitialization(); - setInterval(() => { - eventBus.emit("heartbeat", {}); - }, 10 * 1000); - } + prexit(async () => { + await Runtime.runPromise(runtime)(initializeService.stopCleanup()); + }); + } - return ( - app - // middleware - .use(configMiddleware) - .use(async (c, next) => { - claudeCodeTaskController.updateConfig(c.get("config")); - await next(); - }) + return ( + app + // middleware + .use(configMiddleware) + .use(async (_c, next) => { + await next(); + }) - // routes - .get("/config", async (c) => { - return c.json({ - config: c.get("config"), - }); - }) + // routes + .get("/config", async (c) => { + return c.json({ + config: c.get("config"), + }); + }) - .put("/config", zValidator("json", configSchema), async (c) => { - const { ...config } = c.req.valid("json"); + .put("/config", zValidator("json", configSchema), async (c) => { + const { ...config } = c.req.valid("json"); - setCookie(c, "ccv-config", JSON.stringify(config)); + setCookie(c, "ccv-config", JSON.stringify(config)); - return c.json({ - config, - }); - }) + return c.json({ + config, + }); + }) - .get("/projects", async (c) => { - const { projects } = await projectRepository.getProjects(); - return c.json({ projects }); - }) + .get("/projects", async (c) => { + const program = Effect.gen(function* () { + return yield* projectRepository.getProjects(); + }); - .get( - "/projects/:projectId", - zValidator("query", z.object({ cursor: z.string().optional() })), - async (c) => { - const { projectId } = c.req.param(); - const { cursor } = c.req.valid("query"); + const { projects } = await Runtime.runPromise(runtime)(program); - const [{ project }, { sessions, nextCursor }] = await Promise.all([ - projectRepository.getProject(projectId), - sessionRepository - .getSessions(projectId, { cursor }) - .then(({ sessions }) => { - let filteredSessions = sessions; + return c.json({ projects }); + }) - // Filter sessions based on hideNoUserMessageSession setting - if (c.get("config").hideNoUserMessageSession) { - filteredSessions = filteredSessions.filter((session) => { - return session.meta.firstCommand !== null; - }); - } + .get( + "/projects/:projectId", + zValidator("query", z.object({ cursor: z.string().optional() })), + async (c) => { + const { projectId } = c.req.param(); + const { cursor } = c.req.valid("query"); + const config = c.get("config"); - // Unify sessions with same title if unifySameTitleSession is enabled - if (c.get("config").unifySameTitleSession) { - const sessionMap = new Map< - string, - (typeof filteredSessions)[0] - >(); + const program = Effect.gen(function* () { + const { project } = + yield* projectRepository.getProject(projectId); + const { sessions } = yield* sessionRepository.getSessions( + projectId, + { cursor }, + ); - for (const session of filteredSessions) { - // Generate title for comparison - const title = - session.meta.firstCommand !== null - ? (() => { - const cmd = session.meta.firstCommand; - switch (cmd.kind) { - case "command": - return cmd.commandArgs === undefined - ? cmd.commandName - : `${cmd.commandName} ${cmd.commandArgs}`; - case "local-command": - return cmd.stdout; - case "text": - return cmd.content; - default: - return session.id; - } - })() - : session.id; + let filteredSessions = sessions; - const existingSession = sessionMap.get(title); - if (existingSession) { - // Keep the session with the latest modification date + // Filter sessions based on hideNoUserMessageSession setting + if (config.hideNoUserMessageSession) { + filteredSessions = filteredSessions.filter((session) => { + return session.meta.firstCommand !== null; + }); + } + + // Unify sessions with same title if unifySameTitleSession is enabled + if (config.unifySameTitleSession) { + const sessionMap = new Map< + string, + (typeof filteredSessions)[0] + >(); + + for (const session of filteredSessions) { + // Generate title for comparison + const title = + session.meta.firstCommand !== null + ? (() => { + const cmd = session.meta.firstCommand; + switch (cmd.kind) { + case "command": + return cmd.commandArgs === undefined + ? cmd.commandName + : `${cmd.commandName} ${cmd.commandArgs}`; + case "local-command": + return cmd.stdout; + case "text": + return cmd.content; + default: + return session.id; + } + })() + : session.id; + + const existingSession = sessionMap.get(title); + if (existingSession) { + // Keep the session with the latest modification date + if ( + session.lastModifiedAt && + existingSession.lastModifiedAt + ) { if ( - session.lastModifiedAt && - existingSession.lastModifiedAt - ) { - if ( - session.lastModifiedAt > - existingSession.lastModifiedAt - ) { - sessionMap.set(title, session); - } - } else if ( - session.lastModifiedAt && - !existingSession.lastModifiedAt + session.lastModifiedAt > existingSession.lastModifiedAt ) { sessionMap.set(title, session); } - // If no modification dates, keep the existing one - } else { + } else if ( + session.lastModifiedAt && + !existingSession.lastModifiedAt + ) { sessionMap.set(title, session); } + // If no modification dates, keep the existing one + } else { + sessionMap.set(title, session); } - - filteredSessions = Array.from(sessionMap.values()); } - return { - sessions: filteredSessions, - nextCursor: sessions.at(-1)?.id, - }; - }), - ] as const); - - return c.json({ project, sessions, nextCursor }); - }, - ) - - .get("/projects/:projectId/sessions/:sessionId", async (c) => { - const { projectId, sessionId } = c.req.param(); - const { session } = await sessionRepository.getSession( - projectId, - sessionId, - ); - return c.json({ session }); - }) - - .get( - "/projects/:projectId/file-completion", - zValidator( - "query", - z.object({ - basePath: z.string().optional().default("/"), - }), - ), - async (c) => { - const { projectId } = c.req.param(); - const { basePath } = c.req.valid("query"); - - const { project } = await projectRepository.getProject(projectId); - - if (project.meta.projectPath === null) { - return c.json({ error: "Project path not found" }, 400); - } - - try { - const result = await getFileCompletion( - project.meta.projectPath, - basePath, - ); - return c.json(result); - } catch (error) { - console.error("File completion error:", error); - return c.json({ error: "Failed to get file completion" }, 500); - } - }, - ) - - .get("/projects/:projectId/claude-commands", async (c) => { - const { projectId } = c.req.param(); - const { project } = await projectRepository.getProject(projectId); - - const [globalCommands, projectCommands] = await Promise.allSettled([ - readdir(claudeCommandsDirPath, { - withFileTypes: true, - }).then((dirents) => - dirents - .filter((d) => d.isFile() && d.name.endsWith(".md")) - .map((d) => d.name.replace(/\.md$/, "")), - ), - project.meta.projectPath !== null - ? readdir( - resolve(project.meta.projectPath, ".claude", "commands"), - { - withFileTypes: true, - }, - ).then((dirents) => - dirents - .filter((d) => d.isFile() && d.name.endsWith(".md")) - .map((d) => d.name.replace(/\.md$/, "")), - ) - : [], - ]); - - return c.json({ - globalCommands: - globalCommands.status === "fulfilled" ? globalCommands.value : [], - projectCommands: - projectCommands.status === "fulfilled" ? projectCommands.value : [], - defaultCommands: ["init", "compact"], - }); - }) - - .get("/projects/:projectId/git/branches", async (c) => { - const { projectId } = c.req.param(); - const { project } = await projectRepository.getProject(projectId); - - if (project.meta.projectPath === null) { - return c.json({ error: "Project path not found" }, 400); - } - - try { - const result = await getBranches(project.meta.projectPath); - return c.json(result); - } catch (error) { - console.error("Get branches error:", error); - if (error instanceof Error) { - return c.json({ error: error.message }, 400); - } - return c.json({ error: "Failed to get branches" }, 500); - } - }) - - .get("/projects/:projectId/git/commits", async (c) => { - const { projectId } = c.req.param(); - const { project } = await projectRepository.getProject(projectId); - - if (project.meta.projectPath === null) { - return c.json({ error: "Project path not found" }, 400); - } - - try { - const result = await getCommits(project.meta.projectPath); - return c.json(result); - } catch (error) { - console.error("Get commits error:", error); - if (error instanceof Error) { - return c.json({ error: error.message }, 400); - } - return c.json({ error: "Failed to get commits" }, 500); - } - }) - - .post( - "/projects/:projectId/git/diff", - zValidator( - "json", - z.object({ - fromRef: z.string().min(1, "fromRef is required"), - toRef: z.string().min(1, "toRef is required"), - }), - ), - async (c) => { - const { projectId } = c.req.param(); - const { fromRef, toRef } = c.req.valid("json"); - const { project } = await projectRepository.getProject(projectId); - - if (project.meta.projectPath === null) { - return c.json({ error: "Project path not found" }, 400); - } - - try { - const result = await getDiff( - project.meta.projectPath, - fromRef, - toRef, - ); - return c.json(result); - } catch (error) { - console.error("Get diff error:", error); - if (error instanceof Error) { - return c.json({ error: error.message }, 400); - } - return c.json({ error: "Failed to get diff" }, 500); - } - }, - ) - - .get("/mcp/list", async (c) => { - const { servers } = await getMcpList(); - return c.json({ servers }); - }) - - .post( - "/projects/:projectId/new-session", - zValidator( - "json", - z.object({ - message: z.string(), - }), - ), - async (c) => { - const { projectId } = c.req.param(); - const { message } = c.req.valid("json"); - const { project } = await projectRepository.getProject(projectId); - - if (project.meta.projectPath === null) { - return c.json({ error: "Project path not found" }, 400); - } - - const task = await claudeCodeTaskController.startOrContinueTask( - { - projectId, - cwd: project.meta.projectPath, - }, - message, - ); - - return c.json({ - taskId: task.id, - sessionId: task.sessionId, - }); - }, - ) - - .post( - "/projects/:projectId/sessions/:sessionId/resume", - zValidator( - "json", - z.object({ - resumeMessage: z.string(), - }), - ), - async (c) => { - const { projectId, sessionId } = c.req.param(); - const { resumeMessage } = c.req.valid("json"); - const { project } = await projectRepository.getProject(projectId); - - if (project.meta.projectPath === null) { - return c.json({ error: "Project path not found" }, 400); - } - - const task = await claudeCodeTaskController.startOrContinueTask( - { - projectId, - sessionId, - cwd: project.meta.projectPath, - }, - resumeMessage, - ); - - return c.json({ - taskId: task.id, - sessionId: task.sessionId, - }); - }, - ) - - .get("/tasks/alive", async (c) => { - return c.json({ - aliveTasks: claudeCodeTaskController.aliveTasks.map( - (task): SerializableAliveTask => ({ - id: task.id, - status: task.status, - sessionId: task.sessionId, - }), - ), - }); - }) - - .post( - "/tasks/abort", - zValidator("json", z.object({ sessionId: z.string() })), - async (c) => { - const { sessionId } = c.req.valid("json"); - claudeCodeTaskController.abortTask(sessionId); - return c.json({ message: "Task aborted" }); - }, - ) - - .post( - "/tasks/permission-response", - zValidator( - "json", - z.object({ - permissionRequestId: z.string(), - decision: z.enum(["allow", "deny"]), - }), - ), - async (c) => { - const permissionResponse = c.req.valid("json"); - claudeCodeTaskController.respondToPermissionRequest( - permissionResponse, - ); - return c.json({ message: "Permission response received" }); - }, - ) - - .get("/sse", async (c) => { - return streamSSE( - c, - async (rawStream) => { - const stream = writeTypeSafeSSE(rawStream); - - const onSessionListChanged = ( - event: InternalEventDeclaration["sessionListChanged"], - ) => { - stream.writeSSE("sessionListChanged", { - projectId: event.projectId, - }); - }; - - const onSessionChanged = ( - event: InternalEventDeclaration["sessionChanged"], - ) => { - stream.writeSSE("sessionChanged", { - projectId: event.projectId, - sessionId: event.sessionId, - }); - }; - - const onTaskChanged = ( - event: InternalEventDeclaration["taskChanged"], - ) => { - stream.writeSSE("taskChanged", { - aliveTasks: event.aliveTasks, - changed: { - status: event.changed.status, - sessionId: event.changed.sessionId, - projectId: event.changed.projectId, - }, - }); - - if (event.changed.sessionId !== undefined) { - stream.writeSSE("sessionChanged", { - projectId: event.changed.projectId, - sessionId: event.changed.sessionId, - }); + filteredSessions = Array.from(sessionMap.values()); } - }; - eventBus.on("sessionListChanged", onSessionListChanged); - eventBus.on("sessionChanged", onSessionChanged); - eventBus.on("taskChanged", onTaskChanged); - const { connectionPromise } = adaptInternalEventToSSE(rawStream, { - timeout: 5 /* min */ * 60 /* sec */ * 1000, - cleanUp: () => { - eventBus.off("sessionListChanged", onSessionListChanged); - eventBus.off("sessionChanged", onSessionChanged); - eventBus.off("taskChanged", onTaskChanged); - }, + return { + project, + sessions: filteredSessions, + nextCursor: sessions.at(-1)?.id, + }; }); - await connectionPromise; + const result = await Runtime.runPromise(runtime)(program); + return c.json(result); }, - async (err) => { - console.error("Streaming error:", err); - }, - ); - }) - ); -}; + ) -export type RouteType = Awaited>; + .get("/projects/:projectId/sessions/:sessionId", async (c) => { + const { projectId, sessionId } = c.req.param(); + + const program = Effect.gen(function* () { + const { session } = yield* sessionRepository.getSession( + projectId, + sessionId, + ); + return { session }; + }); + + const result = await Runtime.runPromise(runtime)(program); + return c.json(result); + }) + + .get( + "/projects/:projectId/file-completion", + zValidator( + "query", + z.object({ + basePath: z.string().optional().default("/"), + }), + ), + async (c) => { + const { projectId } = c.req.param(); + const { basePath } = c.req.valid("query"); + + const program = Effect.gen(function* () { + const { project } = + yield* projectRepository.getProject(projectId); + + if (project.meta.projectPath === null) { + return { + error: "Project path not found", + status: 400 as const, + }; + } + + const projectPath = project.meta.projectPath; + + try { + const result = yield* Effect.promise(() => + getFileCompletion(projectPath, basePath), + ); + return { data: result, status: 200 as const }; + } catch (error) { + console.error("File completion error:", error); + return { + error: "Failed to get file completion", + status: 500 as const, + }; + } + }); + + const result = await Runtime.runPromise(runtime)(program); + + if (result.status === 200) { + return c.json(result.data); + } + return c.json({ error: result.error }, result.status); + }, + ) + + .get("/projects/:projectId/claude-commands", async (c) => { + const { projectId } = c.req.param(); + + const program = Effect.gen(function* () { + const { project } = yield* projectRepository.getProject(projectId); + + const [globalCommands, projectCommands] = yield* Effect.promise( + () => + Promise.allSettled([ + readdir(claudeCommandsDirPath, { + withFileTypes: true, + }).then((dirents) => + dirents + .filter((d) => d.isFile() && d.name.endsWith(".md")) + .map((d) => d.name.replace(/\.md$/, "")), + ), + project.meta.projectPath !== null + ? readdir( + resolve( + project.meta.projectPath, + ".claude", + "commands", + ), + { + withFileTypes: true, + }, + ).then((dirents) => + dirents + .filter((d) => d.isFile() && d.name.endsWith(".md")) + .map((d) => d.name.replace(/\.md$/, "")), + ) + : [], + ]), + ); + + return { + globalCommands: + globalCommands.status === "fulfilled" + ? globalCommands.value + : [], + projectCommands: + projectCommands.status === "fulfilled" + ? projectCommands.value + : [], + defaultCommands: ["init", "compact"], + }; + }); + + const result = await Runtime.runPromise(runtime)(program); + return c.json(result); + }) + + .get("/projects/:projectId/git/branches", async (c) => { + const { projectId } = c.req.param(); + + const program = Effect.gen(function* () { + const { project } = yield* projectRepository.getProject(projectId); + + if (project.meta.projectPath === null) { + return { error: "Project path not found", status: 400 as const }; + } + + const projectPath = project.meta.projectPath; + + try { + const result = yield* Effect.promise(() => + getBranches(projectPath), + ); + return { data: result, status: 200 as const }; + } catch (error) { + console.error("Get branches error:", error); + if (error instanceof Error) { + return { error: error.message, status: 400 as const }; + } + return { error: "Failed to get branches", status: 500 as const }; + } + }); + + const result = await Runtime.runPromise(runtime)(program); + if (result.status === 200) { + return c.json(result.data); + } + + return c.json({ error: result.error }, result.status); + }) + + .get("/projects/:projectId/git/commits", async (c) => { + const { projectId } = c.req.param(); + + const program = Effect.gen(function* () { + const { project } = yield* projectRepository.getProject(projectId); + + if (project.meta.projectPath === null) { + return { error: "Project path not found", status: 400 as const }; + } + + const projectPath = project.meta.projectPath; + + try { + const result = yield* Effect.promise(() => + getCommits(projectPath), + ); + return { data: result, status: 200 as const }; + } catch (error) { + console.error("Get commits error:", error); + if (error instanceof Error) { + return { error: error.message, status: 400 as const }; + } + return { error: "Failed to get commits", status: 500 as const }; + } + }); + + const result = await Runtime.runPromise(runtime)(program); + if (result.status === 200) { + return c.json(result.data); + } + return c.json({ error: result.error }, result.status); + }) + + .post( + "/projects/:projectId/git/diff", + zValidator( + "json", + z.object({ + fromRef: z.string().min(1, "fromRef is required"), + toRef: z.string().min(1, "toRef is required"), + }), + ), + async (c) => { + const { projectId } = c.req.param(); + const { fromRef, toRef } = c.req.valid("json"); + + const program = Effect.gen(function* () { + const { project } = + yield* projectRepository.getProject(projectId); + + try { + if (project.meta.projectPath === null) { + return { + error: "Project path not found", + status: 400 as const, + }; + } + + const projectPath = project.meta.projectPath; + + const result = yield* Effect.promise(() => + getDiff(projectPath, fromRef, toRef), + ); + return { data: result, status: 200 as const }; + } catch (error) { + console.error("Get diff error:", error); + if (error instanceof Error) { + return { error: error.message, status: 400 as const }; + } + return { error: "Failed to get diff", status: 500 as const }; + } + }); + + const result = await Runtime.runPromise(runtime)(program); + if (result.status === 200) { + return c.json(result.data); + } + return c.json({ error: result.error }, result.status); + }, + ) + + .get("/projects/:projectId/mcp/list", async (c) => { + const { projectId } = c.req.param(); + const { servers } = await getMcpList(projectId); + return c.json({ servers }); + }) + + .get("/cc/session-processes", async (c) => { + const publicProcesses = await Runtime.runPromise(runtime)( + claudeCodeLifeCycleService.getPublicSessionProcesses(), + ); + return c.json({ + processes: publicProcesses.map( + (process): PublicSessionProcess => ({ + id: process.def.sessionProcessId, + projectId: process.def.projectId, + sessionId: process.sessionId, + status: process.type === "paused" ? "paused" : "running", + }), + ), + }); + }) + + // new or resume + .post( + "/cc/session-processes", + zValidator( + "json", + z.object({ + projectId: z.string(), + message: z.string(), + baseSessionId: z.string().optional(), + }), + ), + async (c) => { + const { projectId, message, baseSessionId } = c.req.valid("json"); + + const program = Effect.gen(function* () { + const { project } = + yield* projectRepository.getProject(projectId); + + if (project.meta.projectPath === null) { + return { + error: "Project path not found", + status: 400 as const, + }; + } + + const result = yield* claudeCodeLifeCycleService.startTask({ + baseSession: { + cwd: project.meta.projectPath, + projectId, + sessionId: baseSessionId, + }, + config: c.get("config"), + message, + }); + + return { + result, + status: 200 as const, + }; + }); + + const result = await Runtime.runPromise(runtime)(program); + + if (result.status === 200) { + return c.json({ + sessionProcess: { + id: result.result.sessionProcess.def.sessionProcessId, + projectId: result.result.sessionProcess.def.projectId, + sessionId: await result.result.awaitSessionInitialized(), + }, + }); + } + + return c.json({ error: result.error }, result.status); + }, + ) + + // continue + .post( + "/cc/session-processes/:sessionProcessId/continue", + zValidator( + "json", + z.object({ + projectId: z.string(), + continueMessage: z.string(), + baseSessionId: z.string(), + }), + ), + async (c) => { + const { sessionProcessId } = c.req.param(); + const { projectId, continueMessage, baseSessionId } = + c.req.valid("json"); + + const program = Effect.gen(function* () { + const { project } = + yield* projectRepository.getProject(projectId); + + if (project.meta.projectPath === null) { + return { + error: "Project path not found", + status: 400 as const, + }; + } + + const result = yield* claudeCodeLifeCycleService.continueTask({ + sessionProcessId, + message: continueMessage, + baseSessionId, + }); + + return { + data: { + sessionProcess: { + id: result.sessionProcess.def.sessionProcessId, + projectId: result.sessionProcess.def.projectId, + sessionId: baseSessionId, + }, + }, + status: 200 as const, + }; + }); + + const result = await Runtime.runPromise(runtime)(program); + if (result.status === 200) { + return c.json(result.data); + } + + return c.json({ error: result.error }, result.status); + }, + ) + + .post( + "/cc/session-processes/:sessionProcessId/abort", + zValidator("json", z.object({ projectId: z.string() })), + async (c) => { + const { sessionProcessId } = c.req.param(); + void Effect.runFork( + claudeCodeLifeCycleService.abortTask(sessionProcessId), + ); + return c.json({ message: "Task aborted" }); + }, + ) + + .post( + "/cc/permission-response", + zValidator( + "json", + z.object({ + permissionRequestId: z.string(), + decision: z.enum(["allow", "deny"]), + }), + ), + async (c) => { + const permissionResponse = c.req.valid("json"); + Effect.runFork( + claudeCodePermissionService.respondToPermissionRequest( + permissionResponse, + ), + ); + return c.json({ message: "Permission response received" }); + }, + ) + + .get("/sse", async (c) => { + return streamSSE( + c, + async (rawStream) => { + const handleSSE = Effect.gen(function* () { + const typeSafeSSE = yield* TypeSafeSSE; + + // Send connect event + yield* typeSafeSSE.writeSSE("connect", { + timestamp: new Date().toISOString(), + }); + + const onHeartbeat = () => { + Effect.runFork( + typeSafeSSE.writeSSE("heartbeat", { + timestamp: new Date().toISOString(), + }), + ); + }; + + const onSessionListChanged = ( + event: InternalEventDeclaration["sessionListChanged"], + ) => { + Effect.runFork( + typeSafeSSE.writeSSE("sessionListChanged", { + projectId: event.projectId, + }), + ); + }; + + const onSessionChanged = ( + event: InternalEventDeclaration["sessionChanged"], + ) => { + Effect.runFork( + typeSafeSSE.writeSSE("sessionChanged", { + projectId: event.projectId, + sessionId: event.sessionId, + }), + ); + }; + + const onSessionProcessChanged = ( + event: InternalEventDeclaration["sessionProcessChanged"], + ) => { + Effect.runFork( + typeSafeSSE.writeSSE("sessionProcessChanged", { + processes: event.processes, + }), + ); + }; + + yield* eventBus.on("sessionListChanged", onSessionListChanged); + yield* eventBus.on("sessionChanged", onSessionChanged); + yield* eventBus.on( + "sessionProcessChanged", + onSessionProcessChanged, + ); + yield* eventBus.on("heartbeat", onHeartbeat); + + const { connectionPromise } = adaptInternalEventToSSE( + rawStream, + { + timeout: 5 /* min */ * 60 /* sec */ * 1000, + cleanUp: async () => { + await Effect.runPromise( + Effect.gen(function* () { + yield* eventBus.off( + "sessionListChanged", + onSessionListChanged, + ); + yield* eventBus.off( + "sessionChanged", + onSessionChanged, + ); + yield* eventBus.off( + "sessionProcessChanged", + onSessionProcessChanged, + ); + yield* eventBus.off("heartbeat", onHeartbeat); + }), + ); + }, + }, + ); + + return { + connectionPromise, + }; + }); + + const { connectionPromise } = await Runtime.runPromise(runtime)( + handleSSE.pipe(Effect.provide(TypeSafeSSE.make(rawStream))), + ); + + await connectionPromise; + }, + async (err) => { + console.error("Streaming error:", err); + }, + ); + }) + ); + }); + +export type RouteType = ReturnType extends Effect.Effect< + infer A, + unknown, + unknown +> + ? A + : never; diff --git a/src/server/lib/effect/types.ts b/src/server/lib/effect/types.ts new file mode 100644 index 0000000..1da6522 --- /dev/null +++ b/src/server/lib/effect/types.ts @@ -0,0 +1,6 @@ +import type { Effect } from "effect"; + +// biome-ignore lint/suspicious/noExplicitAny: for type restriction +export type InferEffect = T extends Effect.Effect + ? U + : never; diff --git a/src/server/lib/env/schema.ts b/src/server/lib/env/schema.ts index 13ac4e2..b60a9ff 100644 --- a/src/server/lib/env/schema.ts +++ b/src/server/lib/env/schema.ts @@ -13,6 +13,7 @@ export const envSchema = z.object({ .optional() .default("3000") .transform((val) => parseInt(val, 10)), + PATH: z.string().optional(), }); export type EnvSchema = z.infer; diff --git a/src/server/lib/storage/FileCacheStorage.ts b/src/server/lib/storage/FileCacheStorage.ts deleted file mode 100644 index ed333e9..0000000 --- a/src/server/lib/storage/FileCacheStorage.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { writeFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { z } from "zod"; -import { claudeCodeViewerCacheDirPath } from "../../service/paths"; - -const saveSchema = z.array(z.tuple([z.string(), z.unknown()])); - -export class FileCacheStorage { - private storage = new Map(); - - private constructor(private readonly key: string) {} - - public static load( - key: string, - schema: z.ZodType, - ) { - const instance = new FileCacheStorage(key); - - if (!existsSync(claudeCodeViewerCacheDirPath)) { - mkdirSync(claudeCodeViewerCacheDirPath, { recursive: true }); - } - - if (!existsSync(instance.cacheFilePath)) { - writeFileSync(instance.cacheFilePath, "[]"); - } else { - const content = readFileSync(instance.cacheFilePath, "utf-8"); - const parsed = saveSchema.safeParse(JSON.parse(content)); - - if (!parsed.success) { - writeFileSync(instance.cacheFilePath, "[]"); - } else { - for (const [key, value] of parsed.data) { - const parsedValue = schema.safeParse(value); - if (!parsedValue.success) { - continue; - } - - instance.storage.set(key, parsedValue.data); - } - } - } - - return instance; - } - - private get cacheFilePath() { - return resolve(claudeCodeViewerCacheDirPath, `${this.key}.json`); - } - - private asSaveFormat() { - return JSON.stringify(Array.from(this.storage.entries())); - } - - private async syncToFile() { - await writeFile(this.cacheFilePath, this.asSaveFormat()); - } - - public get(key: string) { - return this.storage.get(key); - } - - public save(key: string, value: T) { - const previous = this.asSaveFormat(); - this.storage.set(key, value); - - if (previous === this.asSaveFormat()) { - return; - } - - void this.syncToFile(); - } - - public invalidate(key: string) { - if (!this.storage.has(key)) { - return; - } - - this.storage.delete(key); - void this.syncToFile(); - } -} diff --git a/src/server/lib/storage/FileCacheStorage/PersistantService.ts b/src/server/lib/storage/FileCacheStorage/PersistantService.ts new file mode 100644 index 0000000..f59445c --- /dev/null +++ b/src/server/lib/storage/FileCacheStorage/PersistantService.ts @@ -0,0 +1,64 @@ +import { resolve } from "node:path"; +import { FileSystem } from "@effect/platform"; +import { Context, Effect, Layer } from "effect"; +import { z } from "zod"; +import { claudeCodeViewerCacheDirPath } from "../../../service/paths"; + +const saveSchema = z.array(z.tuple([z.string(), z.unknown()])); + +const getCacheFilePath = (key: string) => + resolve(claudeCodeViewerCacheDirPath, `${key}.json`); + +const load = (key: string) => { + const cacheFilePath = getCacheFilePath(key); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + + if (!(yield* fs.exists(claudeCodeViewerCacheDirPath))) { + yield* fs.makeDirectory(claudeCodeViewerCacheDirPath, { + recursive: true, + }); + } + + if (!(yield* fs.exists(cacheFilePath))) { + yield* fs.writeFileString(cacheFilePath, "[]"); + } else { + const content = yield* fs.readFileString(cacheFilePath); + const parsed = saveSchema.safeParse(JSON.parse(content)); + + if (!parsed.success) { + yield* fs.writeFileString(cacheFilePath, "[]"); + } else { + parsed.data; + return parsed.data; + } + } + + return []; + }); +}; + +const save = (key: string, entries: readonly [string, unknown][]) => { + const cacheFilePath = getCacheFilePath(key); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(cacheFilePath, JSON.stringify(entries)); + }); +}; + +export class PersistentService extends Context.Tag("PersistentService")< + PersistentService, + { + readonly load: typeof load; + readonly save: typeof save; + } +>() { + static Live = Layer.succeed(this, { + load, + save, + }); +} + +export type IPersistentService = Context.Tag.Service; diff --git a/src/server/lib/storage/FileCacheStorage/index.test.ts b/src/server/lib/storage/FileCacheStorage/index.test.ts new file mode 100644 index 0000000..9f289f2 --- /dev/null +++ b/src/server/lib/storage/FileCacheStorage/index.test.ts @@ -0,0 +1,516 @@ +import { FileSystem } from "@effect/platform"; +import { Effect, Layer, Ref } from "effect"; +import { z } from "zod"; +import { FileCacheStorage, makeFileCacheStorageLayer } from "./index"; +import { PersistentService } from "./PersistantService"; + +// Schema for testing +const UserSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), +}); + +type User = z.infer; + +const FileSystemMock = FileSystem.layerNoop({}); + +describe("FileCacheStorage", () => { + describe("basic operations", () => { + it("can save and retrieve data with set and get", async () => { + // PersistentService mock (empty data) + const PersistentServiceMock = Layer.succeed(PersistentService, { + load: () => Effect.succeed([]), + save: () => Effect.void, + }); + + const program = Effect.gen(function* () { + const cache = yield* FileCacheStorage(); + + // Save data + yield* cache.set("user-1", { + id: "user-1", + name: "Alice", + email: "alice@example.com", + }); + + // Retrieve data + const user = yield* cache.get("user-1"); + return user; + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide( + makeFileCacheStorageLayer("test-users", UserSchema).pipe( + Layer.provide(PersistentServiceMock), + Layer.provide(FileSystemMock), + ), + ), + ), + ); + + expect(result).toEqual({ + id: "user-1", + name: "Alice", + email: "alice@example.com", + }); + }); + + it("returns undefined when retrieving non-existent key", async () => { + const PersistentServiceMock = Layer.succeed(PersistentService, { + load: () => Effect.succeed([]), + save: () => Effect.void, + }); + + const program = Effect.gen(function* () { + const cache = yield* FileCacheStorage(); + return yield* cache.get("non-existent"); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide( + makeFileCacheStorageLayer("test-users", UserSchema).pipe( + Layer.provide(PersistentServiceMock), + Layer.provide(FileSystemMock), + ), + ), + ), + ); + + expect(result).toBeUndefined(); + }); + + it("can delete data with invalidate", async () => { + const PersistentServiceMock = Layer.succeed(PersistentService, { + load: () => Effect.succeed([]), + save: () => Effect.void, + }); + + const program = Effect.gen(function* () { + const cache = yield* FileCacheStorage(); + + // Save data + yield* cache.set("user-1", { + id: "user-1", + name: "Alice", + email: "alice@example.com", + }); + + // Delete data + yield* cache.invalidate("user-1"); + + // Returns undefined after deletion + return yield* cache.get("user-1"); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide( + makeFileCacheStorageLayer("test-users", UserSchema).pipe( + Layer.provide(PersistentServiceMock), + Layer.provide(FileSystemMock), + ), + ), + ), + ); + + expect(result).toBeUndefined(); + }); + + it("getAll ですべてのデータを取得できる", async () => { + const PersistentServiceMock = Layer.succeed(PersistentService, { + load: () => Effect.succeed([]), + save: () => Effect.void, + }); + + const program = Effect.gen(function* () { + const cache = yield* FileCacheStorage(); + + // 複数のデータを保存 + yield* cache.set("user-1", { + id: "user-1", + name: "Alice", + email: "alice@example.com", + }); + yield* cache.set("user-2", { + id: "user-2", + name: "Bob", + email: "bob@example.com", + }); + + // すべてのデータを取得 + return yield* cache.getAll(); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide( + makeFileCacheStorageLayer("test-users", UserSchema).pipe( + Layer.provide(PersistentServiceMock), + Layer.provide(FileSystemMock), + ), + ), + ), + ); + + expect(result.size).toBe(2); + expect(result.get("user-1")).toEqual({ + id: "user-1", + name: "Alice", + email: "alice@example.com", + }); + expect(result.get("user-2")).toEqual({ + id: "user-2", + name: "Bob", + email: "bob@example.com", + }); + }); + }); + + describe("永続化データの読み込み", () => { + it("初期化時に永続化データを読み込む", async () => { + // 永続化データを返すモック + const PersistentServiceMock = Layer.succeed(PersistentService, { + load: () => + Effect.succeed([ + [ + "user-1", + { + id: "user-1", + name: "Alice", + email: "alice@example.com", + }, + ], + [ + "user-2", + { + id: "user-2", + name: "Bob", + email: "bob@example.com", + }, + ], + ] as const), + save: () => Effect.void, + }); + + const program = Effect.gen(function* () { + const cache = yield* FileCacheStorage(); + return yield* cache.getAll(); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide( + makeFileCacheStorageLayer("test-users", UserSchema).pipe( + Layer.provide(PersistentServiceMock), + Layer.provide(FileSystemMock), + ), + ), + ), + ); + + expect(result.size).toBe(2); + expect(result.get("user-1")?.name).toBe("Alice"); + expect(result.get("user-2")?.name).toBe("Bob"); + }); + + it("スキーマバリデーションに失敗したデータは無視される", async () => { + // 不正なデータを含む永続化データ + const PersistentServiceMock = Layer.succeed(PersistentService, { + load: () => + Effect.succeed([ + [ + "user-1", + { + id: "user-1", + name: "Alice", + email: "alice@example.com", + }, + ], + [ + "user-invalid", + { + id: "invalid", + name: "Invalid", + // email が無い(バリデーションエラー) + }, + ], + [ + "user-2", + { + id: "user-2", + name: "Bob", + email: "invalid-email", // 不正なメールアドレス + }, + ], + ] as const), + save: () => Effect.void, + }); + + const program = Effect.gen(function* () { + const cache = yield* FileCacheStorage(); + return yield* cache.getAll(); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide( + makeFileCacheStorageLayer("test-users", UserSchema).pipe( + Layer.provide(PersistentServiceMock), + Layer.provide(FileSystemMock), + ), + ), + ), + ); + + // 有効なデータのみ読み込まれる + expect(result.size).toBe(1); + expect(result.get("user-1")?.name).toBe("Alice"); + expect(result.get("user-invalid")).toBeUndefined(); + expect(result.get("user-2")).toBeUndefined(); + }); + }); + + describe("永続化への同期", () => { + it("set でデータを保存すると save が呼ばれる", async () => { + const saveCallsRef = await Effect.runPromise(Ref.make(0)); + + const PersistentServiceMock = Layer.succeed(PersistentService, { + load: () => Effect.succeed([]), + save: () => + Effect.gen(function* () { + yield* Ref.update(saveCallsRef, (n) => n + 1); + }), + }); + + const program = Effect.gen(function* () { + const cache = yield* FileCacheStorage(); + + yield* cache.set("user-1", { + id: "user-1", + name: "Alice", + email: "alice@example.com", + }); + + // バックグラウンド実行を待つために少し待機 + yield* Effect.sleep("10 millis"); + }); + + await Effect.runPromise( + program.pipe( + Effect.provide( + makeFileCacheStorageLayer("test-users", UserSchema).pipe( + Layer.provide(PersistentServiceMock), + Layer.provide(FileSystemMock), + ), + ), + ), + ); + + const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef)); + expect(saveCalls).toBeGreaterThan(0); + }); + + it("同じ値を set しても save は呼ばれない(差分検出)", async () => { + const saveCallsRef = await Effect.runPromise(Ref.make(0)); + + const PersistentServiceMock = Layer.succeed(PersistentService, { + load: () => + Effect.succeed([ + [ + "user-1", + { + id: "user-1", + name: "Alice", + email: "alice@example.com", + }, + ], + ] as const), + save: () => + Effect.gen(function* () { + yield* Ref.update(saveCallsRef, (n) => n + 1); + }), + }); + + const program = Effect.gen(function* () { + const cache = yield* FileCacheStorage(); + + // 既に存在する同じ値を set + yield* cache.set("user-1", { + id: "user-1", + name: "Alice", + email: "alice@example.com", + }); + + // バックグラウンド実行を待つために少し待機 + yield* Effect.sleep("10 millis"); + }); + + await Effect.runPromise( + program.pipe( + Effect.provide( + makeFileCacheStorageLayer("test-users", UserSchema).pipe( + Layer.provide(PersistentServiceMock), + Layer.provide(FileSystemMock), + ), + ), + ), + ); + + const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef)); + // 差分がないので save は呼ばれない + expect(saveCalls).toBe(0); + }); + + it("invalidate でデータを削除すると save が呼ばれる", async () => { + const saveCallsRef = await Effect.runPromise(Ref.make(0)); + + const PersistentServiceMock = Layer.succeed(PersistentService, { + load: () => + Effect.succeed([ + [ + "user-1", + { + id: "user-1", + name: "Alice", + email: "alice@example.com", + }, + ], + ] as const), + save: () => + Effect.gen(function* () { + yield* Ref.update(saveCallsRef, (n) => n + 1); + }), + }); + + const program = Effect.gen(function* () { + const cache = yield* FileCacheStorage(); + + yield* cache.invalidate("user-1"); + + // バックグラウンド実行を待つために少し待機 + yield* Effect.sleep("10 millis"); + }); + + await Effect.runPromise( + program.pipe( + Effect.provide( + makeFileCacheStorageLayer("test-users", UserSchema).pipe( + Layer.provide(PersistentServiceMock), + Layer.provide(FileSystemMock), + ), + ), + ), + ); + + const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef)); + expect(saveCalls).toBeGreaterThan(0); + }); + + it("存在しないキーを invalidate しても save は呼ばれない", async () => { + const saveCallsRef = await Effect.runPromise(Ref.make(0)); + + const PersistentServiceMock = Layer.succeed(PersistentService, { + load: () => Effect.succeed([]), + save: () => + Effect.gen(function* () { + yield* Ref.update(saveCallsRef, (n) => n + 1); + }), + }); + + const program = Effect.gen(function* () { + const cache = yield* FileCacheStorage(); + + // 存在しないキーを invalidate + yield* cache.invalidate("non-existent"); + + // バックグラウンド実行を待つために少し待機 + yield* Effect.sleep("10 millis"); + }); + + await Effect.runPromise( + program.pipe( + Effect.provide( + makeFileCacheStorageLayer("test-users", UserSchema).pipe( + Layer.provide(PersistentServiceMock), + Layer.provide(FileSystemMock), + ), + ), + ), + ); + + const saveCalls = await Effect.runPromise(Ref.get(saveCallsRef)); + // 存在しないキーなので save は呼ばれない + expect(saveCalls).toBe(0); + }); + }); + + describe("複雑なシナリオ", () => { + it("複数の操作を順次実行できる", async () => { + const PersistentServiceMock = Layer.succeed(PersistentService, { + load: () => + Effect.succeed([ + [ + "user-1", + { + id: "user-1", + name: "Alice", + email: "alice@example.com", + }, + ], + ] as const), + save: () => Effect.void, + }); + + const program = Effect.gen(function* () { + const cache = yield* FileCacheStorage(); + + // 初期データの確認 + const initial = yield* cache.getAll(); + expect(initial.size).toBe(1); + + // 新しいユーザーを追加 + yield* cache.set("user-2", { + id: "user-2", + name: "Bob", + email: "bob@example.com", + }); + + // 既存のユーザーを更新 + yield* cache.set("user-1", { + id: "user-1", + name: "Alice Updated", + email: "alice.updated@example.com", + }); + + // すべてのデータを取得 + const afterUpdate = yield* cache.getAll(); + expect(afterUpdate.size).toBe(2); + expect(afterUpdate.get("user-1")?.name).toBe("Alice Updated"); + expect(afterUpdate.get("user-2")?.name).toBe("Bob"); + + // ユーザーを削除 + yield* cache.invalidate("user-1"); + + // 削除後の確認 + const afterDelete = yield* cache.getAll(); + expect(afterDelete.size).toBe(1); + expect(afterDelete.get("user-1")).toBeUndefined(); + expect(afterDelete.get("user-2")?.name).toBe("Bob"); + }); + + await Effect.runPromise( + program.pipe( + Effect.provide( + makeFileCacheStorageLayer("test-users", UserSchema).pipe( + Layer.provide(PersistentServiceMock), + Layer.provide(FileSystemMock), + ), + ), + ), + ); + }); + }); +}); diff --git a/src/server/lib/storage/FileCacheStorage/index.ts b/src/server/lib/storage/FileCacheStorage/index.ts new file mode 100644 index 0000000..71656ae --- /dev/null +++ b/src/server/lib/storage/FileCacheStorage/index.ts @@ -0,0 +1,94 @@ +import type { FileSystem } from "@effect/platform"; +import { Context, Effect, Layer, Ref, Runtime } from "effect"; +import type { z } from "zod"; +import { PersistentService } from "./PersistantService"; + +export interface FileCacheStorageService { + readonly get: (key: string) => Effect.Effect; + readonly set: (key: string, value: T) => Effect.Effect; + readonly invalidate: (key: string) => Effect.Effect; + readonly getAll: () => Effect.Effect>; +} + +export const FileCacheStorage = () => + Context.GenericTag>("FileCacheStorage"); + +export const makeFileCacheStorageLayer = ( + storageKey: string, + schema: z.ZodType, +) => + Layer.effect( + FileCacheStorage(), + Effect.gen(function* () { + const persistentService = yield* PersistentService; + + const runtime = yield* Effect.runtime(); + + const storageRef = yield* Effect.gen(function* () { + const persistedData = yield* persistentService.load(storageKey); + + const initialMap = new Map(); + for (const [key, value] of persistedData) { + const parsed = schema.safeParse(value); + if (parsed.success) { + initialMap.set(key, parsed.data); + } + } + + return yield* Ref.make(initialMap); + }); + + const syncToFile = (entries: readonly [string, T][]) => { + Runtime.runFork(runtime)(persistentService.save(storageKey, entries)); + }; + + return { + get: (key: string) => + Effect.gen(function* () { + const storage = yield* Ref.get(storageRef); + return storage.get(key); + }), + + set: (key: string, value: T) => + Effect.gen(function* () { + const before = yield* Ref.get(storageRef); + const beforeString = JSON.stringify(Array.from(before.entries())); + + yield* Ref.update(storageRef, (map) => { + map.set(key, value); + return map; + }); + + const after = yield* Ref.get(storageRef); + const afterString = JSON.stringify(Array.from(after.entries())); + + if (beforeString !== afterString) { + syncToFile(Array.from(after.entries())); + } + }), + + invalidate: (key: string) => + Effect.gen(function* () { + const before = yield* Ref.get(storageRef); + + if (!before.has(key)) { + return; + } + + yield* Ref.update(storageRef, (map) => { + map.delete(key); + return map; + }); + + const after = yield* Ref.get(storageRef); + syncToFile(Array.from(after.entries())); + }), + + getAll: () => + Effect.gen(function* () { + const storage = yield* Ref.get(storageRef); + return new Map(storage); + }), + }; + }), + ); diff --git a/src/server/lib/storage/InMemoryCacheStorage.ts b/src/server/lib/storage/InMemoryCacheStorage.ts deleted file mode 100644 index 8f19e5e..0000000 --- a/src/server/lib/storage/InMemoryCacheStorage.ts +++ /dev/null @@ -1,19 +0,0 @@ -export class InMemoryCacheStorage { - private storage = new Map(); - - public get(key: string) { - return this.storage.get(key); - } - - public save(key: string, value: T) { - this.storage.set(key, value); - } - - public invalidate(key: string) { - if (!this.storage.has(key)) { - return; - } - - this.storage.delete(key); - } -} diff --git a/src/server/service/claude-code/ClaudeCode.test.ts b/src/server/service/claude-code/ClaudeCode.test.ts new file mode 100644 index 0000000..16ba1de --- /dev/null +++ b/src/server/service/claude-code/ClaudeCode.test.ts @@ -0,0 +1,94 @@ +import { CommandExecutor, Path } from "@effect/platform"; +import { NodeContext } from "@effect/platform-node"; +import { Effect, Layer } from "effect"; +import * as ClaudeCode from "./ClaudeCode"; + +describe("ClaudeCode.Config", () => { + describe("when environment variable CLAUDE_CODE_VIEWER_CC_EXECUTABLE_PATH is not set", () => { + it("should correctly parse results of 'which claude' and 'claude --version'", async () => { + const CommandExecutorTest = Layer.effect( + CommandExecutor.CommandExecutor, + Effect.map(CommandExecutor.CommandExecutor, (realExecutor) => ({ + ...realExecutor, + string: (() => { + const responses = ["/path/to/claude", "1.0.53 (Claude Code)\n"]; + return () => Effect.succeed(responses.shift() ?? ""); + })(), + })), + ).pipe(Layer.provide(NodeContext.layer)); + + const config = await Effect.runPromise( + ClaudeCode.Config.pipe( + Effect.provide(Path.layer), + Effect.provide(CommandExecutorTest), + ), + ); + + expect(config.claudeCodeExecutablePath).toBe("/path/to/claude"); + + expect(config.claudeCodeVersion).toStrictEqual({ + major: 1, + minor: 0, + patch: 53, + }); + }); + }); +}); + +describe("ClaudeCode.AvailableFeatures", () => { + describe("when claudeCodeVersion is null", () => { + it("canUseTool and uuidOnSDKMessage should be false", () => { + const features = ClaudeCode.getAvailableFeatures(null); + expect(features.canUseTool).toBe(false); + expect(features.uuidOnSDKMessage).toBe(false); + }); + }); + + describe("when claudeCodeVersion is v1.0.81", () => { + it("canUseTool should be false, uuidOnSDKMessage should be false", () => { + const features = ClaudeCode.getAvailableFeatures({ + major: 1, + minor: 0, + patch: 81, + }); + expect(features.canUseTool).toBe(false); + expect(features.uuidOnSDKMessage).toBe(false); + }); + }); + + describe("when claudeCodeVersion is v1.0.82", () => { + it("canUseTool should be true, uuidOnSDKMessage should be false", () => { + const features = ClaudeCode.getAvailableFeatures({ + major: 1, + minor: 0, + patch: 82, + }); + expect(features.canUseTool).toBe(true); + expect(features.uuidOnSDKMessage).toBe(false); + }); + }); + + describe("when claudeCodeVersion is v1.0.85", () => { + it("canUseTool should be true, uuidOnSDKMessage should be false", () => { + const features = ClaudeCode.getAvailableFeatures({ + major: 1, + minor: 0, + patch: 85, + }); + expect(features.canUseTool).toBe(true); + expect(features.uuidOnSDKMessage).toBe(false); + }); + }); + + describe("when claudeCodeVersion is v1.0.86", () => { + it("canUseTool should be true, uuidOnSDKMessage should be true", () => { + const features = ClaudeCode.getAvailableFeatures({ + major: 1, + minor: 0, + patch: 86, + }); + expect(features.canUseTool).toBe(true); + expect(features.uuidOnSDKMessage).toBe(true); + }); + }); +}); diff --git a/src/server/service/claude-code/ClaudeCode.ts b/src/server/service/claude-code/ClaudeCode.ts new file mode 100644 index 0000000..cd6fecd --- /dev/null +++ b/src/server/service/claude-code/ClaudeCode.ts @@ -0,0 +1,81 @@ +import { query as originalQuery } from "@anthropic-ai/claude-code"; +import { Command, Path } from "@effect/platform"; +import { Effect } from "effect"; +import { env } from "../../lib/env"; +import * as ClaudeCodeVersion from "./models/ClaudeCodeVersion"; + +type CCQuery = typeof originalQuery; +type CCQueryPrompt = Parameters[0]["prompt"]; +type CCQueryOptions = NonNullable[0]["options"]>; + +export const Config = Effect.gen(function* () { + const path = yield* Path.Path; + + const specifiedExecutablePath = env.get( + "CLAUDE_CODE_VIEWER_CC_EXECUTABLE_PATH", + ); + + const claudeCodeExecutablePath = + specifiedExecutablePath !== undefined + ? path.resolve(specifiedExecutablePath) + : (yield* Command.string( + Command.make("which", "claude").pipe( + Command.env({ + PATH: env.get("PATH"), + }), + Command.runInShell(true), + ), + )).trim(); + + const claudeCodeVersion = ClaudeCodeVersion.fromCLIString( + yield* Command.string(Command.make(claudeCodeExecutablePath, "--version")), + ); + + return { + claudeCodeExecutablePath, + claudeCodeVersion, + }; +}); + +export const getAvailableFeatures = ( + claudeCodeVersion: ClaudeCodeVersion.ClaudeCodeVersion | null, +) => ({ + canUseTool: + claudeCodeVersion !== null + ? ClaudeCodeVersion.greaterThanOrEqual(claudeCodeVersion, { + major: 1, + minor: 0, + patch: 82, + }) + : false, + uuidOnSDKMessage: + claudeCodeVersion !== null + ? ClaudeCodeVersion.greaterThanOrEqual(claudeCodeVersion, { + major: 1, + minor: 0, + patch: 86, + }) + : false, +}); + +export const query = (prompt: CCQueryPrompt, options: CCQueryOptions) => { + const { canUseTool, permissionMode, ...baseOptions } = options; + + return Effect.gen(function* () { + const { claudeCodeExecutablePath, claudeCodeVersion } = yield* Config; + const availableFeatures = getAvailableFeatures(claudeCodeVersion); + + return originalQuery({ + prompt, + options: { + pathToClaudeCodeExecutable: claudeCodeExecutablePath, + ...baseOptions, + ...(availableFeatures.canUseTool + ? { canUseTool, permissionMode } + : { + permissionMode: "bypassPermissions", + }), + }, + }); + }); +}; diff --git a/src/server/service/claude-code/ClaudeCodeExecutor.ts b/src/server/service/claude-code/ClaudeCodeExecutor.ts deleted file mode 100644 index b9de1b2..0000000 --- a/src/server/service/claude-code/ClaudeCodeExecutor.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { execSync } from "node:child_process"; -import { resolve } from "node:path"; -import { query } from "@anthropic-ai/claude-code"; -import { env } from "../../lib/env"; -import { ClaudeCodeVersion } from "./ClaudeCodeVersion"; - -type CCQuery = typeof query; -type CCQueryPrompt = Parameters[0]["prompt"]; -type CCQueryOptions = NonNullable[0]["options"]>; - -export class ClaudeCodeExecutor { - private pathToClaudeCodeExecutable: string; - private claudeCodeVersion: ClaudeCodeVersion | null; - - constructor() { - const executablePath = env.get("CLAUDE_CODE_VIEWER_CC_EXECUTABLE_PATH"); - this.pathToClaudeCodeExecutable = - executablePath !== undefined - ? resolve(executablePath) - : execSync("which claude", {}).toString().trim(); - this.claudeCodeVersion = ClaudeCodeVersion.fromCLIString( - execSync(`${this.pathToClaudeCodeExecutable} --version`, {}).toString(), - ); - } - - public get version() { - return this.claudeCodeVersion?.version; - } - - public get availableFeatures() { - return { - canUseTool: - this.claudeCodeVersion?.greaterThanOrEqual( - new ClaudeCodeVersion({ major: 1, minor: 0, patch: 82 }), - ) ?? false, - uuidOnSDKMessage: - this.claudeCodeVersion?.greaterThanOrEqual( - new ClaudeCodeVersion({ major: 1, minor: 0, patch: 86 }), - ) ?? false, - }; - } - - public query(prompt: CCQueryPrompt, options: CCQueryOptions) { - const { canUseTool, ...baseOptions } = options; - - return query({ - prompt, - options: { - pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable, - ...baseOptions, - ...(this.availableFeatures.canUseTool ? { canUseTool } : {}), - }, - }); - } -} diff --git a/src/server/service/claude-code/ClaudeCodeLifeCycleService.ts b/src/server/service/claude-code/ClaudeCodeLifeCycleService.ts new file mode 100644 index 0000000..5e6660a --- /dev/null +++ b/src/server/service/claude-code/ClaudeCodeLifeCycleService.ts @@ -0,0 +1,367 @@ +import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-code"; +import type { FileSystem, Path } from "@effect/platform"; +import type { CommandExecutor } from "@effect/platform/CommandExecutor"; +import { Context, Effect, Layer, Runtime } from "effect"; +import { ulid } from "ulid"; +import { controllablePromise } from "../../../lib/controllablePromise"; +import type { Config } from "../../config/config"; +import type { InferEffect } from "../../lib/effect/types"; +import { EventBus } from "../events/EventBus"; +import { VirtualConversationDatabase } from "../session/PredictSessionsDatabase"; +import type { SessionMetaService } from "../session/SessionMetaService"; +import { SessionRepository } from "../session/SessionRepository"; +import * as ClaudeCode from "./ClaudeCode"; +import { ClaudeCodePermissionService } from "./ClaudeCodePermissionService"; +import { ClaudeCodeSessionProcessService } from "./ClaudeCodeSessionProcessService"; +import { createMessageGenerator } from "./MessageGenerator"; +import * as CCSessionProcess from "./models/CCSessionProcess"; + +export type MessageGenerator = () => AsyncGenerator< + SDKUserMessage, + void, + unknown +>; + +const LayerImpl = Effect.gen(function* () { + const eventBusService = yield* EventBus; + const sessionRepository = yield* SessionRepository; + const sessionProcessService = yield* ClaudeCodeSessionProcessService; + const virtualConversationDatabase = yield* VirtualConversationDatabase; + const permissionService = yield* ClaudeCodePermissionService; + + const runtime = yield* Effect.runtime< + | FileSystem.FileSystem + | Path.Path + | CommandExecutor + | VirtualConversationDatabase + | SessionMetaService + | ClaudeCodePermissionService + >(); + + const continueTask = (options: { + sessionProcessId: string; + baseSessionId: string; + message: string; + }) => { + const { sessionProcessId, baseSessionId, message } = options; + + return Effect.gen(function* () { + const { sessionProcess, task } = + yield* sessionProcessService.continueSessionProcess({ + sessionProcessId, + taskDef: { + type: "continue", + sessionId: baseSessionId, + baseSessionId: baseSessionId, + taskId: ulid(), + }, + }); + + const virtualConversation = + yield* CCSessionProcess.createVirtualConversation(sessionProcess, { + sessionId: baseSessionId, + userMessage: message, + }); + + yield* virtualConversationDatabase.createVirtualConversation( + sessionProcess.def.projectId, + baseSessionId, + [virtualConversation], + ); + + sessionProcess.def.setNextMessage(message); + return { + sessionProcess, + task, + }; + }); + }; + + const startTask = (options: { + config: Config; + baseSession: { + cwd: string; + projectId: string; + sessionId?: string; + }; + message: string; + }) => { + const { baseSession, message, config } = options; + + return Effect.gen(function* () { + const { + generateMessages, + setNextMessage, + setHooks: setMessageGeneratorHooks, + } = createMessageGenerator(); + + const { sessionProcess, task } = + yield* sessionProcessService.startSessionProcess({ + sessionDef: { + projectId: baseSession.projectId, + cwd: baseSession.cwd, + abortController: new AbortController(), + setNextMessage, + sessionProcessId: ulid(), + }, + taskDef: + baseSession.sessionId === undefined + ? { + type: "new", + taskId: ulid(), + } + : { + type: "resume", + taskId: ulid(), + sessionId: undefined, + baseSessionId: baseSession.sessionId, + }, + }); + + const sessionInitializedPromise = controllablePromise(); + + setMessageGeneratorHooks({ + onNewUserMessageResolved: async (message) => { + Effect.runFork( + sessionProcessService.toNotInitializedState({ + sessionProcessId: sessionProcess.def.sessionProcessId, + rawUserMessage: message, + }), + ); + }, + }); + + const handleMessage = (message: SDKMessage) => + Effect.gen(function* () { + const processState = yield* sessionProcessService.getSessionProcess( + sessionProcess.def.sessionProcessId, + ); + + if (processState.type === "completed") { + return "break" as const; + } + + if (processState.type === "paused") { + // rule: paused は not_initialized に更新されてからくる想定 + yield* Effect.die( + new Error("Illegal state: paused is not expected"), + ); + } + + if ( + message.type === "system" && + message.subtype === "init" && + processState.type === "not_initialized" + ) { + yield* sessionProcessService.toInitializedState({ + sessionProcessId: processState.def.sessionProcessId, + initContext: { + initMessage: message, + }, + }); + + // Virtual Conversation Creation + const virtualConversation = + yield* CCSessionProcess.createVirtualConversation(processState, { + sessionId: message.session_id, + userMessage: processState.rawUserMessage, + }); + + if (processState.currentTask.def.type === "new") { + // 末尾に追加するだけで OK + yield* virtualConversationDatabase.createVirtualConversation( + baseSession.projectId, + message.session_id, + [virtualConversation], + ); + } else if (processState.currentTask.def.type === "resume") { + const existingSession = yield* sessionRepository.getSession( + processState.def.projectId, + processState.currentTask.def.baseSessionId, + ); + + const copiedConversations = + existingSession.session === null + ? [] + : existingSession.session.conversations; + + yield* virtualConversationDatabase.createVirtualConversation( + processState.def.projectId, + message.session_id, + [...copiedConversations, virtualConversation], + ); + } else { + // do nothing + } + + sessionInitializedPromise.resolve(message.session_id); + + yield* eventBusService.emit("sessionListChanged", { + projectId: processState.def.projectId, + }); + + yield* eventBusService.emit("sessionChanged", { + projectId: processState.def.projectId, + sessionId: message.session_id, + }); + + return "continue" as const; + } + + if ( + message.type === "result" && + processState.type === "initialized" + ) { + yield* sessionProcessService.toPausedState({ + sessionProcessId: processState.def.sessionProcessId, + resultMessage: message, + }); + + yield* eventBusService.emit("sessionChanged", { + projectId: processState.def.projectId, + sessionId: message.session_id, + }); + + return "continue" as const; + } + + return "continue" as const; + }); + + const handleSessionProcessDaemon = async () => { + const messageIter = await Runtime.runPromise(runtime)( + Effect.gen(function* () { + const permissionOptions = + yield* permissionService.createCanUseToolRelatedOptions({ + taskId: task.def.taskId, + config, + sessionId: task.def.baseSessionId, + }); + + return yield* ClaudeCode.query(generateMessages(), { + resume: task.def.baseSessionId, + cwd: sessionProcess.def.cwd, + abortController: sessionProcess.def.abortController, + ...permissionOptions, + }); + }), + ); + + setNextMessage(message); + + try { + for await (const message of messageIter) { + const result = await Runtime.runPromise(runtime)( + handleMessage(message), + ).catch((error) => { + // iter 自体が落ちてなければ継続したいので握りつぶす + Effect.runFork( + sessionProcessService.changeTaskState({ + sessionProcessId: sessionProcess.def.sessionProcessId, + taskId: task.def.taskId, + nextTask: { + status: "failed", + def: task.def, + error: error, + }, + }), + ); + + return "continue" as const; + }); + + if (result === "break") { + break; + } else { + } + } + } catch (error) { + await Effect.runPromise( + sessionProcessService.changeTaskState({ + sessionProcessId: sessionProcess.def.sessionProcessId, + taskId: task.def.taskId, + nextTask: { + status: "failed", + def: task.def, + error: error, + }, + }), + ); + } + }; + + const daemonPromise = handleSessionProcessDaemon() + .catch((error) => { + console.error("Error occur in task daemon process", error); + throw error; + }) + .finally(() => { + Effect.runFork( + Effect.gen(function* () { + const currentProcess = + yield* sessionProcessService.getSessionProcess( + sessionProcess.def.sessionProcessId, + ); + + yield* sessionProcessService.toCompletedState({ + sessionProcessId: currentProcess.def.sessionProcessId, + }); + }), + ); + }); + + return { + sessionProcess, + task, + daemonPromise, + awaitSessionInitialized: async () => + await sessionInitializedPromise.promise, + }; + }); + }; + + const getPublicSessionProcesses = () => + Effect.gen(function* () { + const processes = yield* sessionProcessService.getSessionProcesses(); + return processes.filter((process) => CCSessionProcess.isPublic(process)); + }); + + const abortTask = (sessionProcessId: string): Effect.Effect => + Effect.gen(function* () { + const currentProcess = + yield* sessionProcessService.getSessionProcess(sessionProcessId); + + yield* sessionProcessService.toCompletedState({ + sessionProcessId: currentProcess.def.sessionProcessId, + error: new Error("Task aborted"), + }); + }); + + const abortAllTasks = () => + Effect.gen(function* () { + const processes = yield* sessionProcessService.getSessionProcesses(); + + for (const process of processes) { + yield* sessionProcessService.toCompletedState({ + sessionProcessId: process.def.sessionProcessId, + error: new Error("Task aborted"), + }); + } + }); + + return { + continueTask, + startTask, + abortTask, + abortAllTasks, + getPublicSessionProcesses, + }; +}); + +export type IClaudeCodeLifeCycleService = InferEffect; + +export class ClaudeCodeLifeCycleService extends Context.Tag( + "ClaudeCodeLifeCycleService", +)() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/service/claude-code/ClaudeCodePermissionService.ts b/src/server/service/claude-code/ClaudeCodePermissionService.ts new file mode 100644 index 0000000..7cee416 --- /dev/null +++ b/src/server/service/claude-code/ClaudeCodePermissionService.ts @@ -0,0 +1,158 @@ +import type { CanUseTool } from "@anthropic-ai/claude-code"; +import { Context, Effect, Layer, Ref } from "effect"; +import { ulid } from "ulid"; +import type { + PermissionRequest, + PermissionResponse, +} from "../../../types/permissions"; +import type { Config } from "../../config/config"; +import type { InferEffect } from "../../lib/effect/types"; +import { EventBus } from "../events/EventBus"; +import * as ClaudeCode from "./ClaudeCode"; + +const LayerImpl = Effect.gen(function* () { + const pendingPermissionRequestsRef = yield* Ref.make< + Map + >(new Map()); + const permissionResponsesRef = yield* Ref.make< + Map + >(new Map()); + const eventBus = yield* EventBus; + + const waitPermissionResponse = ( + request: PermissionRequest, + options: { timeoutMs: number }, + ) => + Effect.gen(function* () { + yield* Ref.update(pendingPermissionRequestsRef, (requests) => { + requests.set(request.id, request); + return requests; + }); + + yield* eventBus.emit("permissionRequested", { + permissionRequest: request, + }); + + let passedMs = 0; + let response: PermissionResponse | null = null; + while (passedMs < options.timeoutMs) { + const responses = yield* Ref.get(permissionResponsesRef); + response = responses.get(request.id) ?? null; + if (response !== null) { + break; + } + + yield* Effect.sleep(1000); + passedMs += 1000; + } + + return response; + }); + + const createCanUseToolRelatedOptions = (options: { + taskId: string; + config: Config; + sessionId?: string; + }) => { + const { taskId, config, sessionId } = options; + + return Effect.gen(function* () { + const claudeCodeConfig = yield* ClaudeCode.Config; + + if ( + !ClaudeCode.getAvailableFeatures(claudeCodeConfig.claudeCodeVersion) + .canUseTool + ) { + return { + permissionMode: "bypassPermissions", + } as const; + } + + const canUseTool: CanUseTool = async (toolName, toolInput, _options) => { + if (config.permissionMode !== "default") { + // Convert Claude Code permission modes to canUseTool behaviors + if ( + config.permissionMode === "bypassPermissions" || + config.permissionMode === "acceptEdits" + ) { + return { + behavior: "allow" as const, + updatedInput: toolInput, + }; + } else { + // plan mode should deny actual tool execution + return { + behavior: "deny" as const, + message: "Tool execution is disabled in plan mode", + }; + } + } + + const permissionRequest: PermissionRequest = { + id: ulid(), + taskId, + sessionId, + toolName, + toolInput, + timestamp: Date.now(), + }; + + const response = await Effect.runPromise( + waitPermissionResponse(permissionRequest, { timeoutMs: 60000 }), + ); + + if (response === null) { + return { + behavior: "deny" as const, + message: "Permission request timed out", + }; + } + + if (response.decision === "allow") { + return { + behavior: "allow" as const, + updatedInput: toolInput, + }; + } else { + return { + behavior: "deny" as const, + message: "Permission denied by user", + }; + } + }; + + return { + canUseTool, + permissionMode: config.permissionMode, + } as const; + }); + }; + + const respondToPermissionRequest = ( + response: PermissionResponse, + ): Effect.Effect => + Effect.gen(function* () { + yield* Ref.update(permissionResponsesRef, (responses) => { + responses.set(response.permissionRequestId, response); + return responses; + }); + + yield* Ref.update(pendingPermissionRequestsRef, (requests) => { + requests.delete(response.permissionRequestId); + return requests; + }); + }); + + return { + createCanUseToolRelatedOptions, + respondToPermissionRequest, + }; +}); + +export type IClaudeCodePermissionService = InferEffect; + +export class ClaudeCodePermissionService extends Context.Tag( + "ClaudeCodePermissionService", +)() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/service/claude-code/ClaudeCodeSessionProcessService.ts b/src/server/service/claude-code/ClaudeCodeSessionProcessService.ts new file mode 100644 index 0000000..1b14435 --- /dev/null +++ b/src/server/service/claude-code/ClaudeCodeSessionProcessService.ts @@ -0,0 +1,463 @@ +import type { SDKResultMessage } from "@anthropic-ai/claude-code"; +import { Context, Data, Effect, Layer, Ref } from "effect"; +import type { InferEffect } from "../../lib/effect/types"; +import { EventBus } from "../events/EventBus"; +import type { InitMessageContext } from "./createMessageGenerator"; +import * as CCSessionProcess from "./models/CCSessionProcess"; +import type * as CCTask from "./models/ClaudeCodeTask"; + +class SessionProcessNotFoundError extends Data.TaggedError( + "SessionProcessNotFoundError", +)<{ + sessionProcessId: string; +}> {} + +class SessionProcessNotPausedError extends Data.TaggedError( + "SessionProcessNotPausedError", +)<{ + sessionProcessId: string; +}> {} + +class SessionProcessAlreadyAliveError extends Data.TaggedError( + "SessionProcessAlreadyAliveError", +)<{ + sessionProcessId: string; + aliveTaskId: string; + aliveTaskSessionId?: string; +}> {} + +class IllegalStateChangeError extends Data.TaggedError( + "IllegalStateChangeError", +)<{ + from: CCSessionProcess.CCSessionProcessState["type"]; + to: CCSessionProcess.CCSessionProcessState["type"]; +}> {} + +class TaskNotFoundError extends Data.TaggedError("TaskNotFoundError")<{ + taskId: string; +}> {} + +const LayerImpl = Effect.gen(function* () { + const processesRef = yield* Ref.make< + CCSessionProcess.CCSessionProcessState[] + >([]); + const eventBus = yield* EventBus; + + const startSessionProcess = (options: { + sessionDef: CCSessionProcess.CCSessionProcessDef; + taskDef: CCTask.NewClaudeCodeTaskDef | CCTask.ResumeClaudeCodeTaskDef; + }) => { + const { sessionDef, taskDef } = options; + + return Effect.gen(function* () { + const task: CCTask.PendingClaudeCodeTaskState = { + def: taskDef, + status: "pending", + }; + + const newProcess: CCSessionProcess.CCSessionProcessState = { + def: sessionDef, + type: "pending", + tasks: [task], + currentTask: task, + }; + + yield* Ref.update(processesRef, (processes) => [ + ...processes, + newProcess, + ]); + return { + sessionProcess: newProcess, + task, + }; + }); + }; + + const continueSessionProcess = (options: { + sessionProcessId: string; + taskDef: CCTask.ContinueClaudeCodeTaskDef; + }) => { + const { sessionProcessId } = options; + + return Effect.gen(function* () { + const process = yield* getSessionProcess(sessionProcessId); + + if (process.type !== "paused") { + return yield* Effect.fail( + new SessionProcessNotPausedError({ + sessionProcessId, + }), + ); + } + + const [firstAliveTask] = CCSessionProcess.getAliveTasks(process); + if (firstAliveTask !== undefined) { + return yield* Effect.fail( + new SessionProcessAlreadyAliveError({ + sessionProcessId, + aliveTaskId: firstAliveTask.def.taskId, + aliveTaskSessionId: + firstAliveTask.def.sessionId ?? firstAliveTask.sessionId, + }), + ); + } + + const newTask: CCTask.PendingClaudeCodeTaskState = { + def: options.taskDef, + status: "pending", + }; + + const newProcess: CCSessionProcess.CCSessionProcessPendingState = { + def: process.def, + type: "pending", + tasks: [...process.tasks, newTask], + currentTask: newTask, + }; + + yield* Ref.update(processesRef, (processes) => { + return processes.map((p) => + p.def.sessionProcessId === sessionProcessId ? newProcess : p, + ); + }); + + return { + sessionProcess: newProcess, + task: newTask, + }; + }); + }; + + const getSessionProcess = (sessionProcessId: string) => { + return Effect.gen(function* () { + const processes = yield* Ref.get(processesRef); + const result = processes.find( + (p) => p.def.sessionProcessId === sessionProcessId, + ); + if (result === undefined) { + return yield* Effect.fail( + new SessionProcessNotFoundError({ sessionProcessId }), + ); + } + return result; + }); + }; + + const getSessionProcesses = () => { + return Effect.gen(function* () { + const processes = yield* Ref.get(processesRef); + return processes; + }); + }; + + const getTask = (taskId: string) => { + return Effect.gen(function* () { + const processes = yield* Ref.get(processesRef); + const result = processes + .flatMap((p) => { + const found = p.tasks.find((t) => t.def.taskId === taskId); + if (found === undefined) { + return []; + } + + return [ + { + sessionProcess: p, + task: found, + }, + ]; + }) + .at(0); + + if (result === undefined) { + return yield* Effect.fail(new TaskNotFoundError({ taskId })); + } + + return result; + }); + }; + + const dangerouslyChangeProcessState = < + T extends CCSessionProcess.CCSessionProcessState, + >(options: { + sessionProcessId: string; + nextState: T; + }) => { + const { sessionProcessId, nextState } = options; + + return Effect.gen(function* () { + const processes = yield* Ref.get(processesRef); + const targetProcess = processes.find( + (p) => p.def.sessionProcessId === sessionProcessId, + ); + + const updatedProcesses = processes.map((p) => + p.def.sessionProcessId === sessionProcessId ? nextState : p, + ); + + yield* Ref.set(processesRef, updatedProcesses); + + if (targetProcess?.type !== nextState.type) { + yield* eventBus.emit("sessionProcessChanged", { + processes: updatedProcesses + .filter(CCSessionProcess.isPublic) + .map((process) => ({ + id: process.def.sessionProcessId, + projectId: process.def.projectId, + sessionId: process.sessionId, + status: process.type === "paused" ? "paused" : "running", + })), + changed: nextState, + }); + } + + console.log( + `sessionProcessStateChanged(${sessionProcessId}): ${targetProcess?.type} -> ${nextState.type}`, + ); + + return nextState; + }); + }; + + const changeTaskState = (options: { + sessionProcessId: string; + taskId: string; + nextTask: T; + }) => { + const { sessionProcessId, taskId, nextTask } = options; + + return Effect.gen(function* () { + const { task } = yield* getTask(taskId); + + yield* Ref.update(processesRef, (processes) => { + return processes.map((p) => + p.def.sessionProcessId === sessionProcessId + ? { + ...p, + tasks: p.tasks.map((t) => + t.def.taskId === task.def.taskId ? { ...nextTask } : t, + ), + } + : p, + ); + }); + + const updated = yield* getTask(taskId); + if (updated === undefined) { + throw new Error("Unreachable: updatedProcess is undefined"); + } + + return updated.task as T; + }); + }; + + const toNotInitializedState = (options: { + sessionProcessId: string; + rawUserMessage: string; + }) => { + const { sessionProcessId, rawUserMessage } = options; + + return Effect.gen(function* () { + const currentProcess = yield* getSessionProcess(sessionProcessId); + + if (currentProcess.type !== "pending") { + return yield* Effect.fail( + new IllegalStateChangeError({ + from: currentProcess.type, + to: "not_initialized", + }), + ); + } + + const newTask = yield* changeTaskState({ + sessionProcessId, + taskId: currentProcess.currentTask.def.taskId, + nextTask: { + status: "running", + def: currentProcess.currentTask.def, + }, + }); + + const newProcess = yield* dangerouslyChangeProcessState({ + sessionProcessId, + nextState: { + type: "not_initialized", + def: currentProcess.def, + tasks: currentProcess.tasks, + currentTask: newTask, + rawUserMessage, + }, + }); + + return { + sessionProcess: newProcess, + task: newTask, + }; + }); + }; + + const toInitializedState = (options: { + sessionProcessId: string; + initContext: InitMessageContext; + }) => { + const { sessionProcessId, initContext } = options; + + return Effect.gen(function* () { + const currentProcess = yield* getSessionProcess(sessionProcessId); + if (currentProcess.type !== "not_initialized") { + return yield* Effect.fail( + new IllegalStateChangeError({ + from: currentProcess.type, + to: "initialized", + }), + ); + } + + const newProcess = yield* dangerouslyChangeProcessState({ + sessionProcessId, + nextState: { + type: "initialized", + def: currentProcess.def, + tasks: currentProcess.tasks, + currentTask: currentProcess.currentTask, + sessionId: initContext.initMessage.session_id, + rawUserMessage: currentProcess.rawUserMessage, + initContext: initContext, + }, + }); + + return { + sessionProcess: newProcess, + }; + }); + }; + + const toPausedState = (options: { + sessionProcessId: string; + resultMessage: SDKResultMessage; + }) => { + const { sessionProcessId, resultMessage } = options; + + return Effect.gen(function* () { + const currentProcess = yield* getSessionProcess(sessionProcessId); + if (currentProcess.type !== "initialized") { + return yield* Effect.fail( + new IllegalStateChangeError({ + from: currentProcess.type, + to: "paused", + }), + ); + } + + const newTask = yield* changeTaskState({ + sessionProcessId, + taskId: currentProcess.currentTask.def.taskId, + nextTask: { + status: "completed", + def: currentProcess.currentTask.def, + sessionId: resultMessage.session_id, + }, + }); + + const newProcess = yield* dangerouslyChangeProcessState({ + sessionProcessId, + nextState: { + type: "paused", + def: currentProcess.def, + tasks: currentProcess.tasks.map((t) => + t.def.taskId === newTask.def.taskId ? newTask : t, + ), + sessionId: currentProcess.sessionId, + }, + }); + + return { + sessionProcess: newProcess, + }; + }); + }; + + const toCompletedState = (options: { + sessionProcessId: string; + error?: unknown; + }) => { + const { sessionProcessId, error } = options; + + return Effect.gen(function* () { + const currentProcess = yield* getSessionProcess(sessionProcessId); + + const currentTask = + currentProcess.type === "not_initialized" || + currentProcess.type === "initialized" + ? currentProcess.currentTask + : undefined; + + const newTask = + currentTask !== undefined + ? error !== undefined + ? ({ + status: "failed", + def: currentTask.def, + error, + } as const) + : ({ + status: "completed", + def: currentTask.def, + sessionId: currentProcess.sessionId, + } as const) + : undefined; + + if (newTask !== undefined) { + yield* changeTaskState({ + sessionProcessId, + taskId: newTask.def.taskId, + nextTask: newTask, + }); + } + + const newProcess = yield* dangerouslyChangeProcessState({ + sessionProcessId, + nextState: { + type: "completed", + def: currentProcess.def, + tasks: + newTask !== undefined + ? currentProcess.tasks.map((t) => + t.def.taskId === newTask.def.taskId ? newTask : t, + ) + : currentProcess.tasks, + sessionId: currentProcess.sessionId, + }, + }); + + return { + sessionProcess: newProcess, + task: newTask, + }; + }); + }; + + return { + // session + startSessionProcess, + continueSessionProcess, + toNotInitializedState, + toInitializedState, + toPausedState, + toCompletedState, + dangerouslyChangeProcessState, + getSessionProcesses, + getSessionProcess, + + // task + getTask, + changeTaskState, + }; +}); + +export type IClaudeCodeSessionProcessService = InferEffect; + +export class ClaudeCodeSessionProcessService extends Context.Tag( + "ClaudeCodeSessionProcessService", +)() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts deleted file mode 100644 index 50cbaa0..0000000 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ /dev/null @@ -1,430 +0,0 @@ -import prexit from "prexit"; -import { ulid } from "ulid"; -import type { Config } from "../../config/config"; -import { eventBus } from "../events/EventBus"; -import { predictSessionsDatabase } from "../session/PredictSessionsDatabase"; -import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor"; -import { createMessageGenerator } from "./createMessageGenerator"; -import type { - AliveClaudeCodeTask, - ClaudeCodeTask, - PendingClaudeCodeTask, - PermissionRequest, - PermissionResponse, - RunningClaudeCodeTask, -} from "./types"; - -export class ClaudeCodeTaskController { - private claudeCode: ClaudeCodeExecutor; - private tasks: ClaudeCodeTask[] = []; - private config: Config; - private pendingPermissionRequests: Map = new Map(); - private permissionResponses: Map = new Map(); - - constructor(config: Config) { - this.claudeCode = new ClaudeCodeExecutor(); - this.eventBus = getEventBus(); - this.config = config; - - prexit(() => { - this.aliveTasks.forEach((task) => { - task.abortController.abort(); - }); - }); - } - - public updateConfig(config: Config) { - this.config = config; - } - - public respondToPermissionRequest(response: PermissionResponse) { - this.permissionResponses.set(response.permissionRequestId, response); - this.pendingPermissionRequests.delete(response.permissionRequestId); - } - - private createCanUseToolCallback(taskId: string, sessionId?: string) { - return async ( - toolName: string, - toolInput: Record, - _options: { signal: AbortSignal }, - ) => { - // If not in default mode, use the configured permission mode behavior - if (this.config.permissionMode !== "default") { - // Convert Claude Code permission modes to canUseTool behaviors - if ( - this.config.permissionMode === "bypassPermissions" || - this.config.permissionMode === "acceptEdits" - ) { - return { - behavior: "allow" as const, - updatedInput: toolInput, - }; - } else { - // plan mode should deny actual tool execution - return { - behavior: "deny" as const, - message: "Tool execution is disabled in plan mode", - }; - } - } - - // Create permission request - const permissionRequest: PermissionRequest = { - id: ulid(), - taskId, - sessionId, - toolName, - toolInput, - timestamp: Date.now(), - }; - - // Store the request - this.pendingPermissionRequests.set( - permissionRequest.id, - permissionRequest, - ); - - // Emit event to notify UI - eventBus.emit("permissionRequested", { - permissionRequest, - }); - - // Wait for user response with timeout - const response = await this.waitForPermissionResponse( - permissionRequest.id, - 60000, - ); // 60 second timeout - - if (response) { - if (response.decision === "allow") { - return { - behavior: "allow" as const, - updatedInput: toolInput, - }; - } else { - return { - behavior: "deny" as const, - message: "Permission denied by user", - }; - } - } else { - // Timeout - default to deny for security - this.pendingPermissionRequests.delete(permissionRequest.id); - return { - behavior: "deny" as const, - message: "Permission request timed out", - }; - } - }; - } - - private async waitForPermissionResponse( - permissionRequestId: string, - timeoutMs: number, - ): Promise { - return new Promise((resolve) => { - const checkResponse = () => { - const response = this.permissionResponses.get(permissionRequestId); - if (response) { - this.permissionResponses.delete(permissionRequestId); - resolve(response); - return; - } - - // Check if request was cancelled/deleted - if (!this.pendingPermissionRequests.has(permissionRequestId)) { - resolve(null); - return; - } - - // Continue polling - setTimeout(checkResponse, 100); - }; - - // Set timeout - setTimeout(() => { - resolve(null); - }, timeoutMs); - - // Start polling - checkResponse(); - }); - } - - public get aliveTasks() { - return this.tasks.filter( - (task) => task.status === "running" || task.status === "paused", - ); - } - - public async startOrContinueTask( - currentSession: { - cwd: string; - projectId: string; - sessionId?: string; - }, - message: string, - ): Promise { - const existingTask = this.aliveTasks.find( - (task) => task.sessionId === currentSession.sessionId, - ); - - if (existingTask) { - console.log( - `Alive task for session(id=${currentSession.sessionId}) continued.`, - ); - const result = await this.continueTask(existingTask, message); - return result; - } else { - if (currentSession.sessionId === undefined) { - console.log(`New task started.`); - } else { - console.log( - `New task started for existing session(id=${currentSession.sessionId}).`, - ); - } - - const result = await this.startTask(currentSession, message); - return result; - } - } - - private async continueTask(task: AliveClaudeCodeTask, message: string) { - task.setNextMessage(message); - await task.awaitFirstMessage(); - return task; - } - - private startTask( - currentSession: { - cwd: string; - projectId: string; - sessionId?: string; - }, - userMessage: string, - ) { - const { - generateMessages, - setNextMessage, - setFirstMessagePromise, - resolveFirstMessage, - awaitFirstMessage, - } = createMessageGenerator(userMessage); - - const task: PendingClaudeCodeTask = { - status: "pending", - id: ulid(), - projectId: currentSession.projectId, - baseSessionId: currentSession.sessionId, - cwd: currentSession.cwd, - generateMessages, - setNextMessage, - setFirstMessagePromise, - resolveFirstMessage, - awaitFirstMessage, - onMessageHandlers: [], - }; - - let aliveTaskResolve: (task: AliveClaudeCodeTask) => void; - let aliveTaskReject: (error: unknown) => void; - - const aliveTaskPromise = new Promise( - (resolve, reject) => { - aliveTaskResolve = resolve; - aliveTaskReject = reject; - }, - ); - - let resolved = false; - - const handleTask = async () => { - try { - const abortController = new AbortController(); - - let currentTask: AliveClaudeCodeTask | undefined; - - for await (const message of this.claudeCode.query( - task.generateMessages(), - { - resume: task.baseSessionId, - cwd: task.cwd, - permissionMode: this.config.permissionMode, - canUseTool: this.createCanUseToolCallback( - task.id, - task.baseSessionId, - ), - abortController: abortController, - }, - )) { - currentTask ??= this.aliveTasks.find((t) => t.id === task.id); - - if (currentTask !== undefined && currentTask.status === "paused") { - this.upsertExistingTask({ - ...currentTask, - status: "running", - }); - } - - if ( - message.type === "system" && - message.subtype === "init" && - currentSession.sessionId === undefined - ) { - // because it takes time for the Claude Code file to be updated, simulate the message - predictSessionsDatabase.createPredictSession({ - id: message.session_id, - jsonlFilePath: message.session_id, - conversations: [ - { - type: "user", - message: { - role: "user", - content: userMessage, - }, - isSidechain: false, - userType: "external", - cwd: message.cwd, - sessionId: message.session_id, - version: this.claudeCode.version?.toString() ?? "unknown", - uuid: message.uuid, - timestamp: new Date().toISOString(), - parentUuid: null, - }, - ], - meta: { - firstCommand: null, - lastModifiedAt: new Date().toISOString(), - messageCount: 0, - }, - }); - } - - if (!resolved) { - const runningTask: RunningClaudeCodeTask = { - status: "running", - id: task.id, - projectId: task.projectId, - cwd: task.cwd, - generateMessages: task.generateMessages, - setNextMessage: task.setNextMessage, - resolveFirstMessage: task.resolveFirstMessage, - setFirstMessagePromise: task.setFirstMessagePromise, - awaitFirstMessage: task.awaitFirstMessage, - onMessageHandlers: task.onMessageHandlers, - sessionId: message.session_id, - abortController: abortController, - }; - this.tasks.push(runningTask); - aliveTaskResolve(runningTask); - resolved = true; - } - - resolveFirstMessage(); - - await Promise.all( - task.onMessageHandlers.map(async (onMessageHandler) => { - await onMessageHandler(message); - }), - ); - - if (currentTask !== undefined && message.type === "result") { - this.upsertExistingTask({ - ...currentTask, - status: "paused", - }); - resolved = true; - setFirstMessagePromise(); - predictSessionsDatabase.deletePredictSession(currentTask.sessionId); - } - } - - const updatedTask = this.aliveTasks.find((t) => t.id === task.id); - - if (updatedTask === undefined) { - console.log( - "[DEBUG startTask] 17. ERROR: Task not found in aliveTasks", - ); - const error = new Error( - `illegal state: task is not running, task: ${JSON.stringify( - updatedTask, - )}`, - ); - aliveTaskReject(error); - throw error; - } - - this.upsertExistingTask({ - ...updatedTask, - status: "completed", - }); - } catch (error) { - if (!resolved) { - console.log( - "[DEBUG startTask] 20. Rejecting task (not yet resolved)", - ); - aliveTaskReject(error); - resolved = true; - } - - if (error instanceof Error) { - console.error(error.message, error.stack); - } else { - console.error(error); - } - - this.upsertExistingTask({ - ...task, - status: "failed", - }); - } - }; - - // continue background - void handleTask(); - - return aliveTaskPromise; - } - - public abortTask(sessionId: string) { - const task = this.aliveTasks.find((task) => task.sessionId === sessionId); - if (!task) { - throw new Error("Alive Task not found"); - } - - task.abortController.abort(); - this.upsertExistingTask({ - id: task.id, - projectId: task.projectId, - sessionId: task.sessionId, - status: "failed", - cwd: task.cwd, - generateMessages: task.generateMessages, - setNextMessage: task.setNextMessage, - resolveFirstMessage: task.resolveFirstMessage, - setFirstMessagePromise: task.setFirstMessagePromise, - awaitFirstMessage: task.awaitFirstMessage, - onMessageHandlers: task.onMessageHandlers, - baseSessionId: task.baseSessionId, - }); - } - - private upsertExistingTask(task: ClaudeCodeTask) { - const target = this.tasks.find((t) => t.id === task.id); - - if (!target) { - console.error("Task not found", task); - this.tasks.push(task); - } else { - Object.assign(target, task); - } - - if (task.status === "paused" || task.status === "running") { - this.eventBus.emit("taskChanged", { - aliveTasks: this.aliveTasks, - changed: task, - }); - } - } -} - -export const claudeCodeTaskController = new ClaudeCodeTaskController(); diff --git a/src/server/service/claude-code/ClaudeCodeVersion.ts b/src/server/service/claude-code/ClaudeCodeVersion.ts deleted file mode 100644 index 9193a7e..0000000 --- a/src/server/service/claude-code/ClaudeCodeVersion.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { z } from "zod"; - -const versionRegex = /^(?\d+)\.(?\d+)\.(?\d+)/; -const versionSchema = z - .object({ - major: z.string().transform((value) => Number.parseInt(value, 10)), - minor: z.string().transform((value) => Number.parseInt(value, 10)), - patch: z.string().transform((value) => Number.parseInt(value, 10)), - }) - .refine((data) => - [data.major, data.minor, data.patch].every((value) => !Number.isNaN(value)), - ); - -type ParsedVersion = z.infer; - -export class ClaudeCodeVersion { - public constructor(public readonly version: ParsedVersion) {} - - public static fromCLIString(version: string) { - const groups = version.trim().match(versionRegex)?.groups; - - if (groups === undefined) { - return null; - } - - const parsed = versionSchema.safeParse(groups); - if (!parsed.success) { - return null; - } - - return new ClaudeCodeVersion(parsed.data); - } - - public get major() { - return this.version.major; - } - - public get minor() { - return this.version.minor; - } - - public get patch() { - return this.version.patch; - } - - public toString() { - return `${this.major}.${this.minor}.${this.patch}`; - } - - public equals(other: ClaudeCodeVersion) { - return ( - this.version.major === other.version.major && - this.version.minor === other.version.minor && - this.version.patch === other.version.patch - ); - } - - public greaterThan(other: ClaudeCodeVersion) { - return ( - this.version.major > other.version.major || - (this.version.major === other.version.major && - (this.version.minor > other.version.minor || - (this.version.minor === other.version.minor && - this.version.patch > other.version.patch))) - ); - } - - public greaterThanOrEqual(other: ClaudeCodeVersion) { - return this.equals(other) || this.greaterThan(other); - } -} diff --git a/src/server/service/claude-code/MessageGenerator.ts b/src/server/service/claude-code/MessageGenerator.ts new file mode 100644 index 0000000..80562ce --- /dev/null +++ b/src/server/service/claude-code/MessageGenerator.ts @@ -0,0 +1,83 @@ +import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-code"; +import { controllablePromise } from "../../../lib/controllablePromise"; + +export type OnMessage = (message: SDKMessage) => void | Promise; + +export type MessageGenerator = () => AsyncGenerator< + SDKUserMessage, + void, + unknown +>; + +export const createMessageGenerator = (): { + generateMessages: MessageGenerator; + setNextMessage: (message: string) => void; + setHooks: (hooks: { + onNextMessageSet?: (message: string) => void | Promise; + onNewUserMessageResolved?: (message: string) => void | Promise; + }) => void; +} => { + let sendMessagePromise = controllablePromise(); + let registeredHooks: { + onNextMessageSet: ((message: string) => void | Promise)[]; + onNewUserMessageResolved: ((message: string) => void | Promise)[]; + } = { + onNextMessageSet: [], + onNewUserMessageResolved: [], + }; + + const createMessage = (message: string): SDKUserMessage => { + return { + type: "user", + message: { + role: "user", + content: message, + }, + } as SDKUserMessage; + }; + + async function* generateMessages(): ReturnType { + sendMessagePromise = controllablePromise(); + + while (true) { + const message = await sendMessagePromise.promise; + sendMessagePromise = controllablePromise(); + void Promise.allSettled( + registeredHooks.onNewUserMessageResolved.map((hook) => hook(message)), + ); + + yield createMessage(message); + } + } + + const setNextMessage = (message: string) => { + sendMessagePromise.resolve(message); + void Promise.allSettled( + registeredHooks.onNextMessageSet.map((hook) => hook(message)), + ); + }; + + const setHooks = (hooks: { + onNextMessageSet?: (message: string) => void | Promise; + onNewUserMessageResolved?: (message: string) => void | Promise; + }) => { + registeredHooks = { + onNextMessageSet: [ + ...(hooks?.onNextMessageSet ? [hooks.onNextMessageSet] : []), + ...registeredHooks.onNextMessageSet, + ], + onNewUserMessageResolved: [ + ...(hooks?.onNewUserMessageResolved + ? [hooks.onNewUserMessageResolved] + : []), + ...registeredHooks.onNewUserMessageResolved, + ], + }; + }; + + return { + generateMessages, + setNextMessage, + setHooks, + }; +}; diff --git a/src/server/service/claude-code/createMessageGenerator.ts b/src/server/service/claude-code/createMessageGenerator.ts index 2534178..541b123 100644 --- a/src/server/service/claude-code/createMessageGenerator.ts +++ b/src/server/service/claude-code/createMessageGenerator.ts @@ -1,4 +1,8 @@ -import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-code"; +import type { + SDKMessage, + SDKSystemMessage, + SDKUserMessage, +} from "@anthropic-ai/claude-code"; export type OnMessage = (message: SDKMessage) => void | Promise; @@ -28,17 +32,21 @@ const createPromise = () => { } as const; }; +export type InitMessageContext = { + initMessage: SDKSystemMessage; +}; + export const createMessageGenerator = ( firstMessage: string, ): { generateMessages: MessageGenerator; setNextMessage: (message: string) => void; - setFirstMessagePromise: () => void; - resolveFirstMessage: () => void; - awaitFirstMessage: () => Promise; + setInitMessagePromise: () => void; + resolveInitMessage: (context: InitMessageContext) => void; + awaitInitMessage: (ctx: InitMessageContext) => Promise; } => { let sendMessagePromise = createPromise(); - let receivedFirstMessagePromise = createPromise(); + let receivedInitMessagePromise = createPromise(); const createMessage = (message: string): SDKUserMessage => { return { @@ -65,23 +73,23 @@ export const createMessageGenerator = ( sendMessagePromise.resolve(message); }; - const setFirstMessagePromise = () => { - receivedFirstMessagePromise = createPromise(); + const setInitMessagePromise = () => { + receivedInitMessagePromise = createPromise(); }; - const resolveFirstMessage = () => { - receivedFirstMessagePromise.resolve(undefined); + const resolveInitMessage = (context: InitMessageContext) => { + receivedInitMessagePromise.resolve(context); }; - const awaitFirstMessage = async () => { - await receivedFirstMessagePromise.promise; + const awaitInitMessage = async () => { + await receivedInitMessagePromise.promise; }; return { generateMessages, setNextMessage, - setFirstMessagePromise, - resolveFirstMessage, - awaitFirstMessage, + setInitMessagePromise, + resolveInitMessage, + awaitInitMessage, }; }; diff --git a/src/server/service/claude-code/models/CCSessionProcess.ts b/src/server/service/claude-code/models/CCSessionProcess.ts new file mode 100644 index 0000000..59e7488 --- /dev/null +++ b/src/server/service/claude-code/models/CCSessionProcess.ts @@ -0,0 +1,108 @@ +import { Effect } from "effect"; +import type { UserEntry } from "../../../../lib/conversation-schema/entry/UserEntrySchema"; +import * as ClaudeCode from "../ClaudeCode"; +import type { InitMessageContext } from "../createMessageGenerator"; +import type * as CCTask from "./ClaudeCodeTask"; +import * as ClaudeCodeVersion from "./ClaudeCodeVersion"; + +export type CCSessionProcessDef = { + sessionProcessId: string; + projectId: string; + cwd: string; + abortController: AbortController; + setNextMessage: (message: string) => void; +}; + +type CCSessionProcessStateBase = { + def: CCSessionProcessDef; + tasks: CCTask.ClaudeCodeTaskState[]; +}; + +export type CCSessionProcessPendingState = CCSessionProcessStateBase & { + type: "pending" /* メッセージがまだ解決されていない状態 */; + sessionId?: undefined; + currentTask: CCTask.PendingClaudeCodeTaskState; +}; + +export type CCSessionProcessNotInitializedState = CCSessionProcessStateBase & { + type: "not_initialized" /* メッセージは解決されているが、init メッセージを未受信 */; + sessionId?: undefined; + currentTask: CCTask.RunningClaudeCodeTaskState; + rawUserMessage: string; +}; + +export type CCSessionProcessInitializedState = CCSessionProcessStateBase & { + type: "initialized" /* init メッセージを受信した状態 */; + sessionId: string; + currentTask: CCTask.RunningClaudeCodeTaskState; + rawUserMessage: string; + initContext: InitMessageContext; +}; + +export type CCSessionProcessPausedState = CCSessionProcessStateBase & { + type: "paused" /* タスクが完了し、次のタスクを受け付け可能 */; + sessionId: string; +}; + +export type CCSessionProcessCompletedState = CCSessionProcessStateBase & { + type: "completed" /* paused あるいは起動中のタスクが中断された状態。再開不可 */; + sessionId?: string | undefined; +}; + +export type CCSessionProcessStatePublic = + | CCSessionProcessInitializedState + | CCSessionProcessPausedState; + +export type CCSessionProcessState = + | CCSessionProcessPendingState + | CCSessionProcessNotInitializedState + | CCSessionProcessStatePublic + | CCSessionProcessCompletedState; + +export const isPublic = ( + process: CCSessionProcessState, +): process is CCSessionProcessStatePublic => { + return process.type === "initialized" || process.type === "paused"; +}; + +export const getAliveTasks = ( + process: CCSessionProcessState, +): CCTask.AliveClaudeCodeTaskState[] => { + return process.tasks.filter( + (task) => task.status === "pending" || task.status === "running", + ); +}; + +export const createVirtualConversation = ( + process: CCSessionProcessState, + ctx: { + sessionId: string; + userMessage: string; + }, +) => { + const timestamp = new Date().toISOString(); + + return Effect.gen(function* () { + const config = yield* ClaudeCode.Config; + + const virtualConversation: UserEntry = { + type: "user", + message: { + role: "user", + content: ctx.userMessage, + }, + isSidechain: false, + userType: "external", + cwd: process.def.cwd, + sessionId: ctx.sessionId, + version: config.claudeCodeVersion + ? ClaudeCodeVersion.versionText(config.claudeCodeVersion) + : "unknown", + uuid: `vc__${ctx.sessionId}__${timestamp}`, + timestamp, + parentUuid: null, + }; + + return virtualConversation; + }); +}; diff --git a/src/server/service/claude-code/models/ClaudeCodeTask.ts b/src/server/service/claude-code/models/ClaudeCodeTask.ts new file mode 100644 index 0000000..d4b3788 --- /dev/null +++ b/src/server/service/claude-code/models/ClaudeCodeTask.ts @@ -0,0 +1,59 @@ +type BaseClaudeCodeTaskDef = { + taskId: string; +}; + +export type NewClaudeCodeTaskDef = BaseClaudeCodeTaskDef & { + type: "new"; + sessionId?: undefined; + baseSessionId?: undefined; +}; + +export type ContinueClaudeCodeTaskDef = BaseClaudeCodeTaskDef & { + type: "continue"; + sessionId: string; + baseSessionId: string; +}; + +export type ResumeClaudeCodeTaskDef = BaseClaudeCodeTaskDef & { + type: "resume"; + sessionId?: undefined; + baseSessionId: string; +}; + +export type ClaudeCodeTaskDef = + | NewClaudeCodeTaskDef + | ContinueClaudeCodeTaskDef + | ResumeClaudeCodeTaskDef; + +type ClaudeCodeTaskStateBase = { + def: ClaudeCodeTaskDef; +}; + +export type PendingClaudeCodeTaskState = ClaudeCodeTaskStateBase & { + status: "pending"; + sessionId?: undefined; +}; + +export type RunningClaudeCodeTaskState = ClaudeCodeTaskStateBase & { + status: "running"; + sessionId?: undefined; +}; + +export type CompletedClaudeCodeTaskState = ClaudeCodeTaskStateBase & { + status: "completed"; + sessionId?: string | undefined; +}; + +export type FailedClaudeCodeTaskState = ClaudeCodeTaskStateBase & { + status: "failed"; + error: unknown; +}; + +export type AliveClaudeCodeTaskState = + | PendingClaudeCodeTaskState + | RunningClaudeCodeTaskState; + +export type ClaudeCodeTaskState = + | AliveClaudeCodeTaskState + | CompletedClaudeCodeTaskState + | FailedClaudeCodeTaskState; diff --git a/src/server/service/claude-code/models/ClaudeCodeVersion.test.ts b/src/server/service/claude-code/models/ClaudeCodeVersion.test.ts new file mode 100644 index 0000000..aac77e5 --- /dev/null +++ b/src/server/service/claude-code/models/ClaudeCodeVersion.test.ts @@ -0,0 +1,84 @@ +import * as ClaudeCodeVersion from "./ClaudeCodeVersion"; + +describe("ClaudeCodeVersion.fromCLIString", () => { + describe("with valid version string", () => { + it("should correctly parse CLI output format: 'x.x.x (Claude Code)'", () => { + const version = ClaudeCodeVersion.fromCLIString("1.0.53 (Claude Code)\n"); + expect(version).toStrictEqual({ + major: 1, + minor: 0, + patch: 53, + }); + }); + }); + + describe("with invalid version string", () => { + it("should return null for non-version format strings", () => { + const version = ClaudeCodeVersion.fromCLIString("invalid version"); + expect(version).toBeNull(); + }); + }); +}); + +describe("ClaudeCodeVersion.versionText", () => { + it("should convert version object to 'major.minor.patch' format string", () => { + const text = ClaudeCodeVersion.versionText({ + major: 1, + minor: 0, + patch: 53, + }); + expect(text).toBe("1.0.53"); + }); +}); + +describe("ClaudeCodeVersion.equals", () => { + describe("with same version", () => { + it("should return true", () => { + const a = { major: 1, minor: 0, patch: 53 }; + const b = { major: 1, minor: 0, patch: 53 }; + expect(ClaudeCodeVersion.equals(a, b)).toBe(true); + }); + }); +}); + +describe("ClaudeCodeVersion.greaterThan", () => { + describe("when a is greater than b", () => { + it("should return true when major is greater", () => { + const a = { major: 2, minor: 0, patch: 0 }; + const b = { major: 1, minor: 9, patch: 99 }; + expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(true); + }); + + it("should return true when major is same and minor is greater", () => { + const a = { major: 1, minor: 1, patch: 0 }; + const b = { major: 1, minor: 0, patch: 99 }; + expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(true); + }); + + it("should return true when major and minor are same and patch is greater", () => { + const a = { major: 1, minor: 0, patch: 86 }; + const b = { major: 1, minor: 0, patch: 85 }; + expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(true); + }); + }); + + describe("when a is less than or equal to b", () => { + it("should return false for same version", () => { + const a = { major: 1, minor: 0, patch: 53 }; + const b = { major: 1, minor: 0, patch: 53 }; + expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(false); + }); + + it("should return false when a is less than b", () => { + const a = { major: 1, minor: 0, patch: 81 }; + const b = { major: 1, minor: 0, patch: 82 }; + expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(false); + }); + + it("should return false when major is less", () => { + const a = { major: 1, minor: 9, patch: 99 }; + const b = { major: 2, minor: 0, patch: 0 }; + expect(ClaudeCodeVersion.greaterThan(a, b)).toBe(false); + }); + }); +}); diff --git a/src/server/service/claude-code/models/ClaudeCodeVersion.ts b/src/server/service/claude-code/models/ClaudeCodeVersion.ts new file mode 100644 index 0000000..707f0ea --- /dev/null +++ b/src/server/service/claude-code/models/ClaudeCodeVersion.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +const versionRegex = /^(?\d+)\.(?\d+)\.(?\d+)/; +const versionSchema = z + .object({ + major: z.string().transform((value) => Number.parseInt(value, 10)), + minor: z.string().transform((value) => Number.parseInt(value, 10)), + patch: z.string().transform((value) => Number.parseInt(value, 10)), + }) + .refine((data) => + [data.major, data.minor, data.patch].every((value) => !Number.isNaN(value)), + ); + +export type ClaudeCodeVersion = z.infer; + +export const fromCLIString = ( + versionOutput: string, +): ClaudeCodeVersion | null => { + const groups = versionOutput.trim().match(versionRegex)?.groups; + + if (groups === undefined) { + return null; + } + + const parsed = versionSchema.safeParse(groups); + if (!parsed.success) { + return null; + } + + return parsed.data; +}; + +export const versionText = (version: ClaudeCodeVersion) => + `${version.major}.${version.minor}.${version.patch}`; + +export const equals = (a: ClaudeCodeVersion, b: ClaudeCodeVersion) => + a.major === b.major && a.minor === b.minor && a.patch === b.patch; + +export const greaterThan = (a: ClaudeCodeVersion, b: ClaudeCodeVersion) => + a.major > b.major || + (a.major === b.major && + (a.minor > b.minor || (a.minor === b.minor && a.patch > b.patch))); + +export const greaterThanOrEqual = ( + a: ClaudeCodeVersion, + b: ClaudeCodeVersion, +) => equals(a, b) || greaterThan(a, b); diff --git a/src/server/service/claude-code/types.ts b/src/server/service/claude-code/types.ts deleted file mode 100644 index c920d14..0000000 --- a/src/server/service/claude-code/types.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { MessageGenerator, OnMessage } from "./createMessageGenerator"; - -type BaseClaudeCodeTask = { - id: string; - projectId: string; - baseSessionId?: string | undefined; // undefined = new session - cwd: string; - generateMessages: MessageGenerator; - setNextMessage: (message: string) => void; - resolveFirstMessage: () => void; - setFirstMessagePromise: () => void; - awaitFirstMessage: () => Promise; - onMessageHandlers: OnMessage[]; -}; - -export type PendingClaudeCodeTask = BaseClaudeCodeTask & { - status: "pending"; -}; - -export type RunningClaudeCodeTask = BaseClaudeCodeTask & { - status: "running"; - sessionId: string; - abortController: AbortController; -}; - -export type PausedClaudeCodeTask = BaseClaudeCodeTask & { - status: "paused"; - sessionId: string; - abortController: AbortController; -}; - -type CompletedClaudeCodeTask = BaseClaudeCodeTask & { - status: "completed"; - sessionId: string; - abortController: AbortController; - resolveFirstMessage: () => void; -}; - -type FailedClaudeCodeTask = BaseClaudeCodeTask & { - status: "failed"; - sessionId?: string; - userMessageId?: string; - abortController?: AbortController; -}; - -export type ClaudeCodeTask = - | RunningClaudeCodeTask - | PausedClaudeCodeTask - | CompletedClaudeCodeTask - | FailedClaudeCodeTask; - -export type AliveClaudeCodeTask = RunningClaudeCodeTask | PausedClaudeCodeTask; - -export type SerializableAliveTask = Pick< - AliveClaudeCodeTask, - "id" | "status" | "sessionId" ->; - -export type PermissionRequest = { - id: string; - taskId: string; - sessionId?: string; - toolName: string; - toolInput: Record; - timestamp: number; -}; - -export type PermissionResponse = { - permissionRequestId: string; - decision: "allow" | "deny"; -}; diff --git a/src/server/service/events/EventBus.test.ts b/src/server/service/events/EventBus.test.ts new file mode 100644 index 0000000..21e5793 --- /dev/null +++ b/src/server/service/events/EventBus.test.ts @@ -0,0 +1,282 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import type { PermissionRequest } from "../../../types/permissions"; +import type { PublicSessionProcess } from "../../../types/session-process"; +import type { CCSessionProcessState } from "../claude-code/models/CCSessionProcess"; +import { EventBus } from "./EventBus"; +import type { InternalEventDeclaration } from "./InternalEventDeclaration"; + +describe("EventBus", () => { + describe("basic event processing", () => { + it("can send and receive events with emit and on", async () => { + const program = Effect.gen(function* () { + const eventBus = yield* EventBus; + const events: Array = []; + + const listener = (event: InternalEventDeclaration["heartbeat"]) => { + events.push(event); + }; + + yield* eventBus.on("heartbeat", listener); + yield* eventBus.emit("heartbeat", {}); + + // Wait a bit since events are processed asynchronously + yield* Effect.sleep("10 millis"); + + return events; + }); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(EventBus.Live)), + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({}); + }); + + it("events are delivered to multiple listeners", async () => { + const program = Effect.gen(function* () { + const eventBus = yield* EventBus; + const events1: Array = []; + const events2: Array = []; + + const listener1 = ( + event: InternalEventDeclaration["sessionChanged"], + ) => { + events1.push(event); + }; + + const listener2 = ( + event: InternalEventDeclaration["sessionChanged"], + ) => { + events2.push(event); + }; + + yield* eventBus.on("sessionChanged", listener1); + yield* eventBus.on("sessionChanged", listener2); + + yield* eventBus.emit("sessionChanged", { + projectId: "project-1", + sessionId: "session-1", + }); + + yield* Effect.sleep("10 millis"); + + return { events1, events2 }; + }); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(EventBus.Live)), + ); + + expect(result.events1).toHaveLength(1); + expect(result.events2).toHaveLength(1); + expect(result.events1[0]).toEqual({ + projectId: "project-1", + sessionId: "session-1", + }); + expect(result.events2[0]).toEqual({ + projectId: "project-1", + sessionId: "session-1", + }); + }); + + it("can remove listener with off", async () => { + const program = Effect.gen(function* () { + const eventBus = yield* EventBus; + const events: Array = []; + + const listener = (event: InternalEventDeclaration["heartbeat"]) => { + events.push(event); + }; + + yield* eventBus.on("heartbeat", listener); + yield* eventBus.emit("heartbeat", {}); + yield* Effect.sleep("10 millis"); + + // Remove listener + yield* eventBus.off("heartbeat", listener); + yield* eventBus.emit("heartbeat", {}); + yield* Effect.sleep("10 millis"); + + return events; + }); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(EventBus.Live)), + ); + + // Only receives first emit + expect(result).toHaveLength(1); + }); + }); + + describe("different event types", () => { + it("can process sessionListChanged event", async () => { + const program = Effect.gen(function* () { + const eventBus = yield* EventBus; + const events: Array = + []; + + const listener = ( + event: InternalEventDeclaration["sessionListChanged"], + ) => { + events.push(event); + }; + + yield* eventBus.on("sessionListChanged", listener); + yield* eventBus.emit("sessionListChanged", { + projectId: "project-1", + }); + + yield* Effect.sleep("10 millis"); + + return events; + }); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(EventBus.Live)), + ); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ projectId: "project-1" }); + }); + + it("can process sessionProcessChanged event", async () => { + const program = Effect.gen(function* () { + const eventBus = yield* EventBus; + const events: Array = + []; + + const listener = ( + event: InternalEventDeclaration["sessionProcessChanged"], + ) => { + events.push(event); + }; + + yield* eventBus.on("sessionProcessChanged", listener); + + const mockProcess: CCSessionProcessState = { + type: "initialized", + sessionId: "session-1", + currentTask: { + status: "running", + def: { + type: "new", + taskId: "task-1", + }, + }, + rawUserMessage: "test message", + initContext: {} as never, + def: { + sessionProcessId: "process-1", + projectId: "project-1", + cwd: "/test/path", + abortController: new AbortController(), + setNextMessage: () => {}, + }, + tasks: [], + }; + + const publicProcess: PublicSessionProcess = { + id: "process-1", + projectId: "project-1", + sessionId: "session-1", + status: "running", + }; + + yield* eventBus.emit("sessionProcessChanged", { + processes: [publicProcess], + changed: mockProcess, + }); + + yield* Effect.sleep("10 millis"); + + return events; + }); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(EventBus.Live)), + ); + + expect(result).toHaveLength(1); + expect(result.at(0)?.processes).toHaveLength(1); + }); + + it("can process permissionRequested event", async () => { + const program = Effect.gen(function* () { + const eventBus = yield* EventBus; + const events: Array = + []; + + const listener = ( + event: InternalEventDeclaration["permissionRequested"], + ) => { + events.push(event); + }; + + yield* eventBus.on("permissionRequested", listener); + + const mockPermissionRequest: PermissionRequest = { + id: "permission-1", + taskId: "task-1", + toolName: "read", + toolInput: {}, + timestamp: Date.now(), + }; + + yield* eventBus.emit("permissionRequested", { + permissionRequest: mockPermissionRequest, + }); + + yield* Effect.sleep("10 millis"); + + return events; + }); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(EventBus.Live)), + ); + + expect(result).toHaveLength(1); + expect(result.at(0)?.permissionRequest.id).toBe("permission-1"); + }); + }); + + describe("error handling", () => { + it("errors thrown by listeners don't affect other listeners", async () => { + const program = Effect.gen(function* () { + const eventBus = yield* EventBus; + const events1: Array = []; + const events2: Array = []; + + const failingListener = ( + _event: InternalEventDeclaration["heartbeat"], + ) => { + throw new Error("Listener error"); + }; + + const successListener = ( + event: InternalEventDeclaration["heartbeat"], + ) => { + events2.push(event); + }; + + yield* eventBus.on("heartbeat", failingListener); + yield* eventBus.on("heartbeat", successListener); + + yield* eventBus.emit("heartbeat", {}); + yield* Effect.sleep("10 millis"); + + return { events1, events2 }; + }); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(EventBus.Live)), + ); + + // failingListener fails, but successListener works normally + expect(result.events2).toHaveLength(1); + }); + }); +}); diff --git a/src/server/service/events/EventBus.ts b/src/server/service/events/EventBus.ts index 9bc0a2b..902b7b2 100644 --- a/src/server/service/events/EventBus.ts +++ b/src/server/service/events/EventBus.ts @@ -1,39 +1,83 @@ -import { EventEmitter } from "node:stream"; +import { Context, Effect, Layer } from "effect"; import type { InternalEventDeclaration } from "./InternalEventDeclaration"; -class EventBus { - private emitter: EventEmitter; +type Listener = (data: T) => void | Promise; - constructor() { - this.emitter = new EventEmitter(); - } - - public emit( +interface EventBusService { + readonly emit: ( event: EventName, data: InternalEventDeclaration[EventName], - ): void { - this.emitter.emit(event, { - ...data, - }); - } - - public on( + ) => Effect.Effect; + readonly on: ( event: EventName, - listener: ( - data: InternalEventDeclaration[EventName], - ) => void | Promise, - ): void { - this.emitter.on(event, listener); - } - - public off( + listener: Listener, + ) => Effect.Effect; + readonly off: ( event: EventName, - listener: ( - data: InternalEventDeclaration[EventName], - ) => void | Promise, - ): void { - this.emitter.off(event, listener); - } + listener: Listener, + ) => Effect.Effect; } -export const eventBus = new EventBus(); +export class EventBus extends Context.Tag("EventBus")< + EventBus, + EventBusService +>() { + static Live = Layer.effect( + this, + Effect.gen(function* () { + const listenersMap = new Map< + keyof InternalEventDeclaration, + Set> + >(); + + const getListeners = ( + event: EventName, + ): Set> => { + if (!listenersMap.has(event)) { + listenersMap.set(event, new Set()); + } + return listenersMap.get(event) as Set< + Listener + >; + }; + + const emit = ( + event: EventName, + data: InternalEventDeclaration[EventName], + ): Effect.Effect => + Effect.gen(function* () { + const listeners = getListeners(event); + + void Promise.allSettled( + Array.from(listeners).map(async (listener) => { + await listener(data); + }), + ); + }); + + const on = ( + event: EventName, + listener: Listener, + ): Effect.Effect => + Effect.sync(() => { + const listeners = getListeners(event); + listeners.add(listener); + }); + + const off = ( + event: EventName, + listener: Listener, + ): Effect.Effect => + Effect.sync(() => { + const listeners = getListeners(event); + listeners.delete(listener); + }); + + return { + emit, + on, + off, + } satisfies EventBusService; + }), + ); +} diff --git a/src/server/service/events/InternalEventDeclaration.ts b/src/server/service/events/InternalEventDeclaration.ts index d221353..b120ef6 100644 --- a/src/server/service/events/InternalEventDeclaration.ts +++ b/src/server/service/events/InternalEventDeclaration.ts @@ -1,8 +1,6 @@ -import type { - AliveClaudeCodeTask, - ClaudeCodeTask, - PermissionRequest, -} from "../claude-code/types"; +import type { PermissionRequest } from "../../../types/permissions"; +import type { PublicSessionProcess } from "../../../types/session-process"; +import type * as CCSessionProcess from "../claude-code/models/CCSessionProcess"; export type InternalEventDeclaration = { // biome-ignore lint/complexity/noBannedTypes: correct type @@ -17,9 +15,9 @@ export type InternalEventDeclaration = { sessionId: string; }; - taskChanged: { - aliveTasks: AliveClaudeCodeTask[]; - changed: ClaudeCodeTask; + sessionProcessChanged: { + processes: PublicSessionProcess[]; + changed: CCSessionProcess.CCSessionProcessState; }; permissionRequested: { diff --git a/src/server/service/events/adaptInternalEventToSSE.ts b/src/server/service/events/adaptInternalEventToSSE.ts index 9e53c41..5c281ce 100644 --- a/src/server/service/events/adaptInternalEventToSSE.ts +++ b/src/server/service/events/adaptInternalEventToSSE.ts @@ -1,7 +1,4 @@ import type { SSEStreamingApi } from "hono/streaming"; -import { eventBus } from "./EventBus"; -import type { InternalEventDeclaration } from "./InternalEventDeclaration"; -import { writeTypeSafeSSE } from "./typeSafeSSE"; export const adaptInternalEventToSSE = ( rawStream: SSEStreamingApi, @@ -12,10 +9,6 @@ export const adaptInternalEventToSSE = ( ) => { const { timeout = 60 * 1000, cleanUp } = options ?? {}; - console.log("SSE connection started"); - - const stream = writeTypeSafeSSE(rawStream); - const abortController = new AbortController(); let connectionResolve: (() => void) | undefined; const connectionPromise = new Promise((resolve) => { @@ -23,42 +16,15 @@ export const adaptInternalEventToSSE = ( }); const closeConnection = () => { - console.log("SSE connection closed"); connectionResolve?.(); abortController.abort(); - - eventBus.off("heartbeat", heartbeat); - eventBus.off("permissionRequested", permissionRequested); cleanUp?.(); }; rawStream.onAbort(() => { - console.log("SSE connection aborted"); closeConnection(); }); - // Event Listeners - const heartbeat = (event: InternalEventDeclaration["heartbeat"]) => { - stream.writeSSE("heartbeat", { - ...event, - }); - }; - - const permissionRequested = ( - event: InternalEventDeclaration["permissionRequested"], - ) => { - stream.writeSSE("permission_requested", { - permissionRequest: event.permissionRequest, - }); - }; - - eventBus.on("heartbeat", heartbeat); - eventBus.on("permissionRequested", permissionRequested); - - stream.writeSSE("connect", { - timestamp: new Date().toISOString(), - }); - setTimeout(() => { closeConnection(); }, timeout); diff --git a/src/server/service/events/fileWatcher.test.ts b/src/server/service/events/fileWatcher.test.ts new file mode 100644 index 0000000..7069085 --- /dev/null +++ b/src/server/service/events/fileWatcher.test.ts @@ -0,0 +1,176 @@ +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; +import { EventBus } from "./EventBus"; +import { FileWatcherService } from "./fileWatcher"; +import type { InternalEventDeclaration } from "./InternalEventDeclaration"; + +describe("FileWatcherService", () => { + describe("startWatching", () => { + it("can start file watching", async () => { + const program = Effect.gen(function* () { + const watcher = yield* FileWatcherService; + + // Start watching + yield* watcher.startWatching(); + + // Confirm successful start (no errors) + return true; + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(FileWatcherService.Live), + Effect.provide(EventBus.Live), + ), + ); + + expect(result).toBe(true); + }); + + it("can stop watching with stop", async () => { + const program = Effect.gen(function* () { + const watcher = yield* FileWatcherService; + + // Start watching + yield* watcher.startWatching(); + + // Stop watching + yield* watcher.stop(); + + return true; + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(FileWatcherService.Live), + Effect.provide(EventBus.Live), + ), + ); + + expect(result).toBe(true); + }); + + it("only starts once even when startWatching is called multiple times", async () => { + const program = Effect.gen(function* () { + const watcher = yield* FileWatcherService; + + // Start watching multiple times + yield* watcher.startWatching(); + yield* watcher.startWatching(); + yield* watcher.startWatching(); + + // Confirm no errors occur + return true; + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(FileWatcherService.Live), + Effect.provide(EventBus.Live), + ), + ); + + expect(result).toBe(true); + }); + + it("can call startWatching again after stop", async () => { + const program = Effect.gen(function* () { + const watcher = yield* FileWatcherService; + + // Start watching + yield* watcher.startWatching(); + + // Stop watching + yield* watcher.stop(); + + // Start watching again + yield* watcher.startWatching(); + + // Stop watching + yield* watcher.stop(); + + return true; + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(FileWatcherService.Live), + Effect.provide(EventBus.Live), + ), + ); + + expect(result).toBe(true); + }); + }); + + describe("verify event firing behavior", () => { + it("file change events propagate to EventBus (integration test)", async () => { + const program = Effect.gen(function* () { + const watcher = yield* FileWatcherService; + const eventBus = yield* EventBus; + + const sessionChangedEvents: Array< + InternalEventDeclaration["sessionChanged"] + > = []; + + // Register event listener + const listener = ( + event: InternalEventDeclaration["sessionChanged"], + ) => { + sessionChangedEvents.push(event); + }; + + yield* eventBus.on("sessionChanged", listener); + + // Start watching + yield* watcher.startWatching(); + + // Note: It's difficult to trigger actual file changes, + // so here we only verify that watching starts successfully + yield* Effect.sleep("50 millis"); + + // Stop watching + yield* watcher.stop(); + + yield* eventBus.off("sessionChanged", listener); + + // Confirm watching started + return true; + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(FileWatcherService.Live), + Effect.provide(EventBus.Live), + ), + ); + + expect(result).toBe(true); + }); + }); + + describe("error handling", () => { + it("continues processing without throwing errors even with invalid directories", async () => { + const program = Effect.gen(function* () { + const watcher = yield* FileWatcherService; + + // Start watching (catches errors and continues even with invalid directories) + yield* watcher.startWatching(); + + // Confirm no errors occur and processing continues normally + yield* watcher.stop(); + + return true; + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(FileWatcherService.Live), + Effect.provide(EventBus.Live), + ), + ); + + expect(result).toBe(true); + }); + }); +}); diff --git a/src/server/service/events/fileWatcher.ts b/src/server/service/events/fileWatcher.ts index 0871678..91f60da 100644 --- a/src/server/service/events/fileWatcher.ts +++ b/src/server/service/events/fileWatcher.ts @@ -1,7 +1,8 @@ import { type FSWatcher, watch } from "node:fs"; +import { Context, Effect, Layer, Ref } from "effect"; import z from "zod"; import { claudeProjectsDirPath } from "../paths"; -import { eventBus } from "./EventBus"; +import { EventBus } from "./EventBus"; const fileRegExp = /(?.*?)\/(?.*?)\.jsonl/; const fileRegExpGroupSchema = z.object({ @@ -9,65 +10,99 @@ const fileRegExpGroupSchema = z.object({ sessionId: z.string(), }); -export class FileWatcherService { - private isWatching = false; - private watcher: FSWatcher | null = null; - private projectWatchers: Map = new Map(); - - public startWatching(): void { - if (this.isWatching) return; - this.isWatching = true; - - try { - console.log("Starting file watcher on:", claudeProjectsDirPath); - // メインプロジェクトディレクトリを監視 - this.watcher = watch( - claudeProjectsDirPath, - { persistent: false, recursive: true }, - (eventType, filename) => { - if (!filename) return; - - const groups = fileRegExpGroupSchema.safeParse( - filename.match(fileRegExp)?.groups, - ); - - if (!groups.success) return; - - const { projectId, sessionId } = groups.data; - - if (eventType === "change") { - // セッションファイルの中身が変更されている - eventBus.emit("sessionChanged", { - projectId, - sessionId, - }); - } else if (eventType === "rename") { - // セッションファイルの追加/削除 - eventBus.emit("sessionListChanged", { - projectId, - }); - } else { - eventType satisfies never; - } - }, - ); - console.log("File watcher initialization completed"); - } catch (error) { - console.error("Failed to start file watching:", error); - } - } - - public stop(): void { - if (this.watcher) { - this.watcher.close(); - this.watcher = null; - } - - for (const [, watcher] of this.projectWatchers) { - watcher.close(); - } - this.projectWatchers.clear(); - } +interface FileWatcherServiceInterface { + readonly startWatching: () => Effect.Effect; + readonly stop: () => Effect.Effect; } -export const fileWatcher = new FileWatcherService(); +export class FileWatcherService extends Context.Tag("FileWatcherService")< + FileWatcherService, + FileWatcherServiceInterface +>() { + static Live = Layer.effect( + this, + Effect.gen(function* () { + const eventBus = yield* EventBus; + const isWatchingRef = yield* Ref.make(false); + const watcherRef = yield* Ref.make(null); + const projectWatchersRef = yield* Ref.make>( + new Map(), + ); + + const startWatching = (): Effect.Effect => + Effect.gen(function* () { + const isWatching = yield* Ref.get(isWatchingRef); + if (isWatching) return; + + yield* Ref.set(isWatchingRef, true); + + yield* Effect.tryPromise({ + try: async () => { + console.log("Starting file watcher on:", claudeProjectsDirPath); + const watcher = watch( + claudeProjectsDirPath, + { persistent: false, recursive: true }, + (_eventType, filename) => { + if (!filename) return; + + const groups = fileRegExpGroupSchema.safeParse( + filename.match(fileRegExp)?.groups, + ); + + if (!groups.success) return; + + const { projectId, sessionId } = groups.data; + + Effect.runFork( + eventBus.emit("sessionChanged", { + projectId, + sessionId, + }), + ); + + Effect.runFork( + eventBus.emit("sessionListChanged", { + projectId, + }), + ); + }, + ); + + await Effect.runPromise(Ref.set(watcherRef, watcher)); + console.log("File watcher initialization completed"); + }, + catch: (error) => { + console.error("Failed to start file watching:", error); + return new Error( + `Failed to start file watching: ${String(error)}`, + ); + }, + }).pipe( + // エラーが発生しても続行する + Effect.catchAll(() => Effect.void), + ); + }); + + const stop = (): Effect.Effect => + Effect.gen(function* () { + const watcher = yield* Ref.get(watcherRef); + if (watcher) { + yield* Effect.sync(() => watcher.close()); + yield* Ref.set(watcherRef, null); + } + + const projectWatchers = yield* Ref.get(projectWatchersRef); + for (const [, projectWatcher] of projectWatchers) { + yield* Effect.sync(() => projectWatcher.close()); + } + yield* Ref.set(projectWatchersRef, new Map()); + yield* Ref.set(isWatchingRef, false); + }); + + return { + startWatching, + stop, + } satisfies FileWatcherServiceInterface; + }), + ); +} diff --git a/src/server/service/events/typeSafeSSE.test.ts b/src/server/service/events/typeSafeSSE.test.ts new file mode 100644 index 0000000..ddd4a00 --- /dev/null +++ b/src/server/service/events/typeSafeSSE.test.ts @@ -0,0 +1,248 @@ +import { Effect } from "effect"; +import type { SSEStreamingApi } from "hono/streaming"; +import { describe, expect, it, vi } from "vitest"; +import type { PermissionRequest } from "../../../types/permissions"; +import { TypeSafeSSE } from "./typeSafeSSE"; + +describe("typeSafeSSE", () => { + describe("writeTypeSafeSSE", () => { + it("can correctly format and write SSE events", async () => { + const writtenEvents: Array<{ + event: string; + id: string; + data: string; + }> = []; + + const mockStream: SSEStreamingApi = { + writeSSE: vi.fn(async (event) => { + writtenEvents.push(event); + }), + } as unknown as SSEStreamingApi; + + const program = Effect.gen(function* () { + const typeSafeSSE = yield* TypeSafeSSE; + + yield* typeSafeSSE.writeSSE("heartbeat", {}); + + return writtenEvents; + }); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(TypeSafeSSE.make(mockStream))), + ); + + expect(result).toHaveLength(1); + + const item = result.at(0); + expect(item).toBeDefined(); + if (!item) { + throw new Error("item is undefined"); + } + + expect(item.event).toBe("heartbeat"); + expect(item.id).toBeDefined(); + + const data = JSON.parse(item.data); + expect(data.kind).toBe("heartbeat"); + expect(data.timestamp).toBeDefined(); + }); + + it("can correctly write connect event", async () => { + const writtenEvents: Array<{ + event: string; + id: string; + data: string; + }> = []; + + const mockStream: SSEStreamingApi = { + writeSSE: vi.fn(async (event) => { + writtenEvents.push(event); + }), + } as unknown as SSEStreamingApi; + + const program = Effect.gen(function* () { + const typeSafeSSE = yield* TypeSafeSSE; + + yield* typeSafeSSE.writeSSE("connect", {}); + + return writtenEvents; + }); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(TypeSafeSSE.make(mockStream))), + ); + + expect(result).toHaveLength(1); + const item = result.at(0); + expect(item).toBeDefined(); + if (!item) { + throw new Error("item is undefined"); + } + expect(item.event).toBe("connect"); + + const data = JSON.parse(item.data); + expect(data.kind).toBe("connect"); + expect(data.timestamp).toBeDefined(); + }); + + it("can correctly write sessionChanged event", async () => { + const writtenEvents: Array<{ + event: string; + id: string; + data: string; + }> = []; + + const mockStream: SSEStreamingApi = { + writeSSE: vi.fn(async (event) => { + writtenEvents.push(event); + }), + } as unknown as SSEStreamingApi; + + const program = Effect.gen(function* () { + const typeSafeSSE = yield* TypeSafeSSE; + + yield* typeSafeSSE.writeSSE("sessionChanged", { + projectId: "project-1", + sessionId: "session-1", + }); + + return writtenEvents; + }); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(TypeSafeSSE.make(mockStream))), + ); + + expect(result).toHaveLength(1); + const item = result.at(0); + expect(item).toBeDefined(); + if (!item) { + throw new Error("item is undefined"); + } + expect(item.event).toBe("sessionChanged"); + + const data = JSON.parse(item.data); + expect(data.kind).toBe("sessionChanged"); + expect(data.projectId).toBe("project-1"); + expect(data.sessionId).toBe("session-1"); + expect(data.timestamp).toBeDefined(); + }); + + it("can correctly write permission_requested event", async () => { + const writtenEvents: Array<{ + event: string; + id: string; + data: string; + }> = []; + + const mockStream: SSEStreamingApi = { + writeSSE: vi.fn(async (event) => { + writtenEvents.push(event); + }), + } as unknown as SSEStreamingApi; + + const mockPermissionRequest: PermissionRequest = { + id: "permission-1", + sessionId: "session-1", + taskId: "task-1", + toolName: "read", + toolInput: {}, + timestamp: Date.now(), + }; + + const program = Effect.gen(function* () { + const typeSafeSSE = yield* TypeSafeSSE; + + yield* typeSafeSSE.writeSSE("permission_requested", { + permissionRequest: mockPermissionRequest, + }); + + return writtenEvents; + }); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(TypeSafeSSE.make(mockStream))), + ); + + expect(result).toHaveLength(1); + const item = result.at(0); + expect(item).toBeDefined(); + if (!item) { + throw new Error("item is undefined"); + } + expect(item.event).toBe("permission_requested"); + + const data = JSON.parse(item.data); + expect(data.kind).toBe("permission_requested"); + expect(data.permissionRequest.id).toBe("permission-1"); + expect(data.timestamp).toBeDefined(); + }); + + it("can write multiple events consecutively", async () => { + const writtenEvents: Array<{ + event: string; + id: string; + data: string; + }> = []; + + const mockStream: SSEStreamingApi = { + writeSSE: vi.fn(async (event) => { + writtenEvents.push(event); + }), + } as unknown as SSEStreamingApi; + + const program = Effect.gen(function* () { + const typeSafeSSE = yield* TypeSafeSSE; + + yield* typeSafeSSE.writeSSE("connect", {}); + yield* typeSafeSSE.writeSSE("heartbeat", {}); + yield* typeSafeSSE.writeSSE("sessionListChanged", { + projectId: "project-1", + }); + + return writtenEvents; + }); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(TypeSafeSSE.make(mockStream))), + ); + + expect(result).toHaveLength(3); + expect(result.at(0)?.event).toBe("connect"); + expect(result.at(1)?.event).toBe("heartbeat"); + expect(result.at(2)?.event).toBe("sessionListChanged"); + }); + + it("assigns unique ID to each event", async () => { + const writtenEvents: Array<{ + event: string; + id: string; + data: string; + }> = []; + + const mockStream: SSEStreamingApi = { + writeSSE: vi.fn(async (event) => { + writtenEvents.push(event); + }), + } as unknown as SSEStreamingApi; + + const program = Effect.gen(function* () { + const typeSafeSSE = yield* TypeSafeSSE; + + yield* typeSafeSSE.writeSSE("heartbeat", {}); + yield* typeSafeSSE.writeSSE("heartbeat", {}); + yield* typeSafeSSE.writeSSE("heartbeat", {}); + + return writtenEvents; + }); + + const result = await Effect.runPromise( + program.pipe(Effect.provide(TypeSafeSSE.make(mockStream))), + ); + + expect(result).toHaveLength(3); + const ids = result.map((e) => e.id); + expect(new Set(ids).size).toBe(3); // All IDs are unique + }); + }); +}); diff --git a/src/server/service/events/typeSafeSSE.ts b/src/server/service/events/typeSafeSSE.ts index 4e0e610..75d40fb 100644 --- a/src/server/service/events/typeSafeSSE.ts +++ b/src/server/service/events/typeSafeSSE.ts @@ -1,21 +1,44 @@ +import { Context, Effect, Layer } from "effect"; import type { SSEStreamingApi } from "hono/streaming"; import { ulid } from "ulid"; import type { SSEEventDeclaration } from "../../../types/sse"; -export const writeTypeSafeSSE = (stream: SSEStreamingApi) => ({ - writeSSE: async ( +interface TypeSafeSSEService { + readonly writeSSE: ( event: EventName, data: SSEEventDeclaration[EventName], - ): Promise => { - const id = ulid(); - await stream.writeSSE({ - event: event, - id: id, - data: JSON.stringify({ - kind: event, - timestamp: new Date().toISOString(), - ...data, - }), - }); - }, -}); + ) => Effect.Effect; +} + +export class TypeSafeSSE extends Context.Tag("TypeSafeSSE")< + TypeSafeSSE, + TypeSafeSSEService +>() { + static make = (stream: SSEStreamingApi) => + Layer.succeed(this, { + writeSSE: ( + event: EventName, + data: SSEEventDeclaration[EventName], + ): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const id = ulid(); + await stream.writeSSE({ + event: event, + id: id, + data: JSON.stringify({ + kind: event, + timestamp: new Date().toISOString(), + ...data, + }), + }); + }, + catch: (error) => { + if (error instanceof Error) { + return error; + } + return new Error(String(error)); + }, + }), + } satisfies TypeSafeSSEService); +} diff --git a/src/server/service/mcp/getMcpList.ts b/src/server/service/mcp/getMcpList.ts index 540b228..ea45520 100644 --- a/src/server/service/mcp/getMcpList.ts +++ b/src/server/service/mcp/getMcpList.ts @@ -1,15 +1,19 @@ import { execSync } from "node:child_process"; +import { decodeProjectId } from "../project/id"; export interface McpServer { name: string; command: string; } -export const getMcpList = async (): Promise<{ servers: McpServer[] }> => { +export const getMcpList = async ( + projectId: string, +): Promise<{ servers: McpServer[] }> => { try { const output = execSync("claude mcp list", { encoding: "utf8", timeout: 10000, + cwd: decodeProjectId(projectId), }); const servers: McpServer[] = []; diff --git a/src/server/service/parseJsonl.ts b/src/server/service/parseJsonl.ts index e3902df..0808e7e 100644 --- a/src/server/service/parseJsonl.ts +++ b/src/server/service/parseJsonl.ts @@ -7,13 +7,13 @@ export const parseJsonl = (content: string) => { .split("\n") .filter((line) => line.trim() !== ""); - return lines.map((line) => { + return lines.map((line, index) => { const parsed = ConversationSchema.safeParse(JSON.parse(line)); if (!parsed.success) { - console.warn("Failed to parse jsonl, skipping", parsed.error); const errorData: ErrorJsonl = { type: "x-error", line, + lineNumber: index + 1, }; return errorData; } diff --git a/src/server/service/project/ProjectMetaService.test.ts b/src/server/service/project/ProjectMetaService.test.ts new file mode 100644 index 0000000..cf3b247 --- /dev/null +++ b/src/server/service/project/ProjectMetaService.test.ts @@ -0,0 +1,221 @@ +import { FileSystem, Path } from "@effect/platform"; +import { Effect, Layer, Option } from "effect"; +import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService"; +import { ProjectMetaService } from "./ProjectMetaService"; + +/** + * Helper function to create a FileSystem mock layer + * @see FileSystem.layerNoop - Can override only necessary methods + */ +const makeFileSystemMock = ( + overrides: Partial, +): Layer.Layer => { + return FileSystem.layerNoop(overrides); +}; + +/** + * Helper function to create a Path mock layer + * @see Path.layer - Uses default POSIX Path implementation + */ +const makePathMock = (): Layer.Layer => { + return Path.layer; +}; + +/** + * Helper function to create a PersistentService mock layer + */ +const makePersistentServiceMock = (): Layer.Layer => { + return Layer.succeed(PersistentService, { + load: () => Effect.succeed([]), + save: () => Effect.void, + }); +}; + +describe("ProjectMetaService", () => { + describe("getProjectMeta", () => { + it("returns cached metadata", async () => { + let readDirectoryCalls = 0; + + const FileSystemMock = makeFileSystemMock({ + readDirectory: () => { + readDirectoryCalls++; + return Effect.succeed(["session1.jsonl"]); + }, + readFileString: () => + Effect.succeed( + '{"type":"user","cwd":"/workspace/app","text":"test"}', + ), + stat: () => + Effect.succeed({ + type: "File", + mtime: Option.some(new Date("2024-01-01")), + atime: Option.none(), + birthtime: Option.none(), + dev: 0, + ino: Option.none(), + mode: 0, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(0n), + blksize: Option.none(), + blocks: Option.none(), + }), + exists: () => Effect.succeed(true), + makeDirectory: () => Effect.void, + writeFileString: () => Effect.void, + }); + + const PathMock = makePathMock(); + + const PersistentServiceMock = makePersistentServiceMock(); + + const program = Effect.gen(function* () { + const storage = yield* ProjectMetaService; + const projectId = Buffer.from("/test/project").toString("base64url"); + + // First call + const result1 = yield* storage.getProjectMeta(projectId); + + // Second call (retrieved from cache) + const result2 = yield* storage.getProjectMeta(projectId); + + return { result1, result2, readDirectoryCalls }; + }); + + const { result1, result2 } = await Effect.runPromise( + program.pipe( + Effect.provide(ProjectMetaService.Live), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + // Both results are the same + expect(result1).toEqual(result2); + + // readDirectory is called only once (cache is working) + expect(readDirectoryCalls).toBe(1); + }); + + it("returns null if project path is not found", async () => { + const FileSystemMock = makeFileSystemMock({ + readDirectory: () => Effect.succeed(["session1.jsonl"]), + readFileString: () => + Effect.succeed('{"type":"summary","text":"summary"}'), + stat: () => + Effect.succeed({ + type: "File", + mtime: Option.some(new Date("2024-01-01")), + atime: Option.none(), + birthtime: Option.none(), + dev: 0, + ino: Option.none(), + mode: 0, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(0n), + blksize: Option.none(), + blocks: Option.none(), + }), + exists: () => Effect.succeed(true), + makeDirectory: () => Effect.void, + writeFileString: () => Effect.void, + }); + + const PathMock = makePathMock(); + + const PersistentServiceMock = makePersistentServiceMock(); + + const program = Effect.gen(function* () { + const storage = yield* ProjectMetaService; + const projectId = Buffer.from("/test/project").toString("base64url"); + return yield* storage.getProjectMeta(projectId); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(ProjectMetaService.Live), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.projectName).toBeNull(); + expect(result.projectPath).toBeNull(); + expect(result.sessionCount).toBe(1); + }); + }); + + describe("invalidateProject", () => { + it("can invalidate project cache", async () => { + let readDirectoryCalls = 0; + + const FileSystemMock = makeFileSystemMock({ + readDirectory: () => { + readDirectoryCalls++; + return Effect.succeed(["session1.jsonl"]); + }, + readFileString: () => + Effect.succeed( + '{"type":"user","cwd":"/workspace/app","text":"test"}', + ), + stat: () => + Effect.succeed({ + type: "File", + mtime: Option.some(new Date("2024-01-01")), + atime: Option.none(), + birthtime: Option.none(), + dev: 0, + ino: Option.none(), + mode: 0, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(0n), + blksize: Option.none(), + blocks: Option.none(), + }), + exists: () => Effect.succeed(true), + makeDirectory: () => Effect.void, + writeFileString: () => Effect.void, + }); + + const PathMock = makePathMock(); + + const PersistentServiceMock = makePersistentServiceMock(); + + const program = Effect.gen(function* () { + const storage = yield* ProjectMetaService; + const projectId = Buffer.from("/test/project").toString("base64url"); + + // First call + yield* storage.getProjectMeta(projectId); + + // Invalidate cache + yield* storage.invalidateProject(projectId); + + // Second call (re-read from file) + yield* storage.getProjectMeta(projectId); + }); + + await Effect.runPromise( + program.pipe( + Effect.provide(ProjectMetaService.Live), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + // readDirectory is called twice (cache was invalidated) + expect(readDirectoryCalls).toBe(2); + }); + }); +}); diff --git a/src/server/service/project/ProjectMetaService.ts b/src/server/service/project/ProjectMetaService.ts new file mode 100644 index 0000000..2f08697 --- /dev/null +++ b/src/server/service/project/ProjectMetaService.ts @@ -0,0 +1,154 @@ +import { basename } from "node:path"; +import { FileSystem, Path } from "@effect/platform"; +import { Context, Effect, Layer, Option, Ref } from "effect"; +import { z } from "zod"; +import { + FileCacheStorage, + makeFileCacheStorageLayer, +} from "../../lib/storage/FileCacheStorage"; +import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService"; +import { parseJsonl } from "../parseJsonl"; +import type { ProjectMeta } from "../types"; +import { decodeProjectId } from "./id"; + +const ProjectPathSchema = z.string().nullable(); + +export class ProjectMetaService extends Context.Tag("ProjectMetaService")< + ProjectMetaService, + { + readonly getProjectMeta: ( + projectId: string, + ) => Effect.Effect; + readonly invalidateProject: (projectId: string) => Effect.Effect; + } +>() { + static Live = Layer.effect( + this, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectPathCache = yield* FileCacheStorage(); + const projectMetaCacheRef = yield* Ref.make( + new Map(), + ); + + const extractProjectPathFromJsonl = ( + filePath: string, + ): Effect.Effect => + Effect.gen(function* () { + const cached = yield* projectPathCache.get(filePath); + if (cached !== undefined) { + return cached; + } + + const content = yield* fs.readFileString(filePath); + const lines = content.split("\n"); + + let cwd: string | null = null; + + for (const line of lines) { + const conversation = parseJsonl(line).at(0); + + if ( + conversation === undefined || + conversation.type === "summary" || + conversation.type === "x-error" + ) { + continue; + } + + cwd = conversation.cwd; + break; + } + + if (cwd !== null) { + yield* projectPathCache.set(filePath, cwd); + } + + return cwd; + }); + + const getProjectMeta = ( + projectId: string, + ): Effect.Effect => + Effect.gen(function* () { + const metaCache = yield* Ref.get(projectMetaCacheRef); + const cached = metaCache.get(projectId); + if (cached !== undefined) { + return cached; + } + + const claudeProjectPath = decodeProjectId(projectId); + + const dirents = yield* fs.readDirectory(claudeProjectPath); + const fileEntries = yield* Effect.all( + dirents + .filter((name) => name.endsWith(".jsonl")) + .map((name) => + Effect.gen(function* () { + const fullPath = path.resolve(claudeProjectPath, name); + const stat = yield* fs.stat(fullPath); + const mtime = Option.getOrElse(stat.mtime, () => new Date(0)); + return { + fullPath, + mtime, + } as const; + }), + ), + { concurrency: "unbounded" }, + ); + + const files = fileEntries.sort((a, b) => { + return a.mtime.getTime() - b.mtime.getTime(); + }); + + let projectPath: string | null = null; + + for (const file of files) { + projectPath = yield* extractProjectPathFromJsonl(file.fullPath); + + if (projectPath === null) { + continue; + } + + break; + } + + const projectMeta: ProjectMeta = { + projectName: projectPath ? basename(projectPath) : null, + projectPath, + sessionCount: files.length, + }; + + yield* Ref.update(projectMetaCacheRef, (cache) => { + cache.set(projectId, projectMeta); + return cache; + }); + + return projectMeta; + }); + + const invalidateProject = (projectId: string): Effect.Effect => + Effect.gen(function* () { + yield* Ref.update(projectMetaCacheRef, (cache) => { + cache.delete(projectId); + return cache; + }); + }); + + return { + getProjectMeta, + invalidateProject, + }; + }), + ).pipe( + Layer.provide( + makeFileCacheStorageLayer("project-path-cache", ProjectPathSchema), + ), + Layer.provide(PersistentService.Live), + ); +} + +export type IProjectMetaService = Context.Tag.Service< + typeof ProjectMetaService +>; diff --git a/src/server/service/project/ProjectRepository.test.ts b/src/server/service/project/ProjectRepository.test.ts new file mode 100644 index 0000000..be87f2b --- /dev/null +++ b/src/server/service/project/ProjectRepository.test.ts @@ -0,0 +1,329 @@ +import { FileSystem, Path } from "@effect/platform"; +import { SystemError } from "@effect/platform/Error"; +import { Effect, Layer, Option } from "effect"; +import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService"; +import type { ProjectMeta } from "../types"; +import { ProjectMetaService } from "./ProjectMetaService"; +import { ProjectRepository } from "./ProjectRepository"; + +/** + * Helper function to create FileSystem mock layer + */ +const makeFileSystemMock = ( + overrides: Partial, +): Layer.Layer => { + return FileSystem.layerNoop(overrides); +}; + +/** + * Helper function to create Path mock layer + */ +const makePathMock = (): Layer.Layer => { + return Path.layer; +}; + +/** + * Helper function to create PersistentService mock layer + */ +const makePersistentServiceMock = (): Layer.Layer => { + return Layer.succeed(PersistentService, { + load: () => Effect.succeed([]), + save: () => Effect.void, + }); +}; + +/** + * Helper function to create ProjectMetaService mock layer + */ +const makeProjectMetaServiceMock = ( + meta: ProjectMeta, +): Layer.Layer => { + return Layer.succeed(ProjectMetaService, { + getProjectMeta: () => Effect.succeed(meta), + invalidateProject: () => Effect.void, + }); +}; + +/** + * Helper function to create File.Info mock + */ +const makeFileInfoMock = ( + type: "File" | "Directory", + mtime: Date, +): FileSystem.File.Info => ({ + type, + mtime: Option.some(mtime), + atime: Option.none(), + birthtime: Option.none(), + dev: 0, + ino: Option.none(), + mode: 0o755, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(0n), + blksize: Option.none(), + blocks: Option.none(), +}); + +describe("ProjectRepository", () => { + describe("getProject", () => { + it("returns project information when project exists", async () => { + const projectPath = "/test/project"; + const projectId = Buffer.from(projectPath).toString("base64url"); + const mockDate = new Date("2024-01-01T00:00:00.000Z"); + const mockMeta: ProjectMeta = { + projectName: "Test Project", + projectPath: "/workspace", + sessionCount: 5, + }; + + const FileSystemMock = makeFileSystemMock({ + exists: (path: string) => Effect.succeed(path === projectPath), + stat: () => Effect.succeed(makeFileInfoMock("Directory", mockDate)), + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + const ProjectMetaServiceMock = makeProjectMetaServiceMock(mockMeta); + + const program = Effect.gen(function* () { + const repo = yield* ProjectRepository; + return yield* repo.getProject(projectId); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(ProjectRepository.Live), + Effect.provide(ProjectMetaServiceMock), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.project).toEqual({ + id: projectId, + claudeProjectPath: projectPath, + lastModifiedAt: mockDate, + meta: mockMeta, + }); + }); + + it("returns error when project does not exist", async () => { + const projectPath = "/test/nonexistent"; + const projectId = Buffer.from(projectPath).toString("base64url"); + const mockMeta: ProjectMeta = { + projectName: null, + projectPath: null, + sessionCount: 0, + }; + + const FileSystemMock = makeFileSystemMock({ + exists: () => Effect.succeed(false), + stat: () => Effect.succeed(makeFileInfoMock("Directory", new Date())), + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + const ProjectMetaServiceMock = makeProjectMetaServiceMock(mockMeta); + + const program = Effect.gen(function* () { + const repo = yield* ProjectRepository; + return yield* repo.getProject(projectId); + }); + + await expect( + Effect.runPromise( + program.pipe( + Effect.provide(ProjectRepository.Live), + Effect.provide(ProjectMetaServiceMock), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ), + ).rejects.toThrow("Project not found"); + }); + }); + + describe("getProjects", () => { + it("returns empty array when project directory does not exist", async () => { + const FileSystemMock = makeFileSystemMock({ + exists: () => Effect.succeed(false), + readDirectory: () => Effect.succeed([]), + stat: () => Effect.succeed(makeFileInfoMock("Directory", new Date())), + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + const mockMeta: ProjectMeta = { + projectName: null, + projectPath: null, + sessionCount: 0, + }; + const ProjectMetaServiceMock = makeProjectMetaServiceMock(mockMeta); + + const program = Effect.gen(function* () { + const repo = yield* ProjectRepository; + return yield* repo.getProjects(); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(ProjectRepository.Live), + Effect.provide(ProjectMetaServiceMock), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.projects).toEqual([]); + }); + + it("returns multiple projects correctly sorted", async () => { + const date1 = new Date("2024-01-01T00:00:00.000Z"); + const date2 = new Date("2024-01-02T00:00:00.000Z"); + const date3 = new Date("2024-01-03T00:00:00.000Z"); + + const FileSystemMock = makeFileSystemMock({ + exists: () => Effect.succeed(true), + readDirectory: () => + Effect.succeed(["project1", "project2", "project3"]), + readFileString: () => + Effect.succeed('{"type":"user","cwd":"/workspace","text":"test"}'), + stat: (path: string) => { + if (path.includes("project1")) { + return Effect.succeed(makeFileInfoMock("Directory", date2)); + } + if (path.includes("project2")) { + return Effect.succeed(makeFileInfoMock("Directory", date3)); + } + if (path.includes("project3")) { + return Effect.succeed(makeFileInfoMock("Directory", date1)); + } + return Effect.succeed(makeFileInfoMock("Directory", new Date())); + }, + makeDirectory: () => Effect.void, + writeFileString: () => Effect.void, + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + + const program = Effect.gen(function* () { + const repo = yield* ProjectRepository; + return yield* repo.getProjects(); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(ProjectRepository.Live), + Effect.provide(ProjectMetaService.Live), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.projects.length).toBe(3); + expect(result.projects.at(0)?.lastModifiedAt).toEqual(date3); // project2 + expect(result.projects.at(1)?.lastModifiedAt).toEqual(date2); // project1 + expect(result.projects.at(2)?.lastModifiedAt).toEqual(date1); // project3 + }); + + it("filters only directories", async () => { + const date = new Date("2024-01-01T00:00:00.000Z"); + + const FileSystemMock = makeFileSystemMock({ + exists: () => Effect.succeed(true), + readDirectory: () => + Effect.succeed(["project1", "file.txt", "project2"]), + readFileString: () => + Effect.succeed('{"type":"user","cwd":"/workspace","text":"test"}'), + stat: (path: string) => { + if (path.includes("file.txt")) { + return Effect.succeed(makeFileInfoMock("File", date)); + } + return Effect.succeed(makeFileInfoMock("Directory", date)); + }, + makeDirectory: () => Effect.void, + writeFileString: () => Effect.void, + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + + const program = Effect.gen(function* () { + const repo = yield* ProjectRepository; + return yield* repo.getProjects(); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(ProjectRepository.Live), + Effect.provide(ProjectMetaService.Live), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.projects.length).toBe(2); + expect( + result.projects.every((p) => p.claudeProjectPath.match(/project[12]$/)), + ).toBe(true); + }); + + it("skips entries where stat retrieval fails", async () => { + const date = new Date("2024-01-01T00:00:00.000Z"); + + const FileSystemMock = makeFileSystemMock({ + exists: () => Effect.succeed(true), + readDirectory: () => Effect.succeed(["project1", "broken", "project2"]), + readFileString: () => + Effect.succeed('{"type":"user","cwd":"/workspace","text":"test"}'), + stat: (path: string) => { + if (path.includes("broken")) { + return Effect.fail( + new SystemError({ + method: "stat", + reason: "PermissionDenied", + module: "FileSystem", + cause: undefined, + }), + ); + } + return Effect.succeed(makeFileInfoMock("Directory", date)); + }, + makeDirectory: () => Effect.void, + writeFileString: () => Effect.void, + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + + const program = Effect.gen(function* () { + const repo = yield* ProjectRepository; + return yield* repo.getProjects(); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(ProjectRepository.Live), + Effect.provide(ProjectMetaService.Live), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.projects.length).toBe(2); + expect( + result.projects.every((p) => p.claudeProjectPath.match(/project[12]$/)), + ).toBe(true); + }); + }); +}); diff --git a/src/server/service/project/ProjectRepository.ts b/src/server/service/project/ProjectRepository.ts index df87935..2222d5f 100644 --- a/src/server/service/project/ProjectRepository.ts +++ b/src/server/service/project/ProjectRepository.ts @@ -1,73 +1,109 @@ -import { existsSync, statSync } from "node:fs"; -import { access, constants, readdir } from "node:fs/promises"; import { resolve } from "node:path"; +import { FileSystem } from "@effect/platform"; +import { Context, Effect, Layer, Option } from "effect"; import { claudeProjectsDirPath } from "../paths"; import type { Project } from "../types"; import { decodeProjectId, encodeProjectId } from "./id"; -import { projectMetaStorage } from "./projectMetaStorage"; +import { ProjectMetaService } from "./ProjectMetaService"; + +const getProject = (projectId: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const projectMetaService = yield* ProjectMetaService; -export class ProjectRepository { - public async getProject(projectId: string): Promise<{ project: Project }> { const fullPath = decodeProjectId(projectId); - if (!existsSync(fullPath)) { - throw new Error("Project not found"); + + // Check if project directory exists + const exists = yield* fs.exists(fullPath); + if (!exists) { + return yield* Effect.fail(new Error("Project not found")); } - const meta = await projectMetaStorage.getProjectMeta(projectId); + // Get file stats + const stat = yield* fs.stat(fullPath); + + // Get project metadata + const meta = yield* projectMetaService.getProjectMeta(projectId); return { project: { id: projectId, claudeProjectPath: fullPath, - lastModifiedAt: statSync(fullPath).mtime, + lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()), meta, }, }; - } + }); - public async getProjects(): Promise<{ projects: Project[] }> { - try { - // Check if the claude projects directory exists - await access(claudeProjectsDirPath, constants.F_OK); - } catch (_error) { - // Directory doesn't exist, return empty array +const getProjects = () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const projectMetaService = yield* ProjectMetaService; + + // Check if the claude projects directory exists + const dirExists = yield* fs.exists(claudeProjectsDirPath); + if (!dirExists) { console.warn( `Claude projects directory not found at ${claudeProjectsDirPath}`, ); return { projects: [] }; } - try { - const dirents = await readdir(claudeProjectsDirPath, { - withFileTypes: true, - }); - const projects = await Promise.all( - dirents - .filter((d) => d.isDirectory()) - .map(async (d) => { - const fullPath = resolve(claudeProjectsDirPath, d.name); - const id = encodeProjectId(fullPath); + // Read directory entries + const entries = yield* fs.readDirectory(claudeProjectsDirPath); - return { - id, - claudeProjectPath: fullPath, - lastModifiedAt: statSync(fullPath).mtime, - meta: await projectMetaStorage.getProjectMeta(id), - }; - }), + // Filter directories and map to Project objects + const projectEffects = entries.map((entry) => + Effect.gen(function* () { + const fullPath = resolve(claudeProjectsDirPath, entry); + + // Check if it's a directory + const stat = yield* Effect.tryPromise(() => + fs.stat(fullPath).pipe(Effect.runPromise), + ).pipe(Effect.catchAll(() => Effect.succeed(null))); + + if (!stat || stat.type !== "Directory") { + return null; + } + + const id = encodeProjectId(fullPath); + const meta = yield* projectMetaService.getProjectMeta(id); + + return { + id, + claudeProjectPath: fullPath, + lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()), + meta, + } satisfies Project; + }), + ); + + // Execute all effects in parallel and filter out nulls + const projectsWithNulls = yield* Effect.all(projectEffects, { + concurrency: "unbounded", + }); + const projects = projectsWithNulls.filter((p): p is Project => p !== null); + + // Sort by last modified date (newest first) + const sortedProjects = projects.sort((a, b) => { + return ( + (b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0) - + (a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0) ); + }); - return { - projects: projects.sort((a, b) => { - return ( - (b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0) - - (a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0) - ); - }), - }; - } catch (error) { - console.error("Error reading projects:", error); - return { projects: [] }; - } + return { projects: sortedProjects }; + }); + +export class ProjectRepository extends Context.Tag("ProjectRepository")< + ProjectRepository, + { + readonly getProject: typeof getProject; + readonly getProjects: typeof getProjects; } +>() { + static Live = Layer.succeed(this, { + getProject, + getProjects, + }); } diff --git a/src/server/service/project/projectMetaStorage.ts b/src/server/service/project/projectMetaStorage.ts deleted file mode 100644 index 85bd698..0000000 --- a/src/server/service/project/projectMetaStorage.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { statSync } from "node:fs"; -import { readdir, readFile } from "node:fs/promises"; -import { basename, resolve } from "node:path"; -import { z } from "zod"; -import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; -import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; -import { parseJsonl } from "../parseJsonl"; -import type { ProjectMeta } from "../types"; -import { decodeProjectId } from "./id"; - -class ProjectMetaStorage { - private projectPathCache = FileCacheStorage.load( - "project-path-cache", - z.string().nullable(), - ); - private projectMetaCache = new InMemoryCacheStorage(); - - public async getProjectMeta(projectId: string): Promise { - const cached = this.projectMetaCache.get(projectId); - if (cached !== undefined) { - return cached; - } - - const claudeProjectPath = decodeProjectId(projectId); - - const dirents = await readdir(claudeProjectPath, { withFileTypes: true }); - const files = dirents - .filter((d) => d.isFile() && d.name.endsWith(".jsonl")) - .map( - (d) => - ({ - fullPath: resolve(claudeProjectPath, d.name), - stats: statSync(resolve(claudeProjectPath, d.name)), - }) as const, - ) - .sort((a, b) => { - return a.stats.mtime.getTime() - b.stats.mtime.getTime(); - }); - - let projectPath: string | null = null; - - for (const file of files) { - projectPath = await this.extractProjectPathFromJsonl(file.fullPath); - - if (projectPath === null) { - continue; - } - - break; - } - - const projectMeta: ProjectMeta = { - projectName: projectPath ? basename(projectPath) : null, - projectPath, - sessionCount: files.length, - }; - - this.projectMetaCache.save(projectId, projectMeta); - - return projectMeta; - } - - public invalidateProject(projectId: string) { - this.projectMetaCache.invalidate(projectId); - } - - private async extractProjectPathFromJsonl( - filePath: string, - ): Promise { - const cached = this.projectPathCache.get(filePath); - if (cached !== undefined) { - return cached; - } - - const content = await readFile(filePath, "utf-8"); - const lines = content.split("\n"); - - let cwd: string | null = null; - - for (const line of lines) { - const conversation = parseJsonl(line).at(0); - - if ( - conversation === undefined || - conversation.type === "summary" || - conversation.type === "x-error" - ) { - continue; - } - - cwd = conversation.cwd; - - break; - } - - if (cwd !== null) { - this.projectPathCache.save(filePath, cwd); - } - - return cwd; - } -} - -export const projectMetaStorage = new ProjectMetaStorage(); diff --git a/src/server/service/session/PredictSessionsDatabase.ts b/src/server/service/session/PredictSessionsDatabase.ts deleted file mode 100644 index 161b273..0000000 --- a/src/server/service/session/PredictSessionsDatabase.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { encodeProjectIdFromSessionFilePath } from "../project/id"; -import type { Session, SessionDetail } from "../types"; - -/** - * For interactively experience, handle sessions not already persisted to the filesystem. - */ -class PredictSessionsDatabase { - private storage = new Map(); - - public getPredictSessions(projectId: string): Session[] { - return Array.from(this.storage.values()).filter( - ({ jsonlFilePath }) => - encodeProjectIdFromSessionFilePath(jsonlFilePath) === projectId, - ); - } - - public getPredictSession(sessionId: string): SessionDetail { - const session = this.storage.get(sessionId); - if (!session) { - throw new Error("Session not found"); - } - return session; - } - - public createPredictSession(session: SessionDetail) { - this.storage.set(session.id, session); - } - - public deletePredictSession(sessionId: string) { - this.storage.delete(sessionId); - } -} - -export const predictSessionsDatabase = new PredictSessionsDatabase(); diff --git a/src/server/service/session/SessionMetaService.test.ts b/src/server/service/session/SessionMetaService.test.ts new file mode 100644 index 0000000..a8be0f2 --- /dev/null +++ b/src/server/service/session/SessionMetaService.test.ts @@ -0,0 +1,247 @@ +import { FileSystem, Path } from "@effect/platform"; +import { Effect, Layer } from "effect"; +import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService"; +import { SessionMetaService } from "./SessionMetaService"; + +/** + * Helper function to create a FileSystem mock layer + */ +const makeFileSystemMock = ( + overrides: Partial, +): Layer.Layer => { + return FileSystem.layerNoop(overrides); +}; + +/** + * Helper function to create a Path mock layer + */ +const makePathMock = (): Layer.Layer => { + return Path.layer; +}; + +/** + * Helper function to create a PersistentService mock layer + * load returns an empty array to avoid file system access + */ +const makePersistentServiceMock = (): Layer.Layer => { + return Layer.succeed(PersistentService, { + load: (_key: string) => Effect.succeed([]), + save: (_key: string, _entries: readonly [string, unknown][]) => Effect.void, + }); +}; + +describe("SessionMetaService", () => { + describe("getSessionMeta", () => { + it("can retrieve session metadata", async () => { + const FileSystemMock = makeFileSystemMock({ + readFileString: () => + Effect.succeed( + '{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace/app","sessionId":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","version":"1.0.0","gitBranch":"","type":"user","message":{"role":"user","content":"test message"},"uuid":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","timestamp":"2024-01-01T00:00:00.000Z"}', + ), + exists: () => Effect.succeed(false), + makeDirectory: () => Effect.void, + writeFileString: () => Effect.void, + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + + const program = Effect.gen(function* () { + const storage = yield* SessionMetaService; + const projectId = Buffer.from("/test/project").toString("base64url"); + const sessionId = Buffer.from("/test/project/session.jsonl").toString( + "base64url", + ); + + return yield* storage.getSessionMeta(projectId, sessionId); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(SessionMetaService.Live), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.messageCount).toBe(1); + expect(result.firstCommand).toEqual({ + kind: "text", + content: "test message", + }); + }); + + it("returns cached metadata", async () => { + let readFileStringCalls = 0; + + const FileSystemMock = makeFileSystemMock({ + readFileString: () => { + readFileStringCalls++; + return Effect.succeed( + '{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace/app","sessionId":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","version":"1.0.0","gitBranch":"","type":"user","message":{"role":"user","content":"test message"},"uuid":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","timestamp":"2024-01-01T00:00:00.000Z"}', + ); + }, + exists: () => Effect.succeed(true), + makeDirectory: () => Effect.void, + writeFileString: () => Effect.void, + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + + const program = Effect.gen(function* () { + const storage = yield* SessionMetaService; + const projectId = Buffer.from("/test/project").toString("base64url"); + const sessionId = Buffer.from("/test/project/session.jsonl").toString( + "base64url", + ); + + // 1回目の呼び出し + const result1 = yield* storage.getSessionMeta(projectId, sessionId); + + // 2回目の呼び出し(キャッシュから取得) + const result2 = yield* storage.getSessionMeta(projectId, sessionId); + + return { result1, result2, readFileStringCalls }; + }); + + const { result1, result2 } = await Effect.runPromise( + program.pipe( + Effect.provide(SessionMetaService.Live), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result1).toEqual(result2); + + expect(readFileStringCalls).toBe(2); + }); + + it("correctly parses commands", async () => { + const FileSystemMock = makeFileSystemMock({ + readFileString: () => + Effect.succeed( + '{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace/app","sessionId":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","version":"1.0.0","gitBranch":"","type":"user","message":{"role":"user","content":"/test"},"uuid":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","timestamp":"2024-01-01T00:00:00.000Z"}', + ), + exists: () => Effect.succeed(false), + makeDirectory: () => Effect.void, + writeFileString: () => Effect.void, + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + + const program = Effect.gen(function* () { + const storage = yield* SessionMetaService; + const projectId = Buffer.from("/test/project").toString("base64url"); + const sessionId = Buffer.from("/test/project/session.jsonl").toString( + "base64url", + ); + + return yield* storage.getSessionMeta(projectId, sessionId); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(SessionMetaService.Live), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.firstCommand).toEqual({ + kind: "command", + commandName: "/test", + }); + }); + + it("skips commands that should be ignored", async () => { + const FileSystemMock = makeFileSystemMock({ + readFileString: () => + Effect.succeed( + '{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace/app","sessionId":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","version":"1.0.0","gitBranch":"","type":"user","message":{"role":"user","content":"/clear"},"uuid":"d78d1de2-52bd-4e64-ad0f-affcbcc1dabf","timestamp":"2024-01-01T00:00:00.000Z"}\n{"parentUuid":"d78d1de2-52bd-4e64-ad0f-affcbcc1dabf","isSidechain":false,"userType":"external","cwd":"/workspace/app","sessionId":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","version":"1.0.0","gitBranch":"","type":"user","message":{"role":"user","content":"actual message"},"uuid":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","timestamp":"2024-01-01T00:00:01.000Z"}', + ), + exists: () => Effect.succeed(false), + makeDirectory: () => Effect.void, + writeFileString: () => Effect.void, + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + + const program = Effect.gen(function* () { + const storage = yield* SessionMetaService; + const projectId = Buffer.from("/test/project").toString("base64url"); + const sessionId = Buffer.from("/test/project/session.jsonl").toString( + "base64url", + ); + + return yield* storage.getSessionMeta(projectId, sessionId); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(SessionMetaService.Live), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.firstCommand).toEqual({ + kind: "text", + content: "actual message", + }); + }); + }); + + describe("invalidateSession", () => { + it("can invalidate session cache", async () => { + let readFileStringCalls = 0; + + const FileSystemMock = makeFileSystemMock({ + readFileString: () => { + readFileStringCalls++; + return Effect.succeed( + '{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/workspace/app","sessionId":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","version":"1.0.0","gitBranch":"","type":"user","message":{"role":"user","content":"test message"},"uuid":"e2ab9812-8be7-4e9e-8194-d9b7b9d6da14","timestamp":"2024-01-01T00:00:00.000Z"}', + ); + }, + exists: () => Effect.succeed(true), + makeDirectory: () => Effect.void, + writeFileString: () => Effect.void, + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + + const program = Effect.gen(function* () { + const storage = yield* SessionMetaService; + const projectId = Buffer.from("/test/project").toString("base64url"); + const sessionId = Buffer.from("/test/project/session.jsonl").toString( + "base64url", + ); + + yield* storage.getSessionMeta(projectId, sessionId); + + yield* storage.invalidateSession(projectId, sessionId); + + yield* storage.getSessionMeta(projectId, sessionId); + }); + + await Effect.runPromise( + program.pipe( + Effect.provide(SessionMetaService.Live), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(readFileStringCalls).toBe(3); + }); + }); +}); diff --git a/src/server/service/session/SessionMetaService.ts b/src/server/service/session/SessionMetaService.ts new file mode 100644 index 0000000..bf669d7 --- /dev/null +++ b/src/server/service/session/SessionMetaService.ts @@ -0,0 +1,185 @@ +import { FileSystem } from "@effect/platform"; +import { Context, Effect, Layer, Ref } from "effect"; +import { + FileCacheStorage, + makeFileCacheStorageLayer, +} from "../../lib/storage/FileCacheStorage"; +import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService"; +import { + type ParsedCommand, + parseCommandXml, + parsedCommandSchema, +} from "../parseCommandXml"; +import { parseJsonl } from "../parseJsonl"; +import type { SessionMeta } from "../types"; +import { decodeSessionId } from "./id"; + +const ignoreCommands = [ + "/clear", + "/login", + "/logout", + "/exit", + "/mcp", + "/memory", +]; + +const parsedCommandOrNullSchema = parsedCommandSchema.nullable(); + +export class SessionMetaService extends Context.Tag("SessionMetaService")< + SessionMetaService, + { + readonly getSessionMeta: ( + projectId: string, + sessionId: string, + ) => Effect.Effect; + readonly invalidateSession: ( + projectId: string, + sessionId: string, + ) => Effect.Effect; + } +>() { + static Live = Layer.effect( + this, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const firstCommandCache = yield* FileCacheStorage(); + const sessionMetaCacheRef = yield* Ref.make( + new Map(), + ); + + const extractFirstUserText = ( + conversation: Exclude[0], undefined>, + ): string | null => { + if (conversation.type !== "user") { + return null; + } + + const firstUserText = + typeof conversation.message.content === "string" + ? conversation.message.content + : (() => { + const firstContent = conversation.message.content.at(0); + if (firstContent === undefined) return null; + if (typeof firstContent === "string") return firstContent; + if (firstContent.type === "text") return firstContent.text; + return null; + })(); + + return firstUserText; + }; + + const getFirstCommand = ( + jsonlFilePath: string, + lines: string[], + ): Effect.Effect => + Effect.gen(function* () { + const cached = yield* firstCommandCache.get(jsonlFilePath); + if (cached !== undefined) { + return cached; + } + + let firstCommand: ParsedCommand | null = null; + + for (const line of lines) { + const conversation = parseJsonl(line).at(0); + + if (conversation === undefined) { + continue; + } + + const firstUserText = extractFirstUserText(conversation); + + if (firstUserText === null) { + continue; + } + + if ( + firstUserText === + "Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to." + ) { + continue; + } + + const command = parseCommandXml(firstUserText); + if (command.kind === "local-command") { + continue; + } + + if ( + command.kind === "command" && + ignoreCommands.includes(command.commandName) + ) { + continue; + } + + firstCommand = command; + break; + } + + if (firstCommand !== null) { + yield* firstCommandCache.set(jsonlFilePath, firstCommand); + } + + return firstCommand; + }); + + const getSessionMeta = ( + projectId: string, + sessionId: string, + ): Effect.Effect => + Effect.gen(function* () { + const metaCache = yield* Ref.get(sessionMetaCacheRef); + const cached = metaCache.get(sessionId); + if (cached !== undefined) { + return cached; + } + + const sessionPath = decodeSessionId(projectId, sessionId); + const content = yield* fs.readFileString(sessionPath); + const lines = content.split("\n"); + + const firstCommand = yield* getFirstCommand(sessionPath, lines); + + const sessionMeta: SessionMeta = { + messageCount: lines.length, + firstCommand, + }; + + yield* Ref.update(sessionMetaCacheRef, (cache) => { + cache.set(sessionId, sessionMeta); + return cache; + }); + + return sessionMeta; + }); + + const invalidateSession = ( + _projectId: string, + sessionId: string, + ): Effect.Effect => + Effect.gen(function* () { + yield* Ref.update(sessionMetaCacheRef, (cache) => { + cache.delete(sessionId); + return cache; + }); + }); + + return { + getSessionMeta, + invalidateSession, + }; + }), + ).pipe( + Layer.provide( + makeFileCacheStorageLayer( + "first-command-cache", + parsedCommandOrNullSchema, + ), + ), + Layer.provide(PersistentService.Live), + ); +} + +export type ISessionMetaService = Context.Tag.Service< + typeof SessionMetaService +>; diff --git a/src/server/service/session/SessionRepository.test.ts b/src/server/service/session/SessionRepository.test.ts new file mode 100644 index 0000000..187a765 --- /dev/null +++ b/src/server/service/session/SessionRepository.test.ts @@ -0,0 +1,604 @@ +import { FileSystem, Path } from "@effect/platform"; +import { SystemError } from "@effect/platform/Error"; +import { Effect, Layer, Option } from "effect"; +import type { Conversation } from "../../../lib/conversation-schema"; +import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService"; +import { decodeProjectId } from "../project/id"; +import type { ErrorJsonl, SessionDetail, SessionMeta } from "../types"; +import { VirtualConversationDatabase } from "./PredictSessionsDatabase"; +import { SessionMetaService } from "./SessionMetaService"; +import { SessionRepository } from "./SessionRepository"; + +/** + * Helper function to create a FileSystem mock layer + */ +const makeFileSystemMock = ( + overrides: Partial, +): Layer.Layer => { + return FileSystem.layerNoop(overrides); +}; + +/** + * Helper function to create a Path mock layer + */ +const makePathMock = (): Layer.Layer => { + return Path.layer; +}; + +/** + * Helper function to create a PersistentService mock layer + */ +const makePersistentServiceMock = (): Layer.Layer => { + return Layer.succeed(PersistentService, { + load: () => Effect.succeed([]), + save: () => Effect.void, + }); +}; + +/** + * Helper function to create a SessionMetaService mock layer + */ +const makeSessionMetaServiceMock = ( + meta: SessionMeta, +): Layer.Layer => { + return Layer.succeed(SessionMetaService, { + getSessionMeta: () => Effect.succeed(meta), + invalidateSession: () => Effect.void, + }); +}; + +/** + * Helper function to create a PredictSessionsDatabase mock layer + */ +const makePredictSessionsDatabaseMock = ( + sessions: Map, +): Layer.Layer => { + return Layer.succeed(VirtualConversationDatabase, { + getProjectVirtualConversations: (projectId: string) => + Effect.succeed( + Array.from(sessions.values()) + .filter((s) => { + const projectPath = decodeProjectId(projectId); + return s.jsonlFilePath.startsWith(projectPath); + }) + .map((s) => ({ + projectId, + sessionId: s.id, + conversations: s.conversations, + })), + ), + getSessionVirtualConversation: (sessionId: string) => { + const session = sessions.get(sessionId); + return Effect.succeed( + session + ? { + projectId: "", + sessionId: session.id, + conversations: session.conversations, + } + : null, + ); + }, + createVirtualConversation: () => Effect.void, + deleteVirtualConversations: () => Effect.void, + }); +}; + +/** + * Helper function to create a File.Info mock + */ +const makeFileInfoMock = ( + type: "File" | "Directory", + mtime: Date, +): FileSystem.File.Info => ({ + type, + mtime: Option.some(mtime), + atime: Option.none(), + birthtime: Option.none(), + dev: 0, + ino: Option.none(), + mode: 0o755, + nlink: Option.none(), + uid: Option.none(), + gid: Option.none(), + rdev: Option.none(), + size: FileSystem.Size(0n), + blksize: Option.none(), + blocks: Option.none(), +}); + +describe("SessionRepository", () => { + describe("getSession", () => { + it("returns session details when session file exists", async () => { + const projectId = Buffer.from("/test/project").toString("base64url"); + const sessionId = "test-session"; + const sessionPath = `/test/project/${sessionId}.jsonl`; + const mockDate = new Date("2024-01-01T00:00:00.000Z"); + const mockMeta: SessionMeta = { + messageCount: 3, + firstCommand: null, + }; + + const mockContent = `{"type":"user","message":{"role":"user","content":"Hello"}}\n{"type":"assistant","message":{"role":"assistant","content":"Hi"}}\n{"type":"user","message":{"role":"user","content":"Test"}}`; + + const FileSystemMock = makeFileSystemMock({ + exists: (path: string) => Effect.succeed(path === sessionPath), + readFileString: (path: string) => + path === sessionPath + ? Effect.succeed(mockContent) + : Effect.fail( + new SystemError({ + method: "readFileString", + reason: "NotFound", + module: "FileSystem", + cause: undefined, + }), + ), + stat: () => Effect.succeed(makeFileInfoMock("File", mockDate)), + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + const SessionMetaServiceMock = makeSessionMetaServiceMock(mockMeta); + const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock( + new Map(), + ); + + const program = Effect.gen(function* () { + const repo = yield* SessionRepository; + return yield* repo.getSession(projectId, sessionId); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(SessionRepository.Live), + Effect.provide(SessionMetaServiceMock), + Effect.provide(PredictSessionsDatabaseMock), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.session).not.toBeNull(); + if (result.session) { + expect(result.session.id).toBe(sessionId); + expect(result.session.jsonlFilePath).toBe(sessionPath); + expect(result.session.meta).toEqual(mockMeta); + expect(result.session.conversations).toHaveLength(3); + expect(result.session.lastModifiedAt).toEqual(mockDate); + } + }); + + it("returns predicted session when session file does not exist but predicted session exists", async () => { + const projectId = Buffer.from("/test/project").toString("base64url"); + const sessionId = "predict-session"; + const mockDate = new Date("2024-01-01T00:00:00.000Z"); + + const mockConversations: (Conversation | ErrorJsonl)[] = [ + { + type: "user", + uuid: "550e8400-e29b-41d4-a716-446655440000", + timestamp: mockDate.toISOString(), + message: { role: "user", content: "Hello" }, + isSidechain: false, + userType: "external", + cwd: "/test", + sessionId, + version: "1.0.0", + parentUuid: null, + }, + ]; + + const FileSystemMock = makeFileSystemMock({ + exists: () => Effect.succeed(false), + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + const SessionMetaServiceMock = makeSessionMetaServiceMock({ + messageCount: 0, + firstCommand: null, + }); + const PredictSessionsDatabaseMock = Layer.succeed( + VirtualConversationDatabase, + { + getProjectVirtualConversations: () => Effect.succeed([]), + getSessionVirtualConversation: (sid: string) => + Effect.succeed( + sid === sessionId + ? { + projectId, + sessionId, + conversations: mockConversations, + } + : null, + ), + createVirtualConversation: () => Effect.void, + deleteVirtualConversations: () => Effect.void, + }, + ); + + const program = Effect.gen(function* () { + const repo = yield* SessionRepository; + return yield* repo.getSession(projectId, sessionId); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(SessionRepository.Live), + Effect.provide(SessionMetaServiceMock), + Effect.provide(PredictSessionsDatabaseMock), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.session).not.toBeNull(); + if (result.session) { + expect(result.session.id).toBe(sessionId); + expect(result.session.conversations).toHaveLength(1); + } + }); + + it("returns null when session does not exist", async () => { + const projectId = Buffer.from("/test/project").toString("base64url"); + const sessionId = "nonexistent-session"; + + const FileSystemMock = makeFileSystemMock({ + exists: () => Effect.succeed(false), + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + const SessionMetaServiceMock = makeSessionMetaServiceMock({ + messageCount: 0, + firstCommand: null, + }); + const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock( + new Map(), + ); + + const program = Effect.gen(function* () { + const repo = yield* SessionRepository; + return yield* repo.getSession(projectId, sessionId); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(SessionRepository.Live), + Effect.provide(SessionMetaServiceMock), + Effect.provide(PredictSessionsDatabaseMock), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.session).toBeNull(); + }); + + it("returns null when resuming session without predict session (reproduces bug)", async () => { + const projectId = Buffer.from("/test/project").toString("base64url"); + const sessionId = "resume-session-id"; + + const FileSystemMock = makeFileSystemMock({ + exists: () => Effect.succeed(false), + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + const SessionMetaServiceMock = makeSessionMetaServiceMock({ + messageCount: 0, + firstCommand: null, + }); + const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock( + new Map(), + ); + + const program = Effect.gen(function* () { + const repo = yield* SessionRepository; + return yield* repo.getSession(projectId, sessionId); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(SessionRepository.Live), + Effect.provide(SessionMetaServiceMock), + Effect.provide(PredictSessionsDatabaseMock), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.session).toBeNull(); + }); + }); + + describe("getSessions", () => { + it("returns list of sessions within project", async () => { + const projectPath = "/test/project"; + const projectId = Buffer.from(projectPath).toString("base64url"); + const date1 = new Date("2024-01-01T00:00:00.000Z"); + const date2 = new Date("2024-01-02T00:00:00.000Z"); + + const mockMeta: SessionMeta = { + messageCount: 1, + firstCommand: null, + }; + + const FileSystemMock = makeFileSystemMock({ + exists: (path: string) => Effect.succeed(path === projectPath), + readDirectory: (path: string) => + path === projectPath + ? Effect.succeed(["session1.jsonl", "session2.jsonl"]) + : Effect.succeed([]), + stat: (path: string) => { + if (path.includes("session1.jsonl")) { + return Effect.succeed(makeFileInfoMock("File", date2)); + } + if (path.includes("session2.jsonl")) { + return Effect.succeed(makeFileInfoMock("File", date1)); + } + return Effect.succeed(makeFileInfoMock("File", new Date())); + }, + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + const SessionMetaServiceMock = makeSessionMetaServiceMock(mockMeta); + const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock( + new Map(), + ); + + const program = Effect.gen(function* () { + const repo = yield* SessionRepository; + return yield* repo.getSessions(projectId); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(SessionRepository.Live), + Effect.provide(SessionMetaServiceMock), + Effect.provide(PredictSessionsDatabaseMock), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.sessions).toHaveLength(2); + expect(result.sessions.at(0)?.lastModifiedAt).toEqual(date2); + expect(result.sessions.at(1)?.lastModifiedAt).toEqual(date1); + }); + + it("can limit number of results with maxCount option", async () => { + const projectPath = "/test/project"; + const projectId = Buffer.from(projectPath).toString("base64url"); + const mockDate = new Date("2024-01-01T00:00:00.000Z"); + + const mockMeta: SessionMeta = { + messageCount: 1, + firstCommand: null, + }; + + const FileSystemMock = makeFileSystemMock({ + exists: (path: string) => Effect.succeed(path === projectPath), + readDirectory: (path: string) => + path === projectPath + ? Effect.succeed([ + "session1.jsonl", + "session2.jsonl", + "session3.jsonl", + ]) + : Effect.succeed([]), + stat: () => Effect.succeed(makeFileInfoMock("File", mockDate)), + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + const SessionMetaServiceMock = makeSessionMetaServiceMock(mockMeta); + const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock( + new Map(), + ); + + const program = Effect.gen(function* () { + const repo = yield* SessionRepository; + return yield* repo.getSessions(projectId, { maxCount: 2 }); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(SessionRepository.Live), + Effect.provide(SessionMetaServiceMock), + Effect.provide(PredictSessionsDatabaseMock), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.sessions).toHaveLength(2); + }); + + it("can paginate with cursor option", async () => { + const projectPath = "/test/project"; + const projectId = Buffer.from(projectPath).toString("base64url"); + const mockDate = new Date("2024-01-01T00:00:00.000Z"); + + const mockMeta: SessionMeta = { + messageCount: 1, + firstCommand: null, + }; + + const FileSystemMock = makeFileSystemMock({ + exists: (path: string) => Effect.succeed(path === projectPath), + readDirectory: (path: string) => + path === projectPath + ? Effect.succeed([ + "session1.jsonl", + "session2.jsonl", + "session3.jsonl", + ]) + : Effect.succeed([]), + stat: () => Effect.succeed(makeFileInfoMock("File", mockDate)), + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + const SessionMetaServiceMock = makeSessionMetaServiceMock(mockMeta); + const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock( + new Map(), + ); + + const program = Effect.gen(function* () { + const repo = yield* SessionRepository; + return yield* repo.getSessions(projectId, { + cursor: "session1", + }); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(SessionRepository.Live), + Effect.provide(SessionMetaServiceMock), + Effect.provide(PredictSessionsDatabaseMock), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.sessions.length).toBeGreaterThan(0); + expect(result.sessions.every((s) => s.id !== "session1")).toBe(true); + }); + + it("returns empty array when project does not exist", async () => { + const projectId = Buffer.from("/nonexistent").toString("base64url"); + + const FileSystemMock = makeFileSystemMock({ + exists: () => Effect.succeed(false), + readDirectory: () => + Effect.fail( + new SystemError({ + method: "readDirectory", + reason: "NotFound", + module: "FileSystem", + cause: undefined, + }), + ), + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + const SessionMetaServiceMock = makeSessionMetaServiceMock({ + messageCount: 0, + firstCommand: null, + }); + const PredictSessionsDatabaseMock = makePredictSessionsDatabaseMock( + new Map(), + ); + + const program = Effect.gen(function* () { + const repo = yield* SessionRepository; + return yield* repo.getSessions(projectId); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(SessionRepository.Live), + Effect.provide(SessionMetaServiceMock), + Effect.provide(PredictSessionsDatabaseMock), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.sessions).toEqual([]); + }); + + it("returns including predicted sessions", async () => { + const projectPath = "/test/project"; + const projectId = Buffer.from(projectPath).toString("base64url"); + const mockDate = new Date("2024-01-01T00:00:00.000Z"); + const virtualDate = new Date("2024-01-03T00:00:00.000Z"); + + const mockMeta: SessionMeta = { + messageCount: 1, + firstCommand: null, + }; + + const mockConversations: (Conversation | ErrorJsonl)[] = [ + { + type: "user", + uuid: "550e8400-e29b-41d4-a716-446655440000", + timestamp: virtualDate.toISOString(), + message: { role: "user", content: "Hello" }, + isSidechain: false, + userType: "external", + cwd: "/test", + sessionId: "predict-session", + version: "1.0.0", + parentUuid: null, + }, + ]; + + const FileSystemMock = makeFileSystemMock({ + exists: (path: string) => Effect.succeed(path === projectPath), + readDirectory: (path: string) => + path === projectPath + ? Effect.succeed(["session1.jsonl"]) + : Effect.succeed([]), + stat: () => Effect.succeed(makeFileInfoMock("File", mockDate)), + }); + + const PathMock = makePathMock(); + const PersistentServiceMock = makePersistentServiceMock(); + const SessionMetaServiceMock = makeSessionMetaServiceMock(mockMeta); + const PredictSessionsDatabaseMock = Layer.succeed( + VirtualConversationDatabase, + { + getProjectVirtualConversations: (pid: string) => + Effect.succeed( + pid === projectId + ? [ + { + projectId, + sessionId: "predict-session", + conversations: mockConversations, + }, + ] + : [], + ), + getSessionVirtualConversation: () => Effect.succeed(null), + createVirtualConversation: () => Effect.void, + deleteVirtualConversations: () => Effect.void, + }, + ); + + const program = Effect.gen(function* () { + const repo = yield* SessionRepository; + return yield* repo.getSessions(projectId); + }); + + const result = await Effect.runPromise( + program.pipe( + Effect.provide(SessionRepository.Live), + Effect.provide(SessionMetaServiceMock), + Effect.provide(PredictSessionsDatabaseMock), + Effect.provide(FileSystemMock), + Effect.provide(PathMock), + Effect.provide(PersistentServiceMock), + ), + ); + + expect(result.sessions.length).toBeGreaterThanOrEqual(2); + expect(result.sessions.some((s) => s.id === "predict-session")).toBe( + true, + ); + }); + }); +}); diff --git a/src/server/service/session/SessionRepository.ts b/src/server/service/session/SessionRepository.ts index df9be24..8e3561f 100644 --- a/src/server/service/session/SessionRepository.ts +++ b/src/server/service/session/SessionRepository.ts @@ -1,144 +1,328 @@ -import { existsSync } from "node:fs"; -import { readdir, readFile } from "node:fs/promises"; import { resolve } from "node:path"; +import { FileSystem } from "@effect/platform"; +import { Context, Effect, Layer, Option } from "effect"; +import { uniqBy } from "es-toolkit"; +import { parseCommandXml } from "../parseCommandXml"; import { parseJsonl } from "../parseJsonl"; import { decodeProjectId } from "../project/id"; import type { Session, SessionDetail } from "../types"; import { decodeSessionId, encodeSessionId } from "./id"; -import { predictSessionsDatabase } from "./PredictSessionsDatabase"; -import { sessionMetaStorage } from "./sessionMetaStorage"; +import { VirtualConversationDatabase } from "./PredictSessionsDatabase"; +import { SessionMetaService } from "./SessionMetaService"; + +const getSession = (projectId: string, sessionId: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sessionMetaService = yield* SessionMetaService; + const virtualConversationDatabase = yield* VirtualConversationDatabase; -export class SessionRepository { - public async getSession( - projectId: string, - sessionId: string, - ): Promise<{ - session: SessionDetail; - }> { const sessionPath = decodeSessionId(projectId, sessionId); - if (!existsSync(sessionPath)) { - const predictSession = - predictSessionsDatabase.getPredictSession(sessionId); - if (predictSession) { - return { - session: predictSession, - }; - } - throw new Error("Session not found"); - } - const content = await readFile(sessionPath, "utf-8"); + const virtualConversation = + yield* virtualConversationDatabase.getSessionVirtualConversation( + sessionId, + ); - if (predictSession !== null) { - return { - session: predictSession, - }; - } + // Check if session file exists + const exists = yield* fs.exists(sessionPath); + const sessionDetail = yield* exists + ? Effect.gen(function* () { + // Read session file + const content = yield* fs.readFileString(sessionPath); + const allLines = content.split("\n").filter((line) => line.trim()); - throw new Error("Session not found"); - } - const content = await readFile(sessionPath, "utf-8"); - const allLines = content.split("\n").filter((line) => line.trim()); + const conversations = parseJsonl(allLines.join("\n")); - const conversations = parseJsonl(allLines.join("\n")); + // Get file stats + const stat = yield* fs.stat(sessionPath); - const sessionDetail: SessionDetail = { - id: sessionId, - jsonlFilePath: sessionPath, - meta: await sessionMetaStorage.getSessionMeta(projectId, sessionId), - conversations, - lastModifiedAt: statSync(sessionPath).mtime, - }; + // Get session metadata + const meta = yield* sessionMetaService.getSessionMeta( + projectId, + sessionId, + ); + + const mergedConversations = [ + ...conversations, + ...(virtualConversation !== null + ? virtualConversation.conversations + : []), + ]; + + const conversationMap = new Map( + mergedConversations.flatMap((c, index) => { + if ( + c.type === "user" || + c.type === "assistant" || + c.type === "system" + ) { + return [[c.uuid, { conversation: c, index }] as const]; + } else { + return []; + } + }), + ); + + const isBroken = mergedConversations.some((item, index) => { + if (item.type !== "summary") return false; + const leftMessage = conversationMap.get(item.leafUuid); + if (leftMessage === undefined) return false; + + return index < leftMessage.index; + }); + + const sessionDetail: SessionDetail = { + id: sessionId, + jsonlFilePath: sessionPath, + meta, + conversations: isBroken + ? conversations + : uniqBy(mergedConversations, (item) => { + switch (item.type) { + case "system": + return `${item.type}-${item.uuid}`; + case "assistant": + return `${item.type}-${item.message.id}`; + case "user": + return `${item.type}-${item.message.content}`; + case "summary": + return `${item.type}-${item.leafUuid}`; + case "x-error": + return `${item.type}-${item.lineNumber}-${item.line}`; + default: + item satisfies never; + throw new Error(`Unknown conversation type: ${item}`); + } + }), + lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()), + }; + + return sessionDetail; + }) + : (() => { + if (virtualConversation === null) { + return Effect.succeed(null); + } + + const lastConversation = virtualConversation.conversations + .filter( + (conversation) => + conversation.type === "user" || + conversation.type === "assistant" || + conversation.type === "system", + ) + .at(-1); + + const virtualSession: SessionDetail = { + id: sessionId, + jsonlFilePath: `${decodeProjectId(projectId)}/${sessionId}.jsonl`, + meta: { + messageCount: 0, + firstCommand: null, + }, + conversations: virtualConversation.conversations, + lastModifiedAt: + lastConversation !== undefined + ? new Date(lastConversation.timestamp) + : new Date(), + }; + + return Effect.succeed(virtualSession); + })(); return { session: sessionDetail, }; - } + }); + +const getSessions = ( + projectId: string, + options?: { + maxCount?: number; + cursor?: string; + }, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const sessionMetaService = yield* SessionMetaService; + const virtualConversationDatabase = yield* VirtualConversationDatabase; - public async getSessions( - projectId: string, - options?: { - maxCount?: number; - cursor?: string; - }, - ): Promise<{ sessions: Session[] }> { const { maxCount = 20, cursor } = options ?? {}; - try { - const claudeProjectPath = decodeProjectId(projectId); - const dirents = await readdir(claudeProjectPath, { withFileTypes: true }); - const sessions = await Promise.all( - dirents - .filter((d) => d.isFile() && d.name.endsWith(".jsonl")) - .map(async (d) => { - const sessionId = encodeSessionId( - resolve(claudeProjectPath, d.name), - ); - const stats = statSync(resolve(claudeProjectPath, d.name)); + const claudeProjectPath = decodeProjectId(projectId); - return { - id: sessionId, - jsonlFilePath: resolve(claudeProjectPath, d.name), - lastModifiedAt: stats.mtime, - }; - }), - ).then((fetched) => - fetched.sort( - (a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(), - ), - ); - const sessionMap = new Map( - sessions.map((session) => [session.id, session]), - ); - - const predictSessions = predictSessionsDatabase - .getPredictSessions(projectId) - .filter((session) => !sessionMap.has(session.id)); - - const sessionMap = new Map( - sessions.map((session) => [session.id, session] as const), - ); - - const index = - cursor !== undefined - ? sessions.findIndex((session) => session.id === cursor) - : -1; - - if (index !== -1) { - return { - sessions: await Promise.all( - sessions - .slice(index + 1, Math.min(index + 1 + maxCount, sessions.length)) - .map(async (item) => { - return { - ...item, - meta: await sessionMetaStorage.getSessionMeta( - projectId, - item.id, - ), - }; - }), - ), - }; - } - - const predictSessions = predictSessionsDatabase - .getPredictSessions(projectId) - .filter((session) => !sessionMap.has(session.id)) - .sort((a, b) => { - return b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(); - }); - - return { - sessions: [...predictSessions, ...sessions].sort((a, b) => { - return ( - getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt) - ); - }), - }; - } catch (error) { - console.warn(`Failed to read sessions for project ${projectId}:`, error); + // Check if project directory exists + const dirExists = yield* fs.exists(claudeProjectPath); + if (!dirExists) { + console.warn(`Project directory not found at ${claudeProjectPath}`); return { sessions: [] }; } + + // Read directory entries with error handling + const dirents = yield* Effect.tryPromise({ + try: () => fs.readDirectory(claudeProjectPath).pipe(Effect.runPromise), + catch: (error) => { + console.warn( + `Failed to read sessions for project ${projectId}:`, + error, + ); + return new Error("Failed to read directory"); + }, + }).pipe(Effect.catchAll(() => Effect.succeed([]))); + + // Process session files + const sessionEffects = dirents + .filter((entry) => entry.endsWith(".jsonl")) + .map((entry) => + Effect.gen(function* () { + const fullPath = resolve(claudeProjectPath, entry); + const sessionId = encodeSessionId(fullPath); + + // Get file stats with error handling + const stat = yield* Effect.tryPromise(() => + fs.stat(fullPath).pipe(Effect.runPromise), + ).pipe(Effect.catchAll(() => Effect.succeed(null))); + + if (!stat) { + return null; + } + + return { + id: sessionId, + jsonlFilePath: fullPath, + lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()), + }; + }), + ); + + // Execute all effects in parallel and filter out nulls + const sessionsWithNulls = yield* Effect.all(sessionEffects, { + concurrency: "unbounded", + }); + const sessions = sessionsWithNulls + .filter((s): s is NonNullable => s !== null) + .sort((a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime()); + + const sessionMap = new Map( + sessions.map((session) => [session.id, session] as const), + ); + + const index = + cursor !== undefined + ? sessions.findIndex((session) => session.id === cursor) + : -1; + + if (index !== -1) { + const sessionsToReturn = sessions.slice( + index + 1, + Math.min(index + 1 + maxCount, sessions.length), + ); + + const sessionsWithMeta = yield* Effect.all( + sessionsToReturn.map((item) => + Effect.gen(function* () { + const meta = yield* sessionMetaService.getSessionMeta( + projectId, + item.id, + ); + return { + ...item, + meta, + }; + }), + ), + { concurrency: "unbounded" }, + ); + + return { + sessions: sessionsWithMeta, + }; + } + + // Get predict sessions + const virtualConversations = + yield* virtualConversationDatabase.getProjectVirtualConversations( + projectId, + ); + + const virtualSessions = virtualConversations + .filter(({ sessionId }) => !sessionMap.has(sessionId)) + .map(({ sessionId, conversations }): Session => { + const first = conversations + .filter((conversation) => conversation.type === "user") + .at(0); + const last = conversations + .filter( + (conversation) => + conversation.type === "user" || + conversation.type === "assistant" || + conversation.type === "system", + ) + .at(-1); + + const firstUserText = + first !== undefined + ? typeof first.message.content === "string" + ? first.message.content + : (() => { + const firstContent = first.message.content.at(0); + if (firstContent === undefined) return null; + if (typeof firstContent === "string") return firstContent; + if (firstContent.type === "text") return firstContent.text; + return null; + })() + : null; + + return { + id: sessionId, + jsonlFilePath: `${decodeProjectId(projectId)}/${sessionId}.jsonl`, + lastModifiedAt: + last !== undefined ? new Date(last.timestamp) : new Date(), + meta: { + messageCount: conversations.length, + firstCommand: firstUserText ? parseCommandXml(firstUserText) : null, + }, + }; + }) + .sort((a, b) => { + return b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(); + }); + + // Get sessions with metadata + const sessionsToReturn = sessions.slice( + 0, + Math.min(maxCount, sessions.length), + ); + const sessionsWithMeta: Session[] = yield* Effect.all( + sessionsToReturn.map((item) => + Effect.gen(function* () { + const meta = yield* sessionMetaService.getSessionMeta( + projectId, + item.id, + ); + return { + ...item, + meta, + }; + }), + ), + { concurrency: "unbounded" }, + ); + + return { + sessions: [...virtualSessions, ...sessionsWithMeta], + }; + }); + +export class SessionRepository extends Context.Tag("SessionRepository")< + SessionRepository, + { + readonly getSession: typeof getSession; + readonly getSessions: typeof getSessions; } +>() { + static Live = Layer.succeed(this, { + getSession, + getSessions, + }); } diff --git a/src/server/service/session/VirtualConversationDatabase.test.ts b/src/server/service/session/VirtualConversationDatabase.test.ts new file mode 100644 index 0000000..4771f0b --- /dev/null +++ b/src/server/service/session/VirtualConversationDatabase.test.ts @@ -0,0 +1,245 @@ +import { Effect } from "effect"; +import type { Conversation } from "../../../lib/conversation-schema"; +import type { ErrorJsonl } from "../types"; +import { VirtualConversationDatabase } from "./PredictSessionsDatabase"; + +describe("VirtualConversationDatabase", () => { + describe("getProjectVirtualConversations", () => { + it("can retrieve session list for specified project ID", async () => { + const program = Effect.gen(function* () { + const db = yield* VirtualConversationDatabase; + + const projectPath = "/projects/test-project"; + const projectId = Buffer.from(projectPath).toString("base64url"); + const conversations1: (Conversation | ErrorJsonl)[] = []; + const conversations2: (Conversation | ErrorJsonl)[] = []; + const conversations3: (Conversation | ErrorJsonl)[] = []; + + yield* db.createVirtualConversation( + projectId, + "session-1", + conversations1, + ); + yield* db.createVirtualConversation( + projectId, + "session-2", + conversations2, + ); + yield* db.createVirtualConversation( + "other-project-id", + "session-3", + conversations3, + ); + + const sessions = yield* db.getProjectVirtualConversations(projectId); + + return { sessions }; + }); + + const { sessions } = await Effect.runPromise( + program.pipe(Effect.provide(VirtualConversationDatabase.Live)), + ); + + expect(sessions).toHaveLength(2); + expect(sessions.map((s) => s.sessionId)).toEqual( + expect.arrayContaining(["session-1", "session-2"]), + ); + }); + + it("returns empty array when no matching sessions exist", async () => { + const program = Effect.gen(function* () { + const db = yield* VirtualConversationDatabase; + const sessions = yield* db.getProjectVirtualConversations( + "non-existent-project", + ); + return { sessions }; + }); + + const { sessions } = await Effect.runPromise( + program.pipe(Effect.provide(VirtualConversationDatabase.Live)), + ); + + expect(sessions).toHaveLength(0); + }); + }); + + describe("getSessionVirtualConversation", () => { + it("can retrieve session by specified ID", async () => { + const program = Effect.gen(function* () { + const db = yield* VirtualConversationDatabase; + + const conversations: (Conversation | ErrorJsonl)[] = []; + + yield* db.createVirtualConversation( + "project-1", + "session-1", + conversations, + ); + const result = yield* db.getSessionVirtualConversation("session-1"); + + return { result }; + }); + + const { result } = await Effect.runPromise( + program.pipe(Effect.provide(VirtualConversationDatabase.Live)), + ); + + expect(result).not.toBeNull(); + expect(result?.sessionId).toBe("session-1"); + }); + + it("returns null for non-existent session ID", async () => { + const program = Effect.gen(function* () { + const db = yield* VirtualConversationDatabase; + const result = yield* db.getSessionVirtualConversation( + "non-existent-session", + ); + return { result }; + }); + + const { result } = await Effect.runPromise( + program.pipe(Effect.provide(VirtualConversationDatabase.Live)), + ); + + expect(result).toBeNull(); + }); + }); + + describe("createVirtualConversation", () => { + it("can add new session", async () => { + const program = Effect.gen(function* () { + const db = yield* VirtualConversationDatabase; + + const conversations: (Conversation | ErrorJsonl)[] = []; + + yield* db.createVirtualConversation( + "project-1", + "session-1", + conversations, + ); + const result = yield* db.getSessionVirtualConversation("session-1"); + + return { result }; + }); + + const { result } = await Effect.runPromise( + program.pipe(Effect.provide(VirtualConversationDatabase.Live)), + ); + + expect(result).not.toBeNull(); + expect(result?.sessionId).toBe("session-1"); + }); + + it("can append conversations to existing session", async () => { + const program = Effect.gen(function* () { + const db = yield* VirtualConversationDatabase; + + const conversations1: (Conversation | ErrorJsonl)[] = []; + const conversations2: (Conversation | ErrorJsonl)[] = []; + + yield* db.createVirtualConversation( + "project-1", + "session-1", + conversations1, + ); + yield* db.createVirtualConversation( + "project-1", + "session-1", + conversations2, + ); + const result = yield* db.getSessionVirtualConversation("session-1"); + + return { result }; + }); + + const { result } = await Effect.runPromise( + program.pipe(Effect.provide(VirtualConversationDatabase.Live)), + ); + + expect(result?.conversations).toHaveLength(0); + }); + }); + + describe("deleteVirtualConversations", () => { + it("can delete specified session", async () => { + const program = Effect.gen(function* () { + const db = yield* VirtualConversationDatabase; + + const conversations: (Conversation | ErrorJsonl)[] = []; + + yield* db.createVirtualConversation( + "project-1", + "session-1", + conversations, + ); + yield* db.deleteVirtualConversations("session-1"); + const result = yield* db.getSessionVirtualConversation("session-1"); + + return { result }; + }); + + const { result } = await Effect.runPromise( + program.pipe(Effect.provide(VirtualConversationDatabase.Live)), + ); + + expect(result).toBeNull(); + }); + + it("deleting non-existent session does not cause error", async () => { + const program = Effect.gen(function* () { + const db = yield* VirtualConversationDatabase; + yield* db.deleteVirtualConversations("non-existent-session"); + }); + + await expect( + Effect.runPromise( + program.pipe(Effect.provide(VirtualConversationDatabase.Live)), + ), + ).resolves.not.toThrow(); + }); + }); + + describe("state is isolated between multiple instances", () => { + it("different layers have different states", async () => { + const projectId = "test-project-id"; + const conversations1: (Conversation | ErrorJsonl)[] = []; + const conversations2: (Conversation | ErrorJsonl)[] = []; + + const program1 = Effect.gen(function* () { + const db = yield* VirtualConversationDatabase; + yield* db.createVirtualConversation( + projectId, + "session-1", + conversations1, + ); + const sessions = yield* db.getProjectVirtualConversations(projectId); + return { sessions }; + }); + + const program2 = Effect.gen(function* () { + const db = yield* VirtualConversationDatabase; + yield* db.createVirtualConversation( + projectId, + "session-2", + conversations2, + ); + const sessions = yield* db.getProjectVirtualConversations(projectId); + return { sessions }; + }); + + const result1 = await Effect.runPromise( + program1.pipe(Effect.provide(VirtualConversationDatabase.Live)), + ); + + const result2 = await Effect.runPromise( + program2.pipe(Effect.provide(VirtualConversationDatabase.Live)), + ); + + expect(result1.sessions).toHaveLength(1); + expect(result1.sessions.at(0)?.sessionId).toBe("session-1"); + + expect(result2.sessions).toHaveLength(1); + expect(result2.sessions.at(0)?.sessionId).toBe("session-2"); + }); + }); +}); diff --git a/src/server/service/session/VirtualConversationDatabase.ts b/src/server/service/session/VirtualConversationDatabase.ts new file mode 100644 index 0000000..3b7a8eb --- /dev/null +++ b/src/server/service/session/VirtualConversationDatabase.ts @@ -0,0 +1,116 @@ +import { Context, Effect, Layer, Ref } from "effect"; +import type { Conversation } from "../../../lib/conversation-schema"; +import type { ErrorJsonl } from "../types"; + +/** + * For interactively experience, handle sessions not already persisted to the filesystem. + */ +export class VirtualConversationDatabase extends Context.Tag( + "VirtualConversationDatabase", +)< + VirtualConversationDatabase, + { + readonly getProjectVirtualConversations: ( + projectId: string, + ) => Effect.Effect< + { + projectId: string; + sessionId: string; + conversations: (Conversation | ErrorJsonl)[]; + }[] + >; + readonly getSessionVirtualConversation: ( + sessionId: string, + ) => Effect.Effect<{ + projectId: string; + sessionId: string; + conversations: (Conversation | ErrorJsonl)[]; + } | null>; + readonly createVirtualConversation: ( + projectId: string, + sessionId: string, + conversations: readonly (Conversation | ErrorJsonl)[], + ) => Effect.Effect; + readonly deleteVirtualConversations: ( + sessionId: string, + ) => Effect.Effect; + } +>() { + static Live = Layer.effect( + this, + Effect.gen(function* () { + const storageRef = yield* Ref.make< + { + projectId: string; + sessionId: string; + conversations: (Conversation | ErrorJsonl)[]; + }[] + >([]); + + const getProjectVirtualConversations = (projectId: string) => + Effect.gen(function* () { + const conversations = yield* Ref.get(storageRef); + return conversations.filter( + (conversation) => conversation.projectId === projectId, + ); + }); + + const getSessionVirtualConversation = (sessionId: string) => + Effect.gen(function* () { + const conversations = yield* Ref.get(storageRef); + return ( + conversations.find( + (conversation) => conversation.sessionId === sessionId, + ) ?? null + ); + }); + + const createVirtualConversation = ( + projectId: string, + sessionId: string, + createConversations: readonly (Conversation | ErrorJsonl)[], + ) => + Effect.gen(function* () { + yield* Ref.update(storageRef, (conversations) => { + const existingRecord = conversations.find( + (record) => + record.projectId === projectId && + record.sessionId === sessionId, + ); + + if (existingRecord === undefined) { + return [ + ...conversations, + { + projectId, + sessionId, + conversations: [...createConversations], + }, + ]; + } + + existingRecord.conversations.push(...createConversations); + return conversations; + }); + }); + + const deleteVirtualConversations = (sessionId: string) => + Effect.gen(function* () { + yield* Ref.update(storageRef, (conversations) => { + return conversations.filter((c) => c.sessionId !== sessionId); + }); + }); + + return { + getProjectVirtualConversations, + getSessionVirtualConversation, + createVirtualConversation, + deleteVirtualConversations, + }; + }), + ); +} + +export type IVirtualConversationDatabase = Context.Tag.Service< + typeof VirtualConversationDatabase +>; diff --git a/src/server/service/session/sessionMetaStorage.ts b/src/server/service/session/sessionMetaStorage.ts deleted file mode 100644 index 00bbb47..0000000 --- a/src/server/service/session/sessionMetaStorage.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; -import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; -import { - type ParsedCommand, - parseCommandXml, - parsedCommandSchema, -} from "../parseCommandXml"; -import { parseJsonl } from "../parseJsonl"; -import type { SessionMeta } from "../types"; -import { decodeSessionId } from "./id"; - -const ignoreCommands = [ - "/clear", - "/login", - "/logout", - "/exit", - "/mcp", - "/memory", -]; - -class SessionMetaStorage { - private firstCommandCache = FileCacheStorage.load( - "first-command-cache", - parsedCommandSchema, - ); - private sessionMetaCache = new InMemoryCacheStorage(); - - public async getSessionMeta( - projectId: string, - sessionId: string, - ): Promise { - const cached = this.sessionMetaCache.get(sessionId); - if (cached !== undefined) { - return cached; - } - - const sessionPath = decodeSessionId(projectId, sessionId); - - const content = await readFile(sessionPath, "utf-8"); - const lines = content.split("\n"); - - const sessionMeta: SessionMeta = { - messageCount: lines.length, - firstCommand: this.getFirstCommand(sessionPath, lines), - }; - - this.sessionMetaCache.save(sessionId, sessionMeta); - - return sessionMeta; - } - - private getFirstCommand = ( - jsonlFilePath: string, - lines: string[], - ): ParsedCommand | null => { - const cached = this.firstCommandCache.get(jsonlFilePath); - if (cached !== undefined) { - return cached; - } - - let firstCommand: ParsedCommand | null = null; - - for (const line of lines) { - const conversation = parseJsonl(line).at(0); - - if (conversation === undefined || conversation.type !== "user") { - continue; - } - - const firstUserText = - conversation === null - ? null - : typeof conversation.message.content === "string" - ? conversation.message.content - : (() => { - const firstContent = conversation.message.content.at(0); - if (firstContent === undefined) return null; - if (typeof firstContent === "string") return firstContent; - if (firstContent.type === "text") return firstContent.text; - return null; - })(); - - if (firstUserText === null) { - continue; - } - - if ( - firstUserText === - "Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to." - ) { - continue; - } - - const command = parseCommandXml(firstUserText); - if (command.kind === "local-command") { - continue; - } - - if ( - command.kind === "command" && - ignoreCommands.includes(command.commandName) - ) { - continue; - } - - firstCommand = command; - break; - } - - if (firstCommand !== null) { - this.firstCommandCache.save(jsonlFilePath, firstCommand); - } - - return firstCommand; - }; - - public invalidateSession(_projectId: string, sessionId: string) { - this.sessionMetaCache.invalidate(sessionId); - } -} - -export const sessionMetaStorage = new SessionMetaStorage(); diff --git a/src/server/service/types.ts b/src/server/service/types.ts index 2668e30..e95c463 100644 --- a/src/server/service/types.ts +++ b/src/server/service/types.ts @@ -23,6 +23,7 @@ export type SessionMeta = z.infer; export type ErrorJsonl = { type: "x-error"; line: string; + lineNumber: number; }; export type SessionDetail = Session & { diff --git a/src/types/session-process.ts b/src/types/session-process.ts new file mode 100644 index 0000000..09068d0 --- /dev/null +++ b/src/types/session-process.ts @@ -0,0 +1,6 @@ +export type PublicSessionProcess = { + id: string; + projectId: string; + sessionId: string; + status: "paused" | "running"; +}; diff --git a/src/types/sse.ts b/src/types/sse.ts index d4def0e..2ab4afc 100644 --- a/src/types/sse.ts +++ b/src/types/sse.ts @@ -1,8 +1,5 @@ -import type { - AliveClaudeCodeTask, - ClaudeCodeTask, - PermissionRequest, -} from "../server/service/claude-code/types"; +import type { PermissionRequest } from "./permissions"; +import type { PublicSessionProcess } from "./session-process"; export type SSEEventDeclaration = { // biome-ignore lint/complexity/noBannedTypes: correct type @@ -20,9 +17,8 @@ export type SSEEventDeclaration = { sessionId: string; }; - taskChanged: { - aliveTasks: AliveClaudeCodeTask[]; - changed: Pick; + sessionProcessChanged: { + processes: PublicSessionProcess[]; }; permission_requested: { From 1795cb499b4b50147cc2da81a61bf1752efcd05e Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Fri, 17 Oct 2025 04:37:09 +0900 Subject: [PATCH 10/21] update design --- src/app/api/[[...route]]/route.ts | 2 +- src/app/components/MarkdownContent.tsx | 11 +- src/app/components/RootErrorBoundary.tsx | 55 +- src/app/components/SSEEventListeners.tsx | 2 - src/app/error.tsx | 69 ++ src/app/layout.tsx | 33 +- src/app/not-found.tsx | 41 + .../[projectId]/components/ProjectPage.tsx | 221 ----- .../components/chatForm/ChatInput.tsx | 177 ++-- .../components/chatForm/CommandCompletion.tsx | 82 +- .../components/chatForm/FileCompletion.tsx | 106 ++- .../components/chatForm/InlineCompletion.tsx | 25 +- .../components/newChat/NewChat.tsx | 9 +- src/app/projects/[projectId]/error.tsx | 67 ++ src/app/projects/[projectId]/latest/page.tsx | 27 + src/app/projects/[projectId]/not-found.tsx | 42 + src/app/projects/[projectId]/page.tsx | 25 +- .../components/SessionPageContent.tsx | 67 +- .../AssistantConversationContent.tsx | 36 +- .../conversationList/ConversationList.tsx | 2 +- .../conversationList/UserTextContent.tsx | 2 +- .../components/resumeChat/ContinueChat.tsx | 38 +- .../components/resumeChat/ResumeChat.tsx | 38 +- .../sessionSidebar/MobileSidebar.tsx | 35 +- .../sessionSidebar/SessionSidebar.tsx | 155 +-- .../components/sessionSidebar/SessionsTab.tsx | 4 +- .../components/sessionSidebar/SettingsTab.tsx | 40 - .../sessions/[sessionId]/error.tsx | 72 ++ .../sessions/[sessionId]/layout.tsx | 15 + .../sessions/[sessionId]/not-found.tsx | 42 + src/app/projects/components/ProjectList.tsx | 4 +- src/app/projects/page.tsx | 64 +- src/components/GlobalSidebar.tsx | 132 +++ src/components/SettingsControls.tsx | 34 +- src/lib/api/queries.ts | 20 + src/lib/sse/hook/useServerEventListener.ts | 15 +- src/server/config/config.ts | 2 +- src/server/hono/initialize.test.ts | 2 +- src/server/hono/initialize.ts | 2 +- src/server/hono/route.ts | 20 +- .../claude-code/ClaudeCodeLifeCycleService.ts | 17 +- .../ClaudeCodeSessionProcessService.test.ts | 901 ++++++++++++++++++ .../ClaudeCodeSessionProcessService.ts | 43 +- .../claude-code/models/CCSessionProcess.ts | 15 +- src/server/service/events/fileWatcher.ts | 52 +- src/server/service/git/getBranches.test.ts | 369 +++++++ src/server/service/git/getCommits.test.ts | 250 +++++ src/server/service/git/getDiff.test.ts | 521 ++++++++++ src/server/service/git/getStatus.test.ts | 351 +++++++ src/server/service/parseCommandXml.test.ts | 301 ++++++ src/server/service/parseJsonl.test.ts | 378 ++++++++ .../service/session/SessionRepository.test.ts | 2 +- .../service/session/SessionRepository.ts | 23 +- .../VirtualConversationDatabase.test.ts | 2 +- 54 files changed, 4299 insertions(+), 761 deletions(-) create mode 100644 src/app/error.tsx create mode 100644 src/app/not-found.tsx delete mode 100644 src/app/projects/[projectId]/components/ProjectPage.tsx create mode 100644 src/app/projects/[projectId]/error.tsx create mode 100644 src/app/projects/[projectId]/latest/page.tsx create mode 100644 src/app/projects/[projectId]/not-found.tsx delete mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SettingsTab.tsx create mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/error.tsx create mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/layout.tsx create mode 100644 src/app/projects/[projectId]/sessions/[sessionId]/not-found.tsx create mode 100644 src/components/GlobalSidebar.tsx create mode 100644 src/server/service/claude-code/ClaudeCodeSessionProcessService.test.ts create mode 100644 src/server/service/git/getBranches.test.ts create mode 100644 src/server/service/git/getCommits.test.ts create mode 100644 src/server/service/git/getDiff.test.ts create mode 100644 src/server/service/git/getStatus.test.ts create mode 100644 src/server/service/parseCommandXml.test.ts create mode 100644 src/server/service/parseJsonl.test.ts diff --git a/src/app/api/[[...route]]/route.ts b/src/app/api/[[...route]]/route.ts index 12ae66e..2485c85 100644 --- a/src/app/api/[[...route]]/route.ts +++ b/src/app/api/[[...route]]/route.ts @@ -11,9 +11,9 @@ import { EventBus } from "../../../server/service/events/EventBus"; import { FileWatcherService } from "../../../server/service/events/fileWatcher"; import { ProjectMetaService } from "../../../server/service/project/ProjectMetaService"; import { ProjectRepository } from "../../../server/service/project/ProjectRepository"; -import { VirtualConversationDatabase } from "../../../server/service/session/PredictSessionsDatabase"; import { SessionMetaService } from "../../../server/service/session/SessionMetaService"; import { SessionRepository } from "../../../server/service/session/SessionRepository"; +import { VirtualConversationDatabase } from "../../../server/service/session/VirtualConversationDatabase"; const program = routes(honoApp); diff --git a/src/app/components/MarkdownContent.tsx b/src/app/components/MarkdownContent.tsx index e5acb61..9b7d2d9 100644 --- a/src/app/components/MarkdownContent.tsx +++ b/src/app/components/MarkdownContent.tsx @@ -1,9 +1,13 @@ "use client"; +import { useTheme } from "next-themes"; import type { FC } from "react"; import Markdown from "react-markdown"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; -import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { + oneDark, + oneLight, +} from "react-syntax-highlighter/dist/esm/styles/prism"; import remarkGfm from "remark-gfm"; interface MarkdownContentProps { @@ -15,6 +19,9 @@ export const MarkdownContent: FC = ({ content, className = "", }) => { + const { resolvedTheme } = useTheme(); + const syntaxTheme = resolvedTheme === "dark" ? oneDark : oneLight; + return (
= ({
= ({ children }) => { return ( ( -
-

Error

-

{error.message}

+ FallbackComponent={({ error, resetErrorBoundary }) => ( +
+ + +
+ +
+ Something went wrong + + An unexpected error occurred in the application + +
+
+
+ + + + Error Details + + {error.message} + + + +
+ + +
+
+
)} > diff --git a/src/app/components/SSEEventListeners.tsx b/src/app/components/SSEEventListeners.tsx index 50bf595..0d0b0c3 100644 --- a/src/app/components/SSEEventListeners.tsx +++ b/src/app/components/SSEEventListeners.tsx @@ -12,14 +12,12 @@ export const SSEEventListeners: FC = ({ children }) => { const setSessionProcesses = useSetAtom(sessionProcessesAtom); useServerEventListener("sessionListChanged", async (event) => { - // invalidate session list await queryClient.invalidateQueries({ queryKey: projectDetailQuery(event.projectId).queryKey, }); }); useServerEventListener("sessionChanged", async (event) => { - // invalidate session detail await queryClient.invalidateQueries({ queryKey: sessionDetailQuery(event.projectId, event.sessionId).queryKey, }); diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..b52733a --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { AlertCircle, Home, RefreshCw } from "lucide-react"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export default function ErrorPage({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+ + +
+ +
+ Something went wrong + + An unexpected error occurred in the application + +
+
+
+ + + + Error Details + + {error.message} + {error.digest && ( +
+ Error ID: {error.digest} +
+ )} +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f882369..66e7d12 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import { QueryClient } from "@tanstack/react-query"; import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeProvider } from "next-themes"; import { Toaster } from "../components/ui/sonner"; import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper"; @@ -47,24 +48,26 @@ export default async function RootLayout({ .then((response) => response.json()); return ( - + - - - - - - {children} - - - - - - + + + + + + + {children} + + + + + + + ); diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..79097e7 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,41 @@ +import { FileQuestion, Home } from "lucide-react"; +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export default function NotFoundPage() { + return ( +
+ + +
+ +
+ Page Not Found + + The page you are looking for does not exist + +
+
+
+ +
+ +
+
+
+
+ ); +} diff --git a/src/app/projects/[projectId]/components/ProjectPage.tsx b/src/app/projects/[projectId]/components/ProjectPage.tsx deleted file mode 100644 index 0d4077d..0000000 --- a/src/app/projects/[projectId]/components/ProjectPage.tsx +++ /dev/null @@ -1,221 +0,0 @@ -"use client"; - -import { useQueryClient } from "@tanstack/react-query"; -import { - ArrowLeftIcon, - ChevronDownIcon, - FolderIcon, - MessageSquareIcon, - PlusIcon, - SettingsIcon, -} from "lucide-react"; -import Link from "next/link"; -import { useEffect, useState } from "react"; -import { SettingsControls } from "@/components/SettingsControls"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import { useConfig } from "../../../hooks/useConfig"; -import { useProject } from "../hooks/useProject"; -import { firstCommandToTitle } from "../services/firstCommandToTitle"; -import { NewChatModal } from "./newChat/NewChatModal"; - -export const ProjectPageContent = ({ projectId }: { projectId: string }) => { - const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = - useProject(projectId); - const { config } = useConfig(); - const queryClient = useQueryClient(); - const [isSettingsOpen, setIsSettingsOpen] = useState(false); - - // Flatten all pages to get project and sessions - const project = data.pages.at(0)?.project; - const sessions = data.pages.flatMap((page) => page.sessions); - - if (!project) { - throw new Error("Unreachable: Project must be defined."); - } - - // biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed - useEffect(() => { - void queryClient.refetchQueries({ - queryKey: ["projects", projectId], - }); - }, [config.hideNoUserMessageSession, config.unifySameTitleSession]); - - return ( -
-
- - -
-
- -

- {project.meta.projectPath ?? project.claudeProjectPath} -

-
-
- - - Start New Chat - New Chat - - } - /> -
-
-

- History File: {project.claudeProjectPath ?? "unknown"} -

-
- -
-
-

- Conversation Sessions{" "} - {sessions.length > 0 ? `(${sessions.length})` : ""} -

- - {/* Filter Controls */} - -
- - - - -
- -
-
-
-
- - {sessions.length === 0 ? ( - - - -

No sessions found

-

- No conversation sessions found for this project. Start a - conversation with Claude Code in this project to create - sessions. -

- - - Start First Chat - - } - /> -
-
- ) : ( -
- {sessions.map((session) => ( - - - - - {session.meta.firstCommand !== null - ? firstCommandToTitle(session.meta.firstCommand) - : session.id} - - - - {session.id} - - - -

- {session.meta.messageCount} messages -

-

- Last modified:{" "} - {session.lastModifiedAt - ? new Date(session.lastModifiedAt).toLocaleDateString() - : ""} -

-

- {session.jsonlFilePath} -

-
- - - -
- ))} -
- )} - - {/* Load More Button */} - {sessions.length > 0 && hasNextPage && ( -
- -
- )} -
-
-
- ); -}; diff --git a/src/app/projects/[projectId]/components/chatForm/ChatInput.tsx b/src/app/projects/[projectId]/components/chatForm/ChatInput.tsx index e707fd4..9d1fbdf 100644 --- a/src/app/projects/[projectId]/components/chatForm/ChatInput.tsx +++ b/src/app/projects/[projectId]/components/chatForm/ChatInput.tsx @@ -1,4 +1,9 @@ -import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react"; +import { + AlertCircleIcon, + LoaderIcon, + SendIcon, + SparklesIcon, +} from "lucide-react"; import { type FC, useCallback, useId, useRef, useState } from "react"; import { Button } from "../../../../../components/ui/button"; import { Textarea } from "../../../../../components/ui/textarea"; @@ -62,16 +67,28 @@ export const ChatInput: FC = ({ // IMEで変換中の場合は送信しない if (e.key === "Enter" && !e.nativeEvent.isComposing) { - const isEnterSend = config?.enterKeyBehavior === "enter-send"; + const enterKeyBehavior = config?.enterKeyBehavior; - if (isEnterSend && !e.shiftKey) { + if (enterKeyBehavior === "enter-send" && !e.shiftKey && !e.metaKey) { // Enter: Send mode e.preventDefault(); handleSubmit(); - } else if (!isEnterSend && e.shiftKey) { + } else if ( + enterKeyBehavior === "shift-enter-send" && + e.shiftKey && + !e.metaKey + ) { // Shift+Enter: Send mode (default) e.preventDefault(); handleSubmit(); + } else if ( + enterKeyBehavior === "command-enter-send" && + e.metaKey && + !e.shiftKey + ) { + // Command+Enter: Send mode (Mac) + e.preventDefault(); + handleSubmit(); } } }; @@ -148,78 +165,98 @@ export const ChatInput: FC = ({ return (
{error && ( -
- - Failed to send message. Please try again. +
+ + + Failed to send message. Please try again. +
)} -
-
-