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/desktop/src/main.ts b/apps/desktop/src/main.ts index c3dba6016e..062c79fa69 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 STATE_DIR = - process.env.T3CODE_STATE_DIR?.trim() || Path.join(OS.homedir(), ".t3", "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); @@ -924,7 +924,7 @@ function backendEnv(): NodeJS.ProcessEnv { T3CODE_MODE: "desktop", T3CODE_NO_BROWSER: "1", T3CODE_PORT: String(backendPort), - T3CODE_STATE_DIR: STATE_DIR, + T3CODE_HOME: BASE_DIR, T3CODE_AUTH_TOKEN: backendAuthToken, }; } diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index c237246ab2..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, @@ -66,7 +65,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, { @@ -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,13 +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 = path.join(rootDir, "state"); - const dbPath = path.join(stateDir, "state.sqlite"); - 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( @@ -262,7 +268,7 @@ export const makeOrchestrationIntegrationHarness = ( }), ).pipe( Layer.provide(makeCodexAdapterLive()), - Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), + Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(providerSessionDirectoryLayer), ); @@ -312,7 +318,7 @@ export const makeOrchestrationIntegrationHarness = ( ); const layer = orchestrationReactorLayer.pipe( Layer.provide(persistenceLayer), - Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), + Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), ); @@ -352,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, @@ -469,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/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 ccbcea469d..8553ce9667 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -12,16 +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 stateDir: string; + readonly baseDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; readonly noBrowser: boolean; @@ -30,31 +46,68 @@ 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) => + 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); + + yield* fs.makeDirectory(derivedPaths.stateDir, { recursive: true }); + yield* fs.makeDirectory(derivedPaths.logsDir, { recursive: true }); + yield* fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true }); + return { cwd, - stateDir: statedir, + baseDir, + ...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 1cf2d0e092..0170d207fe 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -8,11 +8,14 @@ import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { TextGenerationError } from "../Errors.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; -const makeCodexTextGenerationTestLayer = (stateDir: string) => - CodexTextGenerationLive.pipe( - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), - 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* () { @@ -186,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( @@ -325,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; @@ -363,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; @@ -410,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 6c98229e8a..6d50cd2504 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(), { prefix: "t3-git-core-test-" }); 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 f5b9168abb..74c09da5f9 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,7 @@ const makeGitCore = Effect.gen(function* () { const git = yield* GitService; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const { worktreesDir } = yield* ServerConfig; const executeGit = ( operation: string, @@ -1186,9 +1188,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 8c72941cd0..e76994f853 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[]; @@ -474,18 +475,21 @@ function makeManager(input?: { }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-git-manager-test-", + }); 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( Effect.provide(managerLayer), diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 6954cafc59..846c3778b0 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,17 @@ import { } from "./keybindings"; const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig); -const makeKeybindingsLayer = () => - KeybindingsLive.pipe( +const makeKeybindingsLayer = () => { + return 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; + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3code-keybindings-test-", }), ), ), ); +}; const toDetailResult = (effect: Effect.Effect) => effect.pipe( @@ -42,7 +38,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 83976e3d4c..b1e5da0c87 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/dev"); 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/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 0a33be0cbb..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, 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"; @@ -36,7 +37,7 @@ interface CliInput { readonly mode: Option.Option; readonly port: Option.Option; readonly host: Option.Option; - readonly stateDir: Option.Option; + readonly t3Home: Option.Option; readonly devUrl: Option.Option; readonly noBrowser: Option.Option; readonly authToken: Option.Option; @@ -99,10 +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)), - 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, @@ -152,10 +150,10 @@ const ServerConfigLive = (input: CliInput) => return findAvailablePort(DEFAULT_PORT); }, }); - const stateDir = yield* resolveStateDir( - Option.getOrUndefined(input.stateDir) ?? env.stateDir, - ); + const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); + const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home); + 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( @@ -167,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 ?? @@ -178,9 +174,9 @@ const ServerConfigLive = (input: CliInput) => mode, port, cwd: cliConfig.cwd, - keybindingsConfigPath, host, - stateDir, + baseDir, + ...derivedPaths, staticDir, devUrl, noBrowser, @@ -299,8 +295,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 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( @@ -335,7 +331,7 @@ export const t3Cli = Command.make("t3", { mode: modeFlag, port: portFlag, host: hostFlag, - stateDir: stateDirFlag, + t3Home: t3HomeFlag, devUrl: devUrlFlag, noBrowser: noBrowserFlag, authToken: authTokenFlag, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 4d339ba72e..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())), + 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 181b18d60c..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())), + 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())), + 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 5fbe3016fd..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,31 +29,24 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; -const makeProjectionPipelineTestLayer = (stateDir: string) => +const makeProjectionPipelinePrefixedTestLayer = (prefix: string) => OrchestrationProjectionPipelineLive.pipe( Layer.provideMerge(OrchestrationEventStoreLive), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix })), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), ); -const runWithProjectionPipelineLayer = ( - stateDir: string, - effect: Effect.Effect< - A, - E, - OrchestrationProjectionPipeline | OrchestrationEventStore | SqlClient.SqlClient - >, -) => - Effect.acquireUseRelease( - Effect.sync(() => ManagedRuntime.make(makeProjectionPipelineTestLayer(stateDir))), - (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())); +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; @@ -174,157 +164,155 @@ 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.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, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { 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(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe( - Effect.flatMap((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, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { 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", () => @@ -447,136 +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(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")), - ).pipe( - Effect.flatMap((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", @@ -584,76 +546,98 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { mimeType: "image/png", sizeBytes: 5, }, - ]); - }).pipe( - (effect) => runWithProjectionPipelineLayer(stateDir, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { 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(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-rollback-")), - ).pipe( - Effect.flatMap((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' @@ -662,453 +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, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { 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(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-revert-")), - ).pipe( - Effect.flatMap((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, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { 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(() => - fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-delete-")), - ).pipe( - Effect.flatMap((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, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), - ), - ), - ), - ); + ], + 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, + }, + }); - 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.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.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, effect), - Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { 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; @@ -1714,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), @@ -1830,11 +1797,14 @@ 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()), NodeServices.layer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-projection-pipeline-restart-", + }), + NodeServices.layer, + ), ), ), ); @@ -1845,7 +1815,11 @@ 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(), { + 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 f1cf6afc91..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, @@ -67,6 +70,7 @@ describe("ProviderCommandReactor", () => { > | null = null; let scope: Scope.Closeable | null = null; const createdStateDirs = new Set(); + const createdBaseDirs = new Set(); afterEach(async () => { if (scope) { @@ -81,16 +85,22 @@ 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; + readonly baseDir?: string; readonly threadModel?: string; }) { const now = new Date().toISOString(); - const stateDir = input?.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); - const threadModel = input?.threadModel ?? "gpt-5-codex"; + const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); + createdBaseDirs.add(baseDir); + const { stateDir } = deriveServerPathsSync(baseDir, undefined); createdStateDirs.add(stateDir); + const threadModel = input?.threadModel ?? "gpt-5-codex"; const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; const runtimeSessions: Array = []; @@ -214,7 +224,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(), baseDir)), Layer.provideMerge(NodeServices.layer), ); const runtime = ManagedRuntime.make(layer); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 586aca6f79..0721d0d9f8 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -27,10 +27,10 @@ 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())); }); 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 fe9889fe9f..f60b2b1e90 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -133,7 +133,7 @@ function makeHarness(config?: { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; readonly cwd?: string; - readonly stateDir?: string; + readonly baseDir?: string; }) { const query = new FakeClaudeQuery(); let createInput: @@ -165,7 +165,7 @@ function makeHarness(config?: { Layer.provideMerge( ServerConfig.layerTest( config?.cwd ?? "/tmp/claude-adapter-test", - config?.stateDir ?? "/tmp", + config?.baseDir ?? "/tmp", ), ), Layer.provideMerge(NodeServices.layer), @@ -515,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, }), @@ -531,6 +531,7 @@ describe("ClaudeAdapterLive", () => { ); const adapter = yield* ClaudeAdapter; + const { attachmentsDir } = yield* ServerConfig; const attachment = { type: "image" as const, @@ -539,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.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 adf7564ce4..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,13 +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 stateDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-telemetry-flush-", - }); - const capturedRequests: Array = []; - const serverConfigLayer = ServerConfig.layerTest(process.cwd(), stateDir); + 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 f12792a318..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; @@ -474,7 +484,7 @@ describe("WebSocket Server", () => { logWebSocketEvents?: boolean; devUrl?: string; authToken?: string; - stateDir?: string; + baseDir?: string; staticDir?: string; providerLayer?: Layer.Layer; providerHealth?: ProviderHealthShape; @@ -488,7 +498,9 @@ describe("WebSocket Server", () => { throw new Error("Test server is already running"); } - const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-"); + const baseDir = options.baseDir ?? makeTempDir("t3code-ws-base-"); + 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(); @@ -502,10 +514,10 @@ describe("WebSocket Server", () => { port: 0, host: undefined, cwd: options.cwd ?? "/test/project", - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - stateDir, + baseDir, + ...derivedPaths, staticDir: options.staticDir, - devUrl: options.devUrl ? new URL(options.devUrl) : undefined, + devUrl, noBrowser: true, authToken: options.authToken, autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, @@ -590,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); @@ -608,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", @@ -619,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); @@ -634,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); @@ -649,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); @@ -731,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, }); @@ -762,7 +776,7 @@ describe("WebSocket Server", () => { server = await createTestServer({ cwd, - stateDir, + baseDir, persistenceLayer, autoBootstrapProjectFromCwd: true, }); @@ -811,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; @@ -836,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; @@ -866,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; @@ -897,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([ @@ -909,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; @@ -948,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; @@ -1008,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([ @@ -1019,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; @@ -1043,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) { diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 704a285414..e76d187188 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1,10 +1,10 @@ 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, createDevRunnerEnv, findFirstAvailableOffset, resolveModePortOffsets, @@ -46,27 +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, - stateDir: undefined, - authToken: undefined, - noBrowser: undefined, - autoBootstrapProjectFromCwd: undefined, - logWebSocketEvents: undefined, - host: undefined, - port: undefined, - devUrl: undefined, - }), - DEFAULT_DEV_STATE_DIR, - ]); + 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")); }), ); @@ -77,7 +74,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { baseEnv: {}, serverOffset: 0, webOffset: 0, - stateDir: "/tmp/override-state", + t3Home: "/tmp/custom-t3", authToken: "secret", noBrowser: true, autoBootstrapProjectFromCwd: false, @@ -87,7 +84,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { devUrl: new URL("http://localhost:7331"), }); - 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"); @@ -107,7 +104,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }, serverOffset: 0, webOffset: 0, - stateDir: undefined, + t3Home: undefined, authToken: undefined, noBrowser: undefined, autoBootstrapProjectFromCwd: undefined, @@ -129,7 +126,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { baseEnv: {}, serverOffset: 0, webOffset: 0, - stateDir: undefined, + t3Home: undefined, authToken: undefined, noBrowser: undefined, autoBootstrapProjectFromCwd: undefined, @@ -142,6 +139,27 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_LOG_WS_EVENTS, "0"); }), ); + + it.effect("uses custom t3Home when provided", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + t3Home: "/tmp/my-t3", + authToken: undefined, + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); + }), + ); }); describe("findFirstAvailableOffset", () => { diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 3815c4a3b9..da2b3ebe54 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -14,8 +14,8 @@ 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"), +export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => + path.join(homedir(), ".t3"), ); const MODE_ARGS = { @@ -101,17 +101,16 @@ 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 = stateDir?.trim(); + const configured = baseDir?.trim(); if (configured) { - // Resolve relative paths against cwd (monorepo root) before turbo changes directories. return path.resolve(configured); } - return yield* DEFAULT_DEV_STATE_DIR; + return yield* DEFAULT_T3_HOME; }); } @@ -120,7 +119,7 @@ interface CreateDevRunnerEnvInput { readonly baseEnv: NodeJS.ProcessEnv; readonly serverOffset: number; readonly webOffset: number; - readonly stateDir: string | undefined; + readonly t3Home: string | undefined; readonly authToken: string | undefined; readonly noBrowser: boolean | undefined; readonly autoBootstrapProjectFromCwd: boolean | undefined; @@ -135,7 +134,7 @@ export function createDevRunnerEnv({ baseEnv, serverOffset, webOffset, - stateDir, + t3Home, authToken, noBrowser, autoBootstrapProjectFromCwd, @@ -147,7 +146,7 @@ export function createDevRunnerEnv({ return Effect.gen(function* () { const serverPort = port ?? BASE_SERVER_PORT + serverOffset; const webPort = BASE_WEB_PORT + webOffset; - const resolvedStateDir = yield* resolveStateDir(stateDir); + const resolvedBaseDir = yield* resolveBaseDir(t3Home); const output: NodeJS.ProcessEnv = { ...baseEnv, @@ -156,7 +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_STATE_DIR: resolvedStateDir, + T3CODE_HOME: resolvedBaseDir, }; if (host !== undefined) { @@ -335,7 +334,7 @@ export function resolveModePortOffsets({ interface DevRunnerCliInput { readonly mode: DevMode; - readonly stateDir: string | undefined; + readonly t3Home: string | undefined; readonly authToken: string | undefined; readonly noBrowser: boolean | undefined; readonly autoBootstrapProjectFromCwd: boolean | undefined; @@ -415,7 +414,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { baseEnv: process.env, serverOffset, webOffset, - stateDir: input.stateDir, + t3Home: input.t3Home, authToken: input.authToken, noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser), autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride( @@ -437,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) { @@ -485,9 +484,9 @@ const devRunnerCli = Command.make("dev-runner", { mode: Argument.choice("mode", DEV_RUNNER_MODES).pipe( Argument.withDescription("Development mode to run."), ), - 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 671336afae..9359376f59 100644 --- a/turbo.json +++ b/turbo.json @@ -9,7 +9,7 @@ "T3CODE_MODE", "T3CODE_PORT", "T3CODE_NO_BROWSER", - "T3CODE_STATE_DIR", + "T3CODE_HOME", "T3CODE_AUTH_TOKEN", "T3CODE_DESKTOP_WS_URL" ],