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}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn channel_state_serializes_kebab() {
184 let j = serde_json::to_string(&ChannelState::InputRequired).unwrap();
185 assert_eq!(j, "\"input-required\"");
186 }
187
188 #[test]
189 fn event_round_trips() {
190 let ev = ChannelEvent {
191 seq: 3,
192 ts: Utc::now(),
193 actor: ChannelActor::Agent { id: "qa".into() },
194 kind: EventKind::Message,
195 payload: serde_json::json!({ "text": "hello", "task_id": "t-1" }),
196 idempotency_key: None,
197 sig: None,
198 key_version: None,
199 };
200 let line = serde_json::to_string(&ev).unwrap();
201 let back: ChannelEvent = serde_json::from_str(&line).unwrap();
202 assert_eq!(back.seq, 3);
203 assert_eq!(back.actor, ChannelActor::Agent { id: "qa".into() });
204 assert_eq!(back.payload["text"], "hello");
205 }
206
207 #[test]
208 fn system_actor_round_trips() {
209 let a = ChannelActor::System;
210 let j = serde_json::to_string(&a).unwrap();
211 assert_eq!(j, "{\"kind\":\"system\"}");
212 let back: ChannelActor = serde_json::from_str(&j).unwrap();
213 assert_eq!(back, ChannelActor::System);
214 }
215
216 #[test]
217 fn event_omits_sig_fields_when_absent_and_reads_old_rows() {
218 let ev = ChannelEvent {
220 seq: 0,
221 ts: Utc::now(),
222 actor: ChannelActor::System,
223 kind: EventKind::Note,
224 payload: serde_json::json!({}),
225 idempotency_key: None,
226 sig: None,
227 key_version: None,
228 };
229 let line = serde_json::to_string(&ev).unwrap();
230 assert!(!line.contains("sig"), "sig must be omitted when None");
231 assert!(
232 !line.contains("key_version"),
233 "key_version must be omitted when None"
234 );
235
236 let old = r#"{"seq":0,"ts":"2026-06-16T00:00:00Z","actor":{"kind":"system"},"kind":"note","payload":{}}"#;
238 let back: ChannelEvent = serde_json::from_str(old).unwrap();
239 assert_eq!(back.sig, None);
240 assert_eq!(back.key_version, None);
241 }
242
243 #[test]
244 fn legacy_manifest_without_purpose_deserializes_as_none() {
245 let json = r#"{
247 "v": 2,
248 "id": "019ed0af-5e38-7912-b554-dc335a8fc2db",
249 "title": "chat with mur",
250 "state": "working",
251 "owner": {"kind": "human", "name": "david"},
252 "participants": [],
253 "created_at": "2026-08-01T10:00:00Z",
254 "updated_at": "2026-08-01T10:00:00Z"
255 }"#;
256 let ch: Channel = serde_json::from_str(json).expect("legacy manifest must deserialize");
257 assert_eq!(
258 ch.purpose, None,
259 "absent purpose must be None, not a default"
260 );
261 }
262
263 #[test]
264 fn purpose_round_trips_in_kebab_case() {
265 let json = r#"{
266 "v": 2,
267 "id": "x",
268 "title": "t",
269 "state": "working",
270 "owner": {"kind": "system"},
271 "participants": [],
272 "purpose": "fleet-run",
273 "created_at": "2026-08-01T10:00:00Z",
274 "updated_at": "2026-08-01T10:00:00Z"
275 }"#;
276 let ch: Channel = serde_json::from_str(json).unwrap();
277 assert_eq!(ch.purpose, Some(ChannelPurpose::FleetRun));
278
279 let back = serde_json::to_string(&ch).unwrap();
280 assert!(back.contains(r#""purpose":"fleet-run""#), "got: {back}");
281 }
282
283 #[test]
284 fn purpose_is_omitted_when_none() {
285 let json = r#"{
286 "v": 2, "id": "x", "title": "t", "state": "working",
287 "owner": {"kind": "system"}, "participants": [],
288 "created_at": "2026-08-01T10:00:00Z",
289 "updated_at": "2026-08-01T10:00:00Z"
290 }"#;
291 let ch: Channel = serde_json::from_str(json).unwrap();
292 let back = serde_json::to_string(&ch).unwrap();
293 assert!(
294 !back.contains("purpose"),
295 "a None purpose must not be written back as null: {back}"
296 );
297 }
298}