diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 484a4fa0fb7d6d10d162c3aa9b43fb951b2fde04..643c6a2e9e04f08b237940f1106234b16b958d0e 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -215,14 +215,13 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { Layer.provide(providerSessionDirectoryLayer), Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); const runtimeServicesLayer = Layer.mergeAll( orchestrationLayer, OrchestrationProjectionSnapshotQueryLive, ProjectionCheckpointRepositoryLive, ProjectionPendingApprovalRepositoryLive, - checkpointStoreLayer, + CheckpointStoreLive, providerLayer, ); const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 3da8faf731ee1fc0f8d4a2eb49d3b1481ae9516f..bbec8f62b08ce919cd2043d00f7f476491b77f5d 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -233,11 +233,12 @@ describe("CheckpointReactor", () => { Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(SqlitePersistenceMemory), ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); + const layer = CheckpointReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), - Layer.provideMerge(checkpointStoreLayer), + Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(NodeServices.layer), ); runtime = ManagedRuntime.make(layer); @@ -437,7 +438,9 @@ describe("CheckpointReactor", () => { 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?.checkpoints).toHaveLength(0); harness.provider.emit({ diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 83fcb579579dbf1df400e70214f949db9761aaff..9b477f64abeb2e7f2e3fb3dbe92c1fd154fc62a8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -32,6 +32,7 @@ import { } from "./ProjectionPipeline.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; +import { ServerConfig, type ServerConfigShape } from "../../config.ts"; const projectionLayer = it.layer( OrchestrationProjectionPipelineLive.pipe( @@ -158,6 +159,1001 @@ 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: [ + { + type: "image", + 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 }); + }), + ); + + 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: [ + { + type: "image", + name: "untrusted.exe", + mimeType: "image/x-unknown", + sizeBytes: 5, + dataUrl: "data:image/x-unknown;base64,SGVsbG8=", + }, + { + type: "image", + 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 }); + }), + ); + + it.effect( + "passes explicit empty attachment arrays through the projection pipeline to clear attachments", + () => + 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-clear-attachments-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-clear-attachments"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-clear-attachments-1"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-clear-attachments-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-clear-attachments"), + title: "Project Clear Attachments", + workspaceRoot: "/tmp/project-clear-attachments", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-clear-attachments-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-clear-attachments-2"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-clear-attachments-2"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-clear-attachments"), + projectId: ProjectId.makeUnsafe("project-clear-attachments"), + title: "Thread Clear Attachments", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-clear-attachments-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-clear-attachments-3"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-clear-attachments-3"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-clear-attachments"), + messageId: MessageId.makeUnsafe("message-clear-attachments"), + role: "user", + text: "Has attachments", + attachments: [ + { + type: "image", + name: "clear.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:image/png;base64,SGVsbG8=", + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-clear-attachments-4"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), + occurredAt: later, + commandId: CommandId.makeUnsafe("cmd-clear-attachments-4"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-clear-attachments-4"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-clear-attachments"), + messageId: MessageId.makeUnsafe("message-clear-attachments"), + role: "user", + text: "", + attachments: [], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: later, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly attachmentsJson: string | null; + }>` + SELECT + attachments_json AS "attachmentsJson" + FROM projection_thread_messages + WHERE message_id = 'message-clear-attachments' + `; + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), []); + }), + ); + + it.effect( + "overwrites attachment file bytes when a message updates the same attachment index", + () => + 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({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-overwrite-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-overwrite"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-overwrite-1"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-overwrite"), + title: "Project Overwrite", + workspaceRoot: "/tmp/project-overwrite", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-overwrite-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-overwrite-2"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-2"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-overwrite"), + projectId: ProjectId.makeUnsafe("project-overwrite"), + title: "Thread Overwrite", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-overwrite-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-overwrite-3"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-3"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-overwrite"), + messageId: MessageId.makeUnsafe("message-overwrite"), + role: "user", + text: "first image", + attachments: [ + { + type: "image", + name: "file.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:image/png;base64,SGVsbG8=", + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-overwrite-4"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-overwrite"), + occurredAt: later, + commandId: CommandId.makeUnsafe("cmd-overwrite-4"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-overwrite-4"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-overwrite"), + messageId: MessageId.makeUnsafe("message-overwrite"), + role: "user", + text: "", + attachments: [ + { + type: "image", + name: "file.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:image/png;base64,V29ybGQ=", + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: later, + }, + }); + + 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 }); + }), + ); + + 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, + }, + }); + + yield* sql` + CREATE TRIGGER fail_thread_messages_projection_state_update + BEFORE UPDATE ON projection_state + WHEN NEW.projector = 'projection.thread-messages' + BEGIN + SELECT RAISE(ABORT, 'forced-projection-state-failure'); + 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=", + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }), + ); + assert.equal(result._tag, "Failure"); + + 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 }); + }), + ); + + 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({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-revert-files-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-revert-files"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-revert-files"), + title: "Project Revert Files", + workspaceRoot: "/tmp/project-revert-files", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-revert-files-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-revert-files"), + 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"), + projectId: ProjectId.makeUnsafe("project-revert-files"), + title: "Thread Revert Files", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.turn-diff-completed", + eventId: EventId.makeUnsafe("evt-revert-files-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-revert-files"), + 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"), + turnId: TurnId.makeUnsafe("turn-keep"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"), + status: "ready", + files: [], + assistantMessageId: MessageId.makeUnsafe("message-keep"), + completedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-revert-files-4"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-revert-files"), + 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"), + messageId: MessageId.makeUnsafe("message-keep"), + role: "assistant", + text: "Keep", + attachments: [ + { + type: "image", + name: "keep.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:image/png;base64,SGVsbG8=", + }, + ], + turnId: TurnId.makeUnsafe("turn-keep"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.turn-diff-completed", + eventId: EventId.makeUnsafe("evt-revert-files-5"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-revert-files"), + 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"), + turnId: TurnId.makeUnsafe("turn-remove"), + checkpointTurnCount: 2, + checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"), + status: "ready", + files: [], + assistantMessageId: MessageId.makeUnsafe("message-remove"), + completedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-revert-files-6"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-revert-files"), + 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"), + messageId: MessageId.makeUnsafe("message-remove"), + role: "assistant", + text: "Remove", + attachments: [ + { + type: "image", + name: "remove.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:image/png;base64,V29ybGQ=", + }, + ], + turnId: TurnId.makeUnsafe("turn-remove"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + const keepPath = path.join( + stateDir, + "attachments", + "thread-revert-files", + "message-keep-0.png", + ); + const removePath = path.join( + stateDir, + "attachments", + "thread-revert-files", + "message-remove-0.png", + ); + assert.equal(fs.existsSync(keepPath), true); + assert.equal(fs.existsSync(removePath), true); + + yield* appendAndProject({ + type: "thread.reverted", + eventId: EventId.makeUnsafe("evt-revert-files-7"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-revert-files"), + 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"), + turnCount: 1, + }, + }); + + assert.equal(fs.existsSync(keepPath), true); + assert.equal(fs.existsSync(removePath), false); + 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({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-delete-files-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-delete-files"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-delete-files-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-delete-files"), + title: "Project Delete Files", + workspaceRoot: "/tmp/project-delete-files", + defaultModel: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-delete-files-2"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-delete-files"), + 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"), + projectId: ProjectId.makeUnsafe("project-delete-files"), + title: "Thread Delete Files", + model: "gpt-5-codex", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-delete-files-3"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-delete-files"), + 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"), + messageId: MessageId.makeUnsafe("message-delete-files"), + role: "user", + text: "Delete", + attachments: [ + { + type: "image", + name: "delete.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:image/png;base64,SGVsbG8=", + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + const threadAttachmentDir = path.join(stateDir, "attachments", "thread-delete-files"); + assert.equal(fs.existsSync(threadAttachmentDir), true); + + yield* appendAndProject({ + type: "thread.deleted", + eventId: EventId.makeUnsafe("evt-delete-files-4"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-delete-files"), + 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"), + deletedAt: now, + }, + }); + + assert.equal(fs.existsSync(threadAttachmentDir), false); + 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 }); + }), + ); + it.effect("resumes from projector last_applied_sequence without replaying older events", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 262d4545a4a1e0322e16a1a2a9a6fdb320558d61..c518744d138d807bcec461883abae83924590357 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,5 +1,11 @@ -import { ApprovalRequestId, type OrchestrationEvent } from "@t3tools/contracts"; -import { Effect, Layer, Option, Stream } from "effect"; +import Mime from "@effect/platform-node/Mime"; +import { + ApprovalRequestId, + type ChatAttachment, + type OrchestrationEvent, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; @@ -27,10 +33,16 @@ import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; +import { ServerConfig } from "../../config.ts"; import { OrchestrationProjectionPipeline, type OrchestrationProjectionPipelineShape, } from "../Services/ProjectionPipeline.ts"; +import { + ATTACHMENTS_ROUTE_PREFIX, + IMAGE_EXTENSION_BY_MIME_TYPE, + SAFE_IMAGE_FILE_EXTENSIONS, +} from "../../projectFaviconRoute.ts"; export const ORCHESTRATION_PROJECTOR_NAMES = { projects: "projection.projects", @@ -48,9 +60,122 @@ type ProjectorName = interface ProjectorDefinition { readonly name: ProjectorName; - readonly apply: (event: OrchestrationEvent) => Effect.Effect; + readonly apply: ( + event: OrchestrationEvent, + attachmentSideEffects: AttachmentSideEffects, + ) => 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 }, + ); +}); + function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { if (typeof payload !== "object" || payload === null) { return null; @@ -163,6 +288,138 @@ 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, +): Set { + const threadSegment = toSafeThreadAttachmentSegment(threadId); + 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)) { + continue; + } + const threadRelativePath = relativePath.slice(threadPrefix.length).replace(/^[/\\]+/, ""); + if (threadRelativePath.length === 0 || threadRelativePath.startsWith("..")) { + continue; + } + relativePaths.add(threadRelativePath.replace(/\\/g, "/")); + } + } + 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 attachmentsRootDir = input.path.join(serverConfig.value.stateDir, "attachments"); + + yield* Effect.forEach( + input.sideEffects.deletedThreadIds, + (threadId) => + Effect.gen(function* () { + const threadSegment = toSafeThreadAttachmentSegment(threadId); + if (!threadSegment) { + yield* Effect.logWarning("skipping attachment cleanup for unsafe thread id", { + threadId, + }); + return; + } + const threadDir = input.path.join(attachmentsRootDir, threadSegment); + yield* input.fileSystem.remove(threadDir, { recursive: true, force: true }); + }), + { concurrency: 1 }, + ); + + yield* Effect.forEach( + input.sideEffects.prunedThreadRelativePaths.entries(), + ([threadId, keptThreadRelativePaths]) => { + if (input.sideEffects.deletedThreadIds.has(threadId)) { + return Effect.void; + } + return Effect.gen(function* () { + const threadSegment = toSafeThreadAttachmentSegment(threadId); + if (!threadSegment) { + 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 }) + .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("..")) { + return; + } + + const absolutePath = input.path.join(threadDir, threadRelativePath); + const fileInfo = yield* input.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 }); + } + }), + { concurrency: 1 }, + ); + }); + }, + { 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* () { const sql = yield* SqlClient.SqlClient; const eventStore = yield* OrchestrationEventStore; @@ -175,7 +432,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { const projectionTurnRepository = yield* ProjectionTurnRepository; const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; - const applyProjectsProjection: ProjectorDefinition["apply"] = (event) => + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const applyProjectsProjection: ProjectorDefinition["apply"] = (event, _attachmentSideEffects) => Effect.gen(function* () { switch (event.type) { case "project.created": @@ -233,7 +493,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { } }); - const applyThreadsProjection: ProjectorDefinition["apply"] = (event) => + const applyThreadsProjection: ProjectorDefinition["apply"] = (event, attachmentSideEffects) => Effect.gen(function* () { switch (event.type) { case "thread.created": @@ -272,6 +532,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { } case "thread.deleted": { + attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, }); @@ -351,7 +612,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { } }); - const applyThreadMessagesProjection: ProjectorDefinition["apply"] = (event) => + const applyThreadMessagesProjection: ProjectorDefinition["apply"] = ( + event, + attachmentSideEffects, + ) => Effect.gen(function* () { switch (event.type) { case "thread.message-sent": { @@ -367,12 +631,24 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { : existingMessage && event.payload.text.length === 0 ? existingMessage.text : event.payload.text; + 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, threadId: event.payload.threadId, turnId: event.payload.turnId, role: event.payload.role, text: nextText, + ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), isStreaming: event.payload.streaming, createdAt: existingMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -406,6 +682,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { yield* Effect.forEach(keptRows, projectionThreadMessageRepository.upsert, { concurrency: 1, }).pipe(Effect.asVoid); + attachmentSideEffects.prunedThreadRelativePaths.set( + event.payload.threadId, + collectThreadAttachmentRelativePaths(event.payload.threadId, keptRows), + ); return; } @@ -414,7 +694,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { } }); - const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = (event) => + const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = ( + event, + _attachmentSideEffects, + ) => Effect.gen(function* () { switch (event.type) { case "thread.activity-appended": @@ -462,7 +745,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { } }); - const applyThreadSessionsProjection: ProjectorDefinition["apply"] = (event) => + const applyThreadSessionsProjection: ProjectorDefinition["apply"] = ( + event, + _attachmentSideEffects, + ) => Effect.gen(function* () { if (event.type !== "thread.session-set") { return; @@ -481,7 +767,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { }); }); - const applyThreadTurnsProjection: ProjectorDefinition["apply"] = (event) => + const applyThreadTurnsProjection: ProjectorDefinition["apply"] = ( + event, + _attachmentSideEffects, + ) => Effect.gen(function* () { switch (event.type) { case "thread.turn-start-requested": { @@ -714,7 +1003,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { const applyCheckpointsProjection: ProjectorDefinition["apply"] = () => Effect.void; - const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = (event) => + const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = ( + event, + _attachmentSideEffects, + ) => Effect.gen(function* () { switch (event.type) { case "thread.activity-appended": { @@ -837,17 +1129,40 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { ]; const runProjectorForEvent = (projector: ProjectorDefinition, event: OrchestrationEvent) => - sql.withTransaction( - projector.apply(event).pipe( - Effect.flatMap(() => - projectionStateRepository.upsert({ + Effect.gen(function* () { + const attachmentSideEffects: AttachmentSideEffects = { + writes: [], + deletedThreadIds: new Set(), + prunedThreadRelativePaths: new Map>(), + }; + + yield* sql.withTransaction( + projector.apply(event, attachmentSideEffects).pipe( + Effect.flatMap(() => + projectionStateRepository.upsert({ + projector: projector.name, + lastAppliedSequence: event.sequence, + updatedAt: event.occurredAt, + }), + ), + ), + ); + + yield* runAttachmentSideEffects({ + sideEffects: attachmentSideEffects, + fileSystem, + path, + }).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to apply projected attachment side-effects", { projector: projector.name, - lastAppliedSequence: event.sequence, - updatedAt: event.occurredAt, + sequence: event.sequence, + eventType: event.type, + cause, }), ), - ), - ); + ); + }); const bootstrapProjector = (projector: ProjectorDefinition) => projectionStateRepository @@ -901,6 +1216,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( OrchestrationProjectionPipeline, makeOrchestrationProjectionPipeline, ).pipe( + Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ProjectionProjectRepositoryLive), Layer.provideMerge(ProjectionThreadRepositoryLive), Layer.provideMerge(ProjectionThreadMessageRepositoryLive), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3b0798e20eaf9897189a0173b08fdb48bf7d5035..a4f260118c43735096349d0726a51c7bcf9873fd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,4 +1,5 @@ import { + ChatAttachment, IsoDateTime, MessageId, OrchestrationCheckpointFile, @@ -42,12 +43,13 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); -const ProjectionThreadDbRowSchema = ProjectionThread; const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, + attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), }), ); +const ProjectionThreadDbRowSchema = ProjectionThread; const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), @@ -171,6 +173,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { turn_id AS "turnId", role, text, + attachments_json AS "attachments", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -376,6 +379,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { id: row.messageId, role: row.role, text: row.text, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), turnId: row.turnId, streaming: row.isStreaming === 1, createdAt: row.createdAt, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..194c2943fec25b71a26b2d1394e91973121f25b9 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -0,0 +1,106 @@ +import { MessageId, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { ProjectionThreadMessageRepository } from "../Services/ProjectionThreadMessages.ts"; +import { ProjectionThreadMessageRepositoryLive } from "./ProjectionThreadMessages.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; + +const layer = it.layer( + ProjectionThreadMessageRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +layer("ProjectionThreadMessageRepository", (it) => { + it.effect("preserves existing attachments when upsert omits attachments", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.makeUnsafe("thread-preserve-attachments"); + const messageId = MessageId.makeUnsafe("message-preserve-attachments"); + const createdAt = "2026-02-28T19:00:00.000Z"; + const updatedAt = "2026-02-28T19:00:01.000Z"; + const persistedAttachments = [ + { + type: "image" as const, + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "/attachments/thread-preserve-attachments/message-preserve-attachments/0.png", + }, + ]; + + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "user", + text: "initial", + attachments: persistedAttachments, + isStreaming: false, + createdAt, + updatedAt, + }); + + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "user", + text: "updated", + isStreaming: false, + createdAt, + updatedAt: "2026-02-28T19:00:02.000Z", + }); + + const rows = yield* repository.listByThreadId({ threadId }); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.text, "updated"); + assert.deepEqual(rows[0]?.attachments, persistedAttachments); + }), + ); + + it.effect("allows explicit attachment clearing with an empty array", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.makeUnsafe("thread-clear-attachments"); + const messageId = MessageId.makeUnsafe("message-clear-attachments"); + const createdAt = "2026-02-28T19:10:00.000Z"; + + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "with attachment", + attachments: [ + { + type: "image", + name: "example.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "/attachments/thread-clear-attachments/message-clear-attachments/0.png", + }, + ], + isStreaming: false, + createdAt, + updatedAt: "2026-02-28T19:10:01.000Z", + }); + + yield* repository.upsert({ + messageId, + threadId, + turnId: null, + role: "assistant", + text: "cleared", + attachments: [], + isStreaming: false, + createdAt, + updatedAt: "2026-02-28T19:10:02.000Z", + }); + + const rows = yield* repository.listByThreadId({ threadId }); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.text, "cleared"); + assert.deepEqual(rows[0]?.attachments, []); + }), + ); +}); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 13a671e536b4f83671f456df3172a157d114db31..6f0b25ddffade7dd32f1a9db625cd46b9cb0aef5 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -1,6 +1,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { Effect, Layer, Schema, Struct } from "effect"; +import { ChatAttachment } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -14,6 +15,7 @@ import { const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, + attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), }), ); @@ -22,14 +24,17 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { const upsertProjectionThreadMessageRow = SqlSchema.void({ Request: ProjectionThreadMessage, - execute: (row) => - sql` + execute: (row) => { + const nextAttachmentsJson = + row.attachments !== undefined ? JSON.stringify(row.attachments) : null; + return sql` INSERT INTO projection_thread_messages ( message_id, thread_id, turn_id, role, text, + attachments_json, is_streaming, created_at, updated_at @@ -40,6 +45,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ${row.turnId}, ${row.role}, ${row.text}, + COALESCE( + ${nextAttachmentsJson}, + ( + SELECT attachments_json + FROM projection_thread_messages + WHERE message_id = ${row.messageId} + ) + ), ${row.isStreaming ? 1 : 0}, ${row.createdAt}, ${row.updatedAt} @@ -50,10 +63,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { turn_id = excluded.turn_id, role = excluded.role, text = excluded.text, + attachments_json = COALESCE( + excluded.attachments_json, + projection_thread_messages.attachments_json + ), is_streaming = excluded.is_streaming, created_at = excluded.created_at, updated_at = excluded.updated_at - `, + `; + }, }); const listProjectionThreadMessageRows = SqlSchema.findAll({ @@ -67,6 +85,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { turn_id AS "turnId", role, text, + attachments_json AS "attachments", is_streaming AS "isStreaming", created_at AS "createdAt", updated_at AS "updatedAt" @@ -97,8 +116,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ), Effect.map((rows) => rows.map((row) => ({ - ...row, + messageId: row.messageId, + threadId: row.threadId, + turnId: row.turnId, + role: row.role, + text: row.text, isStreaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), })), ), ); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 12873ca29d2a0d72395882f9dd7b7dd76dfab88c..3df831d0c76970909035382aefd422ce9cd576f4 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -18,6 +18,7 @@ import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts"; import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts"; import Migration0005 from "./Migrations/005_Projections.ts"; import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts"; +import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts"; /** * Migration loader with all migrations defined inline. @@ -36,6 +37,7 @@ const loader = Migrator.fromRecord({ "4_ProviderSessionRuntime": Migration0004, "5_Projections": Migration0005, "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006, + "7_ProjectionThreadMessageAttachments": Migration0007, }); /** diff --git a/apps/server/src/persistence/Migrations/007_ProjectionThreadMessageAttachments.ts b/apps/server/src/persistence/Migrations/007_ProjectionThreadMessageAttachments.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2cbe5b2b041d21dc4aa9536a27136ba686c5a7d --- /dev/null +++ b/apps/server/src/persistence/Migrations/007_ProjectionThreadMessageAttachments.ts @@ -0,0 +1,11 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE projection_thread_messages + ADD COLUMN attachments_json TEXT + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index f358ca573963117cebde3bc8e004a887ecc797f4..00b1d399c6fecf9b66cda587d285e5b914303761 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -7,6 +7,7 @@ * @module ProjectionThreadMessageRepository */ import { + ChatAttachment, OrchestrationMessageRole, MessageId, ThreadId, @@ -24,6 +25,7 @@ export const ProjectionThreadMessage = Schema.Struct({ turnId: Schema.NullOr(TurnId), role: OrchestrationMessageRole, text: Schema.String, + attachments: Schema.optional(Schema.Array(ChatAttachment)), isStreaming: Schema.Boolean, createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts index 4d66f0e86f3156ef6c7f194969acbab0b0ac2ea5..cddf2c15af1ae1e944750b9c798ea156a29fd989 100644 --- a/apps/server/src/projectFaviconRoute.ts +++ b/apps/server/src/projectFaviconRoute.ts @@ -2,6 +2,32 @@ 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/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index d6976479af046e4ec32095239011c59b0bc58b53..b18a838939d31e2a6258f0c70798b2d64a80ba18 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -378,6 +378,7 @@ it.effect( [ { provider: "codex", + cwd: "/tmp/project", resumeThreadId: startedSession.threadId, }, ], @@ -465,6 +466,7 @@ routing.layer("ProviderServiceLive routing", (it) => { [ { provider: "codex", + cwd: "/tmp/project", resumeThreadId: initial.threadId, }, ], @@ -475,6 +477,38 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("recovers stale sessions for sendTurn using persisted cwd", () => + Effect.gen(function* () { + const provider = yield* ProviderService; + + const initial = yield* provider.startSession(asThreadId("thread-1"), { + provider: "codex", + cwd: "/tmp/project-send-turn", + }); + + yield* provider.stopAll(); + routing.codex.startSession.mockClear(); + routing.codex.sendTurn.mockClear(); + + yield* provider.sendTurn({ + sessionId: initial.sessionId, + input: "resume", + attachments: [], + }); + + assert.deepEqual(routing.codex.startSession.mock.calls, [ + [ + { + provider: "codex", + cwd: "/tmp/project-send-turn", + resumeThreadId: initial.threadId, + }, + ], + ]); + assert.equal(routing.codex.sendTurn.mock.calls.length, 1); + }), + ); + it.effect("stops all sessions and clears adapter runtime", () => Effect.gen(function* () { const provider = yield* ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 1a50a95d22cc7d2164b2d91b21d611eab719c0b1..7176fb80222e1985c80834d1a2ae723acdb4a6e1 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -93,6 +93,18 @@ function toRuntimePayloadFromSession(session: ProviderSession): Record 0 ? trimmed : undefined; +} + const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.gen(function* () { const canonicalEventLogger = @@ -266,8 +278,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => ); } + const persistedCwd = readPersistedCwd(input.binding.runtimePayload); + const resumed = yield* adapter.startSession({ provider: input.binding.provider, + ...(persistedCwd ? { cwd: persistedCwd } : {}), ...(resumeThreadId ? { resumeThreadId } : {}), ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 0b223c2c7813c4c0668d57f523f0aa3b8853d0fc..471b38a5484730c523fc6521dd7a26b275d19b1c 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -26,6 +26,7 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer { + return new Promise((resolve, reject) => { + const req = Http.request( + { + hostname: "127.0.0.1", + port, + path: requestPath, + method: "GET", + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + res.on("end", () => { + resolve({ + statusCode: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf8"), + }); + }); + }, + ); + req.once("error", reject); + req.end(); + }); +} + function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { const resolved: Array = []; for (const binding of bindings) { @@ -324,13 +355,17 @@ describe("WebSocket Server", () => { async function createTestServer( options: { - persistenceLayer?: Layer.Layer; + persistenceLayer?: Layer.Layer< + SqlClient.SqlClient, + SqlError.SqlError | MigrationError | PlatformError.PlatformError + >; cwd?: string; autoBootstrapProjectFromCwd?: boolean; logWebSocketEvents?: boolean; devUrl?: string; authToken?: string; stateDir?: string; + staticDir?: string; providerLayer?: Layer.Layer; open?: OpenShape; gitManager?: GitManagerShape; @@ -354,7 +389,7 @@ describe("WebSocket Server", () => { cwd: options.cwd ?? "/test/project", keybindingsConfigPath: path.join(stateDir, "keybindings.json"), stateDir, - staticDir: undefined, + staticDir: options.staticDir, devUrl: options.devUrl ? new URL(options.devUrl) : undefined, noBrowser: true, authToken: options.authToken, @@ -438,6 +473,80 @@ describe("WebSocket Server", () => { }); }); + it("serves persisted attachments from stateDir", async () => { + const stateDir = makeTempDir("t3code-state-attachments-"); + const attachmentPath = path.join(stateDir, "attachments", "thread-a", "message-a", "0.png"); + fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); + fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment")); + + server = await createTestServer({ cwd: "/test/project", stateDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const response = await fetch(`http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("image/png"); + const bytes = Buffer.from(await response.arrayBuffer()); + expect(bytes).toEqual(Buffer.from("hello-attachment")); + }); + + it("serves persisted attachments for URL-encoded paths", async () => { + const stateDir = makeTempDir("t3code-state-attachments-encoded-"); + const attachmentPath = path.join( + stateDir, + "attachments", + "thread%20folder", + "message%20folder", + "file%20name.png", + ); + fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); + fs.writeFileSync(attachmentPath, Buffer.from("hello-encoded-attachment")); + + server = await createTestServer({ cwd: "/test/project", stateDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const response = await fetch( + `http://127.0.0.1:${port}/attachments/thread%20folder/message%20folder/file%20name.png`, + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("image/png"); + const bytes = Buffer.from(await response.arrayBuffer()); + expect(bytes).toEqual(Buffer.from("hello-encoded-attachment")); + }); + + it("serves static index for root path", async () => { + const stateDir = makeTempDir("t3code-state-static-root-"); + const staticDir = makeTempDir("t3code-static-root-"); + fs.writeFileSync(path.join(staticDir, "index.html"), "

static-root

", "utf8"); + + server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const response = await fetch(`http://127.0.0.1:${port}/`); + expect(response.status).toBe(200); + expect(await response.text()).toContain("static-root"); + }); + + it("rejects static path traversal attempts", async () => { + const stateDir = makeTempDir("t3code-state-static-traversal-"); + const staticDir = makeTempDir("t3code-static-traversal-"); + fs.writeFileSync(path.join(staticDir, "index.html"), "

safe

", "utf8"); + + server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const response = await requestPath(port, "/..%2f..%2fetc/passwd"); + expect(response.statusCode).toBe(400); + expect(response.body).toBe("Invalid static file path"); + }); + it("bootstraps the cwd project on startup when enabled", async () => { server = await createTestServer({ cwd: "/test/bootstrap-workspace", @@ -509,9 +618,9 @@ describe("WebSocket Server", () => { it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => { const stateDir = makeTempDir("t3code-state-bootstrap-existing-"); - const persistenceLayer = makeSqlitePersistenceLive( - path.join(stateDir, "state.sqlite"), - ).pipe(Layer.provide(NodeServices.layer)) as unknown as Layer.Layer; + const persistenceLayer = makeSqlitePersistenceLive(path.join(stateDir, "state.sqlite")).pipe( + Layer.provide(NodeServices.layer), + ); const cwd = "/test/bootstrap-existing"; server = await createTestServer({ diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index ceb370ab6814557070598d14f1ba626b7d569e14..5f4fb9d616a3d53c8ef6911c5a405167ec9a169b 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -6,11 +6,10 @@ * * @module Server */ -import fs from "node:fs"; import http from "node:http"; -import path from "node:path"; import type { Duplex } from "node:stream"; +import Mime from "@effect/platform-node/Mime"; import { CommandId, ORCHESTRATION_WS_CHANNELS, @@ -25,7 +24,20 @@ import { WsResponse, } from "@t3tools/contracts"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import { Cause, Effect, Exit, Layer, Ref, Schema, Scope, ServiceMap, Stream, Struct } from "effect"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Path, + Ref, + Schema, + Scope, + ServiceMap, + Stream, + Struct, +} from "effect"; import { WebSocketServer, type WebSocket } from "ws"; import { createLogger } from "./logger"; @@ -42,22 +54,7 @@ import { clamp } from "effect/Number"; import { Open } from "./open"; import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; -import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; - -const MIME_TYPES: Record = { - ".html": "text/html; charset=utf-8", - ".js": "application/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".png": "image/png", - ".jpg": "image/jpeg", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".ttf": "font/ttf", - ".map": "application/json", -}; +import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; /** * ServerShape - Service API for server lifecycle control. @@ -69,7 +66,7 @@ export interface ServerShape { readonly start: Effect.Effect< http.Server, ServerLifecycleError, - Scope.Scope | ServerRuntimeServices | ServerConfig + Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path >; /** @@ -168,7 +165,7 @@ class RouteRequestError extends Schema.TaggedErrorClass()("Ro export const createServer = Effect.fn(function* (): Effect.fn.Return< http.Server, ServerLifecycleError, - Scope.Scope | ServerRuntimeServices | ServerConfig + Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path > { const serverConfig = yield* ServerConfig; const { @@ -187,6 +184,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; const git = yield* GitCore; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( Effect.catch((error) => @@ -233,62 +232,157 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< // HTTP server — serves static files or redirects to Vite dev server const httpServer = http.createServer((req, res) => { - const url = new URL(req.url ?? "/", `http://localhost:${port}`); - - if (tryHandleProjectFaviconRequest(url, res)) { - return; - } - - // In dev mode, redirect to Vite dev server - if (devUrl) { - res.writeHead(302, { Location: devUrl.href }); - res.end(); - return; - } - - // Serve static files from the web app build - if (!staticDir) { - res.writeHead(503, { "Content-Type": "text/plain" }); - res.end("No static directory configured and no dev URL set."); - return; - } - - let filePath = path.join(staticDir, url.pathname); - - // SPA fallback: if no file extension and not found, serve index.html - const ext = path.extname(filePath); - if (!ext) { - filePath = path.join(filePath, "index.html"); - } - - fs.stat(filePath, (err, stats) => { - if (err || !stats?.isFile()) { - // SPA fallback - const indexPath = path.join(staticDir, "index.html"); - fs.readFile(indexPath, (readErr, data) => { - if (readErr) { - res.writeHead(404, { "Content-Type": "text/plain" }); - res.end("Not Found"); + const respond = ( + statusCode: number, + headers: Record, + body?: string | Uint8Array, + ) => { + res.writeHead(statusCode, headers); + res.end(body); + }; + + void Effect.runPromise( + Effect.gen(function* () { + const url = new URL(req.url ?? "/", `http://localhost:${port}`); + if (tryHandleProjectFaviconRequest(url, res)) { + 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") + ) { + respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); return; } - res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - res.end(data); - }); - return; + + const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath)); + if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) { + respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); + return; + } + + const fileInfo = yield* fileSystem + .stat(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!fileInfo || fileInfo.type !== "File") { + respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + + const contentType = Mime.getType(filePath) ?? "application/octet-stream"; + res.writeHead(200, { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + }); + const streamExit = yield* Stream.runForEach(fileSystem.stream(filePath), (chunk) => + Effect.sync(() => { + if (!res.destroyed) { + res.write(chunk); + } + }), + ).pipe(Effect.exit); + if (streamExit._tag === "Failure") { + if (!res.destroyed) { + res.destroy(); + } + return; + } + if (!res.writableEnded) { + res.end(); + } + return; + } + + // In dev mode, redirect to Vite dev server + if (devUrl) { + respond(302, { Location: devUrl.href }); + return; + } + + // Serve static files from the web app build + if (!staticDir) { + respond( + 503, + { "Content-Type": "text/plain" }, + "No static directory configured and no dev URL set.", + ); + return; + } + + const staticRoot = path.resolve(staticDir); + const staticRequestPath = url.pathname === "/" ? "/index.html" : url.pathname; + const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); + const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); + const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, ""); + const hasPathTraversalSegment = staticRelativePath.startsWith(".."); + if ( + staticRelativePath.length === 0 || + hasRawLeadingParentSegment || + hasPathTraversalSegment || + staticRelativePath.includes("\0") + ) { + respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); + return; + } + + const isWithinStaticRoot = (candidate: string) => + candidate === staticRoot || + candidate.startsWith( + staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`, + ); + + let filePath = path.resolve(staticRoot, staticRelativePath); + if (!isWithinStaticRoot(filePath)) { + respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); + return; + } + + const ext = path.extname(filePath); + if (!ext) { + filePath = path.resolve(filePath, "index.html"); + if (!isWithinStaticRoot(filePath)) { + respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); + return; + } + } + + const fileInfo = yield* fileSystem + .stat(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!fileInfo || fileInfo.type !== "File") { + const indexPath = path.resolve(staticRoot, "index.html"); + const indexData = yield* fileSystem + .readFile(indexPath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!indexData) { + respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + respond(200, { "Content-Type": "text/html; charset=utf-8" }, indexData); + return; + } + + const contentType = Mime.getType(filePath) ?? "application/octet-stream"; + const data = yield* fileSystem + .readFile(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!data) { + respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); + return; + } + respond(200, { "Content-Type": contentType }, data); + }), + ).catch(() => { + if (!res.headersSent) { + respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); } - - const fileExt = path.extname(filePath); - const contentType = MIME_TYPES[fileExt] ?? "application/octet-stream"; - - fs.readFile(filePath, (readErr, data) => { - if (readErr) { - res.writeHead(500, { "Content-Type": "text/plain" }); - res.end("Internal Server Error"); - return; - } - res.writeHead(200, { "Content-Type": contentType }); - res.end(data); - }); }); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 08a3a2155be7c78797a4072115d7974b6d488c2f..8a7b9e48bd3b7bd5ff200db9d8d2e43a1869fbe1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -74,6 +74,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { BotIcon, ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, CircleAlertIcon, FileIcon, FolderIcon, @@ -111,6 +113,7 @@ import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; import { useAppSettings } from "../appSettings"; +import { clamp } from "effect/Number"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); @@ -165,11 +168,36 @@ interface ComposerImageAttachment extends Omit, + selectedImageId: string, +): ExpandedImagePreview | null { + const previewableImages = images.flatMap((image) => + image.previewUrl ? [{ id: image.id, src: image.previewUrl, name: image.name }] : [], + ); + if (previewableImages.length === 0) { + return null; + } + const selectedIndex = previewableImages.findIndex((image) => image.id === selectedImageId); + if (selectedIndex < 0) { + return null; + } + return { + images: previewableImages.map((image) => ({ src: image.src, name: image.name })), + index: selectedIndex, + }; +} + type ComposerCommandItem = | { id: string; @@ -1070,22 +1098,53 @@ export default function ChatView({ threadId }: ChatViewProps) { }; }, [revokePreviewUrls]); - /** - * Close expanded image on Escape key - */ + const closeExpandedImage = useCallback(() => { + setExpandedImage(null); + }, []); + const navigateExpandedImage = useCallback((direction: -1 | 1) => { + setExpandedImage((existing) => { + if (!existing || existing.images.length <= 1) { + return existing; + } + const nextIndex = + (existing.index + direction + existing.images.length) % existing.images.length; + if (nextIndex === existing.index) { + return existing; + } + return { ...existing, index: nextIndex }; + }); + }, []); + useEffect(() => { if (!expandedImage) { return; } const onKeyDown = (event: globalThis.KeyboardEvent) => { - if (event.key !== "Escape") return; - setExpandedImage(null); + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + closeExpandedImage(); + return; + } + if (expandedImage.images.length <= 1) { + return; + } + if (event.key === "ArrowLeft") { + event.preventDefault(); + event.stopPropagation(); + navigateExpandedImage(-1); + return; + } + if (event.key !== "ArrowRight") return; + event.preventDefault(); + event.stopPropagation(); + navigateExpandedImage(1); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [expandedImage]); + }, [closeExpandedImage, expandedImage, navigateExpandedImage]); const activeWorktreePath = activeThread?.worktreePath; @@ -1722,9 +1781,10 @@ export default function ChatView({ threadId }: ChatViewProps) { [groupId]: !existing[groupId], })); }, []); - const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => { - setExpandedImage(image); + const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { + setExpandedImage(preview); }, []); + const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; const onOpenTurnDiff = useCallback( (turnId: TurnId, filePath?: string) => { void navigate({ @@ -1824,6 +1884,8 @@ export default function ChatView({ threadId }: ChatViewProps) { 0} isWorking={isWorking} + activeTurnInProgress={!latestTurnSettled} + activeTurnStartedAt={activeLatestTurn?.startedAt ?? null} scrollContainerRef={messagesScrollRef} timelineEntries={timelineEntries} completionDividerBeforeEntryId={completionDividerBeforeEntryId} @@ -1885,9 +1947,11 @@ export default function ChatView({ threadId }: ChatViewProps) { src={image.previewUrl} alt={image.name} className="h-full w-full cursor-zoom-in object-cover" - onClick={() => - setExpandedImage({ src: image.previewUrl, name: image.name }) - } + onClick={() => { + const preview = buildExpandedImagePreview(composerImages, image.id); + if (!preview) return; + setExpandedImage(preview); + }} /> ) : (
@@ -2111,14 +2175,29 @@ export default function ChatView({ threadId }: ChatViewProps) { ); })()} - {expandedImage && ( + {expandedImage && expandedImageItem && (
setExpandedImage(null)} + onClick={closeExpandedImage} > + {expandedImage.images.length > 1 && ( + + )}
event.stopPropagation()} @@ -2128,21 +2207,39 @@ export default function ChatView({ threadId }: ChatViewProps) { size="icon-xs" variant="ghost" className="absolute right-2 top-2" - onClick={() => setExpandedImage(null)} + onClick={closeExpandedImage} aria-label="Close image preview" > {expandedImage.name}

- {expandedImage.name} + {expandedImageItem.name} + {expandedImage.images.length > 1 + ? ` (${expandedImage.index + 1}/${expandedImage.images.length})` + : ""}

+ {expandedImage.images.length > 1 && ( + + )}
)}
@@ -2336,13 +2433,7 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri }, [text]); return ( - ); @@ -2351,6 +2442,8 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri interface MessagesTimelineProps { hasMessages: boolean; isWorking: boolean; + activeTurnInProgress: boolean; + activeTurnStartedAt: string | null; scrollContainerRef: React.RefObject; timelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; @@ -2363,7 +2456,7 @@ interface MessagesTimelineProps { revertTurnCountByUserMessageId: Map; onRevertUserMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; - onImageExpand: (image: ExpandedImagePreview) => void; + onImageExpand: (preview: ExpandedImagePreview) => void; markdownCwd: string | undefined; } @@ -2374,19 +2467,23 @@ type TimelineRow = | { kind: "work"; id: string; + createdAt: string; groupedEntries: TimelineWorkEntry[]; } | { kind: "message"; id: string; + createdAt: string; message: TimelineMessage; showCompletionDivider: boolean; } - | { kind: "working"; id: string }; + | { kind: "working"; id: string; createdAt: string | null }; const MessagesTimeline = memo(function MessagesTimeline({ hasMessages, isWorking, + activeTurnInProgress, + activeTurnStartedAt, scrollContainerRef, timelineEntries, completionDividerBeforeEntryId, @@ -2423,6 +2520,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ nextRows.push({ kind: "work", id: timelineEntry.id, + createdAt: timelineEntry.createdAt, groupedEntries, }); index = cursor - 1; @@ -2432,6 +2530,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ nextRows.push({ kind: "message", id: timelineEntry.id, + createdAt: timelineEntry.createdAt, message: timelineEntry.message, showCompletionDivider: timelineEntry.message.role === "assistant" && @@ -2440,14 +2539,62 @@ const MessagesTimeline = memo(function MessagesTimeline({ } if (isWorking) { - nextRows.push({ kind: "working", id: "working-indicator-row" }); + nextRows.push({ + kind: "working", + id: "working-indicator-row", + createdAt: activeTurnStartedAt, + }); } return nextRows; - }, [timelineEntries, completionDividerBeforeEntryId, isWorking]); + }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]); + + const firstUnvirtualizedRowIndex = useMemo(() => { + if (!activeTurnInProgress) return rows.length; + + const turnStartedAtMs = + typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN; + let firstCurrentTurnRowIndex = -1; + if (!Number.isNaN(turnStartedAtMs)) { + firstCurrentTurnRowIndex = rows.findIndex((row) => { + if (row.kind === "working") return true; + if (!row.createdAt) return false; + const rowCreatedAtMs = Date.parse(row.createdAt); + return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs; + }); + } + + if (firstCurrentTurnRowIndex < 0) { + firstCurrentTurnRowIndex = rows.findIndex( + (row) => row.kind === "message" && row.message.streaming, + ); + } + + if (firstCurrentTurnRowIndex < 0) { + return rows.length; + } + + for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) { + const previousRow = rows[index]; + if (!previousRow || previousRow.kind !== "message") continue; + if (previousRow.message.role === "user") { + return index; + } + if (previousRow.message.role === "assistant" && !previousRow.message.streaming) { + break; + } + } + + return firstCurrentTurnRowIndex; + }, [activeTurnInProgress, activeTurnStartedAt, rows]); + + const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { + minimum: 0, + maximum: rows.length, + }); const rowVirtualizer = useVirtualizer({ - count: rows.length, + count: virtualizedRowCount, getScrollElement: () => scrollContainerRef.current, estimateSize: (index: number) => { const row = rows[index]; @@ -2461,303 +2608,305 @@ const MessagesTimeline = memo(function MessagesTimeline({ }); const virtualRows = rowVirtualizer.getVirtualItems(); + const nonVirtualizedRows = rows.slice(virtualizedRowCount); - if (!hasMessages && !isWorking) { - return ( -
-

- Send a message to start the conversation. -

-
- ); - } + const renderRowContent = (row: TimelineRow) => ( +
+ {row.kind === "work" && + (() => { + const groupId = row.id; + const groupedEntries = row.groupedEntries; + const isExpanded = expandedWorkGroups[groupId] ?? false; + const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleEntries = + hasOverflow && !isExpanded + ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) + : groupedEntries; + const hiddenCount = groupedEntries.length - visibleEntries.length; + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); + const groupLabel = onlyToolEntries + ? groupedEntries.length === 1 + ? "Tool call" + : `Tool calls (${groupedEntries.length})` + : groupedEntries.length === 1 + ? "Work event" + : `Work log (${groupedEntries.length})`; - return ( -
- {virtualRows.map((virtualRow: VirtualItem) => { - const row = rows[virtualRow.index]; - if (!row) return null; + return ( +
+
+

+ {groupLabel} +

+ {hasOverflow && ( + + )} +
+
+ {visibleEntries.map((workEntry) => ( +
+ +

+ {workEntry.detail ? ( + <> + {workEntry.label} + + {workEntry.detail} + + + ) : ( + workEntry.label + )} +

+
+ ))} +
+
+ ); + })()} - return ( -
-
- {row.kind === "work" && - (() => { - const groupId = row.id; - const groupedEntries = row.groupedEntries; - const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const groupLabel = onlyToolEntries - ? groupedEntries.length === 1 - ? "Tool call" - : `Tool calls (${groupedEntries.length})` - : groupedEntries.length === 1 - ? "Work event" - : `Work log (${groupedEntries.length})`; + {row.kind === "message" && + row.message.role === "user" && + (() => { + const userImages = row.message.attachments ?? []; + const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); + return ( +
+
+ {userImages.length > 0 && ( +
+ {userImages.map( + (image: NonNullable[number]) => ( +
+ {image.previewUrl ? ( + {image.name} { + const preview = buildExpandedImagePreview(userImages, image.id); + if (!preview) return; + onImageExpand(preview); + }} + /> + ) : ( +
+ {image.name} +
+ )} +
+ ), + )} +
+ )} + {row.message.text && ( +
+                    {row.message.text}
+                  
+ )} +
+
+ {row.message.text && } + {canRevertAgentWork && ( + + )} +
+

+ {formatTimestamp(row.message.createdAt)} +

+
+
+
+ ); + })()} + {row.kind === "message" && + row.message.role === "assistant" && + (() => { + const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + return ( + <> + {row.showCompletionDivider && ( +
+ + + {completionSummary ? `Response • ${completionSummary}` : "Response"} + + +
+ )} +
+ + {(() => { + const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); + if (!turnSummary) return null; + const checkpointFiles = turnSummary.files; + if (checkpointFiles.length === 0) return null; + const summaryStat = checkpointFiles.reduce( + (acc, file) => { + if ( + typeof file.additions !== "number" || + typeof file.deletions !== "number" + ) { + return acc; + } + return { + additions: acc.additions + file.additions, + deletions: acc.deletions + file.deletions, + }; + }, + { additions: 0, deletions: 0 }, + ); + const changedFileCountLabel = String(checkpointFiles.length); return ( -
-
+
+

- {groupLabel} + Changed files ({changedFileCountLabel}) + {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( + <> + • + +{summaryStat.additions} + / + -{summaryStat.deletions} + + )}

- {hasOverflow && ( + +
+
+ {checkpointFiles.map((file) => ( - )} -
-
- {visibleEntries.map((workEntry) => ( -
- -

- {workEntry.detail ? ( + {(() => { + const stat = + typeof file.additions === "number" && + typeof file.deletions === "number" + ? { + additions: file.additions, + deletions: file.deletions, + } + : null; + if (!stat) { + return file.path; + } + return ( <> - {workEntry.label} - - {workEntry.detail} - + {file.path} + ( + +{stat.additions} + / + -{stat.deletions} + ) - ) : ( - workEntry.label - )} -

-
+ ); + })()} + ))}
); })()} +

+ {formatMessageMeta( + row.message.createdAt, + row.message.streaming + ? formatElapsed(row.message.createdAt, nowIso) + : formatElapsed(row.message.createdAt, row.message.completedAt), + )} +

+
+ + ); + })()} - {row.kind === "message" && - row.message.role === "user" && - (() => { - const userImages = row.message.attachments ?? []; - const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); - return ( -
-
- {userImages.length > 0 && ( -
- {userImages.map( - (image: NonNullable[number]) => ( -
- {image.previewUrl ? ( - {image.name} - onImageExpand({ src: image.previewUrl!, name: image.name }) - } - /> - ) : ( -
- {image.name} -
- )} -
- ), - )} -
- )} - {row.message.text && ( -
-                            {row.message.text}
-                          
- )} -
-
- {row.message.text && } - {canRevertAgentWork && ( - - )} -
-

- {formatTimestamp(row.message.createdAt)} -

-
-
-
- ); - })()} - - {row.kind === "message" && - row.message.role === "assistant" && - (() => { - const messageText = - row.message.text || (row.message.streaming ? "" : "(empty response)"); - return ( - <> - {row.showCompletionDivider && ( -
- - - {completionSummary ? `Response • ${completionSummary}` : "Response"} - - -
- )} -
- - {(() => { - const turnSummary = turnDiffSummaryByAssistantMessageId.get( - row.message.id, - ); - if (!turnSummary) return null; - const checkpointFiles = turnSummary.files; - if (checkpointFiles.length === 0) return null; - const summaryStat = checkpointFiles.reduce( - (acc, file) => { - if ( - typeof file.additions !== "number" || - typeof file.deletions !== "number" - ) { - return acc; - } - return { - additions: acc.additions + file.additions, - deletions: acc.deletions + file.deletions, - }; - }, - { additions: 0, deletions: 0 }, - ); - const changedFileCountLabel = String(checkpointFiles.length); - return ( -
-
-

- Changed files ({changedFileCountLabel}) - {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( - <> - • - +{summaryStat.additions} - / - - -{summaryStat.deletions} - - - )} -

- -
-
- {checkpointFiles.map((file) => ( - - ))} -
-
- ); - })()} -

- {formatMessageMeta( - row.message.createdAt, - row.message.streaming - ? formatElapsed(row.message.createdAt, nowIso) - : formatElapsed(row.message.createdAt, row.message.completedAt), - )} -

-
- - ); - })()} - - {row.kind === "working" && ( -
- -
- - - - - -
-
- )} -
+ {row.kind === "working" && ( +
+ +
+ + + + +
- ); - })} +
+ )} +
+ ); + + if (!hasMessages && !isWorking) { + return ( +
+

+ Send a message to start the conversation. +

+
+ ); + } + + return ( +
+ {virtualizedRowCount > 0 && ( +
+ {virtualRows.map((virtualRow: VirtualItem) => { + const row = rows[virtualRow.index]; + if (!row) return null; + + return ( +
+ {renderRowContent(row)} +
+ ); + })} +
+ )} + + {nonVirtualizedRows.map((row) => ( +
{renderRowContent(row)}
+ ))}
); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index bce86d0e66aba76bf8b7bab24bc605f084cedd0a..cd7ed5f98562a0e325b85811ea12a4e946faea7f 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -192,6 +192,43 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { return providerName === "claudeCode" ? "claudeCode" : "codex"; } +function resolveWsHttpOrigin(): string { + if (typeof window === "undefined") { + return ""; + } + const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); + const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined; + const wsCandidate = + typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0 + ? bridgeWsUrl + : typeof envWsUrl === "string" && envWsUrl.length > 0 + ? envWsUrl + : null; + if (!wsCandidate) { + return window.location.origin; + } + + try { + const wsUrl = new URL(wsCandidate); + const protocol = + wsUrl.protocol === "wss:" + ? "https:" + : wsUrl.protocol === "ws:" + ? "http:" + : wsUrl.protocol; + return `${protocol}//${wsUrl.host}`; + } catch { + return window.location.origin; + } +} + +function toAttachmentPreviewUrl(rawUrl: string): string { + if (rawUrl.startsWith("/")) { + return `${resolveWsHttpOrigin()}${rawUrl}`; + } + return rawUrl; +} + 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); @@ -456,7 +493,7 @@ export function reducer(state: AppState, action: Action): AppState { name: attachment.name, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, - previewUrl: attachment.dataUrl, + previewUrl: toAttachmentPreviewUrl(attachment.dataUrl), })); const normalizedMessage: ChatMessage = { id: message.id,