| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "database/sql" |
| 7 | "encoding" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "hash" |
| 11 | "os" |
| 12 | ) |
| 13 | |
| 14 | type checkpointPagerProgress struct { |
| 15 | Header SessionDisplayIndex `json:"header"` |
| 16 | Users int `json:"users"` |
| 17 | Hash []byte `json:"hash"` |
| 18 | } |
| 19 | |
| 20 | // The rebuild owner already fenced the entire source generation and holds its |
| 21 | // cross-process lock. Entries, offset, turn state and semantic digest always |
| 22 | // commit together. Nothing in this staging database is a readable generation. |
| 23 | func saveCheckpointPagerProgress(ctx context.Context, tx *sql.Tx, idx SessionDisplayIndex, users int, digest []byte) error { |
| 24 | body, err := json.Marshal(checkpointPagerProgress{Header: idx, Users: users, Hash: digest}) |
| 25 | if err != nil { |
| 26 | return err |
| 27 | } |
| 28 | _, err = tx.ExecContext(ctx, `INSERT OR REPLACE INTO metadata VALUES('checkpoint_progress',?)`, string(body)) |
| 29 | return err |
| 30 | } |
| 31 | |
| 32 | func restoreCheckpointPagerProgress(ctx context.Context, db *sql.DB, idx *SessionDisplayIndex, users *int, digest hash.Hash) error { |
| 33 | var raw string |
| 34 | err := db.QueryRowContext(ctx, `SELECT value FROM metadata WHERE key='checkpoint_progress'`).Scan(&raw) |
| 35 | if errors.Is(err, sql.ErrNoRows) { |
| 36 | // New staging is empty; a damaged/missing progress row cannot leave |
| 37 | // entries that a fresh zero-offset build could accidentally reuse. |
| 38 | _, err = db.ExecContext(ctx, `DELETE FROM entries; DELETE FROM metadata`) |
| 39 | return err |
| 40 | } |
| 41 | if err != nil { |
| 42 | return err |
| 43 | } |
| 44 | var progress checkpointPagerProgress |
| 45 | valid := json.Unmarshal([]byte(raw), &progress) == nil && progress.Header.SchemaVersion == SessionDisplayIndexSchemaVersion && |
| 46 | progress.Header.Entries == nil && progress.Header.MessageCount >= 0 && progress.Header.TranscriptSize >= 0 && |
| 47 | progress.Users >= 0 && progress.Users <= progress.Header.MessageCount && progress.Header.AuthoredTurns >= 0 && |
| 48 | progress.Header.AuthoredTurns <= progress.Header.MessageCount |
| 49 | var count int |
| 50 | var end int64 |
| 51 | if err := db.QueryRowContext(ctx, `SELECT COUNT(*),COALESCE(MAX(offset+length),0) FROM entries`).Scan(&count, &end); err != nil { |
| 52 | return err |
| 53 | } |
| 54 | valid = valid && count == progress.Header.MessageCount && end == progress.Header.TranscriptSize |
| 55 | if valid { |
| 56 | valid = digest.(encoding.BinaryUnmarshaler).UnmarshalBinary(progress.Hash) == nil |
| 57 | } |
| 58 | if !valid { |
| 59 | // Invalid disposable progress is rebuilt from the original source; it |
| 60 | // never authorizes accepting a prefix or changing the authoritative file. |
| 61 | digest.Reset() |
| 62 | _, err := db.ExecContext(ctx, `DELETE FROM entries; DELETE FROM metadata`) |
| 63 | return err |
| 64 | } |
| 65 | *idx, *users = progress.Header, progress.Users |
| 66 | return nil |
| 67 | } |
| 68 | |
| 69 | func publishCheckpointDisplayPager(ctx context.Context, db *sql.DB, source string, f *os.File, fingerprint string, idx SessionDisplayIndex, hasher hash.Hash) error { |
| 70 | var digest [sha256.Size]byte |
| 71 | copy(digest[:], hasher.Sum(nil)) |
| 72 | idx.ContentDigest = digestString(digest) |
| 73 | identity, known, err := SessionContentIdentity(source) |
| 74 | if err != nil { |
| 75 | return err |
| 76 | } |
| 77 | if known { |
| 78 | if identity.DigestHex != idx.ContentDigest { |
| 79 | return errors.New("checkpoint does not match authoritative identity") |
| 80 | } |
| 81 | idx.Revision, idx.RevisionKnown = identity.Revision, identity.RevisionKnown |
| 82 | } |
| 83 | if err := validateCheckpointPagerFile(source, f, fingerprint); err != nil { |
| 84 | return err |
| 85 | } |
| 86 | body, err := json.Marshal(idx) |
| 87 | if err != nil { |
| 88 | return err |
| 89 | } |
| 90 | _, err = db.ExecContext(ctx, `INSERT OR REPLACE INTO metadata VALUES('source',?),('header',?)`, fingerprint, string(body)) |
| 91 | return err |
| 92 | } |
| 93 |