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