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