Skip to main content

repo/
thread_model.rs

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