Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
/install_onpush.ps1
/run_onpush.bat
/serve_docs.py
.worktrees/
16 changes: 16 additions & 0 deletions crates/agentflare-backend/src/claim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,19 @@ pub fn release(conn: &Connection, item_id: &str, owner: &str) -> rusqlite::Resul
pub fn done(conn: &Connection, item_id: &str, owner: &str, now: i64) -> rusqlite::Result<bool> {
LEDGER.done(conn, &[item_id], owner, now)
}

/// Returns true if there is an active (live, non-stale) claim on this item
/// whose owner differs from `owner`. Used by the comment edit/delete gates
/// to prevent modifying a comment when another agent has started work.
pub fn has_active_claim_by_other(
conn: &Connection,
item_id: &str,
owner: &str,
now: i64,
ttl_secs: i64,
) -> rusqlite::Result<bool> {
let claims = LEDGER.list(conn, false, now, ttl_secs)?;
Ok(claims
.iter()
.any(|c| c.key == [item_id] && c.owner != owner))
}
116 changes: 116 additions & 0 deletions crates/agentflare-backend/src/comment.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
use rusqlite::{Connection, OptionalExtension};
use serde::{Deserialize, Serialize};

use crate::error::Result;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ItemComment {
pub id: String,
pub item_id: String,
pub author_agent: String,
pub body: String,
pub created_at: i64,
pub updated_at: i64,
}

fn row_to_comment(row: &rusqlite::Row) -> rusqlite::Result<ItemComment> {
Ok(ItemComment {
id: row.get(0)?,
item_id: row.get(1)?,
author_agent: row.get(2)?,
body: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
})
}

fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}

/// Create a comment on an item. `author_agent` is the identity of the caller.
pub fn create(
conn: &Connection,
item_id: &str,
author_agent: &str,
body: &str,
) -> Result<ItemComment> {
let id = uuid::Uuid::now_v7().to_string();
let ts = now();
conn.execute(
"INSERT INTO item_comments (id, item_id, author_agent, body, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![id, item_id, author_agent, body, ts, ts],
)?;
get(conn, &id)
}

/// Get a single comment by id.
pub fn get(conn: &Connection, id: &str) -> Result<ItemComment> {
conn.query_row(
"SELECT id, item_id, author_agent, body, created_at, updated_at
FROM item_comments WHERE id = ?1",
rusqlite::params![id],
row_to_comment,
)
.map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => crate::error::Error::NotFound(id.to_string()),
other => other.into(),
})
}

/// Update the body of a comment. Returns the updated comment.
pub fn update(conn: &Connection, id: &str, body: &str) -> Result<ItemComment> {
let ts = now();
let changed = conn.execute(
"UPDATE item_comments SET body = ?2, updated_at = ?3 WHERE id = ?1",
rusqlite::params![id, body, ts],
)?;
if changed == 0 {
return Err(crate::error::Error::NotFound(id.to_string()));
}
get(conn, id)
}

/// Delete a comment by id.
pub fn delete(conn: &Connection, id: &str) -> Result<()> {
let changed = conn.execute(
"DELETE FROM item_comments WHERE id = ?1",
rusqlite::params![id],
)?;
if changed == 0 {
return Err(crate::error::Error::NotFound(id.to_string()));
}
Ok(())
}

/// List all comments for an item, oldest first.
pub fn list_by_item(conn: &Connection, item_id: &str) -> Result<Vec<ItemComment>> {
let mut stmt = conn.prepare(
"SELECT id, item_id, author_agent, body, created_at, updated_at
FROM item_comments WHERE item_id = ?1 ORDER BY created_at ASC, id ASC",
)?;
let rows = stmt.query_map(rusqlite::params![item_id], row_to_comment)?;
Ok(rows.collect::<std::result::Result<_, _>>()?)
}

/// Check if this comment is the latest (most recent) on its item.
pub fn is_latest(conn: &Connection, comment: &ItemComment) -> Result<bool> {
// `created_at` is second-resolution, so two comments posted in the same
// second tie on MAX(created_at) — comparing timestamps alone would treat
// both as "latest". Break ties with `id` (UUIDv7, time-ordered), which
// reflects true insertion order even within one second.
let latest_id: Option<String> = conn
.query_row(
"SELECT id FROM item_comments WHERE item_id = ?1
ORDER BY created_at DESC, id DESC LIMIT 1",
rusqlite::params![comment.item_id],
|row| row.get(0),
)
.optional()
.map_err(crate::error::Error::Database)?;
Ok(latest_id.is_none_or(|id| id == comment.id))
}
1 change: 1 addition & 0 deletions crates/agentflare-backend/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const MIGRATION_LIST: &[M<'static>] = &[
M::up(include_str!("migrations/0001_initial.sql")),
M::up(include_str!("migrations/0002_schema_constraints.sql")),
M::up(include_str!("migrations/0003_asset_versioning.sql")),
M::up(include_str!("migrations/0004_item_comments.sql")),
];
const MIGRATIONS: Migrations = Migrations::from_slice(MIGRATION_LIST);

Expand Down
1 change: 1 addition & 0 deletions crates/agentflare-backend/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod asset;
pub mod claim;
pub mod comment;
pub mod db;
pub mod error;
pub mod events;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS item_comments (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL REFERENCES items(id),
author_agent TEXT NOT NULL,
body TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_item_comments_item ON item_comments(item_id, created_at);
9 changes: 9 additions & 0 deletions src/claims.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,15 @@ pub fn owner_id() -> String {
format!("{agent}:{instance}")
}

/// Strips the `:<instance>` suffix off an owner id, leaving the stable agent
/// identity. Unlike claim ownership (deliberately instance-scoped, see
/// `owner_id` above), authorship of a comment should survive across
/// sessions — an agent restarting shouldn't lose the ability to edit its own
/// words just because its instance suffix changed.
pub fn agent_of(owner_id: &str) -> &str {
owner_id.split(':').next().unwrap_or(owner_id)
}

pub fn ttl_secs() -> i64 {
std::env::var("AGENTFLARE_CLAIM_TTL_SECS")
.ok()
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ mod state;
mod tool_install;
mod uninstall;
mod update;
mod worktree;

use clap::Parser;

Expand Down
Loading
Loading