// 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 } if err := os.MkdirAll(sessionsDir, 0o755); err != nil { 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 := os.Lstat(sessionsDir); 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) } path := filepath.Join(sessionsDir, sessionID+".jsonl") f, err := os.OpenFile(path, 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) } 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() }