Skip to main content

objects/
thread_record.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::path::PathBuf;
3
4use chrono::{DateTime, Utc};
5use schemars::JsonSchema;
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 ThreadIdError {
69    pub fn suggestion(&self) -> &str {
70        &self.suggestion
71    }
72}
73
74impl std::fmt::Display for ThreadIdError {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        if self.input.is_empty() {
77            write!(f, "thread name must not be empty")
78        } else {
79            write!(
80                f,
81                "thread name '{}' is invalid: use only letters, digits, and _ - . / @ : + = \
82                 (no spaces, shell metacharacters, '..' path segments, or a leading '/' or '-') — try '{}'",
83                self.input, self.suggestion
84            )
85        }
86    }
87}
88
89impl std::error::Error for ThreadIdError {}
90
91/// The single rule for thread-id validity. A valid id is non-empty, made up
92/// only of the safe slug set (ASCII alphanumerics plus `_ - . / @ : + =`), has
93/// no `..` segment, and does not begin with `/`. This is deliberately the same
94/// safe set the shell quoting rule treats as needing no quoting, so a valid
95/// thread id is always a single shell token: `feature/x`, `v1.2`, `my-thread`,
96/// and `team@scope` are accepted; spaces, quotes, `;`, `|`, `$`, `&`, `*`,
97/// backticks, and newlines are rejected. Thread ids flow into worktree paths,
98/// so `..` and a leading `/` are rejected to keep them in-tree. A leading `-`
99/// is also rejected: it is in the safe set (for `my-thread`) but a breadcrumb
100/// like `heddle land --thread -foo` parses `-foo` as a flag, not the value.
101pub fn validate_thread_id(value: &str) -> Result<(), ThreadIdError> {
102    let safe_charset = value.bytes().all(|b| {
103        b.is_ascii_alphanumeric()
104            || matches!(b, b'_' | b'-' | b'.' | b'/' | b'@' | b':' | b'+' | b'=')
105    });
106    let ok = !value.is_empty()
107        && safe_charset
108        && !value.contains("..")
109        && !value.starts_with('/')
110        // A leading '-' is in the safe set (for `my-thread`) but makes the id
111        // look like a CLI flag: `heddle land --thread -foo` parses `-foo` as an
112        // option, and argv-template construction panics. Reject it at the source.
113        && !value.starts_with('-')
114        && !crate::object::is_reserved_heddle_namespace(value);
115    if ok {
116        Ok(())
117    } else {
118        Err(ThreadIdError {
119            input: value.to_string(),
120            suggestion: suggest_thread_id(value),
121        })
122    }
123}
124
125/// Best-effort slugify for the rename hint: map every disallowed character to
126/// `-`, collapse runs, drop `..`, and trim. Always returns a non-empty,
127/// [`validate_thread_id`]-valid string.
128fn suggest_thread_id(value: &str) -> String {
129    let value = if crate::object::is_reserved_heddle_namespace(value) {
130        value.split_once('/').map(|(_, rest)| rest).unwrap_or(value)
131    } else {
132        value
133    };
134    let mut slug = String::with_capacity(value.len());
135    for ch in value.chars() {
136        if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.') {
137            slug.push(ch);
138        } else {
139            slug.push('-');
140        }
141    }
142    while slug.contains("--") {
143        slug = slug.replace("--", "-");
144    }
145    while slug.contains("..") {
146        slug = slug.replace("..", "-");
147    }
148    let trimmed = slug.trim_matches(|c| c == '-' || c == '.');
149    if trimmed.is_empty() {
150        "thread".to_string()
151    } else {
152        trimmed.to_string()
153    }
154}
155
156/// How a thread's worktree is realised on disk. Three flavours:
157///
158/// * [`ThreadMode::Materialized`] — clonefile-or-reflink the captured
159///   tree into a thread directory. Real `read(2)`-able bytes, ~zero
160///   disk cost via shared extents (APFS / btrfs / XFS w/ reflinks).
161///   Day-one default on reflink-capable filesystems and the path the
162///   stat-cache fast no-op + manifest sidecar were built for. See
163///   `docs/design/clonefile-threads.md`.
164/// * [`ThreadMode::Virtualized`] — project the captured tree through
165///   a content-addressed FUSE/FSKit/ProjFS mount. Nothing on disk
166///   until the kernel asks. Useful for repos too large to materialize
167///   or when the CAS is remote-backed.
168/// * [`ThreadMode::Solid`] — full file copies with no shared extents.
169///   Strong isolation; the only choice on ext4 / NTFS hosts that have
170///   neither reflinks nor a usable mount API.
171///
172/// The discriminant names match the user-facing `--workspace` flag
173/// values so a single vocabulary spans the CLI, the JSON contract,
174/// and the thread record on disk. Pre-rename data using the older
175/// `"lightweight"` (clonefile) / `"materialized"` (full-copy) names
176/// will fail to deserialize and require a re-export — intentional;
177/// silently degrading isolation modes is the wrong default.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
179#[serde(rename_all = "snake_case")]
180pub enum ThreadMode {
181    Materialized,
182    Virtualized,
183    Solid,
184}
185
186impl std::fmt::Display for ThreadMode {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        match self {
189            ThreadMode::Materialized => write!(f, "materialized"),
190            ThreadMode::Virtualized => write!(f, "virtualized"),
191            ThreadMode::Solid => write!(f, "solid"),
192        }
193    }
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
197#[serde(rename_all = "snake_case")]
198pub enum ThreadState {
199    Draft,
200    Active,
201    Ready,
202    Blocked,
203    Merged,
204    Abandoned,
205    Promoted,
206}
207
208impl std::fmt::Display for ThreadState {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        match self {
211            ThreadState::Draft => write!(f, "draft"),
212            ThreadState::Active => write!(f, "active"),
213            ThreadState::Ready => write!(f, "ready"),
214            ThreadState::Blocked => write!(f, "blocked"),
215            ThreadState::Merged => write!(f, "merged"),
216            ThreadState::Abandoned => write!(f, "abandoned"),
217            ThreadState::Promoted => write!(f, "promoted"),
218        }
219    }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
223#[serde(rename_all = "snake_case")]
224pub enum ThreadFreshness {
225    Current,
226    Stale,
227    Unknown,
228}
229
230impl std::fmt::Display for ThreadFreshness {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        match self {
233            ThreadFreshness::Current => write!(f, "current"),
234            ThreadFreshness::Stale => write!(f, "stale"),
235            ThreadFreshness::Unknown => write!(f, "unknown"),
236        }
237    }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
241#[serde(rename_all = "snake_case")]
242pub enum ThreadImpactCategory {
243    DependencyGraph,
244    BuildRuntimeConfig,
245    GeneratedOutputs,
246    RepoWideRefactor,
247    PublicApiSurface,
248}
249
250impl std::fmt::Display for ThreadImpactCategory {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        match self {
253            ThreadImpactCategory::DependencyGraph => write!(f, "dependency_graph"),
254            ThreadImpactCategory::BuildRuntimeConfig => write!(f, "build_runtime_config"),
255            ThreadImpactCategory::GeneratedOutputs => write!(f, "generated_outputs"),
256            ThreadImpactCategory::RepoWideRefactor => write!(f, "repo_wide_refactor"),
257            ThreadImpactCategory::PublicApiSurface => write!(f, "public_api_surface"),
258        }
259    }
260}
261
262#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
263#[serde(rename_all = "snake_case")]
264pub enum ConfidenceBand {
265    Low,
266    Medium,
267    High,
268}
269
270impl std::fmt::Display for ConfidenceBand {
271    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272        match self {
273            ConfidenceBand::Low => write!(f, "low"),
274            ConfidenceBand::Medium => write!(f, "medium"),
275            ConfidenceBand::High => write!(f, "high"),
276        }
277    }
278}
279
280#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
281pub struct ThreadVerificationSummary {
282    #[serde(default)]
283    pub tests_passed: Option<bool>,
284    #[serde(default)]
285    pub tests_failed: Option<u32>,
286    #[serde(default)]
287    pub coverage_pct: Option<f32>,
288    #[serde(default)]
289    pub lint_warnings: Option<u32>,
290}
291
292#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
293pub struct ThreadConfidenceSummary {
294    #[serde(default)]
295    pub value: Option<f32>,
296    #[serde(default)]
297    pub band: Option<ConfidenceBand>,
298}
299
300#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
301pub struct ThreadIntegrationPolicy {
302    #[serde(default)]
303    pub status: Option<String>,
304    #[serde(default)]
305    pub reason: Option<String>,
306    #[serde(default)]
307    pub manual_resolution_state: Option<String>,
308    /// True only when `manual_resolution_state` was captured by an actual
309    /// human conflict resolution (`heddle sync` materialized conflicts, then
310    /// `heddle resolve` cleared them). False when the same field was set by a
311    /// fully-automatic conflict-free integration (e.g. a clean 3-way merge of
312    /// two threads that touch disjoint files). Both populate
313    /// `manual_resolution_state` to mark the thread land-ready, but only the
314    /// former should be reported as "manually resolved" to the operator.
315    /// Pre-existing on-disk records have no field and serde defaults to
316    /// `false`, so a stale clean-merge record never claims a manual resolution.
317    #[serde(default)]
318    pub conflicts_resolved_manually: bool,
319}
320
321impl ThreadIntegrationPolicy {
322    /// Zero landing fields an unauthenticated peer can forge.
323    pub fn clear_untrusted_landing_fields(&mut self) {
324        self.manual_resolution_state = None;
325        self.conflicts_resolved_manually = false;
326    }
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct ThreadRecord {
331    pub id: String,
332    pub thread: String,
333    pub target_thread: Option<String>,
334    pub parent_thread: Option<String>,
335    pub mode: ThreadMode,
336    pub state: ThreadState,
337    pub base_state: String,
338    pub base_root: String,
339    pub current_state: Option<String>,
340    pub merged_state: Option<String>,
341    pub task: Option<String>,
342    pub changed_paths: Vec<String>,
343    pub impact_categories: Vec<ThreadImpactCategory>,
344    pub heavy_impact_paths: Vec<String>,
345    pub promotion_suggested: bool,
346    pub freshness: ThreadFreshness,
347    pub verification_summary: ThreadVerificationSummary,
348    pub confidence_summary: ThreadConfidenceSummary,
349    pub integration_policy_result: ThreadIntegrationPolicy,
350    pub created_at: DateTime<Utc>,
351    pub updated_at: DateTime<Utc>,
352    // --- W1 tail-append fields below; new fields go here. ---
353    /// Optional ephemeral-thread marker. `None` means the thread is
354    /// persistent; `Some(...)` means the thread auto-collapses after
355    /// `ttl_seconds` from `created_at`. The collapse is recorded
356    /// as an `OpRecord::EphemeralThreadCollapse` and the thread is set
357    /// to [`ThreadState::Abandoned`] — the underlying states remain
358    /// addressable.
359    pub ephemeral: Option<EphemeralMarker>,
360
361    /// Whether the thread was created automatically by a harness
362    /// integration (e.g. Claude Code's segment-rotation path) rather
363    /// than by an explicit `heddle thread create` / `heddle start`
364    /// invocation. Auto-threads are filtered from the default
365    /// `heddle thread list` view and are eligible for sweep by
366    /// `heddle thread cleanup --auto`.
367    ///
368    pub auto: bool,
369
370    /// When the thread was started with `heddle start --shared-target`,
371    /// this is the absolute path of the cargo `target/` directory the
372    /// thread's checkout has been redirected to (via a `.cargo/config.toml`
373    /// committed inside the checkout). `None` for threads that use
374    /// cargo's default per-checkout `target/` (or for non-Rust
375    /// workspaces). Recorded so `heddle thread show` can surface the
376    /// arrangement and downstream tooling can locate build artefacts
377    /// without re-deriving the fingerprint. (Item 2.1 of the heddle
378    /// 6→8 plan.)
379    pub shared_target_dir: Option<PathBuf>,
380}
381
382/// Ephemeral thread metadata. Lives at the tail of [`ThreadRecord`].
383///
384/// Ephemeral threads are spawned for short-lived agent work that should not
385/// crowd `heddle log` or the thread workspace. If not promoted before
386/// `ttl_seconds` elapses, the thread auto-collapses on the next read-side
387/// sweep (`heddle status`, `heddle log`, `heddle thread list`).
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
389pub struct EphemeralMarker {
390    /// Time-to-live, in seconds, measured from [`ThreadRecord::created_at`].
391    pub ttl_seconds: u32,
392    /// When this marker was attached. Usually equal to the thread's own
393    /// `created_at`, but kept separately so a thread can be retroactively
394    /// marked ephemeral by a later operation if we ever need to.
395    pub created_at: DateTime<Utc>,
396    /// When `true` (the default), the auto-collapse sweep collapses the
397    /// thread on TTL expiry. Setting `false` produces a warning at expiry
398    /// but leaves the thread alive — useful for "ephemeral but I'm not
399    /// done yet" situations during debugging.
400    #[serde(default = "default_auto_collapse")]
401    pub auto_collapse: bool,
402}
403
404fn default_auto_collapse() -> bool {
405    true
406}
407
408impl EphemeralMarker {
409    pub fn new(ttl_seconds: u32) -> Self {
410        Self {
411            ttl_seconds,
412            created_at: Utc::now(),
413            auto_collapse: true,
414        }
415    }
416
417    /// Compute the absolute expiry timestamp.
418    pub fn expires_at(&self) -> DateTime<Utc> {
419        self.created_at + chrono::Duration::seconds(self.ttl_seconds as i64)
420    }
421
422    /// Whether this marker has expired at the given instant.
423    pub fn is_expired_at(&self, now: DateTime<Utc>) -> bool {
424        now >= self.expires_at()
425    }
426}
427
428impl ThreadRecord {
429    pub fn thread_id(&self) -> ThreadId {
430        // A persisted record's id was validated at creation — trust it.
431        ThreadId::new_unchecked(self.id.clone())
432    }
433}