| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "os" |
| 6 | |
| 7 | "reasonix/internal/provider" |
| 8 | "reasonix/internal/store" |
| 9 | ) |
| 10 | |
| 11 | // classifySnapshotWrite decides the write shape for a save. Writer-bound |
| 12 | // sessions try the event-log-tail CAS first and only fall back to a full |
| 13 | // disk classification when that tail no longer matches this writer's baseline. |
| 14 | func (s *Session) classifySnapshotWrite(path string, next []provider.Message, nextDigest [sha256.Size]byte, nextVersion uint64, allowOwnedRewrite bool) (snapshotWriteDecision, error) { |
| 15 | if decision, ok := s.writerTailDecision(path, next, nextDigest, allowOwnedRewrite); ok { |
| 16 | return decision, nil |
| 17 | } |
| 18 | return s.checkSnapshotWrite(path, next, nextDigest, nextVersion, allowOwnedRewrite) |
| 19 | } |
| 20 | |
| 21 | // writerTailDecision is the event-log-tail CAS for writer-bound saves. |
| 22 | // ok is false unless a live SessionWriter authority, a paired persist view, |
| 23 | // and an unchanged event-log tail (size + index revision/digest) all agree. |
| 24 | func (s *Session) writerTailDecision(path string, next []provider.Message, nextDigest [sha256.Size]byte, allowOwnedRewrite bool) (snapshotWriteDecision, bool) { |
| 25 | s.mu.RLock() |
| 26 | auth := s.writeAuth |
| 27 | normalizedDirty := s.normalizedDirty |
| 28 | damaged := s.eventLogDamaged |
| 29 | persisted := s.persistedMessages |
| 30 | viewPath := s.persistedViewPath |
| 31 | s.mu.RUnlock() |
| 32 | if auth == nil || auth.writer == nil { |
| 33 | return snapshotWriteDecision{}, false |
| 34 | } |
| 35 | if normalizedDirty || damaged || !auth.Valid() { |
| 36 | return snapshotWriteDecision{}, false |
| 37 | } |
| 38 | key := canonicalSessionSavePath(path) |
| 39 | if viewPath != key { |
| 40 | return snapshotWriteDecision{}, false |
| 41 | } |
| 42 | base, ok := auth.writer.Baseline(path) |
| 43 | if !ok || !base.RevisionKnown || base.LogTail == 0 { |
| 44 | return snapshotWriteDecision{}, false |
| 45 | } |
| 46 | idx, err := readSessionEventIndex(path) |
| 47 | if err != nil || idx == nil || |
| 48 | idx.LogSize != base.LogTail || idx.Revision != base.Revision || idx.ContentDigest != base.ContentDigest { |
| 49 | return snapshotWriteDecision{}, false |
| 50 | } |
| 51 | info, err := os.Stat(store.SessionEventLog(path)) |
| 52 | if err != nil || info.Size() != base.LogTail { |
| 53 | return snapshotWriteDecision{}, false |
| 54 | } |
| 55 | ledgerRevision, ledgerDigest, err := sessionContentRevision(path) |
| 56 | if err != nil || ledgerRevision != base.Revision || ledgerDigest != base.ContentDigest { |
| 57 | // A failed commit can reserve the next metadata revision before its WAL |
| 58 | // record lands. Treat that reservation as a changed tail identity so the |
| 59 | // full disk classifier adopts it as the next commit's base. |
| 60 | return snapshotWriteDecision{}, false |
| 61 | } |
| 62 | |
| 63 | nextDigestHex := digestString(nextDigest) |
| 64 | if base.ContentDigest == nextDigestHex { |
| 65 | return snapshotWriteDecision{revision: base.Revision, upToDate: true}, true |
| 66 | } |
| 67 | if allowOwnedRewrite { |
| 68 | return snapshotWriteDecision{revision: base.Revision}, true |
| 69 | } |
| 70 | if messagesHavePrefix(next, persisted) { |
| 71 | if len(persisted) < len(next) { |
| 72 | return snapshotWriteDecision{revision: base.Revision, appendOnly: true, appendFrom: len(persisted)}, true |
| 73 | } |
| 74 | return snapshotWriteDecision{revision: base.Revision}, true |
| 75 | } |
| 76 | if messagesHavePrefixWithCompatibleSystem(next, persisted) { |
| 77 | return snapshotWriteDecision{revision: base.Revision}, true |
| 78 | } |
| 79 | return snapshotWriteDecision{}, false |
| 80 | } |
| 81 |