返回 CodeWhale
report.rs
根目录 / crates / tui / src / tools / workflow / report.rs
1 use super::{SCHEMA_RAW_PREVIEW_CHARS, WorkflowRunRecord, WorkflowRunStatus};
2 use std::path::{Path, PathBuf};
3
4 /// Persist a durable per-run report under `.codewhale/reports/<run_id>.md`
5 /// so a settled background run leaves one synthesized artifact even after
6 /// the session ends. Best-effort: report IO never affects the run outcome.
7 pub(super) fn write_run_report_artifact(workspace: &Path, record: &WorkflowRunRecord) {
8 if !matches!(
9 record.status,
10 WorkflowRunStatus::Completed
11 | WorkflowRunStatus::Degraded
12 | WorkflowRunStatus::Failed
13 | WorkflowRunStatus::Cancelled
14 ) {
15 return;
16 }
17 let Some(relative) = run_report_relative_path(&record.run_id) else {
18 return;
19 };
20 let path = workspace.join(&relative);
21 // Written through the confined helper: a link in `.codewhale` (or at the
22 // report's own name) is refused instead of redirecting the write out of
23 // the workspace, and missing parents are created without following links.
24 if let Err(err) =
25 crate::fs_confined::write(workspace, &path, render_run_report(record).as_bytes())
26 {
27 crate::logging::warn(format!(
28 "workflow report {} not written: {err}",
29 path.display()
30 ));
31 }
32 }
33
34 /// Workspace-relative path of a run's report, `.codewhale/reports/<id>.md`.
35 /// `None` when the id has no path-safe characters.
36 fn run_report_relative_path(run_id: &str) -> Option<PathBuf> {
37 // Run ids are generated slugs, but never trust one as a path segment.
38 let safe_id: String = run_id
39 .chars()
40 .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
41 .collect();
42 (!safe_id.is_empty()).then(|| {
43 Path::new(".codewhale")
44 .join("reports")
45 .join(format!("{safe_id}.md"))
46 })
47 }
48
49 /// The run's report path relative to the workspace, only when the report
50 /// was actually written. Always `/`-separated: the path is shown to the model
51 /// and the user in receipts, and must read the same on every platform.
52 pub(super) fn written_run_report(workspace: &Path, run_id: &str) -> Option<String> {
53 let relative = run_report_relative_path(run_id)?;
54 workspace.join(&relative).is_file().then(|| {
55 relative
56 .components()
57 .map(|part| part.as_os_str().to_string_lossy())
58 .collect::<Vec<_>>()
59 .join("/")
60 })
61 }
62
63 /// Bounded preview of a raw `responseSchema` reply for run records and
64 /// reports (#5583): the first [`SCHEMA_RAW_PREVIEW_CHARS`] chars on a char
65 /// boundary, with an explicit marker when the text is longer.
66 pub(super) fn bounded_raw_preview(raw: &str) -> String {
67 if raw.chars().count() <= SCHEMA_RAW_PREVIEW_CHARS {
68 return raw.to_string();
69 }
70 let kept: String = raw.chars().take(SCHEMA_RAW_PREVIEW_CHARS).collect();
71 format!("{kept}\n…[preview truncated; full reply in the schema artifact]")
72 }
73
74 /// Write the full raw reply of a failed `responseSchema` attempt as a
75 /// durable artifact beside the run report (#5583), returning its path.
76 /// `None` (with a warning) when the write fails — the bounded preview
77 /// remains either way.
78 pub(super) fn write_schema_raw_artifact(
79 workspace: &Path,
80 run_id: &str,
81 task_id: &str,
82 attempt: u32,
83 raw: &str,
84 ) -> Option<String> {
85 // Run/task ids are generated slugs, but never trust one as a path segment.
86 let safe = |text: &str| {
87 text.chars()
88 .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
89 .collect::<String>()
90 };
91 let (safe_run, safe_task) = (safe(run_id), safe(task_id));
92 if safe_run.is_empty() || safe_task.is_empty() {
93 return None;
94 }
95 let path = workspace.join(".codewhale").join("reports").join(format!(
96 "{safe_run}.schema.{safe_task}.attempt{attempt}.txt"
97 ));
98 // Confined write: no link below the workspace is followed, and the missing
99 // `.codewhale/reports` parents are created without following one.
100 match crate::fs_confined::write(workspace, &path, raw.as_bytes()) {
101 Ok(()) => Some(path.display().to_string()),
102 Err(err) => {
103 crate::logging::warn(format!(
104 "workflow schema artifact {} not written: {err}",
105 path.display()
106 ));
107 None
108 }
109 }
110 }
111
112 pub(super) fn render_run_report(record: &WorkflowRunRecord) -> String {
113 let mut out = String::new();
114 out.push_str(&format!("# Workflow run {}\n\n", record.run_id));
115 out.push_str(&format!("- status: {:?}\n", record.status));
116 if let Some(goal) = record.workflow_goal.as_deref() {
117 out.push_str(&format!("- goal: {goal}\n"));
118 }
119 if let Some(source) = record.source_path.as_deref() {
120 out.push_str(&format!("- source: {}\n", source.display()));
121 }
122 out.push_str(&format!("- started_at_ms: {}\n", record.started_at_ms));
123 if let Some(completed) = record.completed_at_ms {
124 out.push_str(&format!("- completed_at_ms: {completed}\n"));
125 }
126 if let Some(budget) = record.token_budget {
127 out.push_str(&format!("- token_budget: {budget}\n"));
128 }
129 out.push_str(&format!("- child_agents: {}\n", record.child_ids.len()));
130 if let Some(error) = record.error.as_deref() {
131 out.push_str(&format!("- error: {error}\n"));
132 }
133 if record.dispatch_failure_count > 0 {
134 out.push_str(&format!(
135 "\n## Dispatch failures ({})\n\n",
136 record.dispatch_failure_count
137 ));
138 let omitted = record
139 .dispatch_failure_count
140 .saturating_sub(u64::try_from(record.dispatch_failures.len()).unwrap_or(u64::MAX));
141 if omitted > 0 {
142 out.push_str(&format!(
143 "- {omitted} older failure receipt(s) omitted from this bounded report; see the workflow journal\n"
144 ));
145 }
146 for failure in &record.dispatch_failures {
147 let slot = failure
148 .label
149 .as_deref()
150 .or(failure.phase.as_deref())
151 .unwrap_or("task");
152 out.push_str(&format!("- {slot}: {}\n", failure.message));
153 }
154 }
155 if !record.gate_status.is_empty() {
156 out.push_str("\n## Gates\n\n");
157 for line in &record.gate_status {
158 out.push_str(&format!("- {line:?}\n"));
159 }
160 }
161 if !record.progress.is_empty() {
162 out.push_str("\n## Progress\n\n");
163 for line in &record.progress {
164 out.push_str(&format!("- {line}\n"));
165 }
166 }
167 if !record.schema_errors.is_empty() {
168 out.push_str(&format!(
169 "\n## Schema errors ({})\n\n",
170 record.schema_errors.len()
171 ));
172 for error in &record.schema_errors {
173 out.push_str(&format!(
174 "- `{}` attempt {}: [{}] {}\n",
175 error.task_id, error.attempt, error.kind, error.message
176 ));
177 if !error.raw_preview.is_empty() {
178 out.push_str(&format!(
179 " - raw reply ({}):\n",
180 if error.raw_truncated {
181 "bounded preview; carried text was capped"
182 } else {
183 "bounded preview"
184 }
185 ));
186 for line in error.raw_preview.lines() {
187 out.push_str(&format!(" {line}\n"));
188 }
189 }
190 if let Some(artifact) = &error.artifact {
191 out.push_str(&format!(" - full reply: {artifact}\n"));
192 }
193 }
194 }
195 if !record.schema_repairs.is_empty() {
196 out.push_str(&format!(
197 "\n## Schema repairs ({}, including succeeded ones)\n\n",
198 record.schema_repair_count
199 ));
200 for repair in &record.schema_repairs {
201 out.push_str(&format!(
202 "- `{}` attempt {}: [{}] a bounded repair followed\n",
203 repair.task_id, repair.attempt, repair.kind
204 ));
205 if let Some(artifact) = &repair.artifact {
206 out.push_str(&format!(" - full reply: {artifact}\n"));
207 }
208 }
209 }
210 if let Some(result) = record.result.as_ref() {
211 out.push_str("\n## Result\n\n```json\n");
212 out.push_str(&serde_json::to_string_pretty(result).unwrap_or_else(|_| result.to_string()));
213 out.push_str("\n```\n");
214 }
215 if let Some(verification) = record.verification.as_ref() {
216 out.push_str("\n## Verification\n\n```json\n");
217 out.push_str(
218 &serde_json::to_string_pretty(verification)
219 .unwrap_or_else(|_| verification.to_string()),
220 );
221 out.push_str("\n```\n");
222 }
223 out
224 }
225
225 lines RUST