返回 DeepSeek-Reasonix
session_events_probe.go
根目录 / internal / agent / session_events_probe.go
1 package agent
2
3 import (
4 "encoding/json"
5 "io"
6 "os"
7 "path/filepath"
8
9 "reasonix/internal/store"
10 )
11
12 // sessionEventLogProbe classifies whatever sits at the session's event-log
13 // path. Legacy imports can leave a foreign ".events.jsonl" (e.g. the v0.x
14 // Claude-style event transcript) at exactly the native log path; writing into
15 // or over it would corrupt the user's original file, so foreign logs are
16 // read-ignored and never touched.
17 type sessionEventLogProbe struct {
18 size int64
19 native bool // missing/empty, or first record is a supported schema-1 event
20 dag bool // first record is a schema-2 DAG entry
21 futureSchema bool // first record declares a newer schema than this build
22 schemaVersion int
23 }
24
25 // sessionEventSidecarsFit reports whether the event log and index filenames
26 // stay within the filesystem's name limit. Overlong transcript names (from the
27 // pre-bounded recovery cascade, until reconcileOverlongSessionFilenames renames
28 // them) must run checkpoint-only: creating their sidecars would fail with
29 // ENAMETOOLONG mid-save.
30 func sessionEventSidecarsFit(sessionPath string) bool {
31 logName := filepath.Base(store.SessionEventLog(sessionPath))
32 indexName := filepath.Base(store.SessionEventIndex(sessionPath))
33 return len(logName) <= nameMaxBytes && len(indexName) <= nameMaxBytes
34 }
35
36 // probeSessionEventLog inspects the first record of the event log to decide
37 // whether the native persistence layer owns the file. Missing or empty logs
38 // count as native (we may create/append); an undecodable or foreign first
39 // record — or a transcript name too long for the sidecars to fit — marks the
40 // file as not ours.
41 func probeSessionEventLog(sessionPath string) (sessionEventLogProbe, error) {
42 return probeSessionEventLogWithLimits(sessionPath, defaultSessionReplayLimits)
43 }
44
45 func probeSessionEventLogWithLimits(sessionPath string, limits sessionReplayLimits) (sessionEventLogProbe, error) {
46 path := store.SessionEventLog(sessionPath)
47 if path == "" {
48 return sessionEventLogProbe{native: true}, nil
49 }
50 if !sessionEventSidecarsFit(sessionPath) {
51 return sessionEventLogProbe{}, nil
52 }
53 info, err := os.Stat(path)
54 if err != nil {
55 if os.IsNotExist(err) {
56 return sessionEventLogProbe{native: true}, nil
57 }
58 return sessionEventLogProbe{}, err
59 }
60 if info.IsDir() {
61 return sessionEventLogProbe{}, nil
62 }
63 if info.Size() == 0 {
64 return sessionEventLogProbe{native: true}, nil
65 }
66 probe := sessionEventLogProbe{size: info.Size()}
67 f, err := os.Open(path)
68 if err != nil {
69 return sessionEventLogProbe{}, err
70 }
71 defer f.Close()
72 var schemaVersion int
73 var eventType string
74 var ok bool
75 schemaVersion, eventType, ok = probeSessionEventHeader(f)
76 if !ok && info.Size() <= limits.maxBytes {
77 // Native writers put both identifying fields in the bounded prefix; for
78 // other in-budget JSON a minimal struct decode keeps field order a
79 // compatibility property rather than a format requirement.
80 if _, err := f.Seek(0, io.SeekStart); err != nil {
81 return sessionEventLogProbe{}, err
82 }
83 var header struct {
84 SchemaVersion int `json:"schema_version"`
85 Type string `json:"type"`
86 }
87 dec := json.NewDecoder(&io.LimitedReader{R: f, N: limits.maxBytes + 1})
88 if err := dec.Decode(&header); err == nil {
89 schemaVersion, eventType, ok = header.SchemaVersion, header.Type, true
90 }
91 }
92 if !ok {
93 // Nothing decodable at the head: not a native log this build can own.
94 return probe, nil
95 }
96 probe.schemaVersion = schemaVersion
97 switch {
98 case schemaVersion == sessionEventSchemaVersion &&
99 (eventType == sessionEventTypeReplace || eventType == sessionEventTypeAppend):
100 probe.native = true
101 case schemaVersion == sessionDAGSchemaVersion:
102 probe.dag = true
103 case schemaVersion > sessionDAGSchemaVersion:
104 // A newer writer owns this log; ignoring or truncating it would
105 // silently discard that writer's transcript.
106 probe.futureSchema = true
107 }
108 return probe, nil
109 }
110
111 // probeSessionEventHeader searches a bounded prefix for the identifying fields.
112 // Using Decode on a partial struct still buffers the whole JSON value, so native
113 // writer output must take this fast path before replay's byte budget is checked.
114 func probeSessionEventHeader(r io.Reader) (schemaVersion int, eventType string, ok bool) {
115 dec := json.NewDecoder(io.LimitReader(r, sessionEventProbeMaxBytes))
116 tok, err := dec.Token()
117 if err != nil {
118 return 0, "", false
119 }
120 if delim, isDelim := tok.(json.Delim); !isDelim || delim != '{' {
121 return 0, "", false
122 }
123 var haveSchema, haveType bool
124 for dec.More() {
125 key, err := dec.Token()
126 if err != nil {
127 return 0, "", false
128 }
129 name, isString := key.(string)
130 if !isString {
131 return 0, "", false
132 }
133 switch name {
134 case "schema_version":
135 if err := dec.Decode(&schemaVersion); err != nil {
136 return 0, "", false
137 }
138 haveSchema = true
139 case "type":
140 if err := dec.Decode(&eventType); err != nil {
141 return 0, "", false
142 }
143 haveType = true
144 default:
145 var discard json.RawMessage
146 if err := dec.Decode(&discard); err != nil {
147 return 0, "", false
148 }
149 }
150 if haveSchema && haveType {
151 return schemaVersion, eventType, true
152 }
153 }
154 return 0, "", false
155 }
156
156 lines GO