Add RunRecord + StartRecord alongside RunSpec + Manifest

Introduce two new persistence types aligned to the CREATE/START lifecycle:
- RunRecord (run.json): written at CREATE with merged FabroConfig, fully
  transformed Graph, and run metadata
- StartRecord (start.json): written at START with start_time, run_branch,
  and base_sha

All readers (run_lookup, inspect, diff, pr, attach, detached_support,
start, run_fork, pull_request, run_rewind, resume) now read from the
new types first. Legacy manifest.json + spec.json are still written
for backward compatibility (removal in follow-up).

Also adds dry_run, auto_approve, no_retro fields to FabroConfig, derives
Default on LlmConfig and Graph, and updates docs + tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-24 19:06:37 -04:00
parent f022298728
commit 08dbcee01e
No known key found for this signature in database
33 changed files with 886 additions and 206 deletions

View file

@ -12,7 +12,7 @@ Each run creates two Git branches that work in tandem:
| Branch | Ref format | Contains |
|---|---|---|
| **Run branch** | `fabro/run/{run_id}` | File changes made by agents and commands — the actual work product |
| **Metadata branch** | `fabro/meta/{run_id}` | Checkpoint JSON, the workflow graph, a run manifest, and offloaded artifacts |
| **Metadata branch** | `fabro/meta/{run_id}` | Checkpoint JSON, the workflow graph, run and start records, and offloaded artifacts |
The run branch is a regular Git branch that grows one commit per completed node. The metadata branch is an orphan branch (no shared history with your code) that stores structured data using Git's object database directly — no working tree needed.
@ -43,7 +43,8 @@ The `Fabro-Checkpoint` trailer links each run branch commit to its metadata bran
The metadata branch (`fabro/meta/{run_id}`) is an orphan branch that stores structured run data using Git's object storage directly (via `git2`). It is initialized at run start with:
- **`manifest.json`** — Run metadata: run ID, graph name, node/edge counts, base SHA, and branch name
- **`run.json`** — Run record: run ID, created_at, config, graph, workflow slug, working directory, host repo path, base branch, labels
- **`start.json`** — Start record: run ID, start time, run branch, base SHA
- **`graph.fabro`** — The workflow Graphviz source as it was parsed
After each node, the metadata branch is updated with:
@ -110,11 +111,11 @@ Resume from the Git branches created during a previous run:
fabro resume 01JKXYZ
```
This reads the checkpoint, manifest, and Graphviz graph from the metadata branch (`fabro/meta/01JKXYZ...`), re-attaches a worktree to the existing run branch, and resumes execution. No workflow file argument is needed — everything is recovered from Git.
This reads the checkpoint, run record, and Graphviz graph from the metadata branch (`fabro/meta/01JKXYZ...`), re-attaches a worktree to the existing run branch, and resumes execution. No workflow file argument is needed — everything is recovered from Git.
<Accordion title="What happens during resume">
1. Fabro reads `checkpoint.json` from the metadata branch
2. Reads `manifest.json` and `graph.fabro` to reconstruct the workflow
2. Reads `run.json` and `graph.fabro` to reconstruct the workflow
3. Creates a fresh worktree attached to the existing run branch
4. Restores the full context, completed node list, retry counts, and failure signatures
5. If the checkpointed node used `full` fidelity, downgrades the first resumed node to `summary:high` (since the original conversation thread no longer exists in memory)

View file

@ -199,7 +199,8 @@ Each run's directory contains a standard set of files:
| File | Description |
|---|---|
| `manifest.json` | Run metadata — ID, workflow name, start time, labels |
| `run.json` | Run record — ID, config, graph, workflow slug, labels |
| `start.json` | Start record — run ID, start time, run branch, base SHA |
| `progress.jsonl` | Full event stream |
| `live.json` | Last event snapshot (overwritten during run) |
| `checkpoint.json` | Final execution state |

View file

@ -101,7 +101,7 @@ Retro generation happens in two phases after a run completes:
1. **Derive** — Fabro extracts stage durations from `progress.jsonl` and builds a retro from the checkpoint data. This is deterministic, fast, and produces the quantitative layer. The retro is saved immediately as `retro.json` in the run's directory.
2. **Narrate** — An LLM agent session analyzes the run data. The agent has read access to `progress.jsonl`, `checkpoint.json`, and `manifest.json`. It uses grep and read tools to find interesting signals — failures, retries, errors, approach changes — then calls a `submit_retro` tool with its structured analysis. The narrative fields are merged into the existing retro and saved.
2. **Narrate** — An LLM agent session analyzes the run data. The agent has read access to `progress.jsonl`, `checkpoint.json`, `run.json`, and `start.json`. It uses grep and read tools to find interesting signals — failures, retries, errors, approach changes — then calls a `submit_retro` tool with its structured analysis. The narrative fields are merged into the existing retro and saved.
Both phases run automatically at the end of every CLI run. The API server derives the quantitative layer but does not currently run the narrative agent.

View file

@ -115,7 +115,7 @@ The table shows run ID, status, workflow name, goal, and timing.
| `--before <DATE>` | Only show runs started before this date (YYYY-MM-DD prefix match) |
| `--workflow <NAME>` | Filter by workflow name (substring match) |
| `--label <KEY=VALUE>` | Filter by label (repeatable, AND semantics) |
| `--orphans` | Include orphan directories (no `manifest.json`) |
| `--orphans` | Include orphan directories (no `run.json`) |
| `--json` | Output as JSON |
| `-q, --quiet` | Only display full run IDs, one per line (no headers or footers). Takes precedence over `--json`. |
@ -149,7 +149,7 @@ fabro system prune --orphans --yes
| `--before <DATE>` | Only prune runs started before this date (YYYY-MM-DD prefix match) |
| `--workflow <NAME>` | Filter by workflow name (substring match) |
| `--label <KEY=VALUE>` | Filter by label (repeatable, AND semantics) |
| `--orphans` | Include orphan directories (no `manifest.json`) |
| `--orphans` | Include orphan directories (no `run.json`) |
| `--yes` | Actually delete (default is dry-run) |
---
@ -306,7 +306,7 @@ Manage GitHub pull requests created by workflow runs. Requires a [GitHub App](/i
### `fabro pr create`
Create a GitHub pull request from a completed workflow run. Uses the run's persisted manifest, conclusion, and diff.
Create a GitHub pull request from a completed workflow run. Uses the run's persisted run record, conclusion, and diff.
```bash
fabro pr create <run-id>
@ -497,7 +497,7 @@ fabro logs -f my-workflow -p
## `fabro inspect`
Show detailed JSON data for a workflow run, including its manifest, conclusion, checkpoint, and sandbox record.
Show detailed JSON data for a workflow run, including its run record, start record, conclusion, checkpoint, and sandbox record.
```bash
fabro inspect <RUN>

View file

@ -19,7 +19,8 @@ The naming format is `YYYYMMDD-{run_id}`, where `run_id` is the ULID assigned to
| File | Format | When written | Description |
|---|---|---|---|
| `manifest.json` | JSON | Run start | Run metadata — `run_id`, `workflow_name`, `goal`, `start_time`, `node_count`, `edge_count`, `run_branch`, `base_sha`, `labels` |
| `run.json` | JSON | Run create | Run metadata — `run_id`, `created_at`, `config` (FabroConfig), `graph` (Graph), `workflow_slug`, `working_directory`, `host_repo_path`, `base_branch`, `labels` |
| `start.json` | JSON | Run start | Start metadata — `run_id`, `start_time`, `run_branch`, `base_sha` |
| `graph.fabro` | Graphviz | Run start | Copy of the workflow graph |
| `run.pid` | Text | Run start | Process ID of the running CLI process. Presence indicates the run is active; an orphaned file indicates a crash. |
| `run.toml` | TOML | Run start | Copy of the original workflow file (only when the workflow is defined in TOML) |
@ -62,7 +63,7 @@ Every node gets a `status.json` after completion containing `status`, `notes`, `
**Manager loop nodes:**
Manager nodes that run sub-workflows write a nested `child/` directory containing a full run structure (manifest, checkpoint, nodes, etc.).
Manager nodes that run sub-workflows write a nested `child/` directory containing a full run structure (run.json, start.json, checkpoint, nodes, etc.).
## Other directories
@ -85,7 +86,8 @@ fabro ps --filter workflow=my-workflow
```
~/.fabro/runs/
├── 20260307-01JQXYZ123ABC456DEF789/ # One directory per run
│ ├── manifest.json
│ ├── run.json
│ ├── start.json
│ ├── graph.fabro
│ ├── run.pid
│ ├── run.toml
@ -120,7 +122,8 @@ fabro ps --filter workflow=my-workflow
│ │ └── manager/
│ │ ├── status.json
│ │ └── child/
│ │ ├── manifest.json
│ │ ├── run.json
│ │ ├── start.json
│ │ ├── checkpoint.json
│ │ └── nodes/
│ ├── worktree/ # Git worktree (git checkpoint mode)

View file

@ -41,8 +41,8 @@ pub async fn attach_run(
let mut engine_guard = engine_child.map(EngineChildGuard::new);
let is_tty = std::io::stderr().is_terminal();
let verbose = fabro_workflows::run_spec::RunSpec::load(run_dir)
.map(|spec| spec.verbose)
let verbose = fabro_workflows::run_record::RunRecord::load(run_dir)
.map(|record| record.config.verbose_enabled())
.unwrap_or(false);
let mut progress_ui = run_progress::ProgressUI::new(is_tty, verbose);

View file

@ -2,15 +2,68 @@ use std::path::PathBuf;
use chrono::Utc;
use fabro_config::config::FabroConfig;
use fabro_workflows::manifest::Manifest;
use fabro_workflows::run_spec::RunSpec;
use fabro_workflows::run_record::RunRecord;
use fabro_workflows::sandbox_provider::SandboxProvider;
use super::run::{
cached_graph_path, default_run_dir, prepare_workflow, write_run_config_snapshot, RunArgs,
};
use fabro_util::terminal::Styles;
/// Create a workflow run: allocate run directory, persist spec, return (run_id, run_dir).
/// CLI flag overrides for config normalization.
#[derive(Debug, Clone, Copy)]
pub(crate) struct CliFlags {
pub dry_run: bool,
pub auto_approve: bool,
pub no_retro: bool,
pub verbose: bool,
pub preserve_sandbox: bool,
}
impl From<&RunArgs> for CliFlags {
fn from(args: &RunArgs) -> Self {
Self {
dry_run: args.dry_run,
auto_approve: args.auto_approve,
no_retro: args.no_retro,
verbose: args.verbose,
preserve_sandbox: args.preserve_sandbox,
}
}
}
/// Build a normalized FabroConfig that captures the full execution intent.
///
/// Folds resolved model/provider/sandbox/goal and CLI flag overrides back into
/// a single FabroConfig so the RunRecord is self-contained.
pub(crate) fn normalize_config(
run_cfg: Option<&FabroConfig>,
run_defaults: &FabroConfig,
model: &str,
provider: Option<&str>,
sandbox_provider: SandboxProvider,
graph: &fabro_graphviz::graph::Graph,
flags: CliFlags,
) -> FabroConfig {
let mut config = run_cfg.cloned().unwrap_or_else(|| run_defaults.clone());
// Ensure resolved values are written back into config
config.llm.get_or_insert_default().model = Some(model.to_string());
config.llm.get_or_insert_default().provider = provider.map(String::from);
config.sandbox.get_or_insert_default().provider = Some(sandbox_provider.to_string());
let goal = graph.goal().to_string();
config.goal = if goal.is_empty() { None } else { Some(goal) };
// CLI flag overrides
config.dry_run = Some(flags.dry_run);
config.auto_approve = Some(flags.auto_approve);
config.no_retro = Some(flags.no_retro);
config.verbose = Some(flags.verbose);
if flags.preserve_sandbox {
config.sandbox.get_or_insert_default().preserve = Some(true);
}
config
}
/// Create a workflow run: allocate run directory, persist RunRecord, return (run_id, run_dir).
///
/// This does NOT execute the workflow — it only prepares the run directory.
pub async fn create_run(
@ -19,23 +72,9 @@ pub async fn create_run(
styles: &Styles,
quiet: bool,
) -> anyhow::Result<(String, PathBuf)> {
let workflow_path = args
.workflow
.as_ref()
.ok_or_else(|| anyhow::anyhow!("--workflow is required"))?;
let mut prep = prepare_workflow(args, run_defaults, styles, quiet)?;
// Collect graph-derived data before moving fields out of prep
let goal = prep.graph().goal().to_string();
let workflow_name = if prep.graph().name.is_empty() {
"unnamed".to_string()
} else {
prep.graph().name.clone()
};
let node_count = prep.graph().nodes.len();
let edge_count = prep.graph().edges.len();
let prep = prepare_workflow(args, run_defaults, styles, quiet)?;
let dot_source = prep.source().to_string();
let graph = prep.graph().clone();
// Create run directory
let run_id = args
@ -58,10 +97,10 @@ pub async fn create_run(
None,
);
// Serialize the merged run config so the run dir is self-contained.
write_run_config_snapshot(&run_dir, prep.run_cfg.as_mut()).await?;
// Serialize the merged run config so the run dir is self-contained (debug artifact).
write_run_config_snapshot(&run_dir, prep.run_cfg.as_ref()).await?;
// Build and save RunSpec
// Build normalized config and RunRecord
let working_directory = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let labels: std::collections::HashMap<String, String> = args
.label
@ -69,46 +108,32 @@ pub async fn create_run(
.filter_map(|s| s.split_once('='))
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let spec = RunSpec {
run_id: run_id.clone(),
workflow_path: std::fs::canonicalize(workflow_path).unwrap_or(workflow_path.clone()),
dot_source,
working_directory: working_directory.clone(),
goal: if goal.is_empty() {
None
} else {
Some(goal.clone())
},
model: prep.model,
provider: prep.provider,
sandbox_provider: prep.sandbox_provider.to_string(),
labels: labels.clone(),
verbose: args.verbose,
no_retro: args.no_retro,
preserve_sandbox: args.preserve_sandbox,
dry_run: args.dry_run,
auto_approve: args.auto_approve,
};
spec.save(&run_dir)?;
let config = normalize_config(
prep.run_cfg.as_ref(),
&prep.run_defaults,
&prep.model,
prep.provider.as_deref(),
prep.sandbox_provider,
&graph,
CliFlags::from(args),
);
let base_branch = fabro_sandbox::daytona::detect_repo_info(&working_directory)
.ok()
.and_then(|(_, branch)| branch);
let manifest = Manifest {
let record = RunRecord {
run_id: run_id.clone(),
workflow_name,
goal,
start_time: Utc::now(),
node_count,
edge_count,
run_branch: None,
base_sha: None,
labels,
base_branch,
created_at: Utc::now(),
config,
graph,
workflow_slug: prep.workflow_slug.clone(),
working_directory: working_directory.clone(),
host_repo_path: Some(working_directory.to_string_lossy().to_string()),
base_branch,
labels,
};
manifest.save(&run_dir.join("manifest.json"))?;
record.save(&run_dir)?;
Ok((run_id, run_dir))
}

View file

@ -99,9 +99,9 @@ impl Drop for DetachedRunCompletionGuard {
}
pub(crate) fn load_run_id(run_dir: &Path) -> Option<String> {
fabro_workflows::run_spec::RunSpec::load(run_dir)
fabro_workflows::run_record::RunRecord::load(run_dir)
.ok()
.map(|spec| spec.run_id)
.map(|record| record.run_id)
.filter(|run_id| !run_id.trim().is_empty())
.or_else(|| {
std::fs::read_to_string(run_dir.join("id.txt"))

View file

@ -48,10 +48,10 @@ async fn resolve_diff(run_dir: &Path, args: &DiffArgs) -> Result<String> {
});
}
let manifest = fabro_workflows::manifest::Manifest::load(&run_dir.join("manifest.json"))
.context("Failed to load manifest.json")?;
let start = fabro_workflows::start_record::StartRecord::load(run_dir)
.context("Failed to load start.json")?;
let base_sha = manifest
let base_sha = start
.base_sha
.as_deref()
.ok_or_else(|| anyhow::anyhow!("This run was not git-checkpointed; no diff available"))?;

View file

@ -15,7 +15,8 @@ pub struct InspectOutput {
pub run_id: String,
pub run_dir: PathBuf,
pub status: fabro_workflows::run_status::RunStatus,
pub manifest: Option<serde_json::Value>,
pub run_record: Option<serde_json::Value>,
pub start_record: Option<serde_json::Value>,
pub conclusion: Option<serde_json::Value>,
pub checkpoint: Option<serde_json::Value>,
pub sandbox: Option<serde_json::Value>,
@ -35,7 +36,10 @@ fn inspect_run_dir(
run_dir: &Path,
status: fabro_workflows::run_status::RunStatus,
) -> Result<InspectOutput> {
let manifest = fabro_workflows::manifest::Manifest::load(&run_dir.join("manifest.json"))
let run_record = fabro_workflows::run_record::RunRecord::load(run_dir)
.ok()
.and_then(|v| serde_json::to_value(v).ok());
let start_record = fabro_workflows::start_record::StartRecord::load(run_dir)
.ok()
.and_then(|v| serde_json::to_value(v).ok());
let conclusion =
@ -55,7 +59,8 @@ fn inspect_run_dir(
run_id: run_id.to_string(),
run_dir: run_dir.to_path_buf(),
status,
manifest,
run_record,
start_record,
conclusion,
checkpoint,
sandbox,

View file

@ -325,8 +325,11 @@ async fn create_from(
) -> Result<()> {
let run_dir = fabro_workflows::run_lookup::resolve_run(base, &args.run_id)?.path;
let manifest = fabro_workflows::manifest::Manifest::load(&run_dir.join("manifest.json"))
.context("Failed to load manifest.json")?;
let record = fabro_workflows::run_record::RunRecord::load(&run_dir)
.context("Failed to load run.json")?;
let start = fabro_workflows::start_record::StartRecord::load(&run_dir)
.context("Failed to load start.json")?;
let conclusion =
fabro_workflows::conclusion::Conclusion::load(&run_dir.join("conclusion.json"))
@ -338,7 +341,7 @@ async fn create_from(
status => bail!("Run status is '{status}', expected success or partial_success"),
}
let run_branch = manifest
let run_branch = start
.run_branch
.as_deref()
.context("Run has no run_branch — was it run with git push enabled?")?;
@ -353,7 +356,7 @@ async fn create_from(
let (origin_url, detected_branch) =
fabro_sandbox::daytona::detect_repo_info(&cwd).map_err(|err| anyhow::anyhow!("{err}"))?;
let base_branch = manifest
let base_branch = record
.base_branch
.as_deref()
.or(detected_branch.as_deref())
@ -393,7 +396,7 @@ async fn create_from(
&origin_url,
base_branch,
run_branch,
&manifest.goal,
record.goal(),
&diff,
&model,
true,

View file

@ -206,6 +206,9 @@ async fn prepare_from_checkpoint(
let run_cfg = prepared.run_cfg;
let sandbox_provider = prepared.sandbox_provider;
let workflow_slug = prepared.workflow_slug;
let prepared_model = prepared.model;
let prepared_provider = prepared.provider;
let prepared_run_defaults = prepared.run_defaults;
eprintln!(
"{} {} from checkpoint {}",
@ -224,14 +227,48 @@ async fn prepare_from_checkpoint(
.context("Failed to activate per-run log")?;
let status_guard = DetachedRunBootstrapGuard::arm(&run_dir)?;
tokio::fs::write(run_dir.join("graph.fabro"), &source).await?;
let mut run_cfg: Option<FabroConfig> = run_cfg;
write_run_config_snapshot(&run_dir, run_cfg.as_mut()).await?;
let run_cfg: Option<FabroConfig> = run_cfg;
write_run_config_snapshot(&run_dir, run_cfg.as_ref()).await?;
// Write RunRecord for the resumed run
{
let working_directory = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let cli_flags = super::create::CliFlags {
dry_run: args.dry_run,
auto_approve: args.auto_approve,
no_retro: args.no_retro,
verbose: args.verbose,
preserve_sandbox: args.preserve_sandbox,
};
let normalized = super::create::normalize_config(
run_cfg.as_ref(),
&prepared_run_defaults,
&prepared_model,
prepared_provider.as_deref(),
sandbox_provider,
&graph,
cli_flags,
);
let record = fabro_workflows::run_record::RunRecord {
run_id: run_id.clone(),
created_at: chrono::Utc::now(),
config: normalized,
graph: graph.clone(),
workflow_slug: workflow_slug.clone(),
working_directory: working_directory.clone(),
host_repo_path: Some(working_directory.to_string_lossy().to_string()),
base_branch: None,
labels: std::collections::HashMap::new(),
};
let _ = record.save(&run_dir);
}
let original_cwd = std::env::current_dir()?;
let emitter = Arc::new(EventEmitter::new());
// Resolve devcontainer BEFORE sandbox creation (mirrors run_command) so that
// the Daytona snapshot config can be overridden with the devcontainer Dockerfile.
let run_defaults = &run_defaults;
let mut daytona_config = resolve_daytona_config(run_cfg.as_ref(), run_defaults);
let devcontainer_config = if run_cfg
.as_ref()
@ -586,8 +623,47 @@ async fn prepare_from_branch(
.context("Failed to activate per-run log")?;
let status_guard = DetachedRunBootstrapGuard::arm(&run_dir)?;
tokio::fs::write(run_dir.join("graph.fabro"), &graph_source).await?;
let mut run_cfg: Option<FabroConfig> = run_cfg;
write_run_config_snapshot(&run_dir, run_cfg.as_mut()).await?;
let run_cfg: Option<FabroConfig> = run_cfg;
write_run_config_snapshot(&run_dir, run_cfg.as_ref()).await?;
// Write RunRecord for the resumed run
{
let (model_str, provider_str) = resolve_model_provider(
args.model.as_deref(),
args.provider.as_deref(),
run_cfg.as_ref(),
run_defaults,
&graph,
);
let cli_flags = super::create::CliFlags {
dry_run: args.dry_run,
auto_approve: args.auto_approve,
no_retro: args.no_retro,
verbose: args.verbose,
preserve_sandbox: args.preserve_sandbox,
};
let normalized = super::create::normalize_config(
run_cfg.as_ref(),
run_defaults,
&model_str,
provider_str.as_deref(),
sandbox_provider,
&graph,
cli_flags,
);
let record = fabro_workflows::run_record::RunRecord {
run_id: run_id.clone(),
created_at: chrono::Utc::now(),
config: normalized,
graph: graph.clone(),
workflow_slug: workflow_slug.clone(),
working_directory: resume_repo_path.clone(),
host_repo_path: Some(resume_repo_path.to_string_lossy().to_string()),
base_branch: detected_base_branch.clone(),
labels: std::collections::HashMap::new(),
};
let _ = record.save(&run_dir);
}
let emitter = Arc::new(EventEmitter::new());

View file

@ -24,7 +24,6 @@ use fabro_workflows::engine::{RunConfig, WorkflowRunEngine};
use fabro_workflows::event::{EventEmitter, RunNoticeLevel, WorkflowRunEvent};
use fabro_workflows::git::GitSyncStatus;
use fabro_workflows::handler::default_registry;
use fabro_workflows::manifest::Manifest;
use fabro_workflows::outcome::{Outcome, OutcomeExt, StageStatus};
use fabro_workflows::run_status::{RunStatus, StatusReason};
use fabro_workflows::sandbox_provider::SandboxProvider;
@ -496,14 +495,15 @@ pub(crate) fn cached_run_config_path(run_dir: &Path) -> PathBuf {
run_dir.join(RUN_CONFIG_FILE)
}
fn serialize_run_config_snapshot(run_cfg: &mut FabroConfig) -> anyhow::Result<String> {
run_cfg.graph = Some(RUN_GRAPH_FILE.to_string());
toml::to_string_pretty(run_cfg).context("Failed to serialize run config")
fn serialize_run_config_snapshot(run_cfg: &FabroConfig) -> anyhow::Result<String> {
let mut snapshot = run_cfg.clone();
snapshot.graph = Some(RUN_GRAPH_FILE.to_string());
toml::to_string_pretty(&snapshot).context("Failed to serialize run config")
}
pub(crate) async fn write_run_config_snapshot(
run_dir: &Path,
run_cfg: Option<&mut FabroConfig>,
run_cfg: Option<&FabroConfig>,
) -> anyhow::Result<()> {
if let Some(cfg) = run_cfg {
let toml_str = serialize_run_config_snapshot(cfg)?;
@ -786,21 +786,28 @@ pub async fn run_command(
}
// 3. Create logs directory
// Extract values from args before partial move
let dry_run_flag = args.dry_run;
let auto_approve_flag = args.auto_approve;
let no_retro_flag = args.no_retro;
let verbose_flag = args.verbose;
let preserve_sandbox_flag = args.preserve_sandbox;
let label_vec = args.label.clone();
let run_id = args.run_id.unwrap_or_else(|| ulid::Ulid::new().to_string());
let run_dir = args
.run_dir
.unwrap_or_else(|| default_run_dir(&run_id, args.dry_run));
.unwrap_or_else(|| default_run_dir(&run_id, dry_run_flag));
tokio::fs::create_dir_all(&run_dir).await?;
let cached_run_restart = is_cached_run_restart(workflow_path, &run_dir);
let existing_manifest = if cached_run_restart {
Manifest::load(&run_dir.join("manifest.json")).ok()
let existing_record = if cached_run_restart {
fabro_workflows::run_record::RunRecord::load(&run_dir).ok()
} else {
None
};
let workflow_slug = if cached_run_restart {
existing_manifest
existing_record
.as_ref()
.and_then(|manifest| manifest.workflow_slug.clone())
.and_then(|r| r.workflow_slug.clone())
} else {
prepared_workflow_slug
};
@ -809,7 +816,7 @@ pub async fn run_command(
tokio::fs::write(cached_graph_path(&run_dir), &source).await?;
let mut status_guard = DetachedRunBootstrapGuard::arm(&run_dir)?;
// Serialize the merged run config so the run dir is self-contained.
// Serialize the merged run config so the run dir is self-contained (debug artifact).
// Skip when the workflow path is already the cached run.toml (i.e. _run_engine
// restart) — create_run already wrote the correct snapshot and re-writing here
// would persist a double-merged config (merge_overlay ran again on load).
@ -817,9 +824,43 @@ pub async fn run_command(
.file_name()
.is_some_and(|f| f == RUN_CONFIG_FILE);
if !is_cached_snapshot {
// env refs (${env.VARNAME}) are still unresolved at this point, so
// plaintext secrets are never written to disk.
write_run_config_snapshot(&run_dir, run_cfg.as_mut()).await?;
write_run_config_snapshot(&run_dir, run_cfg.as_ref()).await?;
}
// Write RunRecord (replaces spec.json + manifest.json)
if !cached_run_restart {
let cli_flags = super::create::CliFlags {
dry_run: dry_run_flag,
auto_approve: auto_approve_flag,
no_retro: no_retro_flag,
verbose: verbose_flag,
preserve_sandbox: preserve_sandbox_flag,
};
let normalized_config = super::create::normalize_config(
run_cfg.as_ref(),
&run_defaults,
&model,
provider.as_deref(),
sandbox_provider,
&graph,
cli_flags,
);
let record = fabro_workflows::run_record::RunRecord {
run_id: run_id.clone(),
created_at: chrono::Utc::now(),
config: normalized_config,
graph: graph.clone(),
workflow_slug: workflow_slug.clone(),
working_directory: original_cwd.clone(),
host_repo_path: Some(original_cwd.to_string_lossy().to_string()),
base_branch: detected_base_branch.clone(),
labels: label_vec
.iter()
.filter_map(|s| s.split_once('='))
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
};
record.save(&run_dir)?;
}
// Now resolve ${env.VARNAME} references for runtime use.
@ -831,7 +872,7 @@ pub async fn run_command(
let is_tty = std::io::stderr().is_terminal();
let progress_ui = Arc::new(Mutex::new(run_progress::ProgressUI::new(
is_tty,
args.verbose,
verbose_flag,
)));
{
let mut ui = progress_ui.lock().expect("progress lock poisoned");
@ -915,7 +956,7 @@ pub async fn run_command(
run_progress::ProgressUI::register(&progress_ui, &emitter);
// 4. Build interviewer
let interviewer: Arc<dyn Interviewer> = if args.auto_approve {
let interviewer: Arc<dyn Interviewer> = if auto_approve_flag {
Arc::new(AutoApproveInterviewer)
} else if !std::io::stdin().is_terminal() {
// Detached mode (stdin is /dev/null): use file-based IPC so the
@ -982,7 +1023,7 @@ pub async fn run_command(
}
// Auto-push when the execution environment needs commits on the remote.
if !args.dry_run
if !dry_run_flag
&& matches!(
workdir_strategy,
WorkdirStrategy::LocalWorktree | WorkdirStrategy::Cloud
@ -1362,7 +1403,7 @@ pub async fn run_command(
*deferred_sandbox.lock().unwrap() = Some(Arc::clone(&sandbox));
// 6. Resolve backend, model, and provider
let (dry_run_mode, llm_client) = if args.dry_run {
let (dry_run_mode, llm_client) = if dry_run_flag {
(true, None)
} else {
match fabro_llm::client::Client::from_env().await {
@ -1529,15 +1570,14 @@ pub async fn run_command(
dry_run: dry_run_mode,
run_id: run_id.clone(),
git_checkpoint_enabled: worktree_path.is_some(),
host_repo_path: existing_manifest
host_repo_path: existing_record
.as_ref()
.and_then(|manifest| manifest.host_repo_path.as_deref().map(PathBuf::from))
.and_then(|r| r.host_repo_path.as_deref().map(PathBuf::from))
.or_else(|| Some(original_cwd.clone())),
base_sha: worktree_base_sha,
run_branch: worktree_branch,
meta_branch,
labels: args
.label
labels: label_vec
.iter()
.filter_map(|s| s.split_once('='))
.map(|(k, v)| (k.to_string(), v.to_string()))
@ -1545,9 +1585,9 @@ pub async fn run_command(
checkpoint_exclude_globs,
github_app: github_app.clone(),
git_author,
base_branch: existing_manifest
base_branch: existing_record
.as_ref()
.and_then(|manifest| manifest.base_branch.clone())
.and_then(|r| r.base_branch.clone())
.or(detected_base_branch),
pull_request: run_cfg
.as_ref()
@ -1617,7 +1657,7 @@ pub async fn run_command(
);
// Auto-derive retro (always, cheap) and optionally run retro agent
if !args.no_retro && project_config::is_retro_enabled() {
if !no_retro_flag && project_config::is_retro_enabled() {
let failed = match &engine_result {
Ok(ref o) => o.status == StageStatus::Fail,
Err(_) => true,

View file

@ -24,7 +24,7 @@ pub struct RunFilterArgs {
#[arg(long = "label", value_name = "KEY=VALUE")]
pub label: Vec<String>,
/// Include orphan directories (no manifest.json)
/// Include orphan directories (no run.json)
#[arg(long)]
pub orphans: bool,
}

View file

@ -7,7 +7,7 @@ use super::detached_support::persist_detached_failure;
/// Spawn a detached engine process for the given run directory.
///
/// The engine process reads `spec.json` from the run directory and executes the
/// The engine process reads `run.json` from the run directory and executes the
/// workflow. Returns the child process handle (use `.id()` for the PID).
pub fn start_run(run_dir: &Path) -> Result<std::process::Child> {
// Validate status is Submitted
@ -22,9 +22,9 @@ pub fn start_run(run_dir: &Path) -> Result<std::process::Child> {
_ => {} // No status file or Submitted — proceed
}
// Validate spec.json is loadable
fabro_workflows::run_spec::RunSpec::load(run_dir)
.map_err(|e| anyhow::anyhow!("Cannot start run: failed to load spec.json: {e}"))?;
// Validate run.json is loadable
fabro_workflows::run_record::RunRecord::load(run_dir)
.map_err(|e| anyhow::anyhow!("Cannot start run: failed to load run.json: {e}"))?;
// Write Starting status before spawning to prevent duplicate engines
fabro_workflows::run_status::write_run_status(run_dir, RunStatus::Starting, None);
@ -102,27 +102,28 @@ fn kill_child_best_effort(child: &mut std::process::Child) {
#[cfg(test)]
mod tests {
use super::*;
use fabro_workflows::run_spec::RunSpec;
use chrono::Utc;
use fabro_config::config::FabroConfig;
use fabro_graphviz::graph::Graph;
use fabro_workflows::run_record::RunRecord;
use fabro_workflows::run_status::{write_run_status, RunStatus, RunStatusRecord, StatusReason};
use std::collections::HashMap;
use std::path::PathBuf;
fn sample_spec() -> RunSpec {
RunSpec {
fn sample_record() -> RunRecord {
RunRecord {
run_id: "run-test123".to_string(),
workflow_path: PathBuf::from("/tmp/test-workflow.toml"),
dot_source: "digraph { a -> b }".to_string(),
created_at: Utc::now(),
config: FabroConfig::default(),
graph: Graph {
name: "test".to_string(),
..Default::default()
},
workflow_slug: None,
working_directory: PathBuf::from("/tmp"),
goal: None,
model: "claude-sonnet-4-20250514".to_string(),
provider: Some("anthropic".to_string()),
sandbox_provider: "local".to_string(),
host_repo_path: None,
base_branch: None,
labels: HashMap::new(),
verbose: false,
no_retro: true,
preserve_sandbox: false,
dry_run: false,
auto_approve: true,
}
}
@ -130,7 +131,7 @@ mod tests {
fn start_run_marks_failed_when_spawn_cannot_start_engine() {
let dir = tempfile::tempdir().unwrap();
write_run_status(dir.path(), RunStatus::Submitted, None);
sample_spec().save(dir.path()).unwrap();
sample_record().save(dir.path()).unwrap();
std::fs::create_dir(dir.path().join("detach.log")).unwrap();
let _ = start_run(dir.path());

View file

@ -324,23 +324,24 @@ async fn run_engine_entrypoint(
cli_config.git_author().and_then(|a| a.email.clone()),
);
let spec = match fabro_workflows::run_spec::RunSpec::load(&run_dir) {
Ok(spec) => spec,
let record = match fabro_workflows::run_record::RunRecord::load(&run_dir) {
Ok(record) => record,
Err(err) => {
let anyhow_err: anyhow::Error = anyhow::anyhow!("Failed to load run record: {err}");
let _ = commands::detached_support::persist_detached_failure(
&run_dir,
"bootstrap",
fabro_workflows::run_status::StatusReason::BootstrapFailed,
&err,
&anyhow_err,
);
return Err(err);
return Err(anyhow_err);
}
};
if let Err(err) = std::env::set_current_dir(&spec.working_directory).map_err(|e| {
if let Err(err) = std::env::set_current_dir(&record.working_directory).map_err(|e| {
anyhow::anyhow!(
"Failed to set working directory to {}: {e}",
spec.working_directory.display()
record.working_directory.display()
)
}) {
let _ = commands::detached_support::persist_detached_failure(
@ -361,31 +362,47 @@ async fn run_engine_entrypoint(
}
};
let sandbox_provider_str = record
.config
.sandbox
.as_ref()
.and_then(|s| s.provider.as_deref())
.unwrap_or("local");
let run_args = commands::run::RunArgs {
workflow: Some(workflow_path),
run_dir: Some(run_dir.clone()),
dry_run: spec.dry_run,
dry_run: record.config.dry_run_enabled(),
preflight: false,
auto_approve: spec.auto_approve,
goal: spec.goal,
auto_approve: record.config.auto_approve_enabled(),
goal: record.config.goal.clone(),
goal_file: None,
model: Some(spec.model),
provider: Some(spec.provider.unwrap_or_default()).filter(|s| !s.is_empty()),
verbose: spec.verbose,
sandbox: spec
.sandbox_provider
model: record.config.llm.as_ref().and_then(|l| l.model.clone()),
provider: record
.config
.llm
.as_ref()
.and_then(|l| l.provider.clone())
.filter(|s| !s.is_empty()),
verbose: record.config.verbose_enabled(),
sandbox: sandbox_provider_str
.parse::<fabro_workflows::sandbox_provider::SandboxProvider>()
.ok()
.map(commands::run::CliSandboxProvider::from),
label: spec
label: record
.labels
.into_iter()
.map(|(k, v)| format!("{k}={v}"))
.collect(),
no_retro: spec.no_retro,
preserve_sandbox: spec.preserve_sandbox,
no_retro: record.config.no_retro_enabled(),
preserve_sandbox: record
.config
.sandbox
.as_ref()
.and_then(|s| s.preserve)
.unwrap_or(false),
detach: false,
run_id: Some(spec.run_id),
run_id: Some(record.run_id),
};
match commands::run::run_command(run_args, cli_config, styles, github_app, git_author).await {

View file

@ -599,6 +599,40 @@ fn setup_run_dir(
)
.unwrap();
// run.json (RunRecord) for resolve_run and run_engine_entrypoint
let run_record = serde_json::json!({
"run_id": run_id,
"created_at": "2026-01-01T00:00:00Z",
"config": {
"goal": spec.get("goal").and_then(|v| v.as_str()).map(String::from),
"llm": {
"model": spec.get("model").and_then(|v| v.as_str()),
"provider": spec.get("provider").and_then(|v| v.as_str())
},
"sandbox": {
"provider": spec.get("sandbox_provider").and_then(|v| v.as_str()),
"preserve": spec.get("preserve_sandbox").and_then(|v| v.as_bool())
},
"verbose": spec.get("verbose").and_then(|v| v.as_bool()),
"dry_run": spec.get("dry_run").and_then(|v| v.as_bool()),
"auto_approve": spec.get("auto_approve").and_then(|v| v.as_bool()),
"no_retro": spec.get("no_retro").and_then(|v| v.as_bool())
},
"graph": {
"name": "test",
"nodes": {},
"edges": [],
"attrs": {}
},
"working_directory": spec.get("working_directory").and_then(|v| v.as_str()).unwrap_or("/tmp"),
"labels": spec.get("labels").cloned().unwrap_or(serde_json::json!({}))
});
std::fs::write(
run_dir.join("run.json"),
serde_json::to_string(&run_record).unwrap(),
)
.unwrap();
// progress.jsonl
std::fs::write(run_dir.join("progress.jsonl"), progress_lines.join("\n")).unwrap();
@ -811,12 +845,11 @@ digraph BarBaz {
resumed_runs_dir.display()
)
});
let manifest: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(resumed_run_dir.join("manifest.json")).unwrap(),
)
.unwrap();
assert_eq!(manifest["workflow_name"].as_str(), Some("BarBaz"));
assert_eq!(manifest["workflow_slug"].as_str(), Some("sluggy"));
let run_record: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(resumed_run_dir.join("run.json")).unwrap())
.unwrap();
assert_eq!(run_record["graph"]["name"].as_str(), Some("BarBaz"));
assert_eq!(run_record["workflow_slug"].as_str(), Some("sluggy"));
}
#[test]
@ -840,8 +873,8 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
let run_dir = find_run_dir(home.path(), run_id);
assert!(
run_dir.join("manifest.json").exists(),
"create should persist manifest.json so the run is discoverable"
run_dir.join("run.json").exists(),
"create should persist run.json so the run is discoverable"
);
arc()

View file

@ -367,20 +367,23 @@ fn scenario_full_stack(sandbox: &str) {
"duration_ms should be > 0"
);
// Manifest should have key fields
// RunRecord should have key fields
let run_record = read_json(&run_dir.join("run.json"));
assert!(
run_record["run_id"].as_str().is_some(),
"run record should have run_id"
);
assert!(
run_record["graph"]["name"].as_str().is_some(),
"run record should have graph.name"
);
// Manifest should still be written (legacy)
let manifest = read_json(&run_dir.join("manifest.json"));
assert!(
manifest["run_id"].as_str().is_some(),
"manifest should have run_id"
);
assert!(
manifest["goal"].as_str().is_some(),
"manifest should have goal"
);
assert!(
manifest["workflow_name"].as_str().is_some(),
"manifest should have workflow_name"
);
// Progress events
assert!(
@ -722,15 +725,15 @@ fn local_run_lifecycle() {
"workflow_name should be CommandPipeline"
);
// 3. inspect <run_id> — JSON array with manifest and conclusion
// 3. inspect <run_id> — JSON array with run_record and conclusion
let inspect_out = fabro_home(&["inspect", &run_id]).success();
let inspect_stdout = String::from_utf8(inspect_out.get_output().stdout.clone()).unwrap();
let items: Vec<Value> =
serde_json::from_str(&inspect_stdout).expect("inspect should produce a JSON array");
assert!(!items.is_empty(), "inspect should return at least one item");
assert!(
items[0]["manifest"].is_object(),
"inspect should include manifest"
items[0]["run_record"].is_object(),
"inspect should include run_record"
);
assert!(
items[0]["conclusion"].is_object(),

View file

@ -87,6 +87,15 @@ pub struct FabroConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upgrade_check: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dry_run: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_approve: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub no_retro: Option<bool>,
// --- Server config fields ---
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data_dir: Option<PathBuf>,
@ -146,6 +155,18 @@ impl FabroConfig {
self.upgrade_check.unwrap_or(true)
}
pub fn dry_run_enabled(&self) -> bool {
self.dry_run.unwrap_or(false)
}
pub fn auto_approve_enabled(&self) -> bool {
self.auto_approve.unwrap_or(false)
}
pub fn no_retro_enabled(&self) -> bool {
self.no_retro.unwrap_or(false)
}
/// Merge an overlay on top of this base. The overlay takes precedence
/// for simple fields; compound fields (vars, hooks, mcp_servers) are
/// deep-merged with the overlay winning on collision.
@ -310,6 +331,15 @@ impl FabroConfig {
if overlay.upgrade_check.is_some() {
self.upgrade_check = overlay.upgrade_check;
}
if overlay.dry_run.is_some() {
self.dry_run = overlay.dry_run;
}
if overlay.auto_approve.is_some() {
self.auto_approve = overlay.auto_approve;
}
if overlay.no_retro.is_some() {
self.no_retro = overlay.no_retro;
}
// --- Server config fields ---
if overlay.data_dir.is_some() {

View file

@ -54,7 +54,7 @@ pub struct GitHubConfig {
pub permissions: HashMap<String, String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct LlmConfig {
pub model: Option<String>,
pub provider: Option<String>,

View file

@ -321,7 +321,7 @@ impl Edge {
}
/// The parsed workflow graph containing nodes, edges, and graph-level attributes.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Graph {
pub name: String,
pub nodes: HashMap<String, Node>,

View file

@ -18,7 +18,8 @@ const RETRO_SYSTEM_PROMPT: &str = r#"You are a workflow run retrospective analys
You have access to the run's data files:
- `progress.jsonl` the full event stream (stage starts/completions, agent tool calls, errors, retries)
- `checkpoint.json` final execution state with node outcomes
- `manifest.json` run metadata (if available)
- `run.json` run record with config, graph, and metadata (if available)
- `start.json` start record with start time and git info (if available)
## Your task
@ -353,7 +354,12 @@ async fn upload_data_files(
.await
.map_err(|e| anyhow::anyhow!("Failed to create retro data dir: {e}"))?;
let files = ["progress.jsonl", "checkpoint.json", "manifest.json"];
let files = [
"progress.jsonl",
"checkpoint.json",
"run.json",
"start.json",
];
for filename in &files {
let source = run_dir.join(filename);
if source.exists() {

View file

@ -37,8 +37,9 @@ impl RunLifecycle<WorkflowGraph> for DiskLifecycle {
_graph: &WorkflowGraph,
_state: &WfRunState,
) -> fabro_core::error::Result<()> {
// Write manifest.json
// Write manifest.json (legacy) and start.json
engine::write_manifest(&self.run_dir, &self.graph, &self.config);
engine::write_start_record(&self.run_dir, &self.config);
// Write run status as Running
crate::run_status::write_run_status(
&self.run_dir,

View file

@ -63,8 +63,16 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
let dot_source = std::fs::read(self.run_dir.join("graph.fabro"))
.or_else(|_| std::fs::read(self.run_dir.join("graph.dot")))
.unwrap_or_default();
let run_json = std::fs::read(self.run_dir.join("run.json")).ok();
let start_json = std::fs::read(self.run_dir.join("start.json")).ok();
let sandbox_json = std::fs::read(self.run_dir.join("sandbox.json")).ok();
let mut extra_files: Vec<(&str, &[u8])> = Vec::new();
if let Some(ref data) = run_json {
extra_files.push(("run.json", data));
}
if let Some(ref data) = start_json {
extra_files.push(("start.json", data));
}
if let Some(ref data) = sandbox_json {
extra_files.push(("sandbox.json", data));
}

View file

@ -259,6 +259,22 @@ pub(crate) fn write_manifest(
manifest
}
/// Write start.json at the start of a workflow run. Returns the StartRecord.
pub(crate) fn write_start_record(
run_dir: &Path,
config: &RunConfig,
) -> crate::start_record::StartRecord {
let record = crate::start_record::StartRecord {
run_id: config.run_id.clone(),
start_time: Utc::now(),
run_branch: config.run_branch.clone(),
base_sha: config.base_sha.clone(),
};
let _ = std::fs::create_dir_all(run_dir);
let _ = record.save(run_dir);
record
}
/// Return the directory for a node's logs.
///
/// First visit (`visit <= 1`): `{run_dir}/nodes/{node_id}`

View file

@ -409,6 +409,39 @@ impl MetadataStore {
manifest_json: &[u8],
graph_dot: &[u8],
extra_files: &[(&str, &[u8])],
) -> Result<()> {
self.init_run_inner(run_id, manifest_json, graph_dot, None, None, extra_files)
}
/// Initialize a run's metadata branch with manifest, graph DOT, run record, start record,
/// and optional extra files.
pub fn init_run_with_records(
&self,
run_id: &str,
manifest_json: &[u8],
graph_dot: &[u8],
run_record_json: &[u8],
start_record_json: &[u8],
extra_files: &[(&str, &[u8])],
) -> Result<()> {
self.init_run_inner(
run_id,
manifest_json,
graph_dot,
Some(run_record_json),
Some(start_record_json),
extra_files,
)
}
fn init_run_inner(
&self,
run_id: &str,
manifest_json: &[u8],
graph_dot: &[u8],
run_record_json: Option<&[u8]>,
start_record_json: Option<&[u8]>,
extra_files: &[(&str, &[u8])],
) -> Result<()> {
let (store, sig) = self.open_store()?;
let branch = Self::branch_name(run_id);
@ -417,6 +450,12 @@ impl MetadataStore {
.map_err(|e| git_error(format!("ensure_branch failed: {e}")))?;
let mut entries: Vec<(&str, &[u8])> =
vec![("manifest.json", manifest_json), ("graph.fabro", graph_dot)];
if let Some(rr) = run_record_json {
entries.push(("run.json", rr));
}
if let Some(sr) = start_record_json {
entries.push(("start.json", sr));
}
entries.extend_from_slice(extra_files);
let msg = self.commit_message("init run");
bs.write_entries(&entries, &msg)
@ -502,6 +541,36 @@ impl MetadataStore {
}
}
/// Read the run record from the metadata branch. Returns `None` if not found.
pub fn read_run_record(
repo_path: &Path,
run_id: &str,
) -> Result<Option<crate::run_record::RunRecord>> {
match Self::read_file(repo_path, run_id, "run.json")? {
Some(bytes) => {
let record: crate::run_record::RunRecord = serde_json::from_slice(&bytes)
.map_err(|e| git_error(format!("run record deserialize failed: {e}")))?;
Ok(Some(record))
}
None => Ok(None),
}
}
/// Read the start record from the metadata branch. Returns `None` if not found.
pub fn read_start_record(
repo_path: &Path,
run_id: &str,
) -> Result<Option<crate::start_record::StartRecord>> {
match Self::read_file(repo_path, run_id, "start.json")? {
Some(bytes) => {
let record: crate::start_record::StartRecord = serde_json::from_slice(&bytes)
.map_err(|e| git_error(format!("start record deserialize failed: {e}")))?;
Ok(Some(record))
}
None => Ok(None),
}
}
/// Read the graph source from the metadata branch. Tries `graph.fabro` first,
/// then falls back to `graph.dot` for backward compatibility. Returns `None` if not found.
pub fn read_graph_dot(repo_path: &Path, run_id: &str) -> Result<Option<String>> {

View file

@ -112,12 +112,14 @@ pub mod preamble;
pub mod pull_request;
pub mod run_fork;
pub mod run_lookup;
pub mod run_record;
pub mod run_rewind;
pub mod run_spec;
pub mod run_status;
pub mod sandbox_provider;
pub mod sandbox_reconnect;
pub mod sandbox_record;
pub mod start_record;
pub mod stylesheet;
pub mod transform;
pub mod vars;

View file

@ -7,6 +7,7 @@ use tracing::{debug, info};
use fabro_github::{self as github_app, ssh_url_to_https, GitHubAppCredentials};
use crate::conclusion::Conclusion;
use crate::run_record::RunRecord;
use fabro_retro::retro::Retro;
/// Record of a pull request created for a workflow run.
@ -117,8 +118,12 @@ fn format_retro_section(retro: &Retro) -> String {
/// Format the Fabro Details section of the PR body.
///
/// Renders a cost/duration table in a collapsible `<details>` block, and
/// optionally a DOT graph in another `<details>` block.
fn format_arc_details_section(conclusion: &Conclusion, dot_source: Option<&str>) -> String {
/// optionally a workflow graph summary in another `<details>` block.
fn format_arc_details_section(
conclusion: &Conclusion,
run_record: Option<&RunRecord>,
dot_source: Option<&str>,
) -> String {
let mut parts = Vec::new();
parts.push("### Fabro Details".to_string());
parts.push(String::new());
@ -152,8 +157,27 @@ fn format_arc_details_section(conclusion: &Conclusion, dot_source: Option<&str>)
parts.push(String::new());
parts.push("</details>".to_string());
// DOT graph
if let Some(dot) = dot_source {
// Workflow graph summary — prefer RunRecord's graph, fall back to DOT parsing
if let Some(record) = run_record {
let graph_name = format!("{}.fabro", record.workflow_name());
let node_count = record.node_count();
let edge_count = record.edge_count();
parts.push(String::new());
parts.push(format!(
"<details>\n<summary>Ran <code>{graph_name}</code> ({node_count} {} and {edge_count} {})</summary>",
if node_count == 1 { "node" } else { "nodes" },
if edge_count == 1 { "edge" } else { "edges" }
));
if let Some(dot) = dot_source {
parts.push(String::new());
parts.push("```dot".to_string());
parts.push(dot.to_string());
parts.push("```".to_string());
}
parts.push(String::new());
parts.push("</details>".to_string());
} else if let Some(dot) = dot_source {
parts.push(String::new());
// Extract graph name and count nodes/edges for the summary
@ -277,6 +301,7 @@ pub async fn build_pr_body(
let plan_text = read_plan_text(run_dir);
let conclusion = Conclusion::load(&run_dir.join("conclusion.json")).ok();
let retro = Retro::load(run_dir).ok();
let run_record = RunRecord::load(run_dir).ok();
let dot_source = read_dot_source(run_dir);
// Build LLM prompt
@ -320,7 +345,7 @@ pub async fn build_pr_body(
let retro_section = retro.as_ref().map(format_retro_section).unwrap_or_default();
let arc_details_section = conclusion
.as_ref()
.map(|c| format_arc_details_section(c, dot_source.as_deref()))
.map(|c| format_arc_details_section(c, run_record.as_ref(), dot_source.as_deref()))
.unwrap_or_default();
let body = assemble_pr_body(
@ -603,7 +628,7 @@ mod tests {
#[test]
fn format_arc_details_cost_table() {
let conclusion = make_test_conclusion();
let section = format_arc_details_section(&conclusion, None);
let section = format_arc_details_section(&conclusion, None, None);
assert!(section.contains("### Fabro Details"));
assert!(section.contains("Ran 3 stages in 2m 30s for $0.42"));
@ -620,7 +645,7 @@ mod tests {
stage.cost = None;
}
conclusion.total_cost = None;
let section = format_arc_details_section(&conclusion, None);
let section = format_arc_details_section(&conclusion, None, None);
// En-dash for missing costs
assert!(section.contains("| plan | 45s | \u{2013} | 0 |"));
@ -631,7 +656,7 @@ mod tests {
fn format_arc_details_with_dot_graph() {
let conclusion = make_test_conclusion();
let dot = "digraph implement {\n plan [type=\"agent\"]\n code [type=\"agent\"]\n plan -> code\n}\n";
let section = format_arc_details_section(&conclusion, Some(dot));
let section = format_arc_details_section(&conclusion, None, Some(dot));
assert!(section.contains("<code>implement.fabro</code>"));
assert!(section.contains("2 nodes and 1 edge"));
@ -738,7 +763,7 @@ mod tests {
#[test]
fn assemble_conclusion_without_retro() {
let conclusion = make_test_conclusion();
let arc_details = format_arc_details_section(&conclusion, None);
let arc_details = format_arc_details_section(&conclusion, None, None);
let body = assemble_pr_body("Narrative.", None, "", &arc_details);
assert!(body.contains("### Fabro Details"));
@ -751,7 +776,7 @@ mod tests {
let conclusion = make_test_conclusion();
let retro = make_test_retro();
let retro_section = format_retro_section(&retro);
let arc_details = format_arc_details_section(&conclusion, None);
let arc_details = format_arc_details_section(&conclusion, None, None);
let body = assemble_pr_body("Narrative.", None, &retro_section, &arc_details);
assert!(body.contains("### Retro"));

View file

@ -5,6 +5,8 @@ use git2::{Oid, Signature};
use crate::git::MetadataStore;
use crate::manifest::Manifest;
use crate::run_record::RunRecord;
use crate::start_record::StartRecord;
use crate::run_rewind::TimelineEntry;
@ -48,17 +50,27 @@ pub fn execute_fork(
.ensure_branch()
.map_err(|e| anyhow::anyhow!("failed to create metadata branch: {e}"))?;
// Read manifest, graph, and sandbox from source in a single tree lookup
// Read manifest, run record, start record, graph, and sandbox from source
let source_entries = source_bs
.read_entries(&["manifest.json", "graph.fabro", "sandbox.json"])
.read_entries(&[
"manifest.json",
"run.json",
"start.json",
"graph.fabro",
"sandbox.json",
])
.map_err(|e| anyhow::anyhow!("failed to read source metadata: {e}"))?;
let mut manifest_bytes = None;
let mut run_record_bytes = None;
let mut start_record_bytes = None;
let mut graph_bytes = None;
let mut sandbox_bytes = None;
for (path, data) in source_entries {
match path {
"manifest.json" => manifest_bytes = Some(data),
"run.json" => run_record_bytes = Some(data),
"start.json" => start_record_bytes = Some(data),
"graph.fabro" => graph_bytes = Some(data),
"sandbox.json" => sandbox_bytes = Some(data),
_ => {}
@ -69,14 +81,44 @@ pub fn execute_fork(
let graph_bytes =
graph_bytes.ok_or_else(|| anyhow::anyhow!("source run has no graph.fabro"))?;
let now = chrono::Utc::now();
// Update legacy manifest
let mut manifest: Manifest =
serde_json::from_slice(&manifest_bytes).context("failed to parse source manifest.json")?;
manifest.run_id = new_run_id.clone();
manifest.run_branch = Some(new_run_branch.clone());
manifest.start_time = chrono::Utc::now();
manifest.start_time = now;
let new_manifest_bytes =
serde_json::to_vec_pretty(&manifest).context("failed to serialize new manifest")?;
// Create new RunRecord for the forked run
let new_run_record_bytes = if let Some(ref rr_bytes) = run_record_bytes {
let mut run_record: RunRecord =
serde_json::from_slice(rr_bytes).context("failed to parse source run.json")?;
run_record.run_id = new_run_id.clone();
run_record.created_at = now;
Some(serde_json::to_vec_pretty(&run_record).context("failed to serialize new run.json")?)
} else {
None
};
// Create new StartRecord for the forked run
let new_start_record_bytes = if start_record_bytes.is_some() {
let start_record = StartRecord {
run_id: new_run_id.clone(),
start_time: now,
run_branch: Some(new_run_branch.clone()),
base_sha: None,
};
Some(
serde_json::to_vec_pretty(&start_record)
.context("failed to serialize new start.json")?,
)
} else {
None
};
// Read checkpoint from the target metadata commit (not branch tip)
let checkpoint_bytes = store
.read_blob_at(entry.metadata_commit_oid, "checkpoint.json")
@ -94,6 +136,12 @@ pub fn execute_fork(
("graph.fabro", &graph_bytes),
("checkpoint.json", &checkpoint_bytes),
];
if let Some(ref run_record) = new_run_record_bytes {
file_entries.push(("run.json", run_record));
}
if let Some(ref start_record) = new_start_record_bytes {
file_entries.push(("start.json", start_record));
}
if let Some(ref sandbox) = sandbox_bytes {
file_entries.push(("sandbox.json", sandbox));
}
@ -182,6 +230,38 @@ mod tests {
serde_json::to_vec_pretty(&manifest).unwrap()
}
fn make_run_record_json(run_id: &str) -> Vec<u8> {
let record = serde_json::json!({
"run_id": run_id,
"created_at": "2025-01-01T00:00:00Z",
"config": {},
"graph": {
"name": "test_workflow",
"nodes": {
"start": {"id": "start", "attrs": {}},
"build": {"id": "build", "attrs": {}},
"test": {"id": "test", "attrs": {}}
},
"edges": [
{"from": "start", "to": "build", "attrs": {}},
{"from": "build", "to": "test", "attrs": {}}
],
"attrs": {}
},
"working_directory": "/tmp/test",
});
serde_json::to_vec_pretty(&record).unwrap()
}
fn make_start_record_json(run_id: &str) -> Vec<u8> {
let record = serde_json::json!({
"run_id": run_id,
"start_time": "2025-01-01T00:00:00Z",
"run_branch": format!("{}{}", crate::git::RUN_BRANCH_PREFIX, run_id),
});
serde_json::to_vec_pretty(&record).unwrap()
}
/// Set up a source run with the given number of checkpoints.
/// Returns (run_id, vec of run commit OIDs).
fn setup_source_run(store: &Store, run_id: &str, nodes: &[&str]) -> Vec<Oid> {
@ -216,11 +296,18 @@ mod tests {
let bs = BranchStore::new(store, &meta_branch, &sig);
bs.ensure_branch().unwrap();
// Write manifest and graph
// Write manifest, run record, start record, and graph
let manifest = make_manifest_json(run_id);
let run_record = make_run_record_json(run_id);
let start_record = make_start_record_json(run_id);
let graph = b"digraph { start -> build -> test }";
bs.write_entries(
&[("manifest.json", &manifest), ("graph.fabro", graph)],
&[
("manifest.json", &manifest),
("run.json", &run_record),
("start.json", &start_record),
("graph.fabro", graph),
],
"init run",
)
.unwrap();
@ -276,6 +363,20 @@ mod tests {
Some(format!("{}{new_run_id}", crate::git::RUN_BRANCH_PREFIX).as_str())
);
// Check RunRecord has new run_id and updated created_at
let rr_bytes = bs.read_entry("run.json").unwrap().unwrap();
let run_record: RunRecord = serde_json::from_slice(&rr_bytes).unwrap();
assert_eq!(run_record.run_id, new_run_id);
// Check StartRecord has new run_id and updated run_branch
let sr_bytes = bs.read_entry("start.json").unwrap().unwrap();
let start_record: StartRecord = serde_json::from_slice(&sr_bytes).unwrap();
assert_eq!(start_record.run_id, new_run_id);
assert_eq!(
start_record.run_branch.as_deref(),
Some(format!("{}{new_run_id}", crate::git::RUN_BRANCH_PREFIX).as_str())
);
// Check graph exists
let graph_bytes = bs.read_entry("graph.fabro").unwrap().unwrap();
assert_eq!(graph_bytes, b"digraph { start -> build -> test }");

View file

@ -5,7 +5,9 @@ use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use serde::Serialize;
use crate::run_record::RunRecord;
use crate::run_status::{RunStatus, RunStatusRecord, StatusReason};
use crate::start_record::StartRecord;
#[derive(Debug, Clone, Serialize)]
pub struct RunInfo {
@ -66,32 +68,30 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
}
let dir_name = entry.file_name().to_string_lossy().to_string();
let manifest_path = path.join("manifest.json");
if let Ok(manifest) = crate::manifest::Manifest::load(&manifest_path) {
let run_id = manifest.run_id;
let workflow_name = manifest.workflow_name;
let workflow_slug = manifest.workflow_slug;
let host_repo_path = manifest.host_repo_path;
let goal = manifest.goal;
let start_time_dt = manifest.start_time;
if let Ok(record) = RunRecord::load(&path) {
let created_at = record.created_at;
let start_time_dt = StartRecord::load(&path)
.map(|s| s.start_time)
.unwrap_or(created_at);
let start_time = start_time_dt.to_rfc3339();
let labels = manifest.labels;
let workflow_name = record.workflow_name().to_string();
let goal = record.goal().to_string();
let status_info = read_status(&path);
runs.push(RunInfo {
run_id,
run_id: record.run_id,
dir_name,
workflow_name,
workflow_slug,
workflow_slug: record.workflow_slug,
status: status_info.status,
status_reason: status_info.reason,
start_time,
labels,
labels: record.labels,
duration_ms: status_info.duration_ms,
total_cost: status_info.total_cost,
host_repo_path,
start_time_dt: Some(start_time_dt),
host_repo_path: record.host_repo_path,
start_time_dt: Some(created_at),
end_time: status_info.end_time,
path,
goal,
@ -115,7 +115,7 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
run_id,
dir_name,
workflow_name: if is_orphan {
"[no manifest]"
"[no run record]"
} else {
"[starting]"
}
@ -137,7 +137,7 @@ pub fn scan_runs(base: &Path) -> Result<Vec<RunInfo>> {
}
}
runs.sort_by(|a, b| b.start_time.cmp(&a.start_time));
runs.sort_by(|a, b| b.start_time_dt.cmp(&a.start_time_dt));
Ok(runs)
}

View file

@ -0,0 +1,124 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use fabro_config::config::FabroConfig;
use fabro_graphviz::graph::Graph;
use serde::{Deserialize, Serialize};
const FILE_NAME: &str = "run.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecord {
pub run_id: String,
pub created_at: DateTime<Utc>,
pub config: FabroConfig,
pub graph: Graph,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_slug: Option<String>,
pub working_directory: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_repo_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_branch: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
}
impl RunRecord {
pub fn file_name() -> &'static str {
FILE_NAME
}
pub fn save(&self, run_dir: &Path) -> crate::error::Result<()> {
crate::save_json(self, &run_dir.join(FILE_NAME), "run record")
}
pub fn load(run_dir: &Path) -> crate::error::Result<Self> {
crate::load_json(&run_dir.join(FILE_NAME), "run record")
}
/// Workflow name derived from the graph.
pub fn workflow_name(&self) -> &str {
if self.graph.name.is_empty() {
"unnamed"
} else {
&self.graph.name
}
}
/// Goal derived from the graph.
pub fn goal(&self) -> &str {
self.graph.goal()
}
pub fn node_count(&self) -> usize {
self.graph.nodes.len()
}
pub fn edge_count(&self) -> usize {
self.graph.edges.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_record() -> RunRecord {
let graph = Graph {
name: "test_pipeline".to_string(),
..Default::default()
};
RunRecord {
run_id: "run-abc123".to_string(),
created_at: Utc::now(),
config: FabroConfig::default(),
graph,
workflow_slug: Some("smoke".to_string()),
working_directory: PathBuf::from("/home/user/project"),
host_repo_path: Some("/home/user/project".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::from([("env".into(), "test".into())]),
}
}
#[test]
fn save_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let record = sample_record();
record.save(dir.path()).unwrap();
let loaded = RunRecord::load(dir.path()).unwrap();
assert_eq!(loaded.run_id, "run-abc123");
assert_eq!(loaded.workflow_name(), "test_pipeline");
assert_eq!(loaded.workflow_slug.as_deref(), Some("smoke"));
assert_eq!(loaded.labels.get("env").map(String::as_str), Some("test"));
}
#[test]
fn load_nonexistent() {
let dir = PathBuf::from("/tmp/nonexistent-run-record-dir-that-does-not-exist");
assert!(RunRecord::load(&dir).is_err());
}
#[test]
fn labels_omitted_when_empty() {
let dir = tempfile::tempdir().unwrap();
let mut record = sample_record();
record.labels = HashMap::new();
record.host_repo_path = None;
record.base_branch = None;
record.workflow_slug = None;
record.save(dir.path()).unwrap();
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(dir.path().join("run.json")).unwrap())
.unwrap();
assert!(raw.get("labels").is_none());
assert!(raw.get("host_repo_path").is_none());
assert!(raw.get("base_branch").is_none());
assert!(raw.get("workflow_slug").is_none());
}
}

View file

@ -358,6 +358,9 @@ pub fn find_run_id_by_prefix(repo: &Repository, prefix: &str) -> Result<String>
}
/// Load the graph from the metadata branch and build the parallel interior map.
///
/// Tries `run.json` (RunRecord with embedded Graph) first, then falls back to
/// parsing `graph.fabro` DOT source for backward compatibility.
pub fn load_parallel_map(store: &Store, run_id: &str) -> HashMap<String, String> {
let branch = MetadataStore::branch_name(run_id);
let sig = match Signature::now("Fabro", "noreply@fabro.sh") {
@ -365,6 +368,15 @@ pub fn load_parallel_map(store: &Store, run_id: &str) -> HashMap<String, String>
Err(_) => return HashMap::new(),
};
let bs = BranchStore::new(store, &branch, &sig);
// Try run.json first (RunRecord with embedded Graph)
if let Ok(Some(run_bytes)) = bs.read_entry("run.json") {
if let Ok(record) = serde_json::from_slice::<crate::run_record::RunRecord>(&run_bytes) {
return detect_parallel_interior(&record.graph);
}
}
// Fallback: parse graph.fabro DOT
let graph_bytes = match bs.read_entry("graph.fabro") {
Ok(Some(bytes)) => bytes,
_ => return HashMap::new(),

View file

@ -0,0 +1,78 @@
use std::path::Path;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
const FILE_NAME: &str = "start.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StartRecord {
pub run_id: String,
pub start_time: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_sha: Option<String>,
}
impl StartRecord {
pub fn file_name() -> &'static str {
FILE_NAME
}
pub fn save(&self, run_dir: &Path) -> crate::error::Result<()> {
crate::save_json(self, &run_dir.join(FILE_NAME), "start record")
}
pub fn load(run_dir: &Path) -> crate::error::Result<Self> {
crate::load_json(&run_dir.join(FILE_NAME), "start record")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_start_record() -> StartRecord {
StartRecord {
run_id: "run-1".to_string(),
start_time: Utc::now(),
run_branch: Some("fabro/run/run-1".to_string()),
base_sha: Some("abc123".to_string()),
}
}
#[test]
fn save_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let record = sample_start_record();
record.save(dir.path()).unwrap();
let loaded = StartRecord::load(dir.path()).unwrap();
assert_eq!(loaded.run_id, "run-1");
assert_eq!(loaded.run_branch.as_deref(), Some("fabro/run/run-1"));
assert_eq!(loaded.base_sha.as_deref(), Some("abc123"));
}
#[test]
fn load_nonexistent() {
let result = StartRecord::load(Path::new("/nonexistent/dir"));
assert!(result.is_err());
}
#[test]
fn optional_fields_omitted_when_none() {
let dir = tempfile::tempdir().unwrap();
let mut record = sample_start_record();
record.run_branch = None;
record.base_sha = None;
record.save(dir.path()).unwrap();
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(dir.path().join("start.json")).unwrap())
.unwrap();
assert!(raw.get("run_branch").is_none());
assert!(raw.get("base_sha").is_none());
}
}