chore: artifacts cleanup (#33)

* artifacts cleanup

* cargo build

* cargo
This commit is contained in:
Rehaan Ahmad
2026-07-07 22:38:32 -07:00
committed by GitHub
parent 09f4cfd6f0
commit 14bebd2ddf
10 changed files with 386 additions and 316 deletions
Generated
+1 -1
View File
@@ -917,7 +917,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openresearch-cli"
version = "0.1.40"
version = "0.1.41"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "openresearch-cli"
version = "0.1.40"
version = "0.1.41"
edition = "2021"
description = "OpenResearch CLI (orx) — Rust port"
repository = "https://github.com/alphaXiv/openresearch-cli"
+63 -88
View File
@@ -1,8 +1,8 @@
//! Per-project artifacts directory — a plain folder on the user's machine
//! (`<data dir>/artifacts/<project slug>/`). The filesystem is the source of
//! truth: no registry, no upload step. Subfolders with a top-level `report.md`
//! render as reports in the dashboard's Artifacts tab; everything else is
//! browsable files.
//! truth: no registry, no upload step. The dashboard's Artifacts tab is an
//! explorer over this folder; a folder with a top-level `report.md` is still
//! just a folder, it only additionally renders as a report.
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
@@ -68,23 +68,23 @@ pub fn content_type_for_path(path: &str) -> &'static str {
}
}
/// A subfolder with a top-level `report.md`, rendered as a report.
/// One node of the artifacts tree: a file or a directory with its children.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactReport {
/// Folder name — the report's id and path prefix for its files.
pub struct ArtifactEntry {
pub name: String,
pub title: String,
pub modified_at: i64,
}
/// Any other file in the artifacts dir, by dir-relative path.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactFile {
/// Dir-relative, `/`-joined — the id for file/report/delete endpoints.
pub path: String,
pub is_dir: bool,
/// 0 for directories.
pub size: u64,
pub modified_at: i64,
/// Set when this dir holds a top-level `report.md` — the UI offers a
/// rendered-report view on top of the normal folder row.
#[serde(skip_serializing_if = "Option::is_none")]
pub report_title: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub children: Vec<ArtifactEntry>,
}
#[derive(Debug, Clone, Serialize)]
@@ -93,8 +93,7 @@ pub struct ArtifactsListing {
/// Absolute path of the artifacts dir, shown in the UI so the user can
/// drop files in.
pub dir: String,
pub reports: Vec<ArtifactReport>,
pub files: Vec<ArtifactFile>,
pub entries: Vec<ArtifactEntry>,
pub truncated: bool,
}
@@ -131,102 +130,78 @@ fn report_title(md_path: &Path, fallback: &str) -> String {
.unwrap_or_else(|| fallback.to_string())
}
/// Recursively collect files under `dir` as `/`-joined paths relative to
/// `base`, up to `MAX_ENTRIES`. Returns true when it hit the cap.
fn collect_files(base: &Path, dir: &Path, out: &mut Vec<ArtifactFile>) -> bool {
/// Recursively build the tree under `dir`, counting nodes against
/// `MAX_ENTRIES`. Returns (children, hit_cap).
fn collect_tree(dir: &Path, rel_prefix: &str, seen: &mut usize) -> (Vec<ArtifactEntry>, bool) {
let mut out = Vec::new();
let mut truncated = false;
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
return (out, false);
};
for entry in entries.flatten() {
if out.len() >= MAX_ENTRIES {
return true;
if *seen >= MAX_ENTRIES {
return (out, true);
}
let name = entry.file_name().to_string_lossy().into_owned();
if is_ignored(&name) {
continue;
}
let path = entry.path();
let Ok(md) = entry.metadata() else { continue };
*seen += 1;
let rel = if rel_prefix.is_empty() {
name.clone()
} else {
format!("{rel_prefix}/{name}")
};
if md.is_dir() {
if collect_files(base, &path, out) {
return true;
}
let report_md = entry.path().join("report.md");
let report_title = report_md.is_file().then(|| report_title(&report_md, &name));
let (children, hit) = collect_tree(&entry.path(), &rel, seen);
truncated |= hit;
out.push(ArtifactEntry {
name,
path: rel,
is_dir: true,
size: 0,
modified_at: mtime_ms(&md),
report_title,
children,
});
} else if md.is_file() {
if let Ok(rel) = path.strip_prefix(base) {
let rel = rel
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
if !rel.is_empty() {
out.push(ArtifactFile {
path: rel,
size: md.len(),
modified_at: mtime_ms(&md),
});
}
}
}
}
false
}
/// Scan the artifacts dir (creating it if missing): report folders first,
/// then every other file.
pub fn list(project: &LocalProject) -> Result<ArtifactsListing> {
let dir = ensure_dir(project)?;
let mut reports = Vec::new();
let mut files = Vec::new();
let mut truncated = false;
let entries =
std::fs::read_dir(&dir).map_err(|e| anyhow!("Could not read {}: {}", dir.display(), e))?;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if is_ignored(&name) {
continue;
}
let path = entry.path();
let Ok(md) = entry.metadata() else { continue };
if md.is_dir() {
let report_md = path.join("report.md");
if report_md.is_file() {
let modified = std::fs::metadata(&report_md)
.map(|m| mtime_ms(&m))
.unwrap_or(0);
reports.push(ArtifactReport {
title: report_title(&report_md, &name),
name,
modified_at: modified,
});
} else {
truncated |= collect_files(&dir, &path, &mut files);
}
} else if md.is_file() {
files.push(ArtifactFile {
path: name,
out.push(ArtifactEntry {
name,
path: rel,
is_dir: false,
size: md.len(),
modified_at: mtime_ms(&md),
report_title: None,
children: Vec::new(),
});
}
}
// Dirs first, then files, each alphabetical — stable explorer order.
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)));
(out, truncated)
}
reports.sort_by_key(|r| std::cmp::Reverse(r.modified_at));
files.sort_by(|a, b| a.path.cmp(&b.path));
/// Scan the artifacts dir (creating it if missing) into a file tree.
pub fn list(project: &LocalProject) -> Result<ArtifactsListing> {
let dir = ensure_dir(project)?;
let mut seen = 0;
let (entries, truncated) = collect_tree(&dir, "", &mut seen);
Ok(ArtifactsListing {
dir: dir.to_string_lossy().into_owned(),
reports,
files,
entries,
truncated,
})
}
/// A report folder's `report.md` body.
pub fn read_report_markdown(project: &LocalProject, report_name: &str) -> Result<String> {
if !is_safe_artifact_path(report_name) || report_name.contains('/') {
return Err(anyhow!("invalid report name: {report_name}"));
/// A report folder's `report.md` body, by dir-relative folder path.
pub fn read_report_markdown(project: &LocalProject, folder: &str) -> Result<String> {
if !is_safe_artifact_path(folder) {
return Err(anyhow!("invalid report path: {folder}"));
}
let path = artifacts_dir(project).join(report_name).join("report.md");
let path = artifacts_dir(project).join(folder).join("report.md");
std::fs::read_to_string(&path).map_err(|e| anyhow!("Could not read {}: {}", path.display(), e))
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -8,8 +8,8 @@
html { background: #ffffff; }
@media (prefers-color-scheme: dark) { html { background: #0e0c0c; } }
</style>
<script type="module" crossorigin src="/assets/index-DV0y0l2f.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dm6rH5Gf.css">
<script type="module" crossorigin src="/assets/index-CMAbKIpS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bz7UoZ7k.css">
</head>
<body>
<div id="root"></div>
+2 -4
View File
@@ -305,10 +305,8 @@ export default function App() {
onClick={() => setRightTab("artifacts")}
>
Artifacts
{artifacts && artifacts.reports.length + artifacts.files.length > 0 && (
<span className="tab-count">
{artifacts.reports.length + artifacts.files.length}
</span>
{artifacts && artifacts.entries.length > 0 && (
<span className="tab-count">{artifacts.entries.length}</span>
)}
</button>
{expTabs.map((t) => {
+9 -10
View File
@@ -283,25 +283,24 @@ export interface SshPreflight {
export const sshPreflight = (host: string) =>
post<SshPreflight>("/api/settings/ssh/preflight", { host });
/** A subfolder of the artifacts dir with a top-level report.md. */
export interface ArtifactReport {
/** One node of the artifacts tree: a file, or a directory with children. */
export interface ArtifactEntry {
name: string;
title: string;
modifiedAt: number;
}
/** Any other file in the artifacts dir, by dir-relative path. */
export interface ArtifactFile {
/** Dir-relative `/`-joined path — the id for file/report/delete endpoints. */
path: string;
isDir: boolean;
/** 0 for directories. */
size: number;
modifiedAt: number;
/** Set when the dir holds a top-level report.md — renders as a report. */
reportTitle?: string;
children?: ArtifactEntry[];
}
/** Listing of the project's on-disk artifacts directory. */
export interface Artifacts {
dir: string;
reports: ArtifactReport[];
files: ArtifactFile[];
entries: ArtifactEntry[];
truncated: boolean;
}
+185 -111
View File
@@ -1,10 +1,12 @@
import {
ArrowLeft,
Check,
ChevronRight,
Copy,
ExternalLink,
File,
FileText,
Folder,
FolderGit2,
FolderOpen,
Trash2,
@@ -15,8 +17,7 @@ import {
artifactFileUrl,
deleteArtifact,
getArtifactReport,
type ArtifactFile,
type ArtifactReport,
type ArtifactEntry,
type Artifacts,
type Project,
} from "../api";
@@ -40,21 +41,33 @@ function fmtBytes(n: number): string {
const IMAGE_RE = /\.(png|jpe?g|gif|webp|svg)$/i;
/** Depth-first lookup of a tree entry by its dir-relative path. */
function findEntry(entries: ArtifactEntry[], path: string): ArtifactEntry | null {
for (const e of entries) {
if (e.path === path) return e;
if (e.isDir && path.startsWith(e.path + "/")) {
const hit = findEntry(e.children ?? [], path);
if (hit) return hit;
}
}
return null;
}
/** Report markdown with report-relative image/link paths (`images/...`)
* rewritten to the artifact file endpoint, scoped to the report's folder. */
function ReportMd({
projectId,
reportName,
folder,
markdown,
}: {
projectId: string;
reportName: string;
folder: string;
markdown: string;
}) {
const resolve = (src: string) =>
isExternalSrc(src)
? src
: artifactFileUrl(projectId, `${reportName}/${src.replace(/^\.?\//, "")}`);
: artifactFileUrl(projectId, `${folder}/${src.replace(/^\.?\//, "")}`);
return (
<div className="md report-md">
<ReactMarkdown
@@ -89,12 +102,12 @@ function ReportMd({
function ReportView({
projectId,
report,
entry,
onBack,
onDelete,
}: {
projectId: string;
report: ArtifactReport;
entry: ArtifactEntry;
onBack: () => void;
onDelete: () => void;
}) {
@@ -102,25 +115,25 @@ function ReportView({
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getArtifactReport(projectId, report.name)
getArtifactReport(projectId, entry.path)
.then((r) => setMarkdown(r.markdown))
.catch((e) => setError(e instanceof Error ? e.message : String(e)));
}, [projectId, report.name, report.modifiedAt]);
}, [projectId, entry.path, entry.modifiedAt]);
return (
<div className="report-view">
<div className="report-view-head">
<button className="report-back" onClick={onBack}>
<ArrowLeft size={13} /> Reports
<ArrowLeft size={13} /> Artifacts
</button>
<span style={{ flex: 1 }} />
<span className="report-date">{new Date(report.modifiedAt).toLocaleString()}</span>
<span className="report-date">{new Date(entry.modifiedAt).toLocaleString()}</span>
<button
className="icon-btn"
title="Delete report folder"
aria-label="Delete report folder"
onClick={() => {
if (window.confirm(`Delete the "${report.name}" folder from the artifacts dir?`))
if (window.confirm(`Delete the "${entry.path}" folder from the artifacts dir?`))
onDelete();
}}
>
@@ -134,7 +147,7 @@ function ReportView({
<span className="spinner" /> Loading report…
</div>
) : (
<ReportMd projectId={projectId} reportName={report.name} markdown={markdown} />
<ReportMd projectId={projectId} folder={entry.path} markdown={markdown} />
)}
</div>
);
@@ -163,9 +176,119 @@ function DirPath({ dir }: { dir: string }) {
);
}
/** Right-pane Artifacts tab — a live view of the project's artifacts folder
* on disk. Subfolders with a report.md render as reports; everything else
* (figures, CSVs, whatever the user drops in) is listed as files. */
function TreeRows({
projectId,
entries,
depth,
expanded,
onToggle,
onOpenReport,
onDelete,
}: {
projectId: string;
entries: ArtifactEntry[];
depth: number;
expanded: Set<string>;
onToggle: (path: string) => void;
onOpenReport: (path: string) => void;
onDelete: (path: string) => void;
}) {
return (
<>
{entries.map((e) => {
const indent = { paddingLeft: 12 + depth * 18 };
const del = (
<button
className="icon-btn artifact-tree-del"
title={`Delete ${e.path}`}
aria-label={`Delete ${e.path}`}
onClick={(ev) => {
ev.stopPropagation();
if (window.confirm(`Delete "${e.path}" from the artifacts dir?`)) onDelete(e.path);
}}
>
<Trash2 size={13} />
</button>
);
if (e.isDir) {
const isReport = e.reportTitle !== undefined;
const open = expanded.has(e.path);
return (
<div key={e.path}>
<div
className="artifact-tree-row clickable"
style={indent}
onClick={() => (isReport ? onOpenReport(e.path) : onToggle(e.path))}
>
<button
className={`artifact-tree-chevron ${open ? "open" : ""}`}
aria-label={open ? `Collapse ${e.name}` : `Expand ${e.name}`}
onClick={(ev) => {
ev.stopPropagation();
onToggle(e.path);
}}
>
<ChevronRight size={13} />
</button>
{isReport ? <FileText size={14} /> : <Folder size={14} />}
<span className={`artifact-tree-name ${isReport ? "report" : ""}`}>
{e.reportTitle || e.name}
</span>
{del}
<span className="report-date">
{new Date(e.modifiedAt).toLocaleDateString()}
</span>
</div>
{open && (
<TreeRows
projectId={projectId}
entries={e.children ?? []}
depth={depth + 1}
expanded={expanded}
onToggle={onToggle}
onOpenReport={onOpenReport}
onDelete={onDelete}
/>
)}
</div>
);
}
return (
<div key={e.path} className="artifact-tree-row" style={indent}>
<span className="artifact-tree-chevron spacer" />
<a
className="artifact-file-link"
href={artifactFileUrl(projectId, e.path)}
target="_blank"
rel="noopener noreferrer"
title={e.path}
>
{IMAGE_RE.test(e.name) ? (
<img
className="artifact-thumb"
src={artifactFileUrl(projectId, e.path)}
alt=""
loading="lazy"
/>
) : (
<File size={14} />
)}
<span className="artifact-tree-name">{e.name}</span>
</a>
{del}
<span className="report-date">{fmtBytes(e.size)}</span>
</div>
);
})}
</>
);
}
/** Right-pane Artifacts tab — an explorer over the project's artifacts folder
* on disk. Every entry is an artifact; a folder with a top-level report.md
* additionally opens as a rendered report. */
export function ArtifactsTab({
project,
artifacts,
@@ -175,16 +298,24 @@ export function ArtifactsTab({
artifacts: Artifacts | null;
onChanged: () => void;
}) {
const [openName, setOpenName] = useState<string | null>(null);
const open = openName
? (artifacts?.reports.find((r) => r.name === openName) ?? null)
: null;
const [openPath, setOpenPath] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const openEntry = openPath && artifacts ? findEntry(artifacts.entries, openPath) : null;
// The open report vanished from disk (deleted externally) — go back.
useEffect(() => {
if (openName && artifacts && !artifacts.reports.some((r) => r.name === openName))
setOpenName(null);
}, [openName, artifacts]);
if (openPath && artifacts && !findEntry(artifacts.entries, openPath)?.reportTitle)
setOpenPath(null);
}, [openPath, artifacts]);
const toggle = (path: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
const remove = (path: string) => {
void deleteArtifact(project.id, path)
@@ -192,15 +323,15 @@ export function ArtifactsTab({
.finally(onChanged);
};
if (open) {
if (openEntry?.reportTitle) {
return (
<ReportView
projectId={project.id}
report={open}
onBack={() => setOpenName(null)}
entry={openEntry}
onBack={() => setOpenPath(null)}
onDelete={() => {
remove(open.name);
setOpenName(null);
remove(openEntry.path);
setOpenPath(null);
}}
/>
);
@@ -217,7 +348,6 @@ export function ArtifactsTab({
}
const repoUrl = `https://github.com/${project.githubOwner}/${project.githubRepo}`;
const empty = artifacts.reports.length === 0 && artifacts.files.length === 0;
return (
<div className="artifacts">
<section>
@@ -232,93 +362,37 @@ export function ArtifactsTab({
<section>
<h3 className="artifacts-heading">
<FolderOpen size={13} /> Local folder
<FolderOpen size={13} /> Artifacts
</h3>
<DirPath dir={artifacts.dir} />
<p className="artifacts-hint">
Everything in this folder shows up here — the agent writes reports and figures into
it, and you can drop in your own files.
An explorer over this folder — the agent writes reports and figures into it, and you
can drop in your own files.
</p>
{artifacts.entries.length === 0 ? (
<p className="artifacts-empty">
Nothing here yet. Ask the agent for a write-up of its findings — it saves report
folders (<code>report.md</code> + images) into the folder above.
</p>
) : (
<div className="artifact-tree">
<TreeRows
projectId={project.id}
entries={artifacts.entries}
depth={0}
expanded={expanded}
onToggle={toggle}
onOpenReport={setOpenPath}
onDelete={remove}
/>
{artifacts.truncated && (
<p className="artifacts-hint" style={{ padding: "6px 12px" }}>
Listing truncated — the folder has more files.
</p>
)}
</div>
)}
</section>
{empty ? (
<p className="artifacts-empty">
Nothing here yet. Ask the agent for a write-up of its findings — it saves report
folders (<code>report.md</code> + images) into the folder above.
</p>
) : (
<>
{artifacts.reports.length > 0 && (
<section>
<h3 className="artifacts-heading">
<FileText size={13} /> Reports
<span className="artifacts-count">{artifacts.reports.length}</span>
</h3>
<ul className="report-list">
{artifacts.reports.map((r) => (
<li key={r.name}>
<button className="report-row" onClick={() => setOpenName(r.name)}>
<span className="report-row-title">{r.title}</span>
<span className="report-date">
{new Date(r.modifiedAt).toLocaleDateString()}
</span>
</button>
</li>
))}
</ul>
</section>
)}
{artifacts.files.length > 0 && (
<section>
<h3 className="artifacts-heading">
<File size={13} /> Files
<span className="artifacts-count">{artifacts.files.length}</span>
</h3>
<ul className="report-list">
{artifacts.files.map((f: ArtifactFile) => (
<li key={f.path}>
<div className="report-row artifact-file-row">
<a
className="artifact-file-link"
href={artifactFileUrl(project.id, f.path)}
target="_blank"
rel="noopener noreferrer"
title={f.path}
>
{IMAGE_RE.test(f.path) && (
<img
className="artifact-thumb"
src={artifactFileUrl(project.id, f.path)}
alt=""
loading="lazy"
/>
)}
<span className="report-row-title">{f.path}</span>
</a>
<span className="report-date">{fmtBytes(f.size)}</span>
<button
className="icon-btn"
title="Delete file"
aria-label={`Delete ${f.path}`}
onClick={() => {
if (window.confirm(`Delete "${f.path}" from the artifacts dir?`))
remove(f.path);
}}
>
<Trash2 size={13} />
</button>
</div>
</li>
))}
</ul>
{artifacts.truncated && (
<p className="artifacts-hint">Listing truncated — the folder has more files.</p>
)}
</section>
)}
</>
)}
</div>
);
}
+39 -20
View File
@@ -1882,9 +1882,6 @@ select:focus {
font-size: 11.5px;
color: var(--subtext);
}
.artifact-file-row {
align-items: center;
}
.artifact-file-link {
flex: 1;
min-width: 0;
@@ -1894,7 +1891,7 @@ select:focus {
color: var(--text);
text-decoration: none;
}
.artifact-file-link:hover .report-row-title {
.artifact-file-link:hover .artifact-tree-name {
text-decoration: underline;
}
.artifact-thumb {
@@ -1915,34 +1912,56 @@ select:focus {
font-family: var(--mono);
font-size: 11.5px;
}
.report-list {
list-style: none;
margin: 0;
padding: 0;
.artifact-tree {
border: 1px solid var(--border);
margin-top: 10px;
padding: 4px 0;
}
.report-list li + li {
border-top: 1px solid var(--border);
}
.report-row {
.artifact-tree-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 10px 12px;
text-align: left;
gap: 8px;
padding: 6px 12px;
font-size: 13px;
}
.report-row:hover {
.artifact-tree-row:hover {
background: var(--surface);
}
.report-row-title {
font-weight: 600;
.artifact-tree-row.clickable {
cursor: pointer;
}
.artifact-tree-chevron {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--muted);
transition: transform 0.12s ease;
}
.artifact-tree-chevron.open {
transform: rotate(90deg);
}
.artifact-tree-chevron.spacer {
visibility: hidden;
}
.artifact-tree-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artifact-tree-name.report {
font-weight: 600;
}
.artifact-tree-del {
visibility: hidden;
}
.artifact-tree-row:hover .artifact-tree-del {
visibility: visible;
}
.report-date {
font-size: 11px;
color: var(--muted);