// ingestion/internal/session/session.go package session import ( "bufio" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "regexp" "syscall" "time" ) // Entry is one skill invocation record, appended to the session JSONL log. type Entry struct { SessionID string `json:"session_id"` Timestamp time.Time `json:"timestamp"` Skill string `json:"skill"` Phase string `json:"phase,omitempty"` ProjectRoot string `json:"project_root,omitempty"` Input json.RawMessage `json:"input,omitempty"` Attempts []Attempt `json:"attempts,omitempty"` FinalStatus string `json:"final_status"` FilePath string `json:"file_path,omitempty"` ModelUsed string `json:"model_used,omitempty"` DurationMs int64 `json:"duration_ms,omitempty"` Message string `json:"message,omitempty"` } // Attempt represents one subprocess invocation within a skill call. type Attempt struct { Attempt int `json:"attempt"` Model string `json:"model"` Tier string `json:"tier"` // local | subagent | managed DurationMs int64 `json:"duration_ms"` WarmStart bool `json:"warm_start"` // model already loaded in llama-swap Verified bool `json:"verified"` Verdict string `json:"verdict,omitempty"` // accept | escalate | error Feedback string `json:"feedback,omitempty"` // verifier feedback on escalation OutputSummary string `json:"output_summary,omitempty"` RunnerOutput string `json:"runner_output,omitempty"` } // idPattern is a plain file name: session_log takes the id from the caller, // and a path segment in it created or appended files anywhere the pod could // write (hyperguild#169). var idPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$`) // ErrInvalidID is returned when a session id is not a plain file name. var ErrInvalidID = errors.New("session id is not a plain name") func checkID(sessionID string) error { if !idPattern.MatchString(sessionID) { return fmt.Errorf("%w: %q", ErrInvalidID, sessionID) } return nil } // Append writes entry as a single JSON line to sessionsDir/{sessionID}.jsonl. // The open does not follow a final symlink. func Append(sessionsDir, sessionID string, entry Entry) error { if err := checkID(sessionID); err != nil { return err } // Opened through an os.Root on the sessions dir's parent (the brain dir), // so a sessions dir swapped for a symlink after the check below can // redirect the log only inside that parent, never outside it (#173; the // in-brain swap is the #167 residual). parentDir, name := filepath.Dir(sessionsDir), filepath.Base(sessionsDir) if err := os.MkdirAll(parentDir, 0o755); err != nil { return fmt.Errorf("create sessions parent: %w", err) } root, err := os.OpenRoot(parentDir) if err != nil { return fmt.Errorf("open sessions parent: %w", err) } defer func() { _ = root.Close() }() if err := root.Mkdir(name, 0o755); err != nil && !errors.Is(err, fs.ErrExist) { return fmt.Errorf("create sessions dir: %w", err) } // The sessions dir itself must be a real directory, not a symlink that // redirects the log elsewhere (hyperguild#173). if fi, err := root.Lstat(name); err != nil { return fmt.Errorf("stat sessions dir: %w", err) } else if !fi.IsDir() { return fmt.Errorf("sessions dir %s is not a real directory", sessionsDir) } logRel := name + "/" + sessionID + ".jsonl" f, err := root.OpenFile(logRel, os.O_CREATE|os.O_APPEND|os.O_WRONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0o644) if err != nil { return fmt.Errorf("open session log: %w", err) } // os.Root follows an in-root final symlink even with O_NOFOLLOW, so // confirm, before writing, that the path is the regular file we opened. hfi, herr := f.Stat() lfi, lerr := root.Lstat(logRel) if herr != nil || lerr != nil || !lfi.Mode().IsRegular() || !os.SameFile(lfi, hfi) { _ = f.Close() return fmt.Errorf("session log %s is not a regular file", logRel) } line, err := json.Marshal(entry) if err != nil { _ = f.Close() return fmt.Errorf("marshal entry: %w", err) } if _, err = fmt.Fprintf(f, "%s\n", line); err != nil { _ = f.Close() return fmt.Errorf("write entry: %w", err) } if err = f.Close(); err != nil { return fmt.Errorf("close session log: %w", err) } return nil } // Read returns all entries for sessionID. Returns empty slice if no log exists. func Read(sessionsDir, sessionID string) ([]Entry, error) { if err := checkID(sessionID); err != nil { return nil, err } path := filepath.Join(sessionsDir, sessionID+".jsonl") f, err := os.Open(path) if errors.Is(err, fs.ErrNotExist) { return []Entry{}, nil } if err != nil { return nil, fmt.Errorf("open session log: %w", err) } defer f.Close() //nolint:errcheck var entries []Entry scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 256*1024), 1<<20) // up to 1 MB per line for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 { continue } var e Entry if err := json.Unmarshal(line, &e); err != nil { return nil, fmt.Errorf("parse entry: %w", err) } entries = append(entries, e) } return entries, scanner.Err() }