authorgravatar for 60599324+mcking-07@users.noreply.github.comMohammed Muzammil Anwar <60599324+mcking-07@users.noreply.github.com> 2026-03-21 03:42:52+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-20 15:12:52-07:00
log9e29c9d72895022322da52d8e961b38702bad9cc
tree4eb08227b1f5926bba463909a07ad4c492612b2b
parentef2569196f164df1096ebd8c614fafa0ebc43be9
signature Signed by PGP key B5690EEEBB952194

feat: added configurable base directory. (#826)

Co-authored-by: Mohammed Muzammil Anwar <muzammil@pluginhive.com> Co-authored-by: Julius Marminge <julius0216@outlook.com>

37 files changed, 1103 insertions(+), 1032 deletions(-)

.docs/scripts.md+1-1
......@@ -5,7 +5,7 @@
55- `bun run dev:web` — Starts just the Vite dev server for the web app.
66- Dev commands default `T3CODE_STATE_DIR` to `~/.t3/dev` to keep dev state isolated from desktop/prod state.
77- Override server CLI-equivalent flags from root dev commands with `--`, for example:
8 `bun run dev -- --state-dir ~/.t3/another-dev-state`
8 `bun run dev -- --base-dir ~/.t3-2`
99- `bun run start` — Runs the production server (serves built web app as static files).
1010- `bun run build` — Builds contracts, web app, and server through Turbo.
1111- `bun run typecheck` — Strict TypeScript checks for all packages.
REMOTE.md+1-1
......@@ -11,7 +11,7 @@ The T3 Code CLI accepts the following configuration options, available either as
1111| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |
1212| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |
1313| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |
14| `--state-dir <path>` | `T3CODE_STATE_DIR` | State directory. |
14| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |
1515| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |
1616| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |
1717| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. |
apps/desktop/src/main.ts+3-3
......@@ -56,8 +56,8 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
5656const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
5757const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
5858const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
59const STATE_DIR =
60 process.env.T3CODE_STATE_DIR?.trim() || Path.join(OS.homedir(), ".t3", "userdata");
59const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
60const STATE_DIR = Path.join(BASE_DIR, "userdata");
6161const DESKTOP_SCHEME = "t3";
6262const ROOT_DIR = Path.resolve(__dirname, "../../..");
6363const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
......@@ -924,7 +924,7 @@ function backendEnv(): NodeJS.ProcessEnv {
924924 T3CODE_MODE: "desktop",
925925 T3CODE_NO_BROWSER: "1",
926926 T3CODE_PORT: String(backendPort),
927 T3CODE_STATE_DIR: STATE_DIR,
927 T3CODE_HOME: BASE_DIR,
928928 T3CODE_AUTH_TOKEN: backendAuthToken,
929929 };
930930}
apps/server/integration/OrchestrationEngineHarness.integration.ts+24-24
......@@ -1,6 +1,3 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
41import { execFileSync } from "node:child_process";
52
63import * as NodeServices from "@effect/platform-node/NodeServices";
......@@ -13,9 +10,11 @@ import {
1310import {
1411 Effect,
1512 Exit,
13 FileSystem,
1614 Layer,
1715 ManagedRuntime,
1816 Option,
17 Path,
1918 Ref,
2019 Schedule,
2120 Schema,
......@@ -66,7 +65,7 @@ import {
6665 makeTestProviderAdapterHarness,
6766 type TestProviderAdapterHarness,
6867} from "./TestProviderAdapter.integration.ts";
69import { ServerConfig } from "../src/config.ts";
68import { deriveServerPaths, ServerConfig } from "../src/config.ts";
7069
7170function runGit(cwd: string, args: ReadonlyArray<string>) {
7271 return execFileSync("git", args, {
......@@ -76,14 +75,16 @@ function runGit(cwd: string, args: ReadonlyArray<string>) {
7675 });
7776}
7877
79function initializeGitWorkspace(cwd: string) {
78const initializeGitWorkspace = Effect.fn(function* (cwd: string) {
8079 runGit(cwd, ["init", "--initial-branch=main"]);
8180 runGit(cwd, ["config", "user.email", "test@example.com"]);
8281 runGit(cwd, ["config", "user.name", "Test User"]);
83 fs.writeFileSync(path.join(cwd, "README.md"), "v1\n", "utf8");
82 const fileSystem = yield* FileSystem.FileSystem;
83 const { join } = yield* Path.Path;
84 yield* fileSystem.writeFileString(join(cwd, "README.md"), "v1\n");
8485 runGit(cwd, ["add", "."]);
8586 runGit(cwd, ["commit", "-m", "Initial"]);
86}
87});
8788
8889export function gitRefExists(cwd: string, ref: string): boolean {
8990 try {
......@@ -214,7 +215,9 @@ export const makeOrchestrationIntegrationHarness = (
214215 options?: MakeOrchestrationIntegrationHarnessOptions,
215216) =>
216217 Effect.gen(function* () {
217 const sleep = (ms: number) => Effect.sleep(ms);
218 const path = yield* Path.Path;
219 const fileSystem = yield* FileSystem.FileSystem;
220
218221 const provider = options?.provider ?? "codex";
219222 const useRealCodex = options?.realCodex === true;
220223 const adapterHarness = useRealCodex
......@@ -231,13 +234,16 @@ export const makeOrchestrationIntegrationHarness = (
231234 listProviders: () => Effect.succeed([adapterHarness.provider]),
232235 } as typeof ProviderAdapterRegistry.Service)
233236 : null;
234 const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-integration-"));
237 const rootDir = yield* fileSystem.makeTempDirectoryScoped({
238 prefix: "t3-orchestration-integration-",
239 });
235240 const workspaceDir = path.join(rootDir, "workspace");
236 const stateDir = path.join(rootDir, "state");
237 const dbPath = path.join(stateDir, "state.sqlite");
238 fs.mkdirSync(workspaceDir, { recursive: true });
239 fs.mkdirSync(stateDir, { recursive: true });
240 initializeGitWorkspace(workspaceDir);
241 const { stateDir, dbPath } = yield* deriveServerPaths(rootDir, undefined).pipe(
242 Effect.provideService(Path.Path, path),
243 );
244 yield* fileSystem.makeDirectory(workspaceDir, { recursive: true });
245 yield* fileSystem.makeDirectory(stateDir, { recursive: true });
246 yield* initializeGitWorkspace(workspaceDir);
241247
242248 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
243249 const orchestrationLayer = OrchestrationEngineLive.pipe(
......@@ -262,7 +268,7 @@ export const makeOrchestrationIntegrationHarness = (
262268 }),
263269 ).pipe(
264270 Layer.provide(makeCodexAdapterLive()),
265 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),
271 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),
266272 Layer.provideMerge(NodeServices.layer),
267273 Layer.provideMerge(providerSessionDirectoryLayer),
268274 );
......@@ -312,7 +318,7 @@ export const makeOrchestrationIntegrationHarness = (
312318 );
313319 const layer = orchestrationReactorLayer.pipe(
314320 Layer.provide(persistenceLayer),
315 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),
321 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),
316322 Layer.provideMerge(NodeServices.layer),
317323 );
318324
......@@ -352,7 +358,7 @@ export const makeOrchestrationIntegrationHarness = (
352358 yield* Stream.runForEach(runtimeReceiptBus.stream, (receipt) =>
353359 Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid),
354360 ).pipe(Effect.forkIn(scope));
355 yield* sleep(10);
361 yield* Effect.sleep(10);
356362
357363 const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = (
358364 threadId,
......@@ -469,13 +475,7 @@ export const makeOrchestrationIntegrationHarness = (
469475 }
470476 });
471477
472 yield* shutdown.pipe(
473 Effect.ensuring(
474 Effect.sync(() => {
475 fs.rmSync(rootDir, { recursive: true, force: true });
476 }),
477 ),
478 );
478 yield* shutdown;
479479 });
480480
481481 return {
apps/server/integration/orchestrationEngine.integration.test.ts+4-5
......@@ -27,6 +27,7 @@ import type {
2727 CheckpointDiffFinalizedReceipt,
2828 TurnProcessingQuiescedReceipt,
2929} from "../src/orchestration/Services/RuntimeReceiptBus.ts";
30import * as NodeServices from "@effect/platform-node/NodeServices";
3031
3132const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
3233const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
......@@ -51,8 +52,6 @@ class IntegrationWaitTimeoutError extends Schema.TaggedErrorClass<IntegrationWai
5152 },
5253) {}
5354
54const sleep = (ms: number) => Effect.sleep(ms);
55
5655function waitForSync<A>(
5756 read: () => A,
5857 predicate: (value: A) => boolean,
......@@ -70,7 +69,7 @@ function waitForSync<A>(
7069 if (Date.now() >= deadline) {
7170 return yield* Effect.die(new IntegrationWaitTimeoutError({ description }));
7271 }
73 yield* sleep(10);
72 yield* Effect.sleep(10);
7473 }
7574 });
7675}
......@@ -91,7 +90,7 @@ function withHarness<A, E>(
9190 makeOrchestrationIntegrationHarness({ provider }),
9291 use,
9392 (harness) => harness.dispose,
94 );
93 ).pipe(Effect.provide(NodeServices.layer));
9594}
9695
9796function withRealCodexHarness<A, E>(
......@@ -101,7 +100,7 @@ function withRealCodexHarness<A, E>(
101100 makeOrchestrationIntegrationHarness({ provider: "codex", realCodex: true }),
102101 use,
103102 (harness) => harness.dispose,
104 );
103 ).pipe(Effect.provide(NodeServices.layer));
105104}
106105
107106const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
apps/server/src/attachmentPaths.ts+2-2
......@@ -11,7 +11,7 @@ export function normalizeAttachmentRelativePath(rawRelativePath: string): string
1111}
1212
1313export function resolveAttachmentRelativePath(input: {
14 readonly stateDir: string;
14 readonly attachmentsDir: string;
1515 readonly relativePath: string;
1616}): string | null {
1717 const normalizedRelativePath = normalizeAttachmentRelativePath(input.relativePath);
......@@ -19,7 +19,7 @@ export function resolveAttachmentRelativePath(input: {
1919 return null;
2020 }
2121
22 const attachmentsRoot = path.resolve(path.join(input.stateDir, "attachments"));
22 const attachmentsRoot = path.resolve(input.attachmentsDir);
2323 const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath));
2424 if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) {
2525 return null;
apps/server/src/attachmentStore.test.ts+6-8
......@@ -44,34 +44,32 @@ describe("attachmentStore", () => {
4444 });
4545
4646 it("resolves attachment path by id using the extension that exists on disk", () => {
47 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-"));
47 const attachmentsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-"));
4848 try {
4949 const attachmentId = "thread-1-attachment";
50 const attachmentsDir = path.join(stateDir, "attachments");
51 fs.mkdirSync(attachmentsDir, { recursive: true });
5250 const pngPath = path.join(attachmentsDir, `${attachmentId}.png`);
5351 fs.writeFileSync(pngPath, Buffer.from("hello"));
5452
5553 const resolved = resolveAttachmentPathById({
56 stateDir,
54 attachmentsDir,
5755 attachmentId,
5856 });
5957 expect(resolved).toBe(pngPath);
6058 } finally {
61 fs.rmSync(stateDir, { recursive: true, force: true });
59 fs.rmSync(attachmentsDir, { recursive: true, force: true });
6260 }
6361 });
6462
6563 it("returns null when no attachment file exists for the id", () => {
66 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-"));
64 const attachmentsDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-"));
6765 try {
6866 const resolved = resolveAttachmentPathById({
69 stateDir,
67 attachmentsDir,
7068 attachmentId: "thread-1-missing",
7169 });
7270 expect(resolved).toBeNull();
7371 } finally {
74 fs.rmSync(stateDir, { recursive: true, force: true });
72 fs.rmSync(attachmentsDir, { recursive: true, force: true });
7573 }
7674 });
7775});
apps/server/src/attachmentStore.ts+4-4
......@@ -66,17 +66,17 @@ export function attachmentRelativePath(attachment: ChatAttachment): string {
6666}
6767
6868export function resolveAttachmentPath(input: {
69 readonly stateDir: string;
69 readonly attachmentsDir: string;
7070 readonly attachment: ChatAttachment;
7171}): string | null {
7272 return resolveAttachmentRelativePath({
73 stateDir: input.stateDir,
73 attachmentsDir: input.attachmentsDir,
7474 relativePath: attachmentRelativePath(input.attachment),
7575 });
7676}
7777
7878export function resolveAttachmentPathById(input: {
79 readonly stateDir: string;
79 readonly attachmentsDir: string;
8080 readonly attachmentId: string;
8181}): string | null {
8282 const normalizedId = normalizeAttachmentRelativePath(input.attachmentId);
......@@ -85,7 +85,7 @@ export function resolveAttachmentPathById(input: {
8585 }
8686 for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) {
8787 const maybePath = resolveAttachmentRelativePath({
88 stateDir: input.stateDir,
88 attachmentsDir: input.attachmentsDir,
8989 relativePath: `${normalizedId}${extension}`,
9090 });
9191 if (maybePath && existsSync(maybePath)) {
apps/server/src/config.ts+62-9
......@@ -12,16 +12,32 @@ export const DEFAULT_PORT = 3773;
1212
1313export type RuntimeMode = "web" | "desktop";
1414
15/**
16 * ServerDerivedPaths - Derived paths from the base directory.
17 */
18export interface ServerDerivedPaths {
19 readonly stateDir: string;
20 readonly dbPath: string;
21 readonly keybindingsConfigPath: string;
22 readonly worktreesDir: string;
23 readonly attachmentsDir: string;
24 readonly logsDir: string;
25 readonly serverLogPath: string;
26 readonly providerLogsDir: string;
27 readonly providerEventLogPath: string;
28 readonly terminalLogsDir: string;
29 readonly anonymousIdPath: string;
30}
31
1532/**
1633 * ServerConfigShape - Process/runtime configuration required by the server.
1734 */
18export interface ServerConfigShape {
35export interface ServerConfigShape extends ServerDerivedPaths {
1936 readonly mode: RuntimeMode;
2037 readonly port: number;
2138 readonly host: string | undefined;
2239 readonly cwd: string;
23 readonly keybindingsConfigPath: string;
24 readonly stateDir: string;
40 readonly baseDir: string;
2541 readonly staticDir: string | undefined;
2642 readonly devUrl: URL | undefined;
2743 readonly noBrowser: boolean;
......@@ -30,31 +46,68 @@ export interface ServerConfigShape {
3046 readonly logWebSocketEvents: boolean;
3147}
3248
49export const deriveServerPaths = Effect.fn(function* (
50 baseDir: ServerConfigShape["baseDir"],
51 devUrl: ServerConfigShape["devUrl"],
52): Effect.fn.Return<ServerDerivedPaths, never, Path.Path> {
53 const { join } = yield* Path.Path;
54 const stateDir = join(baseDir, devUrl !== undefined ? "dev" : "userdata");
55 const dbPath = join(stateDir, "state.sqlite");
56 const attachmentsDir = join(stateDir, "attachments");
57 const logsDir = join(stateDir, "logs");
58 const providerLogsDir = join(logsDir, "provider");
59 return {
60 stateDir,
61 dbPath,
62 keybindingsConfigPath: join(stateDir, "keybindings.json"),
63 worktreesDir: join(baseDir, "worktrees"),
64 attachmentsDir,
65 logsDir,
66 serverLogPath: join(logsDir, "server.log"),
67 providerLogsDir,
68 providerEventLogPath: join(providerLogsDir, "events.log"),
69 terminalLogsDir: join(logsDir, "terminals"),
70 anonymousIdPath: join(stateDir, "anonymous-id"),
71 };
72});
73
3374/**
3475 * ServerConfig - Service tag for server runtime configuration.
3576 */
3677export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigShape>()(
3778 "t3/config/ServerConfig",
3879) {
39 static readonly layerTest = (cwd: string, statedir: string) =>
80 static readonly layerTest = (cwd: string, baseDirOrPrefix: string | { prefix: string }) =>
4081 Layer.effect(
4182 ServerConfig,
4283 Effect.gen(function* () {
43 const path = yield* Path.Path;
84 const devUrl = undefined;
85
86 const fs = yield* FileSystem.FileSystem;
87 const baseDir =
88 typeof baseDirOrPrefix === "string"
89 ? baseDirOrPrefix
90 : yield* fs.makeTempDirectoryScoped({ prefix: baseDirOrPrefix.prefix });
91 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
92
93 yield* fs.makeDirectory(derivedPaths.stateDir, { recursive: true });
94 yield* fs.makeDirectory(derivedPaths.logsDir, { recursive: true });
95 yield* fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true });
96
4497 return {
4598 cwd,
46 stateDir: statedir,
99 baseDir,
100 ...derivedPaths,
47101 mode: "web",
48102 autoBootstrapProjectFromCwd: false,
49103 logWebSocketEvents: false,
50104 port: 0,
51105 host: undefined,
52106 authToken: undefined,
53 keybindingsConfigPath: path.join(statedir, "keybindings.json"),
54107 staticDir: undefined,
55 devUrl: undefined,
108 devUrl,
56109 noBrowser: false,
57 };
110 } satisfies ServerConfigShape;
58111 }),
59112 );
60113}
apps/server/src/git/Layers/CodexTextGeneration.test.ts+16-12
......@@ -8,11 +8,14 @@ import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
88import { TextGenerationError } from "../Errors.ts";
99import { TextGeneration } from "../Services/TextGeneration.ts";
1010
11const makeCodexTextGenerationTestLayer = (stateDir: string) =>
12 CodexTextGenerationLive.pipe(
13 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)),
14 Layer.provideMerge(NodeServices.layer),
15 );
11const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe(
12 Layer.provideMerge(
13 ServerConfig.layerTest(process.cwd(), {
14 prefix: "t3code-codex-text-generation-test-",
15 }),
16 ),
17 Layer.provideMerge(NodeServices.layer),
18);
1619
1720function makeFakeCodexBinary(dir: string) {
1821 return Effect.gen(function* () {
......@@ -186,8 +189,6 @@ function withFakeCodexEnv<A, E, R>(
186189 );
187190}
188191
189const CodexTextGenerationTestLayer = makeCodexTextGenerationTestLayer(process.cwd());
190
191192it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
192193 it.effect("generates and sanitizes commit messages without branch by default", () =>
193194 withFakeCodexEnv(
......@@ -325,9 +326,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
325326 Effect.gen(function* () {
326327 const fs = yield* FileSystem.FileSystem;
327328 const path = yield* Path.Path;
329 const { attachmentsDir } = yield* ServerConfig;
328330 const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
329 const attachmentPath = path.join(process.cwd(), "attachments", `${attachmentId}.png`);
330 yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true });
331 const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`);
332 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
331333 yield* fs.writeFile(attachmentPath, Buffer.from("hello"));
332334
333335 const textGeneration = yield* TextGeneration;
......@@ -363,9 +365,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
363365 Effect.gen(function* () {
364366 const fs = yield* FileSystem.FileSystem;
365367 const path = yield* Path.Path;
368 const { attachmentsDir } = yield* ServerConfig;
366369 const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
367 const imagePath = path.join(process.cwd(), "attachments", `${attachmentId}.png`);
368 yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true });
370 const imagePath = path.join(attachmentsDir, `${attachmentId}.png`);
371 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
369372 yield* fs.writeFile(imagePath, Buffer.from("hello"));
370373
371374 const textGeneration = yield* TextGeneration;
......@@ -410,8 +413,9 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
410413 Effect.gen(function* () {
411414 const fs = yield* FileSystem.FileSystem;
412415 const path = yield* Path.Path;
416 const { attachmentsDir } = yield* ServerConfig;
413417 const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
414 const missingPath = path.join(process.cwd(), "attachments", `${missingAttachmentId}.png`);
418 const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`);
415419 yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void));
416420
417421 const textGeneration = yield* TextGeneration;
apps/server/src/git/Layers/CodexTextGeneration.ts+1-1
......@@ -163,7 +163,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
163163 }
164164
165165 const resolvedPath = resolveAttachmentPath({
166 stateDir: serverConfig.stateDir,
166 attachmentsDir: serverConfig.attachmentsDir,
167167 attachment,
168168 });
169169 if (!resolvedPath || !path.isAbsolute(resolvedPath)) {
apps/server/src/git/Layers/GitCore.test.ts+4
......@@ -12,12 +12,15 @@ import { GitCoreLive } from "./GitCore.ts";
1212import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
1313import { GitCommandError } from "../Errors.ts";
1414import { type ProcessRunResult, runProcess } from "../../processRunner.ts";
15import { ServerConfig } from "../../config.ts";
1516
1617// ── Helpers ──
1718
1819const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer));
20const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" });
1921const GitCoreTestLayer = GitCoreLive.pipe(
2022 Layer.provide(GitServiceTestLayer),
23 Layer.provide(ServerConfigLayer),
2124 Layer.provide(NodeServices.layer),
2225);
2326const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer);
......@@ -90,6 +93,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) =>
9093 const gitServiceLayer = Layer.succeed(GitService, gitService);
9194 const coreLayer = GitCoreLive.pipe(
9295 Layer.provide(gitServiceLayer),
96 Layer.provide(ServerConfigLayer),
9397 Layer.provide(NodeServices.layer),
9498 );
9599 const core = await Effect.runPromise(Effect.service(GitCore).pipe(Effect.provide(coreLayer)));
apps/server/src/git/Layers/GitCore.ts+3-3
......@@ -3,6 +3,7 @@ import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "ef
33import { GitCommandError } from "../Errors.ts";
44import { GitService } from "../Services/GitService.ts";
55import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
6import { ServerConfig } from "../../config.ts";
67
78const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
89const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
......@@ -221,6 +222,7 @@ const makeGitCore = Effect.gen(function* () {
221222 const git = yield* GitService;
222223 const fileSystem = yield* FileSystem.FileSystem;
223224 const path = yield* Path.Path;
225 const { worktreesDir } = yield* ServerConfig;
224226
225227 const executeGit = (
226228 operation: string,
......@@ -1186,9 +1188,7 @@ const makeGitCore = Effect.gen(function* () {
11861188 const targetBranch = input.newBranch ?? input.branch;
11871189 const sanitizedBranch = targetBranch.replace(/\//g, "-");
11881190 const repoName = path.basename(input.cwd);
1189 const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
1190 const worktreePath =
1191 input.path ?? path.join(homeDir, ".t3", "worktrees", repoName, sanitizedBranch);
1191 const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);
11921192 const args = input.newBranch
11931193 ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
11941194 : ["worktree", "add", worktreePath, input.branch];
apps/server/src/git/Layers/GitManager.test.ts+6-2
......@@ -19,6 +19,7 @@ import { GitServiceLive } from "./GitService.ts";
1919import { GitService } from "../Services/GitService.ts";
2020import { GitCoreLive } from "./GitCore.ts";
2121import { makeGitManager } from "./GitManager.ts";
22import { ServerConfig } from "../../config.ts";
2223
2324interface FakeGhScenario {
2425 prListSequence?: string[];
......@@ -474,18 +475,21 @@ function makeManager(input?: {
474475}) {
475476 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
476477 const textGeneration = createTextGeneration(input?.textGeneration);
478 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
479 prefix: "t3-git-manager-test-",
480 });
477481
478482 const gitCoreLayer = GitCoreLive.pipe(
479483 Layer.provideMerge(GitServiceLive),
480484 Layer.provideMerge(NodeServices.layer),
485 Layer.provideMerge(ServerConfigLayer),
481486 );
482487
483488 const managerLayer = Layer.mergeAll(
484489 Layer.succeed(GitHubCli, gitHubCli),
485490 Layer.succeed(TextGeneration, textGeneration),
486491 gitCoreLayer,
487 NodeServices.layer,
488 );
492 ).pipe(Layer.provideMerge(NodeServices.layer));
489493
490494 return makeGitManager.pipe(
491495 Effect.provide(managerLayer),
apps/server/src/keybindings.test.ts+9-11
......@@ -3,7 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
33import { assert, it } from "@effect/vitest";
44import { assertFailure } from "@effect/vitest/utils";
55import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";
6import { ServerConfig, type ServerConfigShape } from "./config";
6import { ServerConfig } from "./config";
77
88import {
99 DEFAULT_KEYBINDINGS,
......@@ -17,21 +17,17 @@ import {
1717} from "./keybindings";
1818
1919const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
20const makeKeybindingsLayer = () =>
21 KeybindingsLive.pipe(
20const makeKeybindingsLayer = () => {
21 return KeybindingsLive.pipe(
2222 Layer.provideMerge(
23 Layer.effect(
24 ServerConfig,
25 Effect.gen(function* () {
26 const fs = yield* FileSystem.FileSystem;
27 const { join } = yield* Path.Path;
28 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-server-config-test-" });
29 const configPath = join(dir, "keybindings.json");
30 return { keybindingsConfigPath: configPath } as ServerConfigShape;
23 Layer.fresh(
24 ServerConfig.layerTest(process.cwd(), {
25 prefix: "t3code-keybindings-test-",
3126 }),
3227 ),
3328 ),
3429 );
30};
3531
3632const toDetailResult = <A, R>(effect: Effect.Effect<A, KeybindingsConfigError, R>) =>
3733 effect.pipe(
......@@ -42,7 +38,9 @@ const toDetailResult = <A, R>(effect: Effect.Effect<A, KeybindingsConfigError, R
4238const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRule[]) =>
4339 Effect.gen(function* () {
4440 const fileSystem = yield* FileSystem.FileSystem;
41 const path = yield* Path.Path;
4542 const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)(rules);
43 yield* fileSystem.makeDirectory(path.dirname(configPath), { recursive: true });
4644 yield* fileSystem.writeFileString(configPath, encoded);
4745 });
4846
apps/server/src/main.test.ts+7-7
......@@ -60,13 +60,11 @@ const runCli = (
6060 args: ReadonlyArray<string>,
6161 env: Record<string, string> = { T3CODE_NO_BROWSER: "true" },
6262) => {
63 const uniqueStateDir = `/tmp/t3-cli-state-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
6463 return Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe(
6564 Effect.provide(
6665 ConfigProvider.layer(
6766 ConfigProvider.fromEnv({
6867 env: {
69 T3CODE_STATE_DIR: uniqueStateDir,
7068 ...env,
7169 },
7270 }),
......@@ -93,8 +91,8 @@ it.layer(testLayer)("server CLI command", (it) => {
9391 "4010",
9492 "--host",
9593 "0.0.0.0",
96 "--state-dir",
97 "/tmp/t3-cli-state",
94 "--home-dir",
95 "/tmp/t3-cli-home",
9896 "--dev-url",
9997 "http://127.0.0.1:5173",
10098 "--no-browser",
......@@ -106,7 +104,8 @@ it.layer(testLayer)("server CLI command", (it) => {
106104 assert.equal(resolvedConfig?.mode, "desktop");
107105 assert.equal(resolvedConfig?.port, 4010);
108106 assert.equal(resolvedConfig?.host, "0.0.0.0");
109 assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-state");
107 assert.equal(resolvedConfig?.baseDir, "/tmp/t3-cli-home");
108 assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-home/dev");
110109 assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
111110 assert.equal(resolvedConfig?.noBrowser, true);
112111 assert.equal(resolvedConfig?.authToken, "auth-secret");
......@@ -131,7 +130,7 @@ it.layer(testLayer)("server CLI command", (it) => {
131130 T3CODE_MODE: "desktop",
132131 T3CODE_PORT: "4999",
133132 T3CODE_HOST: "100.88.10.4",
134 T3CODE_STATE_DIR: "/tmp/t3-env-state",
133 T3CODE_HOME: "/tmp/t3-env-home",
135134 VITE_DEV_SERVER_URL: "http://localhost:5173",
136135 T3CODE_NO_BROWSER: "true",
137136 T3CODE_AUTH_TOKEN: "env-token",
......@@ -141,7 +140,8 @@ it.layer(testLayer)("server CLI command", (it) => {
141140 assert.equal(resolvedConfig?.mode, "desktop");
142141 assert.equal(resolvedConfig?.port, 4999);
143142 assert.equal(resolvedConfig?.host, "100.88.10.4");
144 assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-state");
143 assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home");
144 assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-home/dev");
145145 assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/");
146146 assert.equal(resolvedConfig?.noBrowser, true);
147147 assert.equal(resolvedConfig?.authToken, "env-token");
apps/server/src/main.ts+12-16
......@@ -11,12 +11,13 @@ import { Command, Flag } from "effect/unstable/cli";
1111import { NetService } from "@t3tools/shared/Net";
1212import {
1313 DEFAULT_PORT,
14 deriveServerPaths,
1415 resolveStaticDir,
1516 ServerConfig,
1617 type RuntimeMode,
1718 type ServerConfigShape,
1819} from "./config";
19import { fixPath, resolveStateDir } from "./os-jank";
20import { fixPath, resolveBaseDir } from "./os-jank";
2021import { Open } from "./open";
2122import * as SqlitePersistence from "./persistence/Layers/Sqlite";
2223import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
......@@ -36,7 +37,7 @@ interface CliInput {
3637 readonly mode: Option.Option<RuntimeMode>;
3738 readonly port: Option.Option<number>;
3839 readonly host: Option.Option<string>;
39 readonly stateDir: Option.Option<string>;
40 readonly t3Home: Option.Option<string>;
4041 readonly devUrl: Option.Option<URL>;
4142 readonly noBrowser: Option.Option<boolean>;
4243 readonly authToken: Option.Option<string>;
......@@ -99,10 +100,7 @@ const CliEnvConfig = Config.all({
99100 ),
100101 port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
101102 host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
102 stateDir: Config.string("T3CODE_STATE_DIR").pipe(
103 Config.option,
104 Config.map(Option.getOrUndefined),
105 ),
103 t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
106104 devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
107105 noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(
108106 Config.option,
......@@ -152,10 +150,10 @@ const ServerConfigLive = (input: CliInput) =>
152150 return findAvailablePort(DEFAULT_PORT);
153151 },
154152 });
155 const stateDir = yield* resolveStateDir(
156 Option.getOrUndefined(input.stateDir) ?? env.stateDir,
157 );
153
158154 const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl);
155 const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home);
156 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
159157 const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop");
160158 const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;
161159 const autoBootstrapProjectFromCwd = resolveBooleanFlag(
......@@ -167,8 +165,6 @@ const ServerConfigLive = (input: CliInput) =>
167165 env.logWebSocketEvents ?? Boolean(devUrl),
168166 );
169167 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
170 const { join } = yield* Path.Path;
171 const keybindingsConfigPath = join(stateDir, "keybindings.json");
172168 const host =
173169 Option.getOrUndefined(input.host) ??
174170 env.host ??
......@@ -178,9 +174,9 @@ const ServerConfigLive = (input: CliInput) =>
178174 mode,
179175 port,
180176 cwd: cliConfig.cwd,
181 keybindingsConfigPath,
182177 host,
183 stateDir,
178 baseDir,
179 ...derivedPaths,
184180 staticDir,
185181 devUrl,
186182 noBrowser,
......@@ -299,8 +295,8 @@ const hostFlag = Flag.string("host").pipe(
299295 Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."),
300296 Flag.optional,
301297);
302const stateDirFlag = Flag.string("state-dir").pipe(
303 Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."),
298const t3HomeFlag = Flag.string("home-dir").pipe(
299 Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."),
304300 Flag.optional,
305301);
306302const devUrlFlag = Flag.string("dev-url").pipe(
......@@ -335,7 +331,7 @@ export const t3Cli = Command.make("t3", {
335331 mode: modeFlag,
336332 port: portFlag,
337333 host: hostFlag,
338 stateDir: stateDirFlag,
334 t3Home: t3HomeFlag,
339335 devUrl: devUrlFlag,
340336 noBrowser: noBrowserFlag,
341337 authToken: authTokenFlag,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+5-1
......@@ -252,12 +252,16 @@ describe("CheckpointReactor", () => {
252252 Layer.provide(SqlitePersistenceMemory),
253253 );
254254
255 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
256 prefix: "t3-checkpoint-reactor-test-",
257 });
258
255259 const layer = CheckpointReactorLive.pipe(
256260 Layer.provideMerge(orchestrationLayer),
257261 Layer.provideMerge(RuntimeReceiptBusLive),
258262 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
259263 Layer.provideMerge(CheckpointStoreLive),
260 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
264 Layer.provideMerge(ServerConfigLayer),
261265 Layer.provideMerge(NodeServices.layer),
262266 );
263267
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+9-2
......@@ -35,12 +35,15 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
3535const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
3636
3737async function createOrchestrationSystem() {
38 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
39 prefix: "t3-orchestration-engine-test-",
40 });
3841 const orchestrationLayer = OrchestrationEngineLive.pipe(
3942 Layer.provide(OrchestrationProjectionPipelineLive),
4043 Layer.provide(OrchestrationEventStoreLive),
4144 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
4245 Layer.provide(SqlitePersistenceMemory),
43 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
46 Layer.provideMerge(ServerConfigLayer),
4447 Layer.provideMerge(NodeServices.layer),
4548 );
4649 const runtime = ManagedRuntime.make(orchestrationLayer);
......@@ -317,13 +320,17 @@ describe("OrchestrationEngine", () => {
317320 },
318321 };
319322
323 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
324 prefix: "t3-orchestration-engine-test-",
325 });
326
320327 const runtime = ManagedRuntime.make(
321328 OrchestrationEngineLive.pipe(
322329 Layer.provide(OrchestrationProjectionPipelineLive),
323330 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
324331 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
325332 Layer.provide(SqlitePersistenceMemory),
326 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
333 Layer.provideMerge(ServerConfigLayer),
327334 Layer.provideMerge(NodeServices.layer),
328335 ),
329336 );
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+757-783
......@@ -10,10 +10,7 @@ import {
1010} from "@t3tools/contracts";
1111import * as NodeServices from "@effect/platform-node/NodeServices";
1212import { assert, it } from "@effect/vitest";
13import { Effect, Layer, ManagedRuntime } from "effect";
14import fs from "node:fs";
15import os from "node:os";
16import path from "node:path";
13import { Effect, FileSystem, Layer, Path } from "effect";
1714import * as SqlClient from "effect/unstable/sql/SqlClient";
1815
1916import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
......@@ -32,31 +29,24 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
3229import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
3330import { ServerConfig } from "../../config.ts";
3431
35const makeProjectionPipelineTestLayer = (stateDir: string) =>
32const makeProjectionPipelinePrefixedTestLayer = (prefix: string) =>
3633 OrchestrationProjectionPipelineLive.pipe(
3734 Layer.provideMerge(OrchestrationEventStoreLive),
38 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)),
35 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix })),
3936 Layer.provideMerge(SqlitePersistenceMemory),
4037 Layer.provideMerge(NodeServices.layer),
4138 );
4239
43const runWithProjectionPipelineLayer = <A, E>(
44 stateDir: string,
45 effect: Effect.Effect<
46 A,
47 E,
48 OrchestrationProjectionPipeline | OrchestrationEventStore | SqlClient.SqlClient
49 >,
50) =>
51 Effect.acquireUseRelease(
52 Effect.sync(() => ManagedRuntime.make(makeProjectionPipelineTestLayer(stateDir))),
53 (runtime) => Effect.promise(() => runtime.runPromise(effect)),
54 (runtime) => Effect.promise(() => runtime.dispose()),
55 );
40const exists = (filePath: string) =>
41 Effect.gen(function* () {
42 const fileSystem = yield* FileSystem.FileSystem;
43 const fileInfo = yield* Effect.result(fileSystem.stat(filePath));
44 return fileInfo._tag === "Success";
45 });
5646
57const projectionLayer = it.layer(makeProjectionPipelineTestLayer(process.cwd()));
47const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-");
5848
59projectionLayer("OrchestrationProjectionPipeline", (it) => {
49it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
6050 it.effect("bootstraps all projection states and writes projection rows", () =>
6151 Effect.gen(function* () {
6252 const projectionPipeline = yield* OrchestrationProjectionPipeline;
......@@ -174,157 +164,155 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
174164 }
175165 }),
176166 );
167});
177168
178 it.effect("stores message attachment references without mutating payloads", () =>
179 Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe(
180 Effect.flatMap((stateDir) =>
181 Effect.gen(function* () {
182 const projectionPipeline = yield* OrchestrationProjectionPipeline;
183 const eventStore = yield* OrchestrationEventStore;
184 const sql = yield* SqlClient.SqlClient;
185 const now = new Date().toISOString();
186
187 yield* eventStore.append({
188 type: "thread.message-sent",
189 eventId: EventId.makeUnsafe("evt-attachments"),
190 aggregateKind: "thread",
191 aggregateId: ThreadId.makeUnsafe("thread-attachments"),
192 occurredAt: now,
193 commandId: CommandId.makeUnsafe("cmd-attachments"),
194 causationEventId: null,
195 correlationId: CommandId.makeUnsafe("cmd-attachments"),
196 metadata: {},
197 payload: {
198 threadId: ThreadId.makeUnsafe("thread-attachments"),
199 messageId: MessageId.makeUnsafe("message-attachments"),
200 role: "user",
201 text: "Inspect this",
202 attachments: [
203 {
204 type: "image",
205 id: "thread-attachments-att-1",
206 name: "example.png",
207 mimeType: "image/png",
208 sizeBytes: 5,
209 },
210 ],
211 turnId: null,
212 streaming: false,
213 createdAt: now,
214 updatedAt: now,
215 },
216 });
169it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))(
170 "OrchestrationProjectionPipeline",
171 (it) => {
172 it.effect("stores message attachment references without mutating payloads", () =>
173 Effect.gen(function* () {
174 const projectionPipeline = yield* OrchestrationProjectionPipeline;
175 const eventStore = yield* OrchestrationEventStore;
176 const sql = yield* SqlClient.SqlClient;
177 const now = new Date().toISOString();
178
179 yield* eventStore.append({
180 type: "thread.message-sent",
181 eventId: EventId.makeUnsafe("evt-attachments"),
182 aggregateKind: "thread",
183 aggregateId: ThreadId.makeUnsafe("thread-attachments"),
184 occurredAt: now,
185 commandId: CommandId.makeUnsafe("cmd-attachments"),
186 causationEventId: null,
187 correlationId: CommandId.makeUnsafe("cmd-attachments"),
188 metadata: {},
189 payload: {
190 threadId: ThreadId.makeUnsafe("thread-attachments"),
191 messageId: MessageId.makeUnsafe("message-attachments"),
192 role: "user",
193 text: "Inspect this",
194 attachments: [
195 {
196 type: "image",
197 id: "thread-attachments-att-1",
198 name: "example.png",
199 mimeType: "image/png",
200 sizeBytes: 5,
201 },
202 ],
203 turnId: null,
204 streaming: false,
205 createdAt: now,
206 updatedAt: now,
207 },
208 });
217209
218 yield* projectionPipeline.bootstrap;
210 yield* projectionPipeline.bootstrap;
219211
220 const rows = yield* sql<{
221 readonly attachmentsJson: string | null;
222 }>`
212 const rows = yield* sql<{
213 readonly attachmentsJson: string | null;
214 }>`
223215 SELECT
224216 attachments_json AS "attachmentsJson"
225217 FROM projection_thread_messages
226218 WHERE message_id = 'message-attachments'
227219 `;
228 assert.equal(rows.length, 1);
229 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
230 {
231 type: "image",
232 id: "thread-attachments-att-1",
233 name: "example.png",
234 mimeType: "image/png",
235 sizeBytes: 5,
236 },
237 ]);
238 }).pipe(
239 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
240 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
241 ),
242 ),
243 ),
244 );
220 assert.equal(rows.length, 1);
221 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
222 {
223 type: "image",
224 id: "thread-attachments-att-1",
225 name: "example.png",
226 mimeType: "image/png",
227 sizeBytes: 5,
228 },
229 ]);
230 }),
231 );
232 },
233);
245234
246 it.effect("preserves mixed image attachment metadata as-is", () =>
247 Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe(
248 Effect.flatMap((stateDir) =>
249 Effect.gen(function* () {
250 const projectionPipeline = yield* OrchestrationProjectionPipeline;
251 const eventStore = yield* OrchestrationEventStore;
252 const sql = yield* SqlClient.SqlClient;
253 const now = new Date().toISOString();
254
255 yield* eventStore.append({
256 type: "thread.message-sent",
257 eventId: EventId.makeUnsafe("evt-attachments-safe"),
258 aggregateKind: "thread",
259 aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"),
260 occurredAt: now,
261 commandId: CommandId.makeUnsafe("cmd-attachments-safe"),
262 causationEventId: null,
263 correlationId: CommandId.makeUnsafe("cmd-attachments-safe"),
264 metadata: {},
265 payload: {
266 threadId: ThreadId.makeUnsafe("thread-attachments-safe"),
267 messageId: MessageId.makeUnsafe("message-attachments-safe"),
268 role: "user",
269 text: "Inspect this",
270 attachments: [
271 {
272 type: "image",
273 id: "thread-attachments-safe-att-1",
274 name: "untrusted.exe",
275 mimeType: "image/x-unknown",
276 sizeBytes: 5,
277 },
278 {
279 type: "image",
280 id: "thread-attachments-safe-att-2",
281 name: "not-image.png",
282 mimeType: "image/png",
283 sizeBytes: 5,
284 },
285 ],
286 turnId: null,
287 streaming: false,
288 createdAt: now,
289 updatedAt: now,
290 },
291 });
235it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-safe-")))(
236 "OrchestrationProjectionPipeline",
237 (it) => {
238 it.effect("preserves mixed image attachment metadata as-is", () =>
239 Effect.gen(function* () {
240 const projectionPipeline = yield* OrchestrationProjectionPipeline;
241 const eventStore = yield* OrchestrationEventStore;
242 const sql = yield* SqlClient.SqlClient;
243 const now = new Date().toISOString();
244
245 yield* eventStore.append({
246 type: "thread.message-sent",
247 eventId: EventId.makeUnsafe("evt-attachments-safe"),
248 aggregateKind: "thread",
249 aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"),
250 occurredAt: now,
251 commandId: CommandId.makeUnsafe("cmd-attachments-safe"),
252 causationEventId: null,
253 correlationId: CommandId.makeUnsafe("cmd-attachments-safe"),
254 metadata: {},
255 payload: {
256 threadId: ThreadId.makeUnsafe("thread-attachments-safe"),
257 messageId: MessageId.makeUnsafe("message-attachments-safe"),
258 role: "user",
259 text: "Inspect this",
260 attachments: [
261 {
262 type: "image",
263 id: "thread-attachments-safe-att-1",
264 name: "untrusted.exe",
265 mimeType: "image/x-unknown",
266 sizeBytes: 5,
267 },
268 {
269 type: "image",
270 id: "thread-attachments-safe-att-2",
271 name: "not-image.png",
272 mimeType: "image/png",
273 sizeBytes: 5,
274 },
275 ],
276 turnId: null,
277 streaming: false,
278 createdAt: now,
279 updatedAt: now,
280 },
281 });
292282
293 yield* projectionPipeline.bootstrap;
283 yield* projectionPipeline.bootstrap;
294284
295 const rows = yield* sql<{
296 readonly attachmentsJson: string | null;
297 }>`
285 const rows = yield* sql<{
286 readonly attachmentsJson: string | null;
287 }>`
298288 SELECT
299289 attachments_json AS "attachmentsJson"
300290 FROM projection_thread_messages
301291 WHERE message_id = 'message-attachments-safe'
302292 `;
303 assert.equal(rows.length, 1);
304 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
305 {
306 type: "image",
307 id: "thread-attachments-safe-att-1",
308 name: "untrusted.exe",
309 mimeType: "image/x-unknown",
310 sizeBytes: 5,
311 },
312 {
313 type: "image",
314 id: "thread-attachments-safe-att-2",
315 name: "not-image.png",
316 mimeType: "image/png",
317 sizeBytes: 5,
318 },
319 ]);
320 }).pipe(
321 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
322 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
323 ),
324 ),
325 ),
326 );
293 assert.equal(rows.length, 1);
294 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
295 {
296 type: "image",
297 id: "thread-attachments-safe-att-1",
298 name: "untrusted.exe",
299 mimeType: "image/x-unknown",
300 sizeBytes: 5,
301 },
302 {
303 type: "image",
304 id: "thread-attachments-safe-att-2",
305 name: "not-image.png",
306 mimeType: "image/png",
307 sizeBytes: 5,
308 },
309 ]);
310 }),
311 );
312 },
313);
327314
315it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
328316 it.effect(
329317 "passes explicit empty attachment arrays through the projection pipeline to clear attachments",
330318 () =>
......@@ -447,136 +435,110 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
447435 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), []);
448436 }),
449437 );
438});
450439
440it.layer(
441 Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-overwrite-")),
442)("OrchestrationProjectionPipeline", (it) => {
451443 it.effect("overwrites stored attachment references when a message updates attachments", () =>
452 Effect.sync(() =>
453 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")),
454 ).pipe(
455 Effect.flatMap((stateDir) =>
456 Effect.gen(function* () {
457 const projectionPipeline = yield* OrchestrationProjectionPipeline;
458 const eventStore = yield* OrchestrationEventStore;
459 const sql = yield* SqlClient.SqlClient;
460 const now = new Date().toISOString();
461 const later = new Date(Date.now() + 1_000).toISOString();
462
463 yield* eventStore.append({
464 type: "project.created",
465 eventId: EventId.makeUnsafe("evt-overwrite-1"),
466 aggregateKind: "project",
467 aggregateId: ProjectId.makeUnsafe("project-overwrite"),
468 occurredAt: now,
469 commandId: CommandId.makeUnsafe("cmd-overwrite-1"),
470 causationEventId: null,
471 correlationId: CommandId.makeUnsafe("cmd-overwrite-1"),
472 metadata: {},
473 payload: {
474 projectId: ProjectId.makeUnsafe("project-overwrite"),
475 title: "Project Overwrite",
476 workspaceRoot: "/tmp/project-overwrite",
477 defaultModel: null,
478 scripts: [],
479 createdAt: now,
480 updatedAt: now,
481 },
482 });
483
484 yield* eventStore.append({
485 type: "thread.created",
486 eventId: EventId.makeUnsafe("evt-overwrite-2"),
487 aggregateKind: "thread",
488 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
489 occurredAt: now,
490 commandId: CommandId.makeUnsafe("cmd-overwrite-2"),
491 causationEventId: null,
492 correlationId: CommandId.makeUnsafe("cmd-overwrite-2"),
493 metadata: {},
494 payload: {
495 threadId: ThreadId.makeUnsafe("thread-overwrite"),
496 projectId: ProjectId.makeUnsafe("project-overwrite"),
497 title: "Thread Overwrite",
498 model: "gpt-5-codex",
499 runtimeMode: "full-access",
500 branch: null,
501 worktreePath: null,
502 createdAt: now,
503 updatedAt: now,
504 },
505 });
506
507 yield* eventStore.append({
508 type: "thread.message-sent",
509 eventId: EventId.makeUnsafe("evt-overwrite-3"),
510 aggregateKind: "thread",
511 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
512 occurredAt: now,
513 commandId: CommandId.makeUnsafe("cmd-overwrite-3"),
514 causationEventId: null,
515 correlationId: CommandId.makeUnsafe("cmd-overwrite-3"),
516 metadata: {},
517 payload: {
518 threadId: ThreadId.makeUnsafe("thread-overwrite"),
519 messageId: MessageId.makeUnsafe("message-overwrite"),
520 role: "user",
521 text: "first image",
522 attachments: [
523 {
524 type: "image",
525 id: "thread-overwrite-att-1",
526 name: "file.png",
527 mimeType: "image/png",
528 sizeBytes: 5,
529 },
530 ],
531 turnId: null,
532 streaming: false,
533 createdAt: now,
534 updatedAt: now,
535 },
536 });
537
538 yield* eventStore.append({
539 type: "thread.message-sent",
540 eventId: EventId.makeUnsafe("evt-overwrite-4"),
541 aggregateKind: "thread",
542 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
543 occurredAt: later,
544 commandId: CommandId.makeUnsafe("cmd-overwrite-4"),
545 causationEventId: null,
546 correlationId: CommandId.makeUnsafe("cmd-overwrite-4"),
547 metadata: {},
548 payload: {
549 threadId: ThreadId.makeUnsafe("thread-overwrite"),
550 messageId: MessageId.makeUnsafe("message-overwrite"),
551 role: "user",
552 text: "",
553 attachments: [
554 {
555 type: "image",
556 id: "thread-overwrite-att-2",
557 name: "file.png",
558 mimeType: "image/png",
559 sizeBytes: 5,
560 },
561 ],
562 turnId: null,
563 streaming: false,
564 createdAt: now,
565 updatedAt: later,
566 },
567 });
444 Effect.gen(function* () {
445 const projectionPipeline = yield* OrchestrationProjectionPipeline;
446 const eventStore = yield* OrchestrationEventStore;
447 const sql = yield* SqlClient.SqlClient;
448 const now = new Date().toISOString();
449 const later = new Date(Date.now() + 1_000).toISOString();
568450
569 yield* projectionPipeline.bootstrap;
451 yield* eventStore.append({
452 type: "project.created",
453 eventId: EventId.makeUnsafe("evt-overwrite-1"),
454 aggregateKind: "project",
455 aggregateId: ProjectId.makeUnsafe("project-overwrite"),
456 occurredAt: now,
457 commandId: CommandId.makeUnsafe("cmd-overwrite-1"),
458 causationEventId: null,
459 correlationId: CommandId.makeUnsafe("cmd-overwrite-1"),
460 metadata: {},
461 payload: {
462 projectId: ProjectId.makeUnsafe("project-overwrite"),
463 title: "Project Overwrite",
464 workspaceRoot: "/tmp/project-overwrite",
465 defaultModel: null,
466 scripts: [],
467 createdAt: now,
468 updatedAt: now,
469 },
470 });
570471
571 const rows = yield* sql<{
572 readonly attachmentsJson: string | null;
573 }>`
574 SELECT attachments_json AS "attachmentsJson"
575 FROM projection_thread_messages
576 WHERE message_id = 'message-overwrite'
577 `;
578 assert.equal(rows.length, 1);
579 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
472 yield* eventStore.append({
473 type: "thread.created",
474 eventId: EventId.makeUnsafe("evt-overwrite-2"),
475 aggregateKind: "thread",
476 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
477 occurredAt: now,
478 commandId: CommandId.makeUnsafe("cmd-overwrite-2"),
479 causationEventId: null,
480 correlationId: CommandId.makeUnsafe("cmd-overwrite-2"),
481 metadata: {},
482 payload: {
483 threadId: ThreadId.makeUnsafe("thread-overwrite"),
484 projectId: ProjectId.makeUnsafe("project-overwrite"),
485 title: "Thread Overwrite",
486 model: "gpt-5-codex",
487 runtimeMode: "full-access",
488 branch: null,
489 worktreePath: null,
490 createdAt: now,
491 updatedAt: now,
492 },
493 });
494
495 yield* eventStore.append({
496 type: "thread.message-sent",
497 eventId: EventId.makeUnsafe("evt-overwrite-3"),
498 aggregateKind: "thread",
499 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
500 occurredAt: now,
501 commandId: CommandId.makeUnsafe("cmd-overwrite-3"),
502 causationEventId: null,
503 correlationId: CommandId.makeUnsafe("cmd-overwrite-3"),
504 metadata: {},
505 payload: {
506 threadId: ThreadId.makeUnsafe("thread-overwrite"),
507 messageId: MessageId.makeUnsafe("message-overwrite"),
508 role: "user",
509 text: "first image",
510 attachments: [
511 {
512 type: "image",
513 id: "thread-overwrite-att-1",
514 name: "file.png",
515 mimeType: "image/png",
516 sizeBytes: 5,
517 },
518 ],
519 turnId: null,
520 streaming: false,
521 createdAt: now,
522 updatedAt: now,
523 },
524 });
525
526 yield* eventStore.append({
527 type: "thread.message-sent",
528 eventId: EventId.makeUnsafe("evt-overwrite-4"),
529 aggregateKind: "thread",
530 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
531 occurredAt: later,
532 commandId: CommandId.makeUnsafe("cmd-overwrite-4"),
533 causationEventId: null,
534 correlationId: CommandId.makeUnsafe("cmd-overwrite-4"),
535 metadata: {},
536 payload: {
537 threadId: ThreadId.makeUnsafe("thread-overwrite"),
538 messageId: MessageId.makeUnsafe("message-overwrite"),
539 role: "user",
540 text: "",
541 attachments: [
580542 {
581543 type: "image",
582544 id: "thread-overwrite-att-2",
......@@ -584,76 +546,98 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
584546 mimeType: "image/png",
585547 sizeBytes: 5,
586548 },
587 ]);
588 }).pipe(
589 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
590 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
591 ),
592 ),
593 ),
549 ],
550 turnId: null,
551 streaming: false,
552 createdAt: now,
553 updatedAt: later,
554 },
555 });
556
557 yield* projectionPipeline.bootstrap;
558
559 const rows = yield* sql<{
560 readonly attachmentsJson: string | null;
561 }>`
562 SELECT attachments_json AS "attachmentsJson"
563 FROM projection_thread_messages
564 WHERE message_id = 'message-overwrite'
565 `;
566 assert.equal(rows.length, 1);
567 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
568 {
569 type: "image",
570 id: "thread-overwrite-att-2",
571 name: "file.png",
572 mimeType: "image/png",
573 sizeBytes: 5,
574 },
575 ]);
576 }),
594577 );
578});
595579
580it.layer(
581 Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-rollback-")),
582)("OrchestrationProjectionPipeline", (it) => {
596583 it.effect("does not persist attachment files when projector transaction rolls back", () =>
597 Effect.sync(() =>
598 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-rollback-")),
599 ).pipe(
600 Effect.flatMap((stateDir) =>
601 Effect.gen(function* () {
602 const projectionPipeline = yield* OrchestrationProjectionPipeline;
603 const eventStore = yield* OrchestrationEventStore;
604 const sql = yield* SqlClient.SqlClient;
605 const now = new Date().toISOString();
606
607 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
608 eventStore
609 .append(event)
610 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
611
612 yield* appendAndProject({
613 type: "project.created",
614 eventId: EventId.makeUnsafe("evt-rollback-1"),
615 aggregateKind: "project",
616 aggregateId: ProjectId.makeUnsafe("project-rollback"),
617 occurredAt: now,
618 commandId: CommandId.makeUnsafe("cmd-rollback-1"),
619 causationEventId: null,
620 correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"),
621 metadata: {},
622 payload: {
623 projectId: ProjectId.makeUnsafe("project-rollback"),
624 title: "Project Rollback",
625 workspaceRoot: "/tmp/project-rollback",
626 defaultModel: null,
627 scripts: [],
628 createdAt: now,
629 updatedAt: now,
630 },
631 });
632
633 yield* appendAndProject({
634 type: "thread.created",
635 eventId: EventId.makeUnsafe("evt-rollback-2"),
636 aggregateKind: "thread",
637 aggregateId: ThreadId.makeUnsafe("thread-rollback"),
638 occurredAt: now,
639 commandId: CommandId.makeUnsafe("cmd-rollback-2"),
640 causationEventId: null,
641 correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"),
642 metadata: {},
643 payload: {
644 threadId: ThreadId.makeUnsafe("thread-rollback"),
645 projectId: ProjectId.makeUnsafe("project-rollback"),
646 title: "Thread Rollback",
647 model: "gpt-5-codex",
648 runtimeMode: "full-access",
649 branch: null,
650 worktreePath: null,
651 createdAt: now,
652 updatedAt: now,
653 },
654 });
584 Effect.gen(function* () {
585 const projectionPipeline = yield* OrchestrationProjectionPipeline;
586 const eventStore = yield* OrchestrationEventStore;
587 const path = yield* Path.Path;
588 const sql = yield* SqlClient.SqlClient;
589 const now = new Date().toISOString();
590
591 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
592 eventStore
593 .append(event)
594 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
655595
656 yield* sql`
596 yield* appendAndProject({
597 type: "project.created",
598 eventId: EventId.makeUnsafe("evt-rollback-1"),
599 aggregateKind: "project",
600 aggregateId: ProjectId.makeUnsafe("project-rollback"),
601 occurredAt: now,
602 commandId: CommandId.makeUnsafe("cmd-rollback-1"),
603 causationEventId: null,
604 correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"),
605 metadata: {},
606 payload: {
607 projectId: ProjectId.makeUnsafe("project-rollback"),
608 title: "Project Rollback",
609 workspaceRoot: "/tmp/project-rollback",
610 defaultModel: null,
611 scripts: [],
612 createdAt: now,
613 updatedAt: now,
614 },
615 });
616
617 yield* appendAndProject({
618 type: "thread.created",
619 eventId: EventId.makeUnsafe("evt-rollback-2"),
620 aggregateKind: "thread",
621 aggregateId: ThreadId.makeUnsafe("thread-rollback"),
622 occurredAt: now,
623 commandId: CommandId.makeUnsafe("cmd-rollback-2"),
624 causationEventId: null,
625 correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"),
626 metadata: {},
627 payload: {
628 threadId: ThreadId.makeUnsafe("thread-rollback"),
629 projectId: ProjectId.makeUnsafe("project-rollback"),
630 title: "Thread Rollback",
631 model: "gpt-5-codex",
632 runtimeMode: "full-access",
633 branch: null,
634 worktreePath: null,
635 createdAt: now,
636 updatedAt: now,
637 },
638 });
639
640 yield* sql`
657641 CREATE TRIGGER fail_thread_messages_projection_state_update
658642 BEFORE UPDATE ON projection_state
659643 WHEN NEW.projector = 'projection.thread-messages'
......@@ -662,453 +646,437 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
662646 END;
663647 `;
664648
665 const result = yield* Effect.result(
666 appendAndProject({
667 type: "thread.message-sent",
668 eventId: EventId.makeUnsafe("evt-rollback-3"),
669 aggregateKind: "thread",
670 aggregateId: ThreadId.makeUnsafe("thread-rollback"),
671 occurredAt: now,
672 commandId: CommandId.makeUnsafe("cmd-rollback-3"),
673 causationEventId: null,
674 correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"),
675 metadata: {},
676 payload: {
677 threadId: ThreadId.makeUnsafe("thread-rollback"),
678 messageId: MessageId.makeUnsafe("message-rollback"),
679 role: "user",
680 text: "Rollback me",
681 attachments: [
682 {
683 type: "image",
684 id: "thread-rollback-att-1",
685 name: "rollback.png",
686 mimeType: "image/png",
687 sizeBytes: 5,
688 },
689 ],
690 turnId: null,
691 streaming: false,
692 createdAt: now,
693 updatedAt: now,
649 const result = yield* Effect.result(
650 appendAndProject({
651 type: "thread.message-sent",
652 eventId: EventId.makeUnsafe("evt-rollback-3"),
653 aggregateKind: "thread",
654 aggregateId: ThreadId.makeUnsafe("thread-rollback"),
655 occurredAt: now,
656 commandId: CommandId.makeUnsafe("cmd-rollback-3"),
657 causationEventId: null,
658 correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"),
659 metadata: {},
660 payload: {
661 threadId: ThreadId.makeUnsafe("thread-rollback"),
662 messageId: MessageId.makeUnsafe("message-rollback"),
663 role: "user",
664 text: "Rollback me",
665 attachments: [
666 {
667 type: "image",
668 id: "thread-rollback-att-1",
669 name: "rollback.png",
670 mimeType: "image/png",
671 sizeBytes: 5,
694672 },
695 }),
696 );
697 assert.equal(result._tag, "Failure");
673 ],
674 turnId: null,
675 streaming: false,
676 createdAt: now,
677 updatedAt: now,
678 },
679 }),
680 );
681 assert.equal(result._tag, "Failure");
698682
699 const rows = yield* sql<{
700 readonly count: number;
701 }>`
683 const rows = yield* sql<{
684 readonly count: number;
685 }>`
702686 SELECT COUNT(*) AS "count"
703687 FROM projection_thread_messages
704688 WHERE message_id = 'message-rollback'
705689 `;
706 assert.equal(rows[0]?.count ?? 0, 0);
707
708 const attachmentPath = path.join(stateDir, "attachments", "thread-rollback-att-1.png");
709 assert.equal(fs.existsSync(attachmentPath), false);
710 yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`;
711 }).pipe(
712 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
713 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
714 ),
715 ),
716 ),
690 assert.equal(rows[0]?.count ?? 0, 0);
691
692 const { attachmentsDir } = yield* ServerConfig;
693 const attachmentPath = path.join(attachmentsDir, "thread-rollback-att-1.png");
694 assert.isFalse(yield* exists(attachmentPath));
695 yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`;
696 }),
717697 );
698});
718699
700it.layer(
701 Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-overwrite-")),
702)("OrchestrationProjectionPipeline", (it) => {
719703 it.effect("removes unreferenced attachment files when a thread is reverted", () =>
720 Effect.sync(() =>
721 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-revert-")),
722 ).pipe(
723 Effect.flatMap((stateDir) =>
724 Effect.gen(function* () {
725 const projectionPipeline = yield* OrchestrationProjectionPipeline;
726 const eventStore = yield* OrchestrationEventStore;
727 const now = new Date().toISOString();
728 const threadId = ThreadId.makeUnsafe("Thread Revert.Files");
729 const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001";
730 const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002";
731 const otherThreadAttachmentId =
732 "thread-revert-files-extra-00000000-0000-4000-8000-000000000003";
733
734 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
735 eventStore
736 .append(event)
737 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
738
739 yield* appendAndProject({
740 type: "project.created",
741 eventId: EventId.makeUnsafe("evt-revert-files-1"),
742 aggregateKind: "project",
743 aggregateId: ProjectId.makeUnsafe("project-revert-files"),
744 occurredAt: now,
745 commandId: CommandId.makeUnsafe("cmd-revert-files-1"),
746 causationEventId: null,
747 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"),
748 metadata: {},
749 payload: {
750 projectId: ProjectId.makeUnsafe("project-revert-files"),
751 title: "Project Revert Files",
752 workspaceRoot: "/tmp/project-revert-files",
753 defaultModel: null,
754 scripts: [],
755 createdAt: now,
756 updatedAt: now,
757 },
758 });
759
760 yield* appendAndProject({
761 type: "thread.created",
762 eventId: EventId.makeUnsafe("evt-revert-files-2"),
763 aggregateKind: "thread",
764 aggregateId: threadId,
765 occurredAt: now,
766 commandId: CommandId.makeUnsafe("cmd-revert-files-2"),
767 causationEventId: null,
768 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"),
769 metadata: {},
770 payload: {
771 threadId,
772 projectId: ProjectId.makeUnsafe("project-revert-files"),
773 title: "Thread Revert Files",
774 model: "gpt-5-codex",
775 runtimeMode: "full-access",
776 branch: null,
777 worktreePath: null,
778 createdAt: now,
779 updatedAt: now,
780 },
781 });
782
783 yield* appendAndProject({
784 type: "thread.turn-diff-completed",
785 eventId: EventId.makeUnsafe("evt-revert-files-3"),
786 aggregateKind: "thread",
787 aggregateId: threadId,
788 occurredAt: now,
789 commandId: CommandId.makeUnsafe("cmd-revert-files-3"),
790 causationEventId: null,
791 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"),
792 metadata: {},
793 payload: {
794 threadId,
795 turnId: TurnId.makeUnsafe("turn-keep"),
796 checkpointTurnCount: 1,
797 checkpointRef: CheckpointRef.makeUnsafe(
798 "refs/t3/checkpoints/thread-revert-files/turn/1",
799 ),
800 status: "ready",
801 files: [],
802 assistantMessageId: MessageId.makeUnsafe("message-keep"),
803 completedAt: now,
804 },
805 });
806
807 yield* appendAndProject({
808 type: "thread.message-sent",
809 eventId: EventId.makeUnsafe("evt-revert-files-4"),
810 aggregateKind: "thread",
811 aggregateId: threadId,
812 occurredAt: now,
813 commandId: CommandId.makeUnsafe("cmd-revert-files-4"),
814 causationEventId: null,
815 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"),
816 metadata: {},
817 payload: {
818 threadId,
819 messageId: MessageId.makeUnsafe("message-keep"),
820 role: "assistant",
821 text: "Keep",
822 attachments: [
823 {
824 type: "image",
825 id: keepAttachmentId,
826 name: "keep.png",
827 mimeType: "image/png",
828 sizeBytes: 5,
829 },
830 ],
831 turnId: TurnId.makeUnsafe("turn-keep"),
832 streaming: false,
833 createdAt: now,
834 updatedAt: now,
835 },
836 });
837
838 yield* appendAndProject({
839 type: "thread.turn-diff-completed",
840 eventId: EventId.makeUnsafe("evt-revert-files-5"),
841 aggregateKind: "thread",
842 aggregateId: threadId,
843 occurredAt: now,
844 commandId: CommandId.makeUnsafe("cmd-revert-files-5"),
845 causationEventId: null,
846 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"),
847 metadata: {},
848 payload: {
849 threadId,
850 turnId: TurnId.makeUnsafe("turn-remove"),
851 checkpointTurnCount: 2,
852 checkpointRef: CheckpointRef.makeUnsafe(
853 "refs/t3/checkpoints/thread-revert-files/turn/2",
854 ),
855 status: "ready",
856 files: [],
857 assistantMessageId: MessageId.makeUnsafe("message-remove"),
858 completedAt: now,
859 },
860 });
861
862 yield* appendAndProject({
863 type: "thread.message-sent",
864 eventId: EventId.makeUnsafe("evt-revert-files-6"),
865 aggregateKind: "thread",
866 aggregateId: threadId,
867 occurredAt: now,
868 commandId: CommandId.makeUnsafe("cmd-revert-files-6"),
869 causationEventId: null,
870 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"),
871 metadata: {},
872 payload: {
873 threadId,
874 messageId: MessageId.makeUnsafe("message-remove"),
875 role: "assistant",
876 text: "Remove",
877 attachments: [
878 {
879 type: "image",
880 id: removeAttachmentId,
881 name: "remove.png",
882 mimeType: "image/png",
883 sizeBytes: 5,
884 },
885 ],
886 turnId: TurnId.makeUnsafe("turn-remove"),
887 streaming: false,
888 createdAt: now,
889 updatedAt: now,
890 },
891 });
892
893 const keepPath = path.join(stateDir, "attachments", `${keepAttachmentId}.png`);
894 const removePath = path.join(stateDir, "attachments", `${removeAttachmentId}.png`);
895 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
896 fs.writeFileSync(keepPath, Buffer.from("keep"));
897 fs.writeFileSync(removePath, Buffer.from("remove"));
898 const otherThreadPath = path.join(
899 stateDir,
900 "attachments",
901 `${otherThreadAttachmentId}.png`,
902 );
903 fs.writeFileSync(otherThreadPath, Buffer.from("other"));
904 assert.equal(fs.existsSync(keepPath), true);
905 assert.equal(fs.existsSync(removePath), true);
906 assert.equal(fs.existsSync(otherThreadPath), true);
907
908 yield* appendAndProject({
909 type: "thread.reverted",
910 eventId: EventId.makeUnsafe("evt-revert-files-7"),
911 aggregateKind: "thread",
912 aggregateId: threadId,
913 occurredAt: now,
914 commandId: CommandId.makeUnsafe("cmd-revert-files-7"),
915 causationEventId: null,
916 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"),
917 metadata: {},
918 payload: {
919 threadId,
920 turnCount: 1,
921 },
922 });
923
924 assert.equal(fs.existsSync(keepPath), true);
925 assert.equal(fs.existsSync(removePath), false);
926 assert.equal(fs.existsSync(otherThreadPath), true);
927 }).pipe(
928 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
929 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
930 ),
931 ),
932 ),
933 );
704 Effect.gen(function* () {
705 const fileSystem = yield* FileSystem.FileSystem;
706 const path = yield* Path.Path;
707 const projectionPipeline = yield* OrchestrationProjectionPipeline;
708 const eventStore = yield* OrchestrationEventStore;
709 const { attachmentsDir } = yield* ServerConfig;
710 const now = new Date().toISOString();
711 const threadId = ThreadId.makeUnsafe("Thread Revert.Files");
712 const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001";
713 const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002";
714 const otherThreadAttachmentId =
715 "thread-revert-files-extra-00000000-0000-4000-8000-000000000003";
934716
935 it.effect("removes thread attachment directory when thread is deleted", () =>
936 Effect.sync(() =>
937 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-delete-")),
938 ).pipe(
939 Effect.flatMap((stateDir) =>
940 Effect.gen(function* () {
941 const projectionPipeline = yield* OrchestrationProjectionPipeline;
942 const eventStore = yield* OrchestrationEventStore;
943 const now = new Date().toISOString();
944 const threadId = ThreadId.makeUnsafe("Thread Delete.Files");
945 const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001";
946 const otherThreadAttachmentId =
947 "thread-delete-files-extra-00000000-0000-4000-8000-000000000002";
948
949 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
950 eventStore
951 .append(event)
952 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
953
954 yield* appendAndProject({
955 type: "project.created",
956 eventId: EventId.makeUnsafe("evt-delete-files-1"),
957 aggregateKind: "project",
958 aggregateId: ProjectId.makeUnsafe("project-delete-files"),
959 occurredAt: now,
960 commandId: CommandId.makeUnsafe("cmd-delete-files-1"),
961 causationEventId: null,
962 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"),
963 metadata: {},
964 payload: {
965 projectId: ProjectId.makeUnsafe("project-delete-files"),
966 title: "Project Delete Files",
967 workspaceRoot: "/tmp/project-delete-files",
968 defaultModel: null,
969 scripts: [],
970 createdAt: now,
971 updatedAt: now,
972 },
973 });
974
975 yield* appendAndProject({
976 type: "thread.created",
977 eventId: EventId.makeUnsafe("evt-delete-files-2"),
978 aggregateKind: "thread",
979 aggregateId: threadId,
980 occurredAt: now,
981 commandId: CommandId.makeUnsafe("cmd-delete-files-2"),
982 causationEventId: null,
983 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"),
984 metadata: {},
985 payload: {
986 threadId,
987 projectId: ProjectId.makeUnsafe("project-delete-files"),
988 title: "Thread Delete Files",
989 model: "gpt-5-codex",
990 runtimeMode: "full-access",
991 branch: null,
992 worktreePath: null,
993 createdAt: now,
994 updatedAt: now,
995 },
996 });
997
998 yield* appendAndProject({
999 type: "thread.message-sent",
1000 eventId: EventId.makeUnsafe("evt-delete-files-3"),
1001 aggregateKind: "thread",
1002 aggregateId: threadId,
1003 occurredAt: now,
1004 commandId: CommandId.makeUnsafe("cmd-delete-files-3"),
1005 causationEventId: null,
1006 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"),
1007 metadata: {},
1008 payload: {
1009 threadId,
1010 messageId: MessageId.makeUnsafe("message-delete-files"),
1011 role: "user",
1012 text: "Delete",
1013 attachments: [
1014 {
1015 type: "image",
1016 id: attachmentId,
1017 name: "delete.png",
1018 mimeType: "image/png",
1019 sizeBytes: 5,
1020 },
1021 ],
1022 turnId: null,
1023 streaming: false,
1024 createdAt: now,
1025 updatedAt: now,
1026 },
1027 });
1028
1029 const threadAttachmentPath = path.join(stateDir, "attachments", `${attachmentId}.png`);
1030 const otherThreadAttachmentPath = path.join(
1031 stateDir,
1032 "attachments",
1033 `${otherThreadAttachmentId}.png`,
1034 );
1035 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
1036 fs.writeFileSync(threadAttachmentPath, Buffer.from("delete"));
1037 fs.writeFileSync(otherThreadAttachmentPath, Buffer.from("other-thread"));
1038 assert.equal(fs.existsSync(threadAttachmentPath), true);
1039 assert.equal(fs.existsSync(otherThreadAttachmentPath), true);
1040
1041 yield* appendAndProject({
1042 type: "thread.deleted",
1043 eventId: EventId.makeUnsafe("evt-delete-files-4"),
1044 aggregateKind: "thread",
1045 aggregateId: threadId,
1046 occurredAt: now,
1047 commandId: CommandId.makeUnsafe("cmd-delete-files-4"),
1048 causationEventId: null,
1049 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"),
1050 metadata: {},
1051 payload: {
1052 threadId,
1053 deletedAt: now,
717 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
718 eventStore
719 .append(event)
720 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
721
722 yield* appendAndProject({
723 type: "project.created",
724 eventId: EventId.makeUnsafe("evt-revert-files-1"),
725 aggregateKind: "project",
726 aggregateId: ProjectId.makeUnsafe("project-revert-files"),
727 occurredAt: now,
728 commandId: CommandId.makeUnsafe("cmd-revert-files-1"),
729 causationEventId: null,
730 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"),
731 metadata: {},
732 payload: {
733 projectId: ProjectId.makeUnsafe("project-revert-files"),
734 title: "Project Revert Files",
735 workspaceRoot: "/tmp/project-revert-files",
736 defaultModel: null,
737 scripts: [],
738 createdAt: now,
739 updatedAt: now,
740 },
741 });
742
743 yield* appendAndProject({
744 type: "thread.created",
745 eventId: EventId.makeUnsafe("evt-revert-files-2"),
746 aggregateKind: "thread",
747 aggregateId: threadId,
748 occurredAt: now,
749 commandId: CommandId.makeUnsafe("cmd-revert-files-2"),
750 causationEventId: null,
751 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"),
752 metadata: {},
753 payload: {
754 threadId,
755 projectId: ProjectId.makeUnsafe("project-revert-files"),
756 title: "Thread Revert Files",
757 model: "gpt-5-codex",
758 runtimeMode: "full-access",
759 branch: null,
760 worktreePath: null,
761 createdAt: now,
762 updatedAt: now,
763 },
764 });
765
766 yield* appendAndProject({
767 type: "thread.turn-diff-completed",
768 eventId: EventId.makeUnsafe("evt-revert-files-3"),
769 aggregateKind: "thread",
770 aggregateId: threadId,
771 occurredAt: now,
772 commandId: CommandId.makeUnsafe("cmd-revert-files-3"),
773 causationEventId: null,
774 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"),
775 metadata: {},
776 payload: {
777 threadId,
778 turnId: TurnId.makeUnsafe("turn-keep"),
779 checkpointTurnCount: 1,
780 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"),
781 status: "ready",
782 files: [],
783 assistantMessageId: MessageId.makeUnsafe("message-keep"),
784 completedAt: now,
785 },
786 });
787
788 yield* appendAndProject({
789 type: "thread.message-sent",
790 eventId: EventId.makeUnsafe("evt-revert-files-4"),
791 aggregateKind: "thread",
792 aggregateId: threadId,
793 occurredAt: now,
794 commandId: CommandId.makeUnsafe("cmd-revert-files-4"),
795 causationEventId: null,
796 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"),
797 metadata: {},
798 payload: {
799 threadId,
800 messageId: MessageId.makeUnsafe("message-keep"),
801 role: "assistant",
802 text: "Keep",
803 attachments: [
804 {
805 type: "image",
806 id: keepAttachmentId,
807 name: "keep.png",
808 mimeType: "image/png",
809 sizeBytes: 5,
1054810 },
1055 });
1056
1057 assert.equal(fs.existsSync(threadAttachmentPath), false);
1058 assert.equal(fs.existsSync(otherThreadAttachmentPath), true);
1059 }).pipe(
1060 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
1061 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
1062 ),
1063 ),
1064 ),
1065 );
811 ],
812 turnId: TurnId.makeUnsafe("turn-keep"),
813 streaming: false,
814 createdAt: now,
815 updatedAt: now,
816 },
817 });
818
819 yield* appendAndProject({
820 type: "thread.turn-diff-completed",
821 eventId: EventId.makeUnsafe("evt-revert-files-5"),
822 aggregateKind: "thread",
823 aggregateId: threadId,
824 occurredAt: now,
825 commandId: CommandId.makeUnsafe("cmd-revert-files-5"),
826 causationEventId: null,
827 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"),
828 metadata: {},
829 payload: {
830 threadId,
831 turnId: TurnId.makeUnsafe("turn-remove"),
832 checkpointTurnCount: 2,
833 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"),
834 status: "ready",
835 files: [],
836 assistantMessageId: MessageId.makeUnsafe("message-remove"),
837 completedAt: now,
838 },
839 });
1066840
1067 it.effect("ignores unsafe thread ids for attachment cleanup paths", () =>
1068 Effect.sync(() =>
1069 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-")),
1070 ).pipe(
1071 Effect.flatMap((stateDir) =>
1072 Effect.gen(function* () {
1073 const projectionPipeline = yield* OrchestrationProjectionPipeline;
1074 const eventStore = yield* OrchestrationEventStore;
1075 const now = new Date().toISOString();
1076 const attachmentsRootDir = path.join(stateDir, "attachments");
1077 const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt");
1078 const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt");
1079 fs.mkdirSync(attachmentsRootDir, { recursive: true });
1080 fs.writeFileSync(attachmentsSentinelPath, "keep-attachments-root", "utf8");
1081 fs.writeFileSync(stateDirSentinelPath, "keep-state-dir", "utf8");
1082
1083 yield* eventStore.append({
1084 type: "thread.deleted",
1085 eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"),
1086 aggregateKind: "thread",
1087 aggregateId: ThreadId.makeUnsafe(".."),
1088 occurredAt: now,
1089 commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"),
1090 causationEventId: null,
1091 correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"),
1092 metadata: {},
1093 payload: {
1094 threadId: ThreadId.makeUnsafe(".."),
1095 deletedAt: now,
841 yield* appendAndProject({
842 type: "thread.message-sent",
843 eventId: EventId.makeUnsafe("evt-revert-files-6"),
844 aggregateKind: "thread",
845 aggregateId: threadId,
846 occurredAt: now,
847 commandId: CommandId.makeUnsafe("cmd-revert-files-6"),
848 causationEventId: null,
849 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"),
850 metadata: {},
851 payload: {
852 threadId,
853 messageId: MessageId.makeUnsafe("message-remove"),
854 role: "assistant",
855 text: "Remove",
856 attachments: [
857 {
858 type: "image",
859 id: removeAttachmentId,
860 name: "remove.png",
861 mimeType: "image/png",
862 sizeBytes: 5,
1096863 },
1097 });
864 ],
865 turnId: TurnId.makeUnsafe("turn-remove"),
866 streaming: false,
867 createdAt: now,
868 updatedAt: now,
869 },
870 });
1098871
1099 yield* projectionPipeline.bootstrap;
872 const keepPath = path.join(attachmentsDir, `${keepAttachmentId}.png`);
873 const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`);
874 yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });
875 yield* fileSystem.writeFileString(keepPath, "keep");
876 yield* fileSystem.writeFileString(removePath, "remove");
877 const otherThreadPath = path.join(attachmentsDir, `${otherThreadAttachmentId}.png`);
878 yield* fileSystem.writeFileString(otherThreadPath, "other");
879 assert.isTrue(yield* exists(keepPath));
880 assert.isTrue(yield* exists(removePath));
881 assert.isTrue(yield* exists(otherThreadPath));
1100882
1101 assert.equal(fs.existsSync(attachmentsRootDir), true);
1102 assert.equal(fs.existsSync(attachmentsSentinelPath), true);
1103 assert.equal(fs.existsSync(stateDirSentinelPath), true);
1104 }).pipe(
1105 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
1106 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
1107 ),
1108 ),
1109 ),
883 yield* appendAndProject({
884 type: "thread.reverted",
885 eventId: EventId.makeUnsafe("evt-revert-files-7"),
886 aggregateKind: "thread",
887 aggregateId: threadId,
888 occurredAt: now,
889 commandId: CommandId.makeUnsafe("cmd-revert-files-7"),
890 causationEventId: null,
891 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"),
892 metadata: {},
893 payload: {
894 threadId,
895 turnCount: 1,
896 },
897 });
898
899 assert.isTrue(yield* exists(keepPath));
900 assert.isFalse(yield* exists(removePath));
901 assert.isTrue(yield* exists(otherThreadPath));
902 }),
1110903 );
904});
905
906it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-revert-")))(
907 "OrchestrationProjectionPipeline",
908 (it) => {
909 it.effect("removes thread attachment directory when thread is deleted", () =>
910 Effect.gen(function* () {
911 const fileSystem = yield* FileSystem.FileSystem;
912 const path = yield* Path.Path;
913 const projectionPipeline = yield* OrchestrationProjectionPipeline;
914 const eventStore = yield* OrchestrationEventStore;
915 const { attachmentsDir } = yield* ServerConfig;
916 const now = new Date().toISOString();
917 const threadId = ThreadId.makeUnsafe("Thread Delete.Files");
918 const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001";
919 const otherThreadAttachmentId =
920 "thread-delete-files-extra-00000000-0000-4000-8000-000000000002";
921
922 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
923 eventStore
924 .append(event)
925 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
926
927 yield* appendAndProject({
928 type: "project.created",
929 eventId: EventId.makeUnsafe("evt-delete-files-1"),
930 aggregateKind: "project",
931 aggregateId: ProjectId.makeUnsafe("project-delete-files"),
932 occurredAt: now,
933 commandId: CommandId.makeUnsafe("cmd-delete-files-1"),
934 causationEventId: null,
935 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"),
936 metadata: {},
937 payload: {
938 projectId: ProjectId.makeUnsafe("project-delete-files"),
939 title: "Project Delete Files",
940 workspaceRoot: "/tmp/project-delete-files",
941 defaultModel: null,
942 scripts: [],
943 createdAt: now,
944 updatedAt: now,
945 },
946 });
1111947
948 yield* appendAndProject({
949 type: "thread.created",
950 eventId: EventId.makeUnsafe("evt-delete-files-2"),
951 aggregateKind: "thread",
952 aggregateId: threadId,
953 occurredAt: now,
954 commandId: CommandId.makeUnsafe("cmd-delete-files-2"),
955 causationEventId: null,
956 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"),
957 metadata: {},
958 payload: {
959 threadId,
960 projectId: ProjectId.makeUnsafe("project-delete-files"),
961 title: "Thread Delete Files",
962 model: "gpt-5-codex",
963 runtimeMode: "full-access",
964 branch: null,
965 worktreePath: null,
966 createdAt: now,
967 updatedAt: now,
968 },
969 });
970
971 yield* appendAndProject({
972 type: "thread.message-sent",
973 eventId: EventId.makeUnsafe("evt-delete-files-3"),
974 aggregateKind: "thread",
975 aggregateId: threadId,
976 occurredAt: now,
977 commandId: CommandId.makeUnsafe("cmd-delete-files-3"),
978 causationEventId: null,
979 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"),
980 metadata: {},
981 payload: {
982 threadId,
983 messageId: MessageId.makeUnsafe("message-delete-files"),
984 role: "user",
985 text: "Delete",
986 attachments: [
987 {
988 type: "image",
989 id: attachmentId,
990 name: "delete.png",
991 mimeType: "image/png",
992 sizeBytes: 5,
993 },
994 ],
995 turnId: null,
996 streaming: false,
997 createdAt: now,
998 updatedAt: now,
999 },
1000 });
1001
1002 const threadAttachmentPath = path.join(attachmentsDir, `${attachmentId}.png`);
1003 const otherThreadAttachmentPath = path.join(
1004 attachmentsDir,
1005 `${otherThreadAttachmentId}.png`,
1006 );
1007 yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true });
1008 yield* fileSystem.writeFileString(threadAttachmentPath, "delete");
1009 yield* fileSystem.writeFileString(otherThreadAttachmentPath, "other-thread");
1010 assert.isTrue(yield* exists(threadAttachmentPath));
1011 assert.isTrue(yield* exists(otherThreadAttachmentPath));
1012
1013 yield* appendAndProject({
1014 type: "thread.deleted",
1015 eventId: EventId.makeUnsafe("evt-delete-files-4"),
1016 aggregateKind: "thread",
1017 aggregateId: threadId,
1018 occurredAt: now,
1019 commandId: CommandId.makeUnsafe("cmd-delete-files-4"),
1020 causationEventId: null,
1021 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"),
1022 metadata: {},
1023 payload: {
1024 threadId,
1025 deletedAt: now,
1026 },
1027 });
1028
1029 assert.isFalse(yield* exists(threadAttachmentPath));
1030 assert.isTrue(yield* exists(otherThreadAttachmentPath));
1031 }),
1032 );
1033 },
1034);
1035
1036it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-delete-")))(
1037 "OrchestrationProjectionPipeline",
1038 (it) => {
1039 it.effect("ignores unsafe thread ids for attachment cleanup paths", () =>
1040 Effect.gen(function* () {
1041 const fileSystem = yield* FileSystem.FileSystem;
1042 const path = yield* Path.Path;
1043 const projectionPipeline = yield* OrchestrationProjectionPipeline;
1044 const eventStore = yield* OrchestrationEventStore;
1045 const now = new Date().toISOString();
1046 const { attachmentsDir: attachmentsRootDir, stateDir } = yield* ServerConfig;
1047 const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt");
1048 const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt");
1049 yield* fileSystem.makeDirectory(attachmentsRootDir, { recursive: true });
1050 yield* fileSystem.writeFileString(attachmentsSentinelPath, "keep-attachments-root");
1051 yield* fileSystem.writeFileString(stateDirSentinelPath, "keep-state-dir");
1052
1053 yield* eventStore.append({
1054 type: "thread.deleted",
1055 eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"),
1056 aggregateKind: "thread",
1057 aggregateId: ThreadId.makeUnsafe(".."),
1058 occurredAt: now,
1059 commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"),
1060 causationEventId: null,
1061 correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"),
1062 metadata: {},
1063 payload: {
1064 threadId: ThreadId.makeUnsafe(".."),
1065 deletedAt: now,
1066 },
1067 });
1068
1069 yield* projectionPipeline.bootstrap;
1070
1071 assert.isTrue(yield* exists(attachmentsRootDir));
1072 assert.isTrue(yield* exists(attachmentsSentinelPath));
1073 assert.isTrue(yield* exists(stateDirSentinelPath));
1074 }),
1075 );
1076 },
1077);
1078
1079it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
11121080 it.effect("resumes from projector last_applied_sequence without replaying older events", () =>
11131081 Effect.gen(function* () {
11141082 const projectionPipeline = yield* OrchestrationProjectionPipeline;
......@@ -1714,8 +1682,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
17141682
17151683it.effect("restores pending turn-start metadata across projection pipeline restart", () =>
17161684 Effect.gen(function* () {
1717 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-pipeline-restart-"));
1718 const dbPath = path.join(tempDir, "orchestration.sqlite");
1685 const { dbPath } = yield* ServerConfig;
17191686 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
17201687 const firstProjectionLayer = OrchestrationProjectionPipelineLive.pipe(
17211688 Layer.provideMerge(OrchestrationEventStoreLive),
......@@ -1830,11 +1797,14 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
18301797 startedAt: turnStartedAt,
18311798 },
18321799 ]);
1833
1834 fs.rmSync(tempDir, { recursive: true, force: true });
18351800 }).pipe(
18361801 Effect.provide(
1837 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd()), NodeServices.layer),
1802 Layer.provideMerge(
1803 ServerConfig.layerTest(process.cwd(), {
1804 prefix: "t3-projection-pipeline-restart-",
1805 }),
1806 NodeServices.layer,
1807 ),
18381808 ),
18391809 ),
18401810);
......@@ -1845,7 +1815,11 @@ const engineLayer = it.layer(
18451815 Layer.provide(OrchestrationEventStoreLive),
18461816 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
18471817 Layer.provideMerge(SqlitePersistenceMemory),
1848 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
1818 Layer.provideMerge(
1819 ServerConfig.layerTest(process.cwd(), {
1820 prefix: "t3-projection-pipeline-engine-dispatch-",
1821 }),
1822 ),
18491823 Layer.provideMerge(NodeServices.layer),
18501824 ),
18511825);
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+1-1
......@@ -243,7 +243,7 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid
243243 const fileSystem = yield* Effect.service(FileSystem.FileSystem);
244244 const path = yield* Effect.service(Path.Path);
245245
246 const attachmentsRootDir = path.join(serverConfig.stateDir, "attachments");
246 const attachmentsRootDir = serverConfig.attachmentsDir;
247247
248248 yield* Effect.forEach(
249249 sideEffects.deletedThreadIds,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+15-5
......@@ -16,7 +16,7 @@ import {
1616import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
1717import { afterEach, describe, expect, it, vi } from "vitest";
1818
19import { ServerConfig } from "../../config.ts";
19import { deriveServerPaths, ServerConfig } from "../../config.ts";
2020import { TextGenerationError } from "../../git/Errors.ts";
2121import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
2222import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
......@@ -41,6 +41,9 @@ const asApprovalRequestId = (value: string): ApprovalRequestId =>
4141const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
4242const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
4343
44const deriveServerPathsSync = (baseDir: string, devUrl: URL | undefined) =>
45 Effect.runSync(deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer)));
46
4447async function waitFor(
4548 predicate: () => boolean | Promise<boolean>,
4649 timeoutMs = 2000,
......@@ -67,6 +70,7 @@ describe("ProviderCommandReactor", () => {
6770 > | null = null;
6871 let scope: Scope.Closeable | null = null;
6972 const createdStateDirs = new Set<string>();
73 const createdBaseDirs = new Set<string>();
7074
7175 afterEach(async () => {
7276 if (scope) {
......@@ -81,16 +85,22 @@ describe("ProviderCommandReactor", () => {
8185 fs.rmSync(stateDir, { recursive: true, force: true });
8286 }
8387 createdStateDirs.clear();
88 for (const baseDir of createdBaseDirs) {
89 fs.rmSync(baseDir, { recursive: true, force: true });
90 }
91 createdBaseDirs.clear();
8492 });
8593
8694 async function createHarness(input?: {
87 readonly stateDir?: string;
95 readonly baseDir?: string;
8896 readonly threadModel?: string;
8997 }) {
9098 const now = new Date().toISOString();
91 const stateDir = input?.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));
92 const threadModel = input?.threadModel ?? "gpt-5-codex";
99 const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));
100 createdBaseDirs.add(baseDir);
101 const { stateDir } = deriveServerPathsSync(baseDir, undefined);
93102 createdStateDirs.add(stateDir);
103 const threadModel = input?.threadModel ?? "gpt-5-codex";
94104 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
95105 let nextSessionIndex = 1;
96106 const runtimeSessions: Array<ProviderSession> = [];
......@@ -214,7 +224,7 @@ describe("ProviderCommandReactor", () => {
214224 Layer.provideMerge(
215225 Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape),
216226 ),
217 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)),
227 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
218228 Layer.provideMerge(NodeServices.layer),
219229 );
220230 const runtime = ManagedRuntime.make(layer);
apps/server/src/os-jank.ts+2-2
......@@ -27,10 +27,10 @@ export const expandHomePath = Effect.fn(function* (input: string) {
2727 return input;
2828});
2929
30export const resolveStateDir = Effect.fn(function* (raw: string | undefined) {
30export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) {
3131 const { join, resolve } = yield* Path.Path;
3232 if (!raw || raw.trim().length === 0) {
33 return join(OS.homedir(), ".t3", "userdata");
33 return join(OS.homedir(), ".t3");
3434 }
3535 return resolve(yield* expandHomePath(raw.trim()));
3636});
apps/server/src/persistence/Layers/Sqlite.ts+3-6
......@@ -49,9 +49,6 @@ export const SqlitePersistenceMemory = Layer.provideMerge(
4949 makeRuntimeSqliteLayer({ filename: ":memory:" }),
5050);
5151
52export const layerConfig = Effect.gen(function* () {
53 const { stateDir } = yield* ServerConfig;
54 const { join } = yield* Path.Path;
55 const dbPath = join(stateDir, "state.sqlite");
56 return makeSqlitePersistenceLive(dbPath);
57}).pipe(Layer.unwrap);
52export const layerConfig = Layer.unwrap(
53 Effect.map(Effect.service(ServerConfig), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)),
54);
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+7-6
......@@ -133,7 +133,7 @@ function makeHarness(config?: {
133133 readonly nativeEventLogPath?: string;
134134 readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"];
135135 readonly cwd?: string;
136 readonly stateDir?: string;
136 readonly baseDir?: string;
137137}) {
138138 const query = new FakeClaudeQuery();
139139 let createInput:
......@@ -165,7 +165,7 @@ function makeHarness(config?: {
165165 Layer.provideMerge(
166166 ServerConfig.layerTest(
167167 config?.cwd ?? "/tmp/claude-adapter-test",
168 config?.stateDir ?? "/tmp",
168 config?.baseDir ?? "/tmp",
169169 ),
170170 ),
171171 Layer.provideMerge(NodeServices.layer),
......@@ -515,15 +515,15 @@ describe("ClaudeAdapterLive", () => {
515515 });
516516
517517 it.effect("embeds image attachments in Claude user messages", () => {
518 const stateDir = mkdtempSync(path.join(os.tmpdir(), "claude-attachments-"));
518 const baseDir = mkdtempSync(path.join(os.tmpdir(), "claude-attachments-"));
519519 const harness = makeHarness({
520520 cwd: "/tmp/project-claude-attachments",
521 stateDir,
521 baseDir,
522522 });
523523 return Effect.gen(function* () {
524524 yield* Effect.addFinalizer(() =>
525525 Effect.sync(() =>
526 rmSync(stateDir, {
526 rmSync(baseDir, {
527527 recursive: true,
528528 force: true,
529529 }),
......@@ -531,6 +531,7 @@ describe("ClaudeAdapterLive", () => {
531531 );
532532
533533 const adapter = yield* ClaudeAdapter;
534 const { attachmentsDir } = yield* ServerConfig;
534535
535536 const attachment = {
536537 type: "image" as const,
......@@ -539,7 +540,7 @@ describe("ClaudeAdapterLive", () => {
539540 mimeType: "image/png",
540541 sizeBytes: 4,
541542 };
542 const attachmentPath = path.join(stateDir, "attachments", attachmentRelativePath(attachment));
543 const attachmentPath = path.join(attachmentsDir, attachmentRelativePath(attachment));
543544 mkdirSync(path.dirname(attachmentPath), { recursive: true });
544545 writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4]));
545546
apps/server/src/provider/Layers/ClaudeAdapter.ts+3-3
......@@ -474,7 +474,7 @@ function buildUserMessageEffect(
474474 input: ProviderSendTurnInput,
475475 dependencies: {
476476 readonly fileSystem: FileSystem.FileSystem;
477 readonly stateDir: string;
477 readonly attachmentsDir: string;
478478 },
479479): Effect.Effect<SDKUserMessage, ProviderAdapterRequestError> {
480480 return Effect.gen(function* () {
......@@ -499,7 +499,7 @@ function buildUserMessageEffect(
499499 }
500500
501501 const attachmentPath = resolveAttachmentPath({
502 stateDir: dependencies.stateDir,
502 attachmentsDir: dependencies.attachmentsDir,
503503 attachment,
504504 });
505505 if (!attachmentPath) {
......@@ -2765,7 +2765,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
27652765
27662766 const message = yield* buildUserMessageEffect(input, {
27672767 fileSystem,
2768 stateDir: serverConfig.stateDir,
2768 attachmentsDir: serverConfig.attachmentsDir,
27692769 });
27702770
27712771 yield* Queue.offer(context.promptQueue, {
apps/server/src/provider/Layers/CodexAdapter.ts+1-1
......@@ -1336,7 +1336,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
13361336 (attachment) =>
13371337 Effect.gen(function* () {
13381338 const attachmentPath = resolveAttachmentPath({
1339 stateDir: serverConfig.stateDir,
1339 attachmentsDir: serverConfig.attachmentsDir,
13401340 attachment,
13411341 });
13421342 if (!attachmentPath) {
apps/server/src/serverLayers.ts+1-5
......@@ -1,5 +1,3 @@
1import path from "node:path";
2
31import * as NodeServices from "@effect/platform-node/NodeServices";
42import { Effect, FileSystem, Layer } from "effect";
53import * as SqlClient from "effect/unstable/sql/SqlClient";
......@@ -44,9 +42,7 @@ export function makeServerProviderLayer(): Layer.Layer<
4442 SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService
4543> {
4644 return Effect.gen(function* () {
47 const { stateDir } = yield* ServerConfig;
48 const providerLogsDir = path.join(stateDir, "logs", "provider");
49 const providerEventLogPath = path.join(providerLogsDir, "events.log");
45 const { providerEventLogPath } = yield* ServerConfig;
5046 const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
5147 stream: "native",
5248 });
apps/server/src/serverLogger.ts+3-7
......@@ -1,5 +1,4 @@
11import fs from "node:fs";
2import path from "node:path";
32
43import { Effect, Logger } from "effect";
54import * as Layer from "effect/Layer";
......@@ -7,16 +6,13 @@ import * as Layer from "effect/Layer";
76import { ServerConfig } from "./config";
87
98export const ServerLoggerLive = Effect.gen(function* () {
10 const config = yield* ServerConfig;
11
12 const logDir = path.join(config.stateDir, "logs");
13 const logPath = path.join(logDir, "server.log");
9 const { logsDir, serverLogPath } = yield* ServerConfig;
1410
1511 yield* Effect.sync(() => {
16 fs.mkdirSync(logDir, { recursive: true });
12 fs.mkdirSync(logsDir, { recursive: true });
1713 });
1814
19 const fileLogger = Logger.formatSimple.pipe(Logger.toFile(logPath));
15 const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath));
2016
2117 return Logger.layer([Logger.defaultLogger, fileLogger], {
2218 mergeWithExisting: false,
apps/server/src/telemetry/Identify.ts+1-3
......@@ -56,10 +56,8 @@ const getClaudeUserId = Effect.gen(function* () {
5656
5757const upsertAnonymousId = Effect.gen(function* () {
5858 const fileSystem = yield* FileSystem.FileSystem;
59 const path = yield* Path.Path;
60 const serverConfig = yield* ServerConfig;
59 const { anonymousIdPath } = yield* ServerConfig;
6160
62 const anonymousIdPath = path.join(serverConfig.stateDir, "anonymous-id");
6361 const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe(
6462 Effect.catch(() =>
6563 Effect.gen(function* () {
apps/server/src/telemetry/Layers/AnalyticsService.test.ts+4-7
......@@ -1,7 +1,7 @@
11import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import { assert, it } from "@effect/vitest";
4import { ConfigProvider, Effect, FileSystem, Layer } from "effect";
4import { ConfigProvider, Effect, Layer } from "effect";
55import * as HttpServer from "effect/unstable/http/HttpServer";
66import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
77import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
......@@ -37,13 +37,10 @@ interface RecordedBatchBody {
3737it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
3838 it.effect("flush drains all buffered events across multiple batches", () =>
3939 Effect.gen(function* () {
40 const fileSystem = yield* FileSystem.FileSystem;
41 const stateDir = yield* fileSystem.makeTempDirectoryScoped({
42 prefix: "t3-telemetry-flush-",
43 });
44
4540 const capturedRequests: Array<RecordedBatchRequest> = [];
46 const serverConfigLayer = ServerConfig.layerTest(process.cwd(), stateDir);
41 const serverConfigLayer = ServerConfig.layerTest(process.cwd(), {
42 prefix: "t3-telemetry-base-",
43 });
4744
4845 const telemetryLayer = AnalyticsServiceLayerLive.pipe(Layer.provideMerge(serverConfigLayer));
4946 const configLayer = ConfigProvider.layer(
apps/server/src/terminal/Layers/Manager.ts+3-5
......@@ -13,7 +13,7 @@ import {
1313 type TerminalEvent,
1414 type TerminalSessionSnapshot,
1515} from "@t3tools/contracts";
16import { Effect, Encoding, Layer, Path, Schema } from "effect";
16import { Effect, Encoding, Layer, Schema } from "effect";
1717
1818import { createLogger } from "../../logger";
1919import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY";
......@@ -1172,13 +1172,11 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
11721172export const TerminalManagerLive = Layer.effect(
11731173 TerminalManager,
11741174 Effect.gen(function* () {
1175 const { stateDir } = yield* ServerConfig;
1176 const { join } = yield* Path.Path;
1177 const logsDir = join(stateDir, "logs", "terminals");
1175 const { terminalLogsDir } = yield* ServerConfig;
11781176
11791177 const ptyAdapter = yield* PtyAdapter;
11801178 const runtime = yield* Effect.acquireRelease(
1181 Effect.sync(() => new TerminalManagerRuntime({ logsDir, ptyAdapter })),
1179 Effect.sync(() => new TerminalManagerRuntime({ logsDir: terminalLogsDir, ptyAdapter })),
11821180 (r) => Effect.sync(() => r.dispose()),
11831181 );
11841182
apps/server/src/wsServer.test.ts+62-42
......@@ -8,7 +8,7 @@ import { Effect, Exit, Layer, PlatformError, PubSub, Scope, Stream } from "effec
88import { describe, expect, it, afterEach, vi } from "vitest";
99import { createServer } from "./wsServer";
1010import WebSocket from "ws";
11import { ServerConfig, type ServerConfigShape } from "./config";
11import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config";
1212import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
1313
1414import {
......@@ -451,6 +451,16 @@ function expectAvailableEditors(value: unknown): void {
451451 }
452452}
453453
454function ensureParentDir(filePath: string): void {
455 fs.mkdirSync(path.dirname(filePath), { recursive: true });
456}
457
458function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) {
459 return Effect.runSync(
460 deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer)),
461 );
462}
463
454464describe("WebSocket Server", () => {
455465 let server: Http.Server | null = null;
456466 let serverScope: Scope.Closeable | null = null;
......@@ -474,7 +484,7 @@ describe("WebSocket Server", () => {
474484 logWebSocketEvents?: boolean;
475485 devUrl?: string;
476486 authToken?: string;
477 stateDir?: string;
487 baseDir?: string;
478488 staticDir?: string;
479489 providerLayer?: Layer.Layer<ProviderService, never>;
480490 providerHealth?: ProviderHealthShape;
......@@ -488,7 +498,9 @@ describe("WebSocket Server", () => {
488498 throw new Error("Test server is already running");
489499 }
490500
491 const stateDir = options.stateDir ?? makeTempDir("t3code-ws-state-");
501 const baseDir = options.baseDir ?? makeTempDir("t3code-ws-base-");
502 const devUrl = options.devUrl ? new URL(options.devUrl) : undefined;
503 const derivedPaths = deriveServerPathsSync(baseDir, devUrl);
492504 const scope = await Effect.runPromise(Scope.make("sequential"));
493505 const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;
494506 const providerLayer = options.providerLayer ?? makeServerProviderLayer();
......@@ -502,10 +514,10 @@ describe("WebSocket Server", () => {
502514 port: 0,
503515 host: undefined,
504516 cwd: options.cwd ?? "/test/project",
505 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
506 stateDir,
517 baseDir,
518 ...derivedPaths,
507519 staticDir: options.staticDir,
508 devUrl: options.devUrl ? new URL(options.devUrl) : undefined,
520 devUrl,
509521 noBrowser: true,
510522 authToken: options.authToken,
511523 autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false,
......@@ -590,12 +602,13 @@ describe("WebSocket Server", () => {
590602 });
591603
592604 it("serves persisted attachments from stateDir", async () => {
593 const stateDir = makeTempDir("t3code-state-attachments-");
594 const attachmentPath = path.join(stateDir, "attachments", "thread-a", "message-a", "0.png");
605 const baseDir = makeTempDir("t3code-state-attachments-");
606 const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined);
607 const attachmentPath = path.join(attachmentsDir, "thread-a", "message-a", "0.png");
595608 fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
596609 fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment"));
597610
598 server = await createTestServer({ cwd: "/test/project", stateDir });
611 server = await createTestServer({ cwd: "/test/project", baseDir });
599612 const addr = server.address();
600613 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
601614 expect(port).toBeGreaterThan(0);
......@@ -608,10 +621,10 @@ describe("WebSocket Server", () => {
608621 });
609622
610623 it("serves persisted attachments for URL-encoded paths", async () => {
611 const stateDir = makeTempDir("t3code-state-attachments-encoded-");
624 const baseDir = makeTempDir("t3code-state-attachments-encoded-");
625 const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined);
612626 const attachmentPath = path.join(
613 stateDir,
614 "attachments",
627 attachmentsDir,
615628 "thread%20folder",
616629 "message%20folder",
617630 "file%20name.png",
......@@ -619,7 +632,7 @@ describe("WebSocket Server", () => {
619632 fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
620633 fs.writeFileSync(attachmentPath, Buffer.from("hello-encoded-attachment"));
621634
622 server = await createTestServer({ cwd: "/test/project", stateDir });
635 server = await createTestServer({ cwd: "/test/project", baseDir });
623636 const addr = server.address();
624637 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
625638 expect(port).toBeGreaterThan(0);
......@@ -634,11 +647,11 @@ describe("WebSocket Server", () => {
634647 });
635648
636649 it("serves static index for root path", async () => {
637 const stateDir = makeTempDir("t3code-state-static-root-");
650 const baseDir = makeTempDir("t3code-state-static-root-");
638651 const staticDir = makeTempDir("t3code-static-root-");
639652 fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>static-root</h1>", "utf8");
640653
641 server = await createTestServer({ cwd: "/test/project", stateDir, staticDir });
654 server = await createTestServer({ cwd: "/test/project", baseDir, staticDir });
642655 const addr = server.address();
643656 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
644657 expect(port).toBeGreaterThan(0);
......@@ -649,11 +662,11 @@ describe("WebSocket Server", () => {
649662 });
650663
651664 it("rejects static path traversal attempts", async () => {
652 const stateDir = makeTempDir("t3code-state-static-traversal-");
665 const baseDir = makeTempDir("t3code-state-static-traversal-");
653666 const staticDir = makeTempDir("t3code-static-traversal-");
654667 fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>safe</h1>", "utf8");
655668
656 server = await createTestServer({ cwd: "/test/project", stateDir, staticDir });
669 server = await createTestServer({ cwd: "/test/project", baseDir, staticDir });
657670 const addr = server.address();
658671 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
659672 expect(port).toBeGreaterThan(0);
......@@ -731,15 +744,16 @@ describe("WebSocket Server", () => {
731744 });
732745
733746 it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => {
734 const stateDir = makeTempDir("t3code-state-bootstrap-existing-");
735 const persistenceLayer = makeSqlitePersistenceLive(path.join(stateDir, "state.sqlite")).pipe(
747 const baseDir = makeTempDir("t3code-state-bootstrap-existing-");
748 const { dbPath } = deriveServerPathsSync(baseDir, undefined);
749 const persistenceLayer = makeSqlitePersistenceLive(dbPath).pipe(
736750 Layer.provide(NodeServices.layer),
737751 );
738752 const cwd = "/test/bootstrap-existing";
739753
740754 server = await createTestServer({
741755 cwd,
742 stateDir,
756 baseDir,
743757 persistenceLayer,
744758 autoBootstrapProjectFromCwd: true,
745759 });
......@@ -762,7 +776,7 @@ describe("WebSocket Server", () => {
762776
763777 server = await createTestServer({
764778 cwd,
765 stateDir,
779 baseDir,
766780 persistenceLayer,
767781 autoBootstrapProjectFromCwd: true,
768782 });
......@@ -811,11 +825,12 @@ describe("WebSocket Server", () => {
811825 });
812826
813827 it("responds to server.getConfig", async () => {
814 const stateDir = makeTempDir("t3code-state-get-config-");
815 const keybindingsPath = path.join(stateDir, "keybindings.json");
828 const baseDir = makeTempDir("t3code-state-get-config-");
829 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
830 ensureParentDir(keybindingsPath);
816831 fs.writeFileSync(keybindingsPath, "[]", "utf8");
817832
818 server = await createTestServer({ cwd: "/my/workspace", stateDir });
833 server = await createTestServer({ cwd: "/my/workspace", baseDir });
819834 const addr = server.address();
820835 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
821836
......@@ -836,11 +851,11 @@ describe("WebSocket Server", () => {
836851 });
837852
838853 it("bootstraps default keybindings file when missing", async () => {
839 const stateDir = makeTempDir("t3code-state-bootstrap-keybindings-");
840 const keybindingsPath = path.join(stateDir, "keybindings.json");
854 const baseDir = makeTempDir("t3code-state-bootstrap-keybindings-");
855 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
841856 expect(fs.existsSync(keybindingsPath)).toBe(false);
842857
843 server = await createTestServer({ cwd: "/my/workspace", stateDir });
858 server = await createTestServer({ cwd: "/my/workspace", baseDir });
844859 const addr = server.address();
845860 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
846861
......@@ -866,11 +881,12 @@ describe("WebSocket Server", () => {
866881 });
867882
868883 it("falls back to defaults and reports malformed keybindings config issues", async () => {
869 const stateDir = makeTempDir("t3code-state-malformed-keybindings-");
870 const keybindingsPath = path.join(stateDir, "keybindings.json");
884 const baseDir = makeTempDir("t3code-state-malformed-keybindings-");
885 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
886 ensureParentDir(keybindingsPath);
871887 fs.writeFileSync(keybindingsPath, "{ not-json", "utf8");
872888
873 server = await createTestServer({ cwd: "/my/workspace", stateDir });
889 server = await createTestServer({ cwd: "/my/workspace", baseDir });
874890 const addr = server.address();
875891 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
876892
......@@ -897,8 +913,9 @@ describe("WebSocket Server", () => {
897913 });
898914
899915 it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => {
900 const stateDir = makeTempDir("t3code-state-partial-invalid-keybindings-");
901 const keybindingsPath = path.join(stateDir, "keybindings.json");
916 const baseDir = makeTempDir("t3code-state-partial-invalid-keybindings-");
917 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
918 ensureParentDir(keybindingsPath);
902919 fs.writeFileSync(
903920 keybindingsPath,
904921 JSON.stringify([
......@@ -909,7 +926,7 @@ describe("WebSocket Server", () => {
909926 "utf8",
910927 );
911928
912 server = await createTestServer({ cwd: "/my/workspace", stateDir });
929 server = await createTestServer({ cwd: "/my/workspace", baseDir });
913930 const addr = server.address();
914931 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
915932
......@@ -948,11 +965,12 @@ describe("WebSocket Server", () => {
948965 });
949966
950967 it("pushes server.configUpdated issues when keybindings file changes", async () => {
951 const stateDir = makeTempDir("t3code-state-keybindings-watch-");
952 const keybindingsPath = path.join(stateDir, "keybindings.json");
968 const baseDir = makeTempDir("t3code-state-keybindings-watch-");
969 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
970 ensureParentDir(keybindingsPath);
953971 fs.writeFileSync(keybindingsPath, "[]", "utf8");
954972
955 server = await createTestServer({ cwd: "/my/workspace", stateDir });
973 server = await createTestServer({ cwd: "/my/workspace", baseDir });
956974 const addr = server.address();
957975 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
958976
......@@ -1008,8 +1026,9 @@ describe("WebSocket Server", () => {
10081026 });
10091027
10101028 it("reads keybindings from the configured state directory", async () => {
1011 const stateDir = makeTempDir("t3code-state-keybindings-");
1012 const keybindingsPath = path.join(stateDir, "keybindings.json");
1029 const baseDir = makeTempDir("t3code-state-keybindings-");
1030 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
1031 ensureParentDir(keybindingsPath);
10131032 fs.writeFileSync(
10141033 keybindingsPath,
10151034 JSON.stringify([
......@@ -1019,7 +1038,7 @@ describe("WebSocket Server", () => {
10191038 ]),
10201039 "utf8",
10211040 );
1022 server = await createTestServer({ cwd: "/my/workspace", stateDir });
1041 server = await createTestServer({ cwd: "/my/workspace", baseDir });
10231042 const addr = server.address();
10241043 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
10251044
......@@ -1043,15 +1062,16 @@ describe("WebSocket Server", () => {
10431062 });
10441063
10451064 it("upserts keybinding rules and updates cached server config", async () => {
1046 const stateDir = makeTempDir("t3code-state-upsert-keybinding-");
1047 const keybindingsPath = path.join(stateDir, "keybindings.json");
1065 const baseDir = makeTempDir("t3code-state-upsert-keybinding-");
1066 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
1067 ensureParentDir(keybindingsPath);
10481068 fs.writeFileSync(
10491069 keybindingsPath,
10501070 JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]),
10511071 "utf8",
10521072 );
10531073
1054 server = await createTestServer({ cwd: "/my/workspace", stateDir });
1074 server = await createTestServer({ cwd: "/my/workspace", baseDir });
10551075 const addr = server.address();
10561076 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
10571077
apps/server/src/wsServer.ts+3-3
......@@ -370,7 +370,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
370370 };
371371
372372 const attachmentPath = resolveAttachmentPath({
373 stateDir: serverConfig.stateDir,
373 attachmentsDir: serverConfig.attachmentsDir,
374374 attachment: persistedAttachment,
375375 });
376376 if (!attachmentPath) {
......@@ -440,11 +440,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
440440 !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes(".");
441441 const filePath = isIdLookup
442442 ? resolveAttachmentPathById({
443 stateDir: serverConfig.stateDir,
443 attachmentsDir: serverConfig.attachmentsDir,
444444 attachmentId: normalizedRelativePath,
445445 })
446446 : resolveAttachmentRelativePath({
447 stateDir: serverConfig.stateDir,
447 attachmentsDir: serverConfig.attachmentsDir,
448448 relativePath: normalizedRelativePath,
449449 });
450450 if (!filePath) {
scripts/dev-runner.test.ts+42-24
......@@ -1,10 +1,10 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import { homedir } from "node:os";
23import { resolve } from "node:path";
34import { assert, describe, it } from "@effect/vitest";
45import { Effect } from "effect";
56
67import {
7 DEFAULT_DEV_STATE_DIR,
88 createDevRunnerEnv,
99 findFirstAvailableOffset,
1010 resolveModePortOffsets,
......@@ -46,27 +46,24 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
4646 });
4747
4848 describe("createDevRunnerEnv", () => {
49 it.effect("defaults state dir to ~/.t3/dev when not provided", () =>
49 it.effect("defaults T3CODE_HOME to ~/.t3 when not provided", () =>
5050 Effect.gen(function* () {
51 const [env, defaultStateDir] = yield* Effect.all([
52 createDevRunnerEnv({
53 mode: "dev",
54 baseEnv: {},
55 serverOffset: 0,
56 webOffset: 0,
57 stateDir: undefined,
58 authToken: undefined,
59 noBrowser: undefined,
60 autoBootstrapProjectFromCwd: undefined,
61 logWebSocketEvents: undefined,
62 host: undefined,
63 port: undefined,
64 devUrl: undefined,
65 }),
66 DEFAULT_DEV_STATE_DIR,
67 ]);
51 const env = yield* createDevRunnerEnv({
52 mode: "dev",
53 baseEnv: {},
54 serverOffset: 0,
55 webOffset: 0,
56 t3Home: undefined,
57 authToken: undefined,
58 noBrowser: undefined,
59 autoBootstrapProjectFromCwd: undefined,
60 logWebSocketEvents: undefined,
61 host: undefined,
62 port: undefined,
63 devUrl: undefined,
64 });
6865
69 assert.equal(env.T3CODE_STATE_DIR, defaultStateDir);
66 assert.equal(env.T3CODE_HOME, resolve(homedir(), ".t3"));
7067 }),
7168 );
7269
......@@ -77,7 +74,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
7774 baseEnv: {},
7875 serverOffset: 0,
7976 webOffset: 0,
80 stateDir: "/tmp/override-state",
77 t3Home: "/tmp/custom-t3",
8178 authToken: "secret",
8279 noBrowser: true,
8380 autoBootstrapProjectFromCwd: false,
......@@ -87,7 +84,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
8784 devUrl: new URL("http://localhost:7331"),
8885 });
8986
90 assert.equal(env.T3CODE_STATE_DIR, resolve("/tmp/override-state"));
87 assert.equal(env.T3CODE_HOME, resolve("/tmp/custom-t3"));
9188 assert.equal(env.T3CODE_PORT, "4222");
9289 assert.equal(env.VITE_WS_URL, "ws://localhost:4222");
9390 assert.equal(env.T3CODE_NO_BROWSER, "1");
......@@ -107,7 +104,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
107104 },
108105 serverOffset: 0,
109106 webOffset: 0,
110 stateDir: undefined,
107 t3Home: undefined,
111108 authToken: undefined,
112109 noBrowser: undefined,
113110 autoBootstrapProjectFromCwd: undefined,
......@@ -129,7 +126,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
129126 baseEnv: {},
130127 serverOffset: 0,
131128 webOffset: 0,
132 stateDir: undefined,
129 t3Home: undefined,
133130 authToken: undefined,
134131 noBrowser: undefined,
135132 autoBootstrapProjectFromCwd: undefined,
......@@ -142,6 +139,27 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
142139 assert.equal(env.T3CODE_LOG_WS_EVENTS, "0");
143140 }),
144141 );
142
143 it.effect("uses custom t3Home when provided", () =>
144 Effect.gen(function* () {
145 const env = yield* createDevRunnerEnv({
146 mode: "dev",
147 baseEnv: {},
148 serverOffset: 0,
149 webOffset: 0,
150 t3Home: "/tmp/my-t3",
151 authToken: undefined,
152 noBrowser: undefined,
153 autoBootstrapProjectFromCwd: undefined,
154 logWebSocketEvents: undefined,
155 host: undefined,
156 port: undefined,
157 devUrl: undefined,
158 });
159
160 assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3"));
161 }),
162 );
145163 });
146164
147165 describe("findFirstAvailableOffset", () => {
scripts/dev-runner.ts+15-16
......@@ -14,8 +14,8 @@ const BASE_WEB_PORT = 5733;
1414const MAX_HASH_OFFSET = 3000;
1515const MAX_PORT = 65535;
1616
17export const DEFAULT_DEV_STATE_DIR = Effect.map(Effect.service(Path.Path), (path) =>
18 path.join(homedir(), ".t3", "dev"),
17export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) =>
18 path.join(homedir(), ".t3"),
1919);
2020
2121const MODE_ARGS = {
......@@ -101,17 +101,16 @@ export function resolveOffset(config: {
101101 return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` };
102102}
103103
104function resolveStateDir(stateDir: string | undefined): Effect.Effect<string, never, Path.Path> {
104function resolveBaseDir(baseDir: string | undefined): Effect.Effect<string, never, Path.Path> {
105105 return Effect.gen(function* () {
106106 const path = yield* Path.Path;
107 const configured = stateDir?.trim();
107 const configured = baseDir?.trim();
108108
109109 if (configured) {
110 // Resolve relative paths against cwd (monorepo root) before turbo changes directories.
111110 return path.resolve(configured);
112111 }
113112
114 return yield* DEFAULT_DEV_STATE_DIR;
113 return yield* DEFAULT_T3_HOME;
115114 });
116115}
117116
......@@ -120,7 +119,7 @@ interface CreateDevRunnerEnvInput {
120119 readonly baseEnv: NodeJS.ProcessEnv;
121120 readonly serverOffset: number;
122121 readonly webOffset: number;
123 readonly stateDir: string | undefined;
122 readonly t3Home: string | undefined;
124123 readonly authToken: string | undefined;
125124 readonly noBrowser: boolean | undefined;
126125 readonly autoBootstrapProjectFromCwd: boolean | undefined;
......@@ -135,7 +134,7 @@ export function createDevRunnerEnv({
135134 baseEnv,
136135 serverOffset,
137136 webOffset,
138 stateDir,
137 t3Home,
139138 authToken,
140139 noBrowser,
141140 autoBootstrapProjectFromCwd,
......@@ -147,7 +146,7 @@ export function createDevRunnerEnv({
147146 return Effect.gen(function* () {
148147 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;
149148 const webPort = BASE_WEB_PORT + webOffset;
150 const resolvedStateDir = yield* resolveStateDir(stateDir);
149 const resolvedBaseDir = yield* resolveBaseDir(t3Home);
151150
152151 const output: NodeJS.ProcessEnv = {
153152 ...baseEnv,
......@@ -156,7 +155,7 @@ export function createDevRunnerEnv({
156155 ELECTRON_RENDERER_PORT: String(webPort),
157156 VITE_WS_URL: `ws://localhost:${serverPort}`,
158157 VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`,
159 T3CODE_STATE_DIR: resolvedStateDir,
158 T3CODE_HOME: resolvedBaseDir,
160159 };
161160
162161 if (host !== undefined) {
......@@ -335,7 +334,7 @@ export function resolveModePortOffsets<R = NetService>({
335334
336335interface DevRunnerCliInput {
337336 readonly mode: DevMode;
338 readonly stateDir: string | undefined;
337 readonly t3Home: string | undefined;
339338 readonly authToken: string | undefined;
340339 readonly noBrowser: boolean | undefined;
341340 readonly autoBootstrapProjectFromCwd: boolean | undefined;
......@@ -415,7 +414,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
415414 baseEnv: process.env,
416415 serverOffset,
417416 webOffset,
418 stateDir: input.stateDir,
417 t3Home: input.t3Home,
419418 authToken: input.authToken,
420419 noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser),
421420 autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride(
......@@ -437,7 +436,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
437436 : "";
438437
439438 yield* Effect.logInfo(
440 `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} stateDir=${String(env.T3CODE_STATE_DIR)}`,
439 `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${String(env.T3CODE_HOME)}`,
441440 );
442441
443442 if (input.dryRun) {
......@@ -485,9 +484,9 @@ const devRunnerCli = Command.make("dev-runner", {
485484 mode: Argument.choice("mode", DEV_RUNNER_MODES).pipe(
486485 Argument.withDescription("Development mode to run."),
487486 ),
488 stateDir: Flag.string("state-dir").pipe(
489 Flag.withDescription("State directory path (forwards to T3CODE_STATE_DIR)."),
490 Flag.withFallbackConfig(optionalStringConfig("T3CODE_STATE_DIR")),
487 t3Home: Flag.string("home-dir").pipe(
488 Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."),
489 Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")),
491490 ),
492491 authToken: Flag.string("auth-token").pipe(
493492 Flag.withDescription("Auth token (forwards to T3CODE_AUTH_TOKEN)."),
turbo.json+1-1
......@@ -9,7 +9,7 @@
99 "T3CODE_MODE",
1010 "T3CODE_PORT",
1111 "T3CODE_NO_BROWSER",
12 "T3CODE_STATE_DIR",
12 "T3CODE_HOME",
1313 "T3CODE_AUTH_TOKEN",
1414 "T3CODE_DESKTOP_WS_URL"
1515 ],