use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct WorkroomId(pub String);
impl WorkroomId {
pub fn new() -> Self {
Self(format!("wr_{}", uuid::Uuid::new_v4().simple()))
}
}
impl Default for WorkroomId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for WorkroomId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workroom {
pub id: WorkroomId,
pub title: String,
pub workspace: Option<String>,
pub repo_identity: Option<RepoRef>,
pub owner: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub visibility: WorkroomVisibility,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoRef {
pub owner: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkroomVisibility {
Private,
Shared { allowed_tokens: Vec<String> },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkroomThread {
pub id: String,
pub workroom_id: WorkroomId,
pub title: String,
pub kind: WorkroomThreadKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_ref: Option<ExternalThreadRef>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorkroomThreadKind {
Channel,
DirectMessage,
AgentTask,
ApprovalQueue,
ReceiptLog,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ExternalThreadRef {
GitHubIssue {
owner: String,
repo: String,
number: u64,
},
GitHubPullRequest {
owner: String,
repo: String,
number: u64,
},
GitHubCommit {
owner: String,
repo: String,
sha: String,
},
GitHubCheck {
owner: String,
repo: String,
check_run_id: u64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkroomEvent {
pub id: String,
pub thread_id: String,
pub workroom_id: WorkroomId,
pub timestamp: DateTime<Utc>,
pub kind: WorkroomEventKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<AgentAttribution>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum WorkroomEventKind {
Message { content: String },
Mention { mentioned_user: String },
ToolCall { tool_name: String, summary: String },
ToolResult { tool_name: String, success: bool },
ApprovalRequest { tool_name: String },
ArtifactLinked { path: String, kind: String },
Receipt { summary: String },
Failure { error: String },
NeedsHuman { reason: String },
Resumed,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentAttribution {
pub provider: String,
pub model: String,
pub agent_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkroomLink {
pub workroom_id: WorkroomId,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub event_id: Option<String>,
}
impl WorkroomLink {
pub fn parse(url: &str) -> Option<Self> {
let rest = url.strip_prefix("codewhale://workroom/")?;
let mut segments = rest.split('/');
let workroom_id = parse_segment_with_prefix(segments.next()?, "wr_")?;
let next = segments.next();
let (thread_id, event_id) = match next {
None => (None, None),
Some("thread") => {
let thread_id = non_empty_segment(segments.next()?)?;
match segments.next() {
None => (Some(thread_id), None),
Some("event") => {
let event_id = non_empty_segment(segments.next()?)?;
if segments.next().is_some() {
return None;
}
(Some(thread_id), Some(event_id))
}
_ => return None,
}
}
Some("event") => {
let event_id = non_empty_segment(segments.next()?)?;
if segments.next().is_some() {
return None;
}
(None, Some(event_id))
}
_ => return None,
};
Some(Self {
workroom_id: WorkroomId(workroom_id),
thread_id,
event_id,
})
}
pub fn to_url(&self) -> String {
let mut url = format!("codewhale://workroom/{}", self.workroom_id);
if let Some(ref thread_id) = self.thread_id {
url.push_str(&format!("/thread/{thread_id}"));
if let Some(ref event_id) = self.event_id {
url.push_str(&format!("/event/{event_id}"));
}
} else if let Some(ref event_id) = self.event_id {
url.push_str(&format!("/event/{event_id}"));
}
url
}
}
fn parse_segment_with_prefix(segment: &str, prefix: &str) -> Option<String> {
let segment = non_empty_segment(segment)?;
if segment.len() == prefix.len() || !segment.starts_with(prefix) {
return None;
}
Some(segment)
}
fn non_empty_segment(segment: &str) -> Option<String> {
if segment.is_empty() {
None
} else {
Some(segment.to_string())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkroomSummary {
pub id: WorkroomId,
pub title: String,
pub updated_at: DateTime<Utc>,
pub active_threads: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkroomListResponse {
pub workrooms: Vec<WorkroomSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkroomResolveResponse {
pub link: WorkroomLink,
pub thread_title: Option<String>,
pub external_ref: Option<ExternalThreadRef>,
pub recent_events: Vec<WorkroomEvent>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workroom_id_new_is_stable() {
let id = WorkroomId::new();
assert!(id.0.starts_with("wr_"));
assert_eq!(id.0.len(), 35); }
#[test]
fn workroom_link_parse_workroom_only() {
let link = WorkroomLink::parse("codewhale://workroom/wr_abc123def456").unwrap();
assert_eq!(link.workroom_id.0, "wr_abc123def456");
assert!(link.thread_id.is_none());
assert!(link.event_id.is_none());
}
#[test]
fn workroom_link_parse_with_thread() {
let link = WorkroomLink::parse("codewhale://workroom/wr_abc/thread/thr_xyz").unwrap();
assert_eq!(link.workroom_id.0, "wr_abc");
assert_eq!(link.thread_id.as_deref(), Some("thr_xyz"));
assert!(link.event_id.is_none());
}
#[test]
fn workroom_link_parse_with_event() {
let link = WorkroomLink::parse("codewhale://workroom/wr_abc/event/evt_789").unwrap();
assert_eq!(link.workroom_id.0, "wr_abc");
assert_eq!(link.event_id.as_deref(), Some("evt_789"));
assert!(link.thread_id.is_none());
}
#[test]
fn workroom_link_roundtrip() {
let original = "codewhale://workroom/wr_abc/thread/thr_x/event/evt_y";
let parsed = WorkroomLink::parse(original).unwrap();
assert_eq!(parsed.to_url(), original);
}
#[test]
fn workroom_link_reject_bad_prefix() {
assert!(WorkroomLink::parse("http://workroom/wr_abc").is_none());
assert!(WorkroomLink::parse("codewhale://not-workroom/wr_abc").is_none());
}
#[test]
fn workroom_link_rejects_malformed_paths() {
assert!(WorkroomLink::parse("codewhale://workroom/").is_none());
assert!(WorkroomLink::parse("codewhale://workroom/abc").is_none());
assert!(WorkroomLink::parse("codewhale://workroom/wr_").is_none());
assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/thread").is_none());
assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/thread/").is_none());
assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/unknown/x").is_none());
assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/event/evt/x").is_none());
}
#[test]
fn external_thread_ref_serde_roundtrip() {
let issue = ExternalThreadRef::GitHubIssue {
owner: "Hmbown".into(),
repo: "CodeWhale".into(),
number: 3209,
};
let json = serde_json::to_string(&issue).unwrap();
let back: ExternalThreadRef = serde_json::from_str(&json).unwrap();
assert!(matches!(back, ExternalThreadRef::GitHubIssue { .. }));
}
#[test]
fn agent_attribution_serde_roundtrip() {
let attr = AgentAttribution {
provider: "deepseek".into(),
model: "deepseek-v4-pro".into(),
agent_id: "sub_agent_1".into(),
};
let json = serde_json::to_string(&attr).unwrap();
let back: AgentAttribution = serde_json::from_str(&json).unwrap();
assert_eq!(back.provider, "deepseek");
assert_eq!(back.model, "deepseek-v4-pro");
}
}