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 && !objects::object::is_reserved_heddle_namespace(value);
109 if ok {
110 Ok(())
111 } else {
112 Err(ThreadIdError {
113 input: value.to_string(),
114 suggestion: suggest_thread_id(value),
115 })
116 }
117}
118
119fn suggest_thread_id(value: &str) -> String {
123 let value = if objects::object::is_reserved_heddle_namespace(value) {
124 value.split_once('/').map(|(_, rest)| rest).unwrap_or(value)
125 } else {
126 value
127 };
128 let mut slug = String::with_capacity(value.len());
129 for ch in value.chars() {
130 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
131 slug.push(ch);
132 } else {
133 slug.push('-');
134 }
135 }
136 while slug.contains("--") {
137 slug = slug.replace("--", "-");
138 }
139 while slug.contains("..") {
140 slug = slug.replace("..", "-");
141 }
142 let trimmed = slug.trim_matches(|c| c == '-' || c == '.');
143 if trimmed.is_empty() {
144 "thread".to_string()
145 } else {
146 trimmed.to_string()
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174pub enum ThreadMode {
175 Materialized,
176 Virtualized,
177 Solid,
178}
179
180impl std::fmt::Display for ThreadMode {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 match self {
183 ThreadMode::Materialized => write!(f, "materialized"),
184 ThreadMode::Virtualized => write!(f, "virtualized"),
185 ThreadMode::Solid => write!(f, "solid"),
186 }
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum ThreadState {
193 Draft,
194 Active,
195 Ready,
196 Blocked,
197 Merged,
198 Abandoned,
199 Promoted,
200}
201
202impl std::fmt::Display for ThreadState {
203 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204 match self {
205 ThreadState::Draft => write!(f, "draft"),
206 ThreadState::Active => write!(f, "active"),
207 ThreadState::Ready => write!(f, "ready"),
208 ThreadState::Blocked => write!(f, "blocked"),
209 ThreadState::Merged => write!(f, "merged"),
210 ThreadState::Abandoned => write!(f, "abandoned"),
211 ThreadState::Promoted => write!(f, "promoted"),
212 }
213 }
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(rename_all = "snake_case")]
218pub enum ThreadFreshness {
219 Current,
220 Stale,
221 Unknown,
222}
223
224impl std::fmt::Display for ThreadFreshness {
225 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226 match self {
227 ThreadFreshness::Current => write!(f, "current"),
228 ThreadFreshness::Stale => write!(f, "stale"),
229 ThreadFreshness::Unknown => write!(f, "unknown"),
230 }
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "snake_case")]
236pub enum ThreadImpactCategory {
237 DependencyGraph,
238 BuildRuntimeConfig,
239 GeneratedOutputs,
240 RepoWideRefactor,
241 PublicApiSurface,
242}
243
244impl std::fmt::Display for ThreadImpactCategory {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 match self {
247 ThreadImpactCategory::DependencyGraph => write!(f, "dependency_graph"),
248 ThreadImpactCategory::BuildRuntimeConfig => write!(f, "build_runtime_config"),
249 ThreadImpactCategory::GeneratedOutputs => write!(f, "generated_outputs"),
250 ThreadImpactCategory::RepoWideRefactor => write!(f, "repo_wide_refactor"),
251 ThreadImpactCategory::PublicApiSurface => write!(f, "public_api_surface"),
252 }
253 }
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(rename_all = "snake_case")]
258pub enum ConfidenceBand {
259 Low,
260 Medium,
261 High,
262}
263
264impl std::fmt::Display for ConfidenceBand {
265 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266 match self {
267 ConfidenceBand::Low => write!(f, "low"),
268 ConfidenceBand::Medium => write!(f, "medium"),
269 ConfidenceBand::High => write!(f, "high"),
270 }
271 }
272}
273
274#[derive(Debug, Clone, Default, Serialize, Deserialize)]
275pub struct ThreadVerificationSummary {
276 #[serde(default)]
277 pub tests_passed: Option<bool>,
278 #[serde(default)]
279 pub tests_failed: Option<u32>,
280 #[serde(default)]
281 pub coverage_pct: Option<f32>,
282 #[serde(default)]
283 pub lint_warnings: Option<u32>,
284}
285
286#[derive(Debug, Clone, Default, Serialize, Deserialize)]
287pub struct ThreadConfidenceSummary {
288 #[serde(default)]
289 pub value: Option<f32>,
290 #[serde(default)]
291 pub band: Option<ConfidenceBand>,
292}
293
294#[derive(Debug, Clone, Default, Serialize, Deserialize)]
295pub struct ThreadIntegrationPolicy {
296 #[serde(default)]
297 pub status: Option<String>,
298 #[serde(default)]
299 pub reason: Option<String>,
300 #[serde(default)]
301 pub manual_resolution_state: Option<String>,
302 #[serde(default)]
312 pub conflicts_resolved_manually: bool,
313}
314
315impl ThreadIntegrationPolicy {
316 pub fn clear_untrusted_landing_fields(&mut self) {
318 self.manual_resolution_state = None;
319 self.conflicts_resolved_manually = false;
320 }
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct ThreadRecord {
325 pub id: String,
326 pub thread: String,
327 pub target_thread: Option<String>,
328 pub parent_thread: Option<String>,
329 pub mode: ThreadMode,
330 pub state: ThreadState,
331 pub base_state: String,
332 pub base_root: String,
333 pub current_state: Option<String>,
334 pub merged_state: Option<String>,
335 pub task: Option<String>,
336 pub changed_paths: Vec<String>,
337 pub impact_categories: Vec<ThreadImpactCategory>,
338 pub heavy_impact_paths: Vec<String>,
339 pub promotion_suggested: bool,
340 pub freshness: ThreadFreshness,
341 pub verification_summary: ThreadVerificationSummary,
342 pub confidence_summary: ThreadConfidenceSummary,
343 pub integration_policy_result: ThreadIntegrationPolicy,
344 pub created_at: DateTime<Utc>,
345 pub updated_at: DateTime<Utc>,
346 pub ephemeral: Option<EphemeralMarker>,
354
355 pub auto: bool,
363
364 pub shared_target_dir: Option<PathBuf>,
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383pub struct EphemeralMarker {
384 pub ttl_seconds: u32,
386 pub created_at: DateTime<Utc>,
390 #[serde(default = "default_auto_collapse")]
395 pub auto_collapse: bool,
396}
397
398fn default_auto_collapse() -> bool {
399 true
400}
401
402impl EphemeralMarker {
403 pub fn new(ttl_seconds: u32) -> Self {
404 Self {
405 ttl_seconds,
406 created_at: Utc::now(),
407 auto_collapse: true,
408 }
409 }
410
411 pub fn expires_at(&self) -> DateTime<Utc> {
413 self.created_at + chrono::Duration::seconds(self.ttl_seconds as i64)
414 }
415
416 pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
418 now >= self.expires_at()
419 }
420}
421
422impl ThreadRecord {
423 pub fn thread_id(&self) -> ThreadId {
424 ThreadId::new_unchecked(self.id.clone())
426 }
427}
428
429#[derive(Debug, Clone, Default, Serialize, Deserialize)]
430pub struct ThreadRuntimeOverlay {
431 #[serde(default)]
432 pub path: Option<PathBuf>,
433 #[serde(default)]
434 pub execution_path: Option<PathBuf>,
435 #[serde(default)]
436 pub materialized_path: Option<PathBuf>,
437 #[serde(default)]
438 pub session_id: Option<String>,
439 #[serde(default)]
440 pub heddle_session_id: Option<String>,
441 #[serde(default)]
442 pub provider: Option<String>,
443 #[serde(default)]
444 pub model: Option<String>,
445 #[serde(default)]
446 pub harness: Option<String>,
447 #[serde(default)]
448 pub thinking_level: Option<String>,
449 #[serde(default)]
450 pub native_actor_key: Option<String>,
451 #[serde(default)]
452 pub native_parent_actor_key: Option<String>,
453 #[serde(default)]
454 pub probe_source: Option<String>,
455 #[serde(default)]
456 pub probe_confidence: Option<f32>,
457 #[serde(default)]
458 pub usage_summary: Option<AgentUsageSummary>,
459 #[serde(default)]
460 pub last_progress_at: Option<DateTime<Utc>>,
461 #[serde(default)]
462 pub report_flush_state: Option<String>,
463 #[serde(default)]
464 pub attach_reason: Option<String>,
465 #[serde(default)]
466 pub thread_mode: Option<ThreadMode>,
467 #[serde(default)]
468 pub thread_state: Option<ThreadState>,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct ThreadView {
473 pub record: ThreadRecord,
474 pub runtime: ThreadRuntimeOverlay,
475 pub is_current: bool,
476 pub is_isolated: bool,
477}
478
479impl ThreadView {
480 pub fn from_record(
481 record: ThreadRecord,
482 runtime: ThreadRuntimeOverlay,
483 is_current: bool,
484 ) -> Self {
485 let is_isolated = path_present(runtime.path.as_ref())
486 || path_present(runtime.execution_path.as_ref())
487 || path_present(runtime.materialized_path.as_ref());
488 Self {
489 record,
490 runtime,
491 is_current,
492 is_isolated,
493 }
494 }
495}
496
497fn path_present(path: Option<&PathBuf>) -> bool {
498 path.is_some_and(|path| !path.as_os_str().is_empty())
499}
500
501#[cfg(test)]
502mod thread_id_tests {
503 use super::*;
504
505 #[test]
506 fn accepts_safe_slugs() {
507 for ok in [
508 "feature/x",
509 "v1.2",
510 "a_b-c.d",
511 "team@scope",
512 "main",
513 "wip+1=2",
514 ] {
515 assert!(
516 ThreadId::new(ok).is_ok(),
517 "expected '{ok}' to be a valid thread id"
518 );
519 }
520 }
521
522 #[test]
523 fn rejects_reserved_heddle_namespace() {
524 for bad in ["heddle/frontier/main/hc-abc", "Heddle/x", "heddle/notes"] {
525 assert!(
526 ThreadId::new(bad).is_err(),
527 "expected '{bad}' to be rejected as a reserved heddle/ name"
528 );
529 }
530 assert!(
531 ThreadId::new("heddle").is_ok(),
532 "a bare 'heddle' thread remains a user name"
533 );
534 assert!(ThreadId::new("main@hd-abc").is_ok());
535 }
536
537 #[test]
538 fn rejects_whitespace_metachars_traversal_and_empty() {
539 for bad in [
540 "my feature", "a;b", "a|b", "a$(x)", "a\nb", "a&b", "`x`", "..", "a/../b", "/abs", "-foo", "--bar", "", ] {
554 assert!(
555 ThreadId::new(bad).is_err(),
556 "expected '{bad}' to be rejected as an invalid thread id"
557 );
558 }
559 }
560
561 #[test]
562 fn error_message_carries_a_valid_rename_hint() {
563 let err = ThreadId::new("my feature").unwrap_err();
564 let msg = err.to_string();
565 assert!(
566 msg.contains("my feature"),
567 "names the offending input: {msg}"
568 );
569 assert!(msg.contains("try 'my-feature'"), "suggests a rename: {msg}");
570 assert!(ThreadId::new(err.suggestion.as_str()).is_ok());
572 }
573
574 #[test]
575 fn deserialize_trusts_persisted_ids_without_revalidating() {
576 let id: ThreadId = serde_json::from_str("\"legacy id\"").unwrap();
579 assert_eq!(id.as_str(), "legacy id");
580 }
581}