1package bootstrap
2
3import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "sort"
8 "strings"
9 "time"
10)
11
12type SessionExitClass string
13
14const (
15 SessionExitCleanExit SessionExitClass = "clean_exit"
16 SessionExitMainProcessCrashOrAbrupt SessionExitClass = "main_process_crash_or_abrupt"
17 SessionExitExternalKillOrOOMInferred SessionExitClass = "external_kill_or_oom_inferred"
18 SessionExitUnknown SessionExitClass = "unknown"
19)
20
21type SessionExitClassification struct {
22 SessionID string `json:"session_id"`
23 Class SessionExitClass `json:"class"`
24 Inference string `json:"inference"`
25 Reason string `json:"reason"`
26 StartupAt *time.Time `json:"startup_at,omitempty"`
27 ShutdownAt *time.Time `json:"shutdown_at,omitempty"`
28 AbruptDetectedAt *time.Time `json:"abrupt_detected_at,omitempty"`
29 LastMarkerObserved time.Time `json:"last_marker_observed"`
30}
31
32func ClassifySessionExitFromMarkers(lockDir, sessionID string) (SessionExitClassification, error) {
33 classification := SessionExitClassification{
34 SessionID: sessionID,
35 Class: SessionExitUnknown,
36 Inference: "marker-missing",
37 Reason: "no known marker files found for session",
38 }
39 if lockDir == "" || sessionID == "" {
40 return classification, fmt.Errorf("lockDir and sessionID are required")
41 }
42
43 startupAt, startupExists, startupModTime, err := readMarkerTime(startupMarkerPath(lockDir, sessionID), "")
44 if err != nil {
45 return classification, err
46 }
47 shutdownAt, shutdownExists, shutdownModTime, err := readMarkerTime(shutdownMarkerPath(lockDir, sessionID), "")
48 if err != nil {
49 return classification, err
50 }
51 abruptAt, abruptExists, abruptModTime, err := readMarkerTime(abruptMarkerPath(lockDir, sessionID), "detected_at=")
52 if err != nil {
53 return classification, err
54 }
55
56 // If the startup marker was already cleaned up (writeShutdownMarker removes
57 // it), fall back to the started_at line embedded in the shutdown marker.
58 if startupAt == nil && shutdownExists {
59 startupAt, _, _, _ = readMarkerTime(shutdownMarkerPath(lockDir, sessionID), "started_at=")
60 }
61 classification.StartupAt = startupAt
62 classification.ShutdownAt = shutdownAt
63 classification.AbruptDetectedAt = abruptAt
64 classification.LastMarkerObserved = latestNonZero(startupModTime, shutdownModTime, abruptModTime)
65
66 switch {
67 case shutdownExists:
68 classification.Class = SessionExitCleanExit
69 classification.Inference = "marker-confirmed"
70 classification.Reason = "shutdown marker present"
71 case abruptExists:
72 classification.Class = SessionExitMainProcessCrashOrAbrupt
73 classification.Inference = "marker-confirmed"
74 classification.Reason = "abrupt marker present and no shutdown marker"
75 case startupExists:
76 classification.Class = SessionExitExternalKillOrOOMInferred
77 classification.Inference = "best-effort"
78 classification.Reason = "startup marker present without shutdown/abrupt markers"
79 }
80
81 return classification, nil
82}
83
84func BuildSessionExitReport(lockDir string) ([]SessionExitClassification, error) {
85 if lockDir == "" {
86 return nil, nil
87 }
88
89 sessionIDs, err := discoverSessionIDsFromMarkers(lockDir)
90 if err != nil {
91 return nil, err
92 }
93 if len(sessionIDs) == 0 {
94 return nil, nil
95 }
96
97 report := make([]SessionExitClassification, 0, len(sessionIDs))
98 for _, sessionID := range sessionIDs {
99 classification, classErr := ClassifySessionExitFromMarkers(lockDir, sessionID)
100 if classErr != nil {
101 return nil, classErr
102 }
103 report = append(report, classification)
104 }
105
106 sort.Slice(report, func(i, j int) bool {
107 return report[i].LastMarkerObserved.After(report[j].LastMarkerObserved)
108 })
109 return report, nil
110}
111
112func discoverSessionIDsFromMarkers(lockDir string) ([]string, error) {
113 pattern := filepath.Join(lockDir, "session_*.marker")
114 paths, err := filepath.Glob(pattern)
115 if err != nil {
116 return nil, err
117 }
118
119 ids := make(map[string]struct{}, len(paths))
120 for _, path := range paths {
121 base := filepath.Base(path)
122 sessionID, ok := markerSessionID(base)
123 if !ok {
124 continue
125 }
126 ids[sessionID] = struct{}{}
127 }
128
129 sessionIDs := make([]string, 0, len(ids))
130 for id := range ids {
131 sessionIDs = append(sessionIDs, id)
132 }
133 sort.Strings(sessionIDs)
134 return sessionIDs, nil
135}
136
137func markerSessionID(base string) (string, bool) {
138 if !strings.HasPrefix(base, "session_") || !strings.HasSuffix(base, ".marker") {
139 return "", false
140 }
141
142 trimmed := strings.TrimSuffix(strings.TrimPrefix(base, "session_"), ".marker")
143 switch {
144 case strings.HasSuffix(trimmed, ".startup"):
145 return strings.TrimSuffix(trimmed, ".startup"), true
146 case strings.HasSuffix(trimmed, ".shutdown"):
147 return strings.TrimSuffix(trimmed, ".shutdown"), true
148 case strings.HasSuffix(trimmed, ".abrupt"):
149 return strings.TrimSuffix(trimmed, ".abrupt"), true
150 default:
151 return "", false
152 }
153}
154
155func readMarkerTime(path, keyPrefix string) (*time.Time, bool, time.Time, error) {
156 stat, err := os.Stat(path)
157 if err != nil {
158 if os.IsNotExist(err) {
159 return nil, false, time.Time{}, nil
160 }
161 return nil, false, time.Time{}, err
162 }
163
164 content, err := os.ReadFile(path)
165 if err != nil {
166 return nil, true, stat.ModTime(), err
167 }
168
169 raw := strings.TrimSpace(string(content))
170 if raw == "" {
171 return nil, true, stat.ModTime(), nil
172 }
173
174 if keyPrefix != "" {
175 lines := strings.Split(raw, "\n")
176 for _, line := range lines {
177 if strings.HasPrefix(line, keyPrefix) {
178 parsed, parseErr := time.Parse(time.RFC3339Nano, strings.TrimPrefix(line, keyPrefix))
179 if parseErr != nil {
180 return nil, true, stat.ModTime(), nil
181 }
182 return &parsed, true, stat.ModTime(), nil
183 }
184 }
185 return nil, true, stat.ModTime(), nil
186 }
187
188 // When no key prefix is given, parse the first line as the timestamp.
189 // Multi-line markers may contain additional metadata (e.g. started_at=).
190 firstLine, _, _ := strings.Cut(raw, "\n")
191 parsed, parseErr := time.Parse(time.RFC3339Nano, firstLine)
192 if parseErr != nil {
193 return nil, true, stat.ModTime(), nil
194 }
195 return &parsed, true, stat.ModTime(), nil
196}
197
198func latestNonZero(values ...time.Time) time.Time {
199 var latest time.Time
200 for _, value := range values {
201 if value.IsZero() {
202 continue
203 }
204 if latest.IsZero() || value.After(latest) {
205 latest = value
206 }
207 }
208 return latest
209}