返回 DeepSeek-Reasonix
session_bundle.go
根目录 / internal / doctor / session_bundle.go
1 package doctor
2
3 import (
4 "archive/zip"
5 "encoding/json"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12 "time"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/config"
16 "reasonix/internal/store"
17 )
18
19 const sessionBundleSchemaVersion = 1
20
21 // SessionBundleOptions controls a session-conflict diagnostic zip. SessionRef
22 // accepts either a branch id such as "20260706-...-recovery-abcd1234", a
23 // matching .jsonl basename, or an absolute/relative transcript path.
24 type SessionBundleOptions struct {
25 Version string
26 SessionRef string
27 OutputPath string
28 Now time.Time
29 }
30
31 type SessionBundleResult struct {
32 Path string
33 SessionPath string
34 Included []SessionBundleFile
35 Missing []string
36 }
37
38 type SessionBundleManifest struct {
39 SchemaVersion int `json:"schema_version"`
40 GeneratedAt time.Time `json:"generated_at"`
41 Version string `json:"version"`
42 RequestedRef string `json:"requested_ref"`
43 SessionPath string `json:"session_path"`
44 Sessions []SessionBundleEntry `json:"sessions"`
45 Included []SessionBundleFile `json:"included"`
46 Missing []string `json:"missing,omitempty"`
47 }
48
49 type SessionBundleEntry struct {
50 BranchID string `json:"branch_id"`
51 Role string `json:"role"`
52 Path string `json:"path"`
53 ParentID string `json:"parent_id,omitempty"`
54 Recovered bool `json:"recovered,omitempty"`
55 RecoveryReason string `json:"recovery_reason,omitempty"`
56 RecoveryDepth int `json:"recovery_depth,omitempty"`
57 // Head fields are set for a schema-2 session log; its versions are heads
58 // of one file, not separate transcripts in the chain above.
59 LogFormat int `json:"log_format,omitempty"`
60 SelectedHead string `json:"selected_head,omitempty"`
61 Heads []SessionBundleHead `json:"heads,omitempty"`
62 }
63
64 type SessionBundleFile struct {
65 Role string `json:"role"`
66 Name string `json:"name"`
67 Path string `json:"path"`
68 Size int64 `json:"size"`
69 }
70
71 type sessionArtifact struct {
72 role string
73 path string
74 }
75
76 // WriteSessionBundle writes a zip containing the selected session transcript,
77 // its persistence sidecars, a redacted doctor report, and the recovery parent
78 // chain discoverable through branch metadata.
79 func WriteSessionBundle(opts SessionBundleOptions) (SessionBundleResult, error) {
80 ref := strings.TrimSpace(opts.SessionRef)
81 if ref == "" {
82 return SessionBundleResult{}, fmt.Errorf("session branch id or path is required")
83 }
84 now := opts.Now
85 if now.IsZero() {
86 now = time.Now()
87 }
88 sessionPath, err := resolveSessionBundlePath(ref)
89 if err != nil {
90 return SessionBundleResult{}, err
91 }
92 chain := sessionBundleChain(sessionPath)
93 outPath := strings.TrimSpace(opts.OutputPath)
94 if outPath == "" {
95 outPath = defaultSessionBundlePath(agent.BranchID(sessionPath))
96 }
97 if abs, err := filepath.Abs(outPath); err == nil {
98 outPath = abs
99 }
100 if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
101 return SessionBundleResult{}, err
102 }
103
104 f, err := os.Create(outPath)
105 if err != nil {
106 return SessionBundleResult{}, err
107 }
108 defer f.Close()
109
110 zw := zip.NewWriter(f)
111 manifest := SessionBundleManifest{
112 SchemaVersion: sessionBundleSchemaVersion,
113 GeneratedAt: now,
114 Version: opts.Version,
115 RequestedRef: redactSessionBundleRef(ref),
116 SessionPath: redactSessionBundlePath(sessionPath),
117 }
118 result := SessionBundleResult{Path: outPath, SessionPath: sessionPath}
119
120 report := Collect(Options{Version: opts.Version})
121 reportData, err := json.MarshalIndent(report, "", " ")
122 if err != nil {
123 _ = zw.Close()
124 return SessionBundleResult{}, err
125 }
126 if err := addBundleBytes(zw, "doctor.json", reportData, now); err != nil {
127 _ = zw.Close()
128 return SessionBundleResult{}, err
129 }
130
131 for i, path := range chain {
132 role := "parent"
133 if i == 0 {
134 role = "requested"
135 }
136 meta, ok, metaErr := agent.LoadBranchMeta(path)
137 if metaErr != nil {
138 metaPath := store.SessionMeta(path)
139 manifest.Missing = append(manifest.Missing, redactSessionBundlePath(metaPath)+": "+redactSessionBundleError(metaErr))
140 }
141 entry := SessionBundleEntry{
142 BranchID: agent.BranchID(path),
143 Role: role,
144 Path: redactSessionBundlePath(path),
145 }
146 if ok {
147 entry.ParentID = meta.ParentID
148 entry.Recovered = meta.Recovered
149 entry.RecoveryReason = meta.RecoveryReason
150 entry.RecoveryDepth = meta.RecoveryDepth
151 }
152 if headErr := describeSessionBundleHeads(&entry, path); headErr != nil {
153 manifest.Missing = append(manifest.Missing, redactSessionBundlePath(store.SessionEventLog(path))+": "+redactSessionBundleError(headErr))
154 }
155 manifest.Sessions = append(manifest.Sessions, entry)
156
157 for _, artifact := range sessionBundleArtifacts(path) {
158 info, statErr := os.Stat(artifact.path)
159 if statErr != nil {
160 if !os.IsNotExist(statErr) {
161 manifest.Missing = append(manifest.Missing, redactSessionBundlePath(artifact.path)+": "+redactSessionBundleError(statErr))
162 }
163 continue
164 }
165 if info.IsDir() {
166 continue
167 }
168 zipName := filepath.ToSlash(filepath.Join("sessions", safeBundleName(agent.BranchID(path)), filepath.Base(artifact.path)))
169 if err := addBundleFile(zw, zipName, artifact.path, info); err != nil {
170 _ = zw.Close()
171 return SessionBundleResult{}, err
172 }
173 file := SessionBundleFile{
174 Role: artifact.role,
175 Name: zipName,
176 Path: redactSessionBundlePath(artifact.path),
177 Size: info.Size(),
178 }
179 manifest.Included = append(manifest.Included, file)
180 result.Included = append(result.Included, file)
181 }
182 }
183 sort.Strings(manifest.Missing)
184 result.Missing = append(result.Missing, manifest.Missing...)
185 manifestData, err := json.MarshalIndent(manifest, "", " ")
186 if err != nil {
187 _ = zw.Close()
188 return SessionBundleResult{}, err
189 }
190 if err := addBundleBytes(zw, "manifest.json", manifestData, now); err != nil {
191 _ = zw.Close()
192 return SessionBundleResult{}, err
193 }
194 if err := zw.Close(); err != nil {
195 return SessionBundleResult{}, err
196 }
197 return result, nil
198 }
199
200 func resolveSessionBundlePath(ref string) (string, error) {
201 if path, ok := existingSessionPath(ref); ok {
202 return path, nil
203 }
204 branch := sessionBundleBranchID(ref)
205 if branch == "" {
206 return "", fmt.Errorf("invalid session reference %q", ref)
207 }
208 name := branch + ".jsonl"
209 var matches []string
210 for _, dir := range sessionBundleSearchDirs() {
211 path := filepath.Join(dir, name)
212 if info, err := os.Stat(path); err == nil && !info.IsDir() {
213 matches = append(matches, path)
214 }
215 }
216 if len(matches) == 0 {
217 return "", fmt.Errorf("session %q not found under Reasonix session directories", branch)
218 }
219 sort.Strings(matches)
220 if len(matches) > 1 {
221 redacted := make([]string, len(matches))
222 for i, match := range matches {
223 redacted[i] = redactSessionBundlePath(match)
224 }
225 return "", fmt.Errorf("session %q matched multiple files; pass an absolute path: %s", branch, strings.Join(redacted, ", "))
226 }
227 return matches[0], nil
228 }
229
230 func redactSessionBundleRef(ref string) string {
231 if strings.ContainsAny(ref, `/\`) {
232 return redactSessionBundlePath(ref)
233 }
234 return ref
235 }
236
237 func redactSessionBundlePath(path string) string {
238 path = strings.TrimSpace(path)
239 if path == "" {
240 return path
241 }
242 root := strings.TrimSpace(config.MemoryUserDir())
243 if root != "" {
244 absRoot, rootErr := filepath.Abs(root)
245 absPath, pathErr := filepath.Abs(path)
246 if rootErr == nil && pathErr == nil {
247 if rel, err := filepath.Rel(absRoot, absPath); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
248 if rel == "." {
249 return "<REASONIX_HOME>"
250 }
251 return filepath.ToSlash(filepath.Join("<REASONIX_HOME>", rel))
252 }
253 }
254 }
255 return redactHome(path)
256 }
257
258 func redactSessionBundleError(err error) string {
259 if err == nil {
260 return ""
261 }
262 return redactSessionBundleText(err.Error())
263 }
264
265 func redactSessionBundleText(text string) string {
266 if text == "" {
267 return text
268 }
269 if root := strings.TrimSpace(config.MemoryUserDir()); root != "" {
270 text = replacePathPrefix(text, root, "<REASONIX_HOME>")
271 if absRoot, err := filepath.Abs(root); err == nil {
272 text = replacePathPrefix(text, absRoot, "<REASONIX_HOME>")
273 }
274 }
275 if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
276 text = replacePathPrefix(text, home, "~")
277 }
278 return text
279 }
280
281 func replacePathPrefix(text, old, new string) string {
282 old = strings.TrimRight(strings.TrimSpace(old), string(os.PathSeparator))
283 if old == "" {
284 return text
285 }
286 text = strings.ReplaceAll(text, old+string(os.PathSeparator), new+string(os.PathSeparator))
287 return strings.ReplaceAll(text, old, new)
288 }
289
290 func existingSessionPath(ref string) (string, bool) {
291 path := strings.Trim(strings.TrimSpace(ref), `"'`)
292 if path == "" {
293 return "", false
294 }
295 if !strings.HasSuffix(filepath.Base(path), ".jsonl") {
296 if candidate := path + ".jsonl"; strings.ContainsAny(path, `/\`) {
297 path = candidate
298 }
299 }
300 if !strings.HasSuffix(filepath.Base(path), ".jsonl") {
301 return "", false
302 }
303 if abs, err := filepath.Abs(path); err == nil {
304 path = abs
305 }
306 info, err := os.Stat(path)
307 if err != nil || info.IsDir() {
308 return "", false
309 }
310 return path, true
311 }
312
313 func sessionBundleBranchID(ref string) string {
314 ref = strings.Trim(strings.TrimSpace(ref), `"'`)
315 base := filepath.Base(ref)
316 for _, suffix := range []string{".jsonl.meta", ".jsonl", ".meta", ".json"} {
317 base = strings.TrimSuffix(base, suffix)
318 }
319 return strings.TrimSpace(base)
320 }
321
322 func sessionBundleSearchDirs() []string {
323 seen := map[string]bool{}
324 var out []string
325 add := func(dir string) {
326 dir = strings.TrimSpace(dir)
327 if dir == "" {
328 return
329 }
330 if abs, err := filepath.Abs(dir); err == nil {
331 dir = abs
332 }
333 if seen[dir] {
334 return
335 }
336 seen[dir] = true
337 out = append(out, dir)
338 }
339 add(config.SessionDir())
340 if cwd, err := os.Getwd(); err == nil {
341 add(config.ProjectSessionDir(cwd))
342 }
343 if root := config.MemoryUserDir(); root != "" {
344 projects := filepath.Join(root, "projects")
345 _ = filepath.WalkDir(projects, func(path string, d os.DirEntry, err error) error {
346 if err != nil || !d.IsDir() {
347 return nil
348 }
349 if d.Name() == "sessions" {
350 add(path)
351 return filepath.SkipDir
352 }
353 return nil
354 })
355 }
356 sort.Strings(out)
357 return out
358 }
359
360 func sessionBundleChain(path string) []string {
361 var out []string
362 seen := map[string]bool{}
363 for len(out) < 16 {
364 if path == "" {
365 break
366 }
367 if abs, err := filepath.Abs(path); err == nil {
368 path = abs
369 }
370 if seen[path] {
371 break
372 }
373 seen[path] = true
374 out = append(out, path)
375 meta, ok, err := agent.LoadBranchMeta(path)
376 if err != nil || !ok || strings.TrimSpace(meta.ParentID) == "" {
377 break
378 }
379 parent := filepath.Join(filepath.Dir(path), meta.ParentID+".jsonl")
380 if info, err := os.Stat(parent); err != nil || info.IsDir() {
381 break
382 }
383 path = parent
384 }
385 return out
386 }
387
388 func sessionBundleArtifacts(sessionPath string) []sessionArtifact {
389 return []sessionArtifact{
390 {role: "transcript", path: sessionPath},
391 {role: "meta", path: store.SessionMeta(sessionPath)},
392 {role: "goal_state", path: store.SessionGoalState(sessionPath)},
393 {role: "events", path: store.SessionEventLog(sessionPath)},
394 // The salvage sidecar is the primary evidence for truncation incidents
395 // (torn tails, dual-writer interleaves) — exactly what session bundles
396 // exist to debug. Bundles copy artifact contents verbatim already, so
397 // it adds no new sensitivity class.
398 {role: "events_damaged", path: store.SessionEventLogDamaged(sessionPath)},
399 {role: "event_index", path: store.SessionEventIndex(sessionPath)},
400 {role: "conflicts", path: store.SessionConflictLog(sessionPath)},
401 {role: "lease", path: store.SessionLeaseInfo(sessionPath)},
402 }
403 }
404
405 func addBundleFile(zw *zip.Writer, name, path string, info os.FileInfo) error {
406 h, err := zip.FileInfoHeader(info)
407 if err != nil {
408 return err
409 }
410 h.Name = name
411 h.Method = zip.Deflate
412 w, err := zw.CreateHeader(h)
413 if err != nil {
414 return err
415 }
416 f, err := os.Open(path)
417 if err != nil {
418 return err
419 }
420 defer f.Close()
421 _, err = io.Copy(w, f)
422 return err
423 }
424
425 func addBundleBytes(zw *zip.Writer, name string, data []byte, modTime time.Time) error {
426 h := &zip.FileHeader{Name: name, Method: zip.Deflate, Modified: modTime}
427 h.SetMode(0o644)
428 w, err := zw.CreateHeader(h)
429 if err != nil {
430 return err
431 }
432 _, err = w.Write(data)
433 return err
434 }
435
436 func defaultSessionBundlePath(branch string) string {
437 branch = safeBundleName(branch)
438 if branch == "" {
439 branch = "session"
440 }
441 return filepath.Join(os.TempDir(), "reasonix-session-"+branch+"-diag.zip")
442 }
443
444 func safeBundleName(name string) string {
445 name = strings.TrimSpace(name)
446 if name == "" {
447 return ""
448 }
449 var b strings.Builder
450 for _, r := range name {
451 switch {
452 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
453 b.WriteRune(r)
454 case r == '-', r == '_', r == '.':
455 b.WriteRune(r)
456 default:
457 b.WriteByte('_')
458 }
459 if b.Len() >= 120 {
460 break
461 }
462 }
463 return strings.Trim(b.String(), "._-")
464 }
465
465 lines GO