1use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29pub const CHANNEL_SCHEMA_VERSION: u32 = 2;
33
34#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "kebab-case")]
45pub enum ChannelState {
46 Submitted,
47 Working,
48 InputRequired,
49 Completed,
50 Failed,
51 Canceled,
52 Rejected,
53 Stale,
56}
57
58#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
67#[serde(rename_all = "kebab-case")]
68pub enum ChannelPurpose {
69 Conversation,
70 FleetRun,
71 WorkflowRun,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77#[serde(tag = "kind", rename_all = "kebab-case")]
78pub enum ChannelActor {
79 Human { name: String },
80 Agent { id: String },
81 System,
82}
83
84impl ChannelActor {
85 pub fn local_human() -> Self {
87 let name = std::env::var("USER")
88 .or_else(|_| std::env::var("USERNAME"))
89 .ok()
90 .filter(|s| !s.is_empty())
91 .unwrap_or_else(|| "you".to_string());
92 ChannelActor::Human { name }
93 }
94}
95
96#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
98#[serde(rename_all = "lowercase")]
99pub enum ParticipantRole {
100 Owner,
101 Router,
102 Delegate,
103 Observer,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct Participant {
108 pub actor: ChannelActor,
109 pub role: ParticipantRole,
110 pub joined_at: DateTime<Utc>,
111}
112
113#[derive(Debug, Clone, Default, Serialize, Deserialize)]
115pub struct Goal {
116 #[serde(default)]
117 pub statement: String,
118 #[serde(default)]
119 pub acceptance_criteria: Vec<String>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct Channel {
125 pub v: u32,
126 pub id: String,
127 pub title: String,
128 #[serde(default)]
129 pub goal: Goal,
130 pub state: ChannelState,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub purpose: Option<ChannelPurpose>,
134 pub owner: ChannelActor,
135 #[serde(default)]
136 pub participants: Vec<Participant>,
137 pub created_at: DateTime<Utc>,
138 pub updated_at: DateTime<Utc>,
139}
140
141#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
142#[serde(rename_all = "kebab-case")]
143pub enum EventKind {
144 Message,
145 Delegation,
146 Handoff,
147 ToolCall,
148 ToolResult,
149 StateChange,
150 Artifact,
151 HitlRequest,
152 HitlResponse,
153 Note,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct ChannelEvent {
159 pub seq: u64,
160 pub ts: DateTime<Utc>,
161 pub actor: ChannelActor,
162 pub kind: EventKind,
163 #[serde(default)]
164 pub payload: serde_json::Value,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub idempotency_key: Option<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub sig: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub key_version: Option<u32>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub prev_hash: Option<String>,
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn channel_state_serializes_kebab() {
189 let j = serde_json::to_string(&ChannelState::InputRequired).unwrap();
190 assert_eq!(j, "\"input-required\"");
191 }
192
193 #[test]
194 fn event_round_trips() {
195 let ev = ChannelEvent {
196 seq: 3,
197 ts: Utc::now(),
198 actor: ChannelActor::Agent { id: "qa".into() },
199 kind: EventKind::Message,
200 payload: serde_json::json!({ "text": "hello", "task_id": "t-1" }),
201 idempotency_key: None,
202 sig: None,
203 key_version: None,
204 prev_hash: None,
205 };
206 let line = serde_json::to_string(&ev).unwrap();
207 let back: ChannelEvent = serde_json::from_str(&line).unwrap();
208 assert_eq!(back.seq, 3);
209 assert_eq!(back.actor, ChannelActor::Agent { id: "qa".into() });
210 assert_eq!(back.payload["text"], "hello");
211 }
212
213 #[test]
214 fn system_actor_round_trips() {
215 let a = ChannelActor::System;
216 let j = serde_json::to_string(&a).unwrap();
217 assert_eq!(j, "{\"kind\":\"system\"}");
218 let back: ChannelActor = serde_json::from_str(&j).unwrap();
219 assert_eq!(back, ChannelActor::System);
220 }
221
222 #[test]
223 fn event_omits_sig_fields_when_absent_and_reads_old_rows() {
224 let ev = ChannelEvent {
226 seq: 0,
227 ts: Utc::now(),
228 actor: ChannelActor::System,
229 kind: EventKind::Note,
230 payload: serde_json::json!({}),
231 idempotency_key: None,
232 sig: None,
233 key_version: None,
234 prev_hash: None,
235 };
236 let line = serde_json::to_string(&ev).unwrap();
237 assert!(!line.contains("sig"), "sig must be omitted when None");
238 assert!(
239 !line.contains("key_version"),
240 "key_version must be omitted when None"
241 );
242
243 let old = r#"{"seq":0,"ts":"2026-06-16T00:00:00Z","actor":{"kind":"system"},"kind":"note","payload":{}}"#;
245 let back: ChannelEvent = serde_json::from_str(old).unwrap();
246 assert_eq!(back.sig, None);
247 assert_eq!(back.key_version, None);
248 }
249
250 #[test]
251 fn legacy_manifest_without_purpose_deserializes_as_none() {
252 let json = r#"{
254 "v": 2,
255 "id": "019ed0af-5e38-7912-b554-dc335a8fc2db",
256 "title": "chat with mur",
257 "state": "working",
258 "owner": {"kind": "human", "name": "david"},
259 "participants": [],
260 "created_at": "2026-08-01T10:00:00Z",
261 "updated_at": "2026-08-01T10:00:00Z"
262 }"#;
263 let ch: Channel = serde_json::from_str(json).expect("legacy manifest must deserialize");
264 assert_eq!(
265 ch.purpose, None,
266 "absent purpose must be None, not a default"
267 );
268 }
269
270 #[test]
271 fn purpose_round_trips_in_kebab_case() {
272 let json = r#"{
273 "v": 2,
274 "id": "x",
275 "title": "t",
276 "state": "working",
277 "owner": {"kind": "system"},
278 "participants": [],
279 "purpose": "fleet-run",
280 "created_at": "2026-08-01T10:00:00Z",
281 "updated_at": "2026-08-01T10:00:00Z"
282 }"#;
283 let ch: Channel = serde_json::from_str(json).unwrap();
284 assert_eq!(ch.purpose, Some(ChannelPurpose::FleetRun));
285
286 let back = serde_json::to_string(&ch).unwrap();
287 assert!(back.contains(r#""purpose":"fleet-run""#), "got: {back}");
288 }
289
290 #[test]
291 fn purpose_is_omitted_when_none() {
292 let json = r#"{
293 "v": 2, "id": "x", "title": "t", "state": "working",
294 "owner": {"kind": "system"}, "participants": [],
295 "created_at": "2026-08-01T10:00:00Z",
296 "updated_at": "2026-08-01T10:00:00Z"
297 }"#;
298 let ch: Channel = serde_json::from_str(json).unwrap();
299 let back = serde_json::to_string(&ch).unwrap();
300 assert!(
301 !back.contains("purpose"),
302 "a None purpose must not be written back as null: {back}"
303 );
304 }
305}