Merge remote-tracking branch 'origin/main'

This commit is contained in:
Bryan Helmkamp 2026-04-04 12:14:23 -04:00
commit efe7ad2785
No known key found for this signature in database
90 changed files with 565 additions and 826 deletions

View file

@ -162,7 +162,7 @@ provider = "daytona"
[sandbox.daytona.snapshot]
name = "daytona-medium"
[assets]
[artifacts]
include = ["screenshots/**"]
[mcp_servers.playwright]
@ -181,7 +181,7 @@ After startup, the agent sees 22 Playwright tools including:
- `mcp__playwright__browser_type`
- `mcp__playwright__browser_fill_form`
The agent uses `browser_snapshot` (accessibility tree) for structured page understanding and `browser_take_screenshot` to save visual captures. Screenshots saved to `screenshots/` are automatically collected as [assets](/execution/run-configuration#assets).
The agent uses `browser_snapshot` (accessibility tree) for structured page understanding and `browser_take_screenshot` to save visual captures. Screenshots saved to `screenshots/` are automatically collected as [artifacts](/execution/run-configuration#assets).
<Note>
When using Playwright MCP with the sandbox transport, call the `browser_install` tool first to ensure the Playwright browser binaries are available inside the sandbox.

View file

@ -225,9 +225,9 @@ After each node executes a command, Fabro automatically scans the sandbox for te
### How asset capture works
1. **Before** the command runs, Fabro takes a baseline snapshot of known asset paths in the sandbox
1. **Before** the command runs, Fabro takes a baseline snapshot of known artifact paths in the sandbox
2. **After** the command completes, Fabro re-scans and diffs against the baseline
3. Files that are new or modified since the command started are downloaded to the stage's asset directory
3. Files that are new or modified since the command started are downloaded to the stage's artifact directory
Only files modified after the command started are collected. Files that match the baseline fingerprint (same size and mtime) are skipped. Individual files over 10 MB and total collections over 50 MB are also skipped.

View file

@ -4268,8 +4268,8 @@ components:
type: array
items:
$ref: "#/components/schemas/HookDefinition"
assets:
$ref: "#/components/schemas/AssetsSettings"
artifacts:
$ref: "#/components/schemas/ArtifactsSettings"
mcp_servers:
type: object
additionalProperties:
@ -4320,15 +4320,15 @@ components:
type: integer
description: Tool call timeout in seconds.
AssetsSettings:
description: Asset collection configuration.
ArtifactsSettings:
description: Artifact collection configuration.
type: object
properties:
include:
type: array
items:
type: string
description: Glob patterns for files to collect as run assets.
description: Glob patterns for files to collect as run artifacts.
LogSettings:
description: Logging configuration.

View file

@ -54,7 +54,7 @@ To migrate, update any scripts or aliases:
<Accordion title="Workflows">
- Script nodes now execute inside the configured sandbox instead of on the host — fixes issues where agents edited files in the sandbox but lint/test commands ran on the host
- Asset collection is now opt-in via `[assets]` config with custom include globs, eliminating ~30s file scans per stage when not needed
- Artifact collection is now opt-in via `[artifacts]` config with custom include globs, eliminating ~30s file scans per stage when not needed
- Workflow runs now fail immediately on git checkpoint commit failure instead of silently continuing
- GitHub webhook listener via Tailscale funnel — auto-configures webhook URL on startup
- `[sandbox.env]` support passes environment variables through to sandbox tool execution

View file

@ -42,7 +42,7 @@ fabro ps -a # all runs including completed
## More
<Accordion title="CLI">
- Added `fabro asset list` to view run artifacts and `fabro asset cp` to copy them locally with optional `--tree` directory structure
- Added `fabro artifact list` to view run artifacts and `fabro artifact cp` to copy them locally with optional `--tree` directory structure
- Added `fabro rm` command to remove runs by ID with sandbox cleanup
- Added `--direction` (`-d`) flag to `fabro graph` for overriding layout direction (`lr` or `tb`)
- Added `-p` short alias for `--pretty` in `fabro logs`
@ -63,7 +63,7 @@ fabro ps -a # all runs including completed
<Accordion title="Improvements">
- Command output in stage preambles now truncated to the last 25-50 lines, reducing token waste from verbose build logs
- Asset collection now enforces a 100-file limit and excludes `.venv`, `venv`, `.cache`, `.tox`, `.pytest_cache`, `.mypy_cache`, and `dist` directories
- Artifact collection now enforces a 100-file limit and excludes `.venv`, `venv`, `.cache`, `.tox`, `.pytest_cache`, `.mypy_cache`, and `dist` directories
- Unified dry-run behavior across all handler types — wait and human-in-the-loop nodes now properly simulate without side effects
</Accordion>

View file

@ -75,7 +75,7 @@ exclude_globs = ["**/node_modules/**", "**/.cache/**"]
repo_name = "fabro"
repo_url = "https://github.com/fabro-sh/fabro"
[assets]
[artifacts]
include = ["test-results/**", "playwright-report/**"]
[mcp_servers.playwright]
@ -258,12 +258,12 @@ digraph CI {
If a `$variable` in the Graphviz file has no matching entry in `[vars]`, Fabro raises an error immediately. A bare `$` not followed by an identifier (e.g. `costs $5`) is left as-is.
### `[assets]`
### `[artifacts]`
Configure automatic collection of test artifacts (Playwright reports, JUnit XML, screenshots, etc.) from the execution environment after each stage.
```toml title="run.toml"
[assets]
[artifacts]
include = ["test-results/**", "playwright-report/**", "*.trace.zip"]
```
@ -271,7 +271,7 @@ include = ["test-results/**", "playwright-report/**", "*.trace.zip"]
|---|---|
| `include` | Glob patterns for files to collect as assets. Matched against the working directory after each stage completes. |
Asset collection is opt-in — when no `[assets]` section is present, no file scanning occurs. This avoids the overhead of scanning large working directories when assets aren't needed.
Artifact collection is opt-in — when no `[artifacts]` section is present, no file scanning occurs. This avoids the overhead of scanning large working directories when assets aren't needed.
### `[mcp_servers]`
@ -401,7 +401,7 @@ For model and provider specifically, the precedence is: CLI flags > TOML config
### Project defaults (`fabro.toml`)
The `fabro.toml` project config can set default values for `[llm]`, `[setup]`, `[sandbox]`, `[vars]`, `[checkpoint]`, `[pull_request]`, `[github]`, `[assets]`, `[[hooks]]`, and `[mcp_servers]`. These defaults apply to all runs in the project unless the run config overrides them:
The `fabro.toml` project config can set default values for `[llm]`, `[setup]`, `[sandbox]`, `[vars]`, `[checkpoint]`, `[pull_request]`, `[github]`, `[artifacts]`, `[[hooks]]`, and `[mcp_servers]`. These defaults apply to all runs in the project unless the run config overrides them:
```toml title="fabro.toml"
version = 1

View file

@ -748,14 +748,14 @@ fabro upgrade --version 0.6.0
Fabro refuses to downgrade unless you specify an explicit `--version`. A daily background check notifies you when a new version is available — disable it with `upgrade_check = false` in [`user.toml`](/reference/user-configuration#upgrade_check) or the `--no-upgrade-check` global flag.
## `fabro asset list`
## `fabro artifact list`
List assets (screenshots, test reports, traces) collected from a workflow run.
List artifacts (screenshots, test reports, traces) collected from a workflow run.
```bash
fabro asset list <RUN_ID>
fabro asset list <RUN_ID> --node verify --json
fabro asset list <RUN_ID> --node verify --retry 2
fabro artifact list <RUN_ID>
fabro artifact list <RUN_ID> --node verify --json
fabro artifact list <RUN_ID> --node verify --retry 2
```
| Argument / Flag | Description |
@ -765,15 +765,15 @@ fabro asset list <RUN_ID> --node verify --retry 2
| `--retry <N>` | Filter to assets from a specific retry attempt |
| `--json` | Output as JSON |
## `fabro asset cp`
## `fabro artifact cp`
Copy assets from a workflow run to the local filesystem.
Copy artifacts from a workflow run to the local filesystem.
```bash
fabro asset cp <RUN_ID> ./output # all assets, flat
fabro asset cp <RUN_ID> ./output --tree # preserve directory structure
fabro asset cp <RUN_ID>:report.html ./output # specific file
fabro asset cp <RUN_ID>:report.html ./output --node verify --retry 2
fabro artifact cp <RUN_ID> ./output # all assets, flat
fabro artifact cp <RUN_ID> ./output --tree # preserve directory structure
fabro artifact cp <RUN_ID>:report.html ./output # specific file
fabro artifact cp <RUN_ID>:report.html ./output --node verify --retry 2
```
| Argument / Flag | Description |

View file

@ -71,10 +71,10 @@ Manager nodes that run sub-workflows write a nested `child/` directory containin
**`runtime/`** — Local-only runtime files, including interview IPC files used by detached runs and `fabro attach`.
**`cache/`** — Local filesystem cache for file-backed artifacts and captured test assets:
**`cache/`** — Local filesystem cache for file-backed artifacts and captured test artifacts:
- `cache/artifacts/values/` — large context values offloaded to file-backed artifacts
- `cache/artifacts/assets/` — captured test artifacts organized by node and retry
- `cache/artifacts/files/` — captured test artifacts organized by node and retry
## Browsing runs

View file

@ -308,33 +308,33 @@ pub(crate) struct ParseArgs {
}
#[derive(Args)]
pub(crate) struct AssetListArgs {
pub(crate) struct ArtifactListArgs {
/// Run ID (or prefix)
pub(crate) run_id: String,
/// Filter to assets from a specific node
/// Filter to artifacts from a specific node
#[arg(long)]
pub(crate) node: Option<String>,
/// Filter to assets from a specific retry attempt
/// Filter to artifacts from a specific retry attempt
#[arg(long)]
pub(crate) retry: Option<u32>,
}
#[derive(Args)]
pub(crate) struct AssetCpArgs {
/// Source: RUN_ID (all assets) or RUN_ID:path (specific asset)
pub(crate) struct ArtifactCpArgs {
/// Source: RUN_ID (all artifacts) or RUN_ID:path (specific artifact)
pub(crate) source: String,
/// Destination directory (defaults to current directory)
#[arg(default_value = ".")]
pub(crate) dest: PathBuf,
/// Filter to assets from a specific node
/// Filter to artifacts from a specific node
#[arg(long)]
pub(crate) node: Option<String>,
/// Filter to assets from a specific retry attempt
/// Filter to artifacts from a specific retry attempt
#[arg(long)]
pub(crate) retry: Option<u32>,
@ -754,8 +754,8 @@ pub(crate) enum Commands {
/// Parse a DOT file and print its AST
#[command(hide = true)]
Parse(ParseArgs),
/// Inspect and copy run assets (screenshots, reports, traces)
Asset(AssetNamespace),
/// Inspect and copy run artifacts (screenshots, reports, traces)
Artifact(ArtifactNamespace),
/// Export store-backed run state for debugging
Store(StoreNamespace),
#[command(flatten)]
@ -834,9 +834,9 @@ impl Commands {
LlmCommand::Prompt(_) => "llm prompt",
LlmCommand::Chat(_) => "llm chat",
},
Self::Asset(ns) => match &ns.command {
AssetCommand::List(_) => "asset list",
AssetCommand::Cp(_) => "asset cp",
Self::Artifact(ns) => match &ns.command {
ArtifactCommand::List(_) => "artifact list",
ArtifactCommand::Cp(_) => "artifact cp",
},
Self::Store(ns) => match &ns.command {
StoreCommand::Dump(_) => "store dump",
@ -925,17 +925,17 @@ pub(crate) enum PrCommand {
}
#[derive(Args)]
pub(crate) struct AssetNamespace {
pub(crate) struct ArtifactNamespace {
#[command(subcommand)]
pub(crate) command: AssetCommand,
pub(crate) command: ArtifactCommand,
}
#[derive(Subcommand)]
pub(crate) enum AssetCommand {
/// List assets for a workflow run
List(AssetListArgs),
/// Copy assets from a workflow run
Cp(AssetCpArgs),
pub(crate) enum ArtifactCommand {
/// List artifacts for a workflow run
List(ArtifactListArgs),
/// Copy artifacts from a workflow run
Cp(ArtifactCpArgs),
}
#[derive(Args)]

View file

@ -2,29 +2,29 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use fabro_store::RuntimeState;
use fabro_workflow::assets::{AssetEntry, scan_assets};
use fabro_workflow::artifacts::{ArtifactEntry, scan_artifacts};
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
use crate::args::{AssetCpArgs, GlobalArgs};
use crate::args::{ArtifactCpArgs, GlobalArgs};
use crate::shared::{print_json_pretty, split_run_path};
use crate::store;
use crate::user_config::load_user_settings_with_globals;
pub(super) async fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Result<()> {
pub(super) async fn cp_command(args: &ArtifactCpArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let store = store::build_store(&cli_settings.storage_dir())?;
let (run_id, asset_path) = parse_source(&args.source);
let run = resolve_run_combined(store.as_ref(), &base, run_id).await?;
let runtime_state = RuntimeState::new(&run.path);
let entries = scan_assets(
&runtime_state.assets_dir(),
let entries = scan_artifacts(
&runtime_state.artifacts_dir(),
args.node.as_deref(),
args.retry,
)?;
if entries.is_empty() {
bail!("No assets found for this run");
bail!("No artifacts found for this run");
}
std::fs::create_dir_all(&args.dest)
@ -36,7 +36,7 @@ pub(super) async fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Resu
.filter(|entry| entry.relative_path == path)
.collect();
if matching.is_empty() {
bail!("No asset matching path '{path}' found in this run");
bail!("No artifact matching path '{path}' found in this run");
}
if matching.len() > 1 {
let candidates: Vec<_> = matching
@ -44,7 +44,7 @@ pub(super) async fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Resu
.map(|entry| format!("{}:retry_{}", entry.node_slug, entry.retry))
.collect();
bail!(
"Path '{path}' matches multiple assets: {}. Use --node and/or --retry to disambiguate.",
"Path '{path}' matches multiple artifacts: {}. Use --node and/or --retry to disambiguate.",
candidates.join(", ")
);
}
@ -101,7 +101,7 @@ pub(super) async fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Resu
}));
}
} else {
let mut by_filename: Vec<(String, &AssetEntry)> = Vec::with_capacity(entries.len());
let mut by_filename: Vec<(String, &ArtifactEntry)> = Vec::with_capacity(entries.len());
for entry in &entries {
let filename = Path::new(&entry.relative_path)
.file_name()
@ -142,7 +142,7 @@ pub(super) async fn cp_command(args: &AssetCpArgs, globals: &GlobalArgs) -> Resu
print_json_pretty(&serde_json::json!({ "copied": copied }))?;
} else {
println!(
"Copied {} asset(s) to {}",
"Copied {} artifact(s) to {}",
entries.len(),
args.dest.display()
);
@ -157,7 +157,7 @@ fn parse_source(source: &str) -> (&str, Option<&str>) {
}
}
fn format_candidate(entry: &AssetEntry) -> String {
fn format_candidate(entry: &ArtifactEntry) -> String {
format!("{}:retry_{}", entry.node_slug, entry.retry)
}
@ -195,7 +195,7 @@ mod tests {
#[test]
fn format_candidate_includes_retry() {
let entry = AssetEntry {
let entry = ArtifactEntry {
node_slug: "retry_assets".to_string(),
retry: 2,
relative_path: "assets/retry/report.txt".to_string(),

View file

@ -1,21 +1,21 @@
use anyhow::Result;
use fabro_store::RuntimeState;
use fabro_workflow::assets::scan_assets;
use fabro_workflow::artifacts::scan_artifacts;
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
use crate::args::{AssetListArgs, GlobalArgs};
use crate::args::{ArtifactListArgs, GlobalArgs};
use crate::shared::format_size;
use crate::store;
use crate::user_config::load_user_settings_with_globals;
pub(super) async fn list_command(args: &AssetListArgs, globals: &GlobalArgs) -> Result<()> {
pub(super) async fn list_command(args: &ArtifactListArgs, globals: &GlobalArgs) -> Result<()> {
let cli_settings = load_user_settings_with_globals(globals)?;
let base = runs_base(&cli_settings.storage_dir());
let store = store::build_store(&cli_settings.storage_dir())?;
let run = resolve_run_combined(store.as_ref(), &base, &args.run_id).await?;
let runtime_state = RuntimeState::new(&run.path);
let entries = scan_assets(
&runtime_state.assets_dir(),
let entries = scan_artifacts(
&runtime_state.artifacts_dir(),
args.node.as_deref(),
args.retry,
)?;
@ -26,7 +26,7 @@ pub(super) async fn list_command(args: &AssetListArgs, globals: &GlobalArgs) ->
}
if entries.is_empty() {
println!("No assets found for this run.");
println!("No artifacts found for this run.");
return Ok(());
}
@ -60,7 +60,7 @@ pub(super) async fn list_command(args: &AssetListArgs, globals: &GlobalArgs) ->
}
println!();
println!(
"{} asset(s), {} total",
"{} artifact(s), {} total",
entries.len(),
format_size(total_size)
);

View file

@ -0,0 +1,13 @@
mod cp;
mod list;
use anyhow::Result;
use crate::args::{ArtifactCommand, ArtifactNamespace, GlobalArgs};
pub(crate) async fn dispatch(ns: ArtifactNamespace, globals: &GlobalArgs) -> Result<()> {
match ns.command {
ArtifactCommand::List(args) => list::list_command(&args, globals).await,
ArtifactCommand::Cp(args) => cp::cp_command(&args, globals).await,
}
}

View file

@ -1,13 +0,0 @@
mod cp;
mod list;
use anyhow::Result;
use crate::args::{AssetCommand, AssetNamespace, GlobalArgs};
pub(crate) async fn dispatch(ns: AssetNamespace, globals: &GlobalArgs) -> Result<()> {
match ns.command {
AssetCommand::List(args) => list::list_command(&args, globals).await,
AssetCommand::Cp(args) => cp::cp_command(&args, globals).await,
}
}

View file

@ -1,4 +1,4 @@
pub(crate) mod asset;
pub(crate) mod artifact;
pub(crate) mod config;
pub(crate) mod doctor;
pub(crate) mod exec;

View file

@ -7,7 +7,7 @@ use fabro_store::RuntimeState;
use fabro_types::PullRequestRecord;
use fabro_util::terminal::Styles;
use fabro_util::text::strip_goal_decoration;
use fabro_workflow::asset_snapshot::collect_asset_paths;
use fabro_workflow::artifact_snapshot::collect_artifact_paths;
use fabro_workflow::outcome::{StageStatus, format_cost};
use fabro_workflow::pipeline::{Persisted, Validated};
use fabro_workflow::records::Conclusion;
@ -229,12 +229,12 @@ pub(crate) async fn print_final_output(
pub(crate) fn print_assets(run_dir: &Path, styles: &Styles) {
let runtime_state = RuntimeState::new(run_dir);
let paths = collect_asset_paths(&runtime_state.assets_dir());
let paths = collect_artifact_paths(&runtime_state.artifacts_dir());
if paths.is_empty() {
return;
}
let home = dirs::home_dir();
eprintln!("\n{}", styles.bold.apply_to("=== Assets ==="));
eprintln!("\n{}", styles.bold.apply_to("=== Artifacts ==="));
for path in &paths {
let display = match &home {
Some(home_dir) => {

View file

@ -536,12 +536,12 @@ mod tests {
.unwrap();
let summary_blob = run.write_blob(br#"{"done":true}"#).await.unwrap();
let plan_blob = run.write_blob(br#"{"steps":3}"#).await.unwrap();
run.put_asset(&node, "src/lib.rs", b"fn main() {}")
run.put_artifact(&node, "src/lib.rs", b"fn main() {}")
.await
.unwrap();
let asset_only_node = StageId::new("artifact-only", 7);
run.put_asset(&asset_only_node, "logs/output.txt", b"hello")
let artifact_only_node = StageId::new("artifact-only", 7);
run.put_artifact(&artifact_only_node, "logs/output.txt", b"hello")
.await
.unwrap();
@ -665,14 +665,14 @@ mod tests {
)
.await
.unwrap();
run.put_asset(&StageId::new("code", 1), "../escape.txt", b"boom")
run.put_artifact(&StageId::new("code", 1), "../escape.txt", b"boom")
.await
.unwrap();
let temp = tempfile::tempdir().unwrap();
let output = temp.path().join("dump");
let err = export_run(&run, &output).await.unwrap_err();
assert!(err.to_string().contains("asset filename"));
assert!(err.to_string().contains("artifact filename"));
assert!(!output.exists());
}

View file

@ -174,7 +174,7 @@ async fn main_inner() -> (String, Result<()>) {
Commands::Parse(args) => {
commands::parse::run(&args, &globals)?;
}
Commands::Asset(ns) => commands::asset::dispatch(ns, &globals).await?,
Commands::Artifact(ns) => commands::artifact::dispatch(ns, &globals).await?,
Commands::Store(ns) => commands::store::dispatch(ns, &globals).await?,
Commands::RunsCmd(cmd) => commands::runs::dispatch(cmd, &globals).await?,
Commands::Model { command } => commands::model::execute(command, &globals).await?,

View file

@ -4,18 +4,18 @@ use fabro_test::{fabro_snapshot, test_context};
fn help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["asset", "--help"]);
cmd.args(["artifact", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Inspect and copy run assets (screenshots, reports, traces)
Inspect and copy run artifacts (screenshots, reports, traces)
Usage: fabro asset [OPTIONS] <COMMAND>
Usage: fabro artifact [OPTIONS] <COMMAND>
Commands:
list List assets for a workflow run
cp Copy assets from a workflow run
list List artifacts for a workflow run
cp Copy artifacts from a workflow run
help Print this message or the help of the given subcommand(s)
Options:

View file

@ -1,29 +1,29 @@
use fabro_test::{fabro_snapshot, test_context};
use super::support::{read_text, setup_asset_run, setup_completed_dry_run, text_tree};
use super::support::{read_text, setup_artifact_run, setup_completed_dry_run, text_tree};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["asset", "cp", "--help"]);
cmd.args(["artifact", "cp", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
Copy assets from a workflow run
Copy artifacts from a workflow run
Usage: fabro asset cp [OPTIONS] <SOURCE> [DEST]
Usage: fabro artifact cp [OPTIONS] <SOURCE> [DEST]
Arguments:
<SOURCE> Source: RUN_ID (all assets) or RUN_ID:path (specific asset)
<SOURCE> Source: RUN_ID (all artifacts) or RUN_ID:path (specific artifact)
[DEST] Destination directory (defaults to current directory) [default: .]
Options:
--json Output as JSON [env: FABRO_JSON=]
--node <NODE> Filter to assets from a specific node
--node <NODE> Filter to artifacts from a specific node
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--retry <RETRY> Filter to assets from a specific retry attempt
--retry <RETRY> Filter to artifacts from a specific retry attempt
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--tree Preserve {node_slug}/retry_{N}/ directory structure
--quiet Suppress non-essential output [env: FABRO_QUIET=]
@ -36,30 +36,30 @@ fn help() {
}
#[test]
fn asset_cp_empty_run_reports_no_assets() {
fn artifact_cp_empty_run_reports_no_artifacts() {
let context = test_context!();
let run = setup_completed_dry_run(&context);
let dest = context.temp_dir.join("asset-dest");
let dest = context.temp_dir.join("artifact-dest");
let mut cmd = context.command();
cmd.args(["asset", "cp", &run.run_id, dest.to_str().unwrap()]);
cmd.args(["artifact", "cp", &run.run_id, dest.to_str().unwrap()]);
fabro_snapshot!(context.filters(), cmd, @"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
error: No assets found for this run
error: No artifacts found for this run
");
}
#[test]
fn asset_cp_specific_path_copies_single_asset() {
fn artifact_cp_specific_path_copies_single_asset() {
let context = test_context!();
let setup = setup_asset_run(&context);
let dest = context.temp_dir.join("asset-one");
let setup = setup_artifact_run(&context);
let dest = context.temp_dir.join("artifact-one");
let mut cmd = context.command();
cmd.args([
"asset",
"artifact",
"cp",
&format!("{}:assets/shared/report.txt", setup.run.run_id),
dest.to_str().unwrap(),
@ -71,20 +71,20 @@ fn asset_cp_specific_path_copies_single_asset() {
success: true
exit_code: 0
----- stdout -----
Copied assets/shared/report.txt to [TEMP_DIR]/asset-one/report.txt
Copied assets/shared/report.txt to [TEMP_DIR]/artifact-one/report.txt
----- stderr -----
");
assert_eq!(read_text(&dest.join("report.txt")), "one");
}
#[test]
fn asset_cp_ambiguous_path_requires_node_or_retry() {
fn artifact_cp_ambiguous_path_requires_node_or_retry() {
let context = test_context!();
let setup = setup_asset_run(&context);
let dest = context.temp_dir.join("asset-one");
let setup = setup_artifact_run(&context);
let dest = context.temp_dir.join("artifact-one");
let mut cmd = context.command();
cmd.args([
"asset",
"artifact",
"cp",
&format!("{}:assets/retry/report.txt", setup.run.run_id),
dest.to_str().unwrap(),
@ -95,18 +95,18 @@ fn asset_cp_ambiguous_path_requires_node_or_retry() {
exit_code: 1
----- stdout -----
----- stderr -----
error: Path 'assets/retry/report.txt' matches multiple assets: create_colliding:retry_1, retry_assets:retry_1, retry_assets:retry_2. Use --node and/or --retry to disambiguate.
error: Path 'assets/retry/report.txt' matches multiple artifacts: create_colliding:retry_1, retry_assets:retry_1, retry_assets:retry_2. Use --node and/or --retry to disambiguate.
");
}
#[test]
fn asset_cp_tree_preserves_structure() {
fn artifact_cp_tree_preserves_structure() {
let context = test_context!();
let setup = setup_asset_run(&context);
let dest = context.temp_dir.join("asset-tree");
let setup = setup_artifact_run(&context);
let dest = context.temp_dir.join("artifact-tree");
let mut cmd = context.command();
cmd.args([
"asset",
"artifact",
"cp",
&setup.run.run_id,
dest.to_str().unwrap(),
@ -117,7 +117,7 @@ fn asset_cp_tree_preserves_structure() {
success: true
exit_code: 0
----- stdout -----
Copied 6 asset(s) to [TEMP_DIR]/asset-tree
Copied 6 artifact(s) to [TEMP_DIR]/artifact-tree
----- stderr -----
");
insta::assert_snapshot!(
@ -134,12 +134,12 @@ fn asset_cp_tree_preserves_structure() {
}
#[test]
fn asset_cp_flat_mode_rejects_filename_collisions() {
fn artifact_cp_flat_mode_rejects_filename_collisions() {
let context = test_context!();
let setup = setup_asset_run(&context);
let dest = context.temp_dir.join("asset-flat");
let setup = setup_artifact_run(&context);
let dest = context.temp_dir.join("artifact-flat");
let mut cmd = context.command();
cmd.args(["asset", "cp", &setup.run.run_id, dest.to_str().unwrap()]);
cmd.args(["artifact", "cp", &setup.run.run_id, dest.to_str().unwrap()]);
fabro_snapshot!(context.filters(), cmd, @"
success: false

View file

@ -1,28 +1,28 @@
use fabro_test::{fabro_snapshot, test_context};
use super::support::{setup_asset_run, setup_completed_dry_run};
use super::support::{setup_artifact_run, setup_completed_dry_run};
#[test]
fn help() {
let context = test_context!();
let mut cmd = context.command();
cmd.args(["asset", "list", "--help"]);
cmd.args(["artifact", "list", "--help"]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
List assets for a workflow run
List artifacts for a workflow run
Usage: fabro asset list [OPTIONS] <RUN_ID>
Usage: fabro artifact list [OPTIONS] <RUN_ID>
Arguments:
<RUN_ID> Run ID (or prefix)
Options:
--json Output as JSON [env: FABRO_JSON=]
--node <NODE> Filter to assets from a specific node
--node <NODE> Filter to artifacts from a specific node
--debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=]
--retry <RETRY> Filter to assets from a specific retry attempt
--retry <RETRY> Filter to artifacts from a specific retry attempt
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--verbose Enable verbose output [env: FABRO_VERBOSE=]
@ -34,32 +34,32 @@ fn help() {
}
#[test]
fn asset_list_empty_run_reports_no_assets() {
fn artifact_list_empty_run_reports_no_artifacts() {
let context = test_context!();
let run = setup_completed_dry_run(&context);
let mut cmd = context.command();
cmd.args(["asset", "list", &run.run_id]);
cmd.args(["artifact", "list", &run.run_id]);
fabro_snapshot!(context.filters(), cmd, @"
success: true
exit_code: 0
----- stdout -----
No assets found for this run.
No artifacts found for this run.
----- stderr -----
");
}
#[test]
fn asset_list_json_outputs_entries() {
fn artifact_list_json_outputs_entries() {
let context = test_context!();
let setup = setup_asset_run(&context);
let setup = setup_artifact_run(&context);
let mut filters = context.filters();
filters.push((
r"\[STORAGE_DIR\]/runs/\d{8}-\[ULID\]".to_string(),
"[RUN_DIR]".to_string(),
));
let mut cmd = context.command();
cmd.args(["asset", "list", &setup.run.run_id, "--json"]);
cmd.args(["artifact", "list", &setup.run.run_id, "--json"]);
fabro_snapshot!(filters, cmd, @r#"
success: true
@ -70,42 +70,42 @@ fn asset_list_json_outputs_entries() {
"node_slug": "create_assets",
"retry": 1,
"relative_path": "assets/node_a/summary.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/assets/create_assets/retry_1/assets/node_a/summary.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/create_assets/retry_1/assets/node_a/summary.txt",
"size": 5
},
{
"node_slug": "create_assets",
"retry": 1,
"relative_path": "assets/shared/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/assets/create_assets/retry_1/assets/shared/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/create_assets/retry_1/assets/shared/report.txt",
"size": 3
},
{
"node_slug": "create_colliding",
"retry": 1,
"relative_path": "assets/other/summary.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/assets/create_colliding/retry_1/assets/other/summary.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/create_colliding/retry_1/assets/other/summary.txt",
"size": 4
},
{
"node_slug": "create_colliding",
"retry": 1,
"relative_path": "assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/assets/create_colliding/retry_1/assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/create_colliding/retry_1/assets/retry/report.txt",
"size": 6
},
{
"node_slug": "retry_assets",
"retry": 1,
"relative_path": "assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/assets/retry_assets/retry_1/assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/retry_assets/retry_1/assets/retry/report.txt",
"size": 5
},
{
"node_slug": "retry_assets",
"retry": 2,
"relative_path": "assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/assets/retry_assets/retry_2/assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/retry_assets/retry_2/assets/retry/report.txt",
"size": 6
}
]
@ -114,9 +114,9 @@ fn asset_list_json_outputs_entries() {
}
#[test]
fn asset_list_filters_by_node_and_retry() {
fn artifact_list_filters_by_node_and_retry() {
let context = test_context!();
let setup = setup_asset_run(&context);
let setup = setup_artifact_run(&context);
let mut filters = context.filters();
filters.push((
r"\[STORAGE_DIR\]/runs/\d{8}-\[ULID\]".to_string(),
@ -124,7 +124,7 @@ fn asset_list_filters_by_node_and_retry() {
));
let mut cmd = context.command();
cmd.args([
"asset",
"artifact",
"list",
&setup.run.run_id,
"--node",
@ -143,7 +143,7 @@ fn asset_list_filters_by_node_and_retry() {
"node_slug": "retry_assets",
"retry": 2,
"relative_path": "assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/assets/retry_assets/retry_2/assets/retry/report.txt",
"absolute_path": "[RUN_DIR]/cache/artifacts/files/retry_assets/retry_2/assets/retry/report.txt",
"size": 6
}
]

View file

@ -24,7 +24,7 @@ fn help() {
preflight Validate run configuration without executing
validate Validate a workflow
graph Render a workflow graph as SVG or PNG
asset Inspect and copy run assets (screenshots, reports, traces)
artifact Inspect and copy run artifacts (screenshots, reports, traces)
store Export store-backed run state for debugging
rm Remove one or more workflow runs
inspect Show detailed information about a workflow run

View file

@ -1,6 +1,6 @@
mod asset;
mod asset_cp;
mod asset_list;
mod artifact;
mod artifact_cp;
mod artifact_list;
mod attach;
mod completion;
mod config;

View file

@ -203,15 +203,15 @@ impl WorkflowGate {
}
}
pub(crate) fn setup_asset_run(context: &TestContext) -> WorkspaceRunSetup {
let workspace_dir = context.temp_dir.join("asset-run");
pub(crate) fn setup_artifact_run(context: &TestContext) -> WorkspaceRunSetup {
let workspace_dir = context.temp_dir.join("artifact-run");
std::fs::create_dir_all(&workspace_dir)
.unwrap_or_else(|err| panic!("failed to create {}: {err}", workspace_dir.display()));
write_text_file(
&workspace_dir.join("asset_run.fabro"),
r#"digraph AssetRun {
graph [goal="Exercise asset commands", default_max_retries=0]
&workspace_dir.join("artifact_run.fabro"),
r#"digraph ArtifactRun {
graph [goal="Exercise artifact commands", default_max_retries=0]
start [shape=Mdiamond]
exit [shape=Msquare]
create_assets [shape=parallelogram, script="mkdir -p assets/shared assets/node_a && printf one > assets/shared/report.txt && printf alpha > assets/node_a/summary.txt", max_retries=0]
@ -224,8 +224,8 @@ pub(crate) fn setup_asset_run(context: &TestContext) -> WorkspaceRunSetup {
write_text_file(
&workspace_dir.join("run.toml"),
r#"version = 1
graph = "asset_run.fabro"
goal = "Exercise asset commands"
graph = "artifact_run.fabro"
goal = "Exercise artifact commands"
[sandbox]
provider = "local"
@ -234,7 +234,7 @@ preserve = true
[sandbox.local]
worktree_mode = "never"
[assets]
[artifacts]
include = ["assets/**"]
"#,
);
@ -242,9 +242,9 @@ include = ["assets/**"]
let run = run_local_workflow(context, &workspace_dir, "run.toml");
assert!(
run.run_dir
.join("cache/artifacts/assets/retry_assets/retry_2/manifest.json")
.join("cache/artifacts/files/retry_assets/retry_2/manifest.json")
.exists(),
"setup_asset_run should materialize retry_2 assets"
"setup_artifact_run should materialize retry_2 assets"
);
WorkspaceRunSetup { run, workspace_dir }

View file

@ -69,12 +69,13 @@ fn local_run_lifecycle() {
"first log line should have an event field"
);
// 5. asset list — no assets yet, should succeed with empty message
let asset_list_out = cmd(&["asset", "list", &run_id]).success();
let asset_list_stdout = String::from_utf8(asset_list_out.get_output().stdout.clone()).unwrap();
// 5. artifact list — no assets yet, should succeed with empty message
let artifact_list_out = cmd(&["artifact", "list", &run_id]).success();
let artifact_list_stdout =
String::from_utf8(artifact_list_out.get_output().stdout.clone()).unwrap();
assert!(
asset_list_stdout.contains("No assets found"),
"asset list should report no assets: {asset_list_stdout}"
artifact_list_stdout.contains("No artifacts found"),
"artifact list should report no artifacts: {artifact_list_stdout}"
);
// 6. system df — mentions "Runs"

View file

@ -8,7 +8,7 @@ use crate::hook::{HookDefinition, HookSettings};
use crate::mcp::McpServerEntry;
use crate::project::{self, ProjectConfig};
use crate::run::{
AssetsConfig, CheckpointConfig, GitHubConfig, LlmConfig, PullRequestConfig, SetupConfig,
ArtifactsConfig, CheckpointConfig, GitHubConfig, LlmConfig, PullRequestConfig, SetupConfig,
};
use crate::sandbox::SandboxConfig;
use crate::server::{self, ApiConfig, FeaturesConfig, GitConfig, LogConfig, WebConfig};
@ -65,7 +65,7 @@ pub struct ConfigLayer {
pub pull_request: Option<PullRequestConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assets: Option<AssetsConfig>,
pub artifacts: Option<ArtifactsConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<HookDefinition>,
@ -157,7 +157,7 @@ impl Combine for ConfigLayer {
vars: self.vars.combine(other.vars),
checkpoint: self.checkpoint.combine(other.checkpoint),
pull_request: self.pull_request.combine(other.pull_request),
assets: self.assets.combine(other.assets),
artifacts: self.artifacts.combine(other.artifacts),
hooks,
mcp_servers: self.mcp_servers.combine(other.mcp_servers),
github: self.github.combine(other.github),

View file

@ -9,7 +9,7 @@ use crate::combine::Combine;
use crate::config::ConfigLayer;
use crate::sandbox::DockerfileSource;
pub use fabro_types::settings::run::{
AssetsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy,
ArtifactsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy,
PullRequestSettings, SetupSettings,
};
@ -59,13 +59,13 @@ impl From<PullRequestConfig> for PullRequestSettings {
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct AssetsConfig {
pub struct ArtifactsConfig {
#[serde(default)]
pub include: Vec<String>,
}
impl From<AssetsConfig> for AssetsSettings {
fn from(value: AssetsConfig) -> Self {
impl From<ArtifactsConfig> for ArtifactsSettings {
fn from(value: ArtifactsConfig) -> Self {
Self {
include: value.include,
}

View file

@ -19,7 +19,7 @@ impl TryFrom<ConfigLayer> for Settings {
vars: value.vars,
checkpoint: value.checkpoint.into(),
pull_request: value.pull_request.map(Into::into),
assets: value.assets.map(Into::into),
artifacts: value.artifacts.map(Into::into),
hooks: value.hooks,
mcp_servers: value.mcp_servers,
github: value.github.map(Into::into),

View file

@ -1327,7 +1327,7 @@ mod runs {
hooks: vec![],
checkpoint: Default::default(),
pull_request: None,
assets: None,
artifacts: None,
mcp_servers: Default::default(),
github: None,
..Default::default()
@ -1496,7 +1496,7 @@ mod workflows {
hooks: vec![],
checkpoint: Default::default(),
pull_request: None,
assets: None,
artifacts: None,
mcp_servers: Default::default(),
github: None,
..Default::default()
@ -1569,7 +1569,7 @@ mod workflows {
hooks: vec![],
checkpoint: Default::default(),
pull_request: None,
assets: None,
artifacts: None,
mcp_servers: Default::default(),
github: None,
..Default::default()
@ -1654,7 +1654,7 @@ mod workflows {
hooks: vec![],
checkpoint: Default::default(),
pull_request: None,
assets: None,
artifacts: None,
mcp_servers: Default::default(),
github: None,
..Default::default()
@ -1728,7 +1728,7 @@ mod workflows {
hooks: vec![],
checkpoint: Default::default(),
pull_request: None,
assets: None,
artifacts: None,
mcp_servers: Default::default(),
github: None,
..Default::default()
@ -3505,7 +3505,7 @@ mod settings {
vars: None,
checkpoint: Default::default(),
pull_request: None,
assets: None,
artifacts: None,
hooks: vec![],
mcp_servers: Default::default(),
github: None,

View file

@ -308,7 +308,7 @@ fn fully_populated_server_config() -> Settings {
auto_merge: false,
merge_strategy: MergeStrategy::Squash,
}),
assets: Some(AssetsSettings {
artifacts: Some(ArtifactsSettings {
include: vec!["test-results/**".into()],
}),
// One hook per HookType variant so the key union covers all fields.

View file

@ -18,7 +18,7 @@ pub(crate) fn blob_key(id: &RunBlobId) -> String {
format!("{BLOBS_PREFIX}{id}")
}
pub(crate) fn node_asset_prefix(node: &StageId) -> String {
pub(crate) fn node_artifact_prefix(node: &StageId) -> String {
format!(
"{ARTIFACT_NODES_PREFIX}{}#visit-{}",
node.node_id(),
@ -26,8 +26,8 @@ pub(crate) fn node_asset_prefix(node: &StageId) -> String {
)
}
pub(crate) fn node_asset(node: &StageId, filename: &str) -> String {
format!("{}#{filename}", node_asset_prefix(node))
pub(crate) fn node_artifact(node: &StageId, filename: &str) -> String {
format!("{}#{filename}", node_artifact_prefix(node))
}
pub(crate) fn parse_event_seq(key: &str) -> Option<u32> {
@ -38,7 +38,7 @@ pub(crate) fn parse_blob_id(key: &str) -> Option<RunBlobId> {
key.strip_prefix(BLOBS_PREFIX)?.parse().ok()
}
pub(crate) fn parse_node_asset_key(key: &str) -> Option<(StageId, String)> {
pub(crate) fn parse_node_artifact_key(key: &str) -> Option<(StageId, String)> {
parse_visit_scoped_key(key, ARTIFACT_NODES_PREFIX)
}
@ -74,7 +74,7 @@ mod tests {
let blob_id = RunBlobId::new(&"01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(), b"summary");
assert_eq!(blob_key(&blob_id), format!("blobs#{blob_id}"));
assert_eq!(
node_asset(&node, "src/main.rs"),
node_artifact(&node, "src/main.rs"),
"artifacts#nodes#code#visit-2#src/main.rs"
);
}
@ -85,7 +85,7 @@ mod tests {
let blob_id = RunBlobId::new(&"01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(), b"summary");
assert_eq!(parse_blob_id(&format!("blobs#{blob_id}")), Some(blob_id));
assert_eq!(
parse_node_asset_key("artifacts#nodes#code#visit-2#src/main.rs"),
parse_node_artifact_key("artifacts#nodes#code#visit-2#src/main.rs"),
Some((StageId::new("code", 2), "src/main.rs".to_string()))
);
}
@ -95,7 +95,7 @@ mod tests {
assert_eq!(parse_event_seq("events#not-a-seq.json"), None);
assert_eq!(parse_blob_id("blobs#not-a-uuid"), None);
assert_eq!(
parse_node_asset_key("artifacts#nodes#code#status.json"),
parse_node_artifact_key("artifacts#nodes#code#status.json"),
None
);
}
@ -103,7 +103,7 @@ mod tests {
#[test]
fn asset_filename_with_slashes_parses_correctly() {
assert_eq!(
parse_node_asset_key("artifacts#nodes#build#visit-1#deep/nested/path/file.rs"),
parse_node_artifact_key("artifacts#nodes#build#visit-1#deep/nested/path/file.rs"),
Some((
StageId::new("build", 1),
"deep/nested/path/file.rs".to_string()

View file

@ -13,7 +13,7 @@ pub use error::{Result, StoreError};
pub use fabro_types::{RunBlobId, StageId};
pub use run_state::{NodeState, RunProjection};
pub use runtime::RuntimeState;
pub use slate::{NodeAsset, SlateRunStore, SlateStore};
pub use slate::{NodeArtifact, SlateRunStore, SlateStore};
pub use types::{EventEnvelope, EventPayload, RunSummary};
pub type StoreHandle = Arc<SlateStore>;

View file

@ -34,24 +34,28 @@ impl RuntimeState {
}
#[must_use]
pub fn artifact_values_dir(&self) -> PathBuf {
pub fn blob_cache_dir(&self) -> PathBuf {
self.root.join("cache").join("artifacts").join("values")
}
#[must_use]
pub fn artifact_values_dir(&self) -> PathBuf {
self.blob_cache_dir()
}
#[must_use]
pub fn artifact_value_path(&self, artifact_id: &str) -> PathBuf {
self.artifact_values_dir()
.join(format!("{artifact_id}.json"))
self.blob_cache_dir().join(format!("{artifact_id}.json"))
}
#[must_use]
pub fn assets_dir(&self) -> PathBuf {
self.root.join("cache").join("artifacts").join("assets")
pub fn artifacts_dir(&self) -> PathBuf {
self.root.join("cache").join("artifacts").join("files")
}
#[must_use]
pub fn asset_stage_dir(&self, node_slug: &str, attempt: u32) -> PathBuf {
self.assets_dir()
pub fn artifact_stage_dir(&self, node_slug: &str, attempt: u32) -> PathBuf {
self.artifacts_dir()
.join(node_slug)
.join(format!("retry_{attempt}"))
}
@ -80,7 +84,7 @@ mod tests {
dir.path().join("runtime").join("interview_request.claim")
);
assert_eq!(
state.artifact_values_dir(),
state.blob_cache_dir(),
dir.path().join("cache").join("artifacts").join("values")
);
assert_eq!(
@ -92,15 +96,15 @@ mod tests {
.join("response.plan.json")
);
assert_eq!(
state.assets_dir(),
dir.path().join("cache").join("artifacts").join("assets")
state.artifacts_dir(),
dir.path().join("cache").join("artifacts").join("files")
);
assert_eq!(
state.asset_stage_dir("plan", 2),
state.artifact_stage_dir("plan", 2),
dir.path()
.join("cache")
.join("artifacts")
.join("assets")
.join("files")
.join("plan")
.join("retry_2")
);

View file

@ -16,7 +16,7 @@ use crate::keys;
use crate::{ListRunsQuery, Result, RunSummary, StoreError};
use fabro_types::RunId;
use run_store::SlateRunStoreInner;
pub use run_store::{NodeAsset, SlateRunStore};
pub use run_store::{NodeArtifact, SlateRunStore};
#[derive(Clone)]
pub struct SlateStore {
@ -1181,7 +1181,7 @@ mod tests {
))
.await
.unwrap();
run.put_asset(&node, "src/lib.rs", b"fn main() {}")
run.put_artifact(&node, "src/lib.rs", b"fn main() {}")
.await
.unwrap();
@ -1189,7 +1189,7 @@ mod tests {
let node_state = state.node(&node).unwrap();
assert_eq!(node_state.prompt, Some("Plan".to_string()));
assert_eq!(
run.get_asset(&node, "src/lib.rs").await.unwrap(),
run.get_artifact(&node, "src/lib.rs").await.unwrap(),
Some(Bytes::from_static(b"fn main() {}"))
);
}
@ -1203,12 +1203,12 @@ mod tests {
let plan_blob = run.write_blob(br#"{"steps":3}"#).await.unwrap();
let snapshot_node = StageId::new("code", 2);
run.put_asset(&snapshot_node, "src/lib.rs", b"fn main() {}")
run.put_artifact(&snapshot_node, "src/lib.rs", b"fn main() {}")
.await
.unwrap();
let asset_only_node = StageId::new("artifact-only", 7);
run.put_asset(&asset_only_node, "logs/output.txt", b"hello")
let artifact_only_node = StageId::new("artifact-only", 7);
run.put_artifact(&artifact_only_node, "logs/output.txt", b"hello")
.await
.unwrap();
@ -1217,13 +1217,13 @@ mod tests {
vec![plan_blob, summary_blob]
);
assert_eq!(
run.list_all_assets().await.unwrap(),
run.list_all_artifacts().await.unwrap(),
vec![
crate::slate::NodeAsset {
crate::slate::NodeArtifact {
node: crate::StageId::new("artifact-only", 7),
filename: "logs/output.txt".to_string(),
},
crate::slate::NodeAsset {
crate::slate::NodeArtifact {
node: crate::StageId::new("code", 2),
filename: "src/lib.rs".to_string(),
}
@ -1470,7 +1470,7 @@ mod tests {
.await
.unwrap();
let summary_blob = run.write_blob(br#"{"done":true}"#).await.unwrap();
run.put_asset(&node, "src/lib.rs", b"fn main() {}")
run.put_artifact(&node, "src/lib.rs", b"fn main() {}")
.await
.unwrap();
@ -1515,7 +1515,7 @@ mod tests {
Some(Bytes::from_static(br#"{"done":true}"#))
);
assert_eq!(
run.get_asset(&node, "src/lib.rs").await.unwrap(),
run.get_artifact(&node, "src/lib.rs").await.unwrap(),
Some(Bytes::from_static(b"fn main() {}"))
);
assert_eq!(

View file

@ -18,7 +18,7 @@ use crate::{EventEnvelope, EventPayload, Result, RunProjection, RunSummary, Stag
use fabro_types::{RunBlobId, RunId};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct NodeAsset {
pub struct NodeArtifact {
pub node: StageId,
pub filename: String,
}
@ -227,22 +227,22 @@ impl SlateRunStore {
self.inner.db.list_blobs().await
}
pub async fn put_asset(&self, node: &StageId, filename: &str, data: &[u8]) -> Result<()> {
pub async fn put_artifact(&self, node: &StageId, filename: &str, data: &[u8]) -> Result<()> {
self.inner
.db
.put_bytes(&keys::node_asset(node, filename), data)
.put_bytes(&keys::node_artifact(node, filename), data)
.await
}
pub async fn get_asset(&self, node: &StageId, filename: &str) -> Result<Option<Bytes>> {
pub async fn get_artifact(&self, node: &StageId, filename: &str) -> Result<Option<Bytes>> {
self.inner
.db
.get_bytes(&keys::node_asset(node, filename))
.get_bytes(&keys::node_artifact(node, filename))
.await
}
pub async fn list_all_assets(&self) -> Result<Vec<NodeAsset>> {
self.inner.db.list_all_assets().await
pub async fn list_all_artifacts(&self) -> Result<Vec<NodeArtifact>> {
self.inner.db.list_all_artifacts().await
}
pub async fn state(&self) -> Result<RunProjection> {
@ -294,10 +294,10 @@ impl SlateRunDb {
}
}
async fn list_all_assets(&self) -> Result<Vec<NodeAsset>> {
async fn list_all_artifacts(&self) -> Result<Vec<NodeArtifact>> {
match self {
Self::Writer(db) => list_all_assets(db).await,
Self::Reader(db) => list_all_assets(db.as_ref()).await,
Self::Writer(db) => list_all_artifacts(db).await,
Self::Reader(db) => list_all_artifacts(db.as_ref()).await,
}
}
}
@ -383,7 +383,7 @@ where
Ok(blob_ids)
}
async fn list_all_assets<R>(db: &R) -> Result<Vec<NodeAsset>>
async fn list_all_artifacts<R>(db: &R) -> Result<Vec<NodeArtifact>>
where
R: DbRead + Sync,
{
@ -393,10 +393,10 @@ where
let mut assets = Vec::new();
while let Some(entry) = iter.next().await? {
let key = key_to_string(&entry.key)?;
let Some((node, filename)) = keys::parse_node_asset_key(&key) else {
let Some((node, filename)) = keys::parse_node_artifact_key(&key) else {
continue;
};
assets.push(NodeAsset { node, filename });
assets.push(NodeArtifact { node, filename });
}
assets.sort();
Ok(assets)

View file

@ -131,7 +131,7 @@ pub struct StallWatchdogTimeoutProps {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AssetCapturedProps {
pub struct ArtifactCapturedProps {
pub attempt: u32,
pub node_slug: String,
pub path: String,

View file

@ -6,6 +6,7 @@ pub mod stage;
use chrono::{DateTime, Utc};
use serde::de::Error as DeError;
use serde::ser::Error as SerError;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{Map, Value, json};
@ -56,6 +57,7 @@ pub struct RunEvent {
pub body: EventBody,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(tag = "event", content = "properties")]
pub enum EventBody {
@ -217,8 +219,8 @@ pub enum EventBody {
SetupFailed(SetupFailedProps),
#[serde(rename = "watchdog.timeout")]
StallWatchdogTimeout(StallWatchdogTimeoutProps),
#[serde(rename = "asset.captured")]
AssetCaptured(AssetCapturedProps),
#[serde(rename = "artifact.captured")]
ArtifactCaptured(ArtifactCapturedProps),
#[serde(rename = "ssh.ready")]
SshAccessReady(SshAccessReadyProps),
#[serde(rename = "agent.failover")]
@ -377,7 +379,7 @@ impl Serialize for RunEvent {
S: Serializer,
{
self.to_value()
.map_err(serde::ser::Error::custom)?
.map_err(S::Error::custom)?
.serialize(serializer)
}
}

View file

@ -18,7 +18,7 @@ pub use mcp::{
};
pub use project::ProjectSettings;
pub use run::{
AssetsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy,
ArtifactsSettings, CheckpointSettings, GitHubSettings, LlmSettings, MergeStrategy,
PullRequestSettings, SetupSettings,
};
pub use sandbox::{
@ -65,7 +65,7 @@ pub struct Settings {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull_request: Option<PullRequestSettings>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assets: Option<AssetsSettings>,
pub artifacts: Option<ArtifactsSettings>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<HookDefinition>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]

View file

@ -34,7 +34,7 @@ pub enum MergeStrategy {
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct AssetsSettings {
pub struct ArtifactsSettings {
#[serde(default)]
pub include: Vec<String>,
}

View file

@ -1,225 +1,50 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::RwLock;
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use fabro_agent::Sandbox;
use fabro_store::SlateRunStore;
use crate::error::{FabroError, Result};
/// Threshold above which artifacts are stored on disk instead of in memory (100KB).
const FILE_BACKING_THRESHOLD: usize = 100 * 1024;
/// Metadata about a stored artifact.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArtifactInfo {
pub id: String,
pub name: String,
pub size_bytes: usize,
pub stored_at: DateTime<Utc>,
pub is_file_backed: bool,
pub file_path: Option<PathBuf>,
}
/// Storage for artifacts, either held in memory or backed by files on disk.
enum StoredData {
InMemory(Value),
FileBacked(PathBuf),
}
/// Named, typed storage for large stage outputs.
pub struct ArtifactStore {
values_dir: Option<PathBuf>,
artifacts: RwLock<HashMap<String, (ArtifactInfo, StoredData)>>,
}
impl std::fmt::Debug for ArtifactStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ArtifactStore")
.field("values_dir", &self.values_dir)
.finish_non_exhaustive()
}
}
impl ArtifactStore {
#[must_use]
pub fn new(values_dir: Option<PathBuf>) -> Self {
Self {
values_dir,
artifacts: RwLock::new(HashMap::new()),
}
}
/// Store an artifact. Large artifacts with a configured `values_dir` are written to disk.
///
/// # Errors
///
/// Returns an error if serialization fails or the file cannot be written.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn store(
&self,
id: impl Into<String>,
name: impl Into<String>,
data: Value,
) -> Result<ArtifactInfo> {
let id = id.into();
let name = name.into();
let serialized = serde_json::to_string(&data)
.map_err(|e| FabroError::engine(format!("artifact serialize failed: {e}")))?;
let size_bytes = serialized.len();
let is_file_backed = size_bytes > FILE_BACKING_THRESHOLD && self.values_dir.is_some();
let (stored, file_path) = if is_file_backed {
let values_dir = self.values_dir.as_ref().expect("values_dir checked above");
std::fs::create_dir_all(values_dir)?;
let path = values_dir.join(format!("{id}.json"));
std::fs::write(&path, &serialized)?;
(StoredData::FileBacked(path.clone()), Some(path))
} else {
(StoredData::InMemory(data), None)
};
let info = ArtifactInfo {
id: id.clone(),
name,
size_bytes,
stored_at: Utc::now(),
is_file_backed,
file_path,
};
self.artifacts
.write()
.expect("artifact lock poisoned")
.insert(id, (info.clone(), stored));
Ok(info)
}
/// Retrieve an artifact's data by ID.
///
/// # Errors
///
/// Returns an error if the artifact is not found or cannot be read from disk.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn retrieve(&self, id: &str) -> Result<Value> {
let guard = self.artifacts.read().expect("artifact lock poisoned");
let (_, stored) = guard
.get(id)
.ok_or_else(|| FabroError::engine(format!("artifact not found: {id}")))?;
match stored {
StoredData::InMemory(v) => Ok(v.clone()),
StoredData::FileBacked(path) => {
let path = path.clone();
drop(guard);
let data = std::fs::read_to_string(&path).map_err(|e| {
FabroError::engine(format!("failed to read file-backed artifact {id}: {e}"))
})?;
serde_json::from_str(&data).map_err(|e| {
FabroError::engine(format!(
"failed to deserialize file-backed artifact {id}: {e}"
))
})
}
}
}
/// Check if an artifact exists.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn has(&self, id: &str) -> bool {
self.artifacts
.read()
.expect("artifact lock poisoned")
.contains_key(id)
}
/// List all artifact metadata.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn list(&self) -> Vec<ArtifactInfo> {
self.artifacts
.read()
.expect("artifact lock poisoned")
.values()
.map(|(info, _)| info.clone())
.collect()
}
/// Remove an artifact by ID. Also deletes file-backed data from disk.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn remove(&self, id: &str) {
let mut guard = self.artifacts.write().expect("artifact lock poisoned");
if let Some((_, StoredData::FileBacked(path))) = guard.remove(id) {
let _ = std::fs::remove_file(path);
}
}
/// Returns the configured values directory.
/// Returns `None` if no file-backed storage is configured.
#[must_use]
pub fn values_dir(&self) -> Option<PathBuf> {
self.values_dir.clone()
}
/// Remove all artifacts. Also deletes file-backed data from disk.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn clear(&self) {
let mut guard = self.artifacts.write().expect("artifact lock poisoned");
for (_, stored) in guard.values() {
if let StoredData::FileBacked(path) = stored {
let _ = std::fs::remove_file(path);
}
}
guard.clear();
}
}
/// Threshold above which values are persisted as blobs and materialized to disk (100KB).
const BLOB_OFFLOAD_THRESHOLD: usize = 100 * 1024;
/// Prefix used to identify artifact pointer strings in context values.
const ARTIFACT_POINTER_PREFIX: &str = "file://";
/// Offload context values exceeding the file-backing threshold into the artifact store.
/// Offload context values exceeding the blob threshold into SlateDB and materialize cache files.
///
/// For each entry in `updates` whose serialized JSON exceeds `FILE_BACKING_THRESHOLD`,
/// the value is stored in `store` and replaced with a `"file://{path}"` pointer.
/// For each entry in `updates` whose serialized JSON exceeds `BLOB_OFFLOAD_THRESHOLD`,
/// the value is persisted as a blob in `run_store`, materialized in `cache_dir`, and
/// replaced with a `"file://{path}"` pointer.
/// Small values are left untouched.
///
/// # Errors
///
/// Returns an error if storing an artifact fails.
pub fn offload_large_values(
/// Returns an error if blob persistence or cache materialization fails.
pub async fn offload_large_values(
updates: &mut HashMap<String, Value>,
store: &ArtifactStore,
run_store: &SlateRunStore,
cache_dir: &Path,
) -> Result<()> {
for (key, value) in updates.iter_mut() {
let serialized_len = serde_json::to_string(&*value).map(|s| s.len()).unwrap_or(0);
if serialized_len > FILE_BACKING_THRESHOLD {
let info = store.store(key, key, value.clone())?;
if let Some(path) = info.file_path {
*value = Value::String(format!("{ARTIFACT_POINTER_PREFIX}{}", path.display()));
std::fs::create_dir_all(cache_dir)?;
for value in updates.values_mut() {
let bytes = serde_json::to_vec(&*value)
.map_err(|e| FabroError::engine(format!("artifact serialize failed: {e}")))?;
if bytes.len() > BLOB_OFFLOAD_THRESHOLD {
let blob_id = run_store
.write_blob(&bytes)
.await
.map_err(|e| FabroError::engine(format!("artifact blob write failed: {e}")))?;
let cache_path = cache_dir.join(format!("{blob_id}.json"));
if !cache_path.exists() {
std::fs::write(&cache_path, &bytes)?;
}
*value = Value::String(format!("{ARTIFACT_POINTER_PREFIX}{}", cache_path.display()));
}
}
Ok(())
@ -303,160 +128,81 @@ pub async fn sync_artifacts_to_env(
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use super::*;
use fabro_store::SlateStore;
use object_store::memory::InMemory;
use ulid::Ulid;
#[test]
fn store_and_retrieve_small_artifact() {
let store = ArtifactStore::new(None);
let data = serde_json::json!({"result": "ok"});
let info = store.store("art1", "test artifact", data.clone()).unwrap();
assert_eq!(info.id, "art1");
assert_eq!(info.name, "test artifact");
assert!(!info.is_file_backed);
assert!(info.size_bytes > 0);
assert!(info.file_path.is_none());
let retrieved = store.retrieve("art1").unwrap();
assert_eq!(retrieved, data);
fn test_run_id(label: &str) -> fabro_types::RunId {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
use std::hash::{Hash, Hasher};
label.hash(&mut hasher);
fabro_types::RunId::from(Ulid(u128::from(hasher.finish())))
}
#[test]
fn retrieve_nonexistent() {
let store = ArtifactStore::new(None);
assert!(store.retrieve("missing").is_err());
async fn make_run_store(label: &str) -> fabro_store::SlateRunStore {
let object_store = Arc::new(InMemory::new());
let store = SlateStore::new(object_store, "runs/", Duration::from_millis(1));
store.create_run(&test_run_id(label)).await.unwrap()
}
#[test]
fn has_artifact() {
let store = ArtifactStore::new(None);
assert!(!store.has("x"));
store.store("x", "x", serde_json::json!(1)).unwrap();
assert!(store.has("x"));
}
#[test]
fn list_artifacts() {
let store = ArtifactStore::new(None);
store.store("a", "alpha", serde_json::json!(1)).unwrap();
store.store("b", "beta", serde_json::json!(2)).unwrap();
let list = store.list();
assert_eq!(list.len(), 2);
}
#[test]
fn remove_artifact() {
let store = ArtifactStore::new(None);
store.store("r", "remove me", serde_json::json!(1)).unwrap();
assert!(store.has("r"));
store.remove("r");
assert!(!store.has("r"));
}
#[test]
fn clear_artifacts() {
let store = ArtifactStore::new(None);
store.store("a", "a", serde_json::json!(1)).unwrap();
store.store("b", "b", serde_json::json!(2)).unwrap();
assert_eq!(store.list().len(), 2);
store.clear();
assert!(store.list().is_empty());
}
#[test]
fn file_backed_storage() {
#[tokio::test]
async fn offload_replaces_large_values_with_blob_backed_pointer() {
let dir = tempfile::tempdir().unwrap();
let store = ArtifactStore::new(Some(dir.path().to_path_buf()));
let run_store = make_run_store("artifact-offload").await;
// Create data larger than the 100KB threshold
let large_string = "x".repeat(FILE_BACKING_THRESHOLD + 1);
let data = serde_json::json!(large_string);
let large_string = "x".repeat(BLOB_OFFLOAD_THRESHOLD + 1);
let serialized = serde_json::to_vec(&serde_json::json!(large_string.clone())).unwrap();
let expected_blob_id =
fabro_types::RunBlobId::new(&test_run_id("artifact-offload"), &serialized);
let info = store.store("big", "large artifact", data.clone()).unwrap();
assert!(info.is_file_backed);
assert!(info.size_bytes > FILE_BACKING_THRESHOLD);
assert_eq!(info.file_path, Some(dir.path().join("big.json")));
let retrieved = store.retrieve("big").unwrap();
assert_eq!(retrieved, data);
}
#[test]
fn file_backed_remove_deletes_file() {
let dir = tempfile::tempdir().unwrap();
let store = ArtifactStore::new(Some(dir.path().to_path_buf()));
let large_string = "x".repeat(FILE_BACKING_THRESHOLD + 1);
let data = serde_json::json!(large_string);
store.store("big", "large", data).unwrap();
let file_path = dir.path().join("big.json");
assert!(file_path.exists());
store.remove("big");
assert!(!file_path.exists());
}
#[test]
fn small_artifact_stays_in_memory_even_with_values_dir() {
let dir = tempfile::tempdir().unwrap();
let store = ArtifactStore::new(Some(dir.path().to_path_buf()));
let data = serde_json::json!({"small": true});
let info = store.store("small", "tiny", data).unwrap();
assert!(!info.is_file_backed);
}
#[test]
fn no_file_backing_without_base_dir() {
let store = ArtifactStore::new(None);
let large_string = "x".repeat(FILE_BACKING_THRESHOLD + 1);
let data = serde_json::json!(large_string);
let info = store.store("big", "large", data).unwrap();
assert!(!info.is_file_backed);
}
#[test]
fn offload_replaces_large_values_with_pointer() {
let dir = tempfile::tempdir().unwrap();
let store = ArtifactStore::new(Some(dir.path().to_path_buf()));
let large_string = "x".repeat(FILE_BACKING_THRESHOLD + 1);
let mut updates = HashMap::new();
updates.insert("response.plan".to_string(), serde_json::json!(large_string));
offload_large_values(&mut updates, &store).unwrap();
offload_large_values(&mut updates, &run_store, dir.path())
.await
.unwrap();
// Value should now be a pointer string
let pointer = updates.get("response.plan").unwrap();
let path = artifact_path(pointer).expect("should be an artifact pointer");
assert_eq!(
path,
dir.path().join("response.plan.json").to_str().unwrap()
dir.path()
.join(format!("{expected_blob_id}.json"))
.to_str()
.unwrap()
);
// The artifact store should contain the original value
let retrieved = store.retrieve("response.plan").unwrap();
assert_eq!(retrieved, serde_json::json!(large_string));
// File should exist on disk
assert!(dir.path().join("response.plan.json").exists());
let blob = run_store
.read_blob(&expected_blob_id)
.await
.unwrap()
.expect("blob should exist");
let blob_value: serde_json::Value = serde_json::from_slice(&blob).unwrap();
assert_eq!(blob_value, serde_json::json!(large_string));
assert!(
dir.path().join(format!("{expected_blob_id}.json")).exists(),
"materialized cache file should exist"
);
}
#[test]
fn offload_leaves_small_values_untouched() {
#[tokio::test]
async fn offload_leaves_small_values_untouched() {
let dir = tempfile::tempdir().unwrap();
let store = ArtifactStore::new(Some(dir.path().to_path_buf()));
let run_store = make_run_store("artifact-small").await;
let small_value = serde_json::json!("hello world");
let mut updates = HashMap::new();
updates.insert("small_key".to_string(), small_value.clone());
offload_large_values(&mut updates, &store).unwrap();
offload_large_values(&mut updates, &run_store, dir.path())
.await
.unwrap();
assert_eq!(updates.get("small_key").unwrap(), &small_value);
assert!(!store.has("small_key"));
assert!(std::fs::read_dir(dir.path()).unwrap().next().is_none());
}
#[test]
@ -468,14 +214,6 @@ mod tests {
);
}
#[test]
fn values_dir_returns_configured_directory() {
let dir = tempfile::tempdir().unwrap();
let store = ArtifactStore::new(Some(dir.path().to_path_buf()));
assert_eq!(store.values_dir(), Some(dir.path().to_path_buf()));
}
#[test]
fn artifact_path_returns_none_for_plain_string() {
let value = serde_json::json!("just a normal string");

View file

@ -13,9 +13,9 @@ pub struct DiscoveredFile {
pub mtime_epoch_secs: f64,
}
/// Metadata for a single captured asset file.
/// Metadata for a single captured artifact file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CapturedAssetInfo {
pub struct CapturedArtifactInfo {
pub path: String,
pub mime: String,
pub content_md5: String,
@ -23,15 +23,15 @@ pub struct CapturedAssetInfo {
pub bytes: u64,
}
/// Summary of an asset collection run.
/// Summary of an artifact collection run.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetCollectionSummary {
pub struct ArtifactCollectionSummary {
pub files_copied: usize,
pub total_bytes: u64,
pub files_skipped: usize,
pub download_errors: usize,
pub hash_errors: usize,
pub captured_assets: Vec<CapturedAssetInfo>,
pub captured_assets: Vec<CapturedArtifactInfo>,
}
/// Directories to exclude from the find search and checkpoint commits.
@ -61,7 +61,7 @@ const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
/// Maximum total size for all collected files (50 MB).
const MAX_TOTAL_SIZE: u64 = 50 * 1024 * 1024;
/// Build a platform-aware find command to discover asset files matching the given globs.
/// Build a platform-aware find command to discover artifact files matching the given globs.
///
/// Globs without `/` are treated as filename patterns (`-name`).
/// Globs with `/` are treated as directory patterns: the trailing `/**` (if any) is stripped
@ -254,10 +254,10 @@ fn normalize_paths(discovered: Vec<DiscoveredFile>, root: &str) -> Vec<Discovere
.collect()
}
fn compute_asset_info(
fn compute_artifact_info(
relative_path: &str,
local_path: &Path,
) -> std::result::Result<CapturedAssetInfo, String> {
) -> std::result::Result<CapturedArtifactInfo, String> {
let mime = mime_guess::from_path(relative_path)
.first_or_octet_stream()
.to_string();
@ -266,7 +266,7 @@ fn compute_asset_info(
let bytes = u64::try_from(data.len()).unwrap_or(u64::MAX);
let content_md5 = format!("{:x}", md5::compute(&data));
let content_sha256 = hex::encode(Sha256::digest(&data));
Ok(CapturedAssetInfo {
Ok(CapturedArtifactInfo {
path: relative_path.to_string(),
mime,
content_md5,
@ -275,13 +275,13 @@ fn compute_asset_info(
})
}
fn write_asset_manifest(
asset_capture_dir: &Path,
summary: &AssetCollectionSummary,
fn write_artifact_manifest(
artifact_capture_dir: &Path,
summary: &ArtifactCollectionSummary,
) -> Result<(), String> {
let json = serde_json::to_string_pretty(summary)
.map_err(|e| format!("failed to serialize manifest: {e}"))?;
let manifest_path = asset_capture_dir.join("manifest.json");
let manifest_path = artifact_capture_dir.join("manifest.json");
if let Some(parent) = manifest_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
format!(
@ -295,26 +295,26 @@ fn write_asset_manifest(
Ok(())
}
fn cleanup_asset_capture_dir(asset_capture_dir: &Path) -> Result<(), String> {
if !asset_capture_dir.exists() {
fn cleanup_artifact_capture_dir(artifact_capture_dir: &Path) -> Result<(), String> {
if !artifact_capture_dir.exists() {
return Ok(());
}
std::fs::remove_dir_all(asset_capture_dir)
.map_err(|e| format!("failed to clean up {}: {e}", asset_capture_dir.display()))
std::fs::remove_dir_all(artifact_capture_dir)
.map_err(|e| format!("failed to clean up {}: {e}", artifact_capture_dir.display()))
}
/// Collect asset files matching the configured globs that were created during this stage.
pub async fn collect_assets(
/// Collect artifact files matching the configured globs that were created during this stage.
pub async fn collect_artifacts(
sandbox: &dyn Sandbox,
asset_capture_dir: &Path,
artifact_capture_dir: &Path,
globs: &[String],
command_start_epoch: f64,
) -> Result<AssetCollectionSummary, String> {
) -> Result<ArtifactCollectionSummary, String> {
let root = sandbox.working_directory();
let platform = sandbox.platform();
let cmd = build_find_command(root, platform, globs);
debug!(cmd = cmd.as_str(), "Collecting assets");
debug!(cmd = cmd.as_str(), "Collecting artifacts");
let result = sandbox
.exec_command(&cmd, FIND_TIMEOUT_MS, None, None, None)
.await?;
@ -330,15 +330,15 @@ pub async fn collect_assets(
let mut total_bytes: u64 = 0;
let mut download_errors: usize = 0;
let mut hash_errors: usize = 0;
let mut captured_assets: Vec<CapturedAssetInfo> = Vec::new();
let mut captured_assets: Vec<CapturedArtifactInfo> = Vec::new();
for file in &to_collect {
let dest = asset_capture_dir.join(&file.relative_path);
let dest = artifact_capture_dir.join(&file.relative_path);
match sandbox
.download_file_to_local(&file.relative_path, &dest)
.await
{
Ok(()) => match compute_asset_info(&file.relative_path, &dest) {
Ok(()) => match compute_artifact_info(&file.relative_path, &dest) {
Ok(info) => {
files_copied += 1;
total_bytes += info.bytes;
@ -366,7 +366,7 @@ pub async fn collect_assets(
}
// Write manifest.json
let summary = AssetCollectionSummary {
let summary = ArtifactCollectionSummary {
files_copied,
total_bytes,
files_skipped,
@ -376,8 +376,8 @@ pub async fn collect_assets(
};
if files_copied > 0 {
if let Err(e) = write_asset_manifest(asset_capture_dir, &summary) {
let cleanup_suffix = match cleanup_asset_capture_dir(asset_capture_dir) {
if let Err(e) = write_artifact_manifest(artifact_capture_dir, &summary) {
let cleanup_suffix = match cleanup_artifact_capture_dir(artifact_capture_dir) {
Ok(()) => String::new(),
Err(cleanup_err) => format!("; cleanup failed: {cleanup_err}"),
};
@ -388,11 +388,11 @@ pub async fn collect_assets(
Ok(summary)
}
/// Collect all asset paths from manifest files under `{assets_dir}/*/retry_*/manifest.json`.
/// Collect all artifact paths from manifest files under `{artifacts_dir}/*/retry_*/manifest.json`.
///
/// Returns the full on-disk paths to the downloaded asset files.
pub fn collect_asset_paths(assets_dir: &Path) -> Vec<String> {
let Ok(nodes) = std::fs::read_dir(assets_dir) else {
/// Returns the full on-disk paths to the downloaded artifact files.
pub fn collect_artifact_paths(artifacts_dir: &Path) -> Vec<String> {
let Ok(nodes) = std::fs::read_dir(artifacts_dir) else {
return Vec::new();
};
@ -409,7 +409,7 @@ pub fn collect_asset_paths(assets_dir: &Path) -> Vec<String> {
let Ok(contents) = std::fs::read_to_string(&manifest) else {
continue;
};
let Ok(summary) = serde_json::from_str::<AssetCollectionSummary>(&contents) else {
let Ok(summary) = serde_json::from_str::<ArtifactCollectionSummary>(&contents) else {
continue;
};
let retry_dir = retry_entry.path();
@ -429,7 +429,7 @@ mod tests {
use std::collections::HashMap;
use std::fs;
/// Minimal mock sandbox for asset_snapshot tests.
/// Minimal mock sandbox for artifact_snapshot tests.
struct AssetMockSandbox {
files: HashMap<String, String>,
exec_result: ExecResult,
@ -718,7 +718,7 @@ mod tests {
let mock = AssetMockSandbox::new(files, "1024\t2000.0\ttest-results/r.xml\n", "linux");
let globs = vec!["test-results/**".to_string()];
let summary = collect_assets(&mock, stage_dir.path(), &globs, 1000.0)
let summary = collect_artifacts(&mock, stage_dir.path(), &globs, 1000.0)
.await
.unwrap();
@ -759,7 +759,7 @@ mod tests {
let mock = AssetMockSandbox::new(files, "1024\t500.0\ttest-results/r.xml\n", "linux");
let globs = vec!["test-results/**".to_string()];
let summary = collect_assets(&mock, stage_dir.path(), &globs, 1000.0)
let summary = collect_artifacts(&mock, stage_dir.path(), &globs, 1000.0)
.await
.unwrap();
@ -778,7 +778,7 @@ mod tests {
);
let globs = vec!["test-results/**".to_string()];
let summary = collect_assets(&mock, stage_dir.path(), &globs, 1000.0)
let summary = collect_artifacts(&mock, stage_dir.path(), &globs, 1000.0)
.await
.unwrap();
@ -789,7 +789,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn write_asset_manifest_failure_cleans_up_asset_capture_dir() {
fn write_artifact_manifest_failure_cleans_up_artifact_capture_dir() {
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
@ -799,13 +799,13 @@ mod tests {
fs::write(stage_dir.join("test-results/report.xml"), "<test/>").unwrap();
fs::set_permissions(&stage_dir, Permissions::from_mode(0o555)).unwrap();
let summary = AssetCollectionSummary {
let summary = ArtifactCollectionSummary {
files_copied: 1,
total_bytes: 7,
files_skipped: 0,
download_errors: 0,
hash_errors: 0,
captured_assets: vec![CapturedAssetInfo {
captured_assets: vec![CapturedArtifactInfo {
path: "test-results/report.xml".to_string(),
mime: "text/xml".to_string(),
content_md5: "f1430934c390c118ed2f148e1d44d36c".to_string(),
@ -815,40 +815,40 @@ mod tests {
}],
};
let err = write_asset_manifest(&stage_dir, &summary).unwrap_err();
let err = write_artifact_manifest(&stage_dir, &summary).unwrap_err();
assert!(err.contains("failed to write"));
fs::set_permissions(&stage_dir, Permissions::from_mode(0o755)).unwrap();
cleanup_asset_capture_dir(&stage_dir).unwrap();
cleanup_artifact_capture_dir(&stage_dir).unwrap();
assert!(!stage_dir.exists());
}
#[test]
fn collect_asset_paths_from_manifests() {
fn collect_artifact_paths_from_manifests() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
let assets_dir = base.join("cache/artifacts/assets");
let artifacts_dir = base.join("cache/artifacts/files");
// Create two node directories with manifests
let node_a = assets_dir.join("node_a/retry_1");
let node_a = artifacts_dir.join("node_a/retry_1");
std::fs::create_dir_all(&node_a).unwrap();
std::fs::write(
node_a.join("manifest.json"),
serde_json::to_string(&AssetCollectionSummary {
serde_json::to_string(&ArtifactCollectionSummary {
files_copied: 2,
total_bytes: 2048,
files_skipped: 0,
download_errors: 0,
hash_errors: 0,
captured_assets: vec![
CapturedAssetInfo {
CapturedArtifactInfo {
path: "test-results/report.xml".to_string(),
mime: "text/xml".to_string(),
content_md5: "md5-report".to_string(),
content_sha256: "sha256-report".to_string(),
bytes: 1024,
},
CapturedAssetInfo {
CapturedArtifactInfo {
path: "test-results/screenshot.png".to_string(),
mime: "image/png".to_string(),
content_md5: "md5-screenshot".to_string(),
@ -861,17 +861,17 @@ mod tests {
)
.unwrap();
let node_b = assets_dir.join("node_b/retry_1");
let node_b = artifacts_dir.join("node_b/retry_1");
std::fs::create_dir_all(&node_b).unwrap();
std::fs::write(
node_b.join("manifest.json"),
serde_json::to_string(&AssetCollectionSummary {
serde_json::to_string(&ArtifactCollectionSummary {
files_copied: 1,
total_bytes: 512,
files_skipped: 0,
download_errors: 0,
hash_errors: 0,
captured_assets: vec![CapturedAssetInfo {
captured_assets: vec![CapturedArtifactInfo {
path: "coverage/lcov.info".to_string(),
mime: "application/octet-stream".to_string(),
content_md5: "md5-lcov".to_string(),
@ -883,24 +883,24 @@ mod tests {
)
.unwrap();
let paths = collect_asset_paths(&assets_dir);
let paths = collect_artifact_paths(&artifacts_dir);
assert_eq!(paths.len(), 3);
let base_str = base.to_string_lossy();
assert!(paths.contains(&format!(
"{base_str}/cache/artifacts/assets/node_a/retry_1/test-results/report.xml"
"{base_str}/cache/artifacts/files/node_a/retry_1/test-results/report.xml"
)));
assert!(paths.contains(&format!(
"{base_str}/cache/artifacts/assets/node_a/retry_1/test-results/screenshot.png"
"{base_str}/cache/artifacts/files/node_a/retry_1/test-results/screenshot.png"
)));
assert!(paths.contains(&format!(
"{base_str}/cache/artifacts/assets/node_b/retry_1/coverage/lcov.info"
"{base_str}/cache/artifacts/files/node_b/retry_1/coverage/lcov.info"
)));
}
#[test]
fn collect_asset_paths_empty_when_no_assets() {
let tmp = tempfile::tempdir().unwrap();
let paths = collect_asset_paths(&tmp.path().join("cache/artifacts/assets"));
let paths = collect_artifact_paths(&tmp.path().join("cache/artifacts/files"));
assert!(paths.is_empty());
}

View file

@ -2,11 +2,11 @@ use std::path::{Path, PathBuf};
use anyhow::Result;
use crate::asset_snapshot::AssetCollectionSummary;
use crate::artifact_snapshot::ArtifactCollectionSummary;
/// An individual asset file discovered from a run's asset manifests.
/// An individual artifact file discovered from a run's artifact manifests.
#[derive(Debug, Clone, serde::Serialize)]
pub struct AssetEntry {
pub struct ArtifactEntry {
pub node_slug: String,
pub retry: u32,
pub relative_path: String,
@ -19,13 +19,13 @@ fn serialize_path<S: serde::Serializer>(path: &Path, serializer: S) -> Result<S:
serializer.serialize_str(&path.display().to_string())
}
/// Walk `{assets_dir}/*/retry_*/manifest.json`, stat each file, and return entries.
pub fn scan_assets(
assets_dir: &Path,
/// Walk `{artifacts_dir}/*/retry_*/manifest.json`, stat each file, and return entries.
pub fn scan_artifacts(
artifacts_dir: &Path,
node_filter: Option<&str>,
retry_filter: Option<u32>,
) -> Result<Vec<AssetEntry>> {
let Ok(nodes) = std::fs::read_dir(assets_dir) else {
) -> Result<Vec<ArtifactEntry>> {
let Ok(nodes) = std::fs::read_dir(artifacts_dir) else {
return Ok(Vec::new());
};
@ -63,13 +63,13 @@ pub fn scan_assets(
let Ok(contents) = std::fs::read_to_string(&manifest) else {
continue;
};
let Ok(summary) = serde_json::from_str::<AssetCollectionSummary>(&contents) else {
let Ok(summary) = serde_json::from_str::<ArtifactCollectionSummary>(&contents) else {
continue;
};
for asset in &summary.captured_assets {
let absolute_path = retry_dir.join(&asset.path);
entries.push(AssetEntry {
entries.push(ArtifactEntry {
node_slug: node_slug.clone(),
retry,
relative_path: asset.path.clone(),
@ -93,24 +93,24 @@ pub fn scan_assets(
#[cfg(test)]
mod tests {
use super::*;
use crate::asset_snapshot::{AssetCollectionSummary, CapturedAssetInfo};
use crate::artifact_snapshot::{ArtifactCollectionSummary, CapturedArtifactInfo};
#[test]
fn scan_assets_filters_by_node_and_retry() {
fn scan_artifacts_filters_by_node_and_retry() {
let tmp = tempfile::tempdir().unwrap();
let assets_dir = tmp.path().join("cache/artifacts/assets");
let artifacts_dir = tmp.path().join("cache/artifacts/files");
let retry_1 = assets_dir.join("work/retry_1");
let retry_1 = artifacts_dir.join("work/retry_1");
std::fs::create_dir_all(&retry_1).unwrap();
std::fs::write(
retry_1.join("manifest.json"),
serde_json::to_string(&AssetCollectionSummary {
serde_json::to_string(&ArtifactCollectionSummary {
files_copied: 1,
total_bytes: 5,
files_skipped: 0,
download_errors: 0,
hash_errors: 0,
captured_assets: vec![CapturedAssetInfo {
captured_assets: vec![CapturedArtifactInfo {
path: "report.txt".to_string(),
mime: "text/plain".to_string(),
content_md5: "a".repeat(32),
@ -122,17 +122,17 @@ mod tests {
)
.unwrap();
let retry_2 = assets_dir.join("work/retry_2");
let retry_2 = artifacts_dir.join("work/retry_2");
std::fs::create_dir_all(&retry_2).unwrap();
std::fs::write(
retry_2.join("manifest.json"),
serde_json::to_string(&AssetCollectionSummary {
serde_json::to_string(&ArtifactCollectionSummary {
files_copied: 1,
total_bytes: 6,
files_skipped: 0,
download_errors: 0,
hash_errors: 0,
captured_assets: vec![CapturedAssetInfo {
captured_assets: vec![CapturedArtifactInfo {
path: "report.txt".to_string(),
mime: "text/plain".to_string(),
content_md5: "c".repeat(32),
@ -144,7 +144,7 @@ mod tests {
)
.unwrap();
let entries = scan_assets(&assets_dir, Some("work"), Some(2)).unwrap();
let entries = scan_artifacts(&artifacts_dir, Some("work"), Some(2)).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].retry, 2);
assert_eq!(entries[0].relative_path, "report.txt");

View file

@ -365,7 +365,7 @@ pub enum Event {
node: String,
idle_seconds: u64,
},
AssetCaptured {
ArtifactCaptured {
node_id: String,
attempt: u32,
node_slug: String,
@ -864,7 +864,7 @@ impl Event {
Self::StallWatchdogTimeout { node, idle_seconds } => {
warn!(node, idle_seconds, "Stall watchdog timeout");
}
Self::AssetCaptured {
Self::ArtifactCaptured {
node_id,
node_slug,
attempt,
@ -872,7 +872,10 @@ impl Event {
bytes,
..
} => {
debug!(node_id, node_slug, attempt, path, bytes, "Asset captured");
debug!(
node_id,
node_slug, attempt, path, bytes, "Artifact captured"
);
}
Self::SshAccessReady { ssh_command } => {
info!(ssh_command, "SSH access ready");
@ -1146,7 +1149,7 @@ pub fn event_name(event: &Event) -> &'static str {
Event::SetupCompleted { .. } => "setup.completed",
Event::SetupFailed { .. } => "setup.failed",
Event::StallWatchdogTimeout { .. } => "watchdog.timeout",
Event::AssetCaptured { .. } => "asset.captured",
Event::ArtifactCaptured { .. } => "artifact.captured",
Event::SshAccessReady { .. } => "ssh.ready",
Event::Failover { .. } => "agent.failover",
Event::CliEnsureStarted { .. } => "cli.ensure.started",
@ -1264,7 +1267,7 @@ fn extract_run_event_fields(event: &Event) -> StoredEventFields {
| Event::CheckpointFailed { .. }
| Event::SubgraphStarted { .. }
| Event::SubgraphCompleted { .. }
| Event::AssetCaptured { .. }
| Event::ArtifactCaptured { .. }
| Event::PromptCompleted { .. }
| Event::ParallelStarted { .. }
| Event::ParallelCompleted { .. }

View file

@ -111,8 +111,8 @@ pub fn extract_stage_durations_from_events(events: &[EventEnvelope]) -> HashMap<
#[doc(hidden)]
pub mod artifact;
pub mod asset_snapshot;
pub mod assets;
pub mod artifact_snapshot;
pub mod artifacts;
pub(crate) mod condition;
pub mod context;
pub mod devcontainer_bridge;

View file

@ -1,15 +1,17 @@
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use async_trait::async_trait;
use fabro_store::SlateRunStore;
use fabro_core::graph::NodeSpec;
use fabro_core::lifecycle::{AttemptContext, AttemptResultContext, RunLifecycle};
use fabro_core::outcome::NodeResult;
use fabro_core::state::ExecutionState;
use crate::artifact::{ArtifactStore, offload_large_values, sync_artifacts_to_env};
use crate::asset_snapshot::collect_assets;
use crate::artifact::{offload_large_values, sync_artifacts_to_env};
use crate::artifact_snapshot::collect_artifacts;
use crate::event::{Event, EventEmitter, RunNoticeLevel};
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
@ -24,33 +26,36 @@ type WfNodeDecision = NodeDecision<Option<StageUsage>>;
/// Sub-lifecycle responsible for artifact collection, offloading, and syncing.
pub(crate) struct ArtifactLifecycle {
pub sandbox: Arc<dyn fabro_sandbox::Sandbox>,
pub artifact_store: Arc<Mutex<ArtifactStore>>,
pub artifact_values_dir: Option<PathBuf>,
pub run_store: SlateRunStore,
pub blob_cache_dir: PathBuf,
pub emitter: Arc<EventEmitter>,
pub assets_dir: PathBuf,
pub asset_globs: Vec<String>,
pub artifacts_dir: PathBuf,
pub artifact_globs: Vec<String>,
pub captured_artifact_count: Arc<AtomicUsize>,
/// Per-attempt state: epoch seconds when the attempt started.
attempt_start_epoch: Mutex<Option<f64>>,
attempt_start_epoch: std::sync::Mutex<Option<f64>>,
}
impl ArtifactLifecycle {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
sandbox: Arc<dyn fabro_sandbox::Sandbox>,
artifact_store: Arc<Mutex<ArtifactStore>>,
artifact_values_dir: Option<PathBuf>,
run_store: SlateRunStore,
blob_cache_dir: PathBuf,
emitter: Arc<EventEmitter>,
assets_dir: PathBuf,
asset_globs: Vec<String>,
artifacts_dir: PathBuf,
artifact_globs: Vec<String>,
captured_artifact_count: Arc<AtomicUsize>,
) -> Self {
Self {
sandbox,
artifact_store,
artifact_values_dir,
run_store,
blob_cache_dir,
emitter,
assets_dir,
asset_globs,
attempt_start_epoch: Mutex::new(None),
artifacts_dir,
artifact_globs,
captured_artifact_count,
attempt_start_epoch: std::sync::Mutex::new(None),
}
}
}
@ -58,9 +63,7 @@ impl ArtifactLifecycle {
#[async_trait]
impl RunLifecycle<WorkflowGraph> for ArtifactLifecycle {
async fn on_run_start(&self, _graph: &WorkflowGraph, _state: &WfRunState) -> CoreResult<()> {
// Swap in a fresh artifact store on restart (don't call clear() — preserves files on disk)
let mut store = self.artifact_store.lock().unwrap();
*store = ArtifactStore::new(self.artifact_values_dir.clone());
self.captured_artifact_count.store(0, Ordering::Relaxed);
*self.attempt_start_epoch.lock().unwrap() = None;
Ok(())
}
@ -84,7 +87,7 @@ impl RunLifecycle<WorkflowGraph> for ArtifactLifecycle {
ctx: &AttemptResultContext<'_, WorkflowGraph>,
state: &WfRunState,
) -> CoreResult<()> {
if self.asset_globs.is_empty() {
if self.artifact_globs.is_empty() {
return Ok(());
}
let epoch = self.attempt_start_epoch.lock().unwrap().unwrap_or(0.0);
@ -95,16 +98,24 @@ impl RunLifecycle<WorkflowGraph> for ArtifactLifecycle {
} else {
format!("{node_id}-visit_{visit}")
};
let asset_capture_dir = self
.assets_dir
let artifact_capture_dir = self
.artifacts_dir
.join(&node_slug)
.join(format!("retry_{}", ctx.attempt));
let _ = std::fs::create_dir_all(&asset_capture_dir);
let _ = std::fs::create_dir_all(&artifact_capture_dir);
match collect_assets(&*self.sandbox, &asset_capture_dir, &self.asset_globs, epoch).await {
match collect_artifacts(
&*self.sandbox,
&artifact_capture_dir,
&self.artifact_globs,
epoch,
)
.await
{
Ok(summary) if summary.files_copied > 0 => {
for asset in &summary.captured_assets {
self.emitter.emit(&Event::AssetCaptured {
self.captured_artifact_count.fetch_add(1, Ordering::Relaxed);
self.emitter.emit(&Event::ArtifactCaptured {
node_id: node_id.to_string(),
attempt: ctx.attempt,
node_slug: node_slug.clone(),
@ -120,8 +131,8 @@ impl RunLifecycle<WorkflowGraph> for ArtifactLifecycle {
Err(e) => {
self.emitter.emit(&Event::RunNotice {
level: RunNoticeLevel::Warn,
code: "asset_collection_failed".to_string(),
message: format!("[node: {node_id}] asset collection failed: {e}"),
code: "artifact_collection_failed".to_string(),
message: format!("[node: {node_id}] artifact collection failed: {e}"),
});
}
}
@ -138,15 +149,18 @@ impl RunLifecycle<WorkflowGraph> for ArtifactLifecycle {
let node_id = node.id();
// Offload large context_updates values to artifact store
if let Err(e) = offload_large_values(
&mut result.outcome.context_updates,
&self.run_store,
&self.blob_cache_dir,
)
.await
{
let store = self.artifact_store.lock().unwrap();
if let Err(e) = offload_large_values(&mut result.outcome.context_updates, &store) {
self.emitter.emit(&Event::RunNotice {
level: RunNoticeLevel::Warn,
code: "artifact_offload_failed".to_string(),
message: format!("[node: {node_id}] artifact offload failed: {e}"),
});
}
self.emitter.emit(&Event::RunNotice {
level: RunNoticeLevel::Warn,
code: "artifact_offload_failed".to_string(),
message: format!("[node: {node_id}] artifact offload failed: {e}"),
});
}
// Sync file-backed artifacts to sandbox environment

View file

@ -1,4 +1,5 @@
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
@ -14,7 +15,6 @@ use fabro_core::state::ExecutionState;
use super::circuit_breaker::CircuitBreakerLifecycle;
use super::git::GitCheckpointResult;
use crate::artifact::ArtifactStore;
use crate::context;
use crate::error::FabroError;
use crate::event::{Event, EventEmitter};
@ -46,8 +46,7 @@ pub(crate) struct EventLifecycle {
pub run_branch: Option<String>,
pub worktree_dir: Option<String>,
pub goal: Option<String>,
// Shared swappable handle (same instance as orchestrator)
pub artifact_store: Arc<Mutex<ArtifactStore>>,
pub captured_artifact_count: Arc<AtomicUsize>,
// Cross-lifecycle data
pub checkpoint_git_result: Arc<Mutex<Option<GitCheckpointResult>>>,
pub last_git_sha: Arc<Mutex<Option<String>>>,
@ -385,7 +384,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
}
let duration_ms =
u64::try_from(self.run_start.lock().unwrap().elapsed().as_millis()).unwrap();
let artifact_count = self.artifact_store.lock().unwrap().list().len();
let artifact_count = self.captured_artifact_count.load(Ordering::Relaxed);
let last_sha = self.last_git_sha.lock().unwrap().clone();
let final_patch = self.final_patch.lock().unwrap().clone();
let total_cost = {

View file

@ -13,7 +13,6 @@ use fabro_core::lifecycle::RunLifecycle;
use fabro_core::outcome::NodeResult;
use fabro_core::state::ExecutionState;
use crate::artifact::ArtifactStore;
use crate::event::{Event, EventEmitter, RunNoticeLevel};
use crate::git::MetadataStore;
use crate::graph::WorkflowGraph;
@ -64,7 +63,6 @@ pub(crate) struct GitCheckpointResult {
/// Sub-lifecycle responsible for git operations (checkpoint commits, pushes, diffs).
pub(crate) struct GitLifecycle {
pub sandbox: Arc<dyn fabro_sandbox::Sandbox>,
pub artifact_store: Arc<Mutex<ArtifactStore>>,
pub emitter: Arc<EventEmitter>,
pub run_dir: PathBuf,
pub run_id: RunId,
@ -152,20 +150,7 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
None,
);
if let Ok(cp_json) = serde_json::to_vec_pretty(&checkpoint) {
let mut extra_entries: Vec<(String, Vec<u8>)> = {
let artifact_store = self.artifact_store.lock().unwrap();
artifact_store
.list()
.iter()
.filter_map(|info| {
info.file_path.as_ref().and_then(|path| {
std::fs::read(path)
.ok()
.map(|data| (format!("artifacts/{}.json", info.id), data))
})
})
.collect()
};
let mut extra_entries: Vec<(String, Vec<u8>)> = Vec::new();
if let Ok(store_state) = self.run_store.state().await {
if let Ok(mut dump_entries) =
RunDump::metadata_checkpoint(&store_state).git_entries()

View file

@ -8,7 +8,7 @@ pub(crate) mod hook;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
@ -25,7 +25,6 @@ use fabro_core::lifecycle::{
use fabro_core::outcome::NodeResult;
use fabro_core::state::ExecutionState;
use crate::artifact::ArtifactStore;
use crate::context;
use crate::error::{FailureSignature, FailureSignatureExt};
use crate::event::EventEmitter;
@ -94,9 +93,7 @@ impl WorkflowLifecycle {
Arc::new(Mutex::new(None));
let last_git_sha: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let final_patch: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let artifact_store = Arc::new(Mutex::new(ArtifactStore::new(Some(
runtime_state.artifact_values_dir(),
))));
let captured_artifact_count = Arc::new(AtomicUsize::new(0));
let circuit_breaker = Arc::new(CircuitBreakerLifecycle::new(loop_restart_signature_limit));
@ -123,7 +120,7 @@ impl WorkflowLifecycle {
run_branch: run_options.git.as_ref().and_then(|g| g.run_branch.clone()),
worktree_dir: working_directory.clone(),
goal: (!graph.goal().is_empty()).then(|| graph.goal().to_string()),
artifact_store: Arc::clone(&artifact_store),
captured_artifact_count: Arc::clone(&captured_artifact_count),
last_git_sha: Arc::clone(&last_git_sha),
final_patch: Arc::clone(&final_patch),
checkpoint_git_result: Arc::clone(&checkpoint_git_result),
@ -144,11 +141,10 @@ impl WorkflowLifecycle {
let git = GitLifecycle {
sandbox: Arc::clone(sandbox),
artifact_store: Arc::clone(&artifact_store),
emitter: Arc::clone(emitter),
run_dir: run_dir.clone(),
run_id: run_options.run_id,
run_store,
run_store: run_store.clone(),
run_options: Arc::clone(run_options),
start_node_id,
checkpoint_git_result: Arc::clone(&checkpoint_git_result),
@ -158,11 +154,12 @@ impl WorkflowLifecycle {
let artifact = ArtifactLifecycle::new(
Arc::clone(sandbox),
Arc::clone(&artifact_store),
Some(runtime_state.artifact_values_dir()),
run_store.clone(),
runtime_state.blob_cache_dir(),
Arc::clone(emitter),
runtime_state.assets_dir(),
run_options.asset_globs().to_vec(),
runtime_state.artifacts_dir(),
run_options.artifact_globs().to_vec(),
captured_artifact_count,
);
Self {

View file

@ -218,11 +218,11 @@ impl RunDump {
));
}
for asset in run_store.list_all_assets().await? {
for asset in run_store.list_all_artifacts().await? {
let node_id_segment = validate_single_path_segment("node id", asset.node.node_id())?;
let filename_path = validate_relative_path("asset filename", &asset.filename)?;
let filename_path = validate_relative_path("artifact filename", &asset.filename)?;
let data = run_store
.get_asset(&asset.node, &asset.filename)
.get_artifact(&asset.node, &asset.filename)
.await?
.with_context(|| {
format!(

View file

@ -58,9 +58,9 @@ impl RunOptions {
self.settings.pull_request.as_ref()
}
pub fn asset_globs(&self) -> &[String] {
pub fn artifact_globs(&self) -> &[String] {
self.settings
.assets
.artifacts
.as_ref()
.map_or(&[], |a| a.include.as_slice())
}

View file

@ -5,7 +5,7 @@ use fabro_checkpoint::trailer as trailerlink;
use fabro_checkpoint::trailer::Trailer;
use fabro_types::RunId;
use crate::asset_snapshot;
use crate::artifact_snapshot;
use crate::git::{GitAuthor, blocking_push_with_timeout, push_ref};
use fabro_sandbox::daytona::detect_repo_info;
@ -42,7 +42,7 @@ pub async fn git_checkpoint(
exclude_globs: &[String],
author: &GitAuthor,
) -> std::result::Result<String, String> {
let mut all_excludes: Vec<String> = asset_snapshot::EXCLUDE_DIRS
let mut all_excludes: Vec<String> = artifact_snapshot::EXCLUDE_DIRS
.iter()
.map(|d| format!("**/{d}/**"))
.collect();

View file

@ -1243,10 +1243,10 @@ async fn daytona_git_checkpoint_with_shadow_branch() {
}
// ---------------------------------------------------------------------------
// Asset collection e2e — Daytona sandbox
// Artifact collection e2e — Daytona sandbox
// ---------------------------------------------------------------------------
/// Handler that creates asset files via exec_command on the sandbox.
/// Handler that creates artifact files via exec_command on the sandbox.
struct AssetCreatorHandler;
#[async_trait::async_trait]
@ -1273,7 +1273,7 @@ impl Handler for AssetCreatorHandler {
}
}
/// Daytona sandbox: asset collection discovers files on the remote sandbox and
/// Daytona sandbox: artifact collection discovers files on the remote sandbox and
/// downloads them to the local logs directory.
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
async fn daytona_asset_collection() {
@ -1292,7 +1292,7 @@ async fn daytona_asset_collection() {
let mut graph = Graph::new("DaytonaAssetTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test asset collection on Daytona".to_string()),
AttrValue::String("Test artifact collection on Daytona".to_string()),
);
let mut start = Node::new("start");
@ -1323,14 +1323,14 @@ async fn daytona_asset_collection() {
let run_options = RunOptions {
settings: Settings {
assets: Some(fabro_config::run::AssetsSettings {
artifacts: Some(fabro_config::run::ArtifactsSettings {
include: vec!["test-results/**".to_string()],
}),
..Settings::default()
},
run_dir: dir.path().to_path_buf(),
cancel_token: None,
run_id: test_run_id("asset-test-daytona"),
run_id: test_run_id("artifact-test-daytona"),
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
@ -1345,9 +1345,9 @@ async fn daytona_asset_collection() {
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let assets_dir = RuntimeState::new(dir.path()).asset_stage_dir("create_assets", 1);
let artifacts_dir = RuntimeState::new(dir.path()).artifact_stage_dir("create_assets", 1);
let report_path = assets_dir.join("test-results/report.xml");
let report_path = artifacts_dir.join("test-results/report.xml");
assert!(
report_path.exists(),
"report.xml should be collected from Daytona sandbox at {}",
@ -1356,7 +1356,7 @@ async fn daytona_asset_collection() {
let content = std::fs::read_to_string(&report_path).unwrap();
assert!(content.contains("testsuites"));
let manifest_path = assets_dir.join("manifest.json");
let manifest_path = artifacts_dir.join("manifest.json");
assert!(manifest_path.exists(), "manifest.json should exist");
env.cleanup().await.unwrap();

View file

@ -8569,8 +8569,16 @@ async fn large_context_values_are_offloaded_to_artifact_store() {
"value should be an artifact pointer, got: {pointer_str}"
);
let expected_blob_id = fabro_types::RunBlobId::new(
&run_options.run_id,
&serde_json::to_vec(&serde_json::json!("x".repeat(150 * 1024)))
.expect("large value should serialize"),
);
// The artifact file should exist on disk
let artifact_file = RuntimeState::new(dir.path()).artifact_value_path("response.big_output");
let artifact_file = RuntimeState::new(dir.path())
.artifact_values_dir()
.join(format!("{expected_blob_id}.json"));
assert!(
artifact_file.exists(),
"artifact file should exist at {artifact_file:?}"
@ -8588,7 +8596,7 @@ async fn large_context_values_are_offloaded_to_artifact_store() {
"artifact should contain the original 150KB value"
);
// WorkflowRunCompleted event should report artifact_count > 0
// WorkflowRunCompleted artifact_count now tracks captured artifacts, not offloaded values.
let evts = events.lock().unwrap();
let completed_event = evts
.iter()
@ -8597,9 +8605,9 @@ async fn large_context_values_are_offloaded_to_artifact_store() {
let artifact_count = completed_event.properties["artifact_count"]
.as_u64()
.expect("run.completed should include artifact_count");
assert!(
artifact_count > 0,
"artifact_count should be > 0, got {artifact_count}"
assert_eq!(
artifact_count, 0,
"artifact_count should ignore offloaded values"
);
}
@ -12294,10 +12302,10 @@ async fn e2e_stall_watchdog_with_explicit_timeout_override() {
// Daytona parallel git branching test is in daytona_integration.rs
// ---------------------------------------------------------------------------
// Asset collection e2e tests
// Artifact collection e2e tests
// ---------------------------------------------------------------------------
/// Handler that creates asset files in the sandbox working directory via exec_command.
/// Handler that creates artifact files in the sandbox working directory via exec_command.
struct AssetCreatorHandler {
should_fail: bool,
}
@ -12322,7 +12330,7 @@ impl Handler for AssetCreatorHandler {
_run_dir: &Path,
services: &fabro_workflow::handler::EngineServices,
) -> Result<Outcome, FabroError> {
// Create asset files via the sandbox's exec_command
// Create artifact files via the sandbox's exec_command
let script = concat!(
"mkdir -p test-results && ",
"echo '<testsuites><testsuite name=\"example\"/></testsuites>' > test-results/report.xml && ",
@ -12342,7 +12350,7 @@ impl Handler for AssetCreatorHandler {
}
}
/// Local sandbox: asset collection discovers and downloads files created by a handler.
/// Local sandbox: artifact collection discovers and downloads files created by a handler.
#[tokio::test]
async fn asset_collection_local_sandbox_success() {
let work_dir = tempfile::tempdir().unwrap();
@ -12365,7 +12373,7 @@ async fn asset_collection_local_sandbox_success() {
let mut graph = Graph::new("AssetCollectionTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test asset collection".to_string()),
AttrValue::String("Test artifact collection".to_string()),
);
let mut start = Node::new("start");
@ -12396,14 +12404,14 @@ async fn asset_collection_local_sandbox_success() {
let run_options = RunOptions {
settings: Settings {
assets: Some(fabro_config::run::AssetsSettings {
artifacts: Some(fabro_config::run::ArtifactsSettings {
include: vec!["test-results/**".to_string()],
}),
..Settings::default()
},
run_dir: run_dir.path().to_path_buf(),
cancel_token: None,
run_id: test_run_id("asset-test-local"),
run_id: test_run_id("artifact-test-local"),
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
@ -12418,10 +12426,10 @@ async fn asset_collection_local_sandbox_success() {
.expect("run should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// Check that asset files were collected into the stage directory
let assets_dir = RuntimeState::new(run_dir.path()).asset_stage_dir("create_assets", 1);
// Check that artifact files were collected into the stage directory
let artifacts_dir = RuntimeState::new(run_dir.path()).artifact_stage_dir("create_assets", 1);
let report_path = assets_dir.join("test-results/report.xml");
let report_path = artifacts_dir.join("test-results/report.xml");
assert!(
report_path.exists(),
"report.xml should be collected at {}",
@ -12431,7 +12439,7 @@ async fn asset_collection_local_sandbox_success() {
assert!(report_content.contains("testsuites"));
// Check manifest.json was written
let manifest_path = assets_dir.join("manifest.json");
let manifest_path = artifacts_dir.join("manifest.json");
assert!(
manifest_path.exists(),
"manifest.json should exist at {}",
@ -12441,15 +12449,15 @@ async fn asset_collection_local_sandbox_success() {
serde_json::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
assert!(manifest["files_copied"].as_u64().unwrap() >= 1);
// Check that AssetCaptured events were emitted
// Check that ArtifactCaptured events were emitted
let captured_events = events.lock().unwrap();
let asset_events: Vec<&RunEvent> = captured_events
.iter()
.filter(|e| e.event == "asset.captured")
.filter(|e| e.event == "artifact.captured")
.collect();
assert!(
!asset_events.is_empty(),
"should emit at least one AssetCaptured event"
"should emit at least one ArtifactCaptured event"
);
let asset_event = asset_events[0];
assert!(!asset_event.properties["path"].as_str().unwrap().is_empty());
@ -12492,7 +12500,7 @@ async fn asset_collection_local_sandbox_on_failure() {
let mut graph = Graph::new("AssetCollectionFailTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test asset collection on failure".to_string()),
AttrValue::String("Test artifact collection on failure".to_string()),
);
let mut start = Node::new("start");
@ -12523,14 +12531,14 @@ async fn asset_collection_local_sandbox_on_failure() {
let run_options = RunOptions {
settings: Settings {
assets: Some(fabro_config::run::AssetsSettings {
artifacts: Some(fabro_config::run::ArtifactsSettings {
include: vec!["test-results/**".to_string()],
}),
..Settings::default()
},
run_dir: run_dir.path().to_path_buf(),
cancel_token: None,
run_id: test_run_id("asset-test-fail"),
run_id: test_run_id("artifact-test-fail"),
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
@ -12547,9 +12555,9 @@ async fn asset_collection_local_sandbox_on_failure() {
// Assets should still be collected regardless of intermediate node failures.
assert_eq!(outcome.status, StageStatus::Success);
let assets_dir = RuntimeState::new(run_dir.path()).asset_stage_dir("create_assets", 1);
let artifacts_dir = RuntimeState::new(run_dir.path()).artifact_stage_dir("create_assets", 1);
let report_path = assets_dir.join("test-results/report.xml");
let report_path = artifacts_dir.join("test-results/report.xml");
assert!(
report_path.exists(),
"report.xml should still be collected after handler failure, at {}",
@ -12557,7 +12565,7 @@ async fn asset_collection_local_sandbox_on_failure() {
);
}
/// Docker sandbox: asset collection works across the bind-mount boundary.
/// Docker sandbox: artifact collection works across the bind-mount boundary.
/// Requires Docker with `fabro-agent:latest` image available locally.
#[tokio::test]
#[ignore]
@ -12583,7 +12591,7 @@ async fn asset_collection_docker_sandbox() {
let mut graph = Graph::new("DockerAssetTest");
graph.attrs.insert(
"goal".to_string(),
AttrValue::String("Test asset collection in Docker".to_string()),
AttrValue::String("Test artifact collection in Docker".to_string()),
);
let mut start = Node::new("start");
@ -12614,14 +12622,14 @@ async fn asset_collection_docker_sandbox() {
let run_options = RunOptions {
settings: Settings {
assets: Some(fabro_config::run::AssetsSettings {
artifacts: Some(fabro_config::run::ArtifactsSettings {
include: vec!["test-results/**".to_string()],
}),
..Settings::default()
},
run_dir: run_dir.path().to_path_buf(),
cancel_token: None,
run_id: test_run_id("asset-test-docker"),
run_id: test_run_id("artifact-test-docker"),
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
@ -12636,9 +12644,9 @@ async fn asset_collection_docker_sandbox() {
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
let assets_dir = RuntimeState::new(run_dir.path()).asset_stage_dir("create_assets", 1);
let artifacts_dir = RuntimeState::new(run_dir.path()).artifact_stage_dir("create_assets", 1);
let report_path = assets_dir.join("test-results/report.xml");
let report_path = artifacts_dir.join("test-results/report.xml");
assert!(
report_path.exists(),
"report.xml should be collected from Docker container at {}",
@ -12647,7 +12655,7 @@ async fn asset_collection_docker_sandbox() {
let content = std::fs::read_to_string(&report_path).unwrap();
assert!(content.contains("testsuites"));
let manifest_path = assets_dir.join("manifest.json");
let manifest_path = artifacts_dir.join("manifest.json");
assert!(manifest_path.exists(), "manifest.json should exist");
sandbox.cleanup().await.unwrap();

View file

@ -22,7 +22,7 @@ models/aggregate-usage.ts
models/api-question-option.ts
models/api-question.ts
models/api-settings.ts
models/assets-settings.ts
models/artifacts-settings.ts
models/assistant-stage-turn.ts
models/assistant-turn.ts
models/auth-settings.ts
@ -54,7 +54,6 @@ models/diff-file.ts
models/diff-stats.ts
models/error-response-entry.ts
models/error-response.ts
models/exe-settings.ts
models/execute-query-request.ts
models/execute-query-response-rows-inner-inner.ts
models/execute-query-response.ts
@ -141,7 +140,6 @@ models/sibling-control.ts
models/signoff-status.ts
models/signoff.ts
models/smoothness-rating.ts
models/ssh-settings.ts
models/stage-retro.ts
models/stage-status.ts
models/stage-turn.ts

View file

@ -1 +1 @@
7.21.0
7.20.0

View file

@ -34,7 +34,7 @@ export interface ApiSettings {
export const ApiSettingsAuthenticationStrategiesEnum = {
JWT: 'jwt',
MTLS: 'mtls',
MTLS: 'mtls'
} as const;
export type ApiSettingsAuthenticationStrategiesEnum = typeof ApiSettingsAuthenticationStrategiesEnum[keyof typeof ApiSettingsAuthenticationStrategiesEnum];

View file

@ -15,11 +15,11 @@
/**
* Asset collection configuration.
* Artifact collection configuration.
*/
export interface AssetsSettings {
export interface ArtifactsSettings {
/**
* Glob patterns for files to collect as run assets.
* Glob patterns for files to collect as run artifacts.
*/
'include'?: Array<string>;
}

View file

@ -15,11 +15,11 @@
/**
* Asset collection configuration.
* Artifact collection configuration.
*/
export interface AssetsSettings {
export interface ArtifactsSettings {
/**
* Glob patterns for files to collect as run assets.
* Glob patterns for files to collect as run artifacts.
*/
'include'?: Array<string>;
}

View file

@ -26,7 +26,7 @@ export interface AssistantStageTurn {
}
export const AssistantStageTurnKindEnum = {
ASSISTANT: 'assistant',
ASSISTANT: 'assistant'
} as const;
export type AssistantStageTurnKindEnum = typeof AssistantStageTurnKindEnum[keyof typeof AssistantStageTurnKindEnum];

View file

@ -30,7 +30,7 @@ export interface AssistantTurn {
}
export const AssistantTurnKindEnum = {
ASSISTANT: 'assistant',
ASSISTANT: 'assistant'
} as const;
export type AssistantTurnKindEnum = typeof AssistantTurnKindEnum[keyof typeof AssistantTurnKindEnum];

View file

@ -30,7 +30,7 @@ export interface AuthSettings {
export const AuthSettingsProviderEnum = {
GITHUB: 'github',
INSECURE_DISABLED: 'insecure_disabled',
INSECURE_DISABLED: 'insecure_disabled'
} as const;
export type AuthSettingsProviderEnum = typeof AuthSettingsProviderEnum[keyof typeof AuthSettingsProviderEnum];

View file

@ -22,7 +22,7 @@ export const BoardColumn = {
WORKING: 'working',
PENDING: 'pending',
REVIEW: 'review',
MERGE: 'merge',
MERGE: 'merge'
} as const;
export type BoardColumn = typeof BoardColumn[keyof typeof BoardColumn];

View file

@ -23,7 +23,7 @@ export const CheckRunStatus = {
FAILURE: 'failure',
SKIPPED: 'skipped',
PENDING: 'pending',
QUEUED: 'queued',
QUEUED: 'queued'
} as const;
export type CheckRunStatus = typeof CheckRunStatus[keyof typeof CheckRunStatus];

View file

@ -44,7 +44,7 @@ export const CompletionMessageRoleEnum = {
USER: 'user',
ASSISTANT: 'assistant',
TOOL: 'tool',
DEVELOPER: 'developer',
DEVELOPER: 'developer'
} as const;
export type CompletionMessageRoleEnum = typeof CompletionMessageRoleEnum[keyof typeof CompletionMessageRoleEnum];

View file

@ -32,7 +32,7 @@ export const CompletionToolChoiceModeEnum = {
AUTO: 'auto',
NONE: 'none',
REQUIRED: 'required',
NAMED: 'named',
NAMED: 'named'
} as const;
export type CompletionToolChoiceModeEnum = typeof CompletionToolChoiceModeEnum[keyof typeof CompletionToolChoiceModeEnum];

View file

@ -23,7 +23,7 @@ export const FrictionKind = {
TIMEOUT: 'timeout',
WRONG_APPROACH: 'wrong_approach',
TOOL_FAILURE: 'tool_failure',
AMBIGUITY: 'ambiguity',
AMBIGUITY: 'ambiguity'
} as const;
export type FrictionKind = typeof FrictionKind[keyof typeof FrictionKind];

View file

@ -45,7 +45,7 @@ export interface GitSettings {
}
export const GitSettingsProviderEnum = {
GITHUB: 'github',
GITHUB: 'github'
} as const;
export type GitSettingsProviderEnum = typeof GitSettingsProviderEnum[keyof typeof GitSettingsProviderEnum];

View file

@ -84,7 +84,7 @@ export const HookDefinitionEventEnum = {
RUN_START: 'run_start',
RUN_COMPLETE: 'run_complete',
STAGE_START: 'stage_start',
STAGE_COMPLETE: 'stage_complete',
STAGE_COMPLETE: 'stage_complete'
} as const;
export type HookDefinitionEventEnum = typeof HookDefinitionEventEnum[keyof typeof HookDefinitionEventEnum];
@ -92,14 +92,14 @@ export const HookDefinitionTypeEnum = {
COMMAND: 'command',
HTTP: 'http',
PROMPT: 'prompt',
AGENT: 'agent',
AGENT: 'agent'
} as const;
export type HookDefinitionTypeEnum = typeof HookDefinitionTypeEnum[keyof typeof HookDefinitionTypeEnum];
export const HookDefinitionTlsEnum = {
VERIFY: 'verify',
NO_VERIFY: 'no_verify',
OFF: 'off',
OFF: 'off'
} as const;
export type HookDefinitionTlsEnum = typeof HookDefinitionTlsEnum[keyof typeof HookDefinitionTlsEnum];

View file

@ -3,7 +3,7 @@ export * from './aggregate-usage-totals';
export * from './api-question';
export * from './api-question-option';
export * from './api-settings';
export * from './assets-settings';
export * from './artifacts-settings';
export * from './assistant-stage-turn';
export * from './assistant-turn';
export * from './auth-settings';
@ -35,7 +35,6 @@ export * from './diff-file';
export * from './diff-stats';
export * from './error-response';
export * from './error-response-entry';
export * from './exe-settings';
export * from './execute-query-request';
export * from './execute-query-response';
export * from './execute-query-response-rows-inner-inner';
@ -121,7 +120,6 @@ export * from './sibling-control';
export * from './signoff';
export * from './signoff-status';
export * from './smoothness-rating';
export * from './ssh-settings';
export * from './stage-retro';
export * from './stage-status';
export * from './stage-turn';

View file

@ -22,7 +22,7 @@ export const LearningCategory = {
REPO: 'repo',
CODE: 'code',
WORKFLOW: 'workflow',
TOOL: 'tool',
TOOL: 'tool'
} as const;
export type LearningCategory = typeof LearningCategory[keyof typeof LearningCategory];

View file

@ -28,7 +28,7 @@ export const LocalSandboxSettingsWorktreeModeEnum = {
ALWAYS: 'always',
CLEAN: 'clean',
DIRTY: 'dirty',
NEVER: 'never',
NEVER: 'never'
} as const;
export type LocalSandboxSettingsWorktreeModeEnum = typeof LocalSandboxSettingsWorktreeModeEnum[keyof typeof LocalSandboxSettingsWorktreeModeEnum];

View file

@ -34,7 +34,7 @@ export interface ModelTestResult {
export const ModelTestResultStatusEnum = {
OK: 'ok',
ERROR: 'error',
ERROR: 'error'
} as const;
export type ModelTestResultStatusEnum = typeof ModelTestResultStatusEnum[keyof typeof ModelTestResultStatusEnum];

View file

@ -22,7 +22,7 @@ export const OpenItemKind = {
TECH_DEBT: 'tech_debt',
FOLLOW_UP: 'follow_up',
INVESTIGATION: 'investigation',
TEST_GAP: 'test_gap',
TEST_GAP: 'test_gap'
} as const;
export type OpenItemKind = typeof OpenItemKind[keyof typeof OpenItemKind];

View file

@ -39,7 +39,7 @@ export interface PullRequestSettings {
export const PullRequestSettingsMergeStrategyEnum = {
SQUASH: 'squash',
MERGE: 'merge',
REBASE: 'rebase',
REBASE: 'rebase'
} as const;
export type PullRequestSettingsMergeStrategyEnum = typeof PullRequestSettingsMergeStrategyEnum[keyof typeof PullRequestSettingsMergeStrategyEnum];

View file

@ -23,7 +23,7 @@ export const QuestionType = {
MULTIPLE_CHOICE: 'multiple_choice',
MULTI_SELECT: 'multi_select',
FREEFORM: 'freeform',
CONFIRMATION: 'confirmation',
CONFIRMATION: 'confirmation'
} as const;
export type QuestionType = typeof QuestionType[keyof typeof QuestionType];

View file

@ -27,7 +27,7 @@ import type { SandboxSettings } from './sandbox-settings';
import type { SetupSettings } from './setup-settings';
/**
* Structured run settings mirroring FabroSettings.
* Structured run settings mirroring fabro_types::Settings.
*/
export interface RunSettings {
/**

View file

@ -25,7 +25,7 @@ export const RunStatus = {
COMPLETED: 'completed',
FAILED: 'failed',
CANCELLED: 'cancelled',
PAUSED: 'paused',
PAUSED: 'paused'
} as const;
export type RunStatus = typeof RunStatus[keyof typeof RunStatus];

View file

@ -18,13 +18,7 @@
import type { DaytonaSettings } from './daytona-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { ExeSettings } from './exe-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { LocalSandboxSettings } from './local-sandbox-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { SshSettings } from './ssh-settings';
/**
* Sandbox execution environment settings.
@ -43,8 +37,6 @@ export interface SandboxSettings {
*/
'devcontainer'?: boolean;
'daytona'?: DaytonaSettings;
'exe'?: ExeSettings;
'ssh'?: SshSettings;
'local'?: LocalSandboxSettings;
/**
* Environment variables injected into the sandbox.

View file

@ -18,7 +18,7 @@
import type { ApiSettings } from './api-configuration';
// May contain unused imports in some cases
// @ts-ignore
import type { AssetsSettings } from './assets-configuration';
import type { ArtifactsSettings } from './artifacts-configuration';
// May contain unused imports in some cases
// @ts-ignore
import type { CheckpointSettings } from './checkpoint-configuration';
@ -87,7 +87,7 @@ export interface ServerSettings {
'checkpoint'?: CheckpointSettings;
'pull_request'?: PullRequestSettings;
'hooks'?: Array<HookDefinition>;
'assets'?: AssetsSettings;
'artifacts'?: ArtifactsSettings;
/**
* Default MCP server configurations.
*/

View file

@ -18,7 +18,7 @@
import type { ApiSettings } from './api-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { AssetsSettings } from './assets-settings';
import type { ArtifactsSettings } from './artifacts-settings';
// May contain unused imports in some cases
// @ts-ignore
import type { CheckpointSettings } from './checkpoint-settings';
@ -57,7 +57,7 @@ import type { SetupSettings } from './setup-settings';
import type { WebSettings } from './web-settings';
/**
* Structured server settings mirroring FabroSettings.
* Structured server settings mirroring fabro_types::Settings.
*/
export interface ServerSettings {
/**
@ -87,7 +87,7 @@ export interface ServerSettings {
'checkpoint'?: CheckpointSettings;
'pull_request'?: PullRequestSettings;
'hooks'?: Array<HookDefinition>;
'assets'?: AssetsSettings;
'artifacts'?: ArtifactsSettings;
/**
* Default MCP server configurations.
*/

View file

@ -21,7 +21,7 @@
export const SignoffStatus = {
PASS: 'pass',
FAIL: 'fail',
PENDING: 'pending',
PENDING: 'pending'
} as const;
export type SignoffStatus = typeof SignoffStatus[keyof typeof SignoffStatus];

View file

@ -23,7 +23,7 @@ export const SmoothnessRating = {
SMOOTH: 'smooth',
BUMPY: 'bumpy',
STRUGGLED: 'struggled',
FAILED: 'failed',
FAILED: 'failed'
} as const;
export type SmoothnessRating = typeof SmoothnessRating[keyof typeof SmoothnessRating];

View file

@ -23,7 +23,7 @@ export const StageStatus = {
RUNNING: 'running',
PENDING: 'pending',
FAILED: 'failed',
CANCELLED: 'cancelled',
CANCELLED: 'cancelled'
} as const;
export type StageStatus = typeof StageStatus[keyof typeof StageStatus];

View file

@ -26,7 +26,7 @@ export interface SystemStageTurn {
}
export const SystemStageTurnKindEnum = {
SYSTEM: 'system',
SYSTEM: 'system'
} as const;
export type SystemStageTurnKindEnum = typeof SystemStageTurnKindEnum[keyof typeof SystemStageTurnKindEnum];

View file

@ -33,7 +33,7 @@ export interface ToolStageTurn {
}
export const ToolStageTurnKindEnum = {
TOOL: 'tool',
TOOL: 'tool'
} as const;
export type ToolStageTurnKindEnum = typeof ToolStageTurnKindEnum[keyof typeof ToolStageTurnKindEnum];

View file

@ -33,7 +33,7 @@ export interface ToolTurn {
}
export const ToolTurnKindEnum = {
TOOL: 'tool',
TOOL: 'tool'
} as const;
export type ToolTurnKindEnum = typeof ToolTurnKindEnum[keyof typeof ToolTurnKindEnum];

View file

@ -30,7 +30,7 @@ export interface UserTurn {
}
export const UserTurnKindEnum = {
USER: 'user',
USER: 'user'
} as const;
export type UserTurnKindEnum = typeof UserTurnKindEnum[keyof typeof UserTurnKindEnum];

View file

@ -21,7 +21,7 @@
export const VerificationMode = {
ACTIVE: 'active',
EVALUATE: 'evaluate',
DISABLED: 'disabled',
DISABLED: 'disabled'
} as const;
export type VerificationMode = typeof VerificationMode[keyof typeof VerificationMode];

View file

@ -22,7 +22,7 @@ export const VerificationResult = {
PASS: 'pass',
FAIL: 'fail',
SKIP: 'skip',
NA: 'na',
NA: 'na'
} as const;
export type VerificationResult = typeof VerificationResult[keyof typeof VerificationResult];

View file

@ -22,7 +22,7 @@ export const VerificationType = {
AI: 'ai',
AUTOMATED: 'automated',
ANALYSIS: 'analysis',
AI_ANALYSIS: 'ai-analysis',
AI_ANALYSIS: 'ai-analysis'
} as const;
export type VerificationType = typeof VerificationType[keyof typeof VerificationType];

View file

@ -25,7 +25,7 @@ export interface WebhookSettings {
}
export const WebhookSettingsStrategyEnum = {
TAILSCALE_FUNNEL: 'tailscale_funnel',
TAILSCALE_FUNNEL: 'tailscale_funnel'
} as const;
export type WebhookSettingsStrategyEnum = typeof WebhookSettingsStrategyEnum[keyof typeof WebhookSettingsStrategyEnum];