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