diff --git a/.gitignore b/.gitignore index f26e74136c0..69a484bacf6 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,6 @@ identity.key # Helm dependency tarballs — regenerable from Chart.lock via `helm dependency build` deploy/charts/*/charts/*.tgz + +# Claude Code context +.claude_context_tree diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 36dc772da3b..df1245b1b78 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -38,6 +38,8 @@ pub mod presence; pub mod private_managed_agent; /// Canonical relay runtime identities. pub mod relay; +/// Task lifecycle enums shared across crates. +pub mod task; /// Tenant identity — the server-resolved community key carried on scoped paths. pub mod tenant; /// Schnorr signature and event ID verification. diff --git a/crates/buzz-core/src/task.rs b/crates/buzz-core/src/task.rs new file mode 100644 index 00000000000..744bc0abbd8 --- /dev/null +++ b/crates/buzz-core/src/task.rs @@ -0,0 +1,270 @@ +//! Task lifecycle enums shared across crates. +//! +//! These live in `buzz-core` (zero I/O deps) so the DB layer, the relay HTTP +//! surface, and future clients agree on one spelling of a task's status and of +//! the lifecycle events that status changes record. +//! +//! Tasks are durable work items owned by a human or a harness agent. They are +//! deliberately unrelated to `buzz-workflow`, which models the scheduled +//! execution engine. + +use std::fmt; +use std::str::FromStr; + +/// Where a task sits in its lifecycle. +/// +/// The spelling of each variant is the value stored in `tasks.status` and +/// pinned by that column's `CHECK` constraint, so adding a variant here +/// requires a migration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskStatus { + /// Accepted but not started. + Todo, + /// Actively being worked. + InProgress, + /// Cannot proceed until something else resolves. + Blocked, + /// Finished successfully. + Done, + /// Abandoned without completion. + Cancelled, +} + +impl TaskStatus { + /// Canonical string representation (matches the `tasks.status` CHECK). + pub fn as_str(&self) -> &'static str { + match self { + Self::Todo => "todo", + Self::InProgress => "in_progress", + Self::Blocked => "blocked", + Self::Done => "done", + Self::Cancelled => "cancelled", + } + } + + /// Whether the task has left the working set (done or cancelled). + pub fn is_closed(&self) -> bool { + matches!(self, Self::Done | Self::Cancelled) + } + + /// Whether `tasks.done_at` must carry a timestamp in this status. + /// + /// `done_at` is the completion timestamp, so it is set for `Done` and only + /// for `Done` — cancelling a task closes it without completing it. The + /// database enforces the same equivalence via + /// `chk_tasks_done_at_matches_status`; this keeps the write path from + /// having to learn that constraint by failing it. + pub fn requires_done_at(&self) -> bool { + matches!(self, Self::Done) + } +} + +impl fmt::Display for TaskStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for TaskStatus { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "todo" => Ok(Self::Todo), + "in_progress" => Ok(Self::InProgress), + "blocked" => Ok(Self::Blocked), + "done" => Ok(Self::Done), + "cancelled" => Ok(Self::Cancelled), + other => Err(format!("unknown task status: {other:?}")), + } + } +} + +/// A row in the append-only `task_events` log. +/// +/// Stored as free `TEXT` rather than a database enum so a new action can ship +/// across a rolling upgrade without a migration; this enum is the set the +/// relay itself writes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TaskAction { + /// The task was created. + Created, + /// `status` moved from one value to another. + StatusChanged, + /// `assignee_pubkey` changed. + Assigned, + /// A human or agent left a comment. + Commented, + /// `title` changed. + TitleChanged, + /// An agent persisted its summary of the task. At most one per task. + SummaryPersisted, +} + +impl TaskAction { + /// Canonical string representation (matches `task_events.action`). + pub fn as_str(&self) -> &'static str { + match self { + Self::Created => "created", + Self::StatusChanged => "status_changed", + Self::Assigned => "assigned", + Self::Commented => "commented", + Self::TitleChanged => "title_changed", + Self::SummaryPersisted => "summary_persisted", + } + } + + /// Whether at most one event with this action may exist per task. + /// + /// Mirrors the partial unique index `idx_task_events_one_summary_per_task`. + pub fn is_singleton_per_task(&self) -> bool { + matches!(self, Self::SummaryPersisted) + } +} + +impl fmt::Display for TaskAction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for TaskAction { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "created" => Ok(Self::Created), + "status_changed" => Ok(Self::StatusChanged), + "assigned" => Ok(Self::Assigned), + "commented" => Ok(Self::Commented), + "title_changed" => Ok(Self::TitleChanged), + "summary_persisted" => Ok(Self::SummaryPersisted), + other => Err(format!("unknown task action: {other:?}")), + } + } +} + +/// The lifecycle event a status change records, or `None` when the requested +/// status is the one the task already has. +/// +/// A `PATCH` that restates the current status is idempotent: it must not append +/// a `status_changed` row claiming a transition that did not happen, otherwise +/// a client retry inflates the task's history. +pub fn status_change_action(from: TaskStatus, to: TaskStatus) -> Option { + (from != to).then_some(TaskAction::StatusChanged) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_round_trips_through_its_canonical_spelling() { + for status in [ + TaskStatus::Todo, + TaskStatus::InProgress, + TaskStatus::Blocked, + TaskStatus::Done, + TaskStatus::Cancelled, + ] { + assert_eq!( + status.as_str().parse::(), + Ok(status), + "{status} must survive a string round trip" + ); + } + assert!("in-progress".parse::().is_err()); + assert!("DONE".parse::().is_err()); + } + + #[test] + fn action_round_trips_through_its_canonical_spelling() { + for action in [ + TaskAction::Created, + TaskAction::StatusChanged, + TaskAction::Assigned, + TaskAction::Commented, + TaskAction::TitleChanged, + TaskAction::SummaryPersisted, + ] { + assert_eq!(action.as_str().parse::(), Ok(action)); + } + assert!("summary".parse::().is_err()); + } + + #[test] + fn a_real_status_change_records_status_changed() { + assert_eq!( + status_change_action(TaskStatus::Todo, TaskStatus::InProgress), + Some(TaskAction::StatusChanged) + ); + assert_eq!( + status_change_action(TaskStatus::Blocked, TaskStatus::Done), + Some(TaskAction::StatusChanged) + ); + // Reopening is a transition like any other — the log is append-only, + // so it records the move rather than rewriting the earlier one. + assert_eq!( + status_change_action(TaskStatus::Done, TaskStatus::Todo), + Some(TaskAction::StatusChanged) + ); + } + + #[test] + fn restating_the_current_status_records_nothing() { + for status in [ + TaskStatus::Todo, + TaskStatus::InProgress, + TaskStatus::Blocked, + TaskStatus::Done, + TaskStatus::Cancelled, + ] { + assert_eq!( + status_change_action(status, status), + None, + "restating {status} must not append a status_changed row" + ); + } + } + + #[test] + fn done_at_is_required_exactly_for_done() { + // Pins the Rust side of `chk_tasks_done_at_matches_status`: cancelled + // closes a task without completing it, so it carries no done_at. + assert!(TaskStatus::Done.requires_done_at()); + for status in [ + TaskStatus::Todo, + TaskStatus::InProgress, + TaskStatus::Blocked, + TaskStatus::Cancelled, + ] { + assert!( + !status.requires_done_at(), + "{status} must not carry a completion timestamp" + ); + } + } + + #[test] + fn closed_covers_both_terminal_statuses() { + assert!(TaskStatus::Done.is_closed()); + assert!(TaskStatus::Cancelled.is_closed()); + assert!(!TaskStatus::Todo.is_closed()); + assert!(!TaskStatus::InProgress.is_closed()); + assert!(!TaskStatus::Blocked.is_closed()); + } + + #[test] + fn only_the_summary_action_is_capped_at_one_per_task() { + assert!(TaskAction::SummaryPersisted.is_singleton_per_task()); + for action in [ + TaskAction::Created, + TaskAction::StatusChanged, + TaskAction::Assigned, + TaskAction::Commented, + TaskAction::TitleChanged, + ] { + assert!(!action.is_singleton_per_task()); + } + } +} diff --git a/crates/buzz-db/src/deletion.rs b/crates/buzz-db/src/deletion.rs index fbe69f22a68..0d216f26478 100644 --- a/crates/buzz-db/src/deletion.rs +++ b/crates/buzz-db/src/deletion.rs @@ -77,6 +77,8 @@ pub const EXPECTED_SCOPED_TABLES: &[&str] = &[ "relay_members", "scheduled_workflow_fires", "subscriptions", + "task_events", + "tasks", "thread_metadata", "users", "workflow_approvals", @@ -93,6 +95,10 @@ pub const PURGE_SCOPED_TABLES: &[&str] = &[ "join_policy_acceptances", "moderation_reports", "subscriptions", + // task_events → tasks (FK, cascading) and tasks → channels/users, so both + // must precede `channels` and `users` below. + "task_events", + "tasks", "api_tokens", "channel_members", "thread_metadata", diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 3ff230f9503..93fe55e939b 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -47,6 +47,8 @@ pub mod relay_invite; pub mod relay_members; /// Replica freshness fence for keyset-cursor read routing. pub mod replica_fence; +/// Task and task-event persistence. +pub mod task; /// Thread metadata persistence. pub mod thread; /// Per-community usage rollup queries for Prometheus gauges. @@ -4529,6 +4531,67 @@ impl Db { relay_invite::mint_relay_invite(&self.pool, community, created_by, ttl_secs, max_uses).await } + /// Create a task and its opening `created` history entry atomically. + #[datastore_span(name = "create_task", system = "postgresql")] + pub async fn create_task( + &self, + community: CommunityId, + new_task: task::NewTask, + ) -> Result { + task::create_task(&self.pool, community, new_task).await + } + + /// Read one task scoped to `community`. + #[datastore_span(name = "get_task", system = "postgresql")] + pub async fn get_task(&self, community: CommunityId, id: Uuid) -> Result { + task::get_task(&self.pool, community, id).await + } + + /// List a community's tasks, newest-modified first. + #[datastore_span(name = "list_tasks", system = "postgresql")] + pub async fn list_tasks( + &self, + community: CommunityId, + filter: &task::TaskFilter, + ) -> Result> { + task::list_tasks(&self.pool, community, filter).await + } + + /// Read one task's append-only history, oldest first. + #[datastore_span(name = "list_task_events", system = "postgresql")] + pub async fn list_task_events( + &self, + community: CommunityId, + task_id: Uuid, + ) -> Result> { + task::list_task_events(&self.pool, community, task_id).await + } + + /// Apply a task patch, appending one history row per field that changed. + #[datastore_span(name = "update_task", system = "postgresql")] + pub async fn update_task( + &self, + community: CommunityId, + id: Uuid, + patch: &task::TaskPatch, + actor_pubkey: Option<&[u8]>, + ) -> Result { + task::update_task(&self.pool, community, id, patch, actor_pubkey).await + } + + /// Append a comment or summary to a task's history. + #[datastore_span(name = "append_task_event", system = "postgresql")] + pub async fn append_task_event( + &self, + community: CommunityId, + task_id: Uuid, + actor_pubkey: Option<&[u8]>, + action: buzz_core::task::TaskAction, + body: Option<&str>, + ) -> Result { + task::append_task_event(&self.pool, community, task_id, actor_pubkey, action, body).await + } + /// Delete one bounded batch of invites expired before `cutoff`. #[datastore_span(name = "reap_expired_relay_invites", system = "postgresql")] pub async fn reap_expired_relay_invites( diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 94c7aea2faf..c3af9bbc683 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -640,7 +640,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 32); + assert_eq!(migrations.len(), 33); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1104,6 +1104,62 @@ mod tests { assert!(include_str!("../../../schema/schema.sql").contains("error_code TEXT")); } + /// 0033 introduces the task system. The load-bearing properties are that it + /// is purely additive (no existing table is altered, so brownfield + /// checksums are untouched), that both tables are tenant-scoped with + /// `community_id`-leading keys, that both are explicitly attached to the + /// universal community write fence, and that `schema/schema.sql` mirrors + /// them so the desired-state schema stays authoritative. + #[test] + fn task_system_tables_are_additive_tenant_scoped_and_fenced() { + let mut migrations: Vec<_> = MIGRATOR.iter().collect(); + migrations.sort_by_key(|migration| migration.version); + + assert_eq!(migrations[32].version, 33); + let sql = migrations[32].sql.as_str(); + assert!(sql.contains("CREATE TABLE tasks")); + assert!(sql.contains("CREATE TABLE task_events")); + assert!(sql.contains("SET LOCAL lock_timeout = '5s'")); + + // Additive only: touching a populated table would rewrite history that + // brownfield relays have already applied. + assert!(!normalize_sql(sql).contains("alter table")); + assert!(!normalize_sql(sql).contains("drop table")); + + // Tenant scoping: composite keys led by community_id, never a bare id. + assert!(sql.contains("PRIMARY KEY (community_id, id)")); + assert!(sql.contains("REFERENCES channels (community_id, id)")); + assert!(sql.contains("REFERENCES users (community_id, pubkey)")); + assert!(sql.contains("REFERENCES tasks (community_id, id)")); + assert!(scoped_constraint_violations(sql).is_empty()); + + // Closed status lifecycle; `source`/`action` stay additive TEXT. + assert!(sql.contains("'todo', 'in_progress', 'blocked', 'done', 'cancelled'")); + assert!(sql.contains("CHECK ((status = 'done') = (done_at IS NOT NULL))")); + + // At most one persisted summary per task, enforced by the database. + assert!(sql.contains("idx_task_events_one_summary_per_task")); + assert!(sql.contains("WHERE action = 'summary_persisted'")); + + // Universal write fence: a fenced or mid-deletion tenant must not be + // able to accept task writes. + assert!(sql.contains("SELECT attach_community_write_fence('tasks')")); + assert!(sql.contains("SELECT attach_community_write_fence('task_events')")); + + // 0001 must never carry the task system — folding it in would change + // 0001's checksum and break brownfield startup (sqlx VersionMismatch). + assert!(!migrations[0].sql.as_str().contains("CREATE TABLE tasks")); + + // The desired-state schema mirrors both tables. + let desired_schema = include_str!("../../../schema/schema.sql"); + assert!(desired_schema.contains("CREATE TABLE tasks")); + assert!(desired_schema.contains("CREATE TABLE task_events")); + + // Deletion must not silently skip the new tenant tables. + assert!(crate::deletion::EXPECTED_SCOPED_TABLES.contains(&"tasks")); + assert!(crate::deletion::EXPECTED_SCOPED_TABLES.contains(&"task_events")); + } + #[test] fn migration_lint_detects_tables_missing_community_id_by_default() { let sql = r#" @@ -1542,6 +1598,14 @@ mod tests { let mut expected_fences = migration.fence_attachments.clone(); expected_fences.remove("product_feedback"); expected_fences.remove("rate_limit_violations"); + // Tenant tables introduced after 0029 declare their own fence + // attachment in their own migration and in schema.sql. Enumerate them + // here so the comparison below stays an exact equality: a new scoped + // table that forgets its fence line still fails this test, and a fence + // line for a table nobody registered here fails it too. + for post_0029_scoped_table in ["tasks", "task_events"] { + expected_fences.insert(post_0029_scoped_table.to_owned()); + } assert_eq!( expected_fences, schema.fence_attachments, "write-fence attachment targets differ after recovery policy" diff --git a/crates/buzz-db/src/task.rs b/crates/buzz-db/src/task.rs new file mode 100644 index 00000000000..dfdbc1d2ad9 --- /dev/null +++ b/crates/buzz-db/src/task.rs @@ -0,0 +1,888 @@ +//! Task and task-event persistence. +//! +//! Tasks are durable work items owned by a human or a harness agent (Claude +//! Code, Codex, the ACP mesh). They are relay-owned rows rather than Nostr +//! events, the same modeling choice already made for `workflow_runs` and +//! `workflow_approvals`, and they are unrelated to `buzz-workflow`'s scheduled +//! execution engine. +//! +//! Every statement here binds `community_id` first, matching the tenant +//! invariant that `(community_id, id)` — never a bare `id` — names a task. A +//! task id presented against the wrong tenant reads as absent, not as another +//! community's row. +//! +//! `task_events` is append-only: mutations record what changed instead of +//! overwriting history. `update_task` therefore runs the read, the write, and +//! the event append in one transaction with `SELECT … FOR UPDATE` on the task +//! row, so two concurrent PATCHes cannot interleave into a log that claims a +//! transition neither of them made. + +use buzz_core::task::{status_change_action, TaskAction, TaskStatus}; +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Postgres, QueryBuilder, Row as _, Transaction}; +use uuid::Uuid; + +use crate::error::{DbError, Result}; +use crate::CommunityId; + +/// Columns selected for every [`TaskRecord`]. Kept in one place so the row +/// parser and every query cannot drift apart. +/// +/// A macro rather than a `const` so callers can splice it with `concat!` and +/// keep every statement a true string literal — sqlx only accepts `&'static +/// str` without an `AssertSqlSafe` escape hatch, and there is nothing dynamic +/// here worth asserting past. +macro_rules! task_columns { + () => { + "community_id, id, channel_id, created_by_pubkey, assignee_pubkey, \ + parent_task_id, title, body, status, priority, source, source_ref, \ + due_at, done_at, archived_at, created_at, updated_at" + }; +} + +/// Columns selected for every [`TaskEventRecord`]. +macro_rules! task_event_columns { + () => { + "id, task_id, actor_pubkey, action, from_status, to_status, body, created_at" + }; +} + +/// A durable work item. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskRecord { + /// Task id, unique within its community. + pub id: Uuid, + /// Channel this task is bound to, if any. + pub channel_id: Option, + /// Creator's pubkey. Agents are users, so this covers both. + pub created_by_pubkey: Option>, + /// Current assignee's pubkey. + pub assignee_pubkey: Option>, + /// Parent task, for subtasks. + pub parent_task_id: Option, + /// Short title (1–200 characters). + pub title: String, + /// Long-form description. + pub body: Option, + /// Lifecycle status. + pub status: TaskStatus, + /// Sort priority; higher sorts first. + pub priority: i32, + /// Harness origin (`manual`, `claude`, `codex`, `acp`, `mesh`, …). + pub source: Option, + /// External reference owned by that harness. + pub source_ref: Option, + /// Due date. + pub due_at: Option>, + /// Completion timestamp. Set exactly when `status` is `done`. + pub done_at: Option>, + /// Archive timestamp. + pub archived_at: Option>, + /// Creation timestamp. + pub created_at: DateTime, + /// Last-modification timestamp. + pub updated_at: DateTime, +} + +/// One entry in a task's append-only history. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TaskEventRecord { + /// Monotonic event id within the community. + pub id: i64, + /// The task this event belongs to. + pub task_id: Uuid, + /// Who performed the action. + pub actor_pubkey: Option>, + /// What happened. + pub action: TaskAction, + /// Status before a `status_changed` event. + pub from_status: Option, + /// Status after a `status_changed` event. + pub to_status: Option, + /// Comment or summary text. + pub body: Option, + /// When it happened. + pub created_at: DateTime, +} + +/// Fields accepted when creating a task. +#[derive(Debug, Clone, Default)] +pub struct NewTask { + /// Channel to bind the task to. + pub channel_id: Option, + /// Creator's pubkey (the authenticated caller). + pub created_by_pubkey: Option>, + /// Initial assignee. + pub assignee_pubkey: Option>, + /// Parent task, for subtasks. + pub parent_task_id: Option, + /// Short title (1–200 characters). + pub title: String, + /// Long-form description. + pub body: Option, + /// Sort priority. + pub priority: i32, + /// Harness origin. + pub source: Option, + /// External reference owned by that harness. + pub source_ref: Option, + /// Due date. + pub due_at: Option>, +} + +/// Filters for [`list_tasks`]. `None` means "do not filter on this field". +#[derive(Debug, Clone, Default)] +pub struct TaskFilter { + /// Restrict to one status. + pub status: Option, + /// Restrict to one assignee. + pub assignee_pubkey: Option>, + /// Restrict to one channel. + pub channel_id: Option, + /// Include archived tasks. Archived tasks are hidden by default. + pub include_archived: bool, + /// Maximum rows to return. + pub limit: i64, +} + +/// Fields a PATCH may change. `None` means "leave unchanged". +/// +/// `assignee_pubkey` is a nested `Option` because unassigning is a real +/// operation: `Some(None)` clears the assignee, while `None` leaves it alone. +/// The same distinction applies to `due_at`. +#[derive(Debug, Clone, Default)] +pub struct TaskPatch { + /// New status. + pub status: Option, + /// New title. + pub title: Option, + /// New priority. + pub priority: Option, + /// New due date, or `Some(None)` to clear it. + pub due_at: Option>>, + /// New assignee, or `Some(None)` to unassign. + pub assignee_pubkey: Option>>, +} + +impl TaskPatch { + /// Whether the patch asks for any change at all. + pub fn is_empty(&self) -> bool { + self.status.is_none() + && self.title.is_none() + && self.priority.is_none() + && self.due_at.is_none() + && self.assignee_pubkey.is_none() + } +} + +fn parse_status(raw: &str) -> Result { + raw.parse::().map_err(DbError::InvalidData) +} + +fn parse_task_row(row: &sqlx::postgres::PgRow) -> Result { + let status: String = row.try_get("status")?; + Ok(TaskRecord { + id: row.try_get("id")?, + channel_id: row.try_get("channel_id")?, + created_by_pubkey: row.try_get("created_by_pubkey")?, + assignee_pubkey: row.try_get("assignee_pubkey")?, + parent_task_id: row.try_get("parent_task_id")?, + title: row.try_get("title")?, + body: row.try_get("body")?, + status: parse_status(&status)?, + priority: row.try_get("priority")?, + source: row.try_get("source")?, + source_ref: row.try_get("source_ref")?, + due_at: row.try_get("due_at")?, + done_at: row.try_get("done_at")?, + archived_at: row.try_get("archived_at")?, + created_at: row.try_get("created_at")?, + updated_at: row.try_get("updated_at")?, + }) +} + +fn parse_task_event_row(row: &sqlx::postgres::PgRow) -> Result { + let action: String = row.try_get("action")?; + let from_status: Option = row.try_get("from_status")?; + let to_status: Option = row.try_get("to_status")?; + Ok(TaskEventRecord { + id: row.try_get("id")?, + task_id: row.try_get("task_id")?, + actor_pubkey: row.try_get("actor_pubkey")?, + action: action.parse::().map_err(DbError::InvalidData)?, + from_status: from_status.as_deref().map(parse_status).transpose()?, + to_status: to_status.as_deref().map(parse_status).transpose()?, + body: row.try_get("body")?, + created_at: row.try_get("created_at")?, + }) +} + +/// Append one row to a task's history inside an open transaction. +/// +/// `transition` carries the `(from, to)` pair for +/// [`TaskAction::StatusChanged`] and is `None` for every other action — the +/// two ends are only ever meaningful together, so they travel together. +async fn insert_task_event( + tx: &mut Transaction<'_, Postgres>, + community: CommunityId, + task_id: Uuid, + actor_pubkey: Option<&[u8]>, + action: TaskAction, + transition: Option<(TaskStatus, TaskStatus)>, + body: Option<&str>, +) -> Result { + let (from_status, to_status) = match transition { + Some((from, to)) => (Some(from), Some(to)), + None => (None, None), + }; + let row = sqlx::query(concat!( + "INSERT INTO task_events \ + (community_id, task_id, actor_pubkey, action, from_status, to_status, body) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) \ + RETURNING ", + task_event_columns!() + )) + .bind(community.as_uuid()) + .bind(task_id) + .bind(actor_pubkey) + .bind(action.as_str()) + .bind(from_status.map(|status| status.as_str())) + .bind(to_status.map(|status| status.as_str())) + .bind(body) + .fetch_one(&mut **tx) + .await?; + parse_task_event_row(&row) +} + +/// Create a task and its opening `created` history entry in one transaction. +/// +/// The two must commit together: a task with no history would be invisible to +/// the task feed, which reads `task_events`. +pub async fn create_task( + pool: &PgPool, + community: CommunityId, + new_task: NewTask, +) -> Result { + let mut tx = pool.begin().await?; + + let row = sqlx::query(concat!( + "INSERT INTO tasks \ + (community_id, channel_id, created_by_pubkey, assignee_pubkey, parent_task_id, \ + title, body, priority, source, source_ref, due_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ + RETURNING ", + task_columns!() + )) + .bind(community.as_uuid()) + .bind(new_task.channel_id) + .bind(new_task.created_by_pubkey.as_deref()) + .bind(new_task.assignee_pubkey.as_deref()) + .bind(new_task.parent_task_id) + .bind(&new_task.title) + .bind(new_task.body.as_deref()) + .bind(new_task.priority) + .bind(new_task.source.as_deref()) + .bind(new_task.source_ref.as_deref()) + .bind(new_task.due_at) + .fetch_one(&mut *tx) + .await?; + let task = parse_task_row(&row)?; + + insert_task_event( + &mut tx, + community, + task.id, + new_task.created_by_pubkey.as_deref(), + TaskAction::Created, + None, + None, + ) + .await?; + + tx.commit().await?; + Ok(task) +} + +/// Read one task, or [`DbError::NotFound`] when no such task exists *in this +/// community*. +pub async fn get_task(pool: &PgPool, community: CommunityId, id: Uuid) -> Result { + let row = sqlx::query(concat!( + "SELECT ", + task_columns!(), + " FROM tasks WHERE community_id = $1 AND id = $2" + )) + .bind(community.as_uuid()) + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| DbError::NotFound(format!("task {id}")))?; + parse_task_row(&row) +} + +/// List tasks newest-modified first, filtered by `filter`. +pub async fn list_tasks( + pool: &PgPool, + community: CommunityId, + filter: &TaskFilter, +) -> Result> { + let mut builder = QueryBuilder::::new("SELECT "); + builder.push(task_columns!()); + builder.push(" FROM tasks WHERE community_id = "); + builder.push_bind(community.as_uuid()); + + if let Some(status) = filter.status { + builder.push(" AND status = "); + builder.push_bind(status.as_str()); + } + if let Some(assignee) = filter.assignee_pubkey.as_deref() { + builder.push(" AND assignee_pubkey = "); + builder.push_bind(assignee); + } + if let Some(channel_id) = filter.channel_id { + builder.push(" AND channel_id = "); + builder.push_bind(channel_id); + } + if !filter.include_archived { + builder.push(" AND archived_at IS NULL"); + } + builder.push(" ORDER BY updated_at DESC, id DESC LIMIT "); + builder.push_bind(filter.limit); + + builder + .build() + .fetch_all(pool) + .await? + .iter() + .map(parse_task_row) + .collect() +} + +/// Read one task's history oldest-first. +pub async fn list_task_events( + pool: &PgPool, + community: CommunityId, + task_id: Uuid, +) -> Result> { + sqlx::query(concat!( + "SELECT ", + task_event_columns!(), + " FROM task_events \ + WHERE community_id = $1 AND task_id = $2 \ + ORDER BY created_at ASC, id ASC" + )) + .bind(community.as_uuid()) + .bind(task_id) + .fetch_all(pool) + .await? + .iter() + .map(parse_task_event_row) + .collect() +} + +/// Apply a patch, appending one history row per field that actually changed. +/// +/// Runs under `SELECT … FOR UPDATE` so the before-image the history records is +/// the one this transaction actually replaced. A patch whose every field +/// already holds the requested value commits no history at all, which keeps a +/// client retry from inflating the log. +/// +/// `done_at` is derived from the new status rather than accepted from the +/// caller — the database's `chk_tasks_done_at_matches_status` requires the two +/// to agree, and deriving it is the only way a caller cannot violate that. +pub async fn update_task( + pool: &PgPool, + community: CommunityId, + id: Uuid, + patch: &TaskPatch, + actor_pubkey: Option<&[u8]>, +) -> Result { + let mut tx = pool.begin().await?; + + let current = sqlx::query(concat!( + "SELECT ", + task_columns!(), + " FROM tasks WHERE community_id = $1 AND id = $2 FOR UPDATE" + )) + .bind(community.as_uuid()) + .bind(id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| DbError::NotFound(format!("task {id}")))?; + let current = parse_task_row(¤t)?; + + let new_status = patch.status.unwrap_or(current.status); + let new_title = patch.title.clone().unwrap_or_else(|| current.title.clone()); + let new_priority = patch.priority.unwrap_or(current.priority); + let new_due_at = patch.due_at.unwrap_or(current.due_at); + let new_assignee = patch + .assignee_pubkey + .clone() + .unwrap_or_else(|| current.assignee_pubkey.clone()); + // Derived, never caller-supplied: keeps `chk_tasks_done_at_matches_status` + // satisfiable. Re-entering `done` preserves the original completion time. + let new_done_at = if new_status.requires_done_at() { + current.done_at.or_else(|| Some(Utc::now())) + } else { + None + }; + + let row = sqlx::query(concat!( + "UPDATE tasks SET status = $3, title = $4, priority = $5, due_at = $6, \ + assignee_pubkey = $7, done_at = $8, updated_at = NOW() \ + WHERE community_id = $1 AND id = $2 \ + RETURNING ", + task_columns!() + )) + .bind(community.as_uuid()) + .bind(id) + .bind(new_status.as_str()) + .bind(&new_title) + .bind(new_priority) + .bind(new_due_at) + .bind(new_assignee.as_deref()) + .bind(new_done_at) + .fetch_one(&mut *tx) + .await?; + let updated = parse_task_row(&row)?; + + if let Some(action) = status_change_action(current.status, new_status) { + insert_task_event( + &mut tx, + community, + id, + actor_pubkey, + action, + Some((current.status, new_status)), + None, + ) + .await?; + } + if new_title != current.title { + insert_task_event( + &mut tx, + community, + id, + actor_pubkey, + TaskAction::TitleChanged, + None, + Some(&new_title), + ) + .await?; + } + if new_assignee != current.assignee_pubkey { + insert_task_event( + &mut tx, + community, + id, + actor_pubkey, + TaskAction::Assigned, + None, + None, + ) + .await?; + } + + tx.commit().await?; + Ok(updated) +} + +/// Append a caller-supplied history entry (a comment, or an agent summary). +/// +/// Returns [`DbError::NotFound`] when the task does not exist in this +/// community, so a comment can never create history for another tenant's task. +/// A second [`TaskAction::SummaryPersisted`] for the same task is rejected by +/// `idx_task_events_one_summary_per_task` and surfaces as +/// [`DbError::InvalidData`] rather than an opaque driver error. +pub async fn append_task_event( + pool: &PgPool, + community: CommunityId, + task_id: Uuid, + actor_pubkey: Option<&[u8]>, + action: TaskAction, + body: Option<&str>, +) -> Result { + let mut tx = pool.begin().await?; + + let exists: Option = + sqlx::query_scalar("SELECT id FROM tasks WHERE community_id = $1 AND id = $2 FOR UPDATE") + .bind(community.as_uuid()) + .bind(task_id) + .fetch_optional(&mut *tx) + .await?; + if exists.is_none() { + return Err(DbError::NotFound(format!("task {task_id}"))); + } + + let event = insert_task_event( + &mut tx, + community, + task_id, + actor_pubkey, + action, + None, + body, + ) + .await + .map_err(|error| match &error { + DbError::Sqlx(sqlx::Error::Database(db_error)) + if db_error.constraint() == Some("idx_task_events_one_summary_per_task") => + { + DbError::InvalidData(format!("task {task_id} already has a persisted summary")) + } + _ => error, + })?; + + tx.commit().await?; + Ok(event) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_empty_patch_asks_for_nothing() { + assert!(TaskPatch::default().is_empty()); + } + + #[test] + fn clearing_a_field_is_not_an_empty_patch() { + // `Some(None)` means "unassign", which is a real change. Treating it as + // empty would silently drop the operation. + let patch = TaskPatch { + assignee_pubkey: Some(None), + ..TaskPatch::default() + }; + assert!(!patch.is_empty()); + + let patch = TaskPatch { + due_at: Some(None), + ..TaskPatch::default() + }; + assert!(!patch.is_empty()); + } + + #[test] + fn every_selected_task_column_is_named_once() { + // The row parser reads these by name; a duplicate or a stray comma + // here would surface as a runtime decode error on every read. + let columns: Vec<&str> = task_columns!().split(',').map(str::trim).collect(); + assert!(columns.contains(&"community_id")); + assert!(columns.contains(&"status")); + assert!(columns.contains(&"done_at")); + let mut sorted = columns.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!( + sorted.len(), + columns.len(), + "duplicate column in projection" + ); + } + + // ── Live-Postgres integration coverage ────────────────────────────────── + // + // `#[ignore]`d, exactly like every other Postgres-backed test in this + // crate: `just test-unit` runs `-p buzz-db --lib`, which skips them, and + // `just test` (Docker Postgres + Redis) is what turns them on. Run one + // directly with: + // + // cargo test -p buzz-db --lib task::tests -- --ignored + // + // against a database that has migration 0033 applied. + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + fn test_database_url() -> String { + std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()) + } + + async fn setup_pool() -> PgPool { + PgPool::connect(&test_database_url()) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("task-test-{}.example", id.simple())) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + /// Tasks reference `users`, so a creator must exist before the insert. + async fn make_test_user(pool: &PgPool, community: CommunityId, seed: u8) -> Vec { + let pubkey = vec![seed; 32]; + crate::user::ensure_user(pool, community, &pubkey) + .await + .expect("ensure test user"); + pubkey + } + + async fn delete_test_community(pool: &PgPool, community: CommunityId) { + for table in ["task_events", "tasks", "users"] { + sqlx::query(sqlx::AssertSqlSafe(format!( + "DELETE FROM {table} WHERE community_id = $1" + ))) + .bind(community.as_uuid()) + .execute(pool) + .await + .expect("delete test rows"); + } + sqlx::query("DELETE FROM communities WHERE id = $1") + .bind(community.as_uuid()) + .execute(pool) + .await + .expect("delete test community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn create_then_list_then_get_round_trips_a_task_and_its_history() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let creator = make_test_user(&pool, community, 0x11).await; + + let created = create_task( + &pool, + community, + NewTask { + created_by_pubkey: Some(creator.clone()), + title: "ship the task system".to_owned(), + body: Some("phase 1".to_owned()), + priority: 5, + source: Some("claude".to_owned()), + ..NewTask::default() + }, + ) + .await + .expect("create task"); + + assert_eq!(created.title, "ship the task system"); + assert_eq!(created.status, TaskStatus::Todo); + assert_eq!(created.priority, 5); + assert_eq!(created.done_at, None); + assert_eq!( + created.created_by_pubkey.as_deref(), + Some(creator.as_slice()) + ); + + let listed = list_tasks( + &pool, + community, + &TaskFilter { + limit: 10, + ..TaskFilter::default() + }, + ) + .await + .expect("list tasks"); + assert_eq!(listed, vec![created.clone()]); + + let fetched = get_task(&pool, community, created.id) + .await + .expect("get task"); + assert_eq!(fetched, created); + + // create_task commits the task and its opening history entry together. + let events = list_task_events(&pool, community, created.id) + .await + .expect("list events"); + assert_eq!(events.len(), 1); + assert_eq!(events[0].action, TaskAction::Created); + + delete_test_community(&pool, community).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn a_status_change_sets_done_at_and_appends_exactly_one_event() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let creator = make_test_user(&pool, community, 0x22).await; + + let task = create_task( + &pool, + community, + NewTask { + created_by_pubkey: Some(creator.clone()), + title: "finish it".to_owned(), + ..NewTask::default() + }, + ) + .await + .expect("create task"); + + let done = update_task( + &pool, + community, + task.id, + &TaskPatch { + status: Some(TaskStatus::Done), + ..TaskPatch::default() + }, + Some(&creator), + ) + .await + .expect("mark done"); + assert_eq!(done.status, TaskStatus::Done); + assert!( + done.done_at.is_some(), + "done_at is derived from the status, not supplied by the caller" + ); + + let events = list_task_events(&pool, community, task.id) + .await + .expect("list events"); + assert_eq!(events.len(), 2, "created + status_changed"); + assert_eq!(events[1].action, TaskAction::StatusChanged); + assert_eq!(events[1].from_status, Some(TaskStatus::Todo)); + assert_eq!(events[1].to_status, Some(TaskStatus::Done)); + + // Restating the same status is idempotent: no second event. + update_task( + &pool, + community, + task.id, + &TaskPatch { + status: Some(TaskStatus::Done), + ..TaskPatch::default() + }, + Some(&creator), + ) + .await + .expect("restate done"); + let events = list_task_events(&pool, community, task.id) + .await + .expect("list events again"); + assert_eq!(events.len(), 2, "restating a status must append nothing"); + + // Reopening clears done_at, keeping chk_tasks_done_at_matches_status + // satisfiable. + let reopened = update_task( + &pool, + community, + task.id, + &TaskPatch { + status: Some(TaskStatus::Todo), + ..TaskPatch::default() + }, + Some(&creator), + ) + .await + .expect("reopen"); + assert_eq!(reopened.done_at, None); + + delete_test_community(&pool, community).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn a_task_id_is_invisible_to_another_community() { + let pool = setup_pool().await; + let owner = make_test_community(&pool).await; + let stranger = make_test_community(&pool).await; + let creator = make_test_user(&pool, owner, 0x33).await; + + let task = create_task( + &pool, + owner, + NewTask { + created_by_pubkey: Some(creator), + title: "tenant-private".to_owned(), + ..NewTask::default() + }, + ) + .await + .expect("create task"); + + // The bare id is not a capability: presented against another tenant it + // reads as absent, never as the owner's row. + assert!(matches!( + get_task(&pool, stranger, task.id).await, + Err(DbError::NotFound(_)) + )); + assert!(matches!( + append_task_event( + &pool, + stranger, + task.id, + None, + TaskAction::Commented, + Some("leak?") + ) + .await, + Err(DbError::NotFound(_)) + )); + + delete_test_community(&pool, owner).await; + delete_test_community(&pool, stranger).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn a_task_keeps_at_most_one_persisted_summary() { + let pool = setup_pool().await; + let community = make_test_community(&pool).await; + let actor = make_test_user(&pool, community, 0x44).await; + + let task = create_task( + &pool, + community, + NewTask { + created_by_pubkey: Some(actor.clone()), + title: "summarize me".to_owned(), + ..NewTask::default() + }, + ) + .await + .expect("create task"); + + append_task_event( + &pool, + community, + task.id, + Some(&actor), + TaskAction::SummaryPersisted, + Some("first summary"), + ) + .await + .expect("first summary"); + + let second = append_task_event( + &pool, + community, + task.id, + Some(&actor), + TaskAction::SummaryPersisted, + Some("second summary"), + ) + .await; + assert!( + matches!(second, Err(DbError::InvalidData(_))), + "the partial unique index must reject a second summary, got {second:?}" + ); + + // Ordinary comments stay unbounded. + for _ in 0..2 { + append_task_event( + &pool, + community, + task.id, + Some(&actor), + TaskAction::Commented, + Some("a comment"), + ) + .await + .expect("comment"); + } + + delete_test_community(&pool, community).await; + } +} diff --git a/crates/buzz-relay/src/api/mod.rs b/crates/buzz-relay/src/api/mod.rs index 2a942bc8039..3247cdf835b 100644 --- a/crates/buzz-relay/src/api/mod.rs +++ b/crates/buzz-relay/src/api/mod.rs @@ -9,6 +9,7 @@ pub mod media; pub mod mesh_demo; pub mod nip05; pub mod operator; +pub mod tasks; pub mod workflows; // Re-export imeta helpers used by ingest pipeline. diff --git a/crates/buzz-relay/src/api/tasks.rs b/crates/buzz-relay/src/api/tasks.rs new file mode 100644 index 00000000000..dffa09ab15f --- /dev/null +++ b/crates/buzz-relay/src/api/tasks.rs @@ -0,0 +1,677 @@ +//! Authorized reads and writes for task state. +//! +//! Tasks are relay-owned database rows, not Nostr events — the same modeling +//! choice `api::workflows` makes for runs and approvals, and for the same +//! reason: there is no synthetic event worth inventing for a work item whose +//! whole value is a queryable, mutable read model. +//! +//! Every route is scoped to the **host-derived** tenant, like the rest of the +//! relay's HTTP surface. There is no `/communities/{id}/…` path segment +//! anywhere in Buzz: `crate::tenant::bind_community` resolves the community +//! from the `Host` header, and NIP-98 signatures are bound to that same host, +//! so a client cannot name a community it did not connect to. +//! +//! Channel-bound tasks additionally require the caller to have access to the +//! bound channel, mirroring `api::workflows::authorize_workflow_read`. + +use std::sync::Arc; + +use axum::{ + extract::{Path, Query, RawQuery, State}, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use serde_json::Value; +use uuid::Uuid; + +use buzz_core::task::{TaskAction, TaskStatus}; +use buzz_core::TenantContext; +use buzz_db::task::{NewTask, TaskEventRecord, TaskFilter, TaskPatch, TaskRecord}; + +use crate::{ + api::{api_error, bridge, internal_error}, + state::AppState, +}; + +const DEFAULT_TASK_LIMIT: i64 = 50; +const MAX_TASK_LIMIT: i64 = 200; +const MAX_TITLE_CHARS: usize = 200; + +/// Query filters for `GET /api/tasks`. +#[derive(Debug, Deserialize, Default)] +pub struct TasksQuery { + status: Option, + assignee: Option, + channel: Option, + include_archived: Option, + limit: Option, +} + +/// Body of `POST /api/tasks`. +#[derive(Debug, Deserialize)] +pub struct CreateTaskRequest { + title: String, + body: Option, + channel_id: Option, + parent_task_id: Option, + assignee: Option, + priority: Option, + due_at: Option>, + source: Option, + source_ref: Option, +} + +/// Body of `PATCH /api/tasks/{id}`. +/// +/// `assignee` and `due_at` are doubly optional on the wire: an absent key +/// leaves the field alone, while an explicit `null` clears it. `serde`'s +/// `double_option` shape (`Option>` with +/// `skip_serializing_if`/`default`) is what distinguishes the two. +#[derive(Debug, Deserialize, Default)] +pub struct UpdateTaskRequest { + status: Option, + title: Option, + priority: Option, + #[serde(default, deserialize_with = "deserialize_double_option")] + due_at: Option>>, + #[serde(default, deserialize_with = "deserialize_double_option")] + assignee: Option>, +} + +/// Body of `POST /api/tasks/{id}/events`. +#[derive(Debug, Deserialize)] +pub struct AppendTaskEventRequest { + action: Option, + body: Option, +} + +fn deserialize_double_option<'de, D, T>( + deserializer: D, +) -> std::result::Result>, D::Error> +where + D: serde::Deserializer<'de>, + T: serde::Deserialize<'de>, +{ + Option::::deserialize(deserializer).map(Some) +} + +fn request_path(path: &str, raw_query: Option<&str>) -> String { + match raw_query { + Some(query) if !query.is_empty() => format!("{path}?{query}"), + _ => path.to_string(), + } +} + +/// Parse a 32-byte pubkey from lowercase hex, rejecting anything else. +fn parse_pubkey(field: &str, raw: &str) -> Result, (StatusCode, Json)> { + let bytes = hex::decode(raw) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, &format!("{field} must be hex")))?; + if bytes.len() != 32 { + return Err(api_error( + StatusCode::BAD_REQUEST, + &format!("{field} must be a 32-byte pubkey"), + )); + } + Ok(bytes) +} + +fn parse_status(raw: &str) -> Result)> { + raw.parse::() + .map_err(|message| api_error(StatusCode::BAD_REQUEST, &message)) +} + +/// Reject titles the database's `CHECK (length(title) BETWEEN 1 AND 200)` +/// would reject, so the caller gets a 400 instead of a 500. +/// +/// The check counts characters, matching PostgreSQL's `length()` on `TEXT` +/// (which counts characters, not bytes) — using `String::len` here would let a +/// 200-character multi-byte title fail in the database after passing this gate. +fn validate_title(title: &str) -> Result)> { + let trimmed = title.trim(); + if trimmed.is_empty() { + return Err(api_error( + StatusCode::BAD_REQUEST, + "title must not be empty", + )); + } + if trimmed.chars().count() > MAX_TITLE_CHARS { + return Err(api_error( + StatusCode::BAD_REQUEST, + "title must be at most 200 characters", + )); + } + Ok(trimmed.to_owned()) +} + +/// Map a database error onto the narrowest status the caller can act on. +/// +/// Foreign-key violations here always mean the caller named a channel, parent +/// task, or assignee that does not exist in this community — a request error, +/// not a server fault. +fn map_task_error(context: &str, error: buzz_db::DbError) -> (StatusCode, Json) { + match &error { + buzz_db::DbError::NotFound(_) => api_error(StatusCode::NOT_FOUND, "task not found"), + buzz_db::DbError::InvalidData(message) => api_error(StatusCode::BAD_REQUEST, message), + buzz_db::DbError::AccessDenied(_) => api_error( + StatusCode::SERVICE_UNAVAILABLE, + "community writes are temporarily unavailable", + ), + buzz_db::DbError::Sqlx(sqlx::Error::Database(db_error)) + if db_error.code().as_deref() == Some("23503") => + { + api_error( + StatusCode::BAD_REQUEST, + "channel, parent task, or assignee does not exist in this community", + ) + } + buzz_db::DbError::Sqlx(sqlx::Error::Database(db_error)) + if db_error.code().as_deref() == Some("23514") => + { + api_error(StatusCode::BAD_REQUEST, "task violates a field constraint") + } + _ => internal_error(&format!("{context}: {error}")), + } +} + +/// Authenticate the caller and bind the request to its host-derived tenant. +/// +/// `body` is `Some` for writes; NIP-98 then additionally requires a `payload` +/// tag covering it, so a signature cannot be replayed against a different body. +async fn authorize_task_request( + state: &Arc, + headers: &HeaderMap, + method: &str, + path: &str, + raw_query: Option<&str>, + body: Option<&[u8]>, +) -> Result<(TenantContext, nostr::PublicKey), (StatusCode, Json)> { + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| { + api_error( + StatusCode::NOT_FOUND, + "relay: no community is configured for this host", + ) + })?; + + let path_with_query = request_path(path, raw_query); + let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); + let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( + headers, + method, + &url, + body, + state.config.require_auth_token, + body.is_some(), + )?; + bridge::enforce_http_admission(state, &tenant, &pubkey).await?; + bridge::check_nip98_replay(state, &tenant, event_id_bytes).await?; + + let pubkey_bytes = pubkey.to_bytes().to_vec(); + let auth_tag = headers + .get("x-auth-tag") + .and_then(|value| value.to_str().ok()); + super::relay_members::enforce_relay_membership( + state, + tenant.community(), + &pubkey_bytes, + auth_tag, + ) + .await?; + + Ok((tenant, pubkey)) +} + +/// Reject access to a channel-bound task the caller cannot see. +/// +/// A task with no channel is community-wide and needs no further check; relay +/// membership already gated it. +async fn enforce_channel_access( + state: &Arc, + tenant: &TenantContext, + pubkey: &nostr::PublicKey, + channel_id: Option, +) -> Result<(), (StatusCode, Json)> { + let Some(channel_id) = channel_id else { + return Ok(()); + }; + let accessible = state + .get_accessible_channel_ids_cached(tenant.community(), &pubkey.to_bytes()) + .await + .map_err(|error| internal_error(&format!("task channel access lookup: {error}")))?; + if !accessible.contains(&channel_id) { + // 404, not 403: the caller cannot see this channel, so it must not + // learn that a task exists in it. + return Err(api_error(StatusCode::NOT_FOUND, "task not found")); + } + Ok(()) +} + +/// `POST /api/tasks` — create a task. Requires relay membership. +pub async fn create_task( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let (tenant, pubkey) = + authorize_task_request(&state, &headers, "POST", "/api/tasks", None, Some(&body)).await?; + + let request: CreateTaskRequest = serde_json::from_slice(&body) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid task JSON: {e}")))?; + + let title = validate_title(&request.title)?; + let assignee = request + .assignee + .as_deref() + .map(|raw| parse_pubkey("assignee", raw)) + .transpose()?; + + enforce_channel_access(&state, &tenant, &pubkey, request.channel_id).await?; + + // `tasks.created_by_pubkey` is a community-scoped FK into `users`. An + // authenticated member may still have no `users` row yet (it is created + // lazily on first profile write), so materialize it before the insert. + let creator = pubkey.to_bytes().to_vec(); + state + .db + .ensure_user(tenant.community(), &creator) + .await + .map_err(|error| internal_error(&format!("ensure task creator: {error}")))?; + + let task = state + .db + .create_task( + tenant.community(), + NewTask { + channel_id: request.channel_id, + created_by_pubkey: Some(creator), + assignee_pubkey: assignee, + parent_task_id: request.parent_task_id, + title, + body: request.body, + priority: request.priority.unwrap_or(0), + // 'app' marks a task typed by a person in a Buzz client, as + // distinct from one a harness opened on their behalf. + source: Some(request.source.unwrap_or_else(|| "app".to_owned())), + source_ref: request.source_ref, + due_at: request.due_at, + }, + ) + .await + .map_err(|error| map_task_error("create task", error))?; + + Ok(Json(task_json(&task))) +} + +/// `GET /api/tasks` — list this community's tasks, newest-modified first. +pub async fn list_tasks( + State(state): State>, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, + Query(query): Query, +) -> Result, (StatusCode, Json)> { + let limit = query.limit.unwrap_or(DEFAULT_TASK_LIMIT); + if !(1..=MAX_TASK_LIMIT).contains(&limit) { + return Err(api_error( + StatusCode::BAD_REQUEST, + "limit must be between 1 and 200", + )); + } + let status = query.status.as_deref().map(parse_status).transpose()?; + let assignee = query + .assignee + .as_deref() + .map(|raw| parse_pubkey("assignee", raw)) + .transpose()?; + + let (tenant, pubkey) = authorize_task_request( + &state, + &headers, + "GET", + "/api/tasks", + raw_query.as_deref(), + None, + ) + .await?; + + if let Some(channel_id) = query.channel { + enforce_channel_access(&state, &tenant, &pubkey, Some(channel_id)).await?; + } + + let tasks = state + .db + .list_tasks( + tenant.community(), + &TaskFilter { + status, + assignee_pubkey: assignee, + channel_id: query.channel, + include_archived: query.include_archived.unwrap_or(false), + limit, + }, + ) + .await + .map_err(|error| map_task_error("list tasks", error))?; + + // Channel-bound tasks the caller cannot see are filtered out rather than + // failing the whole page: a list is a view of what you may see. + let accessible = state + .get_accessible_channel_ids_cached(tenant.community(), &pubkey.to_bytes()) + .await + .map_err(|error| internal_error(&format!("task channel access lookup: {error}")))?; + let visible: Vec = tasks + .iter() + .filter(|task| { + task.channel_id + .is_none_or(|channel_id| accessible.contains(&channel_id)) + }) + .map(task_json) + .collect(); + + Ok(Json(serde_json::json!({ "tasks": visible }))) +} + +/// `GET /api/tasks/{id}` — one task plus its full event history. +pub async fn get_task( + State(state): State>, + Path(task_id): Path, + headers: HeaderMap, +) -> Result, (StatusCode, Json)> { + let path = format!("/api/tasks/{task_id}"); + let (tenant, pubkey) = + authorize_task_request(&state, &headers, "GET", &path, None, None).await?; + + let task = state + .db + .get_task(tenant.community(), task_id) + .await + .map_err(|error| map_task_error("get task", error))?; + enforce_channel_access(&state, &tenant, &pubkey, task.channel_id).await?; + + let events = state + .db + .list_task_events(tenant.community(), task_id) + .await + .map_err(|error| map_task_error("list task events", error))?; + + Ok(Json(serde_json::json!({ + "task": task_json(&task), + "events": events.iter().map(task_event_json).collect::>(), + }))) +} + +/// `PATCH /api/tasks/{id}` — update a task, appending its history entries. +pub async fn update_task( + State(state): State>, + Path(task_id): Path, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let path = format!("/api/tasks/{task_id}"); + let (tenant, pubkey) = + authorize_task_request(&state, &headers, "PATCH", &path, None, Some(&body)).await?; + + let request: UpdateTaskRequest = serde_json::from_slice(&body) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid task JSON: {e}")))?; + + let patch = TaskPatch { + status: request.status.as_deref().map(parse_status).transpose()?, + title: request.title.as_deref().map(validate_title).transpose()?, + priority: request.priority, + due_at: request.due_at, + assignee_pubkey: match request.assignee { + None => None, + Some(None) => Some(None), + Some(Some(raw)) => Some(Some(parse_pubkey("assignee", &raw)?)), + }, + }; + if patch.is_empty() { + return Err(api_error( + StatusCode::BAD_REQUEST, + "patch must change at least one field", + )); + } + + // Authorize against the task's *current* channel before mutating it. + let existing = state + .db + .get_task(tenant.community(), task_id) + .await + .map_err(|error| map_task_error("get task", error))?; + enforce_channel_access(&state, &tenant, &pubkey, existing.channel_id).await?; + + let task = state + .db + .update_task( + tenant.community(), + task_id, + &patch, + Some(&pubkey.to_bytes()), + ) + .await + .map_err(|error| map_task_error("update task", error))?; + + Ok(Json(task_json(&task))) +} + +/// `POST /api/tasks/{id}/events` — append a comment or summary. +pub async fn append_task_event( + State(state): State>, + Path(task_id): Path, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let path = format!("/api/tasks/{task_id}/events"); + let (tenant, pubkey) = + authorize_task_request(&state, &headers, "POST", &path, None, Some(&body)).await?; + + let request: AppendTaskEventRequest = serde_json::from_slice(&body) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?; + + let action = match request.action.as_deref() { + None => TaskAction::Commented, + Some(raw) => raw + .parse::() + .map_err(|message| api_error(StatusCode::BAD_REQUEST, &message))?, + }; + // Lifecycle actions are derived from the mutation that caused them; letting + // a caller post one directly would let it fabricate a transition history + // that never happened. + if !matches!(action, TaskAction::Commented | TaskAction::SummaryPersisted) { + return Err(api_error( + StatusCode::BAD_REQUEST, + "only 'commented' and 'summary_persisted' may be posted directly", + )); + } + let event_body = request + .body + .as_deref() + .map(str::trim) + .filter(|text| !text.is_empty()) + .ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "body must not be empty"))?; + + let task = state + .db + .get_task(tenant.community(), task_id) + .await + .map_err(|error| map_task_error("get task", error))?; + enforce_channel_access(&state, &tenant, &pubkey, task.channel_id).await?; + + let actor = pubkey.to_bytes().to_vec(); + state + .db + .ensure_user(tenant.community(), &actor) + .await + .map_err(|error| internal_error(&format!("ensure task actor: {error}")))?; + + let event = state + .db + .append_task_event( + tenant.community(), + task_id, + Some(&actor), + action, + Some(event_body), + ) + .await + .map_err(|error| map_task_error("append task event", error))?; + + Ok(Json(task_event_json(&event))) +} + +fn task_json(task: &TaskRecord) -> Value { + serde_json::json!({ + "id": task.id, + "channel_id": task.channel_id, + "created_by": task.created_by_pubkey.as_ref().map(hex::encode), + "assignee": task.assignee_pubkey.as_ref().map(hex::encode), + "parent_task_id": task.parent_task_id, + "title": task.title, + "body": task.body, + "status": task.status.as_str(), + "priority": task.priority, + "source": task.source, + "source_ref": task.source_ref, + "due_at": task.due_at.map(|value| value.timestamp()), + "done_at": task.done_at.map(|value| value.timestamp()), + "archived_at": task.archived_at.map(|value| value.timestamp()), + "created_at": task.created_at.timestamp(), + "updated_at": task.updated_at.timestamp(), + }) +} + +fn task_event_json(event: &TaskEventRecord) -> Value { + serde_json::json!({ + "id": event.id, + "task_id": event.task_id, + "actor": event.actor_pubkey.as_ref().map(hex::encode), + "action": event.action.as_str(), + "from_status": event.from_status.map(|status| status.as_str()), + "to_status": event.to_status.map(|status| status.as_str()), + "body": event.body, + "created_at": event.created_at.timestamp(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_path_preserves_signed_query_verbatim() { + assert_eq!( + request_path("/api/tasks", Some("status=todo&limit=10")), + "/api/tasks?status=todo&limit=10" + ); + assert_eq!(request_path("/api/tasks", None), "/api/tasks"); + assert_eq!(request_path("/api/tasks", Some("")), "/api/tasks"); + } + + #[test] + fn title_length_is_counted_in_characters_not_bytes() { + // 200 multi-byte characters is 600 bytes but a legal title; counting + // bytes here would 400 a request the database would have accepted. + let multibyte = "é".repeat(200); + assert_eq!( + validate_title(&multibyte).expect("200 chars is legal"), + multibyte + ); + assert!(validate_title(&"é".repeat(201)).is_err()); + } + + #[test] + fn title_is_trimmed_and_must_not_be_blank() { + assert_eq!(validate_title(" ship it ").expect("trims"), "ship it"); + assert!(validate_title(" ").is_err()); + assert!(validate_title("").is_err()); + } + + #[test] + fn assignee_must_be_a_32_byte_hex_pubkey() { + let valid = "ab".repeat(32); + assert_eq!( + parse_pubkey("assignee", &valid).expect("valid"), + vec![0xab; 32] + ); + assert!(parse_pubkey("assignee", "not-hex").is_err()); + assert!(parse_pubkey("assignee", &"ab".repeat(31)).is_err()); + assert!(parse_pubkey("assignee", &"ab".repeat(33)).is_err()); + } + + #[test] + fn absent_and_null_assignee_are_different_patches() { + // The whole point of the double option: `{}` leaves the assignee + // alone, `{"assignee": null}` unassigns. + let absent: UpdateTaskRequest = serde_json::from_str("{}").expect("absent"); + assert_eq!(absent.assignee, None); + + let cleared: UpdateTaskRequest = + serde_json::from_str(r#"{"assignee": null}"#).expect("null"); + assert_eq!(cleared.assignee, Some(None)); + + let set: UpdateTaskRequest = serde_json::from_str(r#"{"assignee": "abc"}"#).expect("set"); + assert_eq!(set.assignee, Some(Some("abc".to_owned()))); + } + + #[test] + fn absent_and_null_due_at_are_different_patches() { + let absent: UpdateTaskRequest = serde_json::from_str("{}").expect("absent"); + assert_eq!(absent.due_at, None); + + let cleared: UpdateTaskRequest = serde_json::from_str(r#"{"due_at": null}"#).expect("null"); + assert_eq!(cleared.due_at, Some(None)); + } + + #[test] + fn task_wire_renders_status_and_hex_pubkeys() { + let task = TaskRecord { + id: Uuid::nil(), + channel_id: None, + created_by_pubkey: Some(vec![0xab; 32]), + assignee_pubkey: None, + parent_task_id: None, + title: "ship it".to_owned(), + body: None, + status: TaskStatus::InProgress, + priority: 3, + source: Some("claude".to_owned()), + source_ref: None, + due_at: None, + done_at: None, + archived_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + let wire = task_json(&task); + assert_eq!(wire["status"], "in_progress"); + assert_eq!(wire["created_by"], hex::encode([0xab; 32])); + assert!(wire["assignee"].is_null()); + assert_eq!(wire["priority"], 3); + // Raw bytes must never reach the wire. + assert!(wire.get("created_by_pubkey").is_none()); + } + + #[test] + fn task_event_wire_renders_both_status_ends() { + let event = TaskEventRecord { + id: 7, + task_id: Uuid::nil(), + actor_pubkey: None, + action: TaskAction::StatusChanged, + from_status: Some(TaskStatus::Todo), + to_status: Some(TaskStatus::Done), + body: None, + created_at: Utc::now(), + }; + let wire = task_event_json(&event); + assert_eq!(wire["action"], "status_changed"); + assert_eq!(wire["from_status"], "todo"); + assert_eq!(wire["to_status"], "done"); + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 1dce66e91e4..f18dbaf221f 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -118,6 +118,21 @@ pub fn build_router(state: Arc) -> Router { post(api::invites::accept_policy), ) .route("/api/invites/claim", post(api::invites::claim_invite)) + // Tasks: relay-owned work items (NIP-98 auth + relay membership). + // Host-derived tenant, like every other route here — the community is + // never a path segment. + .route( + "/api/tasks", + get(api::tasks::list_tasks).post(api::tasks::create_task), + ) + .route( + "/api/tasks/{task_id}", + get(api::tasks::get_task).patch(api::tasks::update_task), + ) + .route( + "/api/tasks/{task_id}/events", + post(api::tasks::append_task_event), + ) // Moderation queue reads (NIP-98 auth + mod-authz gate, L6) .route("/moderation/reports", get(api::bridge::moderation_reports)) .route("/moderation/audit", get(api::bridge::moderation_audit)) diff --git a/migrations/0033_task_system.sql b/migrations/0033_task_system.sql new file mode 100644 index 00000000000..1ca94991d36 --- /dev/null +++ b/migrations/0033_task_system.sql @@ -0,0 +1,121 @@ +-- First-class task entity: durable work items that harness agents (Claude Code, +-- Codex, the ACP mesh) and humans create, update, and close inside a community. +-- +-- Tasks are deliberately NOT workflows. `workflows`/`workflow_runs` model the +-- scheduled execution engine; a task is a unit of work someone (or some agent) +-- owns. The two never share a lifecycle, so they never share a table. +-- +-- Relay-owned rows, not Nostr events — the same modeling choice already made +-- for `workflow_runs` and `workflow_approvals` (see `buzz-relay::api::workflows`: +-- "relay-owned database rows, not Nostr events ... without inventing synthetic +-- events"). Task reads are exposed as authorized REST reads over the +-- host-derived tenant, never as a new community path segment. +-- +-- Identity model: a task creator/assignee is a `users` row, never a separate +-- agent table. Agents in Buzz *are* users — they carry a `users.agent_type` +-- and an optional `users.agent_owner_pubkey` (NIP-OA). Modeling the creator as +-- a single nullable `created_by_pubkey BYTEA` therefore covers both humans and +-- agents with one community-scoped foreign key, exactly as +-- `users.agent_owner_pubkey` already does. A dedicated `created_by_agent_id` +-- would invent a second identity space that nothing else in the schema uses. +-- +-- Every key leads with `community_id`: the migration lint +-- (`scoped_primary_key_unique_and_foreign_key_constraints_lead_with_community_id`) +-- rejects any primary key, unique constraint, or foreign key on a tenant table +-- that does not, so cross-tenant lookup by bare id is unrepresentable. +SET LOCAL lock_timeout = '5s'; + +CREATE TABLE tasks ( + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), + channel_id UUID, + created_by_pubkey BYTEA, + assignee_pubkey BYTEA, + parent_task_id UUID, + title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200), + body TEXT, + status TEXT NOT NULL DEFAULT 'todo' + CHECK (status IN ('todo', 'in_progress', 'blocked', 'done', 'cancelled')), + priority INT NOT NULL DEFAULT 0, + -- Harness origin ('manual', 'claude', 'codex', 'acp', 'mesh', ...) and the + -- originating external reference. Unconstrained TEXT is intentional, for the + -- reason 0031 gives for `workflow_runs.error_code`: a new harness must be + -- addable across a rolling upgrade without a schema migration. + source TEXT, + source_ref TEXT, + due_at TIMESTAMPTZ, + done_at TIMESTAMPTZ, + archived_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, id), + -- `done_at` is the completion timestamp, so it is set exactly when the task + -- is done. Anything else lets a 'todo' row claim a completion time. + CONSTRAINT chk_tasks_done_at_matches_status + CHECK ((status = 'done') = (done_at IS NOT NULL)), + CONSTRAINT chk_tasks_not_own_parent CHECK (parent_task_id IS DISTINCT FROM id), + CONSTRAINT chk_tasks_created_by_len + CHECK (created_by_pubkey IS NULL OR length(created_by_pubkey) = 32), + CONSTRAINT chk_tasks_assignee_len + CHECK (assignee_pubkey IS NULL OR length(assignee_pubkey) = 32), + -- A channel-bound task names a channel in its OWN community. + FOREIGN KEY (community_id, channel_id) + REFERENCES channels (community_id, id), + FOREIGN KEY (community_id, created_by_pubkey) + REFERENCES users (community_id, pubkey) ON DELETE SET NULL, + FOREIGN KEY (community_id, assignee_pubkey) + REFERENCES users (community_id, pubkey) ON DELETE SET NULL, + -- Subtasks die with their parent; the community purge deletes the whole + -- tenant partition of `tasks` in one statement either way. + FOREIGN KEY (community_id, parent_task_id) + REFERENCES tasks (community_id, id) ON DELETE CASCADE +); + +CREATE INDEX idx_tasks_community_status ON tasks (community_id, status); +CREATE INDEX idx_tasks_community_assignee ON tasks (community_id, assignee_pubkey) + WHERE assignee_pubkey IS NOT NULL; +CREATE INDEX idx_tasks_community_updated ON tasks (community_id, updated_at DESC); +CREATE INDEX idx_tasks_community_channel ON tasks (community_id, channel_id) + WHERE channel_id IS NOT NULL; +CREATE INDEX idx_tasks_community_parent ON tasks (community_id, parent_task_id) + WHERE parent_task_id IS NOT NULL; + +-- Append-only lifecycle and comment log. Also the read model behind the future +-- human-visible task feed, which is why the feed index is (community, time) +-- rather than per-task. +CREATE TABLE task_events ( + community_id UUID NOT NULL REFERENCES communities(id), + id BIGSERIAL, + task_id UUID NOT NULL, + actor_pubkey BYTEA, + -- 'created', 'status_changed', 'assigned', 'commented', 'title_changed', + -- 'summary_persisted', ... Additive TEXT for the same rolling-upgrade + -- reason as `tasks.source`. + action TEXT NOT NULL CHECK (length(action) BETWEEN 1 AND 64), + from_status TEXT, + to_status TEXT, + body TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, id), + CONSTRAINT chk_task_events_actor_len + CHECK (actor_pubkey IS NULL OR length(actor_pubkey) = 32), + FOREIGN KEY (community_id, task_id) + REFERENCES tasks (community_id, id) ON DELETE CASCADE, + FOREIGN KEY (community_id, actor_pubkey) + REFERENCES users (community_id, pubkey) ON DELETE SET NULL +); + +CREATE INDEX idx_task_events_task_created ON task_events (community_id, task_id, created_at); +CREATE INDEX idx_task_events_community_created ON task_events (community_id, created_at DESC); +-- "At most one persisted summary per task" is expressible directly as a partial +-- unique index, so it is enforced in the database rather than only in the relay. +CREATE UNIQUE INDEX idx_task_events_one_summary_per_task + ON task_events (community_id, task_id) + WHERE action = 'summary_persisted'; + +-- Universal community write fence. `attach_community_write_fence` documents the +-- contract: "Future migrations must invoke this helper explicitly after +-- CREATE/ALTER introduces community_id." Without these, a fenced or +-- mid-deletion tenant could still accept task writes. +SELECT attach_community_write_fence('tasks'); +SELECT attach_community_write_fence('task_events'); diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index b36c2d3f1c8..744620b0d99 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -19,6 +19,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/tasks/create_task_sheet.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; @@ -56,6 +57,7 @@ part 'compose_bar/ios_photo_picker.dart'; part 'compose_bar/ios_attachment_popover.dart'; part 'compose_bar/camera_preview.dart'; part 'compose_bar/send_button.dart'; +part 'compose_bar/task_action.dart'; part 'compose_bar/layout.dart'; part 'compose_bar/dock.dart'; part 'compose_bar/compose_bar_widget.dart'; diff --git a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart index b63731662cc..ef8e2183714 100644 --- a/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart +++ b/mobile/lib/features/channels/compose_bar/compose_bar_widget.dart @@ -979,6 +979,7 @@ class ComposeBar extends HookConsumerWidget { focusNode.requestFocus(); }); }, + onCreateTask: () => _openComposerTaskSheet(context, ref, this), onOpenFormatting: () { attachmentSurface.value = _AttachmentSurface.closed; showFormatting.value = true; diff --git a/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart b/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart index c6dc65a8fa9..0a89e8bc296 100644 --- a/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart +++ b/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart @@ -72,7 +72,12 @@ class _ComposeAction extends StatelessWidget { final IconData icon; final VoidCallback onTap; - const _ComposeAction({required this.icon, required this.onTap}); + /// Doubles as the button's semantics label. Optional because the original + /// four actions (@, #, emoji, formatting) are conventional enough to read as + /// glyphs; a less familiar action should name itself. + final String? tooltip; + + const _ComposeAction({required this.icon, required this.onTap, this.tooltip}); @override Widget build(BuildContext context) { @@ -81,6 +86,7 @@ class _ComposeAction extends StatelessWidget { height: 36, child: IconButton( onPressed: () => _runComposerAction(onTap), + tooltip: tooltip, icon: Icon(icon, size: 20, color: context.colors.onSurfaceVariant), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart index 3400edd546d..faf2f3075df 100644 --- a/mobile/lib/features/channels/compose_bar/layout.dart +++ b/mobile/lib/features/channels/compose_bar/layout.dart @@ -23,6 +23,7 @@ class _ComposeBarLayout extends StatelessWidget { final VoidCallback onMention; final VoidCallback onChannel; final VoidCallback onEmoji; + final VoidCallback onCreateTask; final VoidCallback onOpenFormatting; final bool canSend; final bool hasPendingUploads; @@ -51,6 +52,7 @@ class _ComposeBarLayout extends StatelessWidget { required this.onMention, required this.onChannel, required this.onEmoji, + required this.onCreateTask, required this.onOpenFormatting, required this.canSend, required this.hasPendingUploads, @@ -197,6 +199,11 @@ class _ComposeBarLayout extends StatelessWidget { icon: LucideIcons.aLargeSmall, onTap: onOpenFormatting, ), + _ComposeAction( + icon: LucideIcons.listTodo, + tooltip: 'Create task', + onTap: onCreateTask, + ), const Spacer(), _SendButton( isDisabled: !canSend || hasPendingUploads, diff --git a/mobile/lib/features/channels/compose_bar/task_action.dart b/mobile/lib/features/channels/compose_bar/task_action.dart new file mode 100644 index 00000000000..f74cab2e26e --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/task_action.dart @@ -0,0 +1,28 @@ +part of '../compose_bar.dart'; + +/// Opens the "New task" sheet for [composer]'s channel and thread scope. +/// +/// Kept out of `compose_bar_widget.dart` on purpose: that file owns the +/// composer's entire hook and send pipeline and is already at the repo's +/// 1000-line ceiling, so a new action wires in as a single delegating line +/// there and lives here — the same shape `_showComposerEmojiPicker` uses. +/// +/// The composer does not track a `parentEventId` (the message's destination is +/// the parent's business, via `onSend`), so the task's `source_ref` is derived +/// from the thread ids the composer *does* carry: the thread head it is +/// replying under, falling back to the thread root. +void _openComposerTaskSheet( + BuildContext context, + WidgetRef ref, + ComposeBar composer, +) { + unawaited( + showCreateTaskSheet( + context: context, + ref: ref, + channelId: composer.channelId, + channelName: composer.channelName, + sourceEventId: composer.threadHeadId ?? composer.rootId, + ), + ); +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index cbac3cff843..9fca376c301 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../shared/mentions/agent_identity_provider.dart'; @@ -17,6 +18,8 @@ import '../../shared/widgets/keyboard_dismiss_on_drag.dart'; import '../../shared/widgets/message_author_meta.dart'; import '../../shared/profile/user_cache_provider.dart'; import '../../shared/profile/user_profile.dart'; +import '../../shared/tasks/thread_summary.dart'; +import '../../shared/tasks/thread_summary_sheet.dart'; import 'android_ime_lift.dart'; import 'channel_link_navigation.dart'; import 'channel_messages_provider.dart'; @@ -55,6 +58,7 @@ part 'thread_detail_helpers.dart'; part 'thread_detail_page/tail_alignment.dart'; part 'thread_detail_page/thread_message.dart'; part 'thread_detail_page/avatar.dart'; +part 'thread_detail_page/summarize_action.dart'; const _landingHighlightDuration = Duration(seconds: 3); const _landingHighlightDelay = Duration(milliseconds: 50); @@ -853,6 +857,12 @@ class ThreadDetailPage extends HookConsumerWidget { child: const Text('Thread', key: ValueKey('thread-app-bar-title')), ), titleStyle: channelTitleTextStyle, + actions: [ + _SummarizeThreadButton( + channelId: channelId, + messages: [liveHead, ...replies], + ), + ], ), body: Stack( fit: StackFit.expand, diff --git a/mobile/lib/features/channels/thread_detail_page/summarize_action.dart b/mobile/lib/features/channels/thread_detail_page/summarize_action.dart new file mode 100644 index 00000000000..904ade6d3a0 --- /dev/null +++ b/mobile/lib/features/channels/thread_detail_page/summarize_action.dart @@ -0,0 +1,68 @@ +part of '../thread_detail_page.dart'; + +/// Thread app-bar action that digests the thread the reader is looking at. +/// +/// Deliberately scoped to the thread header rather than the channel header: +/// non-DM channels intentionally carry no app-bar actions (their actions live +/// behind the tappable title), and `channel_detail_page_test.dart` asserts that +/// absence. +class _SummarizeThreadButton extends ConsumerWidget { + const _SummarizeThreadButton({ + required this.channelId, + required this.messages, + }); + + final String channelId; + + /// The thread in reading order — head first, then replies. + final List messages; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return IconButton( + key: const ValueKey('thread-summarize-button'), + color: context.colors.primary, + tooltip: 'Summarize thread', + onPressed: () => unawaited( + showThreadSummarySheet( + context: context, + ref: ref, + channelId: channelId, + messages: threadSummaryDigest( + messages, + // Read, not watch: the transcript is assembled once, on tap. A + // watch here would rebuild the button on every profile that + // trickles in from the kind:0 batch fetch. + profiles: ref.read(userCacheProvider), + ), + ), + ), + icon: const Icon(LucideIcons.sparkles, size: 22), + ); + } +} + +/// Converts rendered thread messages into summarizer input. +/// +/// System rows (joins, huddles, edits) are dropped: they are chrome around the +/// conversation, not part of it, and they would crowd out real content in a +/// digest capped at a handful of lines. +/// +/// Author names resolve the same way the message rows resolve them — cached +/// profile label, else a shortened pubkey — so the digest names people the way +/// the thread above it does. +List threadSummaryDigest( + List messages, { + required Map profiles, +}) { + return [ + for (final message in messages) + if (!message.isSystem && message.content.trim().isNotEmpty) + ThreadMessageDigest( + author: + profiles[message.pubkey.toLowerCase()]?.label ?? + shortPubkey(message.pubkey), + text: message.content, + ), + ]; +} diff --git a/mobile/lib/shared/tasks/create_task_sheet.dart b/mobile/lib/shared/tasks/create_task_sheet.dart new file mode 100644 index 00000000000..d54a0c2d877 --- /dev/null +++ b/mobile/lib/shared/tasks/create_task_sheet.dart @@ -0,0 +1,451 @@ +/// The "New task" bottom sheet. +/// +/// Presentation follows `remind_me_later_sheet` (top-level `show…Sheet` +/// function, messenger captured before the first await, snackbar confirmation) +/// and its form follows `manage_channel_sheet` (derived-boolean validation, +/// inline error text, disabled submit with an in-button "Creating…" label). +/// Neither pattern uses `Form`/`TextFormField`, and neither does this. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../mentions/agent_identity_provider.dart'; +import '../profile/user_cache_provider.dart'; +import '../theme/theme.dart'; +import '../widgets/app_list_card.dart'; +import '../widgets/app_list.dart'; +import '../widgets/modal_presentation.dart'; +import 'task.dart'; +import 'task_due_presets.dart'; +import 'tasks_api.dart'; + +/// Title length past which the remaining-character count becomes useful. +const _titleCounterThreshold = 160; + +/// Opens the "New task" sheet for [channelId] and reports the outcome. +/// +/// [channelName] labels the channel-scope row. [sourceEventId] is the message +/// or thread head the task was opened from; it travels as the task's +/// `source_ref` so the task records where it came from. +Future showCreateTaskSheet({ + required BuildContext context, + required WidgetRef ref, + required String channelId, + String channelName = '', + String? sourceEventId, +}) async { + // Resolved before the sheet is shown so the confirmation survives its pop. + final messenger = ScaffoldMessenger.of(context); + if (!ref.read(tasksApiProvider).canSign) { + messenger.showSnackBar( + const SnackBar(content: Text('Sign in to create tasks')), + ); + return; + } + + final created = await showBuzzModalBottomSheet( + context: context, + title: 'New task', + isScrollControlled: true, + showDragHandle: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * 0.9, + ), + builder: (_) => _CreateTaskSheet( + channelId: channelId, + channelName: channelName, + sourceEventId: sourceEventId, + ), + ); + + if (created != null) { + messenger.showSnackBar(const SnackBar(content: Text('Task created'))); + } +} + +class _CreateTaskSheet extends HookConsumerWidget { + const _CreateTaskSheet({ + required this.channelId, + required this.channelName, + required this.sourceEventId, + }); + + final String channelId; + final String channelName; + final String? sourceEventId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final titleController = useTextEditingController(); + final bodyController = useTextEditingController(); + useListenable(titleController); + + final dueAt = useState(null); + final scopeToChannel = useState(true); + final assignToAgents = useState(false); + final isSubmitting = useState(false); + final actionError = useState(null); + + // Presets are resolved once per sheet rather than per rebuild, so a chip + // cannot silently change the instant it stands for while the sheet is open. + final presets = useMemoized(taskDuePresets); + + final agentPubkeys = + ref.watch(channelBotPubkeysProvider(channelId)).asData?.value ?? + const {}; + final userCache = ref.watch(userCacheProvider); + final agentHandles = resolveAgentHandles( + agentPubkeys: agentPubkeys, + // Only the agents' names, not a copy of the whole profile cache: a title + // keystroke rebuilds this widget. + profileNames: { + for (final pubkey in agentPubkeys) + pubkey.toLowerCase(): ?userCache[pubkey.toLowerCase()]?.displayName, + }, + directoryNames: ref.watch(agentDirectoryDisplayNamesProvider), + ); + + final title = titleController.text.trim(); + final titleLength = taskTitleLength(title); + final titleTooLong = titleLength > maxTaskTitleChars; + final canSubmit = title.isNotEmpty && !titleTooLong && !isSubmitting.value; + + Future submit() async { + if (!canSubmit) return; + isSubmitting.value = true; + actionError.value = null; + try { + final task = await ref + .read(tasksApiProvider) + .createTask( + title: title, + body: composeTaskBody( + body: bodyController.text, + agentHandles: assignToAgents.value ? agentHandles : const [], + ), + channelId: scopeToChannel.value ? channelId : null, + sourceRef: sourceEventId, + dueAt: dueAt.value, + ); + if (context.mounted) Navigator.of(context).pop(task); + } catch (error) { + // The sheet is dismissible while the request is in flight, so a state + // write after it has gone would be a `setState() after dispose()`. + if (context.mounted) actionError.value = error.toString(); + } finally { + if (context.mounted) isSubmitting.value = false; + } + } + + Future pickDueDate() async { + final now = DateTime.now(); + final picked = await showBuzzDialog( + context: context, + builder: (_) => DatePickerDialog( + initialDate: dueAt.value ?? now, + firstDate: DateTime(now.year, now.month, now.day), + lastDate: now.add(const Duration(days: 365 * 2)), + ), + ); + // Land on 9am so a date-only pick matches the preset convention rather + // than becoming midnight, which reads as "the day before" to a user. + if (picked != null && context.mounted) { + dueAt.value = DateTime(picked.year, picked.month, picked.day, 9); + } + } + + final scopeLabel = channelName.trim().isEmpty + ? 'This conversation' + : '#${channelName.trim()}'; + + return Padding( + padding: EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + MediaQuery.viewInsetsOf(context).bottom + Grid.xs, + ), + child: SafeArea( + top: false, + // Scrolling fields with a pinned action row: the form is tall enough + // (two fields, a chip row, two option groups) to overflow a short + // viewport, and "Create task" must not be the part that falls below + // the fold. + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _TaskField( + fieldKey: const ValueKey('create-task-title'), + controller: titleController, + enabled: !isSubmitting.value, + hintText: 'What needs doing?', + textInputAction: TextInputAction.next, + ), + if (titleTooLong) + _FieldMessage( + 'Titles are limited to $maxTaskTitleChars characters.', + isError: true, + ) + else if (titleLength > _titleCounterThreshold) + _FieldMessage('$titleLength/$maxTaskTitleChars'), + const SizedBox(height: Grid.xs), + _TaskField( + fieldKey: const ValueKey('create-task-body'), + controller: bodyController, + enabled: !isSubmitting.value, + hintText: 'Add detail (optional)', + minLines: 2, + maxLines: 4, + textInputAction: TextInputAction.newline, + ), + const SizedBox(height: Grid.xs), + _DueDatePicker( + presets: presets, + selected: dueAt.value, + enabled: !isSubmitting.value, + onSelect: (value) => dueAt.value = value, + onPickDate: pickDueDate, + ), + const SizedBox(height: Grid.xs), + AppListCard( + label: 'Scope', + children: [ + AppListRow( + key: const ValueKey('create-task-scope-channel'), + icon: LucideIcons.hash, + title: scopeLabel, + trailing: _SelectedCheck( + selected: scopeToChannel.value, + ), + onTap: isSubmitting.value + ? null + : () => scopeToChannel.value = true, + ), + AppListRow( + key: const ValueKey('create-task-scope-community'), + icon: LucideIcons.globe, + title: 'Whole community', + trailing: _SelectedCheck( + selected: !scopeToChannel.value, + ), + onTap: isSubmitting.value + ? null + : () => scopeToChannel.value = false, + ), + ], + ), + // Hidden rather than disabled when the channel has no agents: an + // always-visible row that can never do anything is just noise. + if (agentHandles.isNotEmpty) + AppListCard( + children: [ + AppListRow( + key: const ValueKey('create-task-assign-agents'), + icon: LucideIcons.bot, + title: 'Mention this channel’s agents', + subtitle: agentHandles.map((h) => '@$h').join(' '), + subtitleMaxLines: 2, + trailing: _SelectedCheck( + selected: assignToAgents.value, + ), + onTap: isSubmitting.value + ? null + : () => assignToAgents.value = + !assignToAgents.value, + ), + ], + ), + if (actionError.value case final error?) + _FieldMessage(error, isError: true), + ], + ), + ), + ), + const SizedBox(height: Grid.xs), + FilledButton( + key: const ValueKey('create-task-submit'), + onPressed: canSubmit ? submit : null, + child: Text(isSubmitting.value ? 'Creating…' : 'Create task'), + ), + ], + ), + ), + ); + } +} + +/// The due-date chip row: one chip per preset, plus a custom-date chip. +class _DueDatePicker extends StatelessWidget { + const _DueDatePicker({ + required this.presets, + required this.selected, + required this.enabled, + required this.onSelect, + required this.onPickDate, + }); + + final List presets; + final DateTime? selected; + final bool enabled; + final ValueChanged onSelect; + final VoidCallback onPickDate; + + @override + Widget build(BuildContext context) { + final isCustom = + selected != null && !presets.any((preset) => preset.dueAt == selected); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: Grid.half, bottom: Grid.xxs), + child: Text( + 'Due', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + Wrap( + spacing: Grid.half, + runSpacing: Grid.half, + children: [ + for (final preset in presets) + InputChip( + key: ValueKey('create-task-due-${preset.label}'), + label: Text(preset.label), + selected: selected == preset.dueAt, + // Re-tapping the selected chip clears the due date, so the + // field stays optional without a separate clear control. + onPressed: enabled + ? () => onSelect( + selected == preset.dueAt ? null : preset.dueAt, + ) + : null, + ), + InputChip( + key: const ValueKey('create-task-due-custom'), + avatar: const Icon(LucideIcons.calendarClock, size: 16), + label: Text(isCustom ? _formatDate(selected!) : 'Pick a date'), + selected: isCustom, + onPressed: enabled ? onPickDate : null, + ), + ], + ), + ], + ); + } +} + +/// `2026-08-24`-style label — unambiguous, and no `intl` locale to thread here. +String _formatDate(DateTime value) { + final month = value.month.toString().padLeft(2, '0'); + final day = value.day.toString().padLeft(2, '0'); + return '${value.year}-$month-$day'; +} + +class _SelectedCheck extends StatelessWidget { + const _SelectedCheck({required this.selected}); + + final bool selected; + + @override + Widget build(BuildContext context) { + if (!selected) return const SizedBox.shrink(); + return Icon(LucideIcons.check, size: 18, color: context.colors.primary); + } +} + +/// Inline helper or error line under a field. +class _FieldMessage extends StatelessWidget { + const _FieldMessage(this.message, {this.isError = false}); + + final String message; + final bool isError; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: Grid.half, left: Grid.half), + child: Text( + message, + style: context.textTheme.bodySmall?.copyWith( + color: isError + ? context.colors.error + : context.colors.onSurfaceVariant, + ), + ), + ); + } +} + +/// Bordered, borderless-inside text field matching `_ManageChannelTextField`. +class _TaskField extends StatelessWidget { + const _TaskField({ + required this.fieldKey, + required this.controller, + required this.enabled, + required this.hintText, + required this.textInputAction, + this.minLines = 1, + this.maxLines = 1, + }); + + final Key fieldKey; + final TextEditingController controller; + final bool enabled; + final String hintText; + final TextInputAction textInputAction; + final int minLines; + final int maxLines; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.8), + ), + borderRadius: BorderRadius.circular(Radii.card), + ), + child: Semantics( + label: hintText, + textField: true, + child: TextField( + key: fieldKey, + controller: controller, + enabled: enabled, + minLines: minLines, + maxLines: maxLines, + style: context.textTheme.bodyLarge, + decoration: InputDecoration( + hintText: hintText, + hintStyle: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.twelve, + ), + ), + textInputAction: textInputAction, + ), + ), + ); + } +} diff --git a/mobile/lib/shared/tasks/task.dart b/mobile/lib/shared/tasks/task.dart new file mode 100644 index 00000000000..23086772fed --- /dev/null +++ b/mobile/lib/shared/tasks/task.dart @@ -0,0 +1,390 @@ +/// Task and task-event models plus the request-payload builders for the +/// relay's `/api/tasks` surface. +/// +/// The wire format is deliberately asymmetric, so the encode and decode paths +/// below are not mirror images: +/// +/// * **Outbound** `due_at` is an RFC 3339 string — `api::tasks` deserializes it +/// into a `chrono::DateTime`. +/// * **Inbound** `due_at`, `created_at`, `updated_at`, `done_at` and +/// `archived_at` are Unix **seconds**, because `task_json` emits +/// `value.timestamp()`. +/// +/// Keep this file free of Flutter and network imports: everything here is a +/// pure function or a value type so it can be unit-tested without a harness. +library; + +import 'package:flutter/foundation.dart'; + +/// Longest title the relay accepts, counted the way it counts. +/// +/// `api::tasks::validate_title` uses `chars().count()` and the table's +/// `CHECK (length(title) BETWEEN 1 AND 200)` counts characters too, so the +/// client-side guard must count Unicode scalar values — `String.runes`, not +/// `String.length` (UTF-16 code units) and not grapheme clusters. +const maxTaskTitleChars = 200; + +/// Number of characters the relay will count in [title]. +int taskTitleLength(String title) => title.runes.length; + +/// Lifecycle state of a task, mirroring `buzz_core::task::TaskStatus`. +enum TaskStatus { + /// Open and unstarted. + todo('todo'), + + /// Being worked on. + inProgress('in_progress'), + + /// Waiting on something else. + blocked('blocked'), + + /// Finished. + done('done'), + + /// Abandoned. + cancelled('cancelled'); + + const TaskStatus(this.wireValue); + + /// The string the relay reads and writes for this status. + final String wireValue; + + /// Parses a relay status string. + /// + /// An unrecognised value degrades to [TaskStatus.todo] rather than throwing: + /// a client built before a status was added must still render the rest of a + /// task list during a rolling upgrade. + static TaskStatus fromWire(String? raw) { + for (final status in TaskStatus.values) { + if (status.wireValue == raw) return status; + } + return TaskStatus.todo; + } +} + +/// The two task-event actions a client may post directly. +/// +/// `api::tasks::append_task_event` rejects every other action, because +/// lifecycle entries (`created`, `status_changed`, `assigned`, +/// `title_changed`) are derived from the mutation that caused them — accepting +/// one from a caller would let it fabricate a history that never happened. +enum TaskEventAction { + /// A free-text comment. + commented('commented'), + + /// The task's single persisted summary. The relay's + /// `idx_task_events_one_summary_per_task` partial unique index rejects a + /// second one with a 400. + summaryPersisted('summary_persisted'); + + const TaskEventAction(this.wireValue); + + /// The string the relay reads for this action. + final String wireValue; +} + +/// Decodes a relay timestamp field (Unix seconds) into local time. +DateTime? _dateFromSeconds(Object? value) { + if (value is! int) return null; + return DateTime.fromMillisecondsSinceEpoch(value * 1000, isUtc: true); +} + +String? _stringOrNull(Object? value) => value is String ? value : null; + +/// A task as returned by `GET`/`POST`/`PATCH /api/tasks`. +@immutable +class Task { + /// Creates a task record. + const Task({ + required this.id, + required this.title, + required this.status, + required this.priority, + required this.createdAt, + required this.updatedAt, + this.channelId, + this.createdBy, + this.assignee, + this.parentTaskId, + this.body, + this.source, + this.sourceRef, + this.dueAt, + this.doneAt, + this.archivedAt, + }); + + /// Parses one relay task object. + factory Task.fromJson(Map json) { + final id = _stringOrNull(json['id']); + final title = _stringOrNull(json['title']); + if (id == null || title == null) { + throw const FormatException('relay returned a task without id or title'); + } + return Task( + id: id, + title: title, + status: TaskStatus.fromWire(_stringOrNull(json['status'])), + priority: json['priority'] is int ? json['priority'] as int : 0, + createdAt: _dateFromSeconds(json['created_at']) ?? DateTime.now().toUtc(), + updatedAt: _dateFromSeconds(json['updated_at']) ?? DateTime.now().toUtc(), + channelId: _stringOrNull(json['channel_id']), + createdBy: _stringOrNull(json['created_by']), + assignee: _stringOrNull(json['assignee']), + parentTaskId: _stringOrNull(json['parent_task_id']), + body: _stringOrNull(json['body']), + source: _stringOrNull(json['source']), + sourceRef: _stringOrNull(json['source_ref']), + dueAt: _dateFromSeconds(json['due_at']), + doneAt: _dateFromSeconds(json['done_at']), + archivedAt: _dateFromSeconds(json['archived_at']), + ); + } + + /// Task id, unique within its community. + final String id; + + /// Single-line summary of the work. + final String title; + + /// Lifecycle state. + final TaskStatus status; + + /// Higher sorts first in the relay's list order. + final int priority; + + /// When the task was opened. + final DateTime createdAt; + + /// When the task last changed. + final DateTime updatedAt; + + /// Channel this task is scoped to, or null for a community-wide task. + final String? channelId; + + /// Hex pubkey of the author. + final String? createdBy; + + /// Hex pubkey of the assignee. + final String? assignee; + + /// Parent task id for a subtask. + final String? parentTaskId; + + /// Optional Markdown detail. + final String? body; + + /// Harness or client that opened the task. + final String? source; + + /// Originating external reference — for a task opened from a thread, the + /// event id of the message it came from. + final String? sourceRef; + + /// When the work is due. + final DateTime? dueAt; + + /// When the task was completed. + final DateTime? doneAt; + + /// When the task was archived. + final DateTime? archivedAt; +} + +/// One entry in a task's history. +@immutable +class TaskEvent { + /// Creates a task-event record. + const TaskEvent({ + required this.id, + required this.taskId, + required this.action, + required this.createdAt, + this.actor, + this.fromStatus, + this.toStatus, + this.body, + }); + + /// Parses one relay task-event object. + factory TaskEvent.fromJson(Map json) { + final taskId = _stringOrNull(json['task_id']); + final action = _stringOrNull(json['action']); + if (taskId == null || action == null) { + throw const FormatException('relay returned a malformed task event'); + } + return TaskEvent( + id: json['id'] is int ? json['id'] as int : 0, + taskId: taskId, + action: action, + createdAt: _dateFromSeconds(json['created_at']) ?? DateTime.now().toUtc(), + actor: _stringOrNull(json['actor']), + fromStatus: _stringOrNull(json['from_status']), + toStatus: _stringOrNull(json['to_status']), + body: _stringOrNull(json['body']), + ); + } + + /// Monotonic event id. + final int id; + + /// The task this entry belongs to. + final String taskId; + + /// Raw action string. + /// + /// Kept as text rather than an enum because `task_events.action` is + /// deliberately unconstrained `TEXT` — a new harness must be able to write a + /// new action without a schema migration, and this client must not choke on + /// one it has never seen. + final String action; + + /// When the entry was written. + final DateTime createdAt; + + /// Hex pubkey of whoever caused the entry. + final String? actor; + + /// Status before a transition. + final String? fromStatus; + + /// Status after a transition. + final String? toStatus; + + /// Free text — a comment, or a persisted summary. + final String? body; + + /// Whether this entry is the task's persisted summary. + bool get isSummary => action == TaskEventAction.summaryPersisted.wireValue; +} + +/// A task plus its full event history, as returned by `GET /api/tasks/{id}`. +@immutable +class TaskDetail { + /// Pairs a task with its history. + const TaskDetail({required this.task, required this.events}); + + /// The task. + final Task task; + + /// Its history, oldest first. + final List events; + + /// The persisted summary entry, if one exists. + /// + /// At most one can exist per task — the relay enforces it with a partial + /// unique index — so callers can use this to decide between "persist" and + /// "already summarized". + TaskEvent? get summary { + for (final event in events) { + if (event.isSummary) return event; + } + return null; + } +} + +/// Builds the JSON body for `POST /api/tasks`. +/// +/// Null and blank optional fields are omitted rather than sent as `null`, so +/// the relay's own defaults apply. +/// +/// Throws [ArgumentError] when [title] is blank or longer than +/// [maxTaskTitleChars]: the same rejection `validate_title` would return as a +/// 400, raised locally so the sheet can show it without a round trip. +Map buildCreateTaskPayload({ + required String title, + String? body, + String? channelId, + String? sourceRef, + String? assignee, + int? priority, + DateTime? dueAt, + String source = 'mobile', +}) { + final trimmedTitle = title.trim(); + if (trimmedTitle.isEmpty) { + throw ArgumentError.value(title, 'title', 'must not be empty'); + } + if (taskTitleLength(trimmedTitle) > maxTaskTitleChars) { + throw ArgumentError.value( + title, + 'title', + 'must be at most $maxTaskTitleChars characters', + ); + } + final trimmedBody = body?.trim(); + return { + 'title': trimmedTitle, + if (trimmedBody != null && trimmedBody.isNotEmpty) 'body': trimmedBody, + 'channel_id': ?channelId, + 'source_ref': ?sourceRef, + 'assignee': ?assignee, + 'priority': ?priority, + // RFC 3339 in UTC: the relay parses this into `DateTime` and only + // echoes Unix seconds back, so the outbound shape is not the inbound one. + if (dueAt != null) 'due_at': dueAt.toUtc().toIso8601String(), + 'source': source, + }; +} + +/// Resolves the `@handle` labels for a channel's agents. +/// +/// Precedence matches the mention pipeline: the agent's own profile display +/// name, then the relay agent directory's, then the first 8 hex characters of +/// its pubkey. All three lookups are lowercase-keyed. +/// +/// The result is sorted case-insensitively because [agentPubkeys] arrives as a +/// `Set` — iteration order there is not part of its contract, and an unsorted +/// result would make the composed body unstable between rebuilds. +List resolveAgentHandles({ + required Iterable agentPubkeys, + required Map profileNames, + required Map directoryNames, +}) { + final handles = []; + for (final rawPubkey in agentPubkeys) { + final pubkey = rawPubkey.toLowerCase(); + final profileName = profileNames[pubkey]?.trim(); + final directoryName = directoryNames[pubkey]?.trim(); + final handle = switch ((profileName, directoryName)) { + (final String name, _) when name.isNotEmpty => name, + (_, final String name) when name.isNotEmpty => name, + _ => pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey, + }; + if (handle.isNotEmpty && !handles.contains(handle)) handles.add(handle); + } + handles.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return handles; +} + +/// Combines an optional [body] with an `@`-mention line for [agentHandles]. +/// +/// Returns null when there is nothing to send, so the caller can omit `body` +/// from the payload entirely rather than posting an empty string. +/// +/// The mention line leads because it is the addressing, not the detail: an +/// agent reading the task should see who it is for on the first line. +String? composeTaskBody({String? body, List agentHandles = const []}) { + final trimmedBody = body?.trim() ?? ''; + final mentions = [ + for (final handle in agentHandles) + if (handle.trim().isNotEmpty) '@${handle.trim()}', + ]; + if (mentions.isEmpty) return trimmedBody.isEmpty ? null : trimmedBody; + final mentionLine = mentions.join(' '); + return trimmedBody.isEmpty ? mentionLine : '$mentionLine\n\n$trimmedBody'; +} + +/// Builds the JSON body for `POST /api/tasks/{id}/events`. +/// +/// Throws [ArgumentError] for a blank body, which the relay rejects with a 400. +Map buildTaskEventPayload({ + required TaskEventAction action, + required String body, +}) { + final trimmed = body.trim(); + if (trimmed.isEmpty) { + throw ArgumentError.value(body, 'body', 'must not be empty'); + } + return {'action': action.wireValue, 'body': trimmed}; +} diff --git a/mobile/lib/shared/tasks/task_due_presets.dart b/mobile/lib/shared/tasks/task_due_presets.dart new file mode 100644 index 00000000000..079581774bb --- /dev/null +++ b/mobile/lib/shared/tasks/task_due_presets.dart @@ -0,0 +1,65 @@ +/// Quick due-date presets for the "New task" sheet. +/// +/// Deliberately built on [nextDayAt9am] and [daysUntilNextMonday] from the +/// reminder presets rather than re-deriving the arithmetic: those two carry the +/// "always strictly in the future" guarantee and the repo's definition of +/// "next Monday", and a second copy would drift from it. +/// +/// Task due dates are days, not minutes, so the labels are coarser than the +/// reminder ones ("Tomorrow at 9am", not "In 30 minutes"). +library; + +import 'package:flutter/foundation.dart'; + +import '../reminders/reminder_time_presets.dart'; + +/// The hour a "later today" task is due, in local time. +const _endOfWorkdayHour = 17; + +/// A labelled due-date shortcut. +@immutable +class TaskDuePreset { + /// Pairs a user-facing [label] with the instant it resolves to. + const TaskDuePreset({required this.label, required this.dueAt}); + + /// Text shown in the sheet. + final String label; + + /// The resolved due instant, in local time. + final DateTime dueAt; +} + +DateTime _fromSeconds(int seconds) => + DateTime.fromMillisecondsSinceEpoch(seconds * 1000); + +/// Builds the presets offered at the moment the sheet opens. +/// +/// "Today at 5pm" is omitted rather than rolled forward once the hour has +/// passed — silently turning it into tomorrow would make the label lie. +List taskDuePresets({DateTime? now}) { + final current = now ?? DateTime.now(); + final endOfToday = DateTime( + current.year, + current.month, + current.day, + _endOfWorkdayHour, + ); + return [ + if (endOfToday.isAfter(current)) + TaskDuePreset(label: 'Today at 5pm', dueAt: endOfToday), + TaskDuePreset( + label: 'Tomorrow at 9am', + dueAt: _fromSeconds(nextDayAt9am(1, now: current)), + ), + TaskDuePreset( + label: 'Next Monday at 9am', + dueAt: _fromSeconds( + nextDayAt9am(daysUntilNextMonday(current), now: current), + ), + ), + TaskDuePreset( + label: 'In a week', + dueAt: _fromSeconds(nextDayAt9am(7, now: current)), + ), + ]; +} diff --git a/mobile/lib/shared/tasks/tasks_api.dart b/mobile/lib/shared/tasks/tasks_api.dart new file mode 100644 index 00000000000..19df83497fd --- /dev/null +++ b/mobile/lib/shared/tasks/tasks_api.dart @@ -0,0 +1,255 @@ +/// Authenticated client for the relay's `/api/tasks` routes. +/// +/// Follows `RelayCommunityInviteActions` (`features/invites/` +/// `invite_create_provider.dart`), the app's existing NIP-98 REST caller: +/// resolve `baseUrl`/`nsec` from [relayConfigProvider], sign each request with +/// [buildNip98AuthHeader], and read the relay's `{"error": …}` body for a +/// message worth showing. +/// +/// The routes are host-derived and tenant-scoped — there is no community id in +/// any path. `bind_community` resolves the community from the `Host` header and +/// NIP-98 binds the signature to that same host, so the community is decided by +/// which relay `baseUrl` points at. +library; + +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../relay/relay.dart'; +import 'task.dart'; + +/// Default per-request timeout, matching the invite minting call. +const _taskRequestTimeout = Duration(seconds: 15); + +/// A non-2xx response from `/api/tasks`, carrying the relay's own message. +/// +/// [toString] returns the bare message so it can be shown to a user directly; +/// `Exception.toString()` would prefix it with `Exception: `. +class TaskApiException implements Exception { + /// Wraps a relay error [message] observed with [statusCode]. + TaskApiException(this.statusCode, this.message); + + /// HTTP status the relay returned. + final int statusCode; + + /// Relay-supplied message, or a synthesized `HTTP ` fallback. + final String message; + + @override + String toString() => message; +} + +/// Reads and writes tasks over the relay's NIP-98-authenticated REST surface. +class TasksApi { + /// Binds a client to one relay and signing identity. + TasksApi({ + required http.Client httpClient, + required String baseUrl, + required String? nsec, + }) : _httpClient = httpClient, + _baseUrl = baseUrl, + _nsec = nsec; + + final http.Client _httpClient; + final String _baseUrl; + final String? _nsec; + + /// Whether this client has a signing key. Without one every call would throw + /// from [buildNip98AuthHeader], so callers gate their UI on this instead. + bool get canSign => _nsec != null && _nsec.isNotEmpty; + + /// `POST /api/tasks` — opens a task and returns it. + Future createTask({ + required String title, + String? body, + String? channelId, + String? sourceRef, + String? assignee, + int? priority, + DateTime? dueAt, + String source = 'mobile', + }) async { + final payload = buildCreateTaskPayload( + title: title, + body: body, + channelId: channelId, + sourceRef: sourceRef, + assignee: assignee, + priority: priority, + dueAt: dueAt, + source: source, + ); + final decoded = await _send('POST', _uri('/api/tasks'), payload); + return Task.fromJson(_asObject(decoded)); + } + + /// `GET /api/tasks` — this community's tasks, newest-modified first. + /// + /// Channel-bound tasks the caller cannot see are filtered out by the relay + /// rather than failing the page. + Future> listTasks({ + TaskStatus? status, + String? channelId, + String? assignee, + int? limit, + }) async { + final query = { + if (status != null) 'status': status.wireValue, + 'channel': ?channelId, + 'assignee': ?assignee, + if (limit != null) 'limit': '$limit', + }; + final decoded = await _send('GET', _uri('/api/tasks', query), null); + final tasks = _asObject(decoded)['tasks']; + if (tasks is! List) { + throw const FormatException('relay returned a malformed task list'); + } + return [ + for (final task in tasks) + if (task is Map) Task.fromJson(task), + ]; + } + + /// `GET /api/tasks/{id}` — one task plus its full event history. + Future getTask(String taskId) async { + final decoded = _asObject( + await _send('GET', _uri('/api/tasks/$taskId'), null), + ); + final task = decoded['task']; + if (task is! Map) { + throw const FormatException('relay returned a malformed task'); + } + final events = decoded['events']; + return TaskDetail( + task: Task.fromJson(task), + events: [ + if (events is List) + for (final event in events) + if (event is Map) TaskEvent.fromJson(event), + ], + ); + } + + /// `PATCH /api/tasks/{id}` — updates a task and returns it. + /// + /// Only the fields passed here are sent, because the relay treats an absent + /// key as "leave alone" and an explicit `null` as "clear". Passing nothing is + /// rejected with a 400, so callers must change at least one field. + Future updateTask( + String taskId, { + TaskStatus? status, + String? title, + int? priority, + }) async { + final payload = { + if (status != null) 'status': status.wireValue, + if (title != null) 'title': title.trim(), + 'priority': ?priority, + }; + final decoded = await _send('PATCH', _uri('/api/tasks/$taskId'), payload); + return Task.fromJson(_asObject(decoded)); + } + + /// `POST /api/tasks/{id}/events` — appends a comment or the task's summary. + /// + /// A second [TaskEventAction.summaryPersisted] for the same task comes back + /// as a 400 (`already has a persisted summary`) from the relay's partial + /// unique index, surfaced here as a [TaskApiException]. + Future appendTaskEvent( + String taskId, { + required TaskEventAction action, + required String body, + }) async { + final payload = buildTaskEventPayload(action: action, body: body); + final decoded = await _send( + 'POST', + _uri('/api/tasks/$taskId/events'), + payload, + ); + return TaskEvent.fromJson(_asObject(decoded)); + } + + Uri _uri(String path, [Map? query]) { + final base = Uri.parse(_baseUrl).resolve(path); + if (query == null || query.isEmpty) return base; + return base.replace(queryParameters: query); + } + + /// Signs, sends, and decodes one request. + /// + /// The NIP-98 `u` tag must carry the full URL including the query string: + /// the relay rebuilds its expected URL from the path plus the raw query it + /// received, so signing the bare path would fail verification on any + /// filtered `GET`. + Future _send(String method, Uri url, Map? payload) { + final bodyBytes = payload == null + ? const [] + : utf8.encode(jsonEncode(payload)); + final request = http.Request(method, url) + ..headers['Authorization'] = buildNip98AuthHeader( + method: method, + url: url.toString(), + bodyBytes: bodyBytes, + nsec: _nsec, + ); + if (payload != null) { + request.headers['Content-Type'] = 'application/json'; + request.bodyBytes = bodyBytes; + } + return _httpClient + .send(request) + .then(http.Response.fromStream) + .timeout(_taskRequestTimeout) + .then(_decode); + } + + Object? _decode(http.Response response) { + final dynamic decoded; + try { + decoded = response.body.isEmpty ? null : jsonDecode(response.body); + } on FormatException { + throw TaskApiException( + response.statusCode, + 'The relay returned an unreadable task response.', + ); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + final rawMessage = decoded is Map + ? decoded['error'] + : null; + throw TaskApiException( + response.statusCode, + rawMessage is String && rawMessage.trim().isNotEmpty + ? rawMessage + : 'HTTP ${response.statusCode}', + ); + } + return decoded; + } + + Map _asObject(Object? decoded) { + if (decoded is! Map) { + throw const FormatException('relay returned a malformed task response'); + } + return decoded; + } +} + +/// Supplies the HTTP client used for task requests. +final tasksHttpClientProvider = Provider((ref) { + final client = http.Client(); + ref.onDispose(client.close); + return client; +}); + +/// Supplies a task client bound to the active community and signing identity. +final tasksApiProvider = Provider((ref) { + final config = ref.watch(relayConfigProvider); + return TasksApi( + httpClient: ref.watch(tasksHttpClientProvider), + baseUrl: config.baseUrl, + nsec: config.nsec, + ); +}); diff --git a/mobile/lib/shared/tasks/thread_summary.dart b/mobile/lib/shared/tasks/thread_summary.dart new file mode 100644 index 00000000000..a0330b3582b --- /dev/null +++ b/mobile/lib/shared/tasks/thread_summary.dart @@ -0,0 +1,282 @@ +/// Client-side thread summarization. +/// +/// **This is an extractive digest, not a generative one.** The mobile app has +/// no LLM: `pubspec.yaml` declares no model SDK, nothing under `lib/` talks to +/// a completion endpoint, and the only agent output mobile consumes is the +/// read-only kind:24200 observer stream. So rather than pretend, [summarizeThread] +/// selects and reorganizes the thread's own most load-bearing lines into a +/// Markdown digest. +/// +/// Everything here is pure and deterministic — same input, same output, no +/// clock, no network, no `Random`. That is what makes it unit-testable, and it +/// is also what makes the digest safe to persist as a task's +/// `summary_persisted` event: two clients summarizing the same thread agree. +library; + +import 'package:flutter/foundation.dart'; + +/// One message handed to [summarizeThread]. +@immutable +class ThreadMessageDigest { + /// Pairs a display [author] with their message [text]. + const ThreadMessageDigest({required this.author, required this.text}); + + /// Display name of whoever wrote the message. + final String author; + + /// Raw message body, Markdown included. + final String text; +} + +/// Words that mark a line as carrying a decision, commitment, or blocker. +/// +/// Matched as substrings against lowercased text so inflections are covered by +/// one stem (`decid` catches "decide", "decided", "deciding"). +const _decisionMarkers = [ + 'decid', + 'agree', + 'let us', + "let's", + 'we should', + 'we will', + "we'll", + 'i will', + "i'll", + 'plan is', + 'next step', + 'action item', + 'todo', + 'to do', + 'blocked', + 'blocker', + 'ship', + 'deadline', + 'due ', + 'owner', + 'assign', + 'merged', + 'fixed', + 'root cause', + 'conclusion', +]; + +final _urlPattern = RegExp(r'https?://[^\s<>()\[\]]+'); +final _fencedCodePattern = RegExp(r'```[\s\S]*?```'); +final _inlineCodePattern = RegExp('`[^`]*`'); +final _whitespacePattern = RegExp(r'\s+'); +final _trailingPunctuation = RegExp(r'[.,;:!)\]}>"’”]+$'); + +/// Builds a Markdown digest of [messages]. +/// +/// Returns a "nothing to summarize" line rather than an empty string for an +/// empty or all-blank thread, so a caller can always show the result. +/// +/// [maxHighlights] caps the statement bullets and [maxOpenQuestions] the +/// question bullets; [maxCharsPerLine] is where a quoted line is elided. +String summarizeThread( + List messages, { + int maxHighlights = 5, + int maxOpenQuestions = 3, + int maxCharsPerLine = 180, +}) { + final entries = <_ScoredLine>[]; + final authors = []; + final links = []; + + for (var index = 0; index < messages.length; index++) { + final message = messages[index]; + final author = _cleanAuthor(message.author); + final text = _condense(message.text); + if (text.isEmpty) continue; + + if (!authors.contains(author)) authors.add(author); + for (final url in _extractLinks(message.text)) { + if (!links.contains(url)) links.add(url); + } + entries.add( + _ScoredLine( + order: index, + author: author, + text: text, + isQuestion: text.endsWith('?'), + score: _score(text), + ), + ); + } + + if (entries.isEmpty) return 'No messages to summarize yet.'; + + final questions = _pick( + entries.where((entry) => entry.isQuestion), + maxOpenQuestions, + ); + final statements = _pick( + entries.where((entry) => !entry.isQuestion), + maxHighlights, + ); + + final lines = [ + '## Thread summary', + '', + _headline(messageCount: entries.length, authors: authors), + ]; + + // A thread of only questions has no statements to highlight; a thread with + // no questions has no open-questions section. Both are normal, so each + // section is emitted only when it has content. + if (statements.isNotEmpty) { + lines + ..add('') + ..add('**Highlights**') + ..add(''); + for (final entry in statements) { + lines.add('- ${entry.bullet(maxCharsPerLine)}'); + } + } + + if (questions.isNotEmpty) { + lines + ..add('') + ..add('**Open questions**') + ..add(''); + for (final entry in questions) { + lines.add('- ${entry.bullet(maxCharsPerLine)}'); + } + } + + if (links.isNotEmpty) { + lines + ..add('') + ..add('**Links**') + ..add(''); + for (final link in links) { + lines.add('- $link'); + } + } + + return lines.join('\n'); +} + +/// Selects the [limit] highest-scoring lines, then restores thread order. +/// +/// Two-stage on purpose: relevance decides *which* lines survive, chronology +/// decides how they read. Ties break toward the earlier message, so the result +/// never depends on iteration order. +List<_ScoredLine> _pick(Iterable<_ScoredLine> candidates, int limit) { + if (limit <= 0) return const []; + final ranked = candidates.toList() + ..sort((a, b) { + final byScore = b.score.compareTo(a.score); + return byScore != 0 ? byScore : a.order.compareTo(b.order); + }); + final selected = ranked.take(limit).toList() + ..sort((a, b) => a.order.compareTo(b.order)); + return selected; +} + +String _headline({required int messageCount, required List authors}) { + final messageLabel = messageCount == 1 + ? '1 message' + : '$messageCount messages'; + return '_$messageLabel from ${_formatAuthors(authors)}._'; +} + +/// Renders an author list as prose, collapsing long rosters. +String _formatAuthors(List authors) { + if (authors.isEmpty) return 'nobody'; + if (authors.length == 1) return authors.single; + if (authors.length == 2) return '${authors[0]} and ${authors[1]}'; + if (authors.length <= 4) { + final head = authors.sublist(0, authors.length - 1).join(', '); + return '$head and ${authors.last}'; + } + final remaining = authors.length - 3; + final others = remaining == 1 ? '1 other' : '$remaining others'; + return '${authors.take(3).join(', ')} and $others'; +} + +/// Scores a line by how much it looks like the point of the thread. +int _score(String text) { + final lowered = text.toLowerCase(); + var score = 0; + for (final marker in _decisionMarkers) { + if (lowered.contains(marker)) score += 2; + } + if (_urlPattern.hasMatch(text)) score += 2; + // Longer lines carry more, but only up to a point — a wall of text is not + // three times the signal of a sentence. + final words = text.split(' ').where((word) => word.isNotEmpty).length; + score += (words ~/ 8).clamp(0, 3); + return score; +} + +/// Collapses a message body to a single quotable line. +/// +/// Code is replaced rather than quoted: a fenced block would break the bullet +/// list it is being inlined into, and its contents are rarely the summary. +String _condense(String raw) { + return raw + .replaceAll(_fencedCodePattern, ' [code] ') + .replaceAll(_inlineCodePattern, ' [code] ') + .replaceAll(_whitespacePattern, ' ') + .trim(); +} + +String _cleanAuthor(String raw) { + final trimmed = raw.replaceAll(_whitespacePattern, ' ').trim(); + return trimmed.isEmpty ? 'Unknown' : trimmed; +} + +List _extractLinks(String raw) { + return [ + for (final match in _urlPattern.allMatches(raw)) + match.group(0)!.replaceFirst(_trailingPunctuation, ''), + ]; +} + +/// Longest title [threadTaskTitle] will produce, comfortably inside the +/// relay's 200-character ceiling. +const _maxDerivedTitleChars = 120; + +/// Derives a task title from the thread's opening message. +/// +/// Used when a summary is saved to a brand-new task: the thread's first line is +/// what a human would have typed as the title anyway. +String threadTaskTitle(List messages) { + for (final message in messages) { + final text = _condense(message.text); + if (text.isNotEmpty) return elideSummaryLine(text, _maxDerivedTitleChars); + } + return 'Thread summary'; +} + +/// Elides [text] at [maxChars] on a word boundary when possible. +String elideSummaryLine(String text, int maxChars) { + if (maxChars <= 1 || text.length <= maxChars) return text; + final clipped = text.substring(0, maxChars - 1); + final lastSpace = clipped.lastIndexOf(' '); + // Only honour a word boundary that is not pathologically early, otherwise a + // single very long token would collapse the line to almost nothing. + final cut = lastSpace > maxChars ~/ 2 ? lastSpace : clipped.length; + return '${clipped.substring(0, cut).trimRight()}…'; +} + +@immutable +class _ScoredLine { + const _ScoredLine({ + required this.order, + required this.author, + required this.text, + required this.isQuestion, + required this.score, + }); + + final int order; + final String author; + final String text; + final bool isQuestion; + final int score; + + String bullet(int maxChars) => + '**$author:** ${elideSummaryLine(text, maxChars)}'; +} diff --git a/mobile/lib/shared/tasks/thread_summary_sheet.dart b/mobile/lib/shared/tasks/thread_summary_sheet.dart new file mode 100644 index 00000000000..d254a4441e7 --- /dev/null +++ b/mobile/lib/shared/tasks/thread_summary_sheet.dart @@ -0,0 +1,288 @@ +/// The "Summarize thread" bottom sheet. +/// +/// Shows the digest [summarizeThread] produced, and lets the reader persist it +/// against a task. The relay stores at most one `summary_persisted` event per +/// task (`idx_task_events_one_summary_per_task`), so a second attempt on the +/// same task comes back as a 400 and is surfaced verbatim rather than retried. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gpt_markdown/gpt_markdown.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../clipboard_utils.dart'; +import '../theme/theme.dart'; +import '../widgets/app_list.dart'; +import '../widgets/app_list_card.dart'; +import '../widgets/buzz_loading_indicator.dart'; +import '../widgets/modal_presentation.dart'; +import '../widgets/sheet_divider.dart'; +import 'task.dart'; +import 'thread_summary.dart'; +import 'tasks_api.dart'; + +/// Opens the summary sheet for an already-collected thread. +/// +/// [messages] is the transcript in thread order; the caller owns collecting it, +/// because only the caller knows which view's messages are on screen. +Future showThreadSummarySheet({ + required BuildContext context, + required WidgetRef ref, + required String channelId, + required List messages, +}) { + return showBuzzModalBottomSheet( + context: context, + title: 'Thread summary', + isScrollControlled: true, + showDragHandle: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * 0.9, + ), + builder: (_) => + _ThreadSummarySheet(channelId: channelId, messages: messages), + ); +} + +class _ThreadSummarySheet extends HookConsumerWidget { + const _ThreadSummarySheet({required this.channelId, required this.messages}); + + final String channelId; + final List messages; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // Pure and deterministic, so it is memoized on the transcript rather than + // recomputed on every rebuild of the sheet. + final summary = useMemoized(() => summarizeThread(messages), [messages]); + final isSaving = useState(false); + final actionError = useState(null); + final savedTo = useState(null); + + Future save() async { + final messenger = ScaffoldMessenger.of(context); + final target = await showSummaryTaskPicker( + context: context, + ref: ref, + channelId: channelId, + ); + if (target == null || !context.mounted) return; + + isSaving.value = true; + actionError.value = null; + try { + final api = ref.read(tasksApiProvider); + final task = + target.task ?? + await api.createTask( + title: threadTaskTitle(messages), + channelId: channelId, + ); + await api.appendTaskEvent( + task.id, + action: TaskEventAction.summaryPersisted, + body: summary, + ); + messenger.showSnackBar( + const SnackBar(content: Text('Summary saved to task')), + ); + // Guarded because this sheet stays dismissible while the two writes + // are in flight; the snackbar above goes through the messenger + // resolved before them, so it lands either way. + if (context.mounted) savedTo.value = task.title; + } catch (error) { + if (context.mounted) actionError.value = error.toString(); + } finally { + if (context.mounted) isSaving.value = false; + } + } + + return Padding( + padding: const EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, Grid.xs), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + child: SingleChildScrollView( + key: const ValueKey('thread-summary-body'), + child: GptMarkdown( + summary, + style: context.textTheme.bodyMedium, + ), + ), + ), + if (savedTo.value case final title?) + Padding( + padding: const EdgeInsets.only(top: Grid.xxs), + child: Text( + 'Saved to “$title”.', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + if (actionError.value case final error?) + Padding( + padding: const EdgeInsets.only(top: Grid.xxs), + child: Text( + error, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ), + const SheetDivider(), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + key: const ValueKey('thread-summary-copy'), + onPressed: () => copyToClipboard( + context, + summary, + message: 'Summary copied to clipboard', + ), + icon: const Icon(LucideIcons.copy, size: 18), + label: const Text('Copy'), + ), + ), + const SizedBox(width: Grid.half), + Expanded( + child: FilledButton.icon( + key: const ValueKey('thread-summary-save'), + // Re-saving to the same task cannot succeed, so the action + // retires once this sheet has persisted a summary. + onPressed: isSaving.value || savedTo.value != null + ? null + : save, + icon: isSaving.value + ? const BuzzLoadingIndicator( + size: 16, + semanticLabel: 'Saving summary', + ) + : const Icon(LucideIcons.listTodo, size: 18), + label: Text(isSaving.value ? 'Saving…' : 'Save to task'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +/// Where a summary should be persisted: an existing [task], or a new one. +@immutable +class SummaryTaskTarget { + /// Names an existing task, or a new one when [task] is null. + const SummaryTaskTarget(this.task); + + /// The chosen task, or null to open a fresh one for this thread. + final Task? task; +} + +/// Asks which task a summary belongs to. +/// +/// Lists this channel's tasks and offers a new one. Whether a listed task +/// already holds a summary is not shown, because knowing would cost one +/// `GET /api/tasks/{id}` per row; the relay's 400 on the second write is the +/// authority, and it is reported as-is. +Future showSummaryTaskPicker({ + required BuildContext context, + required WidgetRef ref, + required String channelId, +}) { + return showBuzzModalBottomSheet( + context: context, + title: 'Save summary to', + isScrollControlled: true, + showDragHandle: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), + builder: (_) => _SummaryTaskPicker(channelId: channelId), + ); +} + +class _SummaryTaskPicker extends HookConsumerWidget { + const _SummaryTaskPicker({required this.channelId}); + + final String channelId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final api = ref.read(tasksApiProvider); + final tasks = useMemoized( + () => api.listTasks(channelId: channelId, limit: 20), + [channelId], + ); + final snapshot = useFuture(tasks); + + return Padding( + padding: const EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, Grid.xs), + child: SafeArea( + top: false, + child: ListView( + shrinkWrap: true, + children: [ + AppListCard( + children: [ + AppListRow( + key: const ValueKey('summary-target-new-task'), + icon: LucideIcons.plus, + title: 'New task from this thread', + onTap: () => + Navigator.of(context).pop(const SummaryTaskTarget(null)), + ), + ], + ), + if (snapshot.connectionState == ConnectionState.waiting) + const Padding( + padding: EdgeInsets.all(Grid.xs), + child: Center( + child: BuzzLoadingIndicator( + size: 32, + semanticLabel: 'Loading tasks', + ), + ), + ) + else if (snapshot.error != null) + Padding( + padding: const EdgeInsets.all(Grid.xs), + child: Text( + '${snapshot.error}', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.error, + ), + ), + ) + else if (snapshot.data case final loaded? when loaded.isNotEmpty) + AppListCard( + label: 'Existing tasks', + children: [ + for (final task in loaded) + AppListRow( + key: ValueKey('summary-target-${task.id}'), + icon: LucideIcons.listTodo, + title: task.title, + subtitle: task.status.wireValue, + onTap: () => + Navigator.of(context).pop(SummaryTaskTarget(task)), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index d1e2e633840..d0517a7d491 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -4271,6 +4271,97 @@ void main() { }); }); + group('ComposeBar task action', () { + testWidgets('sits alongside the existing composer actions', (tester) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + await _expandComposer(tester); + + expect(find.byIcon(LucideIcons.listTodo), findsOneWidget); + expect(find.byTooltip('Create task'), findsOneWidget); + // The four original actions keep their places; the task action is added, + // not substituted. + expect(find.byIcon(LucideIcons.atSign), findsOneWidget); + expect(find.byIcon(LucideIcons.hash), findsOneWidget); + expect(find.byIcon(LucideIcons.smilePlus), findsOneWidget); + expect(find.byIcon(LucideIcons.aLargeSmall), findsOneWidget); + }); + + testWidgets('is hidden until the composer is expanded', (tester) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + // Collapsed, the bar carries only the attachment trigger, the draft + // preview and send — the action row is behind the expansion. + expect(find.byTooltip('Create task').hitTestable(), findsNothing); + }); + + testWidgets('opens the New task sheet', (tester) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + await _expandComposer(tester); + + await tester.tap(find.byTooltip('Create task')); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('create-task-title')), findsOneWidget); + expect(find.byKey(const ValueKey('create-task-submit')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('the fifth action still fits a narrow phone', (tester) async { + // Five 36px actions plus the attachment trigger and send button is the + // tightest the row gets; 320dp is the narrowest phone width shipped. + await tester.binding.setSurfaceSize(const Size(320, 640)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + await _expandComposer(tester); + + expect(find.byTooltip('Create task').hitTestable(), findsOneWidget); + // A RenderFlex overflow is reported as a framework exception, so a null + // here is the assertion that the row did not overflow. + expect(tester.takeException(), isNull); + }); + }); + group('findTrigger', () { test('finds @ at start of text', () { expect(findTrigger('@alice', 6, '@', stopAtSpace: false), 0); diff --git a/mobile/test/features/channels/thread_summarize_action_test.dart b/mobile/test/features/channels/thread_summarize_action_test.dart new file mode 100644 index 00000000000..101692563d3 --- /dev/null +++ b/mobile/test/features/channels/thread_summarize_action_test.dart @@ -0,0 +1,80 @@ +import 'package:buzz/features/channels/thread_detail_page.dart'; +import 'package:buzz/features/channels/timeline_message.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:flutter_test/flutter_test.dart'; + +TimelineMessage _message({ + required String id, + required String pubkey, + required String content, + bool isSystem = false, +}) => TimelineMessage( + id: id, + pubkey: pubkey, + createdAt: 1786000000, + content: content, + isSystem: isSystem, +); + +void main() { + group('threadSummaryDigest', () { + test('names authors the way the thread rows name them', () { + final digest = threadSummaryDigest( + [ + _message(id: '1', pubkey: 'ABC123', content: 'first'), + _message(id: '2', pubkey: 'def456', content: 'second'), + ], + profiles: const { + 'abc123': UserProfile(pubkey: 'abc123', displayName: 'Ada'), + }, + ); + + expect(digest.map((entry) => entry.author), ['Ada', 'def456']); + expect(digest.map((entry) => entry.text), ['first', 'second']); + }); + + test('shortens a long pubkey when no profile is cached', () { + final digest = threadSummaryDigest([ + _message( + id: '1', + pubkey: 'aaaaaaaabbbbbbbbccccccccdddddddd', + content: 'hello', + ), + ], profiles: const {}); + expect(digest.single.author, 'aaaaaaaa…'); + }); + + test('drops system rows, which are chrome rather than conversation', () { + final digest = threadSummaryDigest([ + _message(id: '1', pubkey: 'a', content: 'real message'), + _message(id: '2', pubkey: 'a', content: 'joined', isSystem: true), + ], profiles: const {}); + expect(digest.map((entry) => entry.text), ['real message']); + }); + + test('drops blank messages so they cannot pad the digest', () { + final digest = threadSummaryDigest([ + _message(id: '1', pubkey: 'a', content: ' \n '), + _message(id: '2', pubkey: 'a', content: 'kept'), + ], profiles: const {}); + expect(digest.map((entry) => entry.text), ['kept']); + }); + + test('preserves thread order', () { + final digest = threadSummaryDigest([ + _message(id: '1', pubkey: 'a', content: 'head'), + _message(id: '2', pubkey: 'b', content: 'reply one'), + _message(id: '3', pubkey: 'c', content: 'reply two'), + ], profiles: const {}); + expect(digest.map((entry) => entry.text), [ + 'head', + 'reply one', + 'reply two', + ]); + }); + + test('yields an empty digest for a thread with nothing to say', () { + expect(threadSummaryDigest(const [], profiles: const {}), isEmpty); + }); + }); +} diff --git a/mobile/test/shared/tasks/create_task_sheet_test.dart b/mobile/test/shared/tasks/create_task_sheet_test.dart new file mode 100644 index 00000000000..6186da0d761 --- /dev/null +++ b/mobile/test/shared/tasks/create_task_sheet_test.dart @@ -0,0 +1,312 @@ +import 'dart:convert'; + +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/profile/user_cache_provider.dart'; +import 'package:buzz/shared/profile/user_profile.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/tasks/create_task_sheet.dart'; +import 'package:buzz/shared/tasks/tasks_api.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:nostr/nostr.dart' as nostr; + +const _channelId = 'channel-1'; +const _agentPubkey = + 'aa11bb22cc33dd44ee55ff6600112233445566778899aabbccddeeff0'; + +final _titleField = find.byKey(const ValueKey('create-task-title')); +final _bodyField = find.byKey(const ValueKey('create-task-body')); +final _submit = find.byKey(const ValueKey('create-task-submit')); + +late String _nsec; + +class _TestRelayConfig extends RelayConfigNotifier { + _TestRelayConfig({required this.nsec}); + + final String? nsec; + + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example.com', nsec: nsec); +} + +class _FakeUserCache extends UserCacheNotifier { + _FakeUserCache(this.profiles); + + final Map profiles; + + @override + Map build() => profiles; +} + +/// A page with one button that opens the sheet, mirroring how a composer or a +/// message action would invoke it. +class _Harness extends ConsumerWidget { + const _Harness({required this.channelName}); + + final String channelName; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + body: Center( + child: TextButton( + onPressed: () => showCreateTaskSheet( + context: context, + ref: ref, + channelId: _channelId, + channelName: channelName, + sourceEventId: 'event-1', + ), + child: const Text('open'), + ), + ), + ); + } +} + +Widget _app({ + required http.Client client, + String? nsec, + Set agentPubkeys = const {}, + Map directoryNames = const {}, + Map profiles = const {}, + String channelName = 'general', +}) { + return ProviderScope( + overrides: [ + relayConfigProvider.overrideWith( + () => _TestRelayConfig(nsec: nsec ?? _nsec), + ), + tasksHttpClientProvider.overrideWithValue(client), + userCacheProvider.overrideWith(() => _FakeUserCache(profiles)), + channelBotPubkeysProvider( + _channelId, + ).overrideWith((ref) async => agentPubkeys), + agentDirectoryDisplayNamesProvider.overrideWithValue(directoryNames), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: _Harness(channelName: channelName), + ), + ); +} + +Future _openSheet(WidgetTester tester) async { + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); +} + +/// Taps a row inside the sheet's scrolling field area. +/// +/// The form is taller than the 600px test viewport, so a row below the fold is +/// built but off-screen; scrolling it into view first is what a user does too. +Future _tapRow(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pumpAndSettle(); + await tester.tap(finder); + await tester.pump(); +} + +void main() { + setUp(() => _nsec = nostr.Keys.generate().nsec); + + testWidgets('requires a title before it will submit', (tester) async { + final client = http_testing.MockClient( + (request) async => http.Response('{}', 200), + ); + await tester.pumpWidget(_app(client: client)); + await _openSheet(tester); + + expect(find.text('New task'), findsOneWidget); + expect(tester.widget(_submit).onPressed, isNull); + + await tester.enterText(_titleField, 'Ship the relay change'); + await tester.pump(); + + expect(tester.widget(_submit).onPressed, isNotNull); + }); + + testWidgets('posts the typed task and confirms it', (tester) async { + late http.Request captured; + final client = http_testing.MockClient((request) async { + captured = request; + return http.Response( + jsonEncode({ + 'id': 'task-1', + 'title': 'Ship the relay change', + 'status': 'todo', + 'priority': 0, + 'created_at': 1786000000, + 'updated_at': 1786000000, + }), + 200, + ); + }); + + await tester.pumpWidget(_app(client: client)); + await _openSheet(tester); + await tester.enterText(_titleField, 'Ship the relay change'); + await tester.enterText(_bodyField, 'behind a flag'); + await tester.pump(); + await tester.tap(_submit); + await tester.pumpAndSettle(); + + expect(captured.url.path, '/api/tasks'); + expect(jsonDecode(captured.body), { + 'title': 'Ship the relay change', + 'body': 'behind a flag', + 'channel_id': _channelId, + 'source_ref': 'event-1', + 'source': 'mobile', + }); + // The sheet closes and the confirmation lands on the page beneath it. + expect(_titleField, findsNothing); + expect(find.text('Task created'), findsOneWidget); + }); + + testWidgets('scoping to the whole community drops channel_id', ( + tester, + ) async { + late http.Request captured; + final client = http_testing.MockClient((request) async { + captured = request; + return http.Response( + jsonEncode({ + 'id': 'task-1', + 'title': 'Community wide', + 'status': 'todo', + 'priority': 0, + 'created_at': 1786000000, + 'updated_at': 1786000000, + }), + 200, + ); + }); + + await tester.pumpWidget(_app(client: client)); + await _openSheet(tester); + await tester.enterText(_titleField, 'Community wide'); + await tester.pump(); + + expect(find.text('#general'), findsOneWidget); + await _tapRow( + tester, + find.byKey(const ValueKey('create-task-scope-community')), + ); + await tester.tap(_submit); + await tester.pumpAndSettle(); + + expect( + (jsonDecode(captured.body) as Map).containsKey('channel_id'), + isFalse, + ); + expect((jsonDecode(captured.body) as Map)['title'], 'Community wide'); + }); + + testWidgets('mentioning channel agents prefixes the body', (tester) async { + late http.Request captured; + final client = http_testing.MockClient((request) async { + captured = request; + return http.Response( + jsonEncode({ + 'id': 'task-1', + 'title': 'Investigate the drop', + 'status': 'todo', + 'priority': 0, + 'created_at': 1786000000, + 'updated_at': 1786000000, + }), + 200, + ); + }); + + await tester.pumpWidget( + _app( + client: client, + agentPubkeys: const {_agentPubkey}, + directoryNames: const {_agentPubkey: 'Ada'}, + ), + ); + await _openSheet(tester); + await tester.enterText(_titleField, 'Investigate the drop'); + await tester.enterText(_bodyField, 'starts around 03:00'); + await tester.pump(); + + final agentRow = find.byKey(const ValueKey('create-task-assign-agents')); + expect(agentRow, findsOneWidget); + await _tapRow(tester, agentRow); + await tester.tap(_submit); + await tester.pumpAndSettle(); + + expect( + (jsonDecode(captured.body) as Map)['body'], + '@Ada\n\nstarts around 03:00', + ); + }); + + testWidgets('hides the agent row when the channel has no agents', ( + tester, + ) async { + final client = http_testing.MockClient( + (request) async => http.Response('{}', 200), + ); + await tester.pumpWidget(_app(client: client)); + await _openSheet(tester); + + expect( + find.byKey(const ValueKey('create-task-assign-agents')), + findsNothing, + ); + }); + + testWidgets('shows the relay message and keeps the sheet open on failure', ( + tester, + ) async { + final client = http_testing.MockClient( + (request) async => http.Response( + jsonEncode(const {'error': 'title must be at most 200 characters'}), + 400, + ), + ); + + await tester.pumpWidget(_app(client: client)); + await _openSheet(tester); + await tester.enterText(_titleField, 'Ship it'); + await tester.pump(); + await tester.tap(_submit); + await tester.pumpAndSettle(); + + expect(find.text('title must be at most 200 characters'), findsOneWidget); + // The sheet must survive so the typed content is not lost. + expect(_titleField, findsOneWidget); + expect(find.text('Task created'), findsNothing); + }); + + testWidgets('refuses to open without a signing key', (tester) async { + final client = http_testing.MockClient( + (request) async => http.Response('{}', 200), + ); + await tester.pumpWidget(_app(client: client, nsec: '')); + await _openSheet(tester); + + expect(find.text('Sign in to create tasks'), findsOneWidget); + expect(find.text('New task'), findsNothing); + }); + + testWidgets('labels the scope row when the channel has no name', ( + tester, + ) async { + final client = http_testing.MockClient( + (request) async => http.Response('{}', 200), + ); + await tester.pumpWidget(_app(client: client, channelName: '')); + await _openSheet(tester); + + expect(find.text('This conversation'), findsOneWidget); + }); +} diff --git a/mobile/test/shared/tasks/task_due_presets_test.dart b/mobile/test/shared/tasks/task_due_presets_test.dart new file mode 100644 index 00000000000..dc115072b59 --- /dev/null +++ b/mobile/test/shared/tasks/task_due_presets_test.dart @@ -0,0 +1,59 @@ +import 'package:buzz/shared/tasks/task_due_presets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('taskDuePresets', () { + test('offers "Today at 5pm" while the hour is still ahead', () { + final presets = taskDuePresets(now: DateTime(2026, 8, 20, 9)); + expect(presets.first.label, 'Today at 5pm'); + expect(presets.first.dueAt, DateTime(2026, 8, 20, 17)); + }); + + test('drops "Today at 5pm" once it has passed, rather than lying', () { + final presets = taskDuePresets(now: DateTime(2026, 8, 20, 18)); + expect( + presets.map((preset) => preset.label), + isNot(contains('Today at 5pm')), + ); + expect(presets.first.label, 'Tomorrow at 9am'); + }); + + test('every preset is strictly in the future', () { + for (final now in [ + DateTime(2026, 8, 17, 8, 59), // Monday morning + DateTime(2026, 8, 17, 9, 1), // Monday just after 9am + DateTime(2026, 8, 22, 23, 59), // Saturday night + ]) { + for (final preset in taskDuePresets(now: now)) { + expect( + preset.dueAt.isAfter(now), + isTrue, + reason: '${preset.label} from $now resolved to ${preset.dueAt}', + ); + } + } + }); + + test('"Next Monday" means the following week when today is Monday', () { + final now = DateTime(2026, 8, 17, 10); // a Monday + expect(now.weekday, DateTime.monday); + final monday = taskDuePresets( + now: now, + ).firstWhere((preset) => preset.label == 'Next Monday at 9am'); + expect(monday.dueAt, DateTime(2026, 8, 24, 9)); + }); + + test('"In a week" lands seven days out at 9am', () { + final presets = taskDuePresets(now: DateTime(2026, 8, 20, 9)); + final week = presets.firstWhere((preset) => preset.label == 'In a week'); + expect(week.dueAt, DateTime(2026, 8, 27, 9)); + }); + + test('labels are unique, so chip selection is unambiguous', () { + final labels = taskDuePresets( + now: DateTime(2026, 8, 20, 9), + ).map((preset) => preset.label).toList(); + expect(labels.toSet(), hasLength(labels.length)); + }); + }); +} diff --git a/mobile/test/shared/tasks/task_test.dart b/mobile/test/shared/tasks/task_test.dart new file mode 100644 index 00000000000..2d115166be2 --- /dev/null +++ b/mobile/test/shared/tasks/task_test.dart @@ -0,0 +1,290 @@ +import 'package:buzz/shared/tasks/task.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('buildCreateTaskPayload', () { + test('sends only the fields that were set', () { + expect(buildCreateTaskPayload(title: 'Ship the relay change'), { + 'title': 'Ship the relay change', + 'source': 'mobile', + }); + }); + + test('trims the title and drops a whitespace-only body', () { + expect(buildCreateTaskPayload(title: ' ship it ', body: ' \n '), { + 'title': 'ship it', + 'source': 'mobile', + }); + }); + + test('includes every optional field when provided', () { + final payload = buildCreateTaskPayload( + title: 'Fix the migration', + body: ' needs a backfill ', + channelId: '11111111-1111-1111-1111-111111111111', + sourceRef: 'abc123', + assignee: 'ab' * 32, + priority: 3, + dueAt: DateTime.utc(2026, 8, 24, 13), + source: 'claude', + ); + + expect(payload, { + 'title': 'Fix the migration', + 'body': 'needs a backfill', + 'channel_id': '11111111-1111-1111-1111-111111111111', + 'source_ref': 'abc123', + 'assignee': 'ab' * 32, + 'priority': 3, + 'due_at': '2026-08-24T13:00:00.000Z', + 'source': 'claude', + }); + }); + + test('serializes a local due date as UTC RFC 3339', () { + // The relay deserializes `due_at` into a `DateTime`, so a local + // instant must be converted rather than sent with an offset the handler + // would have to interpret. + final local = DateTime.utc(2026, 8, 24, 13).toLocal(); + expect( + buildCreateTaskPayload(title: 'due', dueAt: local)['due_at'], + '2026-08-24T13:00:00.000Z', + ); + }); + + test('rejects a blank title before the request is made', () { + expect( + () => buildCreateTaskPayload(title: ' '), + throwsA(isA()), + ); + expect( + () => buildCreateTaskPayload(title: ''), + throwsA(isA()), + ); + }); + + test('counts title length in characters, not UTF-16 code units', () { + // `validate_title` uses `chars().count()` and Postgres `length()` counts + // characters, so a 200-character multi-byte title is legal even though + // `String.length` reports more than 200. + final multibyte = 'é' * maxTaskTitleChars; + expect(multibyte.length, maxTaskTitleChars); + expect(taskTitleLength(multibyte), maxTaskTitleChars); + expect(buildCreateTaskPayload(title: multibyte)['title'], multibyte); + + final emoji = '🐝' * maxTaskTitleChars; + expect(emoji.length, maxTaskTitleChars * 2, reason: 'surrogate pairs'); + expect(taskTitleLength(emoji), maxTaskTitleChars); + expect(buildCreateTaskPayload(title: emoji)['title'], emoji); + }); + + test('rejects a title one character over the relay ceiling', () { + expect( + () => buildCreateTaskPayload(title: 'a' * (maxTaskTitleChars + 1)), + throwsA(isA()), + ); + expect( + () => buildCreateTaskPayload(title: '🐝' * (maxTaskTitleChars + 1)), + throwsA(isA()), + ); + }); + }); + + group('buildTaskEventPayload', () { + test('emits the relay action string', () { + expect( + buildTaskEventPayload( + action: TaskEventAction.summaryPersisted, + body: ' ## Thread summary ', + ), + {'action': 'summary_persisted', 'body': '## Thread summary'}, + ); + expect( + buildTaskEventPayload( + action: TaskEventAction.commented, + body: 'looks good', + )['action'], + 'commented', + ); + }); + + test('rejects a blank body the relay would 400', () { + expect( + () => buildTaskEventPayload( + action: TaskEventAction.commented, + body: ' ', + ), + throwsA(isA()), + ); + }); + }); + + group('composeTaskBody', () { + test('returns null when there is nothing to send', () { + expect(composeTaskBody(), isNull); + expect(composeTaskBody(body: ' '), isNull); + expect(composeTaskBody(agentHandles: const [' ']), isNull); + }); + + test('leads with the mention line, then the body', () { + expect( + composeTaskBody(body: ' do the thing ', agentHandles: const ['Ada']), + '@Ada\n\ndo the thing', + ); + }); + + test('sends mentions alone when no body was typed', () { + expect( + composeTaskBody(agentHandles: const ['Ada', 'Grace']), + '@Ada @Grace', + ); + }); + }); + + group('resolveAgentHandles', () { + // Literal 64-char hex: `'aa' * 32` is not a constant expression, and + // these are used inside const map literals below. + const ada = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const grace = + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const unknown = + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc'; + + test('prefers a profile name, then the directory, then the pubkey', () { + expect( + resolveAgentHandles( + agentPubkeys: const [ada, grace, unknown], + profileNames: const {ada: 'Ada'}, + directoryNames: const {grace: 'Grace'}, + ), + ['Ada', 'cccccccc', 'Grace'], + ); + }); + + test('is sorted, so an unordered Set produces a stable body', () { + // `channelBotPubkeysProvider` hands back a Set; iteration order there is + // not part of its contract. + final handles = resolveAgentHandles( + agentPubkeys: {grace, ada}, + profileNames: const {ada: 'Ada', grace: 'grace'}, + directoryNames: const {}, + ); + expect(handles, ['Ada', 'grace']); + }); + + test('normalizes lookup keys to lowercase and ignores blank names', () { + expect( + resolveAgentHandles( + agentPubkeys: [ada.toUpperCase()], + profileNames: const {ada: ' '}, + directoryNames: const {ada: 'Directory Ada'}, + ), + ['Directory Ada'], + ); + }); + + test('deduplicates agents that share a display name', () { + expect( + resolveAgentHandles( + agentPubkeys: const [ada, grace], + profileNames: const {ada: 'Claude', grace: 'Claude'}, + directoryNames: const {}, + ), + ['Claude'], + ); + }); + }); + + group('Task.fromJson', () { + test('decodes the relay wire shape', () { + final task = Task.fromJson({ + 'id': 'task-1', + 'channel_id': 'channel-1', + 'created_by': 'ab' * 32, + 'assignee': null, + 'parent_task_id': null, + 'title': 'Ship it', + 'body': 'with a flag', + 'status': 'in_progress', + 'priority': 2, + 'source': 'mobile', + 'source_ref': 'event-1', + 'due_at': 1787000000, + 'done_at': null, + 'archived_at': null, + 'created_at': 1786000000, + 'updated_at': 1786000001, + }); + + expect(task.id, 'task-1'); + expect(task.status, TaskStatus.inProgress); + expect(task.priority, 2); + expect(task.assignee, isNull); + expect(task.sourceRef, 'event-1'); + // Inbound timestamps are Unix seconds, not the RFC 3339 the client sends. + expect( + task.dueAt, + DateTime.fromMillisecondsSinceEpoch(1787000000 * 1000, isUtc: true), + ); + expect(task.doneAt, isNull); + }); + + test('degrades an unknown status instead of throwing', () { + // A client built before a status was added must still render the task. + expect(TaskStatus.fromWire('deferred'), TaskStatus.todo); + expect(TaskStatus.fromWire(null), TaskStatus.todo); + }); + + test('rejects a response missing an id or title', () { + expect( + () => Task.fromJson({'title': 'no id'}), + throwsA(isA()), + ); + }); + }); + + group('TaskDetail', () { + TaskEvent event(int id, String action) => TaskEvent.fromJson({ + 'id': id, + 'task_id': 'task-1', + 'action': action, + 'created_at': 1786000000 + id, + 'body': action, + }); + + final task = Task.fromJson({ + 'id': 'task-1', + 'title': 'Ship it', + 'status': 'todo', + 'priority': 0, + 'created_at': 1786000000, + 'updated_at': 1786000000, + }); + + test('finds the single persisted summary', () { + final detail = TaskDetail( + task: task, + events: [ + event(1, 'created'), + event(2, 'commented'), + event(3, 'summary_persisted'), + ], + ); + expect(detail.summary?.id, 3); + expect(detail.summary?.isSummary, isTrue); + }); + + test('reports no summary when the history has none', () { + final detail = TaskDetail(task: task, events: [event(1, 'created')]); + expect(detail.summary, isNull); + }); + + test('keeps an unrecognised action as text', () { + // `task_events.action` is unconstrained TEXT so a new harness can write + // an action this build has never heard of. + expect(event(9, 'handed_off').action, 'handed_off'); + expect(event(9, 'handed_off').isSummary, isFalse); + }); + }); +} diff --git a/mobile/test/shared/tasks/tasks_api_test.dart b/mobile/test/shared/tasks/tasks_api_test.dart new file mode 100644 index 00000000000..bda865438e6 --- /dev/null +++ b/mobile/test/shared/tasks/tasks_api_test.dart @@ -0,0 +1,318 @@ +import 'dart:convert'; + +import 'package:buzz/shared/tasks/task.dart'; +import 'package:buzz/shared/tasks/tasks_api.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:nostr/nostr.dart' as nostr; + +const _baseUrl = 'https://relay.example.com'; + +Map _taskJson({String id = 'task-1'}) => { + 'id': id, + 'title': 'Ship it', + 'status': 'todo', + 'priority': 0, + 'created_at': 1786000000, + 'updated_at': 1786000000, +}; + +/// Decodes the `Authorization: Nostr ` header back into its event. +Map _nip98Event(http.Request request) { + final header = request.headers['authorization']; + expect(header, startsWith('Nostr ')); + final json = utf8.decode(base64.decode(header!.substring('Nostr '.length))); + return jsonDecode(json) as Map; +} + +String? _tag(Map event, String name) { + for (final tag in event['tags'] as List) { + final entry = (tag as List).cast(); + if (entry.first == name) return entry[1]; + } + return null; +} + +void main() { + late String nsec; + + setUp(() => nsec = nostr.Keys.generate().nsec); + + TasksApi apiWith( + Future Function(http.Request request) handler, { + String? signingKey, + }) => TasksApi( + httpClient: http_testing.MockClient(handler), + baseUrl: _baseUrl, + nsec: signingKey ?? nsec, + ); + + group('createTask', () { + test('posts the payload with a NIP-98 signature over that body', () async { + late http.Request captured; + final api = apiWith((request) async { + captured = request; + return http.Response(jsonEncode(_taskJson()), 200); + }); + + final task = await api.createTask( + title: 'Ship it', + channelId: 'channel-1', + sourceRef: 'event-1', + ); + + expect(captured.method, 'POST'); + expect(captured.url, Uri.parse('$_baseUrl/api/tasks')); + expect(captured.headers['content-type'], 'application/json'); + expect(jsonDecode(captured.body), { + 'title': 'Ship it', + 'channel_id': 'channel-1', + 'source_ref': 'event-1', + 'source': 'mobile', + }); + + final event = _nip98Event(captured); + expect(event['kind'], 27235); + expect(_tag(event, 'u'), '$_baseUrl/api/tasks'); + expect(_tag(event, 'method'), 'POST'); + // The relay requires a payload tag on every write and rejects a + // signature whose hash does not cover the body it arrived with. + expect( + _tag(event, 'payload'), + sha256.convert(utf8.encode(captured.body)).toString(), + ); + + expect(task.id, 'task-1'); + }); + + test('rejects an over-long title before sending anything', () async { + var called = false; + final api = apiWith((request) async { + called = true; + return http.Response('{}', 200); + }); + + await expectLater( + api.createTask(title: 'a' * (maxTaskTitleChars + 1)), + throwsA(isA()), + ); + expect(called, isFalse); + }); + }); + + group('listTasks', () { + test('signs the URL including its query string', () async { + late http.Request captured; + final api = apiWith((request) async { + captured = request; + return http.Response( + jsonEncode({ + 'tasks': [_taskJson()], + }), + 200, + ); + }); + + final tasks = await api.listTasks( + status: TaskStatus.inProgress, + channelId: 'channel-1', + limit: 20, + ); + + expect(captured.method, 'GET'); + expect(captured.url.path, '/api/tasks'); + expect(captured.url.queryParameters, { + 'status': 'in_progress', + 'channel': 'channel-1', + 'limit': '20', + }); + // `request_path` rebuilds the expected URL from the path plus the raw + // query, so a signature over the bare path would fail verification. + expect(_tag(_nip98Event(captured), 'u'), captured.url.toString()); + expect(tasks.single.id, 'task-1'); + }); + + test('omits filters that were not set', () async { + late http.Request captured; + final api = apiWith((request) async { + captured = request; + return http.Response(jsonEncode(const {'tasks': []}), 200); + }); + + expect(await api.listTasks(), isEmpty); + expect(captured.url, Uri.parse('$_baseUrl/api/tasks')); + expect(captured.url.query, isEmpty); + }); + + test('rejects a response whose task list is not a list', () async { + final api = apiWith( + (request) async => http.Response(jsonEncode(const {'tasks': 3}), 200), + ); + await expectLater(api.listTasks(), throwsA(isA())); + }); + }); + + group('getTask', () { + test('parses the task and its history', () async { + final api = apiWith( + (request) async => http.Response( + jsonEncode({ + 'task': _taskJson(), + 'events': [ + { + 'id': 1, + 'task_id': 'task-1', + 'action': 'created', + 'created_at': 1786000000, + }, + { + 'id': 2, + 'task_id': 'task-1', + 'action': 'summary_persisted', + 'created_at': 1786000005, + 'body': '## Thread summary', + }, + ], + }), + 200, + ), + ); + + final detail = await api.getTask('task-1'); + expect(detail.events, hasLength(2)); + expect(detail.summary?.body, '## Thread summary'); + }); + }); + + group('appendTaskEvent', () { + test('posts the action and body', () async { + late http.Request captured; + final api = apiWith((request) async { + captured = request; + return http.Response( + jsonEncode({ + 'id': 7, + 'task_id': 'task-1', + 'action': 'summary_persisted', + 'created_at': 1786000009, + 'body': 'digest', + }), + 200, + ); + }); + + final event = await api.appendTaskEvent( + 'task-1', + action: TaskEventAction.summaryPersisted, + body: 'digest', + ); + + expect(captured.url, Uri.parse('$_baseUrl/api/tasks/task-1/events')); + expect(jsonDecode(captured.body), { + 'action': 'summary_persisted', + 'body': 'digest', + }); + expect(event.isSummary, isTrue); + }); + + test('surfaces the relay message for a second summary', () async { + // `idx_task_events_one_summary_per_task` rejects the second write; the + // relay maps it to a 400 the user can act on, not a retryable 500. + final api = apiWith( + (request) async => http.Response( + jsonEncode(const { + 'error': 'task task-1 already has a persisted summary', + }), + 400, + ), + ); + + await expectLater( + api.appendTaskEvent( + 'task-1', + action: TaskEventAction.summaryPersisted, + body: 'digest', + ), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 400) + .having( + (e) => e.toString(), + 'toString', + 'task task-1 already has a persisted summary', + ), + ), + ); + }); + }); + + group('updateTask', () { + test('sends only the fields being changed', () async { + late http.Request captured; + final api = apiWith((request) async { + captured = request; + return http.Response(jsonEncode(_taskJson()), 200); + }); + + await api.updateTask('task-1', status: TaskStatus.done); + + expect(captured.method, 'PATCH'); + expect(jsonDecode(captured.body), {'status': 'done'}); + }); + }); + + group('error handling', () { + test('falls back to the status code when there is no message', () async { + final api = apiWith((request) async => http.Response('', 503)); + await expectLater( + api.createTask(title: 'Ship it'), + throwsA( + isA().having( + (e) => e.toString(), + 'toString', + 'HTTP 503', + ), + ), + ); + }); + + test('reports an unreadable body instead of a decode crash', () async { + final api = apiWith((request) async => http.Response('', 502)); + await expectLater( + api.createTask(title: 'Ship it'), + throwsA( + isA().having( + (e) => e.toString(), + 'toString', + 'The relay returned an unreadable task response.', + ), + ), + ); + }); + }); + + group('canSign', () { + test('is false without a signing key', () { + expect( + apiWith((_) async => http.Response('{}', 200), signingKey: '').canSign, + isFalse, + ); + expect( + TasksApi( + httpClient: http_testing.MockClient( + (_) async => http.Response('{}', 200), + ), + baseUrl: _baseUrl, + nsec: null, + ).canSign, + isFalse, + ); + }); + + test('is true with one', () { + expect(apiWith((_) async => http.Response('{}', 200)).canSign, isTrue); + }); + }); +} diff --git a/mobile/test/shared/tasks/thread_summary_sheet_test.dart b/mobile/test/shared/tasks/thread_summary_sheet_test.dart new file mode 100644 index 00000000000..b48fc6c4cd3 --- /dev/null +++ b/mobile/test/shared/tasks/thread_summary_sheet_test.dart @@ -0,0 +1,250 @@ +import 'dart:convert'; + +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/tasks/tasks_api.dart'; +import 'package:buzz/shared/tasks/thread_summary.dart'; +import 'package:buzz/shared/tasks/thread_summary_sheet.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:nostr/nostr.dart' as nostr; + +const _channelId = 'channel-1'; + +const _thread = [ + ThreadMessageDigest( + author: 'Ada', + text: 'The relay drops long-lived connections after an hour.', + ), + ThreadMessageDigest( + author: 'Grace', + text: 'We decided to add a keepalive ping; I will own it.', + ), +]; + +final _save = find.byKey(const ValueKey('thread-summary-save')); +final _copy = find.byKey(const ValueKey('thread-summary-copy')); +final _newTaskRow = find.byKey(const ValueKey('summary-target-new-task')); + +late String _nsec; + +class _TestRelayConfig extends RelayConfigNotifier { + @override + RelayConfig build() => + RelayConfig(baseUrl: 'https://relay.example.com', nsec: _nsec); +} + +class _Harness extends ConsumerWidget { + const _Harness(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + body: Center( + child: TextButton( + onPressed: () => showThreadSummarySheet( + context: context, + ref: ref, + channelId: _channelId, + messages: _thread, + ), + child: const Text('open'), + ), + ), + ); + } +} + +String _taskBody({String id = 'task-1', String title = 'A thread task'}) => + jsonEncode({ + 'id': id, + 'title': title, + 'status': 'todo', + 'priority': 0, + 'created_at': 1786000000, + 'updated_at': 1786000000, + }); + +Widget _app(http.Client client) => ProviderScope( + overrides: [ + relayConfigProvider.overrideWith(_TestRelayConfig.new), + tasksHttpClientProvider.overrideWithValue(client), + ], + child: MaterialApp(theme: AppTheme.light(), home: const _Harness()), +); + +void main() { + setUp(() => _nsec = nostr.Keys.generate().nsec); + + /// Routes by method and path so a test can assert the whole call sequence. + http.Client routing({ + required List log, + String listBody = '{"tasks": []}', + http.Response Function()? appendResponse, + }) { + return http_testing.MockClient((request) async { + log.add('${request.method} ${request.url.path}'); + if (request.url.path.endsWith('/events')) { + return appendResponse?.call() ?? + http.Response( + jsonEncode({ + 'id': 1, + 'task_id': 'task-1', + 'action': 'summary_persisted', + 'created_at': 1786000000, + 'body': 'digest', + }), + 200, + ); + } + if (request.method == 'GET') return http.Response(listBody, 200); + return http.Response(_taskBody(), 200); + }); + } + + testWidgets('renders the digest with copy and save actions', (tester) async { + await tester.pumpWidget(_app(routing(log: []))); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect( + tester.widget(find.byKey(const ValueKey('buzz-sheet-title'))).data, + 'Thread summary', + ); + expect(_copy, findsOneWidget); + expect(_save, findsOneWidget); + // The digest itself is the pure function's output, asserted in + // thread_summary_test.dart; here it only has to reach the sheet — which the + // rendered '## Thread summary' heading, distinct from the sheet title + // above, demonstrates. + expect(find.byKey(const ValueKey('thread-summary-body')), findsOneWidget); + expect(find.text('Thread summary'), findsWidgets); + }); + + testWidgets('opens a new task and persists the summary onto it', ( + tester, + ) async { + final log = []; + await tester.pumpWidget(_app(routing(log: log))); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + await tester.tap(_save); + await tester.pumpAndSettle(); + + expect(find.text('Save summary to'), findsOneWidget); + await tester.tap(_newTaskRow); + await tester.pumpAndSettle(); + + expect(log, [ + 'GET /api/tasks', // the picker lists candidates + 'POST /api/tasks', // no existing task chosen, so open one + 'POST /api/tasks/task-1/events', // then persist the summary + ]); + expect(find.text('Summary saved to task'), findsOneWidget); + expect(find.text('Saved to “A thread task”.'), findsOneWidget); + // Re-saving the same summary can only fail, so the action retires. + expect(tester.widget(_save).onPressed, isNull); + }); + + testWidgets('persists onto an existing task without creating one', ( + tester, + ) async { + final log = []; + await tester.pumpWidget( + _app( + routing( + log: log, + listBody: jsonEncode({ + 'tasks': [jsonDecode(_taskBody(id: 'task-9', title: 'Keepalive'))], + }), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tap(_save); + await tester.pumpAndSettle(); + + expect(find.text('Existing tasks'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('summary-target-task-9'))); + await tester.pumpAndSettle(); + + expect(log, ['GET /api/tasks', 'POST /api/tasks/task-9/events']); + expect(find.text('Saved to “Keepalive”.'), findsOneWidget); + }); + + testWidgets('surfaces the relay refusal of a second summary', (tester) async { + final log = []; + await tester.pumpWidget( + _app( + routing( + log: log, + listBody: jsonEncode({ + 'tasks': [jsonDecode(_taskBody(id: 'task-9', title: 'Keepalive'))], + }), + appendResponse: () => http.Response( + jsonEncode(const { + 'error': 'task task-9 already has a persisted summary', + }), + 400, + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tap(_save); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('summary-target-task-9'))); + await tester.pumpAndSettle(); + + expect( + find.text('task task-9 already has a persisted summary'), + findsOneWidget, + ); + // The action stays live so another task can be chosen. + expect(tester.widget(_save).onPressed, isNotNull); + }); + + testWidgets('dismissing the picker leaves the summary untouched', ( + tester, + ) async { + final log = []; + await tester.pumpWidget(_app(routing(log: log))); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tap(_save); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Close sheet').last); + await tester.pumpAndSettle(); + + expect(log, ['GET /api/tasks']); + expect(find.text('Summary saved to task'), findsNothing); + expect(tester.widget(_save).onPressed, isNotNull); + }); + + testWidgets('reports a failed task list instead of an empty picker', ( + tester, + ) async { + final client = http_testing.MockClient( + (request) async => http.Response( + jsonEncode(const {'error': 'relay: no community for this host'}), + 404, + ), + ); + await tester.pumpWidget(_app(client)); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + await tester.tap(_save); + await tester.pumpAndSettle(); + + expect(find.text('relay: no community for this host'), findsOneWidget); + // Opening a fresh task is still possible even when listing failed. + expect(_newTaskRow, findsOneWidget); + }); +} diff --git a/mobile/test/shared/tasks/thread_summary_test.dart b/mobile/test/shared/tasks/thread_summary_test.dart new file mode 100644 index 00000000000..a24c05447dd --- /dev/null +++ b/mobile/test/shared/tasks/thread_summary_test.dart @@ -0,0 +1,220 @@ +import 'package:buzz/shared/tasks/thread_summary.dart'; +import 'package:flutter_test/flutter_test.dart'; + +ThreadMessageDigest msg(String author, String text) => + ThreadMessageDigest(author: author, text: text); + +void main() { + group('summarizeThread', () { + test('says so rather than returning an empty string', () { + expect(summarizeThread(const []), 'No messages to summarize yet.'); + expect( + summarizeThread([msg('Ada', ' '), msg('Grace', '\n\t')]), + 'No messages to summarize yet.', + ); + }); + + test('headlines the message count and participants', () { + final summary = summarizeThread([ + msg('Ada', 'The relay drops the connection after an hour.'), + msg('Grace', 'We should add a keepalive ping.'), + msg('Ada', 'Agreed, I will take it.'), + ]); + expect(summary, startsWith('## Thread summary\n')); + expect(summary, contains('_3 messages from Ada and Grace._')); + }); + + test('uses the singular for a one-message thread', () { + expect( + summarizeThread([msg('Ada', 'Deploy is done.')]), + contains('_1 message from Ada._'), + ); + }); + + test('collapses a long participant roster', () { + final summary = summarizeThread([ + for (final name in ['A', 'B', 'C', 'D', 'E']) msg(name, 'a note here'), + ]); + expect(summary, contains('A, B, C and 2 others')); + }); + + test('routes questions to their own section, not to highlights', () { + final summary = summarizeThread([ + msg('Ada', 'We decided to ship behind a flag.'), + msg('Grace', 'Who owns the rollback plan?'), + ]); + final highlights = summary.indexOf('**Highlights**'); + final questions = summary.indexOf('**Open questions**'); + expect(highlights, greaterThan(-1)); + expect(questions, greaterThan(highlights)); + expect( + summary.substring(highlights, questions), + contains('**Ada:** We decided to ship behind a flag.'), + ); + expect( + summary.substring(questions), + contains('**Grace:** Who owns the rollback plan?'), + ); + }); + + test('omits sections that have no content', () { + final onlyQuestions = summarizeThread([msg('Ada', 'Is it live yet?')]); + expect(onlyQuestions, isNot(contains('**Highlights**'))); + expect(onlyQuestions, contains('**Open questions**')); + + final onlyStatements = summarizeThread([msg('Ada', 'It is live.')]); + expect(onlyStatements, contains('**Highlights**')); + expect(onlyStatements, isNot(contains('**Open questions**'))); + expect(onlyStatements, isNot(contains('**Links**'))); + }); + + test('prefers decision-bearing lines over filler', () { + final summary = summarizeThread([ + msg('Ada', 'morning'), + msg('Grace', 'hey'), + msg('Ada', 'We decided to revert the migration; I will own it.'), + msg('Grace', 'ok'), + ], maxHighlights: 1); + expect( + summary, + contains('**Ada:** We decided to revert the migration; I will own it.'), + ); + expect(summary, isNot(contains('**Grace:** hey'))); + }); + + test('keeps selected highlights in thread order', () { + // Relevance picks which lines survive; chronology decides how they read. + final summary = summarizeThread([ + msg('Ada', 'The plan is to cut a release candidate on Friday.'), + msg('Grace', 'Blocked on the signing cert until Thursday.'), + ], maxHighlights: 2); + expect( + summary.indexOf('**Ada:**'), + lessThan(summary.indexOf('**Grace:**')), + ); + }); + + test( + 'collects links once, in first-seen order, without trailing punctuation', + () { + final summary = summarizeThread([ + msg('Ada', 'See https://example.com/pr/1.'), + msg( + 'Grace', + 'Also https://example.com/pr/2 and https://example.com/pr/1', + ), + ]); + final links = summary.substring(summary.indexOf('**Links**')); + expect(links, contains('- https://example.com/pr/1\n')); + expect(links, contains('- https://example.com/pr/2')); + expect( + 'https://example.com/pr/1'.allMatches(links).length, + 1, + reason: 'a repeated link must not be listed twice', + ); + }, + ); + + test('replaces code with a placeholder so bullets stay well-formed', () { + final summary = summarizeThread([ + msg('Ada', 'Run this to reproduce:\n```\njust relay\n```\nthen retry.'), + ]); + expect(summary, contains('[code]')); + expect(summary, isNot(contains('just relay'))); + expect(summary.split('\n').where((l) => l == '```'), isEmpty); + }); + + test('flattens newlines so one message stays one bullet', () { + final summary = summarizeThread([ + msg('Ada', 'first line\nsecond line\n\nthird line'), + ]); + expect(summary, contains('**Ada:** first line second line third line')); + }); + + test('names a blank author rather than emitting an empty label', () { + expect(summarizeThread([msg(' ', 'a note')]), contains('**Unknown:**')); + }); + + test('is deterministic for the same input', () { + final messages = [ + msg('Ada', 'We should ship behind a flag.'), + msg('Grace', 'Who owns the rollback?'), + msg('Ada', 'Blocked on https://example.com/cert'), + ]; + expect(summarizeThread(messages), summarizeThread(messages)); + }); + + test('honours the section caps', () { + final messages = [ + for (var i = 0; i < 12; i++) + msg('Ada', 'We decided on option $i for the rollout plan.'), + for (var i = 0; i < 12; i++) msg('Grace', 'What about option $i?'), + ]; + final summary = summarizeThread( + messages, + maxHighlights: 2, + maxOpenQuestions: 1, + ); + expect( + summary.split('\n').where((l) => l.contains('**Ada:**')).length, + 2, + ); + expect( + summary.split('\n').where((l) => l.contains('**Grace:**')).length, + 1, + ); + }); + + test('elides an over-long line at the requested width', () { + final summary = summarizeThread([ + msg('Ada', 'word ' * 200), + ], maxCharsPerLine: 40); + final bullet = summary + .split('\n') + .firstWhere((line) => line.startsWith('- **Ada:**')); + expect(bullet, endsWith('…')); + // '- **Ada:** ' is chrome around the 40-character line itself. + expect(bullet.length, lessThanOrEqualTo('- **Ada:** '.length + 40)); + }); + }); + + group('elideSummaryLine', () { + test('leaves a short line alone', () { + expect(elideSummaryLine('short', 40), 'short'); + expect(elideSummaryLine('exactly ten', 11), 'exactly ten'); + }); + + test('cuts on a word boundary when one is available', () { + expect(elideSummaryLine('alpha beta gamma delta', 16), 'alpha beta…'); + }); + + test('cuts mid-token rather than collapsing to nothing', () { + final elided = elideSummaryLine('a ${'z' * 60}', 20); + expect(elided.length, 20); + expect(elided, endsWith('…')); + }); + }); + + group('threadTaskTitle', () { + test('uses the first message with content', () { + expect( + threadTaskTitle([ + msg('Ada', ' '), + msg('Grace', 'Relay drops long-lived connections'), + ]), + 'Relay drops long-lived connections', + ); + }); + + test('falls back when the thread has no text', () { + expect(threadTaskTitle(const []), 'Thread summary'); + expect(threadTaskTitle([msg('Ada', '\n')]), 'Thread summary'); + }); + + test('stays well inside the relay title ceiling', () { + final title = threadTaskTitle([msg('Ada', 'word ' * 200)]); + expect(title.runes.length, lessThanOrEqualTo(200)); + expect(title, endsWith('…')); + }); + }); +} diff --git a/schema/schema.sql b/schema/schema.sql index 6e14e6be1bf..edb9f513322 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1186,6 +1186,92 @@ INSERT INTO _operator_global_tables (table_name, reason) VALUES ('push_gateway_delivery_auth_replays', 'public gateway signed-event replay admission spans relay communities'), ('push_gateway_delivery_request_replays', 'public gateway stable request-id admission spans relay communities'); +-- ── Task system (migration 0033) ───────────────────────────────────────────── +-- Durable work items owned by humans or harness agents (Claude Code, Codex, +-- the ACP mesh). Deliberately NOT workflows: `workflows`/`workflow_runs` are +-- the scheduled execution engine, a task is a unit of work someone owns. +-- +-- Relay-owned rows rather than Nostr events, matching `workflow_runs` and +-- `workflow_approvals` (see crates/buzz-relay/src/api/workflows.rs). +-- +-- Creator/assignee/actor are `users`, never a separate agent table: agents in +-- Buzz *are* users carrying `users.agent_type` and an optional NIP-OA +-- `users.agent_owner_pubkey`, so one nullable community-scoped pubkey FK +-- covers humans and agents alike. + +CREATE TABLE tasks ( + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), + channel_id UUID, + created_by_pubkey BYTEA, + assignee_pubkey BYTEA, + parent_task_id UUID, + title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200), + body TEXT, + status TEXT NOT NULL DEFAULT 'todo' + CHECK (status IN ('todo', 'in_progress', 'blocked', 'done', 'cancelled')), + priority INT NOT NULL DEFAULT 0, + source TEXT, + source_ref TEXT, + due_at TIMESTAMPTZ, + done_at TIMESTAMPTZ, + archived_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, id), + CONSTRAINT chk_tasks_done_at_matches_status + CHECK ((status = 'done') = (done_at IS NOT NULL)), + CONSTRAINT chk_tasks_not_own_parent CHECK (parent_task_id IS DISTINCT FROM id), + CONSTRAINT chk_tasks_created_by_len + CHECK (created_by_pubkey IS NULL OR length(created_by_pubkey) = 32), + CONSTRAINT chk_tasks_assignee_len + CHECK (assignee_pubkey IS NULL OR length(assignee_pubkey) = 32), + FOREIGN KEY (community_id, channel_id) + REFERENCES channels (community_id, id), + FOREIGN KEY (community_id, created_by_pubkey) + REFERENCES users (community_id, pubkey) ON DELETE SET NULL, + FOREIGN KEY (community_id, assignee_pubkey) + REFERENCES users (community_id, pubkey) ON DELETE SET NULL, + FOREIGN KEY (community_id, parent_task_id) + REFERENCES tasks (community_id, id) ON DELETE CASCADE +); + +CREATE INDEX idx_tasks_community_status ON tasks (community_id, status); +CREATE INDEX idx_tasks_community_assignee ON tasks (community_id, assignee_pubkey) + WHERE assignee_pubkey IS NOT NULL; +CREATE INDEX idx_tasks_community_updated ON tasks (community_id, updated_at DESC); +CREATE INDEX idx_tasks_community_channel ON tasks (community_id, channel_id) + WHERE channel_id IS NOT NULL; +CREATE INDEX idx_tasks_community_parent ON tasks (community_id, parent_task_id) + WHERE parent_task_id IS NOT NULL; + +-- Append-only lifecycle and comment log; also the read model behind the +-- human-visible task feed, hence the (community, time) feed index. +CREATE TABLE task_events ( + community_id UUID NOT NULL REFERENCES communities(id), + id BIGSERIAL, + task_id UUID NOT NULL, + actor_pubkey BYTEA, + action TEXT NOT NULL CHECK (length(action) BETWEEN 1 AND 64), + from_status TEXT, + to_status TEXT, + body TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, id), + CONSTRAINT chk_task_events_actor_len + CHECK (actor_pubkey IS NULL OR length(actor_pubkey) = 32), + FOREIGN KEY (community_id, task_id) + REFERENCES tasks (community_id, id) ON DELETE CASCADE, + FOREIGN KEY (community_id, actor_pubkey) + REFERENCES users (community_id, pubkey) ON DELETE SET NULL +); + +CREATE INDEX idx_task_events_task_created ON task_events (community_id, task_id, created_at); +CREATE INDEX idx_task_events_community_created ON task_events (community_id, created_at DESC); +CREATE UNIQUE INDEX idx_task_events_one_summary_per_task + ON task_events (community_id, task_id) + WHERE action = 'summary_persisted'; + -- ── Replica heartbeat (read-replica freshness fence) ───────────────────────── -- Portable read-side freshness observation for the replica fence (see -- crates/buzz-db/src/replica_fence.rs and migrations/0026). Exactly one row; @@ -1742,6 +1828,8 @@ SELECT attach_community_write_fence('relay_invites'); SELECT attach_community_write_fence('relay_members'); SELECT attach_community_write_fence('scheduled_workflow_fires'); SELECT attach_community_write_fence('subscriptions'); +SELECT attach_community_write_fence('task_events'); +SELECT attach_community_write_fence('tasks'); SELECT attach_community_write_fence('thread_metadata'); SELECT attach_community_write_fence('users'); SELECT attach_community_write_fence('workflow_approvals');