返回 CodeWhale
turn_artifacts.rs
根目录 / crates / tui / src / runtime_threads / turn_artifacts.rs
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
969 lines RUST