Skip to main content

codewhale_protocol/
workroom.rs

1//! Workroom types — durable chat-native containers for threaded agent work.
2//!
3//! A [`Workroom`] groups threads, events, and external references into a
4//! stable, addressable surface that can be accessed from the TUI, mobile page,
5//! chat bridges, and programmatic Runtime API consumers.
6//!
7//! See [RFC 3209](../../docs/rfcs/3209-workrooms.md) for the full design.
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12/// Unique identifier for a workroom.
13///
14/// Stable across restarts. Opaque to callers; generated via UUID v4 with a
15/// `wr_` prefix for link recognition.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
17pub struct WorkroomId(pub String);
18
19impl WorkroomId {
20    /// Create a new workroom id from a UUID v4 string.
21    pub fn new() -> Self {
22        Self(format!("wr_{}", uuid::Uuid::new_v4().simple()))
23    }
24}
25
26impl Default for WorkroomId {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl std::fmt::Display for WorkroomId {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        write!(f, "{}", self.0)
35    }
36}
37
38/// A durable container for threaded agent conversations.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct Workroom {
41    pub id: WorkroomId,
42    pub title: String,
43    pub workspace: Option<String>,
44    pub repo_identity: Option<RepoRef>,
45    pub owner: String,
46    pub created_at: DateTime<Utc>,
47    pub updated_at: DateTime<Utc>,
48    pub visibility: WorkroomVisibility,
49}
50
51/// GitHub repository identity attached to a workroom.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct RepoRef {
54    pub owner: String,
55    pub name: String,
56}
57
58/// Visibility controls for a workroom.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum WorkroomVisibility {
62    /// Only the local user can access.
63    Private,
64    /// Accessible to callers bearing one of the listed bearer tokens.
65    Shared { allowed_tokens: Vec<String> },
66}
67
68/// A thread within a workroom.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct WorkroomThread {
71    pub id: String,
72    pub workroom_id: WorkroomId,
73    pub title: String,
74    pub kind: WorkroomThreadKind,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub external_ref: Option<ExternalThreadRef>,
77    pub created_at: DateTime<Utc>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81#[serde(rename_all = "snake_case")]
82pub enum WorkroomThreadKind {
83    Channel,
84    DirectMessage,
85    AgentTask,
86    ApprovalQueue,
87    ReceiptLog,
88}
89
90/// An external reference that can be attached to a workroom thread.
91///
92/// Stores only metadata — no API keys, tokens, or secrets.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(tag = "kind", rename_all = "snake_case")]
95pub enum ExternalThreadRef {
96    GitHubIssue {
97        owner: String,
98        repo: String,
99        number: u64,
100    },
101    GitHubPullRequest {
102        owner: String,
103        repo: String,
104        number: u64,
105    },
106    GitHubCommit {
107        owner: String,
108        repo: String,
109        sha: String,
110    },
111    GitHubCheck {
112        owner: String,
113        repo: String,
114        check_run_id: u64,
115    },
116}
117
118/// An event within a workroom thread, attributed to a specific agent/model.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct WorkroomEvent {
121    pub id: String,
122    pub thread_id: String,
123    pub workroom_id: WorkroomId,
124    pub timestamp: DateTime<Utc>,
125    pub kind: WorkroomEventKind,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub agent: Option<AgentAttribution>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(tag = "event", rename_all = "snake_case")]
132pub enum WorkroomEventKind {
133    Message { content: String },
134    Mention { mentioned_user: String },
135    ToolCall { tool_name: String, summary: String },
136    ToolResult { tool_name: String, success: bool },
137    ApprovalRequest { tool_name: String },
138    ArtifactLinked { path: String, kind: String },
139    Receipt { summary: String },
140    Failure { error: String },
141    NeedsHuman { reason: String },
142    Resumed,
143}
144
145/// Attribution metadata recording which agent and model produced an event.
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct AgentAttribution {
148    pub provider: String,
149    pub model: String,
150    pub agent_id: String,
151}
152
153/// A shareable link that resolves to a workroom, thread, or event.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct WorkroomLink {
156    pub workroom_id: WorkroomId,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub thread_id: Option<String>,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub event_id: Option<String>,
161}
162
163impl WorkroomLink {
164    /// Parse a `codewhale://workroom/...` URL.
165    ///
166    /// Accepted forms:
167    /// - `codewhale://workroom/wr_<id>`
168    /// - `codewhale://workroom/wr_<id>/thread/<thread_id>`
169    /// - `codewhale://workroom/wr_<id>/event/<event_id>`
170    pub fn parse(url: &str) -> Option<Self> {
171        let rest = url.strip_prefix("codewhale://workroom/")?;
172        let mut segments = rest.split('/');
173        let workroom_id = parse_segment_with_prefix(segments.next()?, "wr_")?;
174        let next = segments.next();
175        let (thread_id, event_id) = match next {
176            None => (None, None),
177            Some("thread") => {
178                let thread_id = non_empty_segment(segments.next()?)?;
179                match segments.next() {
180                    None => (Some(thread_id), None),
181                    Some("event") => {
182                        let event_id = non_empty_segment(segments.next()?)?;
183                        if segments.next().is_some() {
184                            return None;
185                        }
186                        (Some(thread_id), Some(event_id))
187                    }
188                    _ => return None,
189                }
190            }
191            Some("event") => {
192                let event_id = non_empty_segment(segments.next()?)?;
193                if segments.next().is_some() {
194                    return None;
195                }
196                (None, Some(event_id))
197            }
198            _ => return None,
199        };
200
201        Some(Self {
202            workroom_id: WorkroomId(workroom_id),
203            thread_id,
204            event_id,
205        })
206    }
207
208    /// Serialise back to the `codewhale://workroom/...` URL form.
209    pub fn to_url(&self) -> String {
210        let mut url = format!("codewhale://workroom/{}", self.workroom_id);
211        if let Some(ref thread_id) = self.thread_id {
212            url.push_str(&format!("/thread/{thread_id}"));
213            if let Some(ref event_id) = self.event_id {
214                url.push_str(&format!("/event/{event_id}"));
215            }
216        } else if let Some(ref event_id) = self.event_id {
217            url.push_str(&format!("/event/{event_id}"));
218        }
219        url
220    }
221}
222
223fn parse_segment_with_prefix(segment: &str, prefix: &str) -> Option<String> {
224    let segment = non_empty_segment(segment)?;
225    if segment.len() == prefix.len() || !segment.starts_with(prefix) {
226        return None;
227    }
228    Some(segment)
229}
230
231fn non_empty_segment(segment: &str) -> Option<String> {
232    if segment.is_empty() {
233        None
234    } else {
235        Some(segment.to_string())
236    }
237}
238
239/// Summary projection of a workroom for list/inbox views.
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct WorkroomSummary {
242    pub id: WorkroomId,
243    pub title: String,
244    pub updated_at: DateTime<Utc>,
245    pub active_threads: usize,
246}
247
248/// Paginated list of workrooms.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct WorkroomListResponse {
251    pub workrooms: Vec<WorkroomSummary>,
252}
253
254/// Response from the `/workroom/resolve` endpoint.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct WorkroomResolveResponse {
257    pub link: WorkroomLink,
258    pub thread_title: Option<String>,
259    pub external_ref: Option<ExternalThreadRef>,
260    pub recent_events: Vec<WorkroomEvent>,
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn workroom_id_new_is_stable() {
269        let id = WorkroomId::new();
270        assert!(id.0.starts_with("wr_"));
271        assert_eq!(id.0.len(), 35); // "wr_" + 32 hex chars
272    }
273
274    #[test]
275    fn workroom_link_parse_workroom_only() {
276        let link = WorkroomLink::parse("codewhale://workroom/wr_abc123def456").unwrap();
277        assert_eq!(link.workroom_id.0, "wr_abc123def456");
278        assert!(link.thread_id.is_none());
279        assert!(link.event_id.is_none());
280    }
281
282    #[test]
283    fn workroom_link_parse_with_thread() {
284        let link = WorkroomLink::parse("codewhale://workroom/wr_abc/thread/thr_xyz").unwrap();
285        assert_eq!(link.workroom_id.0, "wr_abc");
286        assert_eq!(link.thread_id.as_deref(), Some("thr_xyz"));
287        assert!(link.event_id.is_none());
288    }
289
290    #[test]
291    fn workroom_link_parse_with_event() {
292        let link = WorkroomLink::parse("codewhale://workroom/wr_abc/event/evt_789").unwrap();
293        assert_eq!(link.workroom_id.0, "wr_abc");
294        assert_eq!(link.event_id.as_deref(), Some("evt_789"));
295        assert!(link.thread_id.is_none());
296    }
297
298    #[test]
299    fn workroom_link_roundtrip() {
300        let original = "codewhale://workroom/wr_abc/thread/thr_x/event/evt_y";
301        let parsed = WorkroomLink::parse(original).unwrap();
302        assert_eq!(parsed.to_url(), original);
303    }
304
305    #[test]
306    fn workroom_link_reject_bad_prefix() {
307        assert!(WorkroomLink::parse("http://workroom/wr_abc").is_none());
308        assert!(WorkroomLink::parse("codewhale://not-workroom/wr_abc").is_none());
309    }
310
311    #[test]
312    fn workroom_link_rejects_malformed_paths() {
313        assert!(WorkroomLink::parse("codewhale://workroom/").is_none());
314        assert!(WorkroomLink::parse("codewhale://workroom/abc").is_none());
315        assert!(WorkroomLink::parse("codewhale://workroom/wr_").is_none());
316        assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/thread").is_none());
317        assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/thread/").is_none());
318        assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/unknown/x").is_none());
319        assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/event/evt/x").is_none());
320    }
321
322    #[test]
323    fn external_thread_ref_serde_roundtrip() {
324        let issue = ExternalThreadRef::GitHubIssue {
325            owner: "Hmbown".into(),
326            repo: "CodeWhale".into(),
327            number: 3209,
328        };
329        let json = serde_json::to_string(&issue).unwrap();
330        let back: ExternalThreadRef = serde_json::from_str(&json).unwrap();
331        assert!(matches!(back, ExternalThreadRef::GitHubIssue { .. }));
332    }
333
334    #[test]
335    fn agent_attribution_serde_roundtrip() {
336        let attr = AgentAttribution {
337            provider: "deepseek".into(),
338            model: "deepseek-v4-pro".into(),
339            agent_id: "sub_agent_1".into(),
340        };
341        let json = serde_json::to_string(&attr).unwrap();
342        let back: AgentAttribution = serde_json::from_str(&json).unwrap();
343        assert_eq!(back.provider, "deepseek");
344        assert_eq!(back.model, "deepseek-v4-pro");
345    }
346}