diff --git a/Cargo.lock b/Cargo.lock index 9e2aa4c6c..e69c6af61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1555,6 +1555,7 @@ dependencies = [ "fabro-openai-oauth", "fabro-retro", "fabro-sandbox", + "fabro-store", "fabro-telemetry", "fabro-types", "fabro-util", @@ -1881,6 +1882,7 @@ dependencies = [ "serde", "serde_json", "slatedb", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -2000,6 +2002,7 @@ dependencies = [ "fabro-model", "fabro-retro", "fabro-sandbox", + "fabro-store", "fabro-types", "fabro-util", "fabro-validate", diff --git a/docs/agents/outputs.mdx b/docs/agents/outputs.mdx index fb128488d..a4dd0ace1 100644 --- a/docs/agents/outputs.mdx +++ b/docs/agents/outputs.mdx @@ -139,8 +139,8 @@ When a stage produces a large context value -- an LLM response, command output, After each node completes, Fabro checks every context update. If the serialized JSON of a value exceeds **100KB**, it is written to the artifact store on disk and replaced in the context with a `file://` pointer: ``` -response.plan --> file:///path/to/logs/artifacts/values/response.plan.json -command.output --> file:///path/to/logs/artifacts/values/command.output.json +response.plan --> file:///path/to/logs/cache/artifacts/values/response.plan.json +command.output --> file:///path/to/logs/cache/artifacts/values/command.output.json ``` Values under 100KB remain in the context as-is. @@ -151,11 +151,12 @@ Offloaded artifacts are written to the run's directory: ``` ~/.fabro/runs/{run_id}/ - artifacts/ - values/ - response.plan.json - response.implement.json - command.output.json + cache/ + artifacts/ + values/ + response.plan.json + response.implement.json + command.output.json ``` Each file contains the full serialized JSON value. The `ArtifactStore` manages reads and writes, and cleans up files when artifacts are removed. @@ -169,10 +170,10 @@ When Fabro builds a [preamble](/execution/context#preamble-construction) for a d - **plan**: success - Model: claude-sonnet-4-5, 12.4k tokens in / 3.2k out - Files: src/main.rs, tests/api_test.rs - - Response: See: /path/to/logs/artifacts/values/response.plan.json + - Response: See: /path/to/logs/cache/artifacts/values/response.plan.json - **test**: success - Script: `cargo test 2>&1 || true` - - Stdout: See: /path/to/logs/artifacts/values/command.output.json + - Stdout: See: /path/to/logs/cache/artifacts/values/command.output.json ``` This keeps preambles concise while still giving agents a path to read the full output if needed. @@ -206,7 +207,7 @@ For each pointer in the context updates: ``` # Before sync (host path) -file:///home/user/.fabro/runs/01JK.../artifacts/values/response.plan.json +file:///home/user/.fabro/runs/01JK.../cache/artifacts/values/response.plan.json # After sync (sandbox path) file:///workspace/.fabro/artifacts/response.plan.json @@ -256,13 +257,15 @@ Collected assets are written to the run's directory, organized by node and retry ``` ~/.fabro/runs/{run_id}/ - assets/ - {node_slug}/ - retry_1/ - test-results/ - screenshot.png - video.webm - manifest.json + cache/ + artifacts/ + assets/ + {node_slug}/ + retry_1/ + test-results/ + screenshot.png + video.webm + manifest.json ``` Each collection writes a `manifest.json` summarizing what was captured: @@ -291,4 +294,4 @@ Outputs and artifacts appear in several observability surfaces: | `WorkflowRunCompleted` event | `artifact_count` -- total number of offloaded artifacts across the run | | [Retros](/execution/retros) | Per-stage `files_touched` and aggregate `files_touched` across all stages | | [Preambles](/execution/context#preamble-construction) | File list and artifact pointer references for completed stages | -| Stage logs | `status.json` in each stage's run directory contains the full outcome including `files_touched` | \ No newline at end of file +| Stage logs | `status.json` in each stage's run directory contains the full outcome including `files_touched` | diff --git a/docs/execution/context.mdx b/docs/execution/context.mdx index 09c1afeac..7c47820b5 100644 --- a/docs/execution/context.mdx +++ b/docs/execution/context.mdx @@ -199,7 +199,7 @@ Internal keys (prefixed with `internal.`, `current`, `graph.`, `thread.`, `respo When a stage produces a large output (over 100KB of serialized JSON), Fabro automatically offloads it to the **artifact store** on disk rather than keeping it in the in-memory context. The context value is replaced with a `file://` pointer: ``` -response.plan → file:///tmp/logs/artifacts/values/response.plan.json + response.plan → file:///tmp/logs/cache/artifacts/values/response.plan.json ``` The preamble renderer resolves these pointers and displays a reference to the file path. For remote sandboxes (Docker, Daytona), Fabro syncs artifact files to the sandbox at `{working_directory}/.fabro/artifacts/` so agents can read them. diff --git a/docs/reference/run-directory.mdx b/docs/reference/run-directory.mdx index e0b95c0f4..570f15b75 100644 --- a/docs/reference/run-directory.mdx +++ b/docs/reference/run-directory.mdx @@ -69,7 +69,12 @@ Manager nodes that run sub-workflows write a nested `child/` directory containin **`worktree/`** — When running in git checkpoint mode, Fabro creates a Git worktree here as the working directory for agents and commands. -**`assets/`** — Test artifacts collected from the execution environment (Playwright reports, JUnit XML, Cypress screenshots/videos). Located at `worktree/assets/` for local runs or within node directories for remote sandbox runs. +**`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/artifacts/values/` — large context values offloaded from checkpoints +- `cache/artifacts/assets/` — captured test artifacts organized by node and retry ## Browsing runs @@ -98,6 +103,21 @@ fabro ps --filter workflow=my-workflow │ ├── final.patch │ ├── retro.json │ ├── cli.log +│ ├── runtime/ +│ │ ├── interview_request.json +│ │ ├── interview_response.json +│ │ └── interview_request.claim +│ ├── cache/ +│ │ └── artifacts/ +│ │ ├── values/ +│ │ │ ├── response.plan.json +│ │ │ └── command.output.json +│ │ └── assets/ +│ │ └── test/ +│ │ └── retry_1/ +│ │ ├── test-results/ +│ │ │ └── screenshot.png +│ │ └── manifest.json │ ├── nodes/ │ │ ├── plan/ │ │ │ ├── prompt.md @@ -126,7 +146,5 @@ fabro ps --filter workflow=my-workflow │ │ ├── start.json │ │ ├── checkpoint.json │ │ └── nodes/ -│ ├── worktree/ # Git worktree (git checkpoint mode) -│ │ └── assets/ # Test artifacts -│ └── assets/ # Or here for remote sandboxes +│ └── worktree/ # Git worktree (git checkpoint mode) ``` diff --git a/lib/crates/fabro-cli/Cargo.toml b/lib/crates/fabro-cli/Cargo.toml index cea74ffa1..79610093f 100644 --- a/lib/crates/fabro-cli/Cargo.toml +++ b/lib/crates/fabro-cli/Cargo.toml @@ -34,6 +34,7 @@ fabro-validate = { path = "../fabro-validate" } fabro-workflows = { path = "../fabro-workflows" } fabro-api = { path = "../fabro-api", optional = true } fabro-telemetry = { path = "../fabro-telemetry" } +fabro-store = { path = "../fabro-store" } fabro-types = { path = "../fabro-types" } fabro-util = { path = "../fabro-util" } clap.workspace = true diff --git a/lib/crates/fabro-cli/src/commands/asset/cp.rs b/lib/crates/fabro-cli/src/commands/asset/cp.rs index 1647864bc..594fddbf9 100644 --- a/lib/crates/fabro-cli/src/commands/asset/cp.rs +++ b/lib/crates/fabro-cli/src/commands/asset/cp.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use fabro_config::FabroSettingsExt; +use fabro_store::RuntimeState; use crate::args::AssetCpArgs; use crate::shared::split_run_path; @@ -11,7 +12,9 @@ pub fn cp_command(args: &AssetCpArgs) -> Result<()> { let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir()); let (run_id, asset_path) = parse_source(&args.source); let run = fabro_workflows::run_lookup::resolve_run(&base, run_id)?; - let entries = fabro_workflows::assets::scan_assets(&run.path, args.node.as_deref())?; + let runtime_state = RuntimeState::new(&run.path); + let entries = + fabro_workflows::assets::scan_assets(&runtime_state.assets_dir(), args.node.as_deref())?; if entries.is_empty() { bail!("No assets found for this run"); diff --git a/lib/crates/fabro-cli/src/commands/asset/list.rs b/lib/crates/fabro-cli/src/commands/asset/list.rs index 265a7f9af..70e8f55cb 100644 --- a/lib/crates/fabro-cli/src/commands/asset/list.rs +++ b/lib/crates/fabro-cli/src/commands/asset/list.rs @@ -1,5 +1,6 @@ use anyhow::Result; use fabro_config::FabroSettingsExt; +use fabro_store::RuntimeState; use crate::args::AssetListArgs; use crate::shared::format_size; @@ -8,7 +9,9 @@ pub fn list_command(args: &AssetListArgs) -> Result<()> { let cli_config = crate::cli_config::load_cli_settings(None)?; let base = fabro_workflows::run_lookup::runs_base(&cli_config.storage_dir()); let run = fabro_workflows::run_lookup::resolve_run(&base, &args.run_id)?; - let entries = fabro_workflows::assets::scan_assets(&run.path, args.node.as_deref())?; + let runtime_state = RuntimeState::new(&run.path); + let entries = + fabro_workflows::assets::scan_assets(&runtime_state.assets_dir(), args.node.as_deref())?; if args.json { println!("{}", serde_json::to_string_pretty(&entries)?); diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index fc39ce518..94934a1cf 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -8,6 +8,7 @@ use std::time::{Duration, Instant}; use anyhow::{bail, Result}; use fabro_interview::{AnswerValue, ConsoleInterviewer}; +use fabro_store::RuntimeState; use fabro_util::terminal::Styles; use fabro_workflows::records::{ConclusionExt, RunRecordExt}; use fabro_workflows::run_status::{RunStatus, RunStatusRecord, RunStatusRecordExt}; @@ -33,8 +34,9 @@ pub async fn attach_run( let progress_path = run_dir.join("progress.jsonl"); let conclusion_path = run_dir.join("conclusion.json"); let status_path = run_dir.join("status.json"); - let interview_request_path = run_dir.join("interview_request.json"); - let interview_response_path = run_dir.join("interview_response.json"); + let runtime_state = RuntimeState::new(run_dir); + let runtime_interview_paths = InterviewPaths::from_runtime_state(&runtime_state); + let legacy_interview_paths = InterviewPaths::from_base_dir(run_dir); let mut engine_guard = engine_child.map(EngineChildGuard::new); @@ -163,32 +165,41 @@ pub async fn attach_run( } // Check for interview request - if interview_request_path.exists() && !interview_response_path.exists() { - if let Some(_claim_guard) = InterviewClaimGuard::acquire(run_dir) { - if let Ok(request_data) = std::fs::read_to_string(&interview_request_path) { - if let Ok(question) = - serde_json::from_str::(&request_data) + if let Some(interview_paths) = + active_interview_paths(&runtime_interview_paths, &legacy_interview_paths) + { + if !interview_paths.response_path.exists() { + if let Some(_claim_guard) = InterviewClaimGuard::acquire(&interview_paths.base_dir) + { + if let Ok(request_data) = std::fs::read_to_string(&interview_paths.request_path) { - // Hide progress bars during interview - progress_ui.hide_bars(); + if let Ok(question) = + serde_json::from_str::(&request_data) + { + // Hide progress bars during interview + progress_ui.hide_bars(); - // Prompt user via ConsoleInterviewer - let interviewer = ConsoleInterviewer::new(styles); - let answer = - fabro_interview::Interviewer::ask(&interviewer, question).await; + // Prompt user via ConsoleInterviewer + let interviewer = ConsoleInterviewer::new(styles); + let answer = + fabro_interview::Interviewer::ask(&interviewer, question).await; - // Show progress bars again before any return path. - progress_ui.show_bars(); + // Show progress bars again before any return path. + progress_ui.show_bars(); - if answer_requires_reattach(&answer) { - if let Some(guard) = engine_guard.as_mut() { - guard.defuse(); + if answer_requires_reattach(&answer) { + if let Some(guard) = engine_guard.as_mut() { + guard.defuse(); + } + eprintln!("{INTERVIEW_UNANSWERED_MESSAGE}"); + return Ok(ExitCode::from(1)); } - eprintln!("{INTERVIEW_UNANSWERED_MESSAGE}"); - return Ok(ExitCode::from(1)); - } - write_interview_response_atomically(&interview_response_path, &answer)?; + write_interview_response_atomically( + &interview_paths.response_path, + &answer, + )?; + } } } } @@ -287,8 +298,47 @@ fn progress_file_is_empty(path: &Path) -> bool { .unwrap_or(true) } -fn interview_claim_path(run_dir: &Path) -> std::path::PathBuf { - run_dir.join("interview_request.claim") +#[derive(Debug, Clone, PartialEq, Eq)] +struct InterviewPaths { + base_dir: PathBuf, + request_path: PathBuf, + response_path: PathBuf, +} + +impl InterviewPaths { + fn from_base_dir(base_dir: &Path) -> Self { + let base_dir = base_dir.to_path_buf(); + Self { + request_path: base_dir.join("interview_request.json"), + response_path: base_dir.join("interview_response.json"), + base_dir, + } + } + + fn from_runtime_state(runtime_state: &RuntimeState) -> Self { + Self { + base_dir: runtime_state.runtime_dir(), + request_path: runtime_state.interview_request_path(), + response_path: runtime_state.interview_response_path(), + } + } +} + +fn active_interview_paths( + runtime_paths: &InterviewPaths, + legacy_paths: &InterviewPaths, +) -> Option { + if runtime_paths.request_path.exists() { + Some(runtime_paths.clone()) + } else if legacy_paths.request_path.exists() { + Some(legacy_paths.clone()) + } else { + None + } +} + +fn interview_claim_path(base_dir: &Path) -> PathBuf { + base_dir.join("interview_request.claim") } struct InterviewClaimGuard { @@ -296,10 +346,10 @@ struct InterviewClaimGuard { } impl InterviewClaimGuard { - fn acquire(run_dir: &Path) -> Option { - if try_claim_interview_request(run_dir) { + fn acquire(base_dir: &Path) -> Option { + if try_claim_interview_request(base_dir) { Some(Self { - claim_path: interview_claim_path(run_dir), + claim_path: interview_claim_path(base_dir), }) } else { None @@ -340,8 +390,12 @@ impl Drop for EngineChildGuard { } } -fn try_claim_interview_request(run_dir: &Path) -> bool { - let claim_path = interview_claim_path(run_dir); +fn try_claim_interview_request(base_dir: &Path) -> bool { + if std::fs::create_dir_all(base_dir).is_err() { + return false; + } + + let claim_path = interview_claim_path(base_dir); if let Ok(existing) = std::fs::read_to_string(&claim_path) { if let Ok(pid) = existing.trim().parse::() { if process_alive(pid) { @@ -373,6 +427,9 @@ fn write_interview_response_atomically( answer: &fabro_interview::Answer, ) -> Result<()> { let response_json = serde_json::to_string_pretty(answer)?; + if let Some(parent) = response_path.parent() { + std::fs::create_dir_all(parent)?; + } let temp_path = response_path.with_extension("json.tmp"); std::fs::write(&temp_path, response_json)?; std::fs::rename(temp_path, response_path)?; @@ -522,6 +579,38 @@ mod tests { assert!(!interview_claim_path(dir.path()).exists()); } + #[test] + fn active_interview_paths_prefers_runtime_paths() { + let dir = tempfile::tempdir().unwrap(); + let runtime_state = RuntimeState::new(dir.path()); + let runtime_paths = InterviewPaths::from_runtime_state(&runtime_state); + let legacy_paths = InterviewPaths::from_base_dir(dir.path()); + + std::fs::create_dir_all(runtime_state.runtime_dir()).unwrap(); + std::fs::write(&runtime_paths.request_path, "{}").unwrap(); + std::fs::write(&legacy_paths.request_path, "{}").unwrap(); + + assert_eq!( + active_interview_paths(&runtime_paths, &legacy_paths), + Some(runtime_paths) + ); + } + + #[test] + fn active_interview_paths_falls_back_to_legacy_paths() { + let dir = tempfile::tempdir().unwrap(); + let runtime_state = RuntimeState::new(dir.path()); + let runtime_paths = InterviewPaths::from_runtime_state(&runtime_state); + let legacy_paths = InterviewPaths::from_base_dir(dir.path()); + + std::fs::write(&legacy_paths.request_path, "{}").unwrap(); + + assert_eq!( + active_interview_paths(&runtime_paths, &legacy_paths), + Some(legacy_paths) + ); + } + #[test] fn answer_requires_reattach_for_aborted_and_skipped_answers() { let aborted = Answer { diff --git a/lib/crates/fabro-cli/src/commands/run/detached.rs b/lib/crates/fabro-cli/src/commands/run/detached.rs index 4b446e1df..c2159ccfc 100644 --- a/lib/crates/fabro-cli/src/commands/run/detached.rs +++ b/lib/crates/fabro-cli/src/commands/run/detached.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use anyhow::Result; use fabro_interview::FileInterviewer; +use fabro_store::RuntimeState; use fabro_workflows::event::EventEmitter; use crate::cli_config; @@ -19,11 +20,16 @@ pub async fn execute(run_dir: PathBuf, launcher_path: PathBuf, resume: bool) -> let _launcher_guard = scopeguard::guard(launcher_path.clone(), |path| { super::launcher::remove_launcher_record(&path); }); + let runtime_state = RuntimeState::new(&run_dir); let services = fabro_workflows::operations::StartServices { cancel_token: None, emitter: Arc::new(EventEmitter::new()), - interviewer: Arc::new(FileInterviewer::new(run_dir.clone())), + interviewer: Arc::new(FileInterviewer::new( + runtime_state.interview_request_path(), + runtime_state.interview_response_path(), + runtime_state.interview_claim_path(), + )), git_author, github_app, registry_override: None, diff --git a/lib/crates/fabro-cli/src/commands/run/output.rs b/lib/crates/fabro-cli/src/commands/run/output.rs index 9df127ce7..dccaac6a5 100644 --- a/lib/crates/fabro-cli/src/commands/run/output.rs +++ b/lib/crates/fabro-cli/src/commands/run/output.rs @@ -1,6 +1,7 @@ use std::path::Path; use std::time::Duration; +use fabro_store::RuntimeState; use fabro_util::terminal::Styles; use fabro_workflows::outcome::{format_cost, StageStatus}; use fabro_workflows::pipeline::{Persisted, Validated}; @@ -199,7 +200,8 @@ pub(crate) fn print_final_output(run_dir: &Path, styles: &Styles) { } pub(crate) fn print_assets(run_dir: &Path, styles: &Styles) { - let paths = fabro_workflows::asset_snapshot::collect_asset_paths(run_dir); + let runtime_state = RuntimeState::new(run_dir); + let paths = fabro_workflows::asset_snapshot::collect_asset_paths(&runtime_state.assets_dir()); if paths.is_empty() { return; } diff --git a/lib/crates/fabro-cli/tests/cli.rs b/lib/crates/fabro-cli/tests/cli.rs index 66f41e908..f03c2e937 100644 --- a/lib/crates/fabro-cli/tests/cli.rs +++ b/lib/crates/fabro-cli/tests/cli.rs @@ -1,6 +1,7 @@ use assert_cmd::Command; use fabro_config::mcp::McpTransport; use fabro_config::FabroSettings; +use fabro_store::RuntimeState; use predicates::prelude::*; #[allow(deprecated)] @@ -1303,8 +1304,10 @@ fn bug3_attach_leaves_interview_request_until_engine_consumes_response() { "stage": "gate", "metadata": {} }); + let runtime_state = RuntimeState::new(&run_dir); + std::fs::create_dir_all(runtime_state.runtime_dir()).unwrap(); std::fs::write( - run_dir.join("interview_request.json"), + runtime_state.interview_request_path(), serde_json::to_string(&question).unwrap(), ) .unwrap(); @@ -1324,14 +1327,14 @@ fn bug3_attach_leaves_interview_request_until_engine_consumes_response() { // The attach loop should leave the request durable until the engine consumes // the response, so a crashed attach can be retried safely. assert!( - run_dir.join("interview_request.json").exists(), + runtime_state.interview_request_path().exists(), "bug3: interview_request.json should stay present until the engine consumes the answer" ); assert!( - run_dir.join("interview_response.json").exists(), + runtime_state.interview_response_path().exists(), "bug3: attach should write interview_response.json after handling the prompt" ); - let response = std::fs::read_to_string(run_dir.join("interview_response.json")).unwrap(); + let response = std::fs::read_to_string(runtime_state.interview_response_path()).unwrap(); assert!(response.contains("\"value\": \"Yes\"")); } @@ -1364,8 +1367,10 @@ fn attach_closed_stdin_keeps_interview_pending() { "stage": "gate", "metadata": {} }); + let runtime_state = RuntimeState::new(&run_dir); + std::fs::create_dir_all(runtime_state.runtime_dir()).unwrap(); std::fs::write( - run_dir.join("interview_request.json"), + runtime_state.interview_request_path(), serde_json::to_string(&question).unwrap(), ) .unwrap(); @@ -1386,19 +1391,70 @@ fn attach_closed_stdin_keeps_interview_pending() { "attach should explain that the run is still waiting for a human answer.\nstderr: {stderr}" ); assert!( - run_dir.join("interview_request.json").exists(), + runtime_state.interview_request_path().exists(), "attach with closed stdin must leave the request pending" ); assert!( - !run_dir.join("interview_response.json").exists(), + !runtime_state.interview_response_path().exists(), "attach with closed stdin must not fabricate a response" ); assert!( - !run_dir.join("interview_request.claim").exists(), + !runtime_state.interview_claim_path().exists(), "attach with closed stdin must release the claim so a later attach can answer" ); } +#[test] +fn attach_supports_legacy_root_interview_paths() { + let home = tempfile::tempdir().unwrap(); + + let run_dir = setup_run_dir( + home.path(), + "attach-legacy-interview-paths", + serde_json::json!({}), + &[ + r#"{"ts":"2026-01-01T00:00:01Z","run_id":"attach-legacy-interview-paths","event":"StageStarted","node_id":"gate","name":"Gate","index":0,"attempt":1,"max_attempts":1}"#, + ], + ); + + std::fs::write( + run_dir.join("status.json"), + serde_json::json!({"status": "running", "updated_at": "2026-01-01T00:00:00Z"}).to_string(), + ) + .unwrap(); + + let question = serde_json::json!({ + "text": "Approve?", + "question_type": "YesNo", + "options": [], + "allow_freeform": false, + "default": {"value": "Yes", "selected_option": null, "text": null}, + "timeout_seconds": 1.0, + "stage": "gate", + "metadata": {} + }); + std::fs::write( + run_dir.join("interview_request.json"), + serde_json::to_string(&question).unwrap(), + ) + .unwrap(); + std::fs::write(run_dir.join("run.pid"), "99999999").unwrap(); + + let _ = arc() + .env("HOME", home.path()) + .env("NO_COLOR", "1") + .args(["attach", "attach-legacy-interview-paths"]) + .write_stdin("y\n") + .timeout(std::time::Duration::from_secs(5)) + .output(); + + assert!(run_dir.join("interview_request.json").exists()); + assert!(run_dir.join("interview_response.json").exists()); + assert!(!RuntimeState::new(&run_dir) + .interview_response_path() + .exists()); +} + // Bug 4: attach should respect the verbose flag from run.json. // Currently ProgressUI is created with verbose=false regardless of config. #[test] diff --git a/lib/crates/fabro-cli/tests/scenario.rs b/lib/crates/fabro-cli/tests/scenario.rs index 6e53804d6..60f144182 100644 --- a/lib/crates/fabro-cli/tests/scenario.rs +++ b/lib/crates/fabro-cli/tests/scenario.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use assert_cmd::Command; +use fabro_store::RuntimeState; use predicates; use serde_json::Value; @@ -782,7 +783,7 @@ fn local_run_lifecycle() { ); // 6. Seed a synthetic asset so asset list/cp have something to work with. - let asset_dir = run_dir.join("artifacts/assets/step1/retry_0"); + let asset_dir = RuntimeState::new(&run_dir).asset_stage_dir("step1", 0); std::fs::create_dir_all(&asset_dir).unwrap(); std::fs::write(asset_dir.join("output.txt"), "asset-content-42").unwrap(); std::fs::write( diff --git a/lib/crates/fabro-interview/src/file.rs b/lib/crates/fabro-interview/src/file.rs index 303a9be98..e8a2f40de 100644 --- a/lib/crates/fabro-interview/src/file.rs +++ b/lib/crates/fabro-interview/src/file.rs @@ -10,35 +10,47 @@ const REATTACH_WINDOW: Duration = Duration::from_millis(300); #[cfg(not(test))] const REATTACH_WINDOW: Duration = Duration::from_secs(30); +#[cfg(test)] +use std::path::Path; + /// An interviewer that communicates via JSON files in the run directory. /// /// The engine process writes `interview_request.json` and polls for /// `interview_response.json`. The attach process watches for the request /// file, prompts the user, and writes the response file. pub struct FileInterviewer { - run_dir: PathBuf, + request_path: PathBuf, + response_path: PathBuf, + claim_path: PathBuf, } impl FileInterviewer { - pub fn new(run_dir: PathBuf) -> Self { - Self { run_dir } + pub fn new(request_path: PathBuf, response_path: PathBuf, claim_path: PathBuf) -> Self { + Self { + request_path, + response_path, + claim_path, + } } fn request_path(&self) -> PathBuf { - self.run_dir.join("interview_request.json") + self.request_path.clone() } fn response_path(&self) -> PathBuf { - self.run_dir.join("interview_response.json") + self.response_path.clone() } fn claim_path(&self) -> PathBuf { - self.run_dir.join("interview_request.claim") + self.claim_path.clone() } async fn write_request_atomically(&self, question: &Question) -> std::io::Result<()> { let json = serde_json::to_string_pretty(question).expect("Question serialization failed"); let request_path = self.request_path(); + if let Some(parent) = request_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } let temp_path = request_path.with_extension("json.tmp"); tokio::fs::write(&temp_path, json).await?; tokio::fs::rename(temp_path, request_path).await @@ -133,11 +145,24 @@ mod tests { use super::*; use crate::{AnswerValue, QuestionType}; + fn interviewer_paths(run_dir: &Path) -> (PathBuf, PathBuf, PathBuf) { + ( + run_dir.join("interview_request.json"), + run_dir.join("interview_response.json"), + run_dir.join("interview_request.claim"), + ) + } + #[tokio::test] async fn write_request_poll_response() { let dir = tempfile::tempdir().unwrap(); let run_dir = dir.path().to_path_buf(); - let interviewer = FileInterviewer::new(run_dir.clone()); + let (request_path, response_path, claim_path) = interviewer_paths(&run_dir); + let interviewer = FileInterviewer::new( + request_path.clone(), + response_path.clone(), + claim_path.clone(), + ); let question = Question::new("approve?", QuestionType::YesNo); @@ -145,7 +170,6 @@ mod tests { let ask_handle = tokio::spawn(async move { interviewer.ask(question).await }); // Wait for the request file to appear - let request_path = run_dir.join("interview_request.json"); for _ in 0..50 { if request_path.exists() { break; @@ -162,7 +186,6 @@ mod tests { // Write a response let answer = Answer::yes(); let response_json = serde_json::to_string_pretty(&answer).unwrap(); - let response_path = run_dir.join("interview_response.json"); tokio::fs::write(&response_path, response_json) .await .unwrap(); @@ -174,13 +197,14 @@ mod tests { // Both files should be cleaned up assert!(!request_path.exists()); assert!(!response_path.exists()); - assert!(!run_dir.join("interview_request.claim").exists()); + assert!(!claim_path.exists()); } #[tokio::test] async fn timeout_returns_default() { let dir = tempfile::tempdir().unwrap(); - let interviewer = FileInterviewer::new(dir.path().to_path_buf()); + let (request_path, response_path, claim_path) = interviewer_paths(dir.path()); + let interviewer = FileInterviewer::new(request_path, response_path, claim_path); let mut question = Question::new("approve?", QuestionType::YesNo); question.timeout_seconds = Some(0.1); @@ -194,14 +218,15 @@ mod tests { async fn claim_released_without_response_returns_timeout() { let dir = tempfile::tempdir().unwrap(); let run_dir = dir.path().to_path_buf(); - let interviewer = FileInterviewer::new(run_dir.clone()); + let (request_path, response_path, claim_path) = interviewer_paths(&run_dir); + let interviewer = + FileInterviewer::new(request_path.clone(), response_path, claim_path.clone()); let question = Question::new("approve?", QuestionType::YesNo); let ask_handle = tokio::spawn(async move { interviewer.ask(question).await }); // Wait for request file to appear - let request_path = run_dir.join("interview_request.json"); for _ in 0..50 { if request_path.exists() { break; @@ -211,7 +236,6 @@ mod tests { assert!(request_path.exists()); // Simulate attacher creating claim file - let claim_path = run_dir.join("interview_request.claim"); std::fs::write(&claim_path, "12345\n").unwrap(); // Let the poll loop see the claim @@ -238,7 +262,9 @@ mod tests { async fn claim_released_without_response_returns_default() { let dir = tempfile::tempdir().unwrap(); let run_dir = dir.path().to_path_buf(); - let interviewer = FileInterviewer::new(run_dir.clone()); + let (request_path, response_path, claim_path) = interviewer_paths(&run_dir); + let interviewer = + FileInterviewer::new(request_path.clone(), response_path, claim_path.clone()); let mut question = Question::new("approve?", QuestionType::YesNo); question.default = Some(Answer::no()); @@ -246,7 +272,6 @@ mod tests { let ask_handle = tokio::spawn(async move { interviewer.ask(question).await }); // Wait for request file - let request_path = run_dir.join("interview_request.json"); for _ in 0..50 { if request_path.exists() { break; @@ -256,7 +281,6 @@ mod tests { assert!(request_path.exists()); // Simulate attacher creating then deleting claim - let claim_path = run_dir.join("interview_request.claim"); std::fs::write(&claim_path, "12345\n").unwrap(); tokio::time::sleep(Duration::from_millis(150)).await; std::fs::remove_file(&claim_path).unwrap(); @@ -273,15 +297,18 @@ mod tests { async fn claim_released_then_new_attacher_answers() { let dir = tempfile::tempdir().unwrap(); let run_dir = dir.path().to_path_buf(); - let interviewer = FileInterviewer::new(run_dir.clone()); + let (request_path, response_path, claim_path) = interviewer_paths(&run_dir); + let interviewer = FileInterviewer::new( + request_path.clone(), + response_path.clone(), + claim_path.clone(), + ); let question = Question::new("approve?", QuestionType::YesNo); - let run_dir2 = run_dir.clone(); let ask_handle = tokio::spawn(async move { interviewer.ask(question).await }); // Wait for request file - let request_path = run_dir.join("interview_request.json"); for _ in 0..50 { if request_path.exists() { break; @@ -291,7 +318,6 @@ mod tests { assert!(request_path.exists()); // First attacher creates then releases claim - let claim_path = run_dir.join("interview_request.claim"); std::fs::write(&claim_path, "12345\n").unwrap(); tokio::time::sleep(Duration::from_millis(150)).await; std::fs::remove_file(&claim_path).unwrap(); @@ -302,7 +328,7 @@ mod tests { let answer = Answer::yes(); let response_json = serde_json::to_string_pretty(&answer).unwrap(); - tokio::fs::write(run_dir2.join("interview_response.json"), response_json) + tokio::fs::write(response_path, response_json) .await .unwrap(); @@ -317,7 +343,8 @@ mod tests { #[tokio::test] async fn timeout_without_default_returns_timeout() { let dir = tempfile::tempdir().unwrap(); - let interviewer = FileInterviewer::new(dir.path().to_path_buf()); + let (request_path, response_path, claim_path) = interviewer_paths(dir.path()); + let interviewer = FileInterviewer::new(request_path, response_path, claim_path); let mut question = Question::new("approve?", QuestionType::YesNo); question.timeout_seconds = Some(0.1); diff --git a/lib/crates/fabro-store/Cargo.toml b/lib/crates/fabro-store/Cargo.toml index 4daba5d23..3d0e906a4 100644 --- a/lib/crates/fabro-store/Cargo.toml +++ b/lib/crates/fabro-store/Cargo.toml @@ -24,3 +24,4 @@ futures.workspace = true [dev-dependencies] tokio = { workspace = true, features = ["test-util", "macros"] } +tempfile = "3" diff --git a/lib/crates/fabro-store/src/lib.rs b/lib/crates/fabro-store/src/lib.rs index 9ac9f3128..232fe99da 100644 --- a/lib/crates/fabro-store/src/lib.rs +++ b/lib/crates/fabro-store/src/lib.rs @@ -8,11 +8,13 @@ use futures::Stream; mod error; mod keys; mod memory; +mod runtime; mod slate; mod types; pub use error::{Result, StoreError}; pub use memory::InMemoryStore; +pub use runtime::RuntimeState; pub use slate::SlateStore; pub use types::{ CatalogRecord, EventEnvelope, EventPayload, NodeSnapshot, NodeVisitRef, RunSnapshot, RunSummary, diff --git a/lib/crates/fabro-store/src/runtime.rs b/lib/crates/fabro-store/src/runtime.rs new file mode 100644 index 000000000..9f09bdb1f --- /dev/null +++ b/lib/crates/fabro-store/src/runtime.rs @@ -0,0 +1,134 @@ +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeState { + root: PathBuf, +} + +impl RuntimeState { + #[must_use] + pub fn new(run_dir: impl AsRef) -> Self { + Self { + root: run_dir.as_ref().to_path_buf(), + } + } + + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + #[must_use] + pub fn runtime_dir(&self) -> PathBuf { + self.root.join("runtime") + } + + #[must_use] + pub fn interview_request_path(&self) -> PathBuf { + self.runtime_dir().join("interview_request.json") + } + + #[must_use] + pub fn interview_response_path(&self) -> PathBuf { + self.runtime_dir().join("interview_response.json") + } + + #[must_use] + pub fn interview_claim_path(&self) -> PathBuf { + self.runtime_dir().join("interview_request.claim") + } + + #[must_use] + pub fn artifact_values_dir(&self) -> PathBuf { + self.root.join("cache").join("artifacts").join("values") + } + + #[must_use] + pub fn artifact_value_path(&self, artifact_id: &str) -> PathBuf { + self.artifact_values_dir() + .join(format!("{artifact_id}.json")) + } + + #[must_use] + pub fn assets_dir(&self) -> PathBuf { + self.root.join("cache").join("artifacts").join("assets") + } + + #[must_use] + pub fn asset_stage_dir(&self, node_slug: &str, attempt: u32) -> PathBuf { + self.assets_dir() + .join(node_slug) + .join(format!("retry_{attempt}")) + } + + pub fn ensure_runtime_dir(&self) -> std::io::Result<()> { + std::fs::create_dir_all(self.runtime_dir()) + } + + pub fn ensure_artifact_values_dir(&self) -> std::io::Result<()> { + std::fs::create_dir_all(self.artifact_values_dir()) + } +} + +#[cfg(test)] +mod tests { + use super::RuntimeState; + + #[test] + fn computes_runtime_and_cache_paths() { + let dir = tempfile::tempdir().unwrap(); + let state = RuntimeState::new(dir.path()); + + assert_eq!(state.root(), dir.path()); + assert_eq!(state.runtime_dir(), dir.path().join("runtime")); + assert_eq!( + state.interview_request_path(), + dir.path().join("runtime").join("interview_request.json") + ); + assert_eq!( + state.interview_response_path(), + dir.path().join("runtime").join("interview_response.json") + ); + assert_eq!( + state.interview_claim_path(), + dir.path().join("runtime").join("interview_request.claim") + ); + assert_eq!( + state.artifact_values_dir(), + dir.path().join("cache").join("artifacts").join("values") + ); + assert_eq!( + state.artifact_value_path("response.plan"), + dir.path() + .join("cache") + .join("artifacts") + .join("values") + .join("response.plan.json") + ); + assert_eq!( + state.assets_dir(), + dir.path().join("cache").join("artifacts").join("assets") + ); + assert_eq!( + state.asset_stage_dir("plan", 2), + dir.path() + .join("cache") + .join("artifacts") + .join("assets") + .join("plan") + .join("retry_2") + ); + } + + #[test] + fn ensure_methods_create_directories() { + let dir = tempfile::tempdir().unwrap(); + let state = RuntimeState::new(dir.path()); + + state.ensure_runtime_dir().unwrap(); + state.ensure_artifact_values_dir().unwrap(); + + assert!(state.runtime_dir().is_dir()); + assert!(state.artifact_values_dir().is_dir()); + } +} diff --git a/lib/crates/fabro-workflows/Cargo.toml b/lib/crates/fabro-workflows/Cargo.toml index 004eee379..80a1fa89d 100644 --- a/lib/crates/fabro-workflows/Cargo.toml +++ b/lib/crates/fabro-workflows/Cargo.toml @@ -35,6 +35,7 @@ fabro-llm = { path = "../fabro-llm" } fabro-model = { path = "../fabro-model" } fabro-retro = { path = "../fabro-retro" } fabro-core = { path = "../fabro-core" } +fabro-store = { path = "../fabro-store" } fabro-types = { path = "../fabro-types" } thiserror.workspace = true serde.workspace = true diff --git a/lib/crates/fabro-workflows/src/artifact.rs b/lib/crates/fabro-workflows/src/artifact.rs index 96c5622b8..1899aae68 100644 --- a/lib/crates/fabro-workflows/src/artifact.rs +++ b/lib/crates/fabro-workflows/src/artifact.rs @@ -32,28 +32,28 @@ enum StoredData { /// Named, typed storage for large stage outputs. pub struct ArtifactStore { - base_dir: Option, + values_dir: Option, artifacts: RwLock>, } impl std::fmt::Debug for ArtifactStore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ArtifactStore") - .field("base_dir", &self.base_dir) + .field("values_dir", &self.values_dir) .finish_non_exhaustive() } } impl ArtifactStore { #[must_use] - pub fn new(base_dir: Option) -> Self { + pub fn new(values_dir: Option) -> Self { Self { - base_dir, + values_dir, artifacts: RwLock::new(HashMap::new()), } } - /// Store an artifact. Large artifacts with a configured `base_dir` are written to disk. + /// Store an artifact. Large artifacts with a configured `values_dir` are written to disk. /// /// # Errors /// @@ -74,13 +74,12 @@ impl ArtifactStore { .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.base_dir.is_some(); + let is_file_backed = size_bytes > FILE_BACKING_THRESHOLD && self.values_dir.is_some(); let (stored, file_path) = if is_file_backed { - let base = self.base_dir.as_ref().expect("base_dir checked above"); - let artifacts_dir = base.join("artifacts").join("values"); - std::fs::create_dir_all(&artifacts_dir)?; - let path = artifacts_dir.join(format!("{id}.json")); + 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 { @@ -175,13 +174,11 @@ impl ArtifactStore { } } - /// Returns the absolute path to the artifacts directory under this store's base_dir. - /// Returns `None` if no `base_dir` is configured. + /// Returns the configured values directory. + /// Returns `None` if no file-backed storage is configured. #[must_use] - pub fn artifacts_dir(&self) -> Option { - self.base_dir - .as_ref() - .map(|b| b.join("artifacts").join("values")) + pub fn values_dir(&self) -> Option { + self.values_dir.clone() } /// Remove all artifacts. Also deletes file-backed data from disk. @@ -247,7 +244,8 @@ pub fn is_artifact_pointer(value: &Value) -> bool { /// Resolve an artifact pointer to the base name displayed in preamble rendering. /// -/// Given `"file:///tmp/logs/artifacts/response.plan.json"`, returns `"See: /tmp/logs/artifacts/response.plan.json"`. +/// Given `"file:///tmp/logs/cache/artifacts/values/response.plan.json"`, returns +/// `"See: /tmp/logs/cache/artifacts/values/response.plan.json"`. #[must_use] pub fn format_artifact_reference(path: &str) -> String { format!("See: {path}") @@ -377,10 +375,7 @@ mod tests { 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("artifacts").join("values").join("big.json")) - ); + assert_eq!(info.file_path, Some(dir.path().join("big.json"))); let retrieved = store.retrieve("big").unwrap(); assert_eq!(retrieved, data); @@ -395,7 +390,7 @@ mod tests { let data = serde_json::json!(large_string); store.store("big", "large", data).unwrap(); - let file_path = dir.path().join("artifacts").join("values").join("big.json"); + let file_path = dir.path().join("big.json"); assert!(file_path.exists()); store.remove("big"); @@ -403,7 +398,7 @@ mod tests { } #[test] - fn small_artifact_stays_in_memory_even_with_base_dir() { + 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())); @@ -438,12 +433,7 @@ mod tests { let path = artifact_path(pointer).expect("should be an artifact pointer"); assert_eq!( path, - dir.path() - .join("artifacts") - .join("values") - .join("response.plan.json") - .to_str() - .unwrap() + dir.path().join("response.plan.json").to_str().unwrap() ); // The artifact store should contain the original value @@ -451,12 +441,7 @@ mod tests { assert_eq!(retrieved, serde_json::json!(large_string)); // File should exist on disk - assert!(dir - .path() - .join("artifacts") - .join("values") - .join("response.plan.json") - .exists()); + assert!(dir.path().join("response.plan.json").exists()); } #[test] @@ -476,13 +461,21 @@ mod tests { #[test] fn artifact_path_extracts_path_from_pointer() { - let value = serde_json::json!("file:///tmp/logs/artifacts/response.plan.json"); + let value = serde_json::json!("file:///tmp/logs/cache/artifacts/values/response.plan.json"); assert_eq!( artifact_path(&value), - Some("/tmp/logs/artifacts/response.plan.json") + Some("/tmp/logs/cache/artifacts/values/response.plan.json") ); } + #[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"); diff --git a/lib/crates/fabro-workflows/src/asset_snapshot.rs b/lib/crates/fabro-workflows/src/asset_snapshot.rs index 057ef3ffe..fe620620c 100644 --- a/lib/crates/fabro-workflows/src/asset_snapshot.rs +++ b/lib/crates/fabro-workflows/src/asset_snapshot.rs @@ -317,12 +317,11 @@ pub async fn collect_assets( Ok(summary) } -/// Collect all asset paths from manifest files under `{run_dir}/artifacts/assets/*/retry_*/manifest.json`. +/// Collect all asset paths from manifest files under `{assets_dir}/*/retry_*/manifest.json`. /// /// Returns the full on-disk paths to the downloaded asset files. -pub fn collect_asset_paths(run_dir: &Path) -> Vec { - let assets_dir = run_dir.join("artifacts/assets"); - let Ok(nodes) = std::fs::read_dir(&assets_dir) else { +pub fn collect_asset_paths(assets_dir: &Path) -> Vec { + let Ok(nodes) = std::fs::read_dir(assets_dir) else { return Vec::new(); }; @@ -700,9 +699,10 @@ mod tests { fn collect_asset_paths_from_manifests() { let tmp = tempfile::tempdir().unwrap(); let base = tmp.path(); + let assets_dir = base.join("cache/artifacts/assets"); // Create two node directories with manifests - let node_a = base.join("artifacts/assets/node_a/retry_1"); + let node_a = assets_dir.join("node_a/retry_1"); std::fs::create_dir_all(&node_a).unwrap(); std::fs::write( node_a.join("manifest.json"), @@ -720,7 +720,7 @@ mod tests { ) .unwrap(); - let node_b = base.join("artifacts/assets/node_b/retry_1"); + let node_b = assets_dir.join("node_b/retry_1"); std::fs::create_dir_all(&node_b).unwrap(); std::fs::write( node_b.join("manifest.json"), @@ -735,24 +735,24 @@ mod tests { ) .unwrap(); - let paths = collect_asset_paths(base); + let paths = collect_asset_paths(&assets_dir); assert_eq!(paths.len(), 3); let base_str = base.to_string_lossy(); assert!(paths.contains(&format!( - "{base_str}/artifacts/assets/node_a/retry_1/test-results/report.xml" + "{base_str}/cache/artifacts/assets/node_a/retry_1/test-results/report.xml" ))); assert!(paths.contains(&format!( - "{base_str}/artifacts/assets/node_a/retry_1/test-results/screenshot.png" + "{base_str}/cache/artifacts/assets/node_a/retry_1/test-results/screenshot.png" ))); assert!(paths.contains(&format!( - "{base_str}/artifacts/assets/node_b/retry_1/coverage/lcov.info" + "{base_str}/cache/artifacts/assets/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()); + let paths = collect_asset_paths(&tmp.path().join("cache/artifacts/assets")); assert!(paths.is_empty()); } diff --git a/lib/crates/fabro-workflows/src/assets.rs b/lib/crates/fabro-workflows/src/assets.rs index b0d7aa633..99ecece92 100644 --- a/lib/crates/fabro-workflows/src/assets.rs +++ b/lib/crates/fabro-workflows/src/assets.rs @@ -19,10 +19,9 @@ fn serialize_path(path: &Path, serializer: S) -> Result) -> Result> { - let assets_dir = run_dir.join("artifacts/assets"); - let nodes = match std::fs::read_dir(&assets_dir) { +/// Walk `{assets_dir}/*/retry_*/manifest.json`, stat each file, and return entries. +pub fn scan_assets(assets_dir: &Path, node_filter: Option<&str>) -> Result> { + let nodes = match std::fs::read_dir(assets_dir) { Ok(read_dir) => read_dir, Err(_) => return Ok(Vec::new()), }; diff --git a/lib/crates/fabro-workflows/src/lifecycle/artifact.rs b/lib/crates/fabro-workflows/src/lifecycle/artifact.rs index 064665313..dbd33d7f2 100644 --- a/lib/crates/fabro-workflows/src/lifecycle/artifact.rs +++ b/lib/crates/fabro-workflows/src/lifecycle/artifact.rs @@ -23,9 +23,9 @@ type WfNodeDecision = NodeDecision>; pub struct ArtifactLifecycle { pub sandbox: Arc, pub artifact_store: Arc>, - pub artifact_base_dir: Option, + pub artifact_values_dir: Option, pub emitter: Arc, - pub run_dir: PathBuf, + pub assets_dir: PathBuf, pub asset_globs: Vec, /// Per-attempt state: epoch seconds when the attempt started. attempt_start_epoch: Mutex>, @@ -36,17 +36,17 @@ impl ArtifactLifecycle { pub fn new( sandbox: Arc, artifact_store: Arc>, - artifact_base_dir: Option, + artifact_values_dir: Option, emitter: Arc, - run_dir: PathBuf, + assets_dir: PathBuf, asset_globs: Vec, ) -> Self { Self { sandbox, artifact_store, - artifact_base_dir, + artifact_values_dir, emitter, - run_dir, + assets_dir, asset_globs, attempt_start_epoch: Mutex::new(None), } @@ -62,7 +62,7 @@ impl RunLifecycle for ArtifactLifecycle { ) -> fabro_core::error::Result<()> { // 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_base_dir.clone()); + *store = ArtifactStore::new(self.artifact_values_dir.clone()); *self.attempt_start_epoch.lock().unwrap() = None; Ok(()) } @@ -98,9 +98,7 @@ impl RunLifecycle for ArtifactLifecycle { format!("{node_id}-visit_{visit}") }; let stage_dir = self - .run_dir - .join("artifacts") - .join("assets") + .assets_dir .join(node_slug) .join(format!("retry_{}", ctx.attempt)); let _ = std::fs::create_dir_all(&stage_dir); diff --git a/lib/crates/fabro-workflows/src/lifecycle/mod.rs b/lib/crates/fabro-workflows/src/lifecycle/mod.rs index cc75f668e..a690eab96 100644 --- a/lib/crates/fabro-workflows/src/lifecycle/mod.rs +++ b/lib/crates/fabro-workflows/src/lifecycle/mod.rs @@ -14,6 +14,7 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; use async_trait::async_trait; +use fabro_store::RuntimeState; use fabro_core::error::Result as CoreResult; use fabro_core::graph::NodeSpec; @@ -83,12 +84,15 @@ impl WorkflowLifecycle { run_options: Arc, is_resume: bool, ) -> Self { + let runtime_state = RuntimeState::new(&run_dir); let restarted_from: Arc>> = Arc::new(Mutex::new(None)); let loop_restart_signature_limit = graph.loop_restart_signature_limit(); let checkpoint_git_result: Arc>> = Arc::new(Mutex::new(None)); let last_git_sha: Arc>> = Arc::new(Mutex::new(None)); - let artifact_store = Arc::new(Mutex::new(ArtifactStore::new(Some(run_dir.clone())))); + let artifact_store = Arc::new(Mutex::new(ArtifactStore::new(Some( + runtime_state.artifact_values_dir(), + )))); let circuit_breaker = Arc::new(CircuitBreakerLifecycle::new(loop_restart_signature_limit)); @@ -157,9 +161,9 @@ impl WorkflowLifecycle { let artifact = ArtifactLifecycle::new( Arc::clone(&sandbox), Arc::clone(&artifact_store), - Some(run_dir.clone()), + Some(runtime_state.artifact_values_dir()), Arc::clone(&emitter), - run_dir, + runtime_state.assets_dir(), run_options.asset_globs().to_vec(), ); diff --git a/lib/crates/fabro-workflows/src/operations/resume.rs b/lib/crates/fabro-workflows/src/operations/resume.rs index 54ec5a4b1..589eceec4 100644 --- a/lib/crates/fabro-workflows/src/operations/resume.rs +++ b/lib/crates/fabro-workflows/src/operations/resume.rs @@ -1,5 +1,7 @@ use std::path::Path; +use fabro_store::RuntimeState; + use crate::error::FabroError; use crate::outcome::StageStatus; use crate::records::{Checkpoint, CheckpointExt, Conclusion, ConclusionExt}; @@ -38,6 +40,15 @@ pub async fn resume(run_dir: &Path, services: StartServices) -> Result