| 1 | package sessioncatalog |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "fmt" |
| 7 | |
| 8 | "reasonix/internal/agent" |
| 9 | ) |
| 10 | |
| 11 | // HeadRecord is one head of a schema-2 session as the catalog projects it. |
| 12 | // Rows come from the kernel's head index sidecar, never from replaying a log. |
| 13 | type HeadRecord struct { |
| 14 | Path string `json:"path"` |
| 15 | ID string `json:"id"` |
| 16 | ParentHeadID string `json:"parentHeadId,omitempty"` |
| 17 | Kind string `json:"kind"` |
| 18 | Name string `json:"name,omitempty"` |
| 19 | LeafMessageID string `json:"leafMessageId,omitempty"` |
| 20 | WriterID string `json:"writerId,omitempty"` |
| 21 | LastActivityAt int64 `json:"lastActivityAt,omitempty"` |
| 22 | Turns int `json:"turns"` |
| 23 | Preview string `json:"preview,omitempty"` |
| 24 | Retired bool `json:"retired,omitempty"` |
| 25 | Selected bool `json:"selected,omitempty"` |
| 26 | } |
| 27 | |
| 28 | // sessionHeadProjection is what recordFromOrder learns about a schema-2 |
| 29 | // session without opening its log: the sidecar mirror is always available, |
| 30 | // the per-head rows only while the head index still matches the log. |
| 31 | type sessionHeadProjection struct { |
| 32 | logFormat int |
| 33 | headCount int |
| 34 | selected string |
| 35 | fingerprint string |
| 36 | heads []HeadRecord |
| 37 | stale bool |
| 38 | } |
| 39 | |
| 40 | func projectSessionHeads(info agent.SessionOrderInfo) sessionHeadProjection { |
| 41 | if info.LogSchema < 2 { |
| 42 | return sessionHeadProjection{logFormat: 1} |
| 43 | } |
| 44 | out := sessionHeadProjection{logFormat: info.LogSchema, headCount: info.HeadCount, selected: info.HeadID} |
| 45 | idx, err := agent.ReadSessionHeadIndex(info.Path) |
| 46 | if err != nil || idx == nil || !idx.Current(info.Path) { |
| 47 | out.stale = true |
| 48 | return out |
| 49 | } |
| 50 | out.selected = idx.SelectedHead |
| 51 | out.headCount = len(idx.Heads) |
| 52 | out.heads = make([]HeadRecord, 0, len(idx.Heads)) |
| 53 | for _, h := range idx.Heads { |
| 54 | out.heads = append(out.heads, HeadRecord{ |
| 55 | Path: info.Path, ID: h.ID, ParentHeadID: h.ParentHead, Kind: h.Kind, Name: h.Name, |
| 56 | LeafMessageID: h.LeafID, WriterID: h.Writer, LastActivityAt: unixMilli(h.LastActivity), |
| 57 | Turns: h.Turns, Preview: h.Preview, Retired: h.Retired, Selected: h.ID == idx.SelectedHead, |
| 58 | }) |
| 59 | if h.ID == idx.SelectedHead { |
| 60 | out.fingerprint = "|h:" + h.ID + ":" + h.LeafID |
| 61 | } |
| 62 | } |
| 63 | return out |
| 64 | } |
| 65 | |
| 66 | // writeDirectoryRow lands one scanned session row and its head rows inside |
| 67 | // the directory projection transaction. |
| 68 | func (c *Catalog) writeDirectoryRow(ctx context.Context, tx *sql.Tx, stmt *sql.Stmt, record SessionRecord, pathKey, directoryKey string, generation int64) error { |
| 69 | if _, err := stmt.ExecContext(ctx, c.sessionRowValues(record, pathKey, directoryKey, generation)...); err != nil { |
| 70 | return err |
| 71 | } |
| 72 | return upsertHeadRows(ctx, tx, pathKey, record.heads) |
| 73 | } |
| 74 | |
| 75 | // upsertHeadRows replaces the head rows of one session inside the projection |
| 76 | // transaction. Stale projections keep the previous rows until the head index |
| 77 | // is current again. |
| 78 | func upsertHeadRows(ctx context.Context, tx *sql.Tx, pathKey string, heads []HeadRecord) error { |
| 79 | if heads == nil { |
| 80 | return nil |
| 81 | } |
| 82 | if _, err := tx.ExecContext(ctx, `DELETE FROM catalog_heads WHERE path_key=?`, pathKey); err != nil { |
| 83 | return err |
| 84 | } |
| 85 | for _, h := range heads { |
| 86 | if _, err := tx.ExecContext(ctx, `INSERT INTO catalog_heads(path_key,head_id,parent_head_id,kind,name,leaf_message_id, |
| 87 | writer_id,last_activity_at,turns,preview,retired,selected) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, |
| 88 | pathKey, h.ID, h.ParentHeadID, h.Kind, h.Name, h.LeafMessageID, h.WriterID, h.LastActivityAt, h.Turns, h.Preview, |
| 89 | boolToInt(h.Retired), boolToInt(h.Selected)); err != nil { |
| 90 | return err |
| 91 | } |
| 92 | } |
| 93 | return nil |
| 94 | } |
| 95 | |
| 96 | // ListHeads returns the projected heads of one session in creation order. A |
| 97 | // schema-1 session has none and returns an empty slice. |
| 98 | func (c *Catalog) ListHeads(ctx context.Context, path string) ([]HeadRecord, error) { |
| 99 | out := []HeadRecord{} |
| 100 | if c == nil || path == "" { |
| 101 | return out, nil |
| 102 | } |
| 103 | rows, err := c.db.QueryContext(ctx, `SELECT head_id,parent_head_id,kind,name,leaf_message_id,writer_id,last_activity_at, |
| 104 | turns,preview,retired,selected FROM catalog_heads WHERE path_key=? ORDER BY rowid`, c.pathKey(path)) |
| 105 | if err != nil { |
| 106 | return out, fmt.Errorf("list session heads: %w", err) |
| 107 | } |
| 108 | defer rows.Close() |
| 109 | for rows.Next() { |
| 110 | var h HeadRecord |
| 111 | var retired, selected int |
| 112 | if err := rows.Scan(&h.ID, &h.ParentHeadID, &h.Kind, &h.Name, &h.LeafMessageID, &h.WriterID, &h.LastActivityAt, |
| 113 | &h.Turns, &h.Preview, &retired, &selected); err != nil { |
| 114 | return out, err |
| 115 | } |
| 116 | h.Path = path |
| 117 | h.Retired, h.Selected = retired != 0, selected != 0 |
| 118 | out = append(out, h) |
| 119 | } |
| 120 | return out, rows.Err() |
| 121 | } |
| 122 |