Skip to content

Commit 98a1a0b

Browse files
feat: split thread state into runStates/lifecycleStates with full PR lifecycle reconciliation
Rearchitects PR/worktree state tracking. The `worktrees` table is now the single source of truth; `get_pr_status` is the one place that reconciles GitHub state into the DB, handling merged/closed/reopened/reverted/missing PRs in one gh call per poll. Frontend splits run state (agent process) from lifecycle state (worktree/PR relationship) and surfaces the latter through a new LifecycleBanner pinned above the chat. Adds safer PR pushes with divergence detection, an open_external_url Tauri command, and a link_pr_to_thread command that actually persists the link.
1 parent 4813ddb commit 98a1a0b

38 files changed

Lines changed: 4026 additions & 490 deletions

.vite/deps/_metadata.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"hash": "86b9f780",
3+
"configHash": "cb03bef2",
4+
"lockfileHash": "e3b0c442",
5+
"browserHash": "57056a4f",
6+
"optimized": {},
7+
"chunks": {}
8+
}

.vite/deps/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "module"
3+
}

crates/core/src/id.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,11 @@ typed_id!(
155155
ToolCallId
156156
);
157157

158+
typed_id!(
159+
/// Unique identifier for a git worktree.
160+
WorktreeId
161+
);
162+
158163
/// Helper to generate a batch of IDs of the same type.
159164
pub fn generate_ids<T: From<Uuid>>(count: usize) -> Vec<T> {
160165
(0..count).map(|_| T::from(Uuid::new_v4())).collect()

crates/persistence/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ pub mod migrations;
33
pub mod models;
44
pub mod queries;
55

6-
pub use codeforge_core::id::{MessageId, ProjectId, SessionId, ThreadId};
6+
pub use codeforge_core::id::{MessageId, ProjectId, SessionId, ThreadId, WorktreeId};
77
pub use db::Database;
88
pub use models::*;

crates/persistence/src/migrations.rs

Lines changed: 170 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
use rusqlite::Connection;
22

3+
fn column_exists(conn: &Connection, table: &str, column: &str) -> rusqlite::Result<bool> {
4+
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
5+
let exists = stmt
6+
.query_map([], |row| row.get::<_, String>(1))?
7+
.any(|col| col.as_deref() == Ok(column));
8+
Ok(exists)
9+
}
10+
311
/// Run all database migrations. This is idempotent — tables are created only if
412
/// they do not already exist.
513
pub fn run_migrations(conn: &Connection) -> rusqlite::Result<()> {
@@ -46,12 +54,7 @@ pub fn run_migrations(conn: &Connection) -> rusqlite::Result<()> {
4654
)?;
4755

4856
// Add color column to threads if missing
49-
let has_color: bool = conn
50-
.prepare("PRAGMA table_info(threads)")?
51-
.query_map([], |row| row.get::<_, String>(1))?
52-
.any(|col| col.as_deref() == Ok("color"));
53-
54-
if !has_color {
57+
if !column_exists(conn, "threads", "color")? {
5558
conn.execute_batch("ALTER TABLE threads ADD COLUMN color TEXT;")?;
5659
}
5760

@@ -74,15 +77,167 @@ pub fn run_migrations(conn: &Connection) -> rusqlite::Result<()> {
7477
)?;
7578

7679
// Add claude_session_id column to sessions if missing (used for --resume)
77-
let has_claude_session_id: bool = conn
78-
.prepare("PRAGMA table_info(sessions)")?
79-
.query_map([], |row| row.get::<_, String>(1))?
80-
.any(|col| col.as_deref() == Ok("claude_session_id"));
81-
82-
if !has_claude_session_id {
80+
if !column_exists(conn, "sessions", "claude_session_id")? {
8381
conn.execute_batch("ALTER TABLE sessions ADD COLUMN claude_session_id TEXT;")?;
8482
}
8583

84+
// Worktrees table — replaces worktree:* and pr:* settings keys
85+
conn.execute_batch(
86+
"
87+
CREATE TABLE IF NOT EXISTS worktrees (
88+
id TEXT PRIMARY KEY NOT NULL,
89+
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
90+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
91+
branch TEXT NOT NULL,
92+
path TEXT NOT NULL,
93+
pr_number INTEGER,
94+
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'merged', 'deleted', 'orphaned')),
95+
created_at TEXT NOT NULL,
96+
updated_at TEXT NOT NULL
97+
);
98+
",
99+
)?;
100+
101+
// Migrate existing worktree/pr data from settings table into worktrees table.
102+
// Format in settings: worktree:<thread_id> = <branch>|<path>, pr:<thread_id> = <number>
103+
{
104+
let mut wt_stmt = conn.prepare(
105+
"SELECT key, value FROM settings WHERE key LIKE 'worktree:%'"
106+
)?;
107+
let wt_rows: Vec<(String, String)> = wt_stmt
108+
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))?
109+
.filter_map(|r| r.ok())
110+
.collect();
111+
112+
for (key, val) in &wt_rows {
113+
let thread_id = key.strip_prefix("worktree:").unwrap_or(key);
114+
let parts: Vec<&str> = val.splitn(2, '|').collect();
115+
if parts.len() != 2 { continue; }
116+
let branch = parts[0];
117+
let path = parts[1];
118+
119+
// Look up PR number if linked
120+
let pr_num: Option<u32> = conn
121+
.query_row(
122+
"SELECT value FROM settings WHERE key = ?1",
123+
rusqlite::params![format!("pr:{thread_id}")],
124+
|row| row.get::<_, String>(0),
125+
)
126+
.ok()
127+
.and_then(|v| v.parse().ok());
128+
129+
// Look up project_id from threads table
130+
let project_id: Option<String> = conn
131+
.query_row(
132+
"SELECT project_id FROM threads WHERE id = ?1",
133+
rusqlite::params![thread_id],
134+
|row| row.get(0),
135+
)
136+
.ok();
137+
138+
if let Some(project_id) = project_id {
139+
// Only migrate if not already present
140+
let exists: bool = conn
141+
.query_row(
142+
"SELECT COUNT(*) FROM worktrees WHERE thread_id = ?1",
143+
rusqlite::params![thread_id],
144+
|row| row.get::<_, i64>(0),
145+
)
146+
.unwrap_or(0) > 0;
147+
148+
if !exists {
149+
let id = uuid::Uuid::new_v4().to_string();
150+
let now = chrono::Utc::now().to_rfc3339();
151+
let _ = conn.execute(
152+
"INSERT INTO worktrees (id, thread_id, project_id, branch, path, pr_number, status, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', ?7, ?7)",
153+
rusqlite::params![id, thread_id, project_id, branch, path, pr_num, now],
154+
);
155+
}
156+
}
157+
}
158+
159+
// Clean up migrated settings
160+
if !wt_rows.is_empty() {
161+
conn.execute_batch(
162+
"DELETE FROM settings WHERE key LIKE 'worktree:%'; DELETE FROM settings WHERE key LIKE 'pr:%';",
163+
)?;
164+
}
165+
}
166+
167+
// Turn checkpoints — tracks HEAD commit at start of each AI turn for undo
168+
conn.execute_batch(
169+
"
170+
CREATE TABLE IF NOT EXISTS turn_checkpoints (
171+
id TEXT PRIMARY KEY NOT NULL,
172+
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
173+
turn_id TEXT NOT NULL,
174+
commit_sha TEXT NOT NULL,
175+
created_at TEXT NOT NULL
176+
);
177+
",
178+
)?;
179+
180+
// ── Add full PR lifecycle columns to worktrees ──
181+
if !column_exists(conn, "worktrees", "pr_state")? {
182+
conn.execute_batch("ALTER TABLE worktrees ADD COLUMN pr_state TEXT;")?;
183+
}
184+
if !column_exists(conn, "worktrees", "pr_merge_commit")? {
185+
conn.execute_batch("ALTER TABLE worktrees ADD COLUMN pr_merge_commit TEXT;")?;
186+
}
187+
if !column_exists(conn, "worktrees", "last_seen_comment_count")? {
188+
conn.execute_batch(
189+
"ALTER TABLE worktrees ADD COLUMN last_seen_comment_count INTEGER NOT NULL DEFAULT 0;",
190+
)?;
191+
}
192+
if !column_exists(conn, "worktrees", "pr_url")? {
193+
conn.execute_batch("ALTER TABLE worktrees ADD COLUMN pr_url TEXT;")?;
194+
}
195+
196+
// ── Expand the CHECK constraint on worktrees.status to include 'closed' ──
197+
// SQLite can't ALTER a CHECK constraint; we rebuild the table only if needed.
198+
let needs_status_rebuild: bool = conn
199+
.query_row(
200+
"SELECT COUNT(*) FROM sqlite_master
201+
WHERE type='table' AND name='worktrees'
202+
AND sql LIKE '%''active'', ''merged'', ''deleted'', ''orphaned''%'
203+
AND sql NOT LIKE '%''closed''%'",
204+
[],
205+
|row| row.get::<_, i64>(0),
206+
)
207+
.unwrap_or(0) > 0;
208+
209+
if needs_status_rebuild {
210+
conn.execute_batch(
211+
"
212+
CREATE TABLE worktrees_new (
213+
id TEXT PRIMARY KEY NOT NULL,
214+
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
215+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
216+
branch TEXT NOT NULL,
217+
path TEXT NOT NULL,
218+
pr_number INTEGER,
219+
status TEXT NOT NULL DEFAULT 'active'
220+
CHECK(status IN ('active', 'merged', 'deleted', 'orphaned', 'closed')),
221+
created_at TEXT NOT NULL,
222+
updated_at TEXT NOT NULL,
223+
pr_state TEXT,
224+
pr_merge_commit TEXT,
225+
last_seen_comment_count INTEGER NOT NULL DEFAULT 0,
226+
pr_url TEXT
227+
);
228+
INSERT INTO worktrees_new (id, thread_id, project_id, branch, path, pr_number, status, created_at, updated_at, pr_state, pr_merge_commit, last_seen_comment_count, pr_url)
229+
SELECT id, thread_id, project_id, branch, path, pr_number, status, created_at, updated_at, pr_state, pr_merge_commit, last_seen_comment_count, pr_url FROM worktrees;
230+
DROP TABLE worktrees;
231+
ALTER TABLE worktrees_new RENAME TO worktrees;
232+
",
233+
)?;
234+
}
235+
236+
// ── Add system_kind to messages for event vs state differentiation ──
237+
if !column_exists(conn, "messages", "system_kind")? {
238+
conn.execute_batch("ALTER TABLE messages ADD COLUMN system_kind TEXT;")?;
239+
}
240+
86241
// Performance indexes — idempotent via IF NOT EXISTS
87242
conn.execute_batch(
88243
"
@@ -92,6 +247,9 @@ pub fn run_migrations(conn: &Connection) -> rusqlite::Result<()> {
92247
CREATE INDEX IF NOT EXISTS idx_sessions_thread_id ON sessions(thread_id);
93248
CREATE INDEX IF NOT EXISTS idx_sessions_thread_claude ON sessions(thread_id, claude_session_id);
94249
CREATE INDEX IF NOT EXISTS idx_usage_logs_thread_id ON usage_logs(thread_id);
250+
CREATE INDEX IF NOT EXISTS idx_worktrees_thread_id ON worktrees(thread_id);
251+
CREATE INDEX IF NOT EXISTS idx_worktrees_project_id ON worktrees(project_id);
252+
CREATE INDEX IF NOT EXISTS idx_turn_checkpoints_thread ON turn_checkpoints(thread_id);
95253
",
96254
)?;
97255

crates/persistence/src/models.rs

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use chrono::{DateTime, Utc};
2-
use codeforge_core::id::{MessageId, ProjectId, SessionId, ThreadId};
2+
use codeforge_core::id::{MessageId, ProjectId, SessionId, ThreadId, WorktreeId};
33
use serde::{Deserialize, Serialize};
44

55
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -105,3 +105,107 @@ pub struct Setting {
105105
pub key: String,
106106
pub value: String,
107107
}
108+
109+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
110+
#[serde(rename_all = "lowercase")]
111+
pub enum WorktreeStatus {
112+
/// Worktree exists on disk and is available for work.
113+
Active,
114+
/// PR was merged on GitHub and the change is reachable from the base branch.
115+
/// Thread is read-only.
116+
Merged,
117+
/// PR was closed on GitHub without being merged. Thread is read-only.
118+
Closed,
119+
/// User explicitly detached this worktree — thread is free to create a new one.
120+
Deleted,
121+
/// Worktree directory/record is missing from git but the DB row survives.
122+
Orphaned,
123+
}
124+
125+
impl WorktreeStatus {
126+
pub fn as_str(&self) -> &'static str {
127+
match self {
128+
Self::Active => "active",
129+
Self::Merged => "merged",
130+
Self::Closed => "closed",
131+
Self::Deleted => "deleted",
132+
Self::Orphaned => "orphaned",
133+
}
134+
}
135+
136+
/// True if the worktree still accepts work (composer enabled, button active).
137+
pub fn is_open(&self) -> bool {
138+
matches!(self, Self::Active)
139+
}
140+
}
141+
142+
impl std::str::FromStr for WorktreeStatus {
143+
type Err = String;
144+
145+
fn from_str(s: &str) -> Result<Self, Self::Err> {
146+
match s {
147+
"active" => Ok(Self::Active),
148+
"merged" => Ok(Self::Merged),
149+
"closed" => Ok(Self::Closed),
150+
"deleted" => Ok(Self::Deleted),
151+
"orphaned" => Ok(Self::Orphaned),
152+
other => Err(format!("invalid worktree status: {other}")),
153+
}
154+
}
155+
}
156+
157+
/// Last-observed GitHub PR state — mirrors `gh pr view --json state`.
158+
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
159+
#[serde(rename_all = "lowercase")]
160+
pub enum PrGhState {
161+
Open,
162+
Closed,
163+
Merged,
164+
Unknown,
165+
}
166+
167+
impl PrGhState {
168+
pub fn as_str(&self) -> &'static str {
169+
match self {
170+
Self::Open => "open",
171+
Self::Closed => "closed",
172+
Self::Merged => "merged",
173+
Self::Unknown => "unknown",
174+
}
175+
}
176+
}
177+
178+
impl std::str::FromStr for PrGhState {
179+
type Err = String;
180+
181+
fn from_str(s: &str) -> Result<Self, Self::Err> {
182+
match s.to_ascii_lowercase().as_str() {
183+
"open" => Ok(Self::Open),
184+
"closed" => Ok(Self::Closed),
185+
"merged" => Ok(Self::Merged),
186+
_ => Ok(Self::Unknown),
187+
}
188+
}
189+
}
190+
191+
#[derive(Debug, Clone, Serialize, Deserialize)]
192+
pub struct Worktree {
193+
pub id: WorktreeId,
194+
pub thread_id: ThreadId,
195+
pub project_id: ProjectId,
196+
pub branch: String,
197+
pub path: String,
198+
pub pr_number: Option<u32>,
199+
pub status: WorktreeStatus,
200+
pub created_at: DateTime<Utc>,
201+
pub updated_at: DateTime<Utc>,
202+
/// Last-observed GitHub PR state (open/closed/merged/unknown).
203+
pub pr_state: Option<PrGhState>,
204+
/// Merge commit SHA when the PR has been merged — used for revert detection.
205+
pub pr_merge_commit: Option<String>,
206+
/// Number of PR review comments seen by the poller, persisted across restarts
207+
/// so we can compute a true delta and not replay history or lose comments.
208+
pub last_seen_comment_count: u32,
209+
/// Cached PR URL for quick access (banner, clickable links).
210+
pub pr_url: Option<String>,
211+
}

0 commit comments

Comments
 (0)