mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-07 08:27:12 +00:00
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:
parent
566bef0220
commit
8aa7abf7f4
17 changed files with 163 additions and 244 deletions
54
apps/arc-web/app/components/tool-use.tsx
Normal file
54
apps/arc-web/app/components/tool-use.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"}>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue