1mod observation;
7mod paging;
8mod storage;
9mod update;
10
11pub use observation::{
12 validate_observation_requests, CatalogObservation, ObservationItem, ObservationRequest,
13 DEFAULT_OBSERVATION_PAGE_BYTES, MAX_OBSERVATION_ITEMS, MAX_OBSERVATION_OUTPUT_BYTES,
14};
15
16use std::path::PathBuf;
17
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21use crate::runtime::workflow::PromptReceipt;
22use crate::runtime::ShellExecutionToken;
23use crate::state::turn::{TurnExplanation, TurnState};
24use crate::tools::bash_command::BashCommandState;
25
26pub const DEFAULT_PAGE_BYTES: usize = 16 * 1024;
27pub const MAX_PAGE_BYTES: usize = 64 * 1024;
28pub const MAX_WATCHED_EXECUTIONS: usize = 32;
29
30pub type HistoryResult<T> = Result<T, HistoryError>;
31
32#[derive(Debug, thiserror::Error)]
33pub enum HistoryError {
34 #[error("execution history I/O failed: {0}")]
35 Io(#[from] std::io::Error),
36 #[error("execution history data is invalid: {0}")]
37 Json(#[from] serde_json::Error),
38 #[error("invalid execution history request: {0}")]
39 InvalidInput(String),
40 #[error("invalid or foreign execution cursor")]
41 InvalidCursor,
42 #[error("execution was not found in this session or its retention expired")]
43 NotFound,
44 #[error("execution history capacity is occupied by active executions")]
45 Capacity,
46 #[error("execution identity conflicts with its retained record")]
47 IdentityConflict,
48}
49
50#[derive(Clone, Debug)]
51pub struct HistoryLimits {
52 pub max_records: usize,
53 pub retention_ms: u64,
54 pub max_output_bytes: usize,
55 pub max_session_output_bytes: usize,
56}
57
58impl Default for HistoryLimits {
59 fn default() -> Self {
60 Self {
61 max_records: 128,
62 retention_ms: 24 * 60 * 60 * 1000,
63 max_output_bytes: 1024 * 1024,
64 max_session_output_bytes: 16 * 1024 * 1024,
65 }
66 }
67}
68
69#[derive(Clone, Debug)]
71pub struct ExecutionScope {
72 pub thread_id: String,
73 pub workspace_root: PathBuf,
74}
75
76#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)]
77#[serde(rename_all = "snake_case")]
78pub enum OutputSource {
79 #[default]
80 Output,
81 Screen,
82 Detection,
83}
84
85#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
86#[serde(rename_all = "snake_case")]
87pub enum ExecutionProcessStatus {
88 Running,
89 Exited,
90 Unknown,
91}
92
93impl ExecutionProcessStatus {
94 pub const fn as_str(self) -> &'static str {
95 match self {
96 Self::Running => "running",
97 Self::Exited => "exited",
98 Self::Unknown => "unknown",
99 }
100 }
101}
102
103#[derive(Clone, Debug)]
105pub enum CapturedOutput {
106 Snapshot(String),
108 Fragment(String),
110 None,
112}
113
114#[derive(Clone, Debug)]
115pub struct ExecutionCapture {
116 pub execution_id: String,
117 pub execution_token: Option<ShellExecutionToken>,
118 pub command_generation: Option<u64>,
119 pub state: BashCommandState,
120 pub output: CapturedOutput,
121 pub screen: Option<String>,
122 pub explanation: Option<TurnExplanation>,
123 pub prompt_receipt: Option<PromptReceipt>,
124 pub activity_seq: Option<u64>,
125 pub observed_at_unix_ms: u64,
126 pub label: Option<String>,
127 pub output_truncated: bool,
128 pub dropped_output_file: Option<PathBuf>,
129}
130
131#[expect(clippy::struct_excessive_bools, reason = "independent wire metadata flags")]
133#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
134pub struct ExecutionRecord {
135 pub execution_id: String,
136 pub execution_token: Option<ShellExecutionToken>,
137 pub command_generation: Option<u64>,
138 pub background_id: Option<String>,
139 pub cwd: PathBuf,
140 pub label: Option<String>,
141 pub process_status: ExecutionProcessStatus,
142 pub exit_code: Option<i32>,
143 pub turn_state: Option<TurnState>,
144 pub explanation: Option<TurnExplanation>,
145 pub prompt_receipt: Option<PromptReceipt>,
146 pub first_observed_at_unix_ms: u64,
147 pub observed_at_unix_ms: u64,
148 pub finished_at_unix_ms: Option<u64>,
149 pub expires_at_unix_ms: Option<u64>,
150 pub activity_seq: Option<u64>,
151 #[serde(default)]
153 pub output_activity_seq: Option<u64>,
154 #[serde(default = "default_output_pending")]
156 pub output_pending: bool,
157 pub sequence: u64,
158 pub state_sequence: u64,
159 pub output_sequence: u64,
160 pub output_revision: u64,
161 pub retained_start: u64,
162 pub retained_end: u64,
163 pub retained_bytes: usize,
164 pub output_truncated: bool,
165 pub runtime_output_truncated: bool,
166 pub screen_truncated: bool,
167 pub screen_retained_bytes: usize,
168 pub output_coverage: String,
169 pub dropped_output_file: Option<PathBuf>,
170 pub unknown_reason: Option<String>,
171}
172
173#[derive(Clone, Debug, Serialize)]
174pub struct CatalogSnapshot {
175 pub sequence: u64,
176 pub cursor: String,
177 pub executions: Vec<ExecutionRecord>,
178}
179
180#[derive(Clone, Debug, Serialize)]
181pub struct HistoryChanges {
182 pub sequence: u64,
183 pub after_sequence: u64,
184 pub cursor: String,
185 pub executions: Vec<ExecutionRecord>,
186 pub missing_ids: Vec<String>,
187 pub resync_required: bool,
188}
189
190#[expect(clippy::struct_excessive_bools, reason = "independent pagination metadata flags")]
192#[derive(Clone, Debug, Serialize)]
193pub struct OutputPage {
194 pub execution_id: String,
195 pub source: OutputSource,
196 pub text: String,
197 pub next_cursor: String,
198 pub has_more: bool,
199 pub gap: bool,
200 pub resync_required: bool,
201 pub retained_start: u64,
202 pub retained_end: u64,
203 pub output_truncated: bool,
204 pub output_coverage: String,
205}
206
207#[derive(Clone, Debug)]
209pub struct ExecutionCatalog {
210 root: PathBuf,
211 limits: HistoryLimits,
212}
213
214impl ExecutionCatalog {
215 pub const fn new(root: PathBuf, limits: HistoryLimits) -> Self {
216 Self { root, limits }
217 }
218
219 pub fn record(
220 &self,
221 scope: &ExecutionScope,
222 capture: ExecutionCapture,
223 ) -> HistoryResult<ExecutionRecord> {
224 let now = capture.observed_at_unix_ms;
225 storage::transaction(self, scope, now, move |transaction| {
226 update::record(transaction, &capture)
227 })
228 }
229
230 pub fn update_existing(
232 &self,
233 scope: &ExecutionScope,
234 capture: ExecutionCapture,
235 ) -> HistoryResult<Option<ExecutionRecord>> {
236 validate_id(&capture.execution_id)?;
237 let now = capture.observed_at_unix_ms;
238 storage::transaction(self, scope, now, move |transaction| {
239 let Some(previous) = transaction.find(&capture.execution_id) else {
240 return Ok(None);
241 };
242 if previous
243 .execution_token
244 .as_ref()
245 .zip(capture.execution_token.as_ref())
246 .is_none_or(|(previous, current)| previous != current)
247 {
248 return Err(HistoryError::IdentityConflict);
249 }
250 update::record(transaction, &capture).map(Some)
251 })
252 }
253
254 pub fn get(
255 &self,
256 scope: &ExecutionScope,
257 execution_id: &str,
258 now: u64,
259 ) -> HistoryResult<Option<ExecutionRecord>> {
260 validate_id(execution_id)?;
261 storage::transaction(self, scope, now, |transaction| {
262 Ok(transaction.find(execution_id).cloned())
263 })
264 }
265
266 pub fn list(&self, scope: &ExecutionScope, now: u64) -> HistoryResult<CatalogSnapshot> {
267 storage::transaction(self, scope, now, |transaction| {
268 let mut executions = transaction.index.executions.clone();
269 executions.sort_by_key(|record| std::cmp::Reverse(record.sequence));
270 Ok(CatalogSnapshot {
271 sequence: transaction.index.sequence,
272 cursor: paging::event_cursor(transaction)?,
273 executions,
274 })
275 })
276 }
277
278 pub fn pending_foreground(
281 &self,
282 scope: &ExecutionScope,
283 now: u64,
284 ) -> HistoryResult<Vec<ExecutionRecord>> {
285 storage::transaction(self, scope, now, |transaction| {
288 let mut pending = transaction
289 .index
290 .executions
291 .iter()
292 .filter(|record| {
293 record.background_id.is_none()
294 && (record.process_status == ExecutionProcessStatus::Running
295 || record.output_pending)
296 })
297 .cloned()
298 .collect::<Vec<_>>();
299 pending.sort_by_key(|record| std::cmp::Reverse(record.sequence));
300 Ok(pending)
301 })
302 }
303
304 pub fn read(
305 &self,
306 scope: &ExecutionScope,
307 execution_id: &str,
308 source: OutputSource,
309 cursor: Option<&str>,
310 limit: usize,
311 now: u64,
312 ) -> HistoryResult<OutputPage> {
313 validate_id(execution_id)?;
314 if !(4..=MAX_PAGE_BYTES).contains(&limit) {
315 return Err(HistoryError::InvalidInput(format!(
316 "page limit must be between 4 and {MAX_PAGE_BYTES} bytes"
317 )));
318 }
319 storage::transaction(self, scope, now, |transaction| {
320 paging::read(transaction, execution_id, source, cursor, limit)
321 })
322 }
323
324 pub fn changes(
325 &self,
326 scope: &ExecutionScope,
327 cursor: &str,
328 execution_ids: &[String],
329 now: u64,
330 ) -> HistoryResult<HistoryChanges> {
331 if execution_ids.len() > MAX_WATCHED_EXECUTIONS {
332 return Err(HistoryError::InvalidInput(
333 "at most 32 execution IDs may be watched".into(),
334 ));
335 }
336 for id in execution_ids {
337 validate_id(id)?;
338 }
339 storage::transaction(self, scope, now, |transaction| {
340 paging::changes(transaction, cursor, execution_ids)
341 })
342 }
343
344 pub fn mark_unknown(
345 &self,
346 scope: &ExecutionScope,
347 execution_id: &str,
348 reason: &str,
349 now: u64,
350 ) -> HistoryResult<Option<ExecutionRecord>> {
351 validate_id(execution_id)?;
352 storage::transaction(self, scope, now, |transaction| {
353 Ok(update::mark_unknown(transaction, execution_id, reason))
354 })
355 }
356}
357
358fn default_output_pending() -> bool {
359 true
360}
361
362fn validate_id(id: &str) -> HistoryResult<()> {
363 if id.is_empty() || id.len() > 512 || id.contains('\0') {
364 return Err(HistoryError::InvalidInput("execution ID is empty or too long".into()));
365 }
366 Ok(())
367}