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 @@...@@ -5,7 +5,7 @@
5- `bun run dev:web` — Starts just the Vite dev server for the web app.5- `bun run dev:web` — Starts just the Vite dev server for the web app.
6- Dev commands default `T3CODE_STATE_DIR` to `~/.t3/dev` to keep dev state isolated from desktop/prod state.6- Dev commands default `T3CODE_STATE_DIR` to `~/.t3/dev` to keep dev state isolated from desktop/prod state.
7- Override server CLI-equivalent flags from root dev commands with `--`, for example:7- 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`
9- `bun run start` — Runs the production server (serves built web app as static files).9- `bun run start` — Runs the production server (serves built web app as static files).
10- `bun run build` — Builds contracts, web app, and server through Turbo.10- `bun run build` — Builds contracts, web app, and server through Turbo.
11- `bun run typecheck` — Strict TypeScript checks for all packages.11- `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...@@ -11,7 +11,7 @@ The T3 Code CLI accepts the following configuration options, available either as
11| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |11| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |
12| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |12| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |
13| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |13| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |
14| `--state-dir <path>` | `T3CODE_STATE_DIR` | State directory. |14| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |
15| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |15| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |
16| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |16| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |
17| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. |17| `--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";...@@ -56,8 +56,8 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state";
56const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";56const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
57const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";57const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
58const UPDATE_INSTALL_CHANNEL = "desktop:update-install";58const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
59const STATE_DIR =59const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
60 process.env.T3CODE_STATE_DIR?.trim() || Path.join(OS.homedir(), ".t3", "userdata");60const STATE_DIR = Path.join(BASE_DIR, "userdata");
61const DESKTOP_SCHEME = "t3";61const DESKTOP_SCHEME = "t3";
62const ROOT_DIR = Path.resolve(__dirname, "../../..");62const ROOT_DIR = Path.resolve(__dirname, "../../..");
63const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);63const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
...@@ -924,7 +924,7 @@ function backendEnv(): NodeJS.ProcessEnv {...@@ -924,7 +924,7 @@ function backendEnv(): NodeJS.ProcessEnv {
924 T3CODE_MODE: "desktop",924 T3CODE_MODE: "desktop",
925 T3CODE_NO_BROWSER: "1",925 T3CODE_NO_BROWSER: "1",
926 T3CODE_PORT: String(backendPort),926 T3CODE_PORT: String(backendPort),
927 T3CODE_STATE_DIR: STATE_DIR,927 T3CODE_HOME: BASE_DIR,
928 T3CODE_AUTH_TOKEN: backendAuthToken,928 T3CODE_AUTH_TOKEN: backendAuthToken,
929 };929 };
930}930}
apps/server/integration/OrchestrationEngineHarness.integration.ts+24-24
...@@ -1,6 +1,3 @@...@@ -1,6 +1,3 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4import { execFileSync } from "node:child_process";1import { execFileSync } from "node:child_process";
52
6import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -13,9 +10,11 @@ import {...@@ -13,9 +10,11 @@ import {
13import {10import {
14 Effect,11 Effect,
15 Exit,12 Exit,
13 FileSystem,
16 Layer,14 Layer,
17 ManagedRuntime,15 ManagedRuntime,
18 Option,16 Option,
17 Path,
19 Ref,18 Ref,
20 Schedule,19 Schedule,
21 Schema,20 Schema,
...@@ -66,7 +65,7 @@ import {...@@ -66,7 +65,7 @@ import {
66 makeTestProviderAdapterHarness,65 makeTestProviderAdapterHarness,
67 type TestProviderAdapterHarness,66 type TestProviderAdapterHarness,
68} from "./TestProviderAdapter.integration.ts";67} from "./TestProviderAdapter.integration.ts";
69import { ServerConfig } from "../src/config.ts";68import { deriveServerPaths, ServerConfig } from "../src/config.ts";
7069
71function runGit(cwd: string, args: ReadonlyArray<string>) {70function runGit(cwd: string, args: ReadonlyArray<string>) {
72 return execFileSync("git", args, {71 return execFileSync("git", args, {
...@@ -76,14 +75,16 @@ function runGit(cwd: string, args: ReadonlyArray<string>) {...@@ -76,14 +75,16 @@ function runGit(cwd: string, args: ReadonlyArray<string>) {
76 });75 });
77}76}
7877
79function initializeGitWorkspace(cwd: string) {78const initializeGitWorkspace = Effect.fn(function* (cwd: string) {
80 runGit(cwd, ["init", "--initial-branch=main"]);79 runGit(cwd, ["init", "--initial-branch=main"]);
81 runGit(cwd, ["config", "user.email", "test@example.com"]);80 runGit(cwd, ["config", "user.email", "test@example.com"]);
82 runGit(cwd, ["config", "user.name", "Test User"]);81 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");
84 runGit(cwd, ["add", "."]);85 runGit(cwd, ["add", "."]);
85 runGit(cwd, ["commit", "-m", "Initial"]);86 runGit(cwd, ["commit", "-m", "Initial"]);
86}87});
8788
88export function gitRefExists(cwd: string, ref: string): boolean {89export function gitRefExists(cwd: string, ref: string): boolean {
89 try {90 try {
...@@ -214,7 +215,9 @@ export const makeOrchestrationIntegrationHarness = (...@@ -214,7 +215,9 @@ export const makeOrchestrationIntegrationHarness = (
214 options?: MakeOrchestrationIntegrationHarnessOptions,215 options?: MakeOrchestrationIntegrationHarnessOptions,
215) =>216) =>
216 Effect.gen(function* () {217 Effect.gen(function* () {
217 const sleep = (ms: number) => Effect.sleep(ms);218 const path = yield* Path.Path;
219 const fileSystem = yield* FileSystem.FileSystem;
220
218 const provider = options?.provider ?? "codex";221 const provider = options?.provider ?? "codex";
219 const useRealCodex = options?.realCodex === true;222 const useRealCodex = options?.realCodex === true;
220 const adapterHarness = useRealCodex223 const adapterHarness = useRealCodex
...@@ -231,13 +234,16 @@ export const makeOrchestrationIntegrationHarness = (...@@ -231,13 +234,16 @@ export const makeOrchestrationIntegrationHarness = (
231 listProviders: () => Effect.succeed([adapterHarness.provider]),234 listProviders: () => Effect.succeed([adapterHarness.provider]),
232 } as typeof ProviderAdapterRegistry.Service)235 } as typeof ProviderAdapterRegistry.Service)
233 : null;236 : 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 });
235 const workspaceDir = path.join(rootDir, "workspace");240 const workspaceDir = path.join(rootDir, "workspace");
236 const stateDir = path.join(rootDir, "state");241 const { stateDir, dbPath } = yield* deriveServerPaths(rootDir, undefined).pipe(
237 const dbPath = path.join(stateDir, "state.sqlite");242 Effect.provideService(Path.Path, path),
238 fs.mkdirSync(workspaceDir, { recursive: true });243 );
239 fs.mkdirSync(stateDir, { recursive: true });244 yield* fileSystem.makeDirectory(workspaceDir, { recursive: true });
240 initializeGitWorkspace(workspaceDir);245 yield* fileSystem.makeDirectory(stateDir, { recursive: true });
246 yield* initializeGitWorkspace(workspaceDir);
241247
242 const persistenceLayer = makeSqlitePersistenceLive(dbPath);248 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
243 const orchestrationLayer = OrchestrationEngineLive.pipe(249 const orchestrationLayer = OrchestrationEngineLive.pipe(
...@@ -262,7 +268,7 @@ export const makeOrchestrationIntegrationHarness = (...@@ -262,7 +268,7 @@ export const makeOrchestrationIntegrationHarness = (
262 }),268 }),
263 ).pipe(269 ).pipe(
264 Layer.provide(makeCodexAdapterLive()),270 Layer.provide(makeCodexAdapterLive()),
265 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),271 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),
266 Layer.provideMerge(NodeServices.layer),272 Layer.provideMerge(NodeServices.layer),
267 Layer.provideMerge(providerSessionDirectoryLayer),273 Layer.provideMerge(providerSessionDirectoryLayer),
268 );274 );
...@@ -312,7 +318,7 @@ export const makeOrchestrationIntegrationHarness = (...@@ -312,7 +318,7 @@ export const makeOrchestrationIntegrationHarness = (
312 );318 );
313 const layer = orchestrationReactorLayer.pipe(319 const layer = orchestrationReactorLayer.pipe(
314 Layer.provide(persistenceLayer),320 Layer.provide(persistenceLayer),
315 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)),321 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),
316 Layer.provideMerge(NodeServices.layer),322 Layer.provideMerge(NodeServices.layer),
317 );323 );
318324
...@@ -352,7 +358,7 @@ export const makeOrchestrationIntegrationHarness = (...@@ -352,7 +358,7 @@ export const makeOrchestrationIntegrationHarness = (
352 yield* Stream.runForEach(runtimeReceiptBus.stream, (receipt) =>358 yield* Stream.runForEach(runtimeReceiptBus.stream, (receipt) =>
353 Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid),359 Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid),
354 ).pipe(Effect.forkIn(scope));360 ).pipe(Effect.forkIn(scope));
355 yield* sleep(10);361 yield* Effect.sleep(10);
356362
357 const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = (363 const waitForThread: OrchestrationIntegrationHarness["waitForThread"] = (
358 threadId,364 threadId,
...@@ -469,13 +475,7 @@ export const makeOrchestrationIntegrationHarness = (...@@ -469,13 +475,7 @@ export const makeOrchestrationIntegrationHarness = (
469 }475 }
470 });476 });
471477
472 yield* shutdown.pipe(478 yield* shutdown;
473 Effect.ensuring(
474 Effect.sync(() => {
475 fs.rmSync(rootDir, { recursive: true, force: true });
476 }),
477 ),
478 );
479 });479 });
480480
481 return {481 return {
apps/server/integration/orchestrationEngine.integration.test.ts+4-5
...@@ -27,6 +27,7 @@ import type {...@@ -27,6 +27,7 @@ import type {
27 CheckpointDiffFinalizedReceipt,27 CheckpointDiffFinalizedReceipt,
28 TurnProcessingQuiescedReceipt,28 TurnProcessingQuiescedReceipt,
29} from "../src/orchestration/Services/RuntimeReceiptBus.ts";29} from "../src/orchestration/Services/RuntimeReceiptBus.ts";
30import * as NodeServices from "@effect/platform-node/NodeServices";
3031
31const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);32const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
32const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);33const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);
...@@ -51,8 +52,6 @@ class IntegrationWaitTimeoutError extends Schema.TaggedErrorClass<IntegrationWai...@@ -51,8 +52,6 @@ class IntegrationWaitTimeoutError extends Schema.TaggedErrorClass<IntegrationWai
51 },52 },
52) {}53) {}
5354
54const sleep = (ms: number) => Effect.sleep(ms);
55
56function waitForSync<A>(55function waitForSync<A>(
57 read: () => A,56 read: () => A,
58 predicate: (value: A) => boolean,57 predicate: (value: A) => boolean,
...@@ -70,7 +69,7 @@ function waitForSync<A>(...@@ -70,7 +69,7 @@ function waitForSync<A>(
70 if (Date.now() >= deadline) {69 if (Date.now() >= deadline) {
71 return yield* Effect.die(new IntegrationWaitTimeoutError({ description }));70 return yield* Effect.die(new IntegrationWaitTimeoutError({ description }));
72 }71 }
73 yield* sleep(10);72 yield* Effect.sleep(10);
74 }73 }
75 });74 });
76}75}
...@@ -91,7 +90,7 @@ function withHarness<A, E>(...@@ -91,7 +90,7 @@ function withHarness<A, E>(
91 makeOrchestrationIntegrationHarness({ provider }),90 makeOrchestrationIntegrationHarness({ provider }),
92 use,91 use,
93 (harness) => harness.dispose,92 (harness) => harness.dispose,
94 );93 ).pipe(Effect.provide(NodeServices.layer));
95}94}
9695
97function withRealCodexHarness<A, E>(96function withRealCodexHarness<A, E>(
...@@ -101,7 +100,7 @@ function withRealCodexHarness<A, E>(...@@ -101,7 +100,7 @@ function withRealCodexHarness<A, E>(
101 makeOrchestrationIntegrationHarness({ provider: "codex", realCodex: true }),100 makeOrchestrationIntegrationHarness({ provider: "codex", realCodex: true }),
102 use,101 use,
103 (harness) => harness.dispose,102 (harness) => harness.dispose,
104 );103 ).pipe(Effect.provide(NodeServices.layer));
105}104}
106105
107const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>106const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
apps/server/src/attachmentPaths.ts+2-2
...@@ -11,7 +11,7 @@ export function normalizeAttachmentRelativePath(rawRelativePath: string): string...@@ -11,7 +11,7 @@ export function normalizeAttachmentRelativePath(rawRelativePath: string): string
11}11}
1212
13export function resolveAttachmentRelativePath(input: {13export function resolveAttachmentRelativePath(input: {
14 readonly stateDir: string;14 readonly attachmentsDir: string;
15 readonly relativePath: string;15 readonly relativePath: string;
16}): string | null {16}): string | null {
17 const normalizedRelativePath = normalizeAttachmentRelativePath(input.relativePath);17 const normalizedRelativePath = normalizeAttachmentRelativePath(input.relativePath);
...@@ -19,7 +19,7 @@ export function resolveAttachmentRelativePath(input: {...@@ -19,7 +19,7 @@ export function resolveAttachmentRelativePath(input: {
19 return null;19 return null;
20 }20 }
2121
22 const attachmentsRoot = path.resolve(path.join(input.stateDir, "attachments"));22 const attachmentsRoot = path.resolve(input.attachmentsDir);
23 const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath));23 const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath));
24 if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) {24 if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) {
25 return null;25 return null;
apps/server/src/attachmentStore.test.ts+6-8
...@@ -44,34 +44,32 @@ describe("attachmentStore", () => {...@@ -44,34 +44,32 @@ describe("attachmentStore", () => {
44 });44 });
4545
46 it("resolves attachment path by id using the extension that exists on disk", () => {46 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-"));
48 try {48 try {
49 const attachmentId = "thread-1-attachment";49 const attachmentId = "thread-1-attachment";
50 const attachmentsDir = path.join(stateDir, "attachments");
51 fs.mkdirSync(attachmentsDir, { recursive: true });
52 const pngPath = path.join(attachmentsDir, `${attachmentId}.png`);50 const pngPath = path.join(attachmentsDir, `${attachmentId}.png`);
53 fs.writeFileSync(pngPath, Buffer.from("hello"));51 fs.writeFileSync(pngPath, Buffer.from("hello"));
5452
55 const resolved = resolveAttachmentPathById({53 const resolved = resolveAttachmentPathById({
56 stateDir,54 attachmentsDir,
57 attachmentId,55 attachmentId,
58 });56 });
59 expect(resolved).toBe(pngPath);57 expect(resolved).toBe(pngPath);
60 } finally {58 } finally {
61 fs.rmSync(stateDir, { recursive: true, force: true });59 fs.rmSync(attachmentsDir, { recursive: true, force: true });
62 }60 }
63 });61 });
6462
65 it("returns null when no attachment file exists for the id", () => {63 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-"));
67 try {65 try {
68 const resolved = resolveAttachmentPathById({66 const resolved = resolveAttachmentPathById({
69 stateDir,67 attachmentsDir,
70 attachmentId: "thread-1-missing",68 attachmentId: "thread-1-missing",
71 });69 });
72 expect(resolved).toBeNull();70 expect(resolved).toBeNull();
73 } finally {71 } finally {
74 fs.rmSync(stateDir, { recursive: true, force: true });72 fs.rmSync(attachmentsDir, { recursive: true, force: true });
75 }73 }
76 });74 });
77});75});
apps/server/src/attachmentStore.ts+4-4
...@@ -66,17 +66,17 @@ export function attachmentRelativePath(attachment: ChatAttachment): string {...@@ -66,17 +66,17 @@ export function attachmentRelativePath(attachment: ChatAttachment): string {
66}66}
6767
68export function resolveAttachmentPath(input: {68export function resolveAttachmentPath(input: {
69 readonly stateDir: string;69 readonly attachmentsDir: string;
70 readonly attachment: ChatAttachment;70 readonly attachment: ChatAttachment;
71}): string | null {71}): string | null {
72 return resolveAttachmentRelativePath({72 return resolveAttachmentRelativePath({
73 stateDir: input.stateDir,73 attachmentsDir: input.attachmentsDir,
74 relativePath: attachmentRelativePath(input.attachment),74 relativePath: attachmentRelativePath(input.attachment),
75 });75 });
76}76}
7777
78export function resolveAttachmentPathById(input: {78export function resolveAttachmentPathById(input: {
79 readonly stateDir: string;79 readonly attachmentsDir: string;
80 readonly attachmentId: string;80 readonly attachmentId: string;
81}): string | null {81}): string | null {
82 const normalizedId = normalizeAttachmentRelativePath(input.attachmentId);82 const normalizedId = normalizeAttachmentRelativePath(input.attachmentId);
...@@ -85,7 +85,7 @@ export function resolveAttachmentPathById(input: {...@@ -85,7 +85,7 @@ export function resolveAttachmentPathById(input: {
85 }85 }
86 for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) {86 for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) {
87 const maybePath = resolveAttachmentRelativePath({87 const maybePath = resolveAttachmentRelativePath({
88 stateDir: input.stateDir,88 attachmentsDir: input.attachmentsDir,
89 relativePath: `${normalizedId}${extension}`,89 relativePath: `${normalizedId}${extension}`,
90 });90 });
91 if (maybePath && existsSync(maybePath)) {91 if (maybePath && existsSync(maybePath)) {
apps/server/src/config.ts+62-9
...@@ -12,16 +12,32 @@ export const DEFAULT_PORT = 3773;...@@ -12,16 +12,32 @@ export const DEFAULT_PORT = 3773;
1212
13export type RuntimeMode = "web" | "desktop";13export 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
15/**32/**
16 * ServerConfigShape - Process/runtime configuration required by the server.33 * ServerConfigShape - Process/runtime configuration required by the server.
17 */34 */
18export interface ServerConfigShape {35export interface ServerConfigShape extends ServerDerivedPaths {
19 readonly mode: RuntimeMode;36 readonly mode: RuntimeMode;
20 readonly port: number;37 readonly port: number;
21 readonly host: string | undefined;38 readonly host: string | undefined;
22 readonly cwd: string;39 readonly cwd: string;
23 readonly keybindingsConfigPath: string;40 readonly baseDir: string;
24 readonly stateDir: string;
25 readonly staticDir: string | undefined;41 readonly staticDir: string | undefined;
26 readonly devUrl: URL | undefined;42 readonly devUrl: URL | undefined;
27 readonly noBrowser: boolean;43 readonly noBrowser: boolean;
...@@ -30,31 +46,68 @@ export interface ServerConfigShape {...@@ -30,31 +46,68 @@ export interface ServerConfigShape {
30 readonly logWebSocketEvents: boolean;46 readonly logWebSocketEvents: boolean;
31}47}
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
33/**74/**
34 * ServerConfig - Service tag for server runtime configuration.75 * ServerConfig - Service tag for server runtime configuration.
35 */76 */
36export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigShape>()(77export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigShape>()(
37 "t3/config/ServerConfig",78 "t3/config/ServerConfig",
38) {79) {
39 static readonly layerTest = (cwd: string, statedir: string) =>80 static readonly layerTest = (cwd: string, baseDirOrPrefix: string | { prefix: string }) =>
40 Layer.effect(81 Layer.effect(
41 ServerConfig,82 ServerConfig,
42 Effect.gen(function* () {83 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
44 return {97 return {
45 cwd,98 cwd,
46 stateDir: statedir,99 baseDir,
100 ...derivedPaths,
47 mode: "web",101 mode: "web",
48 autoBootstrapProjectFromCwd: false,102 autoBootstrapProjectFromCwd: false,
49 logWebSocketEvents: false,103 logWebSocketEvents: false,
50 port: 0,104 port: 0,
51 host: undefined,105 host: undefined,
52 authToken: undefined,106 authToken: undefined,
53 keybindingsConfigPath: path.join(statedir, "keybindings.json"),
54 staticDir: undefined,107 staticDir: undefined,
55 devUrl: undefined,108 devUrl,
56 noBrowser: false,109 noBrowser: false,
57 };110 } satisfies ServerConfigShape;
58 }),111 }),
59 );112 );
60}113}
apps/server/src/git/Layers/CodexTextGeneration.test.ts+16-12
...@@ -8,11 +8,14 @@ import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";...@@ -8,11 +8,14 @@ import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
8import { TextGenerationError } from "../Errors.ts";8import { TextGenerationError } from "../Errors.ts";
9import { TextGeneration } from "../Services/TextGeneration.ts";9import { TextGeneration } from "../Services/TextGeneration.ts";
1010
11const makeCodexTextGenerationTestLayer = (stateDir: string) =>11const CodexTextGenerationTestLayer = CodexTextGenerationLive.pipe(
12 CodexTextGenerationLive.pipe(12 Layer.provideMerge(
13 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)),13 ServerConfig.layerTest(process.cwd(), {
14 Layer.provideMerge(NodeServices.layer),14 prefix: "t3code-codex-text-generation-test-",
15 );15 }),
16 ),
17 Layer.provideMerge(NodeServices.layer),
18);
1619
17function makeFakeCodexBinary(dir: string) {20function makeFakeCodexBinary(dir: string) {
18 return Effect.gen(function* () {21 return Effect.gen(function* () {
...@@ -186,8 +189,6 @@ function withFakeCodexEnv<A, E, R>(...@@ -186,8 +189,6 @@ function withFakeCodexEnv<A, E, R>(
186 );189 );
187}190}
188191
189const CodexTextGenerationTestLayer = makeCodexTextGenerationTestLayer(process.cwd());
190
191it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {192it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
192 it.effect("generates and sanitizes commit messages without branch by default", () =>193 it.effect("generates and sanitizes commit messages without branch by default", () =>
193 withFakeCodexEnv(194 withFakeCodexEnv(
...@@ -325,9 +326,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -325,9 +326,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
325 Effect.gen(function* () {326 Effect.gen(function* () {
326 const fs = yield* FileSystem.FileSystem;327 const fs = yield* FileSystem.FileSystem;
327 const path = yield* Path.Path;328 const path = yield* Path.Path;
329 const { attachmentsDir } = yield* ServerConfig;
328 const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;330 const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
329 const attachmentPath = path.join(process.cwd(), "attachments", `${attachmentId}.png`);331 const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`);
330 yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true });332 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
331 yield* fs.writeFile(attachmentPath, Buffer.from("hello"));333 yield* fs.writeFile(attachmentPath, Buffer.from("hello"));
332334
333 const textGeneration = yield* TextGeneration;335 const textGeneration = yield* TextGeneration;
...@@ -363,9 +365,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -363,9 +365,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
363 Effect.gen(function* () {365 Effect.gen(function* () {
364 const fs = yield* FileSystem.FileSystem;366 const fs = yield* FileSystem.FileSystem;
365 const path = yield* Path.Path;367 const path = yield* Path.Path;
368 const { attachmentsDir } = yield* ServerConfig;
366 const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;369 const attachmentId = `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
367 const imagePath = path.join(process.cwd(), "attachments", `${attachmentId}.png`);370 const imagePath = path.join(attachmentsDir, `${attachmentId}.png`);
368 yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true });371 yield* fs.makeDirectory(attachmentsDir, { recursive: true });
369 yield* fs.writeFile(imagePath, Buffer.from("hello"));372 yield* fs.writeFile(imagePath, Buffer.from("hello"));
370373
371 const textGeneration = yield* TextGeneration;374 const textGeneration = yield* TextGeneration;
...@@ -410,8 +413,9 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -410,8 +413,9 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
410 Effect.gen(function* () {413 Effect.gen(function* () {
411 const fs = yield* FileSystem.FileSystem;414 const fs = yield* FileSystem.FileSystem;
412 const path = yield* Path.Path;415 const path = yield* Path.Path;
416 const { attachmentsDir } = yield* ServerConfig;
413 const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;417 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`);
415 yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void));419 yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void));
416420
417 const textGeneration = yield* TextGeneration;421 const textGeneration = yield* TextGeneration;
apps/server/src/git/Layers/CodexTextGeneration.ts+1-1
...@@ -163,7 +163,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -163,7 +163,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
163 }163 }
164164
165 const resolvedPath = resolveAttachmentPath({165 const resolvedPath = resolveAttachmentPath({
166 stateDir: serverConfig.stateDir,166 attachmentsDir: serverConfig.attachmentsDir,
167 attachment,167 attachment,
168 });168 });
169 if (!resolvedPath || !path.isAbsolute(resolvedPath)) {169 if (!resolvedPath || !path.isAbsolute(resolvedPath)) {
apps/server/src/git/Layers/GitCore.test.ts+4
...@@ -12,12 +12,15 @@ import { GitCoreLive } from "./GitCore.ts";...@@ -12,12 +12,15 @@ import { GitCoreLive } from "./GitCore.ts";
12import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";12import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
13import { GitCommandError } from "../Errors.ts";13import { GitCommandError } from "../Errors.ts";
14import { type ProcessRunResult, runProcess } from "../../processRunner.ts";14import { type ProcessRunResult, runProcess } from "../../processRunner.ts";
15import { ServerConfig } from "../../config.ts";
1516
16// ── Helpers ──17// ── Helpers ──
1718
18const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer));19const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer));
20const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" });
19const GitCoreTestLayer = GitCoreLive.pipe(21const GitCoreTestLayer = GitCoreLive.pipe(
20 Layer.provide(GitServiceTestLayer),22 Layer.provide(GitServiceTestLayer),
23 Layer.provide(ServerConfigLayer),
21 Layer.provide(NodeServices.layer),24 Layer.provide(NodeServices.layer),
22);25);
23const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer);26const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer);
...@@ -90,6 +93,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) =>...@@ -90,6 +93,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) =>
90 const gitServiceLayer = Layer.succeed(GitService, gitService);93 const gitServiceLayer = Layer.succeed(GitService, gitService);
91 const coreLayer = GitCoreLive.pipe(94 const coreLayer = GitCoreLive.pipe(
92 Layer.provide(gitServiceLayer),95 Layer.provide(gitServiceLayer),
96 Layer.provide(ServerConfigLayer),
93 Layer.provide(NodeServices.layer),97 Layer.provide(NodeServices.layer),
94 );98 );
95 const core = await Effect.runPromise(Effect.service(GitCore).pipe(Effect.provide(coreLayer)));99 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...@@ -3,6 +3,7 @@ import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "ef
3import { GitCommandError } from "../Errors.ts";3import { GitCommandError } from "../Errors.ts";
4import { GitService } from "../Services/GitService.ts";4import { GitService } from "../Services/GitService.ts";
5import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";5import { GitCore, type GitCoreShape } from "../Services/GitCore.ts";
6import { ServerConfig } from "../../config.ts";
67
7const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);8const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
8const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);9const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
...@@ -221,6 +222,7 @@ const makeGitCore = Effect.gen(function* () {...@@ -221,6 +222,7 @@ const makeGitCore = Effect.gen(function* () {
221 const git = yield* GitService;222 const git = yield* GitService;
222 const fileSystem = yield* FileSystem.FileSystem;223 const fileSystem = yield* FileSystem.FileSystem;
223 const path = yield* Path.Path;224 const path = yield* Path.Path;
225 const { worktreesDir } = yield* ServerConfig;
224226
225 const executeGit = (227 const executeGit = (
226 operation: string,228 operation: string,
...@@ -1186,9 +1188,7 @@ const makeGitCore = Effect.gen(function* () {...@@ -1186,9 +1188,7 @@ const makeGitCore = Effect.gen(function* () {
1186 const targetBranch = input.newBranch ?? input.branch;1188 const targetBranch = input.newBranch ?? input.branch;
1187 const sanitizedBranch = targetBranch.replace(/\//g, "-");1189 const sanitizedBranch = targetBranch.replace(/\//g, "-");
1188 const repoName = path.basename(input.cwd);1190 const repoName = path.basename(input.cwd);
1189 const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";1191 const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch);
1190 const worktreePath =
1191 input.path ?? path.join(homeDir, ".t3", "worktrees", repoName, sanitizedBranch);
1192 const args = input.newBranch1192 const args = input.newBranch
1193 ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]1193 ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch]
1194 : ["worktree", "add", worktreePath, input.branch];1194 : ["worktree", "add", worktreePath, input.branch];
apps/server/src/git/Layers/GitManager.test.ts+6-2
...@@ -19,6 +19,7 @@ import { GitServiceLive } from "./GitService.ts";...@@ -19,6 +19,7 @@ import { GitServiceLive } from "./GitService.ts";
19import { GitService } from "../Services/GitService.ts";19import { GitService } from "../Services/GitService.ts";
20import { GitCoreLive } from "./GitCore.ts";20import { GitCoreLive } from "./GitCore.ts";
21import { makeGitManager } from "./GitManager.ts";21import { makeGitManager } from "./GitManager.ts";
22import { ServerConfig } from "../../config.ts";
2223
23interface FakeGhScenario {24interface FakeGhScenario {
24 prListSequence?: string[];25 prListSequence?: string[];
...@@ -474,18 +475,21 @@ function makeManager(input?: {...@@ -474,18 +475,21 @@ function makeManager(input?: {
474}) {475}) {
475 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);476 const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
476 const textGeneration = createTextGeneration(input?.textGeneration);477 const textGeneration = createTextGeneration(input?.textGeneration);
478 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
479 prefix: "t3-git-manager-test-",
480 });
477481
478 const gitCoreLayer = GitCoreLive.pipe(482 const gitCoreLayer = GitCoreLive.pipe(
479 Layer.provideMerge(GitServiceLive),483 Layer.provideMerge(GitServiceLive),
480 Layer.provideMerge(NodeServices.layer),484 Layer.provideMerge(NodeServices.layer),
485 Layer.provideMerge(ServerConfigLayer),
481 );486 );
482487
483 const managerLayer = Layer.mergeAll(488 const managerLayer = Layer.mergeAll(
484 Layer.succeed(GitHubCli, gitHubCli),489 Layer.succeed(GitHubCli, gitHubCli),
485 Layer.succeed(TextGeneration, textGeneration),490 Layer.succeed(TextGeneration, textGeneration),
486 gitCoreLayer,491 gitCoreLayer,
487 NodeServices.layer,492 ).pipe(Layer.provideMerge(NodeServices.layer));
488 );
489493
490 return makeGitManager.pipe(494 return makeGitManager.pipe(
491 Effect.provide(managerLayer),495 Effect.provide(managerLayer),
apps/server/src/keybindings.test.ts+9-11
...@@ -3,7 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";...@@ -3,7 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";3import { assert, it } from "@effect/vitest";
4import { assertFailure } from "@effect/vitest/utils";4import { assertFailure } from "@effect/vitest/utils";
5import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";5import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";
6import { ServerConfig, type ServerConfigShape } from "./config";6import { ServerConfig } from "./config";
77
8import {8import {
9 DEFAULT_KEYBINDINGS,9 DEFAULT_KEYBINDINGS,
...@@ -17,21 +17,17 @@ import {...@@ -17,21 +17,17 @@ import {
17} from "./keybindings";17} from "./keybindings";
1818
19const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);19const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
20const makeKeybindingsLayer = () =>20const makeKeybindingsLayer = () => {
21 KeybindingsLive.pipe(21 return KeybindingsLive.pipe(
22 Layer.provideMerge(22 Layer.provideMerge(
23 Layer.effect(23 Layer.fresh(
24 ServerConfig,24 ServerConfig.layerTest(process.cwd(), {
25 Effect.gen(function* () {25 prefix: "t3code-keybindings-test-",
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;
31 }),26 }),
32 ),27 ),
33 ),28 ),
34 );29 );
30};
3531
36const toDetailResult = <A, R>(effect: Effect.Effect<A, KeybindingsConfigError, R>) =>32const toDetailResult = <A, R>(effect: Effect.Effect<A, KeybindingsConfigError, R>) =>
37 effect.pipe(33 effect.pipe(
...@@ -42,7 +38,9 @@ const toDetailResult = <A, R>(effect: Effect.Effect<A, KeybindingsConfigError, R...@@ -42,7 +38,9 @@ const toDetailResult = <A, R>(effect: Effect.Effect<A, KeybindingsConfigError, R
42const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRule[]) =>38const writeKeybindingsConfig = (configPath: string, rules: readonly KeybindingRule[]) =>
43 Effect.gen(function* () {39 Effect.gen(function* () {
44 const fileSystem = yield* FileSystem.FileSystem;40 const fileSystem = yield* FileSystem.FileSystem;
41 const path = yield* Path.Path;
45 const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)(rules);42 const encoded = yield* Schema.encodeEffect(KeybindingsConfigJson)(rules);
43 yield* fileSystem.makeDirectory(path.dirname(configPath), { recursive: true });
46 yield* fileSystem.writeFileString(configPath, encoded);44 yield* fileSystem.writeFileString(configPath, encoded);
47 });45 });
4846
apps/server/src/main.test.ts+7-7
...@@ -60,13 +60,11 @@ const runCli = (...@@ -60,13 +60,11 @@ const runCli = (
60 args: ReadonlyArray<string>,60 args: ReadonlyArray<string>,
61 env: Record<string, string> = { T3CODE_NO_BROWSER: "true" },61 env: Record<string, string> = { T3CODE_NO_BROWSER: "true" },
62) => {62) => {
63 const uniqueStateDir = `/tmp/t3-cli-state-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
64 return Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe(63 return Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe(
65 Effect.provide(64 Effect.provide(
66 ConfigProvider.layer(65 ConfigProvider.layer(
67 ConfigProvider.fromEnv({66 ConfigProvider.fromEnv({
68 env: {67 env: {
69 T3CODE_STATE_DIR: uniqueStateDir,
70 ...env,68 ...env,
71 },69 },
72 }),70 }),
...@@ -93,8 +91,8 @@ it.layer(testLayer)("server CLI command", (it) => {...@@ -93,8 +91,8 @@ it.layer(testLayer)("server CLI command", (it) => {
93 "4010",91 "4010",
94 "--host",92 "--host",
95 "0.0.0.0",93 "0.0.0.0",
96 "--state-dir",94 "--home-dir",
97 "/tmp/t3-cli-state",95 "/tmp/t3-cli-home",
98 "--dev-url",96 "--dev-url",
99 "http://127.0.0.1:5173",97 "http://127.0.0.1:5173",
100 "--no-browser",98 "--no-browser",
...@@ -106,7 +104,8 @@ it.layer(testLayer)("server CLI command", (it) => {...@@ -106,7 +104,8 @@ it.layer(testLayer)("server CLI command", (it) => {
106 assert.equal(resolvedConfig?.mode, "desktop");104 assert.equal(resolvedConfig?.mode, "desktop");
107 assert.equal(resolvedConfig?.port, 4010);105 assert.equal(resolvedConfig?.port, 4010);
108 assert.equal(resolvedConfig?.host, "0.0.0.0");106 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");
110 assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");109 assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/");
111 assert.equal(resolvedConfig?.noBrowser, true);110 assert.equal(resolvedConfig?.noBrowser, true);
112 assert.equal(resolvedConfig?.authToken, "auth-secret");111 assert.equal(resolvedConfig?.authToken, "auth-secret");
...@@ -131,7 +130,7 @@ it.layer(testLayer)("server CLI command", (it) => {...@@ -131,7 +130,7 @@ it.layer(testLayer)("server CLI command", (it) => {
131 T3CODE_MODE: "desktop",130 T3CODE_MODE: "desktop",
132 T3CODE_PORT: "4999",131 T3CODE_PORT: "4999",
133 T3CODE_HOST: "100.88.10.4",132 T3CODE_HOST: "100.88.10.4",
134 T3CODE_STATE_DIR: "/tmp/t3-env-state",133 T3CODE_HOME: "/tmp/t3-env-home",
135 VITE_DEV_SERVER_URL: "http://localhost:5173",134 VITE_DEV_SERVER_URL: "http://localhost:5173",
136 T3CODE_NO_BROWSER: "true",135 T3CODE_NO_BROWSER: "true",
137 T3CODE_AUTH_TOKEN: "env-token",136 T3CODE_AUTH_TOKEN: "env-token",
...@@ -141,7 +140,8 @@ it.layer(testLayer)("server CLI command", (it) => {...@@ -141,7 +140,8 @@ it.layer(testLayer)("server CLI command", (it) => {
141 assert.equal(resolvedConfig?.mode, "desktop");140 assert.equal(resolvedConfig?.mode, "desktop");
142 assert.equal(resolvedConfig?.port, 4999);141 assert.equal(resolvedConfig?.port, 4999);
143 assert.equal(resolvedConfig?.host, "100.88.10.4");142 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");
145 assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/");145 assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/");
146 assert.equal(resolvedConfig?.noBrowser, true);146 assert.equal(resolvedConfig?.noBrowser, true);
147 assert.equal(resolvedConfig?.authToken, "env-token");147 assert.equal(resolvedConfig?.authToken, "env-token");
apps/server/src/main.ts+12-16
...@@ -11,12 +11,13 @@ import { Command, Flag } from "effect/unstable/cli";...@@ -11,12 +11,13 @@ import { Command, Flag } from "effect/unstable/cli";
11import { NetService } from "@t3tools/shared/Net";11import { NetService } from "@t3tools/shared/Net";
12import {12import {
13 DEFAULT_PORT,13 DEFAULT_PORT,
14 deriveServerPaths,
14 resolveStaticDir,15 resolveStaticDir,
15 ServerConfig,16 ServerConfig,
16 type RuntimeMode,17 type RuntimeMode,
17 type ServerConfigShape,18 type ServerConfigShape,
18} from "./config";19} from "./config";
19import { fixPath, resolveStateDir } from "./os-jank";20import { fixPath, resolveBaseDir } from "./os-jank";
20import { Open } from "./open";21import { Open } from "./open";
21import * as SqlitePersistence from "./persistence/Layers/Sqlite";22import * as SqlitePersistence from "./persistence/Layers/Sqlite";
22import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";23import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
...@@ -36,7 +37,7 @@ interface CliInput {...@@ -36,7 +37,7 @@ interface CliInput {
36 readonly mode: Option.Option<RuntimeMode>;37 readonly mode: Option.Option<RuntimeMode>;
37 readonly port: Option.Option<number>;38 readonly port: Option.Option<number>;
38 readonly host: Option.Option<string>;39 readonly host: Option.Option<string>;
39 readonly stateDir: Option.Option<string>;40 readonly t3Home: Option.Option<string>;
40 readonly devUrl: Option.Option<URL>;41 readonly devUrl: Option.Option<URL>;
41 readonly noBrowser: Option.Option<boolean>;42 readonly noBrowser: Option.Option<boolean>;
42 readonly authToken: Option.Option<string>;43 readonly authToken: Option.Option<string>;
...@@ -99,10 +100,7 @@ const CliEnvConfig = Config.all({...@@ -99,10 +100,7 @@ const CliEnvConfig = Config.all({
99 ),100 ),
100 port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),101 port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)),
101 host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),102 host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)),
102 stateDir: Config.string("T3CODE_STATE_DIR").pipe(103 t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)),
103 Config.option,
104 Config.map(Option.getOrUndefined),
105 ),
106 devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),104 devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)),
107 noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(105 noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe(
108 Config.option,106 Config.option,
...@@ -152,10 +150,10 @@ const ServerConfigLive = (input: CliInput) =>...@@ -152,10 +150,10 @@ const ServerConfigLive = (input: CliInput) =>
152 return findAvailablePort(DEFAULT_PORT);150 return findAvailablePort(DEFAULT_PORT);
153 },151 },
154 });152 });
155 const stateDir = yield* resolveStateDir(153
156 Option.getOrUndefined(input.stateDir) ?? env.stateDir,
157 );
158 const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl);154 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);
159 const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop");157 const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop");
160 const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;158 const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken;
161 const autoBootstrapProjectFromCwd = resolveBooleanFlag(159 const autoBootstrapProjectFromCwd = resolveBooleanFlag(
...@@ -167,8 +165,6 @@ const ServerConfigLive = (input: CliInput) =>...@@ -167,8 +165,6 @@ const ServerConfigLive = (input: CliInput) =>
167 env.logWebSocketEvents ?? Boolean(devUrl),165 env.logWebSocketEvents ?? Boolean(devUrl),
168 );166 );
169 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;167 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir;
170 const { join } = yield* Path.Path;
171 const keybindingsConfigPath = join(stateDir, "keybindings.json");
172 const host =168 const host =
173 Option.getOrUndefined(input.host) ??169 Option.getOrUndefined(input.host) ??
174 env.host ??170 env.host ??
...@@ -178,9 +174,9 @@ const ServerConfigLive = (input: CliInput) =>...@@ -178,9 +174,9 @@ const ServerConfigLive = (input: CliInput) =>
178 mode,174 mode,
179 port,175 port,
180 cwd: cliConfig.cwd,176 cwd: cliConfig.cwd,
181 keybindingsConfigPath,
182 host,177 host,
183 stateDir,178 baseDir,
179 ...derivedPaths,
184 staticDir,180 staticDir,
185 devUrl,181 devUrl,
186 noBrowser,182 noBrowser,
...@@ -299,8 +295,8 @@ const hostFlag = Flag.string("host").pipe(...@@ -299,8 +295,8 @@ const hostFlag = Flag.string("host").pipe(
299 Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."),295 Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."),
300 Flag.optional,296 Flag.optional,
301);297);
302const stateDirFlag = Flag.string("state-dir").pipe(298const t3HomeFlag = Flag.string("home-dir").pipe(
303 Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."),299 Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."),
304 Flag.optional,300 Flag.optional,
305);301);
306const devUrlFlag = Flag.string("dev-url").pipe(302const devUrlFlag = Flag.string("dev-url").pipe(
...@@ -335,7 +331,7 @@ export const t3Cli = Command.make("t3", {...@@ -335,7 +331,7 @@ export const t3Cli = Command.make("t3", {
335 mode: modeFlag,331 mode: modeFlag,
336 port: portFlag,332 port: portFlag,
337 host: hostFlag,333 host: hostFlag,
338 stateDir: stateDirFlag,334 t3Home: t3HomeFlag,
339 devUrl: devUrlFlag,335 devUrl: devUrlFlag,
340 noBrowser: noBrowserFlag,336 noBrowser: noBrowserFlag,
341 authToken: authTokenFlag,337 authToken: authTokenFlag,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+5-1
...@@ -252,12 +252,16 @@ describe("CheckpointReactor", () => {...@@ -252,12 +252,16 @@ describe("CheckpointReactor", () => {
252 Layer.provide(SqlitePersistenceMemory),252 Layer.provide(SqlitePersistenceMemory),
253 );253 );
254254
255 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
256 prefix: "t3-checkpoint-reactor-test-",
257 });
258
255 const layer = CheckpointReactorLive.pipe(259 const layer = CheckpointReactorLive.pipe(
256 Layer.provideMerge(orchestrationLayer),260 Layer.provideMerge(orchestrationLayer),
257 Layer.provideMerge(RuntimeReceiptBusLive),261 Layer.provideMerge(RuntimeReceiptBusLive),
258 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),262 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
259 Layer.provideMerge(CheckpointStoreLive),263 Layer.provideMerge(CheckpointStoreLive),
260 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),264 Layer.provideMerge(ServerConfigLayer),
261 Layer.provideMerge(NodeServices.layer),265 Layer.provideMerge(NodeServices.layer),
262 );266 );
263267
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+9-2
...@@ -35,12 +35,15 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);...@@ -35,12 +35,15 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
35const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);35const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
3636
37async function createOrchestrationSystem() {37async function createOrchestrationSystem() {
38 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
39 prefix: "t3-orchestration-engine-test-",
40 });
38 const orchestrationLayer = OrchestrationEngineLive.pipe(41 const orchestrationLayer = OrchestrationEngineLive.pipe(
39 Layer.provide(OrchestrationProjectionPipelineLive),42 Layer.provide(OrchestrationProjectionPipelineLive),
40 Layer.provide(OrchestrationEventStoreLive),43 Layer.provide(OrchestrationEventStoreLive),
41 Layer.provide(OrchestrationCommandReceiptRepositoryLive),44 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
42 Layer.provide(SqlitePersistenceMemory),45 Layer.provide(SqlitePersistenceMemory),
43 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),46 Layer.provideMerge(ServerConfigLayer),
44 Layer.provideMerge(NodeServices.layer),47 Layer.provideMerge(NodeServices.layer),
45 );48 );
46 const runtime = ManagedRuntime.make(orchestrationLayer);49 const runtime = ManagedRuntime.make(orchestrationLayer);
...@@ -317,13 +320,17 @@ describe("OrchestrationEngine", () => {...@@ -317,13 +320,17 @@ describe("OrchestrationEngine", () => {
317 },320 },
318 };321 };
319322
323 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
324 prefix: "t3-orchestration-engine-test-",
325 });
326
320 const runtime = ManagedRuntime.make(327 const runtime = ManagedRuntime.make(
321 OrchestrationEngineLive.pipe(328 OrchestrationEngineLive.pipe(
322 Layer.provide(OrchestrationProjectionPipelineLive),329 Layer.provide(OrchestrationProjectionPipelineLive),
323 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),330 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
324 Layer.provide(OrchestrationCommandReceiptRepositoryLive),331 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
325 Layer.provide(SqlitePersistenceMemory),332 Layer.provide(SqlitePersistenceMemory),
326 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),333 Layer.provideMerge(ServerConfigLayer),
327 Layer.provideMerge(NodeServices.layer),334 Layer.provideMerge(NodeServices.layer),
328 ),335 ),
329 );336 );
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+757-783
...@@ -10,10 +10,7 @@ import {...@@ -10,10 +10,7 @@ import {
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import * as NodeServices from "@effect/platform-node/NodeServices";11import * as NodeServices from "@effect/platform-node/NodeServices";
12import { assert, it } from "@effect/vitest";12import { assert, it } from "@effect/vitest";
13import { Effect, Layer, ManagedRuntime } from "effect";13import { Effect, FileSystem, Layer, Path } from "effect";
14import fs from "node:fs";
15import os from "node:os";
16import path from "node:path";
17import * as SqlClient from "effect/unstable/sql/SqlClient";14import * as SqlClient from "effect/unstable/sql/SqlClient";
1815
19import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";16import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
...@@ -32,31 +29,24 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";...@@ -32,31 +29,24 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
32import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";29import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts";
33import { ServerConfig } from "../../config.ts";30import { ServerConfig } from "../../config.ts";
3431
35const makeProjectionPipelineTestLayer = (stateDir: string) =>32const makeProjectionPipelinePrefixedTestLayer = (prefix: string) =>
36 OrchestrationProjectionPipelineLive.pipe(33 OrchestrationProjectionPipelineLive.pipe(
37 Layer.provideMerge(OrchestrationEventStoreLive),34 Layer.provideMerge(OrchestrationEventStoreLive),
38 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)),35 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix })),
39 Layer.provideMerge(SqlitePersistenceMemory),36 Layer.provideMerge(SqlitePersistenceMemory),
40 Layer.provideMerge(NodeServices.layer),37 Layer.provideMerge(NodeServices.layer),
41 );38 );
4239
43const runWithProjectionPipelineLayer = <A, E>(40const exists = (filePath: string) =>
44 stateDir: string,41 Effect.gen(function* () {
45 effect: Effect.Effect<42 const fileSystem = yield* FileSystem.FileSystem;
46 A,43 const fileInfo = yield* Effect.result(fileSystem.stat(filePath));
47 E,44 return fileInfo._tag === "Success";
48 OrchestrationProjectionPipeline | OrchestrationEventStore | SqlClient.SqlClient45 });
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 );
5646
57const projectionLayer = it.layer(makeProjectionPipelineTestLayer(process.cwd()));47const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-");
5848
59projectionLayer("OrchestrationProjectionPipeline", (it) => {49it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
60 it.effect("bootstraps all projection states and writes projection rows", () =>50 it.effect("bootstraps all projection states and writes projection rows", () =>
61 Effect.gen(function* () {51 Effect.gen(function* () {
62 const projectionPipeline = yield* OrchestrationProjectionPipeline;52 const projectionPipeline = yield* OrchestrationProjectionPipeline;
...@@ -174,157 +164,155 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -174,157 +164,155 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
174 }164 }
175 }),165 }),
176 );166 );
167});
177168
178 it.effect("stores message attachment references without mutating payloads", () =>169it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))(
179 Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe(170 "OrchestrationProjectionPipeline",
180 Effect.flatMap((stateDir) =>171 (it) => {
181 Effect.gen(function* () {172 it.effect("stores message attachment references without mutating payloads", () =>
182 const projectionPipeline = yield* OrchestrationProjectionPipeline;173 Effect.gen(function* () {
183 const eventStore = yield* OrchestrationEventStore;174 const projectionPipeline = yield* OrchestrationProjectionPipeline;
184 const sql = yield* SqlClient.SqlClient;175 const eventStore = yield* OrchestrationEventStore;
185 const now = new Date().toISOString();176 const sql = yield* SqlClient.SqlClient;
186177 const now = new Date().toISOString();
187 yield* eventStore.append({178
188 type: "thread.message-sent",179 yield* eventStore.append({
189 eventId: EventId.makeUnsafe("evt-attachments"),180 type: "thread.message-sent",
190 aggregateKind: "thread",181 eventId: EventId.makeUnsafe("evt-attachments"),
191 aggregateId: ThreadId.makeUnsafe("thread-attachments"),182 aggregateKind: "thread",
192 occurredAt: now,183 aggregateId: ThreadId.makeUnsafe("thread-attachments"),
193 commandId: CommandId.makeUnsafe("cmd-attachments"),184 occurredAt: now,
194 causationEventId: null,185 commandId: CommandId.makeUnsafe("cmd-attachments"),
195 correlationId: CommandId.makeUnsafe("cmd-attachments"),186 causationEventId: null,
196 metadata: {},187 correlationId: CommandId.makeUnsafe("cmd-attachments"),
197 payload: {188 metadata: {},
198 threadId: ThreadId.makeUnsafe("thread-attachments"),189 payload: {
199 messageId: MessageId.makeUnsafe("message-attachments"),190 threadId: ThreadId.makeUnsafe("thread-attachments"),
200 role: "user",191 messageId: MessageId.makeUnsafe("message-attachments"),
201 text: "Inspect this",192 role: "user",
202 attachments: [193 text: "Inspect this",
203 {194 attachments: [
204 type: "image",195 {
205 id: "thread-attachments-att-1",196 type: "image",
206 name: "example.png",197 id: "thread-attachments-att-1",
207 mimeType: "image/png",198 name: "example.png",
208 sizeBytes: 5,199 mimeType: "image/png",
209 },200 sizeBytes: 5,
210 ],201 },
211 turnId: null,202 ],
212 streaming: false,203 turnId: null,
213 createdAt: now,204 streaming: false,
214 updatedAt: now,205 createdAt: now,
215 },206 updatedAt: now,
216 });207 },
208 });
217209
218 yield* projectionPipeline.bootstrap;210 yield* projectionPipeline.bootstrap;
219211
220 const rows = yield* sql<{212 const rows = yield* sql<{
221 readonly attachmentsJson: string | null;213 readonly attachmentsJson: string | null;
222 }>`214 }>`
223 SELECT215 SELECT
224 attachments_json AS "attachmentsJson"216 attachments_json AS "attachmentsJson"
225 FROM projection_thread_messages217 FROM projection_thread_messages
226 WHERE message_id = 'message-attachments'218 WHERE message_id = 'message-attachments'
227 `;219 `;
228 assert.equal(rows.length, 1);220 assert.equal(rows.length, 1);
229 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [221 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
230 {222 {
231 type: "image",223 type: "image",
232 id: "thread-attachments-att-1",224 id: "thread-attachments-att-1",
233 name: "example.png",225 name: "example.png",
234 mimeType: "image/png",226 mimeType: "image/png",
235 sizeBytes: 5,227 sizeBytes: 5,
236 },228 },
237 ]);229 ]);
238 }).pipe(230 }),
239 (effect) => runWithProjectionPipelineLayer(stateDir, effect),231 );
240 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),232 },
241 ),233);
242 ),
243 ),
244 );
245234
246 it.effect("preserves mixed image attachment metadata as-is", () =>235it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-safe-")))(
247 Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe(236 "OrchestrationProjectionPipeline",
248 Effect.flatMap((stateDir) =>237 (it) => {
249 Effect.gen(function* () {238 it.effect("preserves mixed image attachment metadata as-is", () =>
250 const projectionPipeline = yield* OrchestrationProjectionPipeline;239 Effect.gen(function* () {
251 const eventStore = yield* OrchestrationEventStore;240 const projectionPipeline = yield* OrchestrationProjectionPipeline;
252 const sql = yield* SqlClient.SqlClient;241 const eventStore = yield* OrchestrationEventStore;
253 const now = new Date().toISOString();242 const sql = yield* SqlClient.SqlClient;
254243 const now = new Date().toISOString();
255 yield* eventStore.append({244
256 type: "thread.message-sent",245 yield* eventStore.append({
257 eventId: EventId.makeUnsafe("evt-attachments-safe"),246 type: "thread.message-sent",
258 aggregateKind: "thread",247 eventId: EventId.makeUnsafe("evt-attachments-safe"),
259 aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"),248 aggregateKind: "thread",
260 occurredAt: now,249 aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"),
261 commandId: CommandId.makeUnsafe("cmd-attachments-safe"),250 occurredAt: now,
262 causationEventId: null,251 commandId: CommandId.makeUnsafe("cmd-attachments-safe"),
263 correlationId: CommandId.makeUnsafe("cmd-attachments-safe"),252 causationEventId: null,
264 metadata: {},253 correlationId: CommandId.makeUnsafe("cmd-attachments-safe"),
265 payload: {254 metadata: {},
266 threadId: ThreadId.makeUnsafe("thread-attachments-safe"),255 payload: {
267 messageId: MessageId.makeUnsafe("message-attachments-safe"),256 threadId: ThreadId.makeUnsafe("thread-attachments-safe"),
268 role: "user",257 messageId: MessageId.makeUnsafe("message-attachments-safe"),
269 text: "Inspect this",258 role: "user",
270 attachments: [259 text: "Inspect this",
271 {260 attachments: [
272 type: "image",261 {
273 id: "thread-attachments-safe-att-1",262 type: "image",
274 name: "untrusted.exe",263 id: "thread-attachments-safe-att-1",
275 mimeType: "image/x-unknown",264 name: "untrusted.exe",
276 sizeBytes: 5,265 mimeType: "image/x-unknown",
277 },266 sizeBytes: 5,
278 {267 },
279 type: "image",268 {
280 id: "thread-attachments-safe-att-2",269 type: "image",
281 name: "not-image.png",270 id: "thread-attachments-safe-att-2",
282 mimeType: "image/png",271 name: "not-image.png",
283 sizeBytes: 5,272 mimeType: "image/png",
284 },273 sizeBytes: 5,
285 ],274 },
286 turnId: null,275 ],
287 streaming: false,276 turnId: null,
288 createdAt: now,277 streaming: false,
289 updatedAt: now,278 createdAt: now,
290 },279 updatedAt: now,
291 });280 },
281 });
292282
293 yield* projectionPipeline.bootstrap;283 yield* projectionPipeline.bootstrap;
294284
295 const rows = yield* sql<{285 const rows = yield* sql<{
296 readonly attachmentsJson: string | null;286 readonly attachmentsJson: string | null;
297 }>`287 }>`
298 SELECT288 SELECT
299 attachments_json AS "attachmentsJson"289 attachments_json AS "attachmentsJson"
300 FROM projection_thread_messages290 FROM projection_thread_messages
301 WHERE message_id = 'message-attachments-safe'291 WHERE message_id = 'message-attachments-safe'
302 `;292 `;
303 assert.equal(rows.length, 1);293 assert.equal(rows.length, 1);
304 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [294 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
305 {295 {
306 type: "image",296 type: "image",
307 id: "thread-attachments-safe-att-1",297 id: "thread-attachments-safe-att-1",
308 name: "untrusted.exe",298 name: "untrusted.exe",
309 mimeType: "image/x-unknown",299 mimeType: "image/x-unknown",
310 sizeBytes: 5,300 sizeBytes: 5,
311 },301 },
312 {302 {
313 type: "image",303 type: "image",
314 id: "thread-attachments-safe-att-2",304 id: "thread-attachments-safe-att-2",
315 name: "not-image.png",305 name: "not-image.png",
316 mimeType: "image/png",306 mimeType: "image/png",
317 sizeBytes: 5,307 sizeBytes: 5,
318 },308 },
319 ]);309 ]);
320 }).pipe(310 }),
321 (effect) => runWithProjectionPipelineLayer(stateDir, effect),311 );
322 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),312 },
323 ),313);
324 ),
325 ),
326 );
327314
315it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
328 it.effect(316 it.effect(
329 "passes explicit empty attachment arrays through the projection pipeline to clear attachments",317 "passes explicit empty attachment arrays through the projection pipeline to clear attachments",
330 () =>318 () =>
...@@ -447,136 +435,110 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -447,136 +435,110 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
447 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), []);435 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), []);
448 }),436 }),
449 );437 );
438});
450439
440it.layer(
441 Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-overwrite-")),
442)("OrchestrationProjectionPipeline", (it) => {
451 it.effect("overwrites stored attachment references when a message updates attachments", () =>443 it.effect("overwrites stored attachment references when a message updates attachments", () =>
452 Effect.sync(() =>444 Effect.gen(function* () {
453 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")),445 const projectionPipeline = yield* OrchestrationProjectionPipeline;
454 ).pipe(446 const eventStore = yield* OrchestrationEventStore;
455 Effect.flatMap((stateDir) =>447 const sql = yield* SqlClient.SqlClient;
456 Effect.gen(function* () {448 const now = new Date().toISOString();
457 const projectionPipeline = yield* OrchestrationProjectionPipeline;449 const later = new Date(Date.now() + 1_000).toISOString();
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 });
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<{472 yield* eventStore.append({
572 readonly attachmentsJson: string | null;473 type: "thread.created",
573 }>`474 eventId: EventId.makeUnsafe("evt-overwrite-2"),
574 SELECT attachments_json AS "attachmentsJson"475 aggregateKind: "thread",
575 FROM projection_thread_messages476 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
576 WHERE message_id = 'message-overwrite'477 occurredAt: now,
577 `;478 commandId: CommandId.makeUnsafe("cmd-overwrite-2"),
578 assert.equal(rows.length, 1);479 causationEventId: null,
579 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "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: [
580 {542 {
581 type: "image",543 type: "image",
582 id: "thread-overwrite-att-2",544 id: "thread-overwrite-att-2",
...@@ -584,76 +546,98 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -584,76 +546,98 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
584 mimeType: "image/png",546 mimeType: "image/png",
585 sizeBytes: 5,547 sizeBytes: 5,
586 },548 },
587 ]);549 ],
588 }).pipe(550 turnId: null,
589 (effect) => runWithProjectionPipelineLayer(stateDir, effect),551 streaming: false,
590 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),552 createdAt: now,
591 ),553 updatedAt: later,
592 ),554 },
593 ),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 }),
594 );577 );
578});
595579
580it.layer(
581 Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-rollback-")),
582)("OrchestrationProjectionPipeline", (it) => {
596 it.effect("does not persist attachment files when projector transaction rolls back", () =>583 it.effect("does not persist attachment files when projector transaction rolls back", () =>
597 Effect.sync(() =>584 Effect.gen(function* () {
598 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-rollback-")),585 const projectionPipeline = yield* OrchestrationProjectionPipeline;
599 ).pipe(586 const eventStore = yield* OrchestrationEventStore;
600 Effect.flatMap((stateDir) =>587 const path = yield* Path.Path;
601 Effect.gen(function* () {588 const sql = yield* SqlClient.SqlClient;
602 const projectionPipeline = yield* OrchestrationProjectionPipeline;589 const now = new Date().toISOString();
603 const eventStore = yield* OrchestrationEventStore;590
604 const sql = yield* SqlClient.SqlClient;591 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
605 const now = new Date().toISOString();592 eventStore
606593 .append(event)
607 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>594 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
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 });
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`
657 CREATE TRIGGER fail_thread_messages_projection_state_update641 CREATE TRIGGER fail_thread_messages_projection_state_update
658 BEFORE UPDATE ON projection_state642 BEFORE UPDATE ON projection_state
659 WHEN NEW.projector = 'projection.thread-messages'643 WHEN NEW.projector = 'projection.thread-messages'
...@@ -662,453 +646,437 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -662,453 +646,437 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
662 END;646 END;
663 `;647 `;
664648
665 const result = yield* Effect.result(649 const result = yield* Effect.result(
666 appendAndProject({650 appendAndProject({
667 type: "thread.message-sent",651 type: "thread.message-sent",
668 eventId: EventId.makeUnsafe("evt-rollback-3"),652 eventId: EventId.makeUnsafe("evt-rollback-3"),
669 aggregateKind: "thread",653 aggregateKind: "thread",
670 aggregateId: ThreadId.makeUnsafe("thread-rollback"),654 aggregateId: ThreadId.makeUnsafe("thread-rollback"),
671 occurredAt: now,655 occurredAt: now,
672 commandId: CommandId.makeUnsafe("cmd-rollback-3"),656 commandId: CommandId.makeUnsafe("cmd-rollback-3"),
673 causationEventId: null,657 causationEventId: null,
674 correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"),658 correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"),
675 metadata: {},659 metadata: {},
676 payload: {660 payload: {
677 threadId: ThreadId.makeUnsafe("thread-rollback"),661 threadId: ThreadId.makeUnsafe("thread-rollback"),
678 messageId: MessageId.makeUnsafe("message-rollback"),662 messageId: MessageId.makeUnsafe("message-rollback"),
679 role: "user",663 role: "user",
680 text: "Rollback me",664 text: "Rollback me",
681 attachments: [665 attachments: [
682 {666 {
683 type: "image",667 type: "image",
684 id: "thread-rollback-att-1",668 id: "thread-rollback-att-1",
685 name: "rollback.png",669 name: "rollback.png",
686 mimeType: "image/png",670 mimeType: "image/png",
687 sizeBytes: 5,671 sizeBytes: 5,
688 },
689 ],
690 turnId: null,
691 streaming: false,
692 createdAt: now,
693 updatedAt: now,
694 },672 },
695 }),673 ],
696 );674 turnId: null,
697 assert.equal(result._tag, "Failure");675 streaming: false,
676 createdAt: now,
677 updatedAt: now,
678 },
679 }),
680 );
681 assert.equal(result._tag, "Failure");
698682
699 const rows = yield* sql<{683 const rows = yield* sql<{
700 readonly count: number;684 readonly count: number;
701 }>`685 }>`
702 SELECT COUNT(*) AS "count"686 SELECT COUNT(*) AS "count"
703 FROM projection_thread_messages687 FROM projection_thread_messages
704 WHERE message_id = 'message-rollback'688 WHERE message_id = 'message-rollback'
705 `;689 `;
706 assert.equal(rows[0]?.count ?? 0, 0);690 assert.equal(rows[0]?.count ?? 0, 0);
707691
708 const attachmentPath = path.join(stateDir, "attachments", "thread-rollback-att-1.png");692 const { attachmentsDir } = yield* ServerConfig;
709 assert.equal(fs.existsSync(attachmentPath), false);693 const attachmentPath = path.join(attachmentsDir, "thread-rollback-att-1.png");
710 yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`;694 assert.isFalse(yield* exists(attachmentPath));
711 }).pipe(695 yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`;
712 (effect) => runWithProjectionPipelineLayer(stateDir, effect),696 }),
713 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
714 ),
715 ),
716 ),
717 );697 );
698});
718699
700it.layer(
701 Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-overwrite-")),
702)("OrchestrationProjectionPipeline", (it) => {
719 it.effect("removes unreferenced attachment files when a thread is reverted", () =>703 it.effect("removes unreferenced attachment files when a thread is reverted", () =>
720 Effect.sync(() =>704 Effect.gen(function* () {
721 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-revert-")),705 const fileSystem = yield* FileSystem.FileSystem;
722 ).pipe(706 const path = yield* Path.Path;
723 Effect.flatMap((stateDir) =>707 const projectionPipeline = yield* OrchestrationProjectionPipeline;
724 Effect.gen(function* () {708 const eventStore = yield* OrchestrationEventStore;
725 const projectionPipeline = yield* OrchestrationProjectionPipeline;709 const { attachmentsDir } = yield* ServerConfig;
726 const eventStore = yield* OrchestrationEventStore;710 const now = new Date().toISOString();
727 const now = new Date().toISOString();711 const threadId = ThreadId.makeUnsafe("Thread Revert.Files");
728 const threadId = ThreadId.makeUnsafe("Thread Revert.Files");712 const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001";
729 const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001";713 const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002";
730 const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002";714 const otherThreadAttachmentId =
731 const otherThreadAttachmentId =715 "thread-revert-files-extra-00000000-0000-4000-8000-000000000003";
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 );
934716
935 it.effect("removes thread attachment directory when thread is deleted", () =>717 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
936 Effect.sync(() =>718 eventStore
937 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-delete-")),719 .append(event)
938 ).pipe(720 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
939 Effect.flatMap((stateDir) =>721
940 Effect.gen(function* () {722 yield* appendAndProject({
941 const projectionPipeline = yield* OrchestrationProjectionPipeline;723 type: "project.created",
942 const eventStore = yield* OrchestrationEventStore;724 eventId: EventId.makeUnsafe("evt-revert-files-1"),
943 const now = new Date().toISOString();725 aggregateKind: "project",
944 const threadId = ThreadId.makeUnsafe("Thread Delete.Files");726 aggregateId: ProjectId.makeUnsafe("project-revert-files"),
945 const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001";727 occurredAt: now,
946 const otherThreadAttachmentId =728 commandId: CommandId.makeUnsafe("cmd-revert-files-1"),
947 "thread-delete-files-extra-00000000-0000-4000-8000-000000000002";729 causationEventId: null,
948730 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"),
949 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>731 metadata: {},
950 eventStore732 payload: {
951 .append(event)733 projectId: ProjectId.makeUnsafe("project-revert-files"),
952 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));734 title: "Project Revert Files",
953735 workspaceRoot: "/tmp/project-revert-files",
954 yield* appendAndProject({736 defaultModel: null,
955 type: "project.created",737 scripts: [],
956 eventId: EventId.makeUnsafe("evt-delete-files-1"),738 createdAt: now,
957 aggregateKind: "project",739 updatedAt: now,
958 aggregateId: ProjectId.makeUnsafe("project-delete-files"),740 },
959 occurredAt: now,741 });
960 commandId: CommandId.makeUnsafe("cmd-delete-files-1"),742
961 causationEventId: null,743 yield* appendAndProject({
962 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"),744 type: "thread.created",
963 metadata: {},745 eventId: EventId.makeUnsafe("evt-revert-files-2"),
964 payload: {746 aggregateKind: "thread",
965 projectId: ProjectId.makeUnsafe("project-delete-files"),747 aggregateId: threadId,
966 title: "Project Delete Files",748 occurredAt: now,
967 workspaceRoot: "/tmp/project-delete-files",749 commandId: CommandId.makeUnsafe("cmd-revert-files-2"),
968 defaultModel: null,750 causationEventId: null,
969 scripts: [],751 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"),
970 createdAt: now,752 metadata: {},
971 updatedAt: now,753 payload: {
972 },754 threadId,
973 });755 projectId: ProjectId.makeUnsafe("project-revert-files"),
974756 title: "Thread Revert Files",
975 yield* appendAndProject({757 model: "gpt-5-codex",
976 type: "thread.created",758 runtimeMode: "full-access",
977 eventId: EventId.makeUnsafe("evt-delete-files-2"),759 branch: null,
978 aggregateKind: "thread",760 worktreePath: null,
979 aggregateId: threadId,761 createdAt: now,
980 occurredAt: now,762 updatedAt: now,
981 commandId: CommandId.makeUnsafe("cmd-delete-files-2"),763 },
982 causationEventId: null,764 });
983 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"),765
984 metadata: {},766 yield* appendAndProject({
985 payload: {767 type: "thread.turn-diff-completed",
986 threadId,768 eventId: EventId.makeUnsafe("evt-revert-files-3"),
987 projectId: ProjectId.makeUnsafe("project-delete-files"),769 aggregateKind: "thread",
988 title: "Thread Delete Files",770 aggregateId: threadId,
989 model: "gpt-5-codex",771 occurredAt: now,
990 runtimeMode: "full-access",772 commandId: CommandId.makeUnsafe("cmd-revert-files-3"),
991 branch: null,773 causationEventId: null,
992 worktreePath: null,774 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"),
993 createdAt: now,775 metadata: {},
994 updatedAt: now,776 payload: {
995 },777 threadId,
996 });778 turnId: TurnId.makeUnsafe("turn-keep"),
997779 checkpointTurnCount: 1,
998 yield* appendAndProject({780 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"),
999 type: "thread.message-sent",781 status: "ready",
1000 eventId: EventId.makeUnsafe("evt-delete-files-3"),782 files: [],
1001 aggregateKind: "thread",783 assistantMessageId: MessageId.makeUnsafe("message-keep"),
1002 aggregateId: threadId,784 completedAt: now,
1003 occurredAt: now,785 },
1004 commandId: CommandId.makeUnsafe("cmd-delete-files-3"),786 });
1005 causationEventId: null,787
1006 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"),788 yield* appendAndProject({
1007 metadata: {},789 type: "thread.message-sent",
1008 payload: {790 eventId: EventId.makeUnsafe("evt-revert-files-4"),
1009 threadId,791 aggregateKind: "thread",
1010 messageId: MessageId.makeUnsafe("message-delete-files"),792 aggregateId: threadId,
1011 role: "user",793 occurredAt: now,
1012 text: "Delete",794 commandId: CommandId.makeUnsafe("cmd-revert-files-4"),
1013 attachments: [795 causationEventId: null,
1014 {796 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"),
1015 type: "image",797 metadata: {},
1016 id: attachmentId,798 payload: {
1017 name: "delete.png",799 threadId,
1018 mimeType: "image/png",800 messageId: MessageId.makeUnsafe("message-keep"),
1019 sizeBytes: 5,801 role: "assistant",
1020 },802 text: "Keep",
1021 ],803 attachments: [
1022 turnId: null,804 {
1023 streaming: false,805 type: "image",
1024 createdAt: now,806 id: keepAttachmentId,
1025 updatedAt: now,807 name: "keep.png",
1026 },808 mimeType: "image/png",
1027 });809 sizeBytes: 5,
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,
1054 },810 },
1055 });811 ],
1056812 turnId: TurnId.makeUnsafe("turn-keep"),
1057 assert.equal(fs.existsSync(threadAttachmentPath), false);813 streaming: false,
1058 assert.equal(fs.existsSync(otherThreadAttachmentPath), true);814 createdAt: now,
1059 }).pipe(815 updatedAt: now,
1060 (effect) => runWithProjectionPipelineLayer(stateDir, effect),816 },
1061 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),817 });
1062 ),818
1063 ),819 yield* appendAndProject({
1064 ),820 type: "thread.turn-diff-completed",
1065 );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", () =>841 yield* appendAndProject({
1068 Effect.sync(() =>842 type: "thread.message-sent",
1069 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-")),843 eventId: EventId.makeUnsafe("evt-revert-files-6"),
1070 ).pipe(844 aggregateKind: "thread",
1071 Effect.flatMap((stateDir) =>845 aggregateId: threadId,
1072 Effect.gen(function* () {846 occurredAt: now,
1073 const projectionPipeline = yield* OrchestrationProjectionPipeline;847 commandId: CommandId.makeUnsafe("cmd-revert-files-6"),
1074 const eventStore = yield* OrchestrationEventStore;848 causationEventId: null,
1075 const now = new Date().toISOString();849 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"),
1076 const attachmentsRootDir = path.join(stateDir, "attachments");850 metadata: {},
1077 const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt");851 payload: {
1078 const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt");852 threadId,
1079 fs.mkdirSync(attachmentsRootDir, { recursive: true });853 messageId: MessageId.makeUnsafe("message-remove"),
1080 fs.writeFileSync(attachmentsSentinelPath, "keep-attachments-root", "utf8");854 role: "assistant",
1081 fs.writeFileSync(stateDirSentinelPath, "keep-state-dir", "utf8");855 text: "Remove",
1082856 attachments: [
1083 yield* eventStore.append({857 {
1084 type: "thread.deleted",858 type: "image",
1085 eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"),859 id: removeAttachmentId,
1086 aggregateKind: "thread",860 name: "remove.png",
1087 aggregateId: ThreadId.makeUnsafe(".."),861 mimeType: "image/png",
1088 occurredAt: now,862 sizeBytes: 5,
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,
1096 },863 },
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);883 yield* appendAndProject({
1102 assert.equal(fs.existsSync(attachmentsSentinelPath), true);884 type: "thread.reverted",
1103 assert.equal(fs.existsSync(stateDirSentinelPath), true);885 eventId: EventId.makeUnsafe("evt-revert-files-7"),
1104 }).pipe(886 aggregateKind: "thread",
1105 (effect) => runWithProjectionPipelineLayer(stateDir, effect),887 aggregateId: threadId,
1106 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),888 occurredAt: now,
1107 ),889 commandId: CommandId.makeUnsafe("cmd-revert-files-7"),
1108 ),890 causationEventId: null,
1109 ),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 }),
1110 );903 );
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) => {
1112 it.effect("resumes from projector last_applied_sequence without replaying older events", () =>1080 it.effect("resumes from projector last_applied_sequence without replaying older events", () =>
1113 Effect.gen(function* () {1081 Effect.gen(function* () {
1114 const projectionPipeline = yield* OrchestrationProjectionPipeline;1082 const projectionPipeline = yield* OrchestrationProjectionPipeline;
...@@ -1714,8 +1682,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -1714,8 +1682,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
17141682
1715it.effect("restores pending turn-start metadata across projection pipeline restart", () =>1683it.effect("restores pending turn-start metadata across projection pipeline restart", () =>
1716 Effect.gen(function* () {1684 Effect.gen(function* () {
1717 const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-pipeline-restart-"));1685 const { dbPath } = yield* ServerConfig;
1718 const dbPath = path.join(tempDir, "orchestration.sqlite");
1719 const persistenceLayer = makeSqlitePersistenceLive(dbPath);1686 const persistenceLayer = makeSqlitePersistenceLive(dbPath);
1720 const firstProjectionLayer = OrchestrationProjectionPipelineLive.pipe(1687 const firstProjectionLayer = OrchestrationProjectionPipelineLive.pipe(
1721 Layer.provideMerge(OrchestrationEventStoreLive),1688 Layer.provideMerge(OrchestrationEventStoreLive),
...@@ -1830,11 +1797,14 @@ it.effect("restores pending turn-start metadata across projection pipeline resta...@@ -1830,11 +1797,14 @@ it.effect("restores pending turn-start metadata across projection pipeline resta
1830 startedAt: turnStartedAt,1797 startedAt: turnStartedAt,
1831 },1798 },
1832 ]);1799 ]);
1833
1834 fs.rmSync(tempDir, { recursive: true, force: true });
1835 }).pipe(1800 }).pipe(
1836 Effect.provide(1801 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 ),
1838 ),1808 ),
1839 ),1809 ),
1840);1810);
...@@ -1845,7 +1815,11 @@ const engineLayer = it.layer(...@@ -1845,7 +1815,11 @@ const engineLayer = it.layer(
1845 Layer.provide(OrchestrationEventStoreLive),1815 Layer.provide(OrchestrationEventStoreLive),
1846 Layer.provide(OrchestrationCommandReceiptRepositoryLive),1816 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
1847 Layer.provideMerge(SqlitePersistenceMemory),1817 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 ),
1849 Layer.provideMerge(NodeServices.layer),1823 Layer.provideMerge(NodeServices.layer),
1850 ),1824 ),
1851);1825);
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+1-1
...@@ -243,7 +243,7 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid...@@ -243,7 +243,7 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid
243 const fileSystem = yield* Effect.service(FileSystem.FileSystem);243 const fileSystem = yield* Effect.service(FileSystem.FileSystem);
244 const path = yield* Effect.service(Path.Path);244 const path = yield* Effect.service(Path.Path);
245245
246 const attachmentsRootDir = path.join(serverConfig.stateDir, "attachments");246 const attachmentsRootDir = serverConfig.attachmentsDir;
247247
248 yield* Effect.forEach(248 yield* Effect.forEach(
249 sideEffects.deletedThreadIds,249 sideEffects.deletedThreadIds,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+15-5
...@@ -16,7 +16,7 @@ import {...@@ -16,7 +16,7 @@ import {
16import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";16import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
17import { afterEach, describe, expect, it, vi } from "vitest";17import { afterEach, describe, expect, it, vi } from "vitest";
1818
19import { ServerConfig } from "../../config.ts";19import { deriveServerPaths, ServerConfig } from "../../config.ts";
20import { TextGenerationError } from "../../git/Errors.ts";20import { TextGenerationError } from "../../git/Errors.ts";
21import { ProviderAdapterRequestError } from "../../provider/Errors.ts";21import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
22import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";22import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
...@@ -41,6 +41,9 @@ const asApprovalRequestId = (value: string): ApprovalRequestId =>...@@ -41,6 +41,9 @@ const asApprovalRequestId = (value: string): ApprovalRequestId =>
41const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);41const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
42const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);42const 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
44async function waitFor(47async function waitFor(
45 predicate: () => boolean | Promise<boolean>,48 predicate: () => boolean | Promise<boolean>,
46 timeoutMs = 2000,49 timeoutMs = 2000,
...@@ -67,6 +70,7 @@ describe("ProviderCommandReactor", () => {...@@ -67,6 +70,7 @@ describe("ProviderCommandReactor", () => {
67 > | null = null;70 > | null = null;
68 let scope: Scope.Closeable | null = null;71 let scope: Scope.Closeable | null = null;
69 const createdStateDirs = new Set<string>();72 const createdStateDirs = new Set<string>();
73 const createdBaseDirs = new Set<string>();
7074
71 afterEach(async () => {75 afterEach(async () => {
72 if (scope) {76 if (scope) {
...@@ -81,16 +85,22 @@ describe("ProviderCommandReactor", () => {...@@ -81,16 +85,22 @@ describe("ProviderCommandReactor", () => {
81 fs.rmSync(stateDir, { recursive: true, force: true });85 fs.rmSync(stateDir, { recursive: true, force: true });
82 }86 }
83 createdStateDirs.clear();87 createdStateDirs.clear();
88 for (const baseDir of createdBaseDirs) {
89 fs.rmSync(baseDir, { recursive: true, force: true });
90 }
91 createdBaseDirs.clear();
84 });92 });
8593
86 async function createHarness(input?: {94 async function createHarness(input?: {
87 readonly stateDir?: string;95 readonly baseDir?: string;
88 readonly threadModel?: string;96 readonly threadModel?: string;
89 }) {97 }) {
90 const now = new Date().toISOString();98 const now = new Date().toISOString();
91 const stateDir = input?.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));99 const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));
92 const threadModel = input?.threadModel ?? "gpt-5-codex";100 createdBaseDirs.add(baseDir);
101 const { stateDir } = deriveServerPathsSync(baseDir, undefined);
93 createdStateDirs.add(stateDir);102 createdStateDirs.add(stateDir);
103 const threadModel = input?.threadModel ?? "gpt-5-codex";
94 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());104 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
95 let nextSessionIndex = 1;105 let nextSessionIndex = 1;
96 const runtimeSessions: Array<ProviderSession> = [];106 const runtimeSessions: Array<ProviderSession> = [];
...@@ -214,7 +224,7 @@ describe("ProviderCommandReactor", () => {...@@ -214,7 +224,7 @@ describe("ProviderCommandReactor", () => {
214 Layer.provideMerge(224 Layer.provideMerge(
215 Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape),225 Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape),
216 ),226 ),
217 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)),227 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)),
218 Layer.provideMerge(NodeServices.layer),228 Layer.provideMerge(NodeServices.layer),
219 );229 );
220 const runtime = ManagedRuntime.make(layer);230 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) {...@@ -27,10 +27,10 @@ export const expandHomePath = Effect.fn(function* (input: string) {
27 return input;27 return input;
28});28});
2929
30export const resolveStateDir = Effect.fn(function* (raw: string | undefined) {30export const resolveBaseDir = Effect.fn(function* (raw: string | undefined) {
31 const { join, resolve } = yield* Path.Path;31 const { join, resolve } = yield* Path.Path;
32 if (!raw || raw.trim().length === 0) {32 if (!raw || raw.trim().length === 0) {
33 return join(OS.homedir(), ".t3", "userdata");33 return join(OS.homedir(), ".t3");
34 }34 }
35 return resolve(yield* expandHomePath(raw.trim()));35 return resolve(yield* expandHomePath(raw.trim()));
36});36});
apps/server/src/persistence/Layers/Sqlite.ts+3-6
...@@ -49,9 +49,6 @@ export const SqlitePersistenceMemory = Layer.provideMerge(...@@ -49,9 +49,6 @@ export const SqlitePersistenceMemory = Layer.provideMerge(
49 makeRuntimeSqliteLayer({ filename: ":memory:" }),49 makeRuntimeSqliteLayer({ filename: ":memory:" }),
50);50);
5151
52export const layerConfig = Effect.gen(function* () {52export const layerConfig = Layer.unwrap(
53 const { stateDir } = yield* ServerConfig;53 Effect.map(Effect.service(ServerConfig), ({ dbPath }) => makeSqlitePersistenceLive(dbPath)),
54 const { join } = yield* Path.Path;54);
55 const dbPath = join(stateDir, "state.sqlite");
56 return makeSqlitePersistenceLive(dbPath);
57}).pipe(Layer.unwrap);
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+7-6
...@@ -133,7 +133,7 @@ function makeHarness(config?: {...@@ -133,7 +133,7 @@ function makeHarness(config?: {
133 readonly nativeEventLogPath?: string;133 readonly nativeEventLogPath?: string;
134 readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"];134 readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"];
135 readonly cwd?: string;135 readonly cwd?: string;
136 readonly stateDir?: string;136 readonly baseDir?: string;
137}) {137}) {
138 const query = new FakeClaudeQuery();138 const query = new FakeClaudeQuery();
139 let createInput:139 let createInput:
...@@ -165,7 +165,7 @@ function makeHarness(config?: {...@@ -165,7 +165,7 @@ function makeHarness(config?: {
165 Layer.provideMerge(165 Layer.provideMerge(
166 ServerConfig.layerTest(166 ServerConfig.layerTest(
167 config?.cwd ?? "/tmp/claude-adapter-test",167 config?.cwd ?? "/tmp/claude-adapter-test",
168 config?.stateDir ?? "/tmp",168 config?.baseDir ?? "/tmp",
169 ),169 ),
170 ),170 ),
171 Layer.provideMerge(NodeServices.layer),171 Layer.provideMerge(NodeServices.layer),
...@@ -515,15 +515,15 @@ describe("ClaudeAdapterLive", () => {...@@ -515,15 +515,15 @@ describe("ClaudeAdapterLive", () => {
515 });515 });
516516
517 it.effect("embeds image attachments in Claude user messages", () => {517 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-"));
519 const harness = makeHarness({519 const harness = makeHarness({
520 cwd: "/tmp/project-claude-attachments",520 cwd: "/tmp/project-claude-attachments",
521 stateDir,521 baseDir,
522 });522 });
523 return Effect.gen(function* () {523 return Effect.gen(function* () {
524 yield* Effect.addFinalizer(() =>524 yield* Effect.addFinalizer(() =>
525 Effect.sync(() =>525 Effect.sync(() =>
526 rmSync(stateDir, {526 rmSync(baseDir, {
527 recursive: true,527 recursive: true,
528 force: true,528 force: true,
529 }),529 }),
...@@ -531,6 +531,7 @@ describe("ClaudeAdapterLive", () => {...@@ -531,6 +531,7 @@ describe("ClaudeAdapterLive", () => {
531 );531 );
532532
533 const adapter = yield* ClaudeAdapter;533 const adapter = yield* ClaudeAdapter;
534 const { attachmentsDir } = yield* ServerConfig;
534535
535 const attachment = {536 const attachment = {
536 type: "image" as const,537 type: "image" as const,
...@@ -539,7 +540,7 @@ describe("ClaudeAdapterLive", () => {...@@ -539,7 +540,7 @@ describe("ClaudeAdapterLive", () => {
539 mimeType: "image/png",540 mimeType: "image/png",
540 sizeBytes: 4,541 sizeBytes: 4,
541 };542 };
542 const attachmentPath = path.join(stateDir, "attachments", attachmentRelativePath(attachment));543 const attachmentPath = path.join(attachmentsDir, attachmentRelativePath(attachment));
543 mkdirSync(path.dirname(attachmentPath), { recursive: true });544 mkdirSync(path.dirname(attachmentPath), { recursive: true });
544 writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4]));545 writeFileSync(attachmentPath, Uint8Array.from([1, 2, 3, 4]));
545546
apps/server/src/provider/Layers/ClaudeAdapter.ts+3-3
...@@ -474,7 +474,7 @@ function buildUserMessageEffect(...@@ -474,7 +474,7 @@ function buildUserMessageEffect(
474 input: ProviderSendTurnInput,474 input: ProviderSendTurnInput,
475 dependencies: {475 dependencies: {
476 readonly fileSystem: FileSystem.FileSystem;476 readonly fileSystem: FileSystem.FileSystem;
477 readonly stateDir: string;477 readonly attachmentsDir: string;
478 },478 },
479): Effect.Effect<SDKUserMessage, ProviderAdapterRequestError> {479): Effect.Effect<SDKUserMessage, ProviderAdapterRequestError> {
480 return Effect.gen(function* () {480 return Effect.gen(function* () {
...@@ -499,7 +499,7 @@ function buildUserMessageEffect(...@@ -499,7 +499,7 @@ function buildUserMessageEffect(
499 }499 }
500500
501 const attachmentPath = resolveAttachmentPath({501 const attachmentPath = resolveAttachmentPath({
502 stateDir: dependencies.stateDir,502 attachmentsDir: dependencies.attachmentsDir,
503 attachment,503 attachment,
504 });504 });
505 if (!attachmentPath) {505 if (!attachmentPath) {
...@@ -2765,7 +2765,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2765,7 +2765,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
27652765
2766 const message = yield* buildUserMessageEffect(input, {2766 const message = yield* buildUserMessageEffect(input, {
2767 fileSystem,2767 fileSystem,
2768 stateDir: serverConfig.stateDir,2768 attachmentsDir: serverConfig.attachmentsDir,
2769 });2769 });
27702770
2771 yield* Queue.offer(context.promptQueue, {2771 yield* Queue.offer(context.promptQueue, {
apps/server/src/provider/Layers/CodexAdapter.ts+1-1
...@@ -1336,7 +1336,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1336,7 +1336,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1336 (attachment) =>1336 (attachment) =>
1337 Effect.gen(function* () {1337 Effect.gen(function* () {
1338 const attachmentPath = resolveAttachmentPath({1338 const attachmentPath = resolveAttachmentPath({
1339 stateDir: serverConfig.stateDir,1339 attachmentsDir: serverConfig.attachmentsDir,
1340 attachment,1340 attachment,
1341 });1341 });
1342 if (!attachmentPath) {1342 if (!attachmentPath) {
apps/server/src/serverLayers.ts+1-5
...@@ -1,5 +1,3 @@...@@ -1,5 +1,3 @@
1import path from "node:path";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
4import { Effect, FileSystem, Layer } from "effect";2import { Effect, FileSystem, Layer } from "effect";
5import * as SqlClient from "effect/unstable/sql/SqlClient";3import * as SqlClient from "effect/unstable/sql/SqlClient";
...@@ -44,9 +42,7 @@ export function makeServerProviderLayer(): Layer.Layer<...@@ -44,9 +42,7 @@ export function makeServerProviderLayer(): Layer.Layer<
44 SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService42 SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | AnalyticsService
45> {43> {
46 return Effect.gen(function* () {44 return Effect.gen(function* () {
47 const { stateDir } = yield* ServerConfig;45 const { providerEventLogPath } = yield* ServerConfig;
48 const providerLogsDir = path.join(stateDir, "logs", "provider");
49 const providerEventLogPath = path.join(providerLogsDir, "events.log");
50 const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {46 const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, {
51 stream: "native",47 stream: "native",
52 });48 });
apps/server/src/serverLogger.ts+3-7
...@@ -1,5 +1,4 @@...@@ -1,5 +1,4 @@
1import fs from "node:fs";1import fs from "node:fs";
2import path from "node:path";
32
4import { Effect, Logger } from "effect";3import { Effect, Logger } from "effect";
5import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
...@@ -7,16 +6,13 @@ import * as Layer from "effect/Layer";...@@ -7,16 +6,13 @@ import * as Layer from "effect/Layer";
7import { ServerConfig } from "./config";6import { ServerConfig } from "./config";
87
9export const ServerLoggerLive = Effect.gen(function* () {8export const ServerLoggerLive = Effect.gen(function* () {
10 const config = yield* ServerConfig;9 const { logsDir, serverLogPath } = yield* ServerConfig;
11
12 const logDir = path.join(config.stateDir, "logs");
13 const logPath = path.join(logDir, "server.log");
1410
15 yield* Effect.sync(() => {11 yield* Effect.sync(() => {
16 fs.mkdirSync(logDir, { recursive: true });12 fs.mkdirSync(logsDir, { recursive: true });
17 });13 });
1814
19 const fileLogger = Logger.formatSimple.pipe(Logger.toFile(logPath));15 const fileLogger = Logger.formatSimple.pipe(Logger.toFile(serverLogPath));
2016
21 return Logger.layer([Logger.defaultLogger, fileLogger], {17 return Logger.layer([Logger.defaultLogger, fileLogger], {
22 mergeWithExisting: false,18 mergeWithExisting: false,
apps/server/src/telemetry/Identify.ts+1-3
...@@ -56,10 +56,8 @@ const getClaudeUserId = Effect.gen(function* () {...@@ -56,10 +56,8 @@ const getClaudeUserId = Effect.gen(function* () {
5656
57const upsertAnonymousId = Effect.gen(function* () {57const upsertAnonymousId = Effect.gen(function* () {
58 const fileSystem = yield* FileSystem.FileSystem;58 const fileSystem = yield* FileSystem.FileSystem;
59 const path = yield* Path.Path;59 const { anonymousIdPath } = yield* ServerConfig;
60 const serverConfig = yield* ServerConfig;
6160
62 const anonymousIdPath = path.join(serverConfig.stateDir, "anonymous-id");
63 const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe(61 const anonymousId = yield* fileSystem.readFileString(anonymousIdPath).pipe(
64 Effect.catch(() =>62 Effect.catch(() =>
65 Effect.gen(function* () {63 Effect.gen(function* () {
apps/server/src/telemetry/Layers/AnalyticsService.test.ts+4-7
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";1import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";3import { assert, it } from "@effect/vitest";
4import { ConfigProvider, Effect, FileSystem, Layer } from "effect";4import { ConfigProvider, Effect, Layer } from "effect";
5import * as HttpServer from "effect/unstable/http/HttpServer";5import * as HttpServer from "effect/unstable/http/HttpServer";
6import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";6import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";7import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
...@@ -37,13 +37,10 @@ interface RecordedBatchBody {...@@ -37,13 +37,10 @@ interface RecordedBatchBody {
37it.layer(NodeServices.layer)("AnalyticsService test", (it) => {37it.layer(NodeServices.layer)("AnalyticsService test", (it) => {
38 it.effect("flush drains all buffered events across multiple batches", () =>38 it.effect("flush drains all buffered events across multiple batches", () =>
39 Effect.gen(function* () {39 Effect.gen(function* () {
40 const fileSystem = yield* FileSystem.FileSystem;
41 const stateDir = yield* fileSystem.makeTempDirectoryScoped({
42 prefix: "t3-telemetry-flush-",
43 });
44
45 const capturedRequests: Array<RecordedBatchRequest> = [];40 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
48 const telemetryLayer = AnalyticsServiceLayerLive.pipe(Layer.provideMerge(serverConfigLayer));45 const telemetryLayer = AnalyticsServiceLayerLive.pipe(Layer.provideMerge(serverConfigLayer));
49 const configLayer = ConfigProvider.layer(46 const configLayer = ConfigProvider.layer(
apps/server/src/terminal/Layers/Manager.ts+3-5
...@@ -13,7 +13,7 @@ import {...@@ -13,7 +13,7 @@ import {
13 type TerminalEvent,13 type TerminalEvent,
14 type TerminalSessionSnapshot,14 type TerminalSessionSnapshot,
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
16import { Effect, Encoding, Layer, Path, Schema } from "effect";16import { Effect, Encoding, Layer, Schema } from "effect";
1717
18import { createLogger } from "../../logger";18import { createLogger } from "../../logger";
19import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY";19import { PtyAdapter, PtyAdapterShape, type PtyExitEvent, type PtyProcess } from "../Services/PTY";
...@@ -1172,13 +1172,11 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>...@@ -1172,13 +1172,11 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
1172export const TerminalManagerLive = Layer.effect(1172export const TerminalManagerLive = Layer.effect(
1173 TerminalManager,1173 TerminalManager,
1174 Effect.gen(function* () {1174 Effect.gen(function* () {
1175 const { stateDir } = yield* ServerConfig;1175 const { terminalLogsDir } = yield* ServerConfig;
1176 const { join } = yield* Path.Path;
1177 const logsDir = join(stateDir, "logs", "terminals");
11781176
1179 const ptyAdapter = yield* PtyAdapter;1177 const ptyAdapter = yield* PtyAdapter;
1180 const runtime = yield* Effect.acquireRelease(1178 const runtime = yield* Effect.acquireRelease(
1181 Effect.sync(() => new TerminalManagerRuntime({ logsDir, ptyAdapter })),1179 Effect.sync(() => new TerminalManagerRuntime({ logsDir: terminalLogsDir, ptyAdapter })),
1182 (r) => Effect.sync(() => r.dispose()),1180 (r) => Effect.sync(() => r.dispose()),
1183 );1181 );
11841182
apps/server/src/wsServer.test.ts+62-42
...@@ -8,7 +8,7 @@ import { Effect, Exit, Layer, PlatformError, PubSub, Scope, Stream } from "effec...@@ -8,7 +8,7 @@ import { Effect, Exit, Layer, PlatformError, PubSub, Scope, Stream } from "effec
8import { describe, expect, it, afterEach, vi } from "vitest";8import { describe, expect, it, afterEach, vi } from "vitest";
9import { createServer } from "./wsServer";9import { createServer } from "./wsServer";
10import WebSocket from "ws";10import WebSocket from "ws";
11import { ServerConfig, type ServerConfigShape } from "./config";11import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config";
12import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";12import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers";
1313
14import {14import {
...@@ -451,6 +451,16 @@ function expectAvailableEditors(value: unknown): void {...@@ -451,6 +451,16 @@ function expectAvailableEditors(value: unknown): void {
451 }451 }
452}452}
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
454describe("WebSocket Server", () => {464describe("WebSocket Server", () => {
455 let server: Http.Server | null = null;465 let server: Http.Server | null = null;
456 let serverScope: Scope.Closeable | null = null;466 let serverScope: Scope.Closeable | null = null;
...@@ -474,7 +484,7 @@ describe("WebSocket Server", () => {...@@ -474,7 +484,7 @@ describe("WebSocket Server", () => {
474 logWebSocketEvents?: boolean;484 logWebSocketEvents?: boolean;
475 devUrl?: string;485 devUrl?: string;
476 authToken?: string;486 authToken?: string;
477 stateDir?: string;487 baseDir?: string;
478 staticDir?: string;488 staticDir?: string;
479 providerLayer?: Layer.Layer<ProviderService, never>;489 providerLayer?: Layer.Layer<ProviderService, never>;
480 providerHealth?: ProviderHealthShape;490 providerHealth?: ProviderHealthShape;
...@@ -488,7 +498,9 @@ describe("WebSocket Server", () => {...@@ -488,7 +498,9 @@ describe("WebSocket Server", () => {
488 throw new Error("Test server is already running");498 throw new Error("Test server is already running");
489 }499 }
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);
492 const scope = await Effect.runPromise(Scope.make("sequential"));504 const scope = await Effect.runPromise(Scope.make("sequential"));
493 const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;505 const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory;
494 const providerLayer = options.providerLayer ?? makeServerProviderLayer();506 const providerLayer = options.providerLayer ?? makeServerProviderLayer();
...@@ -502,10 +514,10 @@ describe("WebSocket Server", () => {...@@ -502,10 +514,10 @@ describe("WebSocket Server", () => {
502 port: 0,514 port: 0,
503 host: undefined,515 host: undefined,
504 cwd: options.cwd ?? "/test/project",516 cwd: options.cwd ?? "/test/project",
505 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),517 baseDir,
506 stateDir,518 ...derivedPaths,
507 staticDir: options.staticDir,519 staticDir: options.staticDir,
508 devUrl: options.devUrl ? new URL(options.devUrl) : undefined,520 devUrl,
509 noBrowser: true,521 noBrowser: true,
510 authToken: options.authToken,522 authToken: options.authToken,
511 autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false,523 autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false,
...@@ -590,12 +602,13 @@ describe("WebSocket Server", () => {...@@ -590,12 +602,13 @@ describe("WebSocket Server", () => {
590 });602 });
591603
592 it("serves persisted attachments from stateDir", async () => {604 it("serves persisted attachments from stateDir", async () => {
593 const stateDir = makeTempDir("t3code-state-attachments-");605 const baseDir = makeTempDir("t3code-state-attachments-");
594 const attachmentPath = path.join(stateDir, "attachments", "thread-a", "message-a", "0.png");606 const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined);
607 const attachmentPath = path.join(attachmentsDir, "thread-a", "message-a", "0.png");
595 fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });608 fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
596 fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment"));609 fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment"));
597610
598 server = await createTestServer({ cwd: "/test/project", stateDir });611 server = await createTestServer({ cwd: "/test/project", baseDir });
599 const addr = server.address();612 const addr = server.address();
600 const port = typeof addr === "object" && addr !== null ? addr.port : 0;613 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
601 expect(port).toBeGreaterThan(0);614 expect(port).toBeGreaterThan(0);
...@@ -608,10 +621,10 @@ describe("WebSocket Server", () => {...@@ -608,10 +621,10 @@ describe("WebSocket Server", () => {
608 });621 });
609622
610 it("serves persisted attachments for URL-encoded paths", async () => {623 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);
612 const attachmentPath = path.join(626 const attachmentPath = path.join(
613 stateDir,627 attachmentsDir,
614 "attachments",
615 "thread%20folder",628 "thread%20folder",
616 "message%20folder",629 "message%20folder",
617 "file%20name.png",630 "file%20name.png",
...@@ -619,7 +632,7 @@ describe("WebSocket Server", () => {...@@ -619,7 +632,7 @@ describe("WebSocket Server", () => {
619 fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });632 fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
620 fs.writeFileSync(attachmentPath, Buffer.from("hello-encoded-attachment"));633 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 });
623 const addr = server.address();636 const addr = server.address();
624 const port = typeof addr === "object" && addr !== null ? addr.port : 0;637 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
625 expect(port).toBeGreaterThan(0);638 expect(port).toBeGreaterThan(0);
...@@ -634,11 +647,11 @@ describe("WebSocket Server", () => {...@@ -634,11 +647,11 @@ describe("WebSocket Server", () => {
634 });647 });
635648
636 it("serves static index for root path", async () => {649 it("serves static index for root path", async () => {
637 const stateDir = makeTempDir("t3code-state-static-root-");650 const baseDir = makeTempDir("t3code-state-static-root-");
638 const staticDir = makeTempDir("t3code-static-root-");651 const staticDir = makeTempDir("t3code-static-root-");
639 fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>static-root</h1>", "utf8");652 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 });
642 const addr = server.address();655 const addr = server.address();
643 const port = typeof addr === "object" && addr !== null ? addr.port : 0;656 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
644 expect(port).toBeGreaterThan(0);657 expect(port).toBeGreaterThan(0);
...@@ -649,11 +662,11 @@ describe("WebSocket Server", () => {...@@ -649,11 +662,11 @@ describe("WebSocket Server", () => {
649 });662 });
650663
651 it("rejects static path traversal attempts", async () => {664 it("rejects static path traversal attempts", async () => {
652 const stateDir = makeTempDir("t3code-state-static-traversal-");665 const baseDir = makeTempDir("t3code-state-static-traversal-");
653 const staticDir = makeTempDir("t3code-static-traversal-");666 const staticDir = makeTempDir("t3code-static-traversal-");
654 fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>safe</h1>", "utf8");667 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 });
657 const addr = server.address();670 const addr = server.address();
658 const port = typeof addr === "object" && addr !== null ? addr.port : 0;671 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
659 expect(port).toBeGreaterThan(0);672 expect(port).toBeGreaterThan(0);
...@@ -731,15 +744,16 @@ describe("WebSocket Server", () => {...@@ -731,15 +744,16 @@ describe("WebSocket Server", () => {
731 });744 });
732745
733 it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => {746 it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => {
734 const stateDir = makeTempDir("t3code-state-bootstrap-existing-");747 const baseDir = makeTempDir("t3code-state-bootstrap-existing-");
735 const persistenceLayer = makeSqlitePersistenceLive(path.join(stateDir, "state.sqlite")).pipe(748 const { dbPath } = deriveServerPathsSync(baseDir, undefined);
749 const persistenceLayer = makeSqlitePersistenceLive(dbPath).pipe(
736 Layer.provide(NodeServices.layer),750 Layer.provide(NodeServices.layer),
737 );751 );
738 const cwd = "/test/bootstrap-existing";752 const cwd = "/test/bootstrap-existing";
739753
740 server = await createTestServer({754 server = await createTestServer({
741 cwd,755 cwd,
742 stateDir,756 baseDir,
743 persistenceLayer,757 persistenceLayer,
744 autoBootstrapProjectFromCwd: true,758 autoBootstrapProjectFromCwd: true,
745 });759 });
...@@ -762,7 +776,7 @@ describe("WebSocket Server", () => {...@@ -762,7 +776,7 @@ describe("WebSocket Server", () => {
762776
763 server = await createTestServer({777 server = await createTestServer({
764 cwd,778 cwd,
765 stateDir,779 baseDir,
766 persistenceLayer,780 persistenceLayer,
767 autoBootstrapProjectFromCwd: true,781 autoBootstrapProjectFromCwd: true,
768 });782 });
...@@ -811,11 +825,12 @@ describe("WebSocket Server", () => {...@@ -811,11 +825,12 @@ describe("WebSocket Server", () => {
811 });825 });
812826
813 it("responds to server.getConfig", async () => {827 it("responds to server.getConfig", async () => {
814 const stateDir = makeTempDir("t3code-state-get-config-");828 const baseDir = makeTempDir("t3code-state-get-config-");
815 const keybindingsPath = path.join(stateDir, "keybindings.json");829 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
830 ensureParentDir(keybindingsPath);
816 fs.writeFileSync(keybindingsPath, "[]", "utf8");831 fs.writeFileSync(keybindingsPath, "[]", "utf8");
817832
818 server = await createTestServer({ cwd: "/my/workspace", stateDir });833 server = await createTestServer({ cwd: "/my/workspace", baseDir });
819 const addr = server.address();834 const addr = server.address();
820 const port = typeof addr === "object" && addr !== null ? addr.port : 0;835 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
821836
...@@ -836,11 +851,11 @@ describe("WebSocket Server", () => {...@@ -836,11 +851,11 @@ describe("WebSocket Server", () => {
836 });851 });
837852
838 it("bootstraps default keybindings file when missing", async () => {853 it("bootstraps default keybindings file when missing", async () => {
839 const stateDir = makeTempDir("t3code-state-bootstrap-keybindings-");854 const baseDir = makeTempDir("t3code-state-bootstrap-keybindings-");
840 const keybindingsPath = path.join(stateDir, "keybindings.json");855 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
841 expect(fs.existsSync(keybindingsPath)).toBe(false);856 expect(fs.existsSync(keybindingsPath)).toBe(false);
842857
843 server = await createTestServer({ cwd: "/my/workspace", stateDir });858 server = await createTestServer({ cwd: "/my/workspace", baseDir });
844 const addr = server.address();859 const addr = server.address();
845 const port = typeof addr === "object" && addr !== null ? addr.port : 0;860 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
846861
...@@ -866,11 +881,12 @@ describe("WebSocket Server", () => {...@@ -866,11 +881,12 @@ describe("WebSocket Server", () => {
866 });881 });
867882
868 it("falls back to defaults and reports malformed keybindings config issues", async () => {883 it("falls back to defaults and reports malformed keybindings config issues", async () => {
869 const stateDir = makeTempDir("t3code-state-malformed-keybindings-");884 const baseDir = makeTempDir("t3code-state-malformed-keybindings-");
870 const keybindingsPath = path.join(stateDir, "keybindings.json");885 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
886 ensureParentDir(keybindingsPath);
871 fs.writeFileSync(keybindingsPath, "{ not-json", "utf8");887 fs.writeFileSync(keybindingsPath, "{ not-json", "utf8");
872888
873 server = await createTestServer({ cwd: "/my/workspace", stateDir });889 server = await createTestServer({ cwd: "/my/workspace", baseDir });
874 const addr = server.address();890 const addr = server.address();
875 const port = typeof addr === "object" && addr !== null ? addr.port : 0;891 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
876892
...@@ -897,8 +913,9 @@ describe("WebSocket Server", () => {...@@ -897,8 +913,9 @@ describe("WebSocket Server", () => {
897 });913 });
898914
899 it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => {915 it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => {
900 const stateDir = makeTempDir("t3code-state-partial-invalid-keybindings-");916 const baseDir = makeTempDir("t3code-state-partial-invalid-keybindings-");
901 const keybindingsPath = path.join(stateDir, "keybindings.json");917 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
918 ensureParentDir(keybindingsPath);
902 fs.writeFileSync(919 fs.writeFileSync(
903 keybindingsPath,920 keybindingsPath,
904 JSON.stringify([921 JSON.stringify([
...@@ -909,7 +926,7 @@ describe("WebSocket Server", () => {...@@ -909,7 +926,7 @@ describe("WebSocket Server", () => {
909 "utf8",926 "utf8",
910 );927 );
911928
912 server = await createTestServer({ cwd: "/my/workspace", stateDir });929 server = await createTestServer({ cwd: "/my/workspace", baseDir });
913 const addr = server.address();930 const addr = server.address();
914 const port = typeof addr === "object" && addr !== null ? addr.port : 0;931 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
915932
...@@ -948,11 +965,12 @@ describe("WebSocket Server", () => {...@@ -948,11 +965,12 @@ describe("WebSocket Server", () => {
948 });965 });
949966
950 it("pushes server.configUpdated issues when keybindings file changes", async () => {967 it("pushes server.configUpdated issues when keybindings file changes", async () => {
951 const stateDir = makeTempDir("t3code-state-keybindings-watch-");968 const baseDir = makeTempDir("t3code-state-keybindings-watch-");
952 const keybindingsPath = path.join(stateDir, "keybindings.json");969 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
970 ensureParentDir(keybindingsPath);
953 fs.writeFileSync(keybindingsPath, "[]", "utf8");971 fs.writeFileSync(keybindingsPath, "[]", "utf8");
954972
955 server = await createTestServer({ cwd: "/my/workspace", stateDir });973 server = await createTestServer({ cwd: "/my/workspace", baseDir });
956 const addr = server.address();974 const addr = server.address();
957 const port = typeof addr === "object" && addr !== null ? addr.port : 0;975 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
958976
...@@ -1008,8 +1026,9 @@ describe("WebSocket Server", () => {...@@ -1008,8 +1026,9 @@ describe("WebSocket Server", () => {
1008 });1026 });
10091027
1010 it("reads keybindings from the configured state directory", async () => {1028 it("reads keybindings from the configured state directory", async () => {
1011 const stateDir = makeTempDir("t3code-state-keybindings-");1029 const baseDir = makeTempDir("t3code-state-keybindings-");
1012 const keybindingsPath = path.join(stateDir, "keybindings.json");1030 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
1031 ensureParentDir(keybindingsPath);
1013 fs.writeFileSync(1032 fs.writeFileSync(
1014 keybindingsPath,1033 keybindingsPath,
1015 JSON.stringify([1034 JSON.stringify([
...@@ -1019,7 +1038,7 @@ describe("WebSocket Server", () => {...@@ -1019,7 +1038,7 @@ describe("WebSocket Server", () => {
1019 ]),1038 ]),
1020 "utf8",1039 "utf8",
1021 );1040 );
1022 server = await createTestServer({ cwd: "/my/workspace", stateDir });1041 server = await createTestServer({ cwd: "/my/workspace", baseDir });
1023 const addr = server.address();1042 const addr = server.address();
1024 const port = typeof addr === "object" && addr !== null ? addr.port : 0;1043 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
10251044
...@@ -1043,15 +1062,16 @@ describe("WebSocket Server", () => {...@@ -1043,15 +1062,16 @@ describe("WebSocket Server", () => {
1043 });1062 });
10441063
1045 it("upserts keybinding rules and updates cached server config", async () => {1064 it("upserts keybinding rules and updates cached server config", async () => {
1046 const stateDir = makeTempDir("t3code-state-upsert-keybinding-");1065 const baseDir = makeTempDir("t3code-state-upsert-keybinding-");
1047 const keybindingsPath = path.join(stateDir, "keybindings.json");1066 const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined);
1067 ensureParentDir(keybindingsPath);
1048 fs.writeFileSync(1068 fs.writeFileSync(
1049 keybindingsPath,1069 keybindingsPath,
1050 JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]),1070 JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]),
1051 "utf8",1071 "utf8",
1052 );1072 );
10531073
1054 server = await createTestServer({ cwd: "/my/workspace", stateDir });1074 server = await createTestServer({ cwd: "/my/workspace", baseDir });
1055 const addr = server.address();1075 const addr = server.address();
1056 const port = typeof addr === "object" && addr !== null ? addr.port : 0;1076 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<...@@ -370,7 +370,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
370 };370 };
371371
372 const attachmentPath = resolveAttachmentPath({372 const attachmentPath = resolveAttachmentPath({
373 stateDir: serverConfig.stateDir,373 attachmentsDir: serverConfig.attachmentsDir,
374 attachment: persistedAttachment,374 attachment: persistedAttachment,
375 });375 });
376 if (!attachmentPath) {376 if (!attachmentPath) {
...@@ -440,11 +440,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -440,11 +440,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
440 !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes(".");440 !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes(".");
441 const filePath = isIdLookup441 const filePath = isIdLookup
442 ? resolveAttachmentPathById({442 ? resolveAttachmentPathById({
443 stateDir: serverConfig.stateDir,443 attachmentsDir: serverConfig.attachmentsDir,
444 attachmentId: normalizedRelativePath,444 attachmentId: normalizedRelativePath,
445 })445 })
446 : resolveAttachmentRelativePath({446 : resolveAttachmentRelativePath({
447 stateDir: serverConfig.stateDir,447 attachmentsDir: serverConfig.attachmentsDir,
448 relativePath: normalizedRelativePath,448 relativePath: normalizedRelativePath,
449 });449 });
450 if (!filePath) {450 if (!filePath) {
scripts/dev-runner.test.ts+42-24
...@@ -1,10 +1,10 @@...@@ -1,10 +1,10 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { homedir } from "node:os";
2import { resolve } from "node:path";3import { resolve } from "node:path";
3import { assert, describe, it } from "@effect/vitest";4import { assert, describe, it } from "@effect/vitest";
4import { Effect } from "effect";5import { Effect } from "effect";
56
6import {7import {
7 DEFAULT_DEV_STATE_DIR,
8 createDevRunnerEnv,8 createDevRunnerEnv,
9 findFirstAvailableOffset,9 findFirstAvailableOffset,
10 resolveModePortOffsets,10 resolveModePortOffsets,
...@@ -46,27 +46,24 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -46,27 +46,24 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
46 });46 });
4747
48 describe("createDevRunnerEnv", () => {48 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", () =>
50 Effect.gen(function* () {50 Effect.gen(function* () {
51 const [env, defaultStateDir] = yield* Effect.all([51 const env = yield* createDevRunnerEnv({
52 createDevRunnerEnv({52 mode: "dev",
53 mode: "dev",53 baseEnv: {},
54 baseEnv: {},54 serverOffset: 0,
55 serverOffset: 0,55 webOffset: 0,
56 webOffset: 0,56 t3Home: undefined,
57 stateDir: undefined,57 authToken: undefined,
58 authToken: undefined,58 noBrowser: undefined,
59 noBrowser: undefined,59 autoBootstrapProjectFromCwd: undefined,
60 autoBootstrapProjectFromCwd: undefined,60 logWebSocketEvents: undefined,
61 logWebSocketEvents: undefined,61 host: undefined,
62 host: undefined,62 port: undefined,
63 port: undefined,63 devUrl: undefined,
64 devUrl: undefined,64 });
65 }),
66 DEFAULT_DEV_STATE_DIR,
67 ]);
6865
69 assert.equal(env.T3CODE_STATE_DIR, defaultStateDir);66 assert.equal(env.T3CODE_HOME, resolve(homedir(), ".t3"));
70 }),67 }),
71 );68 );
7269
...@@ -77,7 +74,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -77,7 +74,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
77 baseEnv: {},74 baseEnv: {},
78 serverOffset: 0,75 serverOffset: 0,
79 webOffset: 0,76 webOffset: 0,
80 stateDir: "/tmp/override-state",77 t3Home: "/tmp/custom-t3",
81 authToken: "secret",78 authToken: "secret",
82 noBrowser: true,79 noBrowser: true,
83 autoBootstrapProjectFromCwd: false,80 autoBootstrapProjectFromCwd: false,
...@@ -87,7 +84,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -87,7 +84,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
87 devUrl: new URL("http://localhost:7331"),84 devUrl: new URL("http://localhost:7331"),
88 });85 });
8986
90 assert.equal(env.T3CODE_STATE_DIR, resolve("/tmp/override-state"));87 assert.equal(env.T3CODE_HOME, resolve("/tmp/custom-t3"));
91 assert.equal(env.T3CODE_PORT, "4222");88 assert.equal(env.T3CODE_PORT, "4222");
92 assert.equal(env.VITE_WS_URL, "ws://localhost:4222");89 assert.equal(env.VITE_WS_URL, "ws://localhost:4222");
93 assert.equal(env.T3CODE_NO_BROWSER, "1");90 assert.equal(env.T3CODE_NO_BROWSER, "1");
...@@ -107,7 +104,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -107,7 +104,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
107 },104 },
108 serverOffset: 0,105 serverOffset: 0,
109 webOffset: 0,106 webOffset: 0,
110 stateDir: undefined,107 t3Home: undefined,
111 authToken: undefined,108 authToken: undefined,
112 noBrowser: undefined,109 noBrowser: undefined,
113 autoBootstrapProjectFromCwd: undefined,110 autoBootstrapProjectFromCwd: undefined,
...@@ -129,7 +126,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -129,7 +126,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
129 baseEnv: {},126 baseEnv: {},
130 serverOffset: 0,127 serverOffset: 0,
131 webOffset: 0,128 webOffset: 0,
132 stateDir: undefined,129 t3Home: undefined,
133 authToken: undefined,130 authToken: undefined,
134 noBrowser: undefined,131 noBrowser: undefined,
135 autoBootstrapProjectFromCwd: undefined,132 autoBootstrapProjectFromCwd: undefined,
...@@ -142,6 +139,27 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {...@@ -142,6 +139,27 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
142 assert.equal(env.T3CODE_LOG_WS_EVENTS, "0");139 assert.equal(env.T3CODE_LOG_WS_EVENTS, "0");
143 }),140 }),
144 );141 );
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 );
145 });163 });
146164
147 describe("findFirstAvailableOffset", () => {165 describe("findFirstAvailableOffset", () => {
scripts/dev-runner.ts+15-16
...@@ -14,8 +14,8 @@ const BASE_WEB_PORT = 5733;...@@ -14,8 +14,8 @@ const BASE_WEB_PORT = 5733;
14const MAX_HASH_OFFSET = 3000;14const MAX_HASH_OFFSET = 3000;
15const MAX_PORT = 65535;15const MAX_PORT = 65535;
1616
17export const DEFAULT_DEV_STATE_DIR = Effect.map(Effect.service(Path.Path), (path) =>17export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) =>
18 path.join(homedir(), ".t3", "dev"),18 path.join(homedir(), ".t3"),
19);19);
2020
21const MODE_ARGS = {21const MODE_ARGS = {
...@@ -101,17 +101,16 @@ export function resolveOffset(config: {...@@ -101,17 +101,16 @@ export function resolveOffset(config: {
101 return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` };101 return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` };
102}102}
103103
104function resolveStateDir(stateDir: string | undefined): Effect.Effect<string, never, Path.Path> {104function resolveBaseDir(baseDir: string | undefined): Effect.Effect<string, never, Path.Path> {
105 return Effect.gen(function* () {105 return Effect.gen(function* () {
106 const path = yield* Path.Path;106 const path = yield* Path.Path;
107 const configured = stateDir?.trim();107 const configured = baseDir?.trim();
108108
109 if (configured) {109 if (configured) {
110 // Resolve relative paths against cwd (monorepo root) before turbo changes directories.
111 return path.resolve(configured);110 return path.resolve(configured);
112 }111 }
113112
114 return yield* DEFAULT_DEV_STATE_DIR;113 return yield* DEFAULT_T3_HOME;
115 });114 });
116}115}
117116
...@@ -120,7 +119,7 @@ interface CreateDevRunnerEnvInput {...@@ -120,7 +119,7 @@ interface CreateDevRunnerEnvInput {
120 readonly baseEnv: NodeJS.ProcessEnv;119 readonly baseEnv: NodeJS.ProcessEnv;
121 readonly serverOffset: number;120 readonly serverOffset: number;
122 readonly webOffset: number;121 readonly webOffset: number;
123 readonly stateDir: string | undefined;122 readonly t3Home: string | undefined;
124 readonly authToken: string | undefined;123 readonly authToken: string | undefined;
125 readonly noBrowser: boolean | undefined;124 readonly noBrowser: boolean | undefined;
126 readonly autoBootstrapProjectFromCwd: boolean | undefined;125 readonly autoBootstrapProjectFromCwd: boolean | undefined;
...@@ -135,7 +134,7 @@ export function createDevRunnerEnv({...@@ -135,7 +134,7 @@ export function createDevRunnerEnv({
135 baseEnv,134 baseEnv,
136 serverOffset,135 serverOffset,
137 webOffset,136 webOffset,
138 stateDir,137 t3Home,
139 authToken,138 authToken,
140 noBrowser,139 noBrowser,
141 autoBootstrapProjectFromCwd,140 autoBootstrapProjectFromCwd,
...@@ -147,7 +146,7 @@ export function createDevRunnerEnv({...@@ -147,7 +146,7 @@ export function createDevRunnerEnv({
147 return Effect.gen(function* () {146 return Effect.gen(function* () {
148 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;147 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;
149 const webPort = BASE_WEB_PORT + webOffset;148 const webPort = BASE_WEB_PORT + webOffset;
150 const resolvedStateDir = yield* resolveStateDir(stateDir);149 const resolvedBaseDir = yield* resolveBaseDir(t3Home);
151150
152 const output: NodeJS.ProcessEnv = {151 const output: NodeJS.ProcessEnv = {
153 ...baseEnv,152 ...baseEnv,
...@@ -156,7 +155,7 @@ export function createDevRunnerEnv({...@@ -156,7 +155,7 @@ export function createDevRunnerEnv({
156 ELECTRON_RENDERER_PORT: String(webPort),155 ELECTRON_RENDERER_PORT: String(webPort),
157 VITE_WS_URL: `ws://localhost:${serverPort}`,156 VITE_WS_URL: `ws://localhost:${serverPort}`,
158 VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`,157 VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`,
159 T3CODE_STATE_DIR: resolvedStateDir,158 T3CODE_HOME: resolvedBaseDir,
160 };159 };
161160
162 if (host !== undefined) {161 if (host !== undefined) {
...@@ -335,7 +334,7 @@ export function resolveModePortOffsets<R = NetService>({...@@ -335,7 +334,7 @@ export function resolveModePortOffsets<R = NetService>({
335334
336interface DevRunnerCliInput {335interface DevRunnerCliInput {
337 readonly mode: DevMode;336 readonly mode: DevMode;
338 readonly stateDir: string | undefined;337 readonly t3Home: string | undefined;
339 readonly authToken: string | undefined;338 readonly authToken: string | undefined;
340 readonly noBrowser: boolean | undefined;339 readonly noBrowser: boolean | undefined;
341 readonly autoBootstrapProjectFromCwd: boolean | undefined;340 readonly autoBootstrapProjectFromCwd: boolean | undefined;
...@@ -415,7 +414,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {...@@ -415,7 +414,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
415 baseEnv: process.env,414 baseEnv: process.env,
416 serverOffset,415 serverOffset,
417 webOffset,416 webOffset,
418 stateDir: input.stateDir,417 t3Home: input.t3Home,
419 authToken: input.authToken,418 authToken: input.authToken,
420 noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser),419 noBrowser: resolveOptionalBooleanOverride(input.noBrowser, envOverrides.noBrowser),
421 autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride(420 autoBootstrapProjectFromCwd: resolveOptionalBooleanOverride(
...@@ -437,7 +436,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {...@@ -437,7 +436,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
437 : "";436 : "";
438437
439 yield* Effect.logInfo(438 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)}`,
441 );440 );
442441
443 if (input.dryRun) {442 if (input.dryRun) {
...@@ -485,9 +484,9 @@ const devRunnerCli = Command.make("dev-runner", {...@@ -485,9 +484,9 @@ const devRunnerCli = Command.make("dev-runner", {
485 mode: Argument.choice("mode", DEV_RUNNER_MODES).pipe(484 mode: Argument.choice("mode", DEV_RUNNER_MODES).pipe(
486 Argument.withDescription("Development mode to run."),485 Argument.withDescription("Development mode to run."),
487 ),486 ),
488 stateDir: Flag.string("state-dir").pipe(487 t3Home: Flag.string("home-dir").pipe(
489 Flag.withDescription("State directory path (forwards to T3CODE_STATE_DIR)."),488 Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."),
490 Flag.withFallbackConfig(optionalStringConfig("T3CODE_STATE_DIR")),489 Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOME")),
491 ),490 ),
492 authToken: Flag.string("auth-token").pipe(491 authToken: Flag.string("auth-token").pipe(
493 Flag.withDescription("Auth token (forwards to T3CODE_AUTH_TOKEN)."),492 Flag.withDescription("Auth token (forwards to T3CODE_AUTH_TOKEN)."),
turbo.json+1-1
...@@ -9,7 +9,7 @@...@@ -9,7 +9,7 @@
9 "T3CODE_MODE",9 "T3CODE_MODE",
10 "T3CODE_PORT",10 "T3CODE_PORT",
11 "T3CODE_NO_BROWSER",11 "T3CODE_NO_BROWSER",
12 "T3CODE_STATE_DIR",12 "T3CODE_HOME",
13 "T3CODE_AUTH_TOKEN",13 "T3CODE_AUTH_TOKEN",
14 "T3CODE_DESKTOP_WS_URL"14 "T3CODE_DESKTOP_WS_URL"
15 ],15 ],