| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "io" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "slices" |
| 13 | "strings" |
| 14 | "time" |
| 15 | |
| 16 | "reasonix/desktop/internal/legacycleanup" |
| 17 | "reasonix/desktop/internal/workspacestate" |
| 18 | "reasonix/internal/agent" |
| 19 | "reasonix/internal/provider" |
| 20 | "reasonix/internal/session" |
| 21 | "reasonix/internal/store" |
| 22 | "reasonix/internal/transcript" |
| 23 | ) |
| 24 | |
| 25 | type legacyCleanupSourceTarget struct { |
| 26 | state workspacestate.State |
| 27 | mapping workspacestate.SourceMapping |
| 28 | ref session.SessionRef |
| 29 | frozen legacycleanup.Candidate |
| 30 | } |
| 31 | |
| 32 | func (a *App) processLegacyCleanupSource(item legacycleanup.Candidate) { |
| 33 | target, classification, reason, ok := a.resolveLegacyCleanupSourceTarget(item) |
| 34 | if !ok { |
| 35 | a.setLegacyCleanupSourceOutcome(item.ID, "", classification, reason) |
| 36 | return |
| 37 | } |
| 38 | if a.reconcileLegacyCleanupArchivedOperation(item, target.mapping.SessionID) { |
| 39 | return |
| 40 | } |
| 41 | target, classification, reason, ok = a.freezeLegacyCleanupSourceTarget(item, target) |
| 42 | if !ok { |
| 43 | a.setLegacyCleanupSourceOutcome(item.ID, target.mapping.SessionID, classification, reason) |
| 44 | return |
| 45 | } |
| 46 | decision := a.classifyLegacyCleanupSession(a.bootContext(), target.ref, target.frozen) |
| 47 | if decision.classification != "empty" { |
| 48 | a.setLegacyCleanupSourceOutcome(item.ID, target.mapping.SessionID, decision.classification, decision.reason) |
| 49 | return |
| 50 | } |
| 51 | if a.legacyCleanupWorker.beforeArchive != nil { |
| 52 | a.legacyCleanupWorker.beforeArchive() |
| 53 | } |
| 54 | a.archiveLegacyCleanupSource(item, target) |
| 55 | } |
| 56 | |
| 57 | func (a *App) resolveLegacyCleanupSourceTarget(item legacycleanup.Candidate) (legacyCleanupSourceTarget, string, string, bool) { |
| 58 | classification, reason, _ := classifyLegacyCleanupSource(item.SourcePath, item.SourceHeadID, item.SourceFingerprint) |
| 59 | if classification != "empty" { |
| 60 | return legacyCleanupSourceTarget{}, classification, reason, false |
| 61 | } |
| 62 | state, err := a.workspaceRegistry().Load(a.bootContext()) |
| 63 | if err != nil { |
| 64 | return legacyCleanupSourceTarget{}, "unknown", "workspace_unavailable", false |
| 65 | } |
| 66 | mapping, ok := state.SourceMappings[desktopSourceKey(item.SourcePath, item.SourceHeadID)] |
| 67 | if !ok { |
| 68 | return legacyCleanupSourceTarget{}, "unknown", "legacy_session_requires_migration", false |
| 69 | } |
| 70 | if !sameDesktopPath(mapping.Path, item.SourcePath) || mapping.HeadID != item.SourceHeadID || mapping.WorkspaceID != item.WorkspaceID { |
| 71 | return legacyCleanupSourceTarget{}, "protected", "migration_identity_changed", false |
| 72 | } |
| 73 | return legacyCleanupSourceTarget{state: state, mapping: mapping}, "", "", true |
| 74 | } |
| 75 | |
| 76 | func (a *App) freezeLegacyCleanupSourceTarget(item legacycleanup.Candidate, target legacyCleanupSourceTarget) (legacyCleanupSourceTarget, string, string, bool) { |
| 77 | status, ok := target.state.SessionStates[target.mapping.SessionID] |
| 78 | if !ok || status.Lifecycle != workspacestate.Active { |
| 79 | return target, "protected", "lifecycle_changed", false |
| 80 | } |
| 81 | target.ref = session.SessionRef{HostID: localDesktopHostID, SessionID: target.mapping.SessionID} |
| 82 | info, err := a.desktopSessionService("").Query().Stat(a.bootContext(), target.ref) |
| 83 | if err != nil || info.MetadataStatus == session.MetadataFailed { |
| 84 | return target, "unknown", "session_metadata_unavailable", false |
| 85 | } |
| 86 | target.frozen = item |
| 87 | if item.SessionID == "" { |
| 88 | target.frozen.SessionID = target.mapping.SessionID |
| 89 | target.frozen.WorkspaceID = target.mapping.WorkspaceID |
| 90 | target.frozen.TitleSequence = info.TitleSequence |
| 91 | target.frozen.EventSequence = info.EventSequence |
| 92 | target.frozen.LifecycleGeneration = status.Generation |
| 93 | } else if item.SessionID != target.mapping.SessionID || item.WorkspaceID != target.mapping.WorkspaceID { |
| 94 | return target, "protected", "migration_binding_changed", false |
| 95 | } |
| 96 | return target, "", "", true |
| 97 | } |
| 98 | |
| 99 | func (a *App) archiveLegacyCleanupSource(item legacycleanup.Candidate, target legacyCleanupSourceTarget) { |
| 100 | releaseRuntime, ok := a.tryLockRuntimeMutation("legacy empty source cleanup") |
| 101 | if !ok { |
| 102 | a.setLegacyCleanupSourceOutcome(item.ID, target.mapping.SessionID, "busy", "runtime_mutation") |
| 103 | return |
| 104 | } |
| 105 | defer releaseRuntime() |
| 106 | sourceGuard, err := acquireSessionRemovalGuard(item.SourcePath) |
| 107 | if err != nil { |
| 108 | classification, reason := "unknown", "legacy_source_lock_failed" |
| 109 | if errors.Is(err, agent.ErrSessionLeaseHeld) { |
| 110 | classification, reason = "busy", "legacy_source_busy" |
| 111 | } |
| 112 | a.setLegacyCleanupSourceOutcome(item.ID, target.mapping.SessionID, classification, reason) |
| 113 | return |
| 114 | } |
| 115 | defer sourceGuard.Release() |
| 116 | verify := func(ctx context.Context, latest workspacestate.State) error { |
| 117 | current, exists := latest.SourceMappings[desktopSourceKey(item.SourcePath, item.SourceHeadID)] |
| 118 | if !exists || current.SessionID != target.mapping.SessionID || current.WorkspaceID != target.mapping.WorkspaceID || |
| 119 | !sameDesktopPath(current.Path, item.SourcePath) || current.HeadID != item.SourceHeadID { |
| 120 | return fmt.Errorf("%w: migration mapping changed", errLegacyCleanupStateChanged) |
| 121 | } |
| 122 | if sourceClass, _, _ := classifyLegacyCleanupSource(item.SourcePath, item.SourceHeadID, item.SourceFingerprint); sourceClass != "empty" { |
| 123 | return fmt.Errorf("%w: legacy source is %s", errLegacyCleanupStateChanged, sourceClass) |
| 124 | } |
| 125 | fresh := a.classifyLegacyCleanupSession(ctx, target.ref, target.frozen) |
| 126 | if fresh.classification != "empty" { |
| 127 | return fmt.Errorf("%w: canonical session is %s", errLegacyCleanupStateChanged, fresh.classification) |
| 128 | } |
| 129 | return nil |
| 130 | } |
| 131 | err = a.archiveSessionRefsWithOperationConditional([]session.SessionRef{target.ref}, item.OperationID, verify) |
| 132 | if err != nil { |
| 133 | classification, reason := legacyCleanupArchiveError(err) |
| 134 | a.setLegacyCleanupSourceOutcome(item.ID, target.mapping.SessionID, classification, reason) |
| 135 | return |
| 136 | } |
| 137 | a.updateLegacyCleanupItem(item.ID, func(next *legacycleanup.Candidate) { |
| 138 | next.SessionID = target.mapping.SessionID |
| 139 | next.Phase, next.Classification, next.Reason, next.ArchivedAt = "archived", "empty", "", time.Now().UTC().UnixMilli() |
| 140 | }) |
| 141 | } |
| 142 | |
| 143 | func legacyCleanupArchiveError(err error) (string, string) { |
| 144 | if errors.Is(err, errTopicHasActiveWork) || errors.Is(err, errTopicArchiveBusy) || errors.Is(err, agent.ErrSessionLeaseHeld) { |
| 145 | return "busy", "runtime_active" |
| 146 | } |
| 147 | if errors.Is(err, errLegacyCleanupStateChanged) || errors.Is(err, workspacestate.ErrMutationConflict) { |
| 148 | return "protected", "state_changed" |
| 149 | } |
| 150 | return "unknown", "archive_failed" |
| 151 | } |
| 152 | |
| 153 | func (a *App) setLegacyCleanupSourceOutcome(id, sessionID, classification, reason string) { |
| 154 | a.updateLegacyCleanupItem(id, func(next *legacycleanup.Candidate) { |
| 155 | if sessionID != "" { |
| 156 | next.SessionID = sessionID |
| 157 | } |
| 158 | next.Phase, next.Classification, next.Reason = classification, classification, reason |
| 159 | }) |
| 160 | } |
| 161 | func (a *App) bindLegacyCleanupMigration(ctx context.Context, path, headID, sessionID, workspaceID string) error { |
| 162 | if a == nil || a.legacyCleanup == nil || strings.TrimSpace(sessionID) == "" { |
| 163 | return nil |
| 164 | } |
| 165 | ref := session.SessionRef{HostID: localDesktopHostID, SessionID: sessionID} |
| 166 | info, err := a.desktopSessionService("").Query().Stat(ctx, ref) |
| 167 | if err != nil { |
| 168 | return err |
| 169 | } |
| 170 | state, err := a.workspaceRegistry().Load(ctx) |
| 171 | if err != nil { |
| 172 | return err |
| 173 | } |
| 174 | status, ok := state.SessionStates[sessionID] |
| 175 | if !ok || status.Lifecycle != workspacestate.Active { |
| 176 | return workspacestate.ErrMutationConflict |
| 177 | } |
| 178 | id := "legacy:" + desktopSourceKey(path, headID) |
| 179 | _, err = a.legacyCleanup.Update(ctx, func(cleanup *legacycleanup.State) error { |
| 180 | item, exists := cleanup.Items[id] |
| 181 | if !exists { |
| 182 | return nil |
| 183 | } |
| 184 | if item.Kind != "legacy" || !sameDesktopPath(item.SourcePath, path) || item.SourceHeadID != headID || item.WorkspaceID != workspaceID { |
| 185 | return errLegacyCleanupStateChanged |
| 186 | } |
| 187 | if item.SessionID != "" && item.SessionID != sessionID { |
| 188 | return errLegacyCleanupStateChanged |
| 189 | } |
| 190 | item.SessionID = sessionID |
| 191 | item.TitleSequence = info.TitleSequence |
| 192 | item.EventSequence = info.EventSequence |
| 193 | item.LifecycleGeneration = status.Generation |
| 194 | cleanup.Items[id] = item |
| 195 | return nil |
| 196 | }) |
| 197 | return err |
| 198 | } |
| 199 | |
| 200 | func (a *App) classifyLegacyCleanupSession(ctx context.Context, ref session.SessionRef, frozen legacycleanup.Candidate) legacyCleanupDecision { |
| 201 | state, decision, ok := a.classifyLegacyCleanupRegistry(ctx, ref, frozen) |
| 202 | if !ok { |
| 203 | return decision |
| 204 | } |
| 205 | if a.legacyCleanupSessionIsOpen(ref.SessionID) { |
| 206 | return legacyCleanupDecision{"busy", "session_open", session.SessionInfo{}, session.Snapshot{}} |
| 207 | } |
| 208 | service := a.desktopSessionService("") |
| 209 | if _, live := service.Runtime(ref); live { |
| 210 | return legacyCleanupDecision{"busy", "runtime_open", session.SessionInfo{}, session.Snapshot{}} |
| 211 | } |
| 212 | return classifyLegacyCleanupCanonicalStorage(ctx, service, state, ref, frozen) |
| 213 | } |
| 214 | |
| 215 | func (a *App) classifyLegacyCleanupRegistry(ctx context.Context, ref session.SessionRef, frozen legacycleanup.Candidate) (workspacestate.State, legacyCleanupDecision, bool) { |
| 216 | state, err := a.workspaceRegistry().Load(ctx) |
| 217 | if err != nil { |
| 218 | return state, legacyCleanupDecision{"unknown", "workspace_unavailable", session.SessionInfo{}, session.Snapshot{}}, false |
| 219 | } |
| 220 | status, ok := state.SessionStates[ref.SessionID] |
| 221 | if !ok || status.Lifecycle != workspacestate.Active || status.Generation != frozen.LifecycleGeneration { |
| 222 | return state, legacyCleanupDecision{"protected", "lifecycle_changed", session.SessionInfo{}, session.Snapshot{}}, false |
| 223 | } |
| 224 | workspace, ok := state.Workspaces[frozen.WorkspaceID] |
| 225 | if !ok || !slices.Contains(workspace.SessionIDs, ref.SessionID) { |
| 226 | return state, legacyCleanupDecision{"protected", "workspace_changed", session.SessionInfo{}, session.Snapshot{}}, false |
| 227 | } |
| 228 | if state.Presentation[ref.SessionID].Pinned { |
| 229 | return state, legacyCleanupDecision{"has_content", "session_pinned", session.SessionInfo{}, session.Snapshot{}}, false |
| 230 | } |
| 231 | for _, pending := range state.PendingCreates { |
| 232 | if pending.SessionID == ref.SessionID || pending.ParentSessionID == ref.SessionID { |
| 233 | return state, legacyCleanupDecision{"protected", "create_or_derivation", session.SessionInfo{}, session.Snapshot{}}, false |
| 234 | } |
| 235 | } |
| 236 | if decision, owned := classifyLegacyCleanupPersistentOwner(state, ref, frozen); owned { |
| 237 | return state, decision, false |
| 238 | } |
| 239 | if decision, valid := classifyLegacyCleanupSourceMappings(state, ref, frozen); !valid { |
| 240 | return state, decision, false |
| 241 | } |
| 242 | ops, err := a.draftStore().PendingOperations(ctx) |
| 243 | if err != nil { |
| 244 | return state, legacyCleanupDecision{"unknown", "draft_state_unavailable", session.SessionInfo{}, session.Snapshot{}}, false |
| 245 | } |
| 246 | for _, op := range ops { |
| 247 | if op.SessionID == ref.SessionID { |
| 248 | return state, legacyCleanupDecision{"protected", "draft_operation", session.SessionInfo{}, session.Snapshot{}}, false |
| 249 | } |
| 250 | } |
| 251 | return state, legacyCleanupDecision{}, true |
| 252 | } |
| 253 | |
| 254 | func classifyLegacyCleanupSourceMappings(state workspacestate.State, ref session.SessionRef, frozen legacycleanup.Candidate) (legacyCleanupDecision, bool) { |
| 255 | for _, source := range frozen.Sources { |
| 256 | mapping, exists := state.SourceMappings[desktopSourceKey(source.Path, source.HeadID)] |
| 257 | if !exists || mapping.SessionID != ref.SessionID || mapping.WorkspaceID != frozen.WorkspaceID || mapping.Format != "legacy" || |
| 258 | !sameDesktopPath(mapping.Path, source.Path) || mapping.HeadID != source.HeadID { |
| 259 | return legacyCleanupDecision{"protected", "legacy_mapping_changed", session.SessionInfo{}, session.Snapshot{}}, false |
| 260 | } |
| 261 | classification, reason, _ := classifyLegacyCleanupSource(source.Path, source.HeadID, source.Fingerprint) |
| 262 | if classification != "empty" { |
| 263 | return legacyCleanupDecision{classification, reason, session.SessionInfo{}, session.Snapshot{}}, false |
| 264 | } |
| 265 | } |
| 266 | return legacyCleanupDecision{}, true |
| 267 | } |
| 268 | |
| 269 | func (a *App) legacyCleanupSessionIsOpen(sessionID string) bool { |
| 270 | a.mu.RLock() |
| 271 | defer a.mu.RUnlock() |
| 272 | for _, tabs := range []map[string]*WorkspaceTab{a.tabs, a.detachedSessions} { |
| 273 | for _, tab := range tabs { |
| 274 | if tab != nil && tab.SessionID == sessionID { |
| 275 | return true |
| 276 | } |
| 277 | } |
| 278 | } |
| 279 | return false |
| 280 | } |
| 281 | |
| 282 | func classifyLegacyCleanupCanonicalStorage(ctx context.Context, service *session.Service, state workspacestate.State, ref session.SessionRef, frozen legacycleanup.Candidate) legacyCleanupDecision { |
| 283 | info, err := service.Query().Stat(ctx, ref) |
| 284 | if err != nil || info.MetadataStatus == session.MetadataFailed { |
| 285 | return legacyCleanupDecision{"unknown", "session_metadata_unavailable", info, session.Snapshot{}} |
| 286 | } |
| 287 | title := state.Presentation[ref.SessionID].Title |
| 288 | if info.TitleSequence > 0 || strings.TrimSpace(info.Title) != "" { |
| 289 | title = info.Title |
| 290 | } |
| 291 | if !isDefaultTopicTitle(title) || info.TitleSequence != frozen.TitleSequence || info.EventSequence != frozen.EventSequence { |
| 292 | return legacyCleanupDecision{"protected", "title_or_content_changed", info, session.Snapshot{}} |
| 293 | } |
| 294 | if info.ParentSessionID != "" || info.Origin == session.SessionOriginFork { |
| 295 | return legacyCleanupDecision{"protected", "derived_session", info, session.Snapshot{}} |
| 296 | } |
| 297 | snapshot, err := service.Query().Snapshot(ctx, ref) |
| 298 | if err != nil || snapshot.PersistenceStatus != session.PersistenceReady { |
| 299 | return legacyCleanupDecision{"unknown", "session_content_unavailable", info, snapshot} |
| 300 | } |
| 301 | if info.ResultSequence > 0 || canonicalProjectionHasUserContent(snapshot.Projection) { |
| 302 | return legacyCleanupDecision{"has_content", "session_content", info, snapshot} |
| 303 | } |
| 304 | if classification, reason := canonicalSessionDurableEvidence(ctx, info); classification != "empty" { |
| 305 | return legacyCleanupDecision{classification, reason, info, snapshot} |
| 306 | } |
| 307 | if classification, reason := classifyLegacyCleanupArtifacts(info.Path); classification != "empty" { |
| 308 | return legacyCleanupDecision{classification, reason, info, snapshot} |
| 309 | } |
| 310 | if classification, reason := classifyCanonicalOwnedDirectories(info.Path); classification != "empty" { |
| 311 | return legacyCleanupDecision{classification, reason, info, snapshot} |
| 312 | } |
| 313 | return legacyCleanupDecision{"empty", "", info, snapshot} |
| 314 | } |
| 315 | |
| 316 | func classifyCanonicalOwnedDirectories(sessionPath string) (string, string) { |
| 317 | for _, owned := range []struct { |
| 318 | name string |
| 319 | reason string |
| 320 | }{ |
| 321 | {name: "attachments", reason: "session_attachment"}, |
| 322 | {name: "assets", reason: "session_asset"}, |
| 323 | } { |
| 324 | nonempty, err := directoryHasDurableEntries(filepath.Join(sessionPath, owned.name), nil) |
| 325 | if err != nil { |
| 326 | return "unknown", owned.reason + "_unavailable" |
| 327 | } |
| 328 | if nonempty { |
| 329 | return "has_content", owned.reason |
| 330 | } |
| 331 | } |
| 332 | return "empty", "" |
| 333 | } |
| 334 | func canonicalProjectionHasUserContent(projection session.Projection) bool { |
| 335 | for _, message := range projection.Messages { |
| 336 | if message.Role == provider.RoleUser || message.Role == provider.RoleAssistant || message.Role == provider.RoleTool { |
| 337 | return true |
| 338 | } |
| 339 | } |
| 340 | if body := strings.TrimSpace(string(projection.GoalState)); body != "" && body != "{}" && body != "null" { |
| 341 | return true |
| 342 | } |
| 343 | return len(projection.Turns) > 0 || projection.TurnID != "" || len(projection.Todos) > 0 || projection.TodoWritten || |
| 344 | len(projection.Interactions) > 0 || len(projection.StartedTools) > 0 || len(projection.ActiveSteps) > 0 || projection.Recovery != nil |
| 345 | } |
| 346 | |
| 347 | type legacyCleanupEvidence struct { |
| 348 | classification string |
| 349 | reason string |
| 350 | } |
| 351 | |
| 352 | func (e *legacyCleanupEvidence) mark(classification, reason string) { |
| 353 | if e.classification == "has_content" || classification == "empty" { |
| 354 | return |
| 355 | } |
| 356 | if classification == "has_content" || e.classification == "" || e.classification == "empty" { |
| 357 | e.classification, e.reason = classification, reason |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | func canonicalSessionDurableEvidence(ctx context.Context, info session.SessionInfo) (string, string) { |
| 362 | evidence := legacyCleanupEvidence{classification: "empty"} |
| 363 | err := session.VisitCommits(ctx, info.Path, func(commit session.Commit) error { |
| 364 | for _, event := range commit.Events { |
| 365 | classification, reason := classifyCanonicalSessionEvent(commit, event) |
| 366 | evidence.mark(classification, reason) |
| 367 | } |
| 368 | return nil |
| 369 | }) |
| 370 | if err != nil { |
| 371 | return "unknown", "session_event_log_unavailable" |
| 372 | } |
| 373 | return evidence.classification, evidence.reason |
| 374 | } |
| 375 | |
| 376 | func classifyCanonicalSessionEvent(commit session.Commit, event session.Event) (string, string) { |
| 377 | switch event.Kind { |
| 378 | case "session/title", "diagnostic": |
| 379 | return "empty", "" |
| 380 | case "session/config": |
| 381 | return classifyCanonicalConfigEvent(commit) |
| 382 | case "message/complete", "message/upsert": |
| 383 | return classifyCanonicalMessageEvent(event.Payload) |
| 384 | case "model/context-replace", "history/replace": |
| 385 | return classifyCanonicalContextEvent(commit, event.Payload) |
| 386 | case "legacy/import": |
| 387 | return classifyCanonicalLegacyImportEvent(event.Payload) |
| 388 | case "submission/accepted", "message/retract", "assistant/attempt", "tool/call", "tool/start", "tool/result", |
| 389 | "turn/start", "turn/end", "step/start", "step/end", "todo/write", "interaction/created", "interaction/resolved", |
| 390 | "plan/state", "goal/state", "compaction", "runtime/recovery": |
| 391 | return "has_content", "execution_event" |
| 392 | default: |
| 393 | return "unknown", "unsupported_session_event" |
| 394 | } |
| 395 | } |
| 396 | |
| 397 | func classifyCanonicalConfigEvent(commit session.Commit) (string, string) { |
| 398 | if commit.OperationID == "session-create" || strings.HasPrefix(commit.OperationID, "legacy-import:") || |
| 399 | strings.HasPrefix(commit.OperationID, "legacy-import-config:") || strings.HasPrefix(commit.OperationID, "prototype-import-config:") { |
| 400 | return "empty", "" |
| 401 | } |
| 402 | if strings.HasPrefix(commit.OperationID, "session-model:") { |
| 403 | return "has_content", "explicit_session_config" |
| 404 | } |
| 405 | return "unknown", "unclassified_session_config" |
| 406 | } |
| 407 | |
| 408 | func classifyCanonicalMessageEvent(payload json.RawMessage) (string, string) { |
| 409 | var body struct { |
| 410 | Message *provider.Message `json:"message"` |
| 411 | } |
| 412 | if err := json.Unmarshal(payload, &body); err != nil || body.Message == nil { |
| 413 | return "unknown", "message_event_unreadable" |
| 414 | } |
| 415 | if isLegacyCleanupContentRole(body.Message.Role) { |
| 416 | return "has_content", "message_event" |
| 417 | } |
| 418 | if body.Message.Role != provider.RoleSystem { |
| 419 | return "unknown", "unknown_message_role" |
| 420 | } |
| 421 | return "empty", "" |
| 422 | } |
| 423 | |
| 424 | func classifyCanonicalContextEvent(commit session.Commit, raw json.RawMessage) (string, string) { |
| 425 | var payload struct { |
| 426 | Messages []provider.Message `json:"messages"` |
| 427 | Reason string `json:"reason"` |
| 428 | } |
| 429 | if err := json.Unmarshal(raw, &payload); err != nil { |
| 430 | return "unknown", "context_event_unreadable" |
| 431 | } |
| 432 | initialization := strings.HasPrefix(commit.OperationID, "legacy-import:") || payload.Reason == "system-prompt-refresh" |
| 433 | if !onlySystemMessages(payload.Messages) || !initialization { |
| 434 | return "has_content", "context_event" |
| 435 | } |
| 436 | return "empty", "" |
| 437 | } |
| 438 | |
| 439 | func classifyCanonicalLegacyImportEvent(raw json.RawMessage) (string, string) { |
| 440 | var payload struct { |
| 441 | Messages []provider.Message `json:"messages"` |
| 442 | Goal json.RawMessage `json:"goal"` |
| 443 | } |
| 444 | if err := json.Unmarshal(raw, &payload); err != nil || payload.Messages == nil { |
| 445 | return "unknown", "legacy_import_unreadable" |
| 446 | } |
| 447 | for _, message := range payload.Messages { |
| 448 | if isLegacyCleanupContentRole(message.Role) { |
| 449 | return "has_content", "legacy_import_message" |
| 450 | } |
| 451 | if message.Role != provider.RoleSystem { |
| 452 | return "unknown", "legacy_import_message_role" |
| 453 | } |
| 454 | } |
| 455 | goal := bytes.TrimSpace(payload.Goal) |
| 456 | if len(goal) > 0 && !bytes.Equal(goal, []byte("null")) && !bytes.Equal(goal, []byte("{}")) { |
| 457 | return "has_content", "legacy_import_goal" |
| 458 | } |
| 459 | return "empty", "" |
| 460 | } |
| 461 | |
| 462 | func onlySystemMessages(messages []provider.Message) bool { |
| 463 | for _, message := range messages { |
| 464 | if message.Role != provider.RoleSystem { |
| 465 | return false |
| 466 | } |
| 467 | } |
| 468 | return true |
| 469 | } |
| 470 | |
| 471 | func isLegacyCleanupContentRole(role provider.Role) bool { |
| 472 | return role == provider.RoleUser || role == provider.RoleAssistant || role == provider.RoleTool |
| 473 | } |
| 474 | func classifyLegacyCleanupSource(path, headID, frozenFingerprint string) (string, string, string) { |
| 475 | currentFingerprint, err := legacyCleanupSourceFingerprint(path) |
| 476 | if err != nil { |
| 477 | return "unknown", "legacy_source_unavailable", "" |
| 478 | } |
| 479 | if frozenFingerprint == "" { |
| 480 | return "unknown", "legacy_source_was_unreadable", currentFingerprint |
| 481 | } |
| 482 | if currentFingerprint != frozenFingerprint { |
| 483 | return "protected", "legacy_source_changed", currentFingerprint |
| 484 | } |
| 485 | if agent.IsCleanupPending(path) { |
| 486 | return "protected", "legacy_cleanup_pending", currentFingerprint |
| 487 | } |
| 488 | var legacySession *agent.Session |
| 489 | if strings.TrimSpace(headID) == "" { |
| 490 | legacySession, err = agent.LoadSession(path) |
| 491 | } else { |
| 492 | legacySession, err = agent.LoadSessionHeadReadOnly(path, headID) |
| 493 | } |
| 494 | if err != nil { |
| 495 | return "unknown", "legacy_transcript_unavailable", currentFingerprint |
| 496 | } |
| 497 | if classification, reason := classifyLegacyEventLog(path); classification != "empty" { |
| 498 | return classification, reason, currentFingerprint |
| 499 | } |
| 500 | for _, message := range legacySession.Snapshot() { |
| 501 | if message.Role == provider.RoleUser || message.Role == provider.RoleAssistant || message.Role == provider.RoleTool { |
| 502 | return "has_content", "legacy_message", currentFingerprint |
| 503 | } |
| 504 | if message.Role != provider.RoleSystem { |
| 505 | return "unknown", "legacy_message_role", currentFingerprint |
| 506 | } |
| 507 | } |
| 508 | if meta, exists, err := agent.LoadBranchMeta(path); err != nil { |
| 509 | return "unknown", "legacy_metadata_unavailable", currentFingerprint |
| 510 | } else if exists { |
| 511 | if meta.ParentID != "" || meta.ParentConversationID != "" || meta.ParentVersionID != "" || meta.Recovered || |
| 512 | meta.EffectiveVersionKind() != agent.VersionNormal || meta.InFlightTurn != nil { |
| 513 | return "protected", "legacy_derivation_or_recovery", currentFingerprint |
| 514 | } |
| 515 | if strings.TrimSpace(meta.Goal) != "" { |
| 516 | return "has_content", "legacy_goal", currentFingerprint |
| 517 | } |
| 518 | if meta.Model != "" || meta.ModelIdentity != "" || meta.TokenMode != "" || meta.AgentPreset != "" || |
| 519 | meta.QualityFloor != "" || meta.Mode != "" || meta.ToolApprovalMode != "" { |
| 520 | return "unknown", "legacy_explicit_configuration", currentFingerprint |
| 521 | } |
| 522 | } |
| 523 | if classification, reason := classifyLegacyCleanupArtifacts(path); classification != "empty" { |
| 524 | return classification, reason, currentFingerprint |
| 525 | } |
| 526 | return "empty", "", currentFingerprint |
| 527 | } |
| 528 | |
| 529 | func classifyLegacyEventLog(path string) (string, string) { |
| 530 | file, err := os.Open(store.SessionEventLog(path)) |
| 531 | if os.IsNotExist(err) { |
| 532 | return "empty", "" |
| 533 | } |
| 534 | if err != nil { |
| 535 | return "unknown", "event_log_unavailable" |
| 536 | } |
| 537 | defer file.Close() |
| 538 | info, err := file.Stat() |
| 539 | if err != nil || info.IsDir() { |
| 540 | return "unknown", "event_log_unavailable" |
| 541 | } |
| 542 | if info.Size() == 0 { |
| 543 | return "empty", "" |
| 544 | } |
| 545 | var header struct { |
| 546 | SchemaVersion int `json:"schema_version"` |
| 547 | Type string `json:"type"` |
| 548 | } |
| 549 | if err := json.NewDecoder(io.LimitReader(file, 1<<20)).Decode(&header); err != nil || header.SchemaVersion < 1 || header.SchemaVersion > 2 || strings.TrimSpace(header.Type) == "" { |
| 550 | return "unknown", "event_log_unreadable" |
| 551 | } |
| 552 | // agent.LoadSession above already replayed and validated supported native |
| 553 | // records. The log itself is not additional content beyond that projection. |
| 554 | return "empty", "" |
| 555 | } |
| 556 | |
| 557 | func classifyLegacyCleanupArtifacts(path string) (string, string) { |
| 558 | if state, err := loadPinnedContextState(path); err != nil { |
| 559 | return "unknown", "pinned_context_unavailable" |
| 560 | } else if len(state.Files) > 0 { |
| 561 | return "has_content", "pinned_context" |
| 562 | } |
| 563 | if classification, reason := classifyLegacyJSONSidecar(store.SessionGoalState(path), "goal"); classification != "empty" { |
| 564 | return classification, reason |
| 565 | } |
| 566 | for _, target := range []struct { |
| 567 | path string |
| 568 | reason string |
| 569 | }{ |
| 570 | {store.SessionRecoveryState(path), "recovery_state"}, |
| 571 | {store.SessionTurnEventLog(path), "turn_ledger"}, |
| 572 | {store.SessionTurnEventLogDamaged(path), "damaged_turn_ledger"}, |
| 573 | {store.SessionEventLogDamaged(path), "damaged_event_log"}, |
| 574 | {store.SessionConflictLog(path), "conflict_log"}, |
| 575 | {sessionTelemetryPath(path), "session_telemetry"}, |
| 576 | } { |
| 577 | info, err := os.Stat(target.path) |
| 578 | if os.IsNotExist(err) { |
| 579 | continue |
| 580 | } |
| 581 | if err != nil || info.IsDir() { |
| 582 | return "unknown", target.reason + "_unavailable" |
| 583 | } |
| 584 | if info.Size() > 0 { |
| 585 | return "has_content", target.reason |
| 586 | } |
| 587 | } |
| 588 | if info, err := os.Stat(store.SessionEventLogRotating(path)); err == nil { |
| 589 | if info.IsDir() || info.Size() > 0 { |
| 590 | return "unknown", "rotating_event_log_requires_recovery" |
| 591 | } |
| 592 | } else if !os.IsNotExist(err) { |
| 593 | return "unknown", "rotating_event_log_unavailable" |
| 594 | } |
| 595 | if classification, reason := classifyTranscriptCheckpoint(path); classification != "empty" { |
| 596 | return classification, reason |
| 597 | } |
| 598 | if classification, reason := classifyContextCheckpoint(path); classification != "empty" { |
| 599 | return classification, reason |
| 600 | } |
| 601 | for _, target := range []struct { |
| 602 | path string |
| 603 | reason string |
| 604 | }{ |
| 605 | {store.SessionCheckpointDir(path), "checkpoint"}, |
| 606 | {store.SessionJobsDir(path), "background_job"}, |
| 607 | } { |
| 608 | nonempty, err := directoryHasDurableEntries(target.path, nil) |
| 609 | if err != nil { |
| 610 | return "unknown", target.reason + "_unavailable" |
| 611 | } |
| 612 | if nonempty { |
| 613 | return "has_content", target.reason |
| 614 | } |
| 615 | } |
| 616 | if classification, reason := classifyLegacyInbox(path); classification != "empty" { |
| 617 | return classification, reason |
| 618 | } |
| 619 | if subagents, err := agent.ListSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil { |
| 620 | return "unknown", "subagent_state_unavailable" |
| 621 | } else if len(subagents) > 0 { |
| 622 | return "has_content", "subagent_state" |
| 623 | } |
| 624 | return "empty", "" |
| 625 | } |
| 626 | |
| 627 | func classifyTranscriptCheckpoint(sessionPath string) (string, string) { |
| 628 | path := store.SessionTranscriptProjection(sessionPath) |
| 629 | body, err := os.ReadFile(path) |
| 630 | if os.IsNotExist(err) { |
| 631 | return "empty", "" |
| 632 | } |
| 633 | if err != nil { |
| 634 | return "unknown", "transcript_projection_unavailable" |
| 635 | } |
| 636 | var raw map[string]json.RawMessage |
| 637 | var checkpoint transcript.Checkpoint |
| 638 | if json.Unmarshal(body, &raw) != nil || json.Unmarshal(body, &checkpoint) != nil || checkpoint.Version != transcript.ProtocolVersion { |
| 639 | return "unknown", "transcript_projection_unreadable" |
| 640 | } |
| 641 | allowed := map[string]bool{ |
| 642 | "version": true, "identity": true, "coveredThroughSeq": true, "transcriptDigest": true, |
| 643 | "providerCount": true, "records": true, "runtime": true, "activeAttempts": true, "completion": true, |
| 644 | } |
| 645 | for field := range raw { |
| 646 | if !allowed[field] { |
| 647 | return "unknown", "transcript_projection_requires_verification" |
| 648 | } |
| 649 | } |
| 650 | runtime := checkpoint.Runtime |
| 651 | if len(checkpoint.Records) > 0 || len(checkpoint.ActiveAttempts) > 0 || checkpoint.Completion != nil || |
| 652 | runtime.FinalMessageID != "" || runtime.DurationMs != 0 || runtime.SamplingCount != 0 || runtime.ToolCount != 0 || |
| 653 | runtime.TurnID != "" || runtime.SubmissionID != "" || runtime.Status != "" || runtime.Phase != "" || runtime.StartedAt != 0 || |
| 654 | len(runtime.PendingEvents) > 0 || runtime.CompletionSummary != nil || runtime.TurnUsage != nil { |
| 655 | return "has_content", "transcript_projection" |
| 656 | } |
| 657 | return "empty", "" |
| 658 | } |
| 659 | |
| 660 | func classifyContextCheckpoint(sessionPath string) (string, string) { |
| 661 | path := store.SessionContext(sessionPath) |
| 662 | body, err := os.ReadFile(path) |
| 663 | if os.IsNotExist(err) { |
| 664 | return "empty", "" |
| 665 | } |
| 666 | if err != nil { |
| 667 | return "unknown", "session_context_unavailable" |
| 668 | } |
| 669 | var raw map[string]json.RawMessage |
| 670 | if json.Unmarshal(body, &raw) != nil { |
| 671 | return "unknown", "session_context_unreadable" |
| 672 | } |
| 673 | allowed := map[string]bool{ |
| 674 | "schema_version": true, "transcript_version": true, "projection": true, "prompt_cache_key": true, |
| 675 | "last_cache_state": true, "last_trigger": true, "last_mode": true, "last_source_tokens": true, |
| 676 | "last_result_tokens": true, "last_compaction_cost": true, "generation": true, "last_receipt": true, |
| 677 | "blocked_input_hash": true, "blocked_reason": true, "native_context_editing_accepted": true, |
| 678 | "context_editing_fallback_local": true, "updated_at": true, |
| 679 | } |
| 680 | for field := range raw { |
| 681 | if !allowed[field] { |
| 682 | return "unknown", "session_context_requires_verification" |
| 683 | } |
| 684 | } |
| 685 | state, ok, err := agent.LoadCompactionState(sessionPath) |
| 686 | if err != nil || !ok { |
| 687 | return "unknown", "session_context_unreadable" |
| 688 | } |
| 689 | if compactionStateHasContent(state) { |
| 690 | return "has_content", "session_context" |
| 691 | } |
| 692 | return "empty", "" |
| 693 | } |
| 694 | |
| 695 | func compactionStateHasContent(state agent.CompactionState) bool { |
| 696 | return contextProjectionHasContent(state.Projection) || state.TranscriptVersion != 0 || |
| 697 | state.PromptCacheKey != "" || state.LastCacheState != "" || state.LastTrigger != "" || state.LastMode != "" || |
| 698 | state.LastSourceTokens != 0 || state.LastResultTokens != 0 || state.LastCompactionCost != 0 || state.Generation != 0 || |
| 699 | state.LastReceipt != nil || state.BlockedInputHash != "" || state.BlockedReason != "" || |
| 700 | state.NativeContextEditingAccepted || state.ContextEditingFallbackLocal |
| 701 | } |
| 702 | |
| 703 | func contextProjectionHasContent(projection agent.ContextProjection) bool { |
| 704 | return projection.TranscriptVersion != 0 || projection.ProjectionVersion != 0 || projection.CoveredCount != 0 || |
| 705 | projection.CoveredPrefixHash != "" || projection.PinnedContextHash != "" || projection.SummaryHash != "" || |
| 706 | projection.SourceTokens != 0 || projection.ProjectionTokens != 0 || projection.ViewInputHash != "" || |
| 707 | projection.ViewOutputHash != "" || len(projection.Messages) > 0 |
| 708 | } |
| 709 | |
| 710 | func classifyLegacyJSONSidecar(path, field string) (string, string) { |
| 711 | body, err := os.ReadFile(path) |
| 712 | if os.IsNotExist(err) { |
| 713 | return "empty", "" |
| 714 | } |
| 715 | if err != nil { |
| 716 | return "unknown", field + "_state_unavailable" |
| 717 | } |
| 718 | if len(bytes.TrimSpace(body)) == 0 { |
| 719 | return "unknown", field + "_state_unreadable" |
| 720 | } |
| 721 | var object map[string]json.RawMessage |
| 722 | if err := json.Unmarshal(body, &object); err != nil { |
| 723 | return "unknown", field + "_state_unreadable" |
| 724 | } |
| 725 | if raw, ok := object[field]; ok { |
| 726 | var text string |
| 727 | if err := json.Unmarshal(raw, &text); err != nil { |
| 728 | return "unknown", field + "_state_unreadable" |
| 729 | } |
| 730 | if strings.TrimSpace(text) != "" { |
| 731 | return "has_content", field + "_state" |
| 732 | } |
| 733 | } |
| 734 | return "empty", "" |
| 735 | } |
| 736 | |
| 737 | func directoryHasDurableEntries(path string, ignored map[string]bool) (bool, error) { |
| 738 | entries, err := os.ReadDir(path) |
| 739 | if os.IsNotExist(err) { |
| 740 | return false, nil |
| 741 | } |
| 742 | if err != nil { |
| 743 | return false, err |
| 744 | } |
| 745 | for _, entry := range entries { |
| 746 | if ignored != nil && ignored[entry.Name()] { |
| 747 | continue |
| 748 | } |
| 749 | return true, nil |
| 750 | } |
| 751 | return false, nil |
| 752 | } |
| 753 | |
| 754 | func classifyLegacyInbox(path string) (string, string) { |
| 755 | dir := store.SessionInboxDir(path) |
| 756 | manifestPath := filepath.Join(dir, "manifest.json") |
| 757 | body, err := os.ReadFile(manifestPath) |
| 758 | if os.IsNotExist(err) { |
| 759 | nonempty, readErr := directoryHasDurableEntries(dir, map[string]bool{"transaction.lock": true}) |
| 760 | if readErr != nil { |
| 761 | return "unknown", "inbox_unavailable" |
| 762 | } |
| 763 | if nonempty { |
| 764 | return "unknown", "inbox_manifest_missing" |
| 765 | } |
| 766 | return "empty", "" |
| 767 | } |
| 768 | if err != nil { |
| 769 | return "unknown", "inbox_unavailable" |
| 770 | } |
| 771 | var manifest struct { |
| 772 | SchemaVersion int `json:"schemaVersion"` |
| 773 | Paused bool `json:"paused"` |
| 774 | Recovered bool `json:"recovered"` |
| 775 | RecoveredCount int `json:"recoveredCount"` |
| 776 | Items []json.RawMessage `json:"items"` |
| 777 | Idempotency map[string]string `json:"idempotency"` |
| 778 | IdempotencyHashes map[string]string `json:"idempotencyHashes"` |
| 779 | Receipts map[string]json.RawMessage `json:"receipts"` |
| 780 | } |
| 781 | if err := json.Unmarshal(body, &manifest); err != nil || manifest.SchemaVersion < 1 || manifest.SchemaVersion > 2 { |
| 782 | return "unknown", "inbox_unreadable" |
| 783 | } |
| 784 | if len(manifest.Items) > 0 || manifest.Paused || manifest.Recovered || manifest.RecoveredCount > 0 || |
| 785 | len(manifest.Idempotency) > 0 || len(manifest.IdempotencyHashes) > 0 || len(manifest.Receipts) > 0 { |
| 786 | return "has_content", "inbox_items" |
| 787 | } |
| 788 | nonempty, err := directoryHasDurableEntries(dir, map[string]bool{"transaction.lock": true, "manifest.json": true}) |
| 789 | if err != nil { |
| 790 | return "unknown", "inbox_unavailable" |
| 791 | } |
| 792 | if nonempty { |
| 793 | return "unknown", "inbox_orphan_artifacts" |
| 794 | } |
| 795 | return "empty", "" |
| 796 | } |
| 797 |