diff --git a/apps/arc-web/app/components/tool-use.tsx b/apps/arc-web/app/components/tool-use.tsx new file mode 100644 index 000000000..7534bfb54 --- /dev/null +++ b/apps/arc-web/app/components/tool-use.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import { ChevronRightIcon } from "@heroicons/react/20/solid"; +import { WrenchScrewdriverIcon } from "@heroicons/react/24/outline"; + +export interface ToolUse { + id: string; + toolName: string; + input: string; + result: string; + isError: boolean; + durationMs?: number; +} + +export function ToolRow({ tool }: { tool: ToolUse }) { + const [open, setOpen] = useState(false); + + return ( +
+ + {open && ( +
+
+
Input
+
{tool.input}
+
+
+
Result
+
{tool.result}
+
+
+ )} +
+ ); +} + +export function ToolBlock({ tools }: { tools: ToolUse[] }) { + return ( +
+ {tools.map((tool) => ( + + ))} +
+ ); +} diff --git a/apps/arc-web/app/data/retros.ts b/apps/arc-web/app/data/retros.ts index 11d36c64f..cb28ce4ba 100644 --- a/apps/arc-web/app/data/retros.ts +++ b/apps/arc-web/app/data/retros.ts @@ -1,3 +1,5 @@ +import { formatDurationSecs } from "../lib/format"; + export type SmoothnessRating = "effortless" | "smooth" | "bumpy" | "struggled" | "failed"; type LearningCategory = "repo" | "code" | "workflow" | "tool"; @@ -88,16 +90,8 @@ export const openItemKindConfig: Record= 60) { - const hours = Math.floor(minutes / 60); - const remainMinutes = minutes % 60; - return `${hours}h ${remainMinutes}m`; - } - return `${minutes}m ${seconds}s`; +function formatDurationMs(ms: number): string { + return formatDurationSecs(Math.floor(ms / 1000)); } -export { formatDuration }; +export { formatDurationMs }; diff --git a/apps/arc-web/app/routes/retros.tsx b/apps/arc-web/app/routes/retros.tsx index 583e1fd56..8ce54871f 100644 --- a/apps/arc-web/app/routes/retros.tsx +++ b/apps/arc-web/app/routes/retros.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useNavigate } from "react-router"; import { MagnifyingGlassIcon, ChevronDownIcon } from "@heroicons/react/24/outline"; -import { smoothnessConfig, formatDuration } from "../data/retros"; +import { smoothnessConfig, formatDurationMs } from "../data/retros"; import type { SmoothnessRating } from "../data/retros"; import { apiJson } from "../api-client"; import type { PaginatedRetroList } from "@qltysh/arc-api-client"; @@ -142,7 +142,7 @@ export default function Retros({ loaderData }: Route.ComponentProps) { - {formatDuration(retro.total_duration_ms)} + {formatDurationMs(retro.total_duration_ms)} {retro.friction_point_count} diff --git a/apps/arc-web/app/routes/run-retro.tsx b/apps/arc-web/app/routes/run-retro.tsx index 7c60bcbf3..1b5e11fcd 100644 --- a/apps/arc-web/app/routes/run-retro.tsx +++ b/apps/arc-web/app/routes/run-retro.tsx @@ -4,7 +4,7 @@ import { learningCategoryConfig, frictionKindConfig, openItemKindConfig, - formatDuration, + formatDurationMs, } from "../data/retros"; import type { Retro } from "../data/retros"; import { apiJson } from "../api-client"; @@ -54,7 +54,7 @@ export default function RunRetro({ loaderData }: Route.ComponentProps) { {/* Aggregate Stats */}
- + 0} /> @@ -181,7 +181,7 @@ export default function RunRetro({ loaderData }: Route.ComponentProps) { - {formatDuration(stage.duration_ms)} + {formatDurationMs(stage.duration_ms)} 0 ? "text-amber" : "text-fg-3"}> diff --git a/apps/arc-web/app/routes/run-stages.tsx b/apps/arc-web/app/routes/run-stages.tsx index df5b87c2e..d0fed92b4 100644 --- a/apps/arc-web/app/routes/run-stages.tsx +++ b/apps/arc-web/app/routes/run-stages.tsx @@ -2,7 +2,9 @@ import { useState } from "react"; import { Link, useParams } from "react-router"; import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { CheckCircleIcon, ArrowPathIcon, PauseCircleIcon, XCircleIcon } from "@heroicons/react/24/solid"; -import { DocumentTextIcon, MapIcon, CommandLineIcon, ChatBubbleLeftIcon, WrenchScrewdriverIcon } from "@heroicons/react/24/outline"; +import { DocumentTextIcon, MapIcon, CommandLineIcon, ChatBubbleLeftIcon } from "@heroicons/react/24/outline"; +import { ToolRow, ToolBlock } from "../components/tool-use"; +import type { ToolUse } from "../components/tool-use"; import { apiJson } from "../api-client"; import { formatDurationSecs } from "../lib/format"; import type { PaginatedRunStageList, StageTurn as ApiStageTurn, PaginatedStageTurnList } from "@qltysh/arc-api-client"; @@ -46,15 +48,6 @@ const statusConfig: Record - - {open && ( -
-
-
Input
-
{tool.input}
-
-
-
Result
-
{tool.result}
-
-
- )} -
- ); -} - -function ToolBlock({ tools }: { tools: ToolUse[] }) { - return ( -
- {tools.map((tool) => ( - - ))} -
- ); -} - function SystemBlock({ content }: { content: string }) { return (
diff --git a/apps/arc-web/app/routes/session-detail.tsx b/apps/arc-web/app/routes/session-detail.tsx index 102e6ffd0..95057dbb5 100644 --- a/apps/arc-web/app/routes/session-detail.tsx +++ b/apps/arc-web/app/routes/session-detail.tsx @@ -1,14 +1,14 @@ import { useState } from "react"; import { Link, useParams } from "react-router"; -import { ChevronRightIcon } from "@heroicons/react/20/solid"; import { ChatBubbleLeftIcon, ClipboardDocumentIcon, CheckIcon, PencilSquareIcon, UserIcon, - WrenchScrewdriverIcon, } from "@heroicons/react/24/outline"; +import { ToolRow, ToolBlock } from "../components/tool-use"; +import type { ToolUse } from "../components/tool-use"; import { timeAgo, groupSessionsByDate } from "../lib/time"; import { apiJson } from "../api-client"; import type { SessionDetail as ApiSessionDetail, PaginatedSessionList } from "@qltysh/arc-api-client"; @@ -58,15 +58,6 @@ export async function loader({ request, params }: Route.LoaderArgs) { return { session, sessionGroups }; } -interface ToolUse { - id: string; - toolName: string; - input: string; - result: string; - isError: boolean; - durationMs?: number; -} - type Turn = | { kind: "user"; content: string; created_at?: string } | { kind: "assistant"; content: string } @@ -294,48 +285,6 @@ const sessionGroups: SessionGroupType[] = [ }, ]; -function ToolRow({ tool }: { tool: ToolUse }) { - const [open, setOpen] = useState(false); - - return ( -
- - {open && ( -
-
-
Input
-
{tool.input}
-
-
-
Result
-
{tool.result}
-
-
- )} -
- ); -} - -function ToolBlock({ tools }: { tools: ToolUse[] }) { - return ( -
- {tools.map((tool) => ( - - ))} -
- ); -} - function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); diff --git a/crates/arc-api/src/server.rs b/crates/arc-api/src/server.rs index 4a4be8ea1..d6ee64b95 100644 --- a/crates/arc-api/src/server.rs +++ b/crates/arc-api/src/server.rs @@ -868,22 +868,7 @@ async fn cancel_run( let mut runs = state.runs.lock().expect("runs lock poisoned"); match runs.get_mut(&id) { Some(managed_run) => match managed_run.status { - RunStatus::Queued => { - managed_run.status = RunStatus::Cancelled; - let created_at = managed_run.created_at; - ( - StatusCode::OK, - Json(RunStatusResponse { - id: id.clone(), - status: RunStatus::Cancelled, - error: None, - queue_position: None, - created_at, - }), - ) - .into_response() - } - RunStatus::Starting | RunStatus::Running => { + RunStatus::Queued | RunStatus::Starting | RunStatus::Running => { if let Some(token) = &managed_run.cancel_token { token.store(true, Ordering::Relaxed); } diff --git a/crates/arc-workflows/src/checkpoint.rs b/crates/arc-workflows/src/checkpoint.rs index f9e541413..d3df58f2a 100644 --- a/crates/arc-workflows/src/checkpoint.rs +++ b/crates/arc-workflows/src/checkpoint.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::context::Context; -use crate::error::{ArcError, FailureSignature, Result}; +use crate::error::{FailureSignature, Result}; use crate::outcome::Outcome; /// Serializable snapshot of execution state for crash recovery and resume. @@ -70,10 +70,7 @@ impl Checkpoint { /// Returns an error if serialization or file writing fails. pub fn save(&self, path: &Path) -> Result<()> { tracing::debug!(path = %path.display(), node = %self.current_node, "Saving checkpoint"); - let json = serde_json::to_string_pretty(self) - .map_err(|e| ArcError::Checkpoint(format!("serialize failed: {e}")))?; - std::fs::write(path, json)?; - Ok(()) + crate::save_json(self, path, "checkpoint") } /// Load a checkpoint from a JSON file. @@ -83,10 +80,7 @@ impl Checkpoint { /// Returns an error if the file cannot be read or deserialization fails. pub fn load(path: &Path) -> Result { tracing::debug!(path = %path.display(), "Loading checkpoint"); - let data = std::fs::read_to_string(path)?; - let checkpoint: Self = serde_json::from_str(&data) - .map_err(|e| ArcError::Checkpoint(format!("deserialize failed: {e}")))?; - Ok(checkpoint) + crate::load_json(path, "checkpoint") } } diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index 2ceb201be..c4124b289 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -709,8 +709,8 @@ pub async fn run_command( { let (status, failure_reason) = match &engine_result { - Ok(o) => (o.status.to_string(), o.failure_reason().map(String::from)), - Err(e) => ("fail".to_string(), Some(e.to_string())), + Ok(o) => (o.status.clone(), o.failure_reason().map(String::from)), + Err(e) => (crate::outcome::StageStatus::Fail, Some(e.to_string())), }; let conclusion = crate::conclusion::Conclusion { timestamp: Utc::now(), diff --git a/crates/arc-workflows/src/cli/runs.rs b/crates/arc-workflows/src/cli/runs.rs index 689dbda21..879d52d95 100644 --- a/crates/arc-workflows/src/cli/runs.rs +++ b/crates/arc-workflows/src/cli/runs.rs @@ -7,8 +7,8 @@ use serde::Serialize; use tracing::{debug, info}; #[derive(Args)] -pub struct RunsListArgs { - /// Only show runs started before this date (YYYY-MM-DD prefix match) +pub struct RunFilterArgs { + /// Only include runs started before this date (YYYY-MM-DD prefix match) #[arg(long)] pub before: Option, @@ -23,6 +23,12 @@ pub struct RunsListArgs { /// Include orphan directories (no manifest.json) #[arg(long)] pub orphans: bool, +} + +#[derive(Args)] +pub struct RunsListArgs { + #[command(flatten)] + pub filter: RunFilterArgs, /// Output as JSON #[arg(long)] @@ -31,21 +37,8 @@ pub struct RunsListArgs { #[derive(Args)] pub struct RunsPruneArgs { - /// Only prune runs started before this date (YYYY-MM-DD prefix match) - #[arg(long)] - pub before: Option, - - /// Filter by workflow name (substring match) - #[arg(long)] - pub workflow: Option, - - /// Filter by label (KEY=VALUE, repeatable, AND semantics) - #[arg(long = "label", value_name = "KEY=VALUE")] - pub label: Vec, - - /// Include orphan directories (no manifest.json) - #[arg(long)] - pub orphans: bool, + #[command(flatten)] + pub filter: RunFilterArgs, /// Actually delete (default is dry-run) #[arg(long)] @@ -87,9 +80,8 @@ pub fn scan_runs(base: &Path) -> Result> { debug!(dir = %dir_name, "scanning run directory"); let manifest_path = path.join("manifest.json"); - if manifest_path.exists() { + if let Ok(manifest) = crate::manifest::Manifest::load(&manifest_path) { debug!(dir = %dir_name, "reading manifest"); - let manifest = crate::manifest::Manifest::load(&manifest_path)?; let run_id = manifest.run_id; let workflow_name = manifest.workflow_name; @@ -139,17 +131,13 @@ pub fn scan_runs(base: &Path) -> Result> { } fn read_status(run_dir: &Path) -> String { - let conclusion_path = run_dir.join("conclusion.json"); - if conclusion_path.exists() { - if let Ok(conclusion) = crate::conclusion::Conclusion::load(&conclusion_path) { - return conclusion.status; - } - "unknown".to_string() - } else if run_dir.join("run.pid").exists() { - "running".to_string() - } else { - "unknown".to_string() + if let Ok(conclusion) = crate::conclusion::Conclusion::load(&run_dir.join("conclusion.json")) { + return conclusion.status.to_string(); } + if run_dir.join("run.pid").exists() { + return "running".to_string(); + } + "unknown".to_string() } /// Filter runs by criteria. Orphans are excluded unless `include_orphans` is true. @@ -205,13 +193,13 @@ fn default_logs_base() -> PathBuf { pub fn list_command(args: &RunsListArgs) -> Result<()> { let base = default_logs_base(); let runs = scan_runs(&base)?; - let label_filters = parse_label_filters(&args.label); + let label_filters = parse_label_filters(&args.filter.label); let filtered = filter_runs( &runs, - args.before.as_deref(), - args.workflow.as_deref(), + args.filter.before.as_deref(), + args.filter.workflow.as_deref(), &label_filters, - args.orphans, + args.filter.orphans, ); if args.json { @@ -265,13 +253,13 @@ pub fn prune_command(args: &RunsPruneArgs) -> Result<()> { pub fn prune_from(args: &RunsPruneArgs, base: &Path) -> Result<()> { let runs = scan_runs(base)?; - let label_filters = parse_label_filters(&args.label); + let label_filters = parse_label_filters(&args.filter.label); let filtered = filter_runs( &runs, - args.before.as_deref(), - args.workflow.as_deref(), + args.filter.before.as_deref(), + args.filter.workflow.as_deref(), &label_filters, - args.orphans, + args.filter.orphans, ); if filtered.is_empty() { @@ -540,10 +528,12 @@ mod tests { ); let args = RunsPruneArgs { - before: Some("2026-01-01".into()), - workflow: None, - label: Vec::new(), - orphans: false, + filter: RunFilterArgs { + before: Some("2026-01-01".into()), + workflow: None, + label: Vec::new(), + orphans: false, + }, yes: false, }; @@ -588,10 +578,12 @@ mod tests { ); let args = RunsPruneArgs { - before: Some("2026-01-01".into()), - workflow: None, - label: Vec::new(), - orphans: false, + filter: RunFilterArgs { + before: Some("2026-01-01".into()), + workflow: None, + label: Vec::new(), + orphans: false, + }, yes: true, }; @@ -611,10 +603,12 @@ mod tests { let orphan_dir = make_run_dir(base, "orphan-dir", None, None, false); let args = RunsPruneArgs { - before: None, - workflow: None, - label: Vec::new(), - orphans: true, + filter: RunFilterArgs { + before: None, + workflow: None, + label: Vec::new(), + orphans: true, + }, yes: true, }; diff --git a/crates/arc-workflows/src/conclusion.rs b/crates/arc-workflows/src/conclusion.rs index e4bcfa927..d18637527 100644 --- a/crates/arc-workflows/src/conclusion.rs +++ b/crates/arc-workflows/src/conclusion.rs @@ -3,12 +3,13 @@ use std::path::Path; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::error::{ArcError, Result}; +use crate::error::Result; +use crate::outcome::StageStatus; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Conclusion { pub timestamp: DateTime, - pub status: String, + pub status: StageStatus, pub duration_ms: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub failure_reason: Option, @@ -18,17 +19,11 @@ pub struct Conclusion { impl Conclusion { pub fn save(&self, path: &Path) -> Result<()> { - let json = serde_json::to_string_pretty(self) - .map_err(|e| ArcError::Checkpoint(format!("conclusion serialize failed: {e}")))?; - std::fs::write(path, json)?; - Ok(()) + crate::save_json(self, path, "conclusion") } pub fn load(path: &Path) -> Result { - let data = std::fs::read_to_string(path)?; - let conclusion: Self = serde_json::from_str(&data) - .map_err(|e| ArcError::Checkpoint(format!("conclusion deserialize failed: {e}")))?; - Ok(conclusion) + crate::load_json(path, "conclusion") } } @@ -39,7 +34,7 @@ mod tests { fn sample_conclusion() -> Conclusion { Conclusion { timestamp: Utc::now(), - status: "success".to_string(), + status: crate::outcome::StageStatus::Success, duration_ms: 12345, failure_reason: None, final_git_commit_sha: Some("deadbeef".to_string()), @@ -55,7 +50,7 @@ mod tests { conclusion.save(&path).unwrap(); let loaded = Conclusion::load(&path).unwrap(); - assert_eq!(loaded.status, "success"); + assert_eq!(loaded.status, crate::outcome::StageStatus::Success); assert_eq!(loaded.duration_ms, 12345); assert!(loaded.failure_reason.is_none()); assert_eq!( @@ -87,7 +82,7 @@ mod tests { let conclusion = Conclusion { timestamp: Utc::now(), - status: "fail".to_string(), + status: crate::outcome::StageStatus::Fail, duration_ms: 500, failure_reason: None, final_git_commit_sha: None, @@ -107,7 +102,7 @@ mod tests { let conclusion = Conclusion { timestamp: Utc::now(), - status: "fail".to_string(), + status: crate::outcome::StageStatus::Fail, duration_ms: 100, failure_reason: Some("timeout".to_string()), final_git_commit_sha: None, diff --git a/crates/arc-workflows/src/engine.rs b/crates/arc-workflows/src/engine.rs index 16d33b351..fa48f3c4c 100644 --- a/crates/arc-workflows/src/engine.rs +++ b/crates/arc-workflows/src/engine.rs @@ -26,13 +26,9 @@ use crate::handler::{EngineServices, HandlerRegistry}; use crate::hook::{HookContext, HookDecision, HookEvent, HookRunner}; use crate::interviewer::Interviewer; use crate::outcome::{Outcome, StageStatus}; +use crate::millis_u64; use crate::preamble::build_preamble; -/// Convert a Duration's milliseconds to u64, saturating on overflow. -fn millis_u64(d: std::time::Duration) -> u64 { - u64::try_from(d.as_millis()).unwrap_or(u64::MAX) -} - /// Classify the failure mode of a completed outcome. /// /// Returns `None` for `Success`, `PartialSuccess`, and `Skipped` outcomes. diff --git a/crates/arc-workflows/src/handler/human.rs b/crates/arc-workflows/src/handler/human.rs index e0831052b..8a6b6bb0b 100644 --- a/crates/arc-workflows/src/handler/human.rs +++ b/crates/arc-workflows/src/handler/human.rs @@ -4,6 +4,7 @@ use std::time::Instant; use async_trait::async_trait; +use crate::millis_u64; use crate::context::keys; use crate::context::Context; use crate::error::ArcError; @@ -16,11 +17,6 @@ use crate::outcome::Outcome; use super::{EngineServices, Handler}; -/// Convert a Duration's milliseconds to u64, saturating on overflow. -fn millis_u64(d: std::time::Duration) -> u64 { - u64::try_from(d.as_millis()).unwrap_or(u64::MAX) -} - /// A choice derived from an outgoing edge. struct Choice { key: String, diff --git a/crates/arc-workflows/src/handler/parallel.rs b/crates/arc-workflows/src/handler/parallel.rs index 3d5081f5f..d5d9a1818 100644 --- a/crates/arc-workflows/src/handler/parallel.rs +++ b/crates/arc-workflows/src/handler/parallel.rs @@ -6,6 +6,7 @@ use arc_agent::Sandbox; use async_trait::async_trait; use tokio::sync::Semaphore; +use crate::millis_u64; use crate::context::keys; use crate::context::Context; use crate::engine::GitCheckpointMode; @@ -104,11 +105,6 @@ impl Sandbox for WorktreeSandbox { } } -/// Convert a Duration's milliseconds to u64, saturating on overflow. -fn millis_u64(d: std::time::Duration) -> u64 { - u64::try_from(d.as_millis()).unwrap_or(u64::MAX) -} - /// Fans out execution to multiple branches concurrently. /// Each branch gets an isolated context clone and runs independently. pub struct ParallelHandler; diff --git a/crates/arc-workflows/src/lib.rs b/crates/arc-workflows/src/lib.rs index 6e1ea6d42..6affde403 100644 --- a/crates/arc-workflows/src/lib.rs +++ b/crates/arc-workflows/src/lib.rs @@ -1,3 +1,30 @@ +/// Convert a Duration's milliseconds to u64, saturating on overflow. +pub(crate) fn millis_u64(d: std::time::Duration) -> u64 { + u64::try_from(d.as_millis()).unwrap_or(u64::MAX) +} + +/// Save a value as pretty-printed JSON to a file. +pub(crate) fn save_json( + value: &T, + path: &std::path::Path, + label: &str, +) -> error::Result<()> { + let json = serde_json::to_string_pretty(value) + .map_err(|e| error::ArcError::Checkpoint(format!("{label} serialize failed: {e}")))?; + std::fs::write(path, json)?; + Ok(()) +} + +/// Load a value from a JSON file. +pub(crate) fn load_json( + path: &std::path::Path, + label: &str, +) -> error::Result { + let data = std::fs::read_to_string(path)?; + serde_json::from_str(&data) + .map_err(|e| error::ArcError::Checkpoint(format!("{label} deserialize failed: {e}"))) +} + pub mod artifact; pub mod asset_snapshot; pub mod checkpoint; diff --git a/crates/arc-workflows/src/manifest.rs b/crates/arc-workflows/src/manifest.rs index 8a7d4c74e..8ad14f40e 100644 --- a/crates/arc-workflows/src/manifest.rs +++ b/crates/arc-workflows/src/manifest.rs @@ -4,7 +4,7 @@ use std::path::Path; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use crate::error::{ArcError, Result}; +use crate::error::Result; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Manifest { @@ -24,17 +24,11 @@ pub struct Manifest { impl Manifest { pub fn save(&self, path: &Path) -> Result<()> { - let json = serde_json::to_string_pretty(self) - .map_err(|e| ArcError::Checkpoint(format!("manifest serialize failed: {e}")))?; - std::fs::write(path, json)?; - Ok(()) + crate::save_json(self, path, "manifest") } pub fn load(path: &Path) -> Result { - let data = std::fs::read_to_string(path)?; - let manifest: Self = serde_json::from_str(&data) - .map_err(|e| ArcError::Checkpoint(format!("manifest deserialize failed: {e}")))?; - Ok(manifest) + crate::load_json(path, "manifest") } } diff --git a/crates/arc-workflows/src/retro.rs b/crates/arc-workflows/src/retro.rs index b9eba9317..b8f283e9e 100644 --- a/crates/arc-workflows/src/retro.rs +++ b/crates/arc-workflows/src/retro.rs @@ -6,7 +6,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::checkpoint::Checkpoint; -use crate::error::{ArcError, Result}; +use crate::error::Result; use crate::outcome::StageStatus; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -170,18 +170,12 @@ impl Retro { /// Save the retro as JSON to `logs_root/retro.json`. pub fn save(&self, logs_root: &Path) -> Result<()> { - let json = serde_json::to_string_pretty(self) - .map_err(|e| ArcError::Checkpoint(format!("retro serialize failed: {e}")))?; - std::fs::write(logs_root.join("retro.json"), json)?; - Ok(()) + crate::save_json(self, &logs_root.join("retro.json"), "retro") } /// Load a retro from `logs_root/retro.json`. pub fn load(logs_root: &Path) -> Result { - let data = std::fs::read_to_string(logs_root.join("retro.json"))?; - let retro: Self = serde_json::from_str(&data) - .map_err(|e| ArcError::Checkpoint(format!("retro deserialize failed: {e}")))?; - Ok(retro) + crate::load_json(&logs_root.join("retro.json"), "retro") } }