返回 DeepSeek-Reasonix
session_events.go
根目录 / internal / agent / session_events.go
1 package agent
2
3 import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log/slog"
11 "os"
12 "path/filepath"
13 "time"
14
15 "reasonix/internal/fileutil"
16 fileencoding "reasonix/internal/fileutil/encoding"
17 "reasonix/internal/provider"
18 "reasonix/internal/store"
19 )
20
21 const (
22 sessionEventSchemaVersion = 1
23 sessionEventTypeReplace = "replace"
24 sessionEventTypeAppend = "append"
25 // sessionEventReplayMaxBytes caps decoder input before encoding/json can
26 // allocate an arbitrarily large record. Session logs normally compact far
27 // below this threshold; the generous ceiling still accommodates histories
28 // with embedded images while keeping corrupt logs from exhausting the host.
29 sessionEventReplayMaxBytes = int64(128 << 20)
30 // A byte limit alone is insufficient: a compact JSON array can expand into
31 // a much larger graph of messages and event records after decoding.
32 sessionEventReplayMaxRecords = 100_000
33 sessionEventReplayMaxMessages = 100_000
34 sessionEventReplayMaxCollectionItems = 100_000
35 sessionEventProbeMaxBytes = int64(4 << 10)
36 // sessionEventLogCompactFloor is the smallest log size that can trigger
37 // compaction, so short sessions never pay a checkpoint rewrite.
38 sessionEventLogCompactFloor = int64(256 << 10)
39 // sessionEventLogCompactFactor bounds the log at this multiple of the live
40 // transcript's encoded size; past it the log is rewritten to one replace
41 // event so replace-heavy histories (compaction, rewind) cannot grow the
42 // file without bound.
43 sessionEventLogCompactFactor = int64(4)
44 )
45
46 // ErrSessionReplayLimitExceeded identifies a session that was left untouched
47 // because replaying it would exceed the process safety budget. Callers must not
48 // fall back to an older checkpoint: the event log may contain newer turns.
49 var ErrSessionReplayLimitExceeded = errors.New("session history exceeds safe replay limits")
50
51 // SessionReplayLimitError carries machine-readable diagnostics while keeping
52 // Error free of local paths for Desktop surfaces that display startup errors.
53 type SessionReplayLimitError struct {
54 Path string
55 Resource string
56 Value int64
57 Limit int64
58 }
59
60 func (e *SessionReplayLimitError) Error() string {
61 if e == nil {
62 return ErrSessionReplayLimitExceeded.Error()
63 }
64 return fmt.Sprintf("%s: %s=%d, limit=%d; session files were left unchanged",
65 ErrSessionReplayLimitExceeded, e.Resource, e.Value, e.Limit)
66 }
67
68 func (e *SessionReplayLimitError) Unwrap() error {
69 return ErrSessionReplayLimitExceeded
70 }
71
72 type sessionReplayLimits struct {
73 maxBytes int64
74 maxRecords int
75 maxMessages int
76 maxCollectionItems int
77 }
78
79 var defaultSessionReplayLimits = sessionReplayLimits{
80 maxBytes: sessionEventReplayMaxBytes,
81 maxRecords: sessionEventReplayMaxRecords,
82 maxMessages: sessionEventReplayMaxMessages,
83 maxCollectionItems: sessionEventReplayMaxCollectionItems,
84 }
85
86 func sessionReplayLimitError(path, resource string, value, limit int64) error {
87 err := &SessionReplayLimitError{Path: path, Resource: resource, Value: value, Limit: limit}
88 slog.Warn("session: refusing unsafe event-log replay",
89 "path", path, "resource", resource, "value", value, "limit", limit)
90 return err
91 }
92
93 type sessionEventRecord struct {
94 SchemaVersion int `json:"schema_version"`
95 Type string `json:"type"`
96 Revision int64 `json:"revision,omitempty"`
97 BaseRevision int64 `json:"base_revision,omitempty"`
98 MessageIndex int `json:"message_index,omitempty"`
99 Messages []provider.Message `json:"messages,omitempty"`
100 ContentDigest string `json:"content_digest,omitempty"`
101 WriterID string `json:"writer_id,omitempty"`
102 Reason string `json:"reason,omitempty"`
103 CreatedAt time.Time `json:"created_at"`
104 }
105
106 // sessionEventWireRecord keeps the messages array encoded until the replay
107 // budget has been checked. Decoding directly into sessionEventRecord would
108 // materialize every provider.Message before replay could enforce maxMessages.
109 type sessionEventWireRecord struct {
110 SchemaVersion int `json:"schema_version"`
111 Type string `json:"type"`
112 Revision int64 `json:"revision,omitempty"`
113 BaseRevision int64 `json:"base_revision,omitempty"`
114 MessageIndex int `json:"message_index,omitempty"`
115 Messages json.RawMessage `json:"messages,omitempty"`
116 ContentDigest string `json:"content_digest,omitempty"`
117 WriterID string `json:"writer_id,omitempty"`
118 Reason string `json:"reason,omitempty"`
119 CreatedAt time.Time `json:"created_at"`
120 }
121
122 type sessionEventIndex struct {
123 SchemaVersion int `json:"schema_version"`
124 LogSize int64 `json:"log_size"`
125 MessageCount int `json:"message_count"`
126 Revision int64 `json:"revision"`
127 ContentDigest string `json:"content_digest"`
128 WriterID string `json:"writer_id"`
129 UpdatedAt time.Time `json:"updated_at"`
130 }
131
132 func SessionEventLogPath(sessionPath string) string {
133 return store.SessionEventLog(sessionPath)
134 }
135
136 func SessionEventIndexPath(sessionPath string) string {
137 return store.SessionEventIndex(sessionPath)
138 }
139
140 func sessionEventLogSize(sessionPath string) int64 {
141 path := store.SessionEventLog(sessionPath)
142 if path == "" {
143 return 0
144 }
145 info, err := os.Stat(path)
146 if err != nil || info.IsDir() {
147 return 0
148 }
149 return info.Size()
150 }
151
152 func sessionEventLogOversized(logSize, contentBytes int64) bool {
153 limit := sessionEventLogCompactFloor
154 if scaled := contentBytes * sessionEventLogCompactFactor; scaled > limit {
155 limit = scaled
156 }
157 return logSize > limit
158 }
159
160 // sessionEventReplay is the result of a tolerant event-log replay: the
161 // transcript up to the last cleanly applied record, plus enough bookkeeping
162 // for writers to self-heal a torn tail.
163 type sessionEventReplay struct {
164 msgs []provider.Message
165 // collectionItems counts the elements in every JSON array nested below a
166 // live message. Keeping this alongside msgs bounds slices such as tool calls,
167 // images, memory citations, and interrupted-turn recovery metadata without
168 // coupling replay safety to today's provider.Message field list.
169 collectionItems int
170 // times mirrors msgs with each message's record CreatedAt. Replace events
171 // collapse per-turn history, so their messages get the zero time and
172 // callers fall back to coarser timestamps.
173 times []time.Time
174 // records counts cleanly applied events.
175 records int
176 // lastGoodEnd is the byte offset just past the last cleanly applied
177 // record; truncating the log here drops only undecodable bytes.
178 lastGoodEnd int64
179 // size is the log size that was replayed.
180 size int64
181 // damaged is set when replay stopped early on a torn/corrupt record or a
182 // broken append chain. The prefix in msgs is still a valid historical
183 // state.
184 damaged bool
185 }
186
187 // sessionEventLogProbe classifies whatever sits at the session's event-log
188 // path. Legacy imports can leave a foreign ".events.jsonl" (e.g. the v0.x
189 // Claude-style event transcript) at exactly the native log path; writing into
190 // or over it would corrupt the user's original file, so foreign logs are
191 // read-ignored and never touched.
192 type sessionEventLogProbe struct {
193 size int64
194 native bool // missing/empty, or first record is a supported native event
195 futureSchema bool // first record declares a newer schema than this build
196 schemaVersion int
197 }
198
199 // sessionEventSidecarsFit reports whether the event log and index filenames
200 // stay within the filesystem's name limit. Overlong transcript names (from the
201 // pre-bounded recovery cascade, until reconcileOverlongSessionFilenames renames
202 // them) must run checkpoint-only: creating their sidecars would fail with
203 // ENAMETOOLONG mid-save.
204 func sessionEventSidecarsFit(sessionPath string) bool {
205 logName := filepath.Base(store.SessionEventLog(sessionPath))
206 indexName := filepath.Base(store.SessionEventIndex(sessionPath))
207 return len(logName) <= nameMaxBytes && len(indexName) <= nameMaxBytes
208 }
209
210 // probeSessionEventLog inspects the first record of the event log to decide
211 // whether the native persistence layer owns the file. Missing or empty logs
212 // count as native (we may create/append); an undecodable or foreign first
213 // record — or a transcript name too long for the sidecars to fit — marks the
214 // file as not ours.
215 func probeSessionEventLog(sessionPath string) (sessionEventLogProbe, error) {
216 return probeSessionEventLogWithLimits(sessionPath, defaultSessionReplayLimits)
217 }
218
219 func probeSessionEventLogWithLimits(sessionPath string, limits sessionReplayLimits) (sessionEventLogProbe, error) {
220 path := store.SessionEventLog(sessionPath)
221 if path == "" {
222 return sessionEventLogProbe{native: true}, nil
223 }
224 if !sessionEventSidecarsFit(sessionPath) {
225 return sessionEventLogProbe{}, nil
226 }
227 info, err := os.Stat(path)
228 if err != nil {
229 if os.IsNotExist(err) {
230 return sessionEventLogProbe{native: true}, nil
231 }
232 return sessionEventLogProbe{}, err
233 }
234 if info.IsDir() {
235 return sessionEventLogProbe{}, nil
236 }
237 if info.Size() == 0 {
238 return sessionEventLogProbe{native: true}, nil
239 }
240 probe := sessionEventLogProbe{size: info.Size()}
241 f, err := os.Open(path)
242 if err != nil {
243 return sessionEventLogProbe{}, err
244 }
245 defer f.Close()
246 var schemaVersion int
247 var eventType string
248 var ok bool
249 schemaVersion, eventType, ok = probeSessionEventHeader(f)
250 if !ok && info.Size() <= limits.maxBytes {
251 // Native writers put both identifying fields in the bounded prefix. For
252 // other valid in-budget JSON, fall back to a minimal struct decode so
253 // field order remains a compatibility property rather than a format
254 // requirement. Unknown fields are not materialized into messages.
255 if _, err := f.Seek(0, io.SeekStart); err != nil {
256 return sessionEventLogProbe{}, err
257 }
258 var header struct {
259 SchemaVersion int `json:"schema_version"`
260 Type string `json:"type"`
261 }
262 dec := json.NewDecoder(&io.LimitedReader{R: f, N: limits.maxBytes + 1})
263 if err := dec.Decode(&header); err == nil {
264 schemaVersion, eventType, ok = header.SchemaVersion, header.Type, true
265 }
266 }
267 if !ok {
268 // Nothing decodable at the head: not a native log this build can own.
269 return probe, nil
270 }
271 probe.schemaVersion = schemaVersion
272 switch {
273 case schemaVersion == sessionEventSchemaVersion &&
274 (eventType == sessionEventTypeReplace || eventType == sessionEventTypeAppend):
275 probe.native = true
276 case schemaVersion > sessionEventSchemaVersion:
277 // A newer writer owns this log; ignoring or truncating it would
278 // silently discard that writer's transcript.
279 probe.futureSchema = true
280 }
281 return probe, nil
282 }
283
284 // probeSessionEventHeader searches a bounded prefix for the identifying fields.
285 // Using Decode on a partial struct still buffers the whole JSON value, so native
286 // writer output must take this fast path before replay's byte budget is checked.
287 func probeSessionEventHeader(r io.Reader) (schemaVersion int, eventType string, ok bool) {
288 dec := json.NewDecoder(io.LimitReader(r, sessionEventProbeMaxBytes))
289 tok, err := dec.Token()
290 if err != nil {
291 return 0, "", false
292 }
293 if delim, isDelim := tok.(json.Delim); !isDelim || delim != '{' {
294 return 0, "", false
295 }
296 var haveSchema, haveType bool
297 for dec.More() {
298 key, err := dec.Token()
299 if err != nil {
300 return 0, "", false
301 }
302 name, isString := key.(string)
303 if !isString {
304 return 0, "", false
305 }
306 switch name {
307 case "schema_version":
308 if err := dec.Decode(&schemaVersion); err != nil {
309 return 0, "", false
310 }
311 haveSchema = true
312 case "type":
313 if err := dec.Decode(&eventType); err != nil {
314 return 0, "", false
315 }
316 haveType = true
317 default:
318 var discard json.RawMessage
319 if err := dec.Decode(&discard); err != nil {
320 return 0, "", false
321 }
322 }
323 if haveSchema && haveType {
324 return schemaVersion, eventType, true
325 }
326 }
327 return 0, "", false
328 }
329
330 // replaySessionEventLog decodes an event log tolerantly: decoding stops at the
331 // first record that fails to parse or chain, and the state up to that point is
332 // returned with damaged=true so writers can self-heal. Unsupported schema
333 // versions and unknown event types stay hard errors — they mean a newer writer
334 // owns this log, and truncating it would discard that writer's data.
335 func replaySessionEventLog(path string) (sessionEventReplay, error) {
336 return replaySessionEventLogWithLimits(path, defaultSessionReplayLimits)
337 }
338
339 func replaySessionEventLogWithLimits(path string, limits sessionReplayLimits) (sessionEventReplay, error) {
340 f, err := os.Open(path)
341 if err != nil {
342 return sessionEventReplay{}, err
343 }
344 defer f.Close()
345 info, err := f.Stat()
346 if err != nil {
347 return sessionEventReplay{}, err
348 }
349 replay := sessionEventReplay{size: info.Size()}
350 if info.Size() > limits.maxBytes {
351 return replay, sessionReplayLimitError(path, "encoded_bytes", info.Size(), limits.maxBytes)
352 }
353 // Stat and read are not atomic across processes. LimitReader keeps a log
354 // that grows after Stat inside the same byte budget.
355 limited := &io.LimitedReader{R: f, N: limits.maxBytes + 1}
356 dec := json.NewDecoder(limited)
357 for {
358 var rec sessionEventWireRecord
359 if err := dec.Decode(&rec); err != nil {
360 if limited.N == 0 {
361 return replay, sessionReplayLimitError(path, "encoded_bytes", limits.maxBytes+1, limits.maxBytes)
362 }
363 if errors.Is(err, io.EOF) {
364 return replay, nil
365 }
366 replay.damaged = true
367 return replay, nil
368 }
369 if rec.SchemaVersion != sessionEventSchemaVersion {
370 return replay, fmt.Errorf("decode session event log %s: unsupported schema version %d", path, rec.SchemaVersion)
371 }
372 if replay.records >= limits.maxRecords {
373 return replay, sessionReplayLimitError(path, "event_records", int64(replay.records+1), int64(limits.maxRecords))
374 }
375 switch rec.Type {
376 case sessionEventTypeReplace:
377 msgs, collectionItems, err := decodeSessionEventMessages(path, rec.Messages, 0, 0, limits)
378 if err != nil {
379 if errors.Is(err, ErrSessionReplayLimitExceeded) {
380 return replay, err
381 }
382 replay.damaged = true
383 return replay, nil
384 }
385 replay.msgs = msgs
386 replay.collectionItems = collectionItems
387 replay.times = make([]time.Time, len(replay.msgs))
388 case sessionEventTypeAppend:
389 if rec.MessageIndex != len(replay.msgs) {
390 replay.damaged = true
391 return replay, nil
392 }
393 msgs, collectionItems, err := decodeSessionEventMessages(
394 path, rec.Messages, len(replay.msgs), replay.collectionItems, limits,
395 )
396 if err != nil {
397 if errors.Is(err, ErrSessionReplayLimitExceeded) {
398 return replay, err
399 }
400 replay.damaged = true
401 return replay, nil
402 }
403 replay.msgs = append(replay.msgs, msgs...)
404 replay.collectionItems = collectionItems
405 for range msgs {
406 replay.times = append(replay.times, rec.CreatedAt)
407 }
408 default:
409 return replay, fmt.Errorf("decode session event log %s: unsupported event type %q", path, rec.Type)
410 }
411 replay.records++
412 replay.lastGoodEnd = dec.InputOffset()
413 }
414 }
415
416 // decodeSessionEventMessages preflights both the top-level message count and
417 // every nested JSON collection before constructing provider.Message values.
418 // The token walk is independent of today's provider.Message fields, so future
419 // slice fields inherit the same aggregate object-graph bound automatically.
420 func decodeSessionEventMessages(
421 path string,
422 raw json.RawMessage,
423 existingMessages, existingCollectionItems int,
424 limits sessionReplayLimits,
425 ) ([]provider.Message, int, error) {
426 trimmed := bytes.TrimSpace(raw)
427 if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
428 return nil, existingCollectionItems, nil
429 }
430 messageCount, collectionItems, err := preflightSessionEventMessages(
431 path, trimmed, existingMessages, existingCollectionItems, limits,
432 )
433 if err != nil {
434 return nil, existingCollectionItems, err
435 }
436 dec := json.NewDecoder(bytes.NewReader(trimmed))
437 tok, err := dec.Token()
438 if err != nil {
439 return nil, existingCollectionItems, err
440 }
441 if delim, ok := tok.(json.Delim); !ok || delim != '[' {
442 return nil, existingCollectionItems, fmt.Errorf("messages must be an array")
443 }
444 msgs := make([]provider.Message, 0, messageCount)
445 for dec.More() {
446 var msg provider.Message
447 if err := dec.Decode(&msg); err != nil {
448 return nil, existingCollectionItems, err
449 }
450 msgs = append(msgs, msg)
451 }
452 if _, err := dec.Token(); err != nil {
453 return nil, existingCollectionItems, err
454 }
455 return msgs, collectionItems, nil
456 }
457
458 func preflightSessionEventMessages(
459 path string,
460 raw []byte,
461 existingMessages, existingCollectionItems int,
462 limits sessionReplayLimits,
463 ) (messageCount, collectionItems int, err error) {
464 dec := json.NewDecoder(bytes.NewReader(raw))
465 tok, err := dec.Token()
466 if err != nil {
467 return 0, existingCollectionItems, err
468 }
469 if delim, ok := tok.(json.Delim); !ok || delim != '[' {
470 return 0, existingCollectionItems, fmt.Errorf("messages must be an array")
471 }
472 collectionItems = existingCollectionItems
473 for dec.More() {
474 if existingMessages+messageCount >= limits.maxMessages {
475 return 0, existingCollectionItems, sessionReplayLimitError(
476 path, "messages", int64(existingMessages+messageCount+1), int64(limits.maxMessages),
477 )
478 }
479 messageCount++
480 if err := preflightSessionEventValue(path, dec, &collectionItems, limits.maxCollectionItems); err != nil {
481 return 0, existingCollectionItems, err
482 }
483 }
484 if _, err := dec.Token(); err != nil {
485 return 0, existingCollectionItems, err
486 }
487 return messageCount, collectionItems, nil
488 }
489
490 // preflightSessionEventValue walks one JSON value without materializing maps or
491 // slices. Each array element is charged before its value is read, so an invalid
492 // over-limit element cannot allocate a typed provider collection first.
493 func preflightSessionEventValue(path string, dec *json.Decoder, collectionItems *int, maxCollectionItems int) error {
494 tok, err := dec.Token()
495 if err != nil {
496 return err
497 }
498 delim, ok := tok.(json.Delim)
499 if !ok {
500 return nil
501 }
502 switch delim {
503 case '{':
504 for dec.More() {
505 key, err := dec.Token()
506 if err != nil {
507 return err
508 }
509 if _, ok := key.(string); !ok {
510 return fmt.Errorf("object key must be a string")
511 }
512 if err := preflightSessionEventValue(path, dec, collectionItems, maxCollectionItems); err != nil {
513 return err
514 }
515 }
516 end, err := dec.Token()
517 if err != nil {
518 return err
519 }
520 if end != json.Delim('}') {
521 return fmt.Errorf("object is not terminated")
522 }
523 return nil
524 case '[':
525 for dec.More() {
526 if *collectionItems >= maxCollectionItems {
527 return sessionReplayLimitError(
528 path,
529 "message_collection_items",
530 int64(*collectionItems+1),
531 int64(maxCollectionItems),
532 )
533 }
534 (*collectionItems)++
535 if err := preflightSessionEventValue(path, dec, collectionItems, maxCollectionItems); err != nil {
536 return err
537 }
538 }
539 end, err := dec.Token()
540 if err != nil {
541 return err
542 }
543 if end != json.Delim(']') {
544 return fmt.Errorf("array is not terminated")
545 }
546 return nil
547 default:
548 return fmt.Errorf("unexpected JSON delimiter %q", delim)
549 }
550 }
551
552 // loadSessionMessages returns the session transcript, preferring the event log
553 // when the native layer owns it and it holds at least one decodable record.
554 // Foreign files squatting the log path (legacy import leftovers) are ignored
555 // in favor of the .jsonl checkpoint. damaged reports that a native log could
556 // not be replayed to its end (torn tail or corrupt record); callers that write
557 // should rewrite-and-compact to heal it.
558 func loadSessionMessages(sessionPath string) (msgs []provider.Message, fromEvents, damaged bool, err error) {
559 return loadSessionMessagesWithLimits(sessionPath, defaultSessionReplayLimits)
560 }
561
562 func loadSessionMessagesWithLimits(sessionPath string, limits sessionReplayLimits) (msgs []provider.Message, fromEvents, damaged bool, err error) {
563 probe, err := probeSessionEventLogWithLimits(sessionPath, limits)
564 if err != nil {
565 return nil, false, false, err
566 }
567 if probe.futureSchema {
568 return nil, true, false, fmt.Errorf("session event log for %s uses schema %d; this build supports up to %d", sessionPath, probe.schemaVersion, sessionEventSchemaVersion)
569 }
570 if probe.native && probe.size > 0 {
571 replay, replayErr := replaySessionEventLogWithLimits(store.SessionEventLog(sessionPath), limits)
572 if replayErr != nil {
573 return nil, true, false, replayErr
574 }
575 if replay.records > 0 {
576 return replay.msgs, true, replay.damaged, nil
577 }
578 // Defensive: the probe saw a native head but nothing replayed; fall
579 // back to the checkpoint and let the next save rebuild the log.
580 msgs, err = loadSessionMessagesFromJSONL(sessionPath)
581 return msgs, false, true, err
582 }
583 msgs, err = loadSessionMessagesFromJSONL(sessionPath)
584 return msgs, false, false, err
585 }
586
587 func loadSessionMessagesFromJSONL(path string) ([]provider.Message, error) {
588 f, err := os.Open(path)
589 if err != nil {
590 return nil, err
591 }
592 defer f.Close()
593
594 var msgs []provider.Message
595 dec := json.NewDecoder(f)
596 for {
597 var m provider.Message
598 if err := dec.Decode(&m); err != nil {
599 if errors.Is(err, io.EOF) {
600 break
601 }
602 return nil, fmt.Errorf("decode %s: %w", path, err)
603 }
604 msgs = append(msgs, m)
605 }
606 return msgs, nil
607 }
608
609 // repairSessionEventLogTail truncates undecodable bytes left by a crash or
610 // disk-full append so the next append cannot bury them mid-log where replay
611 // would stop forever. Callers must hold the session file lock. The event
612 // index's LogSize doubles as a cheap intact check so the common case never
613 // re-reads the log.
614 func repairSessionEventLogTail(sessionPath string) error {
615 path := store.SessionEventLog(sessionPath)
616 if path == "" {
617 return nil
618 }
619 info, err := os.Stat(path)
620 if err != nil {
621 if os.IsNotExist(err) {
622 return nil
623 }
624 return err
625 }
626 if info.IsDir() || info.Size() == 0 {
627 return nil
628 }
629 if idx, err := readSessionEventIndex(sessionPath); err == nil && idx != nil && idx.LogSize == info.Size() {
630 return nil
631 }
632 replay, err := replaySessionEventLog(path)
633 if err != nil {
634 return err
635 }
636 if replay.lastGoodEnd >= replay.size {
637 return nil
638 }
639 // Salvage the bytes the truncation below discards. A torn tail is usually
640 // one partial record, but replay also stops at a buried undecodable or
641 // out-of-order record (e.g. two runtimes interleaving appends on one log) —
642 // then everything past it, including intact turns, would be silently and
643 // permanently lost (#6607). Preservation is best-effort: it must not block
644 // the repair (the log has to become appendable again either way), and its
645 // most likely failure — a full disk — is the same condition that tears
646 // tails in the first place.
647 if preserveErr := preserveDamagedEventLogTail(sessionPath, path, replay.lastGoodEnd, replay.size); preserveErr != nil {
648 slog.Warn("session: could not preserve damaged event log tail; truncating anyway",
649 "path", path, "from", replay.lastGoodEnd, "size", replay.size, "err", preserveErr)
650 }
651 if err := os.Truncate(path, replay.lastGoodEnd); err != nil {
652 return err
653 }
654 if replay.lastGoodEnd == 0 {
655 return nil
656 }
657 // The truncation point sits exactly at the end of a JSON value; restore
658 // the trailing newline so the file stays line-oriented for external tools.
659 f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600)
660 if err != nil {
661 return err
662 }
663 if err := f.Chmod(0o600); err != nil {
664 _ = f.Close()
665 return err
666 }
667 if _, err := f.Write([]byte{'\n'}); err != nil {
668 f.Close()
669 return err
670 }
671 return f.Close()
672 }
673
674 // preserveDamagedEventLogTail appends the about-to-be-truncated byte range of
675 // the event log to the .damaged salvage sidecar, prefixed with a one-line JSON
676 // header recording when and where the bytes came from. The sidecar is a
677 // forensic artifact for recovery, never replayed by the loader, and is removed
678 // with the session's other sidecars on delete.
679 func preserveDamagedEventLogTail(sessionPath, logPath string, from, to int64) error {
680 if to <= from {
681 return nil
682 }
683 src, err := os.Open(logPath)
684 if err != nil {
685 return err
686 }
687 defer src.Close()
688 if _, err := src.Seek(from, io.SeekStart); err != nil {
689 return err
690 }
691 dst, err := os.OpenFile(store.SessionEventLogDamaged(sessionPath), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
692 if err != nil {
693 return err
694 }
695 header := fmt.Sprintf("{\"damaged_tail\":true,\"preserved_at\":%q,\"log_offset\":%d,\"bytes\":%d}\n",
696 time.Now().UTC().Format(time.RFC3339), from, to-from)
697 if _, err := dst.WriteString(header); err != nil {
698 dst.Close()
699 return err
700 }
701 if _, err := io.CopyN(dst, src, to-from); err != nil && !errors.Is(err, io.EOF) {
702 dst.Close()
703 return err
704 }
705 if _, err := dst.WriteString("\n"); err != nil {
706 dst.Close()
707 return err
708 }
709 return dst.Close()
710 }
711
712 func appendSessionEvent(sessionPath string, rec sessionEventRecord, sync bool) error {
713 path := store.SessionEventLog(sessionPath)
714 if path == "" {
715 return fmt.Errorf("empty session event log path")
716 }
717 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
718 return err
719 }
720 rec.SchemaVersion = sessionEventSchemaVersion
721 if rec.CreatedAt.IsZero() {
722 rec.CreatedAt = time.Now().UTC()
723 }
724 if rec.WriterID == "" {
725 rec.WriterID = SessionWriterID()
726 }
727 buf, err := json.Marshal(rec)
728 if err != nil {
729 return fmt.Errorf("encode session event: %w", err)
730 }
731 buf = append(buf, '\n')
732 f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
733 if err != nil {
734 return fmt.Errorf("open session event log: %w", err)
735 }
736 // The event log carries the complete transcript. Chmod after opening so
737 // upgrading a pre-v0.53-boundary 0644 sidecar tightens the existing inode
738 // before any unredacted message is appended; OpenFile's perm only applies
739 // when the file is newly created.
740 if err := f.Chmod(0o600); err != nil {
741 _ = f.Close()
742 return fmt.Errorf("protect session event log: %w", err)
743 }
744 if _, err := f.Write(buf); err != nil {
745 _ = f.Close()
746 return fmt.Errorf("append session event: %w", err)
747 }
748 if sync {
749 if err := f.Sync(); err != nil {
750 _ = f.Close()
751 return err
752 }
753 }
754 return f.Close()
755 }
756
757 func appendSessionReplaceEvent(sessionPath string, msgs []provider.Message, digest [sha256.Size]byte, baseRevision int64, reason string) error {
758 // Replace events carry the whole transcript and mark intentional history
759 // rewrites; they are rare and fsynced so a power cut cannot lose one.
760 return appendSessionEvent(sessionPath, sessionEventRecord{
761 Type: sessionEventTypeReplace,
762 Revision: baseRevision + 1,
763 BaseRevision: baseRevision,
764 MessageIndex: 0,
765 Messages: append([]provider.Message(nil), msgs...),
766 ContentDigest: digestString(digest),
767 Reason: reason,
768 }, true)
769 }
770
771 func appendSessionAppendEvent(sessionPath string, messageIndex int, msgs []provider.Message, digest [sha256.Size]byte, baseRevision int64) error {
772 if len(msgs) == 0 {
773 return nil
774 }
775 return appendSessionEvent(sessionPath, sessionEventRecord{
776 Type: sessionEventTypeAppend,
777 Revision: baseRevision + 1,
778 BaseRevision: baseRevision,
779 MessageIndex: messageIndex,
780 Messages: append([]provider.Message(nil), msgs...),
781 ContentDigest: digestString(digest),
782 }, true)
783 }
784
785 // compactSessionEventLog rewrites the log as a single replace event via an
786 // atomic tmp+fsync+rename, so readers observe either the old log or the
787 // compacted one and never a partial state. It also heals a damaged log by
788 // construction.
789 func compactSessionEventLog(sessionPath string, msgs []provider.Message, digest [sha256.Size]byte, baseRevision int64, reason string) error {
790 path := store.SessionEventLog(sessionPath)
791 if path == "" {
792 return fmt.Errorf("empty session event log path")
793 }
794 rec := sessionEventRecord{
795 SchemaVersion: sessionEventSchemaVersion,
796 Type: sessionEventTypeReplace,
797 Revision: baseRevision + 1,
798 BaseRevision: baseRevision,
799 Messages: append([]provider.Message(nil), msgs...),
800 ContentDigest: digestString(digest),
801 WriterID: SessionWriterID(),
802 Reason: reason,
803 CreatedAt: time.Now().UTC(),
804 }
805 buf, err := json.Marshal(rec)
806 if err != nil {
807 return fmt.Errorf("encode session event: %w", err)
808 }
809 buf = append(buf, '\n')
810 return fileutil.AtomicWriteFile(path, buf, 0o600)
811 }
812
813 func readSessionEventIndex(sessionPath string) (*sessionEventIndex, error) {
814 path := store.SessionEventIndex(sessionPath)
815 if path == "" {
816 return nil, nil
817 }
818 b, err := fileencoding.ReadFileUTF8(path)
819 if err != nil {
820 return nil, err
821 }
822 var idx sessionEventIndex
823 if err := json.Unmarshal(b, &idx); err != nil {
824 return nil, err
825 }
826 if idx.SchemaVersion != sessionEventSchemaVersion {
827 return nil, fmt.Errorf("unsupported session event index schema %d", idx.SchemaVersion)
828 }
829 return &idx, nil
830 }
831
832 func writeSessionEventIndex(path string, msgs []provider.Message, digest [sha256.Size]byte, revision int64) error {
833 indexPath := store.SessionEventIndex(path)
834 if indexPath == "" {
835 return nil
836 }
837 logInfo, err := os.Stat(store.SessionEventLog(path))
838 if err != nil {
839 if os.IsNotExist(err) {
840 // No log means nothing for the index to describe; drop a stale
841 // index (e.g. after a force save folded the log away).
842 if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
843 return err
844 }
845 return nil
846 }
847 return err
848 }
849 idx := sessionEventIndex{
850 SchemaVersion: sessionEventSchemaVersion,
851 LogSize: logInfo.Size(),
852 MessageCount: len(msgs),
853 Revision: revision,
854 ContentDigest: digestString(digest),
855 WriterID: SessionWriterID(),
856 UpdatedAt: time.Now().UTC(),
857 }
858 b, err := json.MarshalIndent(idx, "", " ")
859 if err != nil {
860 return err
861 }
862 b = append(b, '\n')
863 if err := os.MkdirAll(filepath.Dir(indexPath), 0o755); err != nil {
864 return err
865 }
866 tmp, err := os.CreateTemp(filepath.Dir(indexPath), ".session-event-index.*.tmp")
867 if err != nil {
868 return err
869 }
870 tmpPath := tmp.Name()
871 if _, err := tmp.Write(b); err != nil {
872 tmp.Close()
873 os.Remove(tmpPath)
874 return err
875 }
876 if err := tmp.Close(); err != nil {
877 os.Remove(tmpPath)
878 return err
879 }
880 if err := fileutil.ReplaceFile(tmpPath, indexPath); err != nil {
881 os.Remove(tmpPath)
882 return err
883 }
884 return nil
885 }
886
886 lines GO