diff --git a/.gitignore b/.gitignore index 4619ae3c..f0273a85 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,13 @@ /docs/superpowers/ /.worktrees/ +# opencode CLI's own local state (node_modules, package.json/lock, plans dir) +/.opencode/ + +# Per-repo agentflare-backend project link (Vercel-style .vercel/project.json +# equivalent) — machine-local, not something to share via git. +/.agentflare/ + # Local onpush doc-generation tooling (machine-specific, not part of the project) /.onpush/ /onpush_run.log diff --git a/Cargo.lock b/Cargo.lock index a94cfd83..35d6cd46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -69,7 +69,9 @@ dependencies = [ "agent-detector", "agentflare-agent-registry", "agentflare-artifacts", + "agentflare-backend", "agentflare-caveman", + "agentflare-db-kit", "agentflare-gateway-registry", "agentflare-skill-registry", "built", @@ -121,6 +123,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "agentflare-backend" +version = "0.1.0" +dependencies = [ + "hex", + "hmac", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror", + "ureq", + "url", + "uuid", +] + [[package]] name = "agentflare-caveman" version = "0.1.0" @@ -134,6 +153,18 @@ dependencies = [ "which", ] +[[package]] +name = "agentflare-db-kit" +version = "0.1.0" +dependencies = [ + "rusqlite", + "rusqlite_migration", + "serde", + "tempfile", + "thiserror", + "uuid", +] + [[package]] name = "agentflare-gateway-registry" version = "0.1.0" @@ -1946,6 +1977,16 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rusqlite_migration" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03451d4dc523c0a96d9e412c574670b3f5f184c3988b77a435876be25477bbd3" +dependencies = [ + "log", + "rusqlite", +] + [[package]] name = "rustc-demangle" version = "0.1.27" diff --git a/Cargo.toml b/Cargo.toml index 49b6c5b2..145af197 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/ponytail", "crates/agent-registry", "crates/skill-registry", "crates/gateway-registry", "crates/caveman", "crates/agentflare-artifacts"] +members = ["crates/ponytail", "crates/agent-registry", "crates/skill-registry", "crates/gateway-registry", "crates/caveman", "crates/agentflare-artifacts", "crates/agentflare-backend", "crates/agentflare-db-kit"] resolver = "2" [package] @@ -66,6 +66,8 @@ caveman = { package = "agentflare-caveman", path = "crates/caveman" } regex = "1" sysinfo = { version = "0.34", optional = true, default-features = false, features = ["system"] } agentflare-artifacts = { path = "crates/agentflare-artifacts" } +agentflare-backend = { package = "agentflare-backend", path = "crates/agentflare-backend" } +db_kit = { package = "agentflare-db-kit", path = "crates/agentflare-db-kit" } agent-detector = "0.2.1" [features] diff --git a/crates/agentflare-backend/CHANGELOG.md b/crates/agentflare-backend/CHANGELOG.md new file mode 100644 index 00000000..559a856d --- /dev/null +++ b/crates/agentflare-backend/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial crate scaffold with 8-table schema (workspaces, projects, states, items, labels, webhooks, assets, project_sequences) + 3 junction tables (item_labels, item_assignees, item_dependencies) +- CRUD for all entities with soft-delete, partial unique indexes, UUIDv7 primary keys +- State machine with 6 fixed groups + custom names, seeded on project creation +- Per-project auto-increment sequence IDs +- Error type with NotFound, Duplicate, InvalidTransition variants diff --git a/crates/agentflare-backend/Cargo.toml b/crates/agentflare-backend/Cargo.toml new file mode 100644 index 00000000..84b0a508 --- /dev/null +++ b/crates/agentflare-backend/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "agentflare-backend" +version = "0.1.0" +edition = "2024" +rust-version = "1.91" +license = "MIT" +description = "Universal agent backend: workspace/project/item schema + CRUD, ported from Plane's domain model." +publish = false + +[dependencies] +rusqlite = { version = "0.40", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +uuid = { version = "1", features = ["v4", "v7"] } +thiserror = "2" +ureq = { version = "2", features = ["json"] } +sha2 = "0.10" +hmac = "0.12" +hex = "0.4" +url = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/agentflare-backend/src/asset.rs b/crates/agentflare-backend/src/asset.rs new file mode 100644 index 00000000..f9a3777c --- /dev/null +++ b/crates/agentflare-backend/src/asset.rs @@ -0,0 +1,341 @@ +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use crate::error::Result; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Asset { + pub id: String, + pub workspace_id: Option, + pub entity_type: String, + pub entity_id: String, + pub filename: String, + pub size: i64, + pub storage_path: String, + pub mime_type: Option, + pub metadata: String, + pub created_at: i64, + pub updated_at: i64, + pub deleted_at: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateAsset { + pub workspace_id: Option, + pub entity_type: String, + pub entity_id: String, + pub filename: String, + pub size: i64, + pub mime_type: Option, + pub metadata: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub struct UpdateAsset { + pub filename: Option, + pub size: Option, + pub mime_type: Option, + pub metadata: Option, +} + +pub fn storage_path(workspace_id: &str, filename: &str) -> String { + let id = uuid::Uuid::now_v7().to_string(); + format!("{}/assets/{}-{}", workspace_id, id, filename) +} + +fn safe_asset_path( + base_path: &std::path::Path, + storage_path: &str, +) -> std::io::Result { + let rel = std::path::Path::new(storage_path); + let contained = !rel.is_absolute() + && rel + .components() + .all(|c| matches!(c, std::path::Component::Normal(_))); + if !contained { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "storage_path escapes asset root", + )); + } + Ok(base_path.join(rel)) +} + +pub fn write_file( + base_path: &std::path::Path, + storage_path: &str, + data: &[u8], +) -> std::io::Result<()> { + let full_path = safe_asset_path(base_path, storage_path)?; + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&full_path, data) +} + +pub fn read_file(base_path: &std::path::Path, storage_path: &str) -> std::io::Result> { + std::fs::read(safe_asset_path(base_path, storage_path)?) +} + +pub fn delete_file(base_path: &std::path::Path, storage_path: &str) -> std::io::Result<()> { + let path = safe_asset_path(base_path, storage_path)?; + if path.exists() { + std::fs::remove_file(path)?; + } + Ok(()) +} + +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_asset(row: &rusqlite::Row) -> rusqlite::Result { + Ok(Asset { + id: row.get(0)?, + workspace_id: row.get(1)?, + entity_type: row.get(2)?, + entity_id: row.get(3)?, + filename: row.get(4)?, + size: row.get(5)?, + storage_path: row.get(6)?, + mime_type: row.get(7)?, + metadata: row.get(8)?, + created_at: row.get(9)?, + updated_at: row.get(10)?, + deleted_at: row.get(11)?, + }) +} + +pub fn create(conn: &Connection, input: CreateAsset) -> Result { + let id = uuid::Uuid::now_v7().to_string(); + let ts = now(); + let sp = match &input.workspace_id { + Some(wid) => storage_path(wid, &input.filename), + None => { + let id = uuid::Uuid::now_v7().to_string(); + format!("assets/{}-{}", id, input.filename) + } + }; + let metadata = input.metadata.unwrap_or_else(|| "{}".to_string()); + conn.execute( + "INSERT INTO assets (id, workspace_id, entity_type, entity_id, filename, size, storage_path, mime_type, metadata, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + rusqlite::params![ + id, + input.workspace_id, + input.entity_type, + input.entity_id, + input.filename, + input.size, + sp, + input.mime_type, + metadata, + ts, + ts, + ], + )?; + get(conn, &id) +} + +pub fn get(conn: &Connection, id: &str) -> Result { + conn.query_row( + "SELECT id, workspace_id, entity_type, entity_id, filename, size, storage_path, mime_type, metadata, created_at, updated_at, deleted_at + FROM assets WHERE id = ?1 AND deleted_at IS NULL", + rusqlite::params![id], + row_to_asset, + ) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => crate::error::Error::NotFound(id.to_string()), + other => other.into(), + }) +} + +pub fn list_by_entity(conn: &Connection, entity_type: &str, entity_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, workspace_id, entity_type, entity_id, filename, size, storage_path, mime_type, metadata, created_at, updated_at, deleted_at + FROM assets WHERE entity_type = ?1 AND entity_id = ?2 AND deleted_at IS NULL ORDER BY created_at", + )?; + let rows = stmt.query_map(rusqlite::params![entity_type, entity_id], row_to_asset)?; + Ok(rows.collect::>()?) +} + +pub fn list_by_workspace(conn: &Connection, workspace_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, workspace_id, entity_type, entity_id, filename, size, storage_path, mime_type, metadata, created_at, updated_at, deleted_at + FROM assets WHERE workspace_id = ?1 AND deleted_at IS NULL ORDER BY created_at", + )?; + let rows = stmt.query_map(rusqlite::params![workspace_id], row_to_asset)?; + Ok(rows.collect::>()?) +} + +pub fn update(conn: &Connection, id: &str, input: UpdateAsset) -> Result { + let ts = now(); + let mut sets = vec!["updated_at = ?2".to_string()]; + let mut param_idx = 3; + if input.filename.is_some() { + sets.push(format!("filename = ?{param_idx}")); + param_idx += 1; + } + if input.size.is_some() { + sets.push(format!("size = ?{param_idx}")); + param_idx += 1; + } + if input.mime_type.is_some() { + sets.push(format!("mime_type = ?{param_idx}")); + param_idx += 1; + } + if input.metadata.is_some() { + sets.push(format!("metadata = ?{param_idx}")); + } + let sql = format!( + "UPDATE assets 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 filename) = input.filename { + param_values.push(Box::new(filename.clone())); + } + if let Some(size) = input.size { + param_values.push(Box::new(size)); + } + if let Some(ref mime) = input.mime_type { + param_values.push(Box::new(mime.clone())); + } + if let Some(ref meta) = input.metadata { + param_values.push(Box::new(meta.clone())); + } + let changed = stmt.execute(rusqlite::params_from_iter(param_values.iter()))?; + if changed == 0 { + return Err(crate::error::Error::NotFound(id.to_string())); + } + get(conn, id) +} + +pub fn delete(conn: &Connection, id: &str) -> Result<()> { + let ts = now(); + let changed = conn.execute( + "UPDATE assets 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())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + + #[test] + fn storage_path_format() { + let path = storage_path("ws-42", "photo.jpg"); + assert!(path.starts_with("ws-42/assets/")); + assert!(path.ends_with("-photo.jpg")); + } + + #[test] + fn create_and_get() { + let conn = db::open_in_memory().unwrap(); + let asset = create( + &conn, + CreateAsset { + workspace_id: Some("ws-1".into()), + entity_type: "item_attachment".into(), + entity_id: "item-1".into(), + filename: "report.pdf".into(), + size: 1024, + mime_type: Some("application/pdf".into()), + metadata: None, + }, + ) + .unwrap(); + assert_eq!(asset.filename, "report.pdf"); + assert_eq!(asset.size, 1024); + let got = get(&conn, &asset.id).unwrap(); + assert_eq!(got.id, asset.id); + } + + #[test] + fn write_and_read_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test_write"); + std::fs::create_dir_all(&path).unwrap(); + let sp = storage_path("ws-1", "test.txt"); + write_file(&path, &sp, b"hello world").unwrap(); + let data = read_file(&path, &sp).unwrap(); + assert_eq!(data, b"hello world"); + delete_file(&path, &sp).unwrap(); + assert!(!path.join(&sp).exists()); + } + + #[test] + fn write_read_delete_reject_path_traversal() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test_traversal"); + std::fs::create_dir_all(&path).unwrap(); + let evil = "../../../../etc/passwd"; + assert!(write_file(&path, evil, b"pwned").is_err()); + assert!(read_file(&path, evil).is_err()); + assert!(delete_file(&path, evil).is_err()); + } + + #[test] + fn list_by_entity_test() { + let conn = db::open_in_memory().unwrap(); + super::create( + &conn, + CreateAsset { + workspace_id: Some("ws-1".into()), + entity_type: "item_attachment".into(), + entity_id: "item-1".into(), + filename: "a.pdf".into(), + size: 100, + mime_type: None, + metadata: None, + }, + ) + .unwrap(); + super::create( + &conn, + CreateAsset { + workspace_id: Some("ws-1".into()), + entity_type: "item_attachment".into(), + entity_id: "item-1".into(), + filename: "b.pdf".into(), + size: 200, + mime_type: None, + metadata: None, + }, + ) + .unwrap(); + super::create( + &conn, + CreateAsset { + workspace_id: Some("ws-1".into()), + entity_type: "item_attachment".into(), + entity_id: "item-2".into(), + filename: "c.pdf".into(), + size: 300, + mime_type: None, + metadata: None, + }, + ) + .unwrap(); + let assets = super::list_by_entity(&conn, "item_attachment", "item-1").unwrap(); + assert_eq!(assets.len(), 2); + assert_eq!( + super::list_by_entity(&conn, "item_attachment", "item-2") + .unwrap() + .len(), + 1 + ); + } +} diff --git a/crates/agentflare-backend/src/db.rs b/crates/agentflare-backend/src/db.rs new file mode 100644 index 00000000..90debbbc --- /dev/null +++ b/crates/agentflare-backend/src/db.rs @@ -0,0 +1,27 @@ +use rusqlite::Connection; +use std::path::Path; + +pub fn open_db(path: &Path) -> rusqlite::Result { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let conn = Connection::open(path)?; + conn.busy_timeout(std::time::Duration::from_millis(5000))?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + migrate(&conn)?; + Ok(conn) +} + +pub fn open_in_memory() -> rusqlite::Result { + let conn = Connection::open_in_memory()?; + conn.pragma_update(None, "foreign_keys", "ON")?; + migrate(&conn)?; + Ok(conn) +} + +pub fn migrate(conn: &rusqlite::Connection) -> rusqlite::Result<()> { + conn.execute_batch(SCHEMA) +} + +const SCHEMA: &str = include_str!("schema.sql"); diff --git a/crates/agentflare-backend/src/error.rs b/crates/agentflare-backend/src/error.rs new file mode 100644 index 00000000..dd1ca32b --- /dev/null +++ b/crates/agentflare-backend/src/error.rs @@ -0,0 +1,32 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("not found: {0}")] + NotFound(String), + #[error("duplicate: {0}")] + Duplicate(String), + #[error("invalid state transition: {0}")] + InvalidTransition(String), + #[error(transparent)] + Database(rusqlite::Error), +} + +pub type Result = std::result::Result; + +impl From for Error { + fn from(e: rusqlite::Error) -> Self { + match &e { + rusqlite::Error::SqliteFailure(err, _) => { + if err.code == rusqlite::ErrorCode::ConstraintViolation { + let ext = err.extended_code; + if ext == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE + || ext == rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY + { + return Error::Duplicate(e.to_string()); + } + } + Error::Database(e) + } + _ => Error::Database(e), + } + } +} diff --git a/crates/agentflare-backend/src/events.rs b/crates/agentflare-backend/src/events.rs new file mode 100644 index 00000000..952a4d71 --- /dev/null +++ b/crates/agentflare-backend/src/events.rs @@ -0,0 +1,13 @@ +use crate::webhook; +use rusqlite::Connection; +use serde_json::Value; + +pub fn emit(conn: &Connection, workspace_id: &str, event_type: &str, action: &str, data: Value) { + let webhooks = match webhook::list_active_matching(conn, workspace_id, event_type) { + Ok(hooks) => hooks, + Err(_) => return, + }; + for wh in webhooks { + let _ = webhook::deliver(conn, &wh, event_type, action, data.clone()); + } +} diff --git a/crates/agentflare-backend/src/item.rs b/crates/agentflare-backend/src/item.rs new file mode 100644 index 00000000..00fddcb3 --- /dev/null +++ b/crates/agentflare-backend/src/item.rs @@ -0,0 +1,920 @@ +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, +} + +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 = uuid::Uuid::now_v7().to_string(); + 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 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, + input.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(), + }) +} + +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 update(conn: &Connection, id: &str, input: UpdateItem) -> Result { + let ts = now(); + 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 input.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}")); + } + 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) = input.assignee_agent { + param_values.push(Box::new(agent.clone())); + } + if let Some(so) = input.sort_order { + param_values.push(Box::new(so)); + } + 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<()> { + 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::>()?) +} + +#[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()); + } + + #[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(_)) + )); + } +} diff --git a/crates/agentflare-backend/src/label.rs b/crates/agentflare-backend/src/label.rs new file mode 100644 index 00000000..792ba4c6 --- /dev/null +++ b/crates/agentflare-backend/src/label.rs @@ -0,0 +1,284 @@ +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; + +use crate::error::Result; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Label { + pub id: String, + pub project_id: Option, + pub workspace_id: String, + pub name: String, + pub color: String, + pub parent_id: Option, + pub sort_order: f64, + pub external_source: Option, + pub external_id: Option, + pub created_at: i64, + pub updated_at: i64, + pub deleted_at: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateLabel { + pub project_id: Option, + pub workspace_id: String, + pub name: String, + pub color: Option, + pub parent_id: Option, + pub sort_order: Option, + pub external_source: Option, + pub external_id: Option, +} + +#[derive(Debug, Deserialize, Default)] +pub struct UpdateLabel { + pub name: Option, + pub color: Option, + pub sort_order: 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_label(row: &rusqlite::Row) -> rusqlite::Result