v0.27.2 5960dd8

session_exit_classification.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close