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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions crates/buzz-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
270 changes: 270 additions & 0 deletions crates/buzz-core/src/task.rs
Original file line number Diff line number Diff line change
@@ -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<Self, Self::Err> {
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<Self, Self::Err> {
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<TaskAction> {
(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::<TaskStatus>(),
Ok(status),
"{status} must survive a string round trip"
);
}
assert!("in-progress".parse::<TaskStatus>().is_err());
assert!("DONE".parse::<TaskStatus>().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::<TaskAction>(), Ok(action));
}
assert!("summary".parse::<TaskAction>().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());
}
}
}
6 changes: 6 additions & 0 deletions crates/buzz-db/src/deletion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ pub const EXPECTED_SCOPED_TABLES: &[&str] = &[
"relay_members",
"scheduled_workflow_fires",
"subscriptions",
"task_events",
"tasks",
"thread_metadata",
"users",
"workflow_approvals",
Expand All @@ -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",
Expand Down
63 changes: 63 additions & 0 deletions crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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::TaskRecord> {
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::TaskRecord> {
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<Vec<task::TaskRecord>> {
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<Vec<task::TaskEventRecord>> {
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::TaskRecord> {
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::TaskEventRecord> {
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(
Expand Down
Loading