@@ -15,8 +15,12 @@ import type { Duplex } from "node:stream"
1515import { WebSocket , WebSocketServer , type RawData } from "ws"
1616
1717import type { TerminalSession , TerminalSessionStatus } from "../api/contracts.js"
18- import { ApiInternalError , ApiNotFoundError , describeUnknown } from "../api/errors.js"
18+ import { ApiBadRequestError , ApiInternalError , ApiNotFoundError , describeUnknown } from "../api/errors.js"
1919import { emitProjectEvent } from "./events.js"
20+ import {
21+ planTerminalImageFetch ,
22+ terminalImageFetchMaxBytes
23+ } from "./terminal-image-fetch-core.js"
2024import {
2125 createTerminalImagePastePlan ,
2226 terminalImagePasteDirectory ,
@@ -392,6 +396,91 @@ const writeBufferToProjectContainer = (
392396 child . stdin . end ( buffer )
393397 } )
394398
399+ const readBufferFromProjectContainer = (
400+ containerName : string ,
401+ containerPath : string ,
402+ maxBytes : number
403+ ) : Effect . Effect < Buffer , ApiInternalError | ApiBadRequestError | ApiNotFoundError > =>
404+ Effect . async ( ( resume ) => {
405+ const child = spawn (
406+ "docker" ,
407+ [
408+ "exec" ,
409+ "-u" ,
410+ "dev" ,
411+ containerName ,
412+ "cat" ,
413+ "--" ,
414+ containerPath
415+ ] ,
416+ {
417+ cwd : process . cwd ( ) ,
418+ stdio : [ "ignore" , "pipe" , "pipe" ]
419+ }
420+ )
421+ const stdoutChunks : Array < Buffer > = [ ]
422+ const stderrChunks : Array < Buffer > = [ ]
423+ let totalBytes = 0
424+ let exceededLimit = false
425+ let completed = false
426+ const resumeOnce = (
427+ effect : Effect . Effect < Buffer , ApiInternalError | ApiBadRequestError | ApiNotFoundError >
428+ ) : void => {
429+ if ( completed ) {
430+ return
431+ }
432+ completed = true
433+ resume ( effect )
434+ }
435+ child . stdout . on ( "data" , ( chunk : Buffer | string ) => {
436+ const buffer = Buffer . isBuffer ( chunk ) ? chunk : Buffer . from ( chunk )
437+ totalBytes += buffer . length
438+ if ( totalBytes > maxBytes ) {
439+ exceededLimit = true
440+ try {
441+ child . kill ( )
442+ } catch {
443+ // ignore — close handler will resume
444+ }
445+ return
446+ }
447+ stdoutChunks . push ( buffer )
448+ } )
449+ child . stderr . on ( "data" , ( chunk : Buffer | string ) => {
450+ stderrChunks . push ( Buffer . isBuffer ( chunk ) ? chunk : Buffer . from ( chunk ) )
451+ } )
452+ child . on ( "error" , ( error ) => {
453+ resumeOnce ( Effect . fail ( new ApiInternalError ( {
454+ message : `Failed to run docker exec for ${ containerName } .` ,
455+ cause : error
456+ } ) ) )
457+ } )
458+ child . on ( "close" , ( exitCode ) => {
459+ if ( exceededLimit ) {
460+ resumeOnce ( Effect . fail ( new ApiBadRequestError ( {
461+ message : `Image exceeds maximum size of ${ maxBytes } bytes.`
462+ } ) ) )
463+ return
464+ }
465+ if ( exitCode === 0 ) {
466+ resumeOnce ( Effect . succeed ( Buffer . concat ( stdoutChunks ) ) )
467+ return
468+ }
469+ const stderr = Buffer . concat ( stderrChunks ) . toString ( "utf8" ) . trim ( )
470+ if ( / n o s u c h f i l e | n o t a d i r e c t o r y | n o t f o u n d / iu. test ( stderr ) ) {
471+ resumeOnce ( Effect . fail ( new ApiNotFoundError ( {
472+ message : `Image not found at ${ containerPath } .`
473+ } ) ) )
474+ return
475+ }
476+ resumeOnce ( Effect . fail ( new ApiInternalError ( {
477+ message : stderr . length > 0
478+ ? `Failed to read image: ${ stderr } `
479+ : `Failed to read image; docker exec exited with code ${ exitCode ?? "unknown" } .`
480+ } ) ) )
481+ } )
482+ } )
483+
395484const saveTerminalImagePaste = (
396485 record : TerminalRecord ,
397486 payload : TerminalImagePastePayload
@@ -611,6 +700,33 @@ export const getProjectTerminalSession = (
611700 return record . session
612701 } )
613702
703+ export const readProjectTerminalImage = (
704+ projectId : string ,
705+ sessionId : string ,
706+ imagePath : string
707+ ) : Effect . Effect <
708+ { readonly bytes : Buffer ; readonly mediaType : string } ,
709+ ApiBadRequestError | ApiInternalError | ApiNotFoundError
710+ > =>
711+ Effect . gen ( function * ( _ ) {
712+ const record = records . get ( sessionId )
713+ if ( record === undefined || record . projectId !== projectId ) {
714+ return yield * _ (
715+ Effect . fail ( new ApiNotFoundError ( { message : `Terminal session not found: ${ sessionId } ` } ) )
716+ )
717+ }
718+ const plan = planTerminalImageFetch ( imagePath )
719+ if ( plan . _tag === "InvalidTerminalImageFetch" ) {
720+ return yield * _ ( Effect . fail ( new ApiBadRequestError ( { message : plan . message } ) ) )
721+ }
722+ const bytes = yield * _ ( readBufferFromProjectContainer (
723+ record . projectContainerName ,
724+ plan . containerPath ,
725+ terminalImageFetchMaxBytes
726+ ) )
727+ return { bytes, mediaType : plan . mediaType }
728+ } )
729+
614730export const lookupTerminalSessionById = (
615731 sessionId : string
616732) : Effect . Effect <
0 commit comments