@@ -21,6 +21,7 @@ package sync
2121
2222import (
2323 "compress/gzip"
24+ "database/sql"
2425 "encoding/json"
2526 "errors"
2627 "fmt"
@@ -517,8 +518,10 @@ func (sy *Syncer) Import() (*ImportResult, error) {
517518type importMode string
518519
519520const (
520- importModeLocal importMode = "local"
521- importModeCloud importMode = "cloud"
521+ importModeLocal importMode = "local"
522+ importModeCloud importMode = "cloud"
523+ recoveredMissingSessionDirectory = "(recovered-missing-session)"
524+ recoveredMissingSessionStartedAt = "1970-01-01 00:00:00"
522525)
523526
524527func (sy * Syncer ) importEntriesDependencySafe (entries []ChunkEntry , knownChunks map [string ]bool , mode importMode ) (* ImportResult , error ) {
@@ -536,6 +539,14 @@ func (sy *Syncer) importEntriesDependencySafe(entries []ChunkEntry, knownChunks
536539 if len (pendingEntries ) == 0 {
537540 return result , nil
538541 }
542+ availableSessionIDs := map [string ]struct {}{}
543+ if mode == importModeLocal {
544+ var err error
545+ availableSessionIDs , err = sy .sessionIDsAvailableInChunks (entries , knownChunks )
546+ if err != nil {
547+ return nil , err
548+ }
549+ }
539550
540551 lastErrors := map [string ]error {}
541552 for pass := 1 ; len (pendingEntries ) > 0 ; pass ++ {
@@ -562,11 +573,26 @@ func (sy *Syncer) importEntriesDependencySafe(entries []ChunkEntry, knownChunks
562573 }
563574
564575 if err := sy .importMutationChunk (entry .ID , chunk ); err != nil {
576+ if mode == importModeLocal {
577+ recoveredChunk , recovered , recoveryErr := sy .recoverLocalMissingSessionDependencies (chunk , availableSessionIDs )
578+ if recoveryErr != nil {
579+ return nil , recoveryErr
580+ }
581+ if recovered {
582+ if retryErr := sy .importMutationChunk (entry .ID , recoveredChunk ); retryErr == nil {
583+ chunk = recoveredChunk
584+ goto imported
585+ } else {
586+ err = retryErr
587+ }
588+ }
589+ }
565590 lastErrors [entry .ID ] = importDependencyError (chunk , err )
566591 nextPending = append (nextPending , entry )
567592 continue
568593 }
569594
595+ imported:
570596 importResult := estimateMutationImportResult (chunk )
571597 knownChunks [entry .ID ] = true
572598 delete (lastErrors , entry .ID )
@@ -612,6 +638,77 @@ func importDependencyError(chunk ChunkData, err error) error {
612638 return fmt .Errorf ("%w; pending session dependencies: %s" , err , strings .Join (sessions , ", " ))
613639}
614640
641+ func (sy * Syncer ) sessionIDsAvailableInChunks (entries []ChunkEntry , knownChunks map [string ]bool ) (map [string ]struct {}, error ) {
642+ available := make (map [string ]struct {})
643+ for _ , entry := range entries {
644+ chunkJSON , err := sy .transport .ReadChunk (entry .ID )
645+ if err != nil {
646+ if errors .Is (err , ErrChunkNotFound ) || knownChunks [entry .ID ] {
647+ continue
648+ }
649+ return nil , fmt .Errorf ("read chunk %s: %w" , entry .ID , err )
650+ }
651+
652+ var chunk ChunkData
653+ if err := json .Unmarshal (chunkJSON , & chunk ); err != nil {
654+ if knownChunks [entry .ID ] {
655+ continue
656+ }
657+ return nil , fmt .Errorf ("parse chunk %s: %w" , entry .ID , err )
658+ }
659+ for _ , mutation := range buildImportMutations (chunk ) {
660+ if mutation .Entity != store .SyncEntitySession || mutation .Op != store .SyncOpUpsert {
661+ continue
662+ }
663+ sessionID := strings .TrimSpace (mutation .EntityKey )
664+ if sessionID != "" {
665+ available [sessionID ] = struct {}{}
666+ }
667+ }
668+ }
669+ return available , nil
670+ }
671+
672+ func (sy * Syncer ) recoverLocalMissingSessionDependencies (chunk ChunkData , availableSessionIDs map [string ]struct {}) (ChunkData , bool , error ) {
673+ mutations := buildImportMutations (chunk )
674+ projectsBySession := referencedSessionProjectsFromNonSessionUpserts (mutations )
675+ if len (projectsBySession ) == 0 {
676+ return chunk , false , nil
677+ }
678+
679+ missingIDs := make ([]string , 0 , len (projectsBySession ))
680+ for sessionID := range projectsBySession {
681+ if _ , available := availableSessionIDs [sessionID ]; available {
682+ continue
683+ }
684+ _ , err := sy .store .GetSession (sessionID )
685+ if err == nil {
686+ continue
687+ }
688+ if ! errors .Is (err , sql .ErrNoRows ) {
689+ return chunk , false , fmt .Errorf ("check recovered session dependency %s: %w" , sessionID , err )
690+ }
691+ missingIDs = append (missingIDs , sessionID )
692+ }
693+ if len (missingIDs ) == 0 {
694+ return chunk , false , nil
695+ }
696+
697+ sort .Strings (missingIDs )
698+ recovered := chunk
699+ stubSessions := make ([]store.Session , 0 , len (missingIDs ))
700+ for _ , sessionID := range missingIDs {
701+ stubSessions = append (stubSessions , store.Session {
702+ ID : sessionID ,
703+ Project : projectsBySession [sessionID ],
704+ Directory : recoveredMissingSessionDirectory ,
705+ StartedAt : recoveredMissingSessionStartedAt ,
706+ })
707+ }
708+ recovered .Sessions = append (stubSessions , recovered .Sessions ... )
709+ return recovered , true , nil
710+ }
711+
615712func buildImportMutations (chunk ChunkData ) []store.SyncMutation {
616713 if len (chunk .Mutations ) == 0 {
617714 return synthesizeMutationsFromChunk (chunk )
@@ -704,6 +801,39 @@ func referencedSessionIDsFromNonSessionUpserts(mutations []store.SyncMutation) m
704801 return required
705802}
706803
804+ func referencedSessionProjectsFromNonSessionUpserts (mutations []store.SyncMutation ) map [string ]string {
805+ projects := make (map [string ]string )
806+ for _ , mutation := range mutations {
807+ if mutation .Op != store .SyncOpUpsert {
808+ continue
809+ }
810+ switch mutation .Entity {
811+ case store .SyncEntityObservation , store .SyncEntityPrompt :
812+ var payload struct {
813+ SessionID string `json:"session_id"`
814+ Project * string `json:"project"`
815+ }
816+ if err := decodeSyncPayloadForProject ([]byte (mutation .Payload ), & payload ); err != nil {
817+ continue
818+ }
819+ sessionID := strings .TrimSpace (payload .SessionID )
820+ if sessionID == "" {
821+ continue
822+ }
823+ project , _ := store .NormalizeProject (strings .TrimSpace (mutation .Project ))
824+ project = strings .TrimSpace (project )
825+ if project == "" && payload .Project != nil {
826+ project , _ = store .NormalizeProject (strings .TrimSpace (* payload .Project ))
827+ project = strings .TrimSpace (project )
828+ }
829+ if existing := strings .TrimSpace (projects [sessionID ]); existing == "" || (project != "" && project < existing ) {
830+ projects [sessionID ] = project
831+ }
832+ }
833+ }
834+ return projects
835+ }
836+
707837func mutationIdentityKey (mutation store.SyncMutation ) string {
708838 return fmt .Sprintf ("%s:%s" , mutation .Entity , strings .TrimSpace (mutation .EntityKey ))
709839}
0 commit comments