From 076e2b5f1d591bdaf90bf368275a00f33ce46ebe Mon Sep 17 00:00:00 2001 From: Mohammed Muzammil Anwar Date: Tue, 10 Mar 2026 23:18:16 +0530 Subject: [PATCH 01/10] feat: add configurable base directory (`~/.t3`) with `T3CODE_BASE_DIR` env and --base-dir flag. --- apps/desktop/src/main.ts | 5 +- .../OrchestrationEngineHarness.integration.ts | 4 +- apps/server/src/config.ts | 4 +- .../git/Layers/CodexTextGeneration.test.ts | 4 +- apps/server/src/git/Layers/GitCore.test.ts | 4 + apps/server/src/git/Layers/GitCore.ts | 7 +- apps/server/src/git/Layers/GitManager.test.ts | 6 +- apps/server/src/main.ts | 13 +- .../Layers/CheckpointReactor.test.ts | 2 +- .../Layers/OrchestrationEngine.test.ts | 4 +- .../Layers/ProjectionPipeline.test.ts | 113 +++++++++++------- .../Layers/ProviderCommandReactor.test.ts | 13 +- .../Layers/ProviderRuntimeIngestion.test.ts | 2 +- apps/server/src/os-jank.ts | 12 +- .../src/provider/Layers/CodexAdapter.test.ts | 6 +- .../telemetry/Layers/AnalyticsService.test.ts | 3 +- apps/server/src/wsServer.test.ts | 3 + scripts/dev-runner.test.ts | 33 ++++- scripts/dev-runner.ts | 36 +++++- turbo.json | 1 + 20 files changed, 199 insertions(+), 76 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 4fec75804f..f61ef7cc1c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -41,8 +41,8 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; -const STATE_DIR = - process.env.T3CODE_STATE_DIR?.trim() || Path.join(OS.homedir(), ".t3", "userdata"); +const BASE_DIR = process.env.T3CODE_BASE_DIR?.trim() || Path.join(OS.homedir(), ".t3"); +const STATE_DIR = process.env.T3CODE_STATE_DIR?.trim() || Path.join(BASE_DIR, "userdata"); const DESKTOP_SCHEME = "t3"; const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); @@ -865,6 +865,7 @@ function backendEnv(): NodeJS.ProcessEnv { T3CODE_MODE: "desktop", T3CODE_NO_BROWSER: "1", T3CODE_PORT: String(backendPort), + T3CODE_BASE_DIR: BASE_DIR, T3CODE_STATE_DIR: STATE_DIR, T3CODE_AUTH_TOKEN: backendAuthToken, }; diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index b6ae7ee982..666a717de4 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -245,7 +245,7 @@ export const makeOrchestrationIntegrationHarness = ( }), ).pipe( Layer.provide(makeCodexAdapterLive()), - Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), + Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir, rootDir)), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(providerSessionDirectoryLayer), ); @@ -294,7 +294,7 @@ export const makeOrchestrationIntegrationHarness = ( ); const layer = orchestrationReactorLayer.pipe( Layer.provide(persistenceLayer), - Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), + Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir, rootDir)), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ccbcea469d..ae3e13630d 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -21,6 +21,7 @@ export interface ServerConfigShape { readonly host: string | undefined; readonly cwd: string; readonly keybindingsConfigPath: string; + readonly baseDir: string; readonly stateDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; @@ -36,13 +37,14 @@ export interface ServerConfigShape { export class ServerConfig extends ServiceMap.Service()( "t3/config/ServerConfig", ) { - static readonly layerTest = (cwd: string, statedir: string) => + static readonly layerTest = (cwd: string, statedir: string, baseDir: string) => Layer.effect( ServerConfig, Effect.gen(function* () { const path = yield* Path.Path; return { cwd, + baseDir, stateDir: statedir, mode: "web", autoBootstrapProjectFromCwd: false, diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 1cf2d0e092..68a7622640 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -8,9 +8,9 @@ import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { TextGenerationError } from "../Errors.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; -const makeCodexTextGenerationTestLayer = (stateDir: string) => +const makeCodexTextGenerationTestLayer = (baseDir: string) => CodexTextGenerationLive.pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), `${baseDir}/userdata`, baseDir)), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index be752b3c2e..6fef9d021f 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -12,12 +12,15 @@ import { GitCoreLive } from "./GitCore.ts"; import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; import { GitCommandError } from "../Errors.ts"; import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; +import { ServerConfig } from "../../config.ts"; // ── Helpers ── const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer)); +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd()); const GitCoreTestLayer = GitCoreLive.pipe( Layer.provide(GitServiceTestLayer), + Layer.provide(ServerConfigLayer), Layer.provide(NodeServices.layer), ); const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer); @@ -90,6 +93,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => const gitServiceLayer = Layer.succeed(GitService, gitService); const coreLayer = GitCoreLive.pipe( Layer.provide(gitServiceLayer), + Layer.provide(ServerConfigLayer), Layer.provide(NodeServices.layer), ); const core = await Effect.runPromise(Effect.service(GitCore).pipe(Effect.provide(coreLayer))); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index be853eb5fa..d586711055 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -3,6 +3,7 @@ import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "ef import { GitCommandError } from "../Errors.ts"; import { GitService } from "../Services/GitService.ts"; import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; +import { ServerConfig } from "../../config.ts"; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -221,6 +222,8 @@ const makeGitCore = Effect.gen(function* () { const git = yield* GitService; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const { baseDir } = yield* ServerConfig; + const worktreesDir = path.join(baseDir, "worktrees"); const executeGit = ( operation: string, @@ -1105,9 +1108,7 @@ const makeGitCore = Effect.gen(function* () { const targetBranch = input.newBranch ?? input.branch; const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); - const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp"; - const worktreePath = - input.path ?? path.join(homeDir, ".t3", "worktrees", repoName, sanitizedBranch); + const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); const args = input.newBranch ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] : ["worktree", "add", worktreePath, input.branch]; diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 856eb076b5..895259909b 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -19,6 +19,7 @@ import { GitServiceLive } from "./GitService.ts"; import { GitService } from "../Services/GitService.ts"; import { GitCoreLive } from "./GitCore.ts"; import { makeGitManager } from "./GitManager.ts"; +import { ServerConfig } from "../../config.ts"; interface FakeGhScenario { prListSequence?: string[]; @@ -463,17 +464,20 @@ function makeManager(input?: { }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd()); const gitCoreLayer = GitCoreLive.pipe( Layer.provideMerge(GitServiceLive), Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(ServerConfigLayer), ); const managerLayer = Layer.mergeAll( Layer.succeed(GitHubCli, gitHubCli), Layer.succeed(TextGeneration, textGeneration), gitCoreLayer, - NodeServices.layer, + ).pipe( + Layer.provideMerge(NodeServices.layer), ); return makeGitManager.pipe( diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 0a33be0cbb..f5d3279578 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -16,7 +16,7 @@ import { type RuntimeMode, type ServerConfigShape, } from "./config"; -import { fixPath, resolveStateDir } from "./os-jank"; +import { fixPath, resolveBaseDir, resolveStateDir } from "./os-jank"; import { Open } from "./open"; import * as SqlitePersistence from "./persistence/Layers/Sqlite"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; @@ -36,6 +36,7 @@ interface CliInput { readonly mode: Option.Option; readonly port: Option.Option; readonly host: Option.Option; + readonly baseDir: Option.Option; readonly stateDir: Option.Option; readonly devUrl: Option.Option; readonly noBrowser: Option.Option; @@ -99,6 +100,7 @@ const CliEnvConfig = Config.all({ ), port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), + baseDir: Config.string("T3CODE_BASE_DIR").pipe(Config.option, Config.map(Option.getOrUndefined)), stateDir: Config.string("T3CODE_STATE_DIR").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -152,8 +154,11 @@ const ServerConfigLive = (input: CliInput) => return findAvailablePort(DEFAULT_PORT); }, }); + + const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.baseDir) ?? env.baseDir); const stateDir = yield* resolveStateDir( Option.getOrUndefined(input.stateDir) ?? env.stateDir, + baseDir, ); const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); @@ -180,6 +185,7 @@ const ServerConfigLive = (input: CliInput) => cwd: cliConfig.cwd, keybindingsConfigPath, host, + baseDir, stateDir, staticDir, devUrl, @@ -299,6 +305,10 @@ const hostFlag = Flag.string("host").pipe( Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), Flag.optional, ); +const baseDirFlag = Flag.string("base-dir").pipe( + Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_BASE_DIR)."), + Flag.optional, +); const stateDirFlag = Flag.string("state-dir").pipe( Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."), Flag.optional, @@ -335,6 +345,7 @@ export const t3Cli = Command.make("t3", { mode: modeFlag, port: portFlag, host: hostFlag, + baseDir: baseDirFlag, stateDir: stateDirFlag, devUrl: devUrlFlag, noBrowser: noBrowserFlag, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index eecfc069d3..f47a92285a 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -254,7 +254,7 @@ describe("CheckpointReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(CheckpointStoreLive), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 181b18d60c..9fca58010f 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -40,7 +40,7 @@ async function createOrchestrationSystem() { Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ); const runtime = ManagedRuntime.make(orchestrationLayer); @@ -323,7 +323,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index d15b2efa2e..95148db4aa 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -32,16 +32,17 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; -const makeProjectionPipelineTestLayer = (stateDir: string) => +const makeProjectionPipelineTestLayer = (stateDir: string, baseDir: string) => OrchestrationProjectionPipelineLive.pipe( Layer.provideMerge(OrchestrationEventStoreLive), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir, baseDir)), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), ); const runWithProjectionPipelineLayer = ( stateDir: string, + baseDir: string, effect: Effect.Effect< A, E, @@ -49,12 +50,12 @@ const runWithProjectionPipelineLayer = ( >, ) => Effect.acquireUseRelease( - Effect.sync(() => ManagedRuntime.make(makeProjectionPipelineTestLayer(stateDir))), + Effect.sync(() => ManagedRuntime.make(makeProjectionPipelineTestLayer(stateDir, baseDir))), (runtime) => Effect.promise(() => runtime.runPromise(effect)), (runtime) => Effect.promise(() => runtime.dispose()), ); -const projectionLayer = it.layer(makeProjectionPipelineTestLayer(process.cwd())); +const projectionLayer = it.layer(makeProjectionPipelineTestLayer(process.cwd(), process.cwd())); projectionLayer("OrchestrationProjectionPipeline", (it) => { it.effect("bootstraps all projection states and writes projection rows", () => @@ -176,8 +177,12 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { ); it.effect("stores message attachment references without mutating payloads", () => - Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe( - Effect.flatMap((stateDir) => + Effect.sync(() => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-base-")); + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-")); + return { baseDir, stateDir }; + }).pipe( + Effect.flatMap(({ baseDir, stateDir }) => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -236,16 +241,23 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { }, ]); }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), + (effect) => runWithProjectionPipelineLayer(stateDir, baseDir, effect), + Effect.ensuring(Effect.sync(() => { + fs.rmSync(stateDir, { recursive: true, force: true }); + fs.rmSync(baseDir, { recursive: true, force: true }); + })), ), ), ), ); it.effect("preserves mixed image attachment metadata as-is", () => - Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe( - Effect.flatMap((stateDir) => + Effect.sync(() => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-safe-")); + const stateDir = path.join(baseDir, "userdata"); + return { baseDir, stateDir }; + }).pipe( + Effect.flatMap(({ baseDir, stateDir }) => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -318,8 +330,8 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { }, ]); }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), + (effect) => runWithProjectionPipelineLayer(stateDir, baseDir, effect), + Effect.ensuring(Effect.sync(() => fs.rmSync(baseDir, { recursive: true, force: true }))), ), ), ), @@ -449,10 +461,12 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { ); it.effect("overwrites stored attachment references when a message updates attachments", () => - Effect.sync(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")), - ).pipe( - Effect.flatMap((stateDir) => + Effect.sync(() => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")); + const stateDir = path.join(baseDir, "userdata"); + return { baseDir, stateDir }; + }).pipe( + Effect.flatMap(({ baseDir, stateDir }) => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -586,18 +600,20 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { }, ]); }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), + (effect) => runWithProjectionPipelineLayer(stateDir, baseDir, effect), + Effect.ensuring(Effect.sync(() => fs.rmSync(baseDir, { recursive: true, force: true }))), ), ), ), ); it.effect("does not persist attachment files when projector transaction rolls back", () => - Effect.sync(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-rollback-")), - ).pipe( - Effect.flatMap((stateDir) => + Effect.sync(() => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-rollback-")); + const stateDir = path.join(baseDir, "userdata"); + return { baseDir, stateDir }; + }).pipe( + Effect.flatMap(({ baseDir, stateDir }) => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -709,18 +725,20 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { assert.equal(fs.existsSync(attachmentPath), false); yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`; }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), + (effect) => runWithProjectionPipelineLayer(stateDir, baseDir, effect), + Effect.ensuring(Effect.sync(() => fs.rmSync(baseDir, { recursive: true, force: true }))), ), ), ), ); it.effect("removes unreferenced attachment files when a thread is reverted", () => - Effect.sync(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-revert-")), - ).pipe( - Effect.flatMap((stateDir) => + Effect.sync(() => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")); + const stateDir = path.join(baseDir, "userdata"); + return { baseDir, stateDir }; + }).pipe( + Effect.flatMap(({ baseDir, stateDir }) => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -925,18 +943,20 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { assert.equal(fs.existsSync(removePath), false); assert.equal(fs.existsSync(otherThreadPath), true); }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), + (effect) => runWithProjectionPipelineLayer(stateDir, baseDir, effect), + Effect.ensuring(Effect.sync(() => fs.rmSync(baseDir, { recursive: true, force: true }))), ), ), ), ); it.effect("removes thread attachment directory when thread is deleted", () => - Effect.sync(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-delete-")), - ).pipe( - Effect.flatMap((stateDir) => + Effect.sync(() => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-revert-")); + const stateDir = path.join(baseDir, "userdata"); + return { baseDir, stateDir }; + }).pipe( + Effect.flatMap(({ baseDir, stateDir }) => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -1057,18 +1077,20 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { assert.equal(fs.existsSync(threadAttachmentPath), false); assert.equal(fs.existsSync(otherThreadAttachmentPath), true); }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), + (effect) => runWithProjectionPipelineLayer(stateDir, baseDir, effect), + Effect.ensuring(Effect.sync(() => fs.rmSync(baseDir, { recursive: true, force: true }))), ), ), ), ); it.effect("ignores unsafe thread ids for attachment cleanup paths", () => - Effect.sync(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-")), - ).pipe( - Effect.flatMap((stateDir) => + Effect.sync(() => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-delete-")); + const stateDir = path.join(baseDir, "userdata"); + return { baseDir, stateDir }; + }).pipe( + Effect.flatMap(({ baseDir, stateDir }) => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -1102,8 +1124,8 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { assert.equal(fs.existsSync(attachmentsSentinelPath), true); assert.equal(fs.existsSync(stateDirSentinelPath), true); }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), + (effect) => runWithProjectionPipelineLayer(stateDir, baseDir, effect), + Effect.ensuring(Effect.sync(() => fs.rmSync(baseDir, { recursive: true, force: true }))), ), ), ), @@ -1822,7 +1844,10 @@ it.effect("restores pending turn-start metadata across projection pipeline resta fs.rmSync(tempDir, { recursive: true, force: true }); }).pipe( Effect.provide( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd()), NodeServices.layer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd()), + NodeServices.layer, + ), ), ), ); @@ -1833,7 +1858,7 @@ const engineLayer = it.layer( Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provideMerge(SqlitePersistenceMemory), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ), ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index cd74642b84..f8b05d101b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -67,6 +67,7 @@ describe("ProviderCommandReactor", () => { > | null = null; let scope: Scope.Closeable | null = null; const createdStateDirs = new Set(); + const createdBaseDirs = new Set(); afterEach(async () => { if (scope) { @@ -81,11 +82,17 @@ describe("ProviderCommandReactor", () => { fs.rmSync(stateDir, { recursive: true, force: true }); } createdStateDirs.clear(); + for (const baseDir of createdBaseDirs) { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + createdBaseDirs.clear(); }); - async function createHarness(input?: { readonly stateDir?: string }) { + async function createHarness(input?: { readonly baseDir?: string; readonly stateDir?: string }) { const now = new Date().toISOString(); - const stateDir = input?.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); + const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); + createdBaseDirs.add(baseDir); + const stateDir = input?.stateDir ?? path.join(baseDir, "state-file"); createdStateDirs.add(stateDir); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; @@ -210,7 +217,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge( Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), ), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir, baseDir)), Layer.provideMerge(NodeServices.layer), ); const runtime = ManagedRuntime.make(layer); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 76d6ecfee4..4bfab2710c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -151,7 +151,7 @@ describe("ProviderRuntimeIngestion", () => { const layer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ); runtime = ManagedRuntime.make(layer); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 586aca6f79..3b62605e36 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -27,10 +27,18 @@ export const expandHomePath = Effect.fn(function* (input: string) { return input; }); -export const resolveStateDir = Effect.fn(function* (raw: string | undefined) { +export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) { const { join, resolve } = yield* Path.Path; if (!raw || raw.trim().length === 0) { - return join(OS.homedir(), ".t3", "userdata"); + return join(OS.homedir(), ".t3"); + } + return resolve(yield* expandHomePath(raw.trim())); +}); + +export const resolveStateDir = Effect.fn(function* (raw: string | undefined, baseDir: string) { + const { join, resolve } = yield* Path.Path; + if (!raw || raw.trim().length === 0) { + return join(baseDir, "userdata"); } return resolve(yield* expandHomePath(raw.trim())); }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3ad206d0be..7d37650901 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -149,7 +149,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory const validationManager = new FakeCodexManager(); const validationLayer = it.layer( makeCodexAdapterLive({ manager: validationManager }).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), @@ -190,7 +190,7 @@ sessionErrorManager.sendTurnImpl.mockImplementation(async () => { }); const sessionErrorLayer = it.layer( makeCodexAdapterLive({ manager: sessionErrorManager }).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), @@ -257,7 +257,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { const lifecycleManager = new FakeCodexManager(); const lifecycleLayer = it.layer( makeCodexAdapterLive({ manager: lifecycleManager }).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts index adf7564ce4..f4a01a22a3 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts @@ -38,12 +38,13 @@ it.layer(NodeServices.layer)("AnalyticsService test", (it) => { it.effect("flush drains all buffered events across multiple batches", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-telemetry-base-" }); const stateDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-telemetry-flush-", }); const capturedRequests: Array = []; - const serverConfigLayer = ServerConfig.layerTest(process.cwd(), stateDir); + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), stateDir, baseDir); const telemetryLayer = AnalyticsServiceLayerLive.pipe(Layer.provideMerge(serverConfigLayer)); const configLayer = ConfigProvider.layer( diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index f1ad0095f1..b65416b0e6 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -388,6 +388,7 @@ describe("WebSocket Server", () => { logWebSocketEvents?: boolean; devUrl?: string; authToken?: string; + baseDir?: string; stateDir?: string; staticDir?: string; providerLayer?: Layer.Layer; @@ -402,6 +403,7 @@ describe("WebSocket Server", () => { throw new Error("Test server is already running"); } + const baseDir = options.baseDir ?? makeTempDir("t3code-ws-base-"); const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); const scope = await Effect.runPromise(Scope.make("sequential")); const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; @@ -417,6 +419,7 @@ describe("WebSocket Server", () => { host: undefined, cwd: options.cwd ?? "/test/project", keybindingsConfigPath: path.join(stateDir, "keybindings.json"), + baseDir, stateDir, staticDir: options.staticDir, devUrl: options.devUrl ? new URL(options.devUrl) : undefined, diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 704a285414..0bda2c9cf2 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1,10 +1,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import { homedir } from "node:os"; import { resolve } from "node:path"; import { assert, describe, it } from "@effect/vitest"; import { Effect } from "effect"; import { - DEFAULT_DEV_STATE_DIR, + getDevStateDir, createDevRunnerEnv, findFirstAvailableOffset, resolveModePortOffsets, @@ -54,6 +55,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { baseEnv: {}, serverOffset: 0, webOffset: 0, + baseDir: undefined, stateDir: undefined, authToken: undefined, noBrowser: undefined, @@ -63,7 +65,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { port: undefined, devUrl: undefined, }), - DEFAULT_DEV_STATE_DIR, + getDevStateDir(resolve(homedir(), ".t3")), ]); assert.equal(env.T3CODE_STATE_DIR, defaultStateDir); @@ -77,6 +79,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { baseEnv: {}, serverOffset: 0, webOffset: 0, + baseDir: "/tmp/custom-base", stateDir: "/tmp/override-state", authToken: "secret", noBrowser: true, @@ -87,6 +90,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: new URL("http://localhost:7331"), }); + assert.equal(env.T3CODE_BASE_DIR, resolve("/tmp/custom-base")); assert.equal(env.T3CODE_STATE_DIR, resolve("/tmp/override-state")); assert.equal(env.T3CODE_PORT, "4222"); assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); @@ -107,6 +111,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }, serverOffset: 0, webOffset: 0, + baseDir: undefined, stateDir: undefined, authToken: undefined, noBrowser: undefined, @@ -129,6 +134,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { baseEnv: {}, serverOffset: 0, webOffset: 0, + baseDir: undefined, stateDir: undefined, authToken: undefined, noBrowser: undefined, @@ -142,6 +148,29 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_LOG_WS_EVENTS, "0"); }), ); + + it.effect("uses base dir for state dir when state dir not provided", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + baseDir: "/tmp/my-t3", + stateDir: undefined, + authToken: undefined, + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_BASE_DIR, resolve("/tmp/my-t3")); + assert.equal(env.T3CODE_STATE_DIR, resolve("/tmp/my-t3/dev")); + }), + ); }); describe("findFirstAvailableOffset", () => { diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 3815c4a3b9..7444a0f1c0 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -14,10 +14,13 @@ const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; -export const DEFAULT_DEV_STATE_DIR = Effect.map(Effect.service(Path.Path), (path) => - path.join(homedir(), ".t3", "dev"), +const DEFAULT_BASE_DIR = Effect.map(Effect.service(Path.Path), (path) => + path.join(homedir(), ".t3"), ); +export const getDevStateDir = (baseDir: string): Effect.Effect => + Effect.map(Effect.service(Path.Path), (path) => path.join(baseDir, "dev")); + const MODE_ARGS = { dev: [ "run", @@ -101,7 +104,20 @@ export function resolveOffset(config: { return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }; } -function resolveStateDir(stateDir: string | undefined): Effect.Effect { +function resolveBaseDir(baseDir: string | undefined): Effect.Effect { + return Effect.gen(function* () { + const path = yield* Path.Path; + const configured = baseDir?.trim(); + + if (configured) { + return path.resolve(configured); + } + + return yield* DEFAULT_BASE_DIR; + }); +} + +function resolveStateDir(stateDir: string | undefined, baseDir: string): Effect.Effect { return Effect.gen(function* () { const path = yield* Path.Path; const configured = stateDir?.trim(); @@ -111,7 +127,7 @@ function resolveStateDir(stateDir: string | undefined): Effect.Effect({ interface DevRunnerCliInput { readonly mode: DevMode; + readonly baseDir: string | undefined; readonly stateDir: string | undefined; readonly authToken: string | undefined; readonly noBrowser: boolean | undefined; @@ -415,6 +436,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { baseEnv: process.env, serverOffset, webOffset, + baseDir: input.baseDir, stateDir: input.stateDir, authToken: input.authToken, noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser), @@ -485,6 +507,10 @@ const devRunnerCli = Command.make("dev-runner", { mode: Argument.choice("mode", DEV_RUNNER_MODES).pipe( Argument.withDescription("Development mode to run."), ), + baseDir: Flag.string("base-dir").pipe( + Flag.withDescription("Base directory for all T3 Code data (forwards to T3CODE_BASE_DIR)."), + Flag.withFallbackConfig(optionalStringConfig("T3CODE_BASE_DIR")), + ), stateDir: Flag.string("state-dir").pipe( Flag.withDescription("State directory path (forwards to T3CODE_STATE_DIR)."), Flag.withFallbackConfig(optionalStringConfig("T3CODE_STATE_DIR")), diff --git a/turbo.json b/turbo.json index 671336afae..b9d1b56b0a 100644 --- a/turbo.json +++ b/turbo.json @@ -9,6 +9,7 @@ "T3CODE_MODE", "T3CODE_PORT", "T3CODE_NO_BROWSER", + "T3CODE_BASE_DIR", "T3CODE_STATE_DIR", "T3CODE_AUTH_TOKEN", "T3CODE_DESKTOP_WS_URL" From 68c2267391f9675db92ccb7986399ff7209e89f2 Mon Sep 17 00:00:00 2001 From: Mohammed Muzammil Anwar Date: Wed, 11 Mar 2026 00:01:53 +0530 Subject: [PATCH 02/10] fix: use path.join for constructing state directory path for consistency. --- apps/server/src/git/Layers/CodexTextGeneration.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 68a7622640..38cc013918 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; import { expect } from "vitest"; +import path from "node:path"; import { ServerConfig } from "../../config.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; @@ -10,7 +11,7 @@ import { TextGeneration } from "../Services/TextGeneration.ts"; const makeCodexTextGenerationTestLayer = (baseDir: string) => CodexTextGenerationLive.pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), `${baseDir}/userdata`, baseDir)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), path.join(baseDir, "userdata"), baseDir)), Layer.provideMerge(NodeServices.layer), ); From b76be317be02dd754335b6332dcd53d1a9f77ebf Mon Sep 17 00:00:00 2001 From: Mohammed Muzammil Anwar Date: Wed, 11 Mar 2026 03:42:24 +0530 Subject: [PATCH 03/10] feat: simplify base directory config to single `T3CODE_HOME` env var and `--home-dir` flag. --- apps/desktop/src/main.ts | 7 ++--- apps/server/src/main.test.ts | 14 ++++----- apps/server/src/main.ts | 27 +++++------------ apps/server/src/os-jank.ts | 9 ++---- scripts/dev-runner.test.ts | 57 +++++++++++++++--------------------- scripts/dev-runner.ts | 51 ++++++++------------------------ turbo.json | 3 +- 7 files changed, 56 insertions(+), 112 deletions(-) diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ef789f4872..e8dd013bea 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -56,8 +56,8 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; -const BASE_DIR = process.env.T3CODE_BASE_DIR?.trim() || Path.join(OS.homedir(), ".t3"); -const STATE_DIR = process.env.T3CODE_STATE_DIR?.trim() || Path.join(BASE_DIR, "userdata"); +const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); +const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SCHEME = "t3"; const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); @@ -888,8 +888,7 @@ function backendEnv(): NodeJS.ProcessEnv { T3CODE_MODE: "desktop", T3CODE_NO_BROWSER: "1", T3CODE_PORT: String(backendPort), - T3CODE_BASE_DIR: BASE_DIR, - T3CODE_STATE_DIR: STATE_DIR, + T3CODE_HOME: BASE_DIR, T3CODE_AUTH_TOKEN: backendAuthToken, }; } diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 83976e3d4c..8ba58d0082 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -60,13 +60,11 @@ const runCli = ( args: ReadonlyArray, env: Record = { T3CODE_NO_BROWSER: "true" }, ) => { - const uniqueStateDir = `/tmp/t3-cli-state-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; return Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe( Effect.provide( ConfigProvider.layer( ConfigProvider.fromEnv({ env: { - T3CODE_STATE_DIR: uniqueStateDir, ...env, }, }), @@ -93,8 +91,8 @@ it.layer(testLayer)("server CLI command", (it) => { "4010", "--host", "0.0.0.0", - "--state-dir", - "/tmp/t3-cli-state", + "--home-dir", + "/tmp/t3-cli-home", "--dev-url", "http://127.0.0.1:5173", "--no-browser", @@ -106,7 +104,8 @@ it.layer(testLayer)("server CLI command", (it) => { assert.equal(resolvedConfig?.mode, "desktop"); assert.equal(resolvedConfig?.port, 4010); assert.equal(resolvedConfig?.host, "0.0.0.0"); - assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-state"); + assert.equal(resolvedConfig?.baseDir, "/tmp/t3-cli-home"); + assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-home/userdata"); assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); assert.equal(resolvedConfig?.noBrowser, true); assert.equal(resolvedConfig?.authToken, "auth-secret"); @@ -131,7 +130,7 @@ it.layer(testLayer)("server CLI command", (it) => { T3CODE_MODE: "desktop", T3CODE_PORT: "4999", T3CODE_HOST: "100.88.10.4", - T3CODE_STATE_DIR: "/tmp/t3-env-state", + T3CODE_HOME: "/tmp/t3-env-home", VITE_DEV_SERVER_URL: "http://localhost:5173", T3CODE_NO_BROWSER: "true", T3CODE_AUTH_TOKEN: "env-token", @@ -141,7 +140,8 @@ it.layer(testLayer)("server CLI command", (it) => { assert.equal(resolvedConfig?.mode, "desktop"); assert.equal(resolvedConfig?.port, 4999); assert.equal(resolvedConfig?.host, "100.88.10.4"); - assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-state"); + assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home"); + assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-home/userdata"); assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/"); assert.equal(resolvedConfig?.noBrowser, true); assert.equal(resolvedConfig?.authToken, "env-token"); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index f5d3279578..2f597b5a24 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -36,8 +36,7 @@ interface CliInput { readonly mode: Option.Option; readonly port: Option.Option; readonly host: Option.Option; - readonly baseDir: Option.Option; - readonly stateDir: Option.Option; + readonly t3Home: Option.Option; readonly devUrl: Option.Option; readonly noBrowser: Option.Option; readonly authToken: Option.Option; @@ -100,11 +99,7 @@ const CliEnvConfig = Config.all({ ), port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), - baseDir: Config.string("T3CODE_BASE_DIR").pipe(Config.option, Config.map(Option.getOrUndefined)), - stateDir: Config.string("T3CODE_STATE_DIR").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), + t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( Config.option, @@ -155,11 +150,8 @@ const ServerConfigLive = (input: CliInput) => }, }); - const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.baseDir) ?? env.baseDir); - const stateDir = yield* resolveStateDir( - Option.getOrUndefined(input.stateDir) ?? env.stateDir, - baseDir, - ); + const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home); + const stateDir = yield* resolveStateDir(baseDir); const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; @@ -305,12 +297,8 @@ const hostFlag = Flag.string("host").pipe( Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), Flag.optional, ); -const baseDirFlag = Flag.string("base-dir").pipe( - Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_BASE_DIR)."), - Flag.optional, -); -const stateDirFlag = Flag.string("state-dir").pipe( - Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."), +const t3HomeFlag = Flag.string("home-dir").pipe( + Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."), Flag.optional, ); const devUrlFlag = Flag.string("dev-url").pipe( @@ -345,8 +333,7 @@ export const t3Cli = Command.make("t3", { mode: modeFlag, port: portFlag, host: hostFlag, - baseDir: baseDirFlag, - stateDir: stateDirFlag, + t3Home: t3HomeFlag, devUrl: devUrlFlag, noBrowser: noBrowserFlag, authToken: authTokenFlag, diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 3b62605e36..dac17822a3 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -35,10 +35,7 @@ export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) { return resolve(yield* expandHomePath(raw.trim())); }); -export const resolveStateDir = Effect.fn(function* (raw: string | undefined, baseDir: string) { - const { join, resolve } = yield* Path.Path; - if (!raw || raw.trim().length === 0) { - return join(baseDir, "userdata"); - } - return resolve(yield* expandHomePath(raw.trim())); +export const resolveStateDir = Effect.fn(function* (baseDir: string) { + const { join } = yield* Path.Path; + return join(baseDir, "userdata"); }); diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 0bda2c9cf2..e76d187188 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -5,7 +5,6 @@ import { assert, describe, it } from "@effect/vitest"; import { Effect } from "effect"; import { - getDevStateDir, createDevRunnerEnv, findFirstAvailableOffset, resolveModePortOffsets, @@ -47,28 +46,24 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); describe("createDevRunnerEnv", () => { - it.effect("defaults state dir to ~/.t3/dev when not provided", () => + it.effect("defaults T3CODE_HOME to ~/.t3 when not provided", () => Effect.gen(function* () { - const [env, defaultStateDir] = yield* Effect.all([ - createDevRunnerEnv({ - mode: "dev", - baseEnv: {}, - serverOffset: 0, - webOffset: 0, - baseDir: undefined, - stateDir: undefined, - authToken: undefined, - noBrowser: undefined, - autoBootstrapProjectFromCwd: undefined, - logWebSocketEvents: undefined, - host: undefined, - port: undefined, - devUrl: undefined, - }), - getDevStateDir(resolve(homedir(), ".t3")), - ]); + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + t3Home: undefined, + authToken: undefined, + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); - assert.equal(env.T3CODE_STATE_DIR, defaultStateDir); + assert.equal(env.T3CODE_HOME, resolve(homedir(), ".t3")); }), ); @@ -79,8 +74,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { baseEnv: {}, serverOffset: 0, webOffset: 0, - baseDir: "/tmp/custom-base", - stateDir: "/tmp/override-state", + t3Home: "/tmp/custom-t3", authToken: "secret", noBrowser: true, autoBootstrapProjectFromCwd: false, @@ -90,8 +84,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: new URL("http://localhost:7331"), }); - assert.equal(env.T3CODE_BASE_DIR, resolve("/tmp/custom-base")); - assert.equal(env.T3CODE_STATE_DIR, resolve("/tmp/override-state")); + assert.equal(env.T3CODE_HOME, resolve("/tmp/custom-t3")); assert.equal(env.T3CODE_PORT, "4222"); assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); assert.equal(env.T3CODE_NO_BROWSER, "1"); @@ -111,8 +104,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }, serverOffset: 0, webOffset: 0, - baseDir: undefined, - stateDir: undefined, + t3Home: undefined, authToken: undefined, noBrowser: undefined, autoBootstrapProjectFromCwd: undefined, @@ -134,8 +126,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { baseEnv: {}, serverOffset: 0, webOffset: 0, - baseDir: undefined, - stateDir: undefined, + t3Home: undefined, authToken: undefined, noBrowser: undefined, autoBootstrapProjectFromCwd: undefined, @@ -149,15 +140,14 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); - it.effect("uses base dir for state dir when state dir not provided", () => + it.effect("uses custom t3Home when provided", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ mode: "dev", baseEnv: {}, serverOffset: 0, webOffset: 0, - baseDir: "/tmp/my-t3", - stateDir: undefined, + t3Home: "/tmp/my-t3", authToken: undefined, noBrowser: undefined, autoBootstrapProjectFromCwd: undefined, @@ -167,8 +157,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: undefined, }); - assert.equal(env.T3CODE_BASE_DIR, resolve("/tmp/my-t3")); - assert.equal(env.T3CODE_STATE_DIR, resolve("/tmp/my-t3/dev")); + assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); }), ); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 7444a0f1c0..da2b3ebe54 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -14,13 +14,10 @@ const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; -const DEFAULT_BASE_DIR = Effect.map(Effect.service(Path.Path), (path) => +export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => path.join(homedir(), ".t3"), ); -export const getDevStateDir = (baseDir: string): Effect.Effect => - Effect.map(Effect.service(Path.Path), (path) => path.join(baseDir, "dev")); - const MODE_ARGS = { dev: [ "run", @@ -113,21 +110,7 @@ function resolveBaseDir(baseDir: string | undefined): Effect.Effect { - return Effect.gen(function* () { - const path = yield* Path.Path; - const configured = stateDir?.trim(); - - if (configured) { - // Resolve relative paths against cwd (monorepo root) before turbo changes directories. - return path.resolve(configured); - } - - return yield* getDevStateDir(baseDir); + return yield* DEFAULT_T3_HOME; }); } @@ -136,8 +119,7 @@ interface CreateDevRunnerEnvInput { readonly baseEnv: NodeJS.ProcessEnv; readonly serverOffset: number; readonly webOffset: number; - readonly baseDir: string | undefined; - readonly stateDir: string | undefined; + readonly t3Home: string | undefined; readonly authToken: string | undefined; readonly noBrowser: boolean | undefined; readonly autoBootstrapProjectFromCwd: boolean | undefined; @@ -152,8 +134,7 @@ export function createDevRunnerEnv({ baseEnv, serverOffset, webOffset, - baseDir, - stateDir, + t3Home, authToken, noBrowser, autoBootstrapProjectFromCwd, @@ -165,8 +146,7 @@ export function createDevRunnerEnv({ return Effect.gen(function* () { const serverPort = port ?? BASE_SERVER_PORT + serverOffset; const webPort = BASE_WEB_PORT + webOffset; - const resolvedBaseDir = yield* resolveBaseDir(baseDir); - const resolvedStateDir = yield* resolveStateDir(stateDir, resolvedBaseDir); + const resolvedBaseDir = yield* resolveBaseDir(t3Home); const output: NodeJS.ProcessEnv = { ...baseEnv, @@ -175,8 +155,7 @@ export function createDevRunnerEnv({ ELECTRON_RENDERER_PORT: String(webPort), VITE_WS_URL: `ws://localhost:${serverPort}`, VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`, - T3CODE_BASE_DIR: resolvedBaseDir, - T3CODE_STATE_DIR: resolvedStateDir, + T3CODE_HOME: resolvedBaseDir, }; if (host !== undefined) { @@ -355,8 +334,7 @@ export function resolveModePortOffsets({ interface DevRunnerCliInput { readonly mode: DevMode; - readonly baseDir: string | undefined; - readonly stateDir: string | undefined; + readonly t3Home: string | undefined; readonly authToken: string | undefined; readonly noBrowser: boolean | undefined; readonly autoBootstrapProjectFromCwd: boolean | undefined; @@ -436,8 +414,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { baseEnv: process.env, serverOffset, webOffset, - baseDir: input.baseDir, - stateDir: input.stateDir, + t3Home: input.t3Home, authToken: input.authToken, noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser), autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride( @@ -459,7 +436,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { : ""; yield* Effect.logInfo( - `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} stateDir=${String(env.T3CODE_STATE_DIR)}`, + `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${String(env.T3CODE_HOME)}`, ); if (input.dryRun) { @@ -507,13 +484,9 @@ const devRunnerCli = Command.make("dev-runner", { mode: Argument.choice("mode", DEV_RUNNER_MODES).pipe( Argument.withDescription("Development mode to run."), ), - baseDir: Flag.string("base-dir").pipe( - Flag.withDescription("Base directory for all T3 Code data (forwards to T3CODE_BASE_DIR)."), - Flag.withFallbackConfig(optionalStringConfig("T3CODE_BASE_DIR")), - ), - stateDir: Flag.string("state-dir").pipe( - Flag.withDescription("State directory path (forwards to T3CODE_STATE_DIR)."), - Flag.withFallbackConfig(optionalStringConfig("T3CODE_STATE_DIR")), + t3Home: Flag.string("home-dir").pipe( + Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."), + Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")), ), authToken: Flag.string("auth-token").pipe( Flag.withDescription("Auth token (forwards to T3CODE_AUTH_TOKEN)."), diff --git a/turbo.json b/turbo.json index b9d1b56b0a..9359376f59 100644 --- a/turbo.json +++ b/turbo.json @@ -9,8 +9,7 @@ "T3CODE_MODE", "T3CODE_PORT", "T3CODE_NO_BROWSER", - "T3CODE_BASE_DIR", - "T3CODE_STATE_DIR", + "T3CODE_HOME", "T3CODE_AUTH_TOKEN", "T3CODE_DESKTOP_WS_URL" ], From b1cb06852fbcf52ecdd918f954da42e7a96504af Mon Sep 17 00:00:00 2001 From: Mohammed Muzammil Anwar Date: Wed, 11 Mar 2026 22:12:56 +0530 Subject: [PATCH 04/10] enhance state directory resolution to handle dev mode. --- apps/server/src/main.ts | 4 ++-- apps/server/src/os-jank.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 2f597b5a24..7dfece388b 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -150,9 +150,9 @@ const ServerConfigLive = (input: CliInput) => }, }); - const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home); - const stateDir = yield* resolveStateDir(baseDir); const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); + const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home); + const stateDir = yield* resolveStateDir(baseDir, devUrl); const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; const autoBootstrapProjectFromCwd = resolveBooleanFlag( diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index dac17822a3..1e6c41a4d6 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -35,7 +35,10 @@ export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) { return resolve(yield* expandHomePath(raw.trim())); }); -export const resolveStateDir = Effect.fn(function* (baseDir: string) { +export const resolveStateDir = Effect.fn(function* (baseDir: string, devUrl: URL | undefined) { const { join } = yield* Path.Path; + if (devUrl !== undefined) { + return join(baseDir, "dev"); + } return join(baseDir, "userdata"); }); From 517c1efb91d67880185fb9420c957b33d4800dd4 Mon Sep 17 00:00:00 2001 From: Mohammed Muzammil Anwar Date: Fri, 20 Mar 2026 23:58:24 +0530 Subject: [PATCH 05/10] fix(tests): add baseDir option to makeHarness function to claude adapter tests. --- apps/server/src/provider/Layers/ClaudeAdapter.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index fe9889fe9f..7a38c2de53 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -134,6 +134,7 @@ function makeHarness(config?: { readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; readonly cwd?: string; readonly stateDir?: string; + readonly baseDir?: string; }) { const query = new FakeClaudeQuery(); let createInput: @@ -166,6 +167,7 @@ function makeHarness(config?: { ServerConfig.layerTest( config?.cwd ?? "/tmp/claude-adapter-test", config?.stateDir ?? "/tmp", + config?.baseDir ?? "/tmp", ), ), Layer.provideMerge(NodeServices.layer), From 1dca6370b0eabea9f361047c8c6f0074ed7d4592 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 20 Mar 2026 14:34:53 -0700 Subject: [PATCH 06/10] remove state dir and add derivation helpers --- .docs/scripts.md | 2 +- REMOTE.md | 2 +- .../OrchestrationEngineHarness.integration.ts | 13 +- apps/server/src/attachmentPaths.ts | 4 +- apps/server/src/attachmentStore.test.ts | 14 +- apps/server/src/attachmentStore.ts | 8 +- apps/server/src/config.ts | 65 +- .../git/Layers/CodexTextGeneration.test.ts | 29 +- .../src/git/Layers/CodexTextGeneration.ts | 2 +- apps/server/src/git/Layers/GitCore.test.ts | 2 +- apps/server/src/git/Layers/GitCore.ts | 3 +- apps/server/src/git/Layers/GitManager.test.ts | 8 +- apps/server/src/keybindings.test.ts | 35 +- apps/server/src/main.test.ts | 4 +- apps/server/src/main.ts | 10 +- .../Layers/CheckpointReactor.test.ts | 6 +- .../Layers/OrchestrationEngine.test.ts | 11 +- .../Layers/ProjectionPipeline.test.ts | 1559 ++++++++--------- .../Layers/ProjectionPipeline.ts | 2 +- .../Layers/ProviderCommandReactor.test.ts | 10 +- .../Layers/ProviderRuntimeIngestion.test.ts | 2 +- apps/server/src/os-jank.ts | 8 - apps/server/src/persistence/Layers/Sqlite.ts | 9 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 11 +- .../src/provider/Layers/ClaudeAdapter.ts | 6 +- .../src/provider/Layers/CodexAdapter.test.ts | 6 +- .../src/provider/Layers/CodexAdapter.ts | 2 +- apps/server/src/serverLayers.ts | 6 +- apps/server/src/serverLogger.ts | 10 +- apps/server/src/telemetry/Identify.ts | 4 +- .../telemetry/Layers/AnalyticsService.test.ts | 12 +- apps/server/src/terminal/Layers/Manager.ts | 8 +- apps/server/src/wsServer.test.ts | 101 +- apps/server/src/wsServer.ts | 6 +- 34 files changed, 992 insertions(+), 988 deletions(-) diff --git a/.docs/scripts.md b/.docs/scripts.md index f62af9ffb4..b3fcd4b30e 100644 --- a/.docs/scripts.md +++ b/.docs/scripts.md @@ -5,7 +5,7 @@ - `bun run dev:web` — Starts just the Vite dev server for the web app. - Dev commands default `T3CODE_STATE_DIR` to `~/.t3/dev` to keep dev state isolated from desktop/prod state. - Override server CLI-equivalent flags from root dev commands with `--`, for example: - `bun run dev -- --state-dir ~/.t3/another-dev-state` + `bun run dev -- --base-dir ~/.t3-2` - `bun run start` — Runs the production server (serves built web app as static files). - `bun run build` — Builds contracts, web app, and server through Turbo. - `bun run typecheck` — Strict TypeScript checks for all packages. diff --git a/REMOTE.md b/REMOTE.md index 8bbd481dea..5eed2f803e 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -11,7 +11,7 @@ The T3 Code CLI accepts the following configuration options, available either as | `--mode ` | `T3CODE_MODE` | Runtime mode. | | `--port ` | `T3CODE_PORT` | HTTP/WebSocket port. | | `--host
` | `T3CODE_HOST` | Bind interface/address. | -| `--state-dir ` | `T3CODE_STATE_DIR` | State directory. | +| `--base-dir ` | `T3CODE_HOME` | Base directory. | | `--dev-url ` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | | `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | | `--auth-token ` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. | diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 714d30c2f7..b44ccb7a4b 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -233,8 +233,13 @@ export const makeOrchestrationIntegrationHarness = ( : null; const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-")); const workspaceDir = path.join(rootDir, "workspace"); - const stateDir = path.join(rootDir, "state"); - const dbPath = path.join(stateDir, "state.sqlite"); + const serverConfigLayer = ServerConfig.layerTest(workspaceDir, rootDir).pipe( + Layer.provide(NodeServices.layer), + ); + const { stateDir, dbPath } = yield* Effect.gen(function* () { + const serverConfigContext = yield* Layer.build(serverConfigLayer); + return yield* Effect.service(ServerConfig).pipe(Effect.provide(serverConfigContext)); + }).pipe(Scope.use(yield* Scope.make("sequential"))); fs.mkdirSync(workspaceDir, { recursive: true }); fs.mkdirSync(stateDir, { recursive: true }); initializeGitWorkspace(workspaceDir); @@ -262,7 +267,7 @@ export const makeOrchestrationIntegrationHarness = ( }), ).pipe( Layer.provide(makeCodexAdapterLive()), - Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir, rootDir)), + Layer.provideMerge(serverConfigLayer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(providerSessionDirectoryLayer), ); @@ -312,7 +317,7 @@ export const makeOrchestrationIntegrationHarness = ( ); const layer = orchestrationReactorLayer.pipe( Layer.provide(persistenceLayer), - Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir, rootDir)), + Layer.provideMerge(serverConfigLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/attachmentPaths.ts b/apps/server/src/attachmentPaths.ts index c1680f6c08..cd7e1faec8 100644 --- a/apps/server/src/attachmentPaths.ts +++ b/apps/server/src/attachmentPaths.ts @@ -11,7 +11,7 @@ export function normalizeAttachmentRelativePath(rawRelativePath: string): string } export function resolveAttachmentRelativePath(input: { - readonly stateDir: string; + readonly attachmentsDir: string; readonly relativePath: string; }): string | null { const normalizedRelativePath = normalizeAttachmentRelativePath(input.relativePath); @@ -19,7 +19,7 @@ export function resolveAttachmentRelativePath(input: { return null; } - const attachmentsRoot = path.resolve(path.join(input.stateDir, "attachments")); + const attachmentsRoot = path.resolve(input.attachmentsDir); const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath)); if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) { return null; diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index 8e1bc4218e..e92c3d219d 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -44,34 +44,32 @@ describe("attachmentStore", () => { }); it("resolves attachment path by id using the extension that exists on disk", () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-")); + const attachmentsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-")); try { const attachmentId = "thread-1-attachment"; - const attachmentsDir = path.join(stateDir, "attachments"); - fs.mkdirSync(attachmentsDir, { recursive: true }); const pngPath = path.join(attachmentsDir, `${attachmentId}.png`); fs.writeFileSync(pngPath, Buffer.from("hello")); const resolved = resolveAttachmentPathById({ - stateDir, + attachmentsDir, attachmentId, }); expect(resolved).toBe(pngPath); } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); + fs.rmSync(attachmentsDir, { recursive: true, force: true }); } }); it("returns null when no attachment file exists for the id", () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-")); + const attachmentsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-")); try { const resolved = resolveAttachmentPathById({ - stateDir, + attachmentsDir, attachmentId: "thread-1-missing", }); expect(resolved).toBeNull(); } finally { - fs.rmSync(stateDir, { recursive: true, force: true }); + fs.rmSync(attachmentsDir, { recursive: true, force: true }); } }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3440e29fc3..aa85b8c51a 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -66,17 +66,17 @@ export function attachmentRelativePath(attachment: ChatAttachment): string { } export function resolveAttachmentPath(input: { - readonly stateDir: string; + readonly attachmentsDir: string; readonly attachment: ChatAttachment; }): string | null { return resolveAttachmentRelativePath({ - stateDir: input.stateDir, + attachmentsDir: input.attachmentsDir, relativePath: attachmentRelativePath(input.attachment), }); } export function resolveAttachmentPathById(input: { - readonly stateDir: string; + readonly attachmentsDir: string; readonly attachmentId: string; }): string | null { const normalizedId = normalizeAttachmentRelativePath(input.attachmentId); @@ -85,7 +85,7 @@ export function resolveAttachmentPathById(input: { } for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) { const maybePath = resolveAttachmentRelativePath({ - stateDir: input.stateDir, + attachmentsDir: input.attachmentsDir, relativePath: `${normalizedId}${extension}`, }); if (maybePath && existsSync(maybePath)) { diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index ae3e13630d..965fa08ed3 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -12,17 +12,32 @@ export const DEFAULT_PORT = 3773; export type RuntimeMode = "web" | "desktop"; +/** + * ServerDerivedPaths - Derived paths from the base directory. + */ +export interface ServerDerivedPaths { + readonly stateDir: string; + readonly dbPath: string; + readonly keybindingsConfigPath: string; + readonly worktreesDir: string; + readonly attachmentsDir: string; + readonly logsDir: string; + readonly serverLogPath: string; + readonly providerLogsDir: string; + readonly providerEventLogPath: string; + readonly terminalLogsDir: string; + readonly anonymousIdPath: string; +} + /** * ServerConfigShape - Process/runtime configuration required by the server. */ -export interface ServerConfigShape { +export interface ServerConfigShape extends ServerDerivedPaths { readonly mode: RuntimeMode; readonly port: number; readonly host: string | undefined; readonly cwd: string; - readonly keybindingsConfigPath: string; readonly baseDir: string; - readonly stateDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; readonly noBrowser: boolean; @@ -31,32 +46,64 @@ export interface ServerConfigShape { readonly logWebSocketEvents: boolean; } +export const deriveServerPaths = Effect.fn(function* ( + baseDir: ServerConfigShape["baseDir"], + devUrl: ServerConfigShape["devUrl"], +): Effect.fn.Return { + const { join } = yield* Path.Path; + const stateDir = join(baseDir, devUrl !== undefined ? "dev" : "userdata"); + const dbPath = join(stateDir, "state.sqlite"); + const attachmentsDir = join(stateDir, "attachments"); + const logsDir = join(stateDir, "logs"); + const providerLogsDir = join(logsDir, "provider"); + return { + stateDir, + dbPath, + keybindingsConfigPath: join(stateDir, "keybindings.json"), + worktreesDir: join(baseDir, "worktrees"), + attachmentsDir, + logsDir, + serverLogPath: join(logsDir, "server.log"), + providerLogsDir, + providerEventLogPath: join(providerLogsDir, "events.log"), + terminalLogsDir: join(logsDir, "terminals"), + anonymousIdPath: join(stateDir, "anonymous-id"), + }; +}); + /** * ServerConfig - Service tag for server runtime configuration. */ export class ServerConfig extends ServiceMap.Service()( "t3/config/ServerConfig", ) { - static readonly layerTest = (cwd: string, statedir: string, baseDir: string) => + static readonly layerTest = (cwd: string, baseDirOrPrefix: string | { prefix: string }) => Layer.effect( ServerConfig, Effect.gen(function* () { - const path = yield* Path.Path; + const devUrl = undefined; + + const fs = yield* FileSystem.FileSystem; + const baseDir = + typeof baseDirOrPrefix === "string" + ? baseDirOrPrefix + : yield* fs.makeTempDirectoryScoped({ prefix: baseDirOrPrefix.prefix }); + const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); + return { cwd, baseDir, - stateDir: statedir, + ...derivedPaths, mode: "web", autoBootstrapProjectFromCwd: false, logWebSocketEvents: false, port: 0, host: undefined, authToken: undefined, - keybindingsConfigPath: path.join(statedir, "keybindings.json"), staticDir: undefined, - devUrl: undefined, + devUrl, noBrowser: false, - }; + } satisfies ServerConfigShape; }), ); } diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 38cc013918..0170d207fe 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -2,18 +2,20 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; import { expect } from "vitest"; -import path from "node:path"; import { ServerConfig } from "../../config.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { TextGenerationError } from "../Errors.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; -const makeCodexTextGenerationTestLayer = (baseDir: string) => - CodexTextGenerationLive.pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), path.join(baseDir, "userdata"), baseDir)), - Layer.provideMerge(NodeServices.layer), - ); +const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe( + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-codex-text-generation-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); function makeFakeCodexBinary(dir: string) { return Effect.gen(function* () { @@ -187,8 +189,6 @@ function withFakeCodexEnv( ); } -const CodexTextGenerationTestLayer = makeCodexTextGenerationTestLayer(process.cwd()); - it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { it.effect("generates and sanitizes commit messages without branch by default", () => withFakeCodexEnv( @@ -326,9 +326,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const attachmentPath = path.join(process.cwd(), "attachments", `${attachmentId}.png`); - yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true }); + const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); + yield* fs.makeDirectory(attachmentsDir, { recursive: true }); yield* fs.writeFile(attachmentPath, Buffer.from("hello")); const textGeneration = yield* TextGeneration; @@ -364,9 +365,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const imagePath = path.join(process.cwd(), "attachments", `${attachmentId}.png`); - yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true }); + const imagePath = path.join(attachmentsDir, `${attachmentId}.png`); + yield* fs.makeDirectory(attachmentsDir, { recursive: true }); yield* fs.writeFile(imagePath, Buffer.from("hello")); const textGeneration = yield* TextGeneration; @@ -411,8 +413,9 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const missingPath = path.join(process.cwd(), "attachments", `${missingAttachmentId}.png`); + const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); const textGeneration = yield* TextGeneration; diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index a444627c3f..373c191236 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -163,7 +163,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { } const resolvedPath = resolveAttachmentPath({ - stateDir: serverConfig.stateDir, + attachmentsDir: serverConfig.attachmentsDir, attachment, }); if (!resolvedPath || !path.isAbsolute(resolvedPath)) { diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 1fc0b68205..6d50cd2504 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -17,7 +17,7 @@ import { ServerConfig } from "../../config.ts"; // ── Helpers ── const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer)); -const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd()); +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" }); const GitCoreTestLayer = GitCoreLive.pipe( Layer.provide(GitServiceTestLayer), Layer.provide(ServerConfigLayer), diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 965c410acf..74c09da5f9 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -222,8 +222,7 @@ const makeGitCore = Effect.gen(function* () { const git = yield* GitService; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const { baseDir } = yield* ServerConfig; - const worktreesDir = path.join(baseDir, "worktrees"); + const { worktreesDir } = yield* ServerConfig; const executeGit = ( operation: string, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 58cbb19846..e76994f853 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -475,7 +475,9 @@ function makeManager(input?: { }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); - const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd()); + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-manager-test-", + }); const gitCoreLayer = GitCoreLive.pipe( Layer.provideMerge(GitServiceLive), @@ -487,9 +489,7 @@ function makeManager(input?: { Layer.succeed(GitHubCli, gitHubCli), Layer.succeed(TextGeneration, textGeneration), gitCoreLayer, - ).pipe( - Layer.provideMerge(NodeServices.layer), - ); + ).pipe(Layer.provideMerge(NodeServices.layer)); return makeGitManager.pipe( Effect.provide(managerLayer), diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 6954cafc59..b2f1b06260 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -3,7 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect"; -import { ServerConfig, type ServerConfigShape } from "./config"; +import { ServerConfig } from "./config"; import { DEFAULT_KEYBINDINGS, @@ -17,21 +17,22 @@ import { } from "./keybindings"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); -const makeKeybindingsLayer = () => - KeybindingsLive.pipe( - Layer.provideMerge( - Layer.effect( - ServerConfig, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const { join } = yield* Path.Path; - const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-server-config-test-" }); - const configPath = join(dir, "keybindings.json"); - return { keybindingsConfigPath: configPath } as ServerConfigShape; - }), - ), - ), - ); +const makeKeybindingsLayer = () => { + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-keybindings-test-", + }); + const bootstrappedServerConfigLayer = Layer.effect( + ServerConfig, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig; + yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); + return serverConfig; + }), + ).pipe(Layer.provide(serverConfigLayer)); + + return KeybindingsLive.pipe(Layer.provideMerge(bootstrappedServerConfigLayer)); +}; const toDetailResult = (effect: Effect.Effect) => effect.pipe( @@ -42,7 +43,9 @@ const toDetailResult = (effect: Effect.Effect Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)(rules); + yield* fileSystem.makeDirectory(path.dirname(configPath), { recursive: true }); yield* fileSystem.writeFileString(configPath, encoded); }); diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 8ba58d0082..b1e5da0c87 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -105,7 +105,7 @@ it.layer(testLayer)("server CLI command", (it) => { assert.equal(resolvedConfig?.port, 4010); assert.equal(resolvedConfig?.host, "0.0.0.0"); assert.equal(resolvedConfig?.baseDir, "/tmp/t3-cli-home"); - assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-home/userdata"); + assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-home/dev"); assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); assert.equal(resolvedConfig?.noBrowser, true); assert.equal(resolvedConfig?.authToken, "auth-secret"); @@ -141,7 +141,7 @@ it.layer(testLayer)("server CLI command", (it) => { assert.equal(resolvedConfig?.port, 4999); assert.equal(resolvedConfig?.host, "100.88.10.4"); assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home"); - assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-home/userdata"); + assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-home/dev"); assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/"); assert.equal(resolvedConfig?.noBrowser, true); assert.equal(resolvedConfig?.authToken, "env-token"); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 7dfece388b..17bf7f32f7 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -11,12 +11,13 @@ import { Command, Flag } from "effect/unstable/cli"; import { NetService } from "@t3tools/shared/Net"; import { DEFAULT_PORT, + deriveServerPaths, resolveStaticDir, ServerConfig, type RuntimeMode, type ServerConfigShape, } from "./config"; -import { fixPath, resolveBaseDir, resolveStateDir } from "./os-jank"; +import { fixPath, resolveBaseDir } from "./os-jank"; import { Open } from "./open"; import * as SqlitePersistence from "./persistence/Layers/Sqlite"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; @@ -152,7 +153,7 @@ const ServerConfigLive = (input: CliInput) => const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home); - const stateDir = yield* resolveStateDir(baseDir, devUrl); + const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; const autoBootstrapProjectFromCwd = resolveBooleanFlag( @@ -164,8 +165,6 @@ const ServerConfigLive = (input: CliInput) => env.logWebSocketEvents ?? Boolean(devUrl), ); const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; - const { join } = yield* Path.Path; - const keybindingsConfigPath = join(stateDir, "keybindings.json"); const host = Option.getOrUndefined(input.host) ?? env.host ?? @@ -175,10 +174,9 @@ const ServerConfigLive = (input: CliInput) => mode, port, cwd: cliConfig.cwd, - keybindingsConfigPath, host, baseDir, - stateDir, + ...derivedPaths, staticDir, devUrl, noBrowser, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index ced48bc320..260c2e8671 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -252,12 +252,16 @@ describe("CheckpointReactor", () => { Layer.provide(SqlitePersistenceMemory), ); + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-checkpoint-reactor-test-", + }); + const layer = CheckpointReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(CheckpointStoreLive), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 9fca58010f..0aa204d829 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -35,12 +35,15 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); async function createOrchestrationSystem() { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-orchestration-engine-test-", + }); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); const runtime = ManagedRuntime.make(orchestrationLayer); @@ -317,13 +320,17 @@ describe("OrchestrationEngine", () => { }, }; + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-orchestration-engine-test-", + }); + const runtime = ManagedRuntime.make( OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 4915f76446..83ee080fbe 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -10,10 +10,7 @@ import { } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { Effect, Layer, ManagedRuntime } from "effect"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import { Effect, FileSystem, Layer, Path } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -32,32 +29,24 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; -const makeProjectionPipelineTestLayer = (stateDir: string, baseDir: string) => +const makeProjectionPipelinePrefixedTestLayer = (prefix: string) => OrchestrationProjectionPipelineLive.pipe( Layer.provideMerge(OrchestrationEventStoreLive), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir, baseDir)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix })), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), ); -const runWithProjectionPipelineLayer = ( - stateDir: string, - baseDir: string, - effect: Effect.Effect< - A, - E, - OrchestrationProjectionPipeline | OrchestrationEventStore | SqlClient.SqlClient - >, -) => - Effect.acquireUseRelease( - Effect.sync(() => ManagedRuntime.make(makeProjectionPipelineTestLayer(stateDir, baseDir))), - (runtime) => Effect.promise(() => runtime.runPromise(effect)), - (runtime) => Effect.promise(() => runtime.dispose()), - ); +const exists = (filePath: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const fileInfo = yield* Effect.result(fileSystem.stat(filePath)); + return fileInfo._tag === "Success"; + }); -const projectionLayer = it.layer(makeProjectionPipelineTestLayer(process.cwd(), process.cwd())); +const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-"); -projectionLayer("OrchestrationProjectionPipeline", (it) => { +it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect("bootstraps all projection states and writes projection rows", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; @@ -175,168 +164,155 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { } }), ); +}); - it.effect("stores message attachment references without mutating payloads", () => - Effect.sync(() => { - const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-base-")); - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-")); - return { baseDir, stateDir }; - }).pipe( - Effect.flatMap(({ baseDir, stateDir }) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); - - yield* eventStore.append({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-attachments"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-attachments"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-attachments"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-attachments"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-attachments"), - messageId: MessageId.makeUnsafe("message-attachments"), - role: "user", - text: "Inspect this", - attachments: [ - { - type: "image", - id: "thread-attachments-att-1", - name: "example.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("stores message attachment references without mutating payloads", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-attachments"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-attachments"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-attachments"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-attachments"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-attachments"), + messageId: MessageId.makeUnsafe("message-attachments"), + role: "user", + text: "Inspect this", + attachments: [ + { + type: "image", + id: "thread-attachments-att-1", + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); - yield* projectionPipeline.bootstrap; + yield* projectionPipeline.bootstrap; - const rows = yield* sql<{ - readonly attachmentsJson: string | null; - }>` + const rows = yield* sql<{ + readonly attachmentsJson: string | null; + }>` SELECT attachments_json AS "attachmentsJson" FROM projection_thread_messages WHERE message_id = 'message-attachments' `; - assert.equal(rows.length, 1); - assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ - { - type: "image", - id: "thread-attachments-att-1", - name: "example.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ]); - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, baseDir, effect), - Effect.ensuring(Effect.sync(() => { - fs.rmSync(stateDir, { recursive: true, force: true }); - fs.rmSync(baseDir, { recursive: true, force: true }); - })), - ), - ), - ), - ); + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ + { + type: "image", + id: "thread-attachments-att-1", + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ]); + }), + ); + }, +); - it.effect("preserves mixed image attachment metadata as-is", () => - Effect.sync(() => { - const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-safe-")); - const stateDir = path.join(baseDir, "userdata"); - return { baseDir, stateDir }; - }).pipe( - Effect.flatMap(({ baseDir, stateDir }) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); - - yield* eventStore.append({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-attachments-safe"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-attachments-safe"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-attachments-safe"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-attachments-safe"), - messageId: MessageId.makeUnsafe("message-attachments-safe"), - role: "user", - text: "Inspect this", - attachments: [ - { - type: "image", - id: "thread-attachments-safe-att-1", - name: "untrusted.exe", - mimeType: "image/x-unknown", - sizeBytes: 5, - }, - { - type: "image", - id: "thread-attachments-safe-att-2", - name: "not-image.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-safe-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("preserves mixed image attachment metadata as-is", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-attachments-safe"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-attachments-safe"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-attachments-safe"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-attachments-safe"), + messageId: MessageId.makeUnsafe("message-attachments-safe"), + role: "user", + text: "Inspect this", + attachments: [ + { + type: "image", + id: "thread-attachments-safe-att-1", + name: "untrusted.exe", + mimeType: "image/x-unknown", + sizeBytes: 5, + }, + { + type: "image", + id: "thread-attachments-safe-att-2", + name: "not-image.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); - yield* projectionPipeline.bootstrap; + yield* projectionPipeline.bootstrap; - const rows = yield* sql<{ - readonly attachmentsJson: string | null; - }>` + const rows = yield* sql<{ + readonly attachmentsJson: string | null; + }>` SELECT attachments_json AS "attachmentsJson" FROM projection_thread_messages WHERE message_id = 'message-attachments-safe' `; - assert.equal(rows.length, 1); - assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ - { - type: "image", - id: "thread-attachments-safe-att-1", - name: "untrusted.exe", - mimeType: "image/x-unknown", - sizeBytes: 5, - }, - { - type: "image", - id: "thread-attachments-safe-att-2", - name: "not-image.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ]); - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, baseDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(baseDir, { recursive: true, force: true }))), - ), - ), - ), - ); + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ + { + type: "image", + id: "thread-attachments-safe-att-1", + name: "untrusted.exe", + mimeType: "image/x-unknown", + sizeBytes: 5, + }, + { + type: "image", + id: "thread-attachments-safe-att-2", + name: "not-image.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ]); + }), + ); + }, +); +it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect( "passes explicit empty attachment arrays through the projection pipeline to clear attachments", () => @@ -459,138 +435,110 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), []); }), ); +}); +it.layer( + Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-overwrite-")), +)("OrchestrationProjectionPipeline", (it) => { it.effect("overwrites stored attachment references when a message updates attachments", () => - Effect.sync(() => { - const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")); - const stateDir = path.join(baseDir, "userdata"); - return { baseDir, stateDir }; - }).pipe( - Effect.flatMap(({ baseDir, stateDir }) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); - const later = new Date(Date.now() + 1_000).toISOString(); - - yield* eventStore.append({ - type: "project.created", - eventId: EventId.makeUnsafe("evt-overwrite-1"), - aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-overwrite"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-overwrite-1"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-1"), - metadata: {}, - payload: { - projectId: ProjectId.makeUnsafe("project-overwrite"), - title: "Project Overwrite", - workspaceRoot: "/tmp/project-overwrite", - defaultModel: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); - - yield* eventStore.append({ - type: "thread.created", - eventId: EventId.makeUnsafe("evt-overwrite-2"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-overwrite"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-overwrite-2"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-2"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-overwrite"), - projectId: ProjectId.makeUnsafe("project-overwrite"), - title: "Thread Overwrite", - model: "gpt-5-codex", - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }); - - yield* eventStore.append({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-overwrite-3"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-overwrite"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-overwrite-3"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-3"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-overwrite"), - messageId: MessageId.makeUnsafe("message-overwrite"), - role: "user", - text: "first image", - attachments: [ - { - type: "image", - id: "thread-overwrite-att-1", - name: "file.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); - - yield* eventStore.append({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-overwrite-4"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-overwrite"), - occurredAt: later, - commandId: CommandId.makeUnsafe("cmd-overwrite-4"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-overwrite-4"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-overwrite"), - messageId: MessageId.makeUnsafe("message-overwrite"), - role: "user", - text: "", - attachments: [ - { - type: "image", - id: "thread-overwrite-att-2", - name: "file.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: later, - }, - }); + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + const later = new Date(Date.now() + 1_000).toISOString(); - yield* projectionPipeline.bootstrap; + yield* eventStore.append({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-overwrite-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-overwrite"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-overwrite-1"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-overwrite"), + title: "Project Overwrite", + workspaceRoot: "/tmp/project-overwrite", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); - const rows = yield* sql<{ - readonly attachmentsJson: string | null; - }>` - SELECT attachments_json AS "attachmentsJson" - FROM projection_thread_messages - WHERE message_id = 'message-overwrite' - `; - assert.equal(rows.length, 1); - assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-overwrite-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-overwrite-2"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-2"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-overwrite"), + projectId: ProjectId.makeUnsafe("project-overwrite"), + title: "Thread Overwrite", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-overwrite-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-overwrite-3"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-3"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-overwrite"), + messageId: MessageId.makeUnsafe("message-overwrite"), + role: "user", + text: "first image", + attachments: [ + { + type: "image", + id: "thread-overwrite-att-1", + name: "file.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-overwrite-4"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + occurredAt: later, + commandId: CommandId.makeUnsafe("cmd-overwrite-4"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-4"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-overwrite"), + messageId: MessageId.makeUnsafe("message-overwrite"), + role: "user", + text: "", + attachments: [ { type: "image", id: "thread-overwrite-att-2", @@ -598,78 +546,98 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { mimeType: "image/png", sizeBytes: 5, }, - ]); - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, baseDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(baseDir, { recursive: true, force: true }))), - ), - ), - ), + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: later, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly attachmentsJson: string | null; + }>` + SELECT attachments_json AS "attachmentsJson" + FROM projection_thread_messages + WHERE message_id = 'message-overwrite' + `; + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ + { + type: "image", + id: "thread-overwrite-att-2", + name: "file.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ]); + }), ); +}); +it.layer( + Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-rollback-")), +)("OrchestrationProjectionPipeline", (it) => { it.effect("does not persist attachment files when projector transaction rolls back", () => - Effect.sync(() => { - const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-rollback-")); - const stateDir = path.join(baseDir, "userdata"); - return { baseDir, stateDir }; - }).pipe( - Effect.flatMap(({ baseDir, stateDir }) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); - - const appendAndProject = (event: Parameters[0]) => - eventStore - .append(event) - .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); - - yield* appendAndProject({ - type: "project.created", - eventId: EventId.makeUnsafe("evt-rollback-1"), - aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-rollback"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-rollback-1"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"), - metadata: {}, - payload: { - projectId: ProjectId.makeUnsafe("project-rollback"), - title: "Project Rollback", - workspaceRoot: "/tmp/project-rollback", - defaultModel: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.created", - eventId: EventId.makeUnsafe("evt-rollback-2"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-rollback"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-rollback-2"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-rollback"), - projectId: ProjectId.makeUnsafe("project-rollback"), - title: "Thread Rollback", - model: "gpt-5-codex", - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }); + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const path = yield* Path.Path; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); - yield* sql` + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-rollback-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-rollback"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-rollback-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-rollback"), + title: "Project Rollback", + workspaceRoot: "/tmp/project-rollback", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-rollback-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-rollback"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-rollback-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-rollback"), + projectId: ProjectId.makeUnsafe("project-rollback"), + title: "Thread Rollback", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* sql` CREATE TRIGGER fail_thread_messages_projection_state_update BEFORE UPDATE ON projection_state WHEN NEW.projector = 'projection.thread-messages' @@ -678,459 +646,437 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { END; `; - const result = yield* Effect.result( - appendAndProject({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-rollback-3"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-rollback"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-rollback-3"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-rollback"), - messageId: MessageId.makeUnsafe("message-rollback"), - role: "user", - text: "Rollback me", - attachments: [ - { - type: "image", - id: "thread-rollback-att-1", - name: "rollback.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, + const result = yield* Effect.result( + appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-rollback-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-rollback"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-rollback-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-rollback"), + messageId: MessageId.makeUnsafe("message-rollback"), + role: "user", + text: "Rollback me", + attachments: [ + { + type: "image", + id: "thread-rollback-att-1", + name: "rollback.png", + mimeType: "image/png", + sizeBytes: 5, }, - }), - ); - assert.equal(result._tag, "Failure"); + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + assert.equal(result._tag, "Failure"); - const rows = yield* sql<{ - readonly count: number; - }>` + const rows = yield* sql<{ + readonly count: number; + }>` SELECT COUNT(*) AS "count" FROM projection_thread_messages WHERE message_id = 'message-rollback' `; - assert.equal(rows[0]?.count ?? 0, 0); - - const attachmentPath = path.join(stateDir, "attachments", "thread-rollback-att-1.png"); - assert.equal(fs.existsSync(attachmentPath), false); - yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`; - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, baseDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(baseDir, { recursive: true, force: true }))), - ), - ), - ), + assert.equal(rows[0]?.count ?? 0, 0); + + const { attachmentsDir } = yield* ServerConfig; + const attachmentPath = path.join(attachmentsDir, "thread-rollback-att-1.png"); + assert.isFalse(yield* exists(attachmentPath)); + yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`; + }), ); +}); +it.layer( + Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-overwrite-")), +)("OrchestrationProjectionPipeline", (it) => { it.effect("removes unreferenced attachment files when a thread is reverted", () => - Effect.sync(() => { - const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")); - const stateDir = path.join(baseDir, "userdata"); - return { baseDir, stateDir }; - }).pipe( - Effect.flatMap(({ baseDir, stateDir }) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const now = new Date().toISOString(); - const threadId = ThreadId.makeUnsafe("Thread Revert.Files"); - const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001"; - const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002"; - const otherThreadAttachmentId = - "thread-revert-files-extra-00000000-0000-4000-8000-000000000003"; - - const appendAndProject = (event: Parameters[0]) => - eventStore - .append(event) - .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); - - yield* appendAndProject({ - type: "project.created", - eventId: EventId.makeUnsafe("evt-revert-files-1"), - aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-revert-files"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-1"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"), - metadata: {}, - payload: { - projectId: ProjectId.makeUnsafe("project-revert-files"), - title: "Project Revert Files", - workspaceRoot: "/tmp/project-revert-files", - defaultModel: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.created", - eventId: EventId.makeUnsafe("evt-revert-files-2"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-2"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"), - metadata: {}, - payload: { - threadId, - projectId: ProjectId.makeUnsafe("project-revert-files"), - title: "Thread Revert Files", - model: "gpt-5-codex", - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.turn-diff-completed", - eventId: EventId.makeUnsafe("evt-revert-files-3"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-3"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"), - metadata: {}, - payload: { - threadId, - turnId: TurnId.makeUnsafe("turn-keep"), - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.makeUnsafe( - "refs/t3/checkpoints/thread-revert-files/turn/1", - ), - status: "ready", - files: [], - assistantMessageId: MessageId.makeUnsafe("message-keep"), - completedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-revert-files-4"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-4"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"), - metadata: {}, - payload: { - threadId, - messageId: MessageId.makeUnsafe("message-keep"), - role: "assistant", - text: "Keep", - attachments: [ - { - type: "image", - id: keepAttachmentId, - name: "keep.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: TurnId.makeUnsafe("turn-keep"), - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.turn-diff-completed", - eventId: EventId.makeUnsafe("evt-revert-files-5"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-5"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"), - metadata: {}, - payload: { - threadId, - turnId: TurnId.makeUnsafe("turn-remove"), - checkpointTurnCount: 2, - checkpointRef: CheckpointRef.makeUnsafe( - "refs/t3/checkpoints/thread-revert-files/turn/2", - ), - status: "ready", - files: [], - assistantMessageId: MessageId.makeUnsafe("message-remove"), - completedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-revert-files-6"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-6"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"), - metadata: {}, - payload: { - threadId, - messageId: MessageId.makeUnsafe("message-remove"), - role: "assistant", - text: "Remove", - attachments: [ - { - type: "image", - id: removeAttachmentId, - name: "remove.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: TurnId.makeUnsafe("turn-remove"), - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); - - const keepPath = path.join(stateDir, "attachments", `${keepAttachmentId}.png`); - const removePath = path.join(stateDir, "attachments", `${removeAttachmentId}.png`); - fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true }); - fs.writeFileSync(keepPath, Buffer.from("keep")); - fs.writeFileSync(removePath, Buffer.from("remove")); - const otherThreadPath = path.join( - stateDir, - "attachments", - `${otherThreadAttachmentId}.png`, - ); - fs.writeFileSync(otherThreadPath, Buffer.from("other")); - assert.equal(fs.existsSync(keepPath), true); - assert.equal(fs.existsSync(removePath), true); - assert.equal(fs.existsSync(otherThreadPath), true); - - yield* appendAndProject({ - type: "thread.reverted", - eventId: EventId.makeUnsafe("evt-revert-files-7"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-revert-files-7"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"), - metadata: {}, - payload: { - threadId, - turnCount: 1, - }, - }); - - assert.equal(fs.existsSync(keepPath), true); - assert.equal(fs.existsSync(removePath), false); - assert.equal(fs.existsSync(otherThreadPath), true); - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, baseDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(baseDir, { recursive: true, force: true }))), - ), - ), - ), - ); + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const { attachmentsDir } = yield* ServerConfig; + const now = new Date().toISOString(); + const threadId = ThreadId.makeUnsafe("Thread Revert.Files"); + const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001"; + const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002"; + const otherThreadAttachmentId = + "thread-revert-files-extra-00000000-0000-4000-8000-000000000003"; - it.effect("removes thread attachment directory when thread is deleted", () => - Effect.sync(() => { - const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-revert-")); - const stateDir = path.join(baseDir, "userdata"); - return { baseDir, stateDir }; - }).pipe( - Effect.flatMap(({ baseDir, stateDir }) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const now = new Date().toISOString(); - const threadId = ThreadId.makeUnsafe("Thread Delete.Files"); - const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001"; - const otherThreadAttachmentId = - "thread-delete-files-extra-00000000-0000-4000-8000-000000000002"; - - const appendAndProject = (event: Parameters[0]) => - eventStore - .append(event) - .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); - - yield* appendAndProject({ - type: "project.created", - eventId: EventId.makeUnsafe("evt-delete-files-1"), - aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-delete-files"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-1"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"), - metadata: {}, - payload: { - projectId: ProjectId.makeUnsafe("project-delete-files"), - title: "Project Delete Files", - workspaceRoot: "/tmp/project-delete-files", - defaultModel: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.created", - eventId: EventId.makeUnsafe("evt-delete-files-2"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-2"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"), - metadata: {}, - payload: { - threadId, - projectId: ProjectId.makeUnsafe("project-delete-files"), - title: "Thread Delete Files", - model: "gpt-5-codex", - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-delete-files-3"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-3"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"), - metadata: {}, - payload: { - threadId, - messageId: MessageId.makeUnsafe("message-delete-files"), - role: "user", - text: "Delete", - attachments: [ - { - type: "image", - id: attachmentId, - name: "delete.png", - mimeType: "image/png", - sizeBytes: 5, - }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); - - const threadAttachmentPath = path.join(stateDir, "attachments", `${attachmentId}.png`); - const otherThreadAttachmentPath = path.join( - stateDir, - "attachments", - `${otherThreadAttachmentId}.png`, - ); - fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true }); - fs.writeFileSync(threadAttachmentPath, Buffer.from("delete")); - fs.writeFileSync(otherThreadAttachmentPath, Buffer.from("other-thread")); - assert.equal(fs.existsSync(threadAttachmentPath), true); - assert.equal(fs.existsSync(otherThreadAttachmentPath), true); - - yield* appendAndProject({ - type: "thread.deleted", - eventId: EventId.makeUnsafe("evt-delete-files-4"), - aggregateKind: "thread", - aggregateId: threadId, - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-delete-files-4"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"), - metadata: {}, - payload: { - threadId, - deletedAt: now, + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-revert-files-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-revert-files"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-revert-files"), + title: "Project Revert Files", + workspaceRoot: "/tmp/project-revert-files", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-revert-files-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.makeUnsafe("project-revert-files"), + title: "Thread Revert Files", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.turn-diff-completed", + eventId: EventId.makeUnsafe("evt-revert-files-3"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"), + metadata: {}, + payload: { + threadId, + turnId: TurnId.makeUnsafe("turn-keep"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"), + status: "ready", + files: [], + assistantMessageId: MessageId.makeUnsafe("message-keep"), + completedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-revert-files-4"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-4"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.makeUnsafe("message-keep"), + role: "assistant", + text: "Keep", + attachments: [ + { + type: "image", + id: keepAttachmentId, + name: "keep.png", + mimeType: "image/png", + sizeBytes: 5, }, - }); - - assert.equal(fs.existsSync(threadAttachmentPath), false); - assert.equal(fs.existsSync(otherThreadAttachmentPath), true); - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, baseDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(baseDir, { recursive: true, force: true }))), - ), - ), - ), - ); + ], + turnId: TurnId.makeUnsafe("turn-keep"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); - it.effect("ignores unsafe thread ids for attachment cleanup paths", () => - Effect.sync(() => { - const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-delete-")); - const stateDir = path.join(baseDir, "userdata"); - return { baseDir, stateDir }; - }).pipe( - Effect.flatMap(({ baseDir, stateDir }) => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const now = new Date().toISOString(); - const attachmentsRootDir = path.join(stateDir, "attachments"); - const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt"); - const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt"); - fs.mkdirSync(attachmentsRootDir, { recursive: true }); - fs.writeFileSync(attachmentsSentinelPath, "keep-attachments-root", "utf8"); - fs.writeFileSync(stateDirSentinelPath, "keep-state-dir", "utf8"); - - yield* eventStore.append({ - type: "thread.deleted", - eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe(".."), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe(".."), - deletedAt: now, + yield* appendAndProject({ + type: "thread.turn-diff-completed", + eventId: EventId.makeUnsafe("evt-revert-files-5"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-5"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"), + metadata: {}, + payload: { + threadId, + turnId: TurnId.makeUnsafe("turn-remove"), + checkpointTurnCount: 2, + checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"), + status: "ready", + files: [], + assistantMessageId: MessageId.makeUnsafe("message-remove"), + completedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-revert-files-6"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-6"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.makeUnsafe("message-remove"), + role: "assistant", + text: "Remove", + attachments: [ + { + type: "image", + id: removeAttachmentId, + name: "remove.png", + mimeType: "image/png", + sizeBytes: 5, }, - }); + ], + turnId: TurnId.makeUnsafe("turn-remove"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); - yield* projectionPipeline.bootstrap; + const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`); + const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`); + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(keepPath, "keep"); + yield* fileSystem.writeFileString(removePath, "remove"); + const otherThreadPath = path.join(attachmentsDir, `${otherThreadAttachmentId}.png`); + yield* fileSystem.writeFileString(otherThreadPath, "other"); + assert.isTrue(yield* exists(keepPath)); + assert.isTrue(yield* exists(removePath)); + assert.isTrue(yield* exists(otherThreadPath)); - assert.equal(fs.existsSync(attachmentsRootDir), true); - assert.equal(fs.existsSync(attachmentsSentinelPath), true); - assert.equal(fs.existsSync(stateDirSentinelPath), true); - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, baseDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(baseDir, { recursive: true, force: true }))), - ), - ), - ), + yield* appendAndProject({ + type: "thread.reverted", + eventId: EventId.makeUnsafe("evt-revert-files-7"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-7"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"), + metadata: {}, + payload: { + threadId, + turnCount: 1, + }, + }); + + assert.isTrue(yield* exists(keepPath)); + assert.isFalse(yield* exists(removePath)); + assert.isTrue(yield* exists(otherThreadPath)); + }), ); +}); + +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-revert-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("removes thread attachment directory when thread is deleted", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const { attachmentsDir } = yield* ServerConfig; + const now = new Date().toISOString(); + const threadId = ThreadId.makeUnsafe("Thread Delete.Files"); + const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001"; + const otherThreadAttachmentId = + "thread-delete-files-extra-00000000-0000-4000-8000-000000000002"; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-delete-files-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-delete-files"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-delete-files-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-delete-files"), + title: "Project Delete Files", + workspaceRoot: "/tmp/project-delete-files", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-delete-files-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-delete-files-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.makeUnsafe("project-delete-files"), + title: "Thread Delete Files", + model: "gpt-5-codex", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-delete-files-3"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-delete-files-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.makeUnsafe("message-delete-files"), + role: "user", + text: "Delete", + attachments: [ + { + type: "image", + id: attachmentId, + name: "delete.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + const threadAttachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); + const otherThreadAttachmentPath = path.join( + attachmentsDir, + `${otherThreadAttachmentId}.png`, + ); + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(threadAttachmentPath, "delete"); + yield* fileSystem.writeFileString(otherThreadAttachmentPath, "other-thread"); + assert.isTrue(yield* exists(threadAttachmentPath)); + assert.isTrue(yield* exists(otherThreadAttachmentPath)); + + yield* appendAndProject({ + type: "thread.deleted", + eventId: EventId.makeUnsafe("evt-delete-files-4"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-delete-files-4"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"), + metadata: {}, + payload: { + threadId, + deletedAt: now, + }, + }); + + assert.isFalse(yield* exists(threadAttachmentPath)); + assert.isTrue(yield* exists(otherThreadAttachmentPath)); + }), + ); + }, +); + +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-delete-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("ignores unsafe thread ids for attachment cleanup paths", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const now = new Date().toISOString(); + const { attachmentsDir: attachmentsRootDir, stateDir } = yield* ServerConfig; + const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt"); + const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt"); + yield* fileSystem.makeDirectory(attachmentsRootDir, { recursive: true }); + yield* fileSystem.writeFileString(attachmentsSentinelPath, "keep-attachments-root"); + yield* fileSystem.writeFileString(stateDirSentinelPath, "keep-state-dir"); + + yield* eventStore.append({ + type: "thread.deleted", + eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe(".."), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe(".."), + deletedAt: now, + }, + }); + + yield* projectionPipeline.bootstrap; + + assert.isTrue(yield* exists(attachmentsRootDir)); + assert.isTrue(yield* exists(attachmentsSentinelPath)); + assert.isTrue(yield* exists(stateDirSentinelPath)); + }), + ); + }, +); + +it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect("resumes from projector last_applied_sequence without replaying older events", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; @@ -1736,8 +1682,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { it.effect("restores pending turn-start metadata across projection pipeline restart", () => Effect.gen(function* () { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-pipeline-restart-")); - const dbPath = path.join(tempDir, "orchestration.sqlite"); + const { dbPath } = yield* ServerConfig; const persistenceLayer = makeSqlitePersistenceLive(dbPath); const firstProjectionLayer = OrchestrationProjectionPipelineLive.pipe( Layer.provideMerge(OrchestrationEventStoreLive), @@ -1852,12 +1797,12 @@ it.effect("restores pending turn-start metadata across projection pipeline resta startedAt: turnStartedAt, }, ]); - - fs.rmSync(tempDir, { recursive: true, force: true }); }).pipe( Effect.provide( Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd()), + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-projection-pipeline-restart-", + }), NodeServices.layer, ), ), @@ -1870,7 +1815,11 @@ const engineLayer = it.layer( Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provideMerge(SqlitePersistenceMemory), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-projection-pipeline-engine-dispatch-", + }), + ), Layer.provideMerge(NodeServices.layer), ), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index d46764cc8c..0651dab646 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -243,7 +243,7 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid const fileSystem = yield* Effect.service(FileSystem.FileSystem); const path = yield* Effect.service(Path.Path); - const attachmentsRootDir = path.join(serverConfig.stateDir, "attachments"); + const attachmentsRootDir = serverConfig.attachmentsDir; yield* Effect.forEach( sideEffects.deletedThreadIds, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index b36e1b94b0..5a7084a61b 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -16,7 +16,7 @@ import { import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { ServerConfig } from "../../config.ts"; +import { deriveServerPaths, ServerConfig } from "../../config.ts"; import { TextGenerationError } from "../../git/Errors.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; @@ -41,6 +41,9 @@ const asApprovalRequestId = (value: string): ApprovalRequestId => const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); +const deriveServerPathsSync = (baseDir: string, devUrl: URL | undefined) => + Effect.runSync(deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer))); + async function waitFor( predicate: () => boolean | Promise, timeoutMs = 2000, @@ -90,13 +93,12 @@ describe("ProviderCommandReactor", () => { async function createHarness(input?: { readonly baseDir?: string; - readonly stateDir?: string; readonly threadModel?: string; }) { const now = new Date().toISOString(); const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); createdBaseDirs.add(baseDir); - const stateDir = input?.stateDir ?? path.join(baseDir, "state-file"); + const { stateDir } = deriveServerPathsSync(baseDir, undefined); createdStateDirs.add(stateDir); const threadModel = input?.threadModel ?? "gpt-5-codex"; const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); @@ -222,7 +224,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge( Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), ), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir, baseDir)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), ); const runtime = ManagedRuntime.make(layer); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index fee4ef9ced..c1ba48108f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -169,7 +169,7 @@ describe("ProviderRuntimeIngestion", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ); runtime = ManagedRuntime.make(layer); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 1e6c41a4d6..0721d0d9f8 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -34,11 +34,3 @@ export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) { } return resolve(yield* expandHomePath(raw.trim())); }); - -export const resolveStateDir = Effect.fn(function* (baseDir: string, devUrl: URL | undefined) { - const { join } = yield* Path.Path; - if (devUrl !== undefined) { - return join(baseDir, "dev"); - } - return join(baseDir, "userdata"); -}); diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 33f99482d9..c430e79efb 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -49,9 +49,6 @@ export const SqlitePersistenceMemory = Layer.provideMerge( makeRuntimeSqliteLayer({ filename: ":memory:" }), ); -export const layerConfig = Effect.gen(function* () { - const { stateDir } = yield* ServerConfig; - const { join } = yield* Path.Path; - const dbPath = join(stateDir, "state.sqlite"); - return makeSqlitePersistenceLive(dbPath); -}).pipe(Layer.unwrap); +export const layerConfig = Layer.unwrap( + Effect.map(Effect.service(ServerConfig), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)), +); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 7a38c2de53..f60b2b1e90 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -133,7 +133,6 @@ function makeHarness(config?: { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; readonly cwd?: string; - readonly stateDir?: string; readonly baseDir?: string; }) { const query = new FakeClaudeQuery(); @@ -166,7 +165,6 @@ function makeHarness(config?: { Layer.provideMerge( ServerConfig.layerTest( config?.cwd ?? "/tmp/claude-adapter-test", - config?.stateDir ?? "/tmp", config?.baseDir ?? "/tmp", ), ), @@ -517,15 +515,15 @@ describe("ClaudeAdapterLive", () => { }); it.effect("embeds image attachments in Claude user messages", () => { - const stateDir = mkdtempSync(path.join(os.tmpdir(), "claude-attachments-")); + const baseDir = mkdtempSync(path.join(os.tmpdir(), "claude-attachments-")); const harness = makeHarness({ cwd: "/tmp/project-claude-attachments", - stateDir, + baseDir, }); return Effect.gen(function* () { yield* Effect.addFinalizer(() => Effect.sync(() => - rmSync(stateDir, { + rmSync(baseDir, { recursive: true, force: true, }), @@ -533,6 +531,7 @@ describe("ClaudeAdapterLive", () => { ); const adapter = yield* ClaudeAdapter; + const { attachmentsDir } = yield* ServerConfig; const attachment = { type: "image" as const, @@ -541,7 +540,7 @@ describe("ClaudeAdapterLive", () => { mimeType: "image/png", sizeBytes: 4, }; - const attachmentPath = path.join(stateDir, "attachments", attachmentRelativePath(attachment)); + const attachmentPath = path.join(attachmentsDir, attachmentRelativePath(attachment)); mkdirSync(path.dirname(attachmentPath), { recursive: true }); writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4])); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 43adc70f1a..6fbe6d43b1 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -474,7 +474,7 @@ function buildUserMessageEffect( input: ProviderSendTurnInput, dependencies: { readonly fileSystem: FileSystem.FileSystem; - readonly stateDir: string; + readonly attachmentsDir: string; }, ): Effect.Effect { return Effect.gen(function* () { @@ -499,7 +499,7 @@ function buildUserMessageEffect( } const attachmentPath = resolveAttachmentPath({ - stateDir: dependencies.stateDir, + attachmentsDir: dependencies.attachmentsDir, attachment, }); if (!attachmentPath) { @@ -2765,7 +2765,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const message = yield* buildUserMessageEffect(input, { fileSystem, - stateDir: serverConfig.stateDir, + attachmentsDir: serverConfig.attachmentsDir, }); yield* Queue.offer(context.promptQueue, { diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 35fff59a01..31d394c3ec 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -150,7 +150,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory const validationManager = new FakeCodexManager(); const validationLayer = it.layer( makeCodexAdapterLive({ manager: validationManager }).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), @@ -214,7 +214,7 @@ sessionErrorManager.sendTurnImpl.mockImplementation(async () => { }); const sessionErrorLayer = it.layer( makeCodexAdapterLive({ manager: sessionErrorManager }).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), @@ -281,7 +281,7 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { const lifecycleManager = new FakeCodexManager(); const lifecycleLayer = it.layer( makeCodexAdapterLive({ manager: lifecycleManager }).pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd(), process.cwd())), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(providerSessionDirectoryTestLayer), Layer.provideMerge(NodeServices.layer), ), diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index f87284d123..e2fcebe1bc 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1336,7 +1336,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => (attachment) => Effect.gen(function* () { const attachmentPath = resolveAttachmentPath({ - stateDir: serverConfig.stateDir, + attachmentsDir: serverConfig.attachmentsDir, attachment, }); if (!attachmentPath) { diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 4486920dae..7250f8566c 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -1,5 +1,3 @@ -import path from "node:path"; - import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect, FileSystem, Layer } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -44,9 +42,7 @@ export function makeServerProviderLayer(): Layer.Layer< SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService > { return Effect.gen(function* () { - const { stateDir } = yield* ServerConfig; - const providerLogsDir = path.join(stateDir, "logs", "provider"); - const providerEventLogPath = path.join(providerLogsDir, "events.log"); + const { providerEventLogPath } = yield* ServerConfig; const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { stream: "native", }); diff --git a/apps/server/src/serverLogger.ts b/apps/server/src/serverLogger.ts index de6b27f429..1b90babaad 100644 --- a/apps/server/src/serverLogger.ts +++ b/apps/server/src/serverLogger.ts @@ -1,5 +1,4 @@ import fs from "node:fs"; -import path from "node:path"; import { Effect, Logger } from "effect"; import * as Layer from "effect/Layer"; @@ -7,16 +6,13 @@ import * as Layer from "effect/Layer"; import { ServerConfig } from "./config"; export const ServerLoggerLive = Effect.gen(function* () { - const config = yield* ServerConfig; - - const logDir = path.join(config.stateDir, "logs"); - const logPath = path.join(logDir, "server.log"); + const { logsDir, serverLogPath } = yield* ServerConfig; yield* Effect.sync(() => { - fs.mkdirSync(logDir, { recursive: true }); + fs.mkdirSync(logsDir, { recursive: true }); }); - const fileLogger = Logger.formatSimple.pipe(Logger.toFile(logPath)); + const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath)); return Logger.layer([Logger.defaultLogger, fileLogger], { mergeWithExisting: false, diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index cf6ac72178..0ec0dc500c 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -39,10 +39,8 @@ const getCodexAccountId = Effect.gen(function* () { const upsertAnonymousId = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const serverConfig = yield* ServerConfig; + const { anonymousIdPath } = yield* ServerConfig; - const anonymousIdPath = path.join(serverConfig.stateDir, "anonymous-id"); const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe( Effect.catch(() => Effect.gen(function* () { diff --git a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts index f4a01a22a3..5fe0795ce4 100644 --- a/apps/server/src/telemetry/Layers/AnalyticsService.test.ts +++ b/apps/server/src/telemetry/Layers/AnalyticsService.test.ts @@ -1,7 +1,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { ConfigProvider, Effect, FileSystem, Layer } from "effect"; +import { ConfigProvider, Effect, Layer } from "effect"; import * as HttpServer from "effect/unstable/http/HttpServer"; import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; @@ -37,14 +37,10 @@ interface RecordedBatchBody { it.layer(NodeServices.layer)("AnalyticsService test", (it) => { it.effect("flush drains all buffered events across multiple batches", () => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-telemetry-base-" }); - const stateDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-telemetry-flush-", - }); - const capturedRequests: Array = []; - const serverConfigLayer = ServerConfig.layerTest(process.cwd(), stateDir, baseDir); + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-telemetry-base-", + }); const telemetryLayer = AnalyticsServiceLayerLive.pipe(Layer.provideMerge(serverConfigLayer)); const configLayer = ConfigProvider.layer( diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index f84e7b5930..8c71834e9e 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -13,7 +13,7 @@ import { type TerminalEvent, type TerminalSessionSnapshot, } from "@t3tools/contracts"; -import { Effect, Encoding, Layer, Path, Schema } from "effect"; +import { Effect, Encoding, Layer, Schema } from "effect"; import { createLogger } from "../../logger"; import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY"; @@ -1172,13 +1172,11 @@ export class TerminalManagerRuntime extends EventEmitter export const TerminalManagerLive = Layer.effect( TerminalManager, Effect.gen(function* () { - const { stateDir } = yield* ServerConfig; - const { join } = yield* Path.Path; - const logsDir = join(stateDir, "logs", "terminals"); + const { terminalLogsDir } = yield* ServerConfig; const ptyAdapter = yield* PtyAdapter; const runtime = yield* Effect.acquireRelease( - Effect.sync(() => new TerminalManagerRuntime({ logsDir, ptyAdapter })), + Effect.sync(() => new TerminalManagerRuntime({ logsDir: terminalLogsDir, ptyAdapter })), (r) => Effect.sync(() => r.dispose()), ); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 479a074a8f..9c6adfeba9 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -8,7 +8,7 @@ import { Effect, Exit, Layer, PlatformError, PubSub, Scope, Stream } from "effec import { describe, expect, it, afterEach, vi } from "vitest"; import { createServer } from "./wsServer"; import WebSocket from "ws"; -import { ServerConfig, type ServerConfigShape } from "./config"; +import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; import { @@ -451,6 +451,16 @@ function expectAvailableEditors(value: unknown): void { } } +function ensureParentDir(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) { + return Effect.runSync( + deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer)), + ); +} + describe("WebSocket Server", () => { let server: Http.Server | null = null; let serverScope: Scope.Closeable | null = null; @@ -475,7 +485,6 @@ describe("WebSocket Server", () => { devUrl?: string; authToken?: string; baseDir?: string; - stateDir?: string; staticDir?: string; providerLayer?: Layer.Layer; providerHealth?: ProviderHealthShape; @@ -490,7 +499,8 @@ describe("WebSocket Server", () => { } const baseDir = options.baseDir ?? makeTempDir("t3code-ws-base-"); - const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); + const devUrl = options.devUrl ? new URL(options.devUrl) : undefined; + const derivedPaths = deriveServerPathsSync(baseDir, devUrl); const scope = await Effect.runPromise(Scope.make("sequential")); const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; const providerLayer = options.providerLayer ?? makeServerProviderLayer(); @@ -504,11 +514,10 @@ describe("WebSocket Server", () => { port: 0, host: undefined, cwd: options.cwd ?? "/test/project", - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), baseDir, - stateDir, + ...derivedPaths, staticDir: options.staticDir, - devUrl: options.devUrl ? new URL(options.devUrl) : undefined, + devUrl, noBrowser: true, authToken: options.authToken, autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, @@ -593,12 +602,13 @@ describe("WebSocket Server", () => { }); it("serves persisted attachments from stateDir", async () => { - const stateDir = makeTempDir("t3code-state-attachments-"); - const attachmentPath = path.join(stateDir, "attachments", "thread-a", "message-a", "0.png"); + const baseDir = makeTempDir("t3code-state-attachments-"); + const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined); + const attachmentPath = path.join(attachmentsDir, "thread-a", "message-a", "0.png"); fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment")); - server = await createTestServer({ cwd: "/test/project", stateDir }); + server = await createTestServer({ cwd: "/test/project", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -611,10 +621,10 @@ describe("WebSocket Server", () => { }); it("serves persisted attachments for URL-encoded paths", async () => { - const stateDir = makeTempDir("t3code-state-attachments-encoded-"); + const baseDir = makeTempDir("t3code-state-attachments-encoded-"); + const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined); const attachmentPath = path.join( - stateDir, - "attachments", + attachmentsDir, "thread%20folder", "message%20folder", "file%20name.png", @@ -622,7 +632,7 @@ describe("WebSocket Server", () => { fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); fs.writeFileSync(attachmentPath, Buffer.from("hello-encoded-attachment")); - server = await createTestServer({ cwd: "/test/project", stateDir }); + server = await createTestServer({ cwd: "/test/project", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -637,11 +647,11 @@ describe("WebSocket Server", () => { }); it("serves static index for root path", async () => { - const stateDir = makeTempDir("t3code-state-static-root-"); + const baseDir = makeTempDir("t3code-state-static-root-"); const staticDir = makeTempDir("t3code-static-root-"); fs.writeFileSync(path.join(staticDir, "index.html"), "

static-root

", "utf8"); - server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); + server = await createTestServer({ cwd: "/test/project", baseDir, staticDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -652,11 +662,11 @@ describe("WebSocket Server", () => { }); it("rejects static path traversal attempts", async () => { - const stateDir = makeTempDir("t3code-state-static-traversal-"); + const baseDir = makeTempDir("t3code-state-static-traversal-"); const staticDir = makeTempDir("t3code-static-traversal-"); fs.writeFileSync(path.join(staticDir, "index.html"), "

safe

", "utf8"); - server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); + server = await createTestServer({ cwd: "/test/project", baseDir, staticDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); @@ -734,15 +744,16 @@ describe("WebSocket Server", () => { }); it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => { - const stateDir = makeTempDir("t3code-state-bootstrap-existing-"); - const persistenceLayer = makeSqlitePersistenceLive(path.join(stateDir, "state.sqlite")).pipe( + const baseDir = makeTempDir("t3code-state-bootstrap-existing-"); + const { dbPath } = deriveServerPathsSync(baseDir, undefined); + const persistenceLayer = makeSqlitePersistenceLive(dbPath).pipe( Layer.provide(NodeServices.layer), ); const cwd = "/test/bootstrap-existing"; server = await createTestServer({ cwd, - stateDir, + baseDir, persistenceLayer, autoBootstrapProjectFromCwd: true, }); @@ -765,7 +776,7 @@ describe("WebSocket Server", () => { server = await createTestServer({ cwd, - stateDir, + baseDir, persistenceLayer, autoBootstrapProjectFromCwd: true, }); @@ -814,11 +825,12 @@ describe("WebSocket Server", () => { }); it("responds to server.getConfig", async () => { - const stateDir = makeTempDir("t3code-state-get-config-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); + const baseDir = makeTempDir("t3code-state-get-config-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); fs.writeFileSync(keybindingsPath, "[]", "utf8"); - server = await createTestServer({ cwd: "/my/workspace", stateDir }); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -839,11 +851,11 @@ describe("WebSocket Server", () => { }); it("bootstraps default keybindings file when missing", async () => { - const stateDir = makeTempDir("t3code-state-bootstrap-keybindings-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); + const baseDir = makeTempDir("t3code-state-bootstrap-keybindings-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); expect(fs.existsSync(keybindingsPath)).toBe(false); - server = await createTestServer({ cwd: "/my/workspace", stateDir }); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -869,11 +881,12 @@ describe("WebSocket Server", () => { }); it("falls back to defaults and reports malformed keybindings config issues", async () => { - const stateDir = makeTempDir("t3code-state-malformed-keybindings-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); + const baseDir = makeTempDir("t3code-state-malformed-keybindings-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); fs.writeFileSync(keybindingsPath, "{ not-json", "utf8"); - server = await createTestServer({ cwd: "/my/workspace", stateDir }); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -900,8 +913,9 @@ describe("WebSocket Server", () => { }); it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => { - const stateDir = makeTempDir("t3code-state-partial-invalid-keybindings-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); + const baseDir = makeTempDir("t3code-state-partial-invalid-keybindings-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); fs.writeFileSync( keybindingsPath, JSON.stringify([ @@ -912,7 +926,7 @@ describe("WebSocket Server", () => { "utf8", ); - server = await createTestServer({ cwd: "/my/workspace", stateDir }); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -951,11 +965,12 @@ describe("WebSocket Server", () => { }); it("pushes server.configUpdated issues when keybindings file changes", async () => { - const stateDir = makeTempDir("t3code-state-keybindings-watch-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); + const baseDir = makeTempDir("t3code-state-keybindings-watch-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); fs.writeFileSync(keybindingsPath, "[]", "utf8"); - server = await createTestServer({ cwd: "/my/workspace", stateDir }); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1011,8 +1026,9 @@ describe("WebSocket Server", () => { }); it("reads keybindings from the configured state directory", async () => { - const stateDir = makeTempDir("t3code-state-keybindings-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); + const baseDir = makeTempDir("t3code-state-keybindings-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); fs.writeFileSync( keybindingsPath, JSON.stringify([ @@ -1022,7 +1038,7 @@ describe("WebSocket Server", () => { ]), "utf8", ); - server = await createTestServer({ cwd: "/my/workspace", stateDir }); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1046,15 +1062,16 @@ describe("WebSocket Server", () => { }); it("upserts keybinding rules and updates cached server config", async () => { - const stateDir = makeTempDir("t3code-state-upsert-keybinding-"); - const keybindingsPath = path.join(stateDir, "keybindings.json"); + const baseDir = makeTempDir("t3code-state-upsert-keybinding-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); fs.writeFileSync( keybindingsPath, JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), "utf8", ); - server = await createTestServer({ cwd: "/my/workspace", stateDir }); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 2e6ac51b7f..e22c23988b 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -370,7 +370,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }; const attachmentPath = resolveAttachmentPath({ - stateDir: serverConfig.stateDir, + attachmentsDir: serverConfig.attachmentsDir, attachment: persistedAttachment, }); if (!attachmentPath) { @@ -440,11 +440,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); const filePath = isIdLookup ? resolveAttachmentPathById({ - stateDir: serverConfig.stateDir, + attachmentsDir: serverConfig.attachmentsDir, attachmentId: normalizedRelativePath, }) : resolveAttachmentRelativePath({ - stateDir: serverConfig.stateDir, + attachmentsDir: serverConfig.attachmentsDir, relativePath: normalizedRelativePath, }); if (!filePath) { From cd906ee7938be60e33ccc5fcd0b213da7ebbc4b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 20 Mar 2026 14:42:11 -0700 Subject: [PATCH 07/10] simp --- .../integration/OrchestrationEngineHarness.integration.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index b44ccb7a4b..ac53f60f58 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -66,7 +66,7 @@ import { makeTestProviderAdapterHarness, type TestProviderAdapterHarness, } from "./TestProviderAdapter.integration.ts"; -import { ServerConfig } from "../src/config.ts"; +import { deriveServerPaths, ServerConfig } from "../src/config.ts"; function runGit(cwd: string, args: ReadonlyArray) { return execFileSync("git", args, { @@ -236,10 +236,7 @@ export const makeOrchestrationIntegrationHarness = ( const serverConfigLayer = ServerConfig.layerTest(workspaceDir, rootDir).pipe( Layer.provide(NodeServices.layer), ); - const { stateDir, dbPath } = yield* Effect.gen(function* () { - const serverConfigContext = yield* Layer.build(serverConfigLayer); - return yield* Effect.service(ServerConfig).pipe(Effect.provide(serverConfigContext)); - }).pipe(Scope.use(yield* Scope.make("sequential"))); + const { stateDir, dbPath } = yield* deriveServerPaths(rootDir, undefined); fs.mkdirSync(workspaceDir, { recursive: true }); fs.mkdirSync(stateDir, { recursive: true }); initializeGitWorkspace(workspaceDir); From 4200a3978885518643b6337f59def520a45d75f6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 20 Mar 2026 14:43:15 -0700 Subject: [PATCH 08/10] nit --- .../integration/OrchestrationEngineHarness.integration.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index ac53f60f58..f02cc94b68 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -233,9 +233,6 @@ export const makeOrchestrationIntegrationHarness = ( : null; const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-")); const workspaceDir = path.join(rootDir, "workspace"); - const serverConfigLayer = ServerConfig.layerTest(workspaceDir, rootDir).pipe( - Layer.provide(NodeServices.layer), - ); const { stateDir, dbPath } = yield* deriveServerPaths(rootDir, undefined); fs.mkdirSync(workspaceDir, { recursive: true }); fs.mkdirSync(stateDir, { recursive: true }); @@ -264,7 +261,7 @@ export const makeOrchestrationIntegrationHarness = ( }), ).pipe( Layer.provide(makeCodexAdapterLive()), - Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(providerSessionDirectoryLayer), ); @@ -314,7 +311,7 @@ export const makeOrchestrationIntegrationHarness = ( ); const layer = orchestrationReactorLayer.pipe( Layer.provide(persistenceLayer), - Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), ); From 5d09b381dd0244225e374bd5005fc8696281f7f1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 20 Mar 2026 14:59:56 -0700 Subject: [PATCH 09/10] cool --- .../OrchestrationEngineHarness.integration.ts | 41 ++++++++++--------- .../orchestrationEngine.integration.test.ts | 9 ++-- apps/server/src/config.ts | 5 +++ apps/server/src/keybindings.test.ts | 23 ++++------- 4 files changed, 39 insertions(+), 39 deletions(-) diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index f02cc94b68..19f34b49a0 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -1,6 +1,3 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { execFileSync } from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -13,9 +10,11 @@ import { import { Effect, Exit, + FileSystem, Layer, ManagedRuntime, Option, + Path, Ref, Schedule, Schema, @@ -76,14 +75,16 @@ function runGit(cwd: string, args: ReadonlyArray) { }); } -function initializeGitWorkspace(cwd: string) { +const initializeGitWorkspace = Effect.fn(function* (cwd: string) { runGit(cwd, ["init", "--initial-branch=main"]); runGit(cwd, ["config", "user.email", "test@example.com"]); runGit(cwd, ["config", "user.name", "Test User"]); - fs.writeFileSync(path.join(cwd, "README.md"), "v1\n", "utf8"); + const fileSystem = yield* FileSystem.FileSystem; + const { join } = yield* Path.Path; + yield* fileSystem.writeFileString(join(cwd, "README.md"), "v1\n"); runGit(cwd, ["add", "."]); runGit(cwd, ["commit", "-m", "Initial"]); -} +}); export function gitRefExists(cwd: string, ref: string): boolean { try { @@ -214,7 +215,9 @@ export const makeOrchestrationIntegrationHarness = ( options?: MakeOrchestrationIntegrationHarnessOptions, ) => Effect.gen(function* () { - const sleep = (ms: number) => Effect.sleep(ms); + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const provider = options?.provider ?? "codex"; const useRealCodex = options?.realCodex === true; const adapterHarness = useRealCodex @@ -231,12 +234,16 @@ export const makeOrchestrationIntegrationHarness = ( listProviders: () => Effect.succeed([adapterHarness.provider]), } as typeof ProviderAdapterRegistry.Service) : null; - const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-")); + const rootDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-orchestration-integration-", + }); const workspaceDir = path.join(rootDir, "workspace"); - const { stateDir, dbPath } = yield* deriveServerPaths(rootDir, undefined); - fs.mkdirSync(workspaceDir, { recursive: true }); - fs.mkdirSync(stateDir, { recursive: true }); - initializeGitWorkspace(workspaceDir); + const { stateDir, dbPath } = yield* deriveServerPaths(rootDir, undefined).pipe( + Effect.provideService(Path.Path, path), + ); + yield* fileSystem.makeDirectory(workspaceDir, { recursive: true }); + yield* fileSystem.makeDirectory(stateDir, { recursive: true }); + yield* initializeGitWorkspace(workspaceDir); const persistenceLayer = makeSqlitePersistenceLive(dbPath); const orchestrationLayer = OrchestrationEngineLive.pipe( @@ -351,7 +358,7 @@ export const makeOrchestrationIntegrationHarness = ( yield* Stream.runForEach(runtimeReceiptBus.stream, (receipt) => Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid), ).pipe(Effect.forkIn(scope)); - yield* sleep(10); + yield* Effect.sleep(10); const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = ( threadId, @@ -468,13 +475,7 @@ export const makeOrchestrationIntegrationHarness = ( } }); - yield* shutdown.pipe( - Effect.ensuring( - Effect.sync(() => { - fs.rmSync(rootDir, { recursive: true, force: true }); - }), - ), - ); + yield* shutdown; }); return { diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 104a6e5e00..10fb32a5ed 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -27,6 +27,7 @@ import type { CheckpointDiffFinalizedReceipt, TurnProcessingQuiescedReceipt, } from "../src/orchestration/Services/RuntimeReceiptBus.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); @@ -51,8 +52,6 @@ class IntegrationWaitTimeoutError extends Schema.TaggedErrorClass Effect.sleep(ms); - function waitForSync( read: () => A, predicate: (value: A) => boolean, @@ -70,7 +69,7 @@ function waitForSync( if (Date.now() >= deadline) { return yield* Effect.die(new IntegrationWaitTimeoutError({ description })); } - yield* sleep(10); + yield* Effect.sleep(10); } }); } @@ -91,7 +90,7 @@ function withHarness( makeOrchestrationIntegrationHarness({ provider }), use, (harness) => harness.dispose, - ); + ).pipe(Effect.provide(NodeServices.layer)); } function withRealCodexHarness( @@ -101,7 +100,7 @@ function withRealCodexHarness( makeOrchestrationIntegrationHarness({ provider: "codex", realCodex: true }), use, (harness) => harness.dispose, - ); + ).pipe(Effect.provide(NodeServices.layer)); } const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 965fa08ed3..7d7dd9c44e 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,6 +6,7 @@ * * @module ServerConfig */ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; export const DEFAULT_PORT = 3773; @@ -90,6 +91,10 @@ export class ServerConfig extends ServiceMap.Service { - const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-keybindings-test-", - }); - const bootstrappedServerConfigLayer = Layer.effect( - ServerConfig, - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const serverConfig = yield* ServerConfig; - yield* fileSystem.makeDirectory(serverConfig.stateDir, { recursive: true }); - return serverConfig; - }), - ).pipe(Layer.provide(serverConfigLayer)); - - return KeybindingsLive.pipe(Layer.provideMerge(bootstrappedServerConfigLayer)); + return KeybindingsLive.pipe( + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-keybindings-test-", + }), + ), + ), + ); }; const toDetailResult = (effect: Effect.Effect) => From 1b462fdd0b334cdd73d0df3266b2bef612115b0d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 20 Mar 2026 15:01:32 -0700 Subject: [PATCH 10/10] lint --- apps/server/src/config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 7d7dd9c44e..8553ce9667 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,7 +6,6 @@ * * @module ServerConfig */ -import * as NodeServices from "@effect/platform-node/NodeServices"; import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; export const DEFAULT_PORT = 3773;