Skip to main content

repo/
thread_model.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::path::PathBuf;
3
4use chrono::{DateTime, Utc};
5use objects::store::AgentUsageSummary;
6use serde::{Deserialize, Serialize};
7
8/// A validated thread id. Construction from user- or externally-supplied
9/// input goes through [`ThreadId::new`], which rejects anything that is not a
10/// safe single shell token (see [`validate_thread_id`]). That invariant is what
11/// lets recommended-command breadcrumbs interpolate a thread id *bare* — there
12/// is no whitespace or shell metacharacter to quote, by construction.
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
14pub struct ThreadId(String);
15
16impl ThreadId {
17    /// Construct a thread id from user/external input, validating it against
18    /// the safe slug rule. Returns a [`ThreadIdError`] carrying an actionable
19    /// rename hint when the input is empty or contains a space, a shell
20    /// metacharacter, a `..` path segment, or a leading `/`.
21    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    /// Wrap a value WITHOUT validation. Reserved for inputs that are
28    /// safe-by-construction: deserialization of thread records already on disk
29    /// (validate at creation, trust thereafter) and internally-generated slug
30    /// ids. Never call this on user/external input — use [`ThreadId::new`].
31    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        // Persisted thread ids were validated when the thread was created; a
52        // record on disk is trusted, so deserialize through `new_unchecked`
53        // rather than re-running validation (and rejecting historical data).
54        let value = String::deserialize(deserializer)?;
55        Ok(Self::new_unchecked(value))
56    }
57}
58
59/// Rejection from [`ThreadId::new`] / [`validate_thread_id`]. Its `Display` is
60/// a clear, actionable CLI message naming the offending input and suggesting a
61/// valid rename.
62#[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
85/// The single rule for thread-id validity. A valid id is non-empty, made up
86/// only of the safe slug set (ASCII alphanumerics plus `_ - . / @ : + =`), has
87/// no `..` segment, and does not begin with `/`. This is deliberately the same
88/// safe set [`crate::shell_quote`] treats as needing no quoting, so a valid
89/// thread id is always a single shell token: `feature/x`, `v1.2`, `my-thread`,
90/// and `team@scope` are accepted; spaces, quotes, `;`, `|`, `$`, `&`, `*`,
91/// backticks, and newlines are rejected. Thread ids flow into worktree paths,
92/// so `..` and a leading `/` are rejected to keep them in-tree. A leading `-`
93/// is also rejected: it is in the safe set (for `my-thread`) but a breadcrumb
94/// like `heddle land --thread -foo` parses `-foo` as a flag, not the value.
95pub 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        // A leading '-' is in the safe set (for `my-thread`) but makes the id
105        // look like a CLI flag: `heddle land --thread -foo` parses `-foo` as an
106        // option, and argv-template construction panics. Reject it at the source.
107        && !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
119/// Best-effort slugify for the rename hint: map every disallowed character to
120/// `-`, collapse runs, drop `..`, and trim. Always returns a non-empty,
121/// [`validate_thread_id`]-valid string.
122fn 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/// How a thread's worktree is realised on disk. Three flavours:
151///
152/// * [`ThreadMode::Materialized`] — clonefile-or-reflink the captured
153///   tree into a thread directory. Real `read(2)`-able bytes, ~zero
154///   disk cost via shared extents (APFS / btrfs / XFS w/ reflinks).
155///   Day-one default on reflink-capable filesystems and the path the
156///   stat-cache fast no-op + manifest sidecar were built for. See
157///   `docs/design/clonefile-threads.md`.
158/// * [`ThreadMode::Virtualized`] — project the captured tree through
159///   a content-addressed FUSE/FSKit/ProjFS mount. Nothing on disk
160///   until the kernel asks. Useful for repos too large to materialize
161///   or when the CAS is remote-backed.
162/// * [`ThreadMode::Solid`] — full file copies with no shared extents.
163///   Strong isolation; the only choice on ext4 / NTFS hosts that have
164///   neither reflinks nor a usable mount API.
165///
166/// The discriminant names match the user-facing `--workspace` flag
167/// values so a single vocabulary spans the CLI, the JSON contract,
168/// and the thread record on disk. Pre-rename data using the older
169/// `"lightweight"` (clonefile) / `"materialized"` (full-copy) names
170/// will fail to deserialize and require a re-export — intentional;
171/// silently degrading isolation modes is the wrong default.
172#[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    /// True only when `manual_resolution_state` was captured by an actual
303    /// human conflict resolution (`heddle sync` materialized conflicts, then
304    /// `heddle resolve` cleared them). False when the same field was set by a
305    /// fully-automatic conflict-free integration (e.g. a clean 3-way merge of
306    /// two threads that touch disjoint files). Both populate
307    /// `manual_resolution_state` to mark the thread land-ready, but only the
308    /// former should be reported as "manually resolved" to the operator.
309    /// Pre-existing on-disk records have no field and serde defaults to
310    /// `false`, so a stale clean-merge record never claims a manual resolution.
311    #[serde(default)]
312    pub conflicts_resolved_manually: bool,
313}
314
315impl ThreadIntegrationPolicy {
316    /// Zero landing fields an unauthenticated peer can forge.
317    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    // --- W1 tail-append fields below; new fields go here. ---
347    /// Optional ephemeral-thread marker. `None` means the thread is
348    /// persistent; `Some(...)` means the thread auto-collapses after
349    /// `ttl_seconds` from `created_at`. The collapse is recorded
350    /// as an `OpRecord::EphemeralThreadCollapse` and the thread is set
351    /// to [`ThreadState::Abandoned`] — the underlying states remain
352    /// addressable.
353    pub ephemeral: Option<EphemeralMarker>,
354
355    /// Whether the thread was created automatically by a harness
356    /// integration (e.g. Claude Code's segment-rotation path) rather
357    /// than by an explicit `heddle thread create` / `heddle start`
358    /// invocation. Auto-threads are filtered from the default
359    /// `heddle thread list` view and are eligible for sweep by
360    /// `heddle thread cleanup --auto`.
361    ///
362    pub auto: bool,
363
364    /// When the thread was started with `heddle start --shared-target`,
365    /// this is the absolute path of the cargo `target/` directory the
366    /// thread's checkout has been redirected to (via a `.cargo/config.toml`
367    /// committed inside the checkout). `None` for threads that use
368    /// cargo's default per-checkout `target/` (or for non-Rust
369    /// workspaces). Recorded so `heddle thread show` can surface the
370    /// arrangement and downstream tooling can locate build artefacts
371    /// without re-deriving the fingerprint. (Item 2.1 of the heddle
372    /// 6→8 plan.)
373    pub shared_target_dir: Option<PathBuf>,
374}
375
376/// Ephemeral thread metadata. Lives at the tail of [`ThreadRecord`].
377///
378/// Ephemeral threads are spawned for short-lived agent work that should not
379/// crowd `heddle log` or the thread workspace. If not promoted before
380/// `ttl_seconds` elapses, the thread auto-collapses on the next read-side
381/// sweep (`heddle status`, `heddle log`, `heddle thread list`).
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
383pub struct EphemeralMarker {
384    /// Time-to-live, in seconds, measured from [`ThreadRecord::created_at`].
385    pub ttl_seconds: u32,
386    /// When this marker was attached. Usually equal to the thread's own
387    /// `created_at`, but kept separately so a thread can be retroactively
388    /// marked ephemeral by a later operation if we ever need to.
389    pub created_at: DateTime<Utc>,
390    /// When `true` (the default), the auto-collapse sweep collapses the
391    /// thread on TTL expiry. Setting `false` produces a warning at expiry
392    /// but leaves the thread alive — useful for "ephemeral but I'm not
393    /// done yet" situations during debugging.
394    #[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    /// Compute the absolute expiry timestamp.
412    pub fn expires_at(&self) -> DateTime<Utc> {
413        self.created_at + chrono::Duration::seconds(self.ttl_seconds as i64)
414    }
415
416    /// Whether this marker has expired at the given instant.
417    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        // A persisted record's id was validated at creation — trust it.
425        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", // space
541            "a;b",        // shell separator
542            "a|b",        // pipe
543            "a$(x)",      // command substitution
544            "a\nb",       // newline
545            "a&b",        // background
546            "`x`",        // backtick
547            "..",         // bare traversal
548            "a/../b",     // traversal segment
549            "/abs",       // leading slash
550            "-foo",       // leading dash — parses as a CLI flag in breadcrumbs
551            "--bar",      // leading double-dash
552            "",           // empty
553        ] {
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        // The suggestion itself must be a valid thread id.
571        assert!(ThreadId::new(err.suggestion.as_str()).is_ok());
572    }
573
574    #[test]
575    fn deserialize_trusts_persisted_ids_without_revalidating() {
576        // A record written before this rule existed (or by a future version)
577        // must still deserialize — validation is at creation, not on read.
578        let id: ThreadId = serde_json::from_str("\"legacy id\"").unwrap();
579        assert_eq!(id.as_str(), "legacy id");
580    }
581}