1+ // src/db/branches.rs
2+ //
3+ // Persistent record of every agent branch created by Stackbox.
4+ //
5+ // KEY DESIGN:
6+ // - One row per agent session (runbox_id + session_id + agent_kind).
7+ // - branch (stackbox/{short}/{slug}) is permanent — survives worktree removal.
8+ // - worktree_path is nullable — set on spawn, cleared when PTY exits.
9+ // - status: working → done → merged | deleted
10+ //
11+ // Schema DDL lives in db/schema.rs::migrate().
12+
13+ use rusqlite:: { params, Result } ;
14+ use super :: { Db , now_ms} ;
15+
16+ // ─────────────────────────────────────────────────────────────────────────────
17+ // Types
18+ // ─────────────────────────────────────────────────────────────────────────────
19+
20+ #[ derive( Debug , Clone , serde:: Serialize ) ]
21+ pub struct AgentBranch {
22+ pub id : String ,
23+ pub runbox_id : String ,
24+ pub session_id : String ,
25+ pub agent_kind : String ,
26+ /// e.g. "stackbox/a1b2c3d4/codex"
27+ pub branch : String ,
28+ /// None once PTY exits; branch still lives.
29+ pub worktree_path : Option < String > ,
30+ /// working | done | merged | deleted
31+ pub status : String ,
32+ pub commit_count : i64 ,
33+ pub created_at : i64 ,
34+ pub updated_at : i64 ,
35+ /// None until user merges.
36+ pub merged_at : Option < i64 > ,
37+ }
38+
39+ fn row_to_branch ( row : & rusqlite:: Row < ' _ > ) -> rusqlite:: Result < AgentBranch > {
40+ Ok ( AgentBranch {
41+ id : row. get ( 0 ) ?,
42+ runbox_id : row. get ( 1 ) ?,
43+ session_id : row. get ( 2 ) ?,
44+ agent_kind : row. get ( 3 ) ?,
45+ branch : row. get ( 4 ) ?,
46+ worktree_path : row. get ( 5 ) ?,
47+ status : row. get ( 6 ) ?,
48+ commit_count : row. get ( 7 ) ?,
49+ created_at : row. get ( 8 ) ?,
50+ updated_at : row. get ( 9 ) ?,
51+ merged_at : row. get ( 10 ) ?,
52+ } )
53+ }
54+
55+ // ─────────────────────────────────────────────────────────────────────────────
56+ // Write — lifecycle events
57+ // ─────────────────────────────────────────────────────────────────────────────
58+
59+ /// Called at PTY spawn: create the branch record with worktree_path set.
60+ pub fn record_branch_start (
61+ db : & Db ,
62+ runbox_id : & str ,
63+ session_id : & str ,
64+ agent_kind : & str ,
65+ branch : & str ,
66+ worktree_path : & str ,
67+ ) -> Result < ( ) > {
68+ let id = format ! ( "{runbox_id}-{session_id}" ) ;
69+ let rb = runbox_id. to_string ( ) ;
70+ let sid = session_id. to_string ( ) ;
71+ let ak = agent_kind. to_string ( ) ;
72+ let br = branch. to_string ( ) ;
73+ let wt = worktree_path. to_string ( ) ;
74+ let ts = now_ms ( ) ;
75+
76+ db. write_async ( move |conn| {
77+ let _ = conn. execute (
78+ "INSERT INTO agent_branches
79+ (id, runbox_id, session_id, agent_kind, branch, worktree_path,
80+ status, commit_count, created_at, updated_at)
81+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'working', 0, ?7, ?7)
82+ ON CONFLICT(id) DO UPDATE SET
83+ worktree_path = excluded.worktree_path,
84+ status = 'working',
85+ updated_at = excluded.updated_at" ,
86+ params ! [ id, rb, sid, ak, br, wt, ts] ,
87+ ) ;
88+ } ) ;
89+ Ok ( ( ) )
90+ }
91+
92+ /// Called when PTY exits naturally or is killed.
93+ /// Clears worktree_path (directory removed) but keeps the branch.
94+ pub fn record_branch_done ( db : & Db , runbox_id : & str , session_id : & str ) -> Result < ( ) > {
95+ let id = format ! ( "{runbox_id}-{session_id}" ) ;
96+ let ts = now_ms ( ) ;
97+ db. write_async ( move |conn| {
98+ let _ = conn. execute (
99+ "UPDATE agent_branches SET
100+ worktree_path = NULL,
101+ status = CASE WHEN status = 'working' THEN 'done' ELSE status END,
102+ updated_at = ?1
103+ WHERE id = ?2" ,
104+ params ! [ ts, id] ,
105+ ) ;
106+ } ) ;
107+ Ok ( ( ) )
108+ }
109+
110+ /// Called after user merges the branch into main.
111+ pub fn record_branch_merged ( db : & Db , branch : & str ) -> Result < ( ) > {
112+ let br = branch. to_string ( ) ;
113+ let ts = now_ms ( ) ;
114+ db. write_async ( move |conn| {
115+ let _ = conn. execute (
116+ "UPDATE agent_branches SET status='merged', merged_at=?1, updated_at=?1
117+ WHERE branch=?2" ,
118+ params ! [ ts, br] ,
119+ ) ;
120+ } ) ;
121+ Ok ( ( ) )
122+ }
123+
124+ /// Called after user explicitly deletes a branch.
125+ pub fn record_branch_deleted ( db : & Db , branch : & str ) -> Result < ( ) > {
126+ let br = branch. to_string ( ) ;
127+ let ts = now_ms ( ) ;
128+ db. write_async ( move |conn| {
129+ let _ = conn. execute (
130+ "UPDATE agent_branches SET status='deleted', updated_at=?1 WHERE branch=?2" ,
131+ params ! [ ts, br] ,
132+ ) ;
133+ } ) ;
134+ Ok ( ( ) )
135+ }
136+
137+ /// Increment the commit count for a branch (called after git_commit).
138+ pub fn increment_commit_count ( db : & Db , branch : & str ) -> Result < ( ) > {
139+ let br = branch. to_string ( ) ;
140+ let ts = now_ms ( ) ;
141+ db. write_async ( move |conn| {
142+ let _ = conn. execute (
143+ "UPDATE agent_branches SET commit_count=commit_count+1, updated_at=?1 WHERE branch=?2" ,
144+ params ! [ ts, br] ,
145+ ) ;
146+ } ) ;
147+ Ok ( ( ) )
148+ }
149+
150+ // ─────────────────────────────────────────────────────────────────────────────
151+ // Read
152+ // ─────────────────────────────────────────────────────────────────────────────
153+
154+ /// All branch records for a runbox, newest first.
155+ pub fn list_for_runbox ( db : & Db , runbox_id : & str ) -> Result < Vec < AgentBranch > > {
156+ let conn = db. read ( ) ;
157+ let mut stmt = conn. prepare (
158+ "SELECT id, runbox_id, session_id, agent_kind, branch, worktree_path,
159+ status, commit_count, created_at, updated_at, merged_at
160+ FROM agent_branches WHERE runbox_id=?1
161+ ORDER BY created_at DESC" ,
162+ ) ?;
163+ let rows = stmt. query_map ( params ! [ runbox_id] , row_to_branch) ?;
164+ Ok ( rows. filter_map ( |r| r. ok ( ) ) . collect ( ) )
165+ }
166+
167+ /// All branches with an active worktree (worktree_path IS NOT NULL).
168+ /// Used by cleanup to detect orphans.
169+ pub fn list_active_worktrees ( db : & Db ) -> Vec < String > {
170+ let conn = db. read ( ) ;
171+ let mut stmt = match conn. prepare (
172+ "SELECT worktree_path FROM agent_branches WHERE worktree_path IS NOT NULL" ,
173+ ) {
174+ Ok ( s) => s,
175+ Err ( _) => return vec ! [ ] ,
176+ } ;
177+ stmt. query_map ( [ ] , |row| row. get :: < _ , String > ( 0 ) )
178+ . ok ( )
179+ . map ( |rows| rows. filter_map ( |r| r. ok ( ) ) . collect ( ) )
180+ . unwrap_or_default ( )
181+ }
182+
183+ /// Get a single branch record by branch name.
184+ pub fn get_by_branch ( db : & Db , branch : & str ) -> Option < AgentBranch > {
185+ db. read ( )
186+ . query_row (
187+ "SELECT id, runbox_id, session_id, agent_kind, branch, worktree_path,
188+ status, commit_count, created_at, updated_at, merged_at
189+ FROM agent_branches WHERE branch=?1 ORDER BY created_at DESC LIMIT 1" ,
190+ params ! [ branch] ,
191+ row_to_branch,
192+ )
193+ . ok ( )
194+ }
195+
196+ // ─────────────────────────────────────────────────────────────────────────────
197+ // Legacy compatibility — keep runbox_set_worktree working for callers not yet migrated
198+ // ─────────────────────────────────────────────────────────────────────────────
199+
200+ pub use super :: runboxes:: {
201+ runbox_set_branch,
202+ runbox_delete,
203+ } ;
0 commit comments