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