| 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 | |
| 9 | use chrono::{DateTime, Utc}; |
| 10 | use serde::{Deserialize, Serialize}; |
| 11 | use 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)] |
| 18 | pub struct WorkroomId(pub String); |
| 19 | |
| 20 | impl 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 | |
| 27 | impl Default for WorkroomId { |
| 28 | fn default() -> Self { |
| 29 | Self::new() |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | impl 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)] |
| 41 | pub 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)] |
| 54 | pub 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")] |
| 62 | pub 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)] |
| 71 | pub 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")] |
| 83 | pub 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")] |
| 96 | pub 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)] |
| 121 | pub 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")] |
| 133 | pub 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)] |
| 148 | pub 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)] |
| 156 | pub 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 | |
| 164 | impl 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 | |
| 224 | fn 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 | |
| 232 | fn 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)] |
| 242 | pub 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)] |
| 251 | pub struct WorkroomListResponse { |
| 252 | pub workrooms: Vec<WorkroomSummary>, |
| 253 | } |
| 254 | |
| 255 | /// Response from the `/workroom/resolve` endpoint. |
| 256 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 257 | pub 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)] |
| 265 | mod 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 | } |
| 348 |