| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "strings" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | // SessionOrderInfo is the lightweight sidecar/mtime ordering record shared by |
| 10 | // session pickers and prompt-history navigation. It intentionally avoids JSONL. |
| 11 | type SessionOrderInfo struct { |
| 12 | Path string |
| 13 | CreatedAt time.Time |
| 14 | LastActivityAt time.Time |
| 15 | ModTime time.Time // compatibility alias for LastActivityAt |
| 16 | Scope string |
| 17 | WorkspaceRoot string |
| 18 | TopicID string |
| 19 | TopicTitle string |
| 20 | CustomTitle string |
| 21 | Recovered bool |
| 22 | VersionKind SessionVersionKind |
| 23 | VersionState SessionVersionState |
| 24 | ParentConversationID string |
| 25 | ParentVersionID string |
| 26 | RecoveryReason string |
| 27 | RecoveryDigest string |
| 28 | ParentID string |
| 29 | RecoveryPreferred bool |
| 30 | // Counts are trusted only when their listing identity matches the transcript. |
| 31 | Turns int |
| 32 | Preview string |
| 33 | SchemaVersion int |
| 34 | // The transcript and listing identities also fence async listing backfills. |
| 35 | Revision int64 |
| 36 | ContentDigest string |
| 37 | ListingRevision int64 |
| 38 | ListingContentDigest string |
| 39 | // Schema-2 sessions mirror their selected head here so listings never |
| 40 | // replay the log; LogSchema is 0 for schema-1 sidecars. |
| 41 | HeadID string |
| 42 | HeadCount int |
| 43 | LogSchema int |
| 44 | } |
| 45 | |
| 46 | // RecoveryPreferenceResolver validates one explicit recovery preference while |
| 47 | // ListSessionOrder is assembling its metadata-only result. |
| 48 | type RecoveryPreferenceResolver func(path string, meta BranchMeta) bool |
| 49 | |
| 50 | // ListingProjectionFresh reports whether Turns/Preview describe this record's |
| 51 | // current transcript generation. Legacy sidecars that predate the content |
| 52 | // ledger remain usable until their first revisioned save. |
| 53 | func (s SessionOrderInfo) ListingProjectionFresh() bool { |
| 54 | return sessionListingProjectionFresh(s.SchemaVersion, s.Turns, s.Revision, s.ListingRevision, s.ContentDigest, s.ListingContentDigest) |
| 55 | } |
| 56 | |
| 57 | func sessionInfoFromOrder(session SessionOrderInfo, preview string, turns int, countsKnown bool) SessionInfo { |
| 58 | return SessionInfo{ |
| 59 | Path: session.Path, |
| 60 | CreatedAt: session.CreatedAt, |
| 61 | LastActivityAt: session.LastActivityAt, |
| 62 | ModTime: session.ModTime, |
| 63 | Preview: preview, |
| 64 | Turns: turns, |
| 65 | CountsKnown: countsKnown, |
| 66 | Scope: session.Scope, |
| 67 | WorkspaceRoot: session.WorkspaceRoot, |
| 68 | TopicID: session.TopicID, |
| 69 | TopicTitle: session.TopicTitle, |
| 70 | CustomTitle: session.CustomTitle, |
| 71 | Recovered: session.Recovered, |
| 72 | VersionKind: session.VersionKind, |
| 73 | VersionState: session.VersionState, |
| 74 | ParentConversationID: session.ParentConversationID, |
| 75 | ParentVersionID: session.ParentVersionID, |
| 76 | RecoveryReason: session.RecoveryReason, |
| 77 | RecoveryDigest: session.RecoveryDigest, |
| 78 | ParentID: session.ParentID, |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func sessionArtifactsHaveContent(path string) bool { |
| 83 | if info, err := os.Stat(path); err == nil && !info.IsDir() && info.Size() > 0 { |
| 84 | return true |
| 85 | } |
| 86 | return sessionEventLogSize(path) > 0 |
| 87 | } |
| 88 | |
| 89 | func sessionListingProjectionFresh(schemaVersion, turns int, revision, listingRevision int64, contentDigest, listingContentDigest string) bool { |
| 90 | if schemaVersion < branchMetaCountsInitialVersion { |
| 91 | return false |
| 92 | } |
| 93 | if turns == 0 && schemaVersion < BranchMetaCountsVersion { |
| 94 | return false |
| 95 | } |
| 96 | contentDigest = strings.TrimSpace(contentDigest) |
| 97 | listingContentDigest = strings.TrimSpace(listingContentDigest) |
| 98 | if revision == 0 && listingRevision == 0 && contentDigest == "" && listingContentDigest == "" { |
| 99 | return true |
| 100 | } |
| 101 | return revision > 0 && listingRevision == revision && contentDigest != "" && listingContentDigest == contentDigest |
| 102 | } |
| 103 | |
| 104 | func stampSessionListingProjection(meta *BranchMeta) { |
| 105 | if meta == nil { |
| 106 | return |
| 107 | } |
| 108 | meta.ListingRevision = meta.Revision |
| 109 | meta.ListingContentDigest = strings.TrimSpace(meta.ContentDigest) |
| 110 | } |
| 111 | |
| 112 | func updateSessionListingCountsIfCurrent(session SessionOrderInfo, preview string, turns int) (string, int, error) { |
| 113 | unlock, err := LockSessionMetaPath(session.Path) |
| 114 | if err != nil { |
| 115 | return preview, turns, err |
| 116 | } |
| 117 | defer unlock() |
| 118 | |
| 119 | current, err := ensureBranchMetaUnlocked(session.Path) |
| 120 | if err != nil { |
| 121 | return preview, turns, err |
| 122 | } |
| 123 | if current.SchemaVersion != session.SchemaVersion || |
| 124 | current.Turns != session.Turns || |
| 125 | current.Preview != session.Preview || |
| 126 | current.Revision != session.Revision || |
| 127 | current.ContentDigest != session.ContentDigest { |
| 128 | return current.Preview, current.Turns, nil |
| 129 | } |
| 130 | if sessionListingProjectionFresh(current.SchemaVersion, current.Turns, current.Revision, current.ListingRevision, current.ContentDigest, current.ListingContentDigest) { |
| 131 | return current.Preview, current.Turns, nil |
| 132 | } |
| 133 | |
| 134 | current.Preview = preview |
| 135 | current.Turns = turns |
| 136 | current.SchemaVersion = BranchMetaCountsVersion |
| 137 | stampSessionListingProjection(¤t) |
| 138 | if err := saveBranchMeta(session.Path, current, false); err != nil { |
| 139 | return preview, turns, err |
| 140 | } |
| 141 | return preview, turns, nil |
| 142 | } |
| 143 | |
| 144 | func previewSessionWithError(path string) (string, int, error) { |
| 145 | msgs, _, _, err := loadSessionMessages(path) |
| 146 | if err != nil { |
| 147 | return "", 0, err |
| 148 | } |
| 149 | preview, turns := SessionPreviewFromMessages(msgs) |
| 150 | return preview, turns, nil |
| 151 | } |
| 152 |