| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/hex" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "io" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "time" |
| 13 | |
| 14 | fileencoding "reasonix/internal/fileutil/encoding" |
| 15 | "reasonix/internal/provider" |
| 16 | ) |
| 17 | |
| 18 | // legacyEvent is the subset of the v0.x typed event stream (<name>.events.jsonl) |
| 19 | // needed to rebuild the conversation: user input, assistant turns (text + tool |
| 20 | // calls), and tool results. All other event types (UI, plan, checkpoint, …) are |
| 21 | // presentation and carry no message state. |
| 22 | type legacyEvent struct { |
| 23 | Type string `json:"type"` |
| 24 | Text string `json:"text"` // user.message |
| 25 | Content string `json:"content"` // model.final |
| 26 | ReasoningContent string `json:"reasoningContent"` // model.final |
| 27 | ToolCalls []legacyToolCall `json:"toolCalls"` // model.final |
| 28 | CallID string `json:"callId"` // tool.result |
| 29 | Output string `json:"output"` // tool.result |
| 30 | } |
| 31 | |
| 32 | type legacyToolCall struct { |
| 33 | ID string `json:"id"` |
| 34 | Function struct { |
| 35 | Name string `json:"name"` |
| 36 | Arguments string `json:"arguments"` |
| 37 | ThoughtSignature string `json:"thought_signature"` |
| 38 | } `json:"function"` |
| 39 | } |
| 40 | |
| 41 | // legacyImportMarker, once present in the v1+ session dir, records that the |
| 42 | // one-time v0.x import has already run — so a session the user deletes after it |
| 43 | // was imported doesn't reappear on the next launch. |
| 44 | const legacyImportMarker = ".legacy-imported" |
| 45 | const legacyEventsHomeImportMarker = ".legacy-imported.v0-events-home" |
| 46 | const legacyEventsConfigImportMarker = ".legacy-imported.v0-events-config" |
| 47 | |
| 48 | // Routed markers are independent of the flat-import ones above: the routed pass |
| 49 | // must run once even for users whose flat import already completed, because it |
| 50 | // re-homes sessions the flat import left in the global dir (#3937). |
| 51 | const legacyRoutedHomeImportMarker = ".legacy-imported.v2-routed" |
| 52 | const legacyRoutedConfigImportMarker = ".legacy-imported.v0-events-config.v2-routed" |
| 53 | |
| 54 | // legacyJsonlPassMarker gates the v3 pass that imports .jsonl files already in |
| 55 | // message format (no .events.jsonl counterpart). It is independent of all |
| 56 | // earlier markers so existing upgraders whose events-only passes completed still |
| 57 | // get their .jsonl-only sessions imported. |
| 58 | const legacyJsonlPassMarker = ".legacy-imported.v3-jsonl" |
| 59 | |
| 60 | // legacyMeta is the v0.x sidecar (<name>.meta.json): the workspace the session |
| 61 | // belonged to and the generated summary used as its display title. |
| 62 | type legacyMeta struct { |
| 63 | Workspace string `json:"workspace"` |
| 64 | Summary string `json:"summary"` |
| 65 | } |
| 66 | |
| 67 | // MigrateLegacySessions imports v0.x event-log sessions (<name>.events.jsonl under |
| 68 | // srcDir) into the v1+ message-log format, routing each session into the |
| 69 | // per-workspace dir its sidecar meta names (via projectDir) so the desktop |
| 70 | // sidebar can see it; sessions without a live workspace land in globalDest. It |
| 71 | // also re-homes sessions a previous flat import left in globalDest. Runs once — |
| 72 | // guarded by a marker in globalDest — and never modifies the legacy files. |
| 73 | // Returns the count imported (including re-homed). |
| 74 | func MigrateLegacySessions(srcDir, globalDest string, projectDir func(workspaceRoot string) string) (int, error) { |
| 75 | return migrateLegacySessions(srcDir, globalDest, legacyRoutedHomeImportMarker, projectDir) |
| 76 | } |
| 77 | |
| 78 | // MigrateLegacySessionsFromConfigDir imports v0.x event-log sessions found in |
| 79 | // the current user config session directory. It uses an independent marker so a |
| 80 | // previous ~/.reasonix import marker cannot hide sessions from a redirected |
| 81 | // config root on Windows/macOS. |
| 82 | func MigrateLegacySessionsFromConfigDir(srcDir, globalDest string, projectDir func(workspaceRoot string) string) (int, error) { |
| 83 | return migrateLegacySessions(srcDir, globalDest, legacyRoutedConfigImportMarker, projectDir) |
| 84 | } |
| 85 | |
| 86 | // MigrateLegacySessionsFromExplicitDir imports sessions from a user-selected |
| 87 | // legacy directory. It uses a source-specific marker so a previous default |
| 88 | // /migrate pass cannot hide later imports from a custom Windows install/data |
| 89 | // directory. |
| 90 | func MigrateLegacySessionsFromExplicitDir(srcDir, globalDest string, projectDir func(workspaceRoot string) string) (int, error) { |
| 91 | marker := explicitLegacyImportMarker(srcDir) |
| 92 | return migrateLegacySessionsWithMarkers(srcDir, globalDest, marker, marker+".jsonl", projectDir) |
| 93 | } |
| 94 | |
| 95 | func explicitLegacyImportMarker(srcDir string) string { |
| 96 | key := strings.TrimSpace(srcDir) |
| 97 | if abs, err := filepath.Abs(key); err == nil { |
| 98 | key = abs |
| 99 | } |
| 100 | sum := sha256.Sum256([]byte(filepath.Clean(key))) |
| 101 | return ".legacy-imported.explicit." + hex.EncodeToString(sum[:8]) |
| 102 | } |
| 103 | |
| 104 | func migrateLegacySessions(srcDir, globalDest, marker string, projectDir func(string) string) (int, error) { |
| 105 | return migrateLegacySessionsWithMarkers(srcDir, globalDest, marker, legacyJsonlPassMarker, projectDir) |
| 106 | } |
| 107 | |
| 108 | func migrateLegacySessionsWithMarkers(srcDir, globalDest, marker, jsonlMarker string, projectDir func(string) string) (int, error) { |
| 109 | if strings.TrimSpace(marker) == "" { |
| 110 | marker = legacyImportMarker |
| 111 | } |
| 112 | if strings.TrimSpace(jsonlMarker) == "" { |
| 113 | jsonlMarker = legacyJsonlPassMarker |
| 114 | } |
| 115 | // Gate on both the routed marker AND the jsonl marker: an existing upgrader |
| 116 | // whose events pass already stamped the routed marker must still reach the |
| 117 | // .jsonl-only / subdir passes below (Pass 1 is idempotent via dest checks). |
| 118 | if importMarkerExists(globalDest, marker) && importMarkerExists(globalDest, jsonlMarker) { |
| 119 | // The one-time full passes already ran for this source. Still run the |
| 120 | // bounded re-home pass: a user who downgrades to a pre-routing build |
| 121 | // (which writes every session to the flat dir) and then upgrades again |
| 122 | // leaves project sessions stranded in the flat dir that the marker would |
| 123 | // otherwise hide forever (#4666). The pass is watermarked by the marker |
| 124 | // mtime so a session the user imported and then deleted is not revived. |
| 125 | return rehomeStrandedSessions(srcDir, globalDest, marker, projectDir) |
| 126 | } |
| 127 | entries, err := os.ReadDir(srcDir) |
| 128 | if err != nil { |
| 129 | return 0, nil |
| 130 | } |
| 131 | |
| 132 | // Build the set of base names that have a .events.jsonl so the .jsonl-only |
| 133 | // pass can skip sessions that will be (or were) handled by event reconstruction. |
| 134 | hasEvents := map[string]bool{} |
| 135 | for _, e := range entries { |
| 136 | name := e.Name() |
| 137 | if !e.IsDir() && strings.HasSuffix(name, ".events.jsonl") && !isNativeSessionEventLog(filepath.Join(srcDir, name)) { |
| 138 | hasEvents[strings.TrimSuffix(name, ".events.jsonl")] = true |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | imported := 0 |
| 143 | hadArtifactFailure := false |
| 144 | |
| 145 | // Pass 1 — event-log sessions (*.events.jsonl). When a same-named .jsonl |
| 146 | // exists in the source with a modification time >= the event log's, prefer |
| 147 | // the .jsonl directly (it is already in the native message format). |
| 148 | for _, e := range entries { |
| 149 | name := e.Name() |
| 150 | if e.IsDir() || !strings.HasSuffix(name, ".events.jsonl") { |
| 151 | continue |
| 152 | } |
| 153 | if isNativeSessionEventLog(filepath.Join(srcDir, name)) { |
| 154 | continue |
| 155 | } |
| 156 | base := strings.TrimSuffix(name, ".events.jsonl") |
| 157 | meta := readLegacyMeta(srcDir, base) |
| 158 | destDir := globalDest |
| 159 | if projectDir != nil && meta.Workspace != "" && dirExists(meta.Workspace) { |
| 160 | if d := projectDir(meta.Workspace); d != "" { |
| 161 | destDir = d |
| 162 | } |
| 163 | } |
| 164 | dest := filepath.Join(destDir, base+".jsonl") |
| 165 | if _, err := os.Stat(dest); err == nil { |
| 166 | continue // already imported, or a v1+ session of the same name |
| 167 | } |
| 168 | eventsInfo, _ := e.Info() |
| 169 | if destDir != globalDest && moveFlatImport(filepath.Join(globalDest, base+".jsonl"), dest, eventsInfo) { |
| 170 | recordImportedTitle(destDir, base, meta.Summary) |
| 171 | imported++ |
| 172 | continue |
| 173 | } |
| 174 | |
| 175 | // If a .jsonl sidecar exists and is >= the event log's mtime, copy it |
| 176 | // directly — the TS version wrote the native format alongside or after |
| 177 | // the event log, so the .jsonl is the canonical record. |
| 178 | jsonlPath := filepath.Join(srcDir, base+".jsonl") |
| 179 | if jsonlInfo, err := os.Stat(jsonlPath); err == nil && isMessageFormat(jsonlPath) { |
| 180 | if eventsInfo == nil || !jsonlInfo.ModTime().Before(eventsInfo.ModTime()) { |
| 181 | if err := transformAndCopyJsonl(jsonlPath, dest); err == nil { |
| 182 | if eventsInfo != nil { |
| 183 | _ = os.Chtimes(dest, eventsInfo.ModTime(), eventsInfo.ModTime()) |
| 184 | } |
| 185 | recordImportedTitle(destDir, base, meta.Summary) |
| 186 | imported++ |
| 187 | continue |
| 188 | } |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | msgs, err := reconstructSession(filepath.Join(srcDir, name)) |
| 193 | if err != nil || len(msgs) == 0 { |
| 194 | continue |
| 195 | } |
| 196 | s := &Session{Messages: msgs} |
| 197 | if err := s.Save(dest); err != nil { |
| 198 | return imported, err |
| 199 | } |
| 200 | if eventsInfo != nil { |
| 201 | _ = os.Chtimes(dest, eventsInfo.ModTime(), eventsInfo.ModTime()) // preserve resume ordering |
| 202 | } |
| 203 | recordImportedTitle(destDir, base, meta.Summary) |
| 204 | imported++ |
| 205 | } |
| 206 | |
| 207 | // Pass 2 — message-format .jsonl files without a .events.jsonl counterpart. |
| 208 | // These are sessions the TS version wrote directly in the v1+ format (ACP, |
| 209 | // desktop, subagent, and later-version chat sessions). The pass is gated by |
| 210 | // its own marker so existing upgraders whose events passes completed still |
| 211 | // get their .jsonl-only sessions imported. |
| 212 | if !importMarkerExists(globalDest, jsonlMarker) { |
| 213 | n, failed := importJsonlSessions(entries, srcDir, globalDest, hasEvents, projectDir) |
| 214 | imported += n |
| 215 | hadArtifactFailure = hadArtifactFailure || failed |
| 216 | |
| 217 | // .jsonl.bak recovery: when the .jsonl was lost but a backup remains. |
| 218 | for _, e := range entries { |
| 219 | name := e.Name() |
| 220 | if e.IsDir() || !strings.HasSuffix(name, ".jsonl.bak") { |
| 221 | continue |
| 222 | } |
| 223 | base := strings.TrimSuffix(name, ".jsonl.bak") |
| 224 | if hasEvents[base] { |
| 225 | continue |
| 226 | } |
| 227 | jsonlName := base + ".jsonl" |
| 228 | if _, err := os.Stat(filepath.Join(srcDir, jsonlName)); err == nil { |
| 229 | continue // .jsonl exists, prefer it |
| 230 | } |
| 231 | meta := readLegacyMeta(srcDir, base) |
| 232 | destDir := globalDest |
| 233 | if projectDir != nil && meta.Workspace != "" && dirExists(meta.Workspace) { |
| 234 | if d := projectDir(meta.Workspace); d != "" { |
| 235 | destDir = d |
| 236 | } |
| 237 | } |
| 238 | dest := filepath.Join(destDir, base+".jsonl") |
| 239 | if _, err := os.Stat(dest); err == nil { |
| 240 | continue |
| 241 | } |
| 242 | bakPath := filepath.Join(srcDir, name) |
| 243 | if !isMessageFormat(bakPath) { |
| 244 | continue |
| 245 | } |
| 246 | srcInfo, _ := e.Info() |
| 247 | if err := transformAndCopyJsonl(bakPath, dest); err != nil { |
| 248 | continue |
| 249 | } |
| 250 | if srcInfo != nil { |
| 251 | _ = os.Chtimes(dest, srcInfo.ModTime(), srcInfo.ModTime()) |
| 252 | } |
| 253 | recordImportedTitle(destDir, base, meta.Summary) |
| 254 | imported++ |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | // Pass 3 — recurse into subdirectories that look like project session dirs |
| 259 | // (e.g. Users_Yuki_git_polytone-audio-engine/ under ~/.reasonix/sessions/). |
| 260 | // The TS version nested project-scoped sessions under a workspace slug. |
| 261 | for _, e := range entries { |
| 262 | if !e.IsDir() { |
| 263 | continue |
| 264 | } |
| 265 | if e.Name() == "subagents" { |
| 266 | continue |
| 267 | } |
| 268 | subDir := filepath.Join(srcDir, e.Name()) |
| 269 | subEntries, err := os.ReadDir(subDir) |
| 270 | if err != nil { |
| 271 | continue |
| 272 | } |
| 273 | hasSessions := false |
| 274 | for _, se := range subEntries { |
| 275 | sn := se.Name() |
| 276 | if !se.IsDir() && (strings.HasSuffix(sn, ".jsonl") || strings.HasSuffix(sn, ".events.jsonl")) { |
| 277 | hasSessions = true |
| 278 | break |
| 279 | } |
| 280 | } |
| 281 | if !hasSessions { |
| 282 | continue |
| 283 | } |
| 284 | n, err := migrateSubDirectory(subDir, globalDest, projectDir) |
| 285 | if err != nil { |
| 286 | continue |
| 287 | } |
| 288 | imported += n |
| 289 | } |
| 290 | |
| 291 | // Also stamp the flat markers so a downgrade to an older build doesn't |
| 292 | // re-run the flat import over routed sessions. |
| 293 | if hadArtifactFailure { |
| 294 | return imported, nil |
| 295 | } |
| 296 | writeImportMarkers(globalDest, marker, legacyImportMarker, legacyEventsHomeImportMarker, legacyEventsConfigImportMarker, jsonlMarker) |
| 297 | return imported, nil |
| 298 | } |
| 299 | |
| 300 | // importJsonlSessions copies .jsonl files that are already in message format |
| 301 | // (no .events.jsonl counterpart) from srcDir into their appropriate destination |
| 302 | // dirs. Returns the count imported and whether a related artifact copy failed. |
| 303 | func importJsonlSessions(entries []os.DirEntry, srcDir, globalDest string, hasEvents map[string]bool, projectDir func(string) string) (int, bool) { |
| 304 | imported := 0 |
| 305 | hadArtifactFailure := false |
| 306 | for _, e := range entries { |
| 307 | name := e.Name() |
| 308 | if e.IsDir() || !strings.HasSuffix(name, ".jsonl") || strings.HasSuffix(name, ".events.jsonl") || strings.HasSuffix(name, ".jsonl.bak") { |
| 309 | continue |
| 310 | } |
| 311 | base := strings.TrimSuffix(name, ".jsonl") |
| 312 | if hasEvents[base] { |
| 313 | continue // handled (or skipped) in the events pass |
| 314 | } |
| 315 | // Legacy subagent transcripts live under the subagents/ tree in the |
| 316 | // current version and are only meaningful when accessed through their |
| 317 | // parent session. Importing them as standalone sessions clutters the |
| 318 | // history panel with partial, out-of-context conversations. |
| 319 | if strings.HasPrefix(base, "subagent-") { |
| 320 | continue |
| 321 | } |
| 322 | jsonlPath := filepath.Join(srcDir, name) |
| 323 | if !isMessageFormat(jsonlPath) { |
| 324 | continue |
| 325 | } |
| 326 | destDir, summary, copyBranchMeta := jsonlSessionDestDir(srcDir, jsonlPath, base, globalDest, projectDir) |
| 327 | dest := filepath.Join(destDir, base+".jsonl") |
| 328 | if _, err := os.Stat(dest); err == nil { |
| 329 | if copyBranchMeta { |
| 330 | if err := copySubagentArtifacts(srcDir, destDir, base); err != nil { |
| 331 | hadArtifactFailure = true |
| 332 | } |
| 333 | } |
| 334 | continue |
| 335 | } |
| 336 | srcInfo, _ := e.Info() |
| 337 | if isNativeSessionEventLog(SessionEventLogPath(jsonlPath)) { |
| 338 | if err := saveNativeSessionCopy(jsonlPath, dest); err != nil { |
| 339 | continue |
| 340 | } |
| 341 | } else if err := transformAndCopyJsonl(jsonlPath, dest); err != nil { |
| 342 | continue |
| 343 | } |
| 344 | if srcInfo != nil { |
| 345 | _ = os.Chtimes(dest, srcInfo.ModTime(), srcInfo.ModTime()) |
| 346 | } |
| 347 | if copyBranchMeta { |
| 348 | copyBranchMetaSidecar(jsonlPath, dest) |
| 349 | if err := copySubagentArtifacts(srcDir, destDir, base); err != nil { |
| 350 | hadArtifactFailure = true |
| 351 | } |
| 352 | } |
| 353 | recordImportedTitle(destDir, base, summary) |
| 354 | imported++ |
| 355 | } |
| 356 | return imported, hadArtifactFailure |
| 357 | } |
| 358 | |
| 359 | func jsonlSessionDestDir(srcDir, srcPath, base, globalDest string, projectDir func(string) string) (string, string, bool) { |
| 360 | if meta, ok, err := LoadBranchMeta(srcPath); err == nil && ok { |
| 361 | summary := strings.TrimSpace(meta.TopicTitle) |
| 362 | scope := meta.DefaultScope() |
| 363 | if projectDir != nil && scope == "project" && meta.WorkspaceRoot != "" && dirExists(meta.WorkspaceRoot) { |
| 364 | if d := projectDir(meta.WorkspaceRoot); d != "" { |
| 365 | return d, summary, true |
| 366 | } |
| 367 | } |
| 368 | // Explicit branch meta is newer than any stale v0.x sidecar. Preserve |
| 369 | // global branch metadata, but do not carry a dead project scope into the |
| 370 | // global directory when its workspace can no longer be resolved. |
| 371 | if meta.Scope != "" { |
| 372 | return globalDest, summary, scope == "global" |
| 373 | } |
| 374 | } |
| 375 | meta := readLegacyMeta(srcDir, base) |
| 376 | destDir := globalDest |
| 377 | if projectDir != nil && meta.Workspace != "" && dirExists(meta.Workspace) { |
| 378 | if d := projectDir(meta.Workspace); d != "" { |
| 379 | destDir = d |
| 380 | } |
| 381 | } |
| 382 | return destDir, meta.Summary, false |
| 383 | } |
| 384 | |
| 385 | // migrateSubDirectory imports sessions from a project-scoped subdirectory |
| 386 | // within the legacy session dir. It walks the subdirectory for .events.jsonl and |
| 387 | // .jsonl files and imports them using the projectDir callback for routing. |
| 388 | func migrateSubDirectory(subDir, globalDest string, projectDir func(string) string) (int, error) { |
| 389 | entries, err := os.ReadDir(subDir) |
| 390 | if err != nil { |
| 391 | return 0, nil |
| 392 | } |
| 393 | hasEvents := map[string]bool{} |
| 394 | for _, e := range entries { |
| 395 | name := e.Name() |
| 396 | if !e.IsDir() && strings.HasSuffix(name, ".events.jsonl") && !isNativeSessionEventLog(filepath.Join(subDir, name)) { |
| 397 | hasEvents[strings.TrimSuffix(name, ".events.jsonl")] = true |
| 398 | } |
| 399 | } |
| 400 | imported := 0 |
| 401 | for _, e := range entries { |
| 402 | name := e.Name() |
| 403 | if e.IsDir() { |
| 404 | continue |
| 405 | } |
| 406 | var base string |
| 407 | var srcPath string |
| 408 | reconstruct := false |
| 409 | switch { |
| 410 | case strings.HasSuffix(name, ".events.jsonl"): |
| 411 | if isNativeSessionEventLog(filepath.Join(subDir, name)) { |
| 412 | continue |
| 413 | } |
| 414 | base = strings.TrimSuffix(name, ".events.jsonl") |
| 415 | srcPath = filepath.Join(subDir, name) |
| 416 | // Prefer .jsonl sidecar if it's newer. |
| 417 | if jsonlPath := filepath.Join(subDir, base+".jsonl"); fileExists(jsonlPath) && isMessageFormat(jsonlPath) { |
| 418 | eventsInfo, _ := e.Info() |
| 419 | if jsonlInfo, err := os.Stat(jsonlPath); err == nil { |
| 420 | if eventsInfo == nil || !jsonlInfo.ModTime().Before(eventsInfo.ModTime()) { |
| 421 | srcPath = jsonlPath |
| 422 | reconstruct = false |
| 423 | } else { |
| 424 | reconstruct = true |
| 425 | } |
| 426 | } else { |
| 427 | reconstruct = true |
| 428 | } |
| 429 | } else { |
| 430 | reconstruct = true |
| 431 | } |
| 432 | case strings.HasSuffix(name, ".jsonl") && !strings.HasSuffix(name, ".events.jsonl") && !strings.HasSuffix(name, ".jsonl.bak"): |
| 433 | base = strings.TrimSuffix(name, ".jsonl") |
| 434 | if hasEvents[base] { |
| 435 | continue // handled by the events branch above |
| 436 | } |
| 437 | srcPath = filepath.Join(subDir, name) |
| 438 | if !isMessageFormat(srcPath) { |
| 439 | continue |
| 440 | } |
| 441 | // reconstruct stays false |
| 442 | default: |
| 443 | continue |
| 444 | } |
| 445 | meta := readLegacyMeta(subDir, base) |
| 446 | destDir := globalDest |
| 447 | if projectDir != nil && meta.Workspace != "" && dirExists(meta.Workspace) { |
| 448 | if d := projectDir(meta.Workspace); d != "" { |
| 449 | destDir = d |
| 450 | } |
| 451 | } |
| 452 | dest := filepath.Join(destDir, base+".jsonl") |
| 453 | if _, err := os.Stat(dest); err == nil { |
| 454 | continue |
| 455 | } |
| 456 | srcInfo, _ := e.Info() |
| 457 | if reconstruct { |
| 458 | msgs, err := reconstructSession(srcPath) |
| 459 | if err != nil || len(msgs) == 0 { |
| 460 | continue |
| 461 | } |
| 462 | s := &Session{Messages: msgs} |
| 463 | if err := s.Save(dest); err != nil { |
| 464 | return imported, err |
| 465 | } |
| 466 | } else if isNativeSessionEventLog(SessionEventLogPath(srcPath)) { |
| 467 | if err := saveNativeSessionCopy(srcPath, dest); err != nil { |
| 468 | continue |
| 469 | } |
| 470 | } else { |
| 471 | if err := transformAndCopyJsonl(srcPath, dest); err != nil { |
| 472 | continue |
| 473 | } |
| 474 | } |
| 475 | if srcInfo != nil { |
| 476 | _ = os.Chtimes(dest, srcInfo.ModTime(), srcInfo.ModTime()) |
| 477 | } |
| 478 | recordImportedTitle(destDir, base, meta.Summary) |
| 479 | imported++ |
| 480 | } |
| 481 | return imported, nil |
| 482 | } |
| 483 | |
| 484 | // isMessageFormat returns true when path's first non-whitespace bytes look like |
| 485 | // a JSON object with a "role" key — i.e. the v1+ message format — as opposed to |
| 486 | // the legacy event-log format whose first key is "id". |
| 487 | func isMessageFormat(path string) bool { |
| 488 | f, err := os.Open(path) |
| 489 | if err != nil { |
| 490 | return false |
| 491 | } |
| 492 | defer f.Close() |
| 493 | var buf [64]byte |
| 494 | n, _ := f.Read(buf[:]) |
| 495 | s := strings.TrimLeft(string(buf[:n]), " \t\r\n") |
| 496 | return strings.HasPrefix(s, `{"role":`) |
| 497 | } |
| 498 | |
| 499 | // isNativeSessionEventLog reports whether the file at an .events.jsonl path is |
| 500 | // a native session event log (as opposed to a legacy v0.x event transcript |
| 501 | // that happens to share the suffix). |
| 502 | func isNativeSessionEventLog(path string) bool { |
| 503 | sessionPath := strings.TrimSuffix(path, ".events.jsonl") + ".jsonl" |
| 504 | probe, err := probeSessionEventLog(sessionPath) |
| 505 | return err == nil && probe.native && probe.size > 0 |
| 506 | } |
| 507 | |
| 508 | func saveNativeSessionCopy(src, dst string) error { |
| 509 | session, err := LoadSession(src) |
| 510 | if err != nil { |
| 511 | return err |
| 512 | } |
| 513 | return session.Save(dst) |
| 514 | } |
| 515 | |
| 516 | func fileExists(path string) bool { |
| 517 | _, err := os.Stat(path) |
| 518 | return err == nil |
| 519 | } |
| 520 | |
| 521 | // legacyAssistantMsg is the minimal JSON shape needed to detect and transform |
| 522 | // the legacy nested-function tool-call format into the flat format the Go |
| 523 | // version expects. |
| 524 | type legacyAssistantMsg struct { |
| 525 | Role string `json:"role"` |
| 526 | ToolCalls json.RawMessage `json:"tool_calls"` |
| 527 | } |
| 528 | |
| 529 | // legacyToolCallObj matches the OpenAI-style tool call where name and |
| 530 | // arguments live under a "function" key. |
| 531 | type legacyToolCallObj struct { |
| 532 | ID string `json:"id"` |
| 533 | Function struct { |
| 534 | Name string `json:"name"` |
| 535 | Arguments string `json:"arguments"` |
| 536 | ThoughtSignature string `json:"thought_signature"` |
| 537 | } `json:"function"` |
| 538 | } |
| 539 | |
| 540 | // transformAndCopyJsonl copies src to dst, flattening any legacy nested-function |
| 541 | // tool calls into the flat name/arguments format the v1+ message format uses. |
| 542 | // Non-assistant messages and messages without tool_calls pass through unchanged. |
| 543 | func transformAndCopyJsonl(src, dst string) error { |
| 544 | in, err := os.Open(src) |
| 545 | if err != nil { |
| 546 | return err |
| 547 | } |
| 548 | defer in.Close() |
| 549 | if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { |
| 550 | return err |
| 551 | } |
| 552 | tmp, err := os.CreateTemp(filepath.Dir(dst), ".session.*.tmp") |
| 553 | if err != nil { |
| 554 | return err |
| 555 | } |
| 556 | tmpPath := tmp.Name() |
| 557 | ok := false |
| 558 | defer func() { |
| 559 | if !ok { |
| 560 | os.Remove(tmpPath) |
| 561 | } |
| 562 | }() |
| 563 | enc := json.NewEncoder(tmp) |
| 564 | dec := json.NewDecoder(in) |
| 565 | for { |
| 566 | var raw json.RawMessage |
| 567 | if err := dec.Decode(&raw); err != nil { |
| 568 | if errors.Is(err, io.EOF) { |
| 569 | break |
| 570 | } |
| 571 | // Malformed tail — keep what we've written so far. |
| 572 | break |
| 573 | } |
| 574 | var m legacyAssistantMsg |
| 575 | if err := json.Unmarshal(raw, &m); err != nil || m.Role != "assistant" || len(m.ToolCalls) == 0 { |
| 576 | // Pass through unchanged (user, tool, or assistant without tool calls). |
| 577 | if err := enc.Encode(raw); err != nil { |
| 578 | return err |
| 579 | } |
| 580 | continue |
| 581 | } |
| 582 | // Try legacy nested-function format; if it doesn't match, pass through. |
| 583 | var legacyCalls []legacyToolCallObj |
| 584 | if err := json.Unmarshal(m.ToolCalls, &legacyCalls); err != nil || len(legacyCalls) == 0 { |
| 585 | if err := enc.Encode(raw); err != nil { |
| 586 | return err |
| 587 | } |
| 588 | continue |
| 589 | } |
| 590 | // Build flat-format tool calls. |
| 591 | flatCalls := make([]provider.ToolCall, len(legacyCalls)) |
| 592 | for i, tc := range legacyCalls { |
| 593 | flatCalls[i] = provider.ToolCall{ |
| 594 | ID: tc.ID, |
| 595 | Name: tc.Function.Name, |
| 596 | Arguments: tc.Function.Arguments, |
| 597 | ThoughtSignature: tc.Function.ThoughtSignature, |
| 598 | } |
| 599 | } |
| 600 | // Re-serialize the full message with flat tool_calls. We only modify |
| 601 | // tool_calls; all other fields (content, reasoning_content, etc.) stay |
| 602 | // as-is by round-tripping through a map. |
| 603 | var full map[string]json.RawMessage |
| 604 | if err := json.Unmarshal(raw, &full); err != nil { |
| 605 | if err := enc.Encode(raw); err != nil { |
| 606 | return err |
| 607 | } |
| 608 | continue |
| 609 | } |
| 610 | b, err := json.Marshal(flatCalls) |
| 611 | if err != nil { |
| 612 | if err := enc.Encode(raw); err != nil { |
| 613 | return err |
| 614 | } |
| 615 | continue |
| 616 | } |
| 617 | full["tool_calls"] = b |
| 618 | if err := enc.Encode(full); err != nil { |
| 619 | return err |
| 620 | } |
| 621 | } |
| 622 | if err := tmp.Close(); err != nil { |
| 623 | return err |
| 624 | } |
| 625 | if err := os.Rename(tmpPath, dst); err != nil { |
| 626 | return err |
| 627 | } |
| 628 | ok = true |
| 629 | return nil |
| 630 | } |
| 631 | |
| 632 | // readLegacyMeta loads the v0.x sidecar for a session; missing or corrupt |
| 633 | // sidecars yield the zero value (session routes to the global dir, untitled). |
| 634 | func readLegacyMeta(srcDir, base string) legacyMeta { |
| 635 | var m legacyMeta |
| 636 | b, err := fileencoding.ReadFileUTF8(filepath.Join(srcDir, base+".meta.json")) |
| 637 | if err != nil { |
| 638 | return m |
| 639 | } |
| 640 | _ = json.Unmarshal(b, &m) |
| 641 | m.Workspace = strings.TrimSpace(m.Workspace) |
| 642 | m.Summary = strings.TrimSpace(m.Summary) |
| 643 | return m |
| 644 | } |
| 645 | |
| 646 | func dirExists(path string) bool { |
| 647 | info, err := os.Stat(path) |
| 648 | return err == nil && info.IsDir() |
| 649 | } |
| 650 | |
| 651 | // moveFlatImport re-homes a session the flat import left in the global dir. |
| 652 | // The legacy event log's mtime was stamped onto the imported file, so a match |
| 653 | // identifies it; a same-named native v1+ session never matches and stays put. |
| 654 | func moveFlatImport(oldPath, newPath string, srcInfo os.FileInfo) bool { |
| 655 | if srcInfo == nil { |
| 656 | return false |
| 657 | } |
| 658 | info, err := os.Stat(oldPath) |
| 659 | if err != nil { |
| 660 | return false |
| 661 | } |
| 662 | d := info.ModTime().Sub(srcInfo.ModTime()) |
| 663 | if d < -2*time.Second || d > 2*time.Second { |
| 664 | return false |
| 665 | } |
| 666 | if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil { |
| 667 | return false |
| 668 | } |
| 669 | return os.Rename(oldPath, newPath) == nil |
| 670 | } |
| 671 | |
| 672 | // recordImportedTitle stores the legacy summary as the session's display title |
| 673 | // in the dir's .titles.json — the same map the desktop sidebar reads |
| 674 | // (desktop/sessions.go). Existing titles are never overwritten. |
| 675 | func recordImportedTitle(destDir, base, summary string) { |
| 676 | if summary == "" { |
| 677 | return |
| 678 | } |
| 679 | path := filepath.Join(destDir, ".titles.json") |
| 680 | titles := map[string]string{} |
| 681 | if b, err := fileencoding.ReadFileUTF8(path); err == nil { |
| 682 | _ = json.Unmarshal(b, &titles) |
| 683 | } |
| 684 | key := base + ".jsonl" |
| 685 | if titles[key] != "" { |
| 686 | return |
| 687 | } |
| 688 | titles[key] = summary |
| 689 | b, err := json.MarshalIndent(titles, "", " ") |
| 690 | if err != nil { |
| 691 | return |
| 692 | } |
| 693 | tmp := path + ".tmp" |
| 694 | if err := os.WriteFile(tmp, b, 0o644); err != nil { |
| 695 | return |
| 696 | } |
| 697 | _ = os.Rename(tmp, path) |
| 698 | } |
| 699 | |
| 700 | func importMarkerExists(destDir, marker string) bool { |
| 701 | if strings.TrimSpace(destDir) == "" || strings.TrimSpace(marker) == "" { |
| 702 | return false |
| 703 | } |
| 704 | _, err := os.Stat(filepath.Join(destDir, marker)) |
| 705 | return err == nil |
| 706 | } |
| 707 | |
| 708 | func writeImportMarkers(destDir string, markers ...string) { |
| 709 | if strings.TrimSpace(destDir) == "" { |
| 710 | return |
| 711 | } |
| 712 | if err := os.MkdirAll(destDir, 0o755); err != nil { |
| 713 | return |
| 714 | } |
| 715 | seen := map[string]bool{} |
| 716 | for _, marker := range markers { |
| 717 | marker = strings.TrimSpace(marker) |
| 718 | if marker == "" || seen[marker] { |
| 719 | continue |
| 720 | } |
| 721 | seen[marker] = true |
| 722 | _ = os.WriteFile(filepath.Join(destDir, marker), nil, 0o644) |
| 723 | } |
| 724 | } |
| 725 | |
| 726 | // rehomeStrandedSessions copies project-scoped sessions that were written into |
| 727 | // the flat global dir AFTER the one-time routing pass already ran — the |
| 728 | // signature of a user who downgraded to a pre-routing build (which writes every |
| 729 | // session to the flat dir regardless of workspace) and then upgraded again |
| 730 | // (#4666). Without this, the routing marker hides those sessions from the |
| 731 | // desktop sidebar forever, even though they are sitting in the flat dir. |
| 732 | // |
| 733 | // It is deliberately conservative: |
| 734 | // - Only sessions whose mtime is newer than the marker (the last migration |
| 735 | // watermark) are considered, so a session the user imported and then |
| 736 | // deleted is never resurrected. |
| 737 | // - Only sessions that explicitly name a still-existing workspace — via a v1+ |
| 738 | // branch-meta sidecar with scope=project, or a v0.x .meta.json — are moved. |
| 739 | // Flat global sessions (CLI conversations, the desktop's global tab) carry |
| 740 | // no workspace and are left untouched. |
| 741 | // - It never modifies the source files; the destination is written via the |
| 742 | // same transform-and-copy path the full passes use, and the branch-meta |
| 743 | // sidecar is copied alongside so the sidebar shows the right title/topic. |
| 744 | // |
| 745 | // The marker mtime is advanced to now after a successful scan so the next boot |
| 746 | // does not re-walk the same files. |
| 747 | func rehomeStrandedSessions(srcDir, globalDest, marker string, projectDir func(string) string) (int, error) { |
| 748 | if projectDir == nil { |
| 749 | return 0, nil |
| 750 | } |
| 751 | markerPath := filepath.Join(globalDest, marker) |
| 752 | markerInfo, err := os.Stat(markerPath) |
| 753 | if err != nil { |
| 754 | return 0, nil // no watermark to compare against — full passes own this dir |
| 755 | } |
| 756 | watermark := markerInfo.ModTime() |
| 757 | |
| 758 | entries, err := os.ReadDir(srcDir) |
| 759 | if err != nil { |
| 760 | return 0, nil |
| 761 | } |
| 762 | imported := 0 |
| 763 | hadCopyFailure := false |
| 764 | for _, e := range entries { |
| 765 | name := e.Name() |
| 766 | if e.IsDir() || !strings.HasSuffix(name, ".jsonl") || |
| 767 | strings.HasSuffix(name, ".events.jsonl") || strings.HasSuffix(name, ".jsonl.bak") { |
| 768 | continue |
| 769 | } |
| 770 | base := strings.TrimSuffix(name, ".jsonl") |
| 771 | if strings.HasPrefix(base, "subagent-") { |
| 772 | continue // surfaced only through their parent session |
| 773 | } |
| 774 | info, ierr := e.Info() |
| 775 | if ierr != nil || !info.ModTime().After(watermark) { |
| 776 | continue // written before the last migration — not a downgrade straggler |
| 777 | } |
| 778 | srcPath := filepath.Join(srcDir, name) |
| 779 | if !isMessageFormat(srcPath) { |
| 780 | continue |
| 781 | } |
| 782 | destDir, summary := strandedSessionDestDir(srcDir, srcPath, base, projectDir) |
| 783 | if destDir == "" || sameDirPath(destDir, globalDest) { |
| 784 | continue // global session, or no live workspace — leave it in the flat dir |
| 785 | } |
| 786 | dest := filepath.Join(destDir, name) |
| 787 | if _, err := os.Stat(dest); err == nil { |
| 788 | if err := copySubagentArtifacts(srcDir, destDir, base); err != nil { |
| 789 | hadCopyFailure = true |
| 790 | } |
| 791 | continue // already routed on a previous boot |
| 792 | } |
| 793 | if err := transformAndCopyJsonl(srcPath, dest); err != nil { |
| 794 | hadCopyFailure = true |
| 795 | continue |
| 796 | } |
| 797 | _ = os.Chtimes(dest, info.ModTime(), info.ModTime()) // preserve resume ordering |
| 798 | copyBranchMetaSidecar(srcPath, dest) |
| 799 | if err := copySubagentArtifacts(srcDir, destDir, base); err != nil { |
| 800 | hadCopyFailure = true |
| 801 | } |
| 802 | recordImportedTitle(destDir, base, summary) |
| 803 | imported++ |
| 804 | } |
| 805 | // Advance the watermark so the next boot starts from here, unless a matched |
| 806 | // project session failed to copy and still needs a retry. |
| 807 | if !hadCopyFailure { |
| 808 | now := time.Now() |
| 809 | _ = os.Chtimes(markerPath, now, now) |
| 810 | } |
| 811 | return imported, nil |
| 812 | } |
| 813 | |
| 814 | // strandedSessionDestDir resolves the per-project session dir a flat-dir session |
| 815 | // belongs to, preferring the v1+ branch-meta sidecar and falling back to the |
| 816 | // v0.x .meta.json. It returns "" when the session is global or names a workspace |
| 817 | // that no longer exists on disk. The second return is the display summary, if any. |
| 818 | func strandedSessionDestDir(srcDir, srcPath, base string, projectDir func(string) string) (string, string) { |
| 819 | if meta, ok, err := LoadBranchMeta(srcPath); err == nil && ok { |
| 820 | if meta.DefaultScope() == "project" && meta.WorkspaceRoot != "" && dirExists(meta.WorkspaceRoot) { |
| 821 | if d := projectDir(meta.WorkspaceRoot); d != "" { |
| 822 | return d, strings.TrimSpace(meta.TopicTitle) |
| 823 | } |
| 824 | } |
| 825 | // A branch sidecar that explicitly marks the session global wins over a |
| 826 | // stale v0.x sidecar of the same name. |
| 827 | if meta.Scope != "" { |
| 828 | return "", "" |
| 829 | } |
| 830 | } |
| 831 | legacy := readLegacyMeta(srcDir, base) |
| 832 | if legacy.Workspace != "" && dirExists(legacy.Workspace) { |
| 833 | if d := projectDir(legacy.Workspace); d != "" { |
| 834 | return d, legacy.Summary |
| 835 | } |
| 836 | } |
| 837 | return "", "" |
| 838 | } |
| 839 | |
| 840 | // copyBranchMetaSidecar copies <src>.meta to <dst>.meta when present so the |
| 841 | // desktop sidebar keeps the session's title, topic, and tree position. Best |
| 842 | // effort: a missing or unreadable sidecar just means the session shows with a |
| 843 | // generated title. |
| 844 | func copyBranchMetaSidecar(srcPath, dstPath string) { |
| 845 | b, err := os.ReadFile(BranchMetaPath(srcPath)) |
| 846 | if err != nil { |
| 847 | return |
| 848 | } |
| 849 | dstMeta := BranchMetaPath(dstPath) |
| 850 | if err := os.MkdirAll(filepath.Dir(dstMeta), 0o755); err != nil { |
| 851 | return |
| 852 | } |
| 853 | tmp, err := os.CreateTemp(filepath.Dir(dstMeta), ".branch.*.tmp") |
| 854 | if err != nil { |
| 855 | return |
| 856 | } |
| 857 | tmpPath := tmp.Name() |
| 858 | if _, err := tmp.Write(b); err != nil { |
| 859 | tmp.Close() |
| 860 | os.Remove(tmpPath) |
| 861 | return |
| 862 | } |
| 863 | if err := tmp.Close(); err != nil { |
| 864 | os.Remove(tmpPath) |
| 865 | return |
| 866 | } |
| 867 | if err := os.Rename(tmpPath, dstMeta); err != nil { |
| 868 | os.Remove(tmpPath) |
| 869 | } |
| 870 | } |
| 871 | |
| 872 | func copySubagentArtifacts(srcSessionDir, dstSessionDir, parentSession string) error { |
| 873 | if sameDirPath(srcSessionDir, dstSessionDir) { |
| 874 | return nil |
| 875 | } |
| 876 | artifacts, err := ListSubagentsByParent(srcSessionDir, parentSession) |
| 877 | if err != nil { |
| 878 | return err |
| 879 | } |
| 880 | var errs []error |
| 881 | dstSubagentDir := filepath.Join(dstSessionDir, "subagents") |
| 882 | for _, artifact := range artifacts { |
| 883 | for _, src := range []string{artifact.SessionPath, artifact.MetaPath} { |
| 884 | if err := copyFileIfExists(src, filepath.Join(dstSubagentDir, filepath.Base(src))); err != nil { |
| 885 | errs = append(errs, err) |
| 886 | } |
| 887 | } |
| 888 | } |
| 889 | return errors.Join(errs...) |
| 890 | } |
| 891 | |
| 892 | func copyFileIfExists(src, dst string) error { |
| 893 | info, err := os.Stat(src) |
| 894 | if err != nil { |
| 895 | if os.IsNotExist(err) { |
| 896 | return nil |
| 897 | } |
| 898 | return err |
| 899 | } |
| 900 | if info.IsDir() { |
| 901 | return nil |
| 902 | } |
| 903 | if _, err := os.Stat(dst); err == nil { |
| 904 | return nil |
| 905 | } else if !os.IsNotExist(err) { |
| 906 | return err |
| 907 | } |
| 908 | b, err := os.ReadFile(src) |
| 909 | if err != nil { |
| 910 | return err |
| 911 | } |
| 912 | if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { |
| 913 | return err |
| 914 | } |
| 915 | tmp, err := os.CreateTemp(filepath.Dir(dst), ".subagent.*.tmp") |
| 916 | if err != nil { |
| 917 | return err |
| 918 | } |
| 919 | tmpPath := tmp.Name() |
| 920 | if _, err := tmp.Write(b); err != nil { |
| 921 | tmp.Close() |
| 922 | os.Remove(tmpPath) |
| 923 | return err |
| 924 | } |
| 925 | if err := tmp.Close(); err != nil { |
| 926 | os.Remove(tmpPath) |
| 927 | return err |
| 928 | } |
| 929 | if err := os.Rename(tmpPath, dst); err != nil { |
| 930 | os.Remove(tmpPath) |
| 931 | return err |
| 932 | } |
| 933 | _ = os.Chtimes(dst, info.ModTime(), info.ModTime()) |
| 934 | return nil |
| 935 | } |
| 936 | |
| 937 | // sameDirPath reports whether two directory paths resolve to the same location. |
| 938 | func sameDirPath(a, b string) bool { |
| 939 | ca, cb := filepath.Clean(a), filepath.Clean(b) |
| 940 | if ca == cb { |
| 941 | return true |
| 942 | } |
| 943 | if aa, err := filepath.Abs(ca); err == nil { |
| 944 | if bb, err := filepath.Abs(cb); err == nil { |
| 945 | return aa == bb |
| 946 | } |
| 947 | } |
| 948 | return false |
| 949 | } |
| 950 | |
| 951 | // reconstructSession folds the chronological event stream into the provider |
| 952 | // message sequence. Tool results inherit their tool name from the assistant turn |
| 953 | // that issued the call (the v0.x result event carries only the call id). |
| 954 | func reconstructSession(path string) ([]provider.Message, error) { |
| 955 | f, err := os.Open(path) |
| 956 | if err != nil { |
| 957 | return nil, err |
| 958 | } |
| 959 | defer f.Close() |
| 960 | |
| 961 | var msgs []provider.Message |
| 962 | toolName := map[string]string{} |
| 963 | dec := json.NewDecoder(f) |
| 964 | for { |
| 965 | var e legacyEvent |
| 966 | if err := dec.Decode(&e); err != nil { |
| 967 | if !errors.Is(err, io.EOF) { |
| 968 | return msgs, nil // malformed tail — keep what parsed cleanly |
| 969 | } |
| 970 | break |
| 971 | } |
| 972 | switch e.Type { |
| 973 | case "user.message": |
| 974 | if e.Text != "" { |
| 975 | msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: e.Text}) |
| 976 | } |
| 977 | case "model.final": |
| 978 | m := provider.Message{Role: provider.RoleAssistant, Content: e.Content, ReasoningContent: e.ReasoningContent} |
| 979 | for _, tc := range e.ToolCalls { |
| 980 | m.ToolCalls = append(m.ToolCalls, provider.ToolCall{ |
| 981 | ID: tc.ID, Name: tc.Function.Name, Arguments: tc.Function.Arguments, |
| 982 | ThoughtSignature: tc.Function.ThoughtSignature, |
| 983 | }) |
| 984 | toolName[tc.ID] = tc.Function.Name |
| 985 | } |
| 986 | msgs = append(msgs, m) |
| 987 | case "tool.result": |
| 988 | msgs = append(msgs, provider.Message{Role: provider.RoleTool, ToolCallID: e.CallID, Name: toolName[e.CallID], Content: e.Output}) |
| 989 | } |
| 990 | } |
| 991 | return msgs, nil |
| 992 | } |
| 993 |