Skip to main content

task_runs/
types.rs

1//! Core types for the TaskRun system.
2//!
3//! These mirror the data model in `.yah/docs/working/yah-task-runs.md`.
4//! Intentionally kept free of I/O — the store layer owns persistence.
5//!
6//! The types `TaskRunId`, `Level`, `EventSource`, `ChunkRef`, `Event`, and
7//! `Diagnostic` live in `crates/yah/observation/` and are re-exported here
8//! for backward compatibility.
9
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12
13// Re-export the hoisted observation types so all existing callers continue to
14// work via `use task_runs::{TaskRunId, Event, ...}`.
15pub use observation::{
16    ChunkRef, Diagnostic, Event, EventScope, EventSource, ForgeId, Level, TaskRunId,
17    RESERVED_FIELD_PATHS,
18};
19
20// ─── RunStatus ────────────────────────────────────────────────────────────────
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(tag = "status", rename_all = "snake_case")]
24pub enum RunStatus {
25    Pending,
26    Running,
27    Done { exit_code: i32, ended_at: u64 },
28    Killed { signal: i32, ended_at: u64 },
29    Lost { reason: String },
30}
31
32// ─── Initiator ────────────────────────────────────────────────────────────────
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "kind", rename_all = "snake_case")]
36pub enum Initiator {
37    Human { camp: String },
38    Agent { camp: String, agent: String, session: String },
39    Gnome { camp: String, shift: String },
40    Cron { camp: String, schedule: String },
41}
42
43// ─── BeholderStatus ───────────────────────────────────────────────────────────
44
45/// Opaque string surfaced on `TaskRunMeta.beholder_status`.
46///
47/// Examples: `"attached:cargo@1.78"`, `"none:auto"`, `"declined:cargo
48/// reason=\"explicit --message-format=human\""`, `"unknown_format"`,
49/// `"attached:cargo@1.78 rewrite=\"--message-format=json-render-diagnostics\""`.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct BeholderStatus {
52    pub text: String,
53    /// Args added to argv by a `Rewriter` beholder. `None` for `Parser` mode or
54    /// when no beholder attached.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub rewrite_added: Option<Vec<String>>,
57}
58
59impl BeholderStatus {
60    fn make(text: String) -> Self {
61        Self { text, rewrite_added: None }
62    }
63
64    pub fn none_auto() -> Self {
65        Self::make("none:auto".to_string())
66    }
67    /// Bytes-only because the caller explicitly set `BeholderSelect::None`.
68    pub fn none_explicit() -> Self {
69        Self::make("none:explicit".to_string())
70    }
71    pub fn attached(name: &str, version: &str) -> Self {
72        Self::make(format!("attached:{name}@{version}"))
73    }
74    pub fn declined(name: &str, reason: &str) -> Self {
75        Self::make(format!("declined:{name} reason=\"{reason}\""))
76    }
77    /// `BeholderSelect::Force` matched; `matches` predicate agreed.
78    pub fn forced(name: &str, version: &str) -> Self {
79        Self::make(format!("forced:{name}@{version}"))
80    }
81    /// `BeholderSelect::Force` matched; beholder's `matches` would have declined.
82    pub fn forced_against_flags(name: &str, version: &str) -> Self {
83        Self::make(format!("forced-against-flags:{name}@{version}"))
84    }
85    /// `BeholderSelect::Force` matched on a TTY-attached run where a Rewriter
86    /// beholder would normally decline to preserve human output.
87    pub fn forced_against_tty(name: &str, version: &str) -> Self {
88        Self::make(format!("forced-against-tty:{name}@{version}"))
89    }
90    pub fn unknown_format() -> Self {
91        Self::make("unknown_format".to_string())
92    }
93    /// Beholder detected that the tool's output format is unrecognized (schema
94    /// drift). Records which beholder made the call and why.
95    pub fn unknown_format_with_reason(name: &str, reason: &str) -> Self {
96        Self::make(format!("unknown_format:{name} reason=\"{reason}\""))
97    }
98
99    /// Append rewrite info to this status if `added` is non-empty.
100    ///
101    /// Called after a `Rewriter` beholder adjusts argv so agents can see exactly
102    /// what was injected into the command line.
103    pub fn with_rewrite(mut self, added: Vec<String>) -> Self {
104        if !added.is_empty() {
105            let repr = added.join(" ");
106            self.text = format!("{} rewrite=\"{repr}\"", self.text);
107            self.rewrite_added = Some(added);
108        }
109        self
110    }
111}
112
113// ─── TaskRunMeta ──────────────────────────────────────────────────────────────
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct TaskRunMeta {
117    pub id: TaskRunId,
118    pub command: String,
119    pub cwd: PathBuf,
120    pub env: Vec<(String, String)>,
121    pub started_at: u64,
122    pub status: RunStatus,
123    pub label: Option<String>,
124    pub initiator: Initiator,
125    pub beholder_status: Option<BeholderStatus>,
126    /// If true, the GC sweep will not drop this run's output during warm rolloff.
127    /// Pinned runs are exempt until explicitly unpinned or archived.
128    #[serde(default)]
129    pub pinned: bool,
130    /// What surface spawned this run. `None` (the default) is an ordinary
131    /// `task.run` job; `Some("terminal")` marks an interactive terminal
132    /// session (SSH / local PTY / camp shell). A generic provenance tag, not
133    /// a UI concept — it lets a consumer (e.g. the desktop terminal rail) list
134    /// just its own runs from `task.list` without scooping up unrelated jobs.
135    #[serde(default)]
136    pub origin: Option<String>,
137}
138
139// ─── Stream ───────────────────────────────────────────────────────────────────
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "lowercase")]
143pub enum Stream {
144    Stdout,
145    Stderr,
146    Synth,
147}
148
149impl Stream {
150    pub fn as_str(self) -> &'static str {
151        match self {
152            Stream::Stdout => "stdout",
153            Stream::Stderr => "stderr",
154            Stream::Synth => "synth",
155        }
156    }
157}
158
159impl std::str::FromStr for Stream {
160    type Err = String;
161    fn from_str(s: &str) -> Result<Self, Self::Err> {
162        match s {
163            "stdout" => Ok(Stream::Stdout),
164            "stderr" => Ok(Stream::Stderr),
165            "synth" => Ok(Stream::Synth),
166            other => Err(format!("unknown stream: {other}")),
167        }
168    }
169}
170
171// ─── OutputChunk ──────────────────────────────────────────────────────────────
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct OutputChunk {
175    pub run_id: TaskRunId,
176    pub seq: u32,
177    pub offset_ms: u32,
178    pub stream: Stream,
179    pub bytes: Vec<u8>,
180}
181
182// ─── SeqRange ─────────────────────────────────────────────────────────────────
183
184/// Inclusive range over chunk `seq` numbers within a single run.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct SeqRange {
187    pub lo: u32,
188    pub hi: u32,
189}
190
191// ─── Triage (Tier 1.75 — pruner output) ───────────────────────────────────────
192
193/// Pruner output for a run: a list of verbatim chunk ranges + a human-facing
194/// synopsis.
195///
196/// **Agents must read `keep`/`primary` ranges and resolve them to bytes via
197/// `task.lines`. The `synopsis` is for human display only — it can paraphrase
198/// or hallucinate. Never parse it programmatically.**
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct Triage {
201    pub run_id: TaskRunId,
202    pub synopsis: String,
203    pub keep: Vec<KeepRange>,
204    pub primary: SeqRange,
205    pub model: String,
206    pub prompt_version: u32,
207    pub cached_at: u64,
208    pub partial: bool,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct KeepRange {
213    pub range: SeqRange,
214    pub reason: String,
215}