mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-20 14:54:19 +01:00
refactor: split request handle logic to controller
This commit is contained in:
@@ -1,15 +1,25 @@
|
||||
import { NodeContext } from "@effect/platform-node";
|
||||
import { Effect } from "effect";
|
||||
import { Effect, Layer } from "effect";
|
||||
import { handle } from "hono/vercel";
|
||||
import { ClaudeCodeController } from "../../../server/core/claude-code/presentation/ClaudeCodeController";
|
||||
import { ClaudeCodePermissionController } from "../../../server/core/claude-code/presentation/ClaudeCodePermissionController";
|
||||
import { ClaudeCodeSessionProcessController } from "../../../server/core/claude-code/presentation/ClaudeCodeSessionProcessController";
|
||||
import { ClaudeCodeLifeCycleService } from "../../../server/core/claude-code/services/ClaudeCodeLifeCycleService";
|
||||
import { ClaudeCodePermissionService } from "../../../server/core/claude-code/services/ClaudeCodePermissionService";
|
||||
import { ClaudeCodeService } from "../../../server/core/claude-code/services/ClaudeCodeService";
|
||||
import { ClaudeCodeSessionProcessService } from "../../../server/core/claude-code/services/ClaudeCodeSessionProcessService";
|
||||
import { SSEController } from "../../../server/core/events/presentation/SSEController";
|
||||
import { EventBus } from "../../../server/core/events/services/EventBus";
|
||||
import { FileWatcherService } from "../../../server/core/events/services/fileWatcher";
|
||||
import { FileSystemController } from "../../../server/core/file-system/presentation/FileSystemController";
|
||||
import { GitController } from "../../../server/core/git/presentation/GitController";
|
||||
import { HonoConfigService } from "../../../server/core/hono/services/HonoConfigService";
|
||||
import { ProjectRepository } from "../../../server/core/project/infrastructure/ProjectRepository";
|
||||
import { ProjectController } from "../../../server/core/project/presentation/ProjectController";
|
||||
import { ProjectMetaService } from "../../../server/core/project/services/ProjectMetaService";
|
||||
import { SessionRepository } from "../../../server/core/session/infrastructure/SessionRepository";
|
||||
import { VirtualConversationDatabase } from "../../../server/core/session/infrastructure/VirtualConversationDatabase";
|
||||
import { SessionController } from "../../../server/core/session/presentation/SessionController";
|
||||
import { SessionMetaService } from "../../../server/core/session/services/SessionMetaService";
|
||||
import { honoApp } from "../../../server/hono/app";
|
||||
import { InitializeService } from "../../../server/hono/initialize";
|
||||
@@ -17,10 +27,27 @@ import { routes } from "../../../server/hono/route";
|
||||
|
||||
const program = routes(honoApp);
|
||||
|
||||
/** Max count of pipe is 20, so merge some layers here */
|
||||
const storageLayer = Layer.mergeAll(
|
||||
ProjectMetaService.Live,
|
||||
SessionMetaService.Live,
|
||||
VirtualConversationDatabase.Live,
|
||||
);
|
||||
|
||||
await Effect.runPromise(
|
||||
program.pipe(
|
||||
// 依存の浅い順にコンテナに pipe する必要がある
|
||||
|
||||
/** Presentation */
|
||||
Effect.provide(ProjectController.Live),
|
||||
Effect.provide(SessionController.Live),
|
||||
Effect.provide(GitController.Live),
|
||||
Effect.provide(ClaudeCodeController.Live),
|
||||
Effect.provide(ClaudeCodeSessionProcessController.Live),
|
||||
Effect.provide(ClaudeCodePermissionController.Live),
|
||||
Effect.provide(FileSystemController.Live),
|
||||
Effect.provide(SSEController.Live),
|
||||
|
||||
/** Application */
|
||||
Effect.provide(InitializeService.Live),
|
||||
|
||||
@@ -28,10 +55,12 @@ await Effect.runPromise(
|
||||
Effect.provide(ClaudeCodeLifeCycleService.Live),
|
||||
Effect.provide(ClaudeCodePermissionService.Live),
|
||||
Effect.provide(ClaudeCodeSessionProcessService.Live),
|
||||
Effect.provide(ClaudeCodeService.Live),
|
||||
|
||||
// Shared Services
|
||||
Effect.provide(FileWatcherService.Live),
|
||||
Effect.provide(EventBus.Live),
|
||||
Effect.provide(HonoConfigService.Live),
|
||||
|
||||
/** Infrastructure */
|
||||
|
||||
@@ -40,9 +69,7 @@ await Effect.runPromise(
|
||||
Effect.provide(SessionRepository.Live),
|
||||
|
||||
// StorageService
|
||||
Effect.provide(ProjectMetaService.Live),
|
||||
Effect.provide(SessionMetaService.Live),
|
||||
Effect.provide(VirtualConversationDatabase.Live),
|
||||
Effect.provide(storageLayer),
|
||||
|
||||
/** Platform */
|
||||
Effect.provide(NodeContext.layer),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DirectoryListingResult } from "../../server/core/directory-browser/functions/getDirectoryListing";
|
||||
import type { FileCompletionResult } from "../../server/core/file-completion/functions/getFileCompletion";
|
||||
import type { DirectoryListingResult } from "../../server/core/file-system/functions/getDirectoryListing";
|
||||
import type { FileCompletionResult } from "../../server/core/file-system/functions/getFileCompletion";
|
||||
import { honoClient } from "./client";
|
||||
|
||||
export const projectListQuery = {
|
||||
@@ -21,7 +21,7 @@ export const directoryListingQuery = (currentPath?: string) =>
|
||||
({
|
||||
queryKey: ["directory-listing", currentPath],
|
||||
queryFn: async (): Promise<DirectoryListingResult> => {
|
||||
const response = await honoClient.api["directory-browser"].$get({
|
||||
const response = await honoClient.api.fs["directory-browser"].$get({
|
||||
query: currentPath ? { currentPath } : {},
|
||||
});
|
||||
|
||||
@@ -182,11 +182,8 @@ export const fileCompletionQuery = (projectId: string, basePath: string) =>
|
||||
({
|
||||
queryKey: ["file-completion", projectId, basePath],
|
||||
queryFn: async (): Promise<FileCompletionResult> => {
|
||||
const response = await honoClient.api.projects[":projectId"][
|
||||
"file-completion"
|
||||
].$get({
|
||||
param: { projectId },
|
||||
query: { basePath },
|
||||
const response = await honoClient.api.fs["file-completion"].$get({
|
||||
query: { basePath, projectId },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseMcpListOutput } from "./parseMcpListOutput";
|
||||
|
||||
describe("parseMcpListOutput", () => {
|
||||
it("should parse claude mcp list output correctly", async () => {
|
||||
const output = `2.0.21 (Claude Code)
|
||||
Checking MCP server health...
|
||||
|
||||
context7: npx -y @upstash/context7-mcp@latest - ✓ Connected
|
||||
`;
|
||||
|
||||
const result = parseMcpListOutput(output);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "context7",
|
||||
command: "npx -y @upstash/context7-mcp@latest",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should handle multiple MCP servers", async () => {
|
||||
const output = `2.0.21 (Claude Code)
|
||||
Checking MCP server health...
|
||||
|
||||
context7: npx -y @upstash/context7-mcp@latest - ✓ Connected
|
||||
filesystem: /usr/local/bin/mcp-server-fs - ✓ Connected
|
||||
database: docker run db-mcp - ✗ Failed
|
||||
`;
|
||||
|
||||
const result = parseMcpListOutput(output);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "context7",
|
||||
command: "npx -y @upstash/context7-mcp@latest",
|
||||
},
|
||||
{
|
||||
name: "filesystem",
|
||||
command: "/usr/local/bin/mcp-server-fs",
|
||||
},
|
||||
{
|
||||
name: "database",
|
||||
command: "docker run db-mcp",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return empty array for output with no MCP servers", async () => {
|
||||
const output = `2.0.21 (Claude Code)
|
||||
Checking MCP server health...
|
||||
|
||||
`;
|
||||
|
||||
const result = parseMcpListOutput(output);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should skip malformed lines", async () => {
|
||||
const output = `2.0.21 (Claude Code)
|
||||
Checking MCP server health...
|
||||
|
||||
context7: npx -y @upstash/context7-mcp@latest - ✓ Connected
|
||||
invalid line without colon
|
||||
: command without name
|
||||
name without command:
|
||||
`;
|
||||
|
||||
const result = parseMcpListOutput(output);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
name: "context7",
|
||||
command: "npx -y @upstash/context7-mcp@latest",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
32
src/server/core/claude-code/functions/parseMcpListOutput.ts
Normal file
32
src/server/core/claude-code/functions/parseMcpListOutput.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface McpServer {
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export const parseMcpListOutput = (output: string) => {
|
||||
const servers: McpServer[] = [];
|
||||
const lines = output.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip header lines and status indicators
|
||||
if (line.includes("Checking MCP server health") || line.trim() === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse lines like "context7: npx -y @upstash/context7-mcp@latest - ✓ Connected"
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex > 0) {
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
const rest = line.substring(colonIndex + 1).trim();
|
||||
|
||||
// Remove status indicators (✓ Connected, ✗ Failed, etc.)
|
||||
const command = rest.replace(/\s*-\s*[✓✗].*$/, "").trim();
|
||||
|
||||
if (name && command) {
|
||||
servers.push({ name, command });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return servers;
|
||||
};
|
||||
@@ -37,6 +37,22 @@ export const Config = Effect.gen(function* () {
|
||||
};
|
||||
});
|
||||
|
||||
export const getMcpListOutput = (projectCwd: string) =>
|
||||
Effect.gen(function* () {
|
||||
const { claudeCodeExecutablePath } = yield* Config;
|
||||
const output = yield* Command.string(
|
||||
Command.make(
|
||||
"cd",
|
||||
projectCwd,
|
||||
"&&",
|
||||
claudeCodeExecutablePath,
|
||||
"mcp",
|
||||
"list",
|
||||
),
|
||||
);
|
||||
return output;
|
||||
});
|
||||
|
||||
export const getAvailableFeatures = (
|
||||
claudeCodeVersion: ClaudeCodeVersion.ClaudeCodeVersion | null,
|
||||
) => ({
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import { claudeCommandsDirPath } from "../../../lib/config/paths";
|
||||
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
|
||||
import { ClaudeCodeService } from "../services/ClaudeCodeService";
|
||||
import { FileSystem, Path } from "@effect/platform";
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const projectRepository = yield* ProjectRepository;
|
||||
const claudeCodeService = yield* ClaudeCodeService;
|
||||
const fs = yield* FileSystem.FileSystem;
|
||||
const path = yield* Path.Path;
|
||||
|
||||
const getClaudeCommands = (options: { projectId: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId } = options;
|
||||
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
|
||||
const globalCommands: string[] = yield* fs
|
||||
.readDirectory(claudeCommandsDirPath)
|
||||
.pipe(
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.filter((item) => item.endsWith(".md"))
|
||||
.map((item) => item.replace(/\.md$/, "")),
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
Effect.match({
|
||||
onSuccess: (items) => items,
|
||||
onFailure: () => {
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const projectCommands: string[] =
|
||||
project.meta.projectPath === null
|
||||
? []
|
||||
: yield* fs
|
||||
.readDirectory(
|
||||
path.resolve(project.meta.projectPath, ".claude", "commands"),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((items) =>
|
||||
items
|
||||
.filter((item) => item.endsWith(".md"))
|
||||
.map((item) => item.replace(/\.md$/, "")),
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
Effect.match({
|
||||
onSuccess: (items) => items,
|
||||
onFailure: () => {
|
||||
return [];
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
response: {
|
||||
globalCommands: globalCommands,
|
||||
projectCommands: projectCommands,
|
||||
defaultCommands: ["init", "compact"],
|
||||
},
|
||||
status: 200,
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
const getMcpListRoute = (options: { projectId: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId } = options;
|
||||
const servers = yield* claudeCodeService.getMcpList(projectId);
|
||||
return {
|
||||
response: { servers },
|
||||
status: 200,
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
return {
|
||||
getClaudeCommands,
|
||||
getMcpListRoute,
|
||||
};
|
||||
});
|
||||
|
||||
export type IClaudeCodeController = InferEffect<typeof LayerImpl>;
|
||||
export class ClaudeCodeController extends Context.Tag("ClaudeCodeController")<
|
||||
ClaudeCodeController,
|
||||
IClaudeCodeController
|
||||
>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import type { PermissionResponse } from "../../../../types/permissions";
|
||||
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
import { ClaudeCodePermissionService } from "../services/ClaudeCodePermissionService";
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const claudeCodePermissionService = yield* ClaudeCodePermissionService;
|
||||
|
||||
const permissionResponse = (options: {
|
||||
permissionResponse: PermissionResponse;
|
||||
}) =>
|
||||
Effect.sync(() => {
|
||||
const { permissionResponse } = options;
|
||||
|
||||
Effect.runFork(
|
||||
claudeCodePermissionService.respondToPermissionRequest(
|
||||
permissionResponse,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
response: {
|
||||
message: "Permission response received",
|
||||
},
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
return {
|
||||
permissionResponse,
|
||||
};
|
||||
});
|
||||
|
||||
export type IClaudeCodePermissionController = InferEffect<typeof LayerImpl>;
|
||||
export class ClaudeCodePermissionController extends Context.Tag(
|
||||
"ClaudeCodePermissionController",
|
||||
)<ClaudeCodePermissionController, IClaudeCodePermissionController>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import type { PublicSessionProcess } from "../../../../types/session-process";
|
||||
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
import { HonoConfigService } from "../../hono/services/HonoConfigService";
|
||||
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
|
||||
import { ClaudeCodeLifeCycleService } from "../services/ClaudeCodeLifeCycleService";
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const projectRepository = yield* ProjectRepository;
|
||||
const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService;
|
||||
const honoConfigService = yield* HonoConfigService;
|
||||
|
||||
const getSessionProcesses = () =>
|
||||
Effect.gen(function* () {
|
||||
const publicSessionProcesses =
|
||||
yield* claudeCodeLifeCycleService.getPublicSessionProcesses();
|
||||
|
||||
return {
|
||||
response: {
|
||||
processes: publicSessionProcesses.map(
|
||||
(p): PublicSessionProcess => ({
|
||||
id: p.def.sessionProcessId,
|
||||
projectId: p.def.projectId,
|
||||
sessionId: p.sessionId,
|
||||
status: p.type === "paused" ? "paused" : "running",
|
||||
}),
|
||||
),
|
||||
},
|
||||
status: 200,
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
const createSessionProcess = (options: {
|
||||
projectId: string;
|
||||
message: string;
|
||||
baseSessionId?: string | undefined;
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId, message, baseSessionId } = options;
|
||||
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
const config = yield* honoConfigService.getConfig();
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return {
|
||||
response: { error: "Project path not found" },
|
||||
status: 400 as const,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
|
||||
const result = yield* claudeCodeLifeCycleService.startTask({
|
||||
baseSession: {
|
||||
cwd: project.meta.projectPath,
|
||||
projectId,
|
||||
sessionId: baseSessionId,
|
||||
},
|
||||
config: config,
|
||||
message,
|
||||
});
|
||||
|
||||
const { sessionId } = yield* result.yieldSessionInitialized();
|
||||
|
||||
return {
|
||||
status: 201 as const,
|
||||
response: {
|
||||
sessionProcess: {
|
||||
id: result.sessionProcess.def.sessionProcessId,
|
||||
projectId,
|
||||
sessionId,
|
||||
},
|
||||
},
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
const continueSessionProcess = (options: {
|
||||
projectId: string;
|
||||
continueMessage: string;
|
||||
baseSessionId: string;
|
||||
sessionProcessId: string;
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId, continueMessage, baseSessionId, sessionProcessId } =
|
||||
options;
|
||||
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return {
|
||||
response: { error: "Project path not found" },
|
||||
status: 400,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
|
||||
const result = yield* claudeCodeLifeCycleService.continueTask({
|
||||
sessionProcessId,
|
||||
message: continueMessage,
|
||||
baseSessionId,
|
||||
});
|
||||
|
||||
return {
|
||||
response: {
|
||||
sessionProcess: {
|
||||
id: result.sessionProcess.def.sessionProcessId,
|
||||
projectId: result.sessionProcess.def.projectId,
|
||||
sessionId: baseSessionId,
|
||||
},
|
||||
},
|
||||
status: 200,
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
return {
|
||||
getSessionProcesses,
|
||||
createSessionProcess,
|
||||
continueSessionProcess,
|
||||
};
|
||||
});
|
||||
|
||||
export type IClaudeCodeSessionProcessController = InferEffect<typeof LayerImpl>;
|
||||
export class ClaudeCodeSessionProcessController extends Context.Tag(
|
||||
"ClaudeCodeSessionProcessController",
|
||||
)<ClaudeCodeSessionProcessController, IClaudeCodeSessionProcessController>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
@@ -342,6 +342,10 @@ const LayerImpl = Effect.gen(function* () {
|
||||
await sessionInitializedPromise.promise,
|
||||
awaitSessionFileCreated: async () =>
|
||||
await sessionFileCreatedPromise.promise,
|
||||
yieldSessionInitialized: () =>
|
||||
Effect.promise(() => sessionInitializedPromise.promise),
|
||||
yieldSessionFileCreated: () =>
|
||||
Effect.promise(() => sessionFileCreatedPromise.promise),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
41
src/server/core/claude-code/services/ClaudeCodeService.ts
Normal file
41
src/server/core/claude-code/services/ClaudeCodeService.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Context, Data, Effect, Layer } from "effect";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
|
||||
import { parseMcpListOutput } from "../functions/parseMcpListOutput";
|
||||
import * as ClaudeCode from "../models/ClaudeCode";
|
||||
|
||||
class ProjectPathNotFoundError extends Data.TaggedError(
|
||||
"ProjectPathNotFoundError",
|
||||
)<{
|
||||
projectId: string;
|
||||
}> {}
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const projectRepository = yield* ProjectRepository;
|
||||
|
||||
const getMcpList = (projectId: string) =>
|
||||
Effect.gen(function* () {
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
if (project.meta.projectPath === null) {
|
||||
return yield* Effect.fail(new ProjectPathNotFoundError({ projectId }));
|
||||
}
|
||||
|
||||
const output = yield* ClaudeCode.getMcpListOutput(
|
||||
project.meta.projectPath,
|
||||
);
|
||||
return parseMcpListOutput(output);
|
||||
});
|
||||
|
||||
return {
|
||||
getMcpList,
|
||||
};
|
||||
});
|
||||
|
||||
export type IClaudeCodeService = InferEffect<typeof LayerImpl>;
|
||||
|
||||
export class ClaudeCodeService extends Context.Tag("ClaudeCodeService")<
|
||||
ClaudeCodeService,
|
||||
IClaudeCodeService
|
||||
>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
108
src/server/core/events/presentation/SSEController.ts
Normal file
108
src/server/core/events/presentation/SSEController.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import type { SSEStreamingApi } from "hono/streaming";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
import { adaptInternalEventToSSE } from "../functions/adaptInternalEventToSSE";
|
||||
import { TypeSafeSSE } from "../functions/typeSafeSSE";
|
||||
import { EventBus } from "../services/EventBus";
|
||||
import type { InternalEventDeclaration } from "../types/InternalEventDeclaration";
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const eventBus = yield* EventBus;
|
||||
|
||||
const handleSSE = (rawStream: SSEStreamingApi) =>
|
||||
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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onPermissionRequested = (
|
||||
event: InternalEventDeclaration["permissionRequested"],
|
||||
) => {
|
||||
Effect.runFork(
|
||||
typeSafeSSE.writeSSE("permissionRequested", {
|
||||
permissionRequest: event.permissionRequest,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
yield* eventBus.on("sessionListChanged", onSessionListChanged);
|
||||
yield* eventBus.on("sessionChanged", onSessionChanged);
|
||||
yield* eventBus.on("sessionProcessChanged", onSessionProcessChanged);
|
||||
yield* eventBus.on("heartbeat", onHeartbeat);
|
||||
yield* eventBus.on("permissionRequested", onPermissionRequested);
|
||||
|
||||
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);
|
||||
yield* eventBus.off("permissionRequested", onPermissionRequested);
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
yield* Effect.promise(() => connectionPromise);
|
||||
});
|
||||
|
||||
return {
|
||||
handleSSE,
|
||||
};
|
||||
});
|
||||
|
||||
export type ISSEController = InferEffect<typeof LayerImpl>;
|
||||
export class SSEController extends Context.Tag("SSEController")<
|
||||
SSEController,
|
||||
ISSEController
|
||||
>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { homedir } from "node:os";
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
|
||||
import { getDirectoryListing } from "../functions/getDirectoryListing";
|
||||
import { getFileCompletion } from "../functions/getFileCompletion";
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const projectRepository = yield* ProjectRepository;
|
||||
|
||||
const getFileCompletionRoute = (options: {
|
||||
projectId: string;
|
||||
basePath: string;
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId, basePath } = options;
|
||||
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return {
|
||||
response: { error: "Project path not found" },
|
||||
status: 400,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
|
||||
const projectPath = project.meta.projectPath;
|
||||
|
||||
try {
|
||||
const result = yield* Effect.promise(() =>
|
||||
getFileCompletion(projectPath, basePath),
|
||||
);
|
||||
return {
|
||||
response: result,
|
||||
status: 200,
|
||||
} as const satisfies ControllerResponse;
|
||||
} catch (error) {
|
||||
console.error("File completion error:", error);
|
||||
return {
|
||||
response: { error: "Failed to get file completion" },
|
||||
status: 500,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
});
|
||||
|
||||
const getDirectoryListingRoute = (options: {
|
||||
currentPath?: string | undefined;
|
||||
}) =>
|
||||
Effect.promise(async () => {
|
||||
const { currentPath } = options;
|
||||
|
||||
const rootPath = "/";
|
||||
const defaultPath = homedir();
|
||||
|
||||
try {
|
||||
const targetPath = currentPath ?? defaultPath;
|
||||
const relativePath = targetPath.startsWith(rootPath)
|
||||
? targetPath.slice(rootPath.length)
|
||||
: targetPath;
|
||||
|
||||
const result = await getDirectoryListing(rootPath, relativePath);
|
||||
|
||||
return {
|
||||
response: result,
|
||||
status: 200,
|
||||
} as const satisfies ControllerResponse;
|
||||
} catch (error) {
|
||||
console.error("Directory listing error:", error);
|
||||
return {
|
||||
response: { error: "Failed to list directory" },
|
||||
status: 500,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
getFileCompletionRoute,
|
||||
getDirectoryListingRoute,
|
||||
};
|
||||
});
|
||||
|
||||
export type IFileSystemController = InferEffect<typeof LayerImpl>;
|
||||
export class FileSystemController extends Context.Tag("FileSystemController")<
|
||||
FileSystemController,
|
||||
IFileSystemController
|
||||
>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
139
src/server/core/git/presentation/GitController.ts
Normal file
139
src/server/core/git/presentation/GitController.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
|
||||
import { getBranches } from "../functions/getBranches";
|
||||
import { getCommits } from "../functions/getCommits";
|
||||
import { getDiff } from "../functions/getDiff";
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const projectRepository = yield* ProjectRepository;
|
||||
|
||||
const getGitBranches = (options: { projectId: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId } = options;
|
||||
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return {
|
||||
response: { error: "Project path not found" },
|
||||
status: 400,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
|
||||
const projectPath = project.meta.projectPath;
|
||||
|
||||
try {
|
||||
const result = yield* Effect.promise(() => getBranches(projectPath));
|
||||
return {
|
||||
response: result,
|
||||
status: 200,
|
||||
} as const satisfies ControllerResponse;
|
||||
} catch (error) {
|
||||
console.error("Get branches error:", error);
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
response: { error: error.message },
|
||||
status: 400,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
return {
|
||||
response: { error: "Failed to get branches" },
|
||||
status: 500,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
});
|
||||
|
||||
const getGitCommits = (options: { projectId: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId } = options;
|
||||
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return {
|
||||
response: { error: "Project path not found" },
|
||||
status: 400,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
|
||||
const projectPath = project.meta.projectPath;
|
||||
|
||||
try {
|
||||
const result = yield* Effect.promise(() => getCommits(projectPath));
|
||||
return {
|
||||
response: result,
|
||||
status: 200,
|
||||
} as const satisfies ControllerResponse;
|
||||
} catch (error) {
|
||||
console.error("Get commits error:", error);
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
response: { error: error.message },
|
||||
status: 400,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
return {
|
||||
response: { error: "Failed to get commits" },
|
||||
status: 500,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
});
|
||||
|
||||
const getGitDiff = (options: {
|
||||
projectId: string;
|
||||
fromRef: string;
|
||||
toRef: string;
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId, fromRef, toRef } = options;
|
||||
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
|
||||
try {
|
||||
if (project.meta.projectPath === null) {
|
||||
return {
|
||||
response: { error: "Project path not found" },
|
||||
status: 400,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
|
||||
const projectPath = project.meta.projectPath;
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
getDiff(projectPath, fromRef, toRef),
|
||||
);
|
||||
return {
|
||||
response: result,
|
||||
status: 200,
|
||||
} as const satisfies ControllerResponse;
|
||||
} catch (error) {
|
||||
console.error("Get diff error:", error);
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
response: { error: error.message },
|
||||
status: 400,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
return {
|
||||
response: { error: "Failed to get diff" },
|
||||
status: 500,
|
||||
} as const satisfies ControllerResponse;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
getGitBranches,
|
||||
getGitCommits,
|
||||
getGitDiff,
|
||||
};
|
||||
});
|
||||
|
||||
export type IGitController = InferEffect<typeof LayerImpl>;
|
||||
export class GitController extends Context.Tag("GitController")<
|
||||
GitController,
|
||||
IGitController
|
||||
>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
36
src/server/core/hono/services/HonoConfigService.ts
Normal file
36
src/server/core/hono/services/HonoConfigService.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Context, Effect, Layer, Ref } from "effect";
|
||||
import type { Config } from "../../../lib/config/config";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const configRef = yield* Ref.make<Config>({
|
||||
hideNoUserMessageSession: true,
|
||||
unifySameTitleSession: true,
|
||||
enterKeyBehavior: "shift-enter-send",
|
||||
permissionMode: "default",
|
||||
});
|
||||
|
||||
const setConfig = (newConfig: Config) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Ref.update(configRef, () => newConfig);
|
||||
});
|
||||
|
||||
const getConfig = () =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Ref.get(configRef);
|
||||
return config;
|
||||
});
|
||||
|
||||
return {
|
||||
getConfig,
|
||||
setConfig,
|
||||
};
|
||||
});
|
||||
|
||||
export type IHonoConfigService = InferEffect<typeof LayerImpl>;
|
||||
export class HonoConfigService extends Context.Tag("HonoConfigService")<
|
||||
HonoConfigService,
|
||||
IHonoConfigService
|
||||
>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { decodeProjectId } from "../../project/functions/id";
|
||||
|
||||
export interface McpServer {
|
||||
name: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
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[] = [];
|
||||
const lines = output.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip header lines and status indicators
|
||||
if (line.includes("Checking MCP server health") || line.trim() === "") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse lines like "context7: npx -y @upstash/context7-mcp@latest - ✓ Connected"
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex > 0) {
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
const rest = line.substring(colonIndex + 1).trim();
|
||||
|
||||
// Remove status indicators (✓ Connected, ✗ Failed, etc.)
|
||||
const command = rest.replace(/\s*-\s*[✓✗].*$/, "").trim();
|
||||
|
||||
if (name && command) {
|
||||
servers.push({ name, command });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { servers };
|
||||
} catch (error) {
|
||||
console.error("Failed to get MCP list:", error);
|
||||
// Return empty list if command fails
|
||||
return { servers: [] };
|
||||
}
|
||||
};
|
||||
164
src/server/core/project/presentation/ProjectController.ts
Normal file
164
src/server/core/project/presentation/ProjectController.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
import { computeClaudeProjectFilePath } from "../../claude-code/functions/computeClaudeProjectFilePath";
|
||||
import { ClaudeCodeLifeCycleService } from "../../claude-code/services/ClaudeCodeLifeCycleService";
|
||||
import { HonoConfigService } from "../../hono/services/HonoConfigService";
|
||||
import { SessionRepository } from "../../session/infrastructure/SessionRepository";
|
||||
import { encodeProjectId } from "../functions/id";
|
||||
import { ProjectRepository } from "../infrastructure/ProjectRepository";
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const projectRepository = yield* ProjectRepository;
|
||||
const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService;
|
||||
const honoConfigService = yield* HonoConfigService;
|
||||
const sessionRepository = yield* SessionRepository;
|
||||
|
||||
const getProjects = () =>
|
||||
Effect.gen(function* () {
|
||||
const { projects } = yield* projectRepository.getProjects();
|
||||
return {
|
||||
status: 200,
|
||||
response: { projects },
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
const getProject = (options: { projectId: string; cursor?: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId, cursor } = options;
|
||||
|
||||
const config = yield* honoConfigService.getConfig();
|
||||
|
||||
const { project } = yield* projectRepository.getProject(projectId);
|
||||
const { sessions } = yield* sessionRepository.getSessions(projectId, {
|
||||
cursor,
|
||||
});
|
||||
|
||||
let filteredSessions = sessions;
|
||||
|
||||
// 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) {
|
||||
sessionMap.set(title, session);
|
||||
}
|
||||
} 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());
|
||||
}
|
||||
|
||||
const hasMore = sessions.length >= 20;
|
||||
return {
|
||||
status: 200,
|
||||
response: {
|
||||
project,
|
||||
sessions: filteredSessions,
|
||||
nextCursor: hasMore ? sessions.at(-1)?.id : undefined,
|
||||
},
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
const getProjectLatestSession = (options: { projectId: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId } = options;
|
||||
const { sessions } = yield* sessionRepository.getSessions(projectId, {
|
||||
maxCount: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
response: {
|
||||
latestSession: sessions[0] ?? null,
|
||||
},
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
const createProject = (options: { projectPath: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectPath } = options;
|
||||
|
||||
// No project validation needed - startTask will create a new project
|
||||
// if it doesn't exist when running /init command
|
||||
const claudeProjectFilePath = computeClaudeProjectFilePath(projectPath);
|
||||
const projectId = encodeProjectId(claudeProjectFilePath);
|
||||
const config = yield* honoConfigService.getConfig();
|
||||
|
||||
const result = yield* claudeCodeLifeCycleService.startTask({
|
||||
baseSession: {
|
||||
cwd: projectPath,
|
||||
projectId,
|
||||
sessionId: undefined,
|
||||
},
|
||||
config: config,
|
||||
message: "/init",
|
||||
});
|
||||
|
||||
const { sessionId } = yield* result.yieldSessionFileCreated();
|
||||
|
||||
return {
|
||||
status: 201,
|
||||
response: {
|
||||
projectId,
|
||||
sessionId,
|
||||
},
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
return {
|
||||
getProjects,
|
||||
getProject,
|
||||
getProjectLatestSession,
|
||||
createProject,
|
||||
};
|
||||
});
|
||||
|
||||
export type IProjectController = InferEffect<typeof LayerImpl>;
|
||||
export class ProjectController extends Context.Tag("ProjectController")<
|
||||
ProjectController,
|
||||
IProjectController
|
||||
>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
35
src/server/core/session/presentation/SessionController.ts
Normal file
35
src/server/core/session/presentation/SessionController.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Context, Effect, Layer } from "effect";
|
||||
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
|
||||
import type { InferEffect } from "../../../lib/effect/types";
|
||||
import { SessionRepository } from "../../session/infrastructure/SessionRepository";
|
||||
|
||||
const LayerImpl = Effect.gen(function* () {
|
||||
const sessionRepository = yield* SessionRepository;
|
||||
|
||||
const getSession = (options: { projectId: string; sessionId: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const { projectId, sessionId } = options;
|
||||
|
||||
const { session } = yield* sessionRepository.getSession(
|
||||
projectId,
|
||||
sessionId,
|
||||
);
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
response: { session },
|
||||
} as const satisfies ControllerResponse;
|
||||
});
|
||||
|
||||
return {
|
||||
getSession,
|
||||
};
|
||||
});
|
||||
|
||||
export type ISessionController = InferEffect<typeof LayerImpl>;
|
||||
export class SessionController extends Context.Tag("SessionController")<
|
||||
SessionController,
|
||||
ISessionController
|
||||
>() {
|
||||
static Live = Layer.effect(this, LayerImpl);
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import type { CommandExecutor, FileSystem, Path } from "@effect/platform";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Effect, Runtime } from "effect";
|
||||
@@ -8,28 +5,21 @@ import { setCookie } from "hono/cookie";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import prexit from "prexit";
|
||||
import { z } from "zod";
|
||||
import type { PublicSessionProcess } from "../../types/session-process";
|
||||
import { computeClaudeProjectFilePath } from "../core/claude-code/functions/computeClaudeProjectFilePath";
|
||||
import { ClaudeCodeController } from "../core/claude-code/presentation/ClaudeCodeController";
|
||||
import { ClaudeCodePermissionController } from "../core/claude-code/presentation/ClaudeCodePermissionController";
|
||||
import { ClaudeCodeSessionProcessController } from "../core/claude-code/presentation/ClaudeCodeSessionProcessController";
|
||||
import { ClaudeCodeLifeCycleService } from "../core/claude-code/services/ClaudeCodeLifeCycleService";
|
||||
import { ClaudeCodePermissionService } from "../core/claude-code/services/ClaudeCodePermissionService";
|
||||
import { getDirectoryListing } from "../core/directory-browser/functions/getDirectoryListing";
|
||||
import { adaptInternalEventToSSE } from "../core/events/functions/adaptInternalEventToSSE";
|
||||
import { TypeSafeSSE } from "../core/events/functions/typeSafeSSE";
|
||||
import { EventBus } from "../core/events/services/EventBus";
|
||||
import type { InternalEventDeclaration } from "../core/events/types/InternalEventDeclaration";
|
||||
import { getFileCompletion } from "../core/file-completion/functions/getFileCompletion";
|
||||
import { getBranches } from "../core/git/functions/getBranches";
|
||||
import { getCommits } from "../core/git/functions/getCommits";
|
||||
import { getDiff } from "../core/git/functions/getDiff";
|
||||
import { getMcpList } from "../core/mcp/functions/getMcpList";
|
||||
import { encodeProjectId } from "../core/project/functions/id";
|
||||
import { ProjectRepository } from "../core/project/infrastructure/ProjectRepository";
|
||||
import type { ProjectMetaService } from "../core/project/services/ProjectMetaService";
|
||||
import { SessionRepository } from "../core/session/infrastructure/SessionRepository";
|
||||
import { SSEController } from "../core/events/presentation/SSEController";
|
||||
import { FileSystemController } from "../core/file-system/presentation/FileSystemController";
|
||||
import { GitController } from "../core/git/presentation/GitController";
|
||||
import { HonoConfigService } from "../core/hono/services/HonoConfigService";
|
||||
import { ProjectController } from "../core/project/presentation/ProjectController";
|
||||
import type { VirtualConversationDatabase } from "../core/session/infrastructure/VirtualConversationDatabase";
|
||||
import { SessionController } from "../core/session/presentation/SessionController";
|
||||
import type { SessionMetaService } from "../core/session/services/SessionMetaService";
|
||||
import { configSchema } from "../lib/config/config";
|
||||
import { claudeCommandsDirPath } from "../lib/config/paths";
|
||||
import { effectToResponse } from "../lib/effect/toEffectResponse";
|
||||
import { env } from "../lib/env";
|
||||
import type { HonoAppType } from "./app";
|
||||
import { InitializeService } from "./initialize";
|
||||
@@ -37,15 +27,24 @@ import { configMiddleware } from "./middleware/config.middleware";
|
||||
|
||||
export const routes = (app: HonoAppType) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionRepository = yield* SessionRepository;
|
||||
const projectRepository = yield* ProjectRepository;
|
||||
// controllers
|
||||
const projectController = yield* ProjectController;
|
||||
const sessionController = yield* SessionController;
|
||||
const gitController = yield* GitController;
|
||||
const claudeCodeSessionProcessController =
|
||||
yield* ClaudeCodeSessionProcessController;
|
||||
const claudeCodePermissionController =
|
||||
yield* ClaudeCodePermissionController;
|
||||
const sseController = yield* SSEController;
|
||||
const fileSystemController = yield* FileSystemController;
|
||||
const claudeCodeController = yield* ClaudeCodeController;
|
||||
|
||||
// services
|
||||
const honoConfigService = yield* HonoConfigService;
|
||||
const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService;
|
||||
const claudeCodePermissionService = yield* ClaudeCodePermissionService;
|
||||
const initializeService = yield* InitializeService;
|
||||
const eventBus = yield* EventBus;
|
||||
|
||||
const runtime = yield* Effect.runtime<
|
||||
| ProjectMetaService
|
||||
| SessionMetaService
|
||||
| VirtualConversationDatabase
|
||||
| FileSystem.FileSystem
|
||||
@@ -65,7 +64,13 @@ export const routes = (app: HonoAppType) =>
|
||||
app
|
||||
// middleware
|
||||
.use(configMiddleware)
|
||||
.use(async (_c, next) => {
|
||||
.use(async (c, next) => {
|
||||
await Effect.runPromise(
|
||||
honoConfigService.setConfig({
|
||||
...c.get("config"),
|
||||
}),
|
||||
);
|
||||
|
||||
await next();
|
||||
})
|
||||
|
||||
@@ -86,16 +91,35 @@ export const routes = (app: HonoAppType) =>
|
||||
});
|
||||
})
|
||||
|
||||
/**
|
||||
* ProjectController Routes
|
||||
*/
|
||||
|
||||
.get("/projects", async (c) => {
|
||||
const program = Effect.gen(function* () {
|
||||
return yield* projectRepository.getProjects();
|
||||
});
|
||||
|
||||
const { projects } = await Runtime.runPromise(runtime)(program);
|
||||
|
||||
return c.json({ projects });
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
projectController.getProjects(),
|
||||
);
|
||||
return response;
|
||||
})
|
||||
|
||||
.get(
|
||||
"/projects/:projectId",
|
||||
zValidator("query", z.object({ cursor: z.string().optional() })),
|
||||
async (c) => {
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
projectController
|
||||
.getProject({
|
||||
...c.req.param(),
|
||||
...c.req.valid("query"),
|
||||
})
|
||||
.pipe(Effect.provide(runtime)),
|
||||
);
|
||||
return response;
|
||||
},
|
||||
)
|
||||
|
||||
.post(
|
||||
"/projects",
|
||||
zValidator(
|
||||
@@ -105,369 +129,68 @@ export const routes = (app: HonoAppType) =>
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { projectPath } = c.req.valid("json");
|
||||
|
||||
// No project validation needed - startTask will create a new project
|
||||
// if it doesn't exist when running /init command
|
||||
const claudeProjectFilePath =
|
||||
computeClaudeProjectFilePath(projectPath);
|
||||
const projectId = encodeProjectId(claudeProjectFilePath);
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const result = yield* claudeCodeLifeCycleService.startTask({
|
||||
baseSession: {
|
||||
cwd: projectPath,
|
||||
projectId,
|
||||
sessionId: undefined,
|
||||
},
|
||||
config: c.get("config"),
|
||||
message: "/init",
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
status: 200 as const,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await Runtime.runPromise(runtime)(program);
|
||||
|
||||
if (result.status === 200) {
|
||||
const { sessionId } =
|
||||
await result.result.awaitSessionFileCreated();
|
||||
|
||||
return c.json({
|
||||
projectId: result.result.sessionProcess.def.projectId,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ error: "Failed to create project" }, 500);
|
||||
},
|
||||
)
|
||||
|
||||
.get(
|
||||
"/directory-browser",
|
||||
zValidator(
|
||||
"query",
|
||||
z.object({
|
||||
currentPath: z.string().optional(),
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
projectController.createProject({
|
||||
...c.req.valid("json"),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { currentPath } = c.req.valid("query");
|
||||
const rootPath = "/";
|
||||
const defaultPath = homedir();
|
||||
|
||||
try {
|
||||
const targetPath = currentPath || defaultPath;
|
||||
const relativePath = targetPath.startsWith(rootPath)
|
||||
? targetPath.slice(rootPath.length)
|
||||
: targetPath;
|
||||
|
||||
const result = await getDirectoryListing(rootPath, relativePath);
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
console.error("Directory listing error:", error);
|
||||
if (error instanceof Error) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
}
|
||||
return c.json({ error: "Failed to list directory" }, 500);
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
.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");
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const { project } =
|
||||
yield* projectRepository.getProject(projectId);
|
||||
const { sessions } = yield* sessionRepository.getSessions(
|
||||
projectId,
|
||||
{ cursor },
|
||||
);
|
||||
|
||||
let filteredSessions = sessions;
|
||||
|
||||
// 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
|
||||
) {
|
||||
sessionMap.set(title, session);
|
||||
}
|
||||
} 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());
|
||||
}
|
||||
|
||||
const hasMore = sessions.length >= 20;
|
||||
return {
|
||||
project,
|
||||
sessions: filteredSessions,
|
||||
nextCursor: hasMore ? sessions.at(-1)?.id : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await Runtime.runPromise(runtime)(program);
|
||||
return c.json(result);
|
||||
return response;
|
||||
},
|
||||
)
|
||||
|
||||
.get("/projects/:projectId/latest-session", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const { sessions } = yield* sessionRepository.getSessions(
|
||||
projectId,
|
||||
{ maxCount: 1 },
|
||||
);
|
||||
|
||||
return {
|
||||
latestSession: sessions[0] ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
const result = await Runtime.runPromise(runtime)(program);
|
||||
return c.json(result);
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
projectController
|
||||
.getProjectLatestSession({
|
||||
...c.req.param(),
|
||||
})
|
||||
.pipe(Effect.provide(runtime)),
|
||||
);
|
||||
return response;
|
||||
})
|
||||
|
||||
/**
|
||||
* SessionController Routes
|
||||
*/
|
||||
|
||||
.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,
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
sessionController
|
||||
.getSession({ ...c.req.param() })
|
||||
.pipe(Effect.provide(runtime)),
|
||||
);
|
||||
return { session };
|
||||
});
|
||||
|
||||
const result = await Runtime.runPromise(runtime)(program);
|
||||
return c.json(result);
|
||||
return response;
|
||||
})
|
||||
|
||||
.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);
|
||||
})
|
||||
/**
|
||||
* GitController Routes
|
||||
*/
|
||||
|
||||
.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),
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
gitController
|
||||
.getGitBranches({
|
||||
...c.req.param(),
|
||||
})
|
||||
.pipe(Effect.provide(runtime)),
|
||||
);
|
||||
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);
|
||||
return response;
|
||||
})
|
||||
|
||||
.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),
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
gitController
|
||||
.getGitCommits({
|
||||
...c.req.param(),
|
||||
})
|
||||
.pipe(Effect.provide(runtime)),
|
||||
);
|
||||
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);
|
||||
return response;
|
||||
})
|
||||
|
||||
.post(
|
||||
@@ -480,64 +203,55 @@ export const routes = (app: HonoAppType) =>
|
||||
}),
|
||||
),
|
||||
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),
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
gitController
|
||||
.getGitDiff({
|
||||
...c.req.param(),
|
||||
...c.req.valid("json"),
|
||||
})
|
||||
.pipe(Effect.provide(runtime)),
|
||||
);
|
||||
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);
|
||||
return response;
|
||||
},
|
||||
)
|
||||
|
||||
.get("/projects/:projectId/mcp/list", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { servers } = await getMcpList(projectId);
|
||||
return c.json({ servers });
|
||||
/**
|
||||
* ClaudeCodeController Routes
|
||||
*/
|
||||
|
||||
.get("/projects/:projectId/claude-commands", async (c) => {
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
claudeCodeController.getClaudeCommands({
|
||||
...c.req.param(),
|
||||
}),
|
||||
);
|
||||
return response;
|
||||
})
|
||||
|
||||
.get("/cc/session-processes", async (c) => {
|
||||
const publicProcesses = await Runtime.runPromise(runtime)(
|
||||
claudeCodeLifeCycleService.getPublicSessionProcesses(),
|
||||
.get("/projects/:projectId/mcp/list", async (c) => {
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
claudeCodeController
|
||||
.getMcpListRoute({
|
||||
...c.req.param(),
|
||||
})
|
||||
.pipe(Effect.provide(runtime)),
|
||||
);
|
||||
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",
|
||||
}),
|
||||
),
|
||||
});
|
||||
return response;
|
||||
})
|
||||
|
||||
/**
|
||||
* ClaudeCodeSessionProcessController Routes
|
||||
*/
|
||||
|
||||
.get("/cc/session-processes", async (c) => {
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
claudeCodeSessionProcessController.getSessionProcesses(),
|
||||
);
|
||||
return response;
|
||||
})
|
||||
|
||||
// new or resume
|
||||
@@ -552,51 +266,13 @@ export const routes = (app: HonoAppType) =>
|
||||
}),
|
||||
),
|
||||
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) {
|
||||
const { sessionId } =
|
||||
await result.result.awaitSessionInitialized();
|
||||
|
||||
return c.json({
|
||||
sessionProcess: {
|
||||
id: result.result.sessionProcess.def.sessionProcessId,
|
||||
projectId: result.result.sessionProcess.def.projectId,
|
||||
sessionId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ error: result.error }, result.status);
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
claudeCodeSessionProcessController.createSessionProcess(
|
||||
c.req.valid("json"),
|
||||
),
|
||||
);
|
||||
return response;
|
||||
},
|
||||
)
|
||||
|
||||
@@ -612,45 +288,16 @@ export const routes = (app: HonoAppType) =>
|
||||
}),
|
||||
),
|
||||
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);
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
claudeCodeSessionProcessController
|
||||
.continueSessionProcess({
|
||||
...c.req.param(),
|
||||
...c.req.valid("json"),
|
||||
})
|
||||
.pipe(Effect.provide(runtime)),
|
||||
);
|
||||
return response;
|
||||
},
|
||||
)
|
||||
|
||||
@@ -666,6 +313,10 @@ export const routes = (app: HonoAppType) =>
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* ClaudeCodePermissionController Routes
|
||||
*/
|
||||
|
||||
.post(
|
||||
"/cc/permission-response",
|
||||
zValidator(
|
||||
@@ -676,135 +327,79 @@ export const routes = (app: HonoAppType) =>
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const permissionResponse = c.req.valid("json");
|
||||
Effect.runFork(
|
||||
claudeCodePermissionService.respondToPermissionRequest(
|
||||
permissionResponse,
|
||||
),
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
claudeCodePermissionController.permissionResponse({
|
||||
permissionResponse: c.req.valid("json"),
|
||||
}),
|
||||
);
|
||||
return c.json({ message: "Permission response received" });
|
||||
return response;
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* SSEController Routes
|
||||
*/
|
||||
|
||||
.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(),
|
||||
}),
|
||||
await Runtime.runPromise(runtime)(
|
||||
sseController
|
||||
.handleSSE(rawStream)
|
||||
.pipe(Effect.provide(TypeSafeSSE.make(rawStream))),
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onPermissionRequested = (
|
||||
event: InternalEventDeclaration["permissionRequested"],
|
||||
) => {
|
||||
Effect.runFork(
|
||||
typeSafeSSE.writeSSE("permissionRequested", {
|
||||
permissionRequest: event.permissionRequest,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
yield* eventBus.on("sessionListChanged", onSessionListChanged);
|
||||
yield* eventBus.on("sessionChanged", onSessionChanged);
|
||||
yield* eventBus.on(
|
||||
"sessionProcessChanged",
|
||||
onSessionProcessChanged,
|
||||
);
|
||||
yield* eventBus.on("heartbeat", onHeartbeat);
|
||||
yield* eventBus.on(
|
||||
"permissionRequested",
|
||||
onPermissionRequested,
|
||||
);
|
||||
|
||||
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);
|
||||
yield* eventBus.off(
|
||||
"permissionRequested",
|
||||
onPermissionRequested,
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
connectionPromise,
|
||||
};
|
||||
});
|
||||
|
||||
const { connectionPromise } = await Runtime.runPromise(runtime)(
|
||||
handleSSE.pipe(Effect.provide(TypeSafeSSE.make(rawStream))),
|
||||
);
|
||||
|
||||
await connectionPromise;
|
||||
},
|
||||
async (err) => {
|
||||
console.error("Streaming error:", err);
|
||||
},
|
||||
);
|
||||
})
|
||||
|
||||
/**
|
||||
* FileSystemController Routes
|
||||
*/
|
||||
|
||||
.get(
|
||||
"/fs/file-completion",
|
||||
zValidator(
|
||||
"query",
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
basePath: z.string().optional().default("/"),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
fileSystemController.getFileCompletionRoute({
|
||||
...c.req.valid("query"),
|
||||
}),
|
||||
);
|
||||
|
||||
return response;
|
||||
},
|
||||
)
|
||||
|
||||
.get(
|
||||
"/fs/directory-browser",
|
||||
zValidator(
|
||||
"query",
|
||||
z.object({
|
||||
currentPath: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const response = await effectToResponse(
|
||||
c,
|
||||
fileSystemController.getDirectoryListingRoute({
|
||||
...c.req.valid("query"),
|
||||
}),
|
||||
);
|
||||
return response;
|
||||
},
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
43
src/server/lib/effect/toEffectResponse.ts
Normal file
43
src/server/lib/effect/toEffectResponse.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Effect } from "effect";
|
||||
import type { Context, Input } from "hono";
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import type { HonoContext } from "../../hono/app";
|
||||
|
||||
export type ControllerResponse = {
|
||||
status: ContentfulStatusCode;
|
||||
response: object;
|
||||
};
|
||||
|
||||
declare const dummyCtx: Context<HonoContext, string, Input>;
|
||||
const dummyJson = <S extends ContentfulStatusCode, T extends object>(
|
||||
s: S,
|
||||
t: T,
|
||||
) => dummyCtx.json(t, s);
|
||||
type ResponseType<
|
||||
S extends ContentfulStatusCode,
|
||||
T extends object,
|
||||
> = ReturnType<typeof dummyJson<S, T>>;
|
||||
|
||||
export const effectToResponse = async <
|
||||
const P extends string,
|
||||
const I extends Input,
|
||||
const CR extends ControllerResponse,
|
||||
const E,
|
||||
Ret = CR extends infer I
|
||||
? I extends { status: infer S; response: infer T }
|
||||
? S extends ContentfulStatusCode
|
||||
? T extends object
|
||||
? ResponseType<S, T>
|
||||
: never
|
||||
: never
|
||||
: never
|
||||
: never,
|
||||
>(
|
||||
ctx: Context<HonoContext, P, I>,
|
||||
effect: Effect.Effect<CR, E, never>,
|
||||
) => {
|
||||
const result = await Effect.runPromise(effect);
|
||||
const result2 = ctx.json(result.response, result.status);
|
||||
|
||||
return result2 as Ret;
|
||||
};
|
||||
Reference in New Issue
Block a user