Skip to main content

zagens_runtime_api/
task.rs

1//! Background task HTTP wire types (`/v1/tasks/*`) and OpenAPI schemas (D16 E1-c6).
2
3use std::path::PathBuf;
4
5use chrono::{DateTime, Utc};
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use zagens_runtime_adapters::json_schema_util::path_as_string;
9
10const TIMELINE_SUMMARY_LIMIT: usize = 240;
11
12pub const CURRENT_TASK_SCHEMA_VERSION: u32 = 2;
13
14const fn default_task_schema_version() -> u32 {
15    CURRENT_TASK_SCHEMA_VERSION
16}
17
18fn default_auto_approve() -> bool {
19    true
20}
21
22/// Durable task status.
23#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
24#[serde(rename_all = "snake_case")]
25pub enum TaskStatus {
26    Queued,
27    Running,
28    Completed,
29    Failed,
30    Canceled,
31}
32
33impl TaskStatus {
34    #[must_use]
35    pub fn is_terminal(self) -> bool {
36        matches!(self, Self::Completed | Self::Failed | Self::Canceled)
37    }
38}
39
40/// Durable tool-call status within a task timeline.
41#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
42#[serde(rename_all = "snake_case")]
43pub enum TaskToolStatus {
44    Running,
45    Success,
46    Failed,
47    Canceled,
48}
49
50/// Timeline entry for a task execution.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct TaskTimelineEntry {
53    pub timestamp: DateTime<Utc>,
54    pub kind: String,
55    pub summary: String,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub detail_path: Option<PathBuf>,
58}
59
60/// Tool call summary for a task.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct TaskToolCallSummary {
63    pub id: String,
64    pub name: String,
65    pub status: TaskToolStatus,
66    pub started_at: DateTime<Utc>,
67    pub ended_at: Option<DateTime<Utc>>,
68    pub duration_ms: Option<u64>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub input_summary: Option<String>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub output_summary: Option<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub detail_path: Option<PathBuf>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub patch_ref: Option<PathBuf>,
77}
78
79/// Checklist item stored on durable tasks.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct TaskChecklistItem {
82    pub id: u32,
83    pub content: String,
84    pub status: String,
85}
86
87/// Checklist state associated with a task.
88#[derive(Debug, Clone, Serialize, Deserialize, Default)]
89pub struct TaskChecklistState {
90    pub items: Vec<TaskChecklistItem>,
91    pub completion_pct: u8,
92    pub in_progress_id: Option<u32>,
93    pub updated_at: Option<DateTime<Utc>>,
94}
95
96/// Structured verification evidence attached to a task.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct TaskGateRecord {
99    pub id: String,
100    pub gate: String,
101    pub command: String,
102    pub cwd: PathBuf,
103    pub exit_code: Option<i32>,
104    pub status: String,
105    pub classification: String,
106    pub duration_ms: u64,
107    pub summary: String,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub log_path: Option<PathBuf>,
110    pub recorded_at: DateTime<Utc>,
111}
112
113/// PR-attempt metadata and artifacts attached to a task.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct TaskAttemptRecord {
116    pub id: String,
117    pub attempt_group_id: String,
118    pub attempt_index: u32,
119    pub attempt_count: u32,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub base_ref: Option<String>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub base_sha: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub head_ref: Option<String>,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub head_sha: Option<String>,
128    pub summary: String,
129    pub changed_files: Vec<String>,
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub patch_path: Option<PathBuf>,
132    pub verification: Vec<String>,
133    pub selected: bool,
134    pub recorded_at: DateTime<Utc>,
135}
136
137/// Durable artifact reference produced by task-aware tools.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct TaskArtifactRef {
140    pub label: String,
141    pub path: PathBuf,
142    pub summary: String,
143    pub created_at: DateTime<Utc>,
144}
145
146/// GitHub write/read evidence attached to a task timeline.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct TaskGithubEvent {
149    pub id: String,
150    pub action: String,
151    pub target: String,
152    pub number: u64,
153    pub summary: String,
154    pub url: Option<String>,
155    pub recorded_at: DateTime<Utc>,
156}
157
158/// Durable task record.
159#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
160pub struct TaskRecord {
161    #[serde(default = "default_task_schema_version")]
162    pub schema_version: u32,
163    pub id: String,
164    pub prompt: String,
165    pub model: String,
166    #[schemars(schema_with = "path_as_string")]
167    pub workspace: PathBuf,
168    pub mode: String,
169    pub allow_shell: bool,
170    pub trust_mode: bool,
171    #[serde(default = "default_auto_approve")]
172    pub auto_approve: bool,
173    pub status: TaskStatus,
174    pub created_at: DateTime<Utc>,
175    pub started_at: Option<DateTime<Utc>>,
176    pub ended_at: Option<DateTime<Utc>>,
177    pub duration_ms: Option<u64>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub result_summary: Option<String>,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub result_detail_path: Option<PathBuf>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub error: Option<String>,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub thread_id: Option<String>,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub turn_id: Option<String>,
188    #[serde(default)]
189    pub runtime_event_count: usize,
190    #[serde(default)]
191    #[schemars(skip)]
192    pub checklist: TaskChecklistState,
193    #[serde(default)]
194    #[schemars(skip)]
195    pub gates: Vec<TaskGateRecord>,
196    #[serde(default)]
197    #[schemars(skip)]
198    pub attempts: Vec<TaskAttemptRecord>,
199    #[serde(default)]
200    #[schemars(skip)]
201    pub artifacts: Vec<TaskArtifactRef>,
202    #[serde(default)]
203    #[schemars(skip)]
204    pub github_events: Vec<TaskGithubEvent>,
205    #[schemars(skip)]
206    pub tool_calls: Vec<TaskToolCallSummary>,
207    #[schemars(skip)]
208    pub timeline: Vec<TaskTimelineEntry>,
209}
210
211/// Lightweight task view.
212#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
213pub struct TaskSummary {
214    pub id: String,
215    pub status: TaskStatus,
216    pub prompt_summary: String,
217    pub model: String,
218    pub mode: String,
219    pub created_at: DateTime<Utc>,
220    pub started_at: Option<DateTime<Utc>>,
221    pub ended_at: Option<DateTime<Utc>>,
222    pub duration_ms: Option<u64>,
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub error: Option<String>,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub thread_id: Option<String>,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub turn_id: Option<String>,
229}
230
231fn summarize_text(text: &str, limit: usize) -> String {
232    let take = limit.saturating_sub(3);
233    let mut count = 0;
234    let mut out = String::new();
235    for ch in text.chars() {
236        if count >= take {
237            out.push_str("...");
238            return out;
239        }
240        if ch.is_control() && ch != '\n' && ch != '\t' {
241            continue;
242        }
243        out.push(ch);
244        count += 1;
245    }
246    out
247}
248
249impl From<&TaskRecord> for TaskSummary {
250    fn from(value: &TaskRecord) -> Self {
251        Self {
252            id: value.id.clone(),
253            status: value.status,
254            prompt_summary: summarize_text(&value.prompt, TIMELINE_SUMMARY_LIMIT),
255            model: value.model.clone(),
256            mode: value.mode.clone(),
257            created_at: value.created_at,
258            started_at: value.started_at,
259            ended_at: value.ended_at,
260            duration_ms: value.duration_ms,
261            error: value.error.clone(),
262            thread_id: value.thread_id.clone(),
263            turn_id: value.turn_id.clone(),
264        }
265    }
266}
267
268/// Count totals by status for task dashboards.
269#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, JsonSchema)]
270pub struct TaskCounts {
271    pub queued: usize,
272    pub running: usize,
273    pub completed: usize,
274    pub failed: usize,
275    pub canceled: usize,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
279pub struct TasksResponse {
280    pub tasks: Vec<TaskSummary>,
281    pub counts: TaskCounts,
282}
283
284/// Request to enqueue a new task.
285#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct NewTaskRequest {
287    pub prompt: String,
288    pub model: Option<String>,
289    pub workspace: Option<PathBuf>,
290    pub mode: Option<String>,
291    pub allow_shell: Option<bool>,
292    pub trust_mode: Option<bool>,
293    pub auto_approve: Option<bool>,
294}
295
296impl NewTaskRequest {
297    #[must_use]
298    pub fn from_prompt(prompt: impl Into<String>) -> Self {
299        Self {
300            prompt: prompt.into(),
301            model: None,
302            workspace: None,
303            mode: None,
304            allow_shell: None,
305            trust_mode: None,
306            auto_approve: Some(true),
307        }
308    }
309}