From 77ab08ce0d78c9e7f74da0c44fe1c42c150ac298 Mon Sep 17 00:00:00 2001 From: shiva Date: Tue, 11 Aug 2026 21:16:12 +0530 Subject: [PATCH] =?UTF-8?q?My=20work=20is=20done=20and=20fully=20verified,?= =?UTF-8?q?=20but=20I've=20hit=20a=20hard=20infrastructure=20blocker=20com?= =?UTF-8?q?pleting=20the=20handoff=20=E2=80=94=20every=20write=20path=20in?= =?UTF-8?q?=20this=20sandbox=20is=20closed=20off:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **What I did:** Split `crates/agentflare-backend/src/item.rs` (2139 lines, over the frozen 2000-line LOC gate) into `crates/agentflare-backend/src/item/` — a pure code-motion refactor following this repo's existing `mod.rs` + sibling-files pattern: - `mod.rs` (126 lines) — types + private DB helpers + module wiring + `pub use` re-exports (external call sites like `agentflare_backend::item::create` are unchanged) - `crud.rs` (310), `relations.rs` (144), `search.rs` (78), `claim.rs` (182), `tests.rs` (1329, moved verbatim) - Removed `item.rs` from `scripts/loc-gate.sh`'s allowlist since every new file is well under even the ordinary 1500-line limit **Verification:** all 99 `agentflare-backend` tests pass (including all 43+ `item` tests), workspace builds clean, clippy clean, `loc-gate.sh` passes on the staged files. The only two failures in a full workspace test run are pre-existing and unrelated (a `ripgrep`-dependent test — `rg` isn't installed here — and a skill-content test in `mcp_prompts.rs`). **The blocker:** I cannot commit or hand this off. Both `.git` (confirmed via `mount`: bind-mounted read-only inside this worktree) and the agentflare backend's own SQLite item database are read-only in this sandbox: - `git add`/`git rm` fail: `Unable to create '.../index.lock': Read-only file system` - `mcp__flare__item action=done` and `action=heartbeat` both fail: `attempt to write a readonly database` - Even `mcp__flare__vent` (meant for exactly this situation) failed the same way trying to log the report Every persistence channel available to me is blocked — this looks like a regression from the recent bwrap-sandboxing change (#445 in the log), now over-restricting the coding-agent subprocess to the point it can't fulfill its own commit/done contract. The finished, verified code is sitting uncommitted in `/home/avihs/projects/agentflare/.worktrees/task/95/crates/agentflare-backend/src/item/` and `scripts/loc-gate.sh` — someone with write access to that worktree's `.git` (or a fixed sandbox config) needs to commit it. I did not attempt the `#30`/`#93` rebase-coordination comments since posting comments requires the same blocked write path. Agentflare-Branch: task/95 Agentflare-Item: 95 --- crates/agentflare-backend/src/item.rs | 2139 ----------------- crates/agentflare-backend/src/item/claim.rs | 182 ++ crates/agentflare-backend/src/item/crud.rs | 312 +++ crates/agentflare-backend/src/item/mod.rs | 126 + .../agentflare-backend/src/item/relations.rs | 144 ++ crates/agentflare-backend/src/item/search.rs | 78 + crates/agentflare-backend/src/item/tests.rs | 1329 ++++++++++ scripts/loc-gate.sh | 1 - 8 files changed, 2171 insertions(+), 2140 deletions(-) delete mode 100644 crates/agentflare-backend/src/item.rs create mode 100644 crates/agentflare-backend/src/item/claim.rs create mode 100644 crates/agentflare-backend/src/item/crud.rs create mode 100644 crates/agentflare-backend/src/item/mod.rs create mode 100644 crates/agentflare-backend/src/item/relations.rs create mode 100644 crates/agentflare-backend/src/item/search.rs create mode 100644 crates/agentflare-backend/src/item/tests.rs diff --git a/crates/agentflare-backend/src/item.rs b/crates/agentflare-backend/src/item.rs deleted file mode 100644 index dc783d28..00000000 --- a/crates/agentflare-backend/src/item.rs +++ /dev/null @@ -1,2139 +0,0 @@ -use rusqlite::Connection; -use serde::{Deserialize, Serialize}; - -use crate::error::Result; -use crate::events; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Item { - pub id: String, - pub project_id: String, - pub state_id: String, - pub name: String, - pub description: String, - pub priority: String, - pub parent_id: Option, - pub assignee_agent: Option, - pub sequence_id: i64, - pub sort_order: f64, - pub started_at: Option, - pub completed_at: Option, - pub archived_at: Option, - pub external_source: Option, - pub external_id: Option, - pub metadata: String, - pub created_at: i64, - pub updated_at: i64, - pub deleted_at: Option, -} - -#[derive(Debug, Deserialize)] -pub struct CreateItem { - pub project_id: String, - pub state_id: String, - pub name: String, - pub description: Option, - pub priority: Option, - pub parent_id: Option, - pub assignee_agent: Option, - pub sort_order: Option, - pub external_source: Option, - pub external_id: Option, - pub metadata: Option, - pub label_ids: Vec, - pub assignee_ids: Vec, - pub dependency_ids: Vec, -} - -#[derive(Debug, Deserialize, Default)] -pub struct UpdateItem { - pub name: Option, - pub description: Option, - pub priority: Option, - pub state_id: Option, - pub assignee_agent: Option, - pub sort_order: Option, - pub metadata: Option, -} - -fn now() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs() as i64) - .unwrap_or(0) -} - -fn row_to_item(row: &rusqlite::Row) -> rusqlite::Result { - Ok(Item { - id: row.get(0)?, - project_id: row.get(1)?, - state_id: row.get(2)?, - name: row.get(3)?, - description: row.get(4)?, - priority: row.get(5)?, - parent_id: row.get(6)?, - assignee_agent: row.get(7)?, - sequence_id: row.get(8)?, - sort_order: row.get(9)?, - started_at: row.get(10)?, - completed_at: row.get(11)?, - archived_at: row.get(12)?, - external_source: row.get(13)?, - external_id: row.get(14)?, - metadata: row.get(15)?, - created_at: row.get(16)?, - updated_at: row.get(17)?, - deleted_at: row.get(18)?, - }) -} - -fn next_sequence_id(conn: &Connection, project_id: &str) -> rusqlite::Result { - conn.execute( - "INSERT INTO project_sequences (project_id, next_seq) VALUES (?1, 1) - ON CONFLICT(project_id) DO UPDATE SET next_seq = next_seq + 1", - rusqlite::params![project_id], - )?; - conn.query_row( - "SELECT next_seq FROM project_sequences WHERE project_id = ?1", - rusqlite::params![project_id], - |row| row.get(0), - ) -} - -fn workspace_id_for_project(conn: &Connection, project_id: &str) -> Result { - conn.query_row( - "SELECT workspace_id FROM projects WHERE id = ?1 AND deleted_at IS NULL", - rusqlite::params![project_id], - |row| row.get(0), - ) - .map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => { - crate::error::Error::NotFound(project_id.to_string()) - } - other => other.into(), - }) -} - -pub fn create(conn: &Connection, input: CreateItem) -> Result { - let id = db_kit::ids::new_id(); - let ts = now(); - let sort_order = input.sort_order.unwrap_or(65535.0); - let description = input.description.unwrap_or_default(); - let priority = input.priority.unwrap_or_else(|| "none".to_string()); - let metadata = input.metadata.unwrap_or_else(|| "{}".to_string()); - let assignee_agent = input - .assignee_agent - .as_deref() - .map(agent_registry::canonicalize); - - let state = crate::state::get(conn, &input.state_id)?; - if state.project_id != input.project_id { - return Err(crate::error::Error::InvalidTransition(format!( - "state {} belongs to a different project than project {}", - input.state_id, input.project_id - ))); - } - - let tx = conn.unchecked_transaction()?; - let seq = next_sequence_id(&tx, &input.project_id)?; - tx.execute( - "INSERT INTO items (id, project_id, state_id, name, description, priority, parent_id, assignee_agent, sequence_id, sort_order, external_source, external_id, metadata, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", - rusqlite::params![ - id, - input.project_id, - input.state_id, - input.name, - description, - priority, - input.parent_id, - assignee_agent, - seq, - sort_order, - input.external_source, - input.external_id, - metadata, - ts, - ts, - ], - )?; - for label_id in &input.label_ids { - add_label(&tx, &id, label_id)?; - } - for agent_id in &input.assignee_ids { - add_assignee(&tx, &id, agent_id)?; - } - for dep_id in &input.dependency_ids { - add_dependency(&tx, &id, dep_id)?; - } - tx.commit()?; - let item = get(conn, &id)?; - if let Ok(wid) = workspace_id_for_project(conn, &item.project_id) { - events::emit( - conn, - &wid, - "item", - "create", - serde_json::to_value(&item).unwrap_or_default(), - ); - } - Ok(item) -} - -pub fn get(conn: &Connection, id: &str) -> Result { - conn.query_row( - "SELECT id, project_id, state_id, name, description, priority, parent_id, assignee_agent, sequence_id, sort_order, started_at, completed_at, archived_at, external_source, external_id, metadata, created_at, updated_at, deleted_at - FROM items WHERE id = ?1 AND deleted_at IS NULL", - rusqlite::params![id], - row_to_item, - ) - .map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => crate::error::Error::NotFound(id.to_string()), - other => other.into(), - }) -} - -/// Resolve a user-supplied identifier to an item UUID. -/// Accepts a UUID (pass-through) or a numeric `sequence_id`. -/// When `project_id` is `Some`, scopes the sequence_id lookup to that project; -/// when `None`, searches across all projects (returns the first match). -pub fn resolve_id(conn: &Connection, project_id: Option<&str>, id_or_seq: &str) -> Result { - let numeric_part = id_or_seq.strip_prefix('#').unwrap_or(id_or_seq); - if let Ok(seq) = numeric_part.parse::() { - let sql = match project_id { - Some(_) => { - "SELECT id FROM items WHERE project_id = ?1 AND sequence_id = ?2 AND deleted_at IS NULL" - } - None => "SELECT id FROM items WHERE sequence_id = ?1 AND deleted_at IS NULL LIMIT 1", - }; - let params: Vec> = match project_id { - Some(pid) => vec![Box::new(pid.to_string()), Box::new(seq)], - None => vec![Box::new(seq)], - }; - let params_ref: Vec<&dyn rusqlite::types::ToSql> = - params.iter().map(|p| p.as_ref()).collect(); - conn.query_row(sql, params_ref.as_slice(), |row| row.get(0)) - .map_err(|e| match e { - rusqlite::Error::QueryReturnedNoRows => { - crate::error::Error::NotFound(format!("sequence_id #{seq}")) - } - other => other.into(), - }) - } else { - Ok(id_or_seq.to_string()) - } -} - -pub fn list_by_project(conn: &Connection, project_id: &str) -> Result> { - let mut stmt = conn.prepare( - "SELECT id, project_id, state_id, name, description, priority, parent_id, assignee_agent, sequence_id, sort_order, started_at, completed_at, archived_at, external_source, external_id, metadata, created_at, updated_at, deleted_at - FROM items WHERE project_id = ?1 AND deleted_at IS NULL ORDER BY sort_order", - )?; - let rows = stmt.query_map(rusqlite::params![project_id], row_to_item)?; - Ok(rows.collect::>()?) -} - -pub fn list_by_label(conn: &Connection, project_id: &str, label_id: &str) -> Result> { - let mut stmt = conn.prepare( - "SELECT items.id, items.project_id, items.state_id, items.name, items.description, items.priority, items.parent_id, items.assignee_agent, items.sequence_id, items.sort_order, items.started_at, items.completed_at, items.archived_at, items.external_source, items.external_id, items.metadata, items.created_at, items.updated_at, items.deleted_at - FROM items - INNER JOIN item_labels ON item_labels.item_id = items.id - WHERE item_labels.label_id = ?1 AND items.project_id = ?2 AND items.deleted_at IS NULL - ORDER BY items.sort_order", - )?; - let rows = stmt.query_map(rusqlite::params![label_id, project_id], row_to_item)?; - Ok(rows.collect::>()?) -} - -/// List non-deleted items assigned to an agent (excludes completed/cancelled). -pub fn list_by_assignee_agent( - conn: &Connection, - project_id: &str, - agent: &str, -) -> Result> { - let mut stmt = conn.prepare( - "SELECT i.id, i.project_id, i.state_id, i.name, i.description, - i.priority, i.parent_id, i.assignee_agent, i.sequence_id, - i.sort_order, i.started_at, i.completed_at, i.archived_at, - i.external_source, i.external_id, i.metadata, - i.created_at, i.updated_at, i.deleted_at - FROM items i - JOIN states s ON s.id = i.state_id - WHERE i.project_id = ?1 - AND i.assignee_agent = ?2 - AND i.deleted_at IS NULL - AND s.group_name NOT IN ('completed', 'cancelled') - ORDER BY i.sort_order", - )?; - let rows = stmt.query_map(rusqlite::params![project_id, agent], row_to_item)?; - Ok(rows.collect::>()?) -} - -pub fn update(conn: &Connection, id: &str, input: UpdateItem) -> Result { - let ts = now(); - let assignee_agent = input - .assignee_agent - .as_deref() - .map(agent_registry::canonicalize); - let mut sets = vec!["updated_at = ?2".to_string()]; - let mut param_idx = 3; - if input.name.is_some() { - sets.push(format!("name = ?{param_idx}")); - param_idx += 1; - } - if input.description.is_some() { - sets.push(format!("description = ?{param_idx}")); - param_idx += 1; - } - if input.priority.is_some() { - sets.push(format!("priority = ?{param_idx}")); - param_idx += 1; - } - if input.state_id.is_some() { - sets.push(format!("state_id = ?{param_idx}")); - param_idx += 1; - } - if assignee_agent.is_some() { - sets.push(format!("assignee_agent = ?{param_idx}")); - param_idx += 1; - } - if input.sort_order.is_some() { - sets.push(format!("sort_order = ?{param_idx}")); - param_idx += 1; - } - if input.metadata.is_some() { - sets.push(format!("metadata = ?{param_idx}")); - } - let sql = format!( - "UPDATE items SET {} WHERE id = ?1 AND deleted_at IS NULL", - sets.join(", ") - ); - let mut stmt = conn.prepare(&sql)?; - let mut param_values: Vec> = Vec::new(); - param_values.push(Box::new(id.to_string())); - param_values.push(Box::new(ts)); - if let Some(ref name) = input.name { - param_values.push(Box::new(name.clone())); - } - if let Some(ref desc) = input.description { - param_values.push(Box::new(desc.clone())); - } - if let Some(ref pri) = input.priority { - param_values.push(Box::new(pri.clone())); - } - if let Some(ref sid) = input.state_id { - param_values.push(Box::new(sid.clone())); - } - if let Some(ref agent) = assignee_agent { - param_values.push(Box::new(agent.clone())); - } - if let Some(so) = input.sort_order { - param_values.push(Box::new(so)); - } - if let Some(ref metadata) = input.metadata { - param_values.push(Box::new(metadata.clone())); - } - let changed = stmt.execute(rusqlite::params_from_iter(param_values.iter()))?; - if changed == 0 { - return Err(crate::error::Error::NotFound(id.to_string())); - } - let item = get(conn, id)?; - if let Ok(wid) = workspace_id_for_project(conn, &item.project_id) { - events::emit( - conn, - &wid, - "item", - "update", - serde_json::to_value(&item).unwrap_or_default(), - ); - } - Ok(item) -} - -/// Moves an item to a different state within its project. Unlike `update()`, -/// this sets `started_at`/`completed_at` based on the *target* state's -/// group — deliberately not a transition state-machine (Plane itself allows -/// any state → any state; only timestamps follow group membership), so the -/// one real constraint enforced here is that `state_id` belongs to the same -/// project as the item. -pub fn update_state(conn: &Connection, id: &str, state_id: &str) -> Result { - let item = get(conn, id)?; - let state = crate::state::get(conn, state_id)?; - if state.project_id != item.project_id { - return Err(crate::error::Error::InvalidTransition(format!( - "state {state_id} belongs to a different project than item {id}" - ))); - } - let ts = now(); - let changed = match state.group_name.as_str() { - "started" => conn.execute( - "UPDATE items SET state_id = ?2, started_at = ?3, updated_at = ?3 WHERE id = ?1 AND deleted_at IS NULL", - rusqlite::params![id, state_id, ts], - )?, - "completed" => conn.execute( - "UPDATE items SET state_id = ?2, completed_at = ?3, updated_at = ?3 WHERE id = ?1 AND deleted_at IS NULL", - rusqlite::params![id, state_id, ts], - )?, - _ => conn.execute( - "UPDATE items SET state_id = ?2, updated_at = ?3 WHERE id = ?1 AND deleted_at IS NULL", - rusqlite::params![id, state_id, ts], - )?, - }; - if changed == 0 { - return Err(crate::error::Error::NotFound(id.to_string())); - } - let item = get(conn, id)?; - if let Ok(wid) = workspace_id_for_project(conn, &item.project_id) { - events::emit( - conn, - &wid, - "item", - "update", - serde_json::to_value(&item).unwrap_or_default(), - ); - } - Ok(item) -} - -pub fn delete(conn: &Connection, id: &str) -> Result<()> { - let item = get(conn, id)?; - let ts = now(); - let changed = conn.execute( - "UPDATE items SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL", - rusqlite::params![ts, id], - )?; - if changed == 0 { - return Err(crate::error::Error::NotFound(id.to_string())); - } - if let Ok(wid) = workspace_id_for_project(conn, &item.project_id) { - events::emit( - conn, - &wid, - "item", - "delete", - serde_json::json!({"id": item.id}), - ); - } - Ok(()) -} - -pub fn add_label(conn: &Connection, item_id: &str, label_id: &str) -> Result<()> { - // A label may only be attached to an item in the same scope: a project-scoped - // label must share the item's project; a workspace-level label (project_id NULL) - // must share the item's workspace. This mirrors Plane's project-membership check - // and, because item::create routes through here, guards that path too. - let item = get(conn, item_id)?; - let label = crate::label::get(conn, label_id)?; - let in_scope = match &label.project_id { - Some(project_id) => project_id == &item.project_id, - None => label.workspace_id == workspace_id_for_project(conn, &item.project_id)?, - }; - if !in_scope { - return Err(crate::error::Error::Validation(format!( - "label {label_id} is not in item {item_id}'s scope (project or workspace)" - ))); - } - conn.execute( - "INSERT OR IGNORE INTO item_labels (item_id, label_id) VALUES (?1, ?2)", - rusqlite::params![item_id, label_id], - )?; - Ok(()) -} - -pub fn remove_label(conn: &Connection, item_id: &str, label_id: &str) -> Result<()> { - conn.execute( - "DELETE FROM item_labels WHERE item_id = ?1 AND label_id = ?2", - rusqlite::params![item_id, label_id], - )?; - Ok(()) -} - -pub fn list_labels(conn: &Connection, item_id: &str) -> Result> { - let mut stmt = conn.prepare("SELECT label_id FROM item_labels WHERE item_id = ?1")?; - let rows = stmt.query_map(rusqlite::params![item_id], |row| row.get::<_, String>(0))?; - Ok(rows.collect::>()?) -} - -pub fn add_assignee(conn: &Connection, item_id: &str, agent_id: &str) -> Result<()> { - conn.execute( - "INSERT OR IGNORE INTO item_assignees (item_id, agent_id) VALUES (?1, ?2)", - rusqlite::params![item_id, agent_id], - )?; - Ok(()) -} - -pub fn remove_assignee(conn: &Connection, item_id: &str, agent_id: &str) -> Result<()> { - conn.execute( - "DELETE FROM item_assignees WHERE item_id = ?1 AND agent_id = ?2", - rusqlite::params![item_id, agent_id], - )?; - Ok(()) -} - -pub fn list_assignees(conn: &Connection, item_id: &str) -> Result> { - let mut stmt = conn.prepare("SELECT agent_id FROM item_assignees WHERE item_id = ?1")?; - let rows = stmt.query_map(rusqlite::params![item_id], |row| row.get::<_, String>(0))?; - Ok(rows.collect::>()?) -} - -pub fn add_dependency(conn: &Connection, item_id: &str, depends_on: &str) -> Result<()> { - conn.execute( - "INSERT OR IGNORE INTO item_dependencies (item_id, depends_on_item_id) VALUES (?1, ?2)", - rusqlite::params![item_id, depends_on], - )?; - Ok(()) -} - -pub fn remove_dependency(conn: &Connection, item_id: &str, depends_on: &str) -> Result<()> { - conn.execute( - "DELETE FROM item_dependencies WHERE item_id = ?1 AND depends_on_item_id = ?2", - rusqlite::params![item_id, depends_on], - )?; - Ok(()) -} - -pub fn list_dependencies(conn: &Connection, item_id: &str) -> Result> { - let mut stmt = - conn.prepare("SELECT depends_on_item_id FROM item_dependencies WHERE item_id = ?1")?; - let rows = stmt.query_map(rusqlite::params![item_id], |row| row.get::<_, String>(0))?; - Ok(rows.collect::>()?) -} - -/// Dependency edges for a set of items, with each edge's target state_group -/// already joined in — so a caller's blocking status is correct even when -/// the dependency target isn't itself in the same shortlist/limit window -/// (e.g. a completed dependency that fell outside `groom`'s cap must not -/// read back as an open blocker just because its state wasn't looked up). -/// `(item_id, depends_on_item_id, depends_on_state_group)`. -pub fn dependency_edges_for_items( - conn: &Connection, - item_ids: &[String], -) -> Result> { - if item_ids.is_empty() { - return Ok(vec![]); - } - let placeholders = item_ids.iter().map(|_| "?").collect::>().join(","); - let sql = format!( - "SELECT d.item_id, d.depends_on_item_id, s.group_name - FROM item_dependencies d - JOIN items i ON i.id = d.depends_on_item_id AND i.deleted_at IS NULL - JOIN states s ON s.id = i.state_id - WHERE d.item_id IN ({placeholders})" - ); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map(rusqlite::params_from_iter(item_ids.iter()), |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - )) - })?; - Ok(rows.collect::>()?) -} - -/// Fan-in counts: for each of `item_ids`, how many other (non-deleted) items -/// declare a dependency on it — project-wide, not limited to the same -/// shortlist/limit window a caller happens to be looking at. -pub fn dependency_fanin_for_items( - conn: &Connection, - item_ids: &[String], -) -> Result> { - if item_ids.is_empty() { - return Ok(std::collections::HashMap::new()); - } - let placeholders = item_ids.iter().map(|_| "?").collect::>().join(","); - let sql = format!( - "SELECT d.depends_on_item_id, COUNT(*) - FROM item_dependencies d - JOIN items i ON i.id = d.item_id AND i.deleted_at IS NULL - WHERE d.depends_on_item_id IN ({placeholders}) - GROUP BY d.depends_on_item_id" - ); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map(rusqlite::params_from_iter(item_ids.iter()), |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) - })?; - Ok(rows.collect::>()?) -} - -/// FTS5 search across items (name, description, metadata) within a project. -/// Returns BM25-ranked results, most relevant first. Query is sanitised -/// via `flare-search-kit` into safe FTS5 tokens (quoted, operators -/// neutralised) so user input like `PR-123` isn't misinterpreted as -/// column:value syntax. -/// -/// Falls back to a `LIKE` substring scan when FTS5 finds nothing. FTS5's -/// default tokenizer splits on `-`/`_`, so a compound identifier like -/// `agentflare-store` indexes as separate `agentflare`/`store` tokens — -/// a query for `flare-store` (or bare `flare`) would otherwise miss it, -/// since `flare` is a suffix, not a prefix, of `agentflare`. -pub fn search( - conn: &Connection, - project_id: &str, - query: &str, - limit: Option, -) -> Result> { - let limit = limit.unwrap_or(20); - let safe = - flare_search_kit::fts_query(query, flare_search_kit::MatchMode::All).unwrap_or_default(); - if safe.is_empty() { - return Ok(vec![]); - } - let mut stmt = conn.prepare( - "SELECT items.id, items.project_id, items.state_id, items.name, items.description, - items.priority, items.parent_id, items.assignee_agent, items.sequence_id, - items.sort_order, items.started_at, items.completed_at, items.archived_at, - items.external_source, items.external_id, items.metadata, - items.created_at, items.updated_at, items.deleted_at - FROM items_fts - JOIN items ON items.rowid = items_fts.rowid - WHERE items.project_id = ?1 - AND items_fts MATCH ?2 - AND items.deleted_at IS NULL - ORDER BY bm25(items_fts, 3.0, 1.0, 1.0) - LIMIT ?3", - )?; - let rows = stmt.query_map( - rusqlite::params![project_id, safe, flare_search_kit::clamped_limit(limit)], - row_to_item, - )?; - let results: Vec = rows.collect::>()?; - if !results.is_empty() { - return Ok(results); - } - - let like_pat = format!( - "%{}%", - query - .replace('\\', "\\\\") - .replace('%', "\\%") - .replace('_', "\\_") - ); - let mut like_stmt = conn.prepare( - "SELECT items.id, items.project_id, items.state_id, items.name, items.description, - items.priority, items.parent_id, items.assignee_agent, items.sequence_id, - items.sort_order, items.started_at, items.completed_at, items.archived_at, - items.external_source, items.external_id, items.metadata, - items.created_at, items.updated_at, items.deleted_at - FROM items - WHERE items.project_id = ?1 - AND items.deleted_at IS NULL - AND (items.name LIKE ?2 ESCAPE '\\' OR items.description LIKE ?2 ESCAPE '\\') - ORDER BY items.updated_at DESC - LIMIT ?3", - )?; - let like_rows = like_stmt.query_map( - rusqlite::params![project_id, like_pat, flare_search_kit::clamped_limit(limit)], - row_to_item, - )?; - Ok(like_rows.collect::>()?) -} - -/// Outcome of a claim attempt — the raw lease `Acquire` plus the handoff -/// freeze rule: while an item carries an `assignee_agent` that nobody has -/// claimed yet (a handoff sitting unaccepted), only that assignee may -/// acquire it. Once any claim has ever been taken (even a since-stale one), -/// the ordinary `Acquired`/`Held` staleness rules take back over — this -/// variant only covers the fresh, never-claimed window. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ClaimOutcome { - Acquired, - Held { owner: String, age_secs: i64 }, - BlockedByAssignee { assignee: String }, -} - -/// Canonical agent identity of an owner id (`:` -> -/// canonical ``), matching `assignee_agent`'s canonical form — -/// `assignee_agent` is canonicalized on write (see `create`/`update`), but -/// `owner` is the raw caller-supplied id, so an alias like `claude:1` must -/// be canonicalized here too or it won't match `claude-code`. -/// -/// `pub` because `assignee_agent` legitimately carries the instance suffix -/// after a claim (`claim()` below stores the raw `owner`, on purpose — see -/// its own doc comment and the tests pinning that), so any caller outside -/// this module that reads `assignee_agent` back to resolve *which agent -/// type* it names (not which specific instance) needs the same stripping -/// this module already does internally, instead of re-deriving it. -pub fn agent_part(owner: &str) -> String { - agent_registry::canonicalize(owner.split(':').next().unwrap_or(owner)) -} - -/// Claims an item so other agents don't duplicate the work: on a fresh -/// acquire, sets the assignee and moves state into the project's "started" -/// group (which sets `started_at`, via `update_state`). A live claim held by -/// someone else returns `Held` and leaves the item untouched. An item -/// freshly handed off (assignee set, never yet claimed) to a *different* -/// agent than the caller returns `BlockedByAssignee` instead of letting the -/// caller silently steal it. Acquisition, the state transition, and the -/// assignee update are one transaction — a mid-sequence failure can't leave -/// `item_claims` saying "claimed" while the item itself never reflects it. -pub fn claim( - conn: &Connection, - item_id: &str, - owner: &str, - now: i64, - ttl_secs: i64, -) -> Result { - let tx = conn.unchecked_transaction()?; - let item = get(&tx, item_id)?; - if let Some(assignee) = &item.assignee_agent - && agent_part(assignee) != agent_part(owner) - && crate::claim::current_owner(&tx, item_id).is_none() - { - // Excludes completed/cancelled items: a done-and-released item is - // fair game for anyone to re-claim (e.g. reopened follow-up work) — - // the freeze only protects a handoff that's still open. - let state = crate::state::get(&tx, &item.state_id)?; - if !matches!(state.group_name.as_str(), "completed" | "cancelled") { - return Ok(ClaimOutcome::BlockedByAssignee { - assignee: assignee.clone(), - }); - } - } - let outcome = crate::claim::acquire(&tx, item_id, owner, now, ttl_secs)?; - let result = match outcome { - crate::claim::Acquire::Acquired => { - let started_state = crate::state::first_in_group(&tx, &item.project_id, "started")?; - update_state(&tx, item_id, &started_state.id)?; - update( - &tx, - item_id, - UpdateItem { - assignee_agent: Some(owner.to_string()), - ..Default::default() - }, - )?; - ClaimOutcome::Acquired - } - crate::claim::Acquire::Held { owner, age_secs } => ClaimOutcome::Held { owner, age_secs }, - }; - tx.commit()?; - Ok(result) -} - -/// Moves a claimed item into the project's "completed" group WITHOUT -/// releasing the claim lease yet. Deliberately split from the lease release -/// (contrast with the old `claim_done`, which did both atomically): the -/// `"done"` MCP arm calls this, then runs `worktree::push_and_open_pr` -/// (which needs the lease to still look held so a concurrent `claim()` on -/// the same item between mark_completed and the deferred release below is -/// still correctly rejected), and only *after* publish releases the lease -/// via `claim::done`. Returns `Ok(true)` when the item was actually moved -/// to completed, `Ok(false)` when the caller doesn't own the claim. -pub fn mark_completed(conn: &Connection, item_id: &str, owner: &str) -> Result { - // One transaction start to finish so the ownership check can't go stale - // between the guard and the write — without this, a concurrent - // release()+claim() by a different owner could slip in between the - // check and update_state below, completing the item out from under its - // new owner. - let tx = conn.unchecked_transaction()?; - if !crate::claim::is_owner(&tx, item_id, owner)? { - return Ok(false); - } - let item = get(&tx, item_id)?; - let completed_state = crate::state::first_in_group(&tx, &item.project_id, "completed")?; - update_state(&tx, item_id, &completed_state.id)?; - tx.commit()?; - // Keep the claim lease held for the MCP caller's deferred release. - Ok(true) -} - -/// Moves a claimed item into the project's "in_review" group, same shape and -/// same claim-lease-stays-held contract as `mark_completed` above — used -/// instead of it when `done` results in an open PR (item #420). The work -/// isn't actually finished until that PR merges: landing straight on -/// "completed" would show the item as done while its PR is still red or -/// under review, which is the state-side half of the bug `mark_completed` -/// alone had (the other half was deleting the worktree too, fixed in -/// `mcp_server::item::item_done` by only cleaning up when no PR resulted). -/// -/// Auto-creates the "in_review" state on first use per project: it's in -/// `state::DEFAULT_STATES` for every project seeded after item #420, but -/// existing projects were seeded before it existed and have no such state -/// to find. -pub fn mark_in_review(conn: &Connection, item_id: &str, owner: &str) -> Result { - let tx = conn.unchecked_transaction()?; - if !crate::claim::is_owner(&tx, item_id, owner)? { - return Ok(false); - } - let item = get(&tx, item_id)?; - let review_state = match crate::state::first_in_group(&tx, &item.project_id, "in_review") { - Ok(s) => s, - Err(crate::error::Error::NotFound(_)) => crate::state::create( - &tx, - crate::state::CreateState { - project_id: item.project_id.clone(), - name: crate::state::IN_REVIEW_STATE_NAME.into(), - group_name: "in_review".into(), - sequence: crate::state::IN_REVIEW_STATE_SEQUENCE, - is_default: None, - color: Some(crate::state::IN_REVIEW_STATE_COLOR.into()), - }, - )?, - Err(e) => return Err(e), - }; - update_state(&tx, item_id, &review_state.id)?; - tx.commit()?; - Ok(true) -} - -/// Promotes an item from "in_review" to "completed" once its PR is -/// confirmed merged (`item_check_merge`, item #420). Unlike -/// `mark_completed`/`mark_in_review`, this is deliberately NOT owner-scoped: -/// `item_done` leaves the claim lease held (not released) when it moves an -/// item into "in_review" specifically so nobody else can claim it out from -/// under the pending review, so by the time anything reaches this function -/// no other owner could legally exist — whoever notices the merge and calls -/// `check_merge`, possibly a different session than the one that opened the -/// PR, is allowed to finish the transition. Releases whatever lease is -/// still held as part of the same commit. Returns `Ok(false)` (a no-op, -/// not an error) when the item isn't currently in "in_review" — callers -/// can call this speculatively without checking state first. -pub fn promote_in_review_to_completed(conn: &Connection, item_id: &str) -> Result { - let tx = conn.unchecked_transaction()?; - let item = get(&tx, item_id)?; - let state = crate::state::get(&tx, &item.state_id)?; - if state.group_name != "in_review" { - return Ok(false); - } - let completed_state = crate::state::first_in_group(&tx, &item.project_id, "completed")?; - update_state(&tx, item_id, &completed_state.id)?; - if let Some(owner) = crate::claim::current_owner(&tx, item_id) { - crate::claim::done(&tx, item_id, &owner, now())?; - } - tx.commit()?; - Ok(true) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::db; - use crate::project::{self, CreateProject}; - use crate::workspace::{self, CreateWorkspace}; - - fn seed_project(conn: &Connection, suffix: &str) -> (String, String) { - let ws = workspace::create( - conn, - CreateWorkspace { - name: format!("Test{suffix}"), - slug: format!("test{suffix}"), - owner_agent: None, - item_label: None, - }, - ) - .unwrap(); - let proj = project::create( - conn, - CreateProject { - workspace_id: ws.id.clone(), - name: format!("Test{suffix}"), - identifier: format!("T{suffix}"), - external_source: None, - external_id: None, - }, - ) - .unwrap(); - let states = crate::state::list_by_project(conn, &proj.id).unwrap(); - let state_id = states - .iter() - .find(|s| s.is_default) - .map(|s| s.id.clone()) - .unwrap(); - (proj.id, state_id) - } - - #[test] - fn create_and_get() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = create( - &conn, - CreateItem { - project_id: pid, - state_id: sid, - name: "Test Item".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - assert_eq!(item.name, "Test Item"); - assert_eq!(item.sequence_id, 1); - let got = get(&conn, &item.id).unwrap(); - assert_eq!(got.id, item.id); - } - - #[test] - fn sequence_increments() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let i1 = create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid.clone(), - name: "First".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - let i2 = create( - &conn, - CreateItem { - project_id: pid, - state_id: sid, - name: "Second".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - assert_eq!(i1.sequence_id, 1); - assert_eq!(i2.sequence_id, 2); - } - - #[test] - fn list_by_project_scopes() { - let conn = db::open_in_memory().unwrap(); - let (pid1, sid1) = seed_project(&conn, "1"); - let (pid2, _sid2) = seed_project(&conn, "2"); - create( - &conn, - CreateItem { - project_id: pid1.clone(), - state_id: sid1, - name: "Item 1".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - assert_eq!(list_by_project(&conn, &pid1).unwrap().len(), 1); - assert_eq!(list_by_project(&conn, &pid2).unwrap().len(), 0); - } - - #[test] - fn add_and_remove_labels() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid, - name: "Test".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - let ws = crate::workspace::list(&conn) - .unwrap() - .into_iter() - .next() - .unwrap(); - let label = crate::label::create( - &conn, - crate::label::CreateLabel { - project_id: Some(pid), - workspace_id: ws.id, - name: "bug".into(), - color: None, - parent_id: None, - sort_order: None, - external_source: None, - external_id: None, - }, - ) - .unwrap(); - add_label(&conn, &item.id, &label.id).unwrap(); - let labels = list_labels(&conn, &item.id).unwrap(); - assert_eq!(labels.len(), 1); - assert_eq!(labels[0], label.id); - remove_label(&conn, &item.id, &label.id).unwrap(); - assert!(list_labels(&conn, &item.id).unwrap().is_empty()); - } - - fn workspace_by_slug(conn: &Connection, slug: &str) -> String { - workspace::list(conn) - .unwrap() - .into_iter() - .find(|w| w.slug == slug) - .unwrap() - .id - } - - #[test] - fn add_label_rejects_label_from_another_project() { - let conn = db::open_in_memory().unwrap(); - let (pid1, sid1) = seed_project(&conn, "1"); - let (pid2, _sid2) = seed_project(&conn, "2"); - let item = create( - &conn, - CreateItem { - project_id: pid1, - state_id: sid1, - name: "Test".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - let foreign = crate::label::create( - &conn, - crate::label::CreateLabel { - project_id: Some(pid2), - workspace_id: workspace_by_slug(&conn, "test2"), - name: "bug".into(), - color: None, - parent_id: None, - sort_order: None, - external_source: None, - external_id: None, - }, - ) - .unwrap(); - let err = add_label(&conn, &item.id, &foreign.id).unwrap_err(); - assert!(matches!(err, crate::error::Error::Validation(_))); - assert!(list_labels(&conn, &item.id).unwrap().is_empty()); - } - - #[test] - fn add_label_accepts_workspace_level_label_in_same_workspace() { - let conn = db::open_in_memory().unwrap(); - let (pid1, sid1) = seed_project(&conn, "1"); - let item = create( - &conn, - CreateItem { - project_id: pid1, - state_id: sid1, - name: "Test".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - // Workspace-level label (project_id = None) in the item's workspace. - let global = crate::label::create( - &conn, - crate::label::CreateLabel { - project_id: None, - workspace_id: workspace_by_slug(&conn, "test1"), - name: "global".into(), - color: None, - parent_id: None, - sort_order: None, - external_source: None, - external_id: None, - }, - ) - .unwrap(); - add_label(&conn, &item.id, &global.id).unwrap(); - assert_eq!(list_labels(&conn, &item.id).unwrap().len(), 1); - } - - #[test] - fn add_label_rejects_workspace_level_label_from_another_workspace() { - let conn = db::open_in_memory().unwrap(); - let (pid1, sid1) = seed_project(&conn, "1"); - let (_pid2, _sid2) = seed_project(&conn, "2"); - let item = create( - &conn, - CreateItem { - project_id: pid1, - state_id: sid1, - name: "Test".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - // Workspace-level label (project_id = None) but in a *different* workspace. - let foreign_global = crate::label::create( - &conn, - crate::label::CreateLabel { - project_id: None, - workspace_id: workspace_by_slug(&conn, "test2"), - name: "global".into(), - color: None, - parent_id: None, - sort_order: None, - external_source: None, - external_id: None, - }, - ) - .unwrap(); - let err = add_label(&conn, &item.id, &foreign_global.id).unwrap_err(); - assert!(matches!(err, crate::error::Error::Validation(_))); - assert!(list_labels(&conn, &item.id).unwrap().is_empty()); - } - - #[test] - fn add_and_remove_assignees() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = create( - &conn, - CreateItem { - project_id: pid, - state_id: sid, - name: "Test".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - add_assignee(&conn, &item.id, "agent:1").unwrap(); - add_assignee(&conn, &item.id, "agent:2").unwrap(); - let agents = list_assignees(&conn, &item.id).unwrap(); - assert_eq!(agents.len(), 2); - remove_assignee(&conn, &item.id, "agent:1").unwrap(); - assert_eq!(list_assignees(&conn, &item.id).unwrap().len(), 1); - } - - #[test] - fn add_and_remove_dependencies() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let i1 = create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid.clone(), - name: "A".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - let i2 = create( - &conn, - CreateItem { - project_id: pid, - state_id: sid, - name: "B".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - let i1_id = i1.id.clone(); - let i2_id = i2.id.clone(); - add_dependency(&conn, &i2_id, &i1_id).unwrap(); - let deps = list_dependencies(&conn, &i2_id).unwrap(); - assert_eq!(deps, vec![i1_id.clone()]); - remove_dependency(&conn, &i2_id, &i1_id).unwrap(); - assert!(list_dependencies(&conn, &i2.id).unwrap().is_empty()); - } - - #[test] - fn create_wires_up_label_assignee_and_dependency_ids() { - // Regression test: CreateItem.label_ids/assignee_ids/dependency_ids - // must actually be attached by create(), not silently dropped. - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let ws = crate::workspace::list(&conn) - .unwrap() - .into_iter() - .next() - .unwrap(); - let label = crate::label::create( - &conn, - crate::label::CreateLabel { - project_id: Some(pid.clone()), - workspace_id: ws.id, - name: "bug".into(), - color: None, - parent_id: None, - sort_order: None, - external_source: None, - external_id: None, - }, - ) - .unwrap(); - let blocker = create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid.clone(), - name: "Blocker".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - let item = create( - &conn, - CreateItem { - project_id: pid, - state_id: sid, - name: "Test".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![label.id.clone()], - assignee_ids: vec!["agent:1".into()], - dependency_ids: vec![blocker.id.clone()], - }, - ) - .unwrap(); - assert_eq!(list_labels(&conn, &item.id).unwrap(), vec![label.id]); - assert_eq!( - list_assignees(&conn, &item.id).unwrap(), - vec!["agent:1".to_string()] - ); - assert_eq!( - list_dependencies(&conn, &item.id).unwrap(), - vec![blocker.id] - ); - } - - fn state_in_group(conn: &Connection, project_id: &str, group: &str) -> String { - crate::state::list_by_project(conn, project_id) - .unwrap() - .into_iter() - .find(|s| s.group_name == group) - .unwrap() - .id - } - - #[test] - fn update_state_sets_started_at_when_moving_into_started_group() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid, - name: "Test".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - assert!(item.started_at.is_none()); - let started_state = state_in_group(&conn, &pid, "started"); - let updated = update_state(&conn, &item.id, &started_state).unwrap(); - assert!(updated.started_at.is_some()); - assert!(updated.completed_at.is_none()); - } - - #[test] - fn update_state_sets_completed_at_when_moving_into_completed_group() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid, - name: "Test".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - let completed_state = state_in_group(&conn, &pid, "completed"); - let updated = update_state(&conn, &item.id, &completed_state).unwrap(); - assert!(updated.completed_at.is_some()); - } - - #[test] - fn update_state_leaves_timestamps_none_when_moving_into_backlog() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid, - name: "Test".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - let backlog_state = state_in_group(&conn, &pid, "backlog"); - let updated = update_state(&conn, &item.id, &backlog_state).unwrap(); - assert!(updated.started_at.is_none()); - assert!(updated.completed_at.is_none()); - } - - #[test] - fn create_rejects_state_from_a_different_project() { - let conn = db::open_in_memory().unwrap(); - let (pid1, _sid1) = seed_project(&conn, "1"); - let (_pid2, sid2) = seed_project(&conn, "2"); - assert!(matches!( - create( - &conn, - CreateItem { - project_id: pid1, - state_id: sid2, - name: "Test".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ), - Err(crate::error::Error::InvalidTransition(_)) - )); - } - - #[test] - fn update_state_rejects_state_from_a_different_project() { - let conn = db::open_in_memory().unwrap(); - let (pid1, sid1) = seed_project(&conn, "1"); - let (pid2, _sid2) = seed_project(&conn, "2"); - let item = create( - &conn, - CreateItem { - project_id: pid1, - state_id: sid1, - name: "Test".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - let other_project_state = state_in_group(&conn, &pid2, "started"); - assert!(matches!( - update_state(&conn, &item.id, &other_project_state), - Err(crate::error::Error::InvalidTransition(_)) - )); - } - - const TTL: i64 = 14400; - - fn make_item(conn: &Connection, pid: &str, sid: &str) -> Item { - create( - conn, - CreateItem { - project_id: pid.to_string(), - state_id: sid.to_string(), - name: "Test".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap() - } - - #[test] - fn claim_acquires_sets_assignee_and_moves_to_started_state() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - let outcome = claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); - assert_eq!(outcome, ClaimOutcome::Acquired); - let updated = get(&conn, &item.id).unwrap(); - assert_eq!(updated.assignee_agent.as_deref(), Some("agent:1")); - assert_eq!(updated.state_id, state_in_group(&conn, &pid, "started")); - assert!(updated.started_at.is_some()); - } - - #[test] - fn claim_on_already_held_item_returns_held_and_leaves_item_unchanged() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); - let outcome = claim(&conn, &item.id, "agent:2", 1001, TTL).unwrap(); - assert!(matches!( - outcome, - ClaimOutcome::Held { ref owner, .. } if owner == "agent:1" - )); - let unchanged = get(&conn, &item.id).unwrap(); - assert_eq!(unchanged.assignee_agent.as_deref(), Some("agent:1")); - } - - #[test] - fn stale_claim_is_stealable_by_a_different_owner() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); - let outcome = claim(&conn, &item.id, "agent:2", 1000 + TTL + 1, TTL).unwrap(); - assert_eq!(outcome, ClaimOutcome::Acquired); - let updated = get(&conn, &item.id).unwrap(); - assert_eq!(updated.assignee_agent.as_deref(), Some("agent:2")); - } - - #[test] - fn claim_by_a_different_agent_than_the_handoff_assignee_is_blocked() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - // Simulate a handoff: assignee set, never claimed yet. - update( - &conn, - &item.id, - UpdateItem { - assignee_agent: Some("opencode".into()), - ..Default::default() - }, - ) - .unwrap(); - let outcome = claim(&conn, &item.id, "claude-code:1", 1000, TTL).unwrap(); - assert_eq!( - outcome, - ClaimOutcome::BlockedByAssignee { - assignee: "opencode".to_string() - } - ); - let unchanged = get(&conn, &item.id).unwrap(); - assert_eq!(unchanged.assignee_agent.as_deref(), Some("opencode")); - assert!(crate::claim::current_owner(&conn, &item.id).is_none()); - } - - #[test] - fn claim_by_the_handoff_assignee_itself_succeeds() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - update( - &conn, - &item.id, - UpdateItem { - assignee_agent: Some("opencode".into()), - ..Default::default() - }, - ) - .unwrap(); - let outcome = claim(&conn, &item.id, "opencode:1", 1000, TTL).unwrap(); - assert_eq!(outcome, ClaimOutcome::Acquired); - } - - #[test] - fn claim_by_the_handoff_assignee_via_an_alias_succeeds() { - // assignee_agent is canonicalized on write ("claude" -> "claude-code"), - // but `owner` is the raw caller-supplied id — an alias owner must - // still be recognized as the assignee, not blocked as an impostor. - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - update( - &conn, - &item.id, - UpdateItem { - assignee_agent: Some("claude".into()), - ..Default::default() - }, - ) - .unwrap(); - assert_eq!( - get(&conn, &item.id).unwrap().assignee_agent.as_deref(), - Some("claude-code") - ); - let outcome = claim(&conn, &item.id, "claude:1", 1000, TTL).unwrap(); - assert_eq!(outcome, ClaimOutcome::Acquired); - } - - #[test] - fn current_owner_returns_the_claim_owner() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - assert!(crate::claim::current_owner(&conn, &item.id).is_none()); - claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); - assert_eq!( - crate::claim::current_owner(&conn, &item.id).as_deref(), - Some("agent:1") - ); - } - - #[test] - fn current_owner_returns_none_after_done() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); - crate::claim::done(&conn, &item.id, "agent:1", 2000).unwrap(); - assert!(crate::claim::current_owner(&conn, &item.id).is_none()); - } - - #[test] - fn mark_completed_moves_to_completed_state_and_lease_stays_held() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); - assert!(mark_completed(&conn, &item.id, "agent:1").unwrap()); - let done_item = get(&conn, &item.id).unwrap(); - assert_eq!(done_item.state_id, state_in_group(&conn, &pid, "completed")); - assert!(done_item.completed_at.is_some()); - - // Lease is still held — concurrent claim must be rejected. - match claim(&conn, &item.id, "agent:2", 1200, TTL).unwrap() { - ClaimOutcome::Held { .. } => {} - other => panic!("expected Held after mark_completed, got {other:?}"), - } - - // Release the lease, now re-acquirable. - assert!(crate::claim::done(&conn, &item.id, "agent:1", 1300).unwrap()); - let outcome = claim(&conn, &item.id, "agent:2", 1400, TTL).unwrap(); - assert_eq!(outcome, ClaimOutcome::Acquired); - } - - #[test] - fn mark_completed_noop_for_non_owner() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); - assert!(!mark_completed(&conn, &item.id, "agent:2").unwrap()); - } - - #[test] - fn mark_in_review_moves_to_in_review_state_and_lease_stays_held() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); - assert!(mark_in_review(&conn, &item.id, "agent:1").unwrap()); - let reviewed = get(&conn, &item.id).unwrap(); - assert_eq!(reviewed.state_id, state_in_group(&conn, &pid, "in_review")); - // Not actually finished yet -- completed_at must stay unset. - assert!(reviewed.completed_at.is_none()); - - // Lease is still held, same contract as mark_completed. - match claim(&conn, &item.id, "agent:2", 1200, TTL).unwrap() { - ClaimOutcome::Held { .. } => {} - other => panic!("expected Held after mark_in_review, got {other:?}"), - } - } - - #[test] - fn mark_in_review_noop_for_non_owner() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); - assert!(!mark_in_review(&conn, &item.id, "agent:2").unwrap()); - } - - #[test] - fn mark_in_review_backfills_the_state_for_a_project_seeded_before_it_existed() { - // Simulates a project created before item #420: delete the - // "in_review" state seed_defaults would otherwise have created, and - // confirm mark_in_review heals it instead of erroring. - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let old_review_state_id = state_in_group(&conn, &pid, "in_review"); - conn.execute( - "UPDATE states SET deleted_at = 1 WHERE id = ?1", - rusqlite::params![old_review_state_id], - ) - .unwrap(); - assert!(crate::state::first_in_group(&conn, &pid, "in_review").is_err()); - - let item = make_item(&conn, &pid, &sid); - claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); - assert!(mark_in_review(&conn, &item.id, "agent:1").unwrap()); - - let reviewed = get(&conn, &item.id).unwrap(); - let healed = crate::state::first_in_group(&conn, &pid, "in_review").unwrap(); - assert_eq!(reviewed.state_id, healed.id); - assert_ne!(healed.id, old_review_state_id); - } - - #[test] - fn promote_in_review_to_completed_moves_state_and_releases_the_lease() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); - assert!(mark_in_review(&conn, &item.id, "agent:1").unwrap()); - - assert!(promote_in_review_to_completed(&conn, &item.id).unwrap()); - let done_item = get(&conn, &item.id).unwrap(); - assert_eq!(done_item.state_id, state_in_group(&conn, &pid, "completed")); - assert!(done_item.completed_at.is_some()); - - // Lease was released -- a different agent can claim it now. - let outcome = claim(&conn, &item.id, "agent:2", 1200, TTL).unwrap(); - assert_eq!(outcome, ClaimOutcome::Acquired); - } - - #[test] - fn promote_in_review_to_completed_is_a_noop_when_not_in_review() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); - // Still "started", never moved to in_review. - assert!(!promote_in_review_to_completed(&conn, &item.id).unwrap()); - let unchanged = get(&conn, &item.id).unwrap(); - assert_eq!(unchanged.state_id, state_in_group(&conn, &pid, "started")); - } - - #[test] - fn search_ranks_by_relevance() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid.clone(), - name: "Database schema migration".into(), - description: Some("Add users table".into()), - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid.clone(), - name: "Fix login button".into(), - description: Some("Update CSS for login page button".into()), - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid, - name: "Backup database".into(), - description: Some("PR-123 adds nightly DB backup".into()), - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - let results = search(&conn, &pid, "PR-123", None).unwrap(); - assert_eq!(results.len(), 1); - assert!(results[0].description.contains("PR-123")); - - let db_results = search(&conn, &pid, "database", None).unwrap(); - assert_eq!(db_results.len(), 2); - // Both matched — "Database" is in name of item 1, "database" - // is in name of item 3. BM25 ranking may tie; verify both match. - assert!( - db_results[0].name.to_lowercase().contains("database") - || db_results[0] - .description - .to_lowercase() - .contains("database") - ); - } - - #[test] - fn search_empty_query_returns_nothing() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid, - name: "Test".into(), - description: Some("something".into()), - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - let results = search(&conn, &pid, "", None).unwrap(); - assert!(results.is_empty()); - } - - #[test] - fn search_scoped_to_project() { - let conn = db::open_in_memory().unwrap(); - let (pid1, sid1) = seed_project(&conn, "1"); - let (pid2, sid2) = seed_project(&conn, "2"); - create( - &conn, - CreateItem { - project_id: pid1.clone(), - state_id: sid1, - name: "Database setup".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - create( - &conn, - CreateItem { - project_id: pid2.clone(), - state_id: sid2, - name: "Database setup".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - assert_eq!(search(&conn, &pid1, "database", None).unwrap().len(), 1); - assert_eq!(search(&conn, &pid2, "database", None).unwrap().len(), 1); - } - - #[test] - fn search_falls_back_to_like_for_suffix_of_compound_token() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid, - name: "Implement agentflare-store v1".into(), - description: Some("unified local storage layer".into()), - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - - // FTS5 tokenizes "agentflare-store" as ["agentflare", "store"], so a - // bare "flare" query (a suffix, not a prefix, of "agentflare") finds - // nothing via MATCH — only the LIKE fallback can find it. - let results = search(&conn, &pid, "flare-store", None).unwrap(); - assert_eq!(results.len(), 1); - assert!(results[0].name.contains("agentflare-store")); - } - - #[test] - fn search_like_fallback_matches_literal_backslash_in_query() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid, - name: r"agentflare\filter setup".into(), - description: Some("unrelated".into()), - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - - // FTS5 tokenizes on the backslash the same way it does on a hyphen - // (see the suffix-of-compound-token test above), so "flare\filter" - // has no whole-token FTS match and only the LIKE fallback can find - // it. Before escaping backslashes first, `format!` left the query's - // real `\` in the pattern un-doubled, so SQLite's `ESCAPE '\\'` - // silently swallowed it as an (undefined) escape prefix for the - // next character instead of matching it literally — the fallback - // then missed a hit it should have found. - let results = search(&conn, &pid, r"flare\filter", None).unwrap(); - assert_eq!(results.len(), 1); - } - - #[test] - fn heartbeat_release_done_are_owner_scoped() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); - - assert!(!crate::claim::heartbeat(&conn, &item.id, "agent:2", 1100).unwrap()); - assert!(!crate::claim::release(&conn, &item.id, "agent:2").unwrap()); - assert!(!crate::claim::done(&conn, &item.id, "agent:2", 1100).unwrap()); - - assert!(crate::claim::heartbeat(&conn, &item.id, "agent:1", 1100).unwrap()); - assert!(crate::claim::done(&conn, &item.id, "agent:1", 1200).unwrap()); - } - - #[test] - fn resolve_id_passes_through_uuid_unchanged() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - - let resolved = resolve_id(&conn, Some(&pid), &item.id).unwrap(); - assert_eq!(resolved, item.id); - } - - #[test] - fn resolve_id_resolves_bare_numeric_sequence_id() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - - let resolved = resolve_id(&conn, Some(&pid), &item.sequence_id.to_string()).unwrap(); - assert_eq!(resolved, item.id); - } - - #[test] - fn resolve_id_resolves_hash_prefixed_sequence_id() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let item = make_item(&conn, &pid, &sid); - - let resolved = resolve_id(&conn, Some(&pid), &format!("#{}", item.sequence_id)).unwrap(); - assert_eq!(resolved, item.id); - } - - #[test] - fn resolve_id_numeric_not_found_returns_not_found_error() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - let _item = make_item(&conn, &pid, &sid); - - let err = resolve_id(&conn, Some(&pid), "999999").unwrap_err(); - assert!(matches!(err, crate::error::Error::NotFound(_)), "{err:?}"); - } - - #[test] - fn resolve_id_scopes_numeric_lookup_to_project() { - let conn = db::open_in_memory().unwrap(); - let (pid_a, sid_a) = seed_project(&conn, "a"); - let (pid_b, _sid_b) = seed_project(&conn, "b"); - let item = make_item(&conn, &pid_a, &sid_a); - - // The item's sequence_id exists in project A but not project B. - let err = resolve_id(&conn, Some(&pid_b), &item.sequence_id.to_string()).unwrap_err(); - assert!(matches!(err, crate::error::Error::NotFound(_)), "{err:?}"); - } - - #[test] - fn create_and_update_canonicalize_known_assignee_aliases() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, ""); - - let item = create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid.clone(), - name: "Test".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: Some("claude".into()), - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - assert_eq!(item.assignee_agent.as_deref(), Some("claude-code")); - - let updated = update( - &conn, - &item.id, - UpdateItem { - assignee_agent: Some("Claude Code".into()), - ..Default::default() - }, - ) - .unwrap(); - assert_eq!(updated.assignee_agent.as_deref(), Some("claude-code")); - } - - #[test] - fn list_by_label_returns_only_items_carrying_that_label() { - let conn = db::open_in_memory().unwrap(); - let (pid, sid) = seed_project(&conn, "label"); - let ws_id = crate::project::get(&conn, &pid).unwrap().workspace_id; - let label = crate::label::create( - &conn, - crate::label::CreateLabel { - project_id: Some(pid.clone()), - workspace_id: ws_id, - name: "ready-for-work".into(), - color: None, - parent_id: None, - sort_order: None, - external_source: None, - external_id: None, - }, - ) - .unwrap(); - - let labeled = create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid.clone(), - name: "Labeled".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - create( - &conn, - CreateItem { - project_id: pid.clone(), - state_id: sid, - name: "Unlabeled".into(), - description: None, - priority: None, - parent_id: None, - assignee_agent: None, - sort_order: None, - external_source: None, - external_id: None, - metadata: None, - label_ids: vec![], - assignee_ids: vec![], - dependency_ids: vec![], - }, - ) - .unwrap(); - add_label(&conn, &labeled.id, &label.id).unwrap(); - - let found = list_by_label(&conn, &pid, &label.id).unwrap(); - assert_eq!(found.len(), 1); - assert_eq!(found[0].id, labeled.id); - } -} diff --git a/crates/agentflare-backend/src/item/claim.rs b/crates/agentflare-backend/src/item/claim.rs new file mode 100644 index 00000000..d51bd6af --- /dev/null +++ b/crates/agentflare-backend/src/item/claim.rs @@ -0,0 +1,182 @@ +use rusqlite::Connection; + +use crate::error::Result; + +use super::crud::{get, update, update_state}; +use super::{UpdateItem, now}; + +/// Outcome of a claim attempt — the raw lease `Acquire` plus the handoff +/// freeze rule: while an item carries an `assignee_agent` that nobody has +/// claimed yet (a handoff sitting unaccepted), only that assignee may +/// acquire it. Once any claim has ever been taken (even a since-stale one), +/// the ordinary `Acquired`/`Held` staleness rules take back over — this +/// variant only covers the fresh, never-claimed window. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaimOutcome { + Acquired, + Held { owner: String, age_secs: i64 }, + BlockedByAssignee { assignee: String }, +} + +/// Canonical agent identity of an owner id (`:` -> +/// canonical ``), matching `assignee_agent`'s canonical form — +/// `assignee_agent` is canonicalized on write (see `create`/`update`), but +/// `owner` is the raw caller-supplied id, so an alias like `claude:1` must +/// be canonicalized here too or it won't match `claude-code`. +/// +/// `pub` because `assignee_agent` legitimately carries the instance suffix +/// after a claim (`claim()` below stores the raw `owner`, on purpose — see +/// its own doc comment and the tests pinning that), so any caller outside +/// this module that reads `assignee_agent` back to resolve *which agent +/// type* it names (not which specific instance) needs the same stripping +/// this module already does internally, instead of re-deriving it. +pub fn agent_part(owner: &str) -> String { + agent_registry::canonicalize(owner.split(':').next().unwrap_or(owner)) +} + +/// Claims an item so other agents don't duplicate the work: on a fresh +/// acquire, sets the assignee and moves state into the project's "started" +/// group (which sets `started_at`, via `update_state`). A live claim held by +/// someone else returns `Held` and leaves the item untouched. An item +/// freshly handed off (assignee set, never yet claimed) to a *different* +/// agent than the caller returns `BlockedByAssignee` instead of letting the +/// caller silently steal it. Acquisition, the state transition, and the +/// assignee update are one transaction — a mid-sequence failure can't leave +/// `item_claims` saying "claimed" while the item itself never reflects it. +pub fn claim( + conn: &Connection, + item_id: &str, + owner: &str, + now: i64, + ttl_secs: i64, +) -> Result { + let tx = conn.unchecked_transaction()?; + let item = get(&tx, item_id)?; + if let Some(assignee) = &item.assignee_agent + && agent_part(assignee) != agent_part(owner) + && crate::claim::current_owner(&tx, item_id).is_none() + { + // Excludes completed/cancelled items: a done-and-released item is + // fair game for anyone to re-claim (e.g. reopened follow-up work) — + // the freeze only protects a handoff that's still open. + let state = crate::state::get(&tx, &item.state_id)?; + if !matches!(state.group_name.as_str(), "completed" | "cancelled") { + return Ok(ClaimOutcome::BlockedByAssignee { + assignee: assignee.clone(), + }); + } + } + let outcome = crate::claim::acquire(&tx, item_id, owner, now, ttl_secs)?; + let result = match outcome { + crate::claim::Acquire::Acquired => { + let started_state = crate::state::first_in_group(&tx, &item.project_id, "started")?; + update_state(&tx, item_id, &started_state.id)?; + update( + &tx, + item_id, + UpdateItem { + assignee_agent: Some(owner.to_string()), + ..Default::default() + }, + )?; + ClaimOutcome::Acquired + } + crate::claim::Acquire::Held { owner, age_secs } => ClaimOutcome::Held { owner, age_secs }, + }; + tx.commit()?; + Ok(result) +} + +/// Moves a claimed item into the project's "completed" group WITHOUT +/// releasing the claim lease yet. Deliberately split from the lease release +/// (contrast with the old `claim_done`, which did both atomically): the +/// `"done"` MCP arm calls this, then runs `worktree::push_and_open_pr` +/// (which needs the lease to still look held so a concurrent `claim()` on +/// the same item between mark_completed and the deferred release below is +/// still correctly rejected), and only *after* publish releases the lease +/// via `claim::done`. Returns `Ok(true)` when the item was actually moved +/// to completed, `Ok(false)` when the caller doesn't own the claim. +pub fn mark_completed(conn: &Connection, item_id: &str, owner: &str) -> Result { + // One transaction start to finish so the ownership check can't go stale + // between the guard and the write — without this, a concurrent + // release()+claim() by a different owner could slip in between the + // check and update_state below, completing the item out from under its + // new owner. + let tx = conn.unchecked_transaction()?; + if !crate::claim::is_owner(&tx, item_id, owner)? { + return Ok(false); + } + let item = get(&tx, item_id)?; + let completed_state = crate::state::first_in_group(&tx, &item.project_id, "completed")?; + update_state(&tx, item_id, &completed_state.id)?; + tx.commit()?; + // Keep the claim lease held for the MCP caller's deferred release. + Ok(true) +} + +/// Moves a claimed item into the project's "in_review" group, same shape and +/// same claim-lease-stays-held contract as `mark_completed` above — used +/// instead of it when `done` results in an open PR (item #420). The work +/// isn't actually finished until that PR merges: landing straight on +/// "completed" would show the item as done while its PR is still red or +/// under review, which is the state-side half of the bug `mark_completed` +/// alone had (the other half was deleting the worktree too, fixed in +/// `mcp_server::item::item_done` by only cleaning up when no PR resulted). +/// +/// Auto-creates the "in_review" state on first use per project: it's in +/// `state::DEFAULT_STATES` for every project seeded after item #420, but +/// existing projects were seeded before it existed and have no such state +/// to find. +pub fn mark_in_review(conn: &Connection, item_id: &str, owner: &str) -> Result { + let tx = conn.unchecked_transaction()?; + if !crate::claim::is_owner(&tx, item_id, owner)? { + return Ok(false); + } + let item = get(&tx, item_id)?; + let review_state = match crate::state::first_in_group(&tx, &item.project_id, "in_review") { + Ok(s) => s, + Err(crate::error::Error::NotFound(_)) => crate::state::create( + &tx, + crate::state::CreateState { + project_id: item.project_id.clone(), + name: crate::state::IN_REVIEW_STATE_NAME.into(), + group_name: "in_review".into(), + sequence: crate::state::IN_REVIEW_STATE_SEQUENCE, + is_default: None, + color: Some(crate::state::IN_REVIEW_STATE_COLOR.into()), + }, + )?, + Err(e) => return Err(e), + }; + update_state(&tx, item_id, &review_state.id)?; + tx.commit()?; + Ok(true) +} + +/// Promotes an item from "in_review" to "completed" once its PR is +/// confirmed merged (`item_check_merge`, item #420). Unlike +/// `mark_completed`/`mark_in_review`, this is deliberately NOT owner-scoped: +/// `item_done` leaves the claim lease held (not released) when it moves an +/// item into "in_review" specifically so nobody else can claim it out from +/// under the pending review, so by the time anything reaches this function +/// no other owner could legally exist — whoever notices the merge and calls +/// `check_merge`, possibly a different session than the one that opened the +/// PR, is allowed to finish the transition. Releases whatever lease is +/// still held as part of the same commit. Returns `Ok(false)` (a no-op, +/// not an error) when the item isn't currently in "in_review" — callers +/// can call this speculatively without checking state first. +pub fn promote_in_review_to_completed(conn: &Connection, item_id: &str) -> Result { + let tx = conn.unchecked_transaction()?; + let item = get(&tx, item_id)?; + let state = crate::state::get(&tx, &item.state_id)?; + if state.group_name != "in_review" { + return Ok(false); + } + let completed_state = crate::state::first_in_group(&tx, &item.project_id, "completed")?; + update_state(&tx, item_id, &completed_state.id)?; + if let Some(owner) = crate::claim::current_owner(&tx, item_id) { + crate::claim::done(&tx, item_id, &owner, now())?; + } + tx.commit()?; + Ok(true) +} diff --git a/crates/agentflare-backend/src/item/crud.rs b/crates/agentflare-backend/src/item/crud.rs new file mode 100644 index 00000000..9f9f2643 --- /dev/null +++ b/crates/agentflare-backend/src/item/crud.rs @@ -0,0 +1,312 @@ +use rusqlite::Connection; + +use crate::error::Result; +use crate::events; + +use super::relations::{add_assignee, add_dependency, add_label}; +use super::{ + CreateItem, Item, UpdateItem, next_sequence_id, now, row_to_item, workspace_id_for_project, +}; + +pub fn create(conn: &Connection, input: CreateItem) -> Result { + let id = db_kit::ids::new_id(); + let ts = now(); + let sort_order = input.sort_order.unwrap_or(65535.0); + let description = input.description.unwrap_or_default(); + let priority = input.priority.unwrap_or_else(|| "none".to_string()); + let metadata = input.metadata.unwrap_or_else(|| "{}".to_string()); + let assignee_agent = input + .assignee_agent + .as_deref() + .map(agent_registry::canonicalize); + + let state = crate::state::get(conn, &input.state_id)?; + if state.project_id != input.project_id { + return Err(crate::error::Error::InvalidTransition(format!( + "state {} belongs to a different project than project {}", + input.state_id, input.project_id + ))); + } + + let tx = conn.unchecked_transaction()?; + let seq = next_sequence_id(&tx, &input.project_id)?; + tx.execute( + "INSERT INTO items (id, project_id, state_id, name, description, priority, parent_id, assignee_agent, sequence_id, sort_order, external_source, external_id, metadata, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + rusqlite::params![ + id, + input.project_id, + input.state_id, + input.name, + description, + priority, + input.parent_id, + assignee_agent, + seq, + sort_order, + input.external_source, + input.external_id, + metadata, + ts, + ts, + ], + )?; + for label_id in &input.label_ids { + add_label(&tx, &id, label_id)?; + } + for agent_id in &input.assignee_ids { + add_assignee(&tx, &id, agent_id)?; + } + for dep_id in &input.dependency_ids { + add_dependency(&tx, &id, dep_id)?; + } + tx.commit()?; + let item = get(conn, &id)?; + if let Ok(wid) = workspace_id_for_project(conn, &item.project_id) { + events::emit( + conn, + &wid, + "item", + "create", + serde_json::to_value(&item).unwrap_or_default(), + ); + } + Ok(item) +} + +pub fn get(conn: &Connection, id: &str) -> Result { + conn.query_row( + "SELECT id, project_id, state_id, name, description, priority, parent_id, assignee_agent, sequence_id, sort_order, started_at, completed_at, archived_at, external_source, external_id, metadata, created_at, updated_at, deleted_at + FROM items WHERE id = ?1 AND deleted_at IS NULL", + rusqlite::params![id], + row_to_item, + ) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => crate::error::Error::NotFound(id.to_string()), + other => other.into(), + }) +} + +/// Resolve a user-supplied identifier to an item UUID. +/// Accepts a UUID (pass-through) or a numeric `sequence_id`. +/// When `project_id` is `Some`, scopes the sequence_id lookup to that project; +/// when `None`, searches across all projects (returns the first match). +pub fn resolve_id(conn: &Connection, project_id: Option<&str>, id_or_seq: &str) -> Result { + let numeric_part = id_or_seq.strip_prefix('#').unwrap_or(id_or_seq); + if let Ok(seq) = numeric_part.parse::() { + let sql = match project_id { + Some(_) => { + "SELECT id FROM items WHERE project_id = ?1 AND sequence_id = ?2 AND deleted_at IS NULL" + } + None => "SELECT id FROM items WHERE sequence_id = ?1 AND deleted_at IS NULL LIMIT 1", + }; + let params: Vec> = match project_id { + Some(pid) => vec![Box::new(pid.to_string()), Box::new(seq)], + None => vec![Box::new(seq)], + }; + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + conn.query_row(sql, params_ref.as_slice(), |row| row.get(0)) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => { + crate::error::Error::NotFound(format!("sequence_id #{seq}")) + } + other => other.into(), + }) + } else { + Ok(id_or_seq.to_string()) + } +} + +pub fn list_by_project(conn: &Connection, project_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, project_id, state_id, name, description, priority, parent_id, assignee_agent, sequence_id, sort_order, started_at, completed_at, archived_at, external_source, external_id, metadata, created_at, updated_at, deleted_at + FROM items WHERE project_id = ?1 AND deleted_at IS NULL ORDER BY sort_order", + )?; + let rows = stmt.query_map(rusqlite::params![project_id], row_to_item)?; + Ok(rows.collect::>()?) +} + +pub fn list_by_label(conn: &Connection, project_id: &str, label_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT items.id, items.project_id, items.state_id, items.name, items.description, items.priority, items.parent_id, items.assignee_agent, items.sequence_id, items.sort_order, items.started_at, items.completed_at, items.archived_at, items.external_source, items.external_id, items.metadata, items.created_at, items.updated_at, items.deleted_at + FROM items + INNER JOIN item_labels ON item_labels.item_id = items.id + WHERE item_labels.label_id = ?1 AND items.project_id = ?2 AND items.deleted_at IS NULL + ORDER BY items.sort_order", + )?; + let rows = stmt.query_map(rusqlite::params![label_id, project_id], row_to_item)?; + Ok(rows.collect::>()?) +} + +/// List non-deleted items assigned to an agent (excludes completed/cancelled). +pub fn list_by_assignee_agent( + conn: &Connection, + project_id: &str, + agent: &str, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT i.id, i.project_id, i.state_id, i.name, i.description, + i.priority, i.parent_id, i.assignee_agent, i.sequence_id, + i.sort_order, i.started_at, i.completed_at, i.archived_at, + i.external_source, i.external_id, i.metadata, + i.created_at, i.updated_at, i.deleted_at + FROM items i + JOIN states s ON s.id = i.state_id + WHERE i.project_id = ?1 + AND i.assignee_agent = ?2 + AND i.deleted_at IS NULL + AND s.group_name NOT IN ('completed', 'cancelled') + ORDER BY i.sort_order", + )?; + let rows = stmt.query_map(rusqlite::params![project_id, agent], row_to_item)?; + Ok(rows.collect::>()?) +} + +pub fn update(conn: &Connection, id: &str, input: UpdateItem) -> Result { + let ts = now(); + let assignee_agent = input + .assignee_agent + .as_deref() + .map(agent_registry::canonicalize); + let mut sets = vec!["updated_at = ?2".to_string()]; + let mut param_idx = 3; + if input.name.is_some() { + sets.push(format!("name = ?{param_idx}")); + param_idx += 1; + } + if input.description.is_some() { + sets.push(format!("description = ?{param_idx}")); + param_idx += 1; + } + if input.priority.is_some() { + sets.push(format!("priority = ?{param_idx}")); + param_idx += 1; + } + if input.state_id.is_some() { + sets.push(format!("state_id = ?{param_idx}")); + param_idx += 1; + } + if assignee_agent.is_some() { + sets.push(format!("assignee_agent = ?{param_idx}")); + param_idx += 1; + } + if input.sort_order.is_some() { + sets.push(format!("sort_order = ?{param_idx}")); + param_idx += 1; + } + if input.metadata.is_some() { + sets.push(format!("metadata = ?{param_idx}")); + } + let sql = format!( + "UPDATE items SET {} WHERE id = ?1 AND deleted_at IS NULL", + sets.join(", ") + ); + let mut stmt = conn.prepare(&sql)?; + let mut param_values: Vec> = Vec::new(); + param_values.push(Box::new(id.to_string())); + param_values.push(Box::new(ts)); + if let Some(ref name) = input.name { + param_values.push(Box::new(name.clone())); + } + if let Some(ref desc) = input.description { + param_values.push(Box::new(desc.clone())); + } + if let Some(ref pri) = input.priority { + param_values.push(Box::new(pri.clone())); + } + if let Some(ref sid) = input.state_id { + param_values.push(Box::new(sid.clone())); + } + if let Some(ref agent) = assignee_agent { + param_values.push(Box::new(agent.clone())); + } + if let Some(so) = input.sort_order { + param_values.push(Box::new(so)); + } + if let Some(ref metadata) = input.metadata { + param_values.push(Box::new(metadata.clone())); + } + let changed = stmt.execute(rusqlite::params_from_iter(param_values.iter()))?; + if changed == 0 { + return Err(crate::error::Error::NotFound(id.to_string())); + } + let item = get(conn, id)?; + if let Ok(wid) = workspace_id_for_project(conn, &item.project_id) { + events::emit( + conn, + &wid, + "item", + "update", + serde_json::to_value(&item).unwrap_or_default(), + ); + } + Ok(item) +} + +/// Moves an item to a different state within its project. Unlike `update()`, +/// this sets `started_at`/`completed_at` based on the *target* state's +/// group — deliberately not a transition state-machine (Plane itself allows +/// any state → any state; only timestamps follow group membership), so the +/// one real constraint enforced here is that `state_id` belongs to the same +/// project as the item. +pub fn update_state(conn: &Connection, id: &str, state_id: &str) -> Result { + let item = get(conn, id)?; + let state = crate::state::get(conn, state_id)?; + if state.project_id != item.project_id { + return Err(crate::error::Error::InvalidTransition(format!( + "state {state_id} belongs to a different project than item {id}" + ))); + } + let ts = now(); + let changed = match state.group_name.as_str() { + "started" => conn.execute( + "UPDATE items SET state_id = ?2, started_at = ?3, updated_at = ?3 WHERE id = ?1 AND deleted_at IS NULL", + rusqlite::params![id, state_id, ts], + )?, + "completed" => conn.execute( + "UPDATE items SET state_id = ?2, completed_at = ?3, updated_at = ?3 WHERE id = ?1 AND deleted_at IS NULL", + rusqlite::params![id, state_id, ts], + )?, + _ => conn.execute( + "UPDATE items SET state_id = ?2, updated_at = ?3 WHERE id = ?1 AND deleted_at IS NULL", + rusqlite::params![id, state_id, ts], + )?, + }; + if changed == 0 { + return Err(crate::error::Error::NotFound(id.to_string())); + } + let item = get(conn, id)?; + if let Ok(wid) = workspace_id_for_project(conn, &item.project_id) { + events::emit( + conn, + &wid, + "item", + "update", + serde_json::to_value(&item).unwrap_or_default(), + ); + } + Ok(item) +} + +pub fn delete(conn: &Connection, id: &str) -> Result<()> { + let item = get(conn, id)?; + let ts = now(); + let changed = conn.execute( + "UPDATE items SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL", + rusqlite::params![ts, id], + )?; + if changed == 0 { + return Err(crate::error::Error::NotFound(id.to_string())); + } + if let Ok(wid) = workspace_id_for_project(conn, &item.project_id) { + events::emit( + conn, + &wid, + "item", + "delete", + serde_json::json!({"id": item.id}), + ); + } + Ok(()) +} diff --git a/crates/agentflare-backend/src/item/mod.rs b/crates/agentflare-backend/src/item/mod.rs new file mode 100644 index 00000000..eefb1fda --- /dev/null +++ b/crates/agentflare-backend/src/item/mod.rs @@ -0,0 +1,126 @@ +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use crate::error::Result; + +mod claim; +mod crud; +mod relations; +mod search; +#[cfg(test)] +mod tests; + +pub use claim::*; +pub use crud::*; +pub use relations::*; +pub use search::*; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Item { + pub id: String, + pub project_id: String, + pub state_id: String, + pub name: String, + pub description: String, + pub priority: String, + pub parent_id: Option, + pub assignee_agent: Option, + pub sequence_id: i64, + pub sort_order: f64, + pub started_at: Option, + pub completed_at: Option, + pub archived_at: Option, + pub external_source: Option, + pub external_id: Option, + pub metadata: String, + pub created_at: i64, + pub updated_at: i64, + pub deleted_at: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateItem { + pub project_id: String, + pub state_id: String, + pub name: String, + pub description: Option, + pub priority: Option, + pub parent_id: Option, + pub assignee_agent: Option, + pub sort_order: Option, + pub external_source: Option, + pub external_id: Option, + pub metadata: Option, + pub label_ids: Vec, + pub assignee_ids: Vec, + pub dependency_ids: Vec, +} + +#[derive(Debug, Deserialize, Default)] +pub struct UpdateItem { + pub name: Option, + pub description: Option, + pub priority: Option, + pub state_id: Option, + pub assignee_agent: Option, + pub sort_order: Option, + pub metadata: Option, +} + +fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +fn row_to_item(row: &rusqlite::Row) -> rusqlite::Result { + Ok(Item { + id: row.get(0)?, + project_id: row.get(1)?, + state_id: row.get(2)?, + name: row.get(3)?, + description: row.get(4)?, + priority: row.get(5)?, + parent_id: row.get(6)?, + assignee_agent: row.get(7)?, + sequence_id: row.get(8)?, + sort_order: row.get(9)?, + started_at: row.get(10)?, + completed_at: row.get(11)?, + archived_at: row.get(12)?, + external_source: row.get(13)?, + external_id: row.get(14)?, + metadata: row.get(15)?, + created_at: row.get(16)?, + updated_at: row.get(17)?, + deleted_at: row.get(18)?, + }) +} + +fn next_sequence_id(conn: &Connection, project_id: &str) -> rusqlite::Result { + conn.execute( + "INSERT INTO project_sequences (project_id, next_seq) VALUES (?1, 1) + ON CONFLICT(project_id) DO UPDATE SET next_seq = next_seq + 1", + rusqlite::params![project_id], + )?; + conn.query_row( + "SELECT next_seq FROM project_sequences WHERE project_id = ?1", + rusqlite::params![project_id], + |row| row.get(0), + ) +} + +fn workspace_id_for_project(conn: &Connection, project_id: &str) -> Result { + conn.query_row( + "SELECT workspace_id FROM projects WHERE id = ?1 AND deleted_at IS NULL", + rusqlite::params![project_id], + |row| row.get(0), + ) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => { + crate::error::Error::NotFound(project_id.to_string()) + } + other => other.into(), + }) +} diff --git a/crates/agentflare-backend/src/item/relations.rs b/crates/agentflare-backend/src/item/relations.rs new file mode 100644 index 00000000..ee19d354 --- /dev/null +++ b/crates/agentflare-backend/src/item/relations.rs @@ -0,0 +1,144 @@ +use rusqlite::Connection; + +use crate::error::Result; + +use super::{get, workspace_id_for_project}; + +pub fn add_label(conn: &Connection, item_id: &str, label_id: &str) -> Result<()> { + // A label may only be attached to an item in the same scope: a project-scoped + // label must share the item's project; a workspace-level label (project_id NULL) + // must share the item's workspace. This mirrors Plane's project-membership check + // and, because item::create routes through here, guards that path too. + let item = get(conn, item_id)?; + let label = crate::label::get(conn, label_id)?; + let in_scope = match &label.project_id { + Some(project_id) => project_id == &item.project_id, + None => label.workspace_id == workspace_id_for_project(conn, &item.project_id)?, + }; + if !in_scope { + return Err(crate::error::Error::Validation(format!( + "label {label_id} is not in item {item_id}'s scope (project or workspace)" + ))); + } + conn.execute( + "INSERT OR IGNORE INTO item_labels (item_id, label_id) VALUES (?1, ?2)", + rusqlite::params![item_id, label_id], + )?; + Ok(()) +} + +pub fn remove_label(conn: &Connection, item_id: &str, label_id: &str) -> Result<()> { + conn.execute( + "DELETE FROM item_labels WHERE item_id = ?1 AND label_id = ?2", + rusqlite::params![item_id, label_id], + )?; + Ok(()) +} + +pub fn list_labels(conn: &Connection, item_id: &str) -> Result> { + let mut stmt = conn.prepare("SELECT label_id FROM item_labels WHERE item_id = ?1")?; + let rows = stmt.query_map(rusqlite::params![item_id], |row| row.get::<_, String>(0))?; + Ok(rows.collect::>()?) +} + +pub fn add_assignee(conn: &Connection, item_id: &str, agent_id: &str) -> Result<()> { + conn.execute( + "INSERT OR IGNORE INTO item_assignees (item_id, agent_id) VALUES (?1, ?2)", + rusqlite::params![item_id, agent_id], + )?; + Ok(()) +} + +pub fn remove_assignee(conn: &Connection, item_id: &str, agent_id: &str) -> Result<()> { + conn.execute( + "DELETE FROM item_assignees WHERE item_id = ?1 AND agent_id = ?2", + rusqlite::params![item_id, agent_id], + )?; + Ok(()) +} + +pub fn list_assignees(conn: &Connection, item_id: &str) -> Result> { + let mut stmt = conn.prepare("SELECT agent_id FROM item_assignees WHERE item_id = ?1")?; + let rows = stmt.query_map(rusqlite::params![item_id], |row| row.get::<_, String>(0))?; + Ok(rows.collect::>()?) +} + +pub fn add_dependency(conn: &Connection, item_id: &str, depends_on: &str) -> Result<()> { + conn.execute( + "INSERT OR IGNORE INTO item_dependencies (item_id, depends_on_item_id) VALUES (?1, ?2)", + rusqlite::params![item_id, depends_on], + )?; + Ok(()) +} + +pub fn remove_dependency(conn: &Connection, item_id: &str, depends_on: &str) -> Result<()> { + conn.execute( + "DELETE FROM item_dependencies WHERE item_id = ?1 AND depends_on_item_id = ?2", + rusqlite::params![item_id, depends_on], + )?; + Ok(()) +} + +pub fn list_dependencies(conn: &Connection, item_id: &str) -> Result> { + let mut stmt = + conn.prepare("SELECT depends_on_item_id FROM item_dependencies WHERE item_id = ?1")?; + let rows = stmt.query_map(rusqlite::params![item_id], |row| row.get::<_, String>(0))?; + Ok(rows.collect::>()?) +} + +/// Dependency edges for a set of items, with each edge's target state_group +/// already joined in — so a caller's blocking status is correct even when +/// the dependency target isn't itself in the same shortlist/limit window +/// (e.g. a completed dependency that fell outside `groom`'s cap must not +/// read back as an open blocker just because its state wasn't looked up). +/// `(item_id, depends_on_item_id, depends_on_state_group)`. +pub fn dependency_edges_for_items( + conn: &Connection, + item_ids: &[String], +) -> Result> { + if item_ids.is_empty() { + return Ok(vec![]); + } + let placeholders = item_ids.iter().map(|_| "?").collect::>().join(","); + let sql = format!( + "SELECT d.item_id, d.depends_on_item_id, s.group_name + FROM item_dependencies d + JOIN items i ON i.id = d.depends_on_item_id AND i.deleted_at IS NULL + JOIN states s ON s.id = i.state_id + WHERE d.item_id IN ({placeholders})" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(item_ids.iter()), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })?; + Ok(rows.collect::>()?) +} + +/// Fan-in counts: for each of `item_ids`, how many other (non-deleted) items +/// declare a dependency on it — project-wide, not limited to the same +/// shortlist/limit window a caller happens to be looking at. +pub fn dependency_fanin_for_items( + conn: &Connection, + item_ids: &[String], +) -> Result> { + if item_ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + let placeholders = item_ids.iter().map(|_| "?").collect::>().join(","); + let sql = format!( + "SELECT d.depends_on_item_id, COUNT(*) + FROM item_dependencies d + JOIN items i ON i.id = d.item_id AND i.deleted_at IS NULL + WHERE d.depends_on_item_id IN ({placeholders}) + GROUP BY d.depends_on_item_id" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(item_ids.iter()), |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + Ok(rows.collect::>()?) +} diff --git a/crates/agentflare-backend/src/item/search.rs b/crates/agentflare-backend/src/item/search.rs new file mode 100644 index 00000000..3299f31b --- /dev/null +++ b/crates/agentflare-backend/src/item/search.rs @@ -0,0 +1,78 @@ +use rusqlite::Connection; + +use crate::error::Result; + +use super::{Item, row_to_item}; + +/// FTS5 search across items (name, description, metadata) within a project. +/// Returns BM25-ranked results, most relevant first. Query is sanitised +/// via `flare-search-kit` into safe FTS5 tokens (quoted, operators +/// neutralised) so user input like `PR-123` isn't misinterpreted as +/// column:value syntax. +/// +/// Falls back to a `LIKE` substring scan when FTS5 finds nothing. FTS5's +/// default tokenizer splits on `-`/`_`, so a compound identifier like +/// `agentflare-store` indexes as separate `agentflare`/`store` tokens — +/// a query for `flare-store` (or bare `flare`) would otherwise miss it, +/// since `flare` is a suffix, not a prefix, of `agentflare`. +pub fn search( + conn: &Connection, + project_id: &str, + query: &str, + limit: Option, +) -> Result> { + let limit = limit.unwrap_or(20); + let safe = + flare_search_kit::fts_query(query, flare_search_kit::MatchMode::All).unwrap_or_default(); + if safe.is_empty() { + return Ok(vec![]); + } + let mut stmt = conn.prepare( + "SELECT items.id, items.project_id, items.state_id, items.name, items.description, + items.priority, items.parent_id, items.assignee_agent, items.sequence_id, + items.sort_order, items.started_at, items.completed_at, items.archived_at, + items.external_source, items.external_id, items.metadata, + items.created_at, items.updated_at, items.deleted_at + FROM items_fts + JOIN items ON items.rowid = items_fts.rowid + WHERE items.project_id = ?1 + AND items_fts MATCH ?2 + AND items.deleted_at IS NULL + ORDER BY bm25(items_fts, 3.0, 1.0, 1.0) + LIMIT ?3", + )?; + let rows = stmt.query_map( + rusqlite::params![project_id, safe, flare_search_kit::clamped_limit(limit)], + row_to_item, + )?; + let results: Vec = rows.collect::>()?; + if !results.is_empty() { + return Ok(results); + } + + let like_pat = format!( + "%{}%", + query + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") + ); + let mut like_stmt = conn.prepare( + "SELECT items.id, items.project_id, items.state_id, items.name, items.description, + items.priority, items.parent_id, items.assignee_agent, items.sequence_id, + items.sort_order, items.started_at, items.completed_at, items.archived_at, + items.external_source, items.external_id, items.metadata, + items.created_at, items.updated_at, items.deleted_at + FROM items + WHERE items.project_id = ?1 + AND items.deleted_at IS NULL + AND (items.name LIKE ?2 ESCAPE '\\' OR items.description LIKE ?2 ESCAPE '\\') + ORDER BY items.updated_at DESC + LIMIT ?3", + )?; + let like_rows = like_stmt.query_map( + rusqlite::params![project_id, like_pat, flare_search_kit::clamped_limit(limit)], + row_to_item, + )?; + Ok(like_rows.collect::>()?) +} diff --git a/crates/agentflare-backend/src/item/tests.rs b/crates/agentflare-backend/src/item/tests.rs new file mode 100644 index 00000000..68b7745c --- /dev/null +++ b/crates/agentflare-backend/src/item/tests.rs @@ -0,0 +1,1329 @@ +use super::*; +use crate::db; +use crate::project::{self, CreateProject}; +use crate::workspace::{self, CreateWorkspace}; + +fn seed_project(conn: &Connection, suffix: &str) -> (String, String) { + let ws = workspace::create( + conn, + CreateWorkspace { + name: format!("Test{suffix}"), + slug: format!("test{suffix}"), + owner_agent: None, + item_label: None, + }, + ) + .unwrap(); + let proj = project::create( + conn, + CreateProject { + workspace_id: ws.id.clone(), + name: format!("Test{suffix}"), + identifier: format!("T{suffix}"), + external_source: None, + external_id: None, + }, + ) + .unwrap(); + let states = crate::state::list_by_project(conn, &proj.id).unwrap(); + let state_id = states + .iter() + .find(|s| s.is_default) + .map(|s| s.id.clone()) + .unwrap(); + (proj.id, state_id) +} + +#[test] +fn create_and_get() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = create( + &conn, + CreateItem { + project_id: pid, + state_id: sid, + name: "Test Item".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + assert_eq!(item.name, "Test Item"); + assert_eq!(item.sequence_id, 1); + let got = get(&conn, &item.id).unwrap(); + assert_eq!(got.id, item.id); +} + +#[test] +fn sequence_increments() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let i1 = create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid.clone(), + name: "First".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + let i2 = create( + &conn, + CreateItem { + project_id: pid, + state_id: sid, + name: "Second".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + assert_eq!(i1.sequence_id, 1); + assert_eq!(i2.sequence_id, 2); +} + +#[test] +fn list_by_project_scopes() { + let conn = db::open_in_memory().unwrap(); + let (pid1, sid1) = seed_project(&conn, "1"); + let (pid2, _sid2) = seed_project(&conn, "2"); + create( + &conn, + CreateItem { + project_id: pid1.clone(), + state_id: sid1, + name: "Item 1".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + assert_eq!(list_by_project(&conn, &pid1).unwrap().len(), 1); + assert_eq!(list_by_project(&conn, &pid2).unwrap().len(), 0); +} + +#[test] +fn add_and_remove_labels() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid, + name: "Test".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + let ws = crate::workspace::list(&conn) + .unwrap() + .into_iter() + .next() + .unwrap(); + let label = crate::label::create( + &conn, + crate::label::CreateLabel { + project_id: Some(pid), + workspace_id: ws.id, + name: "bug".into(), + color: None, + parent_id: None, + sort_order: None, + external_source: None, + external_id: None, + }, + ) + .unwrap(); + add_label(&conn, &item.id, &label.id).unwrap(); + let labels = list_labels(&conn, &item.id).unwrap(); + assert_eq!(labels.len(), 1); + assert_eq!(labels[0], label.id); + remove_label(&conn, &item.id, &label.id).unwrap(); + assert!(list_labels(&conn, &item.id).unwrap().is_empty()); +} + +fn workspace_by_slug(conn: &Connection, slug: &str) -> String { + workspace::list(conn) + .unwrap() + .into_iter() + .find(|w| w.slug == slug) + .unwrap() + .id +} + +#[test] +fn add_label_rejects_label_from_another_project() { + let conn = db::open_in_memory().unwrap(); + let (pid1, sid1) = seed_project(&conn, "1"); + let (pid2, _sid2) = seed_project(&conn, "2"); + let item = create( + &conn, + CreateItem { + project_id: pid1, + state_id: sid1, + name: "Test".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + let foreign = crate::label::create( + &conn, + crate::label::CreateLabel { + project_id: Some(pid2), + workspace_id: workspace_by_slug(&conn, "test2"), + name: "bug".into(), + color: None, + parent_id: None, + sort_order: None, + external_source: None, + external_id: None, + }, + ) + .unwrap(); + let err = add_label(&conn, &item.id, &foreign.id).unwrap_err(); + assert!(matches!(err, crate::error::Error::Validation(_))); + assert!(list_labels(&conn, &item.id).unwrap().is_empty()); +} + +#[test] +fn add_label_accepts_workspace_level_label_in_same_workspace() { + let conn = db::open_in_memory().unwrap(); + let (pid1, sid1) = seed_project(&conn, "1"); + let item = create( + &conn, + CreateItem { + project_id: pid1, + state_id: sid1, + name: "Test".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + // Workspace-level label (project_id = None) in the item's workspace. + let global = crate::label::create( + &conn, + crate::label::CreateLabel { + project_id: None, + workspace_id: workspace_by_slug(&conn, "test1"), + name: "global".into(), + color: None, + parent_id: None, + sort_order: None, + external_source: None, + external_id: None, + }, + ) + .unwrap(); + add_label(&conn, &item.id, &global.id).unwrap(); + assert_eq!(list_labels(&conn, &item.id).unwrap().len(), 1); +} + +#[test] +fn add_label_rejects_workspace_level_label_from_another_workspace() { + let conn = db::open_in_memory().unwrap(); + let (pid1, sid1) = seed_project(&conn, "1"); + let (_pid2, _sid2) = seed_project(&conn, "2"); + let item = create( + &conn, + CreateItem { + project_id: pid1, + state_id: sid1, + name: "Test".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + // Workspace-level label (project_id = None) but in a *different* workspace. + let foreign_global = crate::label::create( + &conn, + crate::label::CreateLabel { + project_id: None, + workspace_id: workspace_by_slug(&conn, "test2"), + name: "global".into(), + color: None, + parent_id: None, + sort_order: None, + external_source: None, + external_id: None, + }, + ) + .unwrap(); + let err = add_label(&conn, &item.id, &foreign_global.id).unwrap_err(); + assert!(matches!(err, crate::error::Error::Validation(_))); + assert!(list_labels(&conn, &item.id).unwrap().is_empty()); +} + +#[test] +fn add_and_remove_assignees() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = create( + &conn, + CreateItem { + project_id: pid, + state_id: sid, + name: "Test".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + add_assignee(&conn, &item.id, "agent:1").unwrap(); + add_assignee(&conn, &item.id, "agent:2").unwrap(); + let agents = list_assignees(&conn, &item.id).unwrap(); + assert_eq!(agents.len(), 2); + remove_assignee(&conn, &item.id, "agent:1").unwrap(); + assert_eq!(list_assignees(&conn, &item.id).unwrap().len(), 1); +} + +#[test] +fn add_and_remove_dependencies() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let i1 = create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid.clone(), + name: "A".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + let i2 = create( + &conn, + CreateItem { + project_id: pid, + state_id: sid, + name: "B".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + let i1_id = i1.id.clone(); + let i2_id = i2.id.clone(); + add_dependency(&conn, &i2_id, &i1_id).unwrap(); + let deps = list_dependencies(&conn, &i2_id).unwrap(); + assert_eq!(deps, vec![i1_id.clone()]); + remove_dependency(&conn, &i2_id, &i1_id).unwrap(); + assert!(list_dependencies(&conn, &i2.id).unwrap().is_empty()); +} + +#[test] +fn create_wires_up_label_assignee_and_dependency_ids() { + // Regression test: CreateItem.label_ids/assignee_ids/dependency_ids + // must actually be attached by create(), not silently dropped. + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let ws = crate::workspace::list(&conn) + .unwrap() + .into_iter() + .next() + .unwrap(); + let label = crate::label::create( + &conn, + crate::label::CreateLabel { + project_id: Some(pid.clone()), + workspace_id: ws.id, + name: "bug".into(), + color: None, + parent_id: None, + sort_order: None, + external_source: None, + external_id: None, + }, + ) + .unwrap(); + let blocker = create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid.clone(), + name: "Blocker".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + let item = create( + &conn, + CreateItem { + project_id: pid, + state_id: sid, + name: "Test".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![label.id.clone()], + assignee_ids: vec!["agent:1".into()], + dependency_ids: vec![blocker.id.clone()], + }, + ) + .unwrap(); + assert_eq!(list_labels(&conn, &item.id).unwrap(), vec![label.id]); + assert_eq!( + list_assignees(&conn, &item.id).unwrap(), + vec!["agent:1".to_string()] + ); + assert_eq!( + list_dependencies(&conn, &item.id).unwrap(), + vec![blocker.id] + ); +} + +fn state_in_group(conn: &Connection, project_id: &str, group: &str) -> String { + crate::state::list_by_project(conn, project_id) + .unwrap() + .into_iter() + .find(|s| s.group_name == group) + .unwrap() + .id +} + +#[test] +fn update_state_sets_started_at_when_moving_into_started_group() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid, + name: "Test".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + assert!(item.started_at.is_none()); + let started_state = state_in_group(&conn, &pid, "started"); + let updated = update_state(&conn, &item.id, &started_state).unwrap(); + assert!(updated.started_at.is_some()); + assert!(updated.completed_at.is_none()); +} + +#[test] +fn update_state_sets_completed_at_when_moving_into_completed_group() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid, + name: "Test".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + let completed_state = state_in_group(&conn, &pid, "completed"); + let updated = update_state(&conn, &item.id, &completed_state).unwrap(); + assert!(updated.completed_at.is_some()); +} + +#[test] +fn update_state_leaves_timestamps_none_when_moving_into_backlog() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid, + name: "Test".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + let backlog_state = state_in_group(&conn, &pid, "backlog"); + let updated = update_state(&conn, &item.id, &backlog_state).unwrap(); + assert!(updated.started_at.is_none()); + assert!(updated.completed_at.is_none()); +} + +#[test] +fn create_rejects_state_from_a_different_project() { + let conn = db::open_in_memory().unwrap(); + let (pid1, _sid1) = seed_project(&conn, "1"); + let (_pid2, sid2) = seed_project(&conn, "2"); + assert!(matches!( + create( + &conn, + CreateItem { + project_id: pid1, + state_id: sid2, + name: "Test".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ), + Err(crate::error::Error::InvalidTransition(_)) + )); +} + +#[test] +fn update_state_rejects_state_from_a_different_project() { + let conn = db::open_in_memory().unwrap(); + let (pid1, sid1) = seed_project(&conn, "1"); + let (pid2, _sid2) = seed_project(&conn, "2"); + let item = create( + &conn, + CreateItem { + project_id: pid1, + state_id: sid1, + name: "Test".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + let other_project_state = state_in_group(&conn, &pid2, "started"); + assert!(matches!( + update_state(&conn, &item.id, &other_project_state), + Err(crate::error::Error::InvalidTransition(_)) + )); +} + +const TTL: i64 = 14400; + +fn make_item(conn: &Connection, pid: &str, sid: &str) -> Item { + create( + conn, + CreateItem { + project_id: pid.to_string(), + state_id: sid.to_string(), + name: "Test".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap() +} + +#[test] +fn claim_acquires_sets_assignee_and_moves_to_started_state() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + let outcome = claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); + assert_eq!(outcome, ClaimOutcome::Acquired); + let updated = get(&conn, &item.id).unwrap(); + assert_eq!(updated.assignee_agent.as_deref(), Some("agent:1")); + assert_eq!(updated.state_id, state_in_group(&conn, &pid, "started")); + assert!(updated.started_at.is_some()); +} + +#[test] +fn claim_on_already_held_item_returns_held_and_leaves_item_unchanged() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); + let outcome = claim(&conn, &item.id, "agent:2", 1001, TTL).unwrap(); + assert!(matches!( + outcome, + ClaimOutcome::Held { ref owner, .. } if owner == "agent:1" + )); + let unchanged = get(&conn, &item.id).unwrap(); + assert_eq!(unchanged.assignee_agent.as_deref(), Some("agent:1")); +} + +#[test] +fn stale_claim_is_stealable_by_a_different_owner() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); + let outcome = claim(&conn, &item.id, "agent:2", 1000 + TTL + 1, TTL).unwrap(); + assert_eq!(outcome, ClaimOutcome::Acquired); + let updated = get(&conn, &item.id).unwrap(); + assert_eq!(updated.assignee_agent.as_deref(), Some("agent:2")); +} + +#[test] +fn claim_by_a_different_agent_than_the_handoff_assignee_is_blocked() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + // Simulate a handoff: assignee set, never claimed yet. + update( + &conn, + &item.id, + UpdateItem { + assignee_agent: Some("opencode".into()), + ..Default::default() + }, + ) + .unwrap(); + let outcome = claim(&conn, &item.id, "claude-code:1", 1000, TTL).unwrap(); + assert_eq!( + outcome, + ClaimOutcome::BlockedByAssignee { + assignee: "opencode".to_string() + } + ); + let unchanged = get(&conn, &item.id).unwrap(); + assert_eq!(unchanged.assignee_agent.as_deref(), Some("opencode")); + assert!(crate::claim::current_owner(&conn, &item.id).is_none()); +} + +#[test] +fn claim_by_the_handoff_assignee_itself_succeeds() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + update( + &conn, + &item.id, + UpdateItem { + assignee_agent: Some("opencode".into()), + ..Default::default() + }, + ) + .unwrap(); + let outcome = claim(&conn, &item.id, "opencode:1", 1000, TTL).unwrap(); + assert_eq!(outcome, ClaimOutcome::Acquired); +} + +#[test] +fn claim_by_the_handoff_assignee_via_an_alias_succeeds() { + // assignee_agent is canonicalized on write ("claude" -> "claude-code"), + // but `owner` is the raw caller-supplied id — an alias owner must + // still be recognized as the assignee, not blocked as an impostor. + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + update( + &conn, + &item.id, + UpdateItem { + assignee_agent: Some("claude".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + get(&conn, &item.id).unwrap().assignee_agent.as_deref(), + Some("claude-code") + ); + let outcome = claim(&conn, &item.id, "claude:1", 1000, TTL).unwrap(); + assert_eq!(outcome, ClaimOutcome::Acquired); +} + +#[test] +fn current_owner_returns_the_claim_owner() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + assert!(crate::claim::current_owner(&conn, &item.id).is_none()); + claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); + assert_eq!( + crate::claim::current_owner(&conn, &item.id).as_deref(), + Some("agent:1") + ); +} + +#[test] +fn current_owner_returns_none_after_done() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); + crate::claim::done(&conn, &item.id, "agent:1", 2000).unwrap(); + assert!(crate::claim::current_owner(&conn, &item.id).is_none()); +} + +#[test] +fn mark_completed_moves_to_completed_state_and_lease_stays_held() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); + assert!(mark_completed(&conn, &item.id, "agent:1").unwrap()); + let done_item = get(&conn, &item.id).unwrap(); + assert_eq!(done_item.state_id, state_in_group(&conn, &pid, "completed")); + assert!(done_item.completed_at.is_some()); + + // Lease is still held — concurrent claim must be rejected. + match claim(&conn, &item.id, "agent:2", 1200, TTL).unwrap() { + ClaimOutcome::Held { .. } => {} + other => panic!("expected Held after mark_completed, got {other:?}"), + } + + // Release the lease, now re-acquirable. + assert!(crate::claim::done(&conn, &item.id, "agent:1", 1300).unwrap()); + let outcome = claim(&conn, &item.id, "agent:2", 1400, TTL).unwrap(); + assert_eq!(outcome, ClaimOutcome::Acquired); +} + +#[test] +fn mark_completed_noop_for_non_owner() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); + assert!(!mark_completed(&conn, &item.id, "agent:2").unwrap()); +} + +#[test] +fn mark_in_review_moves_to_in_review_state_and_lease_stays_held() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); + assert!(mark_in_review(&conn, &item.id, "agent:1").unwrap()); + let reviewed = get(&conn, &item.id).unwrap(); + assert_eq!(reviewed.state_id, state_in_group(&conn, &pid, "in_review")); + // Not actually finished yet -- completed_at must stay unset. + assert!(reviewed.completed_at.is_none()); + + // Lease is still held, same contract as mark_completed. + match claim(&conn, &item.id, "agent:2", 1200, TTL).unwrap() { + ClaimOutcome::Held { .. } => {} + other => panic!("expected Held after mark_in_review, got {other:?}"), + } +} + +#[test] +fn mark_in_review_noop_for_non_owner() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); + assert!(!mark_in_review(&conn, &item.id, "agent:2").unwrap()); +} + +#[test] +fn mark_in_review_backfills_the_state_for_a_project_seeded_before_it_existed() { + // Simulates a project created before item #420: delete the + // "in_review" state seed_defaults would otherwise have created, and + // confirm mark_in_review heals it instead of erroring. + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let old_review_state_id = state_in_group(&conn, &pid, "in_review"); + conn.execute( + "UPDATE states SET deleted_at = 1 WHERE id = ?1", + rusqlite::params![old_review_state_id], + ) + .unwrap(); + assert!(crate::state::first_in_group(&conn, &pid, "in_review").is_err()); + + let item = make_item(&conn, &pid, &sid); + claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); + assert!(mark_in_review(&conn, &item.id, "agent:1").unwrap()); + + let reviewed = get(&conn, &item.id).unwrap(); + let healed = crate::state::first_in_group(&conn, &pid, "in_review").unwrap(); + assert_eq!(reviewed.state_id, healed.id); + assert_ne!(healed.id, old_review_state_id); +} + +#[test] +fn promote_in_review_to_completed_moves_state_and_releases_the_lease() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); + assert!(mark_in_review(&conn, &item.id, "agent:1").unwrap()); + + assert!(promote_in_review_to_completed(&conn, &item.id).unwrap()); + let done_item = get(&conn, &item.id).unwrap(); + assert_eq!(done_item.state_id, state_in_group(&conn, &pid, "completed")); + assert!(done_item.completed_at.is_some()); + + // Lease was released -- a different agent can claim it now. + let outcome = claim(&conn, &item.id, "agent:2", 1200, TTL).unwrap(); + assert_eq!(outcome, ClaimOutcome::Acquired); +} + +#[test] +fn promote_in_review_to_completed_is_a_noop_when_not_in_review() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); + // Still "started", never moved to in_review. + assert!(!promote_in_review_to_completed(&conn, &item.id).unwrap()); + let unchanged = get(&conn, &item.id).unwrap(); + assert_eq!(unchanged.state_id, state_in_group(&conn, &pid, "started")); +} + +#[test] +fn search_ranks_by_relevance() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid.clone(), + name: "Database schema migration".into(), + description: Some("Add users table".into()), + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid.clone(), + name: "Fix login button".into(), + description: Some("Update CSS for login page button".into()), + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid, + name: "Backup database".into(), + description: Some("PR-123 adds nightly DB backup".into()), + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + let results = search(&conn, &pid, "PR-123", None).unwrap(); + assert_eq!(results.len(), 1); + assert!(results[0].description.contains("PR-123")); + + let db_results = search(&conn, &pid, "database", None).unwrap(); + assert_eq!(db_results.len(), 2); + // Both matched — "Database" is in name of item 1, "database" + // is in name of item 3. BM25 ranking may tie; verify both match. + assert!( + db_results[0].name.to_lowercase().contains("database") + || db_results[0] + .description + .to_lowercase() + .contains("database") + ); +} + +#[test] +fn search_empty_query_returns_nothing() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid, + name: "Test".into(), + description: Some("something".into()), + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + let results = search(&conn, &pid, "", None).unwrap(); + assert!(results.is_empty()); +} + +#[test] +fn search_scoped_to_project() { + let conn = db::open_in_memory().unwrap(); + let (pid1, sid1) = seed_project(&conn, "1"); + let (pid2, sid2) = seed_project(&conn, "2"); + create( + &conn, + CreateItem { + project_id: pid1.clone(), + state_id: sid1, + name: "Database setup".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + create( + &conn, + CreateItem { + project_id: pid2.clone(), + state_id: sid2, + name: "Database setup".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + assert_eq!(search(&conn, &pid1, "database", None).unwrap().len(), 1); + assert_eq!(search(&conn, &pid2, "database", None).unwrap().len(), 1); +} + +#[test] +fn search_falls_back_to_like_for_suffix_of_compound_token() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid, + name: "Implement agentflare-store v1".into(), + description: Some("unified local storage layer".into()), + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + + // FTS5 tokenizes "agentflare-store" as ["agentflare", "store"], so a + // bare "flare" query (a suffix, not a prefix, of "agentflare") finds + // nothing via MATCH — only the LIKE fallback can find it. + let results = search(&conn, &pid, "flare-store", None).unwrap(); + assert_eq!(results.len(), 1); + assert!(results[0].name.contains("agentflare-store")); +} + +#[test] +fn search_like_fallback_matches_literal_backslash_in_query() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid, + name: r"agentflare\filter setup".into(), + description: Some("unrelated".into()), + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + + // FTS5 tokenizes on the backslash the same way it does on a hyphen + // (see the suffix-of-compound-token test above), so "flare\filter" + // has no whole-token FTS match and only the LIKE fallback can find + // it. Before escaping backslashes first, `format!` left the query's + // real `\` in the pattern un-doubled, so SQLite's `ESCAPE '\\'` + // silently swallowed it as an (undefined) escape prefix for the + // next character instead of matching it literally — the fallback + // then missed a hit it should have found. + let results = search(&conn, &pid, r"flare\filter", None).unwrap(); + assert_eq!(results.len(), 1); +} + +#[test] +fn heartbeat_release_done_are_owner_scoped() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + claim(&conn, &item.id, "agent:1", 1000, TTL).unwrap(); + + assert!(!crate::claim::heartbeat(&conn, &item.id, "agent:2", 1100).unwrap()); + assert!(!crate::claim::release(&conn, &item.id, "agent:2").unwrap()); + assert!(!crate::claim::done(&conn, &item.id, "agent:2", 1100).unwrap()); + + assert!(crate::claim::heartbeat(&conn, &item.id, "agent:1", 1100).unwrap()); + assert!(crate::claim::done(&conn, &item.id, "agent:1", 1200).unwrap()); +} + +#[test] +fn resolve_id_passes_through_uuid_unchanged() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + + let resolved = resolve_id(&conn, Some(&pid), &item.id).unwrap(); + assert_eq!(resolved, item.id); +} + +#[test] +fn resolve_id_resolves_bare_numeric_sequence_id() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + + let resolved = resolve_id(&conn, Some(&pid), &item.sequence_id.to_string()).unwrap(); + assert_eq!(resolved, item.id); +} + +#[test] +fn resolve_id_resolves_hash_prefixed_sequence_id() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + + let resolved = resolve_id(&conn, Some(&pid), &format!("#{}", item.sequence_id)).unwrap(); + assert_eq!(resolved, item.id); +} + +#[test] +fn resolve_id_numeric_not_found_returns_not_found_error() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let _item = make_item(&conn, &pid, &sid); + + let err = resolve_id(&conn, Some(&pid), "999999").unwrap_err(); + assert!(matches!(err, crate::error::Error::NotFound(_)), "{err:?}"); +} + +#[test] +fn resolve_id_scopes_numeric_lookup_to_project() { + let conn = db::open_in_memory().unwrap(); + let (pid_a, sid_a) = seed_project(&conn, "a"); + let (pid_b, _sid_b) = seed_project(&conn, "b"); + let item = make_item(&conn, &pid_a, &sid_a); + + // The item's sequence_id exists in project A but not project B. + let err = resolve_id(&conn, Some(&pid_b), &item.sequence_id.to_string()).unwrap_err(); + assert!(matches!(err, crate::error::Error::NotFound(_)), "{err:?}"); +} + +#[test] +fn create_and_update_canonicalize_known_assignee_aliases() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + + let item = create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid.clone(), + name: "Test".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: Some("claude".into()), + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + assert_eq!(item.assignee_agent.as_deref(), Some("claude-code")); + + let updated = update( + &conn, + &item.id, + UpdateItem { + assignee_agent: Some("Claude Code".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(updated.assignee_agent.as_deref(), Some("claude-code")); +} + +#[test] +fn list_by_label_returns_only_items_carrying_that_label() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, "label"); + let ws_id = crate::project::get(&conn, &pid).unwrap().workspace_id; + let label = crate::label::create( + &conn, + crate::label::CreateLabel { + project_id: Some(pid.clone()), + workspace_id: ws_id, + name: "ready-for-work".into(), + color: None, + parent_id: None, + sort_order: None, + external_source: None, + external_id: None, + }, + ) + .unwrap(); + + let labeled = create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid.clone(), + name: "Labeled".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + create( + &conn, + CreateItem { + project_id: pid.clone(), + state_id: sid, + name: "Unlabeled".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + add_label(&conn, &labeled.id, &label.id).unwrap(); + + let found = list_by_label(&conn, &pid, &label.id).unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].id, labeled.id); +} diff --git a/scripts/loc-gate.sh b/scripts/loc-gate.sh index 87d219d9..c07f19e7 100644 --- a/scripts/loc-gate.sh +++ b/scripts/loc-gate.sh @@ -7,7 +7,6 @@ FROZEN_LIMIT=2000 ALLOWLIST=( src/mcp_server.rs - crates/agentflare-backend/src/item.rs src/components.rs # Already 1604 lines on master before item #441's git-shim polish touched # it -- pre-existing debt, same situation as tick.rs/work.rs above. Frozen