| 1 | //! Typed artifact references on runtime turns and items. |
| 2 | //! |
| 3 | //! A turn's artifacts are what it left behind that a client can open: files |
| 4 | //! a tool created or changed in the workspace, tool output that spilled to |
| 5 | //! the session artifact directory, and media a tool produced. Each is named |
| 6 | //! by a [`TurnArtifactRef`] carrying path, kind, size and revision, so a |
| 7 | //! Preview surface can show "what this turn produced" without scanning. |
| 8 | //! |
| 9 | //! Facts come from where the bytes were written, never from re-reading the |
| 10 | //! disk later: file tools and `apply_patch` record `size`/`sha256` in their |
| 11 | //! `mutation.files[]` receipt, spills record `artifact_digest`, and media |
| 12 | //! publication records `sha256`. This module only parses those receipts and |
| 13 | //! confines every path; there is no second artifact store. |
| 14 | |
| 15 | use std::path::{Component, Path, PathBuf}; |
| 16 | |
| 17 | use chrono::{DateTime, Utc}; |
| 18 | use serde::{Deserialize, Serialize}; |
| 19 | use serde_json::Value; |
| 20 | |
| 21 | /// Hard ceiling on refs parsed from one tool result. A patch touching more |
| 22 | /// files than this is still applied; the extra refs are simply not listed. |
| 23 | pub(crate) const MAX_ITEM_ARTIFACTS: usize = 1_000; |
| 24 | |
| 25 | /// What kind of thing the reference names. |
| 26 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 27 | #[serde(rename_all = "snake_case")] |
| 28 | pub enum TurnArtifactKind { |
| 29 | /// A file in the thread workspace; `path` is workspace-relative. |
| 30 | File, |
| 31 | /// Tool output spilled to the session artifact directory; `path` is |
| 32 | /// session-relative (`artifacts/art_<call>.txt`). |
| 33 | ToolOutput, |
| 34 | /// Media a tool returned, published immutably under the session. |
| 35 | Media, |
| 36 | } |
| 37 | |
| 38 | /// How a file changed. Only set on `kind = file`. |
| 39 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 40 | #[serde(rename_all = "snake_case")] |
| 41 | pub enum FileChangeKind { |
| 42 | Created, |
| 43 | Updated, |
| 44 | Deleted, |
| 45 | Renamed, |
| 46 | } |
| 47 | |
| 48 | /// Which receipt produced the reference. |
| 49 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 50 | #[serde(rename_all = "snake_case")] |
| 51 | pub enum TurnArtifactSource { |
| 52 | /// A file tool's `mutation` receipt (write/edit/apply_patch). |
| 53 | ToolMutation, |
| 54 | /// A large tool result spilled to a session artifact. |
| 55 | ToolOutputSpill, |
| 56 | /// A tool's media publication. |
| 57 | ToolMedia, |
| 58 | /// The workspace snapshot delta between the turn's pre-turn and |
| 59 | /// post-turn snapshots. This is everything that changed in the workspace |
| 60 | /// while the turn ran, which includes writes by shell commands and |
| 61 | /// sub-agents but also by anything else writing the same workspace at the |
| 62 | /// same time (an editor, another thread, a background job). |
| 63 | WorkspaceChangedDuringTurn, |
| 64 | } |
| 65 | |
| 66 | /// One thing a turn (or one tool call inside it) produced. |
| 67 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 68 | pub struct TurnArtifactRef { |
| 69 | /// URL-safe id, stable within the turn: `file_<32 hex>` for a file (a |
| 70 | /// digest of its path), the spill's `art_<call>` id, or the media |
| 71 | /// `art_image_<sha256>` handle. |
| 72 | pub id: String, |
| 73 | pub kind: TurnArtifactKind, |
| 74 | /// `kind = file`: workspace-relative with `/` separators. |
| 75 | /// `tool_output`/`media`: session-relative (`artifacts/...`). |
| 76 | pub path: String, |
| 77 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 78 | pub change: Option<FileChangeKind>, |
| 79 | /// The old path of a renamed file. |
| 80 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 81 | pub previous_path: Option<String>, |
| 82 | /// Byte size of the content this reference names. `None` when deleted. |
| 83 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 84 | pub size: Option<u64>, |
| 85 | /// SHA-256 (lowercase hex) of the whole content: the same value the |
| 86 | /// workspace file read reports as `revision`. `None` when deleted or too |
| 87 | /// large to hash. |
| 88 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 89 | pub revision: Option<String>, |
| 90 | /// Exact media type, for `kind = media`. |
| 91 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 92 | pub content_type: Option<String>, |
| 93 | /// Owning artifact session, for `tool_output`/`media`. |
| 94 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 95 | pub session_id: Option<String>, |
| 96 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 97 | pub item_id: Option<String>, |
| 98 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 99 | pub tool_call_id: Option<String>, |
| 100 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 101 | pub tool_name: Option<String>, |
| 102 | pub source: TurnArtifactSource, |
| 103 | /// A restore point `POST /v1/threads/{id}/file-revert` accepts for this |
| 104 | /// file on this thread: the tree id of a `tool` or `pre_turn` snapshot |
| 105 | /// recorded on this turn (`TurnRecord::workspace_snapshots`), so the |
| 106 | /// thread owns it (#6621). Absent when the turn recorded none. |
| 107 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 108 | pub restore_snapshot_id: Option<String>, |
| 109 | pub recorded_at: DateTime<Utc>, |
| 110 | } |
| 111 | |
| 112 | /// Identity of the tool call a set of refs is parsed for. |
| 113 | pub(crate) struct ToolArtifactContext<'a> { |
| 114 | pub item_id: &'a str, |
| 115 | pub tool_call_id: &'a str, |
| 116 | pub tool_name: &'a str, |
| 117 | /// The thread workspace every file path is confined to: as configured, |
| 118 | /// and canonicalized when that differs (tools resolve against the |
| 119 | /// configured path, which may traverse a symlink such as macOS `/var`). |
| 120 | pub workspace_roots: &'a [PathBuf], |
| 121 | /// The `tool` restore point recorded on this turn for this call (its |
| 122 | /// tree id), when there is one. Only a receipt the thread owns is |
| 123 | /// advertised. |
| 124 | pub restore_snapshot_id: Option<&'a str>, |
| 125 | pub recorded_at: DateTime<Utc>, |
| 126 | } |
| 127 | |
| 128 | /// The stable ref id for a workspace file path. |
| 129 | pub(crate) fn file_artifact_id(path: &str) -> String { |
| 130 | let digest = crate::hashing::sha256_hex(path.as_bytes()); |
| 131 | format!("file_{}", &digest[..32]) |
| 132 | } |
| 133 | |
| 134 | pub(crate) fn is_sha256_hex(value: &str) -> bool { |
| 135 | value.len() == 64 |
| 136 | && value |
| 137 | .bytes() |
| 138 | .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) |
| 139 | } |
| 140 | |
| 141 | /// Normalize a tool-supplied path into a workspace-relative display path. |
| 142 | /// |
| 143 | /// Accepts a relative path or an absolute one inside any of the workspace's |
| 144 | /// root spellings. Refuses `..`, empty, non-UTF-8, anything outside, and |
| 145 | /// `.git` (never served). |
| 146 | pub(crate) fn confined_workspace_path(workspace_roots: &[PathBuf], raw: &str) -> Option<String> { |
| 147 | let rel = workspace_roots |
| 148 | .iter() |
| 149 | .find_map(|root| crate::snapshot::workspace_relative_path(root, raw))?; |
| 150 | relative_display(&rel) |
| 151 | } |
| 152 | |
| 153 | fn relative_display(rel: &Path) -> Option<String> { |
| 154 | let mut parts = Vec::new(); |
| 155 | for component in rel.components() { |
| 156 | let Component::Normal(name) = component else { |
| 157 | return None; |
| 158 | }; |
| 159 | if crate::snapshot::is_git_metadata_name(name) { |
| 160 | return None; |
| 161 | } |
| 162 | parts.push(name.to_str()?); |
| 163 | } |
| 164 | (!parts.is_empty()).then(|| parts.join("/")) |
| 165 | } |
| 166 | |
| 167 | /// A session-relative artifact path: relative, `/`-separated, under |
| 168 | /// `artifacts/`, with no `.`/`..` components. |
| 169 | pub(crate) fn confined_session_artifact_path(raw: &str) -> Option<String> { |
| 170 | if raw.contains('\\') { |
| 171 | return None; |
| 172 | } |
| 173 | let path = PathBuf::from(raw); |
| 174 | if path.is_absolute() { |
| 175 | return None; |
| 176 | } |
| 177 | let display = relative_display(&path)?; |
| 178 | display |
| 179 | .split('/') |
| 180 | .next() |
| 181 | .is_some_and(|first| first == crate::artifacts::ARTIFACTS_DIR_NAME) |
| 182 | .then_some(display) |
| 183 | } |
| 184 | |
| 185 | fn is_safe_artifact_id(id: &str) -> bool { |
| 186 | id.starts_with("art_") |
| 187 | && id.len() <= 200 |
| 188 | && id |
| 189 | .bytes() |
| 190 | .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') |
| 191 | } |
| 192 | |
| 193 | fn change_kind(outcome: &str) -> Option<FileChangeKind> { |
| 194 | match outcome { |
| 195 | "created" => Some(FileChangeKind::Created), |
| 196 | "updated" => Some(FileChangeKind::Updated), |
| 197 | "deleted" => Some(FileChangeKind::Deleted), |
| 198 | _ => None, |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | fn written_facts(entry: &Value) -> (Option<u64>, Option<String>) { |
| 203 | let size = entry.get("size").and_then(Value::as_u64); |
| 204 | let revision = entry |
| 205 | .get("sha256") |
| 206 | .and_then(Value::as_str) |
| 207 | .filter(|digest| is_sha256_hex(digest)) |
| 208 | .map(str::to_owned); |
| 209 | (size, revision) |
| 210 | } |
| 211 | |
| 212 | /// Parse the artifact references a tool result's metadata describes. |
| 213 | /// |
| 214 | /// Three receipts are read: `mutation.files[]`/`mutation.renames[]` (file |
| 215 | /// tools), the spill keys `artifact_id`/`artifact_session_id`/ |
| 216 | /// `artifact_relative_path`/`artifact_byte_size`/`artifact_digest`, and |
| 217 | /// `tool_media[]`. Anything that is not confined — an absolute or `..` |
| 218 | /// path, a path outside the workspace, an invalid session id — yields no |
| 219 | /// reference rather than a reference a client could be misled by. |
| 220 | pub(crate) fn artifact_refs_from_tool_metadata( |
| 221 | metadata: &Value, |
| 222 | context: &ToolArtifactContext<'_>, |
| 223 | ) -> Vec<TurnArtifactRef> { |
| 224 | let mut refs = Vec::new(); |
| 225 | let base = |id: String, kind, path: String, source| TurnArtifactRef { |
| 226 | id, |
| 227 | kind, |
| 228 | path, |
| 229 | change: None, |
| 230 | previous_path: None, |
| 231 | size: None, |
| 232 | revision: None, |
| 233 | content_type: None, |
| 234 | session_id: None, |
| 235 | item_id: Some(context.item_id.to_owned()), |
| 236 | tool_call_id: Some(context.tool_call_id.to_owned()), |
| 237 | tool_name: Some(context.tool_name.to_owned()), |
| 238 | source, |
| 239 | restore_snapshot_id: None, |
| 240 | recorded_at: context.recorded_at, |
| 241 | }; |
| 242 | |
| 243 | // The call's own `tool` restore point, from the receipt recorded on this |
| 244 | // turn — never from the tool's result metadata, which a tool controls. |
| 245 | let restore_snapshot_id = context |
| 246 | .restore_snapshot_id |
| 247 | .filter(|id| crate::snapshot::SnapshotId::is_well_formed(id)) |
| 248 | .map(str::to_owned); |
| 249 | |
| 250 | if let Some(mutation) = metadata.get("mutation") { |
| 251 | for entry in mutation |
| 252 | .get("files") |
| 253 | .and_then(Value::as_array) |
| 254 | .into_iter() |
| 255 | .flatten() |
| 256 | { |
| 257 | let Some(change) = entry |
| 258 | .get("outcome") |
| 259 | .and_then(Value::as_str) |
| 260 | .and_then(change_kind) |
| 261 | else { |
| 262 | continue; |
| 263 | }; |
| 264 | let Some(path) = entry |
| 265 | .get("path") |
| 266 | .and_then(Value::as_str) |
| 267 | .and_then(|raw| confined_workspace_path(context.workspace_roots, raw)) |
| 268 | else { |
| 269 | continue; |
| 270 | }; |
| 271 | let mut reference = base( |
| 272 | file_artifact_id(&path), |
| 273 | TurnArtifactKind::File, |
| 274 | path, |
| 275 | TurnArtifactSource::ToolMutation, |
| 276 | ); |
| 277 | reference.change = Some(change); |
| 278 | if change != FileChangeKind::Deleted { |
| 279 | (reference.size, reference.revision) = written_facts(entry); |
| 280 | } |
| 281 | reference.restore_snapshot_id = restore_snapshot_id.clone(); |
| 282 | refs.push(reference); |
| 283 | } |
| 284 | for entry in mutation |
| 285 | .get("renames") |
| 286 | .and_then(Value::as_array) |
| 287 | .into_iter() |
| 288 | .flatten() |
| 289 | { |
| 290 | let confine = |key: &str| { |
| 291 | entry |
| 292 | .get(key) |
| 293 | .and_then(Value::as_str) |
| 294 | .and_then(|raw| confined_workspace_path(context.workspace_roots, raw)) |
| 295 | }; |
| 296 | let (Some(from), Some(to)) = (confine("from"), confine("to")) else { |
| 297 | continue; |
| 298 | }; |
| 299 | let mut reference = base( |
| 300 | file_artifact_id(&to), |
| 301 | TurnArtifactKind::File, |
| 302 | to, |
| 303 | TurnArtifactSource::ToolMutation, |
| 304 | ); |
| 305 | reference.change = Some(FileChangeKind::Renamed); |
| 306 | reference.previous_path = Some(from); |
| 307 | (reference.size, reference.revision) = written_facts(entry); |
| 308 | reference.restore_snapshot_id = restore_snapshot_id.clone(); |
| 309 | refs.push(reference); |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | if let (Some(artifact_id), Some(session_id), Some(path)) = ( |
| 314 | metadata |
| 315 | .get("artifact_id") |
| 316 | .and_then(Value::as_str) |
| 317 | .filter(|id| is_safe_artifact_id(id)), |
| 318 | metadata |
| 319 | .get("artifact_session_id") |
| 320 | .and_then(Value::as_str) |
| 321 | .filter(|id| crate::artifacts::is_valid_session_id(id)), |
| 322 | metadata |
| 323 | .get("artifact_relative_path") |
| 324 | .and_then(Value::as_str) |
| 325 | .and_then(confined_session_artifact_path), |
| 326 | ) { |
| 327 | let mut reference = base( |
| 328 | artifact_id.to_owned(), |
| 329 | TurnArtifactKind::ToolOutput, |
| 330 | path, |
| 331 | TurnArtifactSource::ToolOutputSpill, |
| 332 | ); |
| 333 | reference.session_id = Some(session_id.to_owned()); |
| 334 | reference.size = metadata.get("artifact_byte_size").and_then(Value::as_u64); |
| 335 | reference.revision = metadata |
| 336 | .get("artifact_digest") |
| 337 | .and_then(Value::as_str) |
| 338 | .filter(|digest| is_sha256_hex(digest)) |
| 339 | .map(str::to_owned); |
| 340 | refs.push(reference); |
| 341 | } |
| 342 | |
| 343 | for media in metadata |
| 344 | .get("tool_media") |
| 345 | .and_then(Value::as_array) |
| 346 | .into_iter() |
| 347 | .flatten() |
| 348 | { |
| 349 | let Some(handle) = media |
| 350 | .get("artifact_id") |
| 351 | .and_then(Value::as_str) |
| 352 | .filter(|id| id.strip_prefix("art_image_").is_some_and(is_sha256_hex)) |
| 353 | else { |
| 354 | continue; |
| 355 | }; |
| 356 | let Some(session_id) = media |
| 357 | .get("session_id") |
| 358 | .and_then(Value::as_str) |
| 359 | .filter(|id| crate::artifacts::is_valid_session_id(id)) |
| 360 | else { |
| 361 | continue; |
| 362 | }; |
| 363 | let mut reference = base( |
| 364 | handle.to_owned(), |
| 365 | TurnArtifactKind::Media, |
| 366 | format!("{}/{handle}.image", crate::artifacts::ARTIFACTS_DIR_NAME), |
| 367 | TurnArtifactSource::ToolMedia, |
| 368 | ); |
| 369 | reference.session_id = Some(session_id.to_owned()); |
| 370 | reference.size = media.get("byte_size").and_then(Value::as_u64); |
| 371 | reference.revision = media |
| 372 | .get("sha256") |
| 373 | .and_then(Value::as_str) |
| 374 | .filter(|digest| is_sha256_hex(digest)) |
| 375 | .map(str::to_owned); |
| 376 | reference.content_type = media |
| 377 | .get("media_type") |
| 378 | .and_then(Value::as_str) |
| 379 | .map(str::to_owned); |
| 380 | refs.push(reference); |
| 381 | } |
| 382 | |
| 383 | refs.truncate(MAX_ITEM_ARTIFACTS); |
| 384 | refs |
| 385 | } |
| 386 | |
| 387 | /// The legacy `artifact_refs: Vec<PathBuf>` projection. |
| 388 | /// |
| 389 | /// Clients pinned to the older contract read these as workspace-relative |
| 390 | /// file paths (the desktop Preview does), so only existing workspace files |
| 391 | /// belong here: never a spill or media path, never a deleted file. |
| 392 | pub(crate) fn legacy_artifact_refs(refs: &[TurnArtifactRef]) -> Vec<PathBuf> { |
| 393 | let mut paths: Vec<PathBuf> = Vec::new(); |
| 394 | for reference in refs { |
| 395 | if reference.kind != TurnArtifactKind::File |
| 396 | || reference.change == Some(FileChangeKind::Deleted) |
| 397 | { |
| 398 | continue; |
| 399 | } |
| 400 | let path = PathBuf::from(&reference.path); |
| 401 | if !paths.contains(&path) { |
| 402 | paths.push(path); |
| 403 | } |
| 404 | } |
| 405 | paths |
| 406 | } |
| 407 | |
| 408 | /// Ceiling on a turn's aggregate. The cut is reported, never silent. |
| 409 | pub(crate) const MAX_TURN_ARTIFACTS: usize = 1_000; |
| 410 | |
| 411 | /// Where the turn's workspace-level accounting stands. |
| 412 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 413 | #[serde(rename_all = "snake_case")] |
| 414 | pub enum TurnWorkspaceState { |
| 415 | /// The turn ended; the post-turn snapshot or its diff is still running. |
| 416 | /// `artifacts` holds the item-derived refs until `turn.artifacts` lands. |
| 417 | Pending, |
| 418 | /// The pre/post snapshot delta is merged into `artifacts`. |
| 419 | Settled, |
| 420 | /// No delta will come; `reason` says why. `artifacts` holds what the |
| 421 | /// tool receipts recorded. |
| 422 | Unavailable, |
| 423 | } |
| 424 | |
| 425 | /// Why a turn has no workspace delta. |
| 426 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 427 | #[serde(rename_all = "snake_case")] |
| 428 | pub enum TurnWorkspaceReason { |
| 429 | SnapshotsDisabled, |
| 430 | WorkspaceTooLarge, |
| 431 | TooManyFiles, |
| 432 | UnsafeLocation, |
| 433 | SnapshotFailed, |
| 434 | /// The turn ran no snapshot pair: a compaction or purge operation, or a |
| 435 | /// turn that ended before the engine reported one. |
| 436 | NotCaptured, |
| 437 | /// The Runtime restarted before the delta settled. |
| 438 | RuntimeRestarted, |
| 439 | /// The snapshots exist but diffing them failed. |
| 440 | DeltaFailed, |
| 441 | } |
| 442 | |
| 443 | /// The workspace half of a turn's artifact accounting. |
| 444 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 445 | pub struct TurnWorkspaceArtifacts { |
| 446 | pub state: TurnWorkspaceState, |
| 447 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 448 | pub reason: Option<TurnWorkspaceReason>, |
| 449 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 450 | pub pre_turn_snapshot_id: Option<String>, |
| 451 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 452 | pub post_turn_snapshot_id: Option<String>, |
| 453 | /// The aggregate was cut at `MAX_TURN_ARTIFACTS` (or the delta at its |
| 454 | /// own bound); `omitted` counts what is not listed. |
| 455 | #[serde(default)] |
| 456 | pub truncated: bool, |
| 457 | #[serde(default)] |
| 458 | pub omitted: u64, |
| 459 | } |
| 460 | |
| 461 | impl TurnWorkspaceArtifacts { |
| 462 | pub(crate) fn unavailable(reason: TurnWorkspaceReason) -> Self { |
| 463 | Self { |
| 464 | state: TurnWorkspaceState::Unavailable, |
| 465 | reason: Some(reason), |
| 466 | pre_turn_snapshot_id: None, |
| 467 | post_turn_snapshot_id: None, |
| 468 | truncated: false, |
| 469 | omitted: 0, |
| 470 | } |
| 471 | } |
| 472 | |
| 473 | pub(crate) fn pending(pre_turn_snapshot_id: String) -> Self { |
| 474 | Self { |
| 475 | state: TurnWorkspaceState::Pending, |
| 476 | reason: None, |
| 477 | pre_turn_snapshot_id: Some(pre_turn_snapshot_id), |
| 478 | post_turn_snapshot_id: None, |
| 479 | truncated: false, |
| 480 | omitted: 0, |
| 481 | } |
| 482 | } |
| 483 | } |
| 484 | |
| 485 | /// A settled workspace delta, ready to merge. |
| 486 | pub(crate) struct WorkspaceDelta { |
| 487 | pub refs: Vec<TurnArtifactRef>, |
| 488 | /// Item-reported paths that either snapshot contains. An item path in |
| 489 | /// neither snapshot is invisible to them (excluded or ignored), so the |
| 490 | /// delta's silence about it proves nothing and its item ref is kept. |
| 491 | pub tracked_item_paths: std::collections::HashSet<String>, |
| 492 | pub truncated: bool, |
| 493 | pub omitted: u64, |
| 494 | } |
| 495 | |
| 496 | /// Convert a snapshot delta into turn refs. `restore_snapshot_id` is the |
| 497 | /// pre-turn restore point recorded on the turn, which file-revert accepts |
| 498 | /// for this thread. |
| 499 | pub(crate) fn delta_refs( |
| 500 | delta: &crate::snapshot::SnapshotDelta, |
| 501 | restore_snapshot_id: Option<&str>, |
| 502 | recorded_at: DateTime<Utc>, |
| 503 | ) -> Vec<TurnArtifactRef> { |
| 504 | use crate::snapshot::DeltaChange; |
| 505 | delta |
| 506 | .entries |
| 507 | .iter() |
| 508 | .map(|entry| TurnArtifactRef { |
| 509 | id: file_artifact_id(&entry.path), |
| 510 | kind: TurnArtifactKind::File, |
| 511 | path: entry.path.clone(), |
| 512 | change: Some(match entry.change { |
| 513 | DeltaChange::Created => FileChangeKind::Created, |
| 514 | DeltaChange::Updated => FileChangeKind::Updated, |
| 515 | DeltaChange::Deleted => FileChangeKind::Deleted, |
| 516 | DeltaChange::Renamed => FileChangeKind::Renamed, |
| 517 | }), |
| 518 | previous_path: entry.previous_path.clone(), |
| 519 | size: entry.size, |
| 520 | revision: entry.sha256.clone(), |
| 521 | content_type: None, |
| 522 | session_id: None, |
| 523 | item_id: None, |
| 524 | tool_call_id: None, |
| 525 | tool_name: None, |
| 526 | source: TurnArtifactSource::WorkspaceChangedDuringTurn, |
| 527 | restore_snapshot_id: restore_snapshot_id.map(str::to_owned), |
| 528 | recorded_at, |
| 529 | }) |
| 530 | .collect() |
| 531 | } |
| 532 | |
| 533 | /// The merged turn aggregate. |
| 534 | pub(crate) struct MergedTurnArtifacts { |
| 535 | pub artifacts: Vec<TurnArtifactRef>, |
| 536 | pub truncated: bool, |
| 537 | pub omitted: u64, |
| 538 | } |
| 539 | |
| 540 | /// Fold one item file ref into the per-path net change. |
| 541 | fn compose_file( |
| 542 | files: &mut std::collections::HashMap<String, TurnArtifactRef>, |
| 543 | next: &TurnArtifactRef, |
| 544 | ) { |
| 545 | let mut merged = next.clone(); |
| 546 | if next.change == Some(FileChangeKind::Renamed) { |
| 547 | let source = next |
| 548 | .previous_path |
| 549 | .as_ref() |
| 550 | .and_then(|previous| files.remove(previous)); |
| 551 | if let Some(source) = source { |
| 552 | merged.restore_snapshot_id = source.restore_snapshot_id.or(merged.restore_snapshot_id); |
| 553 | match source.change { |
| 554 | // A file this turn created and then moved is simply created. |
| 555 | Some(FileChangeKind::Created) => { |
| 556 | merged.change = Some(FileChangeKind::Created); |
| 557 | merged.previous_path = None; |
| 558 | } |
| 559 | // Renamed twice: the net rename is from the first origin. |
| 560 | Some(FileChangeKind::Renamed) => merged.previous_path = source.previous_path, |
| 561 | _ => {} |
| 562 | } |
| 563 | if merged.previous_path.as_deref() == Some(merged.path.as_str()) { |
| 564 | merged.change = Some(FileChangeKind::Updated); |
| 565 | merged.previous_path = None; |
| 566 | } |
| 567 | } |
| 568 | files.insert(merged.path.clone(), merged); |
| 569 | return; |
| 570 | } |
| 571 | let Some(previous) = files.remove(&next.path) else { |
| 572 | files.insert(merged.path.clone(), merged); |
| 573 | return; |
| 574 | }; |
| 575 | merged.restore_snapshot_id = previous |
| 576 | .restore_snapshot_id |
| 577 | .clone() |
| 578 | .or(merged.restore_snapshot_id); |
| 579 | match (previous.change, next.change) { |
| 580 | // Created then deleted within the turn: nothing is left to show. |
| 581 | (Some(FileChangeKind::Created), Some(FileChangeKind::Deleted)) => return, |
| 582 | (Some(FileChangeKind::Created), _) => merged.change = Some(FileChangeKind::Created), |
| 583 | (Some(FileChangeKind::Renamed), Some(FileChangeKind::Deleted)) => { |
| 584 | // The file moved and then went away: its origin is what is gone. |
| 585 | let origin = previous.previous_path.clone().unwrap_or(previous.path); |
| 586 | merged.id = file_artifact_id(&origin); |
| 587 | merged.path = origin; |
| 588 | merged.previous_path = None; |
| 589 | } |
| 590 | (Some(FileChangeKind::Renamed), _) => { |
| 591 | merged.change = Some(FileChangeKind::Renamed); |
| 592 | merged.previous_path = previous.previous_path; |
| 593 | } |
| 594 | // Deleted then written again: the file existed before and still does. |
| 595 | (Some(FileChangeKind::Deleted), Some(FileChangeKind::Created)) => { |
| 596 | merged.change = Some(FileChangeKind::Updated); |
| 597 | } |
| 598 | _ => {} |
| 599 | } |
| 600 | files.insert(merged.path.clone(), merged); |
| 601 | } |
| 602 | |
| 603 | /// The one place a turn's aggregate is computed. |
| 604 | /// |
| 605 | /// Items are the authority for what each tool call produced. Tool output and |
| 606 | /// media refs are always kept. File refs compose by item order (last writer |
| 607 | /// wins; created+deleted drops, created+updated stays created, a rename |
| 608 | /// folds its origin). When a settled `delta` exists it is authoritative for |
| 609 | /// the net workspace change, size and revision of every path the snapshots |
| 610 | /// can see; item provenance is copied onto the delta ref for the same path, |
| 611 | /// and an item path the delta omits although the snapshots track it netted |
| 612 | /// to no change and is dropped. Most recent first; capped. |
| 613 | pub(crate) fn merge_turn_artifacts<'a>( |
| 614 | item_refs: impl IntoIterator<Item = &'a TurnArtifactRef>, |
| 615 | delta: Option<&WorkspaceDelta>, |
| 616 | ) -> MergedTurnArtifacts { |
| 617 | let mut outputs: Vec<TurnArtifactRef> = Vec::new(); |
| 618 | let mut files = std::collections::HashMap::new(); |
| 619 | for reference in item_refs { |
| 620 | if reference.kind == TurnArtifactKind::File { |
| 621 | compose_file(&mut files, reference); |
| 622 | } else { |
| 623 | outputs.retain(|existing| existing.id != reference.id); |
| 624 | outputs.push(reference.clone()); |
| 625 | } |
| 626 | } |
| 627 | let (mut truncated, mut omitted) = (false, 0); |
| 628 | let mut artifacts = outputs; |
| 629 | match delta { |
| 630 | Some(delta) => { |
| 631 | truncated = delta.truncated; |
| 632 | omitted = delta.omitted; |
| 633 | for mut reference in delta.refs.iter().cloned() { |
| 634 | if let Some(item) = files.remove(&reference.path) { |
| 635 | reference.item_id = item.item_id; |
| 636 | reference.tool_call_id = item.tool_call_id; |
| 637 | reference.tool_name = item.tool_name; |
| 638 | reference.source = TurnArtifactSource::ToolMutation; |
| 639 | reference.recorded_at = item.recorded_at; |
| 640 | reference.restore_snapshot_id = |
| 641 | reference.restore_snapshot_id.or(item.restore_snapshot_id); |
| 642 | } |
| 643 | artifacts.push(reference); |
| 644 | } |
| 645 | artifacts.extend( |
| 646 | files |
| 647 | .into_values() |
| 648 | .filter(|item| !delta.tracked_item_paths.contains(&item.path)), |
| 649 | ); |
| 650 | } |
| 651 | None => artifacts.extend(files.into_values()), |
| 652 | } |
| 653 | artifacts.sort_by(|left, right| { |
| 654 | right |
| 655 | .recorded_at |
| 656 | .cmp(&left.recorded_at) |
| 657 | .then_with(|| left.path.cmp(&right.path)) |
| 658 | }); |
| 659 | if artifacts.len() > MAX_TURN_ARTIFACTS { |
| 660 | truncated = true; |
| 661 | omitted += (artifacts.len() - MAX_TURN_ARTIFACTS) as u64; |
| 662 | artifacts.truncate(MAX_TURN_ARTIFACTS); |
| 663 | } |
| 664 | MergedTurnArtifacts { |
| 665 | artifacts, |
| 666 | truncated, |
| 667 | omitted, |
| 668 | } |
| 669 | } |
| 670 | |
| 671 | #[cfg(test)] |
| 672 | mod tests { |
| 673 | use super::*; |
| 674 | use serde_json::json; |
| 675 | |
| 676 | const SNAPSHOT: &str = "0123456789abcdef0123456789abcdef01234567"; |
| 677 | |
| 678 | fn context<'a>(workspace: &'a [PathBuf], restore: Option<&'a str>) -> ToolArtifactContext<'a> { |
| 679 | ToolArtifactContext { |
| 680 | item_id: "item_1", |
| 681 | tool_call_id: "call_1", |
| 682 | tool_name: "apply_patch", |
| 683 | workspace_roots: workspace, |
| 684 | restore_snapshot_id: restore, |
| 685 | recorded_at: Utc::now(), |
| 686 | } |
| 687 | } |
| 688 | |
| 689 | #[test] |
| 690 | fn mutation_receipt_yields_confined_file_refs_with_written_facts() { |
| 691 | let workspace = tempfile::tempdir().unwrap(); |
| 692 | let digest = crate::hashing::sha256_hex(b"new\n"); |
| 693 | let absolute = workspace.path().join("src/abs.rs"); |
| 694 | let metadata = json!({ |
| 695 | "mutation": { |
| 696 | "files": [ |
| 697 | { "path": "src/lib.rs", "outcome": "updated", "size": 4, "sha256": digest }, |
| 698 | { "path": absolute.to_str().unwrap(), "outcome": "created", "size": 4, "sha256": digest }, |
| 699 | { "path": "gone.txt", "outcome": "deleted", "size": 9, "sha256": digest }, |
| 700 | // Forged or unconfined paths never become refs. |
| 701 | { "path": "../escape.txt", "outcome": "created" }, |
| 702 | { "path": "/etc/passwd", "outcome": "updated" }, |
| 703 | { "path": ".git/config", "outcome": "updated" }, |
| 704 | { "path": "odd.txt", "outcome": "exploded" }, |
| 705 | { "path": "bad-digest.txt", "outcome": "created", "size": 1, "sha256": "sha256:nothex" } |
| 706 | ], |
| 707 | "renames": [{ "from": "old.txt", "to": "new.txt", "size": 4, "sha256": digest }] |
| 708 | }, |
| 709 | }); |
| 710 | let refs = artifact_refs_from_tool_metadata( |
| 711 | &metadata, |
| 712 | &context(&[workspace.path().to_path_buf()], Some(SNAPSHOT)), |
| 713 | ); |
| 714 | let paths: Vec<&str> = refs.iter().map(|r| r.path.as_str()).collect(); |
| 715 | assert_eq!( |
| 716 | paths, |
| 717 | [ |
| 718 | "src/lib.rs", |
| 719 | "src/abs.rs", |
| 720 | "gone.txt", |
| 721 | "bad-digest.txt", |
| 722 | "new.txt" |
| 723 | ] |
| 724 | ); |
| 725 | let lib = &refs[0]; |
| 726 | assert_eq!(lib.kind, TurnArtifactKind::File); |
| 727 | assert_eq!(lib.id, file_artifact_id("src/lib.rs")); |
| 728 | assert_eq!(lib.change, Some(FileChangeKind::Updated)); |
| 729 | assert_eq!(lib.size, Some(4)); |
| 730 | assert_eq!(lib.revision.as_deref(), Some(digest.as_str())); |
| 731 | assert_eq!(lib.source, TurnArtifactSource::ToolMutation); |
| 732 | assert_eq!(lib.restore_snapshot_id.as_deref(), Some(SNAPSHOT)); |
| 733 | assert_eq!(lib.item_id.as_deref(), Some("item_1")); |
| 734 | assert_eq!(lib.tool_call_id.as_deref(), Some("call_1")); |
| 735 | // Deleted entries carry no size or revision even if a receipt did. |
| 736 | assert_eq!(refs[2].change, Some(FileChangeKind::Deleted)); |
| 737 | assert_eq!((refs[2].size, refs[2].revision.as_deref()), (None, None)); |
| 738 | // A malformed digest is dropped, not passed through. |
| 739 | assert_eq!(refs[3].revision, None); |
| 740 | let renamed = &refs[4]; |
| 741 | assert_eq!(renamed.change, Some(FileChangeKind::Renamed)); |
| 742 | assert_eq!(renamed.previous_path.as_deref(), Some("old.txt")); |
| 743 | |
| 744 | // The legacy projection holds only live workspace paths. |
| 745 | assert_eq!( |
| 746 | legacy_artifact_refs(&refs), |
| 747 | ["src/lib.rs", "src/abs.rs", "bad-digest.txt", "new.txt"] |
| 748 | .map(PathBuf::from) |
| 749 | .to_vec() |
| 750 | ); |
| 751 | } |
| 752 | |
| 753 | #[test] |
| 754 | fn restore_point_comes_only_from_the_recorded_receipt() { |
| 755 | let workspace = tempfile::tempdir().unwrap(); |
| 756 | // A tool cannot name its own restore point: result metadata that |
| 757 | // claims one is ignored (#6621). |
| 758 | let metadata = json!({ |
| 759 | "mutation": { "files": [{ "path": "a.txt", "outcome": "created" }] }, |
| 760 | "restore_snapshot_id": SNAPSHOT, |
| 761 | "restore_snapshot_session_id": "sess-1", |
| 762 | }); |
| 763 | let restore = |recorded| { |
| 764 | artifact_refs_from_tool_metadata( |
| 765 | &metadata, |
| 766 | &context(&[workspace.path().to_path_buf()], recorded), |
| 767 | )[0] |
| 768 | .restore_snapshot_id |
| 769 | .clone() |
| 770 | }; |
| 771 | let recorded = "fedcba9876543210fedcba9876543210fedcba98"; |
| 772 | assert_eq!(restore(Some(recorded)).as_deref(), Some(recorded)); |
| 773 | assert_eq!(restore(None), None); |
| 774 | assert_eq!(restore(Some("not-a-snapshot")), None); |
| 775 | } |
| 776 | |
| 777 | #[test] |
| 778 | fn spill_and_media_receipts_yield_session_refs_and_forgeries_yield_none() { |
| 779 | let workspace = tempfile::tempdir().unwrap(); |
| 780 | let digest = crate::hashing::sha256_hex(b"output"); |
| 781 | let handle = format!("art_image_{digest}"); |
| 782 | let metadata = json!({ |
| 783 | "artifact_id": "art_call_1", |
| 784 | "artifact_session_id": "sess-abc", |
| 785 | "artifact_relative_path": "artifacts/art_call_1.txt", |
| 786 | "artifact_byte_size": 6, |
| 787 | "artifact_digest": digest, |
| 788 | "tool_media": [ |
| 789 | { "session_id": "sess-abc", "artifact_id": handle, "media_type": "image/png", |
| 790 | "byte_size": 10, "sha256": digest }, |
| 791 | { "session_id": "../x", "artifact_id": handle }, |
| 792 | { "session_id": "sess-abc", "artifact_id": "art_image_short" } |
| 793 | ], |
| 794 | }); |
| 795 | let refs = artifact_refs_from_tool_metadata( |
| 796 | &metadata, |
| 797 | &context(&[workspace.path().to_path_buf()], None), |
| 798 | ); |
| 799 | assert_eq!(refs.len(), 2, "{refs:?}"); |
| 800 | let spill = &refs[0]; |
| 801 | assert_eq!(spill.kind, TurnArtifactKind::ToolOutput); |
| 802 | assert_eq!(spill.id, "art_call_1"); |
| 803 | assert_eq!(spill.path, "artifacts/art_call_1.txt"); |
| 804 | assert_eq!(spill.session_id.as_deref(), Some("sess-abc")); |
| 805 | assert_eq!(spill.size, Some(6)); |
| 806 | assert_eq!(spill.revision.as_deref(), Some(digest.as_str())); |
| 807 | assert_eq!(spill.source, TurnArtifactSource::ToolOutputSpill); |
| 808 | let media = &refs[1]; |
| 809 | assert_eq!(media.kind, TurnArtifactKind::Media); |
| 810 | assert_eq!(media.path, format!("artifacts/{handle}.image")); |
| 811 | assert_eq!(media.content_type.as_deref(), Some("image/png")); |
| 812 | assert!(legacy_artifact_refs(&refs).is_empty()); |
| 813 | |
| 814 | for (key, forged) in [ |
| 815 | ("artifact_relative_path", json!("/abs/artifacts/x.txt")), |
| 816 | ("artifact_relative_path", json!("artifacts/../../escape")), |
| 817 | ("artifact_relative_path", json!("elsewhere/x.txt")), |
| 818 | ("artifact_session_id", json!("../other")), |
| 819 | ("artifact_id", json!("not-an-artifact")), |
| 820 | ] { |
| 821 | let mut value = metadata.clone(); |
| 822 | value[key] = forged.clone(); |
| 823 | value.as_object_mut().unwrap().remove("tool_media"); |
| 824 | assert!( |
| 825 | artifact_refs_from_tool_metadata( |
| 826 | &value, |
| 827 | &context(&[workspace.path().to_path_buf()], None) |
| 828 | ) |
| 829 | .is_empty(), |
| 830 | "{key}={forged} must not become a ref" |
| 831 | ); |
| 832 | } |
| 833 | } |
| 834 | |
| 835 | fn file_ref(path: &str, change: FileChangeKind, revision: &str, at: i64) -> TurnArtifactRef { |
| 836 | TurnArtifactRef { |
| 837 | id: file_artifact_id(path), |
| 838 | kind: TurnArtifactKind::File, |
| 839 | path: path.to_string(), |
| 840 | change: Some(change), |
| 841 | previous_path: None, |
| 842 | size: Some(revision.len() as u64), |
| 843 | revision: Some(revision.to_string()), |
| 844 | content_type: None, |
| 845 | session_id: None, |
| 846 | item_id: Some(format!("item_{at}")), |
| 847 | tool_call_id: Some(format!("call_{at}")), |
| 848 | tool_name: Some("write".to_string()), |
| 849 | source: TurnArtifactSource::ToolMutation, |
| 850 | restore_snapshot_id: Some(format!("snap_{at}")), |
| 851 | recorded_at: DateTime::from_timestamp(at, 0).unwrap(), |
| 852 | } |
| 853 | } |
| 854 | |
| 855 | fn by_path(merged: &MergedTurnArtifacts) -> Vec<(&str, Option<FileChangeKind>, Option<&str>)> { |
| 856 | let mut rows: Vec<_> = merged |
| 857 | .artifacts |
| 858 | .iter() |
| 859 | .map(|r| (r.path.as_str(), r.change, r.revision.as_deref())) |
| 860 | .collect(); |
| 861 | rows.sort_by_key(|row| row.0); |
| 862 | rows |
| 863 | } |
| 864 | |
| 865 | #[test] |
| 866 | fn item_refs_compose_by_order_without_a_delta() { |
| 867 | let mut renamed = file_ref("b.txt", FileChangeKind::Renamed, "r_b", 6); |
| 868 | renamed.previous_path = Some("a.txt".into()); |
| 869 | let items = vec![ |
| 870 | file_ref("new.txt", FileChangeKind::Created, "r1", 1), |
| 871 | file_ref("new.txt", FileChangeKind::Updated, "r2", 2), |
| 872 | file_ref("tmp.txt", FileChangeKind::Created, "t1", 3), |
| 873 | file_ref("tmp.txt", FileChangeKind::Deleted, "t2", 4), |
| 874 | file_ref("old.txt", FileChangeKind::Updated, "o1", 5), |
| 875 | file_ref("old.txt", FileChangeKind::Deleted, "o2", 5), |
| 876 | renamed, |
| 877 | ]; |
| 878 | let merged = merge_turn_artifacts(&items, None); |
| 879 | assert_eq!( |
| 880 | by_path(&merged), |
| 881 | vec![ |
| 882 | ("b.txt", Some(FileChangeKind::Renamed), Some("r_b")), |
| 883 | ("new.txt", Some(FileChangeKind::Created), Some("r2")), |
| 884 | ("old.txt", Some(FileChangeKind::Deleted), Some("o2")), |
| 885 | ] |
| 886 | ); |
| 887 | let created = merged |
| 888 | .artifacts |
| 889 | .iter() |
| 890 | .find(|r| r.path == "new.txt") |
| 891 | .unwrap(); |
| 892 | // The earliest restore point survives; provenance is the last writer. |
| 893 | assert_eq!(created.restore_snapshot_id.as_deref(), Some("snap_1")); |
| 894 | assert_eq!(created.item_id.as_deref(), Some("item_2")); |
| 895 | // Most recent first. |
| 896 | assert_eq!(merged.artifacts[0].path, "b.txt"); |
| 897 | assert!(!merged.truncated); |
| 898 | } |
| 899 | |
| 900 | #[test] |
| 901 | fn a_settled_delta_is_authoritative_for_visible_paths() { |
| 902 | let items = vec![ |
| 903 | file_ref("edited.txt", FileChangeKind::Updated, "item_rev", 1), |
| 904 | // Written and then restored to its original bytes: net zero. |
| 905 | file_ref("reverted.txt", FileChangeKind::Updated, "x", 2), |
| 906 | // Under an excluded directory: the snapshots cannot see it. |
| 907 | file_ref("dist/app.js", FileChangeKind::Created, "js", 3), |
| 908 | ]; |
| 909 | let mut shell = file_ref("out.md", FileChangeKind::Created, "shell_rev", 9); |
| 910 | shell.source = TurnArtifactSource::WorkspaceChangedDuringTurn; |
| 911 | shell.item_id = None; |
| 912 | shell.tool_call_id = None; |
| 913 | shell.tool_name = None; |
| 914 | let mut edited = file_ref("edited.txt", FileChangeKind::Updated, "delta_rev", 9); |
| 915 | edited.source = TurnArtifactSource::WorkspaceChangedDuringTurn; |
| 916 | edited.restore_snapshot_id = Some("pre".into()); |
| 917 | let delta = WorkspaceDelta { |
| 918 | refs: vec![edited, shell], |
| 919 | tracked_item_paths: ["edited.txt", "reverted.txt"].map(String::from).into(), |
| 920 | truncated: false, |
| 921 | omitted: 0, |
| 922 | }; |
| 923 | let merged = merge_turn_artifacts(&items, Some(&delta)); |
| 924 | assert_eq!( |
| 925 | by_path(&merged), |
| 926 | vec![ |
| 927 | ("dist/app.js", Some(FileChangeKind::Created), Some("js")), |
| 928 | ( |
| 929 | "edited.txt", |
| 930 | Some(FileChangeKind::Updated), |
| 931 | Some("delta_rev") |
| 932 | ), |
| 933 | ("out.md", Some(FileChangeKind::Created), Some("shell_rev")), |
| 934 | ] |
| 935 | ); |
| 936 | let edited = merged |
| 937 | .artifacts |
| 938 | .iter() |
| 939 | .find(|r| r.path == "edited.txt") |
| 940 | .unwrap(); |
| 941 | assert_eq!(edited.source, TurnArtifactSource::ToolMutation); |
| 942 | assert_eq!(edited.item_id.as_deref(), Some("item_1")); |
| 943 | assert_eq!(edited.restore_snapshot_id.as_deref(), Some("pre")); |
| 944 | let shell = merged |
| 945 | .artifacts |
| 946 | .iter() |
| 947 | .find(|r| r.path == "out.md") |
| 948 | .unwrap(); |
| 949 | assert_eq!(shell.source, TurnArtifactSource::WorkspaceChangedDuringTurn); |
| 950 | assert_eq!(shell.item_id, None); |
| 951 | } |
| 952 | |
| 953 | #[test] |
| 954 | fn the_aggregate_is_capped_and_says_so() { |
| 955 | let items: Vec<_> = (0..(MAX_TURN_ARTIFACTS as i64 + 5)) |
| 956 | .map(|n| file_ref(&format!("f{n}.txt"), FileChangeKind::Created, "r", n)) |
| 957 | .collect(); |
| 958 | let merged = merge_turn_artifacts(&items, None); |
| 959 | assert_eq!(merged.artifacts.len(), MAX_TURN_ARTIFACTS); |
| 960 | assert!(merged.truncated); |
| 961 | assert_eq!(merged.omitted, 5); |
| 962 | // The newest writes are the ones kept. |
| 963 | assert_eq!( |
| 964 | merged.artifacts[0].path, |
| 965 | format!("f{}.txt", MAX_TURN_ARTIFACTS + 4) |
| 966 | ); |
| 967 | } |
| 968 | } |
| 969 |