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