返回 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.SaveIfAbsent(dest); err != nil {
198 if errors.Is(err, os.ErrExist) {
199 continue
200 }
201 return imported, err
202 }
203 if eventsInfo != nil {
204 _ = os.Chtimes(dest, eventsInfo.ModTime(), eventsInfo.ModTime()) // preserve resume ordering
205 }
206 recordImportedTitle(destDir, base, meta.Summary)
207 imported++
208 }
209
210 // Pass 2 — message-format .jsonl files without a .events.jsonl counterpart.
211 // These are sessions the TS version wrote directly in the v1+ format (ACP,
212 // desktop, subagent, and later-version chat sessions). The pass is gated by
213 // its own marker so existing upgraders whose events passes completed still
214 // get their .jsonl-only sessions imported.
215 if !importMarkerExists(globalDest, jsonlMarker) {
216 n, failed := importJsonlSessions(entries, srcDir, globalDest, hasEvents, projectDir)
217 imported += n
218 hadArtifactFailure = hadArtifactFailure || failed
219
220 // .jsonl.bak recovery: when the .jsonl was lost but a backup remains.
221 for _, e := range entries {
222 name := e.Name()
223 if e.IsDir() || !strings.HasSuffix(name, ".jsonl.bak") {
224 continue
225 }
226 base := strings.TrimSuffix(name, ".jsonl.bak")
227 if hasEvents[base] {
228 continue
229 }
230 jsonlName := base + ".jsonl"
231 if _, err := os.Stat(filepath.Join(srcDir, jsonlName)); err == nil {
232 continue // .jsonl exists, prefer it
233 }
234 meta := readLegacyMeta(srcDir, base)
235 destDir := globalDest
236 if projectDir != nil && meta.Workspace != "" && dirExists(meta.Workspace) {
237 if d := projectDir(meta.Workspace); d != "" {
238 destDir = d
239 }
240 }
241 dest := filepath.Join(destDir, base+".jsonl")
242 if _, err := os.Stat(dest); err == nil {
243 continue
244 }
245 bakPath := filepath.Join(srcDir, name)
246 if !isMessageFormat(bakPath) {
247 continue
248 }
249 srcInfo, _ := e.Info()
250 if err := transformAndCopyJsonl(bakPath, dest); err != nil {
251 continue
252 }
253 if srcInfo != nil {
254 _ = os.Chtimes(dest, srcInfo.ModTime(), srcInfo.ModTime())
255 }
256 recordImportedTitle(destDir, base, meta.Summary)
257 imported++
258 }
259 }
260
261 // Pass 3 — recurse into subdirectories that look like project session dirs
262 // (e.g. Users_Yuki_git_polytone-audio-engine/ under ~/.reasonix/sessions/).
263 // The TS version nested project-scoped sessions under a workspace slug.
264 for _, e := range entries {
265 if !e.IsDir() {
266 continue
267 }
268 if e.Name() == "subagents" {
269 continue
270 }
271 subDir := filepath.Join(srcDir, e.Name())
272 subEntries, err := os.ReadDir(subDir)
273 if err != nil {
274 continue
275 }
276 hasSessions := false
277 for _, se := range subEntries {
278 sn := se.Name()
279 if !se.IsDir() && (strings.HasSuffix(sn, ".jsonl") || strings.HasSuffix(sn, ".events.jsonl")) {
280 hasSessions = true
281 break
282 }
283 }
284 if !hasSessions {
285 continue
286 }
287 n, err := migrateSubDirectory(subDir, globalDest, projectDir)
288 if err != nil {
289 continue
290 }
291 imported += n
292 }
293
294 // Also stamp the flat markers so a downgrade to an older build doesn't
295 // re-run the flat import over routed sessions.
296 if hadArtifactFailure {
297 return imported, nil
298 }
299 writeImportMarkers(globalDest, marker, legacyImportMarker, legacyEventsHomeImportMarker, legacyEventsConfigImportMarker, jsonlMarker)
300 return imported, nil
301 }
302
303 // importJsonlSessions copies .jsonl files that are already in message format
304 // (no .events.jsonl counterpart) from srcDir into their appropriate destination
305 // dirs. Returns the count imported and whether a related artifact copy failed.
306 func importJsonlSessions(entries []os.DirEntry, srcDir, globalDest string, hasEvents map[string]bool, projectDir func(string) string) (int, bool) {
307 imported := 0
308 hadArtifactFailure := false
309 for _, e := range entries {
310 name := e.Name()
311 if e.IsDir() || !strings.HasSuffix(name, ".jsonl") || strings.HasSuffix(name, ".events.jsonl") || strings.HasSuffix(name, ".jsonl.bak") {
312 continue
313 }
314 base := strings.TrimSuffix(name, ".jsonl")
315 if hasEvents[base] {
316 continue // handled (or skipped) in the events pass
317 }
318 // Legacy subagent transcripts live under the subagents/ tree in the
319 // current version and are only meaningful when accessed through their
320 // parent session. Importing them as standalone sessions clutters the
321 // history panel with partial, out-of-context conversations.
322 if strings.HasPrefix(base, "subagent-") {
323 continue
324 }
325 jsonlPath := filepath.Join(srcDir, name)
326 if !isMessageFormat(jsonlPath) {
327 continue
328 }
329 destDir, summary, copyBranchMeta := jsonlSessionDestDir(srcDir, jsonlPath, base, globalDest, projectDir)
330 dest := filepath.Join(destDir, base+".jsonl")
331 if _, err := os.Stat(dest); err == nil {
332 if copyBranchMeta {
333 if err := copySubagentArtifacts(srcDir, destDir, base); err != nil {
334 hadArtifactFailure = true
335 }
336 }
337 continue
338 }
339 srcInfo, _ := e.Info()
340 if isNativeSessionEventLog(SessionEventLogPath(jsonlPath)) {
341 if err := saveNativeSessionCopy(jsonlPath, dest); err != nil {
342 continue
343 }
344 } else if err := transformAndCopyJsonl(jsonlPath, dest); err != nil {
345 continue
346 }
347 if srcInfo != nil {
348 _ = os.Chtimes(dest, srcInfo.ModTime(), srcInfo.ModTime())
349 }
350 if copyBranchMeta {
351 copyBranchMetaSidecar(jsonlPath, dest)
352 if err := copySubagentArtifacts(srcDir, destDir, base); err != nil {
353 hadArtifactFailure = true
354 }
355 }
356 recordImportedTitle(destDir, base, summary)
357 imported++
358 }
359 return imported, hadArtifactFailure
360 }
361
362 func jsonlSessionDestDir(srcDir, srcPath, base, globalDest string, projectDir func(string) string) (string, string, bool) {
363 if meta, ok, err := LoadBranchMeta(srcPath); err == nil && ok {
364 summary := strings.TrimSpace(meta.TopicTitle)
365 scope := meta.DefaultScope()
366 if projectDir != nil && scope == "project" && meta.WorkspaceRoot != "" && dirExists(meta.WorkspaceRoot) {
367 if d := projectDir(meta.WorkspaceRoot); d != "" {
368 return d, summary, true
369 }
370 }
371 // Explicit branch meta is newer than any stale v0.x sidecar. Preserve
372 // global branch metadata, but do not carry a dead project scope into the
373 // global directory when its workspace can no longer be resolved.
374 if meta.Scope != "" {
375 return globalDest, summary, scope == "global"
376 }
377 }
378 meta := readLegacyMeta(srcDir, base)
379 destDir := globalDest
380 if projectDir != nil && meta.Workspace != "" && dirExists(meta.Workspace) {
381 if d := projectDir(meta.Workspace); d != "" {
382 destDir = d
383 }
384 }
385 return destDir, meta.Summary, false
386 }
387
388 // migrateSubDirectory imports sessions from a project-scoped subdirectory
389 // within the legacy session dir. It walks the subdirectory for .events.jsonl and
390 // .jsonl files and imports them using the projectDir callback for routing.
391 func migrateSubDirectory(subDir, globalDest string, projectDir func(string) string) (int, error) {
392 entries, err := os.ReadDir(subDir)
393 if err != nil {
394 return 0, nil
395 }
396 hasEvents := map[string]bool{}
397 for _, e := range entries {
398 name := e.Name()
399 if !e.IsDir() && strings.HasSuffix(name, ".events.jsonl") && !isNativeSessionEventLog(filepath.Join(subDir, name)) {
400 hasEvents[strings.TrimSuffix(name, ".events.jsonl")] = true
401 }
402 }
403 imported := 0
404 for _, e := range entries {
405 name := e.Name()
406 if e.IsDir() {
407 continue
408 }
409 var base string
410 var srcPath string
411 reconstruct := false
412 switch {
413 case strings.HasSuffix(name, ".events.jsonl"):
414 if isNativeSessionEventLog(filepath.Join(subDir, name)) {
415 continue
416 }
417 base = strings.TrimSuffix(name, ".events.jsonl")
418 srcPath = filepath.Join(subDir, name)
419 // Prefer .jsonl sidecar if it's newer.
420 if jsonlPath := filepath.Join(subDir, base+".jsonl"); fileExists(jsonlPath) && isMessageFormat(jsonlPath) {
421 eventsInfo, _ := e.Info()
422 if jsonlInfo, err := os.Stat(jsonlPath); err == nil {
423 if eventsInfo == nil || !jsonlInfo.ModTime().Before(eventsInfo.ModTime()) {
424 srcPath = jsonlPath
425 reconstruct = false
426 } else {
427 reconstruct = true
428 }
429 } else {
430 reconstruct = true
431 }
432 } else {
433 reconstruct = true
434 }
435 case strings.HasSuffix(name, ".jsonl") && !strings.HasSuffix(name, ".events.jsonl") && !strings.HasSuffix(name, ".jsonl.bak"):
436 base = strings.TrimSuffix(name, ".jsonl")
437 if hasEvents[base] {
438 continue // handled by the events branch above
439 }
440 srcPath = filepath.Join(subDir, name)
441 if !isMessageFormat(srcPath) {
442 continue
443 }
444 // reconstruct stays false
445 default:
446 continue
447 }
448 meta := readLegacyMeta(subDir, base)
449 destDir := globalDest
450 if projectDir != nil && meta.Workspace != "" && dirExists(meta.Workspace) {
451 if d := projectDir(meta.Workspace); d != "" {
452 destDir = d
453 }
454 }
455 dest := filepath.Join(destDir, base+".jsonl")
456 if _, err := os.Stat(dest); err == nil {
457 continue
458 }
459 srcInfo, _ := e.Info()
460 if reconstruct {
461 msgs, err := reconstructSession(srcPath)
462 if err != nil || len(msgs) == 0 {
463 continue
464 }
465 s := &Session{Messages: msgs}
466 if err := s.SaveIfAbsent(dest); err != nil {
467 if errors.Is(err, os.ErrExist) {
468 continue
469 }
470 return imported, err
471 }
472 } else if isNativeSessionEventLog(SessionEventLogPath(srcPath)) {
473 if err := saveNativeSessionCopy(srcPath, dest); err != nil {
474 continue
475 }
476 } else {
477 if err := transformAndCopyJsonl(srcPath, dest); err != nil {
478 continue
479 }
480 }
481 if srcInfo != nil {
482 _ = os.Chtimes(dest, srcInfo.ModTime(), srcInfo.ModTime())
483 }
484 recordImportedTitle(destDir, base, meta.Summary)
485 imported++
486 }
487 return imported, nil
488 }
489
490 // isMessageFormat returns true when path's first non-whitespace bytes look like
491 // a JSON object with a "role" key — i.e. the v1+ message format — as opposed to
492 // the legacy event-log format whose first key is "id".
493 func isMessageFormat(path string) bool {
494 f, err := os.Open(path)
495 if err != nil {
496 return false
497 }
498 defer f.Close()
499 var buf [64]byte
500 n, _ := f.Read(buf[:])
501 s := strings.TrimLeft(string(buf[:n]), " \t\r\n")
502 return strings.HasPrefix(s, `{"role":`)
503 }
504
505 // isNativeSessionEventLog reports whether the file at an .events.jsonl path is
506 // a native session event log (as opposed to a legacy v0.x event transcript
507 // that happens to share the suffix).
508 func isNativeSessionEventLog(path string) bool {
509 sessionPath := strings.TrimSuffix(path, ".events.jsonl") + ".jsonl"
510 probe, err := probeSessionEventLog(sessionPath)
511 return err == nil && probe.native && probe.size > 0
512 }
513
514 func saveNativeSessionCopy(src, dst string) error {
515 session, err := LoadSession(src)
516 if err != nil {
517 return err
518 }
519 return session.SaveIfAbsent(dst)
520 }
521
522 func fileExists(path string) bool {
523 _, err := os.Stat(path)
524 return err == nil
525 }
526
527 // legacyAssistantMsg is the minimal JSON shape needed to detect and transform
528 // the legacy nested-function tool-call format into the flat format the Go
529 // version expects.
530 type legacyAssistantMsg struct {
531 Role string `json:"role"`
532 ToolCalls json.RawMessage `json:"tool_calls"`
533 }
534
535 // legacyToolCallObj matches the OpenAI-style tool call where name and
536 // arguments live under a "function" key.
537 type legacyToolCallObj struct {
538 ID string `json:"id"`
539 Function struct {
540 Name string `json:"name"`
541 Arguments string `json:"arguments"`
542 ThoughtSignature string `json:"thought_signature"`
543 } `json:"function"`
544 }
545
546 // transformAndCopyJsonl copies src to dst, flattening any legacy nested-function
547 // tool calls into the flat name/arguments format the v1+ message format uses.
548 // Non-assistant messages and messages without tool_calls pass through unchanged.
549 func transformAndCopyJsonl(src, dst string) error {
550 in, err := os.Open(src)
551 if err != nil {
552 return err
553 }
554 defer in.Close()
555 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
556 return err
557 }
558 tmp, err := os.CreateTemp(filepath.Dir(dst), ".session.*.tmp")
559 if err != nil {
560 return err
561 }
562 tmpPath := tmp.Name()
563 ok := false
564 defer func() {
565 if !ok {
566 os.Remove(tmpPath)
567 }
568 }()
569 enc := json.NewEncoder(tmp)
570 dec := json.NewDecoder(in)
571 for {
572 var raw json.RawMessage
573 if err := dec.Decode(&raw); err != nil {
574 if errors.Is(err, io.EOF) {
575 break
576 }
577 // Malformed tail — keep what we've written so far.
578 break
579 }
580 var m legacyAssistantMsg
581 if err := json.Unmarshal(raw, &m); err != nil || m.Role != "assistant" || len(m.ToolCalls) == 0 {
582 // Pass through unchanged (user, tool, or assistant without tool calls).
583 if err := enc.Encode(raw); err != nil {
584 return err
585 }
586 continue
587 }
588 // Try legacy nested-function format; if it doesn't match, pass through.
589 var legacyCalls []legacyToolCallObj
590 if err := json.Unmarshal(m.ToolCalls, &legacyCalls); err != nil || len(legacyCalls) == 0 {
591 if err := enc.Encode(raw); err != nil {
592 return err
593 }
594 continue
595 }
596 // Build flat-format tool calls.
597 flatCalls := make([]provider.ToolCall, len(legacyCalls))
598 for i, tc := range legacyCalls {
599 flatCalls[i] = provider.ToolCall{
600 ID: tc.ID,
601 Name: tc.Function.Name,
602 Arguments: tc.Function.Arguments,
603 ThoughtSignature: tc.Function.ThoughtSignature,
604 }
605 }
606 // Re-serialize the full message with flat tool_calls. We only modify
607 // tool_calls; all other fields (content, reasoning_content, etc.) stay
608 // as-is by round-tripping through a map.
609 var full map[string]json.RawMessage
610 if err := json.Unmarshal(raw, &full); err != nil {
611 if err := enc.Encode(raw); err != nil {
612 return err
613 }
614 continue
615 }
616 b, err := json.Marshal(flatCalls)
617 if err != nil {
618 if err := enc.Encode(raw); err != nil {
619 return err
620 }
621 continue
622 }
623 full["tool_calls"] = b
624 if err := enc.Encode(full); err != nil {
625 return err
626 }
627 }
628 if err := tmp.Close(); err != nil {
629 return err
630 }
631 if err := publishFileNoReplace(tmpPath, dst); err != nil {
632 return err
633 }
634 ok = true
635 return nil
636 }
637
638 // readLegacyMeta loads the v0.x sidecar for a session; missing or corrupt
639 // sidecars yield the zero value (session routes to the global dir, untitled).
640 func readLegacyMeta(srcDir, base string) legacyMeta {
641 var m legacyMeta
642 b, err := fileencoding.ReadFileUTF8(filepath.Join(srcDir, base+".meta.json"))
643 if err != nil {
644 return m
645 }
646 _ = json.Unmarshal(b, &m)
647 m.Workspace = strings.TrimSpace(m.Workspace)
648 m.Summary = strings.TrimSpace(m.Summary)
649 return m
650 }
651
652 func dirExists(path string) bool {
653 info, err := os.Stat(path)
654 return err == nil && info.IsDir()
655 }
656
657 // publishFileNoReplace atomically publishes a completed sibling temp file
658 // without replacing a destination another startup/import writer created.
659 // The temp and destination share a directory, so a hard link is atomic and
660 // portable across the filesystems Reasonix supports.
661 func publishFileNoReplace(tmp, dst string) error {
662 if err := linkFileNoReplace(tmp, dst); err != nil {
663 return err
664 }
665 return os.Remove(tmp)
666 }
667
668 func linkFileNoReplace(src, dst string) error {
669 if err := os.Link(src, dst); err != nil {
670 if os.IsExist(err) {
671 return os.ErrExist
672 }
673 return err
674 }
675 return nil
676 }
677
678 // recordImportedTitle stores the legacy summary as the session's display title
679 // in the dir's .titles.json — the same map the desktop sidebar reads
680 // (desktop/sessions.go). Existing titles are never overwritten.
681 func recordImportedTitle(destDir, base, summary string) {
682 if summary == "" {
683 return
684 }
685 path := filepath.Join(destDir, ".titles.json")
686 titles := map[string]string{}
687 if b, err := fileencoding.ReadFileUTF8(path); err == nil {
688 _ = json.Unmarshal(b, &titles)
689 }
690 key := base + ".jsonl"
691 if titles[key] != "" {
692 return
693 }
694 titles[key] = summary
695 b, err := json.MarshalIndent(titles, "", " ")
696 if err != nil {
697 return
698 }
699 tmp := path + ".tmp"
700 if err := os.WriteFile(tmp, b, 0o644); err != nil {
701 return
702 }
703 _ = os.Rename(tmp, path)
704 }
705
706 func importMarkerExists(destDir, marker string) bool {
707 if strings.TrimSpace(destDir) == "" || strings.TrimSpace(marker) == "" {
708 return false
709 }
710 _, err := os.Stat(filepath.Join(destDir, marker))
711 return err == nil
712 }
713
714 func writeImportMarkers(destDir string, markers ...string) {
715 if strings.TrimSpace(destDir) == "" {
716 return
717 }
718 if err := os.MkdirAll(destDir, 0o755); err != nil {
719 return
720 }
721 seen := map[string]bool{}
722 for _, marker := range markers {
723 marker = strings.TrimSpace(marker)
724 if marker == "" || seen[marker] {
725 continue
726 }
727 seen[marker] = true
728 _ = os.WriteFile(filepath.Join(destDir, marker), nil, 0o644)
729 }
730 }
731
732 // rehomeStrandedSessions copies project-scoped sessions that were written into
733 // the flat global dir AFTER the one-time routing pass already ran — the
734 // signature of a user who downgraded to a pre-routing build (which writes every
735 // session to the flat dir regardless of workspace) and then upgraded again
736 // (#4666). Without this, the routing marker hides those sessions from the
737 // desktop sidebar forever, even though they are sitting in the flat dir.
738 //
739 // It is deliberately conservative:
740 // - Only sessions whose mtime is newer than the marker (the last migration
741 // watermark) are considered, so a session the user imported and then
742 // deleted is never resurrected.
743 // - Only sessions that explicitly name a still-existing workspace — via a v1+
744 // branch-meta sidecar with scope=project, or a v0.x .meta.json — are moved.
745 // Flat global sessions (CLI conversations, the desktop's global tab) carry
746 // no workspace and are left untouched.
747 // - It never modifies the source files; the destination is written via the
748 // same transform-and-copy path the full passes use, and the branch-meta
749 // sidecar is copied alongside so the sidebar shows the right title/topic.
750 //
751 // The marker mtime is advanced to now after a successful scan so the next boot
752 // does not re-walk the same files.
753 func rehomeStrandedSessions(srcDir, globalDest, marker string, projectDir func(string) string) (int, error) {
754 if projectDir == nil {
755 return 0, nil
756 }
757 markerPath := filepath.Join(globalDest, marker)
758 markerInfo, err := os.Stat(markerPath)
759 if err != nil {
760 return 0, nil // no watermark to compare against — full passes own this dir
761 }
762 watermark := markerInfo.ModTime()
763
764 entries, err := os.ReadDir(srcDir)
765 if err != nil {
766 return 0, nil
767 }
768 imported := 0
769 hadCopyFailure := false
770 for _, e := range entries {
771 name := e.Name()
772 if e.IsDir() || !strings.HasSuffix(name, ".jsonl") ||
773 strings.HasSuffix(name, ".events.jsonl") || strings.HasSuffix(name, ".jsonl.bak") {
774 continue
775 }
776 base := strings.TrimSuffix(name, ".jsonl")
777 if strings.HasPrefix(base, "subagent-") {
778 continue // surfaced only through their parent session
779 }
780 info, ierr := e.Info()
781 if ierr != nil || !info.ModTime().After(watermark) {
782 continue // written before the last migration — not a downgrade straggler
783 }
784 srcPath := filepath.Join(srcDir, name)
785 if !isMessageFormat(srcPath) {
786 continue
787 }
788 destDir, summary := strandedSessionDestDir(srcDir, srcPath, base, projectDir)
789 if destDir == "" || sameDirPath(destDir, globalDest) {
790 continue // global session, or no live workspace — leave it in the flat dir
791 }
792 dest := filepath.Join(destDir, name)
793 if _, err := os.Stat(dest); err == nil {
794 if err := copySubagentArtifacts(srcDir, destDir, base); err != nil {
795 hadCopyFailure = true
796 }
797 continue // already routed on a previous boot
798 }
799 if err := transformAndCopyJsonl(srcPath, dest); err != nil {
800 hadCopyFailure = true
801 continue
802 }
803 _ = os.Chtimes(dest, info.ModTime(), info.ModTime()) // preserve resume ordering
804 copyBranchMetaSidecar(srcPath, dest)
805 if err := copySubagentArtifacts(srcDir, destDir, base); err != nil {
806 hadCopyFailure = true
807 }
808 recordImportedTitle(destDir, base, summary)
809 imported++
810 }
811 // Advance the watermark so the next boot starts from here, unless a matched
812 // project session failed to copy and still needs a retry.
813 if !hadCopyFailure {
814 now := time.Now()
815 _ = os.Chtimes(markerPath, now, now)
816 }
817 return imported, nil
818 }
819
820 // strandedSessionDestDir resolves the per-project session dir a flat-dir session
821 // belongs to, preferring the v1+ branch-meta sidecar and falling back to the
822 // v0.x .meta.json. It returns "" when the session is global or names a workspace
823 // that no longer exists on disk. The second return is the display summary, if any.
824 func strandedSessionDestDir(srcDir, srcPath, base string, projectDir func(string) string) (string, string) {
825 if meta, ok, err := LoadBranchMeta(srcPath); err == nil && ok {
826 if meta.DefaultScope() == "project" && meta.WorkspaceRoot != "" && dirExists(meta.WorkspaceRoot) {
827 if d := projectDir(meta.WorkspaceRoot); d != "" {
828 return d, strings.TrimSpace(meta.TopicTitle)
829 }
830 }
831 // A branch sidecar that explicitly marks the session global wins over a
832 // stale v0.x sidecar of the same name.
833 if meta.Scope != "" {
834 return "", ""
835 }
836 }
837 legacy := readLegacyMeta(srcDir, base)
838 if legacy.Workspace != "" && dirExists(legacy.Workspace) {
839 if d := projectDir(legacy.Workspace); d != "" {
840 return d, legacy.Summary
841 }
842 }
843 return "", ""
844 }
845
846 // copyBranchMetaSidecar copies <src>.meta to <dst>.meta when present so the
847 // desktop sidebar keeps the session's title, topic, and tree position. Best
848 // effort: a missing or unreadable sidecar just means the session shows with a
849 // generated title.
850 func copyBranchMetaSidecar(srcPath, dstPath string) {
851 b, err := os.ReadFile(BranchMetaPath(srcPath))
852 if err != nil {
853 return
854 }
855 dstMeta := BranchMetaPath(dstPath)
856 if err := os.MkdirAll(filepath.Dir(dstMeta), 0o755); err != nil {
857 return
858 }
859 tmp, err := os.CreateTemp(filepath.Dir(dstMeta), ".branch.*.tmp")
860 if err != nil {
861 return
862 }
863 tmpPath := tmp.Name()
864 if _, err := tmp.Write(b); err != nil {
865 tmp.Close()
866 os.Remove(tmpPath)
867 return
868 }
869 if err := tmp.Close(); err != nil {
870 os.Remove(tmpPath)
871 return
872 }
873 if err := os.Rename(tmpPath, dstMeta); err != nil {
874 os.Remove(tmpPath)
875 }
876 }
877
878 func copySubagentArtifacts(srcSessionDir, dstSessionDir, parentSession string) error {
879 if sameDirPath(srcSessionDir, dstSessionDir) {
880 return nil
881 }
882 artifacts, err := ListSubagentsByParent(srcSessionDir, parentSession)
883 if err != nil {
884 return err
885 }
886 var errs []error
887 dstSubagentDir := filepath.Join(dstSessionDir, "subagents")
888 for _, artifact := range artifacts {
889 for _, src := range []string{artifact.SessionPath, artifact.MetaPath} {
890 if err := copyFileIfExists(src, filepath.Join(dstSubagentDir, filepath.Base(src))); err != nil {
891 errs = append(errs, err)
892 }
893 }
894 }
895 return errors.Join(errs...)
896 }
897
898 func copyFileIfExists(src, dst string) error {
899 info, err := os.Stat(src)
900 if err != nil {
901 if os.IsNotExist(err) {
902 return nil
903 }
904 return err
905 }
906 if info.IsDir() {
907 return nil
908 }
909 if _, err := os.Stat(dst); err == nil {
910 return nil
911 } else if !os.IsNotExist(err) {
912 return err
913 }
914 b, err := os.ReadFile(src)
915 if err != nil {
916 return err
917 }
918 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
919 return err
920 }
921 tmp, err := os.CreateTemp(filepath.Dir(dst), ".subagent.*.tmp")
922 if err != nil {
923 return err
924 }
925 tmpPath := tmp.Name()
926 if _, err := tmp.Write(b); err != nil {
927 tmp.Close()
928 os.Remove(tmpPath)
929 return err
930 }
931 if err := tmp.Close(); err != nil {
932 os.Remove(tmpPath)
933 return err
934 }
935 if err := os.Rename(tmpPath, dst); err != nil {
936 os.Remove(tmpPath)
937 return err
938 }
939 _ = os.Chtimes(dst, info.ModTime(), info.ModTime())
940 return nil
941 }
942
943 // sameDirPath reports whether two directory paths resolve to the same location.
944 func sameDirPath(a, b string) bool {
945 ca, cb := filepath.Clean(a), filepath.Clean(b)
946 if ca == cb {
947 return true
948 }
949 if aa, err := filepath.Abs(ca); err == nil {
950 if bb, err := filepath.Abs(cb); err == nil {
951 return aa == bb
952 }
953 }
954 return false
955 }
956
957 // reconstructSession folds the chronological event stream into the provider
958 // message sequence. Tool results inherit their tool name from the assistant turn
959 // that issued the call (the v0.x result event carries only the call id).
960 func reconstructSession(path string) ([]provider.Message, error) {
961 f, err := os.Open(path)
962 if err != nil {
963 return nil, err
964 }
965 defer f.Close()
966
967 var msgs []provider.Message
968 toolName := map[string]string{}
969 dec := json.NewDecoder(f)
970 for {
971 var e legacyEvent
972 if err := dec.Decode(&e); err != nil {
973 if !errors.Is(err, io.EOF) {
974 return msgs, nil // malformed tail — keep what parsed cleanly
975 }
976 break
977 }
978 switch e.Type {
979 case "user.message":
980 if e.Text != "" {
981 msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: e.Text})
982 }
983 case "model.final":
984 m := provider.Message{Role: provider.RoleAssistant, Content: e.Content, ReasoningContent: e.ReasoningContent}
985 for _, tc := range e.ToolCalls {
986 m.ToolCalls = append(m.ToolCalls, provider.ToolCall{
987 ID: tc.ID, Name: tc.Function.Name, Arguments: tc.Function.Arguments,
988 ThoughtSignature: tc.Function.ThoughtSignature,
989 })
990 toolName[tc.ID] = tc.Function.Name
991 }
992 msgs = append(msgs, m)
993 case "tool.result":
994 msgs = append(msgs, provider.Message{Role: provider.RoleTool, ToolCallID: e.CallID, Name: toolName[e.CallID], Content: e.Output})
995 }
996 }
997 return msgs, nil
998 }
999
999 lines GO