| 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 |