| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | // UpdateSessionListingProjectionIfCurrent publishes counts decoded from one |
| 9 | // persisted transcript generation together with the runtime's acknowledged |
| 10 | // model identity. It rechecks both the transcript digest and its sidecar |
| 11 | // identity while holding the save lock, so an autosave that landed after the |
| 12 | // caller's decode cannot receive the stale projection. |
| 13 | func UpdateSessionListingProjectionIfCurrent(sessionPath, model, identity, preview string, turns int, markActivity bool, expected PersistedState) (bool, error) { |
| 14 | return updateSessionListingProjectionIfCurrent(sessionPath, model, identity, preview, turns, markActivity, expected, nil, false) |
| 15 | } |
| 16 | |
| 17 | // UpdateOwnedSessionListingProjectionIfCurrent also fences the runtime that |
| 18 | // saved the transcript. Model-only changes need not change its digest, so the |
| 19 | // transcript CAS alone cannot reject a retired runtime's delayed publication. |
| 20 | func UpdateOwnedSessionListingProjectionIfCurrent(sessionPath, model, identity, preview string, turns int, markActivity bool, expected PersistedState, authority *SessionWriteAuthority) (bool, error) { |
| 21 | return updateSessionListingProjectionIfCurrent(sessionPath, model, identity, preview, turns, markActivity, expected, authority, true) |
| 22 | } |
| 23 | |
| 24 | func updateSessionListingProjectionIfCurrent(sessionPath, model, identity, preview string, turns int, markActivity bool, expected PersistedState, authority *SessionWriteAuthority, requireAuthority bool) (bool, error) { |
| 25 | if strings.TrimSpace(sessionPath) == "" { |
| 26 | return false, fmt.Errorf("empty session path") |
| 27 | } |
| 28 | unlockSave := lockSessionSavePath(sessionPath) |
| 29 | defer unlockSave() |
| 30 | unlockFile, err := lockSessionFile(sessionPath) |
| 31 | if err != nil { |
| 32 | return false, fmt.Errorf("lock session file: %w", err) |
| 33 | } |
| 34 | defer unlockFile() |
| 35 | _, current, _, err := loadSessionDisplayMessagesUnlocked(sessionPath) |
| 36 | if err != nil { |
| 37 | return false, err |
| 38 | } |
| 39 | if current.DigestHex != expected.DigestHex || current.RevisionKnown != expected.RevisionKnown || |
| 40 | current.RevisionKnown && current.Revision != expected.Revision { |
| 41 | return false, nil |
| 42 | } |
| 43 | |
| 44 | unlockMeta, err := LockSessionMetaPath(sessionPath) |
| 45 | if err != nil { |
| 46 | return false, err |
| 47 | } |
| 48 | defer unlockMeta() |
| 49 | if requireAuthority { |
| 50 | unlockAuthority, err := authority.lockCurrentLease(sessionPath) |
| 51 | if err != nil { |
| 52 | return false, err |
| 53 | } |
| 54 | defer unlockAuthority() |
| 55 | } |
| 56 | meta, err := ensureBranchMetaUnlocked(sessionPath) |
| 57 | if err != nil { |
| 58 | return false, err |
| 59 | } |
| 60 | digest := strings.TrimSpace(meta.ContentDigest) |
| 61 | if expected.RevisionKnown { |
| 62 | if meta.Revision != expected.Revision || digest != expected.DigestHex { |
| 63 | return false, nil |
| 64 | } |
| 65 | } else if meta.Revision != 0 || digest != "" { |
| 66 | return false, nil |
| 67 | } |
| 68 | if strings.TrimSpace(model) != "" { |
| 69 | setMetaModelSelection(&meta, model, &identity) |
| 70 | } |
| 71 | meta.Preview = preview |
| 72 | meta.Turns = turns |
| 73 | meta.SchemaVersion = BranchMetaCountsVersion |
| 74 | stampSessionListingProjection(&meta) |
| 75 | if err := saveBranchMeta(sessionPath, meta, markActivity); err != nil { |
| 76 | return false, err |
| 77 | } |
| 78 | return true, nil |
| 79 | } |
| 80 |