1use std::path::PathBuf;
3
4use chrono::{DateTime, Utc};
5use objects::store::AgentUsageSummary;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
14pub struct ThreadId(String);
15
16impl ThreadId {
17 pub fn new(value: impl Into<String>) -> Result<Self, ThreadIdError> {
22 let value = value.into();
23 validate_thread_id(&value)?;
24 Ok(Self(value))
25 }
26
27 pub(crate) fn new_unchecked(value: impl Into<String>) -> Self {
32 Self(value.into())
33 }
34
35 pub fn as_str(&self) -> &str {
36 &self.0
37 }
38}
39
40impl std::fmt::Display for ThreadId {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 f.write_str(&self.0)
43 }
44}
45
46impl<'de> Deserialize<'de> for ThreadId {
47 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
48 where
49 D: serde::Deserializer<'de>,
50 {
51 let value = String::deserialize(deserializer)?;
55 Ok(Self::new_unchecked(value))
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ThreadIdError {
64 input: String,
65 suggestion: String,
66}
67
68impl std::fmt::Display for ThreadIdError {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 if self.input.is_empty() {
71 write!(f, "thread name must not be empty")
72 } else {
73 write!(
74 f,
75 "thread name '{}' is invalid: use only letters, digits, and _ - . / @ : + = \
76 (no spaces, shell metacharacters, '..' path segments, or a leading '/' or '-') — try '{}'",
77 self.input, self.suggestion
78 )
79 }
80 }
81}
82
83impl std::error::Error for ThreadIdError {}
84
85pub fn validate_thread_id(value: &str) -> Result<(), ThreadIdError> {
96 let safe_charset = value.bytes().all(|b| {
97 b.is_ascii_alphanumeric()
98 || matches!(b, b'_' | b'-' | b'.' | b'/' | b'@' | b':' | b'+' | b'=')
99 });
100 let ok = !value.is_empty()
101 && safe_charset
102 && !value.contains("..")
103 && !value.starts_with('/')
104 && !value.starts_with('-');
108 if ok {
109 Ok(())
110 } else {
111 Err(ThreadIdError {
112 input: value.to_string(),
113 suggestion: suggest_thread_id(value),
114 })
115 }
116}
117
118fn suggest_thread_id(value: &str) -> String {
122 let mut slug = String::with_capacity(value.len());
123 for ch in value.chars() {
124 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
125 slug.push(ch);
126 } else {
127 slug.push('-');
128 }
129 }
130 while slug.contains("--") {
131 slug = slug.replace("--", "-");
132 }
133 while slug.contains("..") {
134 slug = slug.replace("..", "-");
135 }
136 let trimmed = slug.trim_matches(|c| c == '-' || c == '.');
137 if trimmed.is_empty() {
138 "thread".to_string()
139 } else {
140 trimmed.to_string()
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "snake_case")]
168pub enum ThreadMode {
169 Materialized,
170 Virtualized,
171 Solid,
172}
173
174impl std::fmt::Display for ThreadMode {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 match self {
177 ThreadMode::Materialized => write!(f, "materialized"),
178 ThreadMode::Virtualized => write!(f, "virtualized"),
179 ThreadMode::Solid => write!(f, "solid"),
180 }
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "snake_case")]
186pub enum ThreadState {
187 Draft,
188 Active,
189 Ready,
190 Blocked,
191 Merged,
192 Abandoned,
193 Promoted,
194}
195
196impl std::fmt::Display for ThreadState {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 match self {
199 ThreadState::Draft => write!(f, "draft"),
200 ThreadState::Active => write!(f, "active"),
201 ThreadState::Ready => write!(f, "ready"),
202 ThreadState::Blocked => write!(f, "blocked"),
203 ThreadState::Merged => write!(f, "merged"),
204 ThreadState::Abandoned => write!(f, "abandoned"),
205 ThreadState::Promoted => write!(f, "promoted"),
206 }
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum ThreadFreshness {
213 Current,
214 Stale,
215 Unknown,
216}
217
218impl std::fmt::Display for ThreadFreshness {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 match self {
221 ThreadFreshness::Current => write!(f, "current"),
222 ThreadFreshness::Stale => write!(f, "stale"),
223 ThreadFreshness::Unknown => write!(f, "unknown"),
224 }
225 }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229#[serde(rename_all = "snake_case")]
230pub enum ThreadImpactCategory {
231 DependencyGraph,
232 BuildRuntimeConfig,
233 GeneratedOutputs,
234 RepoWideRefactor,
235 PublicApiSurface,
236}
237
238impl std::fmt::Display for ThreadImpactCategory {
239 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240 match self {
241 ThreadImpactCategory::DependencyGraph => write!(f, "dependency_graph"),
242 ThreadImpactCategory::BuildRuntimeConfig => write!(f, "build_runtime_config"),
243 ThreadImpactCategory::GeneratedOutputs => write!(f, "generated_outputs"),
244 ThreadImpactCategory::RepoWideRefactor => write!(f, "repo_wide_refactor"),
245 ThreadImpactCategory::PublicApiSurface => write!(f, "public_api_surface"),
246 }
247 }
248}
249
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum ConfidenceBand {
253 Low,
254 Medium,
255 High,
256}
257
258impl std::fmt::Display for ConfidenceBand {
259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260 match self {
261 ConfidenceBand::Low => write!(f, "low"),
262 ConfidenceBand::Medium => write!(f, "medium"),
263 ConfidenceBand::High => write!(f, "high"),
264 }
265 }
266}
267
268#[derive(Debug, Clone, Default, Serialize, Deserialize)]
269pub struct ThreadVerificationSummary {
270 #[serde(default)]
271 pub tests_passed: Option<bool>,
272 #[serde(default)]
273 pub tests_failed: Option<u32>,
274 #[serde(default)]
275 pub coverage_pct: Option<f32>,
276 #[serde(default)]
277 pub lint_warnings: Option<u32>,
278}
279
280#[derive(Debug, Clone, Default, Serialize, Deserialize)]
281pub struct ThreadConfidenceSummary {
282 #[serde(default)]
283 pub value: Option<f32>,
284 #[serde(default)]
285 pub band: Option<ConfidenceBand>,
286}
287
288#[derive(Debug, Clone, Default, Serialize, Deserialize)]
289pub struct ThreadIntegrationPolicy {
290 #[serde(default)]
291 pub status: Option<String>,
292 #[serde(default)]
293 pub reason: Option<String>,
294 #[serde(default)]
295 pub manual_resolution_state: Option<String>,
296 #[serde(default)]
306 pub conflicts_resolved_manually: bool,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct ThreadRecord {
311 pub id: String,
312 pub thread: String,
313 pub target_thread: Option<String>,
314 pub parent_thread: Option<String>,
315 pub mode: ThreadMode,
316 pub state: ThreadState,
317 pub base_state: String,
318 pub base_root: String,
319 pub current_state: Option<String>,
320 pub merged_state: Option<String>,
321 pub task: Option<String>,
322 pub changed_paths: Vec<String>,
323 pub impact_categories: Vec<ThreadImpactCategory>,
324 pub heavy_impact_paths: Vec<String>,
325 pub promotion_suggested: bool,
326 pub freshness: ThreadFreshness,
327 pub verification_summary: ThreadVerificationSummary,
328 pub confidence_summary: ThreadConfidenceSummary,
329 pub integration_policy_result: ThreadIntegrationPolicy,
330 pub created_at: DateTime<Utc>,
331 pub updated_at: DateTime<Utc>,
332 pub ephemeral: Option<EphemeralMarker>,
340
341 pub auto: bool,
349
350 pub shared_target_dir: Option<PathBuf>,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct EphemeralMarker {
370 pub ttl_seconds: u32,
372 pub created_at: DateTime<Utc>,
376 #[serde(default = "default_auto_collapse")]
381 pub auto_collapse: bool,
382}
383
384fn default_auto_collapse() -> bool {
385 true
386}
387
388impl EphemeralMarker {
389 pub fn new(ttl_seconds: u32) -> Self {
390 Self {
391 ttl_seconds,
392 created_at: Utc::now(),
393 auto_collapse: true,
394 }
395 }
396
397 pub fn expires_at(&self) -> DateTime<Utc> {
399 self.created_at + chrono::Duration::seconds(self.ttl_seconds as i64)
400 }
401
402 pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
404 now >= self.expires_at()
405 }
406}
407
408impl ThreadRecord {
409 pub fn thread_id(&self) -> ThreadId {
410 ThreadId::new_unchecked(self.id.clone())
412 }
413}
414
415#[derive(Debug, Clone, Default, Serialize, Deserialize)]
416pub struct ThreadRuntimeOverlay {
417 #[serde(default)]
418 pub path: Option<PathBuf>,
419 #[serde(default)]
420 pub execution_path: Option<PathBuf>,
421 #[serde(default)]
422 pub materialized_path: Option<PathBuf>,
423 #[serde(default)]
424 pub session_id: Option<String>,
425 #[serde(default)]
426 pub heddle_session_id: Option<String>,
427 #[serde(default)]
428 pub provider: Option<String>,
429 #[serde(default)]
430 pub model: Option<String>,
431 #[serde(default)]
432 pub harness: Option<String>,
433 #[serde(default)]
434 pub thinking_level: Option<String>,
435 #[serde(default)]
436 pub native_actor_key: Option<String>,
437 #[serde(default)]
438 pub native_parent_actor_key: Option<String>,
439 #[serde(default)]
440 pub probe_source: Option<String>,
441 #[serde(default)]
442 pub probe_confidence: Option<f32>,
443 #[serde(default)]
444 pub usage_summary: Option<AgentUsageSummary>,
445 #[serde(default)]
446 pub last_progress_at: Option<DateTime<Utc>>,
447 #[serde(default)]
448 pub report_flush_state: Option<String>,
449 #[serde(default)]
450 pub attach_reason: Option<String>,
451 #[serde(default)]
452 pub thread_mode: Option<ThreadMode>,
453 #[serde(default)]
454 pub thread_state: Option<ThreadState>,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct ThreadView {
459 pub record: ThreadRecord,
460 pub runtime: ThreadRuntimeOverlay,
461 pub is_current: bool,
462 pub is_isolated: bool,
463}
464
465impl ThreadView {
466 pub fn from_record(
467 record: ThreadRecord,
468 runtime: ThreadRuntimeOverlay,
469 is_current: bool,
470 ) -> Self {
471 let is_isolated = path_present(runtime.path.as_ref())
472 || path_present(runtime.execution_path.as_ref())
473 || path_present(runtime.materialized_path.as_ref());
474 Self {
475 record,
476 runtime,
477 is_current,
478 is_isolated,
479 }
480 }
481}
482
483fn path_present(path: Option<&PathBuf>) -> bool {
484 path.is_some_and(|path| !path.as_os_str().is_empty())
485}
486
487#[cfg(test)]
488mod thread_id_tests {
489 use super::*;
490
491 #[test]
492 fn accepts_safe_slugs() {
493 for ok in [
494 "feature/x",
495 "v1.2",
496 "a_b-c.d",
497 "team@scope",
498 "main",
499 "wip+1=2",
500 ] {
501 assert!(
502 ThreadId::new(ok).is_ok(),
503 "expected '{ok}' to be a valid thread id"
504 );
505 }
506 }
507
508 #[test]
509 fn rejects_whitespace_metachars_traversal_and_empty() {
510 for bad in [
511 "my feature", "a;b", "a|b", "a$(x)", "a\nb", "a&b", "`x`", "..", "a/../b", "/abs", "-foo", "--bar", "", ] {
525 assert!(
526 ThreadId::new(bad).is_err(),
527 "expected '{bad}' to be rejected as an invalid thread id"
528 );
529 }
530 }
531
532 #[test]
533 fn error_message_carries_a_valid_rename_hint() {
534 let err = ThreadId::new("my feature").unwrap_err();
535 let msg = err.to_string();
536 assert!(
537 msg.contains("my feature"),
538 "names the offending input: {msg}"
539 );
540 assert!(msg.contains("try 'my-feature'"), "suggests a rename: {msg}");
541 assert!(ThreadId::new(err.suggestion.as_str()).is_ok());
543 }
544
545 #[test]
546 fn deserialize_trusts_persisted_ids_without_revalidating() {
547 let id: ThreadId = serde_json::from_str("\"legacy id\"").unwrap();
550 assert_eq!(id.as_str(), "legacy id");
551 }
552}