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    if ok {
109        Ok(())
110    } else {
111        Err(ThreadIdError {
112            input: value.to_string(),
113            suggestion: suggest_thread_id(value),
114        })
115    }
116}
117
118/// Best-effort slugify for the rename hint: map every disallowed character to
119/// `-`, collapse runs, drop `..`, and trim. Always returns a non-empty,
120/// [`validate_thread_id`]-valid string.
121fn 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/// How a thread's worktree is realised on disk. Three flavours:
145///
146/// * [`ThreadMode::Materialized`] — clonefile-or-reflink the captured
147///   tree into a thread directory. Real `read(2)`-able bytes, ~zero
148///   disk cost via shared extents (APFS / btrfs / XFS w/ reflinks).
149///   Day-one default on reflink-capable filesystems and the path the
150///   stat-cache fast no-op + manifest sidecar were built for. See
151///   `docs/design/clonefile-threads.md`.
152/// * [`ThreadMode::Virtualized`] — project the captured tree through
153///   a content-addressed FUSE/FSKit/ProjFS mount. Nothing on disk
154///   until the kernel asks. Useful for repos too large to materialize
155///   or when the CAS is remote-backed.
156/// * [`ThreadMode::Solid`] — full file copies with no shared extents.
157///   Strong isolation; the only choice on ext4 / NTFS hosts that have
158///   neither reflinks nor a usable mount API.
159///
160/// The discriminant names match the user-facing `--workspace` flag
161/// values so a single vocabulary spans the CLI, the JSON contract,
162/// and the thread record on disk. Pre-rename data using the older
163/// `"lightweight"` (clonefile) / `"materialized"` (full-copy) names
164/// will fail to deserialize and require a re-export — intentional;
165/// silently degrading isolation modes is the wrong default.
166#[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    /// True only when `manual_resolution_state` was captured by an actual
297    /// human conflict resolution (`heddle sync` materialized conflicts, then
298    /// `heddle resolve` cleared them). False when the same field was set by a
299    /// fully-automatic conflict-free integration (e.g. a clean 3-way merge of
300    /// two threads that touch disjoint files). Both populate
301    /// `manual_resolution_state` to mark the thread land-ready, but only the
302    /// former should be reported as "manually resolved" to the operator.
303    /// Pre-existing on-disk records have no field and serde defaults to
304    /// `false`, so a stale clean-merge record never claims a manual resolution.
305    #[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    // --- W1 tail-append fields below; new fields go here. ---
333    /// Optional ephemeral-thread marker. `None` means the thread is
334    /// persistent; `Some(...)` means the thread auto-collapses after
335    /// `ttl_seconds` from `created_at`. The collapse is recorded
336    /// as an `OpRecord::EphemeralThreadCollapse` and the thread is set
337    /// to [`ThreadState::Abandoned`] — the underlying states remain
338    /// addressable.
339    pub ephemeral: Option<EphemeralMarker>,
340
341    /// Whether the thread was created automatically by a harness
342    /// integration (e.g. Claude Code's segment-rotation path) rather
343    /// than by an explicit `heddle thread create` / `heddle start`
344    /// invocation. Auto-threads are filtered from the default
345    /// `heddle thread list` view and are eligible for sweep by
346    /// `heddle thread cleanup --auto`.
347    ///
348    pub auto: bool,
349
350    /// When the thread was started with `heddle start --shared-target`,
351    /// this is the absolute path of the cargo `target/` directory the
352    /// thread's checkout has been redirected to (via a `.cargo/config.toml`
353    /// committed inside the checkout). `None` for threads that use
354    /// cargo's default per-checkout `target/` (or for non-Rust
355    /// workspaces). Recorded so `heddle thread show` can surface the
356    /// arrangement and downstream tooling can locate build artefacts
357    /// without re-deriving the fingerprint. (Item 2.1 of the heddle
358    /// 6→8 plan.)
359    pub shared_target_dir: Option<PathBuf>,
360}
361
362/// Ephemeral thread metadata. Lives at the tail of [`ThreadRecord`].
363///
364/// Ephemeral threads are spawned for short-lived agent work that should not
365/// crowd `heddle log` or the thread workspace. If not promoted before
366/// `ttl_seconds` elapses, the thread auto-collapses on the next read-side
367/// sweep (`heddle status`, `heddle log`, `heddle thread list`).
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
369pub struct EphemeralMarker {
370    /// Time-to-live, in seconds, measured from [`ThreadRecord::created_at`].
371    pub ttl_seconds: u32,
372    /// When this marker was attached. Usually equal to the thread's own
373    /// `created_at`, but kept separately so a thread can be retroactively
374    /// marked ephemeral by a later operation if we ever need to.
375    pub created_at: DateTime<Utc>,
376    /// When `true` (the default), the auto-collapse sweep collapses the
377    /// thread on TTL expiry. Setting `false` produces a warning at expiry
378    /// but leaves the thread alive — useful for "ephemeral but I'm not
379    /// done yet" situations during debugging.
380    #[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    /// Compute the absolute expiry timestamp.
398    pub fn expires_at(&self) -> DateTime<Utc> {
399        self.created_at + chrono::Duration::seconds(self.ttl_seconds as i64)
400    }
401
402    /// Whether this marker has expired at the given instant.
403    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        // A persisted record's id was validated at creation — trust it.
411        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", // space
512            "a;b",        // shell separator
513            "a|b",        // pipe
514            "a$(x)",      // command substitution
515            "a\nb",       // newline
516            "a&b",        // background
517            "`x`",        // backtick
518            "..",         // bare traversal
519            "a/../b",     // traversal segment
520            "/abs",       // leading slash
521            "-foo",       // leading dash — parses as a CLI flag in breadcrumbs
522            "--bar",      // leading double-dash
523            "",           // empty
524        ] {
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        // The suggestion itself must be a valid thread id.
542        assert!(ThreadId::new(err.suggestion.as_str()).is_ok());
543    }
544
545    #[test]
546    fn deserialize_trusts_persisted_ids_without_revalidating() {
547        // A record written before this rule existed (or by a future version)
548        // must still deserialize — validation is at creation, not on read.
549        let id: ThreadId = serde_json::from_str("\"legacy id\"").unwrap();
550        assert_eq!(id.as_str(), "legacy id");
551    }
552}