use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const CHANNEL_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ChannelState {
Submitted,
Working,
InputRequired,
Completed,
Failed,
Canceled,
Rejected,
Stale,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ChannelPurpose {
Conversation,
FleetRun,
WorkflowRun,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ChannelActor {
Human { name: String },
Agent { id: String },
System,
}
impl ChannelActor {
pub fn local_human() -> Self {
let name = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "you".to_string());
ChannelActor::Human { name }
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ParticipantRole {
Owner,
Router,
Delegate,
Observer,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Participant {
pub actor: ChannelActor,
pub role: ParticipantRole,
pub joined_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Goal {
#[serde(default)]
pub statement: String,
#[serde(default)]
pub acceptance_criteria: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Channel {
pub v: u32,
pub id: String,
pub title: String,
#[serde(default)]
pub goal: Goal,
pub state: ChannelState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub purpose: Option<ChannelPurpose>,
pub owner: ChannelActor,
#[serde(default)]
pub participants: Vec<Participant>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum EventKind {
Message,
Delegation,
Handoff,
ToolCall,
ToolResult,
StateChange,
Artifact,
HitlRequest,
HitlResponse,
Note,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelEvent {
pub seq: u64,
pub ts: DateTime<Utc>,
pub actor: ChannelActor,
pub kind: EventKind,
#[serde(default)]
pub payload: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idempotency_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sig: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_version: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channel_state_serializes_kebab() {
let j = serde_json::to_string(&ChannelState::InputRequired).unwrap();
assert_eq!(j, "\"input-required\"");
}
#[test]
fn event_round_trips() {
let ev = ChannelEvent {
seq: 3,
ts: Utc::now(),
actor: ChannelActor::Agent { id: "qa".into() },
kind: EventKind::Message,
payload: serde_json::json!({ "text": "hello", "task_id": "t-1" }),
idempotency_key: None,
sig: None,
key_version: None,
};
let line = serde_json::to_string(&ev).unwrap();
let back: ChannelEvent = serde_json::from_str(&line).unwrap();
assert_eq!(back.seq, 3);
assert_eq!(back.actor, ChannelActor::Agent { id: "qa".into() });
assert_eq!(back.payload["text"], "hello");
}
#[test]
fn system_actor_round_trips() {
let a = ChannelActor::System;
let j = serde_json::to_string(&a).unwrap();
assert_eq!(j, "{\"kind\":\"system\"}");
let back: ChannelActor = serde_json::from_str(&j).unwrap();
assert_eq!(back, ChannelActor::System);
}
#[test]
fn event_omits_sig_fields_when_absent_and_reads_old_rows() {
let ev = ChannelEvent {
seq: 0,
ts: Utc::now(),
actor: ChannelActor::System,
kind: EventKind::Note,
payload: serde_json::json!({}),
idempotency_key: None,
sig: None,
key_version: None,
};
let line = serde_json::to_string(&ev).unwrap();
assert!(!line.contains("sig"), "sig must be omitted when None");
assert!(
!line.contains("key_version"),
"key_version must be omitted when None"
);
let old = r#"{"seq":0,"ts":"2026-06-16T00:00:00Z","actor":{"kind":"system"},"kind":"note","payload":{}}"#;
let back: ChannelEvent = serde_json::from_str(old).unwrap();
assert_eq!(back.sig, None);
assert_eq!(back.key_version, None);
}
#[test]
fn legacy_manifest_without_purpose_deserializes_as_none() {
let json = r#"{
"v": 2,
"id": "019ed0af-5e38-7912-b554-dc335a8fc2db",
"title": "chat with mur",
"state": "working",
"owner": {"kind": "human", "name": "david"},
"participants": [],
"created_at": "2026-08-01T10:00:00Z",
"updated_at": "2026-08-01T10:00:00Z"
}"#;
let ch: Channel = serde_json::from_str(json).expect("legacy manifest must deserialize");
assert_eq!(
ch.purpose, None,
"absent purpose must be None, not a default"
);
}
#[test]
fn purpose_round_trips_in_kebab_case() {
let json = r#"{
"v": 2,
"id": "x",
"title": "t",
"state": "working",
"owner": {"kind": "system"},
"participants": [],
"purpose": "fleet-run",
"created_at": "2026-08-01T10:00:00Z",
"updated_at": "2026-08-01T10:00:00Z"
}"#;
let ch: Channel = serde_json::from_str(json).unwrap();
assert_eq!(ch.purpose, Some(ChannelPurpose::FleetRun));
let back = serde_json::to_string(&ch).unwrap();
assert!(back.contains(r#""purpose":"fleet-run""#), "got: {back}");
}
#[test]
fn purpose_is_omitted_when_none() {
let json = r#"{
"v": 2, "id": "x", "title": "t", "state": "working",
"owner": {"kind": "system"}, "participants": [],
"created_at": "2026-08-01T10:00:00Z",
"updated_at": "2026-08-01T10:00:00Z"
}"#;
let ch: Channel = serde_json::from_str(json).unwrap();
let back = serde_json::to_string(&ch).unwrap();
assert!(
!back.contains("purpose"),
"a None purpose must not be written back as null: {back}"
);
}
}