diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 643c6a2e9e04..45eb883c6e4b 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -23,6 +23,8 @@ import { import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; +import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts"; +import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; @@ -54,6 +56,7 @@ import { makeTestProviderAdapterHarness, type TestProviderAdapterHarness, } from "./TestProviderAdapter.integration.ts"; +import { ServerConfig } from "../src/config.ts"; function runGit(cwd: string, args: ReadonlyArray) { return execFileSync("git", args, { @@ -227,8 +230,17 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(runtimeServicesLayer), ); + const gitCoreLayer = Layer.succeed(GitCore, { + renameBranch: (input: Parameters[0]) => + Effect.succeed({ branch: input.newBranch }), + } as unknown as GitCoreShape); + const textGenerationLayer = Layer.succeed(TextGeneration, { + generateBranchName: () => Effect.succeed({ branch: null }), + } as unknown as TextGenerationShape); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(textGenerationLayer), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), @@ -240,6 +252,7 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { ); const layer = orchestrationReactorLayer.pipe( Layer.provide(persistenceLayer), + Layer.provideMerge(ServerConfig.layerTest(workspaceDir, stateDir)), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/attachmentPaths.ts b/apps/server/src/attachmentPaths.ts new file mode 100644 index 000000000000..c1680f6c082f --- /dev/null +++ b/apps/server/src/attachmentPaths.ts @@ -0,0 +1,28 @@ +import path from "node:path"; + +export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; + +export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { + const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); + if (normalized.length === 0 || normalized.startsWith("..") || normalized.includes("\0")) { + return null; + } + return normalized.replace(/\\/g, "/"); +} + +export function resolveAttachmentRelativePath(input: { + readonly stateDir: string; + readonly relativePath: string; +}): string | null { + const normalizedRelativePath = normalizeAttachmentRelativePath(input.relativePath); + if (!normalizedRelativePath) { + return null; + } + + const attachmentsRoot = path.resolve(path.join(input.stateDir, "attachments")); + const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath)); + if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) { + return null; + } + return filePath; +} diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts new file mode 100644 index 000000000000..8e1bc4218e34 --- /dev/null +++ b/apps/server/src/attachmentStore.test.ts @@ -0,0 +1,77 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + createAttachmentId, + parseThreadSegmentFromAttachmentId, + resolveAttachmentPathById, +} from "./attachmentStore.ts"; + +describe("attachmentStore", () => { + it("sanitizes thread ids when creating attachment ids", () => { + const attachmentId = createAttachmentId("thread.folder/unsafe space"); + expect(attachmentId).toBeTruthy(); + if (!attachmentId) { + return; + } + + const threadSegment = parseThreadSegmentFromAttachmentId(attachmentId); + expect(threadSegment).toBeTruthy(); + expect(threadSegment).toMatch(/^[a-z0-9_-]+$/i); + expect(threadSegment).not.toContain("."); + expect(threadSegment).not.toContain("%"); + expect(threadSegment).not.toContain("/"); + }); + + it("parses exact thread segments from attachment ids without prefix collisions", () => { + const fooId = "foo-00000000-0000-4000-8000-000000000001"; + const fooBarId = "foo-bar-00000000-0000-4000-8000-000000000002"; + + expect(parseThreadSegmentFromAttachmentId(fooId)).toBe("foo"); + expect(parseThreadSegmentFromAttachmentId(fooBarId)).toBe("foo-bar"); + }); + + it("normalizes created thread segments to lowercase", () => { + const attachmentId = createAttachmentId("Thread.Foo"); + expect(attachmentId).toBeTruthy(); + if (!attachmentId) { + return; + } + expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo"); + }); + + it("resolves attachment path by id using the extension that exists on disk", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-")); + try { + const attachmentId = "thread-1-attachment"; + const attachmentsDir = path.join(stateDir, "attachments"); + fs.mkdirSync(attachmentsDir, { recursive: true }); + const pngPath = path.join(attachmentsDir, `${attachmentId}.png`); + fs.writeFileSync(pngPath, Buffer.from("hello")); + + const resolved = resolveAttachmentPathById({ + stateDir, + attachmentId, + }); + expect(resolved).toBe(pngPath); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); + + it("returns null when no attachment file exists for the id", () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-attachment-store-")); + try { + const resolved = resolveAttachmentPathById({ + stateDir, + attachmentId: "thread-1-missing", + }); + expect(resolved).toBeNull(); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts new file mode 100644 index 000000000000..48be2df8a6cf --- /dev/null +++ b/apps/server/src/attachmentStore.ts @@ -0,0 +1,110 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; + +import type { ChatAttachment } from "@t3tools/contracts"; + +import { + normalizeAttachmentRelativePath, + resolveAttachmentRelativePath, +} from "./attachmentPaths.ts"; +import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; + +const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; +const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; +const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; +const ATTACHMENT_ID_UUID_PATTERN = + "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +const ATTACHMENT_ID_PATTERN = new RegExp( + `^(${ATTACHMENT_ID_THREAD_SEGMENT_PATTERN})-(${ATTACHMENT_ID_UUID_PATTERN})$`, + "i", +); + +export function toSafeThreadAttachmentSegment(threadId: string): string | null { + const segment = threadId + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/gi, "-") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, "") + .slice(0, ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS) + .replace(/[-_]+$/g, ""); + if (segment.length === 0) { + return null; + } + return segment; +} + +export function createAttachmentId(threadId: string): string | null { + const threadSegment = toSafeThreadAttachmentSegment(threadId); + if (!threadSegment) { + return null; + } + return `${threadSegment}-${randomUUID()}`; +} + +export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { + const normalizedId = normalizeAttachmentRelativePath(attachmentId); + if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { + return null; + } + const match = normalizedId.match(ATTACHMENT_ID_PATTERN); + if (!match) { + return null; + } + return match[1]?.toLowerCase() ?? null; +} + +export function attachmentRelativePath(attachment: ChatAttachment): string { + switch (attachment.type) { + case "image": { + const extension = inferImageExtension({ + mimeType: attachment.mimeType, + fileName: attachment.name, + }); + return `${attachment.id}${extension}`; + } + } +} + +export function resolveAttachmentPath(input: { + readonly stateDir: string; + readonly attachment: ChatAttachment; +}): string | null { + return resolveAttachmentRelativePath({ + stateDir: input.stateDir, + relativePath: attachmentRelativePath(input.attachment), + }); +} + +export function resolveAttachmentPathById(input: { + readonly stateDir: string; + readonly attachmentId: string; +}): string | null { + const normalizedId = normalizeAttachmentRelativePath(input.attachmentId); + if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { + return null; + } + for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) { + const maybePath = resolveAttachmentRelativePath({ + stateDir: input.stateDir, + relativePath: `${normalizedId}${extension}`, + }); + if (maybePath && existsSync(maybePath)) { + return maybePath; + } + } + return null; +} + +export function parseAttachmentIdFromRelativePath(relativePath: string): string | null { + const normalized = normalizeAttachmentRelativePath(relativePath); + if (!normalized || normalized.includes("/")) { + return null; + } + const extensionIndex = normalized.lastIndexOf("."); + if (extensionIndex <= 0) { + return null; + } + const id = normalized.slice(0, extensionIndex); + return id.length > 0 && !id.includes(".") ? id : null; +} diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index b7b6c91237c8..7ec669d4c68b 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -191,10 +191,7 @@ describe("sendTurn", () => { attachments: [ { type: "image", - name: "error.png", - mimeType: "image/png", - sizeBytes: 1_024, - dataUrl: "data:image/png;base64,AAAA", + url: "data:image/png;base64,AAAA", }, ], model: "gpt-5.3", @@ -242,10 +239,7 @@ describe("sendTurn", () => { attachments: [ { type: "image", - name: "diagram.png", - mimeType: "image/png", - sizeBytes: 256, - dataUrl: "data:image/png;base64,BBBB", + url: "data:image/png;base64,BBBB", }, ], }); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index c2b594504849..cb50a302f938 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -14,7 +14,6 @@ import { normalizeModelSlug, type ProviderApprovalDecision, type ProviderEvent, - type ProviderSendTurnInput, type ProviderSession, type ProviderSessionStartInput, type ProviderTurnStartResult, @@ -71,6 +70,14 @@ interface JsonRpcNotification { params?: unknown; } +export interface CodexAppServerSendTurnInput { + readonly sessionId: ProviderSessionId; + readonly input?: string; + readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; + readonly model?: string; + readonly effort?: string; +} + export interface CodexThreadTurnSnapshot { id: ProviderTurnId; items: unknown[]; @@ -290,7 +297,7 @@ export class CodexAppServerManager extends EventEmitter { + async sendTurn(input: CodexAppServerSendTurnInput): Promise { const context = this.requireSession(input.sessionId); if (!context.session.threadId) { throw new Error("Session is missing a thread id."); @@ -310,7 +317,7 @@ export class CodexAppServerManager extends EventEmitter()( "t3/config/ServerConfig", -) {} +) { + static readonly layerTest = (cwd: string, statedir: string) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const path = yield* Path.Path; + return { + cwd, + stateDir: statedir, + mode: "web", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + port: 0, + host: undefined, + authToken: undefined, + keybindingsConfigPath: path.join(statedir, "keybindings.json"), + staticDir: undefined, + devUrl: undefined, + noBrowser: false, + }; + }), + ); +} // Helpers diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index bbc23fb04097..576e51b4fa4f 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -3,10 +3,17 @@ import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, Path } from "effect"; import { expect } from "vitest"; +import { ServerConfig } from "../../config.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { TextGenerationError } from "../Errors.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +const makeCodexTextGenerationTestLayer = (stateDir: string) => + CodexTextGenerationLive.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), + Layer.provideMerge(NodeServices.layer), + ); + function makeFakeCodexBinary(dir: string) { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -21,13 +28,36 @@ function makeFakeCodexBinary(dir: string) { "#!/bin/sh", 'output_path=""', "while [ $# -gt 0 ]; do", + ' if [ "$1" = "--image" ]; then', + " shift", + ' if [ -n "$1" ]; then', + ' seen_image="1"', + " fi", + " continue", + " fi", ' if [ "$1" = "--output-last-message" ]; then', " shift", ' output_path="$1"', " fi", " shift", "done", - "cat >/dev/null", + 'stdin_content="$(cat)"', + 'if [ "$T3_FAKE_CODEX_REQUIRE_IMAGE" = "1" ] && [ "$seen_image" != "1" ]; then', + ' printf "%s\\n" "missing --image input" >&2', + " exit 2", + "fi", + 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" ]; then', + ' printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_CONTAIN" >/dev/null || {', + ' printf "%s\\n" "stdin missing expected content" >&2', + " exit 3", + " }", + "fi", + 'if [ -n "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" ]; then', + ' if printf "%s" "$stdin_content" | grep -F -- "$T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN" >/dev/null; then', + ' printf "%s\\n" "stdin contained forbidden content" >&2', + " exit 4", + " fi", + "fi", 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then', ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2', "fi", @@ -48,6 +78,9 @@ function withFakeCodexEnv( output: string; exitCode?: number; stderr?: string; + requireImage?: boolean; + stdinMustContain?: string; + stdinMustNotContain?: string; }, effect: Effect.Effect, ) { @@ -60,6 +93,9 @@ function withFakeCodexEnv( const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64; const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; const previousStderr = process.env.T3_FAKE_CODEX_STDERR; + const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; + const previousStdinMustContain = process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; + const previousStdinMustNotContain = process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; yield* Effect.sync(() => { process.env.PATH = `${binDir}:${previousPath ?? ""}`; @@ -76,6 +112,24 @@ function withFakeCodexEnv( } else { delete process.env.T3_FAKE_CODEX_STDERR; } + + if (input.requireImage) { + process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = "1"; + } else { + delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; + } + + if (input.stdinMustContain !== undefined) { + process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = input.stdinMustContain; + } else { + delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; + } + + if (input.stdinMustNotContain !== undefined) { + process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = input.stdinMustNotContain; + } else { + delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; + } }); return { @@ -83,6 +137,9 @@ function withFakeCodexEnv( previousOutput, previousExitCode, previousStderr, + previousRequireImage, + previousStdinMustContain, + previousStdinMustNotContain, }; }), () => effect, @@ -107,14 +164,29 @@ function withFakeCodexEnv( } else { process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr; } + + if (previous.previousRequireImage === undefined) { + delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; + } else { + process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage; + } + + if (previous.previousStdinMustContain === undefined) { + delete process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN; + } else { + process.env.T3_FAKE_CODEX_STDIN_MUST_CONTAIN = previous.previousStdinMustContain; + } + + if (previous.previousStdinMustNotContain === undefined) { + delete process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN; + } else { + process.env.T3_FAKE_CODEX_STDIN_MUST_NOT_CONTAIN = previous.previousStdinMustNotContain; + } }), ); } -const CodexTextGenerationTestLayer = Layer.provideMerge( - CodexTextGenerationLive, - NodeServices.layer, -); +const CodexTextGenerationTestLayer = makeCodexTextGenerationTestLayer(process.cwd()); it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { it.effect("generates and sanitizes commit messages", () => @@ -170,6 +242,216 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); + it.effect("generates branch names and normalizes branch fragments", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: " Feat/Session ", + }), + stdinMustNotContain: "Image attachments supplied to the model", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Please update session handling.", + }); + + expect(generated.branch).toBe("feat/session"); + }), + ), + ); + + it.effect("omits attachment metadata section when no attachments are provided", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: "fix/session-timeout", + }), + stdinMustNotContain: "Attachment metadata:", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Fix timeout behavior.", + }); + + expect(generated.branch).toBe("fix/session-timeout"); + }), + ), + ); + + it.effect("passes image attachments through as codex image inputs", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: "fix/ui-regression", + }), + requireImage: true, + stdinMustContain: "Attachment metadata:", + }, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const attachmentPath = path.join(process.cwd(), "attachments", `${attachmentId}.png`); + yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true }); + yield* fs.writeFile(attachmentPath, Buffer.from("hello")); + + const textGeneration = yield* TextGeneration; + const generated = yield* textGeneration + .generateBranchName({ + cwd: process.cwd(), + message: "Fix layout bug from screenshot.", + attachments: [ + { + type: "image", + id: attachmentId, + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }) + .pipe(Effect.ensuring(fs.remove(attachmentPath).pipe(Effect.catch(() => Effect.void)))); + + expect(generated.branch).toBe("fix/ui-regression"); + }), + ), + ); + + it.effect("resolves persisted attachment ids to files for codex image inputs", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: "fix/ui-regression", + }), + requireImage: true, + }, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const attachmentId = + `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const imagePath = path.join(process.cwd(), "attachments", `${attachmentId}.png`); + yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true }); + yield* fs.writeFile(imagePath, Buffer.from("hello")); + + const textGeneration = yield* TextGeneration; + const generated = yield* textGeneration + .generateBranchName({ + cwd: process.cwd(), + message: "Fix layout bug from screenshot.", + attachments: [ + { + type: "image", + id: attachmentId, + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }) + .pipe( + Effect.tap(() => + fs.stat(imagePath).pipe( + Effect.map((fileInfo) => { + expect(fileInfo.type).toBe("File"); + }), + ), + ), + Effect.ensuring( + fs.remove(imagePath).pipe(Effect.catch(() => Effect.void)), + ), + ); + + expect(generated.branch).toBe("fix/ui-regression"); + }), + ), + ); + + it.effect("ignores missing attachment ids for codex image inputs", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: "fix/ui-regression", + }), + requireImage: true, + }, + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const missingPath = path.join(process.cwd(), "attachments", `${missingAttachmentId}.png`); + yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); + + const textGeneration = yield* TextGeneration; + const result = yield* textGeneration + .generateBranchName({ + cwd: process.cwd(), + message: "Fix layout bug from screenshot.", + attachments: [ + { + type: "image", + id: missingAttachmentId, + name: "outside.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }) + .pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "Left" as const, left: error }), + onSuccess: (value) => ({ _tag: "Right" as const, right: value }), + }), + ); + + expect(result._tag).toBe("Left"); + if (result._tag === "Left") { + expect(result.left).toBeInstanceOf(TextGenerationError); + expect(result.left.message).toContain("missing --image input"); + } + }), + ), + ); + + it.effect( + "fails with typed TextGenerationError when codex returns wrong branch payload shape", + () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: "This is not a branch payload", + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const result = yield* textGeneration + .generateBranchName({ + cwd: process.cwd(), + message: "Fix websocket reconnect flake", + }) + .pipe( + Effect.match({ + onFailure: (error) => ({ _tag: "Left" as const, left: error }), + onSuccess: (value) => ({ _tag: "Right" as const, right: value }), + }), + ); + + expect(result._tag).toBe("Left"); + if (result._tag === "Left") { + expect(result.left).toBeInstanceOf(TextGenerationError); + expect(result.left.message).toContain("Codex returned invalid structured output"); + } + }), + ), + ); + it.effect("returns typed TextGenerationError when codex exits non-zero", () => withFakeCodexEnv( { diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 3fbeb1aac76e..064d4da9c8d8 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -3,8 +3,12 @@ import { randomUUID } from "node:crypto"; import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; import { TextGenerationError } from "../Errors.ts"; import { + type BranchNameGenerationInput, + type BranchNameGenerationResult, type CommitMessageGenerationResult, type PrContentGenerationResult, type TextGenerationShape, @@ -15,26 +19,6 @@ const CODEX_MODEL = "gpt-5.3-codex"; const CODEX_REASONING_EFFORT = "low"; const CODEX_TIMEOUT_MS = 180_000; -const COMMIT_OUTPUT_SCHEMA_JSON = { - type: "object", - properties: { - subject: { type: "string" }, - body: { type: "string" }, - }, - required: ["subject", "body"], - additionalProperties: false, -} as const; - -const PR_OUTPUT_SCHEMA_JSON = { - type: "object", - properties: { - title: { type: "string" }, - body: { type: "string" }, - }, - required: ["title", "body"], - additionalProperties: false, -} as const; - function normalizeCodexError( operation: string, error: unknown, @@ -71,32 +55,6 @@ function normalizeCodexError( }); } -function parseCommitOutput(raw: unknown): { subject: string; body: string } { - if (!raw || typeof raw !== "object") { - throw new Error("Codex returned a non-object commit message payload."); - } - const record = raw as Record; - const subject = typeof record.subject === "string" ? record.subject.trim() : ""; - const body = typeof record.body === "string" ? record.body : ""; - if (subject.length === 0) { - throw new Error("Codex returned an empty commit subject."); - } - return { subject, body }; -} - -function parsePrOutput(raw: unknown): { title: string; body: string } { - if (!raw || typeof raw !== "object") { - throw new Error("Codex returned a non-object PR payload."); - } - const record = raw as Record; - const title = typeof record.title === "string" ? record.title.trim() : ""; - const body = typeof record.body === "string" ? record.body.trim() : ""; - if (title.length === 0 || body.length === 0) { - throw new Error("Codex returned an invalid PR title/body payload."); - } - return { title, body }; -} - function limitSection(value: string, maxChars: number): string { if (value.length <= maxChars) return value; const truncated = value.slice(0, maxChars); @@ -124,10 +82,33 @@ function sanitizePrTitle(raw: string): string { return "Update project changes"; } +function sanitizeBranchName(raw: string): string { + const normalized = raw + .trim() + .toLowerCase() + .replace(/['"`]/g, "") + .replace(/^[./\s_-]+|[./\s_-]+$/g, ""); + + const branchFragment = normalized + .replace(/[^a-z0-9/_-]+/g, "-") + .replace(/\/+/g, "/") + .replace(/-+/g, "-") + .replace(/^[./_-]+|[./_-]+$/g, "") + .slice(0, 64) + .replace(/[./_-]+$/g, ""); + + return branchFragment.length > 0 ? branchFragment : "update"; +} + const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + + type MaterializedImageAttachments = { + readonly imagePaths: ReadonlyArray; + }; const readStreamAsString = ( operation: string, @@ -171,24 +152,59 @@ const makeCodexTextGeneration = Effect.gen(function* () { const safeUnlink = (filePath: string): Effect.Effect => fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); - const runCodexJson = ({ + const materializeImageAttachments = ( + _operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", + attachments: BranchNameGenerationInput["attachments"], + ): Effect.Effect => + Effect.gen(function* () { + if (!attachments || attachments.length === 0) { + return { imagePaths: [] }; + } + + const imagePaths: string[] = []; + for (const attachment of attachments) { + if (attachment.type !== "image") { + continue; + } + + const resolvedPath = resolveAttachmentPath({ + stateDir: serverConfig.stateDir, + attachment, + }); + if (!resolvedPath || !path.isAbsolute(resolvedPath)) { + continue; + } + const fileInfo = yield* fileSystem + .stat(resolvedPath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!fileInfo || fileInfo.type !== "File") { + continue; + } + imagePaths.push(resolvedPath); + } + return { imagePaths }; + }); + + const runCodexJson = ({ operation, cwd, prompt, outputSchemaJson, - parse, + imagePaths = [], + cleanupPaths = [], }: { - operation: "generateCommitMessage" | "generatePrContent"; + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; cwd: string; prompt: string; - outputSchemaJson: object; - parse: (raw: unknown) => T; - }): Effect.Effect => + outputSchemaJson: S; + imagePaths?: ReadonlyArray; + cleanupPaths?: ReadonlyArray; + }): Effect.Effect => Effect.gen(function* () { const schemaPath = yield* writeTempFile( operation, "codex-schema", - JSON.stringify(outputSchemaJson), + JSON.stringify(Schema.toJsonSchemaDocument(outputSchemaJson).schema), ); const outputPath = yield* writeTempFile(operation, "codex-output", ""); @@ -208,6 +224,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { schemaPath, "--output-last-message", outputPath, + ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), "-", ], { @@ -254,9 +271,12 @@ const makeCodexTextGeneration = Effect.gen(function* () { } }); - const cleanup = Effect.all([safeUnlink(schemaPath), safeUnlink(outputPath)], { - concurrency: "unbounded", - }).pipe(Effect.asVoid); + const cleanup = Effect.all( + [schemaPath, outputPath, ...cleanupPaths].map((filePath) => safeUnlink(filePath)), + { + concurrency: "unbounded", + }, + ).pipe(Effect.asVoid); return yield* Effect.gen(function* () { yield* runCodexCommand.pipe( @@ -273,7 +293,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { ), ); - const rawOutput = yield* fileSystem.readFileString(outputPath).pipe( + return yield* fileSystem.readFileString(outputPath).pipe( Effect.mapError( (cause) => new TextGenerationError({ @@ -282,30 +302,17 @@ const makeCodexTextGeneration = Effect.gen(function* () { cause, }), ), + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson))), + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Codex returned invalid structured output.", + cause, + }), + ), + ), ); - const trimmed = rawOutput.trim(); - if (trimmed.length === 0) { - return yield* new TextGenerationError({ - operation, - detail: "Codex returned an empty response.", - }); - } - - const parsedJson = yield* Effect.try({ - try: () => JSON.parse(trimmed) as unknown, - catch: (cause) => - new TextGenerationError({ - operation, - detail: "Codex returned invalid JSON output.", - cause, - }), - }); - - return yield* Effect.try({ - try: () => parse(parsedJson), - catch: (cause) => - normalizeCodexError(operation, cause, "Codex returned invalid structured output"), - }); }).pipe(Effect.ensuring(cleanup)); }); @@ -331,8 +338,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { operation: "generateCommitMessage", cwd: input.cwd, prompt, - outputSchemaJson: COMMIT_OUTPUT_SCHEMA_JSON, - parse: (raw) => parseCommitOutput(raw), + outputSchemaJson: Schema.Struct({ + subject: Schema.String, + body: Schema.String, + }), }).pipe( Effect.map( (generated) => @@ -371,8 +380,10 @@ const makeCodexTextGeneration = Effect.gen(function* () { operation: "generatePrContent", cwd: input.cwd, prompt, - outputSchemaJson: PR_OUTPUT_SCHEMA_JSON, - parse: (raw) => parsePrOutput(raw), + outputSchemaJson: Schema.Struct({ + title: Schema.String, + body: Schema.String, + }), }).pipe( Effect.map( (generated) => @@ -384,9 +395,55 @@ const makeCodexTextGeneration = Effect.gen(function* () { ); }; + const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { + return Effect.gen(function* () { + const { imagePaths } = yield* materializeImageAttachments("generateBranchName", input.attachments); + const attachmentLines = (input.attachments ?? []).map( + (attachment) => + `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + ); + + const promptSections = [ + "You generate concise git branch names.", + "Return a JSON object with key: branch.", + "Rules:", + "- Branch should describe the requested work from the user message.", + "- Keep it short and specific (2-6 words).", + "- Use plain words only, no issue prefixes and no punctuation-heavy text.", + "- If images are attached, use them as primary context for visual/UI issues.", + "", + "User message:", + limitSection(input.message, 8_000), + ]; + if (attachmentLines.length > 0) { + promptSections.push( + "", + "Attachment metadata:", + limitSection(attachmentLines.join("\n"), 4_000), + ); + } + const prompt = promptSections.join("\n"); + + const generated = yield* runCodexJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: Schema.Struct({ + branch: Schema.String, + }), + imagePaths, + }); + + return { + branch: sanitizeBranchName(generated.branch), + } satisfies BranchNameGenerationResult; + }); + }; + return { generateCommitMessage, generatePrContent, + generateBranchName, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 32f9942b920a..a9c1d2ce4109 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -106,6 +106,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => listBranches: (input) => core.listBranches(input), createWorktree: (input) => core.createWorktree(input), removeWorktree: (input) => core.removeWorktree(input), + renameBranch: (input) => core.renameBranch(input), createBranch: (input) => core.createBranch(input), checkoutBranch: (input) => core.checkoutBranch(input), initRepo: (input) => core.initRepo(input), @@ -154,6 +155,13 @@ function removeGitWorktree(input: Parameters[0]) }); } +function renameGitBranch(input: Parameters[0]) { + return Effect.gen(function* () { + const core = yield* GitCore; + return yield* core.renameBranch(input); + }); +} + function pullGitBranch({ cwd }: { cwd: string }) { return Effect.gen(function* () { const core = yield* GitCore; @@ -650,6 +658,129 @@ it.layer(TestLayer)("git integration", (it) => { ); }); + // ── renameGitBranch ── + + describe("renameGitBranch", () => { + it.effect("renames the current branch", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" }); + yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" }); + + const renamed = yield* renameGitBranch({ + cwd: tmp, + oldBranch: "feature/old-name", + newBranch: "feature/new-name", + }); + + expect(renamed.branch).toBe("feature/new-name"); + + const branches = yield* listGitBranches({ cwd: tmp }); + expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false); + const current = branches.branches.find((branch) => branch.current); + expect(current?.name).toBe("feature/new-name"); + }), + ); + + it.effect("returns success without git invocation when old/new names match", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const current = (yield* listGitBranches({ cwd: tmp })).branches.find((b) => b.current)!; + + const renamed = yield* renameGitBranch({ + cwd: tmp, + oldBranch: current.name, + newBranch: current.name, + }); + + expect(renamed.branch).toBe(current.name); + }), + ); + + it.effect("appends numeric suffix when target branch already exists", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" }); + yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + + const renamed = yield* renameGitBranch({ + cwd: tmp, + oldBranch: "t3code/tmp-working", + newBranch: "t3code/feat/session", + }); + + expect(renamed.branch).toBe("t3code/feat/session-1"); + const branches = yield* listGitBranches({ cwd: tmp }); + expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe( + true, + ); + expect(branches.branches.some((branch) => branch.name === "t3code/feat/session-1")).toBe( + true, + ); + const current = branches.branches.find((branch) => branch.current); + expect(current?.name).toBe("t3code/feat/session-1"); + }), + ); + + it.effect("increments suffix until it finds an available branch name", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" }); + yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session-1" }); + yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + + const renamed = yield* renameGitBranch({ + cwd: tmp, + oldBranch: "t3code/tmp-working", + newBranch: "t3code/feat/session", + }); + + expect(renamed.branch).toBe("t3code/feat/session-2"); + }), + ); + + it.effect("uses '--' separator for branch rename arguments", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" }); + yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" }); + + const realGitService = yield* GitService; + let renameArgs: ReadonlyArray | null = null; + const core = yield* makeIsolatedGitCore({ + execute: (input) => { + if (input.args[0] === "branch" && input.args[1] === "-m") { + renameArgs = [...input.args]; + } + return realGitService.execute(input); + }, + }); + + const renamed = yield* core.renameBranch({ + cwd: tmp, + oldBranch: "feature/old-name", + newBranch: "feature/new-name", + }); + + expect(renamed.branch).toBe("feature/new-name"); + expect(renameArgs).toEqual([ + "branch", + "-m", + "--", + "feature/old-name", + "feature/new-name", + ]); + }), + ); + }); + // ── createGitWorktree + removeGitWorktree ── describe("createGitWorktree", () => { diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index b5fb6b5074a9..f5da427e2631 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -158,6 +158,43 @@ const makeGitCore = Effect.gen(function* () { Effect.map((result) => result.stdout), ); + const branchExists = (cwd: string, branch: string): Effect.Effect => + executeGit( + "GitCore.branchExists", + cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + }, + ).pipe(Effect.map((result) => result.code === 0)); + + const resolveAvailableBranchName = ( + cwd: string, + desiredBranch: string, + ): Effect.Effect => + Effect.gen(function* () { + const isDesiredTaken = yield* branchExists(cwd, desiredBranch); + if (!isDesiredTaken) { + return desiredBranch; + } + + for (let suffix = 1; suffix <= 100; suffix += 1) { + const candidate = `${desiredBranch}-${suffix}`; + const isCandidateTaken = yield* branchExists(cwd, candidate); + if (!isCandidateTaken) { + return candidate; + } + } + + return yield* createGitCommandError( + "GitCore.renameBranch", + cwd, + ["branch", "-m", "--", desiredBranch], + `Could not find an available branch name for '${desiredBranch}'.`, + ); + }); + const resolveCurrentUpstream = ( cwd: string, ): Effect.Effect< @@ -711,6 +748,26 @@ const makeGitCore = Effect.gen(function* () { ); }); + const renameBranch: GitCoreShape["renameBranch"] = (input) => + Effect.gen(function* () { + if (input.oldBranch === input.newBranch) { + return { branch: input.newBranch }; + } + const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); + + yield* executeGit( + "GitCore.renameBranch", + input.cwd, + ["branch", "-m", "--", input.oldBranch, targetBranch], + { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch rename failed", + }, + ); + + return { branch: targetBranch }; + }); + const createBranch: GitCoreShape["createBranch"] = (input) => executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { timeoutMs: 10_000, @@ -725,11 +782,9 @@ const makeGitCore = Effect.gen(function* () { }); // Refresh upstream refs in the background so checkout remains responsive. - yield* Effect.sync(() => { - void Effect.runPromise( - refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.catch(() => Effect.void)), - ); - }); + yield* Effect.forkDetach( + refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.catch(() => Effect.void)), + ); }); const initRepo: GitCoreShape["initRepo"] = (input) => @@ -750,6 +805,7 @@ const makeGitCore = Effect.gen(function* () { listBranches, createWorktree, removeWorktree, + renameBranch, createBranch, checkoutBranch, initRepo, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 88d26f771087..e32bb575a15e 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -41,6 +41,10 @@ interface FakeGitTextGeneration { diffSummary: string; diffPatch: string; }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; + generateBranchName: (input: { + cwd: string; + message: string; + }) => Effect.Effect<{ branch: string }, TextGenerationError>; } function makeTempDir( @@ -110,6 +114,10 @@ function createTextGeneration(overrides: Partial = {}): T title: "Add stacked git actions", body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", }), + generateBranchName: () => + Effect.succeed({ + branch: "update-workflow", + }), ...overrides, }; @@ -136,6 +144,17 @@ function createTextGeneration(overrides: Partial = {}): T }), ), ), + generateBranchName: (input) => + implementation.generateBranchName(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateBranchName", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), }; } diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index d2544de90921..a61da49ed0d1 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -46,6 +46,16 @@ export interface GitRangeContext { diffPatch: string; } +export interface GitRenameBranchInput { + cwd: string; + oldBranch: string; + newBranch: string; +} + +export interface GitRenameBranchResult { + branch: string; +} + /** * GitCoreShape - Service API for low-level Git repository interactions. */ @@ -124,6 +134,13 @@ export interface GitCoreShape { */ readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect; + /** + * Rename an existing local branch. + */ + readonly renameBranch: ( + input: GitRenameBranchInput, + ) => Effect.Effect; + /** * Create a local branch. */ diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index 6073553ac85b..682cb6667e92 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -8,6 +8,7 @@ */ import { ServiceMap } from "effect"; import type { Effect } from "effect"; +import type { ChatAttachment } from "@t3tools/contracts"; import type { TextGenerationError } from "../Errors.ts"; @@ -37,11 +38,22 @@ export interface PrContentGenerationResult { body: string; } +export interface BranchNameGenerationInput { + cwd: string; + message: string; + attachments?: ReadonlyArray | undefined; +} + +export interface BranchNameGenerationResult { + branch: string; +} + export interface TextGenerationService { generateCommitMessage( input: CommitMessageGenerationInput, ): Promise; generatePrContent(input: PrContentGenerationInput): Promise; + generateBranchName(input: BranchNameGenerationInput): Promise; } /** @@ -61,6 +73,13 @@ export interface TextGenerationShape { readonly generatePrContent: ( input: PrContentGenerationInput, ) => Effect.Effect; + + /** + * Generate a concise branch name from a user message. + */ + readonly generateBranchName: ( + input: BranchNameGenerationInput, + ) => Effect.Effect; } /** diff --git a/apps/server/src/imageMime.test.ts b/apps/server/src/imageMime.test.ts new file mode 100644 index 000000000000..ceec282794eb --- /dev/null +++ b/apps/server/src/imageMime.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { inferImageExtension, parseBase64DataUrl } from "./imageMime.ts"; + +describe("imageMime", () => { + it("parses base64 data URL with mime type", () => { + expect(parseBase64DataUrl("data:image/png;base64,SGVsbG8=")).toEqual({ + mimeType: "image/png", + base64: "SGVsbG8=", + }); + }); + + it("parses base64 data URL with mime parameters", () => { + expect(parseBase64DataUrl("data:image/png;charset=utf-8;base64,SGVsbG8=")).toEqual({ + mimeType: "image/png", + base64: "SGVsbG8=", + }); + }); + + it("rejects non-base64 data URL", () => { + expect(parseBase64DataUrl("data:image/png;charset=utf-8,hello")).toBeNull(); + }); + + it("rejects missing mime type", () => { + expect(parseBase64DataUrl("data:;base64,SGVsbG8=")).toBeNull(); + }); + + it("parses base64 data URL with spaces in payload", () => { + expect(parseBase64DataUrl("data:image/png;base64,SGVs bG8=\n")).toEqual({ + mimeType: "image/png", + base64: "SGVsbG8=", + }); + }); + + it("does not read inherited keys from mime extension map", () => { + expect(inferImageExtension({ mimeType: "constructor" })).toBe(".bin"); + }); +}); diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts new file mode 100644 index 000000000000..814abbb32c1c --- /dev/null +++ b/apps/server/src/imageMime.ts @@ -0,0 +1,79 @@ +import Mime from "@effect/platform-node/Mime"; + +export const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { + "image/avif": ".avif", + "image/bmp": ".bmp", + "image/gif": ".gif", + "image/heic": ".heic", + "image/heif": ".heif", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/png": ".png", + "image/svg+xml": ".svg", + "image/tiff": ".tiff", + "image/webp": ".webp", +}; + +export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ + ".avif", + ".bmp", + ".gif", + ".heic", + ".heif", + ".ico", + ".jpeg", + ".jpg", + ".png", + ".svg", + ".tiff", + ".webp", +]); + +export function parseBase64DataUrl( + dataUrl: string, +): { readonly mimeType: string; readonly base64: string } | null { + const match = /^data:([^,]+),([a-z0-9+/=\r\n ]+)$/i.exec(dataUrl.trim()); + if (!match) return null; + + const headerParts = (match[1] ?? "") + .split(";") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + if (headerParts.length < 2) { + return null; + } + const trailingToken = headerParts.at(-1)?.toLowerCase(); + if (trailingToken !== "base64") { + return null; + } + + const mimeType = headerParts[0]?.toLowerCase(); + const base64 = match[2]?.replace(/\s+/g, ""); + if (!mimeType || !base64) return null; + + return { mimeType, base64 }; +} + +export function inferImageExtension(input: { mimeType: string; fileName?: string }): string { + const key = input.mimeType.toLowerCase(); + const fromMime = Object.hasOwn(IMAGE_EXTENSION_BY_MIME_TYPE, key) + ? IMAGE_EXTENSION_BY_MIME_TYPE[key] + : undefined; + if (fromMime) { + return fromMime; + } + + const fromMimeExtension = Mime.getExtension(input.mimeType); + if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) { + return fromMimeExtension; + } + + const fileName = input.fileName?.trim() ?? ""; + const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName); + const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : ""; + if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) { + return fileNameExtension; + } + + return ".bin"; +} diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index a74b6283dc60..be2b810394d3 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -49,10 +49,21 @@ const testLayer = Layer.mergeAll( const runCli = ( args: ReadonlyArray, env: Record = { T3CODE_NO_BROWSER: "true" }, -) => - Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe( - Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), +) => { + const uniqueStateDir = `/tmp/t3-cli-state-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + return Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_STATE_DIR: uniqueStateDir, + ...env, + }, + }), + ), + ), ); +}; beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index bbec8f62b08c..25ca97b8049f 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -37,6 +37,7 @@ import { type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; import { checkpointRefForThreadTurn } from "../../checkpointing/Utils.ts"; +import { ServerConfig } from "../../config.ts"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); @@ -238,6 +239,7 @@ describe("CheckpointReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 925826bb1b43..a22b8979f279 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -25,6 +25,8 @@ import { OrchestrationProjectionPipeline, type OrchestrationProjectionPipelineShape, } from "../Services/ProjectionPipeline.ts"; +import { ServerConfig } from "../../config.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); @@ -37,6 +39,8 @@ async function createOrchestrationSystem() { Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), ); const runtime = ManagedRuntime.make(orchestrationLayer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); @@ -310,6 +314,8 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), ), ); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 9b477f64abeb..741aa16f1605 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -12,7 +12,7 @@ import { } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, Layer, ManagedRuntime } from "effect"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -32,13 +32,32 @@ import { } from "./ProjectionPipeline.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; -import { ServerConfig, type ServerConfigShape } from "../../config.ts"; +import { ServerConfig } from "../../config.ts"; -const projectionLayer = it.layer( +const makeProjectionPipelineTestLayer = (stateDir: string) => OrchestrationProjectionPipelineLive.pipe( Layer.provideMerge(OrchestrationEventStoreLive), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), Layer.provideMerge(SqlitePersistenceMemory), - ), + Layer.provideMerge(NodeServices.layer), + ); + +const runWithProjectionPipelineLayer = ( + stateDir: string, + effect: Effect.Effect< + A, + E, + OrchestrationProjectionPipeline | OrchestrationEventStore | SqlClient.SqlClient + >, +) => + Effect.acquireUseRelease( + Effect.sync(() => ManagedRuntime.make(makeProjectionPipelineTestLayer(stateDir))), + (runtime) => Effect.promise(() => runtime.runPromise(effect)), + (runtime) => Effect.promise(() => runtime.dispose()), + ); + +const projectionLayer = it.layer( + makeProjectionPipelineTestLayer(process.cwd()), ); projectionLayer("OrchestrationProjectionPipeline", (it) => { @@ -159,200 +178,154 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { }), ); - it.effect("materializes message image attachments into stateDir and stores URL references", () => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-")); - - const serverConfig = { - mode: "web", - port: 0, - host: undefined, - cwd: "/tmp/project-attachments", - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - stateDir, - staticDir: undefined, - devUrl: undefined, - noBrowser: true, - authToken: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - } satisfies ServerConfigShape; - - yield* eventStore.append({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-attachments"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-attachments"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-attachments"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-attachments"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-attachments"), - messageId: MessageId.makeUnsafe("message-attachments"), - role: "user", - text: "Inspect this", - attachments: [ + it.effect("stores message attachment references without mutating payloads", () => + Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe( + Effect.flatMap((stateDir) => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-attachments"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-attachments"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-attachments"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-attachments"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-attachments"), + messageId: MessageId.makeUnsafe("message-attachments"), + role: "user", + text: "Inspect this", + attachments: [ + { + type: "image", + id: "thread-attachments-att-1", + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly attachmentsJson: string | null; + }>` + SELECT + attachments_json AS "attachmentsJson" + FROM projection_thread_messages + WHERE message_id = 'message-attachments' + `; + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ { type: "image", + id: "thread-attachments-att-1", name: "example.png", mimeType: "image/png", sizeBytes: 5, - dataUrl: "data:image/png;base64,SGVsbG8=", }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); - - yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); - - const rows = yield* sql<{ - readonly attachmentsJson: string | null; - }>` - SELECT - attachments_json AS "attachmentsJson" - FROM projection_thread_messages - WHERE message_id = 'message-attachments' - `; - assert.equal(rows.length, 1); - assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ - { - type: "image", - name: "example.png", - mimeType: "image/png", - sizeBytes: 5, - dataUrl: "/attachments/thread-attachments/message-attachments-0.png", - }, - ]); - - const attachmentPath = path.join( - stateDir, - "attachments", - "thread-attachments", - "message-attachments-0.png", - ); - assert.equal(fs.existsSync(attachmentPath), true); - assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("SGVsbG8=", "base64")); - fs.rmSync(stateDir, { recursive: true, force: true }); - }), + ]); + }).pipe( + (effect) => runWithProjectionPipelineLayer(stateDir, effect), + Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), + ), + ), + ), ); - it.effect("materializes only image data URLs and uses a safe extension whitelist", () => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const now = new Date().toISOString(); - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-")); - - const serverConfig = { - mode: "web", - port: 0, - host: undefined, - cwd: "/tmp/project-attachments", - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - stateDir, - staticDir: undefined, - devUrl: undefined, - noBrowser: true, - authToken: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - } satisfies ServerConfigShape; - - yield* eventStore.append({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-attachments-safe"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-attachments-safe"), - causationEventId: null, - correlationId: CommandId.makeUnsafe("cmd-attachments-safe"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-attachments-safe"), - messageId: MessageId.makeUnsafe("message-attachments-safe"), - role: "user", - text: "Inspect this", - attachments: [ + it.effect("preserves mixed image attachment metadata as-is", () => + Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe( + Effect.flatMap((stateDir) => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-attachments-safe"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-attachments-safe"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-attachments-safe"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-attachments-safe"), + messageId: MessageId.makeUnsafe("message-attachments-safe"), + role: "user", + text: "Inspect this", + attachments: [ + { + type: "image", + id: "thread-attachments-safe-att-1", + name: "untrusted.exe", + mimeType: "image/x-unknown", + sizeBytes: 5, + }, + { + type: "image", + id: "thread-attachments-safe-att-2", + name: "not-image.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly attachmentsJson: string | null; + }>` + SELECT + attachments_json AS "attachmentsJson" + FROM projection_thread_messages + WHERE message_id = 'message-attachments-safe' + `; + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ { type: "image", + id: "thread-attachments-safe-att-1", name: "untrusted.exe", mimeType: "image/x-unknown", sizeBytes: 5, - dataUrl: "data:image/x-unknown;base64,SGVsbG8=", }, { type: "image", + id: "thread-attachments-safe-att-2", name: "not-image.png", mimeType: "image/png", sizeBytes: 5, - dataUrl: "data:text/plain;base64,SGVsbG8=", }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }); - - yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); - - const rows = yield* sql<{ - readonly attachmentsJson: string | null; - }>` - SELECT - attachments_json AS "attachmentsJson" - FROM projection_thread_messages - WHERE message_id = 'message-attachments-safe' - `; - assert.equal(rows.length, 1); - assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ - { - type: "image", - name: "untrusted.exe", - mimeType: "image/x-unknown", - sizeBytes: 5, - dataUrl: "/attachments/thread-attachments-safe/message-attachments-safe-0.bin", - }, - { - type: "image", - name: "not-image.png", - mimeType: "image/png", - sizeBytes: 5, - dataUrl: "data:text/plain;base64,SGVsbG8=", - }, - ]); - - const firstAttachmentPath = path.join( - stateDir, - "attachments", - "thread-attachments-safe", - "message-attachments-safe-0.bin", - ); - assert.equal(fs.existsSync(firstAttachmentPath), true); - assert.deepEqual(fs.readFileSync(firstAttachmentPath), Buffer.from("SGVsbG8=", "base64")); - - const secondAttachmentPath = path.join( - stateDir, - "attachments", - "thread-attachments-safe", - "message-attachments-safe-1.png", - ); - assert.equal(fs.existsSync(secondAttachmentPath), false); - fs.rmSync(stateDir, { recursive: true, force: true }); - }), + ]); + }).pipe( + (effect) => runWithProjectionPipelineLayer(stateDir, effect), + Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), + ), + ), + ), ); it.effect( @@ -426,10 +399,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { attachments: [ { type: "image", + id: "thread-clear-attachments-att-1", name: "clear.png", mimeType: "image/png", sizeBytes: 5, - dataUrl: "data:image/png;base64,SGVsbG8=", }, ], turnId: null, @@ -478,33 +451,20 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { ); it.effect( - "overwrites attachment file bytes when a message updates the same attachment index", + "overwrites stored attachment references when a message updates attachments", () => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const stateDir = fs.mkdtempSync( - path.join(os.tmpdir(), "t3-projection-attachments-overwrite-"), - ); - const now = new Date().toISOString(); - const later = new Date(Date.now() + 1_000).toISOString(); - - const serverConfig = { - mode: "web", - port: 0, - host: undefined, - cwd: "/tmp/project-attachments", - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - stateDir, - staticDir: undefined, - devUrl: undefined, - noBrowser: true, - authToken: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - } satisfies ServerConfigShape; - - yield* eventStore.append({ + Effect.sync(() => + fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")), + ).pipe( + Effect.flatMap((stateDir) => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + const later = new Date(Date.now() + 1_000).toISOString(); + + yield* eventStore.append({ type: "project.created", eventId: EventId.makeUnsafe("evt-overwrite-1"), aggregateKind: "project", @@ -565,10 +525,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { attachments: [ { type: "image", + id: "thread-overwrite-att-1", name: "file.png", mimeType: "image/png", sizeBytes: 5, - dataUrl: "data:image/png;base64,SGVsbG8=", }, ], turnId: null, @@ -596,10 +556,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { attachments: [ { type: "image", + id: "thread-overwrite-att-2", name: "file.png", mimeType: "image/png", sizeBytes: 5, - dataUrl: "data:image/png;base64,V29ybGQ=", }, ], turnId: null, @@ -609,95 +569,95 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { }, }); - yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); - - const attachmentPath = path.join( - stateDir, - "attachments", - "thread-overwrite", - "message-overwrite-0.png", - ); - assert.equal(fs.existsSync(attachmentPath), true); - assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("V29ybGQ=", "base64")); - fs.rmSync(stateDir, { recursive: true, force: true }); - }), + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly attachmentsJson: string | null; + }>` + SELECT attachments_json AS "attachmentsJson" + FROM projection_thread_messages + WHERE message_id = 'message-overwrite' + `; + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ + { + type: "image", + id: "thread-overwrite-att-2", + name: "file.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ]); + }).pipe( + (effect) => runWithProjectionPipelineLayer(stateDir, effect), + Effect.ensuring( + Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true })), + ), + ), + ), + ), ); it.effect("does not persist attachment files when projector transaction rolls back", () => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const sql = yield* SqlClient.SqlClient; - const stateDir = fs.mkdtempSync( - path.join(os.tmpdir(), "t3-projection-attachments-rollback-"), - ); - const now = new Date().toISOString(); - - const serverConfig = { - mode: "web", - port: 0, - host: undefined, - cwd: "/tmp/project-attachments", - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - stateDir, - staticDir: undefined, - devUrl: undefined, - noBrowser: true, - authToken: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - } satisfies ServerConfigShape; - - const appendAndProject = (event: Parameters[0]) => - eventStore.append(event).pipe( - Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), - Effect.provideService(ServerConfig, serverConfig), - ); - - yield* appendAndProject({ - type: "project.created", - eventId: EventId.makeUnsafe("evt-rollback-1"), - aggregateKind: "project", - aggregateId: ProjectId.makeUnsafe("project-rollback"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-rollback-1"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"), - metadata: {}, - payload: { - projectId: ProjectId.makeUnsafe("project-rollback"), - title: "Project Rollback", - workspaceRoot: "/tmp/project-rollback", - defaultModel: null, - scripts: [], - createdAt: now, - updatedAt: now, - }, - }); - - yield* appendAndProject({ - type: "thread.created", - eventId: EventId.makeUnsafe("evt-rollback-2"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-rollback"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-rollback-2"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-rollback"), - projectId: ProjectId.makeUnsafe("project-rollback"), - title: "Thread Rollback", - model: "gpt-5-codex", - branch: null, - worktreePath: null, - createdAt: now, - updatedAt: now, - }, - }); + Effect.sync(() => + fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-rollback-")), + ).pipe( + Effect.flatMap((stateDir) => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = new Date().toISOString(); + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-rollback-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-rollback"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-rollback-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-rollback"), + title: "Project Rollback", + workspaceRoot: "/tmp/project-rollback", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-rollback-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-rollback"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-rollback-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-rollback"), + projectId: ProjectId.makeUnsafe("project-rollback"), + title: "Thread Rollback", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); - yield* sql` + yield* sql` CREATE TRIGGER fail_thread_messages_projection_state_update BEFORE UPDATE ON projection_state WHEN NEW.projector = 'projection.thread-messages' @@ -706,90 +666,85 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { END; `; - const result = yield* Effect.result( - appendAndProject({ - type: "thread.message-sent", - eventId: EventId.makeUnsafe("evt-rollback-3"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-rollback"), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-rollback-3"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe("thread-rollback"), - messageId: MessageId.makeUnsafe("message-rollback"), - role: "user", - text: "Rollback me", - attachments: [ - { - type: "image", - name: "rollback.png", - mimeType: "image/png", - sizeBytes: 5, - dataUrl: "data:image/png;base64,SGVsbG8=", + const result = yield* Effect.result( + appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-rollback-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-rollback"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-rollback-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-rollback"), + messageId: MessageId.makeUnsafe("message-rollback"), + role: "user", + text: "Rollback me", + attachments: [ + { + type: "image", + id: "thread-rollback-att-1", + name: "rollback.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, }, - ], - turnId: null, - streaming: false, - createdAt: now, - updatedAt: now, - }, - }), - ); - assert.equal(result._tag, "Failure"); + }), + ); + assert.equal(result._tag, "Failure"); - const rows = yield* sql<{ - readonly count: number; - }>` + const rows = yield* sql<{ + readonly count: number; + }>` SELECT COUNT(*) AS "count" FROM projection_thread_messages WHERE message_id = 'message-rollback' `; - assert.equal(rows[0]?.count ?? 0, 0); - - const attachmentPath = path.join( - stateDir, - "attachments", - "thread-rollback", - "message-rollback-0.png", - ); - assert.equal(fs.existsSync(attachmentPath), false); - yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`; - fs.rmSync(stateDir, { recursive: true, force: true }); - }), + assert.equal(rows[0]?.count ?? 0, 0); + + const attachmentPath = path.join( + stateDir, + "attachments", + "thread-rollback-att-1.png", + ); + assert.equal(fs.existsSync(attachmentPath), false); + yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`; + }).pipe( + (effect) => runWithProjectionPipelineLayer(stateDir, effect), + Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), + ), + ), + ), ); it.effect("removes unreferenced attachment files when a thread is reverted", () => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-revert-")); - const now = new Date().toISOString(); - - const serverConfig = { - mode: "web", - port: 0, - host: undefined, - cwd: "/tmp/project-attachments", - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - stateDir, - staticDir: undefined, - devUrl: undefined, - noBrowser: true, - authToken: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - } satisfies ServerConfigShape; - - const appendAndProject = (event: Parameters[0]) => - eventStore.append(event).pipe( - Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), - Effect.provideService(ServerConfig, serverConfig), - ); - - yield* appendAndProject({ + Effect.sync(() => + fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-revert-")), + ).pipe( + Effect.flatMap((stateDir) => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const now = new Date().toISOString(); + const threadId = ThreadId.makeUnsafe("Thread Revert.Files"); + const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001"; + const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002"; + const otherThreadAttachmentId = + "thread-revert-files-extra-00000000-0000-4000-8000-000000000003"; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ type: "project.created", eventId: EventId.makeUnsafe("evt-revert-files-1"), aggregateKind: "project", @@ -814,14 +769,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { type: "thread.created", eventId: EventId.makeUnsafe("evt-revert-files-2"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-revert-files"), + aggregateId: threadId, occurredAt: now, commandId: CommandId.makeUnsafe("cmd-revert-files-2"), causationEventId: null, correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-revert-files"), + threadId, projectId: ProjectId.makeUnsafe("project-revert-files"), title: "Thread Revert Files", model: "gpt-5-codex", @@ -836,14 +791,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { type: "thread.turn-diff-completed", eventId: EventId.makeUnsafe("evt-revert-files-3"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-revert-files"), + aggregateId: threadId, occurredAt: now, commandId: CommandId.makeUnsafe("cmd-revert-files-3"), causationEventId: null, correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-revert-files"), + threadId, turnId: TurnId.makeUnsafe("turn-keep"), checkpointTurnCount: 1, checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"), @@ -858,24 +813,24 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { type: "thread.message-sent", eventId: EventId.makeUnsafe("evt-revert-files-4"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-revert-files"), + aggregateId: threadId, occurredAt: now, commandId: CommandId.makeUnsafe("cmd-revert-files-4"), causationEventId: null, correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-revert-files"), + threadId, messageId: MessageId.makeUnsafe("message-keep"), role: "assistant", text: "Keep", attachments: [ { type: "image", + id: keepAttachmentId, name: "keep.png", mimeType: "image/png", sizeBytes: 5, - dataUrl: "data:image/png;base64,SGVsbG8=", }, ], turnId: TurnId.makeUnsafe("turn-keep"), @@ -889,14 +844,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { type: "thread.turn-diff-completed", eventId: EventId.makeUnsafe("evt-revert-files-5"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-revert-files"), + aggregateId: threadId, occurredAt: now, commandId: CommandId.makeUnsafe("cmd-revert-files-5"), causationEventId: null, correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-revert-files"), + threadId, turnId: TurnId.makeUnsafe("turn-remove"), checkpointTurnCount: 2, checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"), @@ -911,24 +866,24 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { type: "thread.message-sent", eventId: EventId.makeUnsafe("evt-revert-files-6"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-revert-files"), + aggregateId: threadId, occurredAt: now, commandId: CommandId.makeUnsafe("cmd-revert-files-6"), causationEventId: null, correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-revert-files"), + threadId, messageId: MessageId.makeUnsafe("message-remove"), role: "assistant", text: "Remove", attachments: [ { type: "image", + id: removeAttachmentId, name: "remove.png", mimeType: "image/png", sizeBytes: 5, - dataUrl: "data:image/png;base64,V29ybGQ=", }, ], turnId: TurnId.makeUnsafe("turn-remove"), @@ -941,69 +896,69 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { const keepPath = path.join( stateDir, "attachments", - "thread-revert-files", - "message-keep-0.png", + `${keepAttachmentId}.png`, ); const removePath = path.join( stateDir, "attachments", - "thread-revert-files", - "message-remove-0.png", + `${removeAttachmentId}.png`, ); + fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true }); + fs.writeFileSync(keepPath, Buffer.from("keep")); + fs.writeFileSync(removePath, Buffer.from("remove")); + const otherThreadPath = path.join(stateDir, "attachments", `${otherThreadAttachmentId}.png`); + fs.writeFileSync(otherThreadPath, Buffer.from("other")); assert.equal(fs.existsSync(keepPath), true); assert.equal(fs.existsSync(removePath), true); + assert.equal(fs.existsSync(otherThreadPath), true); yield* appendAndProject({ type: "thread.reverted", eventId: EventId.makeUnsafe("evt-revert-files-7"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-revert-files"), + aggregateId: threadId, occurredAt: now, commandId: CommandId.makeUnsafe("cmd-revert-files-7"), causationEventId: null, correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-revert-files"), + threadId, turnCount: 1, }, }); - assert.equal(fs.existsSync(keepPath), true); - assert.equal(fs.existsSync(removePath), false); - fs.rmSync(stateDir, { recursive: true, force: true }); - }), + assert.equal(fs.existsSync(keepPath), true); + assert.equal(fs.existsSync(removePath), false); + assert.equal(fs.existsSync(otherThreadPath), true); + }).pipe( + (effect) => runWithProjectionPipelineLayer(stateDir, effect), + Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), + ), + ), + ), ); it.effect("removes thread attachment directory when thread is deleted", () => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-delete-")); - const now = new Date().toISOString(); - - const serverConfig = { - mode: "web", - port: 0, - host: undefined, - cwd: "/tmp/project-attachments", - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - stateDir, - staticDir: undefined, - devUrl: undefined, - noBrowser: true, - authToken: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - } satisfies ServerConfigShape; - - const appendAndProject = (event: Parameters[0]) => - eventStore.append(event).pipe( - Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), - Effect.provideService(ServerConfig, serverConfig), - ); - - yield* appendAndProject({ + Effect.sync(() => + fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-delete-")), + ).pipe( + Effect.flatMap((stateDir) => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const now = new Date().toISOString(); + const threadId = ThreadId.makeUnsafe("Thread Delete.Files"); + const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001"; + const otherThreadAttachmentId = + "thread-delete-files-extra-00000000-0000-4000-8000-000000000002"; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ type: "project.created", eventId: EventId.makeUnsafe("evt-delete-files-1"), aggregateKind: "project", @@ -1028,14 +983,14 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { type: "thread.created", eventId: EventId.makeUnsafe("evt-delete-files-2"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-delete-files"), + aggregateId: threadId, occurredAt: now, commandId: CommandId.makeUnsafe("cmd-delete-files-2"), causationEventId: null, correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-delete-files"), + threadId, projectId: ProjectId.makeUnsafe("project-delete-files"), title: "Thread Delete Files", model: "gpt-5-codex", @@ -1050,24 +1005,24 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { type: "thread.message-sent", eventId: EventId.makeUnsafe("evt-delete-files-3"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-delete-files"), + aggregateId: threadId, occurredAt: now, commandId: CommandId.makeUnsafe("cmd-delete-files-3"), causationEventId: null, correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-delete-files"), + threadId, messageId: MessageId.makeUnsafe("message-delete-files"), role: "user", text: "Delete", attachments: [ { type: "image", + id: attachmentId, name: "delete.png", mimeType: "image/png", sizeBytes: 5, - dataUrl: "data:image/png;base64,SGVsbG8=", }, ], turnId: null, @@ -1077,81 +1032,85 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { }, }); - const threadAttachmentDir = path.join(stateDir, "attachments", "thread-delete-files"); - assert.equal(fs.existsSync(threadAttachmentDir), true); + const threadAttachmentPath = path.join(stateDir, "attachments", `${attachmentId}.png`); + const otherThreadAttachmentPath = path.join( + stateDir, + "attachments", + `${otherThreadAttachmentId}.png`, + ); + fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true }); + fs.writeFileSync(threadAttachmentPath, Buffer.from("delete")); + fs.writeFileSync(otherThreadAttachmentPath, Buffer.from("other-thread")); + assert.equal(fs.existsSync(threadAttachmentPath), true); + assert.equal(fs.existsSync(otherThreadAttachmentPath), true); yield* appendAndProject({ type: "thread.deleted", eventId: EventId.makeUnsafe("evt-delete-files-4"), aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe("thread-delete-files"), + aggregateId: threadId, occurredAt: now, commandId: CommandId.makeUnsafe("cmd-delete-files-4"), causationEventId: null, correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"), metadata: {}, payload: { - threadId: ThreadId.makeUnsafe("thread-delete-files"), + threadId, deletedAt: now, }, }); - assert.equal(fs.existsSync(threadAttachmentDir), false); - fs.rmSync(stateDir, { recursive: true, force: true }); - }), + assert.equal(fs.existsSync(threadAttachmentPath), false); + assert.equal(fs.existsSync(otherThreadAttachmentPath), true); + }).pipe( + (effect) => runWithProjectionPipelineLayer(stateDir, effect), + Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), + ), + ), + ), ); it.effect("ignores unsafe thread ids for attachment cleanup paths", () => - Effect.gen(function* () { - const projectionPipeline = yield* OrchestrationProjectionPipeline; - const eventStore = yield* OrchestrationEventStore; - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-")); - const now = new Date().toISOString(); - const attachmentsRootDir = path.join(stateDir, "attachments"); - const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt"); - const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt"); - fs.mkdirSync(attachmentsRootDir, { recursive: true }); - fs.writeFileSync(attachmentsSentinelPath, "keep-attachments-root", "utf8"); - fs.writeFileSync(stateDirSentinelPath, "keep-state-dir", "utf8"); - - const serverConfig = { - mode: "web", - port: 0, - host: undefined, - cwd: "/tmp/project-attachments", - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - stateDir, - staticDir: undefined, - devUrl: undefined, - noBrowser: true, - authToken: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - } satisfies ServerConfigShape; - - yield* eventStore.append({ - type: "thread.deleted", - eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"), - aggregateKind: "thread", - aggregateId: ThreadId.makeUnsafe(".."), - occurredAt: now, - commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"), - causationEventId: null, - correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"), - metadata: {}, - payload: { - threadId: ThreadId.makeUnsafe(".."), - deletedAt: now, - }, - }); - - yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); - - assert.equal(fs.existsSync(attachmentsRootDir), true); - assert.equal(fs.existsSync(attachmentsSentinelPath), true); - assert.equal(fs.existsSync(stateDirSentinelPath), true); - fs.rmSync(stateDir, { recursive: true, force: true }); - }), + Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-"))).pipe( + Effect.flatMap((stateDir) => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const now = new Date().toISOString(); + const attachmentsRootDir = path.join(stateDir, "attachments"); + const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt"); + const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt"); + fs.mkdirSync(attachmentsRootDir, { recursive: true }); + fs.writeFileSync(attachmentsSentinelPath, "keep-attachments-root", "utf8"); + fs.writeFileSync(stateDirSentinelPath, "keep-state-dir", "utf8"); + + yield* eventStore.append({ + type: "thread.deleted", + eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe(".."), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe(".."), + deletedAt: now, + }, + }); + + yield* projectionPipeline.bootstrap; + + assert.equal(fs.existsSync(attachmentsRootDir), true); + assert.equal(fs.existsSync(attachmentsSentinelPath), true); + assert.equal(fs.existsSync(stateDirSentinelPath), true); + }).pipe( + (effect) => runWithProjectionPipelineLayer(stateDir, effect), + Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), + ), + ), + ), ); it.effect("resumes from projector last_applied_sequence without replaying older events", () => @@ -1868,7 +1827,11 @@ it.effect("restores pending turn-start metadata across projection pipeline resta ]); fs.rmSync(tempDir, { recursive: true, force: true }); - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe( + Effect.provide( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd()), NodeServices.layer), + ), + ), ); const engineLayer = it.layer( @@ -1877,6 +1840,8 @@ const engineLayer = it.layer( Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), ), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index c518744d138d..f4c8728f39fd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,4 +1,3 @@ -import Mime from "@effect/platform-node/Mime"; import { ApprovalRequestId, type ChatAttachment, @@ -39,10 +38,11 @@ import { type OrchestrationProjectionPipelineShape, } from "../Services/ProjectionPipeline.ts"; import { - ATTACHMENTS_ROUTE_PREFIX, - IMAGE_EXTENSION_BY_MIME_TYPE, - SAFE_IMAGE_FILE_EXTENSIONS, -} from "../../projectFaviconRoute.ts"; + attachmentRelativePath, + parseAttachmentIdFromRelativePath, + parseThreadSegmentFromAttachmentId, + toSafeThreadAttachmentSegment, +} from "../../attachmentStore.ts"; export const ORCHESTRATION_PROJECTOR_NAMES = { projects: "projection.projects", @@ -66,115 +66,16 @@ interface ProjectorDefinition { ) => Effect.Effect; } -interface PendingAttachmentWrite { - readonly absolutePath: string; - readonly bytes: Uint8Array; -} - interface AttachmentSideEffects { - readonly writes: Array; readonly deletedThreadIds: Set; readonly prunedThreadRelativePaths: Map>; } -function toSafeThreadAttachmentSegment(threadId: string): string | null { - const segment = encodeURIComponent(threadId); - if ( - segment.length === 0 || - segment === "." || - segment === ".." || - segment.includes("/") || - segment.includes("\\") || - segment.includes("\0") - ) { - return null; - } - return segment; -} - -function parseBase64DataUrl( - dataUrl: string, -): { readonly mimeType: string; readonly base64: string } | null { - const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim()); - if (!match) return null; - - const mimeType = match[1]?.trim().toLowerCase(); - const base64 = match[2]?.replace(/\s+/g, ""); - if (!mimeType || !base64) return null; - - return { mimeType, base64 }; -} - -function inferImageExtension(attachment: Extract): string { - const normalizedMimeType = attachment.mimeType.toLowerCase(); - if (normalizedMimeType.startsWith("image/")) { - const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[normalizedMimeType]; - if (fromMime) return fromMime; - } - const ext = Mime.getExtension(attachment.mimeType); - if (ext && SAFE_IMAGE_FILE_EXTENSIONS.has(ext)) return ext; - return ".bin"; -} - -const materializeAttachmentsForProjection = Effect.fn(function* (input: { - readonly threadId: string; - readonly messageId: string; - readonly attachments: ReadonlyArray; - readonly stageFileWrite: (pendingWrite: PendingAttachmentWrite) => void; -}) { - if (input.attachments.length === 0) return []; - - const serverConfig = yield* Effect.serviceOption(ServerConfig); - if (Option.isNone(serverConfig)) return input.attachments; - - const path = yield* Path.Path; - const attachmentsRootDir = path.join(serverConfig.value.stateDir, "attachments"); - const threadSegment = toSafeThreadAttachmentSegment(input.threadId); - if (!threadSegment) { - yield* Effect.logWarning("skipping attachment materialization for unsafe thread id", { - threadId: input.threadId, - messageId: input.messageId, - }); - return input.attachments; - } - const messageSegment = encodeURIComponent(input.messageId); - - return yield* Effect.forEach( - input.attachments, - (attachment, index) => - Effect.sync(() => { - if (attachment.type !== "image" || !attachment.dataUrl.startsWith("data:")) { - return attachment; - } - - const parsed = parseBase64DataUrl(attachment.dataUrl); - if (!parsed) { - return attachment; - } - if (!parsed.mimeType.startsWith("image/")) { - return attachment; - } - - const bytes = Buffer.from(parsed.base64, "base64"); - if (bytes.byteLength === 0) return attachment; - - const fileName = `${index}${inferImageExtension({ - ...attachment, - mimeType: parsed.mimeType, - })}`; - const uniqueFileName = `${messageSegment}-${fileName}`; - const relativePath = `${threadSegment}/${uniqueFileName}`; - const absolutePath = path.join(attachmentsRootDir, threadSegment, uniqueFileName); - input.stageFileWrite({ absolutePath, bytes }); - - return { - ...attachment, - dataUrl: `${ATTACHMENTS_ROUTE_PREFIX}/${relativePath}`, - } satisfies ChatAttachment; - }), - { concurrency: 1 }, - ); -}); +const materializeAttachmentsForProjection = Effect.fn( + (input: { + readonly attachments: ReadonlyArray; + }) => Effect.succeed(input.attachments.length === 0 ? [] : input.attachments), +); function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { if (typeof payload !== "object" || payload === null) { @@ -288,18 +189,6 @@ function retainProjectionActivitiesAfterRevert( ); } -function toAttachmentRelativePath(dataUrl: string): string | null { - const prefix = `${ATTACHMENTS_ROUTE_PREFIX}/`; - if (!dataUrl.startsWith(prefix)) { - return null; - } - const relativePath = dataUrl.slice(prefix.length).replace(/^[/\\]+/, ""); - if (relativePath.length === 0 || relativePath.startsWith("..")) { - return null; - } - return relativePath.replace(/\\/g, "/"); -} - function collectThreadAttachmentRelativePaths( threadId: string, messages: ReadonlyArray, @@ -308,41 +197,31 @@ function collectThreadAttachmentRelativePaths( if (!threadSegment) { return new Set(); } - const threadPrefix = `${threadSegment}/`; const relativePaths = new Set(); for (const message of messages) { for (const attachment of message.attachments ?? []) { if (attachment.type !== "image") { continue; } - const relativePath = toAttachmentRelativePath(attachment.dataUrl); - if (!relativePath || !relativePath.startsWith(threadPrefix)) { + const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id); + if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { continue; } - const threadRelativePath = relativePath.slice(threadPrefix.length).replace(/^[/\\]+/, ""); - if (threadRelativePath.length === 0 || threadRelativePath.startsWith("..")) { - continue; - } - relativePaths.add(threadRelativePath.replace(/\\/g, "/")); + relativePaths.add(attachmentRelativePath(attachment)); } } return relativePaths; } -const runAttachmentSideEffects = Effect.fn(function* (input: { - readonly sideEffects: AttachmentSideEffects; - readonly fileSystem: FileSystem.FileSystem; - readonly path: Path.Path; -}) { - const serverConfig = yield* Effect.serviceOption(ServerConfig); - if (Option.isNone(serverConfig)) { - return; - } +const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSideEffects) { + const serverConfig = yield* Effect.service(ServerConfig); + const fileSystem = yield* Effect.service(FileSystem.FileSystem); + const path = yield* Effect.service(Path.Path); - const attachmentsRootDir = input.path.join(serverConfig.value.stateDir, "attachments"); + const attachmentsRootDir = path.join(serverConfig.stateDir, "attachments"); yield* Effect.forEach( - input.sideEffects.deletedThreadIds, + sideEffects.deletedThreadIds, (threadId) => Effect.gen(function* () { const threadSegment = toSafeThreadAttachmentSegment(threadId); @@ -352,16 +231,39 @@ const runAttachmentSideEffects = Effect.fn(function* (input: { }); return; } - const threadDir = input.path.join(attachmentsRootDir, threadSegment); - yield* input.fileSystem.remove(threadDir, { recursive: true, force: true }); + const entries = yield* fileSystem + .readDirectory(attachmentsRootDir, { recursive: false }) + .pipe(Effect.catch(() => Effect.succeed([] as Array))); + yield* Effect.forEach( + entries, + (entry) => + Effect.gen(function* () { + const normalizedEntry = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); + if (normalizedEntry.length === 0 || normalizedEntry.includes("/")) { + return; + } + const attachmentId = parseAttachmentIdFromRelativePath(normalizedEntry); + if (!attachmentId) { + return; + } + const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId); + if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { + return; + } + yield* fileSystem.remove(path.join(attachmentsRootDir, normalizedEntry), { + force: true, + }); + }), + { concurrency: 1 }, + ); }), { concurrency: 1 }, ); yield* Effect.forEach( - input.sideEffects.prunedThreadRelativePaths.entries(), + sideEffects.prunedThreadRelativePaths.entries(), ([threadId, keptThreadRelativePaths]) => { - if (input.sideEffects.deletedThreadIds.has(threadId)) { + if (sideEffects.deletedThreadIds.has(threadId)) { return Effect.void; } return Effect.gen(function* () { @@ -370,29 +272,36 @@ const runAttachmentSideEffects = Effect.fn(function* (input: { yield* Effect.logWarning("skipping attachment prune for unsafe thread id", { threadId }); return; } - const threadDir = input.path.join(attachmentsRootDir, threadSegment); - const entries = yield* input.fileSystem - .readDirectory(threadDir, { recursive: true }) + const entries = yield* fileSystem + .readDirectory(attachmentsRootDir, { recursive: false }) .pipe(Effect.catch(() => Effect.succeed([] as Array))); yield* Effect.forEach( entries, (entry) => Effect.gen(function* () { - const threadRelativePath = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); - if (threadRelativePath.length === 0 || threadRelativePath.startsWith("..")) { + const relativePath = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); + if (relativePath.length === 0 || relativePath.includes("/")) { + return; + } + const attachmentId = parseAttachmentIdFromRelativePath(relativePath); + if (!attachmentId) { + return; + } + const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId); + if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { return; } - const absolutePath = input.path.join(threadDir, threadRelativePath); - const fileInfo = yield* input.fileSystem + const absolutePath = path.join(attachmentsRootDir, relativePath); + const fileInfo = yield* fileSystem .stat(absolutePath) .pipe(Effect.catch(() => Effect.succeed(null))); if (!fileInfo || fileInfo.type !== "File") { return; } - if (!keptThreadRelativePaths.has(threadRelativePath)) { - yield* input.fileSystem.remove(absolutePath, { force: true }); + if (!keptThreadRelativePaths.has(relativePath)) { + yield* fileSystem.remove(absolutePath, { force: true }); } }), { concurrency: 1 }, @@ -402,22 +311,6 @@ const runAttachmentSideEffects = Effect.fn(function* (input: { { concurrency: 1 }, ); - const writesByPath = new Map(); - for (const pendingWrite of input.sideEffects.writes) { - // Last write wins for a path so updated attachment contents replace stale bytes. - writesByPath.set(pendingWrite.absolutePath, pendingWrite.bytes); - } - yield* Effect.forEach( - writesByPath.entries(), - ([absolutePath, bytes]) => - Effect.gen(function* () { - yield* input.fileSystem.makeDirectory(input.path.dirname(absolutePath), { - recursive: true, - }); - yield* input.fileSystem.writeFile(absolutePath, bytes); - }), - { concurrency: 1 }, - ); }); const makeOrchestrationProjectionPipeline = Effect.gen(function* () { @@ -434,6 +327,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; const applyProjectsProjection: ProjectorDefinition["apply"] = (event, _attachmentSideEffects) => Effect.gen(function* () { @@ -634,13 +528,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { const nextAttachments = event.payload.attachments !== undefined ? yield* materializeAttachmentsForProjection({ - threadId: event.payload.threadId, - messageId: event.payload.messageId, attachments: event.payload.attachments, - stageFileWrite: (pendingWrite) => { - attachmentSideEffects.writes.push(pendingWrite); - }, - }).pipe(Effect.provideService(Path.Path, path)) + }) : existingMessage?.attachments; yield* projectionThreadMessageRepository.upsert({ messageId: event.payload.messageId, @@ -1131,7 +1020,6 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { const runProjectorForEvent = (projector: ProjectorDefinition, event: OrchestrationEvent) => Effect.gen(function* () { const attachmentSideEffects: AttachmentSideEffects = { - writes: [], deletedThreadIds: new Set(), prunedThreadRelativePaths: new Map>(), }; @@ -1148,11 +1036,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { ), ); - yield* runAttachmentSideEffects({ - sideEffects: attachmentSideEffects, - fileSystem, - path, - }).pipe( + yield* runAttachmentSideEffects(attachmentSideEffects).pipe( Effect.catch((cause) => Effect.logWarning("failed to apply projected attachment side-effects", { projector: projector.name, @@ -1184,6 +1068,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event), { concurrency: 1, }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ServerConfig, serverConfig), Effect.asVoid, Effect.catchTag("SqlError", (sqlError) => Effect.fail(toPersistenceSqlError("ProjectionPipeline.projectEvent:query")(sqlError)), @@ -1195,6 +1082,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { bootstrapProjector, { concurrency: 1 }, ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ServerConfig, serverConfig), Effect.asVoid, Effect.tap(() => Effect.log("orchestration projection pipeline bootstrapped").pipe( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 6bbf951b7eaf..3abe82b5440c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1,3 +1,7 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import type { ProviderRuntimeEvent } from "@t3tools/contracts"; import { ApprovalRequestId, @@ -13,6 +17,8 @@ import { import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { ServerConfig } from "../../config.ts"; +import { TextGenerationError } from "../../git/Errors.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; @@ -20,11 +26,14 @@ import { ProviderService, type ProviderServiceShape, } from "../../provider/Services/ProviderService.ts"; +import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; +import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { ProviderCommandReactorLive } from "./ProviderCommandReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); @@ -56,6 +65,7 @@ describe("ProviderCommandReactor", () => { unknown > | null = null; let scope: Scope.Closeable | null = null; + const createdStateDirs = new Set(); afterEach(async () => { if (scope) { @@ -66,10 +76,16 @@ describe("ProviderCommandReactor", () => { await runtime.dispose(); } runtime = null; + for (const stateDir of createdStateDirs) { + fs.rmSync(stateDir, { recursive: true, force: true }); + } + createdStateDirs.clear(); }); - async function createHarness() { + async function createHarness(input?: { readonly stateDir?: string }) { const now = new Date().toISOString(); + const stateDir = input?.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); + createdStateDirs.add(stateDir); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; const startSession = vi.fn((_: unknown, __: unknown) => { @@ -92,6 +108,16 @@ describe("ProviderCommandReactor", () => { const interruptTurn = vi.fn((_: unknown) => Effect.void); const respondToRequest = vi.fn((_: unknown) => Effect.void); const stopSession = vi.fn((_: unknown) => Effect.void); + const renameBranch = vi.fn((_: unknown) => + Effect.succeed({ + branch: "t3code/generated-name", + }), + ); + const generateBranchName = vi.fn(() => + Effect.succeed({ + branch: "generated-name", + }), + ); const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; const service: ProviderServiceShape = { @@ -115,8 +141,15 @@ describe("ProviderCommandReactor", () => { const layer = ProviderCommandReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), + Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), + Layer.provideMerge( + Layer.succeed(TextGeneration, { generateBranchName } as unknown as TextGenerationShape), + ), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), stateDir)), + Layer.provideMerge(NodeServices.layer), ); - runtime = ManagedRuntime.make(layer); + const runtime = ManagedRuntime.make(layer); + const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); scope = await Effect.runPromise(Scope.make("sequential")); @@ -155,6 +188,9 @@ describe("ProviderCommandReactor", () => { interruptTurn, respondToRequest, stopSession, + renameBranch, + generateBranchName, + stateDir, }; } @@ -196,6 +232,247 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.sandboxMode).toBe("workspace-write"); }); + it("generates and renames temporary worktree branch on first turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch"), + threadId: ThreadId.makeUnsafe("thread-1"), + branch: "t3code/89abc123", + worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-worktree-rename"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-worktree-rename"), + role: "user", + text: "Fix visual bug from screenshot", + attachments: [ + { + type: "image", + id: "thread-1-att-rename", + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }, + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + await waitFor(() => harness.renameBranch.mock.calls.length === 1); + + expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({ + cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", + message: "Fix visual bug from screenshot", + attachments: [ + { + type: "image", + id: "thread-1-att-rename", + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }); + expect(harness.renameBranch.mock.calls[0]?.[0]).toEqual({ + cwd: "/tmp/provider-project/.t3/worktrees/t3code-89abc123", + oldBranch: "t3code/89abc123", + newBranch: "t3code/generated-name", + }); + + await waitFor(() => { + const readModel = Effect.runSync(harness.engine.getReadModel()); + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); + return thread?.branch === "t3code/generated-name"; + }); + }); + + it("passes persisted attachment references to branch generation and turn start", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-persisted"), + threadId: ThreadId.makeUnsafe("thread-1"), + branch: "t3code/abcdef12", + worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-persisted-attachments"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-persisted-attachments"), + role: "user", + text: "Fix visual bug from screenshot", + attachments: [ + { + type: "image", + id: "thread-1-att-persisted", + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }, + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({ + cwd: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12", + message: "Fix visual bug from screenshot", + attachments: [ + { + type: "image", + id: "thread-1-att-persisted", + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }); + expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ + attachments: [ + { + type: "image", + id: "thread-1-att-persisted", + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }); + }); + + it("skips worktree branch generation after the first user turn", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + branch: "t3code/1234abcd", + worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-1234abcd", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-first"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-first"), + role: "user", + text: "first", + attachments: [], + }, + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-second"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-second"), + role: "user", + text: "second", + attachments: [], + }, + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.generateBranchName.mock.calls.length).toBe(1); + expect(harness.renameBranch.mock.calls.length).toBe(1); + }); + + it("skips worktree rename when branch-name generation fails", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + harness.generateBranchName.mockImplementationOnce(() => + Effect.fail( + new TextGenerationError({ + operation: "generateBranchName", + detail: "model returned invalid payload", + }), + ), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-null"), + threadId: ThreadId.makeUnsafe("thread-1"), + branch: "t3code/0000abcd", + worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-0000abcd", + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-null-branch"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-null-branch"), + role: "user", + text: "Fix visual regression", + attachments: [], + }, + approvalPolicy: "on-request", + sandboxMode: "workspace-write", + createdAt: now, + }), + ); + + await waitFor(() => harness.generateBranchName.mock.calls.length === 1); + await Effect.runPromise(Effect.sleep("20 millis")); + expect(harness.renameBranch.mock.calls.length).toBe(0); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.branch).toBe("t3code/0000abcd"); + }); + it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -328,8 +605,8 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); - harness.startSession.mockImplementationOnce((_: unknown, __: unknown) => - Effect.fail(new Error("simulated restart failure")) as never, + harness.startSession.mockImplementationOnce( + (_: unknown, __: unknown) => Effect.fail(new Error("simulated restart failure")) as never, ); await Effect.runPromise( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 1a206ff64615..bf88e221b91c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -15,6 +15,8 @@ import { import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; +import { GitCore } from "../../git/Services/GitCore.ts"; +import { TextGeneration } from "../../git/Services/TextGeneration.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { @@ -66,10 +68,41 @@ const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_APPROVAL_POLICY: ProviderApprovalPolicy = "never"; const DEFAULT_SANDBOX_MODE: ProviderSandboxMode = "workspace-write"; +const WORKTREE_BRANCH_PREFIX = "t3code"; +const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); + +function isTemporaryWorktreeBranch(branch: string): boolean { + return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); +} + +function buildGeneratedWorktreeBranchName(raw: string): string { + const normalized = raw + .trim() + .toLowerCase() + .replace(/^refs\/heads\//, "") + .replace(/['"`]/g, ""); + + const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`) + ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length) + : normalized; + + const branchFragment = withoutPrefix + .replace(/[^a-z0-9/_-]+/g, "-") + .replace(/\/+/g, "/") + .replace(/-+/g, "-") + .replace(/^[./_-]+|[./_-]+$/g, "") + .slice(0, 64) + .replace(/[./_-]+$/g, ""); + + const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; + return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; +} const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; + const git = yield* GitCore; + const textGeneration = yield* TextGeneration; const handledTurnStartKeys = yield* Cache.make({ capacity: HANDLED_TURN_START_KEY_MAX, timeToLive: HANDLED_TURN_START_KEY_TTL, @@ -190,7 +223,8 @@ const make = Effect.gen(function* () { const existingSessionId = thread.session?.providerSessionId; if (existingSessionId) { const approvalPolicyChanged = - options?.approvalPolicy !== undefined && options.approvalPolicy !== thread.session?.approvalPolicy; + options?.approvalPolicy !== undefined && + options.approvalPolicy !== thread.session?.approvalPolicy; const sandboxModeChanged = options?.sandboxMode !== undefined && options.sandboxMode !== thread.session?.sandboxMode; @@ -198,7 +232,9 @@ const make = Effect.gen(function* () { return existingSessionId; } - const restartedSession = yield* startProviderSession(thread.session?.providerThreadId ?? null); + const restartedSession = yield* startProviderSession( + thread.session?.providerThreadId ?? null, + ); yield* bindSessionToThread(restartedSession); yield* providerService.stopSession({ sessionId: existingSessionId }).pipe( Effect.catchCause((cause) => @@ -247,6 +283,74 @@ const make = Effect.gen(function* () { }); }); + const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: { + readonly threadId: ThreadId; + readonly branch: string | null; + readonly worktreePath: string | null; + readonly messageId: string; + readonly messageText: string; + readonly attachments?: ReadonlyArray; + }) { + if (!input.branch || !input.worktreePath) { + return; + } + if (!isTemporaryWorktreeBranch(input.branch)) { + return; + } + + const thread = yield* resolveThread(input.threadId); + if (!thread) { + return; + } + + const userMessages = thread.messages.filter((message) => message.role === "user"); + if (userMessages.length !== 1 || userMessages[0]?.id !== input.messageId) { + return; + } + + const oldBranch = input.branch; + const cwd = input.worktreePath; + const attachments = input.attachments ?? []; + yield* textGeneration + .generateBranchName({ + cwd, + message: input.messageText, + ...(attachments.length > 0 ? { attachments } : {}), + }) + .pipe( + Effect.catch((error) => + Effect.logWarning( + "provider command reactor failed to generate worktree branch name; skipping rename", + { threadId: input.threadId, cwd, oldBranch, reason: error.message }, + ), + ), + Effect.flatMap((generated) => { + if (!generated) return Effect.void; + + const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + if (targetBranch === oldBranch) return Effect.void; + + return Effect.flatMap( + git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }), + (renamed) => + orchestrationEngine.dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("worktree-branch-rename"), + threadId: input.threadId, + branch: renamed.branch, + worktreePath: cwd, + }), + ); + }), + Effect.catchCause((cause) => + Effect.logWarning( + "provider command reactor failed to generate or rename worktree branch", + { threadId: input.threadId, cwd, oldBranch, cause: Cause.pretty(cause) }, + ), + ), + ); + }); + const processTurnStartRequested = Effect.fnUntraced(function* ( event: Extract, ) { @@ -273,6 +377,15 @@ const make = Effect.gen(function* () { return; } + yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ + threadId: event.payload.threadId, + branch: thread.branch, + worktreePath: thread.worktreePath, + messageId: message.id, + messageText: message.text, + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + }).pipe(Effect.forkScoped); + yield* sendTurnForThread({ threadId: event.payload.threadId, messageText: message.text, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index fa254887d8f4..55a8452be891 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -28,11 +28,12 @@ import { type OrchestrationEngineShape, } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; +import { ServerConfig } from "../../config.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asSessionId = (value: string): ProviderSessionId => ProviderSessionId.makeUnsafe(value); -const asProviderThreadId = (value: string): ProviderThreadId => - ProviderThreadId.makeUnsafe(value); +const asProviderThreadId = (value: string): ProviderThreadId => ProviderThreadId.makeUnsafe(value); const asProviderTurnId = (value: string): ProviderTurnId => ProviderTurnId.makeUnsafe(value); const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); @@ -122,6 +123,8 @@ describe("ProviderRuntimeIngestion", () => { const layer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), ); runtime = ManagedRuntime.make(layer); const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); @@ -255,7 +258,9 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise(Effect.sleep("40 millis")); const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); - const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const midThread = midReadModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); expect(midThread?.session?.status).toBe("running"); expect(midThread?.session?.activeTurnId).toBe("turn-primary"); @@ -308,7 +313,9 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise(Effect.sleep("40 millis")); const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); - const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const midThread = midReadModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); expect(midThread?.session?.status).toBe("running"); expect(midThread?.session?.activeTurnId).toBe("turn-guarded-main"); @@ -401,7 +408,9 @@ describe("ProviderRuntimeIngestion", () => { await Effect.runPromise(Effect.sleep("30 millis")); const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); - const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + const midThread = midReadModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), + ); expect(midThread?.messages.some((message) => message.id === "assistant:item-buffered")).toBe( false, ); @@ -416,10 +425,10 @@ describe("ProviderRuntimeIngestion", () => { itemId: asItemId("item-buffered"), }); - const thread = await waitForThread( - harness.engine, - (entry) => - entry.messages.some((message) => message.id === "assistant:item-buffered" && !message.streaming), + const thread = await waitForThread(harness.engine, (entry) => + entry.messages.some( + (message) => message.id === "assistant:item-buffered" && !message.streaming, + ), ); const message = thread.messages.find((entry) => entry.id === "assistant:item-buffered"); expect(message?.text).toBe("buffer me"); @@ -475,15 +484,13 @@ describe("ProviderRuntimeIngestion", () => { delta: "hello live", }); - const liveThread = await waitForThread( - harness.engine, - (entry) => - entry.messages.some( - (message) => - message.id === "assistant:item-streaming-mode" && - message.streaming && - message.text === "hello live", - ), + const liveThread = await waitForThread(harness.engine, (entry) => + entry.messages.some( + (message) => + message.id === "assistant:item-streaming-mode" && + message.streaming && + message.text === "hello live", + ), ); const liveMessage = liveThread.messages.find( (entry) => entry.id === "assistant:item-streaming-mode", @@ -500,12 +507,10 @@ describe("ProviderRuntimeIngestion", () => { itemId: asItemId("item-streaming-mode"), }); - const finalThread = await waitForThread( - harness.engine, - (entry) => - entry.messages.some( - (message) => message.id === "assistant:item-streaming-mode" && !message.streaming, - ), + const finalThread = await waitForThread(harness.engine, (entry) => + entry.messages.some( + (message) => message.id === "assistant:item-streaming-mode" && !message.streaming, + ), ); const finalMessage = finalThread.messages.find( (entry) => entry.id === "assistant:item-streaming-mode", @@ -554,12 +559,10 @@ describe("ProviderRuntimeIngestion", () => { itemId: asItemId("item-buffer-spill"), }); - const thread = await waitForThread( - harness.engine, - (entry) => - entry.messages.some( - (message) => message.id === "assistant:item-buffer-spill" && !message.streaming, - ), + const thread = await waitForThread(harness.engine, (entry) => + entry.messages.some( + (message) => message.id === "assistant:item-buffer-spill" && !message.streaming, + ), ); const message = thread.messages.find((entry) => entry.id === "assistant:item-buffer-spill"); expect(message?.text.length).toBe(oversizedText.length); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 194c2943fec2..b761387d474c 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -21,10 +21,10 @@ layer("ProjectionThreadMessageRepository", (it) => { const persistedAttachments = [ { type: "image" as const, + id: "thread-preserve-attachments-att-1", name: "example.png", mimeType: "image/png", sizeBytes: 5, - dataUrl: "/attachments/thread-preserve-attachments/message-preserve-attachments/0.png", }, ]; @@ -74,10 +74,10 @@ layer("ProjectionThreadMessageRepository", (it) => { attachments: [ { type: "image", + id: "thread-clear-attachments-att-1", name: "example.png", mimeType: "image/png", sizeBytes: 5, - dataUrl: "/attachments/thread-clear-attachments/message-clear-attachments/0.png", }, ], isStreaming: false, diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts index cddf2c15af1a..4d66f0e86f31 100644 --- a/apps/server/src/projectFaviconRoute.ts +++ b/apps/server/src/projectFaviconRoute.ts @@ -2,32 +2,6 @@ import fs from "node:fs"; import http from "node:http"; import path from "node:path"; -export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; - -export const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/jpg": ".jpg", - "image/gif": ".gif", - "image/webp": ".webp", - "image/svg+xml": ".svg", - "image/bmp": ".bmp", - "image/tiff": ".tiff", - "image/heic": ".heic", -}; - -export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ - ".jpg", - ".jpeg", - ".png", - ".gif", - ".webp", - ".bmp", - ".tiff", - ".svg", - ".ico", -]); - const FAVICON_MIME_TYPES: Record = { ".png": "image/png", ".jpg": "image/jpeg", diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index c51e6963aa49..047adc6e4efd 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -4,7 +4,6 @@ import { ProviderItemId, type ProviderApprovalDecision, type ProviderEvent, - type ProviderSendTurnInput, ProviderSessionId, type ProviderSession, type ProviderSessionStartInput, @@ -12,12 +11,17 @@ import { ProviderTurnId, type ProviderTurnStartResult, } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { afterAll, assert, it, vi } from "@effect/vitest"; import { assertFailure } from "@effect/vitest/utils"; -import { Effect, Fiber, Stream } from "effect"; +import { Effect, Fiber, Layer, Stream } from "effect"; -import { CodexAppServerManager } from "../../codexAppServerManager.ts"; +import { + CodexAppServerManager, + type CodexAppServerSendTurnInput, +} from "../../codexAppServerManager.ts"; +import { ServerConfig } from "../../config.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { makeCodexAdapterLive } from "./CodexAdapter.ts"; @@ -44,7 +48,7 @@ class FakeCodexManager extends CodexAppServerManager { ); public sendTurnImpl = vi.fn( - async (_input: ProviderSendTurnInput): Promise => ({ + async (_input: CodexAppServerSendTurnInput): Promise => ({ threadId: ProviderThreadId.makeUnsafe("thread-1"), turnId: asTurnId("turn-1"), }), @@ -78,7 +82,7 @@ class FakeCodexManager extends CodexAppServerManager { return this.startSessionImpl(input); } - override sendTurn(input: ProviderSendTurnInput): Promise { + override sendTurn(input: CodexAppServerSendTurnInput): Promise { return this.sendTurnImpl(input); } @@ -118,7 +122,12 @@ class FakeCodexManager extends CodexAppServerManager { } const validationManager = new FakeCodexManager(); -const validationLayer = it.layer(makeCodexAdapterLive({ manager: validationManager })); +const validationLayer = it.layer( + makeCodexAdapterLive({ manager: validationManager }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), + ), +); validationLayer("CodexAdapterLive validation", (it) => { it.effect("returns validation error for non-codex provider on startSession", () => @@ -147,7 +156,12 @@ const sessionErrorManager = new FakeCodexManager(); sessionErrorManager.sendTurnImpl.mockImplementation(async () => { throw new Error("Unknown session: sess-missing"); }); -const sessionErrorLayer = it.layer(makeCodexAdapterLive({ manager: sessionErrorManager })); +const sessionErrorLayer = it.layer( + makeCodexAdapterLive({ manager: sessionErrorManager }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), + ), +); sessionErrorLayer("CodexAdapterLive session errors", (it) => { it.effect("maps unknown-session sendTurn errors to ProviderAdapterSessionNotFoundError", () => @@ -178,7 +192,12 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }); const lifecycleManager = new FakeCodexManager(); -const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager })); +const lifecycleLayer = it.layer( + makeCodexAdapterLive({ manager: lifecycleManager }).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), + ), +); lifecycleLayer("CodexAdapterLive lifecycle", (it) => { it.effect("maps completed agent message items to canonical message.completed events", () => diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 35f517a55a91..cd678c84ff79 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -17,7 +17,7 @@ import { ProviderThreadId, ProviderTurnId, } from "@t3tools/contracts"; -import { Effect, Layer, Queue, Schema, Stream } from "effect"; +import { Effect, FileSystem, Layer, Queue, Schema, Stream } from "effect"; import { ProviderAdapterProcessError, @@ -29,6 +29,8 @@ import { } from "../Errors.ts"; import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { CodexAppServerManager } from "../../codexAppServerManager.ts"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "codex" as const; @@ -426,6 +428,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* Effect.service(ServerConfig); const nativeEventLogger = options?.nativeEventLogPath !== undefined ? makeEventNdjsonLogger(options.nativeEventLogPath) @@ -475,9 +479,48 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => }; const sendTurn: CodexAdapterShape["sendTurn"] = (input) => - Effect.tryPromise({ - try: () => manager.sendTurn(input), - catch: (cause) => toRequestError(input.sessionId, "turn/start", cause), + Effect.gen(function* () { + const codexAttachments = yield* Effect.forEach( + input.attachments ?? [], + (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + stateDir: serverConfig.stateDir, + attachment, + }); + if (!attachmentPath) { + return yield* Effect.fail( + toRequestError( + input.sessionId, + "turn/start", + new Error(`Invalid attachment id '${attachment.id}'.`), + ), + ); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)), + ); + return { + type: "image" as const, + url: `data:${attachment.mimeType};base64,${Buffer.from(bytes).toString("base64")}`, + }; + }), + { concurrency: 1 }, + ); + + return yield* Effect.tryPromise({ + try: () => { + const managerInput = { + sessionId: input.sessionId, + ...(input.input !== undefined ? { input: input.input } : {}), + ...(input.model !== undefined ? { model: input.model } : {}), + ...(input.effort !== undefined ? { effort: input.effort } : {}), + ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), + }; + return manager.sendTurn(managerInput); + }, + catch: (cause) => toRequestError(input.sessionId, "turn/start", cause), + }); }); const interruptTurn: CodexAdapterShape["interruptTurn"] = (sessionId, turnId) => diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index d5de9b4e9d15..632c0bb3e939 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -1,7 +1,7 @@ import path from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Effect, Layer } from "effect"; +import { Effect, FileSystem, Layer } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; @@ -37,7 +37,7 @@ import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; export function makeServerProviderLayer(): Layer.Layer< ProviderService, ProviderUnsupportedError, - SqlClient.SqlClient | ServerConfig + SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem > { return Effect.gen(function* () { const { stateDir } = yield* ServerConfig; @@ -56,6 +56,9 @@ export function makeServerProviderLayer(): Layer.Layer< } export function makeServerRuntimeServicesLayer() { + const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); + const textGenerationLayer = CodexTextGenerationLive; + const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), Layer.provide(OrchestrationEventStoreLive), @@ -78,6 +81,8 @@ export function makeServerRuntimeServicesLayer() { ); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(textGenerationLayer), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), @@ -92,12 +97,10 @@ export function makeServerRuntimeServicesLayer() { Layer.provide(typeof Bun !== "undefined" ? BunPtyAdapterLive : NodePtyAdapterLive), ); - const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); - const gitManagerLayer = GitManagerLive.pipe( Layer.provideMerge(gitCoreLayer), Layer.provideMerge(GitHubCliLive), - Layer.provideMerge(CodexTextGenerationLive), + Layer.provideMerge(textGenerationLayer), ); return Layer.mergeAll( diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index d821ac446e6c..1a278e2d3272 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -369,7 +369,10 @@ describe("WebSocket Server", () => { providerLayer?: Layer.Layer; open?: OpenShape; gitManager?: GitManagerShape; - gitCore?: Pick; + gitCore?: Pick< + GitCoreShape, + "listBranches" | "initRepo" | "pullCurrentBranch" + >; terminalManager?: TerminalManagerShape; } = {}, ): Promise { @@ -417,6 +420,7 @@ describe("WebSocket Server", () => { Layer.provideMerge(runtimeLayer), Layer.provideMerge(openLayer), Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(NodeServices.layer), ); const runtimeServices = await Effect.runPromise( Layer.build(dependenciesLayer).pipe(Scope.provide(scope)), @@ -1507,6 +1511,7 @@ describe("WebSocket Server", () => { expect(pullResponse.result).toBeUndefined(); expect(pullResponse.error?.message).toContain("No upstream configured"); expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); + }); it("supports git.status over websocket", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 5f4fb9d616a3..025f8179f5d7 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -12,8 +12,11 @@ import type { Duplex } from "node:stream"; import Mime from "@effect/platform-node/Mime"; import { CommandId, + type ClientOrchestrationCommand, + type OrchestrationCommand, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, ProjectId, ThreadId, TerminalEvent, @@ -54,7 +57,14 @@ import { clamp } from "effect/Number"; import { Open } from "./open"; import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; -import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; +import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; +import { + ATTACHMENTS_ROUTE_PREFIX, + normalizeAttachmentRelativePath, + resolveAttachmentRelativePath, +} from "./attachmentPaths"; +import { createAttachmentId, resolveAttachmentPath, resolveAttachmentPathById } from "./attachmentStore.ts"; +import { parseBase64DataUrl } from "./imageMime.ts"; /** * ServerShape - Service API for server lifecycle control. @@ -230,6 +240,88 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); }); + const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { + readonly command: ClientOrchestrationCommand; + }) { + if (input.command.type !== "thread.turn.start") { + return input.command as OrchestrationCommand; + } + const turnStartCommand = input.command; + + const normalizedAttachments = yield* Effect.forEach( + turnStartCommand.message.attachments, + (attachment) => + Effect.gen(function* () { + const parsed = parseBase64DataUrl(attachment.dataUrl); + if (!parsed || !parsed.mimeType.startsWith("image/")) { + return yield* new RouteRequestError({ + message: `Invalid image attachment payload for '${attachment.name}'.`, + }); + } + + const bytes = Buffer.from(parsed.base64, "base64"); + if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + return yield* new RouteRequestError({ + message: `Image attachment '${attachment.name}' is empty or too large.`, + }); + } + + const attachmentId = createAttachmentId(turnStartCommand.threadId); + if (!attachmentId) { + return yield* new RouteRequestError({ + message: "Failed to create a safe attachment id.", + }); + } + + const persistedAttachment = { + type: "image" as const, + id: attachmentId, + name: attachment.name, + mimeType: parsed.mimeType.toLowerCase(), + sizeBytes: bytes.byteLength, + }; + + const attachmentPath = resolveAttachmentPath({ + stateDir: serverConfig.stateDir, + attachment: persistedAttachment, + }); + if (!attachmentPath) { + return yield* new RouteRequestError({ + message: `Failed to resolve persisted path for '${attachment.name}'.`, + }); + } + + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe( + Effect.mapError( + () => + new RouteRequestError({ + message: `Failed to create attachment directory for '${attachment.name}'.`, + }), + ), + ); + yield* fileSystem.writeFile(attachmentPath, bytes).pipe( + Effect.mapError( + () => + new RouteRequestError({ + message: `Failed to persist attachment '${attachment.name}'.`, + }), + ), + ); + + return persistedAttachment; + }), + { concurrency: 1 }, + ); + + return { + ...turnStartCommand, + message: { + ...turnStartCommand.message, + attachments: normalizedAttachments, + }, + } satisfies OrchestrationCommand; + }); + // HTTP server — serves static files or redirects to Vite dev server const httpServer = http.createServer((req, res) => { const respond = ( @@ -249,22 +341,30 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { - const attachmentsRoot = path.resolve(path.join(serverConfig.stateDir, "attachments")); const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); - const normalizedRelativePath = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); - - if ( - normalizedRelativePath.length === 0 || - normalizedRelativePath.startsWith("..") || - normalizedRelativePath.includes("\0") - ) { + const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); + if (!normalizedRelativePath) { respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); return; } - const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath)); - if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) { - respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); + const isIdLookup = + !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); + const filePath = isIdLookup + ? resolveAttachmentPathById({ + stateDir: serverConfig.stateDir, + attachmentId: normalizedRelativePath, + }) + : resolveAttachmentRelativePath({ + stateDir: serverConfig.stateDir, + relativePath: normalizedRelativePath, + }); + if (!filePath) { + respond( + isIdLookup ? 404 : 400, + { "Content-Type": "text/plain" }, + isIdLookup ? "Not Found" : "Invalid attachment path", + ); return; } @@ -506,7 +606,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< case ORCHESTRATION_WS_METHODS.dispatchCommand: { const { command } = request.body; - return yield* orchestrationEngine.dispatch(command); + const normalizedCommand = yield* normalizeDispatchCommand({ command }); + return yield* orchestrationEngine.dispatch(normalizedCommand); } case ORCHESTRATION_WS_METHODS.getTurnDiff: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 597d067c8fc8..f90c63b39789 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -129,6 +129,7 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +const WORKTREE_BRANCH_PREFIX = "t3code"; function readLastInvokedScriptByProjectFromStorage(): Record { const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); @@ -232,6 +233,12 @@ function readFileAsDataUrl(file: File): Promise { }); } +function buildTemporaryWorktreeBranchName(): string { + // Keep the 8-hex suffix shape for backend temporary-branch detection. + const token = crypto.randomUUID().slice(0, 8).toLowerCase(); + return `${WORKTREE_BRANCH_PREFIX}/${token}`; +} + const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { pathValue: string; kind: "file" | "directory"; @@ -1478,6 +1485,15 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerImagesSnapshot = [...composerImages]; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); + const turnAttachmentsPromise = Promise.all( + composerImagesSnapshot.map(async (image) => ({ + type: "image" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await readFileAsDataUrl(image.file), + })), + ); const optimisticAttachments = composerImagesSnapshot.map((image) => ({ type: "image" as const, id: image.id, @@ -1510,7 +1526,7 @@ export default function ChatView({ threadId }: ChatViewProps) { // On first message: lock in branch + create worktree if needed. if (baseBranchForWorktree) { setSendPhase("preparing-worktree"); - const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`; + const newBranch = buildTemporaryWorktreeBranchName(); const result = await createWorktreeMutation.mutateAsync({ cwd: activeProject.cwd, branch: baseBranchForWorktree, @@ -1558,25 +1574,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } setSendPhase("sending-turn"); - const turnAttachments = await Promise.all( - composerImagesSnapshot.map( - async ( - image, - ): Promise<{ - type: "image"; - name: string; - mimeType: string; - sizeBytes: number; - dataUrl: string; - }> => ({ - type: "image", - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), - }), - ), - ); + const turnAttachments = await turnAttachmentsPromise; attemptedTurnStart = true; const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; const sandboxMode = diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index cd7ed5f98562..6d3a2fff05c3 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -229,6 +229,10 @@ function toAttachmentPreviewUrl(rawUrl: string): string { return rawUrl; } +function attachmentPreviewRoutePath(attachmentId: string): string { + return `/attachments/${encodeURIComponent(attachmentId)}`; +} + function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT); @@ -487,13 +491,13 @@ export function reducer(state: AppState, action: Action): AppState { } : null, messages: thread.messages.map((message) => { - const attachments = message.attachments?.map((attachment, index) => ({ + const attachments = message.attachments?.map((attachment) => ({ type: "image" as const, - id: `${message.id}:${index}`, + id: attachment.id, name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, - previewUrl: toAttachmentPreviewUrl(attachment.dataUrl), + previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), })); const normalizedMessage: ChatMessage = { id: message.id, diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 2d997f2f8dd3..4b4d90227a59 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -88,16 +88,16 @@ describe("getOrphanedWorktreePathForThread", () => { describe("formatWorktreePathForDisplay", () => { it("shows only the last path segment for unix-like paths", () => { const result = formatWorktreePathForDisplay( - "/Users/julius/.t3/worktrees/codething-mvp/codething-4e609bb8", + "/Users/julius/.t3/worktrees/t3code-mvp/t3code-4e609bb8", ); - expect(result).toBe("codething-4e609bb8"); + expect(result).toBe("t3code-4e609bb8"); }); it("normalizes windows separators before selecting the final segment", () => { const result = formatWorktreePathForDisplay( - "C:\\Users\\julius\\.t3\\worktrees\\codething-mvp\\codething-4e609bb8", + "C:\\Users\\julius\\.t3\\worktrees\\t3code-mvp\\t3code-4e609bb8", ); - expect(result).toBe("codething-4e609bb8"); + expect(result).toBe("t3code-4e609bb8"); }); it("uses the final segment even when outside ~/.t3/worktrees", () => { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 1de117eb66b5..a6b1f821415b 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -60,12 +60,28 @@ export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; +export const CHAT_ATTACHMENT_ID_MAX_CHARS = 128; // Correlation id is command id by design in this model. export const CorrelationId = CommandId; export type CorrelationId = typeof CorrelationId.Type; +export const ChatAttachmentId = TrimmedNonEmptyString.check( + Schema.isMaxLength(CHAT_ATTACHMENT_ID_MAX_CHARS), + Schema.isPattern(/^[a-z0-9_-]+$/i), +); +export type ChatAttachmentId = typeof ChatAttachmentId.Type; + export const ChatImageAttachment = Schema.Struct({ + type: Schema.Literal("image"), + id: ChatAttachmentId, + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)), + sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)), +}); +export type ChatImageAttachment = typeof ChatImageAttachment.Type; + +export const UploadChatImageAttachment = Schema.Struct({ type: Schema.Literal("image"), name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)), @@ -74,10 +90,12 @@ export const ChatImageAttachment = Schema.Struct({ Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), ), }); -export type ChatImageAttachment = typeof ChatImageAttachment.Type; +export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type; export const ChatAttachment = Schema.Union([ChatImageAttachment]); export type ChatAttachment = typeof ChatAttachment.Type; +export const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]); +export type UploadChatAttachment = typeof UploadChatAttachment.Type; export const ProjectScriptIcon = Schema.Literals([ "play", @@ -307,6 +325,24 @@ export const ThreadTurnStartCommand = Schema.Struct({ createdAt: IsoDateTime, }); +export const ClientThreadTurnStartCommand = Schema.Struct({ + type: Schema.Literal("thread.turn.start"), + commandId: CommandId, + threadId: ThreadId, + message: Schema.Struct({ + messageId: MessageId, + role: Schema.Literal("user"), + text: Schema.String, + attachments: Schema.Array(UploadChatAttachment), + }), + model: Schema.optional(TrimmedNonEmptyString), + effort: Schema.optional(TrimmedNonEmptyString), + assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), + approvalPolicy: ProviderApprovalPolicy, + sandboxMode: ProviderSandboxMode, + createdAt: IsoDateTime, +}); + export const ThreadTurnInterruptCommand = Schema.Struct({ type: Schema.Literal("thread.turn.interrupt"), commandId: CommandId, @@ -339,7 +375,7 @@ export const ThreadSessionStopCommand = Schema.Struct({ createdAt: IsoDateTime, }); -export const ClientOrchestrationCommand = Schema.Union([ +export const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, ProjectDeleteCommand, @@ -352,6 +388,22 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadCheckpointRevertCommand, ThreadSessionStopCommand, ]); +export type DispatchableClientOrchestrationCommand = + typeof DispatchableClientOrchestrationCommand.Type; + +export const ClientOrchestrationCommand = Schema.Union([ + ProjectCreateCommand, + ProjectMetaUpdateCommand, + ProjectDeleteCommand, + ThreadCreateCommand, + ThreadDeleteCommand, + ThreadMetaUpdateCommand, + ClientThreadTurnStartCommand, + ThreadTurnInterruptCommand, + ThreadApprovalRespondCommand, + ThreadCheckpointRevertCommand, + ThreadSessionStopCommand, +]); export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; export const ThreadSessionSetCommand = Schema.Struct({ @@ -422,7 +474,7 @@ export const InternalOrchestrationCommand = Schema.Union([ export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; export const OrchestrationCommand = Schema.Union([ - ClientOrchestrationCommand, + DispatchableClientOrchestrationCommand, InternalOrchestrationCommand, ]); export type OrchestrationCommand = typeof OrchestrationCommand.Type;