返回 DeepSeek-Reasonix
migrate.go
根目录 / internal / agent / migrate.go
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
993 lines GO