Files
2026-07-13 13:00:08 +08:00

452 lines
12 KiB
Go

package doctor
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/store"
)
const sessionBundleSchemaVersion = 1
// SessionBundleOptions controls a session-conflict diagnostic zip. SessionRef
// accepts either a branch id such as "20260706-...-recovery-abcd1234", a
// matching .jsonl basename, or an absolute/relative transcript path.
type SessionBundleOptions struct {
Version string
SessionRef string
OutputPath string
Now time.Time
}
type SessionBundleResult struct {
Path string
SessionPath string
Included []SessionBundleFile
Missing []string
}
type SessionBundleManifest struct {
SchemaVersion int `json:"schema_version"`
GeneratedAt time.Time `json:"generated_at"`
Version string `json:"version"`
RequestedRef string `json:"requested_ref"`
SessionPath string `json:"session_path"`
Sessions []SessionBundleEntry `json:"sessions"`
Included []SessionBundleFile `json:"included"`
Missing []string `json:"missing,omitempty"`
}
type SessionBundleEntry struct {
BranchID string `json:"branch_id"`
Role string `json:"role"`
Path string `json:"path"`
ParentID string `json:"parent_id,omitempty"`
Recovered bool `json:"recovered,omitempty"`
RecoveryReason string `json:"recovery_reason,omitempty"`
RecoveryDepth int `json:"recovery_depth,omitempty"`
}
type SessionBundleFile struct {
Role string `json:"role"`
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
}
type sessionArtifact struct {
role string
path string
}
// WriteSessionBundle writes a zip containing the selected session transcript,
// its persistence sidecars, a redacted doctor report, and the recovery parent
// chain discoverable through branch metadata.
func WriteSessionBundle(opts SessionBundleOptions) (SessionBundleResult, error) {
ref := strings.TrimSpace(opts.SessionRef)
if ref == "" {
return SessionBundleResult{}, fmt.Errorf("session branch id or path is required")
}
now := opts.Now
if now.IsZero() {
now = time.Now()
}
sessionPath, err := resolveSessionBundlePath(ref)
if err != nil {
return SessionBundleResult{}, err
}
chain := sessionBundleChain(sessionPath)
outPath := strings.TrimSpace(opts.OutputPath)
if outPath == "" {
outPath = defaultSessionBundlePath(agent.BranchID(sessionPath))
}
if abs, err := filepath.Abs(outPath); err == nil {
outPath = abs
}
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return SessionBundleResult{}, err
}
f, err := os.Create(outPath)
if err != nil {
return SessionBundleResult{}, err
}
defer f.Close()
zw := zip.NewWriter(f)
manifest := SessionBundleManifest{
SchemaVersion: sessionBundleSchemaVersion,
GeneratedAt: now,
Version: opts.Version,
RequestedRef: redactSessionBundleRef(ref),
SessionPath: redactSessionBundlePath(sessionPath),
}
result := SessionBundleResult{Path: outPath, SessionPath: sessionPath}
report := Collect(Options{Version: opts.Version})
reportData, err := json.MarshalIndent(report, "", " ")
if err != nil {
_ = zw.Close()
return SessionBundleResult{}, err
}
if err := addBundleBytes(zw, "doctor.json", reportData, now); err != nil {
_ = zw.Close()
return SessionBundleResult{}, err
}
for i, path := range chain {
role := "parent"
if i == 0 {
role = "requested"
}
meta, ok, metaErr := agent.LoadBranchMeta(path)
if metaErr != nil {
metaPath := store.SessionMeta(path)
manifest.Missing = append(manifest.Missing, redactSessionBundlePath(metaPath)+": "+redactSessionBundleError(metaErr))
}
entry := SessionBundleEntry{
BranchID: agent.BranchID(path),
Role: role,
Path: redactSessionBundlePath(path),
}
if ok {
entry.ParentID = meta.ParentID
entry.Recovered = meta.Recovered
entry.RecoveryReason = meta.RecoveryReason
entry.RecoveryDepth = meta.RecoveryDepth
}
manifest.Sessions = append(manifest.Sessions, entry)
for _, artifact := range sessionBundleArtifacts(path) {
info, statErr := os.Stat(artifact.path)
if statErr != nil {
if !os.IsNotExist(statErr) {
manifest.Missing = append(manifest.Missing, redactSessionBundlePath(artifact.path)+": "+redactSessionBundleError(statErr))
}
continue
}
if info.IsDir() {
continue
}
zipName := filepath.ToSlash(filepath.Join("sessions", safeBundleName(agent.BranchID(path)), filepath.Base(artifact.path)))
if err := addBundleFile(zw, zipName, artifact.path, info); err != nil {
_ = zw.Close()
return SessionBundleResult{}, err
}
file := SessionBundleFile{
Role: artifact.role,
Name: zipName,
Path: redactSessionBundlePath(artifact.path),
Size: info.Size(),
}
manifest.Included = append(manifest.Included, file)
result.Included = append(result.Included, file)
}
}
sort.Strings(manifest.Missing)
result.Missing = append(result.Missing, manifest.Missing...)
manifestData, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
_ = zw.Close()
return SessionBundleResult{}, err
}
if err := addBundleBytes(zw, "manifest.json", manifestData, now); err != nil {
_ = zw.Close()
return SessionBundleResult{}, err
}
if err := zw.Close(); err != nil {
return SessionBundleResult{}, err
}
return result, nil
}
func resolveSessionBundlePath(ref string) (string, error) {
if path, ok := existingSessionPath(ref); ok {
return path, nil
}
branch := sessionBundleBranchID(ref)
if branch == "" {
return "", fmt.Errorf("invalid session reference %q", ref)
}
name := branch + ".jsonl"
var matches []string
for _, dir := range sessionBundleSearchDirs() {
path := filepath.Join(dir, name)
if info, err := os.Stat(path); err == nil && !info.IsDir() {
matches = append(matches, path)
}
}
if len(matches) == 0 {
return "", fmt.Errorf("session %q not found under Reasonix session directories", branch)
}
sort.Strings(matches)
if len(matches) > 1 {
redacted := make([]string, len(matches))
for i, match := range matches {
redacted[i] = redactSessionBundlePath(match)
}
return "", fmt.Errorf("session %q matched multiple files; pass an absolute path: %s", branch, strings.Join(redacted, ", "))
}
return matches[0], nil
}
func redactSessionBundleRef(ref string) string {
if strings.ContainsAny(ref, `/\`) {
return redactSessionBundlePath(ref)
}
return ref
}
func redactSessionBundlePath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return path
}
root := strings.TrimSpace(config.MemoryUserDir())
if root != "" {
absRoot, rootErr := filepath.Abs(root)
absPath, pathErr := filepath.Abs(path)
if rootErr == nil && pathErr == nil {
if rel, err := filepath.Rel(absRoot, absPath); err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
if rel == "." {
return "<REASONIX_HOME>"
}
return filepath.ToSlash(filepath.Join("<REASONIX_HOME>", rel))
}
}
}
return redactHome(path)
}
func redactSessionBundleError(err error) string {
if err == nil {
return ""
}
return redactSessionBundleText(err.Error())
}
func redactSessionBundleText(text string) string {
if text == "" {
return text
}
if root := strings.TrimSpace(config.MemoryUserDir()); root != "" {
text = replacePathPrefix(text, root, "<REASONIX_HOME>")
if absRoot, err := filepath.Abs(root); err == nil {
text = replacePathPrefix(text, absRoot, "<REASONIX_HOME>")
}
}
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
text = replacePathPrefix(text, home, "~")
}
return text
}
func replacePathPrefix(text, old, new string) string {
old = strings.TrimRight(strings.TrimSpace(old), string(os.PathSeparator))
if old == "" {
return text
}
text = strings.ReplaceAll(text, old+string(os.PathSeparator), new+string(os.PathSeparator))
return strings.ReplaceAll(text, old, new)
}
func existingSessionPath(ref string) (string, bool) {
path := strings.Trim(strings.TrimSpace(ref), `"'`)
if path == "" {
return "", false
}
if !strings.HasSuffix(filepath.Base(path), ".jsonl") {
if candidate := path + ".jsonl"; strings.ContainsAny(path, `/\`) {
path = candidate
}
}
if !strings.HasSuffix(filepath.Base(path), ".jsonl") {
return "", false
}
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return "", false
}
return path, true
}
func sessionBundleBranchID(ref string) string {
ref = strings.Trim(strings.TrimSpace(ref), `"'`)
base := filepath.Base(ref)
for _, suffix := range []string{".jsonl.meta", ".jsonl", ".meta", ".json"} {
base = strings.TrimSuffix(base, suffix)
}
return strings.TrimSpace(base)
}
func sessionBundleSearchDirs() []string {
seen := map[string]bool{}
var out []string
add := func(dir string) {
dir = strings.TrimSpace(dir)
if dir == "" {
return
}
if abs, err := filepath.Abs(dir); err == nil {
dir = abs
}
if seen[dir] {
return
}
seen[dir] = true
out = append(out, dir)
}
add(config.SessionDir())
if cwd, err := os.Getwd(); err == nil {
add(config.ProjectSessionDir(cwd))
}
if root := config.MemoryUserDir(); root != "" {
projects := filepath.Join(root, "projects")
_ = filepath.WalkDir(projects, func(path string, d os.DirEntry, err error) error {
if err != nil || !d.IsDir() {
return nil
}
if d.Name() == "sessions" {
add(path)
return filepath.SkipDir
}
return nil
})
}
sort.Strings(out)
return out
}
func sessionBundleChain(path string) []string {
var out []string
seen := map[string]bool{}
for len(out) < 16 {
if path == "" {
break
}
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
if seen[path] {
break
}
seen[path] = true
out = append(out, path)
meta, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok || strings.TrimSpace(meta.ParentID) == "" {
break
}
parent := filepath.Join(filepath.Dir(path), meta.ParentID+".jsonl")
if info, err := os.Stat(parent); err != nil || info.IsDir() {
break
}
path = parent
}
return out
}
func sessionBundleArtifacts(sessionPath string) []sessionArtifact {
return []sessionArtifact{
{role: "transcript", path: sessionPath},
{role: "meta", path: store.SessionMeta(sessionPath)},
{role: "goal_state", path: store.SessionGoalState(sessionPath)},
{role: "events", path: store.SessionEventLog(sessionPath)},
{role: "event_index", path: store.SessionEventIndex(sessionPath)},
{role: "conflicts", path: store.SessionConflictLog(sessionPath)},
{role: "lease", path: store.SessionLeaseInfo(sessionPath)},
}
}
func addBundleFile(zw *zip.Writer, name, path string, info os.FileInfo) error {
h, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
h.Name = name
h.Method = zip.Deflate
w, err := zw.CreateHeader(h)
if err != nil {
return err
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(w, f)
return err
}
func addBundleBytes(zw *zip.Writer, name string, data []byte, modTime time.Time) error {
h := &zip.FileHeader{Name: name, Method: zip.Deflate, Modified: modTime}
h.SetMode(0o644)
w, err := zw.CreateHeader(h)
if err != nil {
return err
}
_, err = w.Write(data)
return err
}
func defaultSessionBundlePath(branch string) string {
branch = safeBundleName(branch)
if branch == "" {
branch = "session"
}
return filepath.Join(os.TempDir(), "reasonix-session-"+branch+"-diag.zip")
}
func safeBundleName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
var b strings.Builder
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '-', r == '_', r == '.':
b.WriteRune(r)
default:
b.WriteByte('_')
}
if b.Len() >= 120 {
break
}
}
return strings.Trim(b.String(), "._-")
}