Deduplicate utilities, remove TOCTOU checks, and strengthen types

- Extract millis_u64 and save_json/load_json to shared crate root, replacing
  identical copies in engine, parallel, human, conclusion, manifest,
  checkpoint, and retro modules
- Remove exists() pre-checks before Manifest::load and Conclusion::load
  in CLI runs scanner (TOCTOU anti-pattern)
- Merge duplicate cancel_run match arms for Queued/Starting/Running
- Change Conclusion.status from String to StageStatus enum
- Extract RunFilterArgs shared struct from RunsListArgs/RunsPruneArgs
- Replace hand-rolled formatDuration with formatDurationMs wrapper over
  existing formatDurationSecs
- Extract duplicated ToolRow/ToolBlock components to shared tool-use.tsx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-06 12:59:22 -05:00
parent 566bef0220
commit 8aa7abf7f4
17 changed files with 163 additions and 244 deletions

View file

@ -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 (
<div className="border-b border-line last:border-b-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left transition-colors hover:bg-overlay"
>
<ChevronRightIcon className={`size-3 shrink-0 text-fg-muted transition-transform duration-150 ${open ? "rotate-90" : ""}`} />
<WrenchScrewdriverIcon className="size-3.5 shrink-0 text-fg-muted" />
<span className="font-mono text-xs text-fg-3">{tool.toolName}</span>
{tool.durationMs != null && <span className="text-[11px] text-fg-muted">{tool.durationMs}ms</span>}
<span className="truncate font-mono text-xs text-fg-muted">{tool.input}</span>
</button>
{open && (
<div className="space-y-px bg-overlay px-2.5 pb-2 pt-1">
<div className="rounded bg-overlay px-2.5 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">Input</div>
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-fg-3">{tool.input}</pre>
</div>
<div className="rounded bg-overlay px-2.5 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">Result</div>
<pre className={`whitespace-pre-wrap font-mono text-xs leading-relaxed ${tool.isError ? "text-coral" : "text-fg-3"}`}>{tool.result}</pre>
</div>
</div>
)}
</div>
);
}
export function ToolBlock({ tools }: { tools: ToolUse[] }) {
return (
<div className="rounded-md border border-line bg-overlay overflow-hidden">
{tools.map((tool) => (
<ToolRow key={tool.id} tool={tool} />
))}
</div>
);
}

View file

@ -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<OpenItemKind, { label: string; text: str
test_gap: { label: "Test Gap", text: "text-coral" },
};
function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes >= 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 };

View file

