1use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::fmt::Write;
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
18pub struct WorkroomId(pub String);
19
20impl WorkroomId {
21 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct RepoRef {
55 pub owner: String,
56 pub name: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum WorkroomVisibility {
63 Private,
65 Shared { allowed_tokens: Vec<String> },
67}
68
69#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct AgentAttribution {
149 pub provider: String,
150 pub model: String,
151 pub agent_id: String,
152}
153
154#[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 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 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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct WorkroomListResponse {
252 pub workrooms: Vec<WorkroomSummary>,
253}
254
255#[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); }
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}