@@ -483,6 +483,203 @@ describe("CloudTaskService", () => {
483483 expect ( messages ( ) ) . toEqual ( [ "before retry" , "after retry" ] ) ;
484484 } ) ;
485485
486+ const consoleLogEntry = ( message : string ) => ( {
487+ type : "notification" ,
488+ timestamp : "2026-01-01T00:00:01Z" ,
489+ notification : {
490+ jsonrpc : "2.0" ,
491+ method : "_posthog/console" ,
492+ params : { sessionId : "run-1" , level : "info" , message } ,
493+ } ,
494+ } ) ;
495+
496+ it ( "bootstraps history from presigned chain log urls without touching session_logs" , async ( ) => {
497+ const updates : unknown [ ] = [ ] ;
498+ service . on ( CloudTaskEvent . Update , ( payload ) => updates . push ( payload ) ) ;
499+
500+ const sessionLogsCalls : string [ ] = [ ] ;
501+ mockNetFetch . mockImplementation ( ( input : string | Request ) => {
502+ const url = typeof input === "string" ? input : input . url ;
503+ if ( url . includes ( "/session_logs/" ) ) {
504+ sessionLogsCalls . push ( url ) ;
505+ return Promise . resolve (
506+ createJsonResponse ( [ ] , 200 , { "X-Has-More" : "false" } ) ,
507+ ) ;
508+ }
509+ if ( url . startsWith ( "https://storage.example/run-0.jsonl" ) ) {
510+ return Promise . resolve (
511+ new Response (
512+ `${ JSON . stringify ( consoleLogEntry ( "ancestor 1" ) ) } \n${ JSON . stringify ( consoleLogEntry ( "ancestor 2" ) ) } \n` ,
513+ ) ,
514+ ) ;
515+ }
516+ if ( url . startsWith ( "https://storage.example/run-1.jsonl" ) ) {
517+ // No trailing newline: the final line of a log object is still a complete entry.
518+ return Promise . resolve (
519+ new Response ( JSON . stringify ( consoleLogEntry ( "current" ) ) ) ,
520+ ) ;
521+ }
522+ return Promise . resolve (
523+ createJsonResponse ( {
524+ id : "run-1" ,
525+ status : "completed" ,
526+ stage : null ,
527+ output : null ,
528+ error_message : null ,
529+ branch : "main" ,
530+ updated_at : "2026-01-01T00:00:00Z" ,
531+ log_urls : [
532+ "https://storage.example/run-0.jsonl?sig=1" ,
533+ "https://storage.example/run-1.jsonl?sig=2" ,
534+ ] ,
535+ } ) ,
536+ ) ;
537+ } ) ;
538+
539+ service . watch ( {
540+ taskId : "task-1" ,
541+ runId : "run-1" ,
542+ apiHost : "https://app.example.com" ,
543+ teamId : 2 ,
544+ } ) ;
545+
546+ await waitFor ( ( ) =>
547+ updates . some ( ( u ) => ( u as { kind ?: string } ) . kind === "snapshot" ) ,
548+ ) ;
549+
550+ const snapshot = updates . find (
551+ ( u ) => ( u as { kind ?: string } ) . kind === "snapshot" ,
552+ ) as { newEntries : unknown [ ] ; totalEntryCount : number } ;
553+ expect ( snapshot . newEntries ) . toEqual ( [
554+ consoleLogEntry ( "ancestor 1" ) ,
555+ consoleLogEntry ( "ancestor 2" ) ,
556+ consoleLogEntry ( "current" ) ,
557+ ] ) ;
558+ expect ( snapshot . totalEntryCount ) . toBe ( 3 ) ;
559+ expect ( sessionLogsCalls ) . toEqual ( [ ] ) ;
560+ } ) ;
561+
562+ it ( "falls back to the paginated API when a chain log download fails" , async ( ) => {
563+ const updates : unknown [ ] = [ ] ;
564+ service . on ( CloudTaskEvent . Update , ( payload ) => updates . push ( payload ) ) ;
565+
566+ mockNetFetch . mockImplementation ( ( input : string | Request ) => {
567+ const url = typeof input === "string" ? input : input . url ;
568+ if ( url . includes ( "/session_logs/" ) ) {
569+ return Promise . resolve (
570+ createJsonResponse ( [ consoleLogEntry ( "from api" ) ] , 200 , {
571+ "X-Has-More" : "false" ,
572+ } ) ,
573+ ) ;
574+ }
575+ if ( url . startsWith ( "https://storage.example/" ) ) {
576+ return Promise . resolve ( new Response ( "expired" , { status : 403 } ) ) ;
577+ }
578+ return Promise . resolve (
579+ createJsonResponse ( {
580+ id : "run-1" ,
581+ status : "completed" ,
582+ stage : null ,
583+ output : null ,
584+ error_message : null ,
585+ branch : "main" ,
586+ updated_at : "2026-01-01T00:00:00Z" ,
587+ log_urls : [ "https://storage.example/run-1.jsonl?sig=1" ] ,
588+ } ) ,
589+ ) ;
590+ } ) ;
591+
592+ service . watch ( {
593+ taskId : "task-1" ,
594+ runId : "run-1" ,
595+ apiHost : "https://app.example.com" ,
596+ teamId : 2 ,
597+ } ) ;
598+
599+ await waitFor ( ( ) =>
600+ updates . some ( ( u ) => ( u as { kind ?: string } ) . kind === "snapshot" ) ,
601+ ) ;
602+
603+ const snapshot = updates . find (
604+ ( u ) => ( u as { kind ?: string } ) . kind === "snapshot" ,
605+ ) as { newEntries : unknown [ ] } ;
606+ expect ( snapshot . newEntries ) . toEqual ( [ consoleLogEntry ( "from api" ) ] ) ;
607+ } ) ;
608+
609+ it ( "resumes paginated history from the last fetched page after a retry" , async ( ) => {
610+ vi . useFakeTimers ( ) ;
611+ const updates : unknown [ ] = [ ] ;
612+ service . on ( CloudTaskEvent . Update , ( payload ) => updates . push ( payload ) ) ;
613+
614+ const sessionLogsOffsets : string [ ] = [ ] ;
615+ let failPageFetches = true ;
616+ mockNetFetch . mockImplementation ( ( input : string | Request ) => {
617+ const url = typeof input === "string" ? input : input . url ;
618+ if ( url . includes ( "/session_logs/" ) ) {
619+ const offset = new URL ( url ) . searchParams . get ( "offset" ) ?? "" ;
620+ sessionLogsOffsets . push ( offset ) ;
621+ if ( offset === "0" ) {
622+ return Promise . resolve (
623+ createJsonResponse ( [ consoleLogEntry ( "page one" ) ] , 200 , {
624+ "X-Has-More" : "true" ,
625+ } ) ,
626+ ) ;
627+ }
628+ if ( failPageFetches ) {
629+ return Promise . reject ( new Error ( "socket hang up" ) ) ;
630+ }
631+ return Promise . resolve (
632+ createJsonResponse ( [ consoleLogEntry ( "page two" ) ] , 200 , {
633+ "X-Has-More" : "false" ,
634+ } ) ,
635+ ) ;
636+ }
637+ return Promise . resolve (
638+ createJsonResponse ( {
639+ id : "run-1" ,
640+ status : "completed" ,
641+ stage : null ,
642+ output : null ,
643+ error_message : null ,
644+ branch : "main" ,
645+ updated_at : "2026-01-01T00:00:00Z" ,
646+ } ) ,
647+ ) ;
648+ } ) ;
649+
650+ service . watch ( {
651+ taskId : "task-1" ,
652+ runId : "run-1" ,
653+ apiHost : "https://app.example.com" ,
654+ teamId : 2 ,
655+ } ) ;
656+
657+ // Page 0 succeeds; the offset-1 page fails all retry attempts and the
658+ // watcher surfaces a retryable error instead of a snapshot.
659+ await waitFor (
660+ ( ) => updates . some ( ( u ) => ( u as { kind ?: string } ) . kind === "error" ) ,
661+ 30_000 ,
662+ ) ;
663+ expect ( sessionLogsOffsets ) . toEqual ( [ "0" , "1" , "1" , "1" ] ) ;
664+
665+ failPageFetches = false ;
666+ await service . retry ( "task-1" , "run-1" ) ;
667+ await waitFor (
668+ ( ) => updates . some ( ( u ) => ( u as { kind ?: string } ) . kind === "snapshot" ) ,
669+ 30_000 ,
670+ ) ;
671+
672+ // The retry resumed from offset 1 instead of refetching page 0.
673+ expect ( sessionLogsOffsets ) . toEqual ( [ "0" , "1" , "1" , "1" , "1" ] ) ;
674+ const snapshot = updates . find (
675+ ( u ) => ( u as { kind ?: string } ) . kind === "snapshot" ,
676+ ) as { newEntries : unknown [ ] } ;
677+ expect ( snapshot . newEntries ) . toEqual ( [
678+ consoleLogEntry ( "page one" ) ,
679+ consoleLogEntry ( "page two" ) ,
680+ ] ) ;
681+ } ) ;
682+
486683 it ( "reconnects with Last-Event-ID after a stream error" , async ( ) => {
487684 vi . useFakeTimers ( ) ;
488685
0 commit comments