返回 CodeWhale
workroom.rs
根目录 / crates / protocol / src / 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
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
348 lines RUST