refactor: add platform effects

This commit is contained in:
d-kimsuon
2025-10-18 02:34:33 +09:00
parent 4de41129fe
commit e45a841656
29 changed files with 445 additions and 315 deletions

View File

@@ -1,5 +1,5 @@
import { NodeContext } from "@effect/platform-node";
import { Effect, Layer } from "effect";
import { Effect } 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";
@@ -14,7 +14,9 @@ import { FileWatcherService } from "../../../server/core/events/services/fileWat
import { FileSystemController } from "../../../server/core/file-system/presentation/FileSystemController";
import { GitController } from "../../../server/core/git/presentation/GitController";
import { GitService } from "../../../server/core/git/services/GitService";
import { HonoConfigService } from "../../../server/core/hono/services/HonoConfigService";
import { ApplicationContext } from "../../../server/core/platform/services/ApplicationContext";
import { EnvService } from "../../../server/core/platform/services/EnvService";
import { UserConfigService } from "../../../server/core/platform/services/UserConfigService";
import { ProjectRepository } from "../../../server/core/project/infrastructure/ProjectRepository";
import { ProjectController } from "../../../server/core/project/presentation/ProjectController";
import { ProjectMetaService } from "../../../server/core/project/services/ProjectMetaService";
@@ -28,22 +30,10 @@ 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,
);
const repositoryLayer = Layer.mergeAll(
ProjectRepository.Live,
SessionRepository.Live,
);
await Effect.runPromise(
program.pipe(
program
// 依存の浅い順にコンテナに pipe する必要がある
.pipe(
/** Presentation */
Effect.provide(ProjectController.Live),
Effect.provide(SessionController.Live),
@@ -53,31 +43,34 @@ await Effect.runPromise(
Effect.provide(ClaudeCodePermissionController.Live),
Effect.provide(FileSystemController.Live),
Effect.provide(SSEController.Live),
)
.pipe(
/** Application */
Effect.provide(InitializeService.Live),
Effect.provide(FileWatcherService.Live),
)
.pipe(
/** Domain */
Effect.provide(ClaudeCodeLifeCycleService.Live),
Effect.provide(ClaudeCodePermissionService.Live),
Effect.provide(ClaudeCodeSessionProcessService.Live),
Effect.provide(ClaudeCodeService.Live),
Effect.provide(GitService.Live),
// Shared Services
Effect.provide(FileWatcherService.Live),
Effect.provide(EventBus.Live),
Effect.provide(HonoConfigService.Live),
)
.pipe(
/** Infrastructure */
// Repository
Effect.provide(repositoryLayer),
// StorageService
Effect.provide(storageLayer),
Effect.provide(ProjectRepository.Live),
Effect.provide(SessionRepository.Live),
Effect.provide(ProjectMetaService.Live),
Effect.provide(SessionMetaService.Live),
Effect.provide(VirtualConversationDatabase.Live),
)
.pipe(
/** Platform */
Effect.provide(ApplicationContext.Live),
Effect.provide(UserConfigService.Live),
Effect.provide(EventBus.Live),
Effect.provide(EnvService.Live),
Effect.provide(NodeContext.layer),
),
);

View File

@@ -6,7 +6,7 @@ import {
import { useCallback } from "react";
import { honoClient } from "../../lib/api/client";
import { configQuery } from "../../lib/api/queries";
import type { Config } from "../../server/lib/config/config";
import type { UserConfig } from "../../server/lib/config/config";
export const useConfig = () => {
const queryClient = useQueryClient();
@@ -16,7 +16,7 @@ export const useConfig = () => {
queryFn: configQuery.queryFn,
});
const updateConfigMutation = useMutation({
mutationFn: async (config: Config) => {
mutationFn: async (config: UserConfig) => {
const response = await honoClient.api.config.$put({
json: config,
});
@@ -32,7 +32,7 @@ export const useConfig = () => {
return {
config: data?.config,
updateConfig: useCallback(
(config: Config) => {
(config: UserConfig) => {
updateConfigMutation.mutate(config);
},
[updateConfigMutation],

View File

@@ -1,7 +1,10 @@
import { hc } from "hono/client";
import type { RouteType } from "../../server/hono/route";
import { env } from "../../server/lib/env";
export const honoClient = hc<RouteType>(
typeof window === "undefined" ? `http://localhost:${env.get("PORT")}/` : "/",
typeof window === "undefined"
? // biome-ignore lint/complexity/useLiteralKeys: allow here
// biome-ignore lint/style/noProcessEnv: allow here
`http://localhost:${process.env["PORT"]}/`
: "/",
);

View File

@@ -1,24 +1,10 @@
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
describe("computeClaudeProjectFilePath", () => {
const TEST_GLOBAL_CLAUDE_DIR = "/test/mock/claude";
const TEST_PROJECTS_DIR = path.join(TEST_GLOBAL_CLAUDE_DIR, "projects");
beforeEach(async () => {
vi.resetModules();
vi.doMock("../../../lib/env", () => ({
env: {
get: (key: string) => {
if (key === "GLOBAL_CLAUDE_DIR") {
return TEST_GLOBAL_CLAUDE_DIR;
}
return undefined;
},
},
}));
});
it("プロジェクトパスからClaudeの設定ディレクトリパスを計算する", async () => {
const { computeClaudeProjectFilePath } = await import(
"./computeClaudeProjectFilePath"
@@ -27,7 +13,10 @@ describe("computeClaudeProjectFilePath", () => {
const projectPath = "/home/me/dev/example";
const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`;
const result = computeClaudeProjectFilePath(projectPath);
const result = computeClaudeProjectFilePath({
projectPath,
claudeProjectsDirPath: TEST_PROJECTS_DIR,
});
expect(result).toBe(expected);
});
@@ -40,7 +29,10 @@ describe("computeClaudeProjectFilePath", () => {
const projectPath = "/home/me/dev/example/";
const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`;
const result = computeClaudeProjectFilePath(projectPath);
const result = computeClaudeProjectFilePath({
projectPath,
claudeProjectsDirPath: TEST_PROJECTS_DIR,
});
expect(result).toBe(expected);
});

View File

@@ -1,10 +1,13 @@
import { Path } from "@effect/platform";
import { Effect } from "effect";
import { claudeProjectsDirPath } from "../../../lib/config/paths";
export const computeClaudeProjectFilePath = (projectPath: string) =>
export const computeClaudeProjectFilePath = (options: {
projectPath: string;
claudeProjectsDirPath: string;
}) =>
Effect.gen(function* () {
const path = yield* Path.Path;
const { projectPath, claudeProjectsDirPath } = options;
return path.join(
claudeProjectsDirPath,

View File

@@ -1,6 +1,7 @@
import { CommandExecutor, Path } from "@effect/platform";
import { NodeContext } from "@effect/platform-node";
import { Effect, Layer } from "effect";
import { EnvService } from "../../platform/services/EnvService";
import * as ClaudeCode from "./ClaudeCode";
describe("ClaudeCode.Config", () => {
@@ -19,6 +20,7 @@ describe("ClaudeCode.Config", () => {
const config = await Effect.runPromise(
ClaudeCode.Config.pipe(
Effect.provide(EnvService.Live),
Effect.provide(Path.layer),
Effect.provide(CommandExecutorTest),
),

View File

@@ -1,7 +1,7 @@
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 { EnvService } from "../../platform/services/EnvService";
import * as ClaudeCodeVersion from "./ClaudeCodeVersion";
type CCQuery = typeof originalQuery;
@@ -10,8 +10,9 @@ type CCQueryOptions = NonNullable<Parameters<CCQuery>[0]["options"]>;
export const Config = Effect.gen(function* () {
const path = yield* Path.Path;
const envService = yield* EnvService;
const specifiedExecutablePath = env.get(
const specifiedExecutablePath = yield* envService.getEnv(
"CLAUDE_CODE_VIEWER_CC_EXECUTABLE_PATH",
);
@@ -21,7 +22,7 @@ export const Config = Effect.gen(function* () {
: (yield* Command.string(
Command.make("which", "claude").pipe(
Command.env({
PATH: env.get("PATH"),
PATH: yield* envService.getEnv("PATH"),
}),
Command.runInShell(true),
),

View File

@@ -1,14 +1,15 @@
import { FileSystem, Path } from "@effect/platform";
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 { ApplicationContext } from "../../platform/services/ApplicationContext";
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
import { ClaudeCodeService } from "../services/ClaudeCodeService";
const LayerImpl = Effect.gen(function* () {
const projectRepository = yield* ProjectRepository;
const claudeCodeService = yield* ClaudeCodeService;
const context = yield* ApplicationContext;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
@@ -19,7 +20,7 @@ const LayerImpl = Effect.gen(function* () {
const { project } = yield* projectRepository.getProject(projectId);
const globalCommands: string[] = yield* fs
.readDirectory(claudeCommandsDirPath)
.readDirectory(context.claudeCodePaths.claudeCommandsDirPath)
.pipe(
Effect.map((items) =>
items

View File

@@ -2,14 +2,14 @@ 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 { UserConfigService } from "../../platform/services/UserConfigService";
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 userConfigService = yield* UserConfigService;
const getSessionProcesses = () =>
Effect.gen(function* () {
@@ -40,7 +40,7 @@ const LayerImpl = Effect.gen(function* () {
const { projectId, message, baseSessionId } = options;
const { project } = yield* projectRepository.getProject(projectId);
const config = yield* honoConfigService.getConfig();
const userConfig = yield* userConfigService.getUserConfig();
if (project.meta.projectPath === null) {
return {
@@ -55,7 +55,7 @@ const LayerImpl = Effect.gen(function* () {
projectId,
sessionId: baseSessionId,
},
config: config,
userConfig,
message,
});

View File

@@ -4,9 +4,10 @@ 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 "../../../lib/config/config";
import type { UserConfig } from "../../../lib/config/config";
import type { InferEffect } from "../../../lib/effect/types";
import { EventBus } from "../../events/services/EventBus";
import type { EnvService } from "../../platform/services/EnvService";
import { SessionRepository } from "../../session/infrastructure/SessionRepository";
import { VirtualConversationDatabase } from "../../session/infrastructure/VirtualConversationDatabase";
import type { SessionMetaService } from "../../session/services/SessionMetaService";
@@ -36,6 +37,7 @@ const LayerImpl = Effect.gen(function* () {
| VirtualConversationDatabase
| SessionMetaService
| ClaudeCodePermissionService
| EnvService
>();
const continueTask = (options: {
@@ -78,7 +80,7 @@ const LayerImpl = Effect.gen(function* () {
};
const startTask = (options: {
config: Config;
userConfig: UserConfig;
baseSession: {
cwd: string;
projectId: string;
@@ -86,7 +88,7 @@ const LayerImpl = Effect.gen(function* () {
};
message: string;
}) => {
const { baseSession, message, config } = options;
const { baseSession, message, userConfig } = options;
return Effect.gen(function* () {
const {
@@ -258,7 +260,7 @@ const LayerImpl = Effect.gen(function* () {
const permissionOptions =
yield* permissionService.createCanUseToolRelatedOptions({
taskId: task.def.taskId,
config,
userConfig,
sessionId: task.def.baseSessionId,
});

View File

@@ -5,7 +5,7 @@ import type {
PermissionRequest,
PermissionResponse,
} from "../../../../types/permissions";
import type { Config } from "../../../lib/config/config";
import type { UserConfig } from "../../../lib/config/config";
import type { InferEffect } from "../../../lib/effect/types";
import { EventBus } from "../../events/services/EventBus";
import * as ClaudeCode from "../models/ClaudeCode";
@@ -51,10 +51,10 @@ const LayerImpl = Effect.gen(function* () {
const createCanUseToolRelatedOptions = (options: {
taskId: string;
config: Config;
userConfig: UserConfig;
sessionId?: string;
}) => {
const { taskId, config, sessionId } = options;
const { taskId, userConfig, sessionId } = options;
return Effect.gen(function* () {
const claudeCodeConfig = yield* ClaudeCode.Config;
@@ -69,11 +69,11 @@ const LayerImpl = Effect.gen(function* () {
}
const canUseTool: CanUseTool = async (toolName, toolInput, _options) => {
if (config.permissionMode !== "default") {
if (userConfig.permissionMode !== "default") {
// Convert Claude Code permission modes to canUseTool behaviors
if (
config.permissionMode === "bypassPermissions" ||
config.permissionMode === "acceptEdits"
userConfig.permissionMode === "bypassPermissions" ||
userConfig.permissionMode === "acceptEdits"
) {
return {
behavior: "allow" as const,
@@ -123,7 +123,7 @@ const LayerImpl = Effect.gen(function* () {
return {
canUseTool,
permissionMode: config.permissionMode,
permissionMode: userConfig.permissionMode,
} as const;
});
};

View File

@@ -1,6 +1,8 @@
import { Path } from "@effect/platform";
import { Effect } from "effect";
import { describe, expect, it } from "vitest";
import { ApplicationContext } from "../../platform/services/ApplicationContext";
import { EnvService } from "../../platform/services/EnvService";
import type { InternalEventDeclaration } from "../types/InternalEventDeclaration";
import { EventBus } from "./EventBus";
import { FileWatcherService } from "./fileWatcher";
@@ -21,7 +23,9 @@ describe("FileWatcherService", () => {
const result = await Effect.runPromise(
program.pipe(
Effect.provide(FileWatcherService.Live),
Effect.provide(ApplicationContext.Live),
Effect.provide(EventBus.Live),
Effect.provide(EnvService.Live),
Effect.provide(Path.layer),
),
);
@@ -45,7 +49,9 @@ describe("FileWatcherService", () => {
const result = await Effect.runPromise(
program.pipe(
Effect.provide(FileWatcherService.Live),
Effect.provide(ApplicationContext.Live),
Effect.provide(EventBus.Live),
Effect.provide(EnvService.Live),
Effect.provide(Path.layer),
),
);
@@ -69,7 +75,9 @@ describe("FileWatcherService", () => {
const result = await Effect.runPromise(
program.pipe(
Effect.provide(FileWatcherService.Live),
Effect.provide(ApplicationContext.Live),
Effect.provide(EventBus.Live),
Effect.provide(EnvService.Live),
Effect.provide(Path.layer),
),
);
@@ -99,7 +107,9 @@ describe("FileWatcherService", () => {
const result = await Effect.runPromise(
program.pipe(
Effect.provide(FileWatcherService.Live),
Effect.provide(ApplicationContext.Live),
Effect.provide(EventBus.Live),
Effect.provide(EnvService.Live),
Effect.provide(Path.layer),
),
);
@@ -146,7 +156,9 @@ describe("FileWatcherService", () => {
const result = await Effect.runPromise(
program.pipe(
Effect.provide(FileWatcherService.Live),
Effect.provide(ApplicationContext.Live),
Effect.provide(EventBus.Live),
Effect.provide(EnvService.Live),
Effect.provide(Path.layer),
),
);
@@ -172,7 +184,9 @@ describe("FileWatcherService", () => {
const result = await Effect.runPromise(
program.pipe(
Effect.provide(FileWatcherService.Live),
Effect.provide(ApplicationContext.Live),
Effect.provide(EventBus.Live),
Effect.provide(EnvService.Live),
Effect.provide(Path.layer),
),
);

View File

@@ -2,7 +2,7 @@ import { type FSWatcher, watch } from "node:fs";
import { Path } from "@effect/platform";
import { Context, Effect, Layer, Ref } from "effect";
import z from "zod";
import { claudeProjectsDirPath } from "../../../lib/config/paths";
import { ApplicationContext } from "../../platform/services/ApplicationContext";
import { encodeProjectIdFromSessionFilePath } from "../../project/functions/id";
import { EventBus } from "./EventBus";
@@ -26,6 +26,8 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
Effect.gen(function* () {
const path = yield* Path.Path;
const eventBus = yield* EventBus;
const context = yield* ApplicationContext;
const isWatchingRef = yield* Ref.make(false);
const watcherRef = yield* Ref.make<FSWatcher | null>(null);
const projectWatchersRef = yield* Ref.make<Map<string, FSWatcher>>(
@@ -44,10 +46,13 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
yield* Effect.tryPromise({
try: async () => {
console.log("Starting file watcher on:", claudeProjectsDirPath);
console.log(
"Starting file watcher on:",
context.claudeCodePaths.claudeProjectsDirPath,
);
const watcher = watch(
claudeProjectsDirPath,
context.claudeCodePaths.claudeProjectsDirPath,
{ persistent: false, recursive: true },
(_eventType, filename) => {
if (!filename) return;
@@ -61,7 +66,10 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
const { sessionId } = groups.data;
// フルパスを構築してエンコードされた projectId を取得
const fullPath = path.join(claudeProjectsDirPath, filename);
const fullPath = path.join(
context.claudeCodePaths.claudeProjectsDirPath,
filename,
);
const encodedProjectId =
encodeProjectIdFromSessionFilePath(fullPath);
const debounceKey = `${encodedProjectId}/${sessionId}`;

View File

@@ -1,7 +1,7 @@
import { Command, FileSystem, Path } from "@effect/platform";
import { Context, Data, Effect, Either, Layer } from "effect";
import type { InferEffect } from "../../../lib/effect/types";
import { env } from "../../../lib/env";
import { EnvService } from "../../platform/services/EnvService";
import { parseGitBranchesOutput } from "../functions/parseGitBranchesOutput";
import { parseGitCommitsOutput } from "../functions/parseGitCommitsOutput";
@@ -21,6 +21,7 @@ class DetachedHeadError extends Data.TaggedError("DetachedHeadError")<{
const LayerImpl = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const envService = yield* EnvService;
const execGitCommand = (args: string[], cwd: string) =>
Effect.gen(function* () {
@@ -41,7 +42,7 @@ const LayerImpl = Effect.gen(function* () {
const command = Command.string(
Command.make("cd", absoluteCwd, "&&", "git", ...args).pipe(
Command.env({
PATH: env.get("PATH"),
PATH: yield* envService.getEnv("PATH"),
}),
Command.runInShell(true),
),

View File

@@ -0,0 +1,42 @@
import { homedir } from "node:os";
import { Path } from "@effect/platform";
import { Effect, Context as EffectContext, Layer } from "effect";
import type { InferEffect } from "../../../lib/effect/types";
import { EnvService } from "./EnvService";
const LayerImpl = Effect.gen(function* () {
const path = yield* Path.Path;
const envService = yield* EnvService;
const globalClaudeDirectoryPath = yield* envService
.getEnv("CLAUDE_CODE_VIEWER_CC_EXECUTABLE_PATH")
.pipe(
Effect.map((envVar) =>
envVar === undefined
? path.resolve(homedir(), ".claude")
: path.resolve(envVar),
),
);
const claudeCodePaths = {
globalClaudeDirectoryPath,
claudeCommandsDirPath: path.resolve(globalClaudeDirectoryPath, "commands"),
claudeProjectsDirPath: path.resolve(globalClaudeDirectoryPath, "projects"),
} as const satisfies {
globalClaudeDirectoryPath: string;
claudeCommandsDirPath: string;
claudeProjectsDirPath: string;
};
return {
claudeCodePaths,
};
});
export type IApplicationContext = InferEffect<typeof LayerImpl>;
export class ApplicationContext extends EffectContext.Tag("ApplicationContext")<
ApplicationContext,
IApplicationContext
>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -0,0 +1,53 @@
import { Context, Effect, Layer, Ref } from "effect";
import type { InferEffect } from "../../../lib/effect/types";
import { type EnvSchema, envSchema } from "../schema";
const LayerImpl = Effect.gen(function* () {
const envRef = yield* Ref.make<EnvSchema | undefined>(undefined);
const parseEnv = () => {
// biome-ignore lint/style/noProcessEnv: allow only here
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error(parsed.error);
throw new Error(`Invalid environment variables: ${parsed.error.message}`);
}
return parsed.data;
};
const getEnv = <Key extends keyof EnvSchema>(
key: Key,
): Effect.Effect<EnvSchema[Key]> => {
return Effect.gen(function* () {
yield* Ref.update(envRef, (existingEnv) => {
if (existingEnv === undefined) {
return parseEnv();
}
return existingEnv;
});
const env = yield* Ref.get(envRef);
if (env === undefined) {
throw new Error(
"Unexpected error: Environment variables are not loaded",
);
}
return env[key];
});
};
return {
getEnv,
};
});
export type IEnvService = InferEffect<typeof LayerImpl>;
export class EnvService extends Context.Tag("EnvService")<
EnvService,
IEnvService
>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -1,36 +1,36 @@
import { Context, Effect, Layer, Ref } from "effect";
import type { Config } from "../../../lib/config/config";
import type { UserConfig } from "../../../lib/config/config";
import type { InferEffect } from "../../../lib/effect/types";
const LayerImpl = Effect.gen(function* () {
const configRef = yield* Ref.make<Config>({
const configRef = yield* Ref.make<UserConfig>({
hideNoUserMessageSession: true,
unifySameTitleSession: true,
enterKeyBehavior: "shift-enter-send",
permissionMode: "default",
});
const setConfig = (newConfig: Config) =>
const setUserConfig = (newConfig: UserConfig) =>
Effect.gen(function* () {
yield* Ref.update(configRef, () => newConfig);
});
const getConfig = () =>
const getUserConfig = () =>
Effect.gen(function* () {
const config = yield* Ref.get(configRef);
return config;
});
return {
getConfig,
setConfig,
getUserConfig,
setUserConfig,
};
});
export type IHonoConfigService = InferEffect<typeof LayerImpl>;
export class HonoConfigService extends Context.Tag("HonoConfigService")<
HonoConfigService,
IHonoConfigService
export type IUserConfigService = InferEffect<typeof LayerImpl>;
export class UserConfigService extends Context.Tag("UserConfigService")<
UserConfigService,
IUserConfigService
>() {
static Live = Layer.effect(this, LayerImpl);
}

View File

@@ -2,6 +2,8 @@ import { FileSystem, Path } from "@effect/platform";
import { SystemError } from "@effect/platform/Error";
import { Effect, Layer, Option } from "effect";
import { PersistentService } from "../../../lib/storage/FileCacheStorage/PersistentService";
import { ApplicationContext } from "../../platform/services/ApplicationContext";
import { EnvService } from "../../platform/services/EnvService";
import type { ProjectMeta } from "../../types";
import { ProjectMetaService } from "../services/ProjectMetaService";
import { ProjectRepository } from "./ProjectRepository";
@@ -96,6 +98,8 @@ describe("ProjectRepository", () => {
const result = await Effect.runPromise(
program.pipe(
Effect.provide(ProjectRepository.Live),
Effect.provide(ApplicationContext.Live),
Effect.provide(EnvService.Live),
Effect.provide(ProjectMetaServiceMock),
Effect.provide(FileSystemMock),
Effect.provide(PathMock),
@@ -138,6 +142,8 @@ describe("ProjectRepository", () => {
Effect.runPromise(
program.pipe(
Effect.provide(ProjectRepository.Live),
Effect.provide(ApplicationContext.Live),
Effect.provide(EnvService.Live),
Effect.provide(ProjectMetaServiceMock),
Effect.provide(FileSystemMock),
Effect.provide(PathMock),
@@ -173,6 +179,8 @@ describe("ProjectRepository", () => {
const result = await Effect.runPromise(
program.pipe(
Effect.provide(ProjectRepository.Live),
Effect.provide(ApplicationContext.Live),
Effect.provide(EnvService.Live),
Effect.provide(ProjectMetaServiceMock),
Effect.provide(FileSystemMock),
Effect.provide(PathMock),
@@ -221,6 +229,8 @@ describe("ProjectRepository", () => {
const result = await Effect.runPromise(
program.pipe(
Effect.provide(ProjectRepository.Live),
Effect.provide(ApplicationContext.Live),
Effect.provide(EnvService.Live),
Effect.provide(ProjectMetaService.Live),
Effect.provide(FileSystemMock),
Effect.provide(PathMock),
@@ -264,6 +274,8 @@ describe("ProjectRepository", () => {
const result = await Effect.runPromise(
program.pipe(
Effect.provide(ProjectRepository.Live),
Effect.provide(ApplicationContext.Live),
Effect.provide(EnvService.Live),
Effect.provide(ProjectMetaService.Live),
Effect.provide(FileSystemMock),
Effect.provide(PathMock),
@@ -313,6 +325,8 @@ describe("ProjectRepository", () => {
const result = await Effect.runPromise(
program.pipe(
Effect.provide(ProjectRepository.Live),
Effect.provide(ApplicationContext.Live),
Effect.provide(EnvService.Live),
Effect.provide(ProjectMetaService.Live),
Effect.provide(FileSystemMock),
Effect.provide(PathMock),

View File

@@ -1,7 +1,7 @@
import { FileSystem, Path } from "@effect/platform";
import { Context, Effect, Layer, Option } from "effect";
import { claudeProjectsDirPath } from "../../../lib/config/paths";
import type { InferEffect } from "../../../lib/effect/types";
import { ApplicationContext } from "../../platform/services/ApplicationContext";
import type { Project } from "../../types";
import { decodeProjectId, encodeProjectId } from "../functions/id";
import { ProjectMetaService } from "../services/ProjectMetaService";
@@ -10,6 +10,7 @@ const LayerImpl = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const projectMetaService = yield* ProjectMetaService;
const context = yield* ApplicationContext;
const getProject = (projectId: string) =>
Effect.gen(function* () {
@@ -40,21 +41,28 @@ const LayerImpl = Effect.gen(function* () {
const getProjects = () =>
Effect.gen(function* () {
// Check if the claude projects directory exists
const dirExists = yield* fs.exists(claudeProjectsDirPath);
const dirExists = yield* fs.exists(
context.claudeCodePaths.claudeProjectsDirPath,
);
if (!dirExists) {
console.warn(
`Claude projects directory not found at ${claudeProjectsDirPath}`,
`Claude projects directory not found at ${context.claudeCodePaths.claudeProjectsDirPath}`,
);
return { projects: [] };
}
// Read directory entries
const entries = yield* fs.readDirectory(claudeProjectsDirPath);
const entries = yield* fs.readDirectory(
context.claudeCodePaths.claudeProjectsDirPath,
);
// Filter directories and map to Project objects
const projectEffects = entries.map((entry) =>
Effect.gen(function* () {
const fullPath = path.resolve(claudeProjectsDirPath, entry);
const fullPath = path.resolve(
context.claudeCodePaths.claudeProjectsDirPath,
entry,
);
// Check if it's a directory
const stat = yield* Effect.tryPromise(() =>

View File

@@ -3,7 +3,8 @@ 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 { ApplicationContext } from "../../platform/services/ApplicationContext";
import { UserConfigService } from "../../platform/services/UserConfigService";
import { SessionRepository } from "../../session/infrastructure/SessionRepository";
import { encodeProjectId } from "../functions/id";
import { ProjectRepository } from "../infrastructure/ProjectRepository";
@@ -11,8 +12,9 @@ import { ProjectRepository } from "../infrastructure/ProjectRepository";
const LayerImpl = Effect.gen(function* () {
const projectRepository = yield* ProjectRepository;
const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService;
const honoConfigService = yield* HonoConfigService;
const userConfigService = yield* UserConfigService;
const sessionRepository = yield* SessionRepository;
const context = yield* ApplicationContext;
const getProjects = () =>
Effect.gen(function* () {
@@ -27,7 +29,7 @@ const LayerImpl = Effect.gen(function* () {
Effect.gen(function* () {
const { projectId, cursor } = options;
const config = yield* honoConfigService.getConfig();
const userConfig = yield* userConfigService.getUserConfig();
const { project } = yield* projectRepository.getProject(projectId);
const { sessions } = yield* sessionRepository.getSessions(projectId, {
@@ -37,14 +39,14 @@ const LayerImpl = Effect.gen(function* () {
let filteredSessions = sessions;
// Filter sessions based on hideNoUserMessageSession setting
if (config.hideNoUserMessageSession) {
if (userConfig.hideNoUserMessageSession) {
filteredSessions = filteredSessions.filter((session) => {
return session.meta.firstCommand !== null;
});
}
// Unify sessions with same title if unifySameTitleSession is enabled
if (config.unifySameTitleSession) {
if (userConfig.unifySameTitleSession) {
const sessionMap = new Map<string, (typeof filteredSessions)[0]>();
for (const session of filteredSessions) {
@@ -122,10 +124,12 @@ const LayerImpl = Effect.gen(function* () {
// No project validation needed - startTask will create a new project
// if it doesn't exist when running /init command
const claudeProjectFilePath =
yield* computeClaudeProjectFilePath(projectPath);
const claudeProjectFilePath = yield* computeClaudeProjectFilePath({
projectPath,
claudeProjectsDirPath: context.claudeCodePaths.claudeProjectsDirPath,
});
const projectId = encodeProjectId(claudeProjectFilePath);
const config = yield* honoConfigService.getConfig();
const userConfig = yield* userConfigService.getUserConfig();
const result = yield* claudeCodeLifeCycleService.startTask({
baseSession: {
@@ -133,7 +137,7 @@ const LayerImpl = Effect.gen(function* () {
projectId,
sessionId: undefined,
},
config: config,
userConfig,
message: "/init",
});

View File

@@ -1,6 +1,7 @@
import { FileSystem, Path } from "@effect/platform";
import { Context, Effect, Layer, Option, Ref } from "effect";
import { z } from "zod";
import type { InferEffect } from "../../../lib/effect/types";
import {
FileCacheStorage,
makeFileCacheStorageLayer,
@@ -12,24 +13,11 @@ import { decodeProjectId } from "../functions/id";
const ProjectPathSchema = z.string().nullable();
export class ProjectMetaService extends Context.Tag("ProjectMetaService")<
ProjectMetaService,
{
readonly getProjectMeta: (
projectId: string,
) => Effect.Effect<ProjectMeta, Error>;
readonly invalidateProject: (projectId: string) => Effect.Effect<void>;
}
>() {
static Live = Layer.effect(
this,
Effect.gen(function* () {
const LayerImpl = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const projectPathCache = yield* FileCacheStorage<string | null>();
const projectMetaCacheRef = yield* Ref.make(
new Map<string, ProjectMeta>(),
);
const projectMetaCacheRef = yield* Ref.make(new Map<string, ProjectMeta>());
const extractProjectPathFromJsonl = (
filePath: string,
@@ -139,15 +127,18 @@ export class ProjectMetaService extends Context.Tag("ProjectMetaService")<
getProjectMeta,
invalidateProject,
};
}),
).pipe(
});
export type IProjectMetaService = InferEffect<typeof LayerImpl>;
export class ProjectMetaService extends Context.Tag("ProjectMetaService")<
ProjectMetaService,
IProjectMetaService
>() {
static Live = Layer.effect(this, LayerImpl).pipe(
Layer.provide(
makeFileCacheStorageLayer("project-path-cache", ProjectPathSchema),
),
Layer.provide(PersistentService.Live),
);
}
export type IProjectMetaService = Context.Tag.Service<
typeof ProjectMetaService
>;

View File

@@ -1,9 +1,9 @@
import { Hono } from "hono";
import type { Config } from "../lib/config/config";
import type { UserConfig } from "../lib/config/config";
export type HonoContext = {
Variables: {
config: Config;
userConfig: UserConfig;
};
};

View File

@@ -4,6 +4,8 @@ import { describe, expect, it, vi } from "vitest";
import { EventBus } from "../core/events/services/EventBus";
import { FileWatcherService } from "../core/events/services/fileWatcher";
import type { InternalEventDeclaration } from "../core/events/types/InternalEventDeclaration";
import { ApplicationContext } from "../core/platform/services/ApplicationContext";
import { EnvService } from "../core/platform/services/EnvService";
import { ProjectRepository } from "../core/project/infrastructure/ProjectRepository";
import { ProjectMetaService } from "../core/project/services/ProjectMetaService";
import { SessionRepository } from "../core/session/infrastructure/SessionRepository";
@@ -160,7 +162,12 @@ describe("InitializeService", () => {
);
const result = await Effect.runPromise(
program.pipe(Effect.provide(testLayer), Effect.provide(Path.layer)),
program.pipe(
Effect.provide(testLayer),
Effect.provide(ApplicationContext.Live),
Effect.provide(EnvService.Live),
Effect.provide(Path.layer),
),
);
expect(result).toBeUndefined();
@@ -180,7 +187,12 @@ describe("InitializeService", () => {
const testLayer = createTestLayer();
const result = await Effect.runPromise(
program.pipe(Effect.provide(testLayer), Effect.provide(Path.layer)),
program.pipe(
Effect.provide(testLayer),
Effect.provide(ApplicationContext.Live),
Effect.provide(EnvService.Live),
Effect.provide(Path.layer),
),
);
expect(result).toBe("file watcher started");
@@ -219,7 +231,12 @@ describe("InitializeService", () => {
const testLayer = createTestLayer();
const result = await Effect.runPromise(
program.pipe(Effect.provide(testLayer), Effect.provide(Path.layer)),
program.pipe(
Effect.provide(testLayer),
Effect.provide(ApplicationContext.Live),
Effect.provide(EnvService.Live),
Effect.provide(Path.layer),
),
);
expect(result).toHaveLength(1);
@@ -253,7 +270,12 @@ describe("InitializeService", () => {
const testLayer = createTestLayer();
const result = await Effect.runPromise(
program.pipe(Effect.provide(testLayer), Effect.provide(Path.layer)),
program.pipe(
Effect.provide(testLayer),
Effect.provide(ApplicationContext.Live),
Effect.provide(EnvService.Live),
Effect.provide(Path.layer),
),
);
// heartbeat is emitted immediately once first, then every 10 seconds
@@ -317,7 +339,12 @@ describe("InitializeService", () => {
);
await Effect.runPromise(
program.pipe(Effect.provide(testLayer), Effect.provide(Path.layer)),
program.pipe(
Effect.provide(testLayer),
Effect.provide(ApplicationContext.Live),
Effect.provide(EnvService.Live),
Effect.provide(Path.layer),
),
);
expect(getProjectsCalled).toHaveBeenCalledTimes(1);
@@ -341,7 +368,12 @@ describe("InitializeService", () => {
// Completes without throwing error
await expect(
Effect.runPromise(
program.pipe(Effect.provide(testLayer), Effect.provide(Path.layer)),
program.pipe(
Effect.provide(testLayer),
Effect.provide(ApplicationContext.Live),
Effect.provide(EnvService.Live),
Effect.provide(Path.layer),
),
),
).resolves.toBeUndefined();
});
@@ -359,7 +391,12 @@ describe("InitializeService", () => {
const testLayer = createTestLayer();
const result = await Effect.runPromise(
program.pipe(Effect.provide(testLayer), Effect.provide(Path.layer)),
program.pipe(
Effect.provide(testLayer),
Effect.provide(ApplicationContext.Live),
Effect.provide(EnvService.Live),
Effect.provide(Path.layer),
),
);
expect(result).toBe("cleaned up");

View File

@@ -1,6 +1,6 @@
import { getCookie, setCookie } from "hono/cookie";
import { createMiddleware } from "hono/factory";
import { configSchema } from "../../lib/config/config";
import { userConfigSchema } from "../../lib/config/config";
import type { HonoContext } from "../app";
export const configMiddleware = createMiddleware<HonoContext>(
@@ -8,9 +8,9 @@ export const configMiddleware = createMiddleware<HonoContext>(
const cookie = getCookie(c, "ccv-config");
const parsed = (() => {
try {
return configSchema.parse(JSON.parse(cookie ?? "{}"));
return userConfigSchema.parse(JSON.parse(cookie ?? "{}"));
} catch {
return configSchema.parse({});
return userConfigSchema.parse({});
}
})();
@@ -25,7 +25,7 @@ export const configMiddleware = createMiddleware<HonoContext>(
);
}
c.set("config", parsed);
c.set("userConfig", parsed);
await next();
},

View File

@@ -13,14 +13,14 @@ import { TypeSafeSSE } from "../core/events/functions/typeSafeSSE";
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 { EnvService } from "../core/platform/services/EnvService";
import { UserConfigService } from "../core/platform/services/UserConfigService";
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 { userConfigSchema } from "../lib/config/config";
import { effectToResponse } from "../lib/effect/toEffectResponse";
import { env } from "../lib/env";
import type { HonoAppType } from "./app";
import { InitializeService } from "./initialize";
import { configMiddleware } from "./middleware/config.middleware";
@@ -40,11 +40,13 @@ export const routes = (app: HonoAppType) =>
const claudeCodeController = yield* ClaudeCodeController;
// services
const honoConfigService = yield* HonoConfigService;
const envService = yield* EnvService;
const userConfigService = yield* UserConfigService;
const claudeCodeLifeCycleService = yield* ClaudeCodeLifeCycleService;
const initializeService = yield* InitializeService;
const runtime = yield* Effect.runtime<
| EnvService
| SessionMetaService
| VirtualConversationDatabase
| FileSystem.FileSystem
@@ -52,7 +54,7 @@ export const routes = (app: HonoAppType) =>
| CommandExecutor.CommandExecutor
>();
if (env.get("NEXT_PHASE") !== "phase-production-build") {
if ((yield* envService.getEnv("NEXT_PHASE")) !== "phase-production-build") {
yield* initializeService.startInitialization();
prexit(async () => {
@@ -66,8 +68,8 @@ export const routes = (app: HonoAppType) =>
.use(configMiddleware)
.use(async (c, next) => {
await Effect.runPromise(
honoConfigService.setConfig({
...c.get("config"),
userConfigService.setUserConfig({
...c.get("userConfig"),
}),
);
@@ -77,11 +79,11 @@ export const routes = (app: HonoAppType) =>
// routes
.get("/config", async (c) => {
return c.json({
config: c.get("config"),
config: c.get("userConfig"),
});
})
.put("/config", zValidator("json", configSchema), async (c) => {
.put("/config", zValidator("json", userConfigSchema), async (c) => {
const { ...config } = c.req.valid("json");
setCookie(c, "ccv-config", JSON.stringify(config));

View File

@@ -1,6 +1,6 @@
import z from "zod";
export const configSchema = z.object({
export const userConfigSchema = z.object({
hideNoUserMessageSession: z.boolean().optional().default(true),
unifySameTitleSession: z.boolean().optional().default(true),
enterKeyBehavior: z
@@ -13,4 +13,4 @@ export const configSchema = z.object({
.default("default"),
});
export type Config = z.infer<typeof configSchema>;
export type UserConfig = z.infer<typeof userConfigSchema>;

View File

@@ -1,23 +1,5 @@
import { homedir } from "node:os";
import { resolve } from "node:path";
import { env } from "../env";
const GLOBAL_CLAUDE_DIR = env.get("GLOBAL_CLAUDE_DIR");
export const globalClaudeDirectoryPath =
GLOBAL_CLAUDE_DIR === undefined
? resolve(homedir(), ".claude")
: resolve(GLOBAL_CLAUDE_DIR);
export const claudeProjectsDirPath = resolve(
globalClaudeDirectoryPath,
"projects",
);
export const claudeCommandsDirPath = resolve(
globalClaudeDirectoryPath,
"commands",
);
export const claudeCodeViewerCacheDirPath = resolve(
homedir(),

View File

@@ -1,23 +0,0 @@
import { type EnvSchema, envSchema } from "./schema";
const parseEnv = () => {
// biome-ignore lint/style/noProcessEnv: allow only here
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error(parsed.error);
throw new Error(`Invalid environment variables: ${parsed.error.message}`);
}
return parsed.data;
};
export const env = (() => {
let parsedEnv: EnvSchema | undefined;
return {
get: <Key extends keyof EnvSchema>(key: Key): EnvSchema[Key] => {
parsedEnv ??= parseEnv();
return parsedEnv[key];
},
};
})();