Collapse store handles onto Slate

This commit is contained in:
Bryan Helmkamp 2026-04-02 23:16:05 -07:00
parent 79cad86ed4
commit 491f326c12
No known key found for this signature in database
55 changed files with 2093 additions and 2304 deletions

View file

@ -37,9 +37,7 @@ async fn create_from(
let run_store = store::open_run_reader(storage_dir, &run.run_id).await?;
let state = run_store.state().await?;
let record = state
.run
.context("Failed to load run record from store")?;
let record = state.run.context("Failed to load run record from store")?;
let start = state
.start

View file

@ -33,7 +33,7 @@ pub(super) async fn list_command(
}
async fn list_from(
store: &dyn fabro_store::Store,
store: &fabro_store::SlateStore,
base: &Path,
args: PrListArgs,
github_app: Option<fabro_github::GitHubAppCredentials>,

View file

@ -10,7 +10,7 @@ use fabro_types::RunId;
use futures::StreamExt;
use fabro_interview::{AnswerValue, ConsoleInterviewer};
use fabro_store::{EventEnvelope, RunStore, RuntimeState};
use fabro_store::{EventEnvelope, RuntimeState, SlateRunStore};
use fabro_util::terminal::Styles;
use fabro_workflow::outcome::StageStatus;
use fabro_workflow::records::{Conclusion, ConclusionExt};
@ -106,7 +106,7 @@ pub(crate) async fn attach_run(
async fn attach_run_store(
run_dir: &Path,
run_store: &dyn RunStore,
run_store: &SlateRunStore,
verbose: bool,
existing_events: Vec<String>,
last_seq: u32,
@ -156,15 +156,12 @@ async fn attach_run_store(
}
// Wait briefly for a terminal status or conclusion
for _ in 0..20 {
if run_store
.state()
.await
.ok()
.is_some_and(|state| {
state.conclusion.is_some()
|| state.status.is_some_and(|record| record.status.is_terminal())
})
{
if run_store.state().await.ok().is_some_and(|state| {
state.conclusion.is_some()
|| state
.status
.is_some_and(|record| record.status.is_terminal())
}) {
break;
}
sleep(Duration::from_millis(100)).await;
@ -288,7 +285,7 @@ async fn attach_run_store(
}
async fn flush_remaining_store_events(
run_store: &dyn RunStore,
run_store: &SlateRunStore,
mut next_seq: u32,
progress_ui: &mut run_progress::ProgressUI,
json_output: bool,
@ -745,7 +742,7 @@ fn determine_exit_code(conclusion_path: &Path, status_record: Option<RunStatusRe
}
}
async fn determine_exit_code_with_store(run_store: &dyn RunStore) -> ExitCode {
async fn determine_exit_code_with_store(run_store: &SlateRunStore) -> ExitCode {
let deadline = Instant::now() + ATTACH_FINAL_STATUS_GRACE;
loop {
match run_store.state().await {

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use anyhow::{Result, anyhow};
use fabro_interview::FileInterviewer;
use fabro_store::{RuntimeState, Store};
use fabro_store::RuntimeState;
use fabro_types::RunId;
use fabro_workflow::event::EventEmitter;
use fabro_workflow::operations::{StartServices, resume as resume_run, start as start_run};
@ -31,8 +31,9 @@ pub(crate) async fn execute(
let store = store::build_store(&storage_dir)?;
let run_store = store.open_run(&run_id).await?;
let run_record = run_store
.get_run()
.state()
.await?
.run
.ok_or_else(|| anyhow!("Run {run_id} has no run record in store"))?;
let on_node: fabro_workflow::OnNodeCallback = Some({
let run_id = run_record.run_id.to_string();

View file

@ -52,7 +52,7 @@ pub(crate) async fn run(args: DiffArgs, globals: &GlobalArgs) -> Result<()> {
async fn resolve_diff(
_run_dir: &Path,
run_store: &dyn fabro_store::RunStore,
run_store: &fabro_store::SlateRunStore,
args: &DiffArgs,
) -> Result<String> {
let state = run_store.state().await?;

View file

@ -21,7 +21,7 @@ pub(crate) async fn run(args: &ForkArgs, styles: &Styles, globals: &GlobalArgs)
let store = Store::new(repo);
let run_store = open_run_reader(&cli_settings.storage_dir(), &run_id).await?;
let timeline = build_timeline_or_rebuild(&store, Some(run_store.as_ref()), &run_id).await?;
let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?;
if args.list {
if globals.json {

View file

@ -5,7 +5,7 @@ use std::time::Duration;
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use fabro_store::RunStore;
use fabro_store::SlateRunStore;
use fabro_util::redact::redact_jsonl_line;
use fabro_util::terminal::Styles;
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
@ -138,7 +138,7 @@ fn try_parse_relative_duration(s: &str) -> Option<chrono::Duration> {
}
async fn follow_store_logs(
run_store: &dyn RunStore,
run_store: &SlateRunStore,
run_dir: &Path,
seq: u32,
pretty: bool,
@ -187,16 +187,19 @@ async fn follow_store_logs(
Ok(())
}
async fn run_concluded(run_store: &dyn RunStore, _run_dir: &Path) -> Result<bool> {
async fn run_concluded(run_store: &SlateRunStore, _run_dir: &Path) -> Result<bool> {
let state = run_store
.state()
.await
.context("Failed to read run state from store while following logs")?;
Ok(state.conclusion.is_some() || state.status.is_some_and(|record| record.status.is_terminal()))
Ok(state.conclusion.is_some()
|| state
.status
.is_some_and(|record| record.status.is_terminal()))
}
async fn flush_remaining_store_events(
run_store: &dyn RunStore,
run_store: &SlateRunStore,
next_seq: u32,
pretty: bool,
styles: &Styles,

View file

@ -198,12 +198,16 @@ pub(crate) fn print_run_conclusion(
}
pub(crate) async fn print_final_output(
run_store: Option<&dyn fabro_store::RunStore>,
run_store: Option<&fabro_store::SlateRunStore>,
_run_dir: &Path,
styles: &Styles,
) {
let checkpoint = match run_store {
Some(run_store) => run_store.state().await.ok().and_then(|state| state.checkpoint),
Some(run_store) => run_store
.state()
.await
.ok()
.and_then(|state| state.checkpoint),
None => None,
};
let Some(checkpoint) = checkpoint else {

View file

@ -10,9 +10,8 @@ use fabro_workflow::operations::{
RewindInput, RewindTarget, RunTimeline, TimelineEntry, build_timeline_or_rebuild,
find_run_id_by_prefix_or_store, rewind,
};
use fabro_workflow::records::{RunRecord, RunRecordExt, StartRecord, StartRecordExt};
use fabro_workflow::records::{RunRecord, RunRecordExt};
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
use fabro_workflow::run_status::RunStatus;
use git2::Repository;
use serde::Serialize;
@ -45,7 +44,7 @@ pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs
.await
.ok();
let timeline = build_timeline_or_rebuild(&store, Some(run_store.as_ref()), &run_id).await?;
let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?;
if args.list || args.target.is_none() {
if globals.json {
@ -68,8 +67,14 @@ pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs
)?;
if let Some(run_info) = run_info.as_ref() {
let entry = timeline.resolve(&target)?;
reset_rewound_run_state(&store, durable_store.as_ref(), &run_id, &run_info.path, entry)
.await?;
reset_rewound_run_state(
&store,
durable_store.as_ref(),
&run_id,
&run_info.path,
entry,
)
.await?;
}
let run_id_string = run_id.to_string();
@ -104,7 +109,7 @@ pub(crate) fn timeline_entries_json(timeline: &RunTimeline) -> Vec<TimelineEntry
async fn reset_rewound_run_state(
git_store: &Store,
durable_store: &dyn fabro_store::Store,
durable_store: &fabro_store::SlateStore,
run_id: &fabro_types::RunId,
run_dir: &std::path::Path,
entry: &TimelineEntry,
@ -113,48 +118,27 @@ async fn reset_rewound_run_state(
.open_run_reader(run_id)
.await
.map_err(|err| anyhow::anyhow!("failed to open durable store run before rewind: {err}"))?;
let store_run_record = existing_run_store.get_run().await.ok().flatten();
let store_start_record = existing_run_store.get_start().await.ok().flatten();
let store_graph = existing_run_store.get_graph().await.ok().flatten();
let state = existing_run_store.state().await.map_err(|err| {
anyhow::anyhow!("failed to load durable store state before rewind: {err}")
})?;
let run_record = store_run_record
let _run_record = state
.run
.or_else(|| RunRecord::load(run_dir).ok())
.context("failed to restore run record after rewind: missing run metadata")?;
let checkpoint = MetadataStore::read_checkpoint(git_store.repo_dir(), &run_id.to_string())?
.context("rewound metadata branch is missing checkpoint.json")?;
let previous_status = existing_run_store
.get_status()
.await
.ok()
.flatten()
.map(|status| status.status.to_string());
let previous_status = state.status.map(|status| status.status.to_string());
let _ = std::fs::remove_file(run_dir.join("detached_failure.json"));
let run_store = durable_store
.open_run(run_id)
.await
.map_err(|err| anyhow::anyhow!("failed to open durable store run for rewind reset: {err}"))?;
let run_store = durable_store.open_run(run_id).await.map_err(|err| {
anyhow::anyhow!("failed to open durable store run for rewind reset: {err}")
})?;
run_store
.reset_for_rewind()
.await
.map_err(|err| anyhow::anyhow!("failed to clear rewound run state: {err}"))?;
run_store
.put_run(&run_record)
.await
.map_err(|err| anyhow::anyhow!("failed to restore run record after rewind: {err}"))?;
if let Some(start_record) = store_start_record.or_else(|| StartRecord::load(run_dir).ok()) {
run_store
.put_start(&start_record)
.await
.map_err(|err| anyhow::anyhow!("failed to restore start record after rewind: {err}"))?;
}
if let Some(dot_source) = store_graph {
run_store
.put_graph(&dot_source)
.await
.map_err(|err| anyhow::anyhow!("failed to restore graph after rewind: {err}"))?;
}
append_workflow_event(
run_store.as_ref(),
run_id,
@ -175,13 +159,6 @@ async fn reset_rewound_run_state(
)
.await
.map_err(|err| anyhow::anyhow!("failed to append restored checkpoint event: {err}"))?;
run_store
.put_status(&fabro_types::RunStatusRecord::new(
RunStatus::Submitted,
None,
))
.await
.map_err(|err| anyhow::anyhow!("failed to restore run status after rewind: {err}"))?;
append_workflow_event(
run_store.as_ref(),
run_id,
@ -189,10 +166,6 @@ async fn reset_rewound_run_state(
)
.await
.map_err(|err| anyhow::anyhow!("failed to append restored run status event: {err}"))?;
run_store
.put_checkpoint(&checkpoint)
.await
.map_err(|err| anyhow::anyhow!("failed to restore checkpoint after rewind: {err}"))?;
Ok(())
}
@ -200,7 +173,10 @@ fn restored_checkpoint_event(checkpoint: &fabro_types::Checkpoint) -> WorkflowRu
let current_status = checkpoint
.node_outcomes
.get(&checkpoint.current_node)
.map_or_else(|| "success".to_string(), |outcome| outcome.status.to_string());
.map_or_else(
|| "success".to_string(),
|outcome| outcome.status.to_string(),
);
WorkflowRunEvent::CheckpointCompleted {
node_id: checkpoint.current_node.clone(),
status: current_status,

View file

@ -33,8 +33,8 @@ pub(crate) async fn run(args: &WaitArgs, styles: &Styles, globals: &GlobalArgs)
let started_waiting_at = std::time::Instant::now();
let final_status = loop {
let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run_info.run_id)
.await?;
let run_store =
store::open_run_reader(&cli_settings.storage_dir(), &run_info.run_id).await?;
let status = run_store.state().await?.status.map(|record| record.status);
let status = status.unwrap_or_else(|| {
if started_waiting_at.elapsed() < WAIT_STARTUP_GRACE {
@ -63,8 +63,7 @@ pub(crate) async fn run(args: &WaitArgs, styles: &Styles, globals: &GlobalArgs)
}
};
let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run_info.run_id)
.await?;
let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run_info.run_id).await?;
let conclusion = run_store.state().await?.conclusion;
if globals.json {

View file

@ -39,50 +39,26 @@ async fn inspect_run_store(
run_id: &RunId,
run_dir: &Path,
status: RunStatus,
run_store: &dyn fabro_store::RunStore,
run_store: &fabro_store::SlateRunStore,
) -> InspectOutput {
if let Ok(state) = run_store.state().await {
if let Some(snapshot) = state.to_snapshot() {
return InspectOutput {
run_id: run_id.to_string(),
run_dir: run_dir.to_path_buf(),
status: state.status.as_ref().map_or(status, |record| record.status),
run_record: serde_json::to_value(snapshot.run).ok(),
start_record: snapshot
.start
.and_then(|record| serde_json::to_value(record).ok()),
conclusion: snapshot
.conclusion
.and_then(|record| serde_json::to_value(record).ok()),
checkpoint: snapshot
.checkpoint
.and_then(|record| serde_json::to_value(record).ok()),
sandbox: snapshot
.sandbox
.and_then(|record| serde_json::to_value(record).ok()),
};
}
}
if let Ok(Some(snapshot)) = run_store.get_snapshot().await {
return InspectOutput {
run_id: run_id.to_string(),
run_dir: run_dir.to_path_buf(),
status: snapshot
.status
.as_ref()
.map_or(status, |record| record.status),
run_record: serde_json::to_value(snapshot.run).ok(),
start_record: snapshot
status: state.status.as_ref().map_or(status, |record| record.status),
run_record: state
.run
.and_then(|record| serde_json::to_value(record).ok()),
start_record: state
.start
.and_then(|record| serde_json::to_value(record).ok()),
conclusion: snapshot
conclusion: state
.conclusion
.and_then(|record| serde_json::to_value(record).ok()),
checkpoint: snapshot
checkpoint: state
.checkpoint
.and_then(|record| serde_json::to_value(record).ok()),
sandbox: snapshot
sandbox: state
.sandbox
.and_then(|record| serde_json::to_value(record).ok()),
};
@ -92,35 +68,10 @@ async fn inspect_run_store(
run_id: run_id.to_string(),
run_dir: run_dir.to_path_buf(),
status,
run_record: run_store
.get_run()
.await
.ok()
.flatten()
.and_then(|v| serde_json::to_value(v).ok()),
start_record: run_store
.get_start()
.await
.ok()
.flatten()
.and_then(|v| serde_json::to_value(v).ok()),
conclusion: run_store
.get_conclusion()
.await
.ok()
.flatten()
.and_then(|v| serde_json::to_value(v).ok()),
checkpoint: run_store
.get_checkpoint()
.await
.ok()
.flatten()
.and_then(|v| serde_json::to_value(v).ok()),
sandbox: run_store
.get_sandbox()
.await
.ok()
.flatten()
.and_then(|v| serde_json::to_value(v).ok()),
run_record: None,
start_record: None,
conclusion: None,
checkpoint: None,
sandbox: None,
}
}

View file

@ -1,19 +1,17 @@
use std::path::Path;
use anyhow::{Context, Result, bail};
use fabro_store::Store;
use fabro_store::SlateStore;
use tracing::warn;
use fabro_sandbox::reconnect::reconnect as reconnect_sandbox;
use fabro_workflow::event::{WorkflowRunEvent, append_workflow_event};
use fabro_workflow::run_lookup::RunInfo;
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
use fabro_workflow::run_status::{RunStatus, RunStatusRecord};
use crate::args::{GlobalArgs, RunsRemoveArgs};
use crate::shared::print_json_pretty;
use crate::store;
use crate::user_config::load_user_settings_with_globals;
use fabro_sandbox::reconnect::reconnect as reconnect_sandbox;
use fabro_workflow::event::{WorkflowRunEvent, append_workflow_event};
use fabro_workflow::run_lookup::RunInfo;
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
use super::short_run_id;
@ -26,7 +24,7 @@ pub(crate) async fn remove_command(args: &RunsRemoveArgs, globals: &GlobalArgs)
async fn remove_from(
args: &RunsRemoveArgs,
store: &dyn Store,
store: &SlateStore,
base: &Path,
globals: &GlobalArgs,
) -> Result<()> {
@ -109,12 +107,12 @@ async fn remove_from(
Ok(())
}
pub(crate) async fn remove_run_with_cleanup(store: &dyn Store, run: &RunInfo) -> Result<()> {
pub(crate) async fn remove_run_with_cleanup(store: &SlateStore, run: &RunInfo) -> Result<()> {
remove_run_dir_with_cleanup(store, run).await?;
delete_run_store_state(store, run).await
}
async fn remove_run_dir_with_cleanup(store: &dyn Store, run: &RunInfo) -> Result<()> {
async fn remove_run_dir_with_cleanup(store: &SlateStore, run: &RunInfo) -> Result<()> {
let run_store = match store.open_run_reader(&run.run_id).await {
Ok(run_store) => Some(run_store),
Err(err) => {
@ -127,16 +125,6 @@ async fn remove_run_dir_with_cleanup(store: &dyn Store, run: &RunInfo) -> Result
}
};
if let Some(run_store) = run_store.as_ref() {
if let Err(err) = run_store
.put_status(&RunStatusRecord::new(RunStatus::Removing, None))
.await
{
warn!(
run_id = %run.run_id,
error = %err,
"failed to save removing status to store"
);
}
if let Err(err) = append_workflow_event(
run_store.as_ref(),
&run.run_id,
@ -171,7 +159,7 @@ async fn remove_run_dir_with_cleanup(store: &dyn Store, run: &RunInfo) -> Result
.with_context(|| format!("failed to delete {}", run.path.display()))
}
async fn delete_run_store_state(store: &dyn Store, run: &RunInfo) -> Result<()> {
async fn delete_run_store_state(store: &SlateStore, run: &RunInfo) -> Result<()> {
store
.delete_run(&run.run_id)
.await
@ -180,7 +168,7 @@ async fn delete_run_store_state(store: &dyn Store, run: &RunInfo) -> Result<()>
async fn load_sandbox_record(
_run_dir: &Path,
run_store: Option<&dyn fabro_store::RunStore>,
run_store: Option<&fabro_store::SlateRunStore>,
) -> Option<fabro_sandbox::SandboxRecord> {
if let Some(run_store) = run_store {
match run_store.state().await {

View file

@ -2,7 +2,7 @@ use std::io::{ErrorKind, Write};
use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, bail};
use fabro_store::{NodeVisitRef, RunSnapshot, RunState, RunStore};
use fabro_store::{NodeVisitRef, RunSnapshot, RunState, SlateRunStore};
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
use serde::Serialize;
#[cfg(test)]
@ -37,7 +37,7 @@ pub(crate) async fn dump_command(args: &StoreDumpArgs, globals: &GlobalArgs) ->
Ok(())
}
pub(crate) async fn export_run(run_store: &dyn RunStore, output_dir: &Path) -> Result<usize> {
pub(crate) async fn export_run(run_store: &SlateRunStore, output_dir: &Path) -> Result<usize> {
let state = run_store.state().await?;
let snapshot = state
.to_snapshot()
@ -78,7 +78,7 @@ pub(crate) async fn export_run(run_store: &dyn RunStore, output_dir: &Path) -> R
}
async fn export_run_to_dir(
run_store: &dyn RunStore,
run_store: &SlateRunStore,
state: &RunState,
snapshot: &RunSnapshot,
output_dir: &Path,
@ -349,14 +349,18 @@ mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use chrono::{DateTime, Utc};
use fabro_store::{EventEnvelope, EventPayload, InMemoryStore, Store as _};
use fabro_store::{EventEnvelope, EventPayload, SlateStore};
use fabro_types::{
AggregateStats, AttrValue, Checkpoint, Conclusion, Graph, NodeStatusRecord, Retro, RunId,
RunRecord, RunStatus, RunStatusRecord, SandboxRecord, Settings, StageStatus, StartRecord,
StatusReason, fixtures,
};
use fabro_workflow::event::{WorkflowRunEvent, append_workflow_event};
use object_store::memory::InMemory;
fn dt(rfc3339: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(rfc3339)
@ -368,6 +372,14 @@ mod tests {
fixtures::RUN_1
}
fn test_store() -> Arc<SlateStore> {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
fn sample_run_record(run_id: RunId, created_at: DateTime<Utc>) -> RunRecord {
let mut graph = Graph::new("night-sky");
graph.attrs.insert(
@ -476,82 +488,256 @@ mod tests {
}
}
fn sample_node_status() -> NodeStatusRecord {
NodeStatusRecord {
status: StageStatus::PartialSuccess,
notes: Some("captured output".to_string()),
failure_reason: Some("minor lint".to_string()),
timestamp: dt("2026-03-27T12:12:00Z"),
}
}
fn event_payload(run_id: RunId, ts: &str, event: &str) -> EventPayload {
EventPayload::new(
serde_json::json!({
"id": format!("evt-{run_id}-{event}"),
"ts": ts,
"run_id": run_id.to_string(),
"event": event
}),
&run_id,
)
.unwrap()
}
fn read_json<T: DeserializeOwned>(path: &Path) -> T {
serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
let bytes = std::fs::read(path)
.unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
serde_json::from_slice(&bytes)
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()))
}
#[tokio::test]
async fn export_run_writes_expected_directory_tree() {
let store = InMemoryStore::default();
let store = test_store();
let created_at = dt("2026-03-27T12:00:00Z");
let run_id = test_run_id();
let run = store.create_run(&run_id, created_at, None).await.unwrap();
run.put_run(&sample_run_record(run_id, created_at))
.await
.unwrap();
run.put_start(&sample_start_record(run_id, created_at))
.await
.unwrap();
run.put_status(&sample_status()).await.unwrap();
run.append_checkpoint(&sample_checkpoint("plan", 1))
.await
.unwrap();
run.append_checkpoint(&sample_checkpoint("code", 2))
.await
.unwrap();
run.put_conclusion(&sample_conclusion()).await.unwrap();
run.put_retro(&sample_retro(run_id)).await.unwrap();
run.put_graph("digraph night_sky {}").await.unwrap();
run.put_sandbox(&sample_sandbox()).await.unwrap();
let run_record = sample_run_record(run_id, created_at);
let start_record = sample_start_record(run_id, created_at);
let status_record = sample_status();
let first_checkpoint = sample_checkpoint("plan", 1);
let second_checkpoint = sample_checkpoint("code", 2);
let conclusion = sample_conclusion();
let retro = sample_retro(run_id);
let sandbox = sample_sandbox();
let node = NodeVisitRef {
node_id: "code",
visit: 2,
};
run.put_node_prompt(&node, "Plan the fix").await.unwrap();
run.put_node_response(&node, "Implemented").await.unwrap();
run.put_node_status(&node, &sample_node_status())
.await
.unwrap();
run.put_node_stdout(&node, "stdout line").await.unwrap();
run.put_node_stderr(&node, "").await.unwrap();
run.put_retro_prompt("How did it go?").await.unwrap();
run.put_retro_response("Smooth enough").await.unwrap();
run.append_event(&event_payload(
run_id,
"2026-03-27T12:00:00.000Z",
"run.started",
))
append_workflow_event(
run.as_ref(),
&run_id,
&WorkflowRunEvent::RunCreated {
run_id,
settings: serde_json::to_value(&run_record.settings).unwrap(),
graph: serde_json::to_value(&run_record.graph).unwrap(),
workflow_source: Some("digraph night_sky {}".to_string()),
workflow_config: None,
labels: run_record.labels.clone().into_iter().collect(),
run_dir: "/tmp/night-sky-run".to_string(),
working_directory: run_record.working_directory.display().to_string(),
host_repo_path: run_record.host_repo_path.clone(),
base_branch: run_record.base_branch.clone(),
workflow_slug: run_record.workflow_slug.clone(),
db_prefix: None,
},
)
.await
.unwrap();
run.append_event(&event_payload(
run_id,
"2026-03-27T12:00:01.000Z",
"stage.completed",
))
append_workflow_event(
run.as_ref(),
&run_id,
&WorkflowRunEvent::WorkflowRunStarted {
name: "night-sky".to_string(),
run_id,
base_branch: run_record.base_branch.clone(),
base_sha: start_record.base_sha.clone(),
run_branch: start_record.run_branch.clone(),
worktree_dir: None,
goal: Some("map the constellations".to_string()),
},
)
.await
.unwrap();
append_workflow_event(
run.as_ref(),
&run_id,
&WorkflowRunEvent::RunRunning {
reason: status_record.reason,
},
)
.await
.unwrap();
for checkpoint in [&first_checkpoint, &second_checkpoint] {
append_workflow_event(
run.as_ref(),
&run_id,
&WorkflowRunEvent::CheckpointCompleted {
node_id: checkpoint.current_node.clone(),
status: "success".to_string(),
current_node: checkpoint.current_node.clone(),
completed_nodes: checkpoint.completed_nodes.clone(),
node_retries: checkpoint.node_retries.clone().into_iter().collect(),
context_values: checkpoint.context_values.clone().into_iter().collect(),
node_outcomes: checkpoint.node_outcomes.clone().into_iter().collect(),
next_node_id: checkpoint.next_node_id.clone(),
git_commit_sha: checkpoint.git_commit_sha.clone(),
loop_failure_signatures: checkpoint
.loop_failure_signatures
.clone()
.into_iter()
.map(|(signature, count)| (signature.to_string(), count))
.collect(),
restart_failure_signatures: checkpoint
.restart_failure_signatures
.clone()
.into_iter()
.map(|(signature, count)| (signature.to_string(), count))
.collect(),
node_visits: checkpoint.node_visits.clone().into_iter().collect(),
diff: None,
},
)
.await
.unwrap();
}
append_workflow_event(
run.as_ref(),
&run_id,
&WorkflowRunEvent::SandboxInitialized {
working_directory: sandbox.working_directory.clone(),
provider: sandbox.provider.clone(),
identifier: sandbox.identifier.clone(),
host_working_directory: sandbox.host_working_directory.clone(),
container_mount_point: sandbox.container_mount_point.clone(),
},
)
.await
.unwrap();
append_workflow_event(
run.as_ref(),
&run_id,
&WorkflowRunEvent::Prompt {
stage: "code".to_string(),
visit: 2,
text: "Plan the fix".to_string(),
mode: None,
provider: None,
model: None,
},
)
.await
.unwrap();
append_workflow_event(
run.as_ref(),
&run_id,
&WorkflowRunEvent::PromptCompleted {
node_id: "code".to_string(),
response: "Implemented".to_string(),
model: "gpt-5".to_string(),
provider: "openai".to_string(),
usage: None,
},
)
.await
.unwrap();
append_workflow_event(
run.as_ref(),
&run_id,
&WorkflowRunEvent::StageCompleted {
node_id: "code".to_string(),
name: "Code".to_string(),
index: 1,
duration_ms: 250,
status: "partial_success".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
usage: None,
failure: None,
notes: Some("captured output".to_string()),
files_touched: Vec::new(),
context_updates: None,
jump_to_node: None,
context_values: None,
node_visits: Some(std::collections::BTreeMap::from([(
"code".to_string(),
2usize,
)])),
loop_failure_signatures: None,
restart_failure_signatures: None,
response: Some("Implemented".to_string()),
attempt: 1,
max_attempts: 1,
},
)
.await
.unwrap();
append_workflow_event(
run.as_ref(),
&run_id,
&WorkflowRunEvent::CommandStarted {
node_id: "code".to_string(),
script: "echo hi".to_string(),
language: "sh".to_string(),
timeout_ms: None,
},
)
.await
.unwrap();
append_workflow_event(
run.as_ref(),
&run_id,
&WorkflowRunEvent::CommandCompleted {
node_id: "code".to_string(),
stdout: "stdout line".to_string(),
stderr: String::new(),
exit_code: Some(0),
duration_ms: 100,
timed_out: false,
},
)
.await
.unwrap();
append_workflow_event(
run.as_ref(),
&run_id,
&WorkflowRunEvent::RetroStarted {
prompt: Some("How did it go?".to_string()),
provider: None,
model: None,
},
)
.await
.unwrap();
append_workflow_event(
run.as_ref(),
&run_id,
&WorkflowRunEvent::RetroCompleted {
duration_ms: 50,
response: Some("Smooth enough".to_string()),
retro: Some(serde_json::to_value(&retro).unwrap()),
},
)
.await
.unwrap();
append_workflow_event(
run.as_ref(),
&run_id,
&WorkflowRunEvent::WorkflowRunCompleted {
duration_ms: conclusion.duration_ms,
artifact_count: 0,
status: "success".to_string(),
reason: None,
total_cost: conclusion.total_cost,
final_git_commit_sha: conclusion.final_git_commit_sha.clone(),
final_patch: None,
usage: None,
},
)
.await
.unwrap();
run.append_event(
&EventPayload::new(
serde_json::json!({
"id": format!("evt-{run_id}-stage-completed"),
"ts": "2026-03-27T12:00:01.000Z",
"run_id": run_id.to_string(),
"event": "stage.completed"
}),
&run_id,
)
.unwrap(),
)
.await
.unwrap();
run.put_artifact_value("summary", &serde_json::json!({"done": true}))
@ -583,7 +769,7 @@ mod tests {
assert_eq!(exported_start.run_id, run_id);
let exported_status: RunStatusRecord = read_json(&output.path().join("status.json"));
assert_eq!(exported_status.status, RunStatus::Running);
assert_eq!(exported_status.status, RunStatus::Succeeded);
let exported_checkpoint: Checkpoint = read_json(&output.path().join("checkpoint.json"));
assert_eq!(exported_checkpoint.current_node, "code");
@ -626,12 +812,12 @@ mod tests {
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(events.len(), 2);
assert_eq!(events.len(), 15);
assert_eq!(events[0].seq, 1);
assert_eq!(events[1].seq, 2);
assert_eq!(events.last().unwrap().seq, 15);
let first_checkpoint: Checkpoint = read_json(&output.path().join("checkpoints/0001.json"));
let second_checkpoint: Checkpoint = read_json(&output.path().join("checkpoints/0002.json"));
let first_checkpoint: Checkpoint = read_json(&output.path().join("checkpoints/0004.json"));
let second_checkpoint: Checkpoint = read_json(&output.path().join("checkpoints/0005.json"));
assert_eq!(first_checkpoint.current_node, "plan");
assert_eq!(second_checkpoint.current_node, "code");
@ -665,14 +851,31 @@ mod tests {
#[tokio::test]
async fn export_run_rejects_path_traversal_and_leaves_no_partial_output() {
let store = InMemoryStore::default();
let store = test_store();
let created_at = dt("2026-03-27T12:00:00Z");
let run_id = test_run_id();
let run = store.create_run(&run_id, created_at, None).await.unwrap();
run.put_run(&sample_run_record(run_id, created_at))
.await
.unwrap();
let run_record = sample_run_record(run_id, created_at);
append_workflow_event(
run.as_ref(),
&run_id,
&WorkflowRunEvent::RunCreated {
run_id,
settings: serde_json::to_value(&run_record.settings).unwrap(),
graph: serde_json::to_value(&run_record.graph).unwrap(),
workflow_source: Some("digraph night_sky {}".to_string()),
workflow_config: None,
labels: run_record.labels.clone().into_iter().collect(),
run_dir: "/tmp/night-sky-run".to_string(),
working_directory: run_record.working_directory.display().to_string(),
host_repo_path: run_record.host_repo_path.clone(),
base_branch: run_record.base_branch.clone(),
workflow_slug: run_record.workflow_slug.clone(),
db_prefix: None,
},
)
.await
.unwrap();
run.put_asset(
&NodeVisitRef {
node_id: "code",

View file

@ -64,7 +64,7 @@ pub(super) async fn df_command(args: &DfArgs, globals: &GlobalArgs) -> Result<()
#[allow(clippy::print_stdout)]
async fn df_from(
args: &DfArgs,
store: &dyn fabro_store::Store,
store: &fabro_store::SlateStore,
data_dir: &Path,
runs_base: &Path,
logs_base: &Path,

View file

@ -2,7 +2,7 @@ use std::path::Path;
use anyhow::{Context, Result, bail};
use chrono::Utc;
use fabro_store::Store;
use fabro_store::SlateStore;
use serde::Serialize;
use tracing::{debug, info};
@ -47,7 +47,7 @@ pub(crate) fn parse_duration(s: &str) -> Result<chrono::Duration> {
async fn prune_from(
args: &RunsPruneArgs,
store: &dyn Store,
store: &SlateStore,
base: &Path,
globals: &GlobalArgs,
) -> Result<()> {

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use fabro_store::{RunStore, SlateStore, Store};
use fabro_store::{RunStoreHandle, SlateStore};
use fabro_types::RunId;
use object_store::local::LocalFileSystem;
@ -18,10 +18,7 @@ pub(crate) fn build_store(storage_dir: &Path) -> Result<Arc<SlateStore>> {
)))
}
pub(crate) async fn open_run_reader(
storage_dir: &Path,
run_id: &RunId,
) -> Result<Arc<dyn RunStore>> {
pub(crate) async fn open_run_reader(storage_dir: &Path, run_id: &RunId) -> Result<RunStoreHandle> {
build_store(storage_dir)?
.open_run_reader(run_id)
.await

View file

@ -1,28 +1,7 @@
use std::sync::Arc;
use fabro_store::Store;
use fabro_test::{fabro_snapshot, test_context};
use fabro_types::RunId;
use object_store::local::LocalFileSystem;
use super::support::{git_filters, setup_git_backed_changed_run, setup_git_backed_noop_run};
fn with_runtime<T>(f: impl FnOnce(&tokio::runtime::Runtime) -> T) -> T {
let runtime = tokio::runtime::Runtime::new().unwrap();
f(&runtime)
}
fn build_store(storage_dir: &std::path::Path) -> Arc<fabro_store::SlateStore> {
let store_path = storage_dir.join("store");
std::fs::create_dir_all(&store_path).unwrap();
let object_store = Arc::new(LocalFileSystem::new_with_prefix(&store_path).unwrap());
Arc::new(fabro_store::SlateStore::new(
object_store,
"",
std::time::Duration::from_millis(1),
))
}
#[test]
fn help() {
let context = test_context!();
@ -113,24 +92,8 @@ fn diff_completed_run_with_changes_prints_patch() {
fn diff_completed_run_reads_store_final_patch_without_disk_file() {
let context = test_context!();
let setup = setup_git_backed_changed_run(&context);
let run_id: RunId = setup.run.run_id.parse().unwrap();
let patch = with_runtime(|runtime| {
runtime.block_on(async {
let store = build_store(&context.storage_dir);
let run_store = store.open_run(&run_id).await.unwrap();
run_store.get_final_patch().await.unwrap().unwrap()
})
});
let _ = std::fs::remove_file(setup.run.run_dir.join("final.patch"));
with_runtime(|runtime| {
runtime.block_on(async {
let store = build_store(&context.storage_dir);
let run_store = store.open_run(&run_id).await.unwrap();
run_store.put_final_patch(&patch).await.unwrap();
});
});
let mut cmd = context.command();
cmd.args(["diff", &setup.run.run_id]);
@ -176,41 +139,8 @@ fn diff_node_outputs_specific_patch() {
fn diff_node_reads_store_patch_without_disk_file() {
let context = test_context!();
let setup = setup_git_backed_changed_run(&context);
let run_id: RunId = setup.run.run_id.parse().unwrap();
let patch = with_runtime(|runtime| {
runtime.block_on(async {
let store = build_store(&context.storage_dir);
let run_store = store.open_run(&run_id).await.unwrap();
run_store
.get_node(&fabro_store::NodeVisitRef {
node_id: "step_one",
visit: 1,
})
.await
.unwrap()
.diff
.unwrap()
})
});
let _ = std::fs::remove_file(setup.run.run_dir.join("nodes/step_one/diff.patch"));
with_runtime(|runtime| {
runtime.block_on(async {
let store = build_store(&context.storage_dir);
let run_store = store.open_run(&run_id).await.unwrap();
run_store
.put_node_diff(
&fabro_store::NodeVisitRef {
node_id: "step_one",
visit: 1,
},
&patch,
)
.await
.unwrap();
});
});
let mut cmd = context.command();
cmd.args(["diff", &setup.run.run_id, "--node", "step_one"]);

View file

@ -1,8 +1,8 @@
use std::sync::Arc;
use fabro_store::Store;
use fabro_test::{fabro_snapshot, test_context};
use fabro_types::{PullRequestRecord, RunId};
use fabro_types::RunId;
use fabro_workflow::event::{WorkflowRunEvent, append_workflow_event};
use object_store::local::LocalFileSystem;
use super::support::setup_completed_dry_run;
@ -77,18 +77,22 @@ fn pr_view_reads_pull_request_from_store_without_pull_request_json() {
runtime.block_on(async {
let store = build_store(&context.storage_dir);
let run_store = store.open_run(&run_id).await.unwrap();
run_store
.put_pull_request(&PullRequestRecord {
html_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(),
number: 123,
append_workflow_event(
run_store.as_ref(),
&run_id,
&WorkflowRunEvent::PullRequestCreated {
pr_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(),
pr_number: 123,
owner: "fabro-sh".to_string(),
repo: "fabro".to_string(),
base_branch: "main".to_string(),
head_branch: "fabro/run/demo".to_string(),
title: "Map the constellations".to_string(),
})
.await
.unwrap();
draft: false,
},
)
.await
.unwrap();
});
});

View file

@ -129,7 +129,9 @@ fn rewind_preserves_event_history_and_clears_terminal_snapshot_state() {
let setup = setup_git_backed_changed_run(&context);
let before_events = run_events(&setup.run.run_dir);
assert!(
before_events.iter().any(|event| event.payload.as_value()["event"] == "run.completed"),
before_events
.iter()
.any(|event| event.payload.as_value()["event"] == "run.completed"),
"setup run should be completed before rewind"
);
@ -179,9 +181,18 @@ fn rewind_preserves_event_history_and_clears_terminal_snapshot_state() {
snapshot.status.as_ref().map(|status| &status.status),
Some(&fabro_types::RunStatus::Submitted)
);
assert!(snapshot.conclusion.is_none(), "rewind should clear conclusion");
assert!(snapshot.final_patch.is_none(), "rewind should clear final patch");
assert!(snapshot.pull_request.is_none(), "rewind should clear pull request");
assert!(
snapshot.conclusion.is_none(),
"rewind should clear conclusion"
);
assert!(
snapshot.final_patch.is_none(),
"rewind should clear final patch"
);
assert!(
snapshot.pull_request.is_none(),
"rewind should clear pull request"
);
assert!(
snapshot.nodes.is_empty(),
"rewind should clear node snapshots that belonged to the prior execution"

View file

@ -4,7 +4,7 @@ use std::process::Output;
use std::sync::Arc;
use std::time::{Duration, Instant};
use fabro_store::{EventEnvelope, RunSnapshot, RunStore, SlateStore, Store};
use fabro_store::{EventEnvelope, RunSnapshot, RunStoreHandle, SlateStore};
use fabro_test::TestContext;
use fabro_types::RunId;
use object_store::local::LocalFileSystem;
@ -284,9 +284,9 @@ worktree_mode = "never"
let run = run_local_workflow(context, &workspace_dir, "run.toml");
let store = run_store(&run.run_dir);
assert!(
block_on(store.get_sandbox())
block_on(store.state())
.ok()
.flatten()
.and_then(|state| state.sandbox)
.is_some()
);
@ -374,10 +374,9 @@ pub(crate) fn write_gated_workflow(path: &Path, name: &str, goal: &str) -> Workf
pub(crate) fn wait_for_status(run_dir: &Path, expected: &[&str]) -> String {
let deadline = Instant::now() + COMMAND_TIMEOUT;
loop {
if let Some(status) = block_on(run_store(run_dir).get_status())
if let Some(status) = block_on(run_store(run_dir).state())
.ok()
.flatten()
.map(|record| record.status.to_string())
.and_then(|state| state.status.map(|record| record.status.to_string()))
{
if expected.iter().any(|candidate| *candidate == status) {
return status;
@ -481,7 +480,7 @@ fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
.block_on(future)
}
fn run_store(run_dir: &Path) -> Arc<dyn RunStore> {
fn run_store(run_dir: &Path) -> RunStoreHandle {
let runs_dir = run_dir.parent().expect("run dir should have parent");
let storage_dir = runs_dir.parent().expect("runs dir should have parent");
let run_id: RunId = infer_run_id(run_dir).parse().expect("run id should parse");
@ -495,9 +494,9 @@ fn run_store(run_dir: &Path) -> Arc<dyn RunStore> {
pub(crate) fn run_snapshot(run_dir: &Path) -> RunSnapshot {
let store = run_store(run_dir);
block_on(store.get_snapshot())
block_on(store.state())
.ok()
.flatten()
.and_then(|state| state.to_snapshot())
.expect("run store snapshot should exist")
}

View file

@ -6,7 +6,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use fabro_store::{RunSnapshot, RunStore, SlateStore, Store};
use fabro_store::{RunSnapshot, RunStoreHandle, SlateStore};
use fabro_types::RunId;
use object_store::local::LocalFileSystem;
pub(super) fn fixture(name: &str) -> PathBuf {
@ -23,7 +23,7 @@ fn block_on<T>(future: impl std::future::Future<Output = T>) -> T {
.block_on(future)
}
fn run_store(run_dir: &Path) -> Arc<dyn RunStore> {
fn run_store(run_dir: &Path) -> RunStoreHandle {
let runs_dir = run_dir.parent().expect("run dir should have parent");
let storage_dir = runs_dir.parent().expect("runs dir should have parent");
let run_id: RunId = std::fs::read_to_string(run_dir.join("id.txt"))
@ -48,9 +48,9 @@ fn run_store(run_dir: &Path) -> Arc<dyn RunStore> {
pub(super) fn run_snapshot(run_dir: &Path) -> RunSnapshot {
let store = run_store(run_dir);
block_on(store.get_snapshot())
block_on(store.state())
.ok()
.flatten()
.and_then(|state| state.to_snapshot())
.expect("run store snapshot should exist")
}

View file

@ -10,7 +10,7 @@ use fabro_agent::{
use fabro_llm::client::Client;
use fabro_llm::provider::Provider;
use fabro_llm::types::ToolDefinition;
use fabro_store::RunStore;
use fabro_store::SlateRunStore;
use fabro_util::redact::redact_jsonl_line;
use tokio::sync::broadcast::Receiver;
use tokio::task::JoinHandle;
@ -137,7 +137,7 @@ pub fn build_retro_prompt(retro_data_dir: &str) -> String {
/// files via tool access, then calls `submit_retro` with its analysis.
pub async fn run_retro_agent(
sandbox: &Arc<dyn Sandbox>,
run_store: &dyn RunStore,
run_store: &SlateRunStore,
run_dir: &Path,
llm_client: &Client,
provider: Provider,
@ -286,26 +286,20 @@ pub fn dry_run_narrative() -> RetroNarrative {
}
async fn write_retro_prompt(
run_store: &dyn RunStore,
_run_store: &SlateRunStore,
retro_dir: &Path,
prompt: &str,
) -> anyhow::Result<()> {
if let Err(err) = run_store.put_retro_prompt(prompt).await {
tracing::warn!(error = %err, "Failed to save retro prompt to store");
std::fs::write(retro_dir.join("prompt.md"), prompt)?;
}
std::fs::write(retro_dir.join("prompt.md"), prompt)?;
Ok(())
}
async fn write_retro_response(
run_store: &dyn RunStore,
_run_store: &SlateRunStore,
retro_dir: &Path,
response: &str,
) -> anyhow::Result<()> {
if let Err(err) = run_store.put_retro_response(response).await {
tracing::warn!(error = %err, "Failed to save retro response to store");
std::fs::write(retro_dir.join("response.md"), response)?;
}
std::fs::write(retro_dir.join("response.md"), response)?;
Ok(())
}
@ -385,7 +379,7 @@ fn build_profile(provider: Provider, model: &str) -> Box<dyn AgentProfile> {
async fn upload_data_files(
sandbox: &Arc<dyn Sandbox>,
run_store: &dyn RunStore,
run_store: &SlateRunStore,
_run_dir: &Path,
target_dir: &str,
) -> anyhow::Result<()> {
@ -416,26 +410,24 @@ async fn upload_data_files(
.map_err(|e| anyhow::anyhow!("Failed to upload progress.jsonl: {e}"))?;
}
let checkpoint_content = run_store
.get_checkpoint()
let state = run_store
.state()
.await
.map_err(|e| anyhow::anyhow!("Failed to load checkpoint from store: {e}"))?
.map_err(|e| anyhow::anyhow!("Failed to load run state from store: {e}"))?;
let checkpoint_content = state
.checkpoint
.map(|cp| serde_json::to_string_pretty(&cp))
.transpose()?;
upload_file(sandbox, target_dir, "checkpoint.json", checkpoint_content).await?;
let run_content = run_store
.get_run()
.await
.map_err(|e| anyhow::anyhow!("Failed to load run metadata from store: {e}"))?
let run_content = state
.run
.map(|run| serde_json::to_string_pretty(&run))
.transpose()?;
upload_file(sandbox, target_dir, "run.json", run_content).await?;
let start_content = run_store
.get_start()
.await
.map_err(|e| anyhow::anyhow!("Failed to load start metadata from store: {e}"))?
let start_content = state
.start
.map(|start| serde_json::to_string_pretty(&start))
.transpose()?;
upload_file(sandbox, target_dir, "start.json", start_content).await?;

View file

@ -19,12 +19,13 @@ use fabro_llm::types::{
ContentPart, FinishReason, Message as LlmMessage, Request as LlmRequest,
Response as LlmResponse, Role, StreamEvent, ToolChoice, ToolDefinition, Usage,
};
use fabro_store::{InMemoryStore, Store};
use fabro_store::StoreHandle;
use fabro_types::{RunId, Settings};
use fabro_util::redact::redact_jsonl_line;
use fabro_workflow::error::FabroError;
use fabro_workflow::handler::HandlerRegistry;
use futures_util::stream;
use object_store::memory::InMemory as MemoryObjectStore;
use tokio::sync::broadcast;
use tokio::sync::oneshot;
use tokio::sync::{Notify, OnceCell};
@ -124,7 +125,7 @@ type RegistryFactoryOverride = dyn Fn(Arc<dyn Interviewer>) -> HandlerRegistry +
pub struct AppState {
runs: Mutex<HashMap<RunId, ManagedRun>>,
aggregate_usage: Mutex<AggregateUsageTotals>,
store: Arc<dyn Store>,
store: StoreHandle,
pub db: sqlx::SqlitePool,
max_concurrent_runs: usize,
scheduler_notify: Notify,
@ -417,7 +418,7 @@ pub fn create_app_state_with_registry_factory(
Arc::new(RwLock::new(Settings::default())),
Some(Box::new(registry_factory_override)),
5,
Arc::new(InMemoryStore::default()),
test_store(),
)
}
@ -431,15 +432,23 @@ pub fn create_app_state_with_options(
db,
Arc::new(RwLock::new(settings)),
max_concurrent_runs,
Arc::new(InMemoryStore::default()),
test_store(),
)
}
fn test_store() -> StoreHandle {
Arc::new(fabro_store::SlateStore::new(
Arc::new(MemoryObjectStore::new()),
"",
Duration::from_millis(1),
))
}
pub fn create_app_state_with_store(
db: sqlx::SqlitePool,
settings: Arc<RwLock<Settings>>,
max_concurrent_runs: usize,
store: Arc<dyn Store>,
store: StoreHandle,
) -> Arc<AppState> {
build_app_state(db, settings, None, max_concurrent_runs, store)
}
@ -449,7 +458,7 @@ fn build_app_state(
settings: Arc<RwLock<Settings>>,
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
max_concurrent_runs: usize,
store: Arc<dyn Store>,
store: StoreHandle,
) -> Arc<AppState> {
Arc::new(AppState {
runs: Mutex::new(HashMap::new()),
@ -728,7 +737,7 @@ async fn execute_run(state: Arc<AppState>, run_id: RunId) {
cancel_token: Some(Arc::clone(&cancel_token)),
emitter: Arc::clone(&emitter),
interviewer: Arc::clone(&interviewer) as Arc<dyn Interviewer>,
run_store: Arc::clone(&run_store),
run_store: run_store.clone(),
github_app,
on_node: None,
registry_override,
@ -2521,10 +2530,10 @@ mod tests {
.open_run_reader(&run_id)
.await
.unwrap()
.expect("run store should exist")
.get_run()
.state()
.await
.unwrap()
.run
.expect("run record should exist");
let mut expected_settings = settings;
expected_settings.goal = Some("Test".to_string());
@ -2656,16 +2665,11 @@ mod tests {
assert_eq!(managed_run.status, RunStatus::Cancelled);
drop(runs);
let run_store = state
.store
.open_run_reader(&run_id)
.await
.unwrap()
.expect("run store should exist");
let run_store = state.store.open_run_reader(&run_id).await.unwrap();
let mut status_record = None;
for _ in 0..50 {
if let Some(record) = run_store.get_status().await.unwrap() {
if let Some(record) = run_store.state().await.unwrap().status {
if record.status == fabro_workflow::run_status::RunStatus::Failed
&& record.reason == Some(fabro_workflow::run_status::StatusReason::Cancelled)
{

View file

@ -1,20 +1,9 @@
use crate::NodeVisitRef;
pub(crate) const INIT_KEY: &str = "_init.json";
pub(crate) const RUN_KEY: &str = "run.json";
pub(crate) const START_KEY: &str = "start.json";
pub(crate) const STATUS_KEY: &str = "status.json";
pub(crate) const CHECKPOINT_KEY: &str = "checkpoint.json";
pub(crate) const CONCLUSION_KEY: &str = "conclusion.json";
pub(crate) const RETRO_KEY: &str = "retro.json";
pub(crate) const GRAPH_KEY: &str = "graph.fabro";
pub(crate) const SANDBOX_KEY: &str = "sandbox.json";
pub(crate) const FINAL_PATCH_KEY: &str = "final.patch";
pub(crate) const PULL_REQUEST_KEY: &str = "pull_request.json";
pub(crate) const RETRO_PROMPT_KEY: &str = "retro/prompt.md";
pub(crate) const RETRO_RESPONSE_KEY: &str = "retro/response.md";
pub(crate) const EVENTS_PREFIX: &str = "events/";
pub(crate) const CHECKPOINTS_PREFIX: &str = "checkpoints/";
pub(crate) const ARTIFACT_VALUES_PREFIX: &str = "artifacts/values/";
pub(crate) const ARTIFACT_NODES_PREFIX: &str = "artifacts/nodes/";
@ -22,46 +11,6 @@ pub(crate) fn init() -> &'static str {
INIT_KEY
}
pub(crate) fn run() -> &'static str {
RUN_KEY
}
pub(crate) fn start() -> &'static str {
START_KEY
}
pub(crate) fn status() -> &'static str {
STATUS_KEY
}
pub(crate) fn checkpoint() -> &'static str {
CHECKPOINT_KEY
}
pub(crate) fn conclusion() -> &'static str {
CONCLUSION_KEY
}
pub(crate) fn retro() -> &'static str {
RETRO_KEY
}
pub(crate) fn graph() -> &'static str {
GRAPH_KEY
}
pub(crate) fn sandbox() -> &'static str {
SANDBOX_KEY
}
pub(crate) fn final_patch() -> &'static str {
FINAL_PATCH_KEY
}
pub(crate) fn pull_request() -> &'static str {
PULL_REQUEST_KEY
}
pub(crate) fn node_visit_prefix(node: &NodeVisitRef<'_>) -> String {
format!("nodes/{}/visit-{}", node.node_id, node.visit)
}
@ -122,10 +71,6 @@ pub(crate) fn event_key(seq: u32, epoch_ms: i64) -> String {
format!("{EVENTS_PREFIX}{seq:06}-{epoch_ms}.json")
}
pub(crate) fn checkpoint_history_key(seq: u32, epoch_ms: i64) -> String {
format!("{CHECKPOINTS_PREFIX}{seq:04}-{epoch_ms}.json")
}
pub(crate) fn artifact_value(artifact_id: &str) -> String {
format!("{ARTIFACT_VALUES_PREFIX}{artifact_id}.json")
}
@ -145,10 +90,6 @@ pub(crate) fn parse_event_seq(key: &str) -> Option<u32> {
parse_seq(key, EVENTS_PREFIX)
}
pub(crate) fn parse_checkpoint_seq(key: &str) -> Option<u32> {
parse_seq(key, CHECKPOINTS_PREFIX)
}
pub(crate) fn parse_artifact_value_id(key: &str) -> Option<String> {
key.strip_prefix(ARTIFACT_VALUES_PREFIX)
.and_then(|s| s.strip_suffix(".json"))
@ -181,10 +122,7 @@ mod tests {
#[test]
fn top_level_keys_match_spec() {
assert_eq!(init(), "_init.json");
assert_eq!(run(), "run.json");
assert_eq!(graph(), "graph.fabro");
assert_eq!(final_patch(), "final.patch");
assert_eq!(pull_request(), "pull_request.json");
assert_eq!(event_key(7, 123), "events/000007-123.json");
assert_eq!(retro_prompt(), "retro/prompt.md");
assert_eq!(retro_response(), "retro/response.md");
}
@ -224,7 +162,6 @@ mod tests {
#[test]
fn sequence_keys_are_zero_padded() {
assert_eq!(event_key(7, 123), "events/000007-123.json");
assert_eq!(checkpoint_history_key(42, 456), "checkpoints/0042-456.json");
}
#[test]
@ -243,7 +180,6 @@ mod tests {
#[test]
fn parse_helpers_extract_sequences_and_node_visits() {
assert_eq!(parse_event_seq("events/000007-123.json"), Some(7));
assert_eq!(parse_checkpoint_seq("checkpoints/0042-456.json"), Some(42));
assert_eq!(
parse_artifact_value_id("artifacts/values/summary.json"),
Some("summary".to_string())
@ -261,7 +197,6 @@ mod tests {
#[test]
fn parse_helpers_reject_invalid_keys() {
assert_eq!(parse_event_seq("events/not-a-seq.json"), None);
assert_eq!(parse_checkpoint_seq("checkpoints/oops.json"), None);
assert_eq!(
parse_artifact_value_id("artifacts/values/summary.txt"),
None

View file

@ -1,10 +1,6 @@
use std::pin::Pin;
use std::sync::Arc;
use async_trait::async_trait;
use bytes::Bytes;
use chrono::{DateTime, Utc};
use futures::Stream;
mod error;
mod keys;
@ -15,139 +11,22 @@ mod slate;
mod types;
pub use error::{Result, StoreError};
pub use memory::InMemoryStore;
pub use memory::{InMemoryRunStore, InMemoryStore};
pub use run_state::{NodeState, RunState};
pub use runtime::RuntimeState;
pub use slate::SlateStore;
pub use slate::{SlateRunStore, SlateStore};
pub use types::{
CatalogRecord, EventEnvelope, EventPayload, NodeSnapshot, NodeVisitRef, RunSnapshot, RunSummary,
};
use fabro_types::{
Checkpoint, Conclusion, NodeStatusRecord, Outcome, PullRequestRecord, Retro, RunId, RunRecord,
RunStatusRecord, SandboxRecord, StageUsage, StartRecord,
};
use fabro_types::{Outcome, StageUsage};
pub type NodeOutcomeRecord = Outcome<Option<StageUsage>>;
pub type StoreHandle = Arc<SlateStore>;
pub type RunStoreHandle = Arc<SlateRunStore>;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ListRunsQuery {
pub start: Option<DateTime<Utc>>,
pub end: Option<DateTime<Utc>>,
}
#[async_trait]
pub trait Store: Send + Sync {
async fn create_run(
&self,
run_id: &RunId,
created_at: DateTime<Utc>,
run_dir: Option<&str>,
) -> Result<Arc<dyn RunStore>>;
async fn open_run(&self, run_id: &RunId) -> Result<Arc<dyn RunStore>>;
async fn open_run_reader(&self, run_id: &RunId) -> Result<Arc<dyn RunStore>>;
async fn list_runs(&self, query: &ListRunsQuery) -> Result<Vec<RunSummary>>;
async fn delete_run(&self, run_id: &RunId) -> Result<()>;
}
#[async_trait]
pub trait RunStore: Send + Sync {
async fn put_run(&self, record: &RunRecord) -> Result<()>;
async fn get_run(&self) -> Result<Option<RunRecord>>;
async fn put_start(&self, record: &StartRecord) -> Result<()>;
async fn get_start(&self) -> Result<Option<StartRecord>>;
async fn put_status(&self, record: &RunStatusRecord) -> Result<()>;
async fn get_status(&self) -> Result<Option<RunStatusRecord>>;
async fn put_checkpoint(&self, record: &Checkpoint) -> Result<()>;
async fn get_checkpoint(&self) -> Result<Option<Checkpoint>>;
async fn append_checkpoint(&self, record: &Checkpoint) -> Result<u32>;
async fn list_checkpoints(&self) -> Result<Vec<(u32, Checkpoint)>>;
async fn put_conclusion(&self, record: &Conclusion) -> Result<()>;
async fn get_conclusion(&self) -> Result<Option<Conclusion>>;
async fn put_retro(&self, retro: &Retro) -> Result<()>;
async fn get_retro(&self) -> Result<Option<Retro>>;
async fn put_graph(&self, dot_source: &str) -> Result<()>;
async fn get_graph(&self) -> Result<Option<String>>;
async fn put_sandbox(&self, record: &SandboxRecord) -> Result<()>;
async fn get_sandbox(&self) -> Result<Option<SandboxRecord>>;
async fn put_node_prompt(&self, node: &NodeVisitRef<'_>, prompt: &str) -> Result<()>;
async fn put_node_response(&self, node: &NodeVisitRef<'_>, response: &str) -> Result<()>;
async fn put_node_status(
&self,
node: &NodeVisitRef<'_>,
status: &NodeStatusRecord,
) -> Result<()>;
async fn put_node_outcome(
&self,
node: &NodeVisitRef<'_>,
outcome: &NodeOutcomeRecord,
) -> Result<()>;
async fn put_node_provider_used(
&self,
node: &NodeVisitRef<'_>,
provider_used: &serde_json::Value,
) -> Result<()>;
async fn put_node_diff(&self, node: &NodeVisitRef<'_>, diff: &str) -> Result<()>;
async fn put_node_script_invocation(
&self,
node: &NodeVisitRef<'_>,
invocation: &serde_json::Value,
) -> Result<()>;
async fn put_node_script_timing(
&self,
node: &NodeVisitRef<'_>,
timing: &serde_json::Value,
) -> Result<()>;
async fn put_node_parallel_results(
&self,
node: &NodeVisitRef<'_>,
results: &serde_json::Value,
) -> Result<()>;
async fn put_node_stdout(&self, node: &NodeVisitRef<'_>, log: &str) -> Result<()>;
async fn put_node_stderr(&self, node: &NodeVisitRef<'_>, log: &str) -> Result<()>;
async fn get_node(&self, node: &NodeVisitRef<'_>) -> Result<NodeSnapshot>;
async fn list_node_visits(&self, node_id: &str) -> Result<Vec<u32>>;
async fn list_node_ids(&self) -> Result<Vec<String>>;
async fn put_final_patch(&self, patch: &str) -> Result<()>;
async fn get_final_patch(&self) -> Result<Option<String>>;
async fn put_pull_request(&self, record: &PullRequestRecord) -> Result<()>;
async fn get_pull_request(&self) -> Result<Option<PullRequestRecord>>;
async fn reset_for_rewind(&self) -> Result<()>;
async fn append_event(&self, payload: &EventPayload) -> Result<u32>;
async fn list_events(&self) -> Result<Vec<EventEnvelope>>;
async fn list_events_from(&self, seq: u32) -> Result<Vec<EventEnvelope>>;
async fn watch_events_from(
&self,
seq: u32,
) -> Result<Pin<Box<dyn Stream<Item = Result<EventEnvelope>> + Send>>>;
async fn put_retro_prompt(&self, text: &str) -> Result<()>;
async fn get_retro_prompt(&self) -> Result<Option<String>>;
async fn put_retro_response(&self, text: &str) -> Result<()>;
async fn get_retro_response(&self) -> Result<Option<String>>;
async fn put_artifact_value(&self, artifact_id: &str, value: &serde_json::Value) -> Result<()>;
async fn get_artifact_value(&self, artifact_id: &str) -> Result<Option<serde_json::Value>>;
async fn list_artifact_values(&self) -> Result<Vec<String>>;
async fn put_asset(&self, node: &NodeVisitRef<'_>, filename: &str, data: &[u8]) -> Result<()>;
async fn get_asset(&self, node: &NodeVisitRef<'_>, filename: &str) -> Result<Option<Bytes>>;
async fn list_assets(&self, node: &NodeVisitRef<'_>) -> Result<Vec<String>>;
async fn list_all_assets(&self) -> Result<Vec<(String, u32, String)>>;
async fn state(&self) -> Result<RunState>;
async fn get_snapshot(&self) -> Result<Option<RunSnapshot>>;
}

File diff suppressed because it is too large Load diff

View file

@ -131,17 +131,20 @@ impl RunState {
"run.failed" => {
self.status = Some(run_status_record(RunStatus::Failed, &properties, ts)?);
self.conclusion = Some(conclusion_from_failed(&properties, ts));
self.last_git_sha =
optional_string(&properties, "git_commit_sha").or_else(|| self.last_git_sha.clone());
self.last_git_sha = optional_string(&properties, "git_commit_sha")
.or_else(|| self.last_git_sha.clone());
}
"run.rewound" => {
self.reset_for_rewind();
self.last_git_sha =
optional_string(&properties, "run_commit_sha").or_else(|| self.last_git_sha.clone());
self.last_git_sha = optional_string(&properties, "run_commit_sha")
.or_else(|| self.last_git_sha.clone());
}
"checkpoint.completed" => {
let checkpoint = checkpoint_from_properties(&properties, ts)?;
self.last_git_sha = checkpoint.git_commit_sha.clone().or_else(|| self.last_git_sha.clone());
self.last_git_sha = checkpoint
.git_commit_sha
.clone()
.or_else(|| self.last_git_sha.clone());
if let Some(node_id) = value.get("node_id").and_then(Value::as_str) {
let visit = checkpoint
.node_visits
@ -270,57 +273,6 @@ impl RunState {
Ok(())
}
pub fn merge_legacy(
&mut self,
snapshot: Option<RunSnapshot>,
graph_source: Option<String>,
retro_prompt: Option<String>,
retro_response: Option<String>,
checkpoints: Vec<(u32, Checkpoint)>,
) {
let Some(snapshot) = snapshot else {
self.graph_source = self.graph_source.clone().or(graph_source);
self.retro_prompt = self.retro_prompt.clone().or(retro_prompt);
self.retro_response = self.retro_response.clone().or(retro_response);
if self.checkpoints.is_empty() {
self.checkpoints = checkpoints;
}
if self.checkpoint.is_none() {
self.checkpoint = self.checkpoints.last().map(|(_, checkpoint)| checkpoint.clone());
}
return;
};
if self.run.is_none() {
self.run = Some(snapshot.run);
}
self.start = self.start.clone().or(snapshot.start);
self.status = self.status.clone().or(snapshot.status);
self.checkpoint = self.checkpoint.clone().or(snapshot.checkpoint);
self.conclusion = self.conclusion.clone().or(snapshot.conclusion);
self.retro = self.retro.clone().or(snapshot.retro);
self.graph_source = self.graph_source.clone().or(snapshot.graph).or(graph_source);
self.sandbox = self.sandbox.clone().or(snapshot.sandbox);
self.final_patch = self.final_patch.clone().or(snapshot.final_patch);
self.pull_request = self.pull_request.clone().or(snapshot.pull_request);
self.retro_prompt = self.retro_prompt.clone().or(retro_prompt);
self.retro_response = self.retro_response.clone().or(retro_response);
if self.checkpoints.is_empty() {
self.checkpoints = checkpoints;
}
if self.checkpoint.is_none() {
self.checkpoint = self.checkpoints.last().map(|(_, checkpoint)| checkpoint.clone());
}
for node in snapshot.nodes {
let entry = self
.nodes
.entry((node.node_id.clone(), node.visit))
.or_default();
merge_node_snapshot(entry, node);
}
}
pub fn node(&self, node: &NodeVisitRef<'_>) -> Option<&NodeState> {
self.nodes.get(&(node.node_id.to_string(), node.visit))
}
@ -355,21 +307,23 @@ impl RunState {
let nodes = node_keys
.into_iter()
.filter_map(|(node_id, visit)| {
self.nodes.get(&(node_id.clone(), visit)).map(|node| NodeSnapshot {
node_id,
visit,
prompt: node.prompt.clone(),
response: node.response.clone(),
status: node.status.clone(),
outcome: node.outcome.clone(),
provider_used: node.provider_used.clone(),
diff: node.diff.clone(),
script_invocation: node.script_invocation.clone(),
script_timing: node.script_timing.clone(),
parallel_results: node.parallel_results.clone(),
stdout: node.stdout.clone(),
stderr: node.stderr.clone(),
})
self.nodes
.get(&(node_id.clone(), visit))
.map(|node| NodeSnapshot {
node_id,
visit,
prompt: node.prompt.clone(),
response: node.response.clone(),
status: node.status.clone(),
outcome: node.outcome.clone(),
provider_used: node.provider_used.clone(),
diff: node.diff.clone(),
script_invocation: node.script_invocation.clone(),
script_timing: node.script_timing.clone(),
parallel_results: node.parallel_results.clone(),
stdout: node.stdout.clone(),
stderr: node.stderr.clone(),
})
})
.collect();
@ -417,7 +371,10 @@ impl RunState {
start_time: self.start.as_ref().map(|start| start.start_time),
status: self.status.as_ref().map(|status| status.status),
status_reason: self.status.as_ref().and_then(|status| status.reason),
duration_ms: self.conclusion.as_ref().map(|conclusion| conclusion.duration_ms),
duration_ms: self
.conclusion
.as_ref()
.map(|conclusion| conclusion.duration_ms),
total_cost: self
.conclusion
.as_ref()
@ -426,9 +383,7 @@ impl RunState {
}
fn node_mut(&mut self, node_id: &str, visit: u32) -> &mut NodeState {
self.nodes
.entry((node_id.to_string(), visit))
.or_default()
self.nodes.entry((node_id.to_string(), visit)).or_default()
}
fn current_visit_for(&self, node_id: &str) -> Option<u32> {
@ -454,20 +409,6 @@ impl RunState {
}
}
fn merge_node_snapshot(node: &mut NodeState, snapshot: NodeSnapshot) {
node.prompt = node.prompt.clone().or(snapshot.prompt);
node.response = node.response.clone().or(snapshot.response);
node.status = node.status.clone().or(snapshot.status);
node.outcome = node.outcome.clone().or(snapshot.outcome);
node.provider_used = node.provider_used.clone().or(snapshot.provider_used);
node.diff = node.diff.clone().or(snapshot.diff);
node.script_invocation = node.script_invocation.clone().or(snapshot.script_invocation);
node.script_timing = node.script_timing.clone().or(snapshot.script_timing);
node.parallel_results = node.parallel_results.clone().or(snapshot.parallel_results);
node.stdout = node.stdout.clone().or(snapshot.stdout);
node.stderr = node.stderr.clone().or(snapshot.stderr);
}
fn parse_ts(value: &Value) -> Result<DateTime<Utc>> {
let ts = value
.get("ts")
@ -493,7 +434,9 @@ fn required_string(properties: &serde_json::Map<String, Value>, key: &str) -> Re
.get(key)
.and_then(Value::as_str)
.map(ToString::to_string)
.ok_or_else(|| StoreError::InvalidEvent(format!("event missing string property {key}")).into())
.ok_or_else(|| {
StoreError::InvalidEvent(format!("event missing string property {key}")).into()
})
}
fn optional_string(properties: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
@ -504,10 +447,9 @@ fn optional_string(properties: &serde_json::Map<String, Value>, key: &str) -> Op
}
fn required_u64(properties: &serde_json::Map<String, Value>, key: &str) -> Result<u64> {
properties
.get(key)
.and_then(Value::as_u64)
.ok_or_else(|| StoreError::InvalidEvent(format!("event missing integer property {key}")).into())
properties.get(key).and_then(Value::as_u64).ok_or_else(|| {
StoreError::InvalidEvent(format!("event missing integer property {key}")).into()
})
}
fn required_u32(properties: &serde_json::Map<String, Value>, key: &str) -> Result<u32> {
@ -604,9 +546,9 @@ fn conclusion_from_completed(
let usage = optional_json::<fabro_types::StageUsage>(properties, "usage")?;
Ok(Conclusion {
timestamp,
status: StageStatus::from_str(&required_string(properties, "status")?).map_err(
|err| StoreError::InvalidEvent(format!("invalid completed stage status: {err}")),
)?,
status: StageStatus::from_str(&required_string(properties, "status")?).map_err(|err| {
StoreError::InvalidEvent(format!("invalid completed stage status: {err}"))
})?,
duration_ms: required_u64(properties, "duration_ms")?,
failure_reason: None,
final_git_commit_sha: optional_string(properties, "final_git_commit_sha"),
@ -656,7 +598,11 @@ fn conclusion_from_failed(
}
}
fn stage_visit(node_id: &str, properties: &serde_json::Map<String, Value>, state: &RunState) -> Option<u32> {
fn stage_visit(
node_id: &str,
properties: &serde_json::Map<String, Value>,
state: &RunState,
) -> Option<u32> {
properties
.get("node_visits")
.and_then(|value| serde_json::from_value::<HashMap<String, usize>>(value.clone()).ok())
@ -668,9 +614,8 @@ fn stage_visit(node_id: &str, properties: &serde_json::Map<String, Value>, state
fn stage_outcome_from_properties(
properties: &serde_json::Map<String, Value>,
) -> Result<NodeOutcomeRecord> {
let status = StageStatus::from_str(&required_string(properties, "status")?).map_err(|err| {
StoreError::InvalidEvent(format!("invalid stage status: {err}"))
})?;
let status = StageStatus::from_str(&required_string(properties, "status")?)
.map_err(|err| StoreError::InvalidEvent(format!("invalid stage status: {err}")))?;
Ok(Outcome {
status,
preferred_label: optional_string(properties, "preferred_label"),
@ -685,11 +630,17 @@ fn stage_outcome_from_properties(
})
}
fn node_status_from_outcome(outcome: &NodeOutcomeRecord, timestamp: DateTime<Utc>) -> NodeStatusRecord {
fn node_status_from_outcome(
outcome: &NodeOutcomeRecord,
timestamp: DateTime<Utc>,
) -> NodeStatusRecord {
NodeStatusRecord {
status: outcome.status.clone(),
notes: outcome.notes.clone(),
failure_reason: outcome.failure.as_ref().map(|failure| failure.message.clone()),
failure_reason: outcome
.failure
.as_ref()
.map(|failure| failure.message.clone()),
timestamp,
}
}

View file

@ -5,7 +5,6 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::TryStreamExt;
use object_store::ObjectStore;
@ -15,9 +14,10 @@ use slatedb::config::{DbReaderOptions, Settings};
use tokio::sync::Mutex;
use crate::keys;
use crate::{CatalogRecord, ListRunsQuery, Result, RunStore, RunSummary, Store, StoreError};
use crate::{CatalogRecord, ListRunsQuery, Result, RunStoreHandle, RunSummary, StoreError};
use fabro_types::RunId;
use run_store::{SlateRunStore, SlateRunStoreInner};
pub use run_store::SlateRunStore;
use run_store::SlateRunStoreInner;
#[derive(Clone)]
pub struct SlateStore {
@ -173,14 +173,13 @@ impl SlateStore {
}
}
#[async_trait]
impl Store for SlateStore {
async fn create_run(
impl SlateStore {
pub async fn create_run(
&self,
run_id: &RunId,
created_at: DateTime<Utc>,
run_dir: Option<&str>,
) -> Result<Arc<dyn RunStore>> {
) -> Result<RunStoreHandle> {
let locator =
catalog::read_locator(self.object_store.clone(), &self.base_prefix, run_id).await?;
if let Some(active) = self.get_active_run(run_id).await {
@ -201,7 +200,7 @@ impl Store for SlateStore {
run_dir,
)
.await?;
return Ok(Arc::new(active) as Arc<dyn RunStore>);
return Ok(Arc::new(active));
}
let db_prefix = match locator {
@ -233,10 +232,10 @@ impl Store for SlateStore {
run_dir,
)
.await?;
Ok(Arc::new(run_store) as Arc<dyn RunStore>)
Ok(Arc::new(run_store))
}
async fn open_run(&self, run_id: &RunId) -> Result<Arc<dyn RunStore>> {
pub async fn open_run(&self, run_id: &RunId) -> Result<RunStoreHandle> {
let locator = catalog::read_locator(self.object_store.clone(), &self.base_prefix, run_id)
.await?
.ok_or_else(|| StoreError::RunNotFound(run_id.to_string()))?;
@ -245,10 +244,10 @@ impl Store for SlateStore {
.open_run_store(&locator)
.await?
.ok_or_else(|| StoreError::RunNotFound(run_id.to_string()))?;
Ok(Arc::new(run_store) as Arc<dyn RunStore>)
Ok(Arc::new(run_store))
}
async fn open_run_reader(&self, run_id: &RunId) -> Result<Arc<dyn RunStore>> {
pub async fn open_run_reader(&self, run_id: &RunId) -> Result<RunStoreHandle> {
let locator = catalog::read_locator(self.object_store.clone(), &self.base_prefix, run_id)
.await?
.ok_or_else(|| StoreError::RunNotFound(run_id.to_string()))?;
@ -257,10 +256,10 @@ impl Store for SlateStore {
.open_run_reader_store(&locator)
.await?
.ok_or_else(|| StoreError::RunNotFound(run_id.to_string()))?;
Ok(Arc::new(run_store) as Arc<dyn RunStore>)
Ok(Arc::new(run_store))
}
async fn list_runs(&self, query: &ListRunsQuery) -> Result<Vec<RunSummary>> {
pub async fn list_runs(&self, query: &ListRunsQuery) -> Result<Vec<RunSummary>> {
let catalogs =
catalog::list_catalogs(self.object_store.clone(), &self.base_prefix, query).await?;
let mut summaries = Vec::new();
@ -293,7 +292,7 @@ impl Store for SlateStore {
Ok(summaries)
}
async fn delete_run(&self, run_id: &RunId) -> Result<()> {
pub async fn delete_run(&self, run_id: &RunId) -> Result<()> {
let active = self.remove_active_run(run_id).await;
let active_record = active.as_ref().map(SlateRunStore::record);
if let Some(active) = &active {
@ -384,8 +383,8 @@ mod tests {
use bytes::Bytes;
use fabro_types::{
AttrValue, Checkpoint, Conclusion, Graph, NodeStatusRecord, RunId, RunRecord, RunStatus,
RunStatusRecord, Settings, StageStatus, StartRecord, StatusReason, fixtures,
AttrValue, Graph, NodeStatusRecord, RunId, RunRecord, RunStatus, Settings, StageStatus,
StatusReason, fixtures,
};
use object_store::memory::InMemory;
use slatedb::config::Settings as SlateSettings;
@ -433,58 +432,6 @@ mod tests {
}
}
fn sample_start_record(run_id: &str, created_at: DateTime<Utc>) -> StartRecord {
StartRecord {
run_id: test_run_id(run_id),
start_time: created_at + chrono::Duration::seconds(5),
run_branch: Some("fabro/run/demo".to_string()),
base_sha: Some("abc123".to_string()),
}
}
fn sample_status(status: RunStatus, reason: Option<StatusReason>) -> RunStatusRecord {
RunStatusRecord {
status,
reason,
updated_at: dt("2026-03-27T12:05:00Z"),
}
}
fn sample_checkpoint() -> Checkpoint {
Checkpoint {
timestamp: dt("2026-03-27T12:10:00Z"),
current_node: "code".to_string(),
completed_nodes: vec!["plan".to_string()],
node_retries: std::collections::HashMap::from([("code".to_string(), 1)]),
context_values: std::collections::HashMap::new(),
node_outcomes: std::collections::HashMap::new(),
next_node_id: Some("review".to_string()),
git_commit_sha: Some("def456".to_string()),
loop_failure_signatures: std::collections::HashMap::new(),
restart_failure_signatures: std::collections::HashMap::new(),
node_visits: std::collections::HashMap::from([("code".to_string(), 2)]),
}
}
fn sample_conclusion() -> Conclusion {
Conclusion {
timestamp: dt("2026-03-27T12:15:00Z"),
status: StageStatus::Success,
duration_ms: 3210,
failure_reason: None,
final_git_commit_sha: Some("feedbeef".to_string()),
stages: Vec::new(),
total_cost: Some(1.25),
total_retries: 2,
total_input_tokens: 10,
total_output_tokens: 20,
total_cache_read_tokens: 30,
total_cache_write_tokens: 40,
total_reasoning_tokens: 50,
has_pricing: true,
}
}
fn sample_node_status() -> NodeStatusRecord {
NodeStatusRecord {
status: StageStatus::Success,
@ -494,17 +441,24 @@ mod tests {
}
}
fn event_payload(run_id: &str, ts: &str, event: &str) -> EventPayload {
EventPayload::new(
serde_json::json!({
"id": format!("evt-{run_id}-{event}"),
"ts": ts,
"run_id": test_run_id(run_id).to_string(),
"event": event
}),
&test_run_id(run_id),
)
.unwrap()
fn event_payload(
run_id: &str,
ts: &str,
event: &str,
node_id: Option<&str>,
properties: serde_json::Value,
) -> EventPayload {
let mut value = serde_json::json!({
"id": format!("evt-{run_id}-{event}"),
"ts": ts,
"run_id": test_run_id(run_id).to_string(),
"event": event,
"properties": properties,
});
if let Some(node_id) = node_id {
value["node_id"] = serde_json::Value::String(node_id.to_string());
}
EventPayload::new(value, &test_run_id(run_id)).unwrap()
}
async fn list_paths(store: Arc<dyn ObjectStore>, prefix: &str) -> Vec<String> {
@ -552,19 +506,51 @@ mod tests {
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
.await
.unwrap();
run.put_start(&sample_start_record("run-1", created_at))
.await
.unwrap();
run.put_status(&sample_status(
RunStatus::Succeeded,
Some(StatusReason::Completed),
let run_record = sample_run_record("run-1", created_at);
run.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:00Z",
"run.created",
None,
serde_json::json!({
"settings": run_record.settings,
"graph": run_record.graph,
"workflow_slug": run_record.workflow_slug,
"working_directory": run_record.working_directory,
"host_repo_path": run_record.host_repo_path,
"base_branch": run_record.base_branch,
"labels": run_record.labels,
}),
))
.await
.unwrap();
run.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:01Z",
"run.started",
None,
serde_json::json!({
"run_branch": "fabro/run/demo",
"base_sha": "abc123",
}),
))
.await
.unwrap();
run.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:02Z",
"run.completed",
None,
serde_json::json!({
"duration_ms": 3210,
"artifact_count": 1,
"status": "success",
"reason": "completed",
"total_cost": 1.25,
}),
))
.await
.unwrap();
run.put_conclusion(&sample_conclusion()).await.unwrap();
let by_id = catalog::by_id_path("runs/", &test_run_id("run-1"));
let by_start = catalog::by_start_path("runs/", created_at, &test_run_id("run-1"));
@ -579,20 +565,12 @@ mod tests {
assert_eq!(summary[0].status, Some(RunStatus::Succeeded));
assert_eq!(summary[0].status_reason, Some(StatusReason::Completed));
let reopened = store
.open_run(&test_run_id("run-1"))
.await
.unwrap();
let stored = reopened.get_run().await.unwrap().unwrap();
let reopened = store.open_run(&test_run_id("run-1")).await.unwrap();
let stored = reopened.state().await.unwrap().run.unwrap();
assert_eq!(stored.run_id, test_run_id("run-1"));
store.delete_run(&test_run_id("run-1")).await.unwrap();
assert!(
store
.open_run(&test_run_id("run-1"))
.await
.is_err()
);
assert!(store.open_run(&test_run_id("run-1")).await.is_err());
assert!(!object_exists(object_store.clone(), &by_id).await);
assert!(!object_exists(object_store.clone(), &by_start).await);
assert!(list_paths(object_store, "runs/db").await.is_empty());
@ -611,8 +589,23 @@ mod tests {
let db = seed_db(object_store.clone(), &record, true).await;
db.put(
keys::run(),
serde_json::to_vec(&sample_run_record("run-1", created_at)).unwrap(),
keys::event_key(1, created_at.timestamp_millis()),
serde_json::to_vec(&event_payload(
"run-1",
"2026-03-27T12:00:00Z",
"run.created",
None,
serde_json::json!({
"settings": sample_run_record("run-1", created_at).settings,
"graph": sample_run_record("run-1", created_at).graph,
"workflow_slug": sample_run_record("run-1", created_at).workflow_slug,
"working_directory": sample_run_record("run-1", created_at).working_directory,
"host_repo_path": sample_run_record("run-1", created_at).host_repo_path,
"base_branch": sample_run_record("run-1", created_at).base_branch,
"labels": sample_run_record("run-1", created_at).labels,
}),
))
.unwrap(),
)
.await
.unwrap();
@ -626,12 +619,7 @@ mod tests {
.await
.unwrap();
assert!(
store
.open_run(&test_run_id("run-1"))
.await
.is_ok()
);
assert!(store.open_run(&test_run_id("run-1")).await.is_ok());
assert!(
store
.list_runs(&ListRunsQuery::default())
@ -653,43 +641,63 @@ mod tests {
}
#[tokio::test]
async fn reopen_recovers_event_and_checkpoint_sequences() {
async fn reopen_recovers_event_sequences() {
let (_object_store, store) = make_store();
let created_at = dt("2026-03-27T12:00:00Z");
let run = store
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
.await
.unwrap();
run.append_event(&event_payload("run-1", "2026-03-27T12:00:00Z", "Started"))
.await
.unwrap();
run.append_event(&event_payload("run-1", "2026-03-27T12:00:01Z", "Next"))
.await
.unwrap();
run.append_checkpoint(&sample_checkpoint()).await.unwrap();
let run_record = sample_run_record("run-1", created_at);
run.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:00Z",
"run.created",
None,
serde_json::json!({
"settings": run_record.settings,
"graph": run_record.graph,
"workflow_slug": run_record.workflow_slug,
"working_directory": run_record.working_directory,
"host_repo_path": run_record.host_repo_path,
"base_branch": run_record.base_branch,
"labels": run_record.labels,
}),
))
.await
.unwrap();
run.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:01Z",
"Started",
None,
serde_json::json!({}),
))
.await
.unwrap();
run.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:02Z",
"Next",
None,
serde_json::json!({}),
))
.await
.unwrap();
drop(run);
let reopened = store
.open_run(&test_run_id("run-1"))
.await
.unwrap();
let reopened = store.open_run(&test_run_id("run-1")).await.unwrap();
let next_event = reopened
.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:02Z",
"AfterReopen",
None,
serde_json::json!({}),
))
.await
.unwrap();
let next_checkpoint = reopened
.append_checkpoint(&sample_checkpoint())
.await
.unwrap();
assert_eq!(next_event, 3);
assert_eq!(next_checkpoint, 2);
assert_eq!(next_event, 4);
}
#[tokio::test]
@ -720,12 +728,7 @@ mod tests {
.await
.unwrap();
assert!(
store
.open_run(&test_run_id("run-1"))
.await
.is_err()
);
assert!(store.open_run(&test_run_id("run-1")).await.is_err());
assert!(
store
.list_runs(&ListRunsQuery::default())
@ -766,35 +769,51 @@ mod tests {
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
.await
.unwrap();
let run_record = sample_run_record("run-1", created_at);
run.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:00Z",
"run.created",
None,
serde_json::json!({
"settings": run_record.settings,
"graph": run_record.graph,
"workflow_slug": run_record.workflow_slug,
"working_directory": run_record.working_directory,
"host_repo_path": run_record.host_repo_path,
"base_branch": run_record.base_branch,
"labels": run_record.labels,
}),
))
.await
.unwrap();
let listed = store.list_runs(&ListRunsQuery::default()).await.unwrap();
assert_eq!(listed.len(), 1);
let reopened = store
.open_run(&test_run_id("run-1"))
.await
.unwrap();
let reopened = store.open_run(&test_run_id("run-1")).await.unwrap();
let first_event = run
.append_event(&event_payload("run-1", "2026-03-27T12:00:00Z", "Started"))
.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:00Z",
"Started",
None,
serde_json::json!({}),
))
.await
.unwrap();
let second_event = reopened
.append_event(&event_payload("run-1", "2026-03-27T12:00:01Z", "Continued"))
.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:01Z",
"Continued",
None,
serde_json::json!({}),
))
.await
.unwrap();
let first_checkpoint = run.append_checkpoint(&sample_checkpoint()).await.unwrap();
let second_checkpoint = reopened
.append_checkpoint(&sample_checkpoint())
.await
.unwrap();
assert_eq!(first_event, 1);
assert_eq!(second_event, 2);
assert_eq!(first_checkpoint, 1);
assert_eq!(second_checkpoint, 2);
assert_eq!(first_event, 2);
assert_eq!(second_event, 3);
}
#[tokio::test]
@ -807,9 +826,15 @@ mod tests {
.unwrap();
let mut stream = run.watch_events_from(1).await.unwrap();
run.append_event(&event_payload("run-1", "2026-03-27T12:00:00Z", "Started"))
.await
.unwrap();
run.append_event(&event_payload(
"run-1",
"2026-03-27T12:00:00Z",
"Started",
None,
serde_json::json!({}),
))
.await
.unwrap();
let event = timeout(
Duration::from_secs(2),
@ -834,8 +859,23 @@ mod tests {
};
let db = seed_db(object_store.clone(), &record, true).await;
db.put(
keys::run(),
serde_json::to_vec(&sample_run_record("run-1", created_at)).unwrap(),
keys::event_key(1, created_at.timestamp_millis()),
serde_json::to_vec(&event_payload(
"run-1",
"2026-03-27T12:00:00Z",
"run.created",
None,
serde_json::json!({
"settings": sample_run_record("run-1", created_at).settings,
"graph": sample_run_record("run-1", created_at).graph,
"workflow_slug": sample_run_record("run-1", created_at).workflow_slug,
"working_directory": sample_run_record("run-1", created_at).working_directory,
"host_repo_path": sample_run_record("run-1", created_at).host_repo_path,
"base_branch": sample_run_record("run-1", created_at).base_branch,
"labels": sample_run_record("run-1", created_at).labels,
}),
))
.unwrap(),
)
.await
.unwrap();
@ -861,23 +901,21 @@ mod tests {
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
run.put_artifact_value("summary", &serde_json::json!({"done": true}))
.await
.unwrap();
store.delete_run(&test_run_id("run-1")).await.unwrap();
let err = run.put_graph("digraph night_sky {}").await.unwrap_err();
let err = run
.put_artifact_value("summary", &serde_json::json!({"done": false}))
.await
.unwrap_err();
assert!(matches!(
err,
StoreError::Slate(err) if matches!(err.kind(), ErrorKind::Closed(CloseReason::Clean))
));
assert!(
store
.open_run(&test_run_id("run-1"))
.await
.is_err()
);
assert!(store.open_run(&test_run_id("run-1")).await.is_err());
assert!(list_paths(object_store, "runs").await.is_empty());
}
@ -890,7 +928,7 @@ mod tests {
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
run.put_artifact_value("summary", &serde_json::json!({"done": true}))
.await
.unwrap();
@ -927,14 +965,13 @@ mod tests {
assert_ne!(orphan.db_prefix, new_prefix);
let db = seed_db(object_store.clone(), &orphan, true).await;
db.put(keys::graph(), b"stale graph").await.unwrap();
db.close().await.unwrap();
let run = store
.create_run(&test_run_id("run-1"), new_created_at, None)
.await
.unwrap();
assert_eq!(run.get_graph().await.unwrap(), None);
assert!(run.state().await.unwrap().graph_source.is_none());
let locator = catalog::read_locator(object_store, "runs/", &test_run_id("run-1"))
.await
@ -988,9 +1025,6 @@ mod tests {
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
.await
.unwrap();
let node = NodeVisitRef {
node_id: "code",
visit: 2,
@ -1023,10 +1057,6 @@ mod tests {
.create_run(&test_run_id("run-1"), created_at, None)
.await
.unwrap();
run.put_run(&sample_run_record("run-1", created_at))
.await
.unwrap();
run.put_artifact_value("summary", &serde_json::json!({"done": true}))
.await
.unwrap();
@ -1067,8 +1097,7 @@ mod tests {
]
);
let snapshot = run.get_snapshot().await.unwrap().unwrap();
assert_eq!(snapshot.nodes.len(), 1);
assert_eq!(snapshot.nodes[0].node_id, "code");
let code_node = run.get_node(&snapshot_node).await.unwrap();
assert_eq!(code_node.node_id, "code");
}
}

View file

@ -3,7 +3,6 @@ use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Weak};
use std::time::Duration;
use async_trait::async_trait;
use bytes::Bytes;
use chrono::{DateTime, Utc};
use futures::Stream;
@ -18,18 +17,26 @@ use crate::keys;
use crate::run_state::EventProjectionCache;
use crate::{
CatalogRecord, EventEnvelope, EventPayload, NodeOutcomeRecord, NodeSnapshot, NodeVisitRef,
Result, RunSnapshot, RunState, RunStore, RunSummary, StoreError,
};
use fabro_types::{
Checkpoint, Conclusion, NodeStatusRecord, PullRequestRecord, Retro, RunId, RunRecord,
RunStatusRecord, SandboxRecord, StartRecord,
Result, RunState, RunSummary, StoreError,
};
use fabro_types::{NodeStatusRecord, RunId};
#[derive(Clone)]
pub(crate) struct SlateRunStore {
pub struct SlateRunStore {
inner: Arc<SlateRunStoreInner>,
}
impl std::fmt::Debug for SlateRunStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SlateRunStore")
.field("run_id", &self.inner.run_id)
.field("created_at", &self.inner.created_at)
.field("db_prefix", &self.inner.db_prefix)
.field("run_dir", &self.inner.run_dir)
.finish_non_exhaustive()
}
}
pub(crate) struct SlateRunStoreInner {
run_id: RunId,
created_at: DateTime<Utc>,
@ -37,7 +44,6 @@ pub(crate) struct SlateRunStoreInner {
run_dir: Option<String>,
db: SlateRunDb,
event_seq: AtomicU32,
checkpoint_seq: AtomicU32,
close_lock: Mutex<()>,
projection_cache: Mutex<EventProjectionCache>,
}
@ -50,8 +56,6 @@ enum SlateRunDb {
impl SlateRunStore {
pub(crate) async fn open_writer(record: CatalogRecord, db: slatedb::Db) -> Result<Self> {
let event_seq = recover_next_seq(&db, keys::EVENTS_PREFIX, keys::parse_event_seq).await?;
let checkpoint_seq =
recover_next_seq(&db, keys::CHECKPOINTS_PREFIX, keys::parse_checkpoint_seq).await?;
Ok(Self {
inner: Arc::new(SlateRunStoreInner {
run_id: record.run_id,
@ -60,7 +64,6 @@ impl SlateRunStore {
run_dir: record.run_dir,
db: SlateRunDb::Writer(db),
event_seq: AtomicU32::new(event_seq),
checkpoint_seq: AtomicU32::new(checkpoint_seq),
close_lock: Mutex::new(()),
projection_cache: Mutex::new(EventProjectionCache::default()),
}),
@ -69,8 +72,6 @@ impl SlateRunStore {
pub(crate) async fn open_reader(record: CatalogRecord, db: DbReader) -> Result<Self> {
let event_seq = recover_next_seq(&db, keys::EVENTS_PREFIX, keys::parse_event_seq).await?;
let checkpoint_seq =
recover_next_seq(&db, keys::CHECKPOINTS_PREFIX, keys::parse_checkpoint_seq).await?;
Ok(Self {
inner: Arc::new(SlateRunStoreInner {
run_id: record.run_id,
@ -79,7 +80,6 @@ impl SlateRunStore {
run_dir: record.run_dir,
db: SlateRunDb::Reader(Box::new(db)),
event_seq: AtomicU32::new(event_seq),
checkpoint_seq: AtomicU32::new(checkpoint_seq),
close_lock: Mutex::new(()),
projection_cache: Mutex::new(EventProjectionCache::default()),
}),
@ -148,91 +148,10 @@ impl SlateRunStore {
R: DbRead + Sync,
{
let events = list_events_from(db, 1).await?;
let mut state = RunState::apply_events(&events)?;
let mut nodes = BTreeSet::new();
let mut iter = db.scan_prefix(b"nodes/").await?;
while let Some(entry) = iter.next().await? {
let key = key_to_string(&entry.key)?;
if let Some((node_id, visit, _)) = keys::parse_node_key(&key) {
nodes.insert((node_id, visit));
}
}
let snapshot = if let Some(run) = get_json::<_, RunRecord>(db, keys::run()).await? {
let mut snapshot_nodes = Vec::new();
for (node_id, visit) in nodes {
let node = NodeVisitRef {
node_id: &node_id,
visit,
};
let prompt_key = keys::node_prompt(&node);
let response_key = keys::node_response(&node);
let status_key = keys::node_status(&node);
let outcome_key = keys::node_outcome(&node);
let provider_key = keys::node_provider_used(&node);
let diff_key = keys::node_diff(&node);
let invocation_key = keys::node_script_invocation(&node);
let timing_key = keys::node_script_timing(&node);
let results_key = keys::node_parallel_results(&node);
let stdout_key = keys::node_stdout(&node);
let stderr_key = keys::node_stderr(&node);
snapshot_nodes.push(NodeSnapshot {
node_id: node_id.clone(),
visit,
prompt: get_text(db, &prompt_key).await?,
response: get_text(db, &response_key).await?,
status: get_json(db, &status_key).await?,
outcome: get_json(db, &outcome_key).await?,
provider_used: get_json(db, &provider_key).await?,
diff: get_text(db, &diff_key).await?,
script_invocation: get_json(db, &invocation_key).await?,
script_timing: get_json(db, &timing_key).await?,
parallel_results: get_json(db, &results_key).await?,
stdout: get_text(db, &stdout_key).await?,
stderr: get_text(db, &stderr_key).await?,
});
}
Some(RunSnapshot {
run,
start: get_json::<_, StartRecord>(db, keys::start()).await?,
status: get_json::<_, RunStatusRecord>(db, keys::status()).await?,
checkpoint: get_json::<_, Checkpoint>(db, keys::checkpoint()).await?,
conclusion: get_json::<_, Conclusion>(db, keys::conclusion()).await?,
retro: get_json::<_, Retro>(db, keys::retro()).await?,
graph: get_text(db, keys::graph()).await?,
sandbox: get_json::<_, SandboxRecord>(db, keys::sandbox()).await?,
final_patch: get_text(db, keys::final_patch()).await?,
pull_request: get_json::<_, PullRequestRecord>(db, keys::pull_request()).await?,
nodes: snapshot_nodes,
})
} else {
None
};
state.merge_legacy(
snapshot,
get_text(db, keys::graph()).await?,
get_text(db, keys::retro_prompt()).await?,
get_text(db, keys::retro_response()).await?,
list_checkpoints(db).await?,
);
let state = RunState::apply_events(&events)?;
Ok(state.build_summary(catalog))
}
fn validate_run_record(&self, record: &RunRecord) -> Result<()> {
if record.created_at != self.inner.created_at {
return Err(StoreError::Other(format!(
"run record created_at {:?} does not match store created_at {:?}",
record.created_at, self.inner.created_at
)));
}
if record.run_id != self.inner.run_id {
return Err(StoreError::Other(format!(
"run record run_id {:?} does not match store run_id {:?}",
record.run_id, self.inner.run_id
)));
}
Ok(())
}
async fn build_node_snapshot(&self, node: &NodeVisitRef<'_>) -> Result<NodeSnapshot> {
Ok(NodeSnapshot {
node_id: node.node_id.to_string(),
@ -282,105 +201,22 @@ impl SlateRunStore {
}
}
#[async_trait]
impl RunStore for SlateRunStore {
async fn put_run(&self, record: &RunRecord) -> Result<()> {
self.validate_run_record(record)?;
self.inner.db.put_json(keys::run(), record).await
}
async fn get_run(&self) -> Result<Option<RunRecord>> {
self.inner.db.get_json(keys::run()).await
}
async fn put_start(&self, record: &StartRecord) -> Result<()> {
self.inner.db.put_json(keys::start(), record).await
}
async fn get_start(&self) -> Result<Option<StartRecord>> {
self.inner.db.get_json(keys::start()).await
}
async fn put_status(&self, record: &RunStatusRecord) -> Result<()> {
self.inner.db.put_json(keys::status(), record).await
}
async fn get_status(&self) -> Result<Option<RunStatusRecord>> {
self.inner.db.get_json(keys::status()).await
}
async fn put_checkpoint(&self, record: &Checkpoint) -> Result<()> {
self.inner.db.put_json(keys::checkpoint(), record).await
}
async fn get_checkpoint(&self) -> Result<Option<Checkpoint>> {
self.inner.db.get_json(keys::checkpoint()).await
}
async fn append_checkpoint(&self, record: &Checkpoint) -> Result<u32> {
let seq = self.inner.checkpoint_seq.fetch_add(1, Ordering::SeqCst);
self.put_checkpoint(record).await?;
self.inner
.db
.put_json(
&keys::checkpoint_history_key(seq, Utc::now().timestamp_millis()),
record,
)
.await?;
Ok(seq)
}
async fn list_checkpoints(&self) -> Result<Vec<(u32, Checkpoint)>> {
self.inner.db.list_checkpoints().await
}
async fn put_conclusion(&self, record: &Conclusion) -> Result<()> {
self.inner.db.put_json(keys::conclusion(), record).await
}
async fn get_conclusion(&self) -> Result<Option<Conclusion>> {
self.inner.db.get_json(keys::conclusion()).await
}
async fn put_retro(&self, retro: &Retro) -> Result<()> {
self.inner.db.put_json(keys::retro(), retro).await
}
async fn get_retro(&self) -> Result<Option<Retro>> {
self.inner.db.get_json(keys::retro()).await
}
async fn put_graph(&self, dot_source: &str) -> Result<()> {
self.inner.db.put_text(keys::graph(), dot_source).await
}
async fn get_graph(&self) -> Result<Option<String>> {
self.inner.db.get_text(keys::graph()).await
}
async fn put_sandbox(&self, record: &SandboxRecord) -> Result<()> {
self.inner.db.put_json(keys::sandbox(), record).await
}
async fn get_sandbox(&self) -> Result<Option<SandboxRecord>> {
self.inner.db.get_json(keys::sandbox()).await
}
async fn put_node_prompt(&self, node: &NodeVisitRef<'_>, prompt: &str) -> Result<()> {
impl SlateRunStore {
pub async fn put_node_prompt(&self, node: &NodeVisitRef<'_>, prompt: &str) -> Result<()> {
self.inner
.db
.put_text(&keys::node_prompt(node), prompt)
.await
}
async fn put_node_response(&self, node: &NodeVisitRef<'_>, response: &str) -> Result<()> {
pub async fn put_node_response(&self, node: &NodeVisitRef<'_>, response: &str) -> Result<()> {
self.inner
.db
.put_text(&keys::node_response(node), response)
.await
}
async fn put_node_status(
pub async fn put_node_status(
&self,
node: &NodeVisitRef<'_>,
status: &NodeStatusRecord,
@ -391,7 +227,7 @@ impl RunStore for SlateRunStore {
.await
}
async fn put_node_outcome(
pub async fn put_node_outcome(
&self,
node: &NodeVisitRef<'_>,
outcome: &NodeOutcomeRecord,
@ -402,7 +238,7 @@ impl RunStore for SlateRunStore {
.await
}
async fn put_node_provider_used(
pub async fn put_node_provider_used(
&self,
node: &NodeVisitRef<'_>,
provider_used: &serde_json::Value,
@ -413,11 +249,11 @@ impl RunStore for SlateRunStore {
.await
}
async fn put_node_diff(&self, node: &NodeVisitRef<'_>, diff: &str) -> Result<()> {
pub async fn put_node_diff(&self, node: &NodeVisitRef<'_>, diff: &str) -> Result<()> {
self.inner.db.put_text(&keys::node_diff(node), diff).await
}
async fn put_node_script_invocation(
pub async fn put_node_script_invocation(
&self,
node: &NodeVisitRef<'_>,
invocation: &serde_json::Value,
@ -428,7 +264,7 @@ impl RunStore for SlateRunStore {
.await
}
async fn put_node_script_timing(
pub async fn put_node_script_timing(
&self,
node: &NodeVisitRef<'_>,
timing: &serde_json::Value,
@ -439,7 +275,7 @@ impl RunStore for SlateRunStore {
.await
}
async fn put_node_parallel_results(
pub async fn put_node_parallel_results(
&self,
node: &NodeVisitRef<'_>,
results: &serde_json::Value,
@ -450,19 +286,19 @@ impl RunStore for SlateRunStore {
.await
}
async fn put_node_stdout(&self, node: &NodeVisitRef<'_>, log: &str) -> Result<()> {
pub async fn put_node_stdout(&self, node: &NodeVisitRef<'_>, log: &str) -> Result<()> {
self.inner.db.put_text(&keys::node_stdout(node), log).await
}
async fn put_node_stderr(&self, node: &NodeVisitRef<'_>, log: &str) -> Result<()> {
pub async fn put_node_stderr(&self, node: &NodeVisitRef<'_>, log: &str) -> Result<()> {
self.inner.db.put_text(&keys::node_stderr(node), log).await
}
async fn get_node(&self, node: &NodeVisitRef<'_>) -> Result<NodeSnapshot> {
pub async fn get_node(&self, node: &NodeVisitRef<'_>) -> Result<NodeSnapshot> {
self.build_node_snapshot(node).await
}
async fn list_node_visits(&self, node_id: &str) -> Result<Vec<u32>> {
pub async fn list_node_visits(&self, node_id: &str) -> Result<Vec<u32>> {
let prefix = format!("nodes/{node_id}/visit-");
let mut iter = self.inner.db.scan_prefix(prefix.as_bytes()).await?;
let mut visits = BTreeSet::new();
@ -477,7 +313,7 @@ impl RunStore for SlateRunStore {
Ok(visits.into_iter().collect())
}
async fn list_node_ids(&self) -> Result<Vec<String>> {
pub async fn list_node_ids(&self) -> Result<Vec<String>> {
let mut iter = self.inner.db.scan_prefix(b"nodes/").await?;
let mut node_ids = BTreeSet::new();
while let Some(entry) = iter.next().await? {
@ -502,40 +338,13 @@ impl RunStore for SlateRunStore {
Ok(node_ids.into_iter().collect())
}
async fn put_final_patch(&self, patch: &str) -> Result<()> {
self.inner.db.put_text(keys::final_patch(), patch).await
}
async fn get_final_patch(&self) -> Result<Option<String>> {
self.inner.db.get_text(keys::final_patch()).await
}
async fn put_pull_request(&self, record: &PullRequestRecord) -> Result<()> {
self.inner.db.put_json(keys::pull_request(), record).await
}
async fn get_pull_request(&self) -> Result<Option<PullRequestRecord>> {
self.inner.db.get_json(keys::pull_request()).await
}
async fn reset_for_rewind(&self) -> Result<()> {
pub async fn reset_for_rewind(&self) -> Result<()> {
let db = self.inner.db.writer()?;
for key in [
keys::status(),
keys::checkpoint(),
keys::conclusion(),
keys::retro(),
keys::sandbox(),
keys::final_patch(),
keys::pull_request(),
keys::retro_prompt(),
keys::retro_response(),
] {
for key in [keys::retro_prompt(), keys::retro_response()] {
db.delete(key).await?;
}
for prefix in [
b"nodes/".as_slice(),
keys::CHECKPOINTS_PREFIX.as_bytes(),
keys::ARTIFACT_VALUES_PREFIX.as_bytes(),
keys::ARTIFACT_NODES_PREFIX.as_bytes(),
] {
@ -544,7 +353,7 @@ impl RunStore for SlateRunStore {
Ok(())
}
async fn append_event(&self, payload: &EventPayload) -> Result<u32> {
pub async fn append_event(&self, payload: &EventPayload) -> Result<u32> {
payload.validate(&self.inner.run_id)?;
let seq = self.inner.event_seq.fetch_add(1, Ordering::SeqCst);
self.inner
@ -557,15 +366,15 @@ impl RunStore for SlateRunStore {
Ok(seq)
}
async fn list_events(&self) -> Result<Vec<EventEnvelope>> {
pub async fn list_events(&self) -> Result<Vec<EventEnvelope>> {
self.inner.db.list_events_from(1).await
}
async fn list_events_from(&self, seq: u32) -> Result<Vec<EventEnvelope>> {
pub async fn list_events_from(&self, seq: u32) -> Result<Vec<EventEnvelope>> {
self.inner.db.list_events_from(seq).await
}
async fn watch_events_from(
pub async fn watch_events_from(
&self,
seq: u32,
) -> Result<std::pin::Pin<Box<dyn Stream<Item = Result<EventEnvelope>> + Send>>> {
@ -603,55 +412,68 @@ impl RunStore for SlateRunStore {
Ok(Box::pin(UnboundedReceiverStream::new(receiver)))
}
async fn put_retro_prompt(&self, text: &str) -> Result<()> {
pub async fn put_retro_prompt(&self, text: &str) -> Result<()> {
self.inner.db.put_text(keys::retro_prompt(), text).await
}
async fn get_retro_prompt(&self) -> Result<Option<String>> {
pub async fn get_retro_prompt(&self) -> Result<Option<String>> {
self.inner.db.get_text(keys::retro_prompt()).await
}
async fn put_retro_response(&self, text: &str) -> Result<()> {
pub async fn put_retro_response(&self, text: &str) -> Result<()> {
self.inner.db.put_text(keys::retro_response(), text).await
}
async fn get_retro_response(&self) -> Result<Option<String>> {
pub async fn get_retro_response(&self) -> Result<Option<String>> {
self.inner.db.get_text(keys::retro_response()).await
}
async fn put_artifact_value(&self, artifact_id: &str, value: &serde_json::Value) -> Result<()> {
pub async fn put_artifact_value(
&self,
artifact_id: &str,
value: &serde_json::Value,
) -> Result<()> {
self.inner
.db
.put_json(&keys::artifact_value(artifact_id), value)
.await
}
async fn get_artifact_value(&self, artifact_id: &str) -> Result<Option<serde_json::Value>> {
pub async fn get_artifact_value(&self, artifact_id: &str) -> Result<Option<serde_json::Value>> {
self.inner
.db
.get_json(&keys::artifact_value(artifact_id))
.await
}
async fn list_artifact_values(&self) -> Result<Vec<String>> {
pub async fn list_artifact_values(&self) -> Result<Vec<String>> {
self.inner.db.list_artifact_values().await
}
async fn put_asset(&self, node: &NodeVisitRef<'_>, filename: &str, data: &[u8]) -> Result<()> {
pub async fn put_asset(
&self,
node: &NodeVisitRef<'_>,
filename: &str,
data: &[u8],
) -> Result<()> {
self.inner
.db
.put_bytes(&keys::node_asset(node, filename), data)
.await
}
async fn get_asset(&self, node: &NodeVisitRef<'_>, filename: &str) -> Result<Option<Bytes>> {
pub async fn get_asset(
&self,
node: &NodeVisitRef<'_>,
filename: &str,
) -> Result<Option<Bytes>> {
self.inner
.db
.get_bytes(&keys::node_asset(node, filename))
.await
}
async fn list_assets(&self, node: &NodeVisitRef<'_>) -> Result<Vec<String>> {
pub async fn list_assets(&self, node: &NodeVisitRef<'_>) -> Result<Vec<String>> {
let prefix = format!("{}/", keys::node_asset_prefix(node));
let mut iter = self.inner.db.scan_prefix(prefix.as_bytes()).await?;
let mut assets = Vec::new();
@ -665,64 +487,12 @@ impl RunStore for SlateRunStore {
Ok(assets)
}
async fn list_all_assets(&self) -> Result<Vec<(String, u32, String)>> {
pub async fn list_all_assets(&self) -> Result<Vec<(String, u32, String)>> {
self.inner.db.list_all_assets().await
}
async fn state(&self) -> Result<RunState> {
let mut state = self.projected_state().await?;
state.merge_legacy(
self.get_snapshot_legacy().await?,
self.get_graph().await?,
self.get_retro_prompt().await?,
self.get_retro_response().await?,
self.list_checkpoints().await?,
);
Ok(state)
}
async fn get_snapshot(&self) -> Result<Option<RunSnapshot>> {
self.state().await.map(|state| state.to_snapshot())
}
}
impl SlateRunStore {
async fn get_snapshot_legacy(&self) -> Result<Option<RunSnapshot>> {
let Some(run) = self.get_run().await? else {
return Ok(None);
};
let mut iter = self.inner.db.scan_prefix(b"nodes/").await?;
let mut visits = BTreeSet::new();
while let Some(entry) = iter.next().await? {
let key = key_to_string(&entry.key)?;
if let Some((node_id, visit, _)) = keys::parse_node_key(&key) {
visits.insert((node_id, visit));
}
}
let mut nodes = Vec::new();
for (node_id, visit) in visits {
let node = NodeVisitRef {
node_id: &node_id,
visit,
};
nodes.push(self.build_node_snapshot(&node).await?);
}
Ok(Some(RunSnapshot {
run,
start: self.get_start().await?,
status: self.get_status().await?,
checkpoint: self.get_checkpoint().await?,
conclusion: self.get_conclusion().await?,
retro: self.get_retro().await?,
graph: self.get_graph().await?,
sandbox: self.get_sandbox().await?,
final_patch: self.get_final_patch().await?,
pull_request: self.get_pull_request().await?,
nodes,
}))
pub async fn state(&self) -> Result<RunState> {
self.projected_state().await
}
}
@ -794,13 +564,6 @@ impl SlateRunDb {
}
}
async fn list_checkpoints(&self) -> Result<Vec<(u32, Checkpoint)>> {
match self {
Self::Writer(db) => list_checkpoints(db).await,
Self::Reader(db) => list_checkpoints(db.as_ref()).await,
}
}
async fn list_artifact_values(&self) -> Result<Vec<String>> {
match self {
Self::Writer(db) => list_artifact_values(db).await,
@ -910,23 +673,6 @@ where
Ok(events)
}
async fn list_checkpoints<R>(db: &R) -> Result<Vec<(u32, Checkpoint)>>
where
R: DbRead + Sync,
{
let mut iter = db.scan_prefix(keys::CHECKPOINTS_PREFIX.as_bytes()).await?;
let mut checkpoints = Vec::new();
while let Some(entry) = iter.next().await? {
let key = key_to_string(&entry.key)?;
let Some(seq) = keys::parse_checkpoint_seq(&key) else {
continue;
};
checkpoints.push((seq, serde_json::from_slice(&entry.value)?));
}
checkpoints.sort_by_key(|(seq, _)| *seq);
Ok(checkpoints)
}
async fn list_artifact_values<R>(db: &R) -> Result<Vec<String>>
where
R: DbRead + Sync,

View file

@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicI64, Ordering};
use anyhow::{Context, Result};
use chrono::{SecondsFormat, Utc};
use fabro_store::{EventPayload, NodeVisitRef, RunStore};
use fabro_store::{EventPayload, NodeVisitRef, RunStoreHandle, SlateRunStore};
use fabro_types::RunId;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
@ -1517,7 +1517,7 @@ pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result<Ev
}
pub async fn append_workflow_event(
run_store: &dyn RunStore,
run_store: &SlateRunStore,
run_id: &RunId,
event: &WorkflowRunEvent,
) -> Result<()> {
@ -1562,7 +1562,7 @@ pub struct StoreProgressLogger {
impl StoreProgressLogger {
#[must_use]
pub fn new(run_store: Arc<dyn RunStore>) -> Self {
pub fn new(run_store: RunStoreHandle) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
@ -1627,7 +1627,7 @@ impl StoreProgressLogger {
}
async fn project_provider_used_from_event_payload(
run_store: &dyn RunStore,
run_store: &SlateRunStore,
payload: &EventPayload,
) -> Result<()> {
let value = payload.as_value();

View file

@ -2,7 +2,7 @@ use std::path::Path;
use std::process::Command;
use fabro_checkpoint::git::Store;
use fabro_store::{NodeVisitRef, RunStore};
use fabro_store::{NodeVisitRef, SlateRunStore};
use fabro_types::Settings;
use crate::error::{FabroError, Result};
@ -353,7 +353,7 @@ pub fn scan_node_files(run_dir: &Path) -> Vec<(String, Vec<u8>)> {
result
}
pub async fn scan_node_files_from_store(run_store: &dyn RunStore) -> Vec<(String, Vec<u8>)> {
pub async fn scan_node_files_from_store(run_store: &SlateRunStore) -> Vec<(String, Vec<u8>)> {
let mut result = Vec::new();
let Ok(node_ids) = run_store.list_node_ids().await else {
return result;
@ -441,10 +441,12 @@ fn node_file_path(node_id: &str, visit: u32, filename: &str) -> String {
mod tests {
use super::*;
use chrono::Utc;
use fabro_graphviz::graph::Graph;
use fabro_store::{InMemoryStore, Store};
use fabro_types::{NodeStatusRecord, RunRecord, StageStatus, fixtures};
use fabro_store::SlateStore;
use fabro_types::{NodeStatusRecord, StageStatus, fixtures};
use object_store::memory::InMemory;
use std::fs;
use std::sync::Arc;
use std::time::Duration;
/// Create a temporary git repo with an initial commit.
fn init_repo(dir: &Path) {
@ -469,6 +471,14 @@ mod tests {
.unwrap();
}
fn test_store() -> Arc<SlateStore> {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
#[test]
fn ensure_clean_on_clean_repo() {
let dir = tempfile::tempdir().unwrap();
@ -583,25 +593,11 @@ mod tests {
#[tokio::test]
async fn scan_node_files_from_store_reconstructs_allowlisted_entries() {
let store = InMemoryStore::default();
let created_at = Utc::now();
let store = test_store();
let run = store
.create_run(&fixtures::RUN_1, created_at, None)
.create_run(&fixtures::RUN_1, chrono::Utc::now(), None)
.await
.unwrap();
run.put_run(&RunRecord {
run_id: fixtures::RUN_1,
created_at,
settings: Settings::default(),
graph: Graph::new("test"),
workflow_slug: None,
working_directory: std::path::PathBuf::from("."),
host_repo_path: None,
base_branch: None,
labels: std::collections::HashMap::new(),
})
.await
.unwrap();
let node = NodeVisitRef {
node_id: "work",
visit: 2,

View file

@ -373,30 +373,40 @@ mod tests {
use super::*;
use crate::event::EventEmitter;
use fabro_graphviz::graph::AttrValue;
use fabro_store::{InMemoryStore, NodeVisitRef, RunStore, Store};
use fabro_store::{NodeVisitRef, RunStoreHandle, SlateStore};
use fabro_types::fixtures;
use object_store::memory::InMemory;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
fn make_services() -> EngineServices {
EngineServices::test_default()
}
fn test_store() -> Arc<SlateStore> {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
async fn make_services_with_run_store() -> (
EngineServices,
Arc<dyn RunStore>,
RunStoreHandle,
crate::event::StoreProgressLogger,
) {
let store = InMemoryStore::default();
let store = test_store();
let run_store = store
.create_run(&fixtures::RUN_1, chrono::Utc::now(), None)
.await
.unwrap();
let services = EngineServices {
run_store: Arc::clone(&run_store),
run_store: run_store.clone(),
..EngineServices::test_default()
};
let logger = crate::event::StoreProgressLogger::new(Arc::clone(&run_store));
let logger = crate::event::StoreProgressLogger::new(run_store.clone());
logger.register(services.emitter.as_ref());
(services, run_store, logger)
}

View file

@ -1,12 +1,12 @@
use std::path::Path;
use async_trait::async_trait;
use crate::context::Context;
use crate::context::keys;
use crate::error::FabroError;
use crate::event::WorkflowRunEvent;
use crate::outcome::{Outcome, OutcomeExt};
use crate::run_dir::{node_dir, visit_from_context};
use async_trait::async_trait;
use fabro_graphviz::graph::{Graph, Node};
use tokio::fs;
@ -199,8 +199,9 @@ mod tests {
use super::*;
use crate::outcome::StageStatus;
use fabro_graphviz::graph::AttrValue;
use fabro_store::{InMemoryStore, NodeVisitRef, RunStore, Store};
use fabro_store::{NodeVisitRef, RunStoreHandle, SlateStore};
use fabro_types::fixtures;
use object_store::memory::InMemory;
use std::sync::Arc;
use std::time::Duration;
@ -208,22 +209,30 @@ mod tests {
EngineServices::test_default()
}
fn test_store() -> Arc<SlateStore> {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
async fn make_services_with_run_store() -> (
EngineServices,
Arc<dyn RunStore>,
RunStoreHandle,
crate::event::StoreProgressLogger,
) {
let store = InMemoryStore::default();
let store = test_store();
let run_store = store
.create_run(&fixtures::RUN_1, chrono::Utc::now(), None)
.await
.unwrap();
let services = EngineServices {
emitter: Arc::new(crate::event::EventEmitter::new(fixtures::RUN_1)),
run_store: Arc::clone(&run_store),
run_store: run_store.clone(),
..EngineServices::test_default()
};
let logger = crate::event::StoreProgressLogger::new(Arc::clone(&run_store));
let logger = crate::event::StoreProgressLogger::new(run_store.clone());
logger.register(services.emitter.as_ref());
(services, run_store, logger)
}
@ -585,10 +594,7 @@ mod tests {
.unwrap();
logger.flush().await;
let snapshot = run_store
.state()
.await
.unwrap();
let snapshot = run_store.state().await.unwrap();
let node = snapshot
.node(&NodeVisitRef {
node_id: "script_node",

View file

@ -3,7 +3,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use fabro_agent::Sandbox;
use fabro_store::{NodeVisitRef, RunStore};
use fabro_store::{NodeVisitRef, RunStoreHandle};
use crate::context::Context;
use crate::context::keys;
@ -226,7 +226,7 @@ async fn llm_evaluate(
node_id: &str,
emitter: &Arc<EventEmitter>,
sandbox: &Arc<dyn Sandbox>,
run_store: Arc<dyn RunStore>,
run_store: RunStoreHandle,
) -> Result<Candidate, FabroError> {
let results_text =
serde_json::to_string_pretty(results).unwrap_or_else(|_| results.to_string());

View file

@ -17,8 +17,9 @@ use crate::run_options::RunOptions;
use async_trait::async_trait;
use chrono::Utc;
use fabro_graphviz::graph::{AttrValue, Graph, Node};
use fabro_store::{InMemoryStore, Store};
use fabro_store::SlateStore;
use fabro_types::Settings;
use object_store::memory::InMemory;
use tokio::time::{sleep, timeout};
use super::{EngineServices, Handler};
@ -192,7 +193,12 @@ impl Handler for SubWorkflowHandler {
let hook_runner = services.hook_runner.clone();
let env = services.env.clone();
let dry_run = services.dry_run;
let run_store = InMemoryStore::default()
let store = Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
));
let run_store = store
.create_run(
&child_run_options.run_id,
Utc::now(),

View file

@ -15,12 +15,16 @@ use std::any::Any;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
#[cfg(test)]
use std::time::Duration;
use async_trait::async_trait;
use fabro_agent::Sandbox;
use fabro_store::RunStore;
use fabro_store::RunStoreHandle;
#[cfg(test)]
use fabro_store::Store;
use fabro_store::SlateStore;
#[cfg(test)]
use object_store::memory::InMemory;
use crate::context::Context;
use crate::error::FabroError;
@ -36,7 +40,7 @@ pub struct EngineServices {
pub registry: Arc<HandlerRegistry>,
pub emitter: Arc<EventEmitter>,
pub sandbox: Arc<dyn Sandbox>,
pub run_store: Arc<dyn RunStore>,
pub run_store: RunStoreHandle,
/// Git state for the current run. Set via `set_git_state` at the start of
/// `run_via_core` and read by parallel/fan-in handlers.
pub(crate) git_state: std::sync::RwLock<Option<Arc<GitState>>>,
@ -71,6 +75,11 @@ impl EngineServices {
/// Test-only default: empty registry, no hooks, local sandbox at cwd.
#[cfg(test)]
pub fn test_default() -> Self {
let store = Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
));
Self {
registry: Arc::new(HandlerRegistry::new(Box::new(start::StartHandler))),
emitter: Arc::new(EventEmitter::default()),
@ -78,10 +87,10 @@ impl EngineServices {
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
)),
run_store: futures::executor::block_on(async {
fabro_store::InMemoryStore::default()
store
.create_run(&fabro_types::RunId::new(), chrono::Utc::now(), None)
.await
.expect("in-memory test run store should initialize")
.expect("slate-backed test run store should initialize")
}),
git_state: std::sync::RwLock::new(None),
hook_runner: None,

View file

@ -594,14 +594,24 @@ fn find_join_node(results: &[BranchResult], graph: &Graph) -> Option<String> {
mod tests {
use super::*;
use fabro_graphviz::graph::{AttrValue, Edge};
use fabro_store::{InMemoryStore, RunStore, Store};
use fabro_store::SlateStore;
use fabro_types::fixtures;
use object_store::memory::InMemory;
use std::sync::Arc;
use std::time::Duration;
fn make_services() -> EngineServices {
EngineServices::test_default()
}
fn test_store() -> Arc<SlateStore> {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
fn test_context() -> Context {
let context = Context::new();
context.set(
@ -680,13 +690,13 @@ mod tests {
#[tokio::test]
async fn parallel_handler_stores_results_in_run_store() {
let store = Arc::new(InMemoryStore::default());
let store = test_store();
let run_store = store
.create_run(&fixtures::RUN_1, chrono::Utc::now(), None)
.await
.unwrap();
let services = EngineServices {
run_store: Arc::clone(&run_store) as Arc<dyn RunStore>,
run_store: run_store.clone(),
..EngineServices::test_default()
};
let mut node = Node::new("par");

View file

@ -2,7 +2,6 @@ use std::path::Path;
use async_trait::async_trait;
use fabro_model::Provider;
use crate::context::keys;
use crate::context::{Context, WorkflowContext};
use crate::error::FabroError;
@ -10,6 +9,7 @@ use crate::event::WorkflowRunEvent;
use crate::outcome::Outcome;
use crate::run_dir::{node_dir, visit_from_context};
use fabro_graphviz::graph::{Graph, Node};
use fabro_model::Provider;
use tokio::fs;
use super::agent::{
@ -185,30 +185,40 @@ impl Handler for PromptHandler {
mod tests {
use super::*;
use fabro_graphviz::graph::AttrValue;
use fabro_store::{InMemoryStore, NodeVisitRef, RunStore, Store};
use fabro_store::{NodeVisitRef, RunStoreHandle, SlateStore};
use fabro_types::fixtures;
use object_store::memory::InMemory;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
fn make_services() -> EngineServices {
EngineServices::test_default()
}
fn test_store() -> Arc<SlateStore> {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
async fn make_services_with_run_store() -> (
EngineServices,
Arc<dyn RunStore>,
RunStoreHandle,
crate::event::StoreProgressLogger,
) {
let store = InMemoryStore::default();
let store = test_store();
let run_store = store
.create_run(&fixtures::RUN_1, chrono::Utc::now(), None)
.await
.unwrap();
let services = EngineServices {
run_store: Arc::clone(&run_store),
run_store: run_store.clone(),
..EngineServices::test_default()
};
let logger = crate::event::StoreProgressLogger::new(Arc::clone(&run_store));
let logger = crate::event::StoreProgressLogger::new(run_store.clone());
logger.register(services.emitter.as_ref());
(services, run_store, logger)
}

View file

@ -2,8 +2,8 @@ use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use fabro_store::{NodeVisitRef, RunStore};
use fabro_types::{NodeStatusRecord, RunId};
use fabro_store::RunStoreHandle;
use fabro_types::RunId;
use fabro_core::error::Result as CoreResult;
use fabro_core::graph::NodeSpec;
@ -15,20 +15,18 @@ use super::circuit_breaker::CircuitBreakerLifecycle;
use crate::event::{EventEmitter, RunNoticeLevel, WorkflowRunEvent, append_workflow_event};
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
use crate::outcome::{OutcomeExt, StageUsage};
use crate::records::{Checkpoint, StartRecord};
use crate::outcome::StageUsage;
use crate::run_options::RunOptions;
use crate::run_status::RunStatus;
use fabro_graphviz::graph::types::Graph as GvGraph;
type WfRunState = RunState<Option<StageUsage>>;
type WfNodeResult = NodeResult<Option<StageUsage>>;
/// Sub-lifecycle responsible for writing run state to disk (node status, checkpoints).
/// Sub-lifecycle responsible for emitting store-backed run lifecycle events.
pub(crate) struct DiskLifecycle {
pub run_dir: PathBuf,
pub run_id: RunId,
pub run_store: Arc<dyn RunStore>,
pub run_store: RunStoreHandle,
pub graph: Arc<GvGraph>,
pub run_options: Arc<RunOptions>,
pub emitter: Arc<EventEmitter>,
@ -39,31 +37,6 @@ pub(crate) struct DiskLifecycle {
#[async_trait]
impl RunLifecycle<WorkflowGraph> for DiskLifecycle {
async fn on_run_start(&self, _graph: &WorkflowGraph, _state: &WfRunState) -> CoreResult<()> {
let git_state = self.run_options.git.as_ref();
let start_record = StartRecord {
run_id: self.run_id,
start_time: chrono::Utc::now(),
run_branch: git_state.and_then(|g| g.run_branch.clone()),
base_sha: git_state.and_then(|g| g.base_sha.clone()),
};
if let Err(err) = self.run_store.put_start(&start_record).await {
self.emitter.emit(&WorkflowRunEvent::RunNotice {
level: RunNoticeLevel::Warn,
code: "start_store_save_failed".to_string(),
message: format!("failed to save start record to store: {err}"),
});
}
if let Err(err) = self
.run_store
.put_status(&fabro_types::RunStatusRecord::new(RunStatus::Running, None))
.await
{
self.emitter.emit(&WorkflowRunEvent::RunNotice {
level: RunNoticeLevel::Warn,
code: "status_store_save_failed".to_string(),
message: format!("failed to save running status to store: {err}"),
});
}
if let Err(err) = append_workflow_event(
self.run_store.as_ref(),
&self.run_id,
@ -82,35 +55,10 @@ impl RunLifecycle<WorkflowGraph> for DiskLifecycle {
async fn after_node(
&self,
node: &WorkflowNode,
result: &mut WfNodeResult,
state: &WfRunState,
_node: &WorkflowNode,
_result: &mut WfNodeResult,
_state: &WfRunState,
) -> CoreResult<()> {
let gv = node.inner();
let visit = state.node_visits.get(gv.id.as_str()).copied().unwrap_or(1);
let node_status = NodeStatusRecord {
status: result.outcome.status.clone(),
notes: result.outcome.notes.clone(),
failure_reason: result.outcome.failure_reason().map(ToOwned::to_owned),
timestamp: chrono::Utc::now(),
};
if let Err(err) = self
.run_store
.put_node_status(
&NodeVisitRef {
node_id: &gv.id,
visit: u32::try_from(visit).unwrap_or(u32::MAX),
},
&node_status,
)
.await
{
self.emitter.emit(&WorkflowRunEvent::RunNotice {
level: RunNoticeLevel::Warn,
code: "node_status_store_save_failed".to_string(),
message: format!("[node: {}] node status store save failed: {err}", node.id()),
});
}
Ok(())
}
@ -131,7 +79,7 @@ impl RunLifecycle<WorkflowGraph> for DiskLifecycle {
let mut node_outcomes = state.node_outcomes.clone();
node_outcomes.insert(node.id().to_string(), result.outcome.clone());
let checkpoint = Checkpoint {
let _checkpoint = fabro_types::Checkpoint {
timestamp: chrono::Utc::now(),
current_node: node.id().to_string(),
completed_nodes: state.completed_nodes.clone(),
@ -144,21 +92,6 @@ impl RunLifecycle<WorkflowGraph> for DiskLifecycle {
loop_failure_signatures: loop_sigs,
restart_failure_signatures: restart_sigs,
};
if let Err(err) = self.run_store.put_checkpoint(&checkpoint).await {
self.emitter.emit(&WorkflowRunEvent::RunNotice {
level: RunNoticeLevel::Warn,
code: "checkpoint_store_save_failed".to_string(),
message: format!("[node: {}] checkpoint store save failed: {err}", node.id()),
});
}
if let Err(err) = self.run_store.append_checkpoint(&checkpoint).await {
self.emitter.emit(&WorkflowRunEvent::RunNotice {
level: RunNoticeLevel::Warn,
code: "checkpoint_store_append_failed".to_string(),
message: format!("[node: {}] checkpoint append failed: {err}", node.id()),
});
}
Ok(())
}
}

View file

@ -2,7 +2,7 @@ use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use fabro_store::RunStore;
use fabro_store::RunStoreHandle;
use fabro_types::RunId;
use fabro_core::error::{CoreError, Result as CoreResult};
@ -39,7 +39,7 @@ pub(crate) struct GitLifecycle {
pub emitter: Arc<EventEmitter>,
pub run_dir: PathBuf,
pub run_id: RunId,
pub run_store: Arc<dyn RunStore>,
pub run_store: RunStoreHandle,
pub run_options: Arc<RunOptions>,
pub start_node_id: Option<String>,
// Cross-lifecycle data (shared with EventLifecycle)
@ -66,27 +66,19 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
) {
let git_author = self.run_options.git_author();
let store = MetadataStore::new(repo_path, &git_author);
let run_json = self
.run_store
.get_run()
.await
.ok()
.flatten()
.and_then(|record| serde_json::to_vec_pretty(&record).ok());
let start_json = self
.run_store
.get_start()
.await
.ok()
.flatten()
.and_then(|record| serde_json::to_vec_pretty(&record).ok());
let sandbox_json = self
.run_store
.get_sandbox()
.await
.ok()
.flatten()
.and_then(|record| serde_json::to_vec_pretty(&record).ok());
let state = self.run_store.state().await.ok();
let run_json = state
.as_ref()
.and_then(|state| state.run.as_ref())
.and_then(|record| serde_json::to_vec_pretty(record).ok());
let start_json = state
.as_ref()
.and_then(|state| state.start.as_ref())
.and_then(|record| serde_json::to_vec_pretty(record).ok());
let sandbox_json = state
.as_ref()
.and_then(|state| state.sandbox.as_ref())
.and_then(|record| serde_json::to_vec_pretty(record).ok());
let mut files: Vec<(&str, &[u8])> = Vec::new();
if let Some(ref data) = run_json {
files.push(("run.json", data));
@ -137,10 +129,10 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
// Build checkpoint JSON for shadow branch
if let Some(cp_json) = self
.run_store
.get_checkpoint()
.state()
.await
.ok()
.flatten()
.and_then(|state| state.checkpoint)
.and_then(|checkpoint| serde_json::to_vec_pretty(&checkpoint).ok())
{
let mut extra_entries: Vec<(String, Vec<u8>)> = {
@ -205,31 +197,6 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
diff: None,
};
match self.run_store.get_checkpoint().await {
Ok(Some(mut checkpoint)) => {
checkpoint.git_commit_sha = Some(sha.clone());
if let Err(err) = self.run_store.put_checkpoint(&checkpoint).await {
self.emitter.emit(&WorkflowRunEvent::RunNotice {
level: RunNoticeLevel::Warn,
code: "checkpoint_store_resave_failed".to_string(),
message: format!(
"[node: {node_id}] checkpoint store re-save with SHA failed: {err}"
),
});
}
}
Ok(None) => {}
Err(err) => {
self.emitter.emit(&WorkflowRunEvent::RunNotice {
level: RunNoticeLevel::Warn,
code: "checkpoint_store_load_failed".to_string(),
message: format!(
"[node: {node_id}] checkpoint store load failed: {err}"
),
});
}
}
// Push run branch (skip in dry-run mode)
if !self.run_options.dry_run_enabled() {
if let Some(branch) = self
@ -277,7 +244,6 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
}
// Save diff.patch
let visit = state.node_visits.get(node_id).copied().unwrap_or(1);
let prev = self
.last_git_sha
.lock()
@ -292,22 +258,6 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
.unwrap_or_else(|| sha.clone());
match git_diff(&*self.sandbox, &prev).await {
Ok(patch) if !patch.is_empty() => {
let node_ref = fabro_store::NodeVisitRef {
node_id,
visit: u32::try_from(visit).unwrap_or(u32::MAX),
};
if let Err(err) = self.run_store.put_node_diff(&node_ref, &patch).await {
self.emitter.emit(&WorkflowRunEvent::RunNotice {
level: RunNoticeLevel::Warn,
code: "git_diff_store_failed".to_string(),
message: format!(
"[node: {node_id}] failed to persist diff in run store: {err}"
),
});
return Err(CoreError::Other(format!(
"failed to persist node diff for '{node_id}': {err}"
)));
}
git_result.diff = Some(patch);
}
Ok(_) => {}
@ -353,15 +303,6 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
match git_diff(&*self.sandbox, &base_sha).await {
Ok(patch) if !patch.is_empty() => {
*self.final_patch.lock().unwrap() = Some(patch.clone());
if let Err(err) = self.run_store.put_final_patch(&patch).await {
self.emitter.emit(&WorkflowRunEvent::RunNotice {
level: RunNoticeLevel::Warn,
code: "final_diff_store_failed".to_string(),
message: format!(
"failed to persist final diff in run store: {err}"
),
});
}
}
Ok(_) => {
*self.final_patch.lock().unwrap() = None;

View file

@ -14,7 +14,7 @@ use std::sync::{Arc, Mutex};
use std::time::Instant;
use async_trait::async_trait;
use fabro_store::RunStore;
use fabro_store::RunStoreHandle;
use fabro_store::RuntimeState;
use fabro_types::RunId;
@ -85,7 +85,7 @@ impl WorkflowLifecycle {
sandbox: &Arc<dyn Sandbox>,
graph: Arc<GvGraph>,
run_dir: &PathBuf,
run_store: Arc<dyn RunStore>,
run_store: RunStoreHandle,
run_options: &Arc<RunOptions>,
is_resume: bool,
on_node: crate::OnNodeCallback,
@ -122,7 +122,7 @@ impl WorkflowLifecycle {
run_start: Mutex::new(Instant::now()),
restarted_from: Arc::clone(&restarted_from),
base_branch: run_options.base_branch.clone(),
base_sha: run_options.display_base_sha.clone(),
base_sha: run_options.git.as_ref().and_then(|g| g.base_sha.clone()),
run_branch: run_options.git.as_ref().and_then(|g| g.run_branch.clone()),
worktree_dir: working_directory.clone(),
goal: (!graph.goal().is_empty()).then(|| graph.goal().to_string()),
@ -146,7 +146,7 @@ impl WorkflowLifecycle {
let disk = DiskLifecycle {
run_dir: run_dir.clone(),
run_id: run_options.run_id,
run_store: Arc::clone(&run_store),
run_store: run_store.clone(),
graph: Arc::clone(&graph),
run_options: Arc::clone(run_options),
emitter: Arc::clone(emitter),

View file

@ -2,7 +2,7 @@ use chrono::{Local, Utc};
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_model::{Catalog, Provider};
use fabro_sandbox::SandboxProvider;
use fabro_store::Store;
use fabro_store::SlateStore;
use fabro_types::{RunId, Settings};
use std::collections::BTreeMap;
use std::collections::HashMap;
@ -13,7 +13,6 @@ use crate::pipeline::types::PersistOptions;
use crate::pipeline::{self, Persisted, TransformOptions, Validated};
use crate::records::RunRecord;
use crate::run_lookup::default_runs_base;
use crate::run_status::{RunStatus, RunStatusRecord};
use crate::transforms::{Transform, expand_vars};
use fabro_sandbox::daytona::detect_repo_info;
@ -56,7 +55,7 @@ struct PersistCreateOptions {
}
/// Resolve workflow inputs, normalize settings, and persist a run directory.
pub async fn create(store: &dyn Store, request: CreateRunInput) -> Result<CreatedRun, FabroError> {
pub async fn create(store: &SlateStore, request: CreateRunInput) -> Result<CreatedRun, FabroError> {
let resolved = resolve_workflow(ResolveWorkflowInput {
workflow: request.workflow,
settings: request.settings,
@ -133,7 +132,7 @@ pub async fn create(store: &dyn Store, request: CreateRunInput) -> Result<Create
}
async fn persist_created_run(
store: &dyn Store,
store: &SlateStore,
persisted: &Persisted,
workflow_source: &str,
workflow_config: Option<String>,
@ -152,18 +151,6 @@ async fn persist_created_run(
.or_else(|_| Err(FabroError::engine(err.to_string())))?,
};
run_store.put_run(record).await.map_err(store_error)?;
if !workflow_source.is_empty() {
run_store
.put_graph(workflow_source)
.await
.map_err(store_error)?;
}
run_store
.put_status(&RunStatusRecord::new(RunStatus::Submitted, None))
.await
.map_err(store_error)?;
let envelope = canonicalize_event_at(
&record.run_id,
&WorkflowRunEvent::RunCreated {
@ -411,15 +398,20 @@ pub(crate) fn make_run_dir(runs_base: &Path, run_id: &str, dry_run: bool) -> Pat
mod tests {
use super::*;
use fabro_graphviz::graph::AttrValue;
use fabro_store::{InMemoryStore, SlateStore, Store};
use fabro_store::{SlateStore, StoreHandle};
use fabro_types::fixtures;
use object_store::local::LocalFileSystem;
use object_store::memory::InMemory;
use std::sync::Arc;
use std::time::Duration;
use crate::operations::{ValidateInput, validate};
fn memory_store() -> InMemoryStore {
InMemoryStore::default()
fn memory_store() -> StoreHandle {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
fn validate_dot(dot_source: &str, settings: Settings) -> Validated {
@ -719,7 +711,7 @@ mod tests {
);
let run_store = store.open_run(&fixtures::RUN_1).await.unwrap();
assert_eq!(
run_store.get_status().await.unwrap().unwrap().status,
run_store.state().await.unwrap().status.unwrap().status,
crate::run_status::RunStatus::Submitted
);
assert!(!created.run_dir.join("id.txt").exists());
@ -816,7 +808,11 @@ mod tests {
std::fs::create_dir_all(storage_dir.join("store")).unwrap();
let object_store =
Arc::new(LocalFileSystem::new_with_prefix(storage_dir.join("store")).unwrap());
let store = Arc::new(SlateStore::new(object_store, "", Duration::from_millis(1)));
let store = StoreHandle::from(Arc::new(SlateStore::new(
object_store,
"",
Duration::from_millis(1),
)));
let created = create(
store.as_ref(),
CreateRunInput {

View file

@ -6,7 +6,7 @@ use anyhow::{Context, Result, bail};
use fabro_checkpoint::branch::BranchStore;
use fabro_checkpoint::git::Store as GitStore;
use fabro_store::{
ListRunsQuery, NodeVisitRef, RunStore as DurableRunStore, Store as DurableStore,
ListRunsQuery, NodeVisitRef, RunStoreHandle as DurableRunStore, SlateStore as DurableStore,
};
use fabro_types::RunId;
use git2::{Repository, Signature};
@ -18,7 +18,7 @@ use crate::records::Checkpoint;
pub async fn rebuild_metadata_branch(
git_store: &GitStore,
run_store: &dyn DurableRunStore,
run_store: &DurableRunStore,
run_id: &RunId,
) -> Result<()> {
let branch = MetadataStore::branch_name(&run_id.to_string());
@ -26,10 +26,7 @@ pub async fn rebuild_metadata_branch(
bail!("metadata branch already exists for run {run_id}");
}
let state = run_store
.state()
.await?
;
let state = run_store.state().await?;
let run_record = state
.run
.clone()
@ -157,7 +154,7 @@ pub async fn rebuild_metadata_branch(
pub async fn build_timeline_or_rebuild(
git_store: &GitStore,
run_store: Option<&dyn DurableRunStore>,
run_store: Option<&DurableRunStore>,
run_id: &RunId,
) -> Result<RunTimeline> {
let branch = MetadataStore::branch_name(&run_id.to_string());
@ -178,7 +175,7 @@ pub async fn build_timeline_or_rebuild(
pub async fn find_run_id_by_prefix_or_store(
repo: &Repository,
fabro_store: &dyn DurableStore,
fabro_store: &DurableStore,
prefix: &str,
) -> Result<RunId> {
if let Some(run_id) = find_run_id_by_prefix_in_refs(repo, prefix)? {
@ -334,19 +331,18 @@ fn resolve_prefix_matches(prefix: &str, matches: Vec<RunId>) -> Result<RunId> {
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use fabro_graphviz::graph::Graph;
use fabro_store::{NodeVisitRef, SlateStore, StoreHandle};
use fabro_types::{RunId, RunRecord, SandboxRecord, Settings, StartRecord, fixtures};
use object_store::memory::InMemory;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use chrono::{TimeZone, Utc};
use fabro_graphviz::graph::Graph;
use fabro_store::{InMemoryStore, NodeVisitRef, Store as _};
use fabro_types::{
NodeStatusRecord, RunId, RunRecord, SandboxRecord, Settings, StageStatus, StartRecord,
fixtures,
};
use std::time::Duration;
use super::*;
use crate::event::{WorkflowRunEvent, append_workflow_event};
use crate::operations::test_support::{make_checkpoint_json, temp_repo, test_sig};
use crate::records::Checkpoint;
@ -362,6 +358,14 @@ mod tests {
fixtures::RUN_1
}
fn memory_store() -> StoreHandle {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
fn sample_run_record(run_id: RunId, host_repo_path: Option<&str>) -> RunRecord {
RunRecord {
run_id,
@ -422,26 +426,130 @@ mod tests {
}
}
fn sample_node_status() -> NodeStatusRecord {
NodeStatusRecord {
status: StageStatus::Success,
notes: Some("done".to_string()),
failure_reason: None,
timestamp: created_at(),
}
}
async fn create_run_store(
store: &InMemoryStore,
store: &SlateStore,
run_id: RunId,
host_repo_path: Option<&str>,
) -> Arc<dyn DurableRunStore> {
) -> DurableRunStore {
let run_store = store.create_run(&run_id, created_at(), None).await.unwrap();
let run_record = sample_run_record(run_id, host_repo_path);
append_workflow_event(
run_store.as_ref(),
&run_id,
&WorkflowRunEvent::RunCreated {
run_id,
settings: serde_json::to_value(&run_record.settings).unwrap(),
graph: serde_json::to_value(&run_record.graph).unwrap(),
workflow_source: None,
workflow_config: None,
labels: run_record.labels.clone().into_iter().collect(),
run_dir: String::new(),
working_directory: run_record.working_directory.display().to_string(),
host_repo_path: run_record.host_repo_path.clone(),
base_branch: run_record.base_branch.clone(),
workflow_slug: run_record.workflow_slug.clone(),
db_prefix: None,
},
)
.await
.unwrap();
run_store
.put_run(&sample_run_record(run_id, host_repo_path))
.await
.unwrap();
run_store
}
async fn append_start_event(run_store: &DurableRunStore, run_id: RunId) {
let start = sample_start_record(run_id);
append_workflow_event(
run_store,
&run_id,
&WorkflowRunEvent::WorkflowRunStarted {
name: "test".to_string(),
run_id,
base_branch: None,
base_sha: start.base_sha,
run_branch: start.run_branch,
worktree_dir: None,
goal: None,
},
)
.await
.unwrap();
}
async fn append_sandbox_event(run_store: &DurableRunStore, run_id: RunId) {
let sandbox = sample_sandbox_record();
append_workflow_event(
run_store,
&run_id,
&WorkflowRunEvent::SandboxInitialized {
provider: sandbox.provider,
working_directory: sandbox.working_directory,
identifier: sandbox.identifier,
host_working_directory: sandbox.host_working_directory,
container_mount_point: sandbox.container_mount_point,
},
)
.await
.unwrap();
}
async fn append_checkpoint_event(
run_store: &DurableRunStore,
run_id: RunId,
checkpoint: Checkpoint,
) {
append_workflow_event(
run_store,
&run_id,
&WorkflowRunEvent::CheckpointCompleted {
node_id: checkpoint.current_node.clone(),
status: "success".to_string(),
current_node: checkpoint.current_node.clone(),
completed_nodes: checkpoint.completed_nodes.clone(),
node_retries: checkpoint.node_retries.clone().into_iter().collect(),
context_values: checkpoint.context_values.clone().into_iter().collect(),
node_outcomes: checkpoint.node_outcomes.clone().into_iter().collect(),
next_node_id: checkpoint.next_node_id.clone(),
git_commit_sha: checkpoint.git_commit_sha.clone(),
loop_failure_signatures: checkpoint
.loop_failure_signatures
.clone()
.into_iter()
.map(|(signature, count)| (signature.to_string(), count))
.collect(),
restart_failure_signatures: checkpoint
.restart_failure_signatures
.clone()
.into_iter()
.map(|(signature, count)| (signature.to_string(), count))
.collect(),
node_visits: checkpoint.node_visits.clone().into_iter().collect(),
diff: None,
},
)
.await
.unwrap();
}
async fn append_prompt_event(
run_store: &DurableRunStore,
run_id: RunId,
node: &NodeVisitRef<'_>,
text: &str,
) {
append_workflow_event(
run_store,
&run_id,
&WorkflowRunEvent::Prompt {
stage: node.node_id.to_string(),
visit: node.visit,
text: text.to_string(),
mode: None,
provider: None,
model: None,
},
)
.await
.unwrap();
}
fn seed_run_branch(git_store: &GitStore, run_id: RunId, nodes: &[&str]) -> Vec<String> {
@ -472,46 +580,41 @@ mod tests {
#[tokio::test]
async fn rebuild_metadata_branch_round_trips_timeline() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let durable_store = memory_store();
let run_store = create_run_store(&durable_store, test_run_id(), None).await;
run_store
.put_start(&sample_start_record(test_run_id()))
.await
.unwrap();
run_store
.put_sandbox(&sample_sandbox_record())
.await
.unwrap();
append_start_event(&run_store, test_run_id()).await;
append_sandbox_event(&run_store, test_run_id()).await;
run_store
.append_checkpoint(&sample_checkpoint(
"start",
&["start"],
&[("start", 1)],
Some("aaa"),
))
.await
.unwrap();
run_store
.append_checkpoint(&sample_checkpoint(
append_checkpoint_event(
&run_store,
test_run_id(),
sample_checkpoint("start", &["start"], &[("start", 1)], Some("aaa")),
)
.await;
append_checkpoint_event(
&run_store,
test_run_id(),
sample_checkpoint(
"build",
&["start", "build"],
&[("start", 1), ("build", 1)],
Some("bbb"),
))
.await
.unwrap();
run_store
.append_checkpoint(&sample_checkpoint(
),
)
.await;
append_checkpoint_event(
&run_store,
test_run_id(),
sample_checkpoint(
"build",
&["start", "build"],
&[("start", 1), ("build", 2)],
Some("ccc"),
))
.await
.unwrap();
),
)
.await;
rebuild_metadata_branch(&git_store, run_store.as_ref(), &test_run_id())
rebuild_metadata_branch(&git_store, &run_store, &test_run_id())
.await
.unwrap();
@ -531,51 +634,35 @@ mod tests {
#[tokio::test]
async fn rebuild_metadata_branch_preserves_historical_node_visits() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let durable_store = memory_store();
let run_store = create_run_store(&durable_store, test_run_id(), None).await;
let build_v1 = NodeVisitRef {
node_id: "build",
visit: 1,
};
run_store
.put_node_prompt(&build_v1, "visit one")
.await
.unwrap();
run_store
.put_node_status(&build_v1, &sample_node_status())
.await
.unwrap();
append_prompt_event(&run_store, test_run_id(), &build_v1, "visit one").await;
let build_v2 = NodeVisitRef {
node_id: "build",
visit: 2,
};
run_store
.put_node_prompt(&build_v2, "visit two")
.await
.unwrap();
append_prompt_event(&run_store, test_run_id(), &build_v2, "visit two").await;
run_store
.append_checkpoint(&sample_checkpoint(
"build",
&["build"],
&[("build", 1)],
Some("aaa"),
))
.await
.unwrap();
run_store
.append_checkpoint(&sample_checkpoint(
"build",
&["build"],
&[("build", 2)],
Some("bbb"),
))
.await
.unwrap();
append_checkpoint_event(
&run_store,
test_run_id(),
sample_checkpoint("build", &["build"], &[("build", 1)], Some("aaa")),
)
.await;
append_checkpoint_event(
&run_store,
test_run_id(),
sample_checkpoint("build", &["build"], &[("build", 2)], Some("bbb")),
)
.await;
rebuild_metadata_branch(&git_store, run_store.as_ref(), &test_run_id())
rebuild_metadata_branch(&git_store, &run_store, &test_run_id())
.await
.unwrap();
@ -624,7 +711,7 @@ mod tests {
#[tokio::test]
async fn rebuild_metadata_branch_refuses_to_overwrite_existing_branch() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let durable_store = memory_store();
let run_store = create_run_store(&durable_store, test_run_id(), None).await;
let sig = test_sig();
@ -633,7 +720,7 @@ mod tests {
bs.ensure_branch().unwrap();
bs.write_entry("run.json", b"{}", "init run").unwrap();
let err = rebuild_metadata_branch(&git_store, run_store.as_ref(), &test_run_id())
let err = rebuild_metadata_branch(&git_store, &run_store, &test_run_id())
.await
.unwrap_err();
assert!(err.to_string().contains("metadata branch already exists"));
@ -642,23 +729,19 @@ mod tests {
#[tokio::test]
async fn build_timeline_or_rebuild_rebuilds_missing_branch() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let durable_store = memory_store();
let run_store = create_run_store(&durable_store, test_run_id(), None).await;
run_store
.append_checkpoint(&sample_checkpoint(
"start",
&["start"],
&[("start", 1)],
Some("aaa"),
))
append_checkpoint_event(
&run_store,
test_run_id(),
sample_checkpoint("start", &["start"], &[("start", 1)], Some("aaa")),
)
.await;
let timeline = build_timeline_or_rebuild(&git_store, Some(&run_store), &test_run_id())
.await
.unwrap();
let timeline =
build_timeline_or_rebuild(&git_store, Some(run_store.as_ref()), &test_run_id())
.await
.unwrap();
assert_eq!(timeline.entries.len(), 1);
assert_eq!(timeline.entries[0].node_name, "start");
}
@ -666,35 +749,36 @@ mod tests {
#[tokio::test]
async fn build_timeline_or_rebuild_preserves_existing_branch() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let durable_store = memory_store();
let run_store = create_run_store(&durable_store, test_run_id(), None).await;
run_store
.append_checkpoint(&sample_checkpoint(
"start",
&["start"],
&[("start", 1)],
Some("aaa"),
))
.await
.unwrap();
run_store
.append_checkpoint(&sample_checkpoint(
append_checkpoint_event(
&run_store,
test_run_id(),
sample_checkpoint("start", &["start"], &[("start", 1)], Some("aaa")),
)
.await;
append_checkpoint_event(
&run_store,
test_run_id(),
sample_checkpoint(
"build",
&["start", "build"],
&[("start", 1), ("build", 1)],
Some("bbb"),
))
.await
.unwrap();
run_store
.append_checkpoint(&sample_checkpoint(
),
)
.await;
append_checkpoint_event(
&run_store,
test_run_id(),
sample_checkpoint(
"test",
&["start", "build", "test"],
&[("start", 1), ("build", 1), ("test", 1)],
Some("ccc"),
))
.await
.unwrap();
),
)
.await;
let sig = test_sig();
let branch = MetadataStore::branch_name(&test_run_id().to_string());
@ -714,10 +798,9 @@ mod tests {
)
.unwrap();
let timeline =
build_timeline_or_rebuild(&git_store, Some(run_store.as_ref()), &test_run_id())
.await
.unwrap();
let timeline = build_timeline_or_rebuild(&git_store, Some(&run_store), &test_run_id())
.await
.unwrap();
assert_eq!(timeline.entries.len(), 2);
assert_eq!(timeline.entries[0].node_name, "start");
@ -737,13 +820,13 @@ mod tests {
#[tokio::test]
async fn rebuild_metadata_branch_errors_when_run_record_is_missing() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let durable_store = memory_store();
let run_store = durable_store
.create_run(&test_run_id(), created_at(), None)
.await
.unwrap();
let err = rebuild_metadata_branch(&git_store, run_store.as_ref(), &test_run_id())
let err = rebuild_metadata_branch(&git_store, &run_store, &test_run_id())
.await
.unwrap_err();
assert!(err.to_string().contains("run record not found"));
@ -752,7 +835,7 @@ mod tests {
#[tokio::test]
async fn find_run_id_by_prefix_or_store_falls_back_to_store() {
let (dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let durable_store = memory_store();
let repo_path = dir.path().to_string_lossy().to_string();
let repo_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
let _run_store = create_run_store(&durable_store, repo_run_id, Some(&repo_path)).await;
@ -769,7 +852,7 @@ mod tests {
async fn find_run_id_by_prefix_or_store_excludes_other_repos() {
let (_dir, git_store) = temp_repo();
let (other_dir, _other_git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let durable_store = memory_store();
let other_repo_path = other_dir.path().to_string_lossy().to_string();
let other_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
let _run_store =
@ -786,7 +869,7 @@ mod tests {
#[tokio::test]
async fn find_run_id_by_prefix_or_store_requires_exact_match_without_repo_path() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let durable_store = memory_store();
let repo_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
let _run_store = create_run_store(&durable_store, repo_run_id, None).await;
let prefix = &repo_run_id.to_string()[..6];
@ -809,7 +892,7 @@ mod tests {
#[tokio::test]
async fn exact_match_wins_over_prefix_ambiguity() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let durable_store = memory_store();
let repo_path = git_store.repo_dir().to_string_lossy().to_string();
let exact_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
let other_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAW");
@ -853,31 +936,30 @@ mod tests {
#[tokio::test]
async fn rebuild_metadata_branch_persists_backfilled_run_shas_in_checkpoint_blobs() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let durable_store = memory_store();
let run_store = create_run_store(&durable_store, test_run_id(), None).await;
run_store
.append_checkpoint(&sample_checkpoint(
"start",
&["start"],
&[("start", 1)],
None,
))
.await
.unwrap();
run_store
.append_checkpoint(&sample_checkpoint(
append_checkpoint_event(
&run_store,
test_run_id(),
sample_checkpoint("start", &["start"], &[("start", 1)], None),
)
.await;
append_checkpoint_event(
&run_store,
test_run_id(),
sample_checkpoint(
"build",
&["start", "build"],
&[("start", 1), ("build", 1)],
None,
))
.await
.unwrap();
),
)
.await;
let expected_shas = seed_run_branch(&git_store, test_run_id(), &["start", "build"]);
rebuild_metadata_branch(&git_store, run_store.as_ref(), &test_run_id())
rebuild_metadata_branch(&git_store, &run_store, &test_run_id())
.await
.unwrap();
@ -921,7 +1003,7 @@ mod tests {
#[tokio::test]
async fn rebuild_metadata_branch_is_atomic_on_failure() {
let (_dir, git_store) = temp_repo();
let durable_store = InMemoryStore::default();
let durable_store = memory_store();
let run_store = create_run_store(&durable_store, test_run_id(), None).await;
let bad_node = "bad\0node";
@ -929,21 +1011,15 @@ mod tests {
node_id: bad_node,
visit: 1,
};
run_store
.put_node_prompt(&bad_visit, "prompt")
.await
.unwrap();
run_store
.append_checkpoint(&sample_checkpoint(
bad_node,
&[bad_node],
&[(bad_node, 1)],
None,
))
.await
.unwrap();
append_prompt_event(&run_store, test_run_id(), &bad_visit, "prompt").await;
append_checkpoint_event(
&run_store,
test_run_id(),
sample_checkpoint(bad_node, &[bad_node], &[(bad_node, 1)], None),
)
.await;
let err = rebuild_metadata_branch(&git_store, run_store.as_ref(), &test_run_id())
let err = rebuild_metadata_branch(&git_store, &run_store, &test_run_id())
.await
.unwrap_err();
assert!(err.to_string().contains("nul") || err.to_string().contains("NUL"));

View file

@ -5,7 +5,7 @@ use fabro_store::RuntimeState;
use crate::error::FabroError;
use crate::event::{WorkflowRunEvent, append_workflow_event};
use crate::outcome::StageStatus;
use crate::run_status::{self, RunStatus};
use crate::run_status::RunStatus;
use super::start::{StartServices, Started, execute_persisted_run};
@ -40,14 +40,6 @@ pub async fn resume(run_dir: &Path, services: StartServices) -> Result<Started,
.ok_or_else(|| FabroError::Precondition("no checkpoint to resume from".to_string()))?;
cleanup_resume_artifacts(run_dir);
services
.run_store
.put_status(&run_status::RunStatusRecord::new(
RunStatus::Submitted,
None,
))
.await
.map_err(|err| FabroError::engine(err.to_string()))?;
append_workflow_event(
services.run_store.as_ref(),
&services.run_id,

View file

@ -10,7 +10,7 @@ use fabro_config::{project as project_config, run as run_config, sandbox as sand
use fabro_interview::{AutoApproveInterviewer, Interviewer};
use fabro_model::{Catalog, FallbackTarget, Provider};
use fabro_sandbox::{SandboxProvider, SandboxSpec};
use fabro_store::RunStore;
use fabro_store::{RunStoreHandle, SlateRunStore};
use fabro_types::{RunId, Settings};
use serde::Serialize;
@ -28,9 +28,9 @@ use crate::pipeline::{
PullRequestOptions, RetroOptions, SandboxEnvSpec, build_conclusion_from_store,
classify_engine_result,
};
use crate::records::{Checkpoint, Conclusion};
use crate::records::Checkpoint;
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
use crate::run_status::{self, RunStatus, StatusReason};
use crate::run_status::{RunStatus, StatusReason};
use fabro_config::run::PullRequestSettings;
use fabro_retro::retro::Retro;
use fabro_sandbox::daytona::DaytonaConfig;
@ -49,7 +49,7 @@ struct RunSession {
sandbox_env: SandboxEnvSpec,
devcontainer: Option<DevcontainerSpec>,
seed_context: Option<Context>,
run_store: Arc<dyn RunStore>,
run_store: RunStoreHandle,
git: Option<GitCheckpointOptions>,
github_app: Option<fabro_github::GitHubAppCredentials>,
worktree_mode: Option<WorktreeMode>,
@ -67,7 +67,7 @@ pub struct StartServices {
pub cancel_token: Option<Arc<AtomicBool>>,
pub emitter: Arc<EventEmitter>,
pub interviewer: Arc<dyn Interviewer>,
pub run_store: Arc<dyn RunStore>,
pub run_store: RunStoreHandle,
pub github_app: Option<fabro_github::GitHubAppCredentials>,
pub on_node: crate::OnNodeCallback,
pub registry_override: Option<Arc<HandlerRegistry>>,
@ -112,13 +112,28 @@ pub(super) async fn execute_persisted_run(
) -> Result<Started, FabroError> {
let cancel_token = services.cancel_token.clone();
let run_id = services.run_id;
let run_store = Arc::clone(&services.run_store);
if let Err(err) = run_store
.put_status(&run_status::RunStatusRecord::new(
RunStatus::Starting,
Some(StatusReason::SandboxInitializing),
))
.await
let run_store = services.run_store.clone();
if let Err(err) = run_store.state().await {
let error = FabroError::engine(err.to_string());
let _ = persist_detached_failure(
run_id,
run_store.as_ref(),
run_dir,
"bootstrap",
StatusReason::BootstrapFailed,
&error,
)
.await;
return Err(error);
}
if let Err(err) = append_workflow_event(
run_store.as_ref(),
&run_id,
&WorkflowRunEvent::RunStarting {
reason: Some(StatusReason::SandboxInitializing),
},
)
.await
{
let error = FabroError::engine(err.to_string());
let _ = persist_detached_failure(
@ -132,22 +147,9 @@ pub(super) async fn execute_persisted_run(
.await;
return Err(error);
}
append_workflow_event(
run_store.as_ref(),
&run_id,
&WorkflowRunEvent::RunStarting {
reason: Some(StatusReason::SandboxInitializing),
},
)
.await
.map_err(|err| FabroError::engine(err.to_string()))?;
let mut bootstrap_guard = DetachedRunBootstrapGuard::arm(
run_id,
run_dir,
Arc::clone(&run_store),
cancel_token.clone(),
);
let mut bootstrap_guard =
DetachedRunBootstrapGuard::arm(run_id, run_dir, run_store.clone(), cancel_token.clone());
let persisted = match Persisted::load_from_store(services.run_store.as_ref(), run_dir).await {
Ok(persisted) => persisted,
@ -185,7 +187,7 @@ pub(super) async fn execute_persisted_run(
bootstrap_guard.defuse();
let mut completion_guard =
DetachedRunCompletionGuard::arm(run_id, Arc::clone(&run_store), cancel_token);
DetachedRunCompletionGuard::arm(run_id, run_store.clone(), cancel_token);
let run_start = Instant::now();
let started = Box::pin(session.run(persisted, checkpoint)).await;
@ -211,15 +213,15 @@ pub(super) async fn execute_persisted_run(
async fn persist_terminal_engine_failure(
run_id: RunId,
run_store: &dyn RunStore,
run_store: &SlateRunStore,
_run_dir: &Path,
error: &FabroError,
duration: Duration,
) {
let engine_result: Result<Outcome, FabroError> = Err(error.clone());
let (final_status, failure_reason, run_status, status_reason) =
let (final_status, failure_reason, _run_status, status_reason) =
classify_engine_result(&engine_result);
let conclusion = build_conclusion_from_store(
let _conclusion = build_conclusion_from_store(
run_store,
final_status,
failure_reason,
@ -227,15 +229,6 @@ async fn persist_terminal_engine_failure(
None,
)
.await;
if let Err(err) = run_store.put_conclusion(&conclusion).await {
tracing::warn!(error = %err, "Failed to save terminal engine failure conclusion to store");
}
if let Err(err) = run_store
.put_status(&run_status::RunStatusRecord::new(run_status, status_reason))
.await
{
tracing::warn!(error = %err, "Failed to save terminal engine failure status to store");
}
if let Err(err) = append_workflow_event(
run_store,
&run_id,
@ -257,18 +250,18 @@ impl RunSession {
let record = persisted.run_record();
let mut settings = record.settings.clone();
let working_directory = record.working_directory.clone();
let git = services
let state = services
.run_store
.get_start()
.state()
.await
.map_err(|err| FabroError::engine(err.to_string()))?
.and_then(|start| {
start.run_branch.as_ref().map(|_| GitCheckpointOptions {
base_sha: start.base_sha.clone(),
run_branch: start.run_branch.clone(),
meta_branch: Some(MetadataStore::branch_name(&record.run_id.to_string())),
})
});
.map_err(|err| FabroError::engine(err.to_string()))?;
let git = state.start.and_then(|start| {
start.run_branch.as_ref().map(|_| GitCheckpointOptions {
base_sha: start.base_sha.clone(),
run_branch: start.run_branch.clone(),
meta_branch: Some(MetadataStore::branch_name(&record.run_id.to_string())),
})
});
if let Some(env) = settings
.sandbox
@ -498,12 +491,12 @@ impl RunSession {
});
}
let store_progress_logger = StoreProgressLogger::new(Arc::clone(&self.run_store));
let store_progress_logger = StoreProgressLogger::new(self.run_store.clone());
store_progress_logger.register(self.emitter.as_ref());
let init_options = InitOptions {
run_id: record.run_id,
run_store: Arc::clone(&self.run_store),
run_store: self.run_store.clone(),
dry_run: run_options.dry_run_enabled(),
emitter: self.emitter,
sandbox: self.sandbox,
@ -545,7 +538,7 @@ impl RunSession {
let retro_opts = RetroOptions {
run_id: executed.run_options.run_id,
run_store: Arc::clone(&executed.run_store),
run_store: executed.run_store.clone(),
workflow_name: executed.graph.name.clone(),
goal: executed.graph.goal().to_string(),
run_dir: executed.run_options.run_dir.clone(),
@ -566,7 +559,7 @@ impl RunSession {
let finalize_opts = FinalizeOptions {
run_dir: retroed.run_options.run_dir.clone(),
run_id: retroed.run_options.run_id,
run_store: Arc::clone(&retroed.run_store),
run_store: retroed.run_store.clone(),
workflow_name: retroed.graph.name.clone(),
hook_runner: retroed.hook_runner.clone(),
preserve_sandbox: self.preserve_sandbox,
@ -574,7 +567,7 @@ impl RunSession {
};
let pr_opts = PullRequestOptions {
run_dir: retroed.run_options.run_dir.clone(),
run_store: Arc::clone(&retroed.run_store),
run_store: retroed.run_store.clone(),
pr_config: self.pr_config,
github_app: self.pr_github_app,
origin_url: self.pr_origin_url,
@ -599,7 +592,7 @@ impl RunSession {
struct DetachedRunBootstrapGuard {
run_id: RunId,
run_store: Arc<dyn RunStore>,
run_store: RunStoreHandle,
cancel_token: Option<Arc<AtomicBool>>,
active: bool,
}
@ -608,7 +601,7 @@ impl DetachedRunBootstrapGuard {
fn arm(
run_id: RunId,
_run_dir: &Path,
run_store: Arc<dyn RunStore>,
run_store: RunStoreHandle,
cancel_token: Option<Arc<AtomicBool>>,
) -> Self {
Self {
@ -637,15 +630,9 @@ impl Drop for DetachedRunBootstrapGuard {
StatusReason::SandboxInitFailed
};
let run_id = self.run_id;
let run_store = Arc::clone(&self.run_store);
let run_store = self.run_store.clone();
if let Ok(handle) = Handle::try_current() {
handle.spawn(async move {
let _ = run_store
.put_status(&run_status::RunStatusRecord::new(
RunStatus::Failed,
Some(reason),
))
.await;
let _ = append_workflow_event(
run_store.as_ref(),
&run_id,
@ -667,7 +654,7 @@ const POSTRUN_ABORTED_MESSAGE: &str = "Run aborted before post-run finalization
const POSTRUN_CANCELLED_MESSAGE: &str = "Run cancelled before post-run finalization completed.";
struct DetachedRunCompletionGuard {
run_store: Arc<dyn RunStore>,
run_store: RunStoreHandle,
run_id: RunId,
cancel_token: Option<Arc<AtomicBool>>,
active: bool,
@ -676,7 +663,7 @@ struct DetachedRunCompletionGuard {
impl DetachedRunCompletionGuard {
fn arm(
run_id: RunId,
run_store: Arc<dyn RunStore>,
run_store: RunStoreHandle,
cancel_token: Option<Arc<AtomicBool>>,
) -> Self {
Self {
@ -740,16 +727,10 @@ impl Drop for DetachedRunCompletionGuard {
Some((self.run_id, line))
}
};
let run_store = Arc::clone(&self.run_store);
let run_store = self.run_store.clone();
let run_id = self.run_id;
if let Ok(handle) = Handle::try_current() {
handle.spawn(async move {
let _ = run_store
.put_status(&run_status::RunStatusRecord::new(
RunStatus::Failed,
Some(reason),
))
.await;
let _ = append_workflow_event(
run_store.as_ref(),
&run_id,
@ -761,15 +742,6 @@ impl Drop for DetachedRunCompletionGuard {
},
)
.await;
if let Err(err) = run_store
.put_conclusion(&build_failure_conclusion(message))
.await
{
tracing::warn!(
error = %err,
"Failed to save post-run abort conclusion to store"
);
}
if let Some((run_id, line)) = serialized_notice.or_else(|| {
let envelope = canonicalize_event(
&run_id,
@ -802,7 +774,7 @@ impl Drop for DetachedRunCompletionGuard {
async fn persist_detached_failure(
run_id: RunId,
run_store: &dyn RunStore,
run_store: &SlateRunStore,
run_dir: &Path,
phase: &'static str,
reason: StatusReason,
@ -830,19 +802,6 @@ async fn persist_detached_failure(
)
.map_err(|err| FabroError::Io(err.to_string()))?;
let conclusion = build_failure_conclusion(&message);
if let Err(err) = run_store.put_conclusion(&conclusion).await {
tracing::warn!(error = %err, "Failed to save detached failure conclusion to store");
}
if let Err(err) = run_store
.put_status(&run_status::RunStatusRecord::new(
RunStatus::Failed,
Some(reason),
))
.await
{
tracing::warn!(error = %err, "Failed to save detached failure status to store");
}
if let Err(err) = append_workflow_event(
run_store,
&run_id,
@ -879,33 +838,17 @@ async fn persist_detached_failure(
Ok(())
}
fn build_failure_conclusion(message: &str) -> Conclusion {
Conclusion {
timestamp: Utc::now(),
status: StageStatus::Fail,
duration_ms: 0,
failure_reason: Some(message.to_string()),
final_git_commit_sha: None,
stages: vec![],
total_cost: None,
total_retries: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_reasoning_tokens: 0,
has_pricing: false,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use chrono::Utc;
use fabro_store::{InMemoryStore, Store};
use fabro_store::{SlateStore, StoreHandle};
use fabro_types::{Settings, fixtures};
use object_store::memory::InMemory;
use super::*;
use crate::context::Context;
@ -923,8 +866,16 @@ mod tests {
start -> exit
}"#;
async fn persisted_workflow(dot: &str, run_dir: &Path) -> (Persisted, InMemoryStore) {
let store = InMemoryStore::default();
fn memory_store() -> StoreHandle {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
async fn persisted_workflow(dot: &str, run_dir: &Path) -> (Persisted, StoreHandle) {
let store = memory_store();
let created = crate::operations::create(
&store,
crate::operations::CreateRunInput {
@ -960,7 +911,7 @@ mod tests {
}
async fn test_start_services(
store: &InMemoryStore,
store: &SlateStore,
_run_dir: &Path,
emitter: Arc<EventEmitter>,
registry: Arc<HandlerRegistry>,
@ -1047,7 +998,7 @@ mod tests {
assert_eq!(started.finalized.conclusion.status, StageStatus::Success);
let run_store = store.open_run(&fixtures::RUN_1).await.unwrap();
assert!(run_store.get_conclusion().await.unwrap().is_some());
assert!(run_store.state().await.unwrap().conclusion.is_some());
}
#[tokio::test]
@ -1089,7 +1040,7 @@ mod tests {
let (_persisted, store) = persisted_workflow(MINIMAL_DOT, &run_dir).await;
let services = test_start_services(&store, &run_dir, emitter, registry).await;
// Write a checkpoint to the store (not disk) so start() sees it
// Seed an authoritative checkpoint event so start() sees it
let checkpoint = Checkpoint::from_context(
&Context::new(),
"start",
@ -1101,11 +1052,41 @@ mod tests {
HashMap::new(),
HashMap::new(),
);
services
.run_store
.put_checkpoint(&checkpoint)
.await
.unwrap();
append_workflow_event(
services.run_store.as_ref(),
&services.run_id,
&WorkflowRunEvent::CheckpointCompleted {
node_id: checkpoint.current_node.clone(),
status: checkpoint
.node_outcomes
.get(&checkpoint.current_node)
.map_or_else(
|| "success".to_string(),
|outcome| outcome.status.to_string(),
),
current_node: checkpoint.current_node.clone(),
completed_nodes: checkpoint.completed_nodes.clone(),
node_retries: checkpoint.node_retries.clone().into_iter().collect(),
context_values: checkpoint.context_values.clone().into_iter().collect(),
node_outcomes: checkpoint.node_outcomes.clone().into_iter().collect(),
next_node_id: checkpoint.next_node_id.clone(),
git_commit_sha: checkpoint.git_commit_sha.clone(),
loop_failure_signatures: checkpoint
.loop_failure_signatures
.iter()
.map(|(sig, count)| (sig.to_string(), *count))
.collect(),
restart_failure_signatures: checkpoint
.restart_failure_signatures
.iter()
.map(|(sig, count)| (sig.to_string(), *count))
.collect(),
node_visits: checkpoint.node_visits.clone().into_iter().collect(),
diff: None,
},
)
.await
.unwrap();
let result = start(&run_dir, services).await;

View file

@ -73,7 +73,7 @@ pub async fn execute(init: Initialized) -> Executed {
registry,
emitter: Arc::clone(&emitter),
sandbox: Arc::clone(&sandbox),
run_store: Arc::clone(&run_store),
run_store: run_store.clone(),
git_state: std::sync::RwLock::new(git_state),
hook_runner: hook_runner.clone(),
env,
@ -93,7 +93,7 @@ pub async fn execute(init: Initialized) -> Executed {
&sandbox,
graph_arc,
&run_options.run_dir,
Arc::clone(&run_store),
run_store.clone(),
&settings_arc,
checkpoint.is_some(),
on_node,

View file

@ -13,13 +13,14 @@ use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_hooks::HookSettings;
use fabro_interview::AutoApproveInterviewer;
use fabro_sandbox::SandboxSpec;
use fabro_store::InMemoryStore;
use fabro_store::{SlateStore, StoreHandle};
use fabro_types::{RunId, Settings, fixtures};
use object_store::memory::InMemory;
use super::*;
use crate::context::{self, Context};
use crate::error::FabroError;
use crate::event::{EventEmitter, RunEventEnvelope};
use crate::event::{EventEmitter, RunEventEnvelope, StoreProgressLogger};
use crate::handler::start::StartHandler;
use crate::handler::{Handler as HandlerTrait, HandlerRegistry};
use crate::outcome::{Outcome, OutcomeExt, StageStatus};
@ -156,8 +157,12 @@ fn test_lifecycle(setup_commands: Vec<String>) -> LifecycleOptions {
}
}
async fn test_run_store(_run_dir: &Path, run_id: &RunId) -> Arc<dyn fabro_store::RunStore> {
let store: &dyn fabro_store::Store = &InMemoryStore::default();
async fn test_run_store(_run_dir: &Path, run_id: &RunId) -> fabro_store::RunStoreHandle {
let store: StoreHandle = Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
));
store
.create_run(run_id, chrono::Utc::now(), None)
.await
@ -175,13 +180,17 @@ async fn execute_test_run_with_options(
) -> Executed {
let run_id_value = run_options.run_id;
let git_options = run_options.git.clone();
let run_store = test_run_store(&run_options.run_dir, &run_id_value).await;
let emitter = test_emitter_arc("test-run");
let store_logger = StoreProgressLogger::new(run_store.clone());
store_logger.register(&emitter);
let initialized = initialize(
persisted_workflow(graph, String::new(), &run_options.run_dir, run_id_value),
InitOptions {
run_id: run_id_value,
run_store: test_run_store(&run_options.run_dir, &run_id_value).await,
run_store,
dry_run: false,
emitter: test_emitter_arc("test-run"),
emitter,
sandbox: SandboxSpec::Local {
working_directory: std::env::current_dir().unwrap(),
},
@ -217,7 +226,9 @@ async fn execute_test_run_with_options(
.await
.unwrap();
execute(initialized).await
let executed = execute(initialized).await;
store_logger.flush().await;
executed
}
#[tokio::test]
@ -503,7 +514,15 @@ async fn execute_runs_simple_workflow() {
async fn execute_saves_checkpoint() {
let dir = tempfile::tempdir().unwrap();
let executed = execute_test_run(dir.path(), simple_graph(), "test-run").await;
assert!(executed.run_store.get_checkpoint().await.unwrap().is_some());
assert!(
executed
.run_store
.state()
.await
.unwrap()
.checkpoint
.is_some()
);
}
#[tokio::test]
@ -547,7 +566,13 @@ async fn execute_error_when_no_start_node() {
async fn execute_mirrors_graph_goal_to_context() {
let dir = tempfile::tempdir().unwrap();
let executed = execute_test_run(dir.path(), simple_graph(), "test-run").await;
let cp = executed.run_store.get_checkpoint().await.unwrap().unwrap();
let cp = executed
.run_store
.state()
.await
.unwrap()
.checkpoint
.unwrap();
assert_eq!(
cp.context_values.get(context::keys::GRAPH_GOAL),
Some(&serde_json::json!("Run tests"))
@ -587,7 +612,13 @@ async fn execute_conditional_routing_uses_unconditional_success_path() {
g.edges.push(Edge::new("path_b", "exit"));
let executed = execute_test_run(dir.path(), g, "test-run").await;
let cp = executed.run_store.get_checkpoint().await.unwrap().unwrap();
let cp = executed
.run_store
.state()
.await
.unwrap()
.checkpoint
.unwrap();
assert!(cp.completed_nodes.contains(&"path_b".to_string()));
assert!(!cp.completed_nodes.contains(&"path_a".to_string()));
}
@ -603,7 +634,8 @@ async fn execute_writes_start_json_and_node_status() {
});
let executed = execute_test_run_with_options(run_options, simple_graph(), None).await;
let start = executed.run_store.get_start().await.unwrap().unwrap();
let state = executed.run_store.state().await.unwrap();
let start = state.start.as_ref().unwrap();
assert_eq!(start.run_id, test_run_id("test-run"));
assert_eq!(
start.run_branch.as_deref(),
@ -611,15 +643,13 @@ async fn execute_writes_start_json_and_node_status() {
);
assert_eq!(start.base_sha.as_deref(), Some("abc123"));
let node = executed
.run_store
.get_node(&fabro_store::NodeVisitRef {
let node = state
.node(&fabro_store::NodeVisitRef {
node_id: "start",
visit: 1,
})
.await
.unwrap();
assert_eq!(node.status.unwrap().status, StageStatus::Success);
assert_eq!(node.status.as_ref().unwrap().status, StageStatus::Success);
}
#[tokio::test]
@ -668,15 +698,15 @@ async fn timeout_causes_fail_status_record() {
Some(Arc::new(registry)),
)
.await;
let status = executed
.run_store
.get_node(&fabro_store::NodeVisitRef {
let state = executed.run_store.state().await.unwrap();
let status = state
.node(&fabro_store::NodeVisitRef {
node_id: "work",
visit: 1,
})
.await
.unwrap()
.status
.as_ref()
.unwrap();
assert_eq!(status.status, StageStatus::Fail);
}

View file

@ -10,7 +10,7 @@ use crate::run_options::RunOptions;
use crate::run_status::{RunStatus, StatusReason};
use crate::sandbox_git::git_push_host;
use fabro_hooks::{HookContext, HookEvent, HookRunner};
use fabro_store::RunStore;
use fabro_store::SlateRunStore;
use super::types::{Concluded, FinalizeOptions, Retroed};
@ -63,7 +63,7 @@ pub fn classify_engine_result(
}
pub(crate) async fn build_conclusion_from_store(
run_store: &dyn RunStore,
run_store: &SlateRunStore,
status: StageStatus,
failure_reason: Option<String>,
run_duration_ms: u64,
@ -181,7 +181,7 @@ pub fn persist_terminal_outcome(
pub async fn write_finalize_commit(
run_options: &RunOptions,
_run_dir: &Path,
run_store: &dyn RunStore,
run_store: &SlateRunStore,
) {
let (Some(meta_branch), Some(repo_path)) = (
run_options
@ -275,7 +275,7 @@ pub async fn finalize(
retro: _,
} = retroed;
let (final_status, failure_reason, run_status, status_reason) =
let (final_status, failure_reason, _run_status, _status_reason) =
classify_engine_result(&outcome);
let conclusion = build_conclusion_from_store(
options.run_store.as_ref(),
@ -324,20 +324,6 @@ pub async fn finalize(
);
}
if let Err(err) = options.run_store.put_conclusion(&conclusion).await {
tracing::warn!(error = %err, "Failed to save conclusion to store");
}
if let Err(err) = options
.run_store
.put_status(&fabro_types::RunStatusRecord::new(
run_status,
status_reason,
))
.await
{
tracing::warn!(error = %err, "Failed to save terminal status to store");
}
Ok(Concluded {
run_id: run_options.run_id,
outcome,
@ -353,13 +339,16 @@ pub async fn finalize(
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use fabro_graphviz::graph::Graph;
use fabro_store::{InMemoryStore, Store};
use fabro_store::SlateStore;
use fabro_types::{RunId, Settings, fixtures};
use object_store::memory::InMemory;
use super::*;
use crate::event::StoreProgressLogger;
use crate::pipeline::types::Retroed;
use crate::run_options::RunOptions;
@ -383,12 +372,20 @@ mod tests {
}
}
fn test_store() -> Arc<SlateStore> {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
#[tokio::test]
async fn finalize_writes_conclusion_json() {
let temp = tempfile::tempdir().unwrap();
let run_dir = temp.path().join("run");
std::fs::create_dir_all(&run_dir).unwrap();
let inner_store = InMemoryStore::default()
let inner_store = test_store()
.create_run(
&test_run_id(),
Utc::now(),
@ -396,14 +393,17 @@ mod tests {
)
.await
.unwrap();
let run_store: Arc<dyn fabro_store::RunStore> = inner_store;
let run_store = inner_store;
let emitter = Arc::new(EventEmitter::new(test_run_id()));
let store_logger = StoreProgressLogger::new(run_store.clone());
store_logger.register(&emitter);
let retroed = Retroed {
graph: Graph::new("test"),
outcome: Ok(Outcome::success()),
run_options: test_run_options(&run_dir),
run_store: Arc::clone(&run_store),
run_store: run_store.clone(),
hook_runner: None,
emitter: Arc::new(EventEmitter::default()),
emitter,
sandbox: Arc::new(fabro_agent::LocalSandbox::new(
std::env::current_dir().unwrap(),
)),
@ -416,7 +416,7 @@ mod tests {
&FinalizeOptions {
run_dir: run_dir.clone(),
run_id: test_run_id(),
run_store: Arc::clone(&run_store),
run_store: run_store.clone(),
workflow_name: "test".to_string(),
hook_runner: None,
preserve_sandbox: true,
@ -425,8 +425,8 @@ mod tests {
)
.await
.unwrap();
store_logger.flush().await;
assert!(run_store.get_conclusion().await.unwrap().is_some());
assert_eq!(concluded.conclusion.status, StageStatus::Success);
}
}

View file

@ -520,9 +520,6 @@ pub async fn initialize(
host_working_directory: sandbox_record.host_working_directory.clone(),
container_mount_point: sandbox_record.container_mount_point.clone(),
});
if let Err(err) = options.run_store.put_sandbox(&sandbox_record).await {
tracing::warn!(error = %err, "Failed to save sandbox record to store");
}
let env = build_sandbox_env(
&options.sandbox_env,
@ -670,15 +667,18 @@ pub async fn initialize(
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_interview::AutoApproveInterviewer;
use fabro_sandbox::SandboxSpec;
use fabro_store::InMemoryStore;
use fabro_store::{SlateStore, StoreHandle};
use fabro_types::{RunId, Settings, fixtures};
use object_store::memory::InMemory;
use super::*;
use crate::event::StoreProgressLogger;
use crate::pipeline::types::InitOptions;
use crate::records::RunRecord;
use crate::run_options::RunOptions;
@ -687,6 +687,14 @@ mod tests {
fixtures::RUN_1
}
fn memory_store() -> StoreHandle {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
fn simple_graph() -> (Graph, String) {
let source = r"digraph test {
start [shape=Mdiamond];
@ -754,14 +762,14 @@ mod tests {
std::fs::create_dir_all(&run_dir).unwrap();
let (graph, source) = simple_graph();
let persisted = test_persisted(graph, source.clone(), &run_dir);
let emitter = Arc::new(crate::event::EventEmitter::default());
let emitter = Arc::new(crate::event::EventEmitter::new(test_run_id()));
let initialized = initialize(
persisted,
InitOptions {
run_id: test_run_id(),
run_store: {
let store: &dyn fabro_store::Store = &InMemoryStore::default();
let store = memory_store();
let inner = store
.create_run(
&test_run_id(),
@ -829,24 +837,29 @@ mod tests {
std::fs::create_dir_all(&run_dir).unwrap();
let (graph, source) = simple_graph();
let persisted = test_persisted(graph, source, &run_dir);
let emitter = Arc::new(crate::event::EventEmitter::default());
let emitter = Arc::new(crate::event::EventEmitter::new(test_run_id()));
let store = memory_store();
let run_store = store
.create_run(
&test_run_id(),
chrono::Utc::now(),
Some(run_dir.to_string_lossy().as_ref()),
)
.await
.unwrap();
let store_logger = StoreProgressLogger::new(run_store.clone());
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
emitter.on_event({
let seen = Arc::clone(&seen);
move |event| seen.lock().unwrap().push(event.event.clone())
});
store_logger.register(&emitter);
let initialized = initialize(
persisted,
InitOptions {
run_id: test_run_id(),
run_store: {
let store: &dyn fabro_store::Store = &InMemoryStore::default();
let inner = store
.create_run(
&test_run_id(),
chrono::Utc::now(),
Some(run_dir.to_string_lossy().as_ref()),
)
.await
.unwrap();
inner
},
run_store,
dry_run: false,
emitter,
sandbox: SandboxSpec::Local {
@ -883,8 +896,14 @@ mod tests {
)
.await
.unwrap();
store_logger.flush().await;
assert!(initialized.run_store.get_sandbox().await.unwrap().is_some());
assert_eq!(initialized.run_options.run_dir, run_dir);
assert!(
seen.lock()
.unwrap()
.iter()
.any(|event| event == "sandbox.initialized")
);
}
}

View file

@ -1,6 +1,6 @@
use std::path::Path;
use fabro_store::RunStore;
use fabro_store::SlateRunStore;
use crate::error::FabroError;
@ -26,7 +26,7 @@ pub(crate) fn persist(
}
pub(crate) async fn load_from_store(
run_store: &dyn RunStore,
run_store: &SlateRunStore,
run_dir: &Path,
) -> Result<Persisted, FabroError> {
let state = run_store
@ -55,12 +55,24 @@ mod tests {
use chrono::Utc;
use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_store::{InMemoryStore, Store};
use fabro_store::{RunStoreHandle, SlateStore, StoreHandle};
use fabro_types::{Settings, fixtures};
use object_store::memory::InMemory;
use std::sync::Arc;
use std::time::Duration;
use super::*;
use crate::event::{WorkflowRunEvent, append_workflow_event};
use crate::records::RunRecord;
fn memory_store() -> StoreHandle {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
fn graph_and_source() -> (Graph, String) {
let source = r#"digraph test {
graph [goal="Ship feature"];
@ -130,8 +142,8 @@ mod tests {
run_dir: &Path,
record: &RunRecord,
source: Option<&str>,
) -> std::sync::Arc<dyn RunStore> {
let store = InMemoryStore::default();
) -> RunStoreHandle {
let store = memory_store();
let run_store = store
.create_run(
&record.run_id,
@ -140,10 +152,26 @@ mod tests {
)
.await
.unwrap();
run_store.put_run(record).await.unwrap();
if let Some(source) = source {
run_store.put_graph(source).await.unwrap();
}
append_workflow_event(
run_store.as_ref(),
&record.run_id,
&WorkflowRunEvent::RunCreated {
run_id: record.run_id,
settings: serde_json::to_value(&record.settings).unwrap(),
graph: serde_json::to_value(&record.graph).unwrap(),
workflow_source: source.map(ToOwned::to_owned),
workflow_config: None,
labels: record.labels.clone().into_iter().collect(),
run_dir: run_dir.to_string_lossy().to_string(),
working_directory: record.working_directory.display().to_string(),
host_repo_path: record.host_repo_path.clone(),
base_branch: record.base_branch.clone(),
workflow_slug: record.workflow_slug.clone(),
db_prefix: None,
},
)
.await
.unwrap();
run_store
}
@ -214,10 +242,23 @@ mod tests {
let run_store = seeded_store(&run_dir, &expected, Some(&source)).await;
let loaded = load_from_store(run_store.as_ref(), &run_dir).await.unwrap();
assert_eq!(
serde_json::to_value(loaded.run_record()).unwrap(),
serde_json::to_value(expected).unwrap()
let loaded_record = loaded.run_record();
assert_eq!(loaded_record.run_id, expected.run_id);
assert!(
(loaded_record.created_at.timestamp_millis() - expected.created_at.timestamp_millis())
.abs()
<= 1
);
assert_eq!(loaded_record.settings, expected.settings);
assert_eq!(
serde_json::to_value(&loaded_record.graph).unwrap(),
serde_json::to_value(&expected.graph).unwrap()
);
assert_eq!(loaded_record.workflow_slug, expected.workflow_slug);
assert_eq!(loaded_record.working_directory, expected.working_directory);
assert_eq!(loaded_record.host_repo_path, expected.host_repo_path);
assert_eq!(loaded_record.base_branch, expected.base_branch);
assert_eq!(loaded_record.labels, expected.labels);
assert_eq!(loaded.source(), source);
assert!(loaded.diagnostics().is_empty());
}

View file

@ -1,7 +1,7 @@
use std::path::Path;
use fabro_config::run::MergeStrategy;
use fabro_store::RunStore;
use fabro_store::SlateRunStore;
use fabro_types::PullRequestRecord;
use tracing::{debug, info};
@ -292,7 +292,7 @@ fn emit_run_notice(
});
}
async fn load_pull_request_diff(run_store: &dyn RunStore, run_dir: &Path) -> String {
async fn load_pull_request_diff(run_store: &SlateRunStore, run_dir: &Path) -> String {
let _ = run_dir;
run_store
.state()
@ -311,7 +311,7 @@ pub async fn build_pr_body(
diff: &str,
goal: &str,
model: &str,
run_store: &dyn RunStore,
run_store: &SlateRunStore,
run_dir: &Path,
conclusion: Option<&Conclusion>,
) -> Result<String, String> {
@ -340,7 +340,9 @@ pub async fn build_pr_body(
.ok();
let retro = run_state.as_ref().and_then(|state| state.retro.clone());
let run_record = run_state.as_ref().and_then(|state| state.run.clone());
let dot_source = run_state.as_ref().and_then(|state| state.graph_source.clone());
let dot_source = run_state
.as_ref()
.and_then(|state| state.graph_source.clone());
// Build LLM prompt
let system = if plan_text.is_some() {
@ -418,7 +420,7 @@ pub async fn maybe_open_pull_request(
model: &str,
draft: bool,
auto_merge: Option<AutoMergeOptions>,
run_store: &dyn RunStore,
run_store: &SlateRunStore,
run_dir: &Path,
conclusion: Option<&Conclusion>,
) -> Result<Option<PullRequestRecord>, String> {
@ -489,11 +491,6 @@ pub async fn maybe_open_pull_request(
title,
};
run_store
.put_pull_request(&record)
.await
.map_err(|err| format!("failed to persist pull request in run store: {err}"))?;
Ok(Some(record))
}
@ -522,7 +519,8 @@ pub async fn pull_request(concluded: Concluded, options: &PullRequestOptions) ->
result.status,
StageStatus::Success | StageStatus::PartialSuccess
) {
let diff = load_pull_request_diff(options.run_store.as_ref(), &options.run_dir).await;
let diff =
load_pull_request_diff(options.run_store.as_ref(), &options.run_dir).await;
if let (Some(base_branch), Some(run_branch), Some(creds), Some(origin)) = (
&run_options.base_branch,
pushed_branch.as_deref(),
@ -598,6 +596,7 @@ mod tests {
use std::sync::{Arc, Once};
use super::*;
use crate::event::{WorkflowRunEvent, append_workflow_event};
use crate::records::StageSummary;
use chrono::Utc;
use fabro_graphviz::graph::Graph;
@ -609,9 +608,11 @@ mod tests {
use fabro_retro::retro::{
AggregateStats, FrictionKind, FrictionPoint, OpenItem, OpenItemKind, StageRetro,
};
use fabro_store::{InMemoryStore, Store};
use fabro_store::SlateStore;
use fabro_types::{RunRecord, Settings, fixtures};
use futures::stream;
use object_store::memory::InMemory;
use std::time::Duration;
struct MockProvider {
response_text: String,
@ -684,6 +685,14 @@ mod tests {
}
}
fn test_store() -> Arc<SlateStore> {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
fn install_mock_llm() {
static INIT: Once = Once::new();
@ -1034,7 +1043,7 @@ mod tests {
install_mock_llm();
let tmp = tempfile::tempdir().unwrap();
let store = InMemoryStore::default();
let store = test_store();
let run_store = store
.create_run(
&fixtures::RUN_1,
@ -1066,7 +1075,7 @@ mod tests {
install_mock_llm();
let tmp = tempfile::tempdir().unwrap();
let store = InMemoryStore::default();
let store = test_store();
let created_at = Utc::now();
let run_store = store
.create_run(
@ -1077,32 +1086,55 @@ mod tests {
.await
.unwrap();
run_store
.put_run(&RunRecord {
let run_record = RunRecord {
run_id: fixtures::RUN_1,
created_at,
settings: Settings::default(),
graph: Graph::new("test"),
workflow_slug: Some("test".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::new(),
};
append_workflow_event(
run_store.as_ref(),
&fixtures::RUN_1,
&WorkflowRunEvent::RunCreated {
run_id: fixtures::RUN_1,
created_at,
settings: Settings::default(),
graph: Graph::new("test"),
workflow_slug: Some("test".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::new(),
})
.await
.unwrap();
run_store
.put_graph("digraph test { plan -> code }")
.await
.unwrap();
run_store.put_retro(&make_test_retro()).await.unwrap();
settings: serde_json::to_value(&run_record.settings).unwrap(),
graph: serde_json::to_value(&run_record.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
workflow_config: None,
labels: run_record.labels.clone().into_iter().collect(),
run_dir: tmp.path().display().to_string(),
working_directory: run_record.working_directory.display().to_string(),
host_repo_path: run_record.host_repo_path.clone(),
base_branch: run_record.base_branch.clone(),
workflow_slug: run_record.workflow_slug.clone(),
db_prefix: None,
},
)
.await
.unwrap();
append_workflow_event(
run_store.as_ref(),
&fixtures::RUN_1,
&WorkflowRunEvent::RetroCompleted {
duration_ms: 1,
response: Some(String::new()),
retro: Some(serde_json::to_value(make_test_retro()).unwrap()),
},
)
.await
.unwrap();
let conclusion = make_test_conclusion();
let body = build_pr_body(
"diff --git a/src/lib.rs b/src/lib.rs\n+fn new_feature() {}\n",
"Implement feature",
"mock-model",
run_store.as_ref(),
run_store.as_ref(),
tmp.path(),
Some(&conclusion),
)
@ -1292,7 +1324,7 @@ mod tests {
#[tokio::test]
async fn empty_diff_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let store = InMemoryStore::default();
let store = test_store();
let run_store = store
.create_run(
&fixtures::RUN_1,
@ -1327,7 +1359,7 @@ mod tests {
#[tokio::test]
async fn load_pull_request_diff_uses_store_without_disk_patch() {
let tmp = tempfile::tempdir().unwrap();
let store = InMemoryStore::default();
let store = test_store();
let created_at = Utc::now();
let run_store = store
.create_run(
@ -1337,24 +1369,55 @@ mod tests {
)
.await
.unwrap();
run_store
.put_run(&RunRecord {
let run_record = RunRecord {
run_id: fixtures::RUN_1,
created_at,
settings: Settings::default(),
graph: Graph::new("test"),
workflow_slug: None,
working_directory: tmp.path().to_path_buf(),
host_repo_path: None,
base_branch: None,
labels: std::collections::HashMap::new(),
};
append_workflow_event(
run_store.as_ref(),
&fixtures::RUN_1,
&WorkflowRunEvent::RunCreated {
run_id: fixtures::RUN_1,
created_at,
settings: Settings::default(),
graph: Graph::new("test"),
workflow_slug: None,
working_directory: tmp.path().to_path_buf(),
settings: serde_json::to_value(&run_record.settings).unwrap(),
graph: serde_json::to_value(&run_record.graph).unwrap(),
workflow_source: None,
workflow_config: None,
labels: run_record.labels.clone().into_iter().collect(),
run_dir: tmp.path().display().to_string(),
working_directory: tmp.path().display().to_string(),
host_repo_path: None,
base_branch: None,
labels: std::collections::HashMap::new(),
})
.await
.unwrap();
run_store
.put_final_patch("diff --git a/src/lib.rs b/src/lib.rs\n+fn from_store() {}\n")
.await
.unwrap();
workflow_slug: None,
db_prefix: None,
},
)
.await
.unwrap();
append_workflow_event(
run_store.as_ref(),
&fixtures::RUN_1,
&WorkflowRunEvent::WorkflowRunCompleted {
duration_ms: 1,
artifact_count: 0,
status: "success".to_string(),
reason: None,
total_cost: None,
final_git_commit_sha: None,
final_patch: Some(
"diff --git a/src/lib.rs b/src/lib.rs\n+fn from_store() {}\n".to_string(),
),
usage: None,
},
)
.await
.unwrap();
let diff = load_pull_request_diff(run_store.as_ref(), tmp.path()).await;

View file

@ -8,8 +8,6 @@ use fabro_retro::retro_agent::{
use super::types::{Executed, RetroOptions, Retroed};
use crate::event::WorkflowRunEvent;
#[cfg(test)]
use crate::records::RunRecord;
pub async fn run_retro(options: &RetroOptions, dry_run: bool) -> Option<Retro> {
let state = match options.run_store.state().await {
@ -56,10 +54,6 @@ pub async fn run_retro(options: &RetroOptions, dry_run: bool) -> Option<Retro> {
&stage_durations,
);
if let Err(err) = options.run_store.put_retro(&retro).await {
tracing::warn!(error = %err, "Failed to save initial retro to store");
}
let retro_start = std::time::Instant::now();
let retro_prompt = build_retro_prompt(RETRO_DATA_DIR);
if let Some(ref emitter) = options.emitter {
@ -115,9 +109,6 @@ pub async fn run_retro(options: &RetroOptions, dry_run: bool) -> Option<Retro> {
retro: serde_json::to_value(&retro).ok(),
});
}
if let Err(err) = options.run_store.put_retro(&retro).await {
tracing::warn!(error = %err, "Failed to save retro with narrative to store");
}
}
Err(e) => {
if let Some(ref emitter) = options.emitter {
@ -178,17 +169,20 @@ pub async fn retro(executed: Executed, options: &RetroOptions) -> Retroed {
mod tests {
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use chrono::Utc;
use fabro_graphviz::graph::Graph;
use fabro_store::{InMemoryStore, Store};
use fabro_store::SlateStore;
use fabro_types::{RunId, Settings, fixtures};
use object_store::memory::InMemory;
use super::*;
use crate::context::Context;
use crate::event::EventEmitter;
use crate::event::{StoreProgressLogger, WorkflowRunEvent, append_workflow_event};
use crate::pipeline::types::Executed;
use crate::records::{Checkpoint, CheckpointExt};
use crate::records::{Checkpoint, CheckpointExt, RunRecord};
use crate::run_options::RunOptions;
fn test_run_id() -> RunId {
@ -214,12 +208,20 @@ mod tests {
checkpoint
}
fn test_store() -> Arc<SlateStore> {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
async fn test_run_store(
run_dir: &std::path::Path,
checkpoint: &Checkpoint,
) -> Arc<dyn fabro_store::RunStore> {
) -> fabro_store::RunStoreHandle {
let created_at = Utc::now();
let inner = InMemoryStore::default()
let inner = test_store()
.create_run(
&test_run_id(),
created_at,
@ -227,22 +229,69 @@ mod tests {
)
.await
.unwrap();
let run_store: Arc<dyn fabro_store::RunStore> = inner;
run_store
.put_run(&RunRecord {
let run_store = inner;
let run_record = RunRecord {
run_id: test_run_id(),
created_at,
settings: Settings::default(),
graph: Graph::new("test"),
workflow_slug: None,
working_directory: run_dir.to_path_buf(),
host_repo_path: None,
base_branch: None,
labels: std::collections::HashMap::new(),
};
append_workflow_event(
run_store.as_ref(),
&test_run_id(),
&WorkflowRunEvent::RunCreated {
run_id: test_run_id(),
created_at,
settings: Settings::default(),
graph: Graph::new("test"),
workflow_slug: None,
working_directory: run_dir.to_path_buf(),
settings: serde_json::to_value(&run_record.settings).unwrap(),
graph: serde_json::to_value(&run_record.graph).unwrap(),
workflow_source: None,
workflow_config: None,
labels: run_record.labels.clone().into_iter().collect(),
run_dir: run_dir.to_string_lossy().to_string(),
working_directory: run_dir.to_string_lossy().to_string(),
host_repo_path: None,
base_branch: None,
labels: std::collections::HashMap::new(),
})
.await
.unwrap();
run_store.put_checkpoint(checkpoint).await.unwrap();
workflow_slug: None,
db_prefix: None,
},
)
.await
.unwrap();
append_workflow_event(
run_store.as_ref(),
&test_run_id(),
&WorkflowRunEvent::CheckpointCompleted {
node_id: checkpoint.current_node.clone(),
status: "success".to_string(),
current_node: checkpoint.current_node.clone(),
completed_nodes: checkpoint.completed_nodes.clone(),
node_retries: checkpoint.node_retries.clone().into_iter().collect(),
context_values: checkpoint.context_values.clone().into_iter().collect(),
node_outcomes: checkpoint.node_outcomes.clone().into_iter().collect(),
next_node_id: checkpoint.next_node_id.clone(),
git_commit_sha: checkpoint.git_commit_sha.clone(),
loop_failure_signatures: checkpoint
.loop_failure_signatures
.clone()
.into_iter()
.map(|(signature, count)| (signature.to_string(), count))
.collect(),
restart_failure_signatures: checkpoint
.restart_failure_signatures
.clone()
.into_iter()
.map(|(signature, count)| (signature.to_string(), count))
.collect(),
node_visits: checkpoint.node_visits.clone().into_iter().collect(),
diff: None,
},
)
.await
.unwrap();
run_store
}
@ -270,7 +319,9 @@ mod tests {
let checkpoint = build_checkpoint();
let run_store = test_run_store(&run_dir, &checkpoint).await;
let emitter = Arc::new(EventEmitter::default());
let emitter = Arc::new(EventEmitter::new(test_run_id()));
let store_logger = StoreProgressLogger::new(run_store.clone());
store_logger.register(&emitter);
let sandbox: Arc<dyn fabro_agent::Sandbox> = Arc::new(fabro_agent::LocalSandbox::new(
std::env::current_dir().unwrap(),
));
@ -278,7 +329,7 @@ mod tests {
graph: Graph::new("test"),
outcome: Ok(crate::outcome::Outcome::success()),
run_options: test_run_options(&run_dir),
run_store: Arc::clone(&run_store),
run_store: run_store.clone(),
hook_runner: None,
emitter: Arc::clone(&emitter),
sandbox: Arc::clone(&sandbox),
@ -308,8 +359,8 @@ mod tests {
},
)
.await;
store_logger.flush().await;
assert!(retroed.run_store.get_retro().await.unwrap().is_some());
assert!(retroed.retro.is_some());
}

View file

@ -11,7 +11,7 @@ use fabro_llm::Provider;
use fabro_mcp::config::McpServerSettings;
use fabro_model::FallbackTarget;
use fabro_sandbox::SandboxSpec;
use fabro_store::RunStore;
use fabro_store::{RunStoreHandle, SlateRunStore};
use fabro_types::RunId;
use fabro_validate::Diagnostic;
@ -195,7 +195,7 @@ impl Persisted {
}
pub async fn load_from_store(
run_store: &dyn RunStore,
run_store: &SlateRunStore,
run_dir: &Path,
) -> Result<Self, FabroError> {
super::persist::load_from_store(run_store, run_dir).await
@ -227,7 +227,7 @@ pub struct DevcontainerSpec {
pub struct InitOptions {
pub run_id: RunId,
pub run_store: Arc<dyn RunStore>,
pub run_store: RunStoreHandle,
pub dry_run: bool,
pub emitter: Arc<EventEmitter>,
pub sandbox: SandboxSpec,
@ -251,7 +251,7 @@ pub struct Initialized {
pub graph: Graph,
pub source: String,
pub run_options: RunOptions,
pub run_store: Arc<dyn RunStore>,
pub run_store: RunStoreHandle,
pub(crate) checkpoint: Option<Checkpoint>,
pub(crate) seed_context: Option<Context>,
pub emitter: Arc<EventEmitter>,
@ -272,7 +272,7 @@ pub struct Executed {
pub graph: Graph,
pub outcome: Result<Outcome, FabroError>,
pub run_options: RunOptions,
pub run_store: Arc<dyn RunStore>,
pub run_store: RunStoreHandle,
pub hook_runner: Option<Arc<HookRunner>>,
pub emitter: Arc<EventEmitter>,
pub sandbox: Arc<dyn Sandbox>,
@ -289,7 +289,7 @@ pub struct Retroed {
pub graph: Graph,
pub outcome: Result<Outcome, FabroError>,
pub run_options: RunOptions,
pub run_store: Arc<dyn RunStore>,
pub run_store: RunStoreHandle,
pub hook_runner: Option<Arc<HookRunner>>,
pub emitter: Arc<EventEmitter>,
pub sandbox: Arc<dyn Sandbox>,
@ -328,7 +328,7 @@ pub struct TransformOptions {
/// Options for the RETRO phase.
pub struct RetroOptions {
pub run_id: RunId,
pub run_store: Arc<dyn RunStore>,
pub run_store: RunStoreHandle,
pub workflow_name: String,
pub goal: String,
pub run_dir: PathBuf,
@ -346,7 +346,7 @@ pub struct RetroOptions {
pub struct FinalizeOptions {
pub run_dir: PathBuf,
pub run_id: RunId,
pub run_store: Arc<dyn RunStore>,
pub run_store: RunStoreHandle,
pub workflow_name: String,
pub hook_runner: Option<Arc<HookRunner>>,
pub preserve_sandbox: bool,
@ -356,7 +356,7 @@ pub struct FinalizeOptions {
/// Options for the PULL_REQUEST phase.
pub struct PullRequestOptions {
pub run_dir: PathBuf,
pub run_store: Arc<dyn RunStore>,
pub run_store: RunStoreHandle,
pub pr_config: Option<PullRequestSettings>,
pub github_app: Option<fabro_github::GitHubAppCredentials>,
pub origin_url: Option<String>,

View file

@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Utc};
use fabro_store::{ListRunsQuery, Store};
use fabro_store::{ListRunsQuery, SlateStore};
use fabro_types::RunId;
use serde::Serialize;
@ -170,7 +170,7 @@ fn scan_runs_inner(base: &Path, include_status: bool) -> Result<Vec<RunInfo>> {
Ok(runs)
}
pub async fn scan_runs_combined(store: &dyn Store, base: &Path) -> Result<Vec<RunInfo>> {
pub async fn scan_runs_combined(store: &SlateStore, base: &Path) -> Result<Vec<RunInfo>> {
let mut runs_by_id: HashMap<RunId, RunInfo> = HashMap::new();
if let Ok(store_runs) = store.list_runs(&ListRunsQuery::default()).await {
@ -373,7 +373,7 @@ pub fn resolve_run(base: &Path, identifier: &str) -> Result<RunInfo> {
}
pub async fn resolve_run_combined(
store: &dyn Store,
store: &SlateStore,
base: &Path,
identifier: &str,
) -> Result<RunInfo> {
@ -438,15 +438,27 @@ fn run_id_matches(run_id: RunId, prefix: &str) -> bool {
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use fabro_graphviz::graph::Graph;
use fabro_store::{InMemoryStore, Store};
use fabro_types::{RunStatus, RunStatusRecord, Settings, fixtures};
use fabro_store::{SlateStore, StoreHandle};
use fabro_types::{RunStatus, Settings, fixtures};
use object_store::memory::InMemory;
use super::scan_runs_combined;
use crate::event::{WorkflowRunEvent, append_workflow_event};
use crate::records::{RunRecord, RunRecordExt};
fn memory_store() -> StoreHandle {
Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
))
}
fn sample_run_record() -> RunRecord {
RunRecord {
run_id: fixtures::RUN_1,
@ -471,7 +483,7 @@ mod tests {
run_record.save(&run_dir).unwrap();
std::fs::write(run_dir.join("id.txt"), format!("{}\n", fixtures::RUN_1)).unwrap();
let store = InMemoryStore::default();
let store = memory_store();
let run_dir_string = run_dir.to_string_lossy().to_string();
let run_store = store
.create_run(
@ -481,11 +493,33 @@ mod tests {
)
.await
.unwrap();
run_store.put_run(&run_record).await.unwrap();
run_store
.put_status(&RunStatusRecord::new(RunStatus::Submitted, None))
.await
.unwrap();
append_workflow_event(
run_store.as_ref(),
&fixtures::RUN_1,
&WorkflowRunEvent::RunCreated {
run_id: fixtures::RUN_1,
settings: serde_json::to_value(&run_record.settings).unwrap(),
graph: serde_json::to_value(&run_record.graph).unwrap(),
workflow_source: None,
workflow_config: None,
labels: run_record.labels.clone().into_iter().collect(),
run_dir: run_dir_string.clone(),
working_directory: run_record.working_directory.display().to_string(),
host_repo_path: run_record.host_repo_path.clone(),
base_branch: run_record.base_branch.clone(),
workflow_slug: run_record.workflow_slug.clone(),
db_prefix: None,
},
)
.await
.unwrap();
append_workflow_event(
run_store.as_ref(),
&fixtures::RUN_1,
&WorkflowRunEvent::RunSubmitted { reason: None },
)
.await
.unwrap();
let runs = scan_runs_combined(&store, temp.path()).await.unwrap();
let run = runs

View file

@ -1,15 +1,16 @@
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use fabro_agent::Sandbox;
use fabro_graphviz::graph::Graph as GvGraph;
use fabro_store::{InMemoryStore, RunStore, Store};
use fabro_types::run::RunRecord;
use fabro_store::{SlateRunStore, SlateStore};
use object_store::memory::InMemory;
use crate::error::Result;
use crate::event::EventEmitter;
use crate::event::{EventEmitter, WorkflowRunEvent, append_workflow_event};
use crate::git::scan_node_files_from_store;
use crate::handler::HandlerRegistry;
use crate::outcome::Outcome;
@ -41,32 +42,50 @@ async fn initialized(
) -> Initialized {
std::fs::create_dir_all(&run_options.run_dir).expect("failed to create run dir");
let created_at = Utc::now();
let inner_store = InMemoryStore::default()
let store = Arc::new(SlateStore::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
));
let inner_store = store
.create_run(
&run_options.run_id,
created_at,
Some(run_options.run_dir.to_string_lossy().as_ref()),
)
.await
.expect("failed to create in-memory run store");
.expect("failed to create slate-backed test run store");
let run_store = inner_store;
run_store
.put_run(&RunRecord {
append_workflow_event(
run_store.as_ref(),
&run_options.run_id,
&WorkflowRunEvent::RunCreated {
run_id: run_options.run_id,
created_at,
settings: run_options.settings.clone(),
graph: graph.clone(),
workflow_slug: run_options.workflow_slug.clone(),
working_directory: PathBuf::from(sandbox.working_directory()),
settings: serde_json::to_value(&run_options.settings)
.expect("failed to serialize settings"),
graph: serde_json::to_value(graph).expect("failed to serialize graph"),
workflow_source: None,
workflow_config: None,
labels: run_options
.labels
.clone()
.into_iter()
.collect::<BTreeMap<_, _>>(),
run_dir: run_options.run_dir.display().to_string(),
working_directory: PathBuf::from(sandbox.working_directory())
.display()
.to_string(),
host_repo_path: run_options
.host_repo_path
.as_ref()
.map(|path| path.display().to_string()),
base_branch: run_options.base_branch.clone(),
labels: run_options.labels.clone(),
})
.await
.expect("failed to seed run record in run store");
workflow_slug: run_options.workflow_slug.clone(),
db_prefix: None,
},
)
.await
.expect("failed to seed run.created event in run store");
let emitter = bound_emitter(run_options.run_id, &emitter);
Initialized {
graph: graph.clone(),
@ -166,12 +185,17 @@ pub async fn run_graph_from_checkpoint(
executed.outcome
}
async fn persist_run_artifacts_for_tests(run_store: &dyn RunStore, run_dir: &std::path::Path) {
if let Ok(Some(checkpoint)) = run_store.get_checkpoint().await {
async fn persist_run_artifacts_for_tests(run_store: &SlateRunStore, run_dir: &std::path::Path) {
let state: fabro_store::RunState = match run_store.state().await {
Ok(state) => state,
Err(_) => return,
};
if let Some(checkpoint) = state.checkpoint.as_ref() {
let _ = checkpoint.save(&run_dir.join("checkpoint.json"));
}
if let Ok(Some(final_patch)) = run_store.get_final_patch().await {
if let Some(final_patch) = state.final_patch.as_ref() {
let _ = std::fs::write(run_dir.join("final.patch"), final_patch);
}