@ -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) {
<SmoothnesssBadge smoothness={retro.smoothness} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
{formatDuration(retro.total_duration_ms)}
{formatDurationMs(retro.total_duration_ms)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
{retro.friction_point_count}

View file

@ -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 */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Duration" value={formatDuration(retro.stats.total_duration_ms)} />
<StatCard label="Duration" value={formatDurationMs(retro.stats.total_duration_ms)} />
<StatCard label="Cost" value={formatCost(retro.stats.total_cost)} />
<StatCard label="Retries" value={String(retro.stats.total_retries)} warn={retro.stats.total_retries > 0} />
<StatCard label="Files" value={String(retro.stats.files_touched.length)} />
@ -181,7 +181,7 @@ export default function RunRetro({ loaderData }: Route.ComponentProps) {
<StageStatusBadge status={stage.status} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
{formatDuration(stage.duration_ms)}
{formatDurationMs(stage.duration_ms)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums">
<span className={stage.retries > 0 ? "text-amber" : "text-fg-3"}>

View file

@ -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<StageStatus, { icon: typeof CheckCircleIcon; color: s
failed: { icon: XCircleIcon, color: "text-coral" },
};
interface ToolUse {
id: string;
toolName: string;
input: string;
result: string;
isError: boolean;
durationMs?: number;
}
type TurnType =
| { kind: "system"; content: string }
| { kind: "assistant"; content: string }
@ -62,48 +55,6 @@ type TurnType =
// selectedStage is resolved from the URL param in RunStages below
function ToolRow({ tool }: { tool: ToolUse }) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-line last:border-b-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left transition-colors hover:bg-overlay"
>
<ChevronRightIcon className={`size-3 shrink-0 text-fg-muted transition-transform duration-150 ${open ? "rotate-90" : ""}`} />
<WrenchScrewdriverIcon className="size-3.5 shrink-0 text-fg-muted" />
<span className="font-mono text-xs text-fg-3">{tool.toolName}</span>
{tool.durationMs != null && <span className="text-[11px] text-fg-muted">{tool.durationMs}ms</span>}
<span className="truncate font-mono text-xs text-fg-muted">{tool.input}</span>
</button>
{open && (
<div className="space-y-px bg-overlay px-2.5 pb-2 pt-1">
<div className="rounded bg-overlay px-2.5 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">Input</div>
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-fg-3">{tool.input}</pre>
</div>
<div className="rounded bg-overlay px-2.5 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">Result</div>
<pre className={`whitespace-pre-wrap font-mono text-xs leading-relaxed ${tool.isError ? "text-coral" : "text-fg-3"}`}>{tool.result}</pre>
</div>
</div>
)}
</div>
);
}
function ToolBlock({ tools }: { tools: ToolUse[] }) {
return (
<div className="rounded-md border border-line bg-overlay overflow-hidden">
{tools.map((tool) => (
<ToolRow key={tool.id} tool={tool} />
))}
</div>
);
}
function SystemBlock({ content }: { content: string }) {
return (
<div className="rounded-md border border-amber/10 bg-amber/5 overflow-hidden">

View file

@ -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 (
<div className="border-b border-line last:border-b-0">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left transition-colors hover:bg-overlay"
>
<ChevronRightIcon className={`size-3 shrink-0 text-fg-muted transition-transform duration-150 ${open ? "rotate-90" : ""}`} />
<WrenchScrewdriverIcon className="size-3.5 shrink-0 text-fg-muted" />
<span className="font-mono text-xs text-fg-3">{tool.toolName}</span>
{tool.durationMs != null && <span className="text-[11px] text-fg-muted">{tool.durationMs}ms</span>}
<span className="truncate font-mono text-xs text-fg-muted">{tool.input}</span>
</button>
{open && (
<div className="space-y-px bg-overlay px-2.5 pb-2 pt-1">
<div className="rounded bg-overlay px-2.5 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">Input</div>
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-fg-3">{tool.input}</pre>
</div>
<div className="rounded bg-overlay px-2.5 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">Result</div>
<pre className={`whitespace-pre-wrap font-mono text-xs leading-relaxed ${tool.isError ? "text-coral" : "text-fg-3"}`}>{tool.result}</pre>
</div>
</div>
)}
</div>
);
}
function ToolBlock({ tools }: { tools: ToolUse[] }) {
return (
<div className="rounded-md border border-line bg-overlay overflow-hidden">
{tools.map((tool) => (
<ToolRow key={tool.id} tool={tool} />
))}
</div>
);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);

View file

@ -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);
}

View file

@ -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<Self> {
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")
}
}

View file

@ -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(),

View file

@ -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<String>,
@ -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<String>,
/// Filter by workflow name (substring match)
#[arg(long)]
pub workflow: Option<String>,
/// Filter by label (KEY=VALUE, repeatable, AND semantics)
#[arg(long = "label", value_name = "KEY=VALUE")]
pub label: Vec<String>,
/// 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<Vec<RunInfo>> {
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<Vec<RunInfo>> {
}
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,
};

View file

@ -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<Utc>,
pub status: String,
pub status: StageStatus,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_reason: Option<String>,
@ -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<Self> {
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,

View file

@ -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.

View file

@ -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,

View file

@ -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;

View file

@ -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<T: serde::Serialize>(
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<T: serde::de::DeserializeOwned>(
path: &std::path::Path,
label: &str,
) -> error::Result<T> {
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;

View file

@ -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<Self> {
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")
}
}

View file

@ -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<Self> {
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")
}
}