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