| author | |
| committer | |
| log | 4b3825e500594250cc9aeec9f6182a38059085f1 |
| tree | 8f1a659b010e44858998fad7c8ed2bbd75f051ee |
| parent | e8dfc42f444600237beb2fa7772c93b618307e32 |
| parent | d48b42c0174f7ed26de8fdd1b0f4f75c82c71fe7 |
19 files changed, 2346 insertions(+), 416 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+1-2| ... | @@ -215,14 +215,13 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { | ... | @@ -215,14 +215,13 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { |
| 215 | Layer.provide(providerSessionDirectoryLayer), | 215 | Layer.provide(providerSessionDirectoryLayer), |
| 216 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), | 216 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), |
| 217 | ); | 217 | ); |
| 218 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); | ||
| 219 | 218 | ||
| 220 | const runtimeServicesLayer = Layer.mergeAll( | 219 | const runtimeServicesLayer = Layer.mergeAll( |
| 221 | orchestrationLayer, | 220 | orchestrationLayer, |
| 222 | OrchestrationProjectionSnapshotQueryLive, | 221 | OrchestrationProjectionSnapshotQueryLive, |
| 223 | ProjectionCheckpointRepositoryLive, | 222 | ProjectionCheckpointRepositoryLive, |
| 224 | ProjectionPendingApprovalRepositoryLive, | 223 | ProjectionPendingApprovalRepositoryLive, |
| 225 | checkpointStoreLayer, | 224 | CheckpointStoreLive, |
| 226 | providerLayer, | 225 | providerLayer, |
| 227 | ); | 226 | ); |
| 228 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( | 227 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+6-3| ... | @@ -233,11 +233,12 @@ describe("CheckpointReactor", () => { | ... | @@ -233,11 +233,12 @@ describe("CheckpointReactor", () => { |
| 233 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 233 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 234 | Layer.provide(SqlitePersistenceMemory), | 234 | Layer.provide(SqlitePersistenceMemory), |
| 235 | ); | 235 | ); |
| 236 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); | 236 | |
| 237 | const layer = CheckpointReactorLive.pipe( | 237 | const layer = CheckpointReactorLive.pipe( |
| 238 | Layer.provideMerge(orchestrationLayer), | 238 | Layer.provideMerge(orchestrationLayer), |
| 239 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), | 239 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 240 | Layer.provideMerge(checkpointStoreLayer), | 240 | Layer.provideMerge(CheckpointStoreLive), |
| 241 | Layer.provideMerge(NodeServices.layer), | ||
| 241 | ); | 242 | ); |
| 242 | 243 | ||
| 243 | runtime = ManagedRuntime.make(layer); | 244 | runtime = ManagedRuntime.make(layer); |
| ... | @@ -437,7 +438,9 @@ describe("CheckpointReactor", () => { | ... | @@ -437,7 +438,9 @@ describe("CheckpointReactor", () => { |
| 437 | 438 | ||
| 438 | await Effect.runPromise(Effect.sleep("40 millis")); | 439 | await Effect.runPromise(Effect.sleep("40 millis")); |
| 439 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | 440 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 440 | const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | 441 | const midThread = midReadModel.threads.find( |
| 442 | (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), | ||
| 443 | ); | ||
| 441 | expect(midThread?.checkpoints).toHaveLength(0); | 444 | expect(midThread?.checkpoints).toHaveLength(0); |
| 442 | 445 | ||
| 443 | harness.provider.emit({ | 446 | harness.provider.emit({ |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+996| ... | @@ -32,6 +32,7 @@ import { | ... | @@ -32,6 +32,7 @@ import { |
| 32 | } from "./ProjectionPipeline.ts"; | 32 | } from "./ProjectionPipeline.ts"; |
| 33 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 33 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 34 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; | 34 | import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; |
| 35 | import { ServerConfig, type ServerConfigShape } from "../../config.ts"; | ||
| 35 | 36 | ||
| 36 | const projectionLayer = it.layer( | 37 | const projectionLayer = it.layer( |
| 37 | OrchestrationProjectionPipelineLive.pipe( | 38 | OrchestrationProjectionPipelineLive.pipe( |
| ... | @@ -158,6 +159,1001 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { | ... | @@ -158,6 +159,1001 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 158 | }), | 159 | }), |
| 159 | ); | 160 | ); |
| 160 | 161 | ||
| 162 | it.effect("materializes message image attachments into stateDir and stores URL references", () => | ||
| 163 | Effect.gen(function* () { | ||
| 164 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | ||
| 165 | const eventStore = yield* OrchestrationEventStore; | ||
| 166 | const sql = yield* SqlClient.SqlClient; | ||
| 167 | const now = new Date().toISOString(); | ||
| 168 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-")); | ||
| 169 | |||
| 170 | const serverConfig = { | ||
| 171 | mode: "web", | ||
| 172 | port: 0, | ||
| 173 | host: undefined, | ||
| 174 | cwd: "/tmp/project-attachments", | ||
| 175 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), | ||
| 176 | stateDir, | ||
| 177 | staticDir: undefined, | ||
| 178 | devUrl: undefined, | ||
| 179 | noBrowser: true, | ||
| 180 | authToken: undefined, | ||
| 181 | autoBootstrapProjectFromCwd: false, | ||
| 182 | logWebSocketEvents: false, | ||
| 183 | } satisfies ServerConfigShape; | ||
| 184 | |||
| 185 | yield* eventStore.append({ | ||
| 186 | type: "thread.message-sent", | ||
| 187 | eventId: EventId.makeUnsafe("evt-attachments"), | ||
| 188 | aggregateKind: "thread", | ||
| 189 | aggregateId: ThreadId.makeUnsafe("thread-attachments"), | ||
| 190 | occurredAt: now, | ||
| 191 | commandId: CommandId.makeUnsafe("cmd-attachments"), | ||
| 192 | causationEventId: null, | ||
| 193 | correlationId: CommandId.makeUnsafe("cmd-attachments"), | ||
| 194 | metadata: {}, | ||
| 195 | payload: { | ||
| 196 | threadId: ThreadId.makeUnsafe("thread-attachments"), | ||
| 197 | messageId: MessageId.makeUnsafe("message-attachments"), | ||
| 198 | role: "user", | ||
| 199 | text: "Inspect this", | ||
| 200 | attachments: [ | ||
| 201 | { | ||
| 202 | type: "image", | ||
| 203 | name: "example.png", | ||
| 204 | mimeType: "image/png", | ||
| 205 | sizeBytes: 5, | ||
| 206 | dataUrl: "data:image/png;base64,SGVsbG8=", | ||
| 207 | }, | ||
| 208 | ], | ||
| 209 | turnId: null, | ||
| 210 | streaming: false, | ||
| 211 | createdAt: now, | ||
| 212 | updatedAt: now, | ||
| 213 | }, | ||
| 214 | }); | ||
| 215 | |||
| 216 | yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); | ||
| 217 | |||
| 218 | const rows = yield* sql<{ | ||
| 219 | readonly attachmentsJson: string | null; | ||
| 220 | }>` | ||
| 221 | SELECT | ||
| 222 | attachments_json AS "attachmentsJson" | ||
| 223 | FROM projection_thread_messages | ||
| 224 | WHERE message_id = 'message-attachments' | ||
| 225 | `; | ||
| 226 | assert.equal(rows.length, 1); | ||
| 227 | assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ | ||
| 228 | { | ||
| 229 | type: "image", | ||
| 230 | name: "example.png", | ||
| 231 | mimeType: "image/png", | ||
| 232 | sizeBytes: 5, | ||
| 233 | dataUrl: "/attachments/thread-attachments/message-attachments-0.png", | ||
| 234 | }, | ||
| 235 | ]); | ||
| 236 | |||
| 237 | const attachmentPath = path.join( | ||
| 238 | stateDir, | ||
| 239 | "attachments", | ||
| 240 | "thread-attachments", | ||
| 241 | "message-attachments-0.png", | ||
| 242 | ); | ||
| 243 | assert.equal(fs.existsSync(attachmentPath), true); | ||
| 244 | assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("SGVsbG8=", "base64")); | ||
| 245 | fs.rmSync(stateDir, { recursive: true, force: true }); | ||
| 246 | }), | ||
| 247 | ); | ||
| 248 | |||
| 249 | it.effect("materializes only image data URLs and uses a safe extension whitelist", () => | ||
| 250 | Effect.gen(function* () { | ||
| 251 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | ||
| 252 | const eventStore = yield* OrchestrationEventStore; | ||
| 253 | const sql = yield* SqlClient.SqlClient; | ||
| 254 | const now = new Date().toISOString(); | ||
| 255 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-")); | ||
| 256 | |||
| 257 | const serverConfig = { | ||
| 258 | mode: "web", | ||
| 259 | port: 0, | ||
| 260 | host: undefined, | ||
| 261 | cwd: "/tmp/project-attachments", | ||
| 262 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), | ||
| 263 | stateDir, | ||
| 264 | staticDir: undefined, | ||
| 265 | devUrl: undefined, | ||
| 266 | noBrowser: true, | ||
| 267 | authToken: undefined, | ||
| 268 | autoBootstrapProjectFromCwd: false, | ||
| 269 | logWebSocketEvents: false, | ||
| 270 | } satisfies ServerConfigShape; | ||
| 271 | |||
| 272 | yield* eventStore.append({ | ||
| 273 | type: "thread.message-sent", | ||
| 274 | eventId: EventId.makeUnsafe("evt-attachments-safe"), | ||
| 275 | aggregateKind: "thread", | ||
| 276 | aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"), | ||
| 277 | occurredAt: now, | ||
| 278 | commandId: CommandId.makeUnsafe("cmd-attachments-safe"), | ||
| 279 | causationEventId: null, | ||
| 280 | correlationId: CommandId.makeUnsafe("cmd-attachments-safe"), | ||
| 281 | metadata: {}, | ||
| 282 | payload: { | ||
| 283 | threadId: ThreadId.makeUnsafe("thread-attachments-safe"), | ||
| 284 | messageId: MessageId.makeUnsafe("message-attachments-safe"), | ||
| 285 | role: "user", | ||
| 286 | text: "Inspect this", | ||
| 287 | attachments: [ | ||
| 288 | { | ||
| 289 | type: "image", | ||
| 290 | name: "untrusted.exe", | ||
| 291 | mimeType: "image/x-unknown", | ||
| 292 | sizeBytes: 5, | ||
| 293 | dataUrl: "data:image/x-unknown;base64,SGVsbG8=", | ||
| 294 | }, | ||
| 295 | { | ||
| 296 | type: "image", | ||
| 297 | name: "not-image.png", | ||
| 298 | mimeType: "image/png", | ||
| 299 | sizeBytes: 5, | ||
| 300 | dataUrl: "data:text/plain;base64,SGVsbG8=", | ||
| 301 | }, | ||
| 302 | ], | ||
| 303 | turnId: null, | ||
| 304 | streaming: false, | ||
| 305 | createdAt: now, | ||
| 306 | updatedAt: now, | ||
| 307 | }, | ||
| 308 | }); | ||
| 309 | |||
| 310 | yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); | ||
| 311 | |||
| 312 | const rows = yield* sql<{ | ||
| 313 | readonly attachmentsJson: string | null; | ||
| 314 | }>` | ||
| 315 | SELECT | ||
| 316 | attachments_json AS "attachmentsJson" | ||
| 317 | FROM projection_thread_messages | ||
| 318 | WHERE message_id = 'message-attachments-safe' | ||
| 319 | `; | ||
| 320 | assert.equal(rows.length, 1); | ||
| 321 | assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ | ||
| 322 | { | ||
| 323 | type: "image", | ||
| 324 | name: "untrusted.exe", | ||
| 325 | mimeType: "image/x-unknown", | ||
| 326 | sizeBytes: 5, | ||
| 327 | dataUrl: "/attachments/thread-attachments-safe/message-attachments-safe-0.bin", | ||
| 328 | }, | ||
| 329 | { | ||
| 330 | type: "image", | ||
| 331 | name: "not-image.png", | ||
| 332 | mimeType: "image/png", | ||
| 333 | sizeBytes: 5, | ||
| 334 | dataUrl: "data:text/plain;base64,SGVsbG8=", | ||
| 335 | }, | ||
| 336 | ]); | ||
| 337 | |||
| 338 | const firstAttachmentPath = path.join( | ||
| 339 | stateDir, | ||
| 340 | "attachments", | ||
| 341 | "thread-attachments-safe", | ||
| 342 | "message-attachments-safe-0.bin", | ||
| 343 | ); | ||
| 344 | assert.equal(fs.existsSync(firstAttachmentPath), true); | ||
| 345 | assert.deepEqual(fs.readFileSync(firstAttachmentPath), Buffer.from("SGVsbG8=", "base64")); | ||
| 346 | |||
| 347 | const secondAttachmentPath = path.join( | ||
| 348 | stateDir, | ||
| 349 | "attachments", | ||
| 350 | "thread-attachments-safe", | ||
| 351 | "message-attachments-safe-1.png", | ||
| 352 | ); | ||
| 353 | assert.equal(fs.existsSync(secondAttachmentPath), false); | ||
| 354 | fs.rmSync(stateDir, { recursive: true, force: true }); | ||
| 355 | }), | ||
| 356 | ); | ||
| 357 | |||
| 358 | it.effect( | ||
| 359 | "passes explicit empty attachment arrays through the projection pipeline to clear attachments", | ||
| 360 | () => | ||
| 361 | Effect.gen(function* () { | ||
| 362 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | ||
| 363 | const eventStore = yield* OrchestrationEventStore; | ||
| 364 | const sql = yield* SqlClient.SqlClient; | ||
| 365 | const now = new Date().toISOString(); | ||
| 366 | const later = new Date(Date.now() + 1_000).toISOString(); | ||
| 367 | |||
| 368 | yield* eventStore.append({ | ||
| 369 | type: "project.created", | ||
| 370 | eventId: EventId.makeUnsafe("evt-clear-attachments-1"), | ||
| 371 | aggregateKind: "project", | ||
| 372 | aggregateId: ProjectId.makeUnsafe("project-clear-attachments"), | ||
| 373 | occurredAt: now, | ||
| 374 | commandId: CommandId.makeUnsafe("cmd-clear-attachments-1"), | ||
| 375 | causationEventId: null, | ||
| 376 | correlationId: CommandId.makeUnsafe("cmd-clear-attachments-1"), | ||
| 377 | metadata: {}, | ||
| 378 | payload: { | ||
| 379 | projectId: ProjectId.makeUnsafe("project-clear-attachments"), | ||
| 380 | title: "Project Clear Attachments", | ||
| 381 | workspaceRoot: "/tmp/project-clear-attachments", | ||
| 382 | defaultModel: null, | ||
| 383 | scripts: [], | ||
| 384 | createdAt: now, | ||
| 385 | updatedAt: now, | ||
| 386 | }, | ||
| 387 | }); | ||
| 388 | |||
| 389 | yield* eventStore.append({ | ||
| 390 | type: "thread.created", | ||
| 391 | eventId: EventId.makeUnsafe("evt-clear-attachments-2"), | ||
| 392 | aggregateKind: "thread", | ||
| 393 | aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), | ||
| 394 | occurredAt: now, | ||
| 395 | commandId: CommandId.makeUnsafe("cmd-clear-attachments-2"), | ||
| 396 | causationEventId: null, | ||
| 397 | correlationId: CommandId.makeUnsafe("cmd-clear-attachments-2"), | ||
| 398 | metadata: {}, | ||
| 399 | payload: { | ||
| 400 | threadId: ThreadId.makeUnsafe("thread-clear-attachments"), | ||
| 401 | projectId: ProjectId.makeUnsafe("project-clear-attachments"), | ||
| 402 | title: "Thread Clear Attachments", | ||
| 403 | model: "gpt-5-codex", | ||
| 404 | branch: null, | ||
| 405 | worktreePath: null, | ||
| 406 | createdAt: now, | ||
| 407 | updatedAt: now, | ||
| 408 | }, | ||
| 409 | }); | ||
| 410 | |||
| 411 | yield* eventStore.append({ | ||
| 412 | type: "thread.message-sent", | ||
| 413 | eventId: EventId.makeUnsafe("evt-clear-attachments-3"), | ||
| 414 | aggregateKind: "thread", | ||
| 415 | aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), | ||
| 416 | occurredAt: now, | ||
| 417 | commandId: CommandId.makeUnsafe("cmd-clear-attachments-3"), | ||
| 418 | causationEventId: null, | ||
| 419 | correlationId: CommandId.makeUnsafe("cmd-clear-attachments-3"), | ||
| 420 | metadata: {}, | ||
| 421 | payload: { | ||
| 422 | threadId: ThreadId.makeUnsafe("thread-clear-attachments"), | ||
| 423 | messageId: MessageId.makeUnsafe("message-clear-attachments"), | ||
| 424 | role: "user", | ||
| 425 | text: "Has attachments", | ||
| 426 | attachments: [ | ||
| 427 | { | ||
| 428 | type: "image", | ||
| 429 | name: "clear.png", | ||
| 430 | mimeType: "image/png", | ||
| 431 | sizeBytes: 5, | ||
| 432 | dataUrl: "data:image/png;base64,SGVsbG8=", | ||
| 433 | }, | ||
| 434 | ], | ||
| 435 | turnId: null, | ||
| 436 | streaming: false, | ||
| 437 | createdAt: now, | ||
| 438 | updatedAt: now, | ||
| 439 | }, | ||
| 440 | }); | ||
| 441 | |||
| 442 | yield* eventStore.append({ | ||
| 443 | type: "thread.message-sent", | ||
| 444 | eventId: EventId.makeUnsafe("evt-clear-attachments-4"), | ||
| 445 | aggregateKind: "thread", | ||
| 446 | aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"), | ||
| 447 | occurredAt: later, | ||
| 448 | commandId: CommandId.makeUnsafe("cmd-clear-attachments-4"), | ||
| 449 | causationEventId: null, | ||
| 450 | correlationId: CommandId.makeUnsafe("cmd-clear-attachments-4"), | ||
| 451 | metadata: {}, | ||
| 452 | payload: { | ||
| 453 | threadId: ThreadId.makeUnsafe("thread-clear-attachments"), | ||
| 454 | messageId: MessageId.makeUnsafe("message-clear-attachments"), | ||
| 455 | role: "user", | ||
| 456 | text: "", | ||
| 457 | attachments: [], | ||
| 458 | turnId: null, | ||
| 459 | streaming: false, | ||
| 460 | createdAt: now, | ||
| 461 | updatedAt: later, | ||
| 462 | }, | ||
| 463 | }); | ||
| 464 | |||
| 465 | yield* projectionPipeline.bootstrap; | ||
| 466 | |||
| 467 | const rows = yield* sql<{ | ||
| 468 | readonly attachmentsJson: string | null; | ||
| 469 | }>` | ||
| 470 | SELECT | ||
| 471 | attachments_json AS "attachmentsJson" | ||
| 472 | FROM projection_thread_messages | ||
| 473 | WHERE message_id = 'message-clear-attachments' | ||
| 474 | `; | ||
| 475 | assert.equal(rows.length, 1); | ||
| 476 | assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), []); | ||
| 477 | }), | ||
| 478 | ); | ||
| 479 | |||
| 480 | it.effect( | ||
| 481 | "overwrites attachment file bytes when a message updates the same attachment index", | ||
| 482 | () => | ||
| 483 | Effect.gen(function* () { | ||
| 484 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | ||
| 485 | const eventStore = yield* OrchestrationEventStore; | ||
| 486 | const stateDir = fs.mkdtempSync( | ||
| 487 | path.join(os.tmpdir(), "t3-projection-attachments-overwrite-"), | ||
| 488 | ); | ||
| 489 | const now = new Date().toISOString(); | ||
| 490 | const later = new Date(Date.now() + 1_000).toISOString(); | ||
| 491 | |||
| 492 | const serverConfig = { | ||
| 493 | mode: "web", | ||
| 494 | port: 0, | ||
| 495 | host: undefined, | ||
| 496 | cwd: "/tmp/project-attachments", | ||
| 497 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), | ||
| 498 | stateDir, | ||
| 499 | staticDir: undefined, | ||
| 500 | devUrl: undefined, | ||
| 501 | noBrowser: true, | ||
| 502 | authToken: undefined, | ||
| 503 | autoBootstrapProjectFromCwd: false, | ||
| 504 | logWebSocketEvents: false, | ||
| 505 | } satisfies ServerConfigShape; | ||
| 506 | |||
| 507 | yield* eventStore.append({ | ||
| 508 | type: "project.created", | ||
| 509 | eventId: EventId.makeUnsafe("evt-overwrite-1"), | ||
| 510 | aggregateKind: "project", | ||
| 511 | aggregateId: ProjectId.makeUnsafe("project-overwrite"), | ||
| 512 | occurredAt: now, | ||
| 513 | commandId: CommandId.makeUnsafe("cmd-overwrite-1"), | ||
| 514 | causationEventId: null, | ||
| 515 | correlationId: CommandId.makeUnsafe("cmd-overwrite-1"), | ||
| 516 | metadata: {}, | ||
| 517 | payload: { | ||
| 518 | projectId: ProjectId.makeUnsafe("project-overwrite"), | ||
| 519 | title: "Project Overwrite", | ||
| 520 | workspaceRoot: "/tmp/project-overwrite", | ||
| 521 | defaultModel: null, | ||
| 522 | scripts: [], | ||
| 523 | createdAt: now, | ||
| 524 | updatedAt: now, | ||
| 525 | }, | ||
| 526 | }); | ||
| 527 | |||
| 528 | yield* eventStore.append({ | ||
| 529 | type: "thread.created", | ||
| 530 | eventId: EventId.makeUnsafe("evt-overwrite-2"), | ||
| 531 | aggregateKind: "thread", | ||
| 532 | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), | ||
| 533 | occurredAt: now, | ||
| 534 | commandId: CommandId.makeUnsafe("cmd-overwrite-2"), | ||
| 535 | causationEventId: null, | ||
| 536 | correlationId: CommandId.makeUnsafe("cmd-overwrite-2"), | ||
| 537 | metadata: {}, | ||
| 538 | payload: { | ||
| 539 | threadId: ThreadId.makeUnsafe("thread-overwrite"), | ||
| 540 | projectId: ProjectId.makeUnsafe("project-overwrite"), | ||
| 541 | title: "Thread Overwrite", | ||
| 542 | model: "gpt-5-codex", | ||
| 543 | branch: null, | ||
| 544 | worktreePath: null, | ||
| 545 | createdAt: now, | ||
| 546 | updatedAt: now, | ||
| 547 | }, | ||
| 548 | }); | ||
| 549 | |||
| 550 | yield* eventStore.append({ | ||
| 551 | type: "thread.message-sent", | ||
| 552 | eventId: EventId.makeUnsafe("evt-overwrite-3"), | ||
| 553 | aggregateKind: "thread", | ||
| 554 | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), | ||
| 555 | occurredAt: now, | ||
| 556 | commandId: CommandId.makeUnsafe("cmd-overwrite-3"), | ||
| 557 | causationEventId: null, | ||
| 558 | correlationId: CommandId.makeUnsafe("cmd-overwrite-3"), | ||
| 559 | metadata: {}, | ||
| 560 | payload: { | ||
| 561 | threadId: ThreadId.makeUnsafe("thread-overwrite"), | ||
| 562 | messageId: MessageId.makeUnsafe("message-overwrite"), | ||
| 563 | role: "user", | ||
| 564 | text: "first image", | ||
| 565 | attachments: [ | ||
| 566 | { | ||
| 567 | type: "image", | ||
| 568 | name: "file.png", | ||
| 569 | mimeType: "image/png", | ||
| 570 | sizeBytes: 5, | ||
| 571 | dataUrl: "data:image/png;base64,SGVsbG8=", | ||
| 572 | }, | ||
| 573 | ], | ||
| 574 | turnId: null, | ||
| 575 | streaming: false, | ||
| 576 | createdAt: now, | ||
| 577 | updatedAt: now, | ||
| 578 | }, | ||
| 579 | }); | ||
| 580 | |||
| 581 | yield* eventStore.append({ | ||
| 582 | type: "thread.message-sent", | ||
| 583 | eventId: EventId.makeUnsafe("evt-overwrite-4"), | ||
| 584 | aggregateKind: "thread", | ||
| 585 | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), | ||
| 586 | occurredAt: later, | ||
| 587 | commandId: CommandId.makeUnsafe("cmd-overwrite-4"), | ||
| 588 | causationEventId: null, | ||
| 589 | correlationId: CommandId.makeUnsafe("cmd-overwrite-4"), | ||
| 590 | metadata: {}, | ||
| 591 | payload: { | ||
| 592 | threadId: ThreadId.makeUnsafe("thread-overwrite"), | ||
| 593 | messageId: MessageId.makeUnsafe("message-overwrite"), | ||
| 594 | role: "user", | ||
| 595 | text: "", | ||
| 596 | attachments: [ | ||
| 597 | { | ||
| 598 | type: "image", | ||
| 599 | name: "file.png", | ||
| 600 | mimeType: "image/png", | ||
| 601 | sizeBytes: 5, | ||
| 602 | dataUrl: "data:image/png;base64,V29ybGQ=", | ||
| 603 | }, | ||
| 604 | ], | ||
| 605 | turnId: null, | ||
| 606 | streaming: false, | ||
| 607 | createdAt: now, | ||
| 608 | updatedAt: later, | ||
| 609 | }, | ||
| 610 | }); | ||
| 611 | |||
| 612 | yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); | ||
| 613 | |||
| 614 | const attachmentPath = path.join( | ||
| 615 | stateDir, | ||
| 616 | "attachments", | ||
| 617 | "thread-overwrite", | ||
| 618 | "message-overwrite-0.png", | ||
| 619 | ); | ||
| 620 | assert.equal(fs.existsSync(attachmentPath), true); | ||
| 621 | assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("V29ybGQ=", "base64")); | ||
| 622 | fs.rmSync(stateDir, { recursive: true, force: true }); | ||
| 623 | }), | ||
| 624 | ); | ||
| 625 | |||
| 626 | it.effect("does not persist attachment files when projector transaction rolls back", () => | ||
| 627 | Effect.gen(function* () { | ||
| 628 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | ||
| 629 | const eventStore = yield* OrchestrationEventStore; | ||
| 630 | const sql = yield* SqlClient.SqlClient; | ||
| 631 | const stateDir = fs.mkdtempSync( | ||
| 632 | path.join(os.tmpdir(), "t3-projection-attachments-rollback-"), | ||
| 633 | ); | ||
| 634 | const now = new Date().toISOString(); | ||
| 635 | |||
| 636 | const serverConfig = { | ||
| 637 | mode: "web", | ||
| 638 | port: 0, | ||
| 639 | host: undefined, | ||
| 640 | cwd: "/tmp/project-attachments", | ||
| 641 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), | ||
| 642 | stateDir, | ||
| 643 | staticDir: undefined, | ||
| 644 | devUrl: undefined, | ||
| 645 | noBrowser: true, | ||
| 646 | authToken: undefined, | ||
| 647 | autoBootstrapProjectFromCwd: false, | ||
| 648 | logWebSocketEvents: false, | ||
| 649 | } satisfies ServerConfigShape; | ||
| 650 | |||
| 651 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => | ||
| 652 | eventStore.append(event).pipe( | ||
| 653 | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), | ||
| 654 | Effect.provideService(ServerConfig, serverConfig), | ||
| 655 | ); | ||
| 656 | |||
| 657 | yield* appendAndProject({ | ||
| 658 | type: "project.created", | ||
| 659 | eventId: EventId.makeUnsafe("evt-rollback-1"), | ||
| 660 | aggregateKind: "project", | ||
| 661 | aggregateId: ProjectId.makeUnsafe("project-rollback"), | ||
| 662 | occurredAt: now, | ||
| 663 | commandId: CommandId.makeUnsafe("cmd-rollback-1"), | ||
| 664 | causationEventId: null, | ||
| 665 | correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"), | ||
| 666 | metadata: {}, | ||
| 667 | payload: { | ||
| 668 | projectId: ProjectId.makeUnsafe("project-rollback"), | ||
| 669 | title: "Project Rollback", | ||
| 670 | workspaceRoot: "/tmp/project-rollback", | ||
| 671 | defaultModel: null, | ||
| 672 | scripts: [], | ||
| 673 | createdAt: now, | ||
| 674 | updatedAt: now, | ||
| 675 | }, | ||
| 676 | }); | ||
| 677 | |||
| 678 | yield* appendAndProject({ | ||
| 679 | type: "thread.created", | ||
| 680 | eventId: EventId.makeUnsafe("evt-rollback-2"), | ||
| 681 | aggregateKind: "thread", | ||
| 682 | aggregateId: ThreadId.makeUnsafe("thread-rollback"), | ||
| 683 | occurredAt: now, | ||
| 684 | commandId: CommandId.makeUnsafe("cmd-rollback-2"), | ||
| 685 | causationEventId: null, | ||
| 686 | correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"), | ||
| 687 | metadata: {}, | ||
| 688 | payload: { | ||
| 689 | threadId: ThreadId.makeUnsafe("thread-rollback"), | ||
| 690 | projectId: ProjectId.makeUnsafe("project-rollback"), | ||
| 691 | title: "Thread Rollback", | ||
| 692 | model: "gpt-5-codex", | ||
| 693 | branch: null, | ||
| 694 | worktreePath: null, | ||
| 695 | createdAt: now, | ||
| 696 | updatedAt: now, | ||
| 697 | }, | ||
| 698 | }); | ||
| 699 | |||
| 700 | yield* sql` | ||
| 701 | CREATE TRIGGER fail_thread_messages_projection_state_update | ||
| 702 | BEFORE UPDATE ON projection_state | ||
| 703 | WHEN NEW.projector = 'projection.thread-messages' | ||
| 704 | BEGIN | ||
| 705 | SELECT RAISE(ABORT, 'forced-projection-state-failure'); | ||
| 706 | END; | ||
| 707 | `; | ||
| 708 | |||
| 709 | const result = yield* Effect.result( | ||
| 710 | appendAndProject({ | ||
| 711 | type: "thread.message-sent", | ||
| 712 | eventId: EventId.makeUnsafe("evt-rollback-3"), | ||
| 713 | aggregateKind: "thread", | ||
| 714 | aggregateId: ThreadId.makeUnsafe("thread-rollback"), | ||
| 715 | occurredAt: now, | ||
| 716 | commandId: CommandId.makeUnsafe("cmd-rollback-3"), | ||
| 717 | causationEventId: null, | ||
| 718 | correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"), | ||
| 719 | metadata: {}, | ||
| 720 | payload: { | ||
| 721 | threadId: ThreadId.makeUnsafe("thread-rollback"), | ||
| 722 | messageId: MessageId.makeUnsafe("message-rollback"), | ||
| 723 | role: "user", | ||
| 724 | text: "Rollback me", | ||
| 725 | attachments: [ | ||
| 726 | { | ||
| 727 | type: "image", | ||
| 728 | name: "rollback.png", | ||
| 729 | mimeType: "image/png", | ||
| 730 | sizeBytes: 5, | ||
| 731 | dataUrl: "data:image/png;base64,SGVsbG8=", | ||
| 732 | }, | ||
| 733 | ], | ||
| 734 | turnId: null, | ||
| 735 | streaming: false, | ||
| 736 | createdAt: now, | ||
| 737 | updatedAt: now, | ||
| 738 | }, | ||
| 739 | }), | ||
| 740 | ); | ||
| 741 | assert.equal(result._tag, "Failure"); | ||
| 742 | |||
| 743 | const rows = yield* sql<{ | ||
| 744 | readonly count: number; | ||
| 745 | }>` | ||
| 746 | SELECT COUNT(*) AS "count" | ||
| 747 | FROM projection_thread_messages | ||
| 748 | WHERE message_id = 'message-rollback' | ||
| 749 | `; | ||
| 750 | assert.equal(rows[0]?.count ?? 0, 0); | ||
| 751 | |||
| 752 | const attachmentPath = path.join( | ||
| 753 | stateDir, | ||
| 754 | "attachments", | ||
| 755 | "thread-rollback", | ||
| 756 | "message-rollback-0.png", | ||
| 757 | ); | ||
| 758 | assert.equal(fs.existsSync(attachmentPath), false); | ||
| 759 | yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`; | ||
| 760 | fs.rmSync(stateDir, { recursive: true, force: true }); | ||
| 761 | }), | ||
| 762 | ); | ||
| 763 | |||
| 764 | it.effect("removes unreferenced attachment files when a thread is reverted", () => | ||
| 765 | Effect.gen(function* () { | ||
| 766 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | ||
| 767 | const eventStore = yield* OrchestrationEventStore; | ||
| 768 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-revert-")); | ||
| 769 | const now = new Date().toISOString(); | ||
| 770 | |||
| 771 | const serverConfig = { | ||
| 772 | mode: "web", | ||
| 773 | port: 0, | ||
| 774 | host: undefined, | ||
| 775 | cwd: "/tmp/project-attachments", | ||
| 776 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), | ||
| 777 | stateDir, | ||
| 778 | staticDir: undefined, | ||
| 779 | devUrl: undefined, | ||
| 780 | noBrowser: true, | ||
| 781 | authToken: undefined, | ||
| 782 | autoBootstrapProjectFromCwd: false, | ||
| 783 | logWebSocketEvents: false, | ||
| 784 | } satisfies ServerConfigShape; | ||
| 785 | |||
| 786 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => | ||
| 787 | eventStore.append(event).pipe( | ||
| 788 | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), | ||
| 789 | Effect.provideService(ServerConfig, serverConfig), | ||
| 790 | ); | ||
| 791 | |||
| 792 | yield* appendAndProject({ | ||
| 793 | type: "project.created", | ||
| 794 | eventId: EventId.makeUnsafe("evt-revert-files-1"), | ||
| 795 | aggregateKind: "project", | ||
| 796 | aggregateId: ProjectId.makeUnsafe("project-revert-files"), | ||
| 797 | occurredAt: now, | ||
| 798 | commandId: CommandId.makeUnsafe("cmd-revert-files-1"), | ||
| 799 | causationEventId: null, | ||
| 800 | correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"), | ||
| 801 | metadata: {}, | ||
| 802 | payload: { | ||
| 803 | projectId: ProjectId.makeUnsafe("project-revert-files"), | ||
| 804 | title: "Project Revert Files", | ||
| 805 | workspaceRoot: "/tmp/project-revert-files", | ||
| 806 | defaultModel: null, | ||
| 807 | scripts: [], | ||
| 808 | createdAt: now, | ||
| 809 | updatedAt: now, | ||
| 810 | }, | ||
| 811 | }); | ||
| 812 | |||
| 813 | yield* appendAndProject({ | ||
| 814 | type: "thread.created", | ||
| 815 | eventId: EventId.makeUnsafe("evt-revert-files-2"), | ||
| 816 | aggregateKind: "thread", | ||
| 817 | aggregateId: ThreadId.makeUnsafe("thread-revert-files"), | ||
| 818 | occurredAt: now, | ||
| 819 | commandId: CommandId.makeUnsafe("cmd-revert-files-2"), | ||
| 820 | causationEventId: null, | ||
| 821 | correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"), | ||
| 822 | metadata: {}, | ||
| 823 | payload: { | ||
| 824 | threadId: ThreadId.makeUnsafe("thread-revert-files"), | ||
| 825 | projectId: ProjectId.makeUnsafe("project-revert-files"), | ||
| 826 | title: "Thread Revert Files", | ||
| 827 | model: "gpt-5-codex", | ||
| 828 | branch: null, | ||
| 829 | worktreePath: null, | ||
| 830 | createdAt: now, | ||
| 831 | updatedAt: now, | ||
| 832 | }, | ||
| 833 | }); | ||
| 834 | |||
| 835 | yield* appendAndProject({ | ||
| 836 | type: "thread.turn-diff-completed", | ||
| 837 | eventId: EventId.makeUnsafe("evt-revert-files-3"), | ||
| 838 | aggregateKind: "thread", | ||
| 839 | aggregateId: ThreadId.makeUnsafe("thread-revert-files"), | ||
| 840 | occurredAt: now, | ||
| 841 | commandId: CommandId.makeUnsafe("cmd-revert-files-3"), | ||
| 842 | causationEventId: null, | ||
| 843 | correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"), | ||
| 844 | metadata: {}, | ||
| 845 | payload: { | ||
| 846 | threadId: ThreadId.makeUnsafe("thread-revert-files"), | ||
| 847 | turnId: TurnId.makeUnsafe("turn-keep"), | ||
| 848 | checkpointTurnCount: 1, | ||
| 849 | checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"), | ||
| 850 | status: "ready", | ||
| 851 | files: [], | ||
| 852 | assistantMessageId: MessageId.makeUnsafe("message-keep"), | ||
| 853 | completedAt: now, | ||
| 854 | }, | ||
| 855 | }); | ||
| 856 | |||
| 857 | yield* appendAndProject({ | ||
| 858 | type: "thread.message-sent", | ||
| 859 | eventId: EventId.makeUnsafe("evt-revert-files-4"), | ||
| 860 | aggregateKind: "thread", | ||
| 861 | aggregateId: ThreadId.makeUnsafe("thread-revert-files"), | ||
| 862 | occurredAt: now, | ||
| 863 | commandId: CommandId.makeUnsafe("cmd-revert-files-4"), | ||
| 864 | causationEventId: null, | ||
| 865 | correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"), | ||
| 866 | metadata: {}, | ||
| 867 | payload: { | ||
| 868 | threadId: ThreadId.makeUnsafe("thread-revert-files"), | ||
| 869 | messageId: MessageId.makeUnsafe("message-keep"), | ||
| 870 | role: "assistant", | ||
| 871 | text: "Keep", | ||
| 872 | attachments: [ | ||
| 873 | { | ||
| 874 | type: "image", | ||
| 875 | name: "keep.png", | ||
| 876 | mimeType: "image/png", | ||
| 877 | sizeBytes: 5, | ||
| 878 | dataUrl: "data:image/png;base64,SGVsbG8=", | ||
| 879 | }, | ||
| 880 | ], | ||
| 881 | turnId: TurnId.makeUnsafe("turn-keep"), | ||
| 882 | streaming: false, | ||
| 883 | createdAt: now, | ||
| 884 | updatedAt: now, | ||
| 885 | }, | ||
| 886 | }); | ||
| 887 | |||
| 888 | yield* appendAndProject({ | ||
| 889 | type: "thread.turn-diff-completed", | ||
| 890 | eventId: EventId.makeUnsafe("evt-revert-files-5"), | ||
| 891 | aggregateKind: "thread", | ||
| 892 | aggregateId: ThreadId.makeUnsafe("thread-revert-files"), | ||
| 893 | occurredAt: now, | ||
| 894 | commandId: CommandId.makeUnsafe("cmd-revert-files-5"), | ||
| 895 | causationEventId: null, | ||
| 896 | correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"), | ||
| 897 | metadata: {}, | ||
| 898 | payload: { | ||
| 899 | threadId: ThreadId.makeUnsafe("thread-revert-files"), | ||
| 900 | turnId: TurnId.makeUnsafe("turn-remove"), | ||
| 901 | checkpointTurnCount: 2, | ||
| 902 | checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"), | ||
| 903 | status: "ready", | ||
| 904 | files: [], | ||
| 905 | assistantMessageId: MessageId.makeUnsafe("message-remove"), | ||
| 906 | completedAt: now, | ||
| 907 | }, | ||
| 908 | }); | ||
| 909 | |||
| 910 | yield* appendAndProject({ | ||
| 911 | type: "thread.message-sent", | ||
| 912 | eventId: EventId.makeUnsafe("evt-revert-files-6"), | ||
| 913 | aggregateKind: "thread", | ||
| 914 | aggregateId: ThreadId.makeUnsafe("thread-revert-files"), | ||
| 915 | occurredAt: now, | ||
| 916 | commandId: CommandId.makeUnsafe("cmd-revert-files-6"), | ||
| 917 | causationEventId: null, | ||
| 918 | correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"), | ||
| 919 | metadata: {}, | ||
| 920 | payload: { | ||
| 921 | threadId: ThreadId.makeUnsafe("thread-revert-files"), | ||
| 922 | messageId: MessageId.makeUnsafe("message-remove"), | ||
| 923 | role: "assistant", | ||
| 924 | text: "Remove", | ||
| 925 | attachments: [ | ||
| 926 | { | ||
| 927 | type: "image", | ||
| 928 | name: "remove.png", | ||
| 929 | mimeType: "image/png", | ||
| 930 | sizeBytes: 5, | ||
| 931 | dataUrl: "data:image/png;base64,V29ybGQ=", | ||
| 932 | }, | ||
| 933 | ], | ||
| 934 | turnId: TurnId.makeUnsafe("turn-remove"), | ||
| 935 | streaming: false, | ||
| 936 | createdAt: now, | ||
| 937 | updatedAt: now, | ||
| 938 | }, | ||
| 939 | }); | ||
| 940 | |||
| 941 | const keepPath = path.join( | ||
| 942 | stateDir, | ||
| 943 | "attachments", | ||
| 944 | "thread-revert-files", | ||
| 945 | "message-keep-0.png", | ||
| 946 | ); | ||
| 947 | const removePath = path.join( | ||
| 948 | stateDir, | ||
| 949 | "attachments", | ||
| 950 | "thread-revert-files", | ||
| 951 | "message-remove-0.png", | ||
| 952 | ); | ||
| 953 | assert.equal(fs.existsSync(keepPath), true); | ||
| 954 | assert.equal(fs.existsSync(removePath), true); | ||
| 955 | |||
| 956 | yield* appendAndProject({ | ||
| 957 | type: "thread.reverted", | ||
| 958 | eventId: EventId.makeUnsafe("evt-revert-files-7"), | ||
| 959 | aggregateKind: "thread", | ||
| 960 | aggregateId: ThreadId.makeUnsafe("thread-revert-files"), | ||
| 961 | occurredAt: now, | ||
| 962 | commandId: CommandId.makeUnsafe("cmd-revert-files-7"), | ||
| 963 | causationEventId: null, | ||
| 964 | correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"), | ||
| 965 | metadata: {}, | ||
| 966 | payload: { | ||
| 967 | threadId: ThreadId.makeUnsafe("thread-revert-files"), | ||
| 968 | turnCount: 1, | ||
| 969 | }, | ||
| 970 | }); | ||
| 971 | |||
| 972 | assert.equal(fs.existsSync(keepPath), true); | ||
| 973 | assert.equal(fs.existsSync(removePath), false); | ||
| 974 | fs.rmSync(stateDir, { recursive: true, force: true }); | ||
| 975 | }), | ||
| 976 | ); | ||
| 977 | |||
| 978 | it.effect("removes thread attachment directory when thread is deleted", () => | ||
| 979 | Effect.gen(function* () { | ||
| 980 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | ||
| 981 | const eventStore = yield* OrchestrationEventStore; | ||
| 982 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-delete-")); | ||
| 983 | const now = new Date().toISOString(); | ||
| 984 | |||
| 985 | const serverConfig = { | ||
| 986 | mode: "web", | ||
| 987 | port: 0, | ||
| 988 | host: undefined, | ||
| 989 | cwd: "/tmp/project-attachments", | ||
| 990 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), | ||
| 991 | stateDir, | ||
| 992 | staticDir: undefined, | ||
| 993 | devUrl: undefined, | ||
| 994 | noBrowser: true, | ||
| 995 | authToken: undefined, | ||
| 996 | autoBootstrapProjectFromCwd: false, | ||
| 997 | logWebSocketEvents: false, | ||
| 998 | } satisfies ServerConfigShape; | ||
| 999 | |||
| 1000 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => | ||
| 1001 | eventStore.append(event).pipe( | ||
| 1002 | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), | ||
| 1003 | Effect.provideService(ServerConfig, serverConfig), | ||
| 1004 | ); | ||
| 1005 | |||
| 1006 | yield* appendAndProject({ | ||
| 1007 | type: "project.created", | ||
| 1008 | eventId: EventId.makeUnsafe("evt-delete-files-1"), | ||
| 1009 | aggregateKind: "project", | ||
| 1010 | aggregateId: ProjectId.makeUnsafe("project-delete-files"), | ||
| 1011 | occurredAt: now, | ||
| 1012 | commandId: CommandId.makeUnsafe("cmd-delete-files-1"), | ||
| 1013 | causationEventId: null, | ||
| 1014 | correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"), | ||
| 1015 | metadata: {}, | ||
| 1016 | payload: { | ||
| 1017 | projectId: ProjectId.makeUnsafe("project-delete-files"), | ||
| 1018 | title: "Project Delete Files", | ||
| 1019 | workspaceRoot: "/tmp/project-delete-files", | ||
| 1020 | defaultModel: null, | ||
| 1021 | scripts: [], | ||
| 1022 | createdAt: now, | ||
| 1023 | updatedAt: now, | ||
| 1024 | }, | ||
| 1025 | }); | ||
| 1026 | |||
| 1027 | yield* appendAndProject({ | ||
| 1028 | type: "thread.created", | ||
| 1029 | eventId: EventId.makeUnsafe("evt-delete-files-2"), | ||
| 1030 | aggregateKind: "thread", | ||
| 1031 | aggregateId: ThreadId.makeUnsafe("thread-delete-files"), | ||
| 1032 | occurredAt: now, | ||
| 1033 | commandId: CommandId.makeUnsafe("cmd-delete-files-2"), | ||
| 1034 | causationEventId: null, | ||
| 1035 | correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"), | ||
| 1036 | metadata: {}, | ||
| 1037 | payload: { | ||
| 1038 | threadId: ThreadId.makeUnsafe("thread-delete-files"), | ||
| 1039 | projectId: ProjectId.makeUnsafe("project-delete-files"), | ||
| 1040 | title: "Thread Delete Files", | ||
| 1041 | model: "gpt-5-codex", | ||
| 1042 | branch: null, | ||
| 1043 | worktreePath: null, | ||
| 1044 | createdAt: now, | ||
| 1045 | updatedAt: now, | ||
| 1046 | }, | ||
| 1047 | }); | ||
| 1048 | |||
| 1049 | yield* appendAndProject({ | ||
| 1050 | type: "thread.message-sent", | ||
| 1051 | eventId: EventId.makeUnsafe("evt-delete-files-3"), | ||
| 1052 | aggregateKind: "thread", | ||
| 1053 | aggregateId: ThreadId.makeUnsafe("thread-delete-files"), | ||
| 1054 | occurredAt: now, | ||
| 1055 | commandId: CommandId.makeUnsafe("cmd-delete-files-3"), | ||
| 1056 | causationEventId: null, | ||
| 1057 | correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"), | ||
| 1058 | metadata: {}, | ||
| 1059 | payload: { | ||
| 1060 | threadId: ThreadId.makeUnsafe("thread-delete-files"), | ||
| 1061 | messageId: MessageId.makeUnsafe("message-delete-files"), | ||
| 1062 | role: "user", | ||
| 1063 | text: "Delete", | ||
| 1064 | attachments: [ | ||
| 1065 | { | ||
| 1066 | type: "image", | ||
| 1067 | name: "delete.png", | ||
| 1068 | mimeType: "image/png", | ||
| 1069 | sizeBytes: 5, | ||
| 1070 | dataUrl: "data:image/png;base64,SGVsbG8=", | ||
| 1071 | }, | ||
| 1072 | ], | ||
| 1073 | turnId: null, | ||
| 1074 | streaming: false, | ||
| 1075 | createdAt: now, | ||
| 1076 | updatedAt: now, | ||
| 1077 | }, | ||
| 1078 | }); | ||
| 1079 | |||
| 1080 | const threadAttachmentDir = path.join(stateDir, "attachments", "thread-delete-files"); | ||
| 1081 | assert.equal(fs.existsSync(threadAttachmentDir), true); | ||
| 1082 | |||
| 1083 | yield* appendAndProject({ | ||
| 1084 | type: "thread.deleted", | ||
| 1085 | eventId: EventId.makeUnsafe("evt-delete-files-4"), | ||
| 1086 | aggregateKind: "thread", | ||
| 1087 | aggregateId: ThreadId.makeUnsafe("thread-delete-files"), | ||
| 1088 | occurredAt: now, | ||
| 1089 | commandId: CommandId.makeUnsafe("cmd-delete-files-4"), | ||
| 1090 | causationEventId: null, | ||
| 1091 | correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"), | ||
| 1092 | metadata: {}, | ||
| 1093 | payload: { | ||
| 1094 | threadId: ThreadId.makeUnsafe("thread-delete-files"), | ||
| 1095 | deletedAt: now, | ||
| 1096 | }, | ||
| 1097 | }); | ||
| 1098 | |||
| 1099 | assert.equal(fs.existsSync(threadAttachmentDir), false); | ||
| 1100 | fs.rmSync(stateDir, { recursive: true, force: true }); | ||
| 1101 | }), | ||
| 1102 | ); | ||
| 1103 | |||
| 1104 | it.effect("ignores unsafe thread ids for attachment cleanup paths", () => | ||
| 1105 | Effect.gen(function* () { | ||
| 1106 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | ||
| 1107 | const eventStore = yield* OrchestrationEventStore; | ||
| 1108 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-")); | ||
| 1109 | const now = new Date().toISOString(); | ||
| 1110 | const attachmentsRootDir = path.join(stateDir, "attachments"); | ||
| 1111 | const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt"); | ||
| 1112 | const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt"); | ||
| 1113 | fs.mkdirSync(attachmentsRootDir, { recursive: true }); | ||
| 1114 | fs.writeFileSync(attachmentsSentinelPath, "keep-attachments-root", "utf8"); | ||
| 1115 | fs.writeFileSync(stateDirSentinelPath, "keep-state-dir", "utf8"); | ||
| 1116 | |||
| 1117 | const serverConfig = { | ||
| 1118 | mode: "web", | ||
| 1119 | port: 0, | ||
| 1120 | host: undefined, | ||
| 1121 | cwd: "/tmp/project-attachments", | ||
| 1122 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), | ||
| 1123 | stateDir, | ||
| 1124 | staticDir: undefined, | ||
| 1125 | devUrl: undefined, | ||
| 1126 | noBrowser: true, | ||
| 1127 | authToken: undefined, | ||
| 1128 | autoBootstrapProjectFromCwd: false, | ||
| 1129 | logWebSocketEvents: false, | ||
| 1130 | } satisfies ServerConfigShape; | ||
| 1131 | |||
| 1132 | yield* eventStore.append({ | ||
| 1133 | type: "thread.deleted", | ||
| 1134 | eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"), | ||
| 1135 | aggregateKind: "thread", | ||
| 1136 | aggregateId: ThreadId.makeUnsafe(".."), | ||
| 1137 | occurredAt: now, | ||
| 1138 | commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"), | ||
| 1139 | causationEventId: null, | ||
| 1140 | correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"), | ||
| 1141 | metadata: {}, | ||
| 1142 | payload: { | ||
| 1143 | threadId: ThreadId.makeUnsafe(".."), | ||
| 1144 | deletedAt: now, | ||
| 1145 | }, | ||
| 1146 | }); | ||
| 1147 | |||
| 1148 | yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); | ||
| 1149 | |||
| 1150 | assert.equal(fs.existsSync(attachmentsRootDir), true); | ||
| 1151 | assert.equal(fs.existsSync(attachmentsSentinelPath), true); | ||
| 1152 | assert.equal(fs.existsSync(stateDirSentinelPath), true); | ||
| 1153 | fs.rmSync(stateDir, { recursive: true, force: true }); | ||
| 1154 | }), | ||
| 1155 | ); | ||
| 1156 | |||
| 161 | it.effect("resumes from projector last_applied_sequence without replaying older events", () => | 1157 | it.effect("resumes from projector last_applied_sequence without replaying older events", () => |
| 162 | Effect.gen(function* () { | 1158 | Effect.gen(function* () { |
| 163 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | 1159 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+334-18| ... | @@ -1,5 +1,11 @@ | ... | @@ -1,5 +1,11 @@ |
| 1 | import { ApprovalRequestId, type OrchestrationEvent } from "@t3tools/contracts"; | 1 | import Mime from "@effect/platform-node/Mime"; |
| 2 | import { Effect, Layer, Option, Stream } from "effect"; | 2 | import { |
| 3 | ApprovalRequestId, | ||
| 4 | type ChatAttachment, | ||
| 5 | type OrchestrationEvent, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 8 | import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect"; | ||
| 3 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 9 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 4 | 10 | ||
| 5 | import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; | 11 | import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; |
| ... | @@ -27,10 +33,16 @@ import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ | ... | @@ -27,10 +33,16 @@ import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ |
| 27 | import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; | 33 | import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; |
| 28 | import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; | 34 | import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; |
| 29 | import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; | 35 | import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; |
| 36 | import { ServerConfig } from "../../config.ts"; | ||
| 30 | import { | 37 | import { |
| 31 | OrchestrationProjectionPipeline, | 38 | OrchestrationProjectionPipeline, |
| 32 | type OrchestrationProjectionPipelineShape, | 39 | type OrchestrationProjectionPipelineShape, |
| 33 | } from "../Services/ProjectionPipeline.ts"; | 40 | } from "../Services/ProjectionPipeline.ts"; |
| 41 | import { | ||
| 42 | ATTACHMENTS_ROUTE_PREFIX, | ||
| 43 | IMAGE_EXTENSION_BY_MIME_TYPE, | ||
| 44 | SAFE_IMAGE_FILE_EXTENSIONS, | ||
| 45 | } from "../../projectFaviconRoute.ts"; | ||
| 34 | 46 | ||
| 35 | export const ORCHESTRATION_PROJECTOR_NAMES = { | 47 | export const ORCHESTRATION_PROJECTOR_NAMES = { |
| 36 | projects: "projection.projects", | 48 | projects: "projection.projects", |
| ... | @@ -48,9 +60,122 @@ type ProjectorName = | ... | @@ -48,9 +60,122 @@ type ProjectorName = |
| 48 | 60 | ||
| 49 | interface ProjectorDefinition { | 61 | interface ProjectorDefinition { |
| 50 | readonly name: ProjectorName; | 62 | readonly name: ProjectorName; |
| 51 | readonly apply: (event: OrchestrationEvent) => Effect.Effect<void, ProjectionRepositoryError>; | 63 | readonly apply: ( |
| 64 | event: OrchestrationEvent, | ||
| 65 | attachmentSideEffects: AttachmentSideEffects, | ||
| 66 | ) => Effect.Effect<void, ProjectionRepositoryError>; | ||
| 67 | } | ||
| 68 | |||
| 69 | interface PendingAttachmentWrite { | ||
| 70 | readonly absolutePath: string; | ||
| 71 | readonly bytes: Uint8Array; | ||
| 72 | } | ||
| 73 | |||
| 74 | interface AttachmentSideEffects { | ||
| 75 | readonly writes: Array<PendingAttachmentWrite>; | ||
| 76 | readonly deletedThreadIds: Set<string>; | ||
| 77 | readonly prunedThreadRelativePaths: Map<string, Set<string>>; | ||
| 78 | } | ||
| 79 | |||
| 80 | function toSafeThreadAttachmentSegment(threadId: string): string | null { | ||
| 81 | const segment = encodeURIComponent(threadId); | ||
| 82 | if ( | ||
| 83 | segment.length === 0 || | ||
| 84 | segment === "." || | ||
| 85 | segment === ".." || | ||
| 86 | segment.includes("/") || | ||
| 87 | segment.includes("\\") || | ||
| 88 | segment.includes("\0") | ||
| 89 | ) { | ||
| 90 | return null; | ||
| 91 | } | ||
| 92 | return segment; | ||
| 93 | } | ||
| 94 | |||
| 95 | function parseBase64DataUrl( | ||
| 96 | dataUrl: string, | ||
| 97 | ): { readonly mimeType: string; readonly base64: string } | null { | ||
| 98 | const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim()); | ||
| 99 | if (!match) return null; | ||
| 100 | |||
| 101 | const mimeType = match[1]?.trim().toLowerCase(); | ||
| 102 | const base64 = match[2]?.replace(/\s+/g, ""); | ||
| 103 | if (!mimeType || !base64) return null; | ||
| 104 | |||
| 105 | return { mimeType, base64 }; | ||
| 106 | } | ||
| 107 | |||
| 108 | function inferImageExtension(attachment: Extract<ChatAttachment, { type: "image" }>): string { | ||
| 109 | const normalizedMimeType = attachment.mimeType.toLowerCase(); | ||
| 110 | if (normalizedMimeType.startsWith("image/")) { | ||
| 111 | const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[normalizedMimeType]; | ||
| 112 | if (fromMime) return fromMime; | ||
| 113 | } | ||
| 114 | const ext = Mime.getExtension(attachment.mimeType); | ||
| 115 | if (ext && SAFE_IMAGE_FILE_EXTENSIONS.has(ext)) return ext; | ||
| 116 | return ".bin"; | ||
| 52 | } | 117 | } |
| 53 | 118 | ||
| 119 | const materializeAttachmentsForProjection = Effect.fn(function* (input: { | ||
| 120 | readonly threadId: string; | ||
| 121 | readonly messageId: string; | ||
| 122 | readonly attachments: ReadonlyArray<ChatAttachment>; | ||
| 123 | readonly stageFileWrite: (pendingWrite: PendingAttachmentWrite) => void; | ||
| 124 | }) { | ||
| 125 | if (input.attachments.length === 0) return []; | ||
| 126 | |||
| 127 | const serverConfig = yield* Effect.serviceOption(ServerConfig); | ||
| 128 | if (Option.isNone(serverConfig)) return input.attachments; | ||
| 129 | |||
| 130 | const path = yield* Path.Path; | ||
| 131 | const attachmentsRootDir = path.join(serverConfig.value.stateDir, "attachments"); | ||
| 132 | const threadSegment = toSafeThreadAttachmentSegment(input.threadId); | ||
| 133 | if (!threadSegment) { | ||
| 134 | yield* Effect.logWarning("skipping attachment materialization for unsafe thread id", { | ||
| 135 | threadId: input.threadId, | ||
| 136 | messageId: input.messageId, | ||
| 137 | }); | ||
| 138 | return input.attachments; | ||
| 139 | } | ||
| 140 | const messageSegment = encodeURIComponent(input.messageId); | ||
| 141 | |||
| 142 | return yield* Effect.forEach( | ||
| 143 | input.attachments, | ||
| 144 | (attachment, index) => | ||
| 145 | Effect.sync(() => { | ||
| 146 | if (attachment.type !== "image" || !attachment.dataUrl.startsWith("data:")) { | ||
| 147 | return attachment; | ||
| 148 | } | ||
| 149 | |||
| 150 | const parsed = parseBase64DataUrl(attachment.dataUrl); | ||
| 151 | if (!parsed) { | ||
| 152 | return attachment; | ||
| 153 | } | ||
| 154 | if (!parsed.mimeType.startsWith("image/")) { | ||
| 155 | return attachment; | ||
| 156 | } | ||
| 157 | |||
| 158 | const bytes = Buffer.from(parsed.base64, "base64"); | ||
| 159 | if (bytes.byteLength === 0) return attachment; | ||
| 160 | |||
| 161 | const fileName = `${index}${inferImageExtension({ | ||
| 162 | ...attachment, | ||
| 163 | mimeType: parsed.mimeType, | ||
| 164 | })}`; | ||
| 165 | const uniqueFileName = `${messageSegment}-${fileName}`; | ||
| 166 | const relativePath = `${threadSegment}/${uniqueFileName}`; | ||
| 167 | const absolutePath = path.join(attachmentsRootDir, threadSegment, uniqueFileName); | ||
| 168 | input.stageFileWrite({ absolutePath, bytes }); | ||
| 169 | |||
| 170 | return { | ||
| 171 | ...attachment, | ||
| 172 | dataUrl: `${ATTACHMENTS_ROUTE_PREFIX}/${relativePath}`, | ||
| 173 | } satisfies ChatAttachment; | ||
| 174 | }), | ||
| 175 | { concurrency: 1 }, | ||
| 176 | ); | ||
| 177 | }); | ||
| 178 | |||
| 54 | function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { | 179 | function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { |
| 55 | if (typeof payload !== "object" || payload === null) { | 180 | if (typeof payload !== "object" || payload === null) { |
| 56 | return null; | 181 | return null; |
| ... | @@ -163,6 +288,138 @@ function retainProjectionActivitiesAfterRevert( | ... | @@ -163,6 +288,138 @@ function retainProjectionActivitiesAfterRevert( |
| 163 | ); | 288 | ); |
| 164 | } | 289 | } |
| 165 | 290 | ||
| 291 | function toAttachmentRelativePath(dataUrl: string): string | null { | ||
| 292 | const prefix = `${ATTACHMENTS_ROUTE_PREFIX}/`; | ||
| 293 | if (!dataUrl.startsWith(prefix)) { | ||
| 294 | return null; | ||
| 295 | } | ||
| 296 | const relativePath = dataUrl.slice(prefix.length).replace(/^[/\\]+/, ""); | ||
| 297 | if (relativePath.length === 0 || relativePath.startsWith("..")) { | ||
| 298 | return null; | ||
| 299 | } | ||
| 300 | return relativePath.replace(/\\/g, "/"); | ||
| 301 | } | ||
| 302 | |||
| 303 | function collectThreadAttachmentRelativePaths( | ||
| 304 | threadId: string, | ||
| 305 | messages: ReadonlyArray<ProjectionThreadMessage>, | ||
| 306 | ): Set<string> { | ||
| 307 | const threadSegment = toSafeThreadAttachmentSegment(threadId); | ||
| 308 | if (!threadSegment) { | ||
| 309 | return new Set(); | ||
| 310 | } | ||
| 311 | const threadPrefix = `${threadSegment}/`; | ||
| 312 | const relativePaths = new Set<string>(); | ||
| 313 | for (const message of messages) { | ||
| 314 | for (const attachment of message.attachments ?? []) { | ||
| 315 | if (attachment.type !== "image") { | ||
| 316 | continue; | ||
| 317 | } | ||
| 318 | const relativePath = toAttachmentRelativePath(attachment.dataUrl); | ||
| 319 | if (!relativePath || !relativePath.startsWith(threadPrefix)) { | ||
| 320 | continue; | ||
| 321 | } | ||
| 322 | const threadRelativePath = relativePath.slice(threadPrefix.length).replace(/^[/\\]+/, ""); | ||
| 323 | if (threadRelativePath.length === 0 || threadRelativePath.startsWith("..")) { | ||
| 324 | continue; | ||
| 325 | } | ||
| 326 | relativePaths.add(threadRelativePath.replace(/\\/g, "/")); | ||
| 327 | } | ||
| 328 | } | ||
| 329 | return relativePaths; | ||
| 330 | } | ||
| 331 | |||
| 332 | const runAttachmentSideEffects = Effect.fn(function* (input: { | ||
| 333 | readonly sideEffects: AttachmentSideEffects; | ||
| 334 | readonly fileSystem: FileSystem.FileSystem; | ||
| 335 | readonly path: Path.Path; | ||
| 336 | }) { | ||
| 337 | const serverConfig = yield* Effect.serviceOption(ServerConfig); | ||
| 338 | if (Option.isNone(serverConfig)) { | ||
| 339 | return; | ||
| 340 | } | ||
| 341 | |||
| 342 | const attachmentsRootDir = input.path.join(serverConfig.value.stateDir, "attachments"); | ||
| 343 | |||
| 344 | yield* Effect.forEach( | ||
| 345 | input.sideEffects.deletedThreadIds, | ||
| 346 | (threadId) => | ||
| 347 | Effect.gen(function* () { | ||
| 348 | const threadSegment = toSafeThreadAttachmentSegment(threadId); | ||
| 349 | if (!threadSegment) { | ||
| 350 | yield* Effect.logWarning("skipping attachment cleanup for unsafe thread id", { | ||
| 351 | threadId, | ||
| 352 | }); | ||
| 353 | return; | ||
| 354 | } | ||
| 355 | const threadDir = input.path.join(attachmentsRootDir, threadSegment); | ||
| 356 | yield* input.fileSystem.remove(threadDir, { recursive: true, force: true }); | ||
| 357 | }), | ||
| 358 | { concurrency: 1 }, | ||
| 359 | ); | ||
| 360 | |||
| 361 | yield* Effect.forEach( | ||
| 362 | input.sideEffects.prunedThreadRelativePaths.entries(), | ||
| 363 | ([threadId, keptThreadRelativePaths]) => { | ||
| 364 | if (input.sideEffects.deletedThreadIds.has(threadId)) { | ||
| 365 | return Effect.void; | ||
| 366 | } | ||
| 367 | return Effect.gen(function* () { | ||
| 368 | const threadSegment = toSafeThreadAttachmentSegment(threadId); | ||
| 369 | if (!threadSegment) { | ||
| 370 | yield* Effect.logWarning("skipping attachment prune for unsafe thread id", { threadId }); | ||
| 371 | return; | ||
| 372 | } | ||
| 373 | const threadDir = input.path.join(attachmentsRootDir, threadSegment); | ||
| 374 | const entries = yield* input.fileSystem | ||
| 375 | .readDirectory(threadDir, { recursive: true }) | ||
| 376 | .pipe(Effect.catch(() => Effect.succeed([] as Array<string>))); | ||
| 377 | yield* Effect.forEach( | ||
| 378 | entries, | ||
| 379 | (entry) => | ||
| 380 | Effect.gen(function* () { | ||
| 381 | const threadRelativePath = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); | ||
| 382 | if (threadRelativePath.length === 0 || threadRelativePath.startsWith("..")) { | ||
| 383 | return; | ||
| 384 | } | ||
| 385 | |||
| 386 | const absolutePath = input.path.join(threadDir, threadRelativePath); | ||
| 387 | const fileInfo = yield* input.fileSystem | ||
| 388 | .stat(absolutePath) | ||
| 389 | .pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 390 | if (!fileInfo || fileInfo.type !== "File") { | ||
| 391 | return; | ||
| 392 | } | ||
| 393 | |||
| 394 | if (!keptThreadRelativePaths.has(threadRelativePath)) { | ||
| 395 | yield* input.fileSystem.remove(absolutePath, { force: true }); | ||
| 396 | } | ||
| 397 | }), | ||
| 398 | { concurrency: 1 }, | ||
| 399 | ); | ||
| 400 | }); | ||
| 401 | }, | ||
| 402 | { concurrency: 1 }, | ||
| 403 | ); | ||
| 404 | |||
| 405 | const writesByPath = new Map<string, Uint8Array>(); | ||
| 406 | for (const pendingWrite of input.sideEffects.writes) { | ||
| 407 | // Last write wins for a path so updated attachment contents replace stale bytes. | ||
| 408 | writesByPath.set(pendingWrite.absolutePath, pendingWrite.bytes); | ||
| 409 | } | ||
| 410 | yield* Effect.forEach( | ||
| 411 | writesByPath.entries(), | ||
| 412 | ([absolutePath, bytes]) => | ||
| 413 | Effect.gen(function* () { | ||
| 414 | yield* input.fileSystem.makeDirectory(input.path.dirname(absolutePath), { | ||
| 415 | recursive: true, | ||
| 416 | }); | ||
| 417 | yield* input.fileSystem.writeFile(absolutePath, bytes); | ||
| 418 | }), | ||
| 419 | { concurrency: 1 }, | ||
| 420 | ); | ||
| 421 | }); | ||
| 422 | |||
| 166 | const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | 423 | const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 167 | const sql = yield* SqlClient.SqlClient; | 424 | const sql = yield* SqlClient.SqlClient; |
| 168 | const eventStore = yield* OrchestrationEventStore; | 425 | const eventStore = yield* OrchestrationEventStore; |
| ... | @@ -175,7 +432,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -175,7 +432,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 175 | const projectionTurnRepository = yield* ProjectionTurnRepository; | 432 | const projectionTurnRepository = yield* ProjectionTurnRepository; |
| 176 | const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; | 433 | const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; |
| 177 | 434 | ||
| 178 | const applyProjectsProjection: ProjectorDefinition["apply"] = (event) => | 435 | const fileSystem = yield* FileSystem.FileSystem; |
| 436 | const path = yield* Path.Path; | ||
| 437 | |||
| 438 | const applyProjectsProjection: ProjectorDefinition["apply"] = (event, _attachmentSideEffects) => | ||
| 179 | Effect.gen(function* () { | 439 | Effect.gen(function* () { |
| 180 | switch (event.type) { | 440 | switch (event.type) { |
| 181 | case "project.created": | 441 | case "project.created": |
| ... | @@ -233,7 +493,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -233,7 +493,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 233 | } | 493 | } |
| 234 | }); | 494 | }); |
| 235 | 495 | ||
| 236 | const applyThreadsProjection: ProjectorDefinition["apply"] = (event) => | 496 | const applyThreadsProjection: ProjectorDefinition["apply"] = (event, attachmentSideEffects) => |
| 237 | Effect.gen(function* () { | 497 | Effect.gen(function* () { |
| 238 | switch (event.type) { | 498 | switch (event.type) { |
| 239 | case "thread.created": | 499 | case "thread.created": |
| ... | @@ -272,6 +532,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -272,6 +532,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 272 | } | 532 | } |
| 273 | 533 | ||
| 274 | case "thread.deleted": { | 534 | case "thread.deleted": { |
| 535 | attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); | ||
| 275 | const existingRow = yield* projectionThreadRepository.getById({ | 536 | const existingRow = yield* projectionThreadRepository.getById({ |
| 276 | threadId: event.payload.threadId, | 537 | threadId: event.payload.threadId, |
| 277 | }); | 538 | }); |
| ... | @@ -351,7 +612,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -351,7 +612,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 351 | } | 612 | } |
| 352 | }); | 613 | }); |
| 353 | 614 | ||
| 354 | const applyThreadMessagesProjection: ProjectorDefinition["apply"] = (event) => | 615 | const applyThreadMessagesProjection: ProjectorDefinition["apply"] = ( |
| 616 | event, | ||
| 617 | attachmentSideEffects, | ||
| 618 | ) => | ||
| 355 | Effect.gen(function* () { | 619 | Effect.gen(function* () { |
| 356 | switch (event.type) { | 620 | switch (event.type) { |
| 357 | case "thread.message-sent": { | 621 | case "thread.message-sent": { |
| ... | @@ -367,12 +631,24 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -367,12 +631,24 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 367 | : existingMessage && event.payload.text.length === 0 | 631 | : existingMessage && event.payload.text.length === 0 |
| 368 | ? existingMessage.text | 632 | ? existingMessage.text |
| 369 | : event.payload.text; | 633 | : event.payload.text; |
| 634 | const nextAttachments = | ||
| 635 | event.payload.attachments !== undefined | ||
| 636 | ? yield* materializeAttachmentsForProjection({ | ||
| 637 | threadId: event.payload.threadId, | ||
| 638 | messageId: event.payload.messageId, | ||
| 639 | attachments: event.payload.attachments, | ||
| 640 | stageFileWrite: (pendingWrite) => { | ||
| 641 | attachmentSideEffects.writes.push(pendingWrite); | ||
| 642 | }, | ||
| 643 | }).pipe(Effect.provideService(Path.Path, path)) | ||
| 644 | : existingMessage?.attachments; | ||
| 370 | yield* projectionThreadMessageRepository.upsert({ | 645 | yield* projectionThreadMessageRepository.upsert({ |
| 371 | messageId: event.payload.messageId, | 646 | messageId: event.payload.messageId, |
| 372 | threadId: event.payload.threadId, | 647 | threadId: event.payload.threadId, |
| 373 | turnId: event.payload.turnId, | 648 | turnId: event.payload.turnId, |
| 374 | role: event.payload.role, | 649 | role: event.payload.role, |
| 375 | text: nextText, | 650 | text: nextText, |
| 651 | ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), | ||
| 376 | isStreaming: event.payload.streaming, | 652 | isStreaming: event.payload.streaming, |
| 377 | createdAt: existingMessage?.createdAt ?? event.payload.createdAt, | 653 | createdAt: existingMessage?.createdAt ?? event.payload.createdAt, |
| 378 | updatedAt: event.payload.updatedAt, | 654 | updatedAt: event.payload.updatedAt, |
| ... | @@ -406,6 +682,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -406,6 +682,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 406 | yield* Effect.forEach(keptRows, projectionThreadMessageRepository.upsert, { | 682 | yield* Effect.forEach(keptRows, projectionThreadMessageRepository.upsert, { |
| 407 | concurrency: 1, | 683 | concurrency: 1, |
| 408 | }).pipe(Effect.asVoid); | 684 | }).pipe(Effect.asVoid); |
| 685 | attachmentSideEffects.prunedThreadRelativePaths.set( | ||
| 686 | event.payload.threadId, | ||
| 687 | collectThreadAttachmentRelativePaths(event.payload.threadId, keptRows), | ||
| 688 | ); | ||
| 409 | return; | 689 | return; |
| 410 | } | 690 | } |
| 411 | 691 | ||
| ... | @@ -414,7 +694,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -414,7 +694,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 414 | } | 694 | } |
| 415 | }); | 695 | }); |
| 416 | 696 | ||
| 417 | const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = (event) => | 697 | const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = ( |
| 698 | event, | ||
| 699 | _attachmentSideEffects, | ||
| 700 | ) => | ||
| 418 | Effect.gen(function* () { | 701 | Effect.gen(function* () { |
| 419 | switch (event.type) { | 702 | switch (event.type) { |
| 420 | case "thread.activity-appended": | 703 | case "thread.activity-appended": |
| ... | @@ -462,7 +745,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -462,7 +745,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 462 | } | 745 | } |
| 463 | }); | 746 | }); |
| 464 | 747 | ||
| 465 | const applyThreadSessionsProjection: ProjectorDefinition["apply"] = (event) => | 748 | const applyThreadSessionsProjection: ProjectorDefinition["apply"] = ( |
| 749 | event, | ||
| 750 | _attachmentSideEffects, | ||
| 751 | ) => | ||
| 466 | Effect.gen(function* () { | 752 | Effect.gen(function* () { |
| 467 | if (event.type !== "thread.session-set") { | 753 | if (event.type !== "thread.session-set") { |
| 468 | return; | 754 | return; |
| ... | @@ -481,7 +767,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -481,7 +767,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 481 | }); | 767 | }); |
| 482 | }); | 768 | }); |
| 483 | 769 | ||
| 484 | const applyThreadTurnsProjection: ProjectorDefinition["apply"] = (event) => | 770 | const applyThreadTurnsProjection: ProjectorDefinition["apply"] = ( |
| 771 | event, | ||
| 772 | _attachmentSideEffects, | ||
| 773 | ) => | ||
| 485 | Effect.gen(function* () { | 774 | Effect.gen(function* () { |
| 486 | switch (event.type) { | 775 | switch (event.type) { |
| 487 | case "thread.turn-start-requested": { | 776 | case "thread.turn-start-requested": { |
| ... | @@ -714,7 +1003,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -714,7 +1003,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 714 | 1003 | ||
| 715 | const applyCheckpointsProjection: ProjectorDefinition["apply"] = () => Effect.void; | 1004 | const applyCheckpointsProjection: ProjectorDefinition["apply"] = () => Effect.void; |
| 716 | 1005 | ||
| 717 | const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = (event) => | 1006 | const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = ( |
| 1007 | event, | ||
| 1008 | _attachmentSideEffects, | ||
| 1009 | ) => | ||
| 718 | Effect.gen(function* () { | 1010 | Effect.gen(function* () { |
| 719 | switch (event.type) { | 1011 | switch (event.type) { |
| 720 | case "thread.activity-appended": { | 1012 | case "thread.activity-appended": { |
| ... | @@ -837,17 +1129,40 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -837,17 +1129,40 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 837 | ]; | 1129 | ]; |
| 838 | 1130 | ||
| 839 | const runProjectorForEvent = (projector: ProjectorDefinition, event: OrchestrationEvent) => | 1131 | const runProjectorForEvent = (projector: ProjectorDefinition, event: OrchestrationEvent) => |
| 840 | sql.withTransaction( | 1132 | Effect.gen(function* () { |
| 841 | projector.apply(event).pipe( | 1133 | const attachmentSideEffects: AttachmentSideEffects = { |
| 842 | Effect.flatMap(() => | 1134 | writes: [], |
| 843 | projectionStateRepository.upsert({ | 1135 | deletedThreadIds: new Set<string>(), |
| 1136 | prunedThreadRelativePaths: new Map<string, Set<string>>(), | ||
| 1137 | }; | ||
| 1138 | |||
| 1139 | yield* sql.withTransaction( | ||
| 1140 | projector.apply(event, attachmentSideEffects).pipe( | ||
| 1141 | Effect.flatMap(() => | ||
| 1142 | projectionStateRepository.upsert({ | ||
| 1143 | projector: projector.name, | ||
| 1144 | lastAppliedSequence: event.sequence, | ||
| 1145 | updatedAt: event.occurredAt, | ||
| 1146 | }), | ||
| 1147 | ), | ||
| 1148 | ), | ||
| 1149 | ); | ||
| 1150 | |||
| 1151 | yield* runAttachmentSideEffects({ | ||
| 1152 | sideEffects: attachmentSideEffects, | ||
| 1153 | fileSystem, | ||
| 1154 | path, | ||
| 1155 | }).pipe( | ||
| 1156 | Effect.catch((cause) => | ||
| 1157 | Effect.logWarning("failed to apply projected attachment side-effects", { | ||
| 844 | projector: projector.name, | 1158 | projector: projector.name, |
| 845 | lastAppliedSequence: event.sequence, | 1159 | sequence: event.sequence, |
| 846 | updatedAt: event.occurredAt, | 1160 | eventType: event.type, |
| 1161 | cause, | ||
| 847 | }), | 1162 | }), |
| 848 | ), | 1163 | ), |
| 849 | ), | 1164 | ); |
| 850 | ); | 1165 | }); |
| 851 | 1166 | ||
| 852 | const bootstrapProjector = (projector: ProjectorDefinition) => | 1167 | const bootstrapProjector = (projector: ProjectorDefinition) => |
| 853 | projectionStateRepository | 1168 | projectionStateRepository |
| ... | @@ -901,6 +1216,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( | ... | @@ -901,6 +1216,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( |
| 901 | OrchestrationProjectionPipeline, | 1216 | OrchestrationProjectionPipeline, |
| 902 | makeOrchestrationProjectionPipeline, | 1217 | makeOrchestrationProjectionPipeline, |
| 903 | ).pipe( | 1218 | ).pipe( |
| 1219 | Layer.provideMerge(NodeServices.layer), | ||
| 904 | Layer.provideMerge(ProjectionProjectRepositoryLive), | 1220 | Layer.provideMerge(ProjectionProjectRepositoryLive), |
| 905 | Layer.provideMerge(ProjectionThreadRepositoryLive), | 1221 | Layer.provideMerge(ProjectionThreadRepositoryLive), |
| 906 | Layer.provideMerge(ProjectionThreadMessageRepositoryLive), | 1222 | Layer.provideMerge(ProjectionThreadMessageRepositoryLive), |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+5-1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | ChatAttachment, | ||
| 2 | IsoDateTime, | 3 | IsoDateTime, |
| 3 | MessageId, | 4 | MessageId, |
| 4 | OrchestrationCheckpointFile, | 5 | OrchestrationCheckpointFile, |
| ... | @@ -42,12 +43,13 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( | ... | @@ -42,12 +43,13 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( |
| 42 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), | 43 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), |
| 43 | }), | 44 | }), |
| 44 | ); | 45 | ); |
| 45 | const ProjectionThreadDbRowSchema = ProjectionThread; | ||
| 46 | const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( | 46 | const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( |
| 47 | Struct.assign({ | 47 | Struct.assign({ |
| 48 | isStreaming: Schema.Number, | 48 | isStreaming: Schema.Number, |
| 49 | attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), | ||
| 49 | }), | 50 | }), |
| 50 | ); | 51 | ); |
| 52 | const ProjectionThreadDbRowSchema = ProjectionThread; | ||
| 51 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( | 53 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( |
| 52 | Struct.assign({ | 54 | Struct.assign({ |
| 53 | payload: Schema.fromJsonString(Schema.Unknown), | 55 | payload: Schema.fromJsonString(Schema.Unknown), |
| ... | @@ -171,6 +173,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -171,6 +173,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 171 | turn_id AS "turnId", | 173 | turn_id AS "turnId", |
| 172 | role, | 174 | role, |
| 173 | text, | 175 | text, |
| 176 | attachments_json AS "attachments", | ||
| 174 | is_streaming AS "isStreaming", | 177 | is_streaming AS "isStreaming", |
| 175 | created_at AS "createdAt", | 178 | created_at AS "createdAt", |
| 176 | updated_at AS "updatedAt" | 179 | updated_at AS "updatedAt" |
| ... | @@ -376,6 +379,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -376,6 +379,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 376 | id: row.messageId, | 379 | id: row.messageId, |
| 377 | role: row.role, | 380 | role: row.role, |
| 378 | text: row.text, | 381 | text: row.text, |
| 382 | ...(row.attachments !== null ? { attachments: row.attachments } : {}), | ||
| 379 | turnId: row.turnId, | 383 | turnId: row.turnId, |
| 380 | streaming: row.isStreaming === 1, | 384 | streaming: row.isStreaming === 1, |
| 381 | createdAt: row.createdAt, | 385 | createdAt: row.createdAt, |
apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts created+106| ... | @@ -0,0 +1,106 @@ | ||
| 1 | import { MessageId, ThreadId } from "@t3tools/contracts"; | ||
| 2 | import { assert, it } from "@effect/vitest"; | ||
| 3 | import { Effect, Layer } from "effect"; | ||
| 4 | |||
| 5 | import { ProjectionThreadMessageRepository } from "../Services/ProjectionThreadMessages.ts"; | ||
| 6 | import { ProjectionThreadMessageRepositoryLive } from "./ProjectionThreadMessages.ts"; | ||
| 7 | import { SqlitePersistenceMemory } from "./Sqlite.ts"; | ||
| 8 | |||
| 9 | const layer = it.layer( | ||
| 10 | ProjectionThreadMessageRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), | ||
| 11 | ); | ||
| 12 | |||
| 13 | layer("ProjectionThreadMessageRepository", (it) => { | ||
| 14 | it.effect("preserves existing attachments when upsert omits attachments", () => | ||
| 15 | Effect.gen(function* () { | ||
| 16 | const repository = yield* ProjectionThreadMessageRepository; | ||
| 17 | const threadId = ThreadId.makeUnsafe("thread-preserve-attachments"); | ||
| 18 | const messageId = MessageId.makeUnsafe("message-preserve-attachments"); | ||
| 19 | const createdAt = "2026-02-28T19:00:00.000Z"; | ||
| 20 | const updatedAt = "2026-02-28T19:00:01.000Z"; | ||
| 21 | const persistedAttachments = [ | ||
| 22 | { | ||
| 23 | type: "image" as const, | ||
| 24 | name: "example.png", | ||
| 25 | mimeType: "image/png", | ||
| 26 | sizeBytes: 5, | ||
| 27 | dataUrl: "/attachments/thread-preserve-attachments/message-preserve-attachments/0.png", | ||
| 28 | }, | ||
| 29 | ]; | ||
| 30 | |||
| 31 | yield* repository.upsert({ | ||
| 32 | messageId, | ||
| 33 | threadId, | ||
| 34 | turnId: null, | ||
| 35 | role: "user", | ||
| 36 | text: "initial", | ||
| 37 | attachments: persistedAttachments, | ||
| 38 | isStreaming: false, | ||
| 39 | createdAt, | ||
| 40 | updatedAt, | ||
| 41 | }); | ||
| 42 | |||
| 43 | yield* repository.upsert({ | ||
| 44 | messageId, | ||
| 45 | threadId, | ||
| 46 | turnId: null, | ||
| 47 | role: "user", | ||
| 48 | text: "updated", | ||
| 49 | isStreaming: false, | ||
| 50 | createdAt, | ||
| 51 | updatedAt: "2026-02-28T19:00:02.000Z", | ||
| 52 | }); | ||
| 53 | |||
| 54 | const rows = yield* repository.listByThreadId({ threadId }); | ||
| 55 | assert.equal(rows.length, 1); | ||
| 56 | assert.equal(rows[0]?.text, "updated"); | ||
| 57 | assert.deepEqual(rows[0]?.attachments, persistedAttachments); | ||
| 58 | }), | ||
| 59 | ); | ||
| 60 | |||
| 61 | it.effect("allows explicit attachment clearing with an empty array", () => | ||
| 62 | Effect.gen(function* () { | ||
| 63 | const repository = yield* ProjectionThreadMessageRepository; | ||
| 64 | const threadId = ThreadId.makeUnsafe("thread-clear-attachments"); | ||
| 65 | const messageId = MessageId.makeUnsafe("message-clear-attachments"); | ||
| 66 | const createdAt = "2026-02-28T19:10:00.000Z"; | ||
| 67 | |||
| 68 | yield* repository.upsert({ | ||
| 69 | messageId, | ||
| 70 | threadId, | ||
| 71 | turnId: null, | ||
| 72 | role: "assistant", | ||
| 73 | text: "with attachment", | ||
| 74 | attachments: [ | ||
| 75 | { | ||
| 76 | type: "image", | ||
| 77 | name: "example.png", | ||
| 78 | mimeType: "image/png", | ||
| 79 | sizeBytes: 5, | ||
| 80 | dataUrl: "/attachments/thread-clear-attachments/message-clear-attachments/0.png", | ||
| 81 | }, | ||
| 82 | ], | ||
| 83 | isStreaming: false, | ||
| 84 | createdAt, | ||
| 85 | updatedAt: "2026-02-28T19:10:01.000Z", | ||
| 86 | }); | ||
| 87 | |||
| 88 | yield* repository.upsert({ | ||
| 89 | messageId, | ||
| 90 | threadId, | ||
| 91 | turnId: null, | ||
| 92 | role: "assistant", | ||
| 93 | text: "cleared", | ||
| 94 | attachments: [], | ||
| 95 | isStreaming: false, | ||
| 96 | createdAt, | ||
| 97 | updatedAt: "2026-02-28T19:10:02.000Z", | ||
| 98 | }); | ||
| 99 | |||
| 100 | const rows = yield* repository.listByThreadId({ threadId }); | ||
| 101 | assert.equal(rows.length, 1); | ||
| 102 | assert.equal(rows[0]?.text, "cleared"); | ||
| 103 | assert.deepEqual(rows[0]?.attachments, []); | ||
| 104 | }), | ||
| 105 | ); | ||
| 106 | }); | ||
apps/server/src/persistence/Layers/ProjectionThreadMessages.ts+30-4| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 3 | import { Effect, Layer, Schema, Struct } from "effect"; | 3 | import { Effect, Layer, Schema, Struct } from "effect"; |
| 4 | import { ChatAttachment } from "@t3tools/contracts"; | ||
| 4 | 5 | ||
| 5 | import { toPersistenceSqlError } from "../Errors.ts"; | 6 | import { toPersistenceSqlError } from "../Errors.ts"; |
| 6 | import { | 7 | import { |
| ... | @@ -14,6 +15,7 @@ import { | ... | @@ -14,6 +15,7 @@ import { |
| 14 | const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( | 15 | const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( |
| 15 | Struct.assign({ | 16 | Struct.assign({ |
| 16 | isStreaming: Schema.Number, | 17 | isStreaming: Schema.Number, |
| 18 | attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), | ||
| 17 | }), | 19 | }), |
| 18 | ); | 20 | ); |
| 19 | 21 | ||
| ... | @@ -22,14 +24,17 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { | ... | @@ -22,14 +24,17 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 22 | 24 | ||
| 23 | const upsertProjectionThreadMessageRow = SqlSchema.void({ | 25 | const upsertProjectionThreadMessageRow = SqlSchema.void({ |
| 24 | Request: ProjectionThreadMessage, | 26 | Request: ProjectionThreadMessage, |
| 25 | execute: (row) => | 27 | execute: (row) => { |
| 26 | sql` | 28 | const nextAttachmentsJson = |
| 29 | row.attachments !== undefined ? JSON.stringify(row.attachments) : null; | ||
| 30 | return sql` | ||
| 27 | INSERT INTO projection_thread_messages ( | 31 | INSERT INTO projection_thread_messages ( |
| 28 | message_id, | 32 | message_id, |
| 29 | thread_id, | 33 | thread_id, |
| 30 | turn_id, | 34 | turn_id, |
| 31 | role, | 35 | role, |
| 32 | text, | 36 | text, |
| 37 | attachments_json, | ||
| 33 | is_streaming, | 38 | is_streaming, |
| 34 | created_at, | 39 | created_at, |
| 35 | updated_at | 40 | updated_at |
| ... | @@ -40,6 +45,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { | ... | @@ -40,6 +45,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 40 | ${row.turnId}, | 45 | ${row.turnId}, |
| 41 | ${row.role}, | 46 | ${row.role}, |
| 42 | ${row.text}, | 47 | ${row.text}, |
| 48 | COALESCE( | ||
| 49 | ${nextAttachmentsJson}, | ||
| 50 | ( | ||
| 51 | SELECT attachments_json | ||
| 52 | FROM projection_thread_messages | ||
| 53 | WHERE message_id = ${row.messageId} | ||
| 54 | ) | ||
| 55 | ), | ||
| 43 | ${row.isStreaming ? 1 : 0}, | 56 | ${row.isStreaming ? 1 : 0}, |
| 44 | ${row.createdAt}, | 57 | ${row.createdAt}, |
| 45 | ${row.updatedAt} | 58 | ${row.updatedAt} |
| ... | @@ -50,10 +63,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { | ... | @@ -50,10 +63,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 50 | turn_id = excluded.turn_id, | 63 | turn_id = excluded.turn_id, |
| 51 | role = excluded.role, | 64 | role = excluded.role, |
| 52 | text = excluded.text, | 65 | text = excluded.text, |
| 66 | attachments_json = COALESCE( | ||
| 67 | excluded.attachments_json, | ||
| 68 | projection_thread_messages.attachments_json | ||
| 69 | ), | ||
| 53 | is_streaming = excluded.is_streaming, | 70 | is_streaming = excluded.is_streaming, |
| 54 | created_at = excluded.created_at, | 71 | created_at = excluded.created_at, |
| 55 | updated_at = excluded.updated_at | 72 | updated_at = excluded.updated_at |
| 56 | `, | 73 | `; |
| 74 | }, | ||
| 57 | }); | 75 | }); |
| 58 | 76 | ||
| 59 | const listProjectionThreadMessageRows = SqlSchema.findAll({ | 77 | const listProjectionThreadMessageRows = SqlSchema.findAll({ |
| ... | @@ -67,6 +85,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { | ... | @@ -67,6 +85,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 67 | turn_id AS "turnId", | 85 | turn_id AS "turnId", |
| 68 | role, | 86 | role, |
| 69 | text, | 87 | text, |
| 88 | attachments_json AS "attachments", | ||
| 70 | is_streaming AS "isStreaming", | 89 | is_streaming AS "isStreaming", |
| 71 | created_at AS "createdAt", | 90 | created_at AS "createdAt", |
| 72 | updated_at AS "updatedAt" | 91 | updated_at AS "updatedAt" |
| ... | @@ -97,8 +116,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { | ... | @@ -97,8 +116,15 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { |
| 97 | ), | 116 | ), |
| 98 | Effect.map((rows) => | 117 | Effect.map((rows) => |
| 99 | rows.map((row) => ({ | 118 | rows.map((row) => ({ |
| 100 | ...row, | 119 | messageId: row.messageId, |
| 120 | threadId: row.threadId, | ||
| 121 | turnId: row.turnId, | ||
| 122 | role: row.role, | ||
| 123 | text: row.text, | ||
| 101 | isStreaming: row.isStreaming === 1, | 124 | isStreaming: row.isStreaming === 1, |
| 125 | createdAt: row.createdAt, | ||
| 126 | updatedAt: row.updatedAt, | ||
| 127 | ...(row.attachments !== null ? { attachments: row.attachments } : {}), | ||
| 102 | })), | 128 | })), |
| 103 | ), | 129 | ), |
| 104 | ); | 130 | ); |
apps/server/src/persistence/Migrations.ts+2| ... | @@ -18,6 +18,7 @@ import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts"; | ... | @@ -18,6 +18,7 @@ import Migration0003 from "./Migrations/003_CheckpointDiffBlobs.ts"; |
| 18 | import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts"; | 18 | import Migration0004 from "./Migrations/004_ProviderSessionRuntime.ts"; |
| 19 | import Migration0005 from "./Migrations/005_Projections.ts"; | 19 | import Migration0005 from "./Migrations/005_Projections.ts"; |
| 20 | import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts"; | 20 | import Migration0006 from "./Migrations/006_ProjectionThreadSessionRuntimeModeColumns.ts"; |
| 21 | import Migration0007 from "./Migrations/007_ProjectionThreadMessageAttachments.ts"; | ||
| 21 | 22 | ||
| 22 | /** | 23 | /** |
| 23 | * Migration loader with all migrations defined inline. | 24 | * Migration loader with all migrations defined inline. |
| ... | @@ -36,6 +37,7 @@ const loader = Migrator.fromRecord({ | ... | @@ -36,6 +37,7 @@ const loader = Migrator.fromRecord({ |
| 36 | "4_ProviderSessionRuntime": Migration0004, | 37 | "4_ProviderSessionRuntime": Migration0004, |
| 37 | "5_Projections": Migration0005, | 38 | "5_Projections": Migration0005, |
| 38 | "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006, | 39 | "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006, |
| 40 | "7_ProjectionThreadMessageAttachments": Migration0007, | ||
| 39 | }); | 41 | }); |
| 40 | 42 | ||
| 41 | /** | 43 | /** |
apps/server/src/persistence/Migrations/007_ProjectionThreadMessageAttachments.ts created+11| ... | @@ -0,0 +1,11 @@ | ||
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | |||
| 4 | export default Effect.gen(function* () { | ||
| 5 | const sql = yield* SqlClient.SqlClient; | ||
| 6 | |||
| 7 | yield* sql` | ||
| 8 | ALTER TABLE projection_thread_messages | ||
| 9 | ADD COLUMN attachments_json TEXT | ||
| 10 | `; | ||
| 11 | }); | ||
apps/server/src/persistence/Services/ProjectionThreadMessages.ts+2| ... | @@ -7,6 +7,7 @@ | ... | @@ -7,6 +7,7 @@ |
| 7 | * @module ProjectionThreadMessageRepository | 7 | * @module ProjectionThreadMessageRepository |
| 8 | */ | 8 | */ |
| 9 | import { | 9 | import { |
| 10 | ChatAttachment, | ||
| 10 | OrchestrationMessageRole, | 11 | OrchestrationMessageRole, |
| 11 | MessageId, | 12 | MessageId, |
| 12 | ThreadId, | 13 | ThreadId, |
| ... | @@ -24,6 +25,7 @@ export const ProjectionThreadMessage = Schema.Struct({ | ... | @@ -24,6 +25,7 @@ export const ProjectionThreadMessage = Schema.Struct({ |
| 24 | turnId: Schema.NullOr(TurnId), | 25 | turnId: Schema.NullOr(TurnId), |
| 25 | role: OrchestrationMessageRole, | 26 | role: OrchestrationMessageRole, |
| 26 | text: Schema.String, | 27 | text: Schema.String, |
| 28 | attachments: Schema.optional(Schema.Array(ChatAttachment)), | ||
| 27 | isStreaming: Schema.Boolean, | 29 | isStreaming: Schema.Boolean, |
| 28 | createdAt: IsoDateTime, | 30 | createdAt: IsoDateTime, |
| 29 | updatedAt: IsoDateTime, | 31 | updatedAt: IsoDateTime, |
apps/server/src/projectFaviconRoute.ts+26| ... | @@ -2,6 +2,32 @@ import fs from "node:fs"; | ... | @@ -2,6 +2,32 @@ import fs from "node:fs"; |
| 2 | import http from "node:http"; | 2 | import http from "node:http"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | 4 | ||
| 5 | export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; | ||
| 6 | |||
| 7 | export const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = { | ||
| 8 | "image/png": ".png", | ||
| 9 | "image/jpeg": ".jpg", | ||
| 10 | "image/jpg": ".jpg", | ||
| 11 | "image/gif": ".gif", | ||
| 12 | "image/webp": ".webp", | ||
| 13 | "image/svg+xml": ".svg", | ||
| 14 | "image/bmp": ".bmp", | ||
| 15 | "image/tiff": ".tiff", | ||
| 16 | "image/heic": ".heic", | ||
| 17 | }; | ||
| 18 | |||
| 19 | export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ | ||
| 20 | ".jpg", | ||
| 21 | ".jpeg", | ||
| 22 | ".png", | ||
| 23 | ".gif", | ||
| 24 | ".webp", | ||
| 25 | ".bmp", | ||
| 26 | ".tiff", | ||
| 27 | ".svg", | ||
| 28 | ".ico", | ||
| 29 | ]); | ||
| 30 | |||
| 5 | const FAVICON_MIME_TYPES: Record<string, string> = { | 31 | const FAVICON_MIME_TYPES: Record<string, string> = { |
| 6 | ".png": "image/png", | 32 | ".png": "image/png", |
| 7 | ".jpg": "image/jpeg", | 33 | ".jpg": "image/jpeg", |
apps/server/src/provider/Layers/ProviderService.test.ts+34| ... | @@ -378,6 +378,7 @@ it.effect( | ... | @@ -378,6 +378,7 @@ it.effect( |
| 378 | [ | 378 | [ |
| 379 | { | 379 | { |
| 380 | provider: "codex", | 380 | provider: "codex", |
| 381 | cwd: "/tmp/project", | ||
| 381 | resumeThreadId: startedSession.threadId, | 382 | resumeThreadId: startedSession.threadId, |
| 382 | }, | 383 | }, |
| 383 | ], | 384 | ], |
| ... | @@ -465,6 +466,7 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -465,6 +466,7 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 465 | [ | 466 | [ |
| 466 | { | 467 | { |
| 467 | provider: "codex", | 468 | provider: "codex", |
| 469 | cwd: "/tmp/project", | ||
| 468 | resumeThreadId: initial.threadId, | 470 | resumeThreadId: initial.threadId, |
| 469 | }, | 471 | }, |
| 470 | ], | 472 | ], |
| ... | @@ -475,6 +477,38 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -475,6 +477,38 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 475 | }), | 477 | }), |
| 476 | ); | 478 | ); |
| 477 | 479 | ||
| 480 | it.effect("recovers stale sessions for sendTurn using persisted cwd", () => | ||
| 481 | Effect.gen(function* () { | ||
| 482 | const provider = yield* ProviderService; | ||
| 483 | |||
| 484 | const initial = yield* provider.startSession(asThreadId("thread-1"), { | ||
| 485 | provider: "codex", | ||
| 486 | cwd: "/tmp/project-send-turn", | ||
| 487 | }); | ||
| 488 | |||
| 489 | yield* provider.stopAll(); | ||
| 490 | routing.codex.startSession.mockClear(); | ||
| 491 | routing.codex.sendTurn.mockClear(); | ||
| 492 | |||
| 493 | yield* provider.sendTurn({ | ||
| 494 | sessionId: initial.sessionId, | ||
| 495 | input: "resume", | ||
| 496 | attachments: [], | ||
| 497 | }); | ||
| 498 | |||
| 499 | assert.deepEqual(routing.codex.startSession.mock.calls, [ | ||
| 500 | [ | ||
| 501 | { | ||
| 502 | provider: "codex", | ||
| 503 | cwd: "/tmp/project-send-turn", | ||
| 504 | resumeThreadId: initial.threadId, | ||
| 505 | }, | ||
| 506 | ], | ||
| 507 | ]); | ||
| 508 | assert.equal(routing.codex.sendTurn.mock.calls.length, 1); | ||
| 509 | }), | ||
| 510 | ); | ||
| 511 | |||
| 478 | it.effect("stops all sessions and clears adapter runtime", () => | 512 | it.effect("stops all sessions and clears adapter runtime", () => |
| 479 | Effect.gen(function* () { | 513 | Effect.gen(function* () { |
| 480 | const provider = yield* ProviderService; | 514 | const provider = yield* ProviderService; |
apps/server/src/provider/Layers/ProviderService.ts+15| ... | @@ -93,6 +93,18 @@ function toRuntimePayloadFromSession(session: ProviderSession): Record<string, u | ... | @@ -93,6 +93,18 @@ function toRuntimePayloadFromSession(session: ProviderSession): Record<string, u |
| 93 | }; | 93 | }; |
| 94 | } | 94 | } |
| 95 | 95 | ||
| 96 | function readPersistedCwd( | ||
| 97 | runtimePayload: ProviderSessionBinding["runtimePayload"], | ||
| 98 | ): string | undefined { | ||
| 99 | if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { | ||
| 100 | return undefined; | ||
| 101 | } | ||
| 102 | const rawCwd = "cwd" in runtimePayload ? runtimePayload.cwd : undefined; | ||
| 103 | if (typeof rawCwd !== "string") return undefined; | ||
| 104 | const trimmed = rawCwd.trim(); | ||
| 105 | return trimmed.length > 0 ? trimmed : undefined; | ||
| 106 | } | ||
| 107 | |||
| 96 | const makeProviderService = (options?: ProviderServiceLiveOptions) => | 108 | const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 97 | Effect.gen(function* () { | 109 | Effect.gen(function* () { |
| 98 | const canonicalEventLogger = | 110 | const canonicalEventLogger = |
| ... | @@ -266,8 +278,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -266,8 +278,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 266 | ); | 278 | ); |
| 267 | } | 279 | } |
| 268 | 280 | ||
| 281 | const persistedCwd = readPersistedCwd(input.binding.runtimePayload); | ||
| 282 | |||
| 269 | const resumed = yield* adapter.startSession({ | 283 | const resumed = yield* adapter.startSession({ |
| 270 | provider: input.binding.provider, | 284 | provider: input.binding.provider, |
| 285 | ...(persistedCwd ? { cwd: persistedCwd } : {}), | ||
| 271 | ...(resumeThreadId ? { resumeThreadId } : {}), | 286 | ...(resumeThreadId ? { resumeThreadId } : {}), |
| 272 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), | 287 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), |
| 273 | }); | 288 | }); |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+2-1| ... | @@ -26,6 +26,7 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli | ... | @@ -26,6 +26,7 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli |
| 26 | return Layer.mergeAll( | 26 | return Layer.mergeAll( |
| 27 | runtimeRepositoryLayer, | 27 | runtimeRepositoryLayer, |
| 28 | ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), | 28 | ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), |
| 29 | NodeServices.layer, | ||
| 29 | ); | 30 | ); |
| 30 | } | 31 | } |
| 31 | 32 | ||
| ... | @@ -223,5 +224,5 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -223,5 +224,5 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 223 | }).pipe(Effect.provide(directoryLayer)); | 224 | }).pipe(Effect.provide(directoryLayer)); |
| 224 | 225 | ||
| 225 | fs.rmSync(tempDir, { recursive: true, force: true }); | 226 | fs.rmSync(tempDir, { recursive: true, force: true }); |
| 226 | }).pipe(Effect.provide(NodeServices.layer))); | 227 | })); |
| 227 | }); | 228 | }); |
apps/server/src/serverLayers.ts+3-3| ... | @@ -61,16 +61,16 @@ export function makeServerRuntimeServicesLayer() { | ... | @@ -61,16 +61,16 @@ export function makeServerRuntimeServicesLayer() { |
| 61 | Layer.provide(OrchestrationEventStoreLive), | 61 | Layer.provide(OrchestrationEventStoreLive), |
| 62 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 62 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 63 | ); | 63 | ); |
| 64 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); | 64 | |
| 65 | const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe( | 65 | const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe( |
| 66 | Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), | 66 | Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), |
| 67 | Layer.provideMerge(checkpointStoreLayer), | 67 | Layer.provideMerge(CheckpointStoreLive), |
| 68 | ); | 68 | ); |
| 69 | 69 | ||
| 70 | const runtimeServicesLayer = Layer.mergeAll( | 70 | const runtimeServicesLayer = Layer.mergeAll( |
| 71 | orchestrationLayer, | 71 | orchestrationLayer, |
| 72 | OrchestrationProjectionSnapshotQueryLive, | 72 | OrchestrationProjectionSnapshotQueryLive, |
| 73 | checkpointStoreLayer, | 73 | CheckpointStoreLive, |
| 74 | checkpointDiffQueryLayer, | 74 | checkpointDiffQueryLayer, |
| 75 | ); | 75 | ); |
| 76 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( | 76 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
apps/server/src/wsServer.test.ts+116-7| ... | @@ -4,7 +4,7 @@ import os from "node:os"; | ... | @@ -4,7 +4,7 @@ import os from "node:os"; |
| 4 | import path from "node:path"; | 4 | import path from "node:path"; |
| 5 | 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect"; | 7 | import { Effect, Exit, Layer, PlatformError, PubSub, Scope, Stream } from "effect"; |
| 8 | import { describe, expect, it, afterEach, vi } from "vitest"; | 8 | import { describe, expect, it, afterEach, vi } from "vitest"; |
| 9 | import { createServer } from "./wsServer"; | 9 | import { createServer } from "./wsServer"; |
| 10 | import WebSocket from "ws"; | 10 | import WebSocket from "ws"; |
| ... | @@ -40,13 +40,14 @@ import type { | ... | @@ -40,13 +40,14 @@ import type { |
| 40 | } from "@t3tools/contracts"; | 40 | } from "@t3tools/contracts"; |
| 41 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager"; | 41 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager"; |
| 42 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; | 42 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; |
| 43 | import { SqlClient } from "effect/unstable/sql"; | 43 | import { SqlClient, SqlError } from "effect/unstable/sql"; |
| 44 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; | 44 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 45 | import { Open, type OpenShape } from "./open"; | 45 | import { Open, type OpenShape } from "./open"; |
| 46 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; | 46 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; |
| 47 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; | 47 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; |
| 48 | import { GitCore } from "./git/Services/GitCore.ts"; | 48 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 49 | import { GitCommandError, GitManagerError } from "./git/Errors.ts"; | 49 | import { GitCommandError, GitManagerError } from "./git/Errors.ts"; |
| 50 | import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; | ||
| 50 | 51 | ||
| 51 | interface PendingMessages { | 52 | interface PendingMessages { |
| 52 | queue: unknown[]; | 53 | queue: unknown[]; |
| ... | @@ -296,6 +297,36 @@ async function waitForPush( | ... | @@ -296,6 +297,36 @@ async function waitForPush( |
| 296 | return take(maxMessages); | 297 | return take(maxMessages); |
| 297 | } | 298 | } |
| 298 | 299 | ||
| 300 | async function requestPath( | ||
| 301 | port: number, | ||
| 302 | requestPath: string, | ||
| 303 | ): Promise<{ statusCode: number; body: string }> { | ||
| 304 | return new Promise((resolve, reject) => { | ||
| 305 | const req = Http.request( | ||
| 306 | { | ||
| 307 | hostname: "127.0.0.1", | ||
| 308 | port, | ||
| 309 | path: requestPath, | ||
| 310 | method: "GET", | ||
| 311 | }, | ||
| 312 | (res) => { | ||
| 313 | const chunks: Buffer[] = []; | ||
| 314 | res.on("data", (chunk) => { | ||
| 315 | chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); | ||
| 316 | }); | ||
| 317 | res.on("end", () => { | ||
| 318 | resolve({ | ||
| 319 | statusCode: res.statusCode ?? 0, | ||
| 320 | body: Buffer.concat(chunks).toString("utf8"), | ||
| 321 | }); | ||
| 322 | }); | ||
| 323 | }, | ||
| 324 | ); | ||
| 325 | req.once("error", reject); | ||
| 326 | req.end(); | ||
| 327 | }); | ||
| 328 | } | ||
| 329 | |||
| 299 | function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { | 330 | function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { |
| 300 | const resolved: Array<ResolvedKeybindingsConfig[number]> = []; | 331 | const resolved: Array<ResolvedKeybindingsConfig[number]> = []; |
| 301 | for (const binding of bindings) { | 332 | for (const binding of bindings) { |
| ... | @@ -324,13 +355,17 @@ describe("WebSocket Server", () => { | ... | @@ -324,13 +355,17 @@ describe("WebSocket Server", () => { |
| 324 | 355 | ||
| 325 | async function createTestServer( | 356 | async function createTestServer( |
| 326 | options: { | 357 | options: { |
| 327 | persistenceLayer?: Layer.Layer<SqlClient.SqlClient, never>; | 358 | persistenceLayer?: Layer.Layer< |
| 359 | SqlClient.SqlClient, | ||
| 360 | SqlError.SqlError | MigrationError | PlatformError.PlatformError | ||
| 361 | >; | ||
| 328 | cwd?: string; | 362 | cwd?: string; |
| 329 | autoBootstrapProjectFromCwd?: boolean; | 363 | autoBootstrapProjectFromCwd?: boolean; |
| 330 | logWebSocketEvents?: boolean; | 364 | logWebSocketEvents?: boolean; |
| 331 | devUrl?: string; | 365 | devUrl?: string; |
| 332 | authToken?: string; | 366 | authToken?: string; |
| 333 | stateDir?: string; | 367 | stateDir?: string; |
| 368 | staticDir?: string; | ||
| 334 | providerLayer?: Layer.Layer<ProviderService, never>; | 369 | providerLayer?: Layer.Layer<ProviderService, never>; |
| 335 | open?: OpenShape; | 370 | open?: OpenShape; |
| 336 | gitManager?: GitManagerShape; | 371 | gitManager?: GitManagerShape; |
| ... | @@ -354,7 +389,7 @@ describe("WebSocket Server", () => { | ... | @@ -354,7 +389,7 @@ describe("WebSocket Server", () => { |
| 354 | cwd: options.cwd ?? "/test/project", | 389 | cwd: options.cwd ?? "/test/project", |
| 355 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), | 390 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), |
| 356 | stateDir, | 391 | stateDir, |
| 357 | staticDir: undefined, | 392 | staticDir: options.staticDir, |
| 358 | devUrl: options.devUrl ? new URL(options.devUrl) : undefined, | 393 | devUrl: options.devUrl ? new URL(options.devUrl) : undefined, |
| 359 | noBrowser: true, | 394 | noBrowser: true, |
| 360 | authToken: options.authToken, | 395 | authToken: options.authToken, |
| ... | @@ -438,6 +473,80 @@ describe("WebSocket Server", () => { | ... | @@ -438,6 +473,80 @@ describe("WebSocket Server", () => { |
| 438 | }); | 473 | }); |
| 439 | }); | 474 | }); |
| 440 | 475 | ||
| 476 | it("serves persisted attachments from stateDir", async () => { | ||
| 477 | const stateDir = makeTempDir("t3code-state-attachments-"); | ||
| 478 | const attachmentPath = path.join(stateDir, "attachments", "thread-a", "message-a", "0.png"); | ||
| 479 | fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); | ||
| 480 | fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment")); | ||
| 481 | |||
| 482 | server = await createTestServer({ cwd: "/test/project", stateDir }); | ||
| 483 | const addr = server.address(); | ||
| 484 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 485 | expect(port).toBeGreaterThan(0); | ||
| 486 | |||
| 487 | const response = await fetch(`http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`); | ||
| 488 | expect(response.status).toBe(200); | ||
| 489 | expect(response.headers.get("content-type")).toContain("image/png"); | ||
| 490 | const bytes = Buffer.from(await response.arrayBuffer()); | ||
| 491 | expect(bytes).toEqual(Buffer.from("hello-attachment")); | ||
| 492 | }); | ||
| 493 | |||
| 494 | it("serves persisted attachments for URL-encoded paths", async () => { | ||
| 495 | const stateDir = makeTempDir("t3code-state-attachments-encoded-"); | ||
| 496 | const attachmentPath = path.join( | ||
| 497 | stateDir, | ||
| 498 | "attachments", | ||
| 499 | "thread%20folder", | ||
| 500 | "message%20folder", | ||
| 501 | "file%20name.png", | ||
| 502 | ); | ||
| 503 | fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); | ||
| 504 | fs.writeFileSync(attachmentPath, Buffer.from("hello-encoded-attachment")); | ||
| 505 | |||
| 506 | server = await createTestServer({ cwd: "/test/project", stateDir }); | ||
| 507 | const addr = server.address(); | ||
| 508 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 509 | expect(port).toBeGreaterThan(0); | ||
| 510 | |||
| 511 | const response = await fetch( | ||
| 512 | `http://127.0.0.1:${port}/attachments/thread%20folder/message%20folder/file%20name.png`, | ||
| 513 | ); | ||
| 514 | expect(response.status).toBe(200); | ||
| 515 | expect(response.headers.get("content-type")).toContain("image/png"); | ||
| 516 | const bytes = Buffer.from(await response.arrayBuffer()); | ||
| 517 | expect(bytes).toEqual(Buffer.from("hello-encoded-attachment")); | ||
| 518 | }); | ||
| 519 | |||
| 520 | it("serves static index for root path", async () => { | ||
| 521 | const stateDir = makeTempDir("t3code-state-static-root-"); | ||
| 522 | const staticDir = makeTempDir("t3code-static-root-"); | ||
| 523 | fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>static-root</h1>", "utf8"); | ||
| 524 | |||
| 525 | server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); | ||
| 526 | const addr = server.address(); | ||
| 527 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 528 | expect(port).toBeGreaterThan(0); | ||
| 529 | |||
| 530 | const response = await fetch(`http://127.0.0.1:${port}/`); | ||
| 531 | expect(response.status).toBe(200); | ||
| 532 | expect(await response.text()).toContain("static-root"); | ||
| 533 | }); | ||
| 534 | |||
| 535 | it("rejects static path traversal attempts", async () => { | ||
| 536 | const stateDir = makeTempDir("t3code-state-static-traversal-"); | ||
| 537 | const staticDir = makeTempDir("t3code-static-traversal-"); | ||
| 538 | fs.writeFileSync(path.join(staticDir, "index.html"), "<h1>safe</h1>", "utf8"); | ||
| 539 | |||
| 540 | server = await createTestServer({ cwd: "/test/project", stateDir, staticDir }); | ||
| 541 | const addr = server.address(); | ||
| 542 | const port = typeof addr === "object" && addr !== null ? addr.port : 0; | ||
| 543 | expect(port).toBeGreaterThan(0); | ||
| 544 | |||
| 545 | const response = await requestPath(port, "/..%2f..%2fetc/passwd"); | ||
| 546 | expect(response.statusCode).toBe(400); | ||
| 547 | expect(response.body).toBe("Invalid static file path"); | ||
| 548 | }); | ||
| 549 | |||
| 441 | it("bootstraps the cwd project on startup when enabled", async () => { | 550 | it("bootstraps the cwd project on startup when enabled", async () => { |
| 442 | server = await createTestServer({ | 551 | server = await createTestServer({ |
| 443 | cwd: "/test/bootstrap-workspace", | 552 | cwd: "/test/bootstrap-workspace", |
| ... | @@ -509,9 +618,9 @@ describe("WebSocket Server", () => { | ... | @@ -509,9 +618,9 @@ describe("WebSocket Server", () => { |
| 509 | 618 | ||
| 510 | it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => { | 619 | it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => { |
| 511 | const stateDir = makeTempDir("t3code-state-bootstrap-existing-"); | 620 | const stateDir = makeTempDir("t3code-state-bootstrap-existing-"); |
| 512 | const persistenceLayer = makeSqlitePersistenceLive( | 621 | const persistenceLayer = makeSqlitePersistenceLive(path.join(stateDir, "state.sqlite")).pipe( |
| 513 | path.join(stateDir, "state.sqlite"), | 622 | Layer.provide(NodeServices.layer), |
| 514 | ).pipe(Layer.provide(NodeServices.layer)) as unknown as Layer.Layer<SqlClient.SqlClient, never>; | 623 | ); |
| 515 | const cwd = "/test/bootstrap-existing"; | 624 | const cwd = "/test/bootstrap-existing"; |
| 516 | 625 | ||
| 517 | server = await createTestServer({ | 626 | server = await createTestServer({ |
apps/server/src/wsServer.ts+159-65| ... | @@ -6,11 +6,10 @@ | ... | @@ -6,11 +6,10 @@ |
| 6 | * | 6 | * |
| 7 | * @module Server | 7 | * @module Server |
| 8 | */ | 8 | */ |
| 9 | import fs from "node:fs"; | ||
| 10 | import http from "node:http"; | 9 | import http from "node:http"; |
| 11 | import path from "node:path"; | ||
| 12 | import type { Duplex } from "node:stream"; | 10 | import type { Duplex } from "node:stream"; |
| 13 | 11 | ||
| 12 | import Mime from "@effect/platform-node/Mime"; | ||
| 14 | import { | 13 | import { |
| 15 | CommandId, | 14 | CommandId, |
| 16 | ORCHESTRATION_WS_CHANNELS, | 15 | ORCHESTRATION_WS_CHANNELS, |
| ... | @@ -25,7 +24,20 @@ import { | ... | @@ -25,7 +24,20 @@ import { |
| 25 | WsResponse, | 24 | WsResponse, |
| 26 | } from "@t3tools/contracts"; | 25 | } from "@t3tools/contracts"; |
| 27 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | 26 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 28 | import { Cause, Effect, Exit, Layer, Ref, Schema, Scope, ServiceMap, Stream, Struct } from "effect"; | 27 | import { |
| 28 | Cause, | ||
| 29 | Effect, | ||
| 30 | Exit, | ||
| 31 | FileSystem, | ||
| 32 | Layer, | ||
| 33 | Path, | ||
| 34 | Ref, | ||
| 35 | Schema, | ||
| 36 | Scope, | ||
| 37 | ServiceMap, | ||
| 38 | Stream, | ||
| 39 | Struct, | ||
| 40 | } from "effect"; | ||
| 29 | import { WebSocketServer, type WebSocket } from "ws"; | 41 | import { WebSocketServer, type WebSocket } from "ws"; |
| 30 | 42 | ||
| 31 | import { createLogger } from "./logger"; | 43 | import { createLogger } from "./logger"; |
| ... | @@ -42,22 +54,7 @@ import { clamp } from "effect/Number"; | ... | @@ -42,22 +54,7 @@ import { clamp } from "effect/Number"; |
| 42 | import { Open } from "./open"; | 54 | import { Open } from "./open"; |
| 43 | import { ServerConfig } from "./config"; | 55 | import { ServerConfig } from "./config"; |
| 44 | import { GitCore } from "./git/Services/GitCore.ts"; | 56 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 45 | import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | 57 | import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; |
| 46 | |||
| 47 | const MIME_TYPES: Record<string, string> = { | ||
| 48 | ".html": "text/html; charset=utf-8", | ||
| 49 | ".js": "application/javascript; charset=utf-8", | ||
| 50 | ".css": "text/css; charset=utf-8", | ||
| 51 | ".json": "application/json; charset=utf-8", | ||
| 52 | ".png": "image/png", | ||
| 53 | ".jpg": "image/jpeg", | ||
| 54 | ".svg": "image/svg+xml", | ||
| 55 | ".ico": "image/x-icon", | ||
| 56 | ".woff": "font/woff", | ||
| 57 | ".woff2": "font/woff2", | ||
| 58 | ".ttf": "font/ttf", | ||
| 59 | ".map": "application/json", | ||
| 60 | }; | ||
| 61 | 58 | ||
| 62 | /** | 59 | /** |
| 63 | * ServerShape - Service API for server lifecycle control. | 60 | * ServerShape - Service API for server lifecycle control. |
| ... | @@ -69,7 +66,7 @@ export interface ServerShape { | ... | @@ -69,7 +66,7 @@ export interface ServerShape { |
| 69 | readonly start: Effect.Effect< | 66 | readonly start: Effect.Effect< |
| 70 | http.Server, | 67 | http.Server, |
| 71 | ServerLifecycleError, | 68 | ServerLifecycleError, |
| 72 | Scope.Scope | ServerRuntimeServices | ServerConfig | 69 | Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path |
| 73 | >; | 70 | >; |
| 74 | 71 | ||
| 75 | /** | 72 | /** |
| ... | @@ -168,7 +165,7 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro | ... | @@ -168,7 +165,7 @@ class RouteRequestError extends Schema.TaggedErrorClass<RouteRequestError>()("Ro |
| 168 | export const createServer = Effect.fn(function* (): Effect.fn.Return< | 165 | export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 169 | http.Server, | 166 | http.Server, |
| 170 | ServerLifecycleError, | 167 | ServerLifecycleError, |
| 171 | Scope.Scope | ServerRuntimeServices | ServerConfig | 168 | Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path |
| 172 | > { | 169 | > { |
| 173 | const serverConfig = yield* ServerConfig; | 170 | const serverConfig = yield* ServerConfig; |
| 174 | const { | 171 | const { |
| ... | @@ -187,6 +184,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -187,6 +184,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 187 | const terminalManager = yield* TerminalManager; | 184 | const terminalManager = yield* TerminalManager; |
| 188 | const keybindingsManager = yield* Keybindings; | 185 | const keybindingsManager = yield* Keybindings; |
| 189 | const git = yield* GitCore; | 186 | const git = yield* GitCore; |
| 187 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 188 | const path = yield* Path.Path; | ||
| 190 | 189 | ||
| 191 | yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( | 190 | yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( |
| 192 | Effect.catch((error) => | 191 | Effect.catch((error) => |
| ... | @@ -233,62 +232,157 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -233,62 +232,157 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 233 | 232 | ||
| 234 | // HTTP server — serves static files or redirects to Vite dev server | 233 | // HTTP server — serves static files or redirects to Vite dev server |
| 235 | const httpServer = http.createServer((req, res) => { | 234 | const httpServer = http.createServer((req, res) => { |
| 236 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); | 235 | const respond = ( |
| 236 | statusCode: number, | ||
| 237 | headers: Record<string, string>, | ||
| 238 | body?: string | Uint8Array, | ||
| 239 | ) => { | ||
| 240 | res.writeHead(statusCode, headers); | ||
| 241 | res.end(body); | ||
| 242 | }; | ||
| 237 | 243 | ||
| 238 | if (tryHandleProjectFaviconRequest(url, res)) { | 244 | void Effect.runPromise( |
| 239 | return; | 245 | Effect.gen(function* () { |
| 240 | } | 246 | const url = new URL(req.url ?? "/", `http://localhost:${port}`); |
| 247 | if (tryHandleProjectFaviconRequest(url, res)) { | ||
| 248 | return; | ||
| 249 | } | ||
| 241 | 250 | ||
| 242 | // In dev mode, redirect to Vite dev server | 251 | if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { |
| 243 | if (devUrl) { | 252 | const attachmentsRoot = path.resolve(path.join(serverConfig.stateDir, "attachments")); |
| 244 | res.writeHead(302, { Location: devUrl.href }); | 253 | const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); |
| 245 | res.end(); | 254 | const normalizedRelativePath = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); |
| 246 | return; | 255 | |
| 247 | } | 256 | if ( |
| 257 | normalizedRelativePath.length === 0 || | ||
| 258 | normalizedRelativePath.startsWith("..") || | ||
| 259 | normalizedRelativePath.includes("\0") | ||
| 260 | ) { | ||
| 261 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); | ||
| 262 | return; | ||
| 263 | } | ||
| 248 | 264 | ||
| 249 | // Serve static files from the web app build | 265 | const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath)); |
| 250 | if (!staticDir) { | 266 | if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) { |
| 251 | res.writeHead(503, { "Content-Type": "text/plain" }); | 267 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); |
| 252 | res.end("No static directory configured and no dev URL set."); | 268 | return; |
| 253 | return; | 269 | } |
| 254 | } | ||
| 255 | 270 | ||
| 256 | let filePath = path.join(staticDir, url.pathname); | 271 | const fileInfo = yield* fileSystem |
| 272 | .stat(filePath) | ||
| 273 | .pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 274 | if (!fileInfo || fileInfo.type !== "File") { | ||
| 275 | respond(404, { "Content-Type": "text/plain" }, "Not Found"); | ||
| 276 | return; | ||
| 277 | } | ||
| 257 | 278 | ||
| 258 | // SPA fallback: if no file extension and not found, serve index.html | 279 | const contentType = Mime.getType(filePath) ?? "application/octet-stream"; |
| 259 | const ext = path.extname(filePath); | 280 | res.writeHead(200, { |
| 260 | if (!ext) { | 281 | "Content-Type": contentType, |
| 261 | filePath = path.join(filePath, "index.html"); | 282 | "Cache-Control": "public, max-age=31536000, immutable", |
| 262 | } | 283 | }); |
| 284 | const streamExit = yield* Stream.runForEach(fileSystem.stream(filePath), (chunk) => | ||
| 285 | Effect.sync(() => { | ||
| 286 | if (!res.destroyed) { | ||
| 287 | res.write(chunk); | ||
| 288 | } | ||
| 289 | }), | ||
| 290 | ).pipe(Effect.exit); | ||
| 291 | if (streamExit._tag === "Failure") { | ||
| 292 | if (!res.destroyed) { | ||
| 293 | res.destroy(); | ||
| 294 | } | ||
| 295 | return; | ||
| 296 | } | ||
| 297 | if (!res.writableEnded) { | ||
| 298 | res.end(); | ||
| 299 | } | ||
| 300 | return; | ||
| 301 | } | ||
| 263 | 302 | ||
| 264 | fs.stat(filePath, (err, stats) => { | 303 | // In dev mode, redirect to Vite dev server |
| 265 | if (err || !stats?.isFile()) { | 304 | if (devUrl) { |
| 266 | // SPA fallback | 305 | respond(302, { Location: devUrl.href }); |
| 267 | const indexPath = path.join(staticDir, "index.html"); | 306 | return; |
| 268 | fs.readFile(indexPath, (readErr, data) => { | 307 | } |
| 269 | if (readErr) { | 308 | |
| 270 | res.writeHead(404, { "Content-Type": "text/plain" }); | 309 | // Serve static files from the web app build |
| 271 | res.end("Not Found"); | 310 | if (!staticDir) { |
| 311 | respond( | ||
| 312 | 503, | ||
| 313 | { "Content-Type": "text/plain" }, | ||
| 314 | "No static directory configured and no dev URL set.", | ||
| 315 | ); | ||
| 316 | return; | ||
| 317 | } | ||
| 318 | |||
| 319 | const staticRoot = path.resolve(staticDir); | ||
| 320 | const staticRequestPath = url.pathname === "/" ? "/index.html" : url.pathname; | ||
| 321 | const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); | ||
| 322 | const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); | ||
| 323 | const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, ""); | ||
| 324 | const hasPathTraversalSegment = staticRelativePath.startsWith(".."); | ||
| 325 | if ( | ||
| 326 | staticRelativePath.length === 0 || | ||
| 327 | hasRawLeadingParentSegment || | ||
| 328 | hasPathTraversalSegment || | ||
| 329 | staticRelativePath.includes("\0") | ||
| 330 | ) { | ||
| 331 | respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); | ||
| 332 | return; | ||
| 333 | } | ||
| 334 | |||
| 335 | const isWithinStaticRoot = (candidate: string) => | ||
| 336 | candidate === staticRoot || | ||
| 337 | candidate.startsWith( | ||
| 338 | staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`, | ||
| 339 | ); | ||
| 340 | |||
| 341 | let filePath = path.resolve(staticRoot, staticRelativePath); | ||
| 342 | if (!isWithinStaticRoot(filePath)) { | ||
| 343 | respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); | ||
| 344 | return; | ||
| 345 | } | ||
| 346 | |||
| 347 | const ext = path.extname(filePath); | ||
| 348 | if (!ext) { | ||
| 349 | filePath = path.resolve(filePath, "index.html"); | ||
| 350 | if (!isWithinStaticRoot(filePath)) { | ||
| 351 | respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); | ||
| 272 | return; | 352 | return; |
| 273 | } | 353 | } |
| 274 | res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); | 354 | } |
| 275 | res.end(data); | ||
| 276 | }); | ||
| 277 | return; | ||
| 278 | } | ||
| 279 | 355 | ||
| 280 | const fileExt = path.extname(filePath); | 356 | const fileInfo = yield* fileSystem |
| 281 | const contentType = MIME_TYPES[fileExt] ?? "application/octet-stream"; | 357 | .stat(filePath) |
| 358 | .pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 359 | if (!fileInfo || fileInfo.type !== "File") { | ||
| 360 | const indexPath = path.resolve(staticRoot, "index.html"); | ||
| 361 | const indexData = yield* fileSystem | ||
| 362 | .readFile(indexPath) | ||
| 363 | .pipe(Effect.catch(() => Effect.succeed(null))); | ||
| 364 | if (!indexData) { | ||
| 365 | respond(404, { "Content-Type": "text/plain" }, "Not Found"); | ||
| 366 | return; | ||
| 367 | } | ||
| 368 | respond(200, { "Content-Type": "text/html; charset=utf-8" }, indexData); | ||
| 369 | return; | ||
| 370 | } | ||
| 282 | 371 | ||
| 283 | fs.readFile(filePath, (readErr, data) => { | 372 | const contentType = Mime.getType(filePath) ?? "application/octet-stream"; |
| 284 | if (readErr) { | 373 | const data = yield* fileSystem |
| 285 | res.writeHead(500, { "Content-Type": "text/plain" }); | 374 | .readFile(filePath) |
| 286 | res.end("Internal Server Error"); | 375 | .pipe(Effect.catch(() => Effect.succeed(null))); |
| 376 | if (!data) { | ||
| 377 | respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); | ||
| 287 | return; | 378 | return; |
| 288 | } | 379 | } |
| 289 | res.writeHead(200, { "Content-Type": contentType }); | 380 | respond(200, { "Content-Type": contentType }, data); |
| 290 | res.end(data); | 381 | }), |
| 291 | }); | 382 | ).catch(() => { |
| 383 | if (!res.headersSent) { | ||
| 384 | respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); | ||
| 385 | } | ||
| 292 | }); | 386 | }); |
| 293 | }); | 387 | }); |
| 294 | 388 |
apps/web/src/components/ChatView.tsx+460-311| ... | @@ -74,6 +74,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; | ... | @@ -74,6 +74,8 @@ import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; |
| 74 | import { | 74 | import { |
| 75 | BotIcon, | 75 | BotIcon, |
| 76 | ChevronDownIcon, | 76 | ChevronDownIcon, |
| 77 | ChevronLeftIcon, | ||
| 78 | ChevronRightIcon, | ||
| 77 | CircleAlertIcon, | 79 | CircleAlertIcon, |
| 78 | FileIcon, | 80 | FileIcon, |
| 79 | FolderIcon, | 81 | FolderIcon, |
| ... | @@ -111,6 +113,7 @@ import { SidebarTrigger } from "./ui/sidebar"; | ... | @@ -111,6 +113,7 @@ import { SidebarTrigger } from "./ui/sidebar"; |
| 111 | import { newCommandId, newMessageId } from "~/lib/utils"; | 113 | import { newCommandId, newMessageId } from "~/lib/utils"; |
| 112 | import { readNativeApi } from "~/nativeApi"; | 114 | import { readNativeApi } from "~/nativeApi"; |
| 113 | import { useAppSettings } from "../appSettings"; | 115 | import { useAppSettings } from "../appSettings"; |
| 116 | import { clamp } from "effect/Number"; | ||
| 114 | 117 | ||
| 115 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 118 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 116 | if (!duration) return formatTimestamp(createdAt); | 119 | if (!duration) return formatTimestamp(createdAt); |
| ... | @@ -165,11 +168,36 @@ interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl" | ... | @@ -165,11 +168,36 @@ interface ComposerImageAttachment extends Omit<ChatImageAttachment, "previewUrl" |
| 165 | file: File; | 168 | file: File; |
| 166 | } | 169 | } |
| 167 | 170 | ||
| 168 | interface ExpandedImagePreview { | 171 | interface ExpandedImageItem { |
| 169 | src: string; | 172 | src: string; |
| 170 | name: string; | 173 | name: string; |
| 171 | } | 174 | } |
| 172 | 175 | ||
| 176 | interface ExpandedImagePreview { | ||
| 177 | images: ExpandedImageItem[]; | ||
| 178 | index: number; | ||
| 179 | } | ||
| 180 | |||
| 181 | function buildExpandedImagePreview( | ||
| 182 | images: ReadonlyArray<{ id: string; name: string; previewUrl?: string }>, | ||
| 183 | selectedImageId: string, | ||
| 184 | ): ExpandedImagePreview | null { | ||
| 185 | const previewableImages = images.flatMap((image) => | ||
| 186 | image.previewUrl ? [{ id: image.id, src: image.previewUrl, name: image.name }] : [], | ||
| 187 | ); | ||
| 188 | if (previewableImages.length === 0) { | ||
| 189 | return null; | ||
| 190 | } | ||
| 191 | const selectedIndex = previewableImages.findIndex((image) => image.id === selectedImageId); | ||
| 192 | if (selectedIndex < 0) { | ||
| 193 | return null; | ||
| 194 | } | ||
| 195 | return { | ||
| 196 | images: previewableImages.map((image) => ({ src: image.src, name: image.name })), | ||
| 197 | index: selectedIndex, | ||
| 198 | }; | ||
| 199 | } | ||
| 200 | |||
| 173 | type ComposerCommandItem = | 201 | type ComposerCommandItem = |
| 174 | | { | 202 | | { |
| 175 | id: string; | 203 | id: string; |
| ... | @@ -1070,22 +1098,53 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1070,22 +1098,53 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1070 | }; | 1098 | }; |
| 1071 | }, [revokePreviewUrls]); | 1099 | }, [revokePreviewUrls]); |
| 1072 | 1100 | ||
| 1073 | /** | 1101 | const closeExpandedImage = useCallback(() => { |
| 1074 | * Close expanded image on Escape key | 1102 | setExpandedImage(null); |
| 1075 | */ | 1103 | }, []); |
| 1104 | const navigateExpandedImage = useCallback((direction: -1 | 1) => { | ||
| 1105 | setExpandedImage((existing) => { | ||
| 1106 | if (!existing || existing.images.length <= 1) { | ||
| 1107 | return existing; | ||
| 1108 | } | ||
| 1109 | const nextIndex = | ||
| 1110 | (existing.index + direction + existing.images.length) % existing.images.length; | ||
| 1111 | if (nextIndex === existing.index) { | ||
| 1112 | return existing; | ||
| 1113 | } | ||
| 1114 | return { ...existing, index: nextIndex }; | ||
| 1115 | }); | ||
| 1116 | }, []); | ||
| 1117 | |||
| 1076 | useEffect(() => { | 1118 | useEffect(() => { |
| 1077 | if (!expandedImage) { | 1119 | if (!expandedImage) { |
| 1078 | return; | 1120 | return; |
| 1079 | } | 1121 | } |
| 1080 | 1122 | ||
| 1081 | const onKeyDown = (event: globalThis.KeyboardEvent) => { | 1123 | const onKeyDown = (event: globalThis.KeyboardEvent) => { |
| 1082 | if (event.key !== "Escape") return; | 1124 | if (event.key === "Escape") { |
| 1083 | setExpandedImage(null); | 1125 | event.preventDefault(); |
| 1126 | event.stopPropagation(); | ||
| 1127 | closeExpandedImage(); | ||
| 1128 | return; | ||
| 1129 | } | ||
| 1130 | if (expandedImage.images.length <= 1) { | ||
| 1131 | return; | ||
| 1132 | } | ||
| 1133 | if (event.key === "ArrowLeft") { | ||
| 1134 | event.preventDefault(); | ||
| 1135 | event.stopPropagation(); | ||
| 1136 | navigateExpandedImage(-1); | ||
| 1137 | return; | ||
| 1138 | } | ||
| 1139 | if (event.key !== "ArrowRight") return; | ||
| 1140 | event.preventDefault(); | ||
| 1141 | event.stopPropagation(); | ||
| 1142 | navigateExpandedImage(1); | ||
| 1084 | }; | 1143 | }; |
| 1085 | 1144 | ||
| 1086 | window.addEventListener("keydown", onKeyDown); | 1145 | window.addEventListener("keydown", onKeyDown); |
| 1087 | return () => window.removeEventListener("keydown", onKeyDown); | 1146 | return () => window.removeEventListener("keydown", onKeyDown); |
| 1088 | }, [expandedImage]); | 1147 | }, [closeExpandedImage, expandedImage, navigateExpandedImage]); |
| 1089 | 1148 | ||
| 1090 | const activeWorktreePath = activeThread?.worktreePath; | 1149 | const activeWorktreePath = activeThread?.worktreePath; |
| 1091 | 1150 | ||
| ... | @@ -1722,9 +1781,10 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1722,9 +1781,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1722 | [groupId]: !existing[groupId], | 1781 | [groupId]: !existing[groupId], |
| 1723 | })); | 1782 | })); |
| 1724 | }, []); | 1783 | }, []); |
| 1725 | const onExpandTimelineImage = useCallback((image: ExpandedImagePreview) => { | 1784 | const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { |
| 1726 | setExpandedImage(image); | 1785 | setExpandedImage(preview); |
| 1727 | }, []); | 1786 | }, []); |
| 1787 | const expandedImageItem = expandedImage ? expandedImage.images[expandedImage.index] : null; | ||
| 1728 | const onOpenTurnDiff = useCallback( | 1788 | const onOpenTurnDiff = useCallback( |
| 1729 | (turnId: TurnId, filePath?: string) => { | 1789 | (turnId: TurnId, filePath?: string) => { |
| 1730 | void navigate({ | 1790 | void navigate({ |
| ... | @@ -1824,6 +1884,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1824,6 +1884,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1824 | <MessagesTimeline | 1884 | <MessagesTimeline |
| 1825 | hasMessages={timelineMessages.length > 0} | 1885 | hasMessages={timelineMessages.length > 0} |
| 1826 | isWorking={isWorking} | 1886 | isWorking={isWorking} |
| 1887 | activeTurnInProgress={!latestTurnSettled} | ||
| 1888 | activeTurnStartedAt={activeLatestTurn?.startedAt ?? null} | ||
| 1827 | scrollContainerRef={messagesScrollRef} | 1889 | scrollContainerRef={messagesScrollRef} |
| 1828 | timelineEntries={timelineEntries} | 1890 | timelineEntries={timelineEntries} |
| 1829 | completionDividerBeforeEntryId={completionDividerBeforeEntryId} | 1891 | completionDividerBeforeEntryId={completionDividerBeforeEntryId} |
| ... | @@ -1885,9 +1947,11 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1885,9 +1947,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1885 | src={image.previewUrl} | 1947 | src={image.previewUrl} |
| 1886 | alt={image.name} | 1948 | alt={image.name} |
| 1887 | className="h-full w-full cursor-zoom-in object-cover" | 1949 | className="h-full w-full cursor-zoom-in object-cover" |
| 1888 | onClick={() => | 1950 | onClick={() => { |
| 1889 | setExpandedImage({ src: image.previewUrl, name: image.name }) | 1951 | const preview = buildExpandedImagePreview(composerImages, image.id); |
| 1890 | } | 1952 | if (!preview) return; |
| 1953 | setExpandedImage(preview); | ||
| 1954 | }} | ||
| 1891 | /> | 1955 | /> |
| 1892 | ) : ( | 1956 | ) : ( |
| 1893 | <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70"> | 1957 | <div className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-muted-foreground/70"> |
| ... | @@ -2111,14 +2175,29 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2111,14 +2175,29 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2111 | ); | 2175 | ); |
| 2112 | })()} | 2176 | })()} |
| 2113 | 2177 | ||
| 2114 | {expandedImage && ( | 2178 | {expandedImage && expandedImageItem && ( |
| 2115 | <div | 2179 | <div |
| 2116 | className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]" | 2180 | className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 px-4 py-6 [-webkit-app-region:no-drag]" |
| 2117 | role="dialog" | 2181 | role="dialog" |
| 2118 | aria-modal="true" | 2182 | aria-modal="true" |
| 2119 | aria-label="Expanded image preview" | 2183 | aria-label="Expanded image preview" |
| 2120 | onClick={() => setExpandedImage(null)} | 2184 | onClick={closeExpandedImage} |
| 2121 | > | 2185 | > |
| 2186 | {expandedImage.images.length > 1 && ( | ||
| 2187 | <Button | ||
| 2188 | type="button" | ||
| 2189 | size="icon" | ||
| 2190 | variant="ghost" | ||
| 2191 | className="absolute left-2 top-1/2 z-10 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6" | ||
| 2192 | aria-label="Previous image" | ||
| 2193 | onClick={(event) => { | ||
| 2194 | event.stopPropagation(); | ||
| 2195 | navigateExpandedImage(-1); | ||
| 2196 | }} | ||
| 2197 | > | ||
| 2198 | <ChevronLeftIcon className="size-5" /> | ||
| 2199 | </Button> | ||
| 2200 | )} | ||
| 2122 | <div | 2201 | <div |
| 2123 | className="relative isolate max-h-[92vh] max-w-[92vw]" | 2202 | className="relative isolate max-h-[92vh] max-w-[92vw]" |
| 2124 | onClick={(event) => event.stopPropagation()} | 2203 | onClick={(event) => event.stopPropagation()} |
| ... | @@ -2128,21 +2207,39 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2128,21 +2207,39 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2128 | size="icon-xs" | 2207 | size="icon-xs" |
| 2129 | variant="ghost" | 2208 | variant="ghost" |
| 2130 | className="absolute right-2 top-2" | 2209 | className="absolute right-2 top-2" |
| 2131 | onClick={() => setExpandedImage(null)} | 2210 | onClick={closeExpandedImage} |
| 2132 | aria-label="Close image preview" | 2211 | aria-label="Close image preview" |
| 2133 | > | 2212 | > |
| 2134 | <XIcon /> | 2213 | <XIcon /> |
| 2135 | </Button> | 2214 | </Button> |
| 2136 | <img | 2215 | <img |
| 2137 | src={expandedImage.src} | 2216 | src={expandedImageItem.src} |
| 2138 | alt={expandedImage.name} | 2217 | alt={expandedImageItem.name} |
| 2139 | className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl" | 2218 | className="max-h-[86vh] max-w-[92vw] select-none rounded-lg border border-border/70 bg-background object-contain shadow-2xl" |
| 2140 | draggable={false} | 2219 | draggable={false} |
| 2141 | /> | 2220 | /> |
| 2142 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> | 2221 | <p className="mt-2 max-w-[92vw] truncate text-center text-xs text-muted-foreground/80"> |
| 2143 | {expandedImage.name} | 2222 | {expandedImageItem.name} |
| 2223 | {expandedImage.images.length > 1 | ||
| 2224 | ? ` (${expandedImage.index + 1}/${expandedImage.images.length})` | ||
| 2225 | : ""} | ||
| 2144 | </p> | 2226 | </p> |
| 2145 | </div> | 2227 | </div> |
| 2228 | {expandedImage.images.length > 1 && ( | ||
| 2229 | <Button | ||
| 2230 | type="button" | ||
| 2231 | size="icon" | ||
| 2232 | variant="ghost" | ||
| 2233 | className="absolute right-2 top-1/2 z-10 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6" | ||
| 2234 | aria-label="Next image" | ||
| 2235 | onClick={(event) => { | ||
| 2236 | event.stopPropagation(); | ||
| 2237 | navigateExpandedImage(1); | ||
| 2238 | }} | ||
| 2239 | > | ||
| 2240 | <ChevronRightIcon className="size-5" /> | ||
| 2241 | </Button> | ||
| 2242 | )} | ||
| 2146 | </div> | 2243 | </div> |
| 2147 | )} | 2244 | )} |
| 2148 | </div> | 2245 | </div> |
| ... | @@ -2336,13 +2433,7 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri | ... | @@ -2336,13 +2433,7 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri |
| 2336 | }, [text]); | 2433 | }, [text]); |
| 2337 | 2434 | ||
| 2338 | return ( | 2435 | return ( |
| 2339 | <Button | 2436 | <Button type="button" size="xs" variant="outline" onClick={handleCopy} title="Copy message"> |
| 2340 | type="button" | ||
| 2341 | size="xs" | ||
| 2342 | variant="outline" | ||
| 2343 | onClick={handleCopy} | ||
| 2344 | title="Copy message" | ||
| 2345 | > | ||
| 2346 | {copied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />} | 2437 | {copied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />} |
| 2347 | </Button> | 2438 | </Button> |
| 2348 | ); | 2439 | ); |
| ... | @@ -2351,6 +2442,8 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri | ... | @@ -2351,6 +2442,8 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri |
| 2351 | interface MessagesTimelineProps { | 2442 | interface MessagesTimelineProps { |
| 2352 | hasMessages: boolean; | 2443 | hasMessages: boolean; |
| 2353 | isWorking: boolean; | 2444 | isWorking: boolean; |
| 2445 | activeTurnInProgress: boolean; | ||
| 2446 | activeTurnStartedAt: string | null; | ||
| 2354 | scrollContainerRef: React.RefObject<HTMLDivElement | null>; | 2447 | scrollContainerRef: React.RefObject<HTMLDivElement | null>; |
| 2355 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; | 2448 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| 2356 | completionDividerBeforeEntryId: string | null; | 2449 | completionDividerBeforeEntryId: string | null; |
| ... | @@ -2363,7 +2456,7 @@ interface MessagesTimelineProps { | ... | @@ -2363,7 +2456,7 @@ interface MessagesTimelineProps { |
| 2363 | revertTurnCountByUserMessageId: Map<MessageId, number>; | 2456 | revertTurnCountByUserMessageId: Map<MessageId, number>; |
| 2364 | onRevertUserMessage: (messageId: MessageId) => void; | 2457 | onRevertUserMessage: (messageId: MessageId) => void; |
| 2365 | isRevertingCheckpoint: boolean; | 2458 | isRevertingCheckpoint: boolean; |
| 2366 | onImageExpand: (image: ExpandedImagePreview) => void; | 2459 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| 2367 | markdownCwd: string | undefined; | 2460 | markdownCwd: string | undefined; |
| 2368 | } | 2461 | } |
| 2369 | 2462 | ||
| ... | @@ -2374,19 +2467,23 @@ type TimelineRow = | ... | @@ -2374,19 +2467,23 @@ type TimelineRow = |
| 2374 | | { | 2467 | | { |
| 2375 | kind: "work"; | 2468 | kind: "work"; |
| 2376 | id: string; | 2469 | id: string; |
| 2470 | createdAt: string; | ||
| 2377 | groupedEntries: TimelineWorkEntry[]; | 2471 | groupedEntries: TimelineWorkEntry[]; |
| 2378 | } | 2472 | } |
| 2379 | | { | 2473 | | { |
| 2380 | kind: "message"; | 2474 | kind: "message"; |
| 2381 | id: string; | 2475 | id: string; |
| 2476 | createdAt: string; | ||
| 2382 | message: TimelineMessage; | 2477 | message: TimelineMessage; |
| 2383 | showCompletionDivider: boolean; | 2478 | showCompletionDivider: boolean; |
| 2384 | } | 2479 | } |
| 2385 | | { kind: "working"; id: string }; | 2480 | | { kind: "working"; id: string; createdAt: string | null }; |
| 2386 | 2481 | ||
| 2387 | const MessagesTimeline = memo(function MessagesTimeline({ | 2482 | const MessagesTimeline = memo(function MessagesTimeline({ |
| 2388 | hasMessages, | 2483 | hasMessages, |
| 2389 | isWorking, | 2484 | isWorking, |
| 2485 | activeTurnInProgress, | ||
| 2486 | activeTurnStartedAt, | ||
| 2390 | scrollContainerRef, | 2487 | scrollContainerRef, |
| 2391 | timelineEntries, | 2488 | timelineEntries, |
| 2392 | completionDividerBeforeEntryId, | 2489 | completionDividerBeforeEntryId, |
| ... | @@ -2423,6 +2520,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -2423,6 +2520,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2423 | nextRows.push({ | 2520 | nextRows.push({ |
| 2424 | kind: "work", | 2521 | kind: "work", |
| 2425 | id: timelineEntry.id, | 2522 | id: timelineEntry.id, |
| 2523 | createdAt: timelineEntry.createdAt, | ||
| 2426 | groupedEntries, | 2524 | groupedEntries, |
| 2427 | }); | 2525 | }); |
| 2428 | index = cursor - 1; | 2526 | index = cursor - 1; |
| ... | @@ -2432,6 +2530,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -2432,6 +2530,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2432 | nextRows.push({ | 2530 | nextRows.push({ |
| 2433 | kind: "message", | 2531 | kind: "message", |
| 2434 | id: timelineEntry.id, | 2532 | id: timelineEntry.id, |
| 2533 | createdAt: timelineEntry.createdAt, | ||
| 2435 | message: timelineEntry.message, | 2534 | message: timelineEntry.message, |
| 2436 | showCompletionDivider: | 2535 | showCompletionDivider: |
| 2437 | timelineEntry.message.role === "assistant" && | 2536 | timelineEntry.message.role === "assistant" && |
| ... | @@ -2440,14 +2539,62 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -2440,14 +2539,62 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2440 | } | 2539 | } |
| 2441 | 2540 | ||
| 2442 | if (isWorking) { | 2541 | if (isWorking) { |
| 2443 | nextRows.push({ kind: "working", id: "working-indicator-row" }); | 2542 | nextRows.push({ |
| 2543 | kind: "working", | ||
| 2544 | id: "working-indicator-row", | ||
| 2545 | createdAt: activeTurnStartedAt, | ||
| 2546 | }); | ||
| 2444 | } | 2547 | } |
| 2445 | 2548 | ||
| 2446 | return nextRows; | 2549 | return nextRows; |
| 2447 | }, [timelineEntries, completionDividerBeforeEntryId, isWorking]); | 2550 | }, [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt]); |
| 2551 | |||
| 2552 | const firstUnvirtualizedRowIndex = useMemo(() => { | ||
| 2553 | if (!activeTurnInProgress) return rows.length; | ||
| 2554 | |||
| 2555 | const turnStartedAtMs = | ||
| 2556 | typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN; | ||
| 2557 | let firstCurrentTurnRowIndex = -1; | ||
| 2558 | if (!Number.isNaN(turnStartedAtMs)) { | ||
| 2559 | firstCurrentTurnRowIndex = rows.findIndex((row) => { | ||
| 2560 | if (row.kind === "working") return true; | ||
| 2561 | if (!row.createdAt) return false; | ||
| 2562 | const rowCreatedAtMs = Date.parse(row.createdAt); | ||
| 2563 | return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs; | ||
| 2564 | }); | ||
| 2565 | } | ||
| 2566 | |||
| 2567 | if (firstCurrentTurnRowIndex < 0) { | ||
| 2568 | firstCurrentTurnRowIndex = rows.findIndex( | ||
| 2569 | (row) => row.kind === "message" && row.message.streaming, | ||
| 2570 | ); | ||
| 2571 | } | ||
| 2572 | |||
| 2573 | if (firstCurrentTurnRowIndex < 0) { | ||
| 2574 | return rows.length; | ||
| 2575 | } | ||
| 2576 | |||
| 2577 | for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) { | ||
| 2578 | const previousRow = rows[index]; | ||
| 2579 | if (!previousRow || previousRow.kind !== "message") continue; | ||
| 2580 | if (previousRow.message.role === "user") { | ||
| 2581 | return index; | ||
| 2582 | } | ||
| 2583 | if (previousRow.message.role === "assistant" && !previousRow.message.streaming) { | ||
| 2584 | break; | ||
| 2585 | } | ||
| 2586 | } | ||
| 2587 | |||
| 2588 | return firstCurrentTurnRowIndex; | ||
| 2589 | }, [activeTurnInProgress, activeTurnStartedAt, rows]); | ||
| 2590 | |||
| 2591 | const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { | ||
| 2592 | minimum: 0, | ||
| 2593 | maximum: rows.length, | ||
| 2594 | }); | ||
| 2448 | 2595 | ||
| 2449 | const rowVirtualizer = useVirtualizer({ | 2596 | const rowVirtualizer = useVirtualizer({ |
| 2450 | count: rows.length, | 2597 | count: virtualizedRowCount, |
| 2451 | getScrollElement: () => scrollContainerRef.current, | 2598 | getScrollElement: () => scrollContainerRef.current, |
| 2452 | estimateSize: (index: number) => { | 2599 | estimateSize: (index: number) => { |
| 2453 | const row = rows[index]; | 2600 | const row = rows[index]; |
| ... | @@ -2461,303 +2608,305 @@ const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -2461,303 +2608,305 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2461 | }); | 2608 | }); |
| 2462 | 2609 | ||
| 2463 | const virtualRows = rowVirtualizer.getVirtualItems(); | 2610 | const virtualRows = rowVirtualizer.getVirtualItems(); |
| 2464 | 2611 | const nonVirtualizedRows = rows.slice(virtualizedRowCount); | |
| 2465 | if (!hasMessages && !isWorking) { | 2612 | |
| 2466 | return ( | 2613 | const renderRowContent = (row: TimelineRow) => ( |
| 2467 | <div className="flex h-full items-center justify-center"> | 2614 | <div className="pb-4"> |
| 2468 | <p className="text-sm text-muted-foreground/30"> | 2615 | {row.kind === "work" && |
| 2469 | Send a message to start the conversation. | 2616 | (() => { |
| 2470 | </p> | 2617 | const groupId = row.id; |
| 2471 | </div> | 2618 | const groupedEntries = row.groupedEntries; |
| 2472 | ); | 2619 | const isExpanded = expandedWorkGroups[groupId] ?? false; |
| 2473 | } | 2620 | const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; |
| 2474 | 2621 | const visibleEntries = | |
| 2475 | return ( | 2622 | hasOverflow && !isExpanded |
| 2476 | <div | 2623 | ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) |
| 2477 | className="relative mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden" | 2624 | : groupedEntries; |
| 2478 | style={{ height: `${rowVirtualizer.getTotalSize()}px` }} | 2625 | const hiddenCount = groupedEntries.length - visibleEntries.length; |
| 2479 | > | 2626 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); |
| 2480 | {virtualRows.map((virtualRow: VirtualItem) => { | 2627 | const groupLabel = onlyToolEntries |
| 2481 | const row = rows[virtualRow.index]; | 2628 | ? groupedEntries.length === 1 |
| 2482 | if (!row) return null; | 2629 | ? "Tool call" |
| 2483 | 2630 | : `Tool calls (${groupedEntries.length})` | |
| 2484 | return ( | 2631 | : groupedEntries.length === 1 |
| 2485 | <div | 2632 | ? "Work event" |
| 2486 | key={row.id} | 2633 | : `Work log (${groupedEntries.length})`; |
| 2487 | data-index={virtualRow.index} | 2634 | |
| 2488 | ref={rowVirtualizer.measureElement} | 2635 | return ( |
| 2489 | className="absolute left-0 top-0 w-full" | 2636 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> |
| 2490 | style={{ transform: `translateY(${virtualRow.start}px)` }} | 2637 | <div className="mb-1.5 flex items-center justify-between gap-3"> |
| 2491 | > | 2638 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> |
| 2492 | <div className="pb-4"> | 2639 | {groupLabel} |
| 2493 | {row.kind === "work" && | 2640 | </p> |
| 2494 | (() => { | 2641 | {hasOverflow && ( |
| 2495 | const groupId = row.id; | 2642 | <button |
| 2496 | const groupedEntries = row.groupedEntries; | 2643 | type="button" |
| 2497 | const isExpanded = expandedWorkGroups[groupId] ?? false; | 2644 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" |
| 2498 | const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; | 2645 | onClick={() => onToggleWorkGroup(groupId)} |
| 2499 | const visibleEntries = | 2646 | > |
| 2500 | hasOverflow && !isExpanded | 2647 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} |
| 2501 | ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) | 2648 | </button> |
| 2502 | : groupedEntries; | 2649 | )} |
| 2503 | const hiddenCount = groupedEntries.length - visibleEntries.length; | 2650 | </div> |
| 2504 | const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); | 2651 | <div className="space-y-1"> |
| 2505 | const groupLabel = onlyToolEntries | 2652 | {visibleEntries.map((workEntry) => ( |
| 2506 | ? groupedEntries.length === 1 | 2653 | <div key={`work-row:${workEntry.id}`} className="flex items-start gap-2 py-0.5"> |
| 2507 | ? "Tool call" | 2654 | <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> |
| 2508 | : `Tool calls (${groupedEntries.length})` | 2655 | <p |
| 2509 | : groupedEntries.length === 1 | 2656 | className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`} |
| 2510 | ? "Work event" | 2657 | > |
| 2511 | : `Work log (${groupedEntries.length})`; | 2658 | {workEntry.detail ? ( |
| 2512 | 2659 | <> | |
| 2660 | {workEntry.label} | ||
| 2661 | <span | ||
| 2662 | className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60" | ||
| 2663 | title={workEntry.detail} | ||
| 2664 | > | ||
| 2665 | {workEntry.detail} | ||
| 2666 | </span> | ||
| 2667 | </> | ||
| 2668 | ) : ( | ||
| 2669 | workEntry.label | ||
| 2670 | )} | ||
| 2671 | </p> | ||
| 2672 | </div> | ||
| 2673 | ))} | ||
| 2674 | </div> | ||
| 2675 | </div> | ||
| 2676 | ); | ||
| 2677 | })()} | ||
| 2678 | |||
| 2679 | {row.kind === "message" && | ||
| 2680 | row.message.role === "user" && | ||
| 2681 | (() => { | ||
| 2682 | const userImages = row.message.attachments ?? []; | ||
| 2683 | const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); | ||
| 2684 | return ( | ||
| 2685 | <div className="flex justify-end"> | ||
| 2686 | <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | ||
| 2687 | {userImages.length > 0 && ( | ||
| 2688 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> | ||
| 2689 | {userImages.map( | ||
| 2690 | (image: NonNullable<TimelineMessage["attachments"]>[number]) => ( | ||
| 2691 | <div | ||
| 2692 | key={image.id} | ||
| 2693 | className="overflow-hidden rounded-lg border border-border/80 bg-background/70" | ||
| 2694 | > | ||
| 2695 | {image.previewUrl ? ( | ||
| 2696 | <img | ||
| 2697 | src={image.previewUrl} | ||
| 2698 | alt={image.name} | ||
| 2699 | className="h-full max-h-[220px] w-full cursor-zoom-in object-cover" | ||
| 2700 | onClick={() => { | ||
| 2701 | const preview = buildExpandedImagePreview(userImages, image.id); | ||
| 2702 | if (!preview) return; | ||
| 2703 | onImageExpand(preview); | ||
| 2704 | }} | ||
| 2705 | /> | ||
| 2706 | ) : ( | ||
| 2707 | <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> | ||
| 2708 | {image.name} | ||
| 2709 | </div> | ||
| 2710 | )} | ||
| 2711 | </div> | ||
| 2712 | ), | ||
| 2713 | )} | ||
| 2714 | </div> | ||
| 2715 | )} | ||
| 2716 | {row.message.text && ( | ||
| 2717 | <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground"> | ||
| 2718 | {row.message.text} | ||
| 2719 | </pre> | ||
| 2720 | )} | ||
| 2721 | <div className="mt-1.5 flex items-center justify-end gap-2"> | ||
| 2722 | <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100"> | ||
| 2723 | {row.message.text && <MessageCopyButton text={row.message.text} />} | ||
| 2724 | {canRevertAgentWork && ( | ||
| 2725 | <Button | ||
| 2726 | type="button" | ||
| 2727 | size="xs" | ||
| 2728 | variant="outline" | ||
| 2729 | disabled={isRevertingCheckpoint || isWorking} | ||
| 2730 | onClick={() => onRevertUserMessage(row.message.id)} | ||
| 2731 | title="Revert to this message" | ||
| 2732 | > | ||
| 2733 | <Undo2Icon className="size-3" /> | ||
| 2734 | </Button> | ||
| 2735 | )} | ||
| 2736 | </div> | ||
| 2737 | <p className="text-right text-[10px] text-muted-foreground/30"> | ||
| 2738 | {formatTimestamp(row.message.createdAt)} | ||
| 2739 | </p> | ||
| 2740 | </div> | ||
| 2741 | </div> | ||
| 2742 | </div> | ||
| 2743 | ); | ||
| 2744 | })()} | ||
| 2745 | |||
| 2746 | {row.kind === "message" && | ||
| 2747 | row.message.role === "assistant" && | ||
| 2748 | (() => { | ||
| 2749 | const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); | ||
| 2750 | return ( | ||
| 2751 | <> | ||
| 2752 | {row.showCompletionDivider && ( | ||
| 2753 | <div className="my-3 flex items-center gap-3"> | ||
| 2754 | <span className="h-px flex-1 bg-border" /> | ||
| 2755 | <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | ||
| 2756 | {completionSummary ? `Response • ${completionSummary}` : "Response"} | ||
| 2757 | </span> | ||
| 2758 | <span className="h-px flex-1 bg-border" /> | ||
| 2759 | </div> | ||
| 2760 | )} | ||
| 2761 | <div className="min-w-0 px-1 py-0.5"> | ||
| 2762 | <ChatMarkdown text={messageText} cwd={markdownCwd} /> | ||
| 2763 | {(() => { | ||
| 2764 | const turnSummary = turnDiffSummaryByAssistantMessageId.get(row.message.id); | ||
| 2765 | if (!turnSummary) return null; | ||
| 2766 | const checkpointFiles = turnSummary.files; | ||
| 2767 | if (checkpointFiles.length === 0) return null; | ||
| 2768 | const summaryStat = checkpointFiles.reduce( | ||
| 2769 | (acc, file) => { | ||
| 2770 | if ( | ||
| 2771 | typeof file.additions !== "number" || | ||
| 2772 | typeof file.deletions !== "number" | ||
| 2773 | ) { | ||
| 2774 | return acc; | ||
| 2775 | } | ||
| 2776 | return { | ||
| 2777 | additions: acc.additions + file.additions, | ||
| 2778 | deletions: acc.deletions + file.deletions, | ||
| 2779 | }; | ||
| 2780 | }, | ||
| 2781 | { additions: 0, deletions: 0 }, | ||
| 2782 | ); | ||
| 2783 | const changedFileCountLabel = String(checkpointFiles.length); | ||
| 2513 | return ( | 2784 | return ( |
| 2514 | <div className="rounded-lg border border-border/80 bg-card/45 px-3 py-2"> | 2785 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> |
| 2515 | <div className="mb-1.5 flex items-center justify-between gap-3"> | 2786 | <div className="mb-1.5 flex items-center justify-between gap-2"> |
| 2516 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | 2787 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> |
| 2517 | {groupLabel} | 2788 | <span>Changed files ({changedFileCountLabel})</span> |
| 2789 | {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( | ||
| 2790 | <> | ||
| 2791 | <span className="mx-1">•</span> | ||
| 2792 | <span className="text-success">+{summaryStat.additions}</span> | ||
| 2793 | <span className="mx-0.5 text-muted-foreground/70">/</span> | ||
| 2794 | <span className="text-destructive">-{summaryStat.deletions}</span> | ||
| 2795 | </> | ||
| 2796 | )} | ||
| 2518 | </p> | 2797 | </p> |
| 2519 | {hasOverflow && ( | 2798 | <Button |
| 2799 | type="button" | ||
| 2800 | size="xs" | ||
| 2801 | variant="outline" | ||
| 2802 | onClick={() => | ||
| 2803 | onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path) | ||
| 2804 | } | ||
| 2805 | > | ||
| 2806 | View diff | ||
| 2807 | </Button> | ||
| 2808 | </div> | ||
| 2809 | <div className="flex flex-wrap gap-1.5"> | ||
| 2810 | {checkpointFiles.map((file) => ( | ||
| 2520 | <button | 2811 | <button |
| 2812 | key={`${turnSummary.turnId}:${file.path}`} | ||
| 2521 | type="button" | 2813 | type="button" |
| 2522 | className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/55 transition-colors duration-150 hover:text-muted-foreground/80" | 2814 | className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90" |
| 2523 | onClick={() => onToggleWorkGroup(groupId)} | 2815 | onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)} |
| 2524 | > | 2816 | > |
| 2525 | {isExpanded ? "Show less" : `Show ${hiddenCount} more`} | 2817 | {(() => { |
| 2526 | </button> | 2818 | const stat = |
| 2527 | )} | 2819 | typeof file.additions === "number" && |
| 2528 | </div> | 2820 | typeof file.deletions === "number" |
| 2529 | <div className="space-y-1"> | 2821 | ? { |
| 2530 | {visibleEntries.map((workEntry) => ( | 2822 | additions: file.additions, |
| 2531 | <div | 2823 | deletions: file.deletions, |
| 2532 | key={`work-row:${workEntry.id}`} | 2824 | } |
| 2533 | className="flex items-start gap-2 py-0.5" | 2825 | : null; |
| 2534 | > | 2826 | if (!stat) { |
| 2535 | <span className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | 2827 | return file.path; |
| 2536 | <p | 2828 | } |
| 2537 | className={`py-[2px] text-[11px] leading-relaxed ${workToneClass(workEntry.tone)}`} | 2829 | return ( |
| 2538 | > | ||
| 2539 | {workEntry.detail ? ( | ||
| 2540 | <> | 2830 | <> |
| 2541 | {workEntry.label} | 2831 | <span>{file.path}</span> |
| 2542 | <span | 2832 | <span className="ml-1 text-muted-foreground/70">(</span> |
| 2543 | className="ml-1.5 inline-block max-w-[70ch] truncate align-bottom font-mono text-[11px] opacity-60" | 2833 | <span className="text-success">+{stat.additions}</span> |
| 2544 | title={workEntry.detail} | 2834 | <span className="mx-0.5 text-muted-foreground/70">/</span> |
| 2545 | > | 2835 | <span className="text-destructive">-{stat.deletions}</span> |
| 2546 | {workEntry.detail} | 2836 | <span className="text-muted-foreground/70">)</span> |
| 2547 | </span> | ||
| 2548 | </> | 2837 | </> |
| 2549 | ) : ( | 2838 | ); |
| 2550 | workEntry.label | 2839 | })()} |
| 2551 | )} | 2840 | </button> |
| 2552 | </p> | ||
| 2553 | </div> | ||
| 2554 | ))} | 2841 | ))} |
| 2555 | </div> | 2842 | </div> |
| 2556 | </div> | 2843 | </div> |
| 2557 | ); | 2844 | ); |
| 2558 | })()} | 2845 | })()} |
| 2846 | <p className="mt-1.5 text-[10px] text-muted-foreground/30"> | ||
| 2847 | {formatMessageMeta( | ||
| 2848 | row.message.createdAt, | ||
| 2849 | row.message.streaming | ||
| 2850 | ? formatElapsed(row.message.createdAt, nowIso) | ||
| 2851 | : formatElapsed(row.message.createdAt, row.message.completedAt), | ||
| 2852 | )} | ||
| 2853 | </p> | ||
| 2854 | </div> | ||
| 2855 | </> | ||
| 2856 | ); | ||
| 2857 | })()} | ||
| 2858 | |||
| 2859 | {row.kind === "working" && ( | ||
| 2860 | <div className="flex items-center gap-2 py-0.5 pl-1.5"> | ||
| 2861 | <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | ||
| 2862 | <div className="flex items-center pt-1"> | ||
| 2863 | <span className="inline-flex items-center gap-[3px]"> | ||
| 2864 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | ||
| 2865 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | ||
| 2866 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | ||
| 2867 | </span> | ||
| 2868 | </div> | ||
| 2869 | </div> | ||
| 2870 | )} | ||
| 2871 | </div> | ||
| 2872 | ); | ||
| 2559 | 2873 | ||
| 2560 | {row.kind === "message" && | 2874 | if (!hasMessages && !isWorking) { |
| 2561 | row.message.role === "user" && | 2875 | return ( |
| 2562 | (() => { | 2876 | <div className="flex h-full items-center justify-center"> |
| 2563 | const userImages = row.message.attachments ?? []; | 2877 | <p className="text-sm text-muted-foreground/30"> |
| 2564 | const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); | 2878 | Send a message to start the conversation. |
| 2565 | return ( | 2879 | </p> |
| 2566 | <div className="flex justify-end"> | 2880 | </div> |
| 2567 | <div className="group relative max-w-[80%] rounded-2xl rounded-br-sm border border-border bg-secondary px-4 py-3"> | 2881 | ); |
| 2568 | {userImages.length > 0 && ( | 2882 | } |
| 2569 | <div className="mb-2 grid max-w-[420px] grid-cols-2 gap-2"> | ||
| 2570 | {userImages.map( | ||
| 2571 | (image: NonNullable<TimelineMessage["attachments"]>[number]) => ( | ||
| 2572 | <div | ||
| 2573 | key={image.id} | ||
| 2574 | className="overflow-hidden rounded-lg border border-border/80 bg-background/70" | ||
| 2575 | > | ||
| 2576 | {image.previewUrl ? ( | ||
| 2577 | <img | ||
| 2578 | src={image.previewUrl} | ||
| 2579 | alt={image.name} | ||
| 2580 | className="h-full max-h-[220px] w-full cursor-zoom-in object-cover" | ||
| 2581 | onClick={() => | ||
| 2582 | onImageExpand({ src: image.previewUrl!, name: image.name }) | ||
| 2583 | } | ||
| 2584 | /> | ||
| 2585 | ) : ( | ||
| 2586 | <div className="flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-[11px] text-muted-foreground/70"> | ||
| 2587 | {image.name} | ||
| 2588 | </div> | ||
| 2589 | )} | ||
| 2590 | </div> | ||
| 2591 | ), | ||
| 2592 | )} | ||
| 2593 | </div> | ||
| 2594 | )} | ||
| 2595 | {row.message.text && ( | ||
| 2596 | <pre className="whitespace-pre-wrap wrap-break-word font-mono text-sm leading-relaxed text-foreground"> | ||
| 2597 | {row.message.text} | ||
| 2598 | </pre> | ||
| 2599 | )} | ||
| 2600 | <div className="mt-1.5 flex items-center justify-end gap-2"> | ||
| 2601 | <div className="flex items-center gap-1.5 opacity-0 transition-opacity duration-200 focus-within:opacity-100 group-hover:opacity-100"> | ||
| 2602 | {row.message.text && <MessageCopyButton text={row.message.text} />} | ||
| 2603 | {canRevertAgentWork && ( | ||
| 2604 | <Button | ||
| 2605 | type="button" | ||
| 2606 | size="xs" | ||
| 2607 | variant="outline" | ||
| 2608 | disabled={isRevertingCheckpoint || isWorking} | ||
| 2609 | onClick={() => onRevertUserMessage(row.message.id)} | ||
| 2610 | title="Revert to this message" | ||
| 2611 | > | ||
| 2612 | <Undo2Icon className="size-3" /> | ||
| 2613 | </Button> | ||
| 2614 | )} | ||
| 2615 | </div> | ||
| 2616 | <p className="text-right text-[10px] text-muted-foreground/30"> | ||
| 2617 | {formatTimestamp(row.message.createdAt)} | ||
| 2618 | </p> | ||
| 2619 | </div> | ||
| 2620 | </div> | ||
| 2621 | </div> | ||
| 2622 | ); | ||
| 2623 | })()} | ||
| 2624 | 2883 | ||
| 2625 | {row.kind === "message" && | 2884 | return ( |
| 2626 | row.message.role === "assistant" && | 2885 | <div className="mx-auto w-full min-w-0 max-w-3xl overflow-x-hidden"> |
| 2627 | (() => { | 2886 | {virtualizedRowCount > 0 && ( |
| 2628 | const messageText = | 2887 | <div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}> |
| 2629 | row.message.text || (row.message.streaming ? "" : "(empty response)"); | 2888 | {virtualRows.map((virtualRow: VirtualItem) => { |
| 2630 | return ( | 2889 | const row = rows[virtualRow.index]; |
| 2631 | <> | 2890 | if (!row) return null; |
| 2632 | {row.showCompletionDivider && ( | 2891 | |
| 2633 | <div className="my-3 flex items-center gap-3"> | 2892 | return ( |
| 2634 | <span className="h-px flex-1 bg-border" /> | 2893 | <div |
| 2635 | <span className="rounded-full border border-border bg-background px-2.5 py-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground/80"> | 2894 | key={`virtual-row:${row.id}`} |
| 2636 | {completionSummary ? `Response • ${completionSummary}` : "Response"} | 2895 | data-index={virtualRow.index} |
| 2637 | </span> | 2896 | ref={rowVirtualizer.measureElement} |
| 2638 | <span className="h-px flex-1 bg-border" /> | 2897 | className="absolute left-0 top-0 w-full" |
| 2639 | </div> | 2898 | style={{ transform: `translateY(${virtualRow.start}px)` }} |
| 2640 | )} | 2899 | > |
| 2641 | <div className="min-w-0 px-1 py-0.5"> | 2900 | {renderRowContent(row)} |
| 2642 | <ChatMarkdown text={messageText} cwd={markdownCwd} /> | 2901 | </div> |
| 2643 | {(() => { | 2902 | ); |
| 2644 | const turnSummary = turnDiffSummaryByAssistantMessageId.get( | 2903 | })} |
| 2645 | row.message.id, | 2904 | </div> |
| 2646 | ); | 2905 | )} |
| 2647 | if (!turnSummary) return null; | ||
| 2648 | const checkpointFiles = turnSummary.files; | ||
| 2649 | if (checkpointFiles.length === 0) return null; | ||
| 2650 | const summaryStat = checkpointFiles.reduce( | ||
| 2651 | (acc, file) => { | ||
| 2652 | if ( | ||
| 2653 | typeof file.additions !== "number" || | ||
| 2654 | typeof file.deletions !== "number" | ||
| 2655 | ) { | ||
| 2656 | return acc; | ||
| 2657 | } | ||
| 2658 | return { | ||
| 2659 | additions: acc.additions + file.additions, | ||
| 2660 | deletions: acc.deletions + file.deletions, | ||
| 2661 | }; | ||
| 2662 | }, | ||
| 2663 | { additions: 0, deletions: 0 }, | ||
| 2664 | ); | ||
| 2665 | const changedFileCountLabel = String(checkpointFiles.length); | ||
| 2666 | return ( | ||
| 2667 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> | ||
| 2668 | <div className="mb-1.5 flex items-center justify-between gap-2"> | ||
| 2669 | <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65"> | ||
| 2670 | <span>Changed files ({changedFileCountLabel})</span> | ||
| 2671 | {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( | ||
| 2672 | <> | ||
| 2673 | <span className="mx-1">•</span> | ||
| 2674 | <span className="text-success">+{summaryStat.additions}</span> | ||
| 2675 | <span className="mx-0.5 text-muted-foreground/70">/</span> | ||
| 2676 | <span className="text-destructive"> | ||
| 2677 | -{summaryStat.deletions} | ||
| 2678 | </span> | ||
| 2679 | </> | ||
| 2680 | )} | ||
| 2681 | </p> | ||
| 2682 | <Button | ||
| 2683 | type="button" | ||
| 2684 | size="xs" | ||
| 2685 | variant="outline" | ||
| 2686 | onClick={() => | ||
| 2687 | onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path) | ||
| 2688 | } | ||
| 2689 | > | ||
| 2690 | View diff | ||
| 2691 | </Button> | ||
| 2692 | </div> | ||
| 2693 | <div className="flex flex-wrap gap-1.5"> | ||
| 2694 | {checkpointFiles.map((file) => ( | ||
| 2695 | <button | ||
| 2696 | key={`${turnSummary.turnId}:${file.path}`} | ||
| 2697 | type="button" | ||
| 2698 | className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90" | ||
| 2699 | onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)} | ||
| 2700 | > | ||
| 2701 | {(() => { | ||
| 2702 | const stat = | ||
| 2703 | typeof file.additions === "number" && | ||
| 2704 | typeof file.deletions === "number" | ||
| 2705 | ? { | ||
| 2706 | additions: file.additions, | ||
| 2707 | deletions: file.deletions, | ||
| 2708 | } | ||
| 2709 | : null; | ||
| 2710 | if (!stat) { | ||
| 2711 | return file.path; | ||
| 2712 | } | ||
| 2713 | return ( | ||
| 2714 | <> | ||
| 2715 | <span>{file.path}</span> | ||
| 2716 | <span className="ml-1 text-muted-foreground/70">(</span> | ||
| 2717 | <span className="text-success">+{stat.additions}</span> | ||
| 2718 | <span className="mx-0.5 text-muted-foreground/70">/</span> | ||
| 2719 | <span className="text-destructive"> | ||
| 2720 | -{stat.deletions} | ||
| 2721 | </span> | ||
| 2722 | <span className="text-muted-foreground/70">)</span> | ||
| 2723 | </> | ||
| 2724 | ); | ||
| 2725 | })()} | ||
| 2726 | </button> | ||
| 2727 | ))} | ||
| 2728 | </div> | ||
| 2729 | </div> | ||
| 2730 | ); | ||
| 2731 | })()} | ||
| 2732 | <p className="mt-1.5 text-[10px] text-muted-foreground/30"> | ||
| 2733 | {formatMessageMeta( | ||
| 2734 | row.message.createdAt, | ||
| 2735 | row.message.streaming | ||
| 2736 | ? formatElapsed(row.message.createdAt, nowIso) | ||
| 2737 | : formatElapsed(row.message.createdAt, row.message.completedAt), | ||
| 2738 | )} | ||
| 2739 | </p> | ||
| 2740 | </div> | ||
| 2741 | </> | ||
| 2742 | ); | ||
| 2743 | })()} | ||
| 2744 | 2906 | ||
| 2745 | {row.kind === "working" && ( | 2907 | {nonVirtualizedRows.map((row) => ( |
| 2746 | <div className="flex items-center gap-2 py-0.5 pl-1.5"> | 2908 | <div key={`non-virtual-row:${row.id}`}>{renderRowContent(row)}</div> |
| 2747 | <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-muted-foreground/30" /> | 2909 | ))} |
| 2748 | <div className="flex items-center pt-1"> | ||
| 2749 | <span className="inline-flex items-center gap-[3px]"> | ||
| 2750 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse" /> | ||
| 2751 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:200ms]" /> | ||
| 2752 | <span className="h-1 w-1 rounded-full bg-muted-foreground/30 animate-pulse [animation-delay:400ms]" /> | ||
| 2753 | </span> | ||
| 2754 | </div> | ||
| 2755 | </div> | ||
| 2756 | )} | ||
| 2757 | </div> | ||
| 2758 | </div> | ||
| 2759 | ); | ||
| 2760 | })} | ||
| 2761 | </div> | 2910 | </div> |
| 2762 | ); | 2911 | ); |
| 2763 | }); | 2912 | }); |
apps/web/src/store.ts+38-1| ... | @@ -192,6 +192,43 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { | ... | @@ -192,6 +192,43 @@ function toLegacyProvider(providerName: string | null): "codex" | "claudeCode" { |
| 192 | return providerName === "claudeCode" ? "claudeCode" : "codex"; | 192 | return providerName === "claudeCode" ? "claudeCode" : "codex"; |
| 193 | } | 193 | } |
| 194 | 194 | ||
| 195 | function resolveWsHttpOrigin(): string { | ||
| 196 | if (typeof window === "undefined") { | ||
| 197 | return ""; | ||
| 198 | } | ||
| 199 | const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); | ||
| 200 | const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined; | ||
| 201 | const wsCandidate = | ||
| 202 | typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0 | ||
| 203 | ? bridgeWsUrl | ||
| 204 | : typeof envWsUrl === "string" && envWsUrl.length > 0 | ||
| 205 | ? envWsUrl | ||
| 206 | : null; | ||
| 207 | if (!wsCandidate) { | ||
| 208 | return window.location.origin; | ||
| 209 | } | ||
| 210 | |||
| 211 | try { | ||
| 212 | const wsUrl = new URL(wsCandidate); | ||
| 213 | const protocol = | ||
| 214 | wsUrl.protocol === "wss:" | ||
| 215 | ? "https:" | ||
| 216 | : wsUrl.protocol === "ws:" | ||
| 217 | ? "http:" | ||
| 218 | : wsUrl.protocol; | ||
| 219 | return `${protocol}//${wsUrl.host}`; | ||
| 220 | } catch { | ||
| 221 | return window.location.origin; | ||
| 222 | } | ||
| 223 | } | ||
| 224 | |||
| 225 | function toAttachmentPreviewUrl(rawUrl: string): string { | ||
| 226 | if (rawUrl.startsWith("/")) { | ||
| 227 | return `${resolveWsHttpOrigin()}${rawUrl}`; | ||
| 228 | } | ||
| 229 | return rawUrl; | ||
| 230 | } | ||
| 231 | |||
| 195 | function normalizeTerminalIds(terminalIds: string[]): string[] { | 232 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 196 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); | 233 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); |
| 197 | const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT); | 234 | const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT); |
| ... | @@ -456,7 +493,7 @@ export function reducer(state: AppState, action: Action): AppState { | ... | @@ -456,7 +493,7 @@ export function reducer(state: AppState, action: Action): AppState { |
| 456 | name: attachment.name, | 493 | name: attachment.name, |
| 457 | mimeType: attachment.mimeType, | 494 | mimeType: attachment.mimeType, |
| 458 | sizeBytes: attachment.sizeBytes, | 495 | sizeBytes: attachment.sizeBytes, |
| 459 | previewUrl: attachment.dataUrl, | 496 | previewUrl: toAttachmentPreviewUrl(attachment.dataUrl), |
| 460 | })); | 497 | })); |
| 461 | const normalizedMessage: ChatMessage = { | 498 | const normalizedMessage: ChatMessage = { |
| 462 | id: message.id, | 499 | id: message.id, |