refactor(store): share run projection with cli

Make fabro-store::RunProjection the single projection type used by the
server, CLI, and CLI test helpers. This removes the duplicated CLI-side
mirrors and adds serde coverage for the store-owned projection.
This commit is contained in:
Bryan Helmkamp 2026-04-07 11:36:22 -04:00
parent a2672aef46
commit 0cf80cdcfb
3 changed files with 119 additions and 156 deletions

View file

@ -1,4 +1,3 @@
use std::collections::HashMap;
use std::num::NonZeroU64;
use std::path::{Path, PathBuf};
use std::time::Duration;
@ -7,10 +6,7 @@ use anyhow::{Context as _, Result, anyhow, bail};
use fabro_api::types;
use fabro_server::bind::Bind;
use fabro_store::{EventEnvelope, RunSummary, StageId};
use fabro_types::{
Checkpoint, Conclusion, NodeStatusRecord, PullRequestRecord, Retro, RunEvent, RunId, RunRecord,
RunStatusRecord, SandboxRecord, Settings, StartRecord,
};
use fabro_types::{RunEvent, RunId, Settings};
use futures::StreamExt;
use serde::de::DeserializeOwned;
use tokio::time::sleep;
@ -30,82 +26,7 @@ struct LocalServerRuntime {
storage_dir: PathBuf,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub(crate) struct RunProjection {
#[serde(default)]
pub run: Option<RunRecord>,
#[serde(default)]
pub graph_source: Option<String>,
#[serde(default)]
pub start: Option<StartRecord>,
#[serde(default)]
pub status: Option<RunStatusRecord>,
#[serde(default)]
pub checkpoint: Option<Checkpoint>,
#[serde(default)]
pub checkpoints: Vec<(u32, Checkpoint)>,
#[serde(default)]
pub conclusion: Option<Conclusion>,
#[serde(default)]
pub retro: Option<Retro>,
#[serde(default)]
pub retro_prompt: Option<String>,
#[serde(default)]
pub retro_response: Option<String>,
#[serde(default)]
pub sandbox: Option<SandboxRecord>,
#[serde(default)]
pub final_patch: Option<String>,
#[serde(default)]
pub pull_request: Option<PullRequestRecord>,
#[serde(default)]
nodes: HashMap<String, NodeState>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub(crate) struct NodeState {
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub response: Option<String>,
#[serde(default)]
pub status: Option<NodeStatusRecord>,
#[serde(default)]
pub provider_used: Option<serde_json::Value>,
#[serde(default)]
pub diff: Option<String>,
#[serde(default)]
pub script_invocation: Option<serde_json::Value>,
#[serde(default)]
pub script_timing: Option<serde_json::Value>,
#[serde(default)]
pub parallel_results: Option<serde_json::Value>,
#[serde(default)]
pub stdout: Option<String>,
#[serde(default)]
pub stderr: Option<String>,
}
impl RunProjection {
pub(crate) fn list_node_visits(&self, node_id: &str) -> Vec<u32> {
let mut visits = self
.nodes
.keys()
.filter_map(|key| key.parse::<StageId>().ok())
.filter(|stage_id| stage_id.node_id() == node_id)
.map(|stage_id| stage_id.visit())
.collect::<Vec<_>>();
visits.sort_unstable();
visits.dedup();
visits
}
pub(crate) fn node(&self, stage_id: &StageId) -> Option<&NodeState> {
self.nodes.get(&stage_id.to_string())
}
}
pub(crate) use fabro_store::RunProjection;
pub(crate) async fn connect_server(storage_dir: &Path) -> Result<ServerStoreClient> {
Ok(ServerStoreClient {

View file

@ -13,72 +13,12 @@ use fabro_config::Storage;
use fabro_server::bind::Bind;
use fabro_store::EventEnvelope;
use fabro_test::TestContext;
use fabro_types::{
Checkpoint, Conclusion, NodeStatusRecord, PullRequestRecord, Retro, RunRecord, RunStatusRecord,
SandboxRecord, StageId, StartRecord,
};
use serde_json::Value;
use shlex::try_quote;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
#[allow(dead_code)]
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub(crate) struct RunProjection {
#[serde(default)]
pub run: Option<RunRecord>,
#[serde(default)]
pub graph_source: Option<String>,
#[serde(default)]
pub start: Option<StartRecord>,
#[serde(default)]
pub status: Option<RunStatusRecord>,
#[serde(default)]
pub checkpoint: Option<Checkpoint>,
#[serde(default)]
pub checkpoints: Vec<(u32, Checkpoint)>,
#[serde(default)]
pub conclusion: Option<Conclusion>,
#[serde(default)]
pub retro: Option<Retro>,
#[serde(default)]
pub retro_prompt: Option<String>,
#[serde(default)]
pub retro_response: Option<String>,
#[serde(default)]
pub sandbox: Option<SandboxRecord>,
#[serde(default)]
pub final_patch: Option<String>,
#[serde(default)]
pub pull_request: Option<PullRequestRecord>,
#[serde(default)]
pub nodes: std::collections::HashMap<String, NodeState>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub(crate) struct NodeState {
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub response: Option<String>,
#[serde(default)]
pub status: Option<NodeStatusRecord>,
#[serde(default)]
pub provider_used: Option<serde_json::Value>,
#[serde(default)]
pub diff: Option<String>,
#[serde(default)]
pub script_invocation: Option<serde_json::Value>,
#[serde(default)]
pub script_timing: Option<serde_json::Value>,
#[serde(default)]
pub parallel_results: Option<serde_json::Value>,
#[serde(default)]
pub stdout: Option<String>,
#[serde(default)]
pub stderr: Option<String>,
}
pub(crate) use fabro_store::RunProjection;
#[derive(Debug, Clone, Default, serde::Deserialize)]
struct RunSummaryRecord {
@ -87,18 +27,6 @@ struct RunSummaryRecord {
labels: std::collections::HashMap<String, String>,
}
impl RunProjection {
pub(crate) fn iter_nodes(&self) -> impl Iterator<Item = (StageId, &NodeState)> {
self.nodes
.iter()
.filter_map(|(stage_id, state)| stage_id.parse::<StageId>().ok().map(|id| (id, state)))
}
pub(crate) fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
}
pub(crate) struct RunSetup {
pub(crate) run_id: String,
pub(crate) run_dir: PathBuf,

View file

@ -16,7 +16,8 @@ use fabro_types::{
RunStatusRecord, SandboxRecord, StageStatus, StageUsage, StartRecord, StatusReason, TokenUsage,
};
#[derive(Debug, Clone, Default, serde::Serialize)]
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct RunProjection {
pub run: Option<RunRecord>,
pub graph_source: Option<String>,
@ -35,7 +36,7 @@ pub struct RunProjection {
nodes: HashMap<StageId, NodeState>,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct NodeState {
pub prompt: Option<String>,
pub response: Option<String>,
@ -578,3 +579,116 @@ fn run_usage_from_token_usage(usage: &TokenUsage) -> RunUsage {
cost: None,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::{NodeState, RunProjection};
use crate::StageId;
use fabro_types::{Checkpoint, RunControlAction};
#[test]
fn deserialize_projection_defaults_missing_nodes_and_checkpoints() {
let state: RunProjection = serde_json::from_value(serde_json::json!({
"pending_control": "pause"
}))
.unwrap();
assert_eq!(state.pending_control, Some(RunControlAction::Pause));
assert!(state.checkpoints.is_empty());
assert!(state.is_empty());
}
#[test]
fn deserialize_and_round_trip_projection_preserves_stage_ids_and_pending_control() {
let state: RunProjection = serde_json::from_value(serde_json::json!({
"pending_control": "cancel",
"checkpoints": [[
0,
{
"timestamp": "2026-04-07T12:00:00Z",
"current_node": "build",
"completed_nodes": ["build"],
"node_retries": {},
"context_values": {},
"node_outcomes": {},
"loop_failure_signatures": {},
"restart_failure_signatures": {},
"node_visits": { "build": 2 }
}
]],
"nodes": {
"build@2": {
"diff": "diff --git a/file b/file",
"stdout": "done"
}
}
}))
.unwrap();
let stage_id = StageId::new("build", 2);
let node = state.node(&stage_id).unwrap();
assert_eq!(node.diff.as_deref(), Some("diff --git a/file b/file"));
assert_eq!(state.list_node_visits("build"), vec![2]);
assert_eq!(state.pending_control, Some(RunControlAction::Cancel));
let round_tripped: RunProjection =
serde_json::from_value(serde_json::to_value(&state).unwrap()).unwrap();
let round_tripped_node = round_tripped.node(&stage_id).unwrap();
assert_eq!(round_tripped_node.stdout.as_deref(), Some("done"));
assert_eq!(round_tripped.list_node_visits("build"), vec![2]);
assert_eq!(
round_tripped.pending_control,
Some(RunControlAction::Cancel)
);
}
#[test]
fn set_node_round_trips_through_json() {
let mut state = RunProjection {
pending_control: Some(RunControlAction::Unpause),
checkpoints: vec![(
7,
Checkpoint {
timestamp: "2026-04-07T12:00:00Z".parse().unwrap(),
current_node: "build".to_string(),
completed_nodes: vec!["build".to_string()],
node_retries: HashMap::new(),
context_values: HashMap::new(),
node_outcomes: HashMap::new(),
next_node_id: None,
git_commit_sha: None,
loop_failure_signatures: HashMap::new(),
restart_failure_signatures: HashMap::new(),
node_visits: HashMap::from([("build".to_string(), 2usize)]),
},
)],
..RunProjection::default()
};
state.set_node(
StageId::new("build", 2),
NodeState {
stdout: Some("done".to_string()),
..NodeState::default()
},
);
let round_tripped: RunProjection =
serde_json::from_value(serde_json::to_value(&state).unwrap()).unwrap();
assert_eq!(
round_tripped
.node(&StageId::new("build", 2))
.unwrap()
.stdout
.as_deref(),
Some("done")
);
assert_eq!(round_tripped.list_node_visits("build"), vec![2]);
assert_eq!(
round_tripped.pending_control,
Some(RunControlAction::Unpause)
);
}
}