use std::sync::Arc;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use serde::{Deserialize, Serialize};
use crate as x0x;
use super::super::crdt_subscriptions;
use super::super::state::AppState;
use super::super::{api_error, bad_request, forbidden, not_found};
pub(in crate::server) fn parse_group_scoped_task_list_id(id: &str) -> Option<GroupScopedId> {
let parts: Vec<&str> = id.split('.').collect();
if parts.len() != 5 {
return None;
}
if parts[0] != "x0x" || parts[1] != "group" || parts[3] != "symphony" {
return None;
}
let group_id = parts[2];
let list_id = parts[4];
if group_id.is_empty() || list_id.is_empty() {
return Some(GroupScopedId::malformed());
}
Some(GroupScopedId {
group_id: group_id.to_string(),
list_id: list_id.to_string(),
})
}
#[derive(Debug, PartialEq, Eq)]
pub(in crate::server) struct GroupScopedId {
pub(in crate::server) group_id: String,
#[allow(dead_code)]
pub(in crate::server) list_id: String,
}
impl GroupScopedId {
pub(in crate::server) fn malformed() -> Self {
Self {
group_id: String::new(),
list_id: String::new(),
}
}
pub(in crate::server) fn is_malformed(&self) -> bool {
self.group_id.is_empty()
}
}
pub(in crate::server) async fn ensure_task_list_access(
state: &Arc<AppState>,
id: &str,
) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
let Some(scoped) = parse_group_scoped_task_list_id(id) else {
return Ok(());
};
if scoped.is_malformed() {
return Err(forbidden("malformed group-scoped task-list id"));
}
let local_agent_hex = hex::encode(state.agent.agent_id().as_bytes());
let groups = state.named_groups.read().await;
let Some(info) = groups.get(&scoped.group_id) else {
return Err(forbidden("not a member of task-list group"));
};
let member = info.members_v2.get(&local_agent_hex);
let allowed = member.is_some_and(|m| matches!(m.state, x0x::groups::GroupMemberState::Active));
if allowed {
Ok(())
} else {
Err(forbidden("not a member of task-list group"))
}
}
pub(in crate::server) async fn apply_group_authorization(
state: &Arc<AppState>,
id: &str,
handle: &x0x::TaskListHandle,
) {
let Some(scoped) = parse_group_scoped_task_list_id(id) else {
return; };
if scoped.is_malformed() {
return;
}
let mut agents = std::collections::HashSet::new();
{
let groups = state.named_groups.read().await;
let Some(info) = groups.get(&scoped.group_id) else {
return; };
for (agent_hex, member) in &info.members_v2 {
if matches!(member.state, x0x::groups::GroupMemberState::Active) {
if let Ok(bytes) = hex::decode(agent_hex) {
if bytes.len() == x0x::identity::PEER_ID_LENGTH {
let mut arr = [0u8; x0x::identity::PEER_ID_LENGTH];
arr.copy_from_slice(&bytes);
agents.insert(x0x::identity::AgentId(arr));
}
}
}
}
}
handle.set_authorized_agents(agents).await;
}
#[derive(Debug, Deserialize)]
pub(in crate::server) struct CreateTaskListRequest {
pub(in crate::server) name: String,
pub(in crate::server) topic: String,
}
#[derive(Debug, Deserialize)]
pub(in crate::server) struct AddTaskRequest {
pub(in crate::server) title: String,
#[serde(default)]
pub(in crate::server) description: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub(in crate::server) struct UpdateTaskRequest {
pub(in crate::server) action: String, #[serde(default)]
pub(in crate::server) fence_token: Option<String>,
}
#[derive(Debug, Serialize)]
pub(in crate::server) struct TaskListEntry {
pub(in crate::server) id: String,
pub(in crate::server) topic: String,
}
#[derive(Debug, Serialize)]
pub(in crate::server) struct TaskEntry {
pub(in crate::server) id: String,
pub(in crate::server) title: String,
pub(in crate::server) description: String,
pub(in crate::server) state: String,
pub(in crate::server) assignee: Option<String>,
pub(in crate::server) priority: u8,
pub(in crate::server) claimed_by: Option<String>,
pub(in crate::server) claimed_at: Option<u64>,
pub(in crate::server) completed_by: Option<String>,
pub(in crate::server) completed_at: Option<u64>,
}
pub(in crate::server) async fn list_task_lists(
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
let ids: Vec<String> = state.task_lists.read().await.keys().cloned().collect();
let mut entries = Vec::with_capacity(ids.len());
for id in ids {
if ensure_task_list_access(&state, &id).await.is_ok() {
entries.push(TaskListEntry {
id: id.clone(),
topic: id, });
}
}
Json(serde_json::json!({ "ok": true, "task_lists": entries }))
}
pub(in crate::server) async fn create_task_list(
State(state): State<Arc<AppState>>,
Json(req): Json<CreateTaskListRequest>,
) -> impl IntoResponse {
if let Err(denied) = ensure_task_list_access(&state, &req.topic).await {
return denied;
}
let id = req.topic.clone();
let reservation =
crdt_subscriptions::handle_reservation(&state, crdt_subscriptions::KIND_TASK_LIST, &id)
.await;
let _guard = reservation.lock().await;
if state.task_lists.read().await.contains_key(&id) {
return api_error(StatusCode::CONFLICT, "task list already exists");
}
match state.agent.create_task_list(&req.name, &req.topic).await {
Ok(handle) => {
let version = handle.version().await;
apply_group_authorization(&state, &id, &handle).await;
state.task_lists.write().await.insert(id.clone(), handle);
let entry = crdt_subscriptions::CrdtSubscriptionEntry {
kind: crdt_subscriptions::KIND_TASK_LIST.to_string(),
id: id.clone(),
name: req.name.clone(),
topic: req.topic.clone(),
role: crdt_subscriptions::ROLE_CREATED.to_string(),
extra: serde_json::Map::new(),
};
if let Err(e) = crdt_subscriptions::record(&state, entry).await {
tracing::error!(
topic = %req.topic,
"failed to persist task-list subscription registration: {e}"
);
state.task_lists.write().await.remove(&id);
return api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"failed to persist subscription registration",
);
}
(
StatusCode::CREATED,
Json(serde_json::json!({
"ok": true,
"id": id,
"version": version.revision,
"fence_token": version.to_wire(),
"committed": "local",
})),
)
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")),
}
}
pub(in crate::server) async fn list_tasks(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
if let Err(denied) = ensure_task_list_access(&state, &id).await {
return denied;
}
let lists = state.task_lists.read().await;
let Some(handle) = lists.get(&id) else {
return not_found("task list not found");
};
match handle.list_tasks_with_version().await {
Ok((tasks, fence)) => {
let entries: Vec<TaskEntry> = tasks
.into_iter()
.map(|t| TaskEntry {
id: format!("{}", t.id),
title: t.title,
description: t.description,
state: format!("{}", t.state),
assignee: t.assignee.map(|a| hex::encode(a.as_bytes())),
priority: t.priority,
claimed_by: t.claimed_by.map(|a| hex::encode(a.as_bytes())),
claimed_at: t.claimed_at,
completed_by: t.completed_by.map(|a| hex::encode(a.as_bytes())),
completed_at: t.completed_at,
})
.collect();
(
StatusCode::OK,
Json(serde_json::json!({
"ok": true,
"version": fence.revision,
"fence_token": fence.to_wire(),
"tasks": entries,
})),
)
}
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")),
}
}
pub(in crate::server) async fn add_task(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<AddTaskRequest>,
) -> impl IntoResponse {
if let Err(denied) = ensure_task_list_access(&state, &id).await {
return denied;
}
let lists = state.task_lists.read().await;
let Some(handle) = lists.get(&id) else {
return not_found("task list not found");
};
match handle
.add_task_versioned(req.title, req.description.unwrap_or_default())
.await
{
Ok((task_id, version)) => (
StatusCode::CREATED,
Json(serde_json::json!({
"ok": true,
"task_id": format!("{task_id}"),
"version": version,
"committed": "local",
})),
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")),
}
}
pub(in crate::server) async fn update_task(
State(state): State<Arc<AppState>>,
Path((id, tid)): Path<(String, String)>,
Json(req): Json<UpdateTaskRequest>,
) -> impl IntoResponse {
if let Err(denied) = ensure_task_list_access(&state, &id).await {
return denied;
}
let lists = state.task_lists.read().await;
let Some(handle) = lists.get(&id) else {
return not_found("task list not found");
};
let task_id_bytes: [u8; 32] = match hex::decode(&tid) {
Ok(bytes) if bytes.len() == 32 => {
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
arr
}
_ => {
return bad_request("invalid task ID (expected 64 hex chars)");
}
};
let task_id = x0x::crdt::TaskId::from_bytes(task_id_bytes);
let expected = match req.fence_token.as_deref() {
None => None,
Some(s) => match x0x::FenceToken::from_wire(s) {
Ok(token) => Some(token),
Err(_) => {
return bad_request("malformed_fence_token");
}
},
};
let result = match req.action.as_str() {
"claim" => handle.claim_task_versioned(task_id, expected).await,
"complete" => handle.complete_task_versioned(task_id, expected).await,
_ => {
return bad_request("action must be 'claim' or 'complete'");
}
};
match result {
Ok(x0x::TaskMutationOutcome::Committed { fence, advisory }) => {
let current_winner = advisory.current_winner.map(|(agent, ts)| {
serde_json::json!({
"agent_id": hex::encode(agent.as_bytes()),
"timestamp_ms": ts,
})
});
(
StatusCode::OK,
Json(serde_json::json!({
"ok": true,
"version": fence.revision,
"fence_token": fence.to_wire(),
"committed": "local",
"resolution": {
"agent_id": hex::encode(advisory.agent.as_bytes()),
"locally_winning": advisory.locally_winning,
"current_winner": current_winner,
"pending_convergence": true,
},
"cas": { "scope": "local_replica" },
"execution": { "authorization": "advisory" },
"exclusive": false,
})),
)
}
Ok(x0x::TaskMutationOutcome::StaleLocalVersion { current }) => (
StatusCode::CONFLICT,
Json(serde_json::json!({
"ok": false,
"error": "stale_local_version",
"current_version": current.revision,
"fence_token": current.to_wire(),
"cas": { "scope": "local_replica" },
})),
),
Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parser_recognizes_well_formed_scoped_id() {
let parsed = parse_group_scoped_task_list_id("x0x.group.acme-corp.symphony.inbox");
let scoped = parsed.expect("well-formed scoped id parses");
assert!(!scoped.is_malformed());
assert_eq!(scoped.group_id, "acme-corp");
}
#[test]
fn parser_treats_plain_id_as_none() {
assert_eq!(parse_group_scoped_task_list_id("plain-topic"), None);
assert_eq!(parse_group_scoped_task_list_id("inbox"), None);
assert_eq!(parse_group_scoped_task_list_id(""), None);
assert_eq!(parse_group_scoped_task_list_id("x0x.group.acme"), None);
assert_eq!(
parse_group_scoped_task_list_id("x0x.group.acme.symphony.inbox.extra"),
None
);
}
#[test]
fn parser_rejects_wrong_prefix_as_plain() {
assert_eq!(
parse_group_scoped_task_list_id("x0x.grop.acme.symphony.inbox"),
None
);
assert_eq!(
parse_group_scoped_task_list_id("foo.group.acme.symphony.inbox"),
None
);
assert_eq!(
parse_group_scoped_task_list_id("x0x.group.acme.secure.inbox"),
None );
}
#[test]
fn parser_flags_empty_group_or_list_as_malformed() {
let empty_group = parse_group_scoped_task_list_id("x0x.group..symphony.inbox");
let scoped = empty_group.expect("scoped shape with empty group is Some");
assert!(scoped.is_malformed(), "empty group_id ⇒ malformed ⇒ deny");
let empty_list = parse_group_scoped_task_list_id("x0x.group.acme.symphony.");
let scoped = empty_list.expect("scoped shape with empty list is Some");
assert!(scoped.is_malformed(), "empty list_id ⇒ malformed ⇒ deny");
}
#[test]
fn update_task_request_rejects_obsolete_expected_version_field() {
let obsolete =
serde_json::from_str::<UpdateTaskRequest>(r#"{"action":"claim","expected_version":3}"#);
assert!(
obsolete.is_err(),
"obsolete expected_version must be rejected (4xx), not silently \
downgraded to an unfenced claim"
);
}
#[test]
fn update_task_request_accepts_current_contract_and_rejects_typos() {
let fenced =
serde_json::from_str::<UpdateTaskRequest>(r#"{"action":"claim","fence_token":"1:2"}"#);
assert!(fenced.is_ok(), "fenced request parses");
let unfenced = serde_json::from_str::<UpdateTaskRequest>(r#"{"action":"claim"}"#);
assert!(unfenced.is_ok(), "unfenced request parses");
let typo =
serde_json::from_str::<UpdateTaskRequest>(r#"{"action":"claim","fence_tokn":"1:2"}"#);
assert!(typo.is_err(), "typo'd field name must be rejected");
}
}