Delete the legacy run event log, its reducer and its types

Step 4 of the legacy executor deletion, fourth commit: with no writer
and no reader left, the legacy event log goes.

- `fabro-types`: `run_event` (`EventBody`, `RunEvent` and every props
  struct), `EventEnvelope` and the `RunEventDetail*` types are deleted.
  What the projection and the API still use moves out of the event
  vocabulary: `AgentEventProps`, `AgentSessionActivatedProps`,
  `AgentToolsAvailableProps`, `StagePromptProps`, `SessionCapability`
  and the coding event names to `agent_props`; `RunNoticeLevel` and
  `RunNoticeCode` to `notice`; `InterviewOption` beside the question
  types; `RunRunnableSource` beside the run status. `Checkpoint` is
  what Fabro records for a Petri run: `timestamp`, `current_node`,
  `git_commit_sha`; the conclusion's stage summaries derive from the
  projection's stages instead of the checkpoint's node maps.
- `fabro-store`: the Slate bridge (`RunDatabase`, the Slate `Database`,
  `keys`, `record`, `EventPayload`) and the reducer (`run_state`) are
  deleted. `Database` is the blob table and the run summary store over
  one pool; the blob store is SQLite only; the run summary store keeps
  the `runs` row a projector writes and lists, and finds the pull
  request creation candidates over `platform_records`; `build_summary`
  and `projected_usage` live in `run_summary`. The SlateDB dependency
  is gone. Test fixtures build the store from its two SQLite stores.
- `fabro-workflow`: the `event` module (the `Event` enum, its
  conversion, sink, emitter, redaction, stored fields and names),
  `runtime_store`, `StageScope` and the legacy seeding test helpers are
  deleted; the tests that seeded legacy runs read platform records or
  a projection instead.
- `fabro-sandbox` owns `GitRetryReason`.
- The server builds the store without an object store; the legacy
  `POST /runs/{id}/events` tests go, an interrupt answers
  `interrupt_unsupported` in the tests as it does in the handler, and
  the tests that read a run back through the Slate handle read its
  projection or its platform records. The projection folds a block
  that lands while the run is paused as the pause's prior block, and a
  pause or unpause clears the pending control it answers; a control
  request's check-and-append holds a per-run lock so two concurrent
  cancels record one request.
- The CLI's final output is the response of the last stage that
  produced one; the workflow tests read completed nodes from the
  succeeded stages.
- The spec's `RunCheckpoint` carries the three fields the type keeps.

Still failing until the next commits: the CLI tests that seed runs
through `POST /runs/{id}/events` or wait for legacy event names, and
the two Ask Fabro resume tests (the sandbox instance gap).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-18 14:09:17 -04:00
parent bc31b8d23a
commit 60b503322c
No known key found for this signature in database
81 changed files with 1127 additions and 25057 deletions

843
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -13766,54 +13766,24 @@ components:
example: Propose Changes
RunCheckpoint:
description: Serializable snapshot of execution state for crash recovery and resume.
description: >-
A checkpoint Fabro recorded for the run: when, at which node, and
the commit the workspace was checkpointed at, when it was committed.
type: object
required:
- timestamp
- current_node
- completed_nodes
- node_retries
- context_values
properties:
timestamp:
type: string
format: date-time
description: ISO 8601 timestamp when the checkpoint was created.
description: When the checkpoint was recorded.
current_node:
type: string
description: Identifier of the node being executed at checkpoint time.
completed_nodes:
type: array
items:
type: string
description: Identifiers of nodes that have completed execution.
node_retries:
type: object
additionalProperties:
type: integer
description: Map of node identifier to retry count.
context_values:
type: object
additionalProperties: true
description: Key-value context map accumulated during execution.
node_outcomes:
type: object
additionalProperties: true
description: Map of node identifier to outcome data for goal gate checks after resume.
next_node_id:
type: string
description: The node to resume execution at after this checkpoint.
description: The node the checkpoint was recorded for.
git_commit_sha:
type: string
description: SHA of the git commit created at this checkpoint.
loop_failure_signatures:
type: object
additionalProperties: true
description: Failure signature counts within the main loop.
restart_failure_signatures:
type: object
additionalProperties: true
description: Failure signature counts across loop_restart edges.
# ── Stage / Turn Schemas ─────────────────────────────────────────────

View file

@ -156,7 +156,6 @@ pub(crate) async fn print_run_summary_with_client(
printer: Printer,
) -> Result<()> {
let run_state = client.get_run_state(run_id).await?;
let checkpoint = run_state.current_checkpoint().cloned();
let conclusion = run_state.conclusion.clone();
let pr_url = run_state
.pull_request
@ -174,8 +173,7 @@ pub(crate) async fn print_run_summary_with_client(
styles,
printer,
);
let final_output =
resolve_final_output_with_client(client, run_id, checkpoint.as_ref()).await?;
let final_output = resolve_final_output_with_client(client, run_id, &run_state).await?;
print_final_output(final_output.as_deref(), styles, printer);
print_assets_with_client(client, run_id, styles, printer).await?;
Ok(())
@ -297,20 +295,19 @@ pub(crate) fn print_final_output(output: Option<&str>, styles: &Styles, printer:
}
}
/// The run's final output: the response of the last stage that produced
/// one, resolved from the blob table when the projection holds a blob
/// reference in its place.
async fn resolve_final_output_with_client(
client: &server_client::Client,
run_id: &RunId,
checkpoint: Option<&fabro_types::Checkpoint>,
run_state: &server_client::RunProjection,
) -> Result<Option<String>> {
let Some(checkpoint) = checkpoint else {
return Ok(None);
};
for node_id in checkpoint.completed_nodes.iter().rev() {
let key = format!("response.{node_id}");
let Some(serde_json::Value::String(response)) = checkpoint.context_values.get(&key) else {
continue;
};
let responses = run_state
.iter_stages()
.filter_map(|(_, stage)| stage.response.clone())
.collect::<Vec<_>>();
for response in responses.iter().rev() {
let Some(output) = resolve_response_string(client, run_id, response).await? else {
continue;
};

View file

@ -1,5 +1,5 @@
use chrono::{DateTime, Utc};
use fabro_workflow::event::RunNoticeLevel;
use fabro_types::RunNoticeLevel;
use pebble_coding_agent::events::{CodingEvent, ErrorKind as AgentErrorKind, LlmOutputKind};
use serde_json::Value;

View file

@ -1,6 +1,6 @@
use std::path::Path;
use fabro_workflow::event::RunNoticeLevel;
use fabro_types::RunNoticeLevel;
use super::renderer::ProgressRenderer;
use super::styles;

View file

@ -7,8 +7,7 @@
//! fork's `parallel.branch` delegates are the branches of the parallel
//! group, never stages of their own.
use fabro_types::run_event::RunNoticeLevel;
use fabro_types::{CodingAgentEvent, RunStreamItem, StageOutcome, StageTiming};
use fabro_types::{CodingAgentEvent, RunNoticeLevel, RunStreamItem, StageOutcome, StageTiming};
use serde_json::Value;
use super::event::{ProgressEvent, ProgressUsage, coding_progress_event};

View file

@ -43,12 +43,16 @@ pub(super) fn read_run_spec(run_dir: &Path) -> Value {
serde_json::to_value(run_state(run_dir).spec).expect("run spec should serialize")
}
/// The nodes whose stages succeeded, in the order they first ran.
pub(super) fn completed_nodes(run_dir: &Path) -> Vec<String> {
let state = run_state(run_dir);
let cp = state
.current_checkpoint()
.expect("run store checkpoint should exist");
cp.completed_nodes.clone()
let mut nodes = state
.iter_stages()
.filter(|(_, stage)| stage.state == fabro_types::StageState::Succeeded)
.map(|(stage_id, _)| stage_id.node_id().to_string())
.collect::<Vec<_>>();
nodes.dedup();
nodes
}
pub(super) fn has_event(run_dir: &Path, event_name: &str) -> bool {

View file

@ -533,9 +533,7 @@ mod tests {
use std::fs;
use std::path::Path;
use std::sync::Mutex;
use std::time::Duration;
use object_store::memory::InMemory;
use tempfile::TempDir;
use super::*;
@ -762,12 +760,7 @@ mod tests {
}
fn test_version_store() -> WorkflowVersionStore {
let database = fabro_store::test_support::test_database(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
None,
);
let database = fabro_store::test_support::test_database();
WorkflowVersionStore::new(database.blobs())
}

View file

@ -582,32 +582,6 @@ pub fn build_artifact_object_store(
build_artifact_object_store_with_server_secrets(settings, &server_secrets)
}
fn build_slatedb_store_with_server_secrets(
settings: &ServerNamespace,
server_secrets: &ServerSecrets,
) -> anyhow::Result<(Arc<dyn ObjectStore>, String, Duration, bool)> {
let prefix = settings.slatedb.prefix.clone();
let object_store = build_object_store_from_settings_with_lookup(
&settings.slatedb.store,
&|name| server_secrets.get(name),
None,
)?;
Ok((
object_store,
prefix,
settings.slatedb.flush_interval,
settings.slatedb.disk_cache,
))
}
#[cfg(test)]
fn build_slatedb_store(
settings: &ServerNamespace,
) -> anyhow::Result<(Arc<dyn ObjectStore>, String, Duration, bool)> {
let server_secrets = load_server_secrets_for_settings(settings)?;
build_slatedb_store_with_server_secrets(settings, &server_secrets)
}
/// Start the HTTP API server.
///
/// # Errors
@ -741,32 +715,10 @@ where
} else {
false
};
let (object_store, slatedb_prefix, flush_interval, disk_cache) =
build_slatedb_store_with_server_secrets(&resolved_server_settings, &server_secrets)?;
let cache_path = if disk_cache {
Some(storage.slatedb_cache_dir())
} else {
None
};
let store = Arc::new(fabro_store::Database::new(
object_store,
slatedb_prefix,
flush_interval,
cache_path,
Arc::new(fabro_store::BlobStore::new(database.clone_pool())),
Arc::new(fabro_store::RunSummaryStore::new(database.clone_pool())),
));
// Refresh tokens now live in SQLite. Nothing reads the old records and no
// reaper collects them any more, so clear them out once rather than
// leaving them in the object store forever. Pending authorization codes
// also moved to SQLite, but their old records are left in place: at most a
// handful exist at cutover, every binary rejects them within 60 seconds of
// issue, and nothing reads their keyspace again.
match store.retire_refresh_token_keyspace().await {
Ok(0) => {}
Ok(removed) => info!(removed, "Removed retired SlateDB refresh token records"),
Err(err) => warn!(error = %err, "Failed to remove retired SlateDB refresh token records"),
}
let (artifact_object_store, artifact_prefix) = build_artifact_object_store_with_server_secrets(
&resolved_server_settings,
&server_secrets,
@ -1216,9 +1168,9 @@ mod tests {
use super::{
SHUTDOWN_GRACE_PERIOD, ServeArgs, ServerTitlePhase, apply_effective_log_destination,
bind_tcp_host_with_fallback, build_local_object_store_with_preference,
build_object_store_from_settings_with_lookup, build_slatedb_store,
force_exit_after_shutdown, resolve_bind_request_from_server_settings, serve_overrides,
serve_until_shutdown, server_bind_title, server_title, spawn_shutdown_orchestrator_inner,
build_object_store_from_settings_with_lookup, force_exit_after_shutdown,
resolve_bind_request_from_server_settings, serve_overrides, serve_until_shutdown,
server_bind_title, server_title, spawn_shutdown_orchestrator_inner,
};
use crate::server::ResolvedAppStateSettings;
@ -1613,46 +1565,6 @@ strategy = "token"
drop(mem_store);
}
#[test]
fn build_slatedb_store_uses_configured_local_root() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("custom-slatedb");
let resolved = server_settings(&format!(
r#"
_version = 1
[server.slatedb.local]
root = "{}"
"#,
root.display()
))
.server;
let (_object_store, prefix, flush_interval, disk_cache) =
build_slatedb_store(&resolved).expect("slatedb store should build");
assert!(root.exists(), "configured SlateDB root should be created");
assert_eq!(prefix, "");
assert_eq!(flush_interval, Duration::from_millis(1));
assert!(!disk_cache);
}
#[test]
fn build_slatedb_store_returns_disk_cache_when_enabled() {
let resolved = server_settings(
r"
_version = 1
[server.slatedb]
disk_cache = true
",
)
.server;
let (_object_store, _prefix, _flush_interval, disk_cache) =
build_slatedb_store(&resolved).expect("slatedb store should build");
assert!(disk_cache);
}
#[test]
fn build_object_store_from_settings_uses_injected_static_credentials() {
let settings = ObjectStoreSettings::S3 {

View file

@ -1024,6 +1024,8 @@ pub struct AppState {
/// proceed in parallel. See `crate::run_files` for semantics.
pub(crate) files_in_flight: FilesInFlight,
pull_request_create_locks: KeyedMutex<RunId>,
/// One lock per run around a control request's check-and-append.
control_request_locks: KeyedMutex<RunId>,
parent_link_lock: AsyncMutex<()>,
pub(super) server_secrets: ServerSecrets,
@ -2556,6 +2558,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
global_event_tx,
files_in_flight: new_files_in_flight(),
pull_request_create_locks: KeyedMutex::new(),
control_request_locks: KeyedMutex::new(),
parent_link_lock: AsyncMutex::new(()),
server_secrets,
llm_source,
@ -4139,6 +4142,9 @@ async fn append_control_request(
RunControlAction::Pause => RunLifecycleKind::PauseRequested,
RunControlAction::Unpause => RunLifecycleKind::UnpauseRequested,
};
// The check and the append are one step under the run's control lock,
// so two concurrent requests for the same control record it once.
let _guard = state.control_request_locks.lock(run_id).await;
if action == RunControlAction::Cancel {
// A cancel already pending is not asked for twice.
let pending = run_records::projection(state, run_id)

View file

@ -1009,14 +1009,23 @@ mod tests {
#[cfg(test)]
mod retrieve_sandbox_tests {
use std::collections::BTreeMap;
use axum::Router;
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use fabro_sandbox::test_support::local_sandbox_id;
use fabro_types::{Graph, RunId, WorkflowSettings, test_support};
use fabro_types::{RunId, WorkflowPath, WorkflowVersion};
use serde_json::{Value, json};
use tower::ServiceExt;
use crate::test_support::{build_test_router, test_app_state};
use crate::test_support::{build_test_router, test_app_state, test_register_workflow_version};
const MINIMAL_DOT: &str = r#"digraph Test {
graph [goal="Test"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#;
fn req_get(uri: &str) -> Request<Body> {
Request::builder()
@ -1026,14 +1035,6 @@ mod retrieve_sandbox_tests {
.expect("sandbox details GET request should build")
}
fn req_post(uri: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri(uri)
.body(Body::empty())
.expect("sandbox POST request should build")
}
async fn body_json(response: axum::response::Response) -> Value {
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
@ -1041,86 +1042,38 @@ mod retrieve_sandbox_tests {
serde_json::from_slice(&bytes).expect("response body should be valid JSON")
}
async fn append_run_created(run_store: &fabro_store::RunDatabase, run_id: &RunId) {
let payload = fabro_store::EventPayload::new(
json!({
"id": "evt-run-created",
"ts": "2026-05-09T11:59:00Z",
"run_id": run_id,
"event": "run.created",
"properties": {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"provenance": test_support::test_run_provenance(),
},
}),
run_id,
)
.expect("run.created payload should validate");
run_store.append_event(&payload).await.unwrap();
}
async fn append_sandbox_initialized(
run_store: &fabro_store::RunDatabase,
run_id: &RunId,
provider: &str,
) {
append_sandbox_initialized_in(
run_store,
run_id,
provider,
&format!("{provider}:sandbox-id"),
"/workspace",
)
.await;
}
/// A local sandbox reconnects by the id the Host provider derives from
/// its working directory, so a test that reaches one records an
/// existing directory under the id fabro would have written for it.
async fn append_sandbox_initialized_in(
run_store: &fabro_store::RunDatabase,
run_id: &RunId,
provider: &str,
id: &str,
working_directory: &str,
) {
let payload = fabro_store::EventPayload::new(
json!({
"id": "evt-sandbox-init",
"ts": "2026-05-09T12:00:00Z",
"run_id": run_id,
"event": "sandbox.initialized",
"properties": {
"provider": provider,
"id": id,
"working_directory": working_directory,
},
}),
run_id,
)
.expect("sandbox.initialized payload should validate");
run_store.append_event(&payload).await.unwrap();
}
async fn append_sandbox_failed(run_store: &fabro_store::RunDatabase, run_id: &RunId) {
let payload = fabro_store::EventPayload::new(
json!({
"id": "evt-sandbox-failed",
"ts": "2026-05-09T12:00:00Z",
"run_id": run_id,
"event": "sandbox.failed",
"properties": {
"provider": "docker",
"error": "Docker daemon unavailable",
"causes": ["connection refused"],
"duration_ms": 42,
},
}),
run_id,
)
.expect("sandbox.failed payload should validate");
run_store.append_event(&payload).await.unwrap();
/// Create a run through the API. A run that has not started has a
/// planned sandbox and nothing else.
async fn create_run(app: &Router) -> RunId {
let entrypoint = WorkflowPath::new("workflow.fabro").expect("entrypoint should parse");
let files = BTreeMap::from([(entrypoint.clone(), MINIMAL_DOT.to_string())]);
let version = WorkflowVersion::new(entrypoint, files, BTreeMap::new())
.expect("workflow version should build");
let workflow_version_id = test_register_workflow_version(app, &version, None).await;
let intent = json!({
"workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
"args": {}
});
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/runs")
.header("content-type", "application/json")
.body(Body::from(intent.to_string()))
.expect("run creation request should build"),
)
.await
.expect("run creation should route");
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_json(response).await;
body["id"]
.as_str()
.expect("run creation response should carry the run id")
.parse()
.expect("run id should parse")
}
async fn assert_sandbox_not_created_response(response: axum::response::Response) {
@ -1156,15 +1109,8 @@ mod retrieve_sandbox_tests {
#[tokio::test]
async fn planned_sandbox_returns_404_from_details_endpoint() {
let state = test_app_state();
let app = build_test_router(state.clone());
let run_id = RunId::new();
let run_store = state
.store_ref()
.create_run(&run_id)
.await
.expect("test run should be creatable");
append_run_created(&run_store, &run_id).await;
let app = build_test_router(test_app_state());
let run_id = create_run(&app).await;
let response = app
.oneshot(req_get(&format!("/api/v1/runs/{run_id}/sandbox")))
.await
@ -1174,15 +1120,8 @@ mod retrieve_sandbox_tests {
#[tokio::test]
async fn planned_sandbox_rejects_live_operations() {
let state = test_app_state();
let app = build_test_router(state.clone());
let run_id = RunId::new();
let run_store = state
.store_ref()
.create_run(&run_id)
.await
.expect("test run should be creatable");
append_run_created(&run_store, &run_id).await;
let app = build_test_router(test_app_state());
let run_id = create_run(&app).await;
for uri in [
format!("/api/v1/runs/{run_id}/sandbox/services"),
@ -1193,118 +1132,4 @@ mod retrieve_sandbox_tests {
assert_sandbox_not_created_response(response).await;
}
}
#[tokio::test]
async fn failed_sandbox_rejects_live_operations() {
let state = test_app_state();
let app = build_test_router(state.clone());
let run_id = RunId::new();
let run_store = state
.store_ref()
.create_run(&run_id)
.await
.expect("test run should be creatable");
append_run_created(&run_store, &run_id).await;
append_sandbox_failed(&run_store, &run_id).await;
for uri in [
format!("/api/v1/runs/{run_id}/sandbox/services"),
format!("/api/v1/runs/{run_id}/sandbox/files?path=/workspace"),
format!("/api/v1/runs/{run_id}/sandbox/file?path=/workspace/README.md"),
] {
let response = app.clone().oneshot(req_get(&uri)).await.unwrap();
assert_sandbox_not_created_response(response).await;
}
}
#[tokio::test]
async fn local_sandbox_returns_provider_neutral_details() {
let state = test_app_state();
let app = build_test_router(state.clone());
let run_id = RunId::new();
let run_store = state
.store_ref()
.create_run(&run_id)
.await
.expect("test run should be creatable");
append_run_created(&run_store, &run_id).await;
let workspace = tempfile::tempdir().expect("scratch directory");
let working_directory = workspace.path().to_str().expect("utf-8").to_owned();
let id = local_sandbox_id(workspace.path()).await;
append_sandbox_initialized_in(&run_store, &run_id, "local", &id, &working_directory).await;
let response = app
.oneshot(req_get(&format!("/api/v1/runs/{run_id}/sandbox")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["sandbox"]["provider"], "local");
assert_eq!(body["sandbox"]["runtime"]["id"], id);
assert_eq!(
body["sandbox"]["runtime"]["working_directory"],
working_directory
);
assert_eq!(body["status"]["state"], "running");
assert_eq!(body["status"]["workspace_ownership"], "designated");
assert!(
body["status"]["id"]
.as_str()
.is_some_and(|id| id.starts_with("host-dir-")),
"{}",
body["status"]["id"]
);
assert!(body.get("state").is_none(), "the status is not flattened");
assert!(body.get("identifier").is_none());
}
#[tokio::test]
async fn local_sandbox_vnc_returns_501() {
let state = test_app_state();
let app = build_test_router(state.clone());
let run_id = RunId::new();
let run_store = state
.store_ref()
.create_run(&run_id)
.await
.expect("test run should be creatable");
append_run_created(&run_store, &run_id).await;
let workspace = tempfile::tempdir().expect("scratch directory");
append_sandbox_initialized_in(
&run_store,
&run_id,
"local",
&local_sandbox_id(workspace.path()).await,
workspace.path().to_str().expect("utf-8"),
)
.await;
let response = app
.oneshot(req_post(&format!("/api/v1/runs/{run_id}/sandbox/vnc")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
}
#[tokio::test]
async fn docker_sandbox_vnc_returns_501_without_reconnect() {
let state = test_app_state();
let app = build_test_router(state.clone());
let run_id = RunId::new();
let run_store = state
.store_ref()
.create_run(&run_id)
.await
.expect("test run should be creatable");
append_run_created(&run_store, &run_id).await;
append_sandbox_initialized(&run_store, &run_id, "docker").await;
let response = app
.oneshot(req_post(&format!("/api/v1/runs/{run_id}/sandbox/vnc")))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
}
}

View file

@ -15,6 +15,9 @@ use fabro_interview::{
AnswerValue, WorkerControlDeliveryFrame, WorkerControlEnvelope, WorkerControlMessage,
};
use fabro_llm::lithos_catalog::Catalog;
use fabro_store::platform_records::{
PlatformRecord, RunLifecycleKind, RunLifecycleRecord, StoredPlatformRecord,
};
use fabro_types::settings::ServerAuthMethod;
use fabro_types::settings::run::{ApprovalMode, RunMode};
use fabro_types::{
@ -3255,15 +3258,7 @@ async fn post_runs_run_intent_derives_workflow_slug_from_immutable_entrypoint()
)
.await;
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let projection = state
.stores
.runs
.open_run_reader(&run_id)
.await
.unwrap()
.state()
.await
.unwrap();
let projection = state.load_run_projection(&run_id).await.unwrap();
assert_eq!(
projection.spec.workflow_slug.as_deref(),
@ -3325,16 +3320,8 @@ file = "goal.md"
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
assert_eq!(body["lifecycle"]["status"]["kind"], "submitted");
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
let events = run_store.list_events().await.unwrap();
assert_eq!(
events
.iter()
.map(|event| event.event.event_name())
.collect::<Vec<_>>(),
vec!["run.created", "run.submitted"]
);
let projection = run_store.state().await.unwrap();
assert_created_then_submitted(&platform_records(&state, run_id).await);
let projection = state.load_run_projection(&run_id).await.unwrap();
assert_eq!(
projection.spec.workflow_version_id,
Some(workflow_version_id)
@ -3425,16 +3412,8 @@ async fn post_runs_run_intent_creates_submitted_none_target_without_git_projecti
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
assert_eq!(body["lifecycle"]["status"]["kind"], "submitted");
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
let events = run_store.list_events().await.unwrap();
assert_eq!(
events
.iter()
.map(|event| event.event.event_name())
.collect::<Vec<_>>(),
vec!["run.created", "run.submitted"]
);
let projection = run_store.state().await.unwrap();
assert_created_then_submitted(&platform_records(&state, run_id).await);
let projection = state.load_run_projection(&run_id).await.unwrap();
assert_eq!(
projection.spec.target,
Some(fabro_types::RunTarget::None {})
@ -3477,8 +3456,7 @@ async fn post_runs_run_intent_args_true_override_resolved_settings_without_start
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
assert_eq!(body["lifecycle"]["status"]["kind"], "submitted");
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
let projection = run_store.state().await.unwrap();
let projection = state.load_run_projection(&run_id).await.unwrap();
assert_eq!(projection.spec.settings.run.execution.mode, RunMode::DryRun);
assert_eq!(
projection.spec.settings.run.execution.approval,
@ -3563,19 +3541,11 @@ async fn post_runs_run_intent_dry_run_uses_configured_target_provider() {
)
.await;
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let projection = state
.stores
.runs
.open_run_reader(&run_id)
.await
.unwrap()
.state()
.await
.unwrap();
let projection = state.load_run_projection(&run_id).await.unwrap();
assert_eq!(projection.spec.settings.run.execution.mode, RunMode::DryRun);
assert_eq!(
serde_json::to_value(projection.spec.target.unwrap()).unwrap(),
serde_json::to_value(projection.spec.target.clone().unwrap()).unwrap(),
target
);
}
@ -3690,20 +3660,8 @@ preserve = true
.parse::<RunId>()
.unwrap();
let omitted_id = omitted["id"].as_str().unwrap().parse::<RunId>().unwrap();
let explicit_false_store = state
.stores
.runs
.open_run_reader(&explicit_false_id)
.await
.unwrap();
let explicit_false = explicit_false_store.state().await.unwrap();
let omitted_store = state
.stores
.runs
.open_run_reader(&omitted_id)
.await
.unwrap();
let omitted = omitted_store.state().await.unwrap();
let explicit_false = state.load_run_projection(&explicit_false_id).await.unwrap();
let omitted = state.load_run_projection(&omitted_id).await.unwrap();
assert_eq!(
explicit_false.spec.settings.run.execution.mode,
@ -3763,16 +3721,8 @@ async fn post_runs_run_intent_canonicalizes_and_persists_a_local_folder_target()
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
assert_eq!(body["lifecycle"]["status"]["kind"], "submitted");
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
let events = run_store.list_events().await.unwrap();
assert_eq!(
events
.iter()
.map(|event| event.event.event_name())
.collect::<Vec<_>>(),
vec!["run.created", "run.submitted"]
);
let projection = run_store.state().await.unwrap();
assert_created_then_submitted(&platform_records(&state, run_id).await);
let projection = state.load_run_projection(&run_id).await.unwrap();
assert_eq!(
projection.spec.target,
Some(fabro_types::RunTarget::Folder {
@ -3872,15 +3822,7 @@ async fn post_runs_run_intent_accepts_automatic_pull_requests_for_configured_doc
)
.await;
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let projection = state
.stores
.runs
.open_run_reader(&run_id)
.await
.unwrap()
.state()
.await
.unwrap();
let projection = state.load_run_projection(&run_id).await.unwrap();
assert_eq!(
projection.spec.settings.run.environment.provider,
@ -3919,16 +3861,8 @@ async fn post_runs_run_intent_observes_folder_git_metadata_without_a_remote_call
let body = post_run_intent(&app, folder_intent(workflow_version_id, canonical)).await;
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let projection = state
.stores
.runs
.open_run_reader(&run_id)
.await
.unwrap()
.state()
.await
.unwrap();
let git = projection.spec.git.unwrap();
let projection = state.load_run_projection(&run_id).await.unwrap();
let git = projection.spec.git.clone().unwrap();
assert_eq!(git.origin_url, "https://github.com/acme/widgets");
assert!(!git.branch.is_empty());
@ -4177,15 +4111,7 @@ async fn post_runs_run_intent_accepts_none_target_with_ready_daytona_environment
.await;
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let projection = state
.stores
.runs
.open_run_reader(&run_id)
.await
.unwrap()
.state()
.await
.unwrap();
let projection = state.load_run_projection(&run_id).await.unwrap();
assert_eq!(
projection.spec.target,
Some(fabro_types::RunTarget::None {})
@ -4518,24 +4444,14 @@ async fn create_run_from_intent_helper_persists_automation_version_and_exact_tar
.unwrap()
.unwrap();
assert_eq!(summary.automation, Some(automation.clone()));
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
let projection = run_store.state().await.unwrap();
let projection = state.load_run_projection(&run_id).await.unwrap();
assert_eq!(
projection.spec.workflow_version_id,
Some(workflow_version_id)
);
assert_eq!(projection.spec.target, Some(target));
assert_eq!(projection.spec.automation, Some(automation));
assert_eq!(
run_store
.list_events()
.await
.unwrap()
.iter()
.map(|event| event.event.event_name())
.collect::<Vec<_>>(),
["run.created", "run.submitted"]
);
assert_created_then_submitted(&platform_records(&state, run_id).await);
}
#[tokio::test]
@ -4635,13 +4551,50 @@ async fn wait_for_mock_hits(mock: &httpmock::Mock<'_>, expected: usize) {
}
async fn title_update_event_count(state: &AppState, run_id: RunId) -> usize {
let run_store = state.stores.runs.open_run(&run_id).await.unwrap();
run_store
.list_events()
platform_records(state, run_id)
.await
.iter()
.filter(|stored| matches!(stored.record, PlatformRecord::RunTitle(_)))
.count()
}
/// Every platform record of the run, in seq order.
async fn platform_records(state: &AppState, run_id: RunId) -> Vec<StoredPlatformRecord> {
state
.stores
.run_summaries
.platform_records()
.read(&run_id)
.await
.unwrap()
.into_iter()
.filter(|event| event.event.event_name() == "run.title.updated")
}
/// The records a freshly created run holds: `run.created`, then the
/// `submitted` lifecycle transition.
fn assert_created_then_submitted(records: &[StoredPlatformRecord]) {
assert_eq!(
records
.iter()
.map(|stored| stored.record.kind().to_string())
.collect::<Vec<_>>(),
["run.created", "run.lifecycle"]
);
let PlatformRecord::RunLifecycle(lifecycle) = &records[1].record else {
panic!("the second record should be a lifecycle transition");
};
assert_eq!(lifecycle.transition, RunLifecycleKind::Submitted);
}
/// The run's lifecycle transitions of `kind`.
fn lifecycle_transition_count(records: &[StoredPlatformRecord], kind: RunLifecycleKind) -> usize {
records
.iter()
.filter(|stored| {
matches!(
&stored.record,
PlatformRecord::RunLifecycle(lifecycle) if lifecycle.transition == kind
)
})
.count()
}
@ -4864,29 +4817,12 @@ fn test_priced_usage(
)
}
async fn append_raw_run_event(
state: &Arc<AppState>,
run_id: RunId,
seq_hint: &str,
ts: &str,
event: &str,
properties: serde_json::Value,
node_id: Option<&str>,
) {
let run_store = state.stores.runs.open_run(&run_id).await.unwrap();
let payload = fabro_store::EventPayload::new(
json!({
"id": format!("evt-{seq_hint}"),
"ts": ts,
"run_id": run_id,
"event": event,
"node_id": node_id,
"properties": properties,
}),
&run_id,
)
.unwrap();
run_store.append_event(&payload).await.unwrap();
/// Seed the lifecycle transitions a worker would have recorded, so the
/// run's projection stands where the test needs it.
async fn seed_lifecycle(state: &AppState, run_id: RunId, records: Vec<RunLifecycleRecord>) {
for record in records {
run_records::lifecycle(state, run_id, record).await.unwrap();
}
}
fn github_token_settings() -> ServerSettings {
@ -6473,7 +6409,7 @@ async fn submit_pending_interview_answer_rejects_invalid_answer_shape() {
text: "Approve deploy?".to_string(),
stage: "gate".to_string(),
question_type: QuestionType::MultipleChoice,
options: vec![fabro_types::run_event::InterviewOption {
options: vec![fabro_types::InterviewOption {
key: "approve".to_string(),
label: "Approve".to_string(),
description: None,
@ -6566,7 +6502,7 @@ fn answer_from_typed_selected_request_validates_and_attaches_option() {
text: "Choose one.".to_string(),
stage: "gate".to_string(),
question_type: QuestionType::MultipleChoice,
options: vec![fabro_types::run_event::InterviewOption {
options: vec![fabro_types::InterviewOption {
key: "approve".to_string(),
label: "Approve".to_string(),
description: None,
@ -6600,13 +6536,13 @@ fn answer_from_typed_multi_selected_request_validates_option_keys() {
stage: "gate".to_string(),
question_type: QuestionType::MultiSelect,
options: vec![
fabro_types::run_event::InterviewOption {
fabro_types::InterviewOption {
key: "approve".to_string(),
label: "Approve".to_string(),
description: None,
preview: None,
},
fabro_types::run_event::InterviewOption {
fabro_types::InterviewOption {
key: "notify".to_string(),
label: "Notify".to_string(),
description: None,
@ -6856,12 +6792,13 @@ async fn unlink_run_pull_request_appends_event_and_clears_projected_state() {
assert!(state_body["pull_request"].is_null());
let run_id = run_id.parse::<RunId>().unwrap();
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
let events = run_store.list_events().await.unwrap();
assert!(events.iter().any(|event| {
event.event.event_name() == "pull_request.unlinked"
&& event.event.properties().unwrap()["pull_request"]["html_url"]
== "https://github.com/acme/widgets/pull/42"
let records = platform_records(&state, run_id).await;
assert!(records.iter().any(|stored| {
matches!(
&stored.record,
PlatformRecord::PullRequestUnlinked(unlinked)
if unlinked.link().html_url() == "https://github.com/acme/widgets/pull/42"
)
}));
}
@ -7068,192 +7005,6 @@ async fn list_run_events_returns_paginated_json() {
assert!(body["meta"]["has_more"].is_boolean());
}
#[tokio::test]
async fn list_run_events_rejects_cursor_for_opposite_order() {
let app = crate::test_support::build_test_router(test_app_state());
let run_id = RunId::new();
let cases = [
(
format!("/runs/{run_id}/events?order=desc&since_seq=2"),
"since_seq cannot be combined with order=desc; use before_seq instead.",
),
(
format!("/runs/{run_id}/events?before_seq=2"),
"before_seq requires order=desc.",
),
];
for (path, expected_detail) in cases {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(api(&path))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response, StatusCode::BAD_REQUEST).await;
assert_eq!(body["errors"][0]["detail"], expected_detail);
}
}
#[tokio::test]
async fn append_run_event_rejects_run_id_mismatch() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let req = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(intent_body(&app, MINIMAL_DOT).await)
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap();
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/events")))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"id": "evt-test",
"ts": "2026-03-27T12:00:00Z",
"run_id": fixtures::RUN_64.to_string(),
"event": "run.submitted",
"properties": {}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_status!(response, StatusCode::BAD_REQUEST).await;
}
#[tokio::test]
async fn append_run_event_accepts_a_body_larger_than_two_mib() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = create_run(&app, MINIMAL_DOT).await;
let payload = json!({
"id": "evt-large-agent-output",
"ts": "2026-08-24T12:00:00Z",
"run_id": run_id,
"event": "agent.tool.completed",
"properties": {
"stage": "code",
"visit": 1,
"session_id": "ses_large",
"timestamp": "2026-08-24T12:00:00.000Z",
"event": {
"ToolCallCompleted": {
"tool_name": "shell",
"tool_call_id": "call-large",
"output": "x".repeat(2 * 1024 * 1024),
"is_error": false,
"output_bytes_observed": 2 * 1024 * 1024,
"output_bytes_retained": 2 * 1024 * 1024,
"output_bytes_omitted": 0
}
}
}
})
.to_string();
assert!(payload.len() > 2 * 1024 * 1024);
assert!(payload.len() < 3 * 1024 * 1024);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/events")))
.header("content-type", "application/json")
.body(Body::from(payload))
.unwrap(),
)
.await
.unwrap();
assert_status!(response, StatusCode::OK).await;
}
#[tokio::test]
async fn append_run_event_rejects_a_body_larger_than_three_mib() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = create_run(&app, MINIMAL_DOT).await;
let payload = json!({
"id": "evt-oversized-agent-output",
"ts": "2026-08-24T12:00:00Z",
"run_id": run_id,
"event": "agent.tool.completed",
"properties": {
"tool_name": "shell",
"tool_call_id": "call-oversized",
"output": "x".repeat(3 * 1024 * 1024),
"is_error": false,
"visit": 1
}
})
.to_string();
assert!(payload.len() > 3 * 1024 * 1024);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/events")))
.header("content-type", "application/json")
.body(Body::from(payload))
.unwrap(),
)
.await
.unwrap();
assert_status!(response, StatusCode::PAYLOAD_TOO_LARGE).await;
}
#[tokio::test]
async fn append_run_event_rejects_reserved_archive_event() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = create_run(&app, MINIMAL_DOT).await;
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/events")))
.header("content-type", "application/json")
.body(Body::from(
json!({
"id": "evt-run-archived",
"ts": "2026-04-19T12:00:00Z",
"run_id": run_id,
"event": "run.archived",
"properties": {
"actor": null
}
})
.to_string(),
))
.unwrap();
let response = app.oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::BAD_REQUEST).await;
assert!(
body["errors"][0]["detail"].as_str().is_some_and(|message| {
message
.contains("run.archived must be performed through its dedicated operation endpoint")
}),
"expected dedicated-operation rejection, got: {body}"
);
}
#[tokio::test]
async fn get_checkpoint_returns_null_initially() {
let state = test_app_state();
@ -7445,15 +7196,7 @@ async fn create_run_persists_run_spec() {
.await
.parse::<RunId>()
.unwrap();
let run_state = state
.stores
.runs
.open_run_reader(&run_id)
.await
.unwrap()
.state()
.await
.unwrap();
let run_state = state.load_run_projection(&run_id).await.unwrap();
assert_eq!(run_state.spec.graph.name, "Test");
}
@ -7487,15 +7230,7 @@ async fn create_run_keeps_missing_project_and_workflow_names_absent() {
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let run_state = state
.stores
.runs
.open_run_reader(&run_id)
.await
.unwrap()
.state()
.await
.unwrap();
let run_state = state.load_run_projection(&run_id).await.unwrap();
assert_eq!(run_state.spec.settings.project.name.as_deref(), None);
assert_eq!(run_state.spec.settings.workflow.name.as_deref(), None);
@ -7530,12 +7265,9 @@ async fn worker_token_accepts_run_scoped_routes_and_falls_back_to_user_jwt() {
let other_run_id = create_run_with_bearer(&app, &user_jwt).await;
let other_worker_token = issue_test_worker_token(&other_run_id);
let blob_hash = state
.stores
.runs
.open_run(&run_id)
.await
.unwrap()
.write_blob(b"preloaded blob")
.store_ref()
.blobs()
.write(b"preloaded blob")
.await
.unwrap();
@ -7568,33 +7300,6 @@ async fn worker_token_accepts_run_scoped_routes_and_falls_back_to_user_jwt() {
assert_status!(response, StatusCode::OK).await;
}
let append_body = serde_json::to_vec(&serde_json::json!({
"id": "evt-run-notice",
"ts": "2026-04-23T12:00:00Z",
"event": "run.notice",
"run_id": run_id.to_string(),
"properties": {
"level": "info",
"code": "worker",
"message": "hello"
}
}))
.unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(api(&format!("/runs/{run_id}/events")))
.header(header::AUTHORIZATION, format!("Bearer {worker_token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(append_body))
.unwrap(),
)
.await
.unwrap();
assert_status!(response, StatusCode::OK).await;
let response = app
.clone()
.oneshot(bearer_request(
@ -7845,7 +7550,6 @@ async fn run_tools_worker_cannot_call_user_only_non_mcp_routes() {
for (method, path) in [
(Method::POST, format!("/runs/{target_run_id}/approve")),
(Method::POST, format!("/runs/{target_run_id}/deny")),
(Method::GET, format!("/runs/{target_run_id}/timeline")),
] {
let response = app
.clone()
@ -8239,12 +7943,7 @@ async fn start_run_transitions_to_runnable() {
assert_eq!(body["title"], "Test");
let status = state
.stores
.runs
.open_run_reader(&run_id.parse::<RunId>().unwrap())
.await
.unwrap()
.state()
.load_run_projection(&run_id.parse::<RunId>().unwrap())
.await
.unwrap()
.status;
@ -8649,7 +8348,7 @@ async fn steer_with_active_non_steerable_session_returns_conflict() {
}
#[tokio::test]
async fn steer_interrupt_without_active_steerable_session_returns_conflict() {
async fn steer_with_interrupt_returns_unsupported() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
@ -8663,79 +8362,15 @@ async fn steer_interrupt_without_active_steerable_session_returns_conflict() {
.body(Body::from(r#"{"text":"try again","interrupt":true}"#))
.unwrap();
// A steer with an interrupt is not a control a Petri run takes.
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
let body = body_json(response.into_body()).await;
assert_eq!(body["errors"][0]["code"], "no_active_steerable_session");
assert_eq!(body["errors"][0]["code"], "interrupt_unsupported");
}
#[tokio::test]
async fn interrupt_with_active_steerable_session_forwards_interrupt() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let stage_id = StageId::new("agent", 1);
let (transport, mut control_rx) = worker_transport_with_receiver(run_id).await;
let _temp_dir = insert_running_control_run(&state, run_id, Some(transport));
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
runs.get_mut(&run_id)
.unwrap()
.active_steerable_stages
.insert(stage_id, "session-a".to_string());
}
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/interrupt")))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_status!(response, StatusCode::ACCEPTED).await;
let envelope = recv_worker_control_envelope(&mut control_rx).await;
assert!(matches!(
envelope.message,
WorkerControlMessage::Interrupt {
actor: Principal::User(_),
}
));
}
#[tokio::test]
async fn steer_interrupt_with_active_steerable_session_forwards_combined_control_message() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
let stage_id = StageId::new("agent", 1);
let (transport, mut control_rx) = worker_transport_with_receiver(run_id).await;
let _temp_dir = insert_running_control_run(&state, run_id, Some(transport));
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
runs.get_mut(&run_id)
.unwrap()
.active_steerable_stages
.insert(stage_id, "session-a".to_string());
}
let req = Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/steer")))
.header("content-type", "application/json")
.body(Body::from(r#"{"text":"try again","interrupt":true}"#))
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_status!(response, StatusCode::ACCEPTED).await;
let envelope = recv_worker_control_envelope(&mut control_rx).await;
assert!(matches!(
envelope.message,
WorkerControlMessage::InterruptThenSteer { ref text, .. } if text == "try again"
));
}
#[tokio::test]
async fn interrupt_terminal_run_returns_run_not_interruptible() {
async fn interrupt_returns_unsupported() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = fixtures::RUN_1;
@ -8762,10 +8397,12 @@ async fn interrupt_terminal_run_returns_run_not_interruptible() {
.body(Body::empty())
.unwrap();
// An interrupt is not a control a Petri run takes: the answer is
// `unsupported`, whatever the run's state.
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
let body = body_json(response.into_body()).await;
assert_eq!(body["errors"][0]["code"], "run_not_interruptible");
assert_eq!(body["errors"][0]["code"], "interrupt_unsupported");
}
#[tokio::test]
@ -9588,16 +9225,8 @@ level = "debug"
.and_then(|run| run.run_dir.clone())
.expect("run_dir should be recorded")
};
let run_spec = state
.stores
.runs
.open_run_reader(&run_id)
.await
.unwrap()
.state()
.await
.unwrap()
.spec;
let projection = state.load_run_projection(&run_id).await.unwrap();
let run_spec = &projection.spec;
let resolved_run = &run_spec.settings.run;
// Verify a sampling of the persisted v2 settings, including inherited
@ -9685,8 +9314,7 @@ async fn cancel_runnable_run_succeeds() {
"cancelled run should preserve the failed lifecycle status"
);
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
let status = run_store.state().await.unwrap().status;
let status = state.load_run_projection(&run_id).await.unwrap().status;
assert_eq!(status, RunStatus::Failed {
reason: FailureReason::Cancelled,
});
@ -9951,14 +9579,10 @@ async fn repeated_cancel_request_arms_one_watchdog_and_persists_one_intent() {
assert_status!(first_response.unwrap(), StatusCode::ACCEPTED).await;
assert_status!(second_response.unwrap(), StatusCode::ACCEPTED).await;
let run_store = state.stores.runs.open_run_reader(&run_id).await.unwrap();
let request_count = run_store
.list_events()
.await
.unwrap()
.iter()
.filter(|event| event.event.event_name() == "run.cancel.requested")
.count();
let request_count = lifecycle_transition_count(
&platform_records(&state, run_id).await,
RunLifecycleKind::CancelRequested,
);
assert_eq!(request_count, 1);
tokio::time::pause();
@ -10067,35 +9691,13 @@ async fn pause_run_immediately_pauses_blocked_run() {
let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
let run_id = run_id_str.parse::<RunId>().unwrap();
append_raw_run_event(
&state,
run_id,
"pause-starting",
"2026-04-19T11:59:58Z",
"run.starting",
json!({}),
None,
)
.await;
append_raw_run_event(
&state,
run_id,
"pause-running",
"2026-04-19T11:59:59Z",
"run.running",
json!({}),
None,
)
.await;
append_raw_run_event(
&state,
run_id,
"pause-blocked",
"2026-04-19T12:00:00Z",
"run.blocked",
json!({ "blocked_reason": "human_input_required" }),
None,
)
seed_lifecycle(&state, run_id, vec![
run_records::transition(RunLifecycleKind::Starting, RunStatus::Starting),
run_records::transition(RunLifecycleKind::Running, RunStatus::Running),
run_records::transition(RunLifecycleKind::Blocked, RunStatus::Blocked {
blocked_reason: BlockedReason::HumanInputRequired,
}),
])
.await;
{
@ -10180,45 +9782,14 @@ async fn unpause_run_returns_blocked_when_human_gate_is_still_unresolved() {
let run_id_str = create_and_start_run(&app, MINIMAL_DOT).await;
let run_id = run_id_str.parse::<RunId>().unwrap();
append_raw_run_event(
&state,
run_id,
"paused-blocked-starting",
"2026-04-19T11:59:58Z",
"run.starting",
json!({}),
None,
)
.await;
append_raw_run_event(
&state,
run_id,
"paused-blocked-running",
"2026-04-19T11:59:59Z",
"run.running",
json!({}),
None,
)
.await;
append_raw_run_event(
&state,
run_id,
"paused-blocked-paused",
"2026-04-19T12:00:00Z",
"run.paused",
json!({}),
None,
)
.await;
append_raw_run_event(
&state,
run_id,
"paused-blocked-status",
"2026-04-19T12:00:01Z",
"run.blocked",
json!({ "blocked_reason": "human_input_required" }),
None,
)
seed_lifecycle(&state, run_id, vec![
run_records::transition(RunLifecycleKind::Starting, RunStatus::Starting),
run_records::transition(RunLifecycleKind::Running, RunStatus::Running),
RunLifecycleRecord::new(RunLifecycleKind::Paused),
run_records::transition(RunLifecycleKind::Blocked, RunStatus::Blocked {
blocked_reason: BlockedReason::HumanInputRequired,
}),
])
.await;
{

View file

@ -3,7 +3,6 @@ use std::path::{Path, PathBuf};
#[cfg(test)]
use std::sync::Mutex;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use anyhow::Context as _;
use axum::body::{self, Body};
@ -566,12 +565,7 @@ pub fn test_app_state_with_store(
pub fn test_store_bundle() -> (Arc<Database>, ArtifactStore) {
let object_store: Arc<dyn object_store::ObjectStore> = Arc::new(MemoryObjectStore::new());
let store = Arc::new(store_test_support::test_database(
Arc::clone(&object_store),
"",
Duration::from_millis(1),
None,
));
let store = Arc::new(store_test_support::test_database());
let artifact_store = ArtifactStore::new(object_store, "artifacts");
(store, artifact_store)
}

View file

@ -1,6 +1,5 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
@ -22,12 +21,7 @@ use crate::helpers::{response_json, response_status, settings_from_toml};
fn test_app(source: &str) -> (axum::Router, Arc<AppState>) {
let settings = settings_from_toml(source);
let object_store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
let store = Arc::new(fabro_store::test_support::test_database(
Arc::clone(&object_store),
"",
Duration::from_millis(1),
None,
));
let store = Arc::new(fabro_store::test_support::test_database());
let artifact_store = ArtifactStore::new(object_store, "artifacts");
let auth_mode =
resolve_auth_mode_with_lookup(&settings.server_settings.server, |name| match name {

View file

@ -1,5 +1,4 @@
use std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
@ -19,12 +18,7 @@ use crate::helpers::{body_json, settings_from_toml};
fn test_app(source: &str) -> (axum::Router, Arc<AppState>) {
let settings = settings_from_toml(source);
let object_store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
let store = Arc::new(fabro_store::test_support::test_database(
Arc::clone(&object_store),
"",
Duration::from_millis(1),
None,
));
let store = Arc::new(fabro_store::test_support::test_database());
let artifact_store = ArtifactStore::new(object_store, "artifacts");
let auth_mode =
resolve_auth_mode_with_lookup(&settings.server_settings.server, |name| match name {

View file

@ -7,24 +7,12 @@
//! unit tests on the sandbox-git helpers and by `stitch_file_diff` tests
//! in `run_files.rs`.
use std::sync::Arc;
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_server::test_support::test_app_state_with_store;
use fabro_store::{ArtifactStore, Database};
use fabro_types::{
Graph, PetriAdmission, RunId, SandboxProviderKind, WorkflowSettings, test_support,
};
use fabro_workflow::event as workflow_event;
use fabro_workflow::run_status::SuccessReason;
use object_store::memory::InMemory as MemoryObjectStore;
use tower::ServiceExt;
use crate::helpers::{
MINIMAL_DOT, api, minimal_intent_json, response_json, response_status, test_app_state,
test_settings,
};
fn files_url(run_id: &str) -> String {
@ -39,121 +27,6 @@ fn files_url_with_scope(run_id: &str, scope: &str) -> String {
format!("{}?scope={scope}", files_url(run_id))
}
fn store_bundle() -> (Arc<Database>, ArtifactStore) {
let object_store: Arc<dyn object_store::ObjectStore> = Arc::new(MemoryObjectStore::new());
let store = Arc::new(fabro_store::test_support::test_database(
Arc::clone(&object_store),
"",
Duration::from_millis(1),
None,
));
let artifact_store = ArtifactStore::new(object_store, "artifacts");
(store, artifact_store)
}
async fn append_completed_run_with_final_patch(
store: &Database,
run_id: &RunId,
final_patch: &str,
) {
let run_store = store.create_run(run_id).await.expect("create run store");
workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunCreated {
run_id: *run_id,
title: None,
settings: serde_json::to_value(WorkflowSettings::default())
.expect("workflow settings should serialize"),
graph: serde_json::to_value(Graph::new("test"))
.expect("graph should serialize"),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
target: None,
automation: None,
provenance: test_support::test_run_provenance(),
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
admission: PetriAdmission::default(),
})
.await
.expect("append RunCreated");
workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.expect("append RunRunnable");
workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunStarting)
.await
.expect("append RunStarting");
workflow_event::append_event(
&run_store,
run_id,
&workflow_event::Event::WorkflowRunStarted {
name: "test".to_string(),
run_id: *run_id,
base_branch: Some("main".to_string()),
base_sha: Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()),
run_branch: Some("fabro/run/test".to_string()),
worktree_dir: None,
goal: Some("Test degraded files".to_string()),
},
)
.await
.expect("append WorkflowRunStarted");
workflow_event::append_event(&run_store, run_id, &workflow_event::Event::RunRunning)
.await
.expect("append RunRunning");
workflow_event::append_event(
&run_store,
run_id,
&workflow_event::Event::WorkflowRunCompleted {
timing: fabro_types::RunTiming::wall_only(1),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
final_git_commit_sha: Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string()),
final_patch: Some(final_patch.to_string()),
diff_summary: None,
usage: None,
},
)
.await
.expect("append WorkflowRunCompleted");
}
async fn append_local_sandbox_initialized(store: &Database, run_id: &RunId) {
let run_store = store.open_run(run_id).await.expect("open run store");
workflow_event::append_event(
&run_store,
run_id,
&workflow_event::Event::SandboxInitialized {
working_directory: std::env::current_dir()
.expect("test should run inside a source checkout")
.display()
.to_string(),
provider: SandboxProviderKind::LOCAL,
id: "local:test-sandbox".to_string(),
image: None,
snapshot: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
workspace_root: None,
repos_root: None,
primary_repo_path: None,
primary_repo_link: None,
},
)
.await
.expect("append SandboxInitialized");
}
#[tokio::test]
async fn invalid_run_id_returns_400() {
let app = fabro_server::test_support::build_test_router(test_app_state());
@ -313,112 +186,6 @@ async fn submitted_run_without_sandbox_returns_empty_envelope() {
assert_eq!(body["meta"]["degraded"].as_bool(), Some(false));
}
#[tokio::test]
async fn degraded_run_returns_file_diff_shape_without_meta_patch() {
let settings = test_settings();
let (store, artifact_store) = store_bundle();
let state = test_app_state_with_store(
settings.server_settings,
settings.manifest_run_defaults,
5,
Arc::clone(&store),
artifact_store,
);
let app = fabro_server::test_support::build_test_router(state);
let run_id = RunId::new();
let patch = "\
diff --git a/src/lib.rs b/src/lib.rs
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1 +1,2 @@
old
+new
diff --git a/.env.production b/.env.production
--- a/.env.production
+++ b/.env.production
@@ -1 +1 @@
-SECRET=old
+SECRET=new
";
append_completed_run_with_final_patch(&store, &run_id, patch).await;
append_local_sandbox_initialized(&store, &run_id).await;
let req = Request::builder()
.method("GET")
.uri(files_url(&run_id.to_string()))
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = response_json(
resp,
StatusCode::OK,
format!("GET /api/v1/runs/{run_id}/files"),
)
.await;
assert_eq!(body["meta"]["degraded"].as_bool(), Some(true));
assert!(body["meta"]["degraded_reason"].is_string());
assert_eq!(body["meta"]["source"].as_str(), Some("final_patch"));
assert_eq!(body["meta"]["scope"].as_str(), Some("committed"));
assert!(body["meta"].get("patch").is_none());
assert_eq!(body["meta"]["total_changed"], 2);
assert_eq!(body["meta"]["truncated"].as_bool(), Some(false));
let data = body["data"].as_array().expect("data should be an array");
assert_eq!(data.len(), 2);
assert_eq!(data[0]["old_file"]["contents"], serde_json::Value::Null);
assert_eq!(data[0]["new_file"]["contents"], serde_json::Value::Null);
assert!(data[0]["unified_patch"].is_string());
assert_eq!(data[1]["sensitive"].as_bool(), Some(true));
assert_eq!(data[1]["old_file"]["contents"], serde_json::Value::Null);
assert_eq!(data[1]["new_file"]["contents"], serde_json::Value::Null);
assert!(data[1].get("unified_patch").is_none());
}
#[tokio::test]
async fn planned_sandbox_rejects_files_for_every_scope() {
let settings = test_settings();
let (store, artifact_store) = store_bundle();
let state = test_app_state_with_store(
settings.server_settings,
settings.manifest_run_defaults,
5,
Arc::clone(&store),
artifact_store,
);
let app = fabro_server::test_support::build_test_router(state);
let run_id = RunId::new();
let patch = "\
diff --git a/src/lib.rs b/src/lib.rs
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1 +1,2 @@
old
+new
";
append_completed_run_with_final_patch(&store, &run_id, patch).await;
for scope in ["committed", "uncommitted", "all"] {
let req = Request::builder()
.method("GET")
.uri(files_url_with_scope(&run_id.to_string(), scope))
.body(Body::empty())
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
let body = response_json(
resp,
StatusCode::NOT_FOUND,
format!("GET /api/v1/runs/{run_id}/files?scope={scope}"),
)
.await;
assert_eq!(
body["errors"][0]["detail"].as_str(),
Some("Run sandbox was not created.")
);
}
}
#[tokio::test]
async fn demo_mode_returns_fixture_without_touching_store() {
// R34: demo handler must return the illustrative fixture with no

View file

@ -30,8 +30,7 @@ use axum::body::Body;
use axum::http::{Request, StatusCode};
use fabro_server::server::AppState;
use fabro_store::platform_records::{PlatformRecord, PlatformRecordStore, RunNoticeRecord};
use fabro_types::run_event::RunNoticeLevel;
use fabro_types::{RunId, RunStreamItem, RunStreamItemKind};
use fabro_types::{RunId, RunNoticeLevel, RunStreamItem, RunStreamItemKind};
use http_body_util::BodyExt;
use tokio::time::timeout;
use tower::ServiceExt;

View file

@ -511,20 +511,12 @@ mod tests {
fn sample_checkpoint() -> Checkpoint {
Checkpoint {
timestamp: Utc
timestamp: Utc
.with_ymd_and_hms(2026, 4, 20, 12, 0, 0)
.single()
.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: Some("ship".to_string()),
git_commit_sha: Some("abc123".to_string()),
loop_failure_signatures: HashMap::new(),
restart_failure_signatures: HashMap::new(),
node_visits: HashMap::from([("build".to_string(), 2usize)]),
current_node: "build".to_string(),
git_commit_sha: Some("abc123".to_string()),
}
}

View file

@ -25,7 +25,7 @@
//! milliseconds from the run's creation to the stage's `visit.started`,
//! plus one, which is the same however the records were delivered.
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, TimeZone as _, Utc};
use fabro_store::platform_records::{
@ -237,17 +237,9 @@ impl RunView {
.get(&stage_key(record.execution, record.firing));
let current_node = stage.map_or_else(String::new, |stage| stage.node_name.clone());
let checkpoint = fabro_types::Checkpoint {
timestamp: at,
current_node: current_node.clone(),
completed_nodes: Vec::new(),
node_retries: HashMap::default(),
context_values: HashMap::default(),
node_outcomes: HashMap::default(),
next_node_id: None,
git_commit_sha: record.git_commit_sha.clone(),
loop_failure_signatures: HashMap::default(),
restart_failure_signatures: HashMap::default(),
node_visits: HashMap::default(),
timestamp: at,
current_node: current_node.clone(),
git_commit_sha: record.git_commit_sha.clone(),
};
projection.checkpoints.push(ViewCheckpoint {
seq: u32::try_from(stream_seq).unwrap_or(u32::MAX),
@ -1119,7 +1111,19 @@ fn fold_lifecycle(projection: &mut RunProjection, record: &RunLifecycleRecord, a
use RunLifecycleKind as Kind;
match record.transition {
Kind::Submitted => apply_status(projection, RunStatus::Submitted, at),
Kind::StartRequested | Kind::Unpaused => {}
Kind::StartRequested => {}
Kind::Unpaused => {
let status = match projection.status {
RunStatus::Paused {
prior_block: Some(blocked_reason),
} => RunStatus::Blocked { blocked_reason },
_ => RunStatus::Running,
};
apply_status(projection, status, at);
if projection.pending_control == Some(RunControlAction::Unpause) {
projection.pending_control = None;
}
}
Kind::Pending => {
if let Some(status) = record.status {
apply_status(projection, status, at);
@ -1168,12 +1172,31 @@ fn fold_lifecycle(projection: &mut RunProjection, record: &RunLifecycleRecord, a
apply_status(projection, status, at);
}
}
Kind::Starting
| Kind::Running
| Kind::Blocked
| Kind::Unblocked
| Kind::Removing
| Kind::Dead => {
Kind::Blocked => {
// A block that lands while the run is paused waits behind the
// pause: the unpause restores it.
match (projection.status, record.status) {
(RunStatus::Paused { .. }, Some(RunStatus::Blocked { blocked_reason })) => {
apply_status(
projection,
RunStatus::Paused {
prior_block: Some(blocked_reason),
},
at,
);
}
(_, Some(status)) => apply_status(projection, status, at),
(_, None) => {}
}
}
Kind::Unblocked => {
let status = match projection.status {
RunStatus::Paused { .. } => RunStatus::Paused { prior_block: None },
_ => record.status.unwrap_or(RunStatus::Running),
};
apply_status(projection, status, at);
}
Kind::Starting | Kind::Running | Kind::Removing | Kind::Dead => {
if let Some(status) = record.status {
apply_status(projection, status, at);
}
@ -1181,9 +1204,13 @@ fn fold_lifecycle(projection: &mut RunProjection, record: &RunLifecycleRecord, a
Kind::Paused => {
let prior_block = match projection.status {
RunStatus::Blocked { blocked_reason } => Some(blocked_reason),
RunStatus::Paused { prior_block } => prior_block,
_ => None,
};
apply_status(projection, RunStatus::Paused { prior_block }, at);
if projection.pending_control == Some(RunControlAction::Pause) {
projection.pending_control = None;
}
}
Kind::Succeeded | Kind::Failed => {
if let Some(status) = record.status {

View file

@ -17,11 +17,22 @@ use std::sync::{Mutex, PoisonError};
use std::time::{Duration, SystemTime};
use fabro_github::token_source::TokenSnapshot;
pub use fabro_types::run_event::GitPushRetryReason as GitRetryReason;
use sandbox_driver::{GitBackoff, GitCredentials, GitFailure, GitFailureKind, GitRetryPolicy};
use serde::{Deserialize, Serialize};
use crate::credentials::GITHUB_TOKEN_USERNAME;
/// Why a failed git push attempt is safe to retry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum GitRetryReason {
/// A recently minted token may not have reached every GitHub git endpoint.
TokenReplication,
/// The failure came from transient network or service infrastructure.
TransientInfra,
}
/// Backoff between attempts: 3s, then 9s.
///
/// GitHub's guidance for token replication is to wait a few seconds and

View file

@ -21,7 +21,6 @@ lithos-llm = { workspace = true, features = ["runtime"] }
pebble-coding-agent.workspace = true
fabro-util = { path = "../../foundation/fabro-util" }
hex.workspace = true
slatedb.workspace = true
object_store.workspace = true
percent-encoding.workspace = true
async-trait.workspace = true

View file

@ -1,14 +1,7 @@
#[cfg(test)]
use std::sync::Arc;
use bytes::Bytes;
use fabro_types::BlobHash;
use sqlx::SqlitePool;
#[cfg(test)]
use crate::record::Repository;
#[cfg(test)]
use crate::record::{RawBytesCodec, Record};
use crate::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
@ -26,46 +19,15 @@ impl From<Bytes> for Blob {
}
}
#[cfg(test)]
impl Record for Blob {
type Id = BlobHash;
type Codec = RawBytesCodec;
const PREFIX: &'static str = "blobs/sha256";
#[cfg(test)]
fn id(&self) -> Self::Id {
BlobHash::new(&self.0)
}
}
/// Temporary backend split for compatibility tests.
///
/// Production compiles only the SQLite arm. The Slate arm remains test-only
/// while the startup import bridge is supported, so tests can exercise the
/// old-source boundary directly. Delete the Slate arm (and this enum) with the
/// separately authorized compatibility cleanup, then inline SQLite into
/// [`BlobStore`].
enum BlobBackend {
#[cfg(test)]
Slate(Repository<Blob>),
Sqlite(SqlitePool),
}
/// The content-addressed `blobs` table: every blob once, under its
/// SHA-256.
pub struct BlobStore {
backend: BlobBackend,
pool: SqlitePool,
}
impl std::fmt::Debug for BlobStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let backend = match &self.backend {
#[cfg(test)]
BlobBackend::Slate(_) => "slate",
BlobBackend::Sqlite(_) => "sqlite",
};
f.debug_struct("BlobStore")
.field("backend", &backend)
.finish_non_exhaustive()
f.debug_struct("BlobStore").finish_non_exhaustive()
}
}
@ -73,91 +35,57 @@ impl BlobStore {
/// Creates a blob store backed by a SQLite pool whose migrations have run.
#[must_use]
pub fn new(pool: SqlitePool) -> Self {
Self {
backend: BlobBackend::Sqlite(pool),
}
}
#[cfg(test)]
pub(crate) fn from_slate(db: Arc<slatedb::Db>) -> Self {
Self {
backend: BlobBackend::Slate(Repository::new(db)),
}
Self { pool }
}
pub async fn write(&self, bytes: &[u8]) -> Result<BlobHash> {
match &self.backend {
#[cfg(test)]
BlobBackend::Slate(repo) => {
let blob = Blob(Bytes::copy_from_slice(bytes));
let id = blob.id();
repo.put(&blob).await?;
Ok(id)
}
BlobBackend::Sqlite(pool) => {
let blob_hash = BlobHash::new(bytes);
let result = sqlx::query(
"INSERT INTO blobs (hash, data) VALUES (?, ?) \
ON CONFLICT(hash) DO NOTHING",
)
.bind(blob_hash.to_string())
.bind(bytes)
.execute(pool)
.await?;
let blob_hash = BlobHash::new(bytes);
let result = sqlx::query(
"INSERT INTO blobs (hash, data) VALUES (?, ?) \
ON CONFLICT(hash) DO NOTHING",
)
.bind(blob_hash.to_string())
.bind(bytes)
.execute(&self.pool)
.await?;
if result.rows_affected() == 1 {
return Ok(blob_hash);
}
if result.rows_affected() == 1 {
return Ok(blob_hash);
}
let stored: Vec<u8> = sqlx::query_scalar("SELECT data FROM blobs WHERE hash = ?")
.bind(blob_hash.to_string())
.fetch_one(pool)
.await?;
if stored == bytes {
Ok(blob_hash)
} else {
Err(Error::BlobHashConflict { blob_hash })
}
}
let stored: Vec<u8> = sqlx::query_scalar("SELECT data FROM blobs WHERE hash = ?")
.bind(blob_hash.to_string())
.fetch_one(&self.pool)
.await?;
if stored == bytes {
Ok(blob_hash)
} else {
Err(Error::BlobHashConflict { blob_hash })
}
}
pub async fn read(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
match &self.backend {
#[cfg(test)]
BlobBackend::Slate(repo) => Ok(repo.get(blob_hash).await?.map(|blob| blob.0)),
BlobBackend::Sqlite(pool) => {
let stored: Option<Vec<u8>> =
sqlx::query_scalar("SELECT data FROM blobs WHERE hash = ?")
.bind(blob_hash.to_string())
.fetch_optional(pool)
.await?;
let Some(stored) = stored else {
return Ok(None);
};
if BlobHash::new(&stored) != *blob_hash {
return Err(Error::BlobIntegrity {
blob_hash: *blob_hash,
});
}
Ok(Some(Bytes::from(stored)))
}
let stored: Option<Vec<u8>> = sqlx::query_scalar("SELECT data FROM blobs WHERE hash = ?")
.bind(blob_hash.to_string())
.fetch_optional(&self.pool)
.await?;
let Some(stored) = stored else {
return Ok(None);
};
if BlobHash::new(&stored) != *blob_hash {
return Err(Error::BlobIntegrity {
blob_hash: *blob_hash,
});
}
Ok(Some(Bytes::from(stored)))
}
pub async fn exists(&self, blob_hash: &BlobHash) -> Result<bool> {
match &self.backend {
#[cfg(test)]
BlobBackend::Slate(repo) => repo.exists(blob_hash).await,
BlobBackend::Sqlite(pool) => {
let exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM blobs WHERE hash = ?)")
.bind(blob_hash.to_string())
.fetch_one(pool)
.await?;
Ok(exists)
}
}
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM blobs WHERE hash = ?)")
.bind(blob_hash.to_string())
.fetch_one(&self.pool)
.await?;
Ok(exists)
}
}
@ -167,33 +95,12 @@ mod tests {
use bytes::Bytes;
use fabro_types::BlobHash;
use object_store::memory::InMemory;
use super::BlobStore;
use crate::Error;
use crate::keys::SlateKey;
type TestResult<T> = std::result::Result<T, Box<dyn std::error::Error>>;
async fn slate_store() -> Arc<BlobStore> {
let raw_db = Arc::new(
slatedb::Db::open("blob-store-tests", Arc::new(InMemory::new()))
.await
.unwrap(),
);
Arc::new(BlobStore::from_slate(raw_db))
}
async fn raw_slate_store(name: &str) -> (Arc<slatedb::Db>, BlobStore) {
let raw_db = Arc::new(
slatedb::Db::open(name, Arc::new(InMemory::new()))
.await
.unwrap(),
);
let store = BlobStore::from_slate(raw_db.clone());
(raw_db, store)
}
async fn sqlite_store() -> TestResult<(tempfile::TempDir, fabro_db::Database, BlobStore)> {
let dir = tempfile::tempdir()?;
let database = fabro_db::Database::connect(dir.path().join("fabro.sqlite3")).await?;
@ -202,43 +109,6 @@ mod tests {
Ok((dir, database, store))
}
#[tokio::test]
async fn slate_writes_reads_and_checks_existence() {
let store = slate_store().await;
let bytes = b"hello world";
let id = store.write(bytes).await.unwrap();
assert_eq!(
store.read(&id).await.unwrap(),
Some(Bytes::from_static(bytes))
);
assert_eq!(store.write(bytes).await.unwrap(), id);
assert!(store.exists(&id).await.unwrap());
assert!(!store.exists(&BlobHash::new(b"missing")).await.unwrap());
}
#[tokio::test]
async fn slate_empty_blobs_round_trip() {
let store = slate_store().await;
let id = store.write(b"").await.unwrap();
assert_eq!(store.read(&id).await.unwrap(), Some(Bytes::new()));
}
#[tokio::test]
async fn raw_slate_db_reads_exact_blob_bytes() {
let (raw_db, store) = raw_slate_store("blob-store-tests").await;
let bytes = b"{\"ok\":true}";
let id = store.write(bytes).await.unwrap();
let saved = raw_db
.get(SlateKey::new("blobs").with("sha256").with(id))
.await
.unwrap()
.unwrap();
assert_eq!(saved.as_ref(), bytes);
}
#[tokio::test]
async fn sqlite_writes_reads_and_checks_existence() -> TestResult<()> {
let (_dir, database, store) = sqlite_store().await?;

View file

@ -0,0 +1,65 @@
//! The run store the server holds: the blob table and the run summary
//! store over one SQLite pool, behind one handle.
//!
//! A run's history is Petri's records (`petri_records`) and Fabro's
//! platform records; its view is the projection the projector commits.
//! This handle carries the two stores every reader of a run reaches, and
//! the run-level operations that span them.
use std::sync::Arc;
use fabro_types::{RunId, RunProjection};
use crate::{BlobStore, PlatformRecordHook, Result, RunSummaryStore};
#[derive(Clone)]
pub struct Database {
blobs: Arc<BlobStore>,
run_summary_store: Arc<RunSummaryStore>,
}
impl std::fmt::Debug for Database {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Database").finish_non_exhaustive()
}
}
impl Database {
#[must_use]
pub fn new(blobs: Arc<BlobStore>, run_summary_store: Arc<RunSummaryStore>) -> Self {
Self {
blobs,
run_summary_store,
}
}
#[must_use]
pub fn run_summary_store(&self) -> Arc<RunSummaryStore> {
Arc::clone(&self.run_summary_store)
}
#[must_use]
pub fn blobs(&self) -> Arc<BlobStore> {
Arc::clone(&self.blobs)
}
/// The run's projection: the one its projector last committed over
/// Petri's records and the platform records, or `None` before the
/// first view pass commits (or for no such run).
pub async fn load_run_projection(&self, run_id: &RunId) -> Result<Option<Arc<RunProjection>>> {
self.run_summary_store.load_petri_projection(run_id).await
}
/// Install the wake-up called after a platform record of a run is
/// committed.
pub fn set_platform_record_hook(&self, hook: PlatformRecordHook) {
self.run_summary_store.set_platform_record_hook(hook);
}
/// Forget the run's row. Its Petri records, platform records and view
/// are the projector's to delete; its blobs are content-addressed and
/// shared.
pub async fn delete_run(&self, run_id: &RunId) -> Result<()> {
self.run_summary_store.delete_canonical(run_id).await
}
}

View file

@ -4,8 +4,6 @@ pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("SlateDB error: {0}")]
Slate(#[from] slatedb::Error),
#[error("Object store error: {0}")]
ObjectStore(#[from] object_store::Error),
#[error("Serialization error: {0}")]

View file

@ -1,186 +0,0 @@
use std::fmt::{self, Write};
#[cfg(test)]
use std::ops::Range;
use fabro_types::RunId;
pub(crate) const MAX_EVENT_SEQ: u32 = 999_999;
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct SlateKey(String);
impl SlateKey {
const SEP: char = '\0';
pub(crate) fn new(segment: impl fmt::Display) -> Self {
Self(segment.to_string())
}
pub(crate) fn with(mut self, segment: impl fmt::Display) -> Self {
self.0.push(Self::SEP);
write!(&mut self.0, "{segment}").expect("write to String cannot fail");
self
}
pub(crate) fn into_prefix(mut self) -> Self {
self.0.push(Self::SEP);
self
}
/// Exclusive end bound of this key's prefix keyspace: every key under
/// `self.into_prefix()` sorts below it and no other key sorts between.
#[cfg(test)]
fn into_prefix_end(mut self) -> Self {
self.0.push('\u{1}');
self
}
#[cfg(test)]
fn as_str(&self) -> &str {
&self.0
}
#[cfg(test)]
pub(crate) fn segments(raw: &str) -> impl Iterator<Item = &str> {
raw.split(Self::SEP)
}
}
impl AsRef<[u8]> for SlateKey {
fn as_ref(&self) -> &[u8] {
self.0.as_bytes()
}
}
// --- Construction ---
// Sequence keys zero-pad `seq` to six digits so lexicographic key order
// matches numeric seq order through `MAX_EVENT_SEQ`. Seek-based event listing
// (`run_events_range`) depends on this invariant, so event allocation rejects
// larger sequences.
#[cfg(any(test, feature = "test-support"))]
pub(crate) fn run_event_key(run_id: &RunId, seq: u32, epoch_ms: i64) -> SlateKey {
SlateKey::new("runs")
.with(run_id)
.with("events")
.with(format!("{seq:06}-{epoch_ms}"))
}
#[cfg(test)]
pub(crate) fn run_event_seq_prefix(run_id: &RunId, seq: u32) -> SlateKey {
SlateKey::new("runs")
.with(run_id)
.with("events")
.with(format!("{seq:06}-"))
}
/// Scan range covering the run's event keys from `start_seq` to the end of
/// the run's event namespace, so seek-based listing never touches keys of
/// other runs or namespaces.
#[cfg(test)]
pub(crate) fn run_events_range(run_id: &RunId, start_seq: u32) -> Range<SlateKey> {
let end = SlateKey::new("runs")
.with(run_id)
.with("events")
.into_prefix_end();
run_event_seq_prefix(run_id, start_seq)..end
}
#[cfg(test)]
pub(crate) fn session_by_id_key(session_id: &fabro_types::SessionId) -> SlateKey {
SlateKey::new("sessions").with("by-id").with(session_id)
}
// --- Parsing ---
#[cfg(test)]
pub(crate) fn parse_event_seq(key: &str) -> Option<u32> {
let mut segments = SlateKey::segments(key);
let _ = segments.next()?; // "runs"
let _ = segments.next()?; // run_id
if segments.next()? != "events" {
return None;
}
segments.next()?.split_once('-')?.0.parse().ok()
}
#[cfg(test)]
mod tests {
use fabro_types::RunId;
use super::*;
#[test]
fn builder_joins_segments_with_null_byte() {
let key = SlateKey::new("a").with("b").with("c");
assert_eq!(key.as_ref(), b"a\0b\0c");
}
#[test]
fn into_prefix_appends_trailing_null_byte() {
let key = SlateKey::new("a").with("b").into_prefix();
assert_eq!(key.as_ref(), b"a\0b\0");
}
#[test]
fn event_key_segments() {
let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap();
let key = run_event_key(&run_id, 7, 123);
let segments: Vec<&str> = SlateKey::segments(key.as_str()).collect();
assert_eq!(segments, [
"runs",
"01JT56VE4Z5NZ814GZN2JZD65A",
"events",
"000007-123"
]);
}
#[test]
fn sequence_keys_are_zero_padded() {
let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap();
let key = run_event_key(&run_id, 7, 123);
let leaf = SlateKey::segments(key.as_str()).last().unwrap();
assert_eq!(leaf, "000007-123");
}
#[test]
fn run_events_range_bounds_the_event_namespace() {
let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap();
let range = run_events_range(&run_id, 2);
let contains = |key: &SlateKey| {
range.start.as_ref() <= key.as_ref() && key.as_ref() < range.end.as_ref()
};
assert!(!contains(&run_event_key(&run_id, 1, 123)));
assert!(contains(&run_event_key(&run_id, 2, 123)));
assert!(contains(&run_event_key(&run_id, MAX_EVENT_SEQ, 123)));
// Sibling namespaces of the same run sort outside the range.
assert!(!contains(&SlateKey::new("runs").with(run_id).with("state")));
assert!(!contains(
&session_by_id_key(&fabro_types::SessionId::new())
));
}
#[test]
fn parse_event_seq_roundtrips() {
let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap();
assert_eq!(
parse_event_seq(run_event_key(&run_id, 7, 123).as_str()),
Some(7)
);
}
#[test]
fn parse_event_seq_rejects_invalid_keys() {
assert_eq!(
parse_event_seq(
SlateKey::new("runs")
.with("not-a-run")
.with("events")
.with("not-a-seq")
.as_str()
),
None
);
}
}

View file

@ -2,23 +2,19 @@ mod artifact_store;
mod auth_code_store;
pub mod auth_session_store;
mod blob_store;
mod database;
mod error;
mod keyed_mutex;
mod keys;
pub mod platform_records;
#[cfg(test)]
mod record;
mod run_session_event_store;
mod run_session_record_store;
mod run_sessions;
mod run_state;
mod run_summary;
mod run_summary_store;
mod serializable_projection;
mod slate;
mod sqlite_row;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
mod types;
pub use artifact_store::{
ArtifactKey, ArtifactStore, NodeArtifact, StageArtifactEntry, retry_storage_segment,
@ -29,9 +25,10 @@ pub use auth_session_store::{
ActiveCliSession, AuthSessionRecord, AuthSessionStore, InitialRefreshToken, RotateOutcome,
};
pub use blob_store::{Blob, BlobStore};
pub use database::Database;
pub use error::{Error, Result};
pub use fabro_types::{
BlobHash, EventEnvelope, PendingInterviewRecord, Run, RunProjection, StageId, StageProjection,
BlobHash, PendingInterviewRecord, Run, RunProjection, StageId, StageProjection,
};
pub use keyed_mutex::{KeyedMutex, KeyedMutexGuard};
pub use platform_records::{
@ -41,11 +38,9 @@ pub use platform_records::{
pub use run_session_event_store::RunSessionEventStore;
pub use run_session_record_store::{RunSessionRecordStore, StoredSessionRecord};
pub use run_sessions::{ProjectedRunSession, project_run_session, project_run_sessions};
pub use run_state::{RunProjectionReducer, build_summary, projected_usage};
pub use run_summary::{build_summary, projected_usage};
pub use run_summary_store::{
RunSummaryIdentity, RunSummaryListQuery, RunSummaryPage, RunSummarySort,
RunSummarySortDirection, RunSummaryStore, RunSummaryVisibility,
};
pub use serializable_projection::SerializableProjection;
pub use slate::{Database, RunDatabase, UnreadableRun};
pub use types::EventPayload;

View file

@ -24,16 +24,11 @@
//! kind), so the record and the effect share one identity and a retry after
//! a crash finds the effect already done.
use std::collections::HashMap;
use std::sync::Arc;
use fabro_types::run_event::{
InterviewCompletedProps, PullRequestCreatedProps, RunCreatedProps, RunFailedProps,
RunNoticeLevel, RunPairStartedProps, RunRunnableSource, RunStartedProps, RunSupersededByProps,
};
use fabro_types::{
BlobHash, DiffSummary, EventBody, GitIdentity, PairId, PairTarget, Principal,
PullRequestCreationId, PullRequestLink, RunControlAction, RunEvent, RunId, RunSpec, RunStatus,
BlobHash, DiffSummary, GitIdentity, PairId, PairTarget, Principal, PullRequestCreationId,
PullRequestLink, RunControlAction, RunId, RunNoticeLevel, RunSpec, RunStatus,
};
use serde::{Deserialize, Serialize};
use sqlx::sqlite::{SqliteConnection, SqliteRow};
@ -676,205 +671,9 @@ pub fn now_ms() -> u64 {
u64::try_from(chrono::Utc::now().timestamp_millis()).unwrap_or(0)
}
/// The platform record a legacy run event of a Petri run stands for, when
/// it stands for one. The lifecycle paths append legacy events until the
/// old executor is deleted; for a Petri run the store derives the platform
/// record from the event and keeps both, so the projection over Petri's
/// records reads the lifecycle from platform records alone.
#[must_use]
pub fn platform_record_for(event: &RunEvent) -> Option<PlatformRecord> {
use RunLifecycleKind as Kind;
let lifecycle = |kind: Kind| Some(PlatformRecord::RunLifecycle(RunLifecycleRecord::new(kind)));
let status = |kind: Kind, status: RunStatus| {
Some(PlatformRecord::RunLifecycle(
RunLifecycleRecord::new(kind).with_status(status),
))
};
let control = |kind: Kind, action: RunControlAction| {
let mut record = RunLifecycleRecord::new(kind);
record.action = Some(action);
Some(PlatformRecord::RunLifecycle(record))
};
#[expect(
clippy::wildcard_enum_match_arm,
reason = "stage, agent and sandbox events are Petri's records for a Petri run"
)]
match &event.body {
EventBody::RunCreated(props) => Some(PlatformRecord::RunCreated(run_created_record(
event.run_id,
props,
))),
EventBody::RunSubmitted(_) => status(Kind::Submitted, RunStatus::Submitted),
EventBody::RunStartRequested(props) => {
let mut record = RunLifecycleRecord::new(Kind::StartRequested);
record.source = Some(if props.resume { "resume" } else { "start" }.to_string());
Some(PlatformRecord::RunLifecycle(record))
}
EventBody::RunPending(props) => status(Kind::Pending, RunStatus::Pending {
reason: props.reason,
}),
EventBody::RunApproved(_) => lifecycle(Kind::Approved),
EventBody::RunDenied(props) => {
let mut record = RunLifecycleRecord::new(Kind::Denied);
record.reason.clone_from(&props.reason);
Some(PlatformRecord::RunLifecycle(record))
}
EventBody::RunRunnable(props) => {
let mut record =
RunLifecycleRecord::new(Kind::Runnable).with_status(RunStatus::Runnable);
record.source = Some(runnable_source(props.source).to_string());
Some(PlatformRecord::RunLifecycle(record))
}
EventBody::RunStarting(_) => status(Kind::Starting, RunStatus::Starting),
EventBody::RunRunning(_) => status(Kind::Running, RunStatus::Running),
EventBody::RunBlocked(props) => status(Kind::Blocked, RunStatus::Blocked {
blocked_reason: props.blocked_reason,
}),
EventBody::RunUnblocked(_) => status(Kind::Unblocked, RunStatus::Running),
EventBody::RunRemoving(_) => status(Kind::Removing, RunStatus::Removing),
EventBody::RunCancelRequested(props) => control(Kind::CancelRequested, props.action),
EventBody::RunPauseRequested(props) => control(Kind::PauseRequested, props.action),
EventBody::RunUnpauseRequested(props) => control(Kind::UnpauseRequested, props.action),
EventBody::RunPaused(_) => lifecycle(Kind::Paused),
EventBody::RunUnpaused(_) => lifecycle(Kind::Unpaused),
EventBody::RunCompleted(props) => status(Kind::Succeeded, RunStatus::Succeeded {
reason: props.reason,
}),
EventBody::RunFailed(props) => Some(PlatformRecord::RunLifecycle(failed_record(props))),
EventBody::RunSupersededBy(props) => {
Some(PlatformRecord::RunSuperseded(superseded_record(props)))
}
EventBody::RunArchived(_) => Some(PlatformRecord::RunArchived),
EventBody::RunUnarchived(_) => Some(PlatformRecord::RunUnarchived),
EventBody::RunTitleUpdated(props) => Some(PlatformRecord::RunTitle(RunTitleRecord {
title: props.title.clone(),
})),
EventBody::RunParentLinked(props) => Some(PlatformRecord::RunParent(RunParentRecord {
parent_id: Some(props.parent_id),
previous_parent_id: props.previous_parent_id,
})),
EventBody::RunParentUnlinked(props) => Some(PlatformRecord::RunParent(RunParentRecord {
parent_id: None,
previous_parent_id: Some(props.previous_parent_id),
})),
EventBody::RunNotice(props) => Some(PlatformRecord::RunNotice(RunNoticeRecord {
level: props.level,
code: props.code.clone(),
message: props.message.clone(),
})),
EventBody::RunStarted(props) => Some(PlatformRecord::RunBranch(run_branch_record(props))),
EventBody::GitIdentityResolved(props) => {
Some(PlatformRecord::GitIdentity(GitIdentityRecord {
identity: props.identity.clone(),
}))
}
EventBody::PullRequestCreated(props) => Some(PlatformRecord::PullRequestCreated(
pull_request_created_record(props),
)),
EventBody::RunPairStarted(props) => {
Some(PlatformRecord::RunPaired(run_paired_record(props)))
}
EventBody::InterviewCompleted(props) => Some(PlatformRecord::InterviewAnswered(
interview_answered_record(props, event.actor.clone()),
)),
_ => None,
}
}
fn runnable_source(source: RunRunnableSource) -> &'static str {
source.into()
}
fn run_created_record(run_id: RunId, props: &RunCreatedProps) -> RunCreatedRecord {
let labels = props.labels.clone().into_iter().collect::<HashMap<_, _>>();
RunCreatedRecord {
spec: RunSpec {
run_id,
settings: props.settings.clone(),
graph: props.graph.clone(),
graph_source: props.workflow_source.clone(),
workflow_slug: props.workflow_slug.clone(),
workflow_version_id: props.workflow_version_id,
target: props.target.clone(),
automation: props.automation.clone(),
source_directory: props.source_directory.clone(),
labels,
provenance: props.provenance.clone(),
definition_blob: None,
spec_blob: props.spec_blob,
git: props.git.clone(),
fork_source_ref: props.fork_source_ref.clone(),
admission: props.admission.clone(),
},
title: props.title.clone(),
parent_id: props.parent_id,
retried_from: props.retried_from,
web_url: props.web_url.clone(),
}
}
fn failed_record(props: &RunFailedProps) -> RunLifecycleRecord {
let mut record =
RunLifecycleRecord::new(RunLifecycleKind::Failed).with_status(RunStatus::Failed {
reason: props.failure.reason,
});
record.reason = Some(props.failure.detail.message.clone());
record
}
fn superseded_record(props: &RunSupersededByProps) -> RunSupersededRecord {
RunSupersededRecord {
new_run_id: props.new_run_id,
target_checkpoint_ordinal: props.target_checkpoint_ordinal,
target_node_id: props.target_node_id.clone(),
target_visit: props.target_visit,
}
}
fn run_branch_record(props: &RunStartedProps) -> RunBranchRecord {
RunBranchRecord {
run_branch: props.run_branch.clone(),
base_sha: props.base_sha.clone(),
}
}
fn pull_request_created_record(props: &PullRequestCreatedProps) -> PullRequestCreatedRecord {
PullRequestCreatedRecord {
number: props.pr_number,
owner: props.owner.clone(),
repo: props.repo.clone(),
html_url: props.pr_url.clone(),
head_sha: props.head_sha.clone(),
draft: props.draft,
operation: None,
}
}
fn run_paired_record(props: &RunPairStartedProps) -> RunPairedRecord {
RunPairedRecord {
pair_id: props.pair_id,
target: props.target.clone(),
}
}
fn interview_answered_record(
props: &InterviewCompletedProps,
principal: Option<Principal>,
) -> InterviewAnsweredRecord {
InterviewAnsweredRecord {
question: props.question_id.clone(),
principal,
channel: None,
text: Some(props.question.clone()),
answer: Some(props.answer.clone()),
}
}
#[cfg(test)]
mod tests {
use fabro_types::{
FailureReason, RunStatus, SystemActorKind, fixtures, test_support as types_support,
};
use fabro_types::{RunStatus, fixtures, test_support as types_support};
use serde_json::json;
use super::*;
@ -1098,83 +897,4 @@ mod tests {
None
);
}
/// The interview adapter completes a Petri question under Petri's own
/// id with the answering principal as the event's actor; the record
/// keeps both, so who answered is a platform fact keyed on that id.
#[test]
fn a_completed_interview_becomes_an_answered_record_under_petris_id_with_its_actor() {
let actor = Principal::System {
system_kind: SystemActorKind::Engine,
};
let event = fabro_types::RunEvent {
id: "evt".to_string(),
ts: chrono::Utc::now(),
run_id: fixtures::RUN_1,
node_id: None,
node_label: None,
stage_id: None,
parallel_group_id: None,
parallel_branch_id: None,
session_id: None,
parent_session_id: None,
tool_call_id: None,
actor: Some(actor.clone()),
body: EventBody::InterviewCompleted(InterviewCompletedProps {
question_id: "gate#2".to_string(),
question: "Go?".to_string(),
answer: "N".to_string(),
duration_ms: 1_200,
}),
};
let Some(PlatformRecord::InterviewAnswered(record)) = platform_record_for(&event) else {
panic!("a completed interview maps to an answered record");
};
assert_eq!(record.question, "gate#2");
assert_eq!(record.principal, Some(actor));
assert_eq!(record.channel, None);
}
#[test]
fn a_failed_legacy_event_becomes_a_failed_lifecycle_record_with_its_message() {
let event = fabro_types::RunEvent {
id: "evt".to_string(),
ts: chrono::Utc::now(),
run_id: fixtures::RUN_1,
node_id: None,
node_label: None,
stage_id: None,
parallel_group_id: None,
parallel_branch_id: None,
session_id: None,
parent_session_id: None,
tool_call_id: None,
actor: None,
body: EventBody::RunFailed(RunFailedProps {
failure: fabro_types::RunFailure {
reason: FailureReason::Cancelled,
detail: fabro_types::FailureDetail::new(
"stopped",
fabro_types::FailureCategory::Canceled,
),
},
timing: fabro_types::RunTiming::default(),
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
usage: None,
}),
};
let Some(PlatformRecord::RunLifecycle(record)) = platform_record_for(&event) else {
panic!("a failed run maps to a lifecycle record");
};
assert_eq!(record.transition, RunLifecycleKind::Failed);
assert_eq!(
record.status,
Some(RunStatus::Failed {
reason: FailureReason::Cancelled,
})
);
assert_eq!(record.reason.as_deref(), Some("stopped"));
}
}

View file

@ -1,99 +0,0 @@
use bytes::Bytes;
#[cfg(test)]
use serde::Serialize;
#[cfg(test)]
use serde::de::DeserializeOwned;
use crate::{Error, Result};
pub(crate) trait Codec<R>: Send + Sync + 'static {
fn encode(value: &R) -> Result<Vec<u8>>;
fn decode(bytes: &[u8]) -> Result<R>;
}
#[cfg(test)]
pub(crate) struct JsonCodec;
#[cfg(test)]
impl<R> Codec<R> for JsonCodec
where
R: Serialize + DeserializeOwned,
{
fn encode(value: &R) -> Result<Vec<u8>> {
serde_json::to_vec(value).map_err(Into::into)
}
fn decode(bytes: &[u8]) -> Result<R> {
serde_json::from_slice(bytes).map_err(Into::into)
}
}
pub(crate) struct RawBytesCodec;
impl<R> Codec<R> for RawBytesCodec
where
R: AsRef<[u8]> + From<Bytes>,
{
fn encode(value: &R) -> Result<Vec<u8>> {
Ok(value.as_ref().to_vec())
}
fn decode(bytes: &[u8]) -> Result<R> {
Ok(R::from(Bytes::copy_from_slice(bytes)))
}
}
pub(crate) struct MarkerCodec;
impl<R> Codec<R> for MarkerCodec
where
R: Default,
{
fn encode(_: &R) -> Result<Vec<u8>> {
Ok(Vec::new())
}
fn decode(bytes: &[u8]) -> Result<R> {
if bytes.is_empty() {
return Ok(R::default());
}
Err(Error::Other(
"marker records must decode from an empty byte slice".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use serde::{Deserialize, Serialize};
use super::{Codec, JsonCodec};
#[derive(Debug, Serialize, Deserialize)]
struct SnapshotRecord {
code: String,
issued_at: chrono::DateTime<Utc>,
expires_at: chrono::DateTime<Utc>,
attempts: u32,
}
#[test]
fn json_codec_matches_snapshot() {
let record = SnapshotRecord {
code: "code-123".to_string(),
issued_at: Utc.with_ymd_and_hms(2026, 4, 20, 12, 34, 56).unwrap(),
expires_at: Utc.with_ymd_and_hms(2026, 4, 20, 12, 39, 56).unwrap(),
attempts: 2,
};
let encoded = JsonCodec::encode(&record).unwrap();
let encoded = std::str::from_utf8(&encoded).unwrap();
insta::assert_snapshot!(
encoded,
@"{\"code\":\"code-123\",\"issued_at\":\"2026-04-20T12:34:56Z\",\"expires_at\":\"2026-04-20T12:39:56Z\",\"attempts\":2}"
);
}
}

View file

@ -1,38 +0,0 @@
//! Internal typed key/value helpers for simple SlateDB-backed records.
//!
//! The split of responsibility is:
//! - [`Record`]: declares the key prefix, id type, and codec for one persisted
//! type.
//! - [`RecordId`]: converts the typed id to and from key segments.
//! - [`Repository`]: performs the generic get/put/delete/scan operations.
//!
//! Production stores no longer read or write SlateDB records; this module is
//! compiled only for tests that model the retired Slate layout and goes away
//! with the remaining compatibility bridges.
mod codec;
mod record_id;
mod repository;
#[cfg(test)]
pub(crate) use codec::JsonCodec;
pub(crate) use codec::{Codec, MarkerCodec, RawBytesCodec};
pub(crate) use repository::Repository;
use crate::Result;
pub(crate) trait Record: Sized + Send + Sync + 'static {
type Id: RecordId;
type Codec: Codec<Self>;
const PREFIX: &'static str;
#[cfg(test)]
fn id(&self) -> Self::Id;
}
pub(crate) trait RecordId: Sized {
fn key_segments(&self) -> Vec<String>;
fn from_key_segments(segs: &[&str]) -> Result<Self>;
}

View file

@ -1,78 +0,0 @@
use fabro_types::{BlobHash, RunId};
use super::RecordId;
use crate::{Error, Result};
impl RecordId for [u8; 32] {
fn key_segments(&self) -> Vec<String> {
vec![hex::encode(self)]
}
fn from_key_segments(segs: &[&str]) -> Result<Self> {
let [segment] = segs else {
return Err(Error::KeyParse(format!(
"expected 1 segment for [u8; 32], got {}",
segs.len()
)));
};
let mut bytes = [0_u8; 32];
hex::decode_to_slice(segment, &mut bytes)
.map_err(|err| Error::KeyParse(format!("invalid hex segment {segment:?}: {err}")))?;
Ok(bytes)
}
}
impl RecordId for String {
fn key_segments(&self) -> Vec<String> {
vec![self.clone()]
}
fn from_key_segments(segs: &[&str]) -> Result<Self> {
let [segment] = segs else {
return Err(Error::KeyParse(format!(
"expected 1 segment for String, got {}",
segs.len()
)));
};
Ok((*segment).to_string())
}
}
impl RecordId for BlobHash {
fn key_segments(&self) -> Vec<String> {
vec![self.to_string()]
}
fn from_key_segments(segs: &[&str]) -> Result<Self> {
let [segment] = segs else {
return Err(Error::KeyParse(format!(
"expected 1 segment for BlobHash, got {}",
segs.len()
)));
};
segment
.parse()
.map_err(|err| Error::KeyParse(format!("invalid BlobHash segment {segment:?}: {err}")))
}
}
impl RecordId for RunId {
fn key_segments(&self) -> Vec<String> {
vec![
self.created_at().format("%Y-%m-%d").to_string(),
self.to_string(),
]
}
fn from_key_segments(segs: &[&str]) -> Result<Self> {
if segs.len() != 2 {
return Err(Error::KeyParse(format!(
"expected 2 segments for RunId, got {}",
segs.len()
)));
}
segs[1]
.parse()
.map_err(|err| Error::KeyParse(format!("invalid RunId segment {:?}: {err}", segs[1])))
}
}

View file

@ -1,518 +0,0 @@
//! Thin typed storage wrapper for simple records that live directly in SlateDB.
//!
//! When adding a new persisted record type:
//! 1. Define the data struct.
//! 2. Implement [`Record`] for it with a stable `PREFIX`, `Id`, and `Codec`.
//! 3. Wrap `Repository<RecordType>` in a small domain store that exposes the
//! operations callers should use.
//!
//! Example:
//!
//! ```rust,ignore
//! use std::sync::Arc;
//!
//! use chrono::{DateTime, Utc};
//! use serde::{Deserialize, Serialize};
//!
//! use crate::record::{JsonCodec, Record, Repository};
//! use crate::Result;
//!
//! #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
//! struct Session {
//! id: String,
//! user_id: String,
//! expires_at: DateTime<Utc>,
//! }
//!
//! impl Record for Session {
//! type Id = String;
//! type Codec = JsonCodec;
//! const PREFIX: &'static str = "auth/session";
//!
//! fn id(&self) -> Self::Id {
//! self.id.clone()
//! }
//! }
//!
//! struct SessionStore {
//! repo: Repository<Session>,
//! }
//!
//! impl SessionStore {
//! fn new(db: Arc<slatedb::Db>) -> Self {
//! Self {
//! repo: Repository::new(db),
//! }
//! }
//!
//! async fn insert(&self, session: Session) -> Result<()> {
//! self.repo.put(&session).await
//! }
//!
//! async fn get(&self, id: &str) -> Result<Option<Session>> {
//! self.repo.get(&id.to_string()).await
//! }
//! }
//! ```
//!
//! `JsonCodec` is currently compiled only for tests; un-gate it when the
//! first production JSON-encoded record type appears.
//!
//! Keep `Repository<R>` internal. Domain-specific invariants such as consume
//! locks, token rotation, or marker-only behavior belong in the named store
//! that wraps it, not in this generic layer.
use std::marker::PhantomData;
use std::pin::Pin;
use std::sync::Arc;
use futures::stream::{self};
use futures::{Stream, StreamExt};
use slatedb::{Db, KeyValue};
use super::{Codec, Record, RecordId};
use crate::{Error, Result, keys};
/// Generic typed key/value operations shared by the simple record-backed
/// stores.
///
/// Test-only: production stores are SQLite-backed, so this exists solely to
/// exercise the retired Slate layout from tests.
pub(crate) struct Repository<R: Record> {
db: Arc<Db>,
_record: PhantomData<R>,
}
impl<R: Record> Repository<R> {
pub(crate) fn new(db: Arc<Db>) -> Self {
validate_prefix::<R>();
Self {
db,
_record: PhantomData,
}
}
#[cfg(test)]
pub(crate) async fn get(&self, id: &R::Id) -> Result<Option<R>> {
self.db
.get(key_for_id::<R>(id)?)
.await?
.map(|bytes| R::Codec::decode(&bytes))
.transpose()
}
#[cfg(test)]
pub(crate) async fn put(&self, record: &R) -> Result<()> {
let id = record.id();
self.put_at(&id, record).await
}
pub(crate) async fn put_at(&self, id: &R::Id, record: &R) -> Result<()> {
self.db
.put(key_for_id::<R>(id)?, R::Codec::encode(record)?)
.await?;
Ok(())
}
pub(crate) async fn delete(&self, id: &R::Id) -> Result<()> {
self.db.delete(key_for_id::<R>(id)?).await?;
Ok(())
}
#[cfg(test)]
pub(crate) async fn exists(&self, id: &R::Id) -> Result<bool> {
Ok(self.db.get(key_for_id::<R>(id)?).await?.is_some())
}
#[allow(
dead_code,
reason = "Part of the shared Repository surface; current consumers do not need value scans yet"
)]
pub(crate) fn scan_stream(&self) -> RepositoryStream<'_, (R::Id, R)> {
self.scan_prefix_stream(&[])
}
#[allow(
dead_code,
reason = "Part of the shared Repository surface; current consumers do not need value scans by sub-prefix yet"
)]
pub(crate) fn scan_prefix_stream<'a>(
&'a self,
extra_segments: &'a [&'a str],
) -> RepositoryStream<'a, (R::Id, R)> {
match prefix_key::<R>(extra_segments) {
Ok(prefix) => Box::pin(scan_entries(Arc::clone(&self.db), &prefix).map(|result| {
result
.map_err(Into::into)
.and_then(|entry| decode_entry::<R>(&entry))
})),
Err(err) => Box::pin(stream::once(async move { Err(err) })),
}
}
pub(crate) fn scan_ids_stream(&self) -> RepositoryStream<'_, R::Id> {
match prefix_key::<R>(&[]) {
Ok(prefix) => Box::pin(scan_entries(Arc::clone(&self.db), &prefix).map(|result| {
result
.map_err(Into::into)
.and_then(|entry| parse_entry_id::<R>(&entry))
})),
Err(err) => Box::pin(stream::once(async move { Err(err) })),
}
}
}
pub(crate) type RepositoryStream<'a, T> = Pin<Box<dyn Stream<Item = Result<T>> + Send + 'a>>;
pub(super) fn key_for_id<R: Record>(id: &R::Id) -> Result<keys::SlateKey> {
let id_segments = id.key_segments();
key_from_segments(
R::PREFIX
.split('/')
.chain(id_segments.iter().map(String::as_str)),
)
}
pub(super) fn prefix_key<R: Record>(extra_segments: &[&str]) -> Result<keys::SlateKey> {
prefix_from_segments(R::PREFIX.split('/').chain(extra_segments.iter().copied()))
}
fn decode_entry<R: Record>(entry: &KeyValue) -> Result<(R::Id, R)> {
let id = parse_entry_id::<R>(entry)?;
let value = R::Codec::decode(&entry.value)?;
Ok((id, value))
}
fn parse_entry_id<R: Record>(entry: &KeyValue) -> Result<R::Id> {
let raw_key = String::from_utf8(entry.key.to_vec())
.map_err(|err| Error::Other(format!("stored key is not valid UTF-8: {err}")))?;
let segments: Vec<&str> = keys::SlateKey::segments(&raw_key).collect();
let prefix_len = R::PREFIX.split('/').count();
if segments.len() < prefix_len {
return Err(Error::KeyParse(format!(
"key {raw_key:?} had {} segments, expected at least {} for prefix {}",
segments.len(),
prefix_len,
R::PREFIX
)));
}
if !segments[..prefix_len]
.iter()
.copied()
.eq(R::PREFIX.split('/'))
{
return Err(Error::KeyParse(format!(
"key {raw_key:?} did not match expected prefix {}",
R::PREFIX
)));
}
R::Id::from_key_segments(&segments[prefix_len..])
}
fn scan_entries(
db: Arc<Db>,
prefix: &keys::SlateKey,
) -> impl Stream<Item = std::result::Result<KeyValue, slatedb::Error>> + Send {
enum ScanState {
Opening { db: Arc<Db>, prefix: Vec<u8> },
Iterating(Box<slatedb::DbIterator>),
}
stream::try_unfold(
ScanState::Opening {
db,
prefix: prefix.as_ref().to_vec(),
},
|state| async move {
let mut iter = match state {
ScanState::Opening { db, prefix } => db.scan_prefix(prefix).await?,
ScanState::Iterating(iter) => *iter,
};
match iter.next().await? {
Some(entry) => Ok(Some((entry, ScanState::Iterating(Box::new(iter))))),
None => Ok(None),
}
},
)
}
fn validate_prefix<R: Record>() {
debug_assert!(
!R::PREFIX.is_empty()
&& !R::PREFIX.starts_with('/')
&& !R::PREFIX.ends_with('/')
&& R::PREFIX.split('/').all(|segment| !segment.is_empty()),
"Record::PREFIX must be a non-empty '/'-separated path with no empty segments: {}",
R::PREFIX
);
}
fn key_from_segments<'a>(segments: impl IntoIterator<Item = &'a str>) -> Result<keys::SlateKey> {
let mut segments = segments.into_iter();
let first = segments.next().ok_or_else(|| {
Error::Other("record key assembly requires at least one segment".to_string())
})?;
validate_key_segment(first)?;
let mut key = keys::SlateKey::new(first);
for segment in segments {
validate_key_segment(segment)?;
key = key.with(segment);
}
Ok(key)
}
fn prefix_from_segments<'a>(segments: impl IntoIterator<Item = &'a str>) -> Result<keys::SlateKey> {
Ok(key_from_segments(segments)?.into_prefix())
}
fn validate_key_segment(segment: &str) -> Result<()> {
if segment.as_bytes().contains(&b'\0') {
return Err(Error::InvalidKeySegment {
segment: segment.to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use futures::TryStreamExt;
use object_store::memory::InMemory;
use serde::{Deserialize, Serialize};
use super::Repository;
use crate::record::{JsonCodec, MarkerCodec, RawBytesCodec, Record, RecordId};
use crate::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct TestRecord {
id: TestId,
payload: String,
delete_me: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct TestId {
bucket: String,
name: String,
}
impl RecordId for TestId {
fn key_segments(&self) -> Vec<String> {
vec![self.bucket.clone(), self.name.clone()]
}
fn from_key_segments(segs: &[&str]) -> Result<Self> {
let [bucket, name] = segs else {
return Err(Error::KeyParse(format!(
"expected 2 segments for TestId, got {}",
segs.len()
)));
};
Ok(Self {
bucket: (*bucket).to_string(),
name: (*name).to_string(),
})
}
}
impl Record for TestRecord {
type Id = TestId;
type Codec = JsonCodec;
const PREFIX: &'static str = "test/repository";
fn id(&self) -> Self::Id {
self.id.clone()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct TestMarker;
impl Record for TestMarker {
type Id = String;
type Codec = MarkerCodec;
const PREFIX: &'static str = "test/marker";
fn id(&self) -> Self::Id {
unreachable!("marker records must use put_at")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct RawBlob(bytes::Bytes);
impl AsRef<[u8]> for RawBlob {
fn as_ref(&self) -> &[u8] {
self.0.as_ref()
}
}
impl From<bytes::Bytes> for RawBlob {
fn from(value: bytes::Bytes) -> Self {
Self(value)
}
}
impl Record for RawBlob {
type Id = String;
type Codec = RawBytesCodec;
const PREFIX: &'static str = "test/raw";
fn id(&self) -> Self::Id {
"blob".to_string()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct InvalidSegmentRecord {
id: String,
}
impl Record for InvalidSegmentRecord {
type Id = String;
type Codec = JsonCodec;
const PREFIX: &'static str = "test/invalid";
fn id(&self) -> Self::Id {
self.id.clone()
}
}
async fn db() -> Arc<slatedb::Db> {
Arc::new(
slatedb::Db::open("repository-tests", Arc::new(InMemory::new()))
.await
.unwrap(),
)
}
fn record(bucket: &str, name: &str, delete_me: bool) -> TestRecord {
TestRecord {
id: TestId {
bucket: bucket.to_string(),
name: name.to_string(),
},
payload: format!("{bucket}/{name}"),
delete_me,
}
}
#[tokio::test]
async fn put_get_delete_and_scan_round_trip() {
let repo = Repository::<TestRecord>::new(db().await);
let saved = record("bucket-a", "alpha", false);
assert!(repo.get(&saved.id()).await.unwrap().is_none());
repo.put(&saved).await.unwrap();
assert_eq!(repo.get(&saved.id()).await.unwrap(), Some(saved.clone()));
let records = [
saved.clone(),
record("bucket-a", "beta", false),
record("bucket-b", "alpha", false),
record("bucket-b", "beta", false),
record("bucket-c", "gamma", false),
];
for record in &records[1..] {
repo.put(record).await.unwrap();
}
let scanned = repo.scan_stream().try_collect::<Vec<_>>().await.unwrap();
assert_eq!(scanned.len(), records.len());
assert_eq!(scanned[0], (records[0].id(), records[0].clone()));
let bucket_a = repo
.scan_prefix_stream(&["bucket-a"])
.try_collect::<Vec<_>>()
.await
.unwrap();
assert_eq!(bucket_a, vec![
(records[0].id(), records[0].clone()),
(records[1].id(), records[1].clone()),
]);
repo.delete(&saved.id()).await.unwrap();
assert!(repo.get(&saved.id()).await.unwrap().is_none());
}
#[tokio::test]
async fn marker_records_use_put_at_exists_and_scan_ids() {
let repo = Repository::<TestMarker>::new(db().await);
let marker = TestMarker;
repo.put_at(&"marker-a".to_string(), &marker).await.unwrap();
repo.put_at(&"marker-b".to_string(), &marker).await.unwrap();
assert!(repo.exists(&"marker-a".to_string()).await.unwrap());
let ids = repo
.scan_ids_stream()
.try_collect::<Vec<_>>()
.await
.unwrap();
assert_eq!(ids, vec!["marker-a".to_string(), "marker-b".to_string()]);
repo.delete(&"marker-a".to_string()).await.unwrap();
assert!(!repo.exists(&"marker-a".to_string()).await.unwrap());
}
#[tokio::test]
async fn raw_bytes_codec_round_trips_bytes() {
let repo = Repository::<RawBlob>::new(db().await);
let blob = RawBlob(bytes::Bytes::from_static(b"hello"));
repo.put(&blob).await.unwrap();
assert_eq!(repo.get(&"blob".to_string()).await.unwrap(), Some(blob));
}
#[tokio::test]
async fn malformed_bytes_propagate_decode_errors() {
let db = db().await;
let repo = Repository::<TestRecord>::new(Arc::clone(&db));
db.put(
super::key_for_id::<TestRecord>(&TestId {
bucket: "bucket-a".to_string(),
name: "broken".to_string(),
})
.unwrap(),
b"not-json",
)
.await
.unwrap();
let error = repo
.get(&TestId {
bucket: "bucket-a".to_string(),
name: "broken".to_string(),
})
.await
.unwrap_err();
assert!(matches!(error, Error::Serde(_)));
}
#[tokio::test]
async fn invalid_key_segments_return_runtime_error() {
let repo = Repository::<InvalidSegmentRecord>::new(db().await);
let error = repo
.put(&InvalidSegmentRecord {
id: "bad\0segment".to_string(),
})
.await
.unwrap_err();
match error {
Error::InvalidKeySegment { segment } => assert_eq!(segment, "bad\0segment"),
other => panic!("expected invalid key segment error, got {other:?}"),
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,143 @@
//! The run summary (`Run`) a projection stands for: the row the run list,
//! the board and the scheduler read, derived from the projection.
use fabro_types::{
AskFabro, RepositoryRef, Run, RunId, RunLifecycle, RunLinks, RunModel, RunOrigin,
RunProjection, RunSize, RunTimestamps, WorkflowRef, sum_usage,
};
use lithos_llm::types::Usage;
/// The run summary (`Run`) a projection stands for: what the run list, the
/// board and the scheduler read.
#[must_use]
pub fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
let goal = state.spec.graph.goal().to_string();
let diff_summary = state
.conclusion
.as_ref()
.and_then(|conclusion| conclusion.diff.summary)
.or_else(|| {
state
.checkpoints
.iter()
.rev()
.find_map(|checkpoint| checkpoint.diff.summary)
});
let current_question = state
.pending_interviews
.iter()
.min_by(|(left_id, left), (right_id, right)| {
left.started_at
.cmp(&right.started_at)
.then_with(|| left_id.cmp(right_id))
})
.map(|(_, record)| record.question.clone());
let models = run_models(state);
let created_by = state.spec.provenance.subject.clone();
let source_directory = state.spec.source_directory.clone();
let repo_origin_url = state.spec.git.as_ref().map(|git| git.origin_url.clone());
let start_time = state.start.as_ref().map(|start| start.start_time);
let completed_at = state
.conclusion
.as_ref()
.map(|conclusion| conclusion.timestamp);
let run_timing = state
.conclusion
.as_ref()
.map(|conclusion| conclusion.timing);
let usage = projected_usage(state);
Run {
id: *run_id,
parent_id: state.parent_id,
children_count: 0,
title: state.title().into_owned(),
goal,
workflow: WorkflowRef {
slug: state.spec.workflow_slug.clone(),
name: state.spec.workflow_name().map(ToOwned::to_owned),
graph_name: state.spec.graph_name().map(ToOwned::to_owned),
node_count: i64::try_from(state.spec.graph.nodes.len())
.expect("graph node count should fit in i64"),
edge_count: i64::try_from(state.spec.graph.edges.len())
.expect("graph edge count should fit in i64"),
},
automation: state.spec.automation.clone(),
repository: Some(RepositoryRef::from_origin_and_source(
repo_origin_url,
source_directory.as_deref(),
)),
created_by,
origin: RunOrigin::default(),
labels: state.spec.labels.clone(),
lifecycle: RunLifecycle {
status: state.status,
approval: state.approval.clone(),
pending_control: state.pending_control,
queue_position: None,
error: None,
archived: state.archived_at.is_some(),
archived_at: state.archived_at,
},
sandbox: state.sandbox.clone(),
models,
source_directory,
timestamps: RunTimestamps {
created_at: run_id.created_at(),
started_at: start_time,
last_event_at: Some(state.last_event_at),
completed_at,
},
timing: run_timing,
usage,
size: RunSize::from_cost(usage.cost),
ask_fabro: AskFabro::default(),
diff: diff_summary,
pull_request: state.pull_request.clone(),
current_question,
superseded_by: state.superseded_by,
retried_from: state.retried_from,
links: RunLinks {
web: state.web_url.clone(),
},
}
}
/// The run's usage: the conclusion's total once the run ended, else the sum
/// of every non-boundary stage's usage so far.
#[must_use]
pub fn projected_usage(state: &RunProjection) -> Usage {
if let Some(usage) = state
.conclusion
.as_ref()
.and_then(|conclusion| conclusion.usage)
{
return usage;
}
sum_usage(
state
.iter_stages()
.filter(|(stage_id, _)| !state.is_boundary_stage(stage_id.node_id()))
.map(|(_, stage)| stage.usage),
)
}
fn run_models(state: &RunProjection) -> Vec<RunModel> {
let mut models = state
.iter_stages()
.filter_map(|(_, stage)| stage.model.as_ref())
.map(|model| RunModel {
provider: Some(model.provider.to_string()),
name: model.model_id.to_string(),
})
.collect::<Vec<_>>();
models.sort_by(|left, right| {
left.provider
.cmp(&right.provider)
.then_with(|| left.name.cmp(&right.name))
});
models.dedup_by(|left, right| left.provider == right.provider && left.name == right.name);
models
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,692 +0,0 @@
use std::sync::{Arc, Mutex as StdMutex, MutexGuard as StdMutexGuard};
use bytes::Bytes;
use fabro_types::{BlobHash, RunEvent, RunId, SessionId};
use futures::Stream;
use tokio::sync::{Mutex as AsyncMutex, broadcast, mpsc};
use tokio_stream::wrappers::UnboundedReceiverStream;
use crate::run_state::{EventProjectionCache, ProjectedRun, RunProjectionReducer};
use crate::{
BlobStore, Error, EventEnvelope, EventPayload, Result, RunProjection, RunSummaryStore, StageId,
run_summary_store,
};
/// Broadcast capacity for live event subscribers; a lagging subscriber refills
/// from SQLite.
const EVENT_BROADCAST_CAPACITY: usize = 1024;
#[derive(Clone)]
pub struct RunDatabase {
inner: Arc<RunDatabaseInner>,
read_only: bool,
}
impl std::fmt::Debug for RunDatabase {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RunDatabase")
.field("run_id", &self.inner.run_id)
.field("read_only", &self.read_only)
.finish_non_exhaustive()
}
}
pub(crate) struct RunDatabaseInner {
pub(crate) run_id: RunId,
blob_store: Arc<BlobStore>,
pub(crate) state_lock: AsyncMutex<()>,
projection_cache: StdMutex<EventProjectionCache>,
run_summary_store: Arc<RunSummaryStore>,
event_tx: broadcast::Sender<EventEnvelope>,
}
impl RunDatabaseInner {
fn lock_projection_cache(&self) -> StdMutexGuard<'_, EventProjectionCache> {
self.projection_cache.lock().expect(
"event projection cache mutex is never poisoned: no code panics while holding this lock",
)
}
}
impl RunDatabase {
pub(crate) async fn build(
run_id: RunId,
read_only: bool,
blob_store: Arc<BlobStore>,
run_summary_store: Arc<RunSummaryStore>,
) -> Result<Self> {
let projected = run_summary_store.load_projection(&run_id).await?;
Ok(Self::from_event_projection_cache(
run_id,
read_only,
blob_store,
run_summary_store,
projected.into(),
))
}
pub(crate) fn build_empty(
run_id: RunId,
blob_store: Arc<BlobStore>,
run_summary_store: Arc<RunSummaryStore>,
) -> Self {
Self::from_event_projection_cache(
run_id,
false,
blob_store,
run_summary_store,
EventProjectionCache::default(),
)
}
fn from_event_projection_cache(
run_id: RunId,
read_only: bool,
blob_store: Arc<BlobStore>,
run_summary_store: Arc<RunSummaryStore>,
projection_cache: EventProjectionCache,
) -> Self {
let (event_tx, _) = broadcast::channel(EVENT_BROADCAST_CAPACITY);
Self {
inner: Arc::new(RunDatabaseInner {
run_id,
blob_store,
state_lock: AsyncMutex::new(()),
projection_cache: StdMutex::new(projection_cache),
run_summary_store,
event_tx,
}),
read_only,
}
}
pub(crate) fn from_inner(inner: Arc<RunDatabaseInner>) -> Self {
Self {
inner,
read_only: false,
}
}
pub(crate) fn read_only_clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
read_only: true,
}
}
pub(crate) fn inner_arc(&self) -> Arc<RunDatabaseInner> {
Arc::clone(&self.inner)
}
pub(crate) fn run_id(&self) -> RunId {
self.inner.run_id
}
pub fn subscribe(&self) -> broadcast::Receiver<EventEnvelope> {
self.inner.event_tx.subscribe()
}
pub(super) async fn projection_snapshot(&self) -> Result<Arc<RunProjection>> {
let _state_guard = self.inner.state_lock.lock().await;
self.projection_snapshot_locked()
}
fn projection_snapshot_locked(&self) -> Result<Arc<RunProjection>> {
self.inner
.lock_projection_cache()
.state
.clone()
.ok_or_else(|| {
Error::InvalidEvent(format!(
"run {} has no run.created event",
self.inner.run_id
))
})
}
pub(crate) fn install_in_memory_state(&self, projected: ProjectedRun) {
*self.inner.lock_projection_cache() = projected.into();
}
pub(crate) fn publish(&self, event: &EventEnvelope) {
let _ = self.inner.event_tx.send(event.clone());
}
pub(crate) async fn commit_first_event(
&self,
payload: &EventPayload,
) -> Result<(EventEnvelope, ProjectedRun)> {
payload.validate(&self.inner.run_id)?;
let event = RunEvent::try_from(payload)?;
let _state_guard = self.inner.state_lock.lock().await;
if self.inner.lock_projection_cache().last_seq != 0 {
return Err(Error::RunAlreadyExists(self.inner.run_id.to_string()));
}
self.commit_event_locked(payload, event).await
}
}
impl RunDatabase {
/// Appends an event after validating it against the current run projection.
///
/// Every returned error means the event/current-row transaction did not
/// commit and is safe to retry. Memory and broadcasts advance only after
/// the SQLite commit succeeds.
pub async fn append_event(&self, payload: &EventPayload) -> Result<u32> {
Ok(Box::pin(self.append_event_envelope(payload)).await?.seq)
}
/// Atomically appends `payload` when `predicate` matches the latest run
/// projection.
pub async fn append_event_if(
&self,
payload: &EventPayload,
predicate: impl FnOnce(&RunProjection) -> bool,
) -> Result<Option<u32>> {
if self.read_only {
return Err(Error::ReadOnly);
}
payload.validate(&self.inner.run_id)?;
let event = RunEvent::try_from(payload)?;
let _state_guard = self.inner.state_lock.lock().await;
let projection = self.projection_snapshot_locked()?;
if !predicate(&projection) {
return Ok(None);
}
Ok(Some(
Box::pin(self.append_event_envelope_locked(payload, event))
.await?
.seq,
))
}
/// Appends and returns the stored event envelope after pre-write reduction.
pub async fn append_event_envelope(&self, payload: &EventPayload) -> Result<EventEnvelope> {
if self.read_only {
return Err(Error::ReadOnly);
}
payload.validate(&self.inner.run_id)?;
let event = RunEvent::try_from(payload)?;
let _state_guard = self.inner.state_lock.lock().await;
Box::pin(self.append_event_envelope_locked(payload, event)).await
}
async fn append_event_envelope_locked(
&self,
payload: &EventPayload,
event: RunEvent,
) -> Result<EventEnvelope> {
let (envelope, projected) = self.commit_event_locked(payload, event).await?;
// Keep post-commit propagation await-free: cancellation after SQLite
// commits must not leave in-memory state stale or omit the broadcast.
let platform_record = run_summary_store::platform_record_written(&projected, &envelope);
self.install_in_memory_state(projected);
self.publish(&envelope);
if platform_record.is_some() {
self.inner
.run_summary_store
.notify_platform_record(self.inner.run_id);
}
Ok(envelope)
}
async fn commit_event_locked(
&self,
payload: &EventPayload,
event: RunEvent,
) -> Result<(EventEnvelope, ProjectedRun)> {
let (expected_last_seq, mut next_state) = {
let cache = self.inner.lock_projection_cache();
(cache.last_seq, cache.state.clone())
};
let seq = run_summary_store::next_event_seq_after(expected_last_seq)?;
let prospective = EventEnvelope { seq, event };
apply_cached_projection_event(&mut next_state, &prospective).map_err(event_rejected)?;
let next_projection =
next_state.expect("applying a valid event should always produce a projection");
let projected = ProjectedRun::new(self.inner.run_id, next_projection, seq);
let mut transaction = self.inner.run_summary_store.begin().await?;
let envelope = if expected_last_seq == 0 {
RunSummaryStore::insert_first_event_on_connection(&mut transaction, &projected, payload)
.await?
} else {
RunSummaryStore::append_event_on_connection(
&mut transaction,
expected_last_seq,
&projected,
payload,
)
.await?
};
transaction.commit().await?;
Ok((envelope, projected))
}
pub async fn list_events(&self) -> Result<Vec<EventEnvelope>> {
self.inner
.run_summary_store
.list_events_for_run(&self.inner.run_id)
.await
}
pub async fn last_event_seq(&self) -> Result<Option<u32>> {
self.inner.run_summary_store.head(&self.inner.run_id).await
}
/// Returns up to `limit + 1` events starting at `start_seq`.
pub async fn list_events_from_with_limit(
&self,
start_seq: u32,
limit: usize,
) -> Result<Vec<EventEnvelope>> {
self.inner
.run_summary_store
.list_events_from_with_limit(&self.inner.run_id, start_seq, limit)
.await
}
/// Returns up to `limit + 1` events before `before_seq`, newest first.
pub async fn list_events_before_with_limit(
&self,
before_seq: Option<u32>,
limit: usize,
) -> Result<Vec<EventEnvelope>> {
self.inner
.run_summary_store
.list_events_before_with_limit(&self.inner.run_id, before_seq, limit)
.await
}
pub async fn get_event(&self, seq: u32) -> Result<Option<EventEnvelope>> {
self.inner
.run_summary_store
.get_event_for_run(&self.inner.run_id, seq)
.await
}
pub async fn list_events_for_stage_from_with_limit(
&self,
stage_id: &StageId,
start_seq: u32,
limit: usize,
) -> Result<Vec<EventEnvelope>> {
self.inner
.run_summary_store
.list_events_for_stage_from_with_limit(&self.inner.run_id, stage_id, start_seq, limit)
.await
}
pub async fn list_events_for_session_from_with_limit(
&self,
session_id: SessionId,
start_seq: u32,
limit: usize,
) -> Result<Vec<EventEnvelope>> {
self.inner
.run_summary_store
.list_events_for_session_from_with_limit(
&self.inner.run_id,
&session_id,
start_seq,
limit,
)
.await
}
pub fn watch_events_from(
&self,
seq: u32,
) -> Result<std::pin::Pin<Box<dyn Stream<Item = Result<EventEnvelope>> + Send>>> {
let inner = Arc::clone(&self.inner);
// Subscribe before the durable catch-up query to close the read/subscribe race.
let mut broadcasts = inner.event_tx.subscribe();
let (sender, receiver) = mpsc::unbounded_channel();
tokio::spawn(async move {
let mut next_seq = seq;
if !refill_from_sql(&inner, &sender, &mut next_seq).await {
return;
}
loop {
match broadcasts.recv().await {
Ok(event) if event.seq < next_seq => {}
Ok(event) if event.seq == next_seq => {
next_seq = event.seq.saturating_add(1);
if sender.send(Ok(event)).is_err() {
return;
}
}
Ok(_) | Err(broadcast::error::RecvError::Lagged(_)) => {
if !refill_from_sql(&inner, &sender, &mut next_seq).await {
return;
}
}
Err(broadcast::error::RecvError::Closed) => return,
}
}
});
Ok(Box::pin(UnboundedReceiverStream::new(receiver)))
}
pub async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
if self.read_only {
return Err(Error::ReadOnly);
}
self.inner.blob_store.write(data).await
}
pub async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
self.inner.blob_store.read(blob_hash).await
}
pub async fn state(&self) -> Result<RunProjection> {
Ok(Arc::unwrap_or_clone(self.projection_snapshot().await?))
}
}
async fn refill_from_sql(
inner: &RunDatabaseInner,
sender: &mpsc::UnboundedSender<Result<EventEnvelope>>,
next_seq: &mut u32,
) -> bool {
let events = match inner
.run_summary_store
.list_events_from_with_limit(&inner.run_id, *next_seq, usize::MAX)
.await
{
Ok(events) => events,
Err(error) => {
let _ = sender.send(Err(error));
return false;
}
};
for event in events {
*next_seq = event.seq.saturating_add(1);
if sender.send(Ok(event)).is_err() {
return false;
}
}
true
}
fn event_rejected(error: Error) -> Error {
Error::EventRejected {
source: Box::new(error),
}
}
fn apply_cached_projection_event(
state: &mut Option<Arc<RunProjection>>,
event: &EventEnvelope,
) -> Result<()> {
if let Some(projection) = state {
Arc::make_mut(projection).apply_event(event)?;
} else {
*state = Some(Arc::new(RunProjection::apply_events(
std::slice::from_ref(event),
)?));
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use fabro_types::{Graph, RunId, WorkflowSettings, test_support};
use futures::StreamExt as _;
use object_store::memory::InMemory;
use serde_json::json;
use tokio::task;
use crate::{EventPayload, test_support as store_test_support};
fn run_created_payload(run_id: &RunId) -> EventPayload {
EventPayload::new(
json!({
"id": "evt-created",
"ts": "2026-04-09T11:59:00Z",
"run_id": run_id.to_string(),
"event": "run.created",
"properties": {
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"provenance": test_support::test_run_provenance(),
},
}),
run_id,
)
.unwrap()
}
fn stage_payload(run_id: &RunId, index: u32) -> EventPayload {
EventPayload::new(
json!({
"id": format!("evt-{index}"),
"ts": "2026-04-09T12:00:00Z",
"run_id": run_id.to_string(),
"event": "stage.prompt",
"node_id": "build",
"stage_id": "build@1",
"properties": { "visit": 1, "text": format!("prompt {index}") },
}),
run_id,
)
.unwrap()
}
fn store() -> crate::Database {
store_test_support::test_database(
Arc::new(InMemory::new()),
"run-store-sql-tests",
Duration::from_millis(1),
None,
)
}
#[tokio::test]
async fn first_and_later_events_commit_to_sql_before_publication() {
let store = store();
let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap();
let run = store
.create_run_with_first_event(&run_id, &run_created_payload(&run_id))
.await
.unwrap();
assert_eq!(
run.append_event(&stage_payload(&run_id, 2)).await.unwrap(),
2
);
assert_eq!(
run.list_events()
.await
.unwrap()
.iter()
.map(|event| event.seq)
.collect::<Vec<_>>(),
vec![1, 2]
);
}
/// A Petri run's legacy lifecycle events leave platform records beside
/// them, in the same commit, and the hook fires after each; a legacy
/// run's events leave none.
#[tokio::test]
async fn a_petri_runs_lifecycle_events_become_platform_records_and_wake_the_hook() {
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::platform_records::{PlatformRecord, PlatformRecordKind, RunLifecycleKind};
let store = store();
let woken = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&woken);
store.set_platform_record_hook(Arc::new(move |_| {
counter.fetch_add(1, Ordering::SeqCst);
}));
let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65E".parse().unwrap();
let mut created = run_created_payload(&run_id);
let mut petri = serde_json::to_value(&created).unwrap();
petri["properties"]["engine"] = json!({
"kind": "petri",
"graph": { "blob": fabro_types::BlobHash::new(b"graph").to_string(), "digest": "d" },
});
created = EventPayload::new(petri, &run_id).unwrap();
let run = store
.create_run_with_first_event(&run_id, &created)
.await
.unwrap();
run.append_event(
&EventPayload::new(
json!({
"id": "evt-starting",
"ts": "2026-04-09T12:00:00Z",
"run_id": run_id.to_string(),
"event": "run.start_requested",
"properties": { "resume": false },
}),
&run_id,
)
.unwrap(),
)
.await
.unwrap();
run.append_event(&stage_payload(&run_id, 3)).await.unwrap();
let records = store
.run_summary_store()
.platform_records()
.read(&run_id)
.await
.unwrap();
let kinds: Vec<PlatformRecordKind> = records.iter().map(|r| r.record.kind()).collect();
assert_eq!(kinds, vec![
PlatformRecordKind::RunCreated,
PlatformRecordKind::RunLifecycle
]);
let PlatformRecord::RunLifecycle(lifecycle) = &records[1].record else {
panic!("the second record is the lifecycle");
};
assert_eq!(lifecycle.transition, RunLifecycleKind::StartRequested);
assert_eq!(lifecycle.source.as_deref(), Some("start"));
assert_eq!(woken.load(Ordering::SeqCst), 2, "one wake-up per record");
let legacy_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65F".parse().unwrap();
store
.create_run_with_first_event(&legacy_id, &run_created_payload(&legacy_id))
.await
.unwrap();
assert!(
store
.run_summary_store()
.platform_records()
.read(&legacy_id)
.await
.unwrap()
.is_empty(),
"a legacy run leaves no platform records"
);
assert_eq!(woken.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn watcher_catches_up_from_sql_without_duplicates() {
let store = store();
let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65B".parse().unwrap();
let run = store
.create_run_with_first_event(&run_id, &run_created_payload(&run_id))
.await
.unwrap();
run.append_event(&stage_payload(&run_id, 2)).await.unwrap();
let mut stream = run.watch_events_from(1).unwrap();
assert_eq!(stream.next().await.unwrap().unwrap().seq, 1);
assert_eq!(stream.next().await.unwrap().unwrap().seq, 2);
run.append_event(&stage_payload(&run_id, 3)).await.unwrap();
assert_eq!(stream.next().await.unwrap().unwrap().seq, 3);
}
#[tokio::test]
async fn simultaneous_appends_allocate_one_contiguous_sql_sequence() {
let store = store();
let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65C".parse().unwrap();
let run = store
.create_run_with_first_event(&run_id, &run_created_payload(&run_id))
.await
.unwrap();
let mut tasks = Vec::new();
for index in 2..=33 {
let writer = run.clone();
tasks.push(tokio::spawn(async move {
writer.append_event(&stage_payload(&run_id, index)).await
}));
}
let mut sequences = Vec::new();
for task in tasks {
sequences.push(task.await.unwrap().unwrap());
}
sequences.sort_unstable();
assert_eq!(sequences, (2..=33).collect::<Vec<_>>());
assert_eq!(run.last_event_seq().await.unwrap(), Some(33));
assert_eq!(run.list_events().await.unwrap().len(), 33);
}
#[tokio::test]
async fn simultaneous_creation_has_exactly_one_winner() {
let store = store();
let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65D".parse().unwrap();
let left_payload = run_created_payload(&run_id);
let right_payload = run_created_payload(&run_id);
let left = store.create_run_with_first_event(&run_id, &left_payload);
let right = store.create_run_with_first_event(&run_id, &right_payload);
let (left, right) = tokio::join!(left, right);
assert_eq!(usize::from(left.is_ok()) + usize::from(right.is_ok()), 1);
let error = left.err().or_else(|| right.err()).unwrap();
assert!(matches!(error, crate::Error::RunAlreadyExists(_)));
assert_eq!(
store
.open_run(&run_id)
.await
.unwrap()
.list_events()
.await
.unwrap()
.len(),
1
);
}
#[tokio::test]
async fn readers_observe_only_complete_committed_prefixes_during_appends() {
let store = store();
let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65E".parse().unwrap();
let run = store
.create_run_with_first_event(&run_id, &run_created_payload(&run_id))
.await
.unwrap();
let writer = run.clone();
let write_task = tokio::spawn(async move {
for index in 2..=25 {
writer.append_event(&stage_payload(&run_id, index)).await?;
task::yield_now().await;
}
crate::Result::Ok(())
});
while !write_task.is_finished() {
let events = run.list_events().await.unwrap();
assert!(
events
.iter()
.enumerate()
.all(|(index, event)| event.seq as usize == index + 1)
);
task::yield_now().await;
}
write_task.await.unwrap().unwrap();
assert_eq!(run.list_events().await.unwrap().len(), 25);
}
}

View file

@ -1,15 +1,11 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use fabro_types::{BlobHash, RunId};
use object_store::ObjectStore;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use crate::keys::SlateKey;
#[cfg(test)]
use crate::{AuthCodeStore, AuthSessionStore};
use crate::{BlobStore, Database, Result, RunSummaryStore};
use crate::{BlobStore, Database, RunSummaryStore};
/// Returns an isolated SQLite blob authority backed by its own in-memory
/// database.
@ -25,17 +21,21 @@ pub fn test_blob_store() -> Arc<BlobStore> {
])))
}
/// The migrations a run summary fixture installs: the `runs` row, the
/// platform records and the projection tables.
const RUN_SUMMARY_MIGRATIONS: &[&str] = &[
fabro_db::RUNS_MIGRATION_SQL,
fabro_db::PETRI_PROJECTION_MIGRATION_SQL,
];
/// Returns an isolated SQLite run-summary store backed by its own in-memory
/// database and the production `runs` and `run_events` schemas.
/// database and the production `runs`, platform record and projection
/// schemas.
#[must_use]
pub fn test_run_summary_store() -> Arc<RunSummaryStore> {
Arc::new(RunSummaryStore::new(lazy_in_memory_pool(&[
fabro_db::RUNS_MIGRATION_SQL,
fabro_db::RUN_EVENTS_MIGRATION_SQL,
fabro_db::RUN_HISTORY_ACTIVATION_MIGRATION_SQL,
fabro_db::RUN_EVENT_SESSION_OWNER_MIGRATION_SQL,
fabro_db::PETRI_PROJECTION_MIGRATION_SQL,
])))
Arc::new(RunSummaryStore::new(lazy_in_memory_pool(
RUN_SUMMARY_MIGRATIONS,
)))
}
/// An isolated in-memory SQLite pool with `migrations` installed on first
@ -111,13 +111,7 @@ pub fn test_run_summary_store_at(store_dir: &Path) -> Arc<RunSummaryStore> {
Arc::new(RunSummaryStore::new(lazy_file_pool(
test_run_summary_store_path(store_dir),
"runs",
&[
fabro_db::RUNS_MIGRATION_SQL,
fabro_db::RUN_EVENTS_MIGRATION_SQL,
fabro_db::RUN_HISTORY_ACTIVATION_MIGRATION_SQL,
fabro_db::RUN_EVENT_SESSION_OWNER_MIGRATION_SQL,
fabro_db::PETRI_PROJECTION_MIGRATION_SQL,
],
RUN_SUMMARY_MIGRATIONS,
)))
}
@ -160,123 +154,40 @@ fn lazy_file_pool(
.connect_lazy_with(options)
}
/// Builds a test database whose SQLite blob and run-history authorities are
/// durable beside `store_dir` and shared by reopen-style handles.
/// Builds a test database whose blob and run summary stores are durable
/// beside `store_dir` and shared by reopen-style handles.
#[must_use]
pub fn test_database_at(
object_store: Arc<dyn ObjectStore>,
base_prefix: impl Into<String>,
flush_interval: Duration,
cache_path: Option<PathBuf>,
store_dir: &Path,
) -> Database {
test_database_with_stores(
object_store,
base_prefix,
flush_interval,
cache_path,
pub fn test_database_at(store_dir: &Path) -> Database {
Database::new(
test_blob_store_at(store_dir),
test_run_summary_store_at(store_dir),
)
}
/// Builds a Slate-backed run database with its own isolated blob authority.
/// Builds a run database with its own isolated blob and run summary
/// stores.
#[must_use]
pub fn test_database(
object_store: Arc<dyn ObjectStore>,
base_prefix: impl Into<String>,
flush_interval: Duration,
cache_path: Option<PathBuf>,
) -> Database {
test_database_with_blobs(
object_store,
base_prefix,
flush_interval,
cache_path,
test_blob_store(),
)
pub fn test_database() -> Database {
Database::new(test_blob_store(), test_run_summary_store())
}
/// Builds a Slate-backed run database sharing an explicit blob authority.
/// Builds a run database sharing an explicit blob store.
///
/// Use this for reopen-style tests where two store handles must observe the
/// same signed SQLite blob table, mirroring the one blob authority a
/// production process shares across every run handle.
/// same blob table, mirroring the one blob authority a production process
/// shares across every run handle.
#[must_use]
pub fn test_database_with_blobs(
object_store: Arc<dyn ObjectStore>,
base_prefix: impl Into<String>,
flush_interval: Duration,
cache_path: Option<PathBuf>,
blobs: Arc<BlobStore>,
) -> Database {
test_database_with_stores(
object_store,
base_prefix,
flush_interval,
cache_path,
blobs,
test_run_summary_store(),
)
pub fn test_database_with_blobs(blobs: Arc<BlobStore>) -> Database {
Database::new(blobs, test_run_summary_store())
}
/// Builds a Slate-backed run database with explicit shared SQLite stores.
///
/// Use this only when a test needs a failing, persistent, or shared store;
/// ordinary fixtures should use [`test_database`].
/// Builds a run database with explicit shared stores.
#[must_use]
pub fn test_database_with_stores(
object_store: Arc<dyn ObjectStore>,
base_prefix: impl Into<String>,
flush_interval: Duration,
cache_path: Option<PathBuf>,
blobs: Arc<BlobStore>,
run_summaries: Arc<RunSummaryStore>,
) -> Database {
Database::new(
object_store,
base_prefix,
flush_interval,
cache_path,
blobs,
run_summaries,
)
}
/// Seeds one canonical row in the legacy SlateDB blob keyspace.
pub async fn put_legacy_blob(database: &Database, bytes: &[u8]) -> Result<BlobHash> {
let hash = BlobHash::new(bytes);
let source = database.open_db().await?;
source
.put(SlateKey::new("blobs").with("sha256").with(hash), bytes)
.await?;
source.flush().await?;
Ok(hash)
}
/// Writes an event without append validation to model a log corrupted by an
/// older Fabro version.
pub async fn put_unvalidated_run_event(
database: &Database,
run_id: &RunId,
seq: u32,
payload: &serde_json::Value,
) -> Result<()> {
database
.put_unvalidated_run_event(run_id, seq, payload)
.await
}
/// Seeds one event in the retired Slate run-history keyspace.
pub async fn put_legacy_run_event(
database: &Database,
run_id: &RunId,
seq: u32,
payload: &serde_json::Value,
) -> Result<()> {
database
.put_unvalidated_legacy_run_event(run_id, seq, payload)
.await
Database::new(blobs, run_summaries)
}
/// Connects to a migrated `fabro.sqlite3` in `directory` and returns its pool.

View file

@ -1,63 +0,0 @@
use fabro_types::{RunEvent, RunId};
use serde::{Deserialize, Serialize};
use crate::{Error, Result};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct EventPayload(serde_json::Value);
impl EventPayload {
pub fn new(value: serde_json::Value, expected_run_id: &RunId) -> Result<Self> {
let payload = Self(value);
payload.validate(expected_run_id)?;
Ok(payload)
}
pub(crate) fn validate(&self, expected_run_id: &RunId) -> Result<()> {
let obj = self
.0
.as_object()
.ok_or_else(|| Error::InvalidEvent("event payload must be a JSON object".into()))?;
for field in ["id", "ts", "run_id", "event"] {
match obj.get(field) {
Some(serde_json::Value::String(_)) => {}
_ => {
return Err(Error::InvalidEvent(format!(
"missing or non-string required field: {field}"
)));
}
}
}
match obj.get("run_id") {
Some(serde_json::Value::String(run_id)) if run_id == &expected_run_id.to_string() => {
Ok(())
}
Some(serde_json::Value::String(run_id)) => Err(Error::InvalidEvent(format!(
"payload run_id {run_id:?} does not match store run_id {expected_run_id:?}"
))),
_ => Err(Error::InvalidEvent(
"missing or non-string required field: run_id".into(),
)),
}
}
pub fn into_inner(self) -> serde_json::Value {
self.0
}
pub fn as_value(&self) -> &serde_json::Value {
&self.0
}
}
impl TryFrom<&EventPayload> for RunEvent {
type Error = Error;
fn try_from(value: &EventPayload) -> Result<Self> {
Self::from_value(value.as_value().clone())
.map_err(|err| Error::InvalidEvent(format!("invalid stored event: {err}")))
}
}

View file

@ -30,20 +30,12 @@ fn sample_run_spec() -> RunSpec {
fn sample_checkpoint() -> Checkpoint {
Checkpoint {
timestamp: Utc
timestamp: Utc
.with_ymd_and_hms(2026, 4, 20, 12, 0, 0)
.single()
.expect("timestamp should be representable"),
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: Some("ship".to_string()),
git_commit_sha: Some("abc123".to_string()),
loop_failure_signatures: HashMap::new(),
restart_failure_signatures: HashMap::new(),
node_visits: HashMap::from([("build".to_string(), 2usize)]),
current_node: "build".to_string(),
git_commit_sha: Some("abc123".to_string()),
}
}

View file

@ -225,11 +225,9 @@ impl WorkflowVersionStore {
mod tests {
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use fabro_store::{BlobStore, test_support};
use fabro_types::{WorkflowPath, WorkflowVersion, WorkflowVersionId};
use object_store::memory::InMemory;
use super::{WorkflowVersionStore, WorkflowVersionStoreError};
use crate::ValidatedWorkflowVersion;
@ -260,12 +258,7 @@ mod tests {
}
fn stores() -> (Arc<BlobStore>, WorkflowVersionStore) {
let database = test_support::test_database(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
None,
);
let database = test_support::test_database();
let blobs = database.blobs();
let versions = WorkflowVersionStore::new(Arc::clone(&blobs));
(blobs, versions)

View file

@ -13,7 +13,6 @@ use fabro_util::error::{SharedError, collect_causes, collect_chain, render_with_
use regex::Regex;
use thiserror::Error as ThisError;
use crate::event::RunEventPersistenceError;
use crate::outcome::{FailureDetail, Outcome, StageOutcome};
/// Classify an LLM error into a `FailureCategory` based on its structure.
@ -712,12 +711,6 @@ impl From<fabro_template::TemplateError> for Error {
}
}
impl From<RunEventPersistenceError> for Error {
fn from(err: RunEventPersistenceError) -> Self {
Self::engine_with_source("run event persistence failed", err)
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]

View file

@ -1,27 +0,0 @@
mod convert;
mod driver_events;
mod emitter;
mod events;
mod names;
mod redaction;
mod sink;
mod stored_fields;
#[cfg(test)]
mod test_support;
pub use fabro_types::{EventBody, RunNoticeCode, RunNoticeLevel};
pub use self::convert::{to_run_event, to_run_event_at};
pub use self::driver_events::DriverEventRecorder;
pub use self::emitter::Emitter;
pub use self::events::{Event, SandboxLifecycle};
pub use self::names::event_name;
pub use self::redaction::{
build_redacted_event_payload, event_payload_from_redacted_json, redacted_event_json,
};
pub use self::sink::{
RunEventLogger, RunEventPersistenceError, RunEventSink, StoreProgressLogger, append_event,
append_event_if, append_event_to_sink, create_run,
};
pub use self::stored_fields::{actor_from_principal, principal_from_actor};
pub use crate::stage_scope::StageScope;

File diff suppressed because it is too large Load diff

View file

@ -1,36 +0,0 @@
//! The sandbox driver's events for a run's sandbox, kept as run events.
//!
//! A run's sandbox is created or attached with a driver [`EventContext`]
//! whose observer is a [`DriverEventRecorder`]. Everything the driver
//! reports about the sandbox — the operations it performs and their
//! outcome, progress inside a create such as an image pull, snapshot
//! builds, state observations, notices — is stored whole as an
//! [`Event::SandboxDriver`], named from the event (see
//! `fabro_types::sandbox_driver_event_name`).
//!
//! [`EventContext`]: sandbox_driver::EventContext
use std::sync::Arc;
use async_trait::async_trait;
use sandbox_driver::{Event as DriverEvent, EventObserver};
use super::{Emitter, Event};
/// Records every event the driver reports as a run event.
pub struct DriverEventRecorder {
emitter: Arc<Emitter>,
}
impl DriverEventRecorder {
pub fn new(emitter: Arc<Emitter>) -> Self {
Self { emitter }
}
}
#[async_trait]
impl EventObserver for DriverEventRecorder {
async fn observe(&self, event: DriverEvent) {
self.emitter.emit(&Event::SandboxDriver { event });
}
}

View file

@ -1,261 +0,0 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use ::fabro_types::{ExecOutputTail, RunEvent, RunId, RunNoticeCode, RunNoticeLevel};
use chrono::Utc;
use tokio::time::Instant;
use super::Event;
use super::convert::to_run_event_at;
use super::sink::{RunEventLogger, RunEventPersistenceError};
use crate::millis_u64;
use crate::stage_scope::StageScope;
/// Listener callback type for workflow run events.
type EventListener = Arc<dyn Fn(&RunEvent) + Send + Sync>;
/// Callback-based event emitter for workflow run events.
pub struct Emitter {
run_id: RunId,
listeners: std::sync::Mutex<Vec<EventListener>>,
/// The persistence path. Events reach it before any listener, and
/// [`Emitter::emit_durable`] waits for it.
persisters: std::sync::Mutex<Vec<RunEventLogger>>,
/// Monotonic origin that `last_activity_ms` is measured from.
activity_origin: Instant,
/// Milliseconds after `activity_origin` of the last `emit()` or `touch()`.
/// 0 until the first event.
last_activity_ms: AtomicU64,
}
impl std::fmt::Debug for Emitter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let count = self.listeners.lock().map_or(0, |l| l.len());
f.debug_struct("Emitter")
.field("run_id", &self.run_id)
.field("listener_count", &count)
.field(
"last_activity_ms",
&self.last_activity_ms.load(Ordering::Relaxed),
)
.finish_non_exhaustive()
}
}
impl Default for Emitter {
fn default() -> Self {
Self::new(RunId::new())
}
}
impl Emitter {
#[must_use]
pub fn new(run_id: RunId) -> Self {
Self {
run_id,
listeners: std::sync::Mutex::new(Vec::new()),
persisters: std::sync::Mutex::new(Vec::new()),
activity_origin: Instant::now(),
last_activity_ms: AtomicU64::new(0),
}
}
#[must_use]
pub fn run_id(&self) -> RunId {
self.run_id
}
pub fn on_event(&self, listener: impl Fn(&RunEvent) + Send + Sync + 'static) {
self.listeners
.lock()
.expect("listeners lock poisoned")
.push(Arc::new(listener));
}
pub(super) fn attach_persistence(&self, logger: RunEventLogger) {
self.persisters
.lock()
.expect("persisters lock poisoned")
.push(logger);
}
pub fn emit(&self, event: &Event) {
self.emit_with_scope(event, None);
}
/// Emits `event` and returns once every attached persistence path has
/// accepted it.
///
/// Listeners see the event only after it is durable. With no persistence
/// attached the event is dispatched to listeners and the call succeeds.
///
/// # Errors
///
/// Returns the persistence failure that stopped the run event log.
pub async fn emit_durable(
&self,
event: &Event,
scope: Option<&StageScope>,
) -> Result<(), RunEventPersistenceError> {
event.trace();
let stored = to_run_event_at(&self.run_id, event, Utc::now(), scope);
self.record_activity();
let persisters: Vec<RunEventLogger> = self
.persisters
.lock()
.expect("persisters lock poisoned")
.clone();
for persister in &persisters {
persister.write_acknowledged(&stored).await?;
}
self.dispatch_to_listeners(&stored);
Ok(())
}
pub fn emit_scoped(&self, event: &Event, scope: &StageScope) {
self.emit_with_scope(event, Some(scope));
}
pub fn notice(&self, level: RunNoticeLevel, code: RunNoticeCode, message: impl Into<String>) {
self.emit(&Event::RunNotice {
level,
code: code.to_string(),
message: message.into(),
exec_output_tail: None,
});
}
pub fn notice_scoped(
&self,
level: RunNoticeLevel,
code: RunNoticeCode,
message: impl Into<String>,
scope: &StageScope,
) {
self.emit_scoped(
&Event::RunNotice {
level,
code: code.to_string(),
message: message.into(),
exec_output_tail: None,
},
scope,
);
}
pub fn notice_with_tail(
&self,
level: RunNoticeLevel,
code: RunNoticeCode,
message: impl Into<String>,
exec_output_tail: Option<ExecOutputTail>,
) {
self.emit(&Event::RunNotice {
level,
code: code.to_string(),
message: message.into(),
exec_output_tail,
});
}
fn emit_with_scope(&self, event: &Event, scope: Option<&StageScope>) {
event.trace();
if let Event::WorkflowRunStarted { run_id, .. } = event {
debug_assert_eq!(
*run_id, self.run_id,
"workflow run started event must match emitter run_id"
);
}
let stored = to_run_event_at(&self.run_id, event, Utc::now(), scope);
self.dispatch_run_event(&stored);
}
pub(crate) fn dispatch_run_event(&self, event: &RunEvent) {
self.record_activity();
let persisters: Vec<RunEventLogger> = self
.persisters
.lock()
.expect("persisters lock poisoned")
.clone();
for persister in &persisters {
persister.enqueue(event);
}
self.dispatch_to_listeners(event);
}
fn dispatch_to_listeners(&self, event: &RunEvent) {
// Clone the listener list so we don't hold the lock during dispatch.
// This prevents deadlocks if a listener calls emit() reentrantly.
// Note: listeners added during this emit() won't receive the current event.
let snapshot: Vec<EventListener> = self
.listeners
.lock()
.expect("listeners lock poisoned")
.clone();
for listener in &snapshot {
listener(event);
}
}
/// Manually record activity (e.g. to seed the watchdog at workflow run
/// start, or for agent stream deltas that are not emitted as run events).
pub fn touch(&self) {
self.record_activity();
}
/// Called for every event, including agent streaming deltas. Keep this to a
/// single clock read and a relaxed store — the stall watchdog samples it at
/// its own deadline rather than being woken here.
fn record_activity(&self) {
self.last_activity_ms.store(
millis_u64(self.activity_origin.elapsed()),
Ordering::Relaxed,
);
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use ::fabro_types::fixtures;
use super::*;
use crate::event::Event;
#[test]
fn event_emitter_new_has_no_listeners() {
let emitter = Emitter::new(fixtures::RUN_1);
assert_eq!(emitter.listeners.lock().unwrap().len(), 0);
}
#[test]
fn event_emitter_calls_listener_with_envelope() {
let emitter = Emitter::new(fixtures::RUN_1);
let received = Arc::new(Mutex::new(Vec::new()));
let received_clone = Arc::clone(&received);
emitter.on_event(move |event| {
received_clone.lock().unwrap().push(event.clone());
});
emitter.emit(&Event::WorkflowRunStarted {
name: "test".to_string(),
run_id: fixtures::RUN_1,
base_branch: None,
base_sha: None,
run_branch: None,
worktree_dir: None,
goal: None,
});
let events = received.lock().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].event_name(), "run.started");
assert_eq!(events[0].run_id, fixtures::RUN_1);
assert!(events[0].id.len() >= 32);
}
#[test]
fn event_emitter_default() {
let emitter = Emitter::default();
assert_eq!(emitter.listeners.lock().unwrap().len(), 0);
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,214 +0,0 @@
use std::borrow::Cow;
use super::{Event, SandboxLifecycle};
#[must_use]
pub fn event_name(event: &Event) -> Cow<'static, str> {
let name: &'static str = match event {
Event::SandboxDriver { event } => {
return Cow::Owned(fabro_types::sandbox_driver_event_name(event));
}
Event::RunCreated { .. } => "run.created",
Event::WorkflowRunStarted { .. } => "run.started",
Event::RunSubmitted { .. } => "run.submitted",
Event::RunStartRequested { .. } => "run.start_requested",
Event::RunPending { .. } => "run.pending",
Event::RunApproved { .. } => "run.approved",
Event::RunDenied { .. } => "run.denied",
Event::RunRunnable { .. } => "run.runnable",
Event::RunStarting => "run.starting",
Event::RunRunning => "run.running",
Event::RunInterrupt { .. } => "run.interrupt",
Event::RunSteer { .. } => "run.steer",
Event::RunPairStarted { .. } => "run.pair.started",
Event::RunPairEnded { .. } => "run.pair.ended",
Event::RunPairFailed { .. } => "run.pair.failed",
Event::RunBlocked { .. } => "run.blocked",
Event::RunUnblocked => "run.unblocked",
Event::RunRemoving => "run.removing",
Event::RunCancelRequested { .. } => "run.cancel.requested",
Event::RunPauseRequested { .. } => "run.pause.requested",
Event::RunUnpauseRequested { .. } => "run.unpause.requested",
Event::RunPaused => "run.paused",
Event::RunUnpaused => "run.unpaused",
Event::RunSupersededBy { .. } => "run.superseded_by",
Event::RunArchived { .. } => "run.archived",
Event::RunUnarchived { .. } => "run.unarchived",
Event::RunTitleUpdated { .. } => "run.title.updated",
Event::RunParentLinked { .. } => "run.parent.linked",
Event::RunParentUnlinked { .. } => "run.parent.unlinked",
Event::WorkflowRunCompleted { .. } => "run.completed",
Event::WorkflowRunFailed { .. } => "run.failed",
Event::RunNotice { .. } => "run.notice",
Event::MetadataSnapshotStarted { .. } => "metadata.snapshot.started",
Event::MetadataSnapshotCompleted { .. } => "metadata.snapshot.completed",
Event::MetadataSnapshotFailed { .. } => "metadata.snapshot.failed",
Event::StageStarted { .. } => "stage.started",
Event::StageCompleted { .. } => "stage.completed",
Event::StageFailed { .. } => "stage.failed",
Event::StageRetrying { .. } => "stage.retrying",
Event::ParallelStarted { .. } => "parallel.started",
Event::ParallelBranchStarted { .. } => "parallel.branch.started",
Event::ParallelBranchCompleted { .. } => "parallel.branch.completed",
Event::ParallelCompleted { .. } => "parallel.completed",
Event::InterviewStarted { .. } => "interview.started",
Event::InterviewCompleted { .. } => "interview.completed",
Event::InterviewTimeout { .. } => "interview.timeout",
Event::InterviewInterrupted { .. } => "interview.interrupted",
Event::CheckpointCompleted { .. } => "checkpoint.completed",
Event::CheckpointFailed { .. } => "checkpoint.failed",
Event::GitCommit { .. } => "git.commit",
Event::GitPush { .. } => "git.push",
Event::GitFetch { .. } => "git.fetch",
Event::GitReset { .. } => "git.reset",
Event::EdgeSelected { .. } => "edge.selected",
Event::LoopRestart { .. } => "loop.restart",
Event::Prompt { .. } => "stage.prompt",
Event::PromptCompleted { .. } => "prompt.completed",
Event::Agent { event, .. } => fabro_types::coding_event_name(&event.event),
Event::SubgraphStarted { .. } => "subgraph.started",
Event::SubgraphCompleted { .. } => "subgraph.completed",
Event::Sandbox { event } => match event {
SandboxLifecycle::Initializing { .. } => "sandbox.initializing",
SandboxLifecycle::Ready { .. } => "sandbox.ready",
SandboxLifecycle::InitializeFailed { .. } => "sandbox.failed",
},
Event::SandboxInitialized { .. } => "sandbox.initialized",
Event::SetupStarted { .. } => "setup.started",
Event::SetupCommandStarted { .. } => "setup.command.started",
Event::SetupCommandCompleted { .. } => "setup.command.completed",
Event::SetupCompleted { .. } => "setup.completed",
Event::GitIdentityResolved { .. } => "git.identity.resolved",
Event::SetupFailed { .. } => "setup.failed",
Event::StallWatchdogTimeout { .. } => "watchdog.timeout",
Event::ArtifactCaptured { .. } => "artifact.captured",
Event::SshAccessReady { .. } => "ssh.ready",
Event::Failover { .. } => "prompt.failover",
Event::CommandStarted { .. } => "command.started",
Event::CommandCompleted { .. } => "command.completed",
Event::AgentSessionActivated { .. } => "agent.session.activated",
Event::AgentToolsAvailable { .. } => "agent.tools.available",
Event::AgentSessionDeactivated { .. } => "agent.session.deactivated",
Event::AgentInterruptInjected { .. } => "agent.interrupt.injected",
Event::AgentPairUserMessage { .. } => "agent.pair.user_message",
Event::AgentPairSystemMessage { .. } => "agent.pair.system_message",
Event::AgentSteerBuffered { .. } => "agent.steer.buffered",
Event::AgentSteerDropped { .. } => "agent.steer.dropped",
Event::AgentAcpStarted { .. } => "agent.acp.started",
Event::AgentAcpCompleted { .. } => "agent.acp.completed",
Event::AgentAcpCancelled { .. } => "agent.acp.cancelled",
Event::AgentAcpTimedOut { .. } => "agent.acp.timed_out",
Event::PullRequestCreationRequested { .. } => "pull_request.creation_requested",
Event::PullRequestCreated { .. } => "pull_request.created",
Event::PullRequestLinked { .. } => "pull_request.linked",
Event::PullRequestUnlinked { .. } => "pull_request.unlinked",
Event::PullRequestFailed { .. } => "pull_request.failed",
};
Cow::Borrowed(name)
}
#[cfg(test)]
mod tests {
use ::fabro_types::{ParallelBranchId, StageId};
use pebble_coding_agent::events::{CodingAgentEvent, CodingEvent};
use super::*;
use crate::event::Event;
#[test]
fn event_name_matches_new_dot_notation() {
assert_eq!(
event_name(&Event::ParallelBranchStarted {
graph_visit: None,
resumed_from_stage_id: None,
parallel_group_id: StageId::new("plan", 1),
parallel_branch_id: ParallelBranchId::new(StageId::new("plan", 1), 0),
branch: "fork".to_string(),
index: 0,
item_label: None,
}),
"parallel.branch.started"
);
assert_eq!(
event_name(&Event::Failover {
stage: "code".to_string(),
props: fabro_types::FailoverProps {
from_provider: "anthropic".to_string(),
from_model: "claude-fable-5".to_string(),
to_provider: "openai".to_string(),
to_model: "gpt-5.6-sol".to_string(),
attempt: Some(1),
error: "overloaded".to_string(),
},
}),
"prompt.failover"
);
assert_eq!(
event_name(&Event::Agent {
stage: "code".to_string(),
visit: 1,
event: CodingAgentEvent::new(
"ses_test".to_string(),
CodingEvent::SubAgentSpawned {
agent_id: "a1".to_string(),
depth: 1,
task: "do it".to_string(),
generation: 1,
},
std::time::SystemTime::UNIX_EPOCH,
),
}),
"agent.sub.spawned"
);
assert_eq!(
event_name(&Event::Agent {
stage: "code".to_string(),
visit: 1,
event: CodingAgentEvent::new(
"ses_test".to_string(),
CodingEvent::SubAgentTurnStarted {
agent_id: "a1".to_string(),
depth: 1,
task: "fix it".to_string(),
generation: 2,
},
std::time::SystemTime::UNIX_EPOCH,
),
}),
"agent.sub.turn.started"
);
assert_eq!(
event_name(&Event::Agent {
stage: "code".to_string(),
visit: 1,
event: CodingAgentEvent::new(
"session-1".to_string(),
CodingEvent::RoundInterrupted { generation: 1 },
std::time::SystemTime::UNIX_EPOCH,
),
}),
"agent.round.interrupted"
);
assert_eq!(
event_name(&Event::AgentToolsAvailable {
node_id: "code".to_string(),
visit: 1,
session_id: "session-1".to_string(),
tools: Vec::new(),
}),
"agent.tools.available"
);
}
#[test]
fn run_archived_event_name_matches_dot_notation() {
assert_eq!(
event_name(&Event::RunArchived { actor: None }),
"run.archived"
);
assert_eq!(
event_name(&Event::RunUnarchived { actor: None }),
"run.unarchived"
);
}
}

View file

@ -1,153 +0,0 @@
use ::fabro_types::{RunEvent, RunId};
use anyhow::{Context, Result};
use fabro_redact::redact_json_value;
use fabro_store::EventPayload;
use fabro_util::json::normalize_json_value;
use serde_json::Value;
pub fn build_redacted_event_payload(event: &RunEvent, run_id: &RunId) -> Result<EventPayload> {
let value = redacted_event_value(event)?;
EventPayload::new(value, run_id).map_err(anyhow::Error::from)
}
pub fn redacted_event_json(event: &RunEvent) -> Result<String> {
serde_json::to_string(&redacted_event_value(event)?).map_err(anyhow::Error::from)
}
fn normalized_event_value(event: &RunEvent) -> Result<Value> {
let value = event.to_value()?;
Ok(normalize_json_value(value))
}
fn redacted_event_value(event: &RunEvent) -> Result<Value> {
Ok(redact_json_value(normalized_event_value(event)?))
}
pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result<EventPayload> {
let value = serde_json::from_str(line).context("Failed to parse redacted event payload")?;
EventPayload::new(value, run_id).map_err(anyhow::Error::from)
}
#[cfg(test)]
mod tests {
use ::fabro_types::{fixtures, run_event as fabro_types};
use lithos_llm::types::ReasoningOutput;
use pebble_coding_agent::events::{CodingAgentEvent, CodingEvent, Usage};
use super::*;
use crate::event::{Event, to_run_event};
#[test]
fn build_redacted_event_payload_requires_id() {
let stored = to_run_event(&fixtures::RUN_8, &Event::RunSubmitted {
definition_blob: None,
});
let payload = build_redacted_event_payload(&stored, &fixtures::RUN_8).unwrap();
assert_eq!(payload.as_value()["id"], stored.id);
assert_eq!(payload.as_value()["event"], "run.submitted");
}
#[test]
fn build_redacted_event_payload_redacts_exec_output_tail_values() {
let secret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA";
let stored = to_run_event(&fixtures::RUN_8, &Event::SetupFailed {
command: "setup".to_string(),
index: 0,
exit_code: 1,
stderr: "compat stderr".to_string(),
exec_output_tail: Some(fabro_types::ExecOutputTail {
stdout: Some(format!("stdout {secret}")),
stderr: Some("plain stderr".to_string()),
stdout_truncated: false,
stderr_truncated: false,
}),
});
let payload = build_redacted_event_payload(&stored, &fixtures::RUN_8).unwrap();
let payload_text = serde_json::to_string(payload.as_value()).unwrap();
assert!(!payload_text.contains(secret));
assert!(payload_text.contains("REDACTED"));
assert_eq!(payload.as_value()["event"], "setup.failed");
assert_eq!(
payload.as_value()["properties"]["exec_output_tail"]["stderr"],
"plain stderr"
);
}
#[test]
fn build_redacted_event_payload_redacts_tool_process_output_tails() {
let secret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA";
let stored = to_run_event(&fixtures::RUN_8, &Event::Agent {
stage: "code".to_string(),
visit: 1,
event: CodingAgentEvent::new(
"ses_child".to_string(),
CodingEvent::ToolProcessCompleted {
exit_code: Some(7),
termination: ::fabro_types::CommandTermination::Exited,
duration_ms: 12,
streams_separated: true,
output_bytes_observed: 100,
output_bytes_retained: 100,
output_bytes_omitted: 0,
exec_output_tail: Some(fabro_types::ExecOutputTail {
stdout: Some(format!("stdout {secret}")),
stderr: Some("plain stderr".to_string()),
stdout_truncated: false,
stderr_truncated: false,
}),
},
std::time::SystemTime::UNIX_EPOCH,
)
.with_tool_call_id("call_1".to_string()),
});
let payload = build_redacted_event_payload(&stored, &fixtures::RUN_8).unwrap();
let payload_text = serde_json::to_string(payload.as_value()).unwrap();
assert!(!payload_text.contains(secret));
assert!(payload_text.contains("REDACTED"));
assert_eq!(payload.as_value()["event"], "agent.tool.process.completed");
assert_eq!(
payload.as_value()["properties"]["event"]["ToolProcessCompleted"]["exec_output_tail"]["stderr"],
"plain stderr"
);
}
/// Reasoning is model-authored text like any other, so it goes through
/// the same canonical redaction pass as assistant output.
#[test]
fn build_redacted_event_payload_redacts_secrets_inside_reasoning() {
let secret = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA";
let stored = to_run_event(&fixtures::RUN_8, &Event::Agent {
stage: "code".to_string(),
visit: 1,
event: CodingAgentEvent::new(
"ses_agent".to_string(),
CodingEvent::AssistantMessage {
text: "done".to_string(),
model: "gpt-5.4".to_string(),
usage: Usage::default(),
tool_call_count: 0,
context_window: None,
reasoning: Some(ReasoningOutput::new(
format!("the key is {secret}"),
format!("reading {secret} from the env"),
)),
},
std::time::SystemTime::UNIX_EPOCH,
),
});
let payload = build_redacted_event_payload(&stored, &fixtures::RUN_8).unwrap();
let reasoning = &payload.as_value()["properties"]["event"]["AssistantMessage"]["reasoning"];
let summary = reasoning["summary"].as_str().unwrap();
let trace = reasoning["trace"].as_str().unwrap();
assert!(!summary.contains(secret));
assert!(!trace.contains(secret));
assert!(summary.contains("REDACTED"));
assert!(trace.contains("REDACTED"));
}
}

View file

@ -1,575 +0,0 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use ::fabro_types::{RunEvent, RunId, RunProjection};
use anyhow::Result;
use chrono::{DateTime, Utc};
use fabro_store::{Database, RunDatabase};
use fabro_util::error::{SharedError, collect_chain};
use tokio::io::{AsyncWrite, AsyncWriteExt};
use tokio::sync::{Mutex as AsyncMutex, mpsc, oneshot, watch};
use super::emitter::Emitter;
use super::redaction::{build_redacted_event_payload, redacted_event_json};
use super::{Event, to_run_event, to_run_event_at};
use crate::runtime_store::RunStoreHandle;
pub async fn append_event(run_store: &RunDatabase, run_id: &RunId, event: &Event) -> Result<()> {
let stored = to_run_event(run_id, event);
let payload = build_redacted_event_payload(&stored, run_id)?;
run_store
.append_event(&payload)
.await
.map(|_| ())
.map_err(anyhow::Error::from)
}
/// Creates a run by committing its redacted `run.created` event and canonical
/// current row in one SQLite transaction.
pub async fn create_run(
store: &Database,
run_id: &RunId,
event: &Event,
timestamp: DateTime<Utc>,
) -> Result<RunDatabase> {
let stored = to_run_event_at(run_id, event, timestamp, None);
let payload = build_redacted_event_payload(&stored, run_id)?;
Box::pin(store.create_run_with_first_event(run_id, &payload))
.await
.map_err(anyhow::Error::from)
}
pub async fn append_event_if(
run_store: &RunDatabase,
run_id: &RunId,
event: &Event,
predicate: impl FnOnce(&RunProjection) -> bool,
) -> Result<bool> {
let stored = to_run_event(run_id, event);
let payload = build_redacted_event_payload(&stored, run_id)?;
run_store
.append_event_if(&payload, predicate)
.await
.map(|seq| seq.is_some())
.map_err(anyhow::Error::from)
}
pub async fn append_event_to_sink(
sink: &RunEventSink,
run_id: &RunId,
event: &Event,
) -> Result<(), RunEventPersistenceError> {
let stored = to_run_event(run_id, event);
sink.write_run_event(&stored)
.await
.map_err(|err| RunEventPersistenceError::Write {
run_id: *run_id,
event: stored.body.event_name().to_string(),
source: SharedError::new(err),
})
}
#[derive(Clone)]
pub enum RunEventSink {
Store(RunStoreHandle),
JsonLines(Arc<AsyncMutex<Pin<Box<dyn AsyncWrite + Send>>>>),
Callback(Arc<RunEventSinkCallback>),
Map {
transform: Arc<RunEventTransform>,
inner: Box<Self>,
},
Composite(Vec<Self>),
}
type RunEventSinkFuture = Pin<Box<dyn Future<Output = Result<()>> + Send + 'static>>;
type RunEventSinkCallback = dyn Fn(RunEvent) -> RunEventSinkFuture + Send + Sync + 'static;
type RunEventTransform = dyn Fn(RunEvent) -> RunEvent + Send + Sync + 'static;
impl RunEventSink {
#[must_use]
pub fn store(run_store: RunDatabase) -> Self {
Self::Store(RunStoreHandle::local(run_store))
}
#[must_use]
pub fn backend(run_store: RunStoreHandle) -> Self {
Self::Store(run_store)
}
#[must_use]
pub fn json_lines<W>(writer: W) -> Self
where
W: AsyncWrite + Send + 'static,
{
Self::JsonLines(Arc::new(AsyncMutex::new(Box::pin(writer))))
}
#[must_use]
pub fn callback<F, Fut>(callback: F) -> Self
where
F: Fn(RunEvent) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<()>> + Send + 'static,
{
Self::Callback(Arc::new(move |event| Box::pin(callback(event))))
}
#[must_use]
pub fn fanout(sinks: Vec<Self>) -> Self {
let mut flattened = Vec::new();
for sink in sinks {
match sink {
Self::Composite(inner) => flattened.extend(inner),
other => flattened.push(other),
}
}
Self::Composite(flattened)
}
#[must_use]
pub fn map<F>(transform: F, inner: Self) -> Self
where
F: Fn(RunEvent) -> RunEvent + Send + Sync + 'static,
{
Self::Map {
transform: Arc::new(transform),
inner: Box::new(inner),
}
}
pub async fn write_run_event(&self, event: &RunEvent) -> Result<()> {
let mut pending = vec![(self, event.clone())];
while let Some((sink, event)) = pending.pop() {
match sink {
Self::Store(run_store) => {
run_store.append_run_event(&event).await?;
}
Self::JsonLines(writer) => {
let line = redacted_event_json(&event)?;
let mut writer = writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
}
Self::Callback(callback) => callback(event).await?,
Self::Map { transform, inner } => {
pending.push((inner.as_ref(), transform(event)));
}
Self::Composite(sinks) => {
for sink in sinks.iter().rev() {
pending.push((sink, event.clone()));
}
}
}
}
Ok(())
}
}
#[allow(
clippy::large_enum_variant,
reason = "Logger queue messages stay inline to avoid boxing hot-path payloads."
)]
enum RunEventCommand {
Event(RunEvent),
/// An event whose writer waits for the store to accept it.
Acknowledged(
RunEvent,
oneshot::Sender<Result<(), RunEventPersistenceError>>,
),
Flush(oneshot::Sender<Result<(), RunEventPersistenceError>>),
}
#[derive(Clone, Debug, thiserror::Error)]
pub enum RunEventPersistenceError {
#[error("failed to persist run event {event} for run {run_id}")]
Write {
run_id: RunId,
event: String,
#[source]
source: SharedError,
},
#[error("run event persistence task stopped")]
TaskStopped,
}
async fn write_event(
sink: &RunEventSink,
event: &RunEvent,
) -> Result<(), RunEventPersistenceError> {
match sink.write_run_event(event).await {
Ok(()) => Ok(()),
Err(err) => {
let rendered_error = collect_chain(err.as_ref()).join(": ");
tracing::error!(
run_id = %event.run_id,
event = %event.body.event_name(),
error = %rendered_error,
"Failed to persist run event; stopping workflow",
);
Err(RunEventPersistenceError::Write {
run_id: event.run_id,
event: event.body.event_name().to_string(),
source: SharedError::new(err),
})
}
}
}
#[derive(Clone)]
pub struct RunEventLogger {
tx: mpsc::UnboundedSender<RunEventCommand>,
failure_rx: watch::Receiver<Option<RunEventPersistenceError>>,
}
impl RunEventLogger {
#[must_use]
pub fn new(sink: RunEventSink) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel();
let (failure_tx, failure_rx) = watch::channel(None);
tokio::spawn(async move {
// The watch channel is the single record of the latched failure:
// the worker is its only writer, so borrowing it here cannot race.
while let Some(command) = rx.recv().await {
match command {
RunEventCommand::Event(event) => {
if failure_tx.borrow().is_some() {
continue;
}
if let Err(failure) = write_event(&sink, &event).await {
failure_tx.send_replace(Some(failure));
}
}
RunEventCommand::Acknowledged(event, tx) => {
let latched = failure_tx.borrow().clone();
let result = match latched {
Some(failure) => Err(failure),
None => match write_event(&sink, &event).await {
Ok(()) => Ok(()),
Err(failure) => {
failure_tx.send_replace(Some(failure.clone()));
Err(failure)
}
},
};
let _ = tx.send(result);
}
RunEventCommand::Flush(tx) => {
let result = failure_tx.borrow().clone().map_or(Ok(()), Err);
let _ = tx.send(result);
}
}
}
});
Self { tx, failure_rx }
}
/// Makes this logger the emitter's persistence path: every emitted event
/// is queued here, and [`Emitter::emit_durable`] waits for this logger's
/// acknowledgement.
pub fn register(&self, emitter: &Emitter) {
emitter.attach_persistence(self.clone());
}
pub(super) fn enqueue(&self, event: &RunEvent) {
if self.tx.send(RunEventCommand::Event(event.clone())).is_err() {
tracing::error!(
run_id = %event.run_id,
event = %event.body.event_name(),
"Run event persistence task stopped while forwarding event",
);
}
}
/// Writes `event` and returns once the sink has accepted it, or with the
/// failure that stopped persistence.
///
/// Ordering with events queued through the emitter is preserved: the
/// write goes through the same queue.
pub async fn write_acknowledged(
&self,
event: &RunEvent,
) -> Result<(), RunEventPersistenceError> {
let (tx, rx) = oneshot::channel();
if self
.tx
.send(RunEventCommand::Acknowledged(event.clone(), tx))
.is_err()
{
return Err(RunEventPersistenceError::TaskStopped);
}
rx.await
.unwrap_or(Err(RunEventPersistenceError::TaskStopped))
}
pub async fn wait_for_failure(&self) -> RunEventPersistenceError {
let mut failure_rx = self.failure_rx.clone();
let failure = failure_rx.wait_for(Option::is_some).await;
match failure {
Ok(failure) => failure
.clone()
.expect("wait_for only returns values matching the predicate"),
Err(_) => RunEventPersistenceError::TaskStopped,
}
}
pub async fn flush(&self) -> Result<(), RunEventPersistenceError> {
let (tx, rx) = oneshot::channel();
if self.tx.send(RunEventCommand::Flush(tx)).is_err() {
return Err(RunEventPersistenceError::TaskStopped);
}
rx.await
.unwrap_or(Err(RunEventPersistenceError::TaskStopped))
}
}
#[derive(Clone)]
pub struct StoreProgressLogger {
inner: RunEventLogger,
}
impl StoreProgressLogger {
#[must_use]
pub fn new(run_store: impl Into<RunStoreHandle>) -> Self {
Self {
inner: RunEventLogger::new(RunEventSink::backend(run_store.into())),
}
}
pub fn register(&self, emitter: &Emitter) {
self.inner.register(emitter);
}
pub async fn flush(&self) -> Result<(), RunEventPersistenceError> {
self.inner.flush().await
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use ::fabro_types::{Graph, RunNoticeLevel, WorkflowSettings, fixtures};
use fabro_types::{PetriAdmission, test_support};
use lithos_llm::types::ReasoningOutput;
use pebble_coding_agent::events::{CodingAgentEvent, CodingEvent, Usage};
use tokio::sync::Mutex as AsyncMutex;
use super::*;
use crate::event::test_support::user_principal;
use crate::event::{
Emitter, Event, append_event, build_redacted_event_payload,
event_payload_from_redacted_json, to_run_event,
};
#[tokio::test]
async fn append_event_writes_store_event_shape() {
let store = fabro_store::test_support::test_database(
std::sync::Arc::new(object_store::memory::InMemory::new()),
"",
std::time::Duration::from_millis(1),
None,
);
let run_store = store.create_run(&fixtures::RUN_7).await.unwrap();
append_event(&run_store, &fixtures::RUN_7, &Event::RunCreated {
run_id: fixtures::RUN_7,
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::new(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
target: None,
automation: None,
provenance: test_support::test_run_provenance(),
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
admission: PetriAdmission::default(),
})
.await
.unwrap();
let stored = to_run_event(&fixtures::RUN_7, &Event::RunNotice {
level: RunNoticeLevel::Warn,
code: "example".to_string(),
message: "notice".to_string(),
exec_output_tail: None,
});
let payload = build_redacted_event_payload(&stored, &fixtures::RUN_7).unwrap();
run_store.append_event(&payload).await.unwrap();
let events = run_store.list_events().await.unwrap();
let line = events
.into_iter()
.find(|event| event.event.event_name() == "run.notice")
.map(|event| event.event.to_value().unwrap())
.unwrap();
assert!(line.get("id").is_some());
assert_eq!(line["event"], "run.notice");
assert_eq!(line["properties"]["code"], "example");
}
#[tokio::test]
async fn run_event_sink_json_lines_writes_canonical_event_lines() {
use tokio::io::{AsyncBufReadExt, BufReader};
let (writer, reader) = tokio::io::duplex(4096);
let sink = RunEventSink::json_lines(writer);
let event = to_run_event(&fixtures::RUN_7, &Event::RunPauseRequested { actor: None });
sink.write_run_event(&event).await.unwrap();
let mut reader = BufReader::new(reader);
let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
let payload = event_payload_from_redacted_json(line.trim_end(), &fixtures::RUN_7).unwrap();
assert_eq!(payload.as_value()["event"], "run.pause.requested");
assert_eq!(payload.as_value()["properties"]["action"], "pause");
}
#[tokio::test]
async fn run_event_sink_json_lines_carries_agent_message_reasoning() {
use tokio::io::{AsyncBufReadExt, BufReader};
let (writer, reader) = tokio::io::duplex(4096);
let sink = RunEventSink::json_lines(writer);
let event = to_run_event(&fixtures::RUN_7, &Event::Agent {
stage: "code".to_string(),
visit: 1,
event: CodingAgentEvent::new(
"ses_agent".to_string(),
CodingEvent::AssistantMessage {
text: String::new(),
model: "gpt-5.4".to_string(),
usage: Usage::default(),
tool_call_count: 1,
context_window: None,
reasoning: Some(ReasoningOutput::new(
"inspect the sink first",
"write the line, then read it back",
)),
},
std::time::SystemTime::UNIX_EPOCH,
),
});
sink.write_run_event(&event).await.unwrap();
let mut reader = BufReader::new(reader);
let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
let payload = event_payload_from_redacted_json(line.trim_end(), &fixtures::RUN_7).unwrap();
assert_eq!(payload.as_value()["event"], "agent.message");
let message = &payload.as_value()["properties"]["event"]["AssistantMessage"];
assert_eq!(message["reasoning"]["summary"], "inspect the sink first");
assert_eq!(
message["reasoning"]["trace"],
"write the line, then read it back"
);
}
#[tokio::test]
async fn run_event_sink_map_applies_transform_before_fanout() {
let first = Arc::new(AsyncMutex::new(Vec::new()));
let second = Arc::new(AsyncMutex::new(Vec::new()));
let first_events = Arc::clone(&first);
let second_events = Arc::clone(&second);
let sink = RunEventSink::map(
|mut event| {
event.actor = Some(user_principal("alice"));
event
},
RunEventSink::fanout(vec![
RunEventSink::callback(move |event| {
let first_events = Arc::clone(&first_events);
async move {
first_events.lock().await.push(event);
Ok(())
}
}),
RunEventSink::callback(move |event| {
let second_events = Arc::clone(&second_events);
async move {
second_events.lock().await.push(event);
Ok(())
}
}),
]),
);
let event = to_run_event(&fixtures::RUN_7, &Event::RunPauseRequested { actor: None });
sink.write_run_event(&event).await.unwrap();
let first = first.lock().await;
let second = second.lock().await;
assert_eq!(first.len(), 1);
assert_eq!(second.len(), 1);
assert_eq!(first[0].actor, Some(user_principal("alice")));
assert_eq!(second[0].actor, Some(user_principal("alice")));
}
#[tokio::test]
async fn run_event_logger_registers_emitter_events_to_json_lines() {
use tokio::io::{AsyncBufReadExt, BufReader};
let (writer, reader) = tokio::io::duplex(4096);
let sink = RunEventSink::json_lines(writer);
let logger = RunEventLogger::new(sink);
let emitter = Emitter::new(fixtures::RUN_8);
logger.register(&emitter);
emitter.emit(&Event::RunPaused);
logger.flush().await.unwrap();
let mut reader = BufReader::new(reader);
let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
let payload = event_payload_from_redacted_json(line.trim_end(), &fixtures::RUN_8).unwrap();
assert_eq!(payload.as_value()["event"], "run.paused");
}
#[tokio::test]
async fn run_event_logger_latches_write_failure_and_preserves_cause_chain() {
let writes = Arc::new(AtomicUsize::new(0));
let writes_for_sink = Arc::clone(&writes);
let sink = RunEventSink::callback(move |_| {
writes_for_sink.fetch_add(1, Ordering::SeqCst);
async {
Err(
anyhow::anyhow!("request failed with status 413 Payload Too Large")
.context("worker lost canonical run store during append run event"),
)
}
});
let logger = RunEventLogger::new(sink);
let emitter = Emitter::new(fixtures::RUN_8);
logger.register(&emitter);
emitter.emit(&Event::RunPaused);
let failure = logger.wait_for_failure().await;
let rendered = collect_chain(&failure).join(": ");
assert!(rendered.contains("run.paused"), "{rendered}");
assert!(
rendered.contains("worker lost canonical run store"),
"{rendered}"
);
assert!(rendered.contains("413 Payload Too Large"), "{rendered}");
emitter.emit(&Event::RunUnpaused);
let flush_failure = logger.flush().await.unwrap_err();
assert_eq!(collect_chain(&flush_failure), collect_chain(&failure));
assert_eq!(writes.load(Ordering::SeqCst), 1);
}
}

View file

@ -1,377 +0,0 @@
use ::fabro_types::{ParallelBranchId, Principal, StageId, SystemActorKind};
use pebble_coding_agent::events::{Actor, CodingEvent};
use super::Event;
use crate::stage_scope::StageScope;
#[derive(Debug, Default)]
pub(super) struct StoredEventFields {
pub(super) session_id: Option<String>,
pub(super) parent_session_id: Option<String>,
pub(super) node_id: Option<String>,
pub(super) node_label: Option<String>,
pub(super) stage_id: Option<StageId>,
pub(super) parallel_group_id: Option<StageId>,
pub(super) parallel_branch_id: Option<ParallelBranchId>,
pub(super) tool_call_id: Option<String>,
pub(super) actor: Option<Principal>,
}
fn default_node_label(node_id: Option<&String>, node_label: Option<String>) -> Option<String> {
node_label.or_else(|| node_id.cloned())
}
fn node_stored_fields(node_id: Option<String>) -> StoredEventFields {
let node_label = default_node_label(node_id.as_ref(), None);
StoredEventFields {
node_id,
node_label,
..StoredEventFields::default()
}
}
pub(super) fn stored_event_fields(event: &Event, scope: Option<&StageScope>) -> StoredEventFields {
let mut fields = stored_event_fields_for_variant(event);
if let Some(scope) = scope {
if fields.node_id.is_none() {
fields.node_id = Some(scope.node_id.clone());
fields.node_label = default_node_label(Some(&scope.node_id), fields.node_label);
}
if fields.stage_id.is_none() {
fields.stage_id = Some(StageId::new(scope.node_id.clone(), scope.visit));
}
if fields.parallel_group_id.is_none() {
fields
.parallel_group_id
.clone_from(&scope.parallel_group_id);
}
if fields.parallel_branch_id.is_none() {
fields
.parallel_branch_id
.clone_from(&scope.parallel_branch_id);
}
}
fields
}
fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields {
match event {
Event::RunCreated { provenance, .. } => StoredEventFields {
actor: Some(provenance.subject.clone()),
..StoredEventFields::default()
},
Event::RunCancelRequested { actor }
| Event::RunStartRequested { actor, .. }
| Event::RunPending { actor, .. }
| Event::RunApproved { actor }
| Event::RunDenied { actor, .. }
| Event::RunRunnable { actor, .. }
| Event::RunPauseRequested { actor }
| Event::RunUnpauseRequested { actor }
| Event::RunInterrupt { actor }
| Event::RunSteer { actor, .. }
| Event::RunPairStarted { actor, .. }
| Event::RunPairEnded { actor, .. }
| Event::RunPairFailed { actor, .. }
| Event::RunArchived { actor }
| Event::RunUnarchived { actor, .. }
| Event::RunTitleUpdated { actor, .. }
| Event::RunParentLinked { actor, .. }
| Event::RunParentUnlinked { actor, .. }
| Event::InterviewCompleted { actor, .. }
| Event::AgentSteerBuffered { actor, .. } => StoredEventFields {
actor: actor.clone(),
..StoredEventFields::default()
},
Event::StageCompleted { node_id, name, .. }
| Event::StageStarted { node_id, name, .. }
| Event::StageRetrying { node_id, name, .. } => {
let node_id_str = node_id.clone();
let node_label = default_node_label(Some(&node_id_str), Some(name.clone()));
StoredEventFields {
node_id: Some(node_id_str),
node_label,
..StoredEventFields::default()
}
}
Event::StageFailed {
node_id,
name,
actor,
..
} => {
let node_id_str = node_id.clone();
let node_label = default_node_label(Some(&node_id_str), Some(name.clone()));
StoredEventFields {
node_id: Some(node_id_str),
node_label,
actor: actor.clone(),
..StoredEventFields::default()
}
}
Event::ParallelStarted { node_id, visit, .. }
| Event::ParallelCompleted { node_id, visit, .. } => {
let node_id_str = node_id.clone();
let node_label = default_node_label(Some(&node_id_str), None);
let parallel_group_id = Some(StageId::new(node_id_str.clone(), *visit));
StoredEventFields {
node_id: Some(node_id_str),
node_label,
parallel_group_id,
..StoredEventFields::default()
}
}
Event::CheckpointCompleted { node_id, .. }
| Event::CheckpointFailed { node_id, .. }
| Event::SubgraphStarted { node_id, .. }
| Event::SubgraphCompleted { node_id, .. }
| Event::ArtifactCaptured { node_id, .. }
| Event::PromptCompleted { node_id, .. }
| Event::CommandStarted { node_id, .. }
| Event::CommandCompleted { node_id, .. }
| Event::AgentAcpCompleted { node_id, .. }
| Event::AgentAcpCancelled { node_id, .. }
| Event::AgentAcpTimedOut { node_id, .. } => node_stored_fields(Some(node_id.clone())),
Event::AgentAcpStarted { node_id, visit, .. } => {
let node_id_str = node_id.clone();
let node_label = default_node_label(Some(&node_id_str), None);
StoredEventFields {
node_id: Some(node_id_str.clone()),
node_label,
stage_id: Some(StageId::new(node_id_str, *visit)),
..StoredEventFields::default()
}
}
Event::AgentSessionActivated {
node_id,
visit,
session_id,
..
}
| Event::AgentToolsAvailable {
node_id,
visit,
session_id,
..
}
| Event::AgentSessionDeactivated {
node_id,
visit,
session_id,
}
| Event::AgentPairSystemMessage {
node_id,
visit,
session_id,
..
} => {
let node_id_str = node_id.clone();
let node_label = default_node_label(Some(&node_id_str), None);
StoredEventFields {
session_id: Some(session_id.clone()),
node_id: Some(node_id_str.clone()),
node_label,
stage_id: Some(StageId::new(node_id_str, *visit)),
..StoredEventFields::default()
}
}
Event::AgentInterruptInjected {
node_id,
visit,
session_id,
actor,
}
| Event::AgentPairUserMessage {
node_id,
visit,
session_id,
actor,
..
} => {
let node_id_str = node_id.clone();
let node_label = default_node_label(Some(&node_id_str), None);
StoredEventFields {
session_id: Some(session_id.clone()),
node_id: Some(node_id_str.clone()),
node_label,
stage_id: Some(StageId::new(node_id_str, *visit)),
actor: actor.clone(),
..StoredEventFields::default()
}
}
Event::AgentSteerDropped {
actor,
node_id,
visit,
..
} => {
let node_id_str = node_id.clone();
let node_label = node_id_str
.as_ref()
.and_then(|n| default_node_label(Some(n), None));
let stage_id = match (node_id.clone(), visit) {
(Some(n), Some(v)) => Some(StageId::new(n, *v)),
_ => None,
};
StoredEventFields {
node_id: node_id_str,
node_label,
stage_id,
actor: actor.clone(),
..StoredEventFields::default()
}
}
Event::Agent {
stage,
visit,
event: envelope,
} => {
let node_id = Some(stage.clone());
let node_label = default_node_label(node_id.as_ref(), None);
let stage_id = Some(StageId::new(stage.clone(), *visit));
let tool_call_id = envelope
.tool_call_id
.clone()
.or_else(|| agent_tool_call_id(&envelope.event).map(str::to_string));
let actor = agent_actor_for_event(
&envelope.event,
Some(envelope.session_id.as_str()),
envelope.parent_session_id.as_deref(),
);
StoredEventFields {
session_id: Some(envelope.session_id.clone()),
parent_session_id: envelope.parent_session_id.clone(),
node_id,
node_label,
stage_id,
tool_call_id,
actor,
..StoredEventFields::default()
}
}
Event::GitCommit { node_id, .. } => node_stored_fields(node_id.clone()),
Event::ParallelBranchStarted {
parallel_group_id,
parallel_branch_id,
branch,
..
}
| Event::ParallelBranchCompleted {
parallel_group_id,
parallel_branch_id,
branch,
..
} => {
let node_id = Some(branch.clone());
let node_label = default_node_label(node_id.as_ref(), None);
StoredEventFields {
node_id,
node_label,
parallel_group_id: Some(parallel_group_id.clone()),
parallel_branch_id: Some(parallel_branch_id.clone()),
..StoredEventFields::default()
}
}
Event::Prompt { stage, .. }
| Event::InterviewStarted { stage, .. }
| Event::Failover { stage, .. } => node_stored_fields(Some(stage.clone())),
Event::InterviewTimeout { actor, stage, .. }
| Event::InterviewInterrupted { actor, stage, .. } => {
let mut fields = node_stored_fields(Some(stage.clone()));
fields.actor.clone_from(actor);
fields
}
Event::StallWatchdogTimeout { node, .. } => {
let mut fields = node_stored_fields(Some(node.clone()));
fields.actor = Some(Principal::System {
system_kind: SystemActorKind::Watchdog,
});
fields
}
_ => StoredEventFields::default(),
}
}
fn agent_tool_call_id(event: &CodingEvent) -> Option<&str> {
match event {
CodingEvent::ToolCallStarted { tool_call_id, .. }
| CodingEvent::ToolCallCompleted { tool_call_id, .. } => Some(tool_call_id.as_str()),
_ => None,
}
}
fn agent_actor_for_event(
event: &CodingEvent,
session_id: Option<&str>,
parent_session_id: Option<&str>,
) -> Option<Principal> {
match event {
CodingEvent::AssistantMessage { model, .. } => Some(Principal::Agent {
session_id: session_id.map(str::to_string),
parent_session_id: parent_session_id.map(str::to_string),
model: Some(model.clone()),
}),
CodingEvent::ToolCallStarted { .. }
| CodingEvent::ToolCallOutputDelta { .. }
| CodingEvent::ToolCallCompleted { .. }
| CodingEvent::ToolProcessCompleted { .. } => Some(Principal::Agent {
session_id: session_id.map(str::to_string),
parent_session_id: parent_session_id.map(str::to_string),
model: None,
}),
CodingEvent::SteeringInjected { actor, .. } => {
actor.as_ref().and_then(principal_from_actor)
}
_ => None,
}
}
/// The principal pebble's steering author stands for, where the mapping is
/// lossless. A human author cannot be rebuilt from pebble's `Actor`; the
/// durable `run.steer` event that delivered the steer carries the principal.
pub fn principal_from_actor(actor: &Actor) -> Option<Principal> {
match actor {
Actor::Agent { id } => Some(Principal::Agent {
session_id: id.clone(),
parent_session_id: None,
model: None,
}),
Actor::System => Some(Principal::System {
system_kind: SystemActorKind::Engine,
}),
_ => None,
}
}
/// The pebble author for a fabro principal steering a session.
#[must_use]
pub fn actor_from_principal(principal: &Principal) -> Actor {
match principal {
Principal::User(user) => Actor::User {
id: Some(format!(
"{}|{}",
user.identity.issuer(),
user.identity.subject()
)),
display_name: Some(user.login.clone()),
},
Principal::Agent { session_id, .. } => Actor::Agent {
id: session_id.clone(),
},
Principal::System { .. } | Principal::Worker { .. } => Actor::System,
Principal::Webhook { delivery_id } => Actor::External {
label: Some(format!("webhook:{delivery_id}")),
},
Principal::Slack {
team_id,
user_id,
user_name,
} => Actor::External {
label: Some(
user_name
.clone()
.unwrap_or_else(|| format!("slack:{team_id}:{user_id}")),
),
},
}
}

View file

@ -1,9 +0,0 @@
use ::fabro_types::{AuthMethod, IdpIdentity, Principal};
pub(crate) fn user_principal(login: &str) -> Principal {
Principal::user(
IdpIdentity::new("https://github.com", "12345").unwrap(),
login.to_string(),
AuthMethod::Github,
)
}

View file

@ -370,15 +370,6 @@ pub fn sync_status(repo: &Path, remote: &str, branch: Option<&str>) -> GitSyncSt
)]
mod tests {
use std::fs;
use std::sync::Arc;
use std::time::Duration;
use fabro_dump::RunDump;
use fabro_store::Database;
use fabro_types::{
CommandTermination, PetriAdmission, StageModelUsage, fixtures, test_support,
};
use object_store::memory::InMemory;
use super::*;
@ -487,15 +478,6 @@ mod tests {
assert_eq!(observe_git_context(dir.path()).unwrap(), None);
}
fn test_store() -> Arc<Database> {
Arc::new(fabro_store::test_support::test_database(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
None,
))
}
#[test]
fn ensure_clean_on_clean_repo() {
let dir = tempfile::tempdir().unwrap();
@ -528,154 +510,6 @@ mod tests {
assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
}
#[tokio::test]
async fn scan_node_files_from_state_reconstructs_allowlisted_entries() {
use crate::event::{Event, append_event};
let store = test_store();
let run = store.create_run(&fixtures::RUN_1).await.unwrap();
append_event(&run, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(fabro_types::WorkflowSettings::default())
.unwrap(),
graph: serde_json::to_value(fabro_types::Graph::new("test")).unwrap(),
workflow_source: None,
labels: std::collections::BTreeMap::default(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
target: None,
automation: None,
provenance: test_support::test_run_provenance(),
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
admission: PetriAdmission::default(),
})
.await
.unwrap();
append_event(&run, &fixtures::RUN_1, &Event::Prompt {
stage: "work".into(),
visit: 2,
text: "hello".into(),
mode: Some(StageModelUsage::MODE_PROMPT.to_string()),
provider: Some("openai".into()),
model: Some("gpt-5.4".into()),
reasoning_effort: None,
speed: None,
})
.await
.unwrap();
append_event(&run, &fixtures::RUN_1, &Event::PromptCompleted {
node_id: "work".into(),
response: "world".into(),
model: "gpt-5.4".into(),
provider: "openai".into(),
usage: None,
})
.await
.unwrap();
append_event(&run, &fixtures::RUN_1, &Event::StageCompleted {
node_id: "work".into(),
name: "Work".into(),
index: 2,
timing: fabro_types::StageTiming::wall_only(100),
status: "succeeded".into(),
preferred_label: None,
suggested_next_ids: Vec::new(),
usage_by_model: Vec::new(),
usage: None,
failure: None,
notes: None,
files_touched: Vec::new(),
context_updates: None,
jump_to_node: None,
context_values: None,
node_visits: Some(std::collections::BTreeMap::from([("work".into(), 2)])),
loop_failure_signatures: None,
restart_failure_signatures: None,
response: Some("world".into()),
attempt: 1,
max_attempts: 1,
})
.await
.unwrap();
append_event(&run, &fixtures::RUN_1, &Event::CommandStarted {
node_id: "work".into(),
script: "echo hi".into(),
command: "echo hi".into(),
language: "shell".into(),
timeout_ms: None,
})
.await
.unwrap();
append_event(&run, &fixtures::RUN_1, &Event::CommandCompleted {
node_id: "work".into(),
output: "hi\n".into(),
exit_code: Some(0),
duration_ms: 10,
termination: CommandTermination::Exited,
output_bytes: 3,
live_streaming: true,
})
.await
.unwrap();
append_event(&run, &fixtures::RUN_1, &Event::ParallelCompleted {
node_id: "work".into(),
visit: 2,
duration_ms: 100,
success_count: 1,
failure_count: 0,
results: vec![fabro_types::ParallelBranchResult {
id: "a".to_string(),
index: Some(0),
item_label: None,
status: fabro_types::StageOutcome::Succeeded,
context_updates: std::collections::BTreeMap::new(),
}],
})
.await
.unwrap();
append_event(&run, &fixtures::RUN_1, &Event::CheckpointCompleted {
graph_visit: None,
resumed_from_stage_id: None,
node_id: "work".into(),
status: "succeeded".into(),
current_node: "work".into(),
completed_nodes: Vec::new(),
node_retries: std::collections::BTreeMap::new(),
context_values: std::collections::BTreeMap::new(),
node_outcomes: std::collections::BTreeMap::new(),
next_node_id: None,
git_commit_sha: None,
loop_failure_signatures: std::collections::BTreeMap::new(),
restart_failure_signatures: std::collections::BTreeMap::new(),
node_visits: std::collections::BTreeMap::from([("work".into(), 2)]),
diff: Some("diff --git a/story.txt b/story.txt".into()),
diff_summary: None,
})
.await
.unwrap();
let state = run.state().await.unwrap();
let files = RunDump::from_projection(&state)
.unwrap()
.git_entries()
.unwrap();
let paths: Vec<&str> = files.iter().map(|(path, _)| path.as_str()).collect();
assert!(paths.contains(&"stages/001-work@2/prompt.md"));
assert!(paths.contains(&"stages/001-work@2/response.md"));
assert!(paths.contains(&"stages/001-work@2/status.json"));
assert!(paths.contains(&"stages/001-work@2/provider_used.json"));
assert!(paths.contains(&"stages/001-work@2/script_invocation.json"));
assert!(paths.contains(&"stages/001-work@2/script_timing.json"));
assert!(paths.contains(&"stages/001-work@2/parallel_results.json"));
}
#[test]
fn push_branch_fails_for_nonexistent_remote() {
let dir = tempfile::tempdir().unwrap();

View file

@ -8,8 +8,7 @@
//! helpers a run's platform effects use (`git`, `git_identity`,
//! `sandbox_git`), pull request creation (`pull_request`), the run tools an
//! agent session calls (`run_tools`, `services`), the built-in web search
//! backend (`web_search`), and, until the legacy event store is deleted,
//! the legacy run event vocabulary (`event`, `runtime_store`).
//! backend (`web_search`).
#![cfg_attr(
test,
@ -29,7 +28,6 @@
)]
pub mod error;
pub mod event;
pub mod file_resolver;
pub mod git;
pub mod git_identity;
@ -50,18 +48,11 @@ pub use usage_rollup::{
pub mod run_materialization;
pub mod run_status;
pub mod run_tools;
pub mod runtime_store;
pub mod sandbox_git;
pub mod services;
mod stage_scope;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
#[doc(hidden)]
pub mod transforms;
pub mod web_search;
pub mod workflow_bundle;
/// Convert a Duration's milliseconds to u64, saturating on overflow.
pub(crate) fn millis_u64(d: std::time::Duration) -> u64 {
u64::try_from(d.as_millis()).unwrap_or(u64::MAX)
}

View file

@ -491,7 +491,6 @@ pub fn make_run_dir(scratch_base: &Path, run_id: &RunId) -> PathBuf {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use chrono::{Local, TimeZone, Utc};
use fabro_config::{
@ -500,13 +499,12 @@ mod tests {
};
use fabro_graphviz::graph::AttrValue;
use fabro_store::Database;
use fabro_store::platform_records::StoredPlatformRecord;
use fabro_types::diagnostic::Severity;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunMode;
use fabro_types::{EventBody, PetriAdmission, WorkflowSettings, fixtures, test_support};
use fabro_types::{PetriAdmission, WorkflowSettings, fixtures, test_support};
use fabro_util::error::collect_chain;
use object_store::local::LocalFileSystem;
use object_store::memory::InMemory;
use super::*;
use crate::file_resolver::FileResolver;
@ -514,13 +512,41 @@ mod tests {
use crate::pipeline::types::{GOAL_SELF_REFERENCE_RULE, TEMPLATE_UNDEFINED_VARIABLE_RULE};
use crate::transforms::Transform;
use crate::workflow_bundle::BundledWorkflow;
/// The platform records the create operation appended for the run.
async fn platform_records(store: &Database, run_id: RunId) -> Vec<StoredPlatformRecord> {
store
.run_summary_store()
.platform_records()
.read(&run_id)
.await
.unwrap()
}
/// The `run.created` record of the run.
fn run_created(records: &[StoredPlatformRecord]) -> &RunCreatedRecord {
records
.iter()
.find_map(|stored| match &stored.record {
PlatformRecord::RunCreated(created) => Some(created),
_ => None,
})
.expect("run.created record should be persisted")
}
/// The status the run's last lifecycle transition leads to.
fn last_lifecycle_status(records: &[StoredPlatformRecord]) -> Option<RunStatus> {
records
.iter()
.rev()
.find_map(|stored| match &stored.record {
PlatformRecord::RunLifecycle(lifecycle) => Some(lifecycle.status),
_ => None,
})
.flatten()
}
fn memory_store() -> Arc<Database> {
Arc::new(fabro_store::test_support::test_database(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
None,
))
Arc::new(fabro_store::test_support::test_database())
}
fn settings_from_run_layer(run: RunLayer) -> WorkflowSettings {
@ -1622,28 +1648,30 @@ mod tests {
assert_eq!(created.persisted.graph().goal(), "Compiled goal");
assert_eq!(created.persisted.source(), compiled_source);
let run_store = store.open_run_reader(&fixtures::RUN_2).await.unwrap();
let state = run_store.state().await.unwrap();
assert_eq!(state.spec.graph.goal(), "Compiled goal");
assert_eq!(state.spec.automation, Some(automation));
assert_eq!(state.spec.workflow_version_id, Some(workflow_version_id));
let events = run_store.list_events().await.unwrap();
let records = platform_records(&store, fixtures::RUN_2).await;
assert_eq!(
events
records
.iter()
.map(|event| event.event.event_name())
.map(|stored| stored.record.kind().to_string())
.collect::<Vec<_>>(),
vec!["run.created", "run.submitted"]
vec!["run.created", "run.lifecycle"]
);
let EventBody::RunCreated(created) = &events[0].event.body else {
panic!("first durable event should be run.created");
let PlatformRecord::RunCreated(created) = &records[0].record else {
panic!("first durable record should be run.created");
};
assert_eq!(created.spec.graph.goal(), "Compiled goal");
assert_eq!(created.spec.automation, Some(automation));
assert_eq!(created.spec.workflow_version_id, Some(workflow_version_id));
assert_eq!(
created.workflow_source.as_deref(),
created.spec.graph_source.as_deref(),
Some(compiled_source.as_str())
);
assert_eq!(created.workflow_version_id, Some(workflow_version_id));
assert!(created.spec_blob.is_some());
assert!(created.spec.spec_blob.is_some());
let PlatformRecord::RunLifecycle(submitted) = &records[1].record else {
panic!("second durable record should be the submitted transition");
};
assert_eq!(submitted.transition, RunLifecycleKind::Submitted);
assert_eq!(submitted.status, Some(RunStatus::Submitted));
}
#[expect(
@ -1756,10 +1784,9 @@ mod tests {
created.persisted.run_spec().workflow_slug.as_deref(),
Some("slug")
);
let run_store = store.open_run(&fixtures::RUN_1).await.unwrap();
assert_eq!(
run_store.state().await.unwrap().status,
crate::run_status::RunStatus::Submitted
last_lifecycle_status(&platform_records(&store, fixtures::RUN_1).await),
Some(crate::run_status::RunStatus::Submitted)
);
assert_eq!(
created.run_dir,
@ -1825,16 +1852,9 @@ mod tests {
.await
.unwrap();
let run_store = store.open_run(&created.run_id).await.unwrap();
let events = run_store.list_events().await.unwrap();
let run_created = events
.iter()
.find_map(|event| match &event.event.body {
EventBody::RunCreated(props) => Some(props),
_ => None,
})
.expect("run.created event should be persisted");
let step = run_created
let records = platform_records(&store, created.run_id).await;
let step = run_created(&records)
.spec
.settings
.run
.prepare
@ -2081,14 +2101,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let storage_dir = dir.path().join("storage");
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(fabro_store::test_support::test_database(
object_store,
"",
Duration::from_millis(1),
None,
));
let store = Arc::new(fabro_store::test_support::test_database());
let automation = fabro_types::AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
@ -2123,16 +2136,17 @@ mod tests {
)
.await
.unwrap();
let run_store = store.open_run_reader(&created.run_id).await.unwrap();
let events = run_store.list_events().await.unwrap();
let state = run_store.state().await.unwrap();
let records = platform_records(&store, created.run_id).await;
assert_eq!(events.first().unwrap().event.event_name(), "run.created");
assert_eq!(
records.first().unwrap().record.kind().to_string(),
"run.created"
);
assert_eq!(
created.persisted.run_spec().automation,
Some(automation.clone())
);
assert_eq!(state.spec.automation, Some(automation));
assert_eq!(run_created(&records).spec.automation, Some(automation));
}
#[tokio::test]
@ -2140,14 +2154,7 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
let storage_dir = dir.path().join("storage");
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(fabro_store::test_support::test_database(
object_store,
"",
Duration::from_millis(1),
None,
));
let store = Arc::new(fabro_store::test_support::test_database());
let created = create(
store.as_ref(),
CreateRunInput {
@ -2191,10 +2198,8 @@ mod tests {
.await
.unwrap();
let run_store = store.open_run_reader(&created.run_id).await.unwrap();
let state = run_store.state().await.unwrap();
let run = state.spec;
let provenance = run.provenance;
let records = platform_records(&store, created.run_id).await;
let provenance = run_created(&records).spec.provenance.clone();
assert_eq!(provenance.server.unwrap().version, "0.9.0");
assert_eq!(

View file

@ -666,7 +666,6 @@ pub async fn open_pull_request(
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use fabro_auth::VaultCredentialSource;
@ -675,20 +674,17 @@ mod tests {
use fabro_llm::credentials::CredentialProvider;
use fabro_llm::lithos_catalog::AdapterId;
use fabro_llm::{Response, ResponseStream};
use fabro_store::Database;
use fabro_types::{
PetriAdmission, RunProjection, RunSpec, SuccessReason, WorkflowSettings, first_event_seq,
fixtures, test_support,
PetriAdmission, RunProjection, RunSpec, WorkflowSettings, first_event_seq, fixtures,
test_support,
};
use fabro_vault::{SecretType, Vault};
use httpmock::Method::{GET, POST};
use httpmock::MockServer;
use lithos_llm::types::{ContentPart, CostSource, TokenCounts, Usage};
use object_store::memory::InMemory;
use tokio::sync::RwLock as AsyncRwLock;
use super::*;
use crate::event::{Event, append_event};
use crate::records::StageSummary;
/// Answers every completion with one fixed text, attributed to the route
@ -741,15 +737,6 @@ mod tests {
}
}
fn test_store() -> Arc<Database> {
Arc::new(fabro_store::test_support::test_database(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
None,
))
}
fn test_catalog_with_provider_base_url(provider: &str, base_url: &str) -> Arc<Catalog> {
Arc::new(fabro_llm::test_support::test_catalog_with_provider_base_url(provider, base_url))
}
@ -1054,8 +1041,6 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
#[tokio::test]
async fn build_pr_content_uses_in_memory_conclusion() {
let store = test_store();
let _run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let PrContent { title, body } = build_pr_content_with_client(
"diff --git a/src/lib.rs b/src/lib.rs\n+fn new_feature() {}\n",
"Implement feature",
@ -1078,179 +1063,8 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
assert!(body.contains("| **Total** | **2m 30s** | **$0.42** | **0** |"));
}
#[tokio::test]
async fn build_pr_content_uses_store_records_without_legacy_files() {
let store = test_store();
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let run_spec = RunSpec {
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
target: None,
automation: None,
source_directory: Some("/tmp/project".to_string()),
git: Some(fabro_types::GitContext {
origin_url: String::new(),
branch: "main".to_string(),
sha: None,
dirty: fabro_types::DirtyStatus::Clean,
}),
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
admission: PetriAdmission::default(),
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: run_spec.source_directory.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
workflow_version_id: run_spec.workflow_version_id,
target: run_spec.target.clone(),
automation: None,
provenance: run_spec.provenance.clone(),
spec_blob: None,
git: run_spec.git.clone(),
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
admission: PetriAdmission::default(),
})
.await
.unwrap();
let body = build_pr_content_with_client(
"diff --git a/src/lib.rs b/src/lib.rs\n+fn new_feature() {}\n",
"Implement feature",
"mock-model",
&mock_catalog(),
Some(&make_test_conclusion()),
None,
explicit_client(
"mock",
&pr_content_json("Mock title", "Narrative from mock."),
),
)
.await
.unwrap()
.body;
assert!(body.contains("Narrative from mock."));
assert!(body.contains("### Fabro Details"));
assert!(body.contains("test.fabro"));
}
#[tokio::test]
async fn build_pr_content_uses_plan_text_from_store_without_response_md() {
let store = test_store();
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let run_spec = RunSpec {
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
target: None,
automation: None,
source_directory: Some("/tmp/project".to_string()),
git: Some(fabro_types::GitContext {
origin_url: String::new(),
branch: "main".to_string(),
sha: None,
dirty: fabro_types::DirtyStatus::Clean,
}),
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
admission: PetriAdmission::default(),
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: run_spec.source_directory.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
workflow_version_id: run_spec.workflow_version_id,
target: run_spec.target.clone(),
automation: None,
provenance: run_spec.provenance.clone(),
spec_blob: None,
git: run_spec.git.clone(),
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
admission: PetriAdmission::default(),
})
.await
.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::StageCompleted {
node_id: "plan".to_string(),
name: "plan".to_string(),
index: 0,
timing: fabro_types::StageTiming::wall_only(1),
status: "succeeded".to_string(),
preferred_label: None,
suggested_next_ids: vec![],
usage_by_model: Vec::new(),
usage: None,
failure: None,
notes: None,
files_touched: vec![],
context_updates: None,
jump_to_node: None,
context_values: None,
node_visits: None,
loop_failure_signatures: None,
restart_failure_signatures: None,
response: Some("Plan from store".to_string()),
attempt: 1,
max_attempts: 1,
})
.await
.unwrap();
let body = build_pr_content_with_client(
"diff --git a/src/lib.rs b/src/lib.rs\n+fn new_feature() {}\n",
"Implement feature",
"mock-model",
&mock_catalog(),
Some(&make_test_conclusion()),
None,
explicit_client(
"mock",
&pr_content_json("Mock title", "Narrative from mock."),
),
)
.await
.unwrap()
.body;
assert!(body.contains("<summary>Full plan</summary>"));
assert!(body.contains("Plan from store"));
}
#[tokio::test]
async fn build_pr_content_uses_explicit_llm_client() {
let store = test_store();
let _run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let body = build_pr_content_with_client(
"diff --git a/src/lib.rs b/src/lib.rs\n+fn new_feature() {}\n",
"Implement feature",
@ -1304,9 +1118,6 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
// Use catalog settings to override base_url instead of env var
let catalog = test_catalog_with_provider_base_url("openai", &server.url("/v1"));
let store = test_store();
let _run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let PrContent { title, body } = build_pr_content(
"diff --git a/src/lib.rs b/src/lib.rs\n+fn new_feature() {}\n",
"Implement feature",
@ -1528,8 +1339,6 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
/// [`build_pr_content_with_client`].
#[tokio::test]
async fn build_pr_content_truncates_long_title() {
let store = test_store();
let _run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let long_title = "x".repeat(200);
let payload = pr_content_json(&long_title, "Body content.");
let title = build_pr_content_with_client(
@ -1551,8 +1360,6 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
#[tokio::test]
async fn build_pr_content_uses_default_title_when_generated_and_goal_titles_empty() {
let store = test_store();
let _run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let payload = pr_content_json("", "Body content.");
let title = build_pr_content_with_client(
"diff --git a/src/lib.rs b/src/lib.rs\n+fn x() {}\n",
@ -1574,75 +1381,10 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
/// aborting PR creation.
#[tokio::test]
async fn build_pr_content_uses_skeleton_when_body_empty() {
let store = test_store();
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let run_spec = RunSpec {
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
target: None,
automation: None,
source_directory: Some("/tmp/project".to_string()),
git: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
admission: PetriAdmission::default(),
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: run_spec.source_directory.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
workflow_version_id: run_spec.workflow_version_id,
target: run_spec.target.clone(),
automation: None,
provenance: test_support::test_run_provenance(),
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
admission: PetriAdmission::default(),
})
.await
.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::StageCompleted {
node_id: "plan".to_string(),
name: "plan".to_string(),
index: 0,
timing: fabro_types::StageTiming::wall_only(1),
status: "succeeded".to_string(),
preferred_label: None,
suggested_next_ids: vec![],
usage_by_model: Vec::new(),
usage: None,
failure: None,
notes: None,
files_touched: vec![],
context_updates: None,
jump_to_node: None,
context_values: None,
node_visits: None,
loop_failure_signatures: None,
restart_failure_signatures: None,
response: Some("Plan from store".to_string()),
attempt: 1,
max_attempts: 1,
})
.await
.unwrap();
// The plan node's response is what the body quotes as the plan.
let mut state = test_projection();
state.stage_entry("plan", 1, first_event_seq(1)).response =
Some("Plan from store".to_string());
let payload = pr_content_json("Mock", " \n");
let body = build_pr_content_with_client(
"diff --git a/src/lib.rs b/src/lib.rs\n+fn x() {}\n",
@ -1650,7 +1392,7 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
"mock-model",
&mock_catalog(),
Some(&make_test_conclusion()),
None,
Some(&state),
explicit_client("mock", &payload),
)
.await
@ -1796,77 +1538,6 @@ capabilities = { text = true, tools = true, response_format = { json_object = tr
let creds = fabro_github::GitHubCredentials::Pat("test-token".to_string());
let store = test_store();
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
// Seed a completed run so the PR body can include run details.
let run_spec = RunSpec {
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
graph_source: None,
workflow_slug: None,
workflow_version_id: None,
target: None,
automation: None,
source_directory: None,
git: None,
labels: HashMap::new(),
provenance: test_support::test_run_provenance(),
definition_blob: None,
spec_blob: None,
fork_source_ref: None,
admission: PetriAdmission::default(),
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
target: None,
automation: None,
provenance: test_support::test_run_provenance(),
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
admission: PetriAdmission::default(),
})
.await
.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::RunStarting)
.await
.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::RunRunning)
.await
.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::WorkflowRunCompleted {
timing: fabro_types::RunTiming::wall_only(1),
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
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(),
),
diff_summary: None,
usage: None,
})
.await
.unwrap();
let openai_mock_id = openai_mock.id;
let branch_mock_id = branch_mock.id;
let reconcile_mock_id = reconcile_mock.id;

View file

@ -446,28 +446,17 @@ fn run_id_matches(run_id: RunId, prefix: &str) -> bool {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use fabro_store::Database;
use fabro_types::{PetriAdmission, RunStatus, fixtures, test_support};
use object_store::memory::InMemory;
use fabro_store::RunSummaryStore;
use fabro_types::{RunProjection, RunStatus, fixtures, test_support};
use super::scan_runs_combined;
use crate::event::{Event, append_event};
use crate::operations::make_run_dir;
use crate::records::RunSpec;
fn memory_store() -> Arc<Database> {
Arc::new(fabro_store::test_support::test_database(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
None,
))
}
fn sample_run_spec() -> RunSpec {
RunSpec {
run_id: fixtures::RUN_1,
workflow_slug: Some("test".to_string()),
source_directory: Some("/tmp/project".to_string()),
git: Some(fabro_types::GitContext {
@ -486,37 +475,24 @@ mod tests {
let run_dir = make_run_dir(temp.path(), &fixtures::RUN_1);
std::fs::create_dir_all(&run_dir).unwrap();
let store = memory_store();
let run_spec = sample_run_spec();
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,
labels: run_spec.labels.clone().into_iter().collect(),
source_directory: run_spec.source_directory.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
workflow_version_id: None,
target: None,
automation: None,
provenance: run_spec.provenance.clone(),
spec_blob: None,
git: run_spec.git.clone(),
fork_source_ref: run_spec.fork_source_ref.clone(),
retried_from: None,
parent_id: None,
web_url: None,
admission: PetriAdmission::default(),
})
.await
.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::RunSubmitted {
definition_blob: None,
})
let store = Arc::new(fabro_store::test_support::test_database());
// The run's row as its projector writes it once the run is submitted.
let mut projection = RunProjection::new(
"test".to_string(),
sample_run_spec(),
fixtures::RUN_1.created_at(),
);
projection.status = RunStatus::Submitted;
let summaries = store.run_summary_store();
let mut transaction = summaries.pool().begin().await.unwrap();
RunSummaryStore::write_petri_run_row_on_connection(
&mut transaction,
&fixtures::RUN_1,
&projection,
)
.await
.unwrap();
transaction.commit().await.unwrap();
let runs = scan_runs_combined(&store, temp.path()).await.unwrap();
let run = runs

View file

@ -1,225 +0,0 @@
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use bytes::Bytes;
use fabro_store::{EventEnvelope, RunDatabase, RunProjection};
use fabro_types::{BlobHash, RunEvent};
use crate::event::build_redacted_event_payload;
#[async_trait]
pub trait RunStoreBackend: Send + Sync {
async fn load_state(&self) -> Result<RunProjection>;
async fn list_events(&self) -> Result<Vec<EventEnvelope>>;
async fn append_run_event(&self, event: &RunEvent) -> Result<()>;
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash>;
async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>>;
async fn read_run_log(&self) -> Result<Option<Vec<u8>>>;
}
#[derive(Clone)]
pub struct RunStoreHandle {
backend: Arc<dyn RunStoreBackend>,
}
impl RunStoreHandle {
#[must_use]
pub fn new(backend: Arc<dyn RunStoreBackend>) -> Self {
Self { backend }
}
#[must_use]
pub fn local(run_store: RunDatabase) -> Self {
Self::new(Arc::new(LocalRunStoreBackend { run_store }))
}
pub async fn state(&self) -> Result<RunProjection> {
self.backend.load_state().await
}
pub async fn list_events(&self) -> Result<Vec<EventEnvelope>> {
self.backend.list_events().await
}
pub async fn append_run_event(&self, event: &RunEvent) -> Result<()> {
self.backend.append_run_event(event).await
}
pub async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
self.backend.write_blob(data).await
}
pub async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
self.backend.read_blob(blob_hash).await
}
pub async fn read_run_log(&self) -> Result<Option<Vec<u8>>> {
self.backend.read_run_log().await
}
}
impl From<RunDatabase> for RunStoreHandle {
fn from(value: RunDatabase) -> Self {
Self::local(value)
}
}
struct LocalRunStoreBackend {
run_store: RunDatabase,
}
#[async_trait]
impl RunStoreBackend for LocalRunStoreBackend {
async fn load_state(&self) -> Result<RunProjection> {
self.run_store.state().await.map_err(anyhow::Error::from)
}
async fn list_events(&self) -> Result<Vec<EventEnvelope>> {
self.run_store
.list_events()
.await
.map_err(anyhow::Error::from)
}
async fn append_run_event(&self, event: &RunEvent) -> Result<()> {
let payload = build_redacted_event_payload(event, &event.run_id)?;
self.run_store
.append_event(&payload)
.await
.map(|_| ())
.map_err(anyhow::Error::from)
}
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash> {
self.run_store
.write_blob(data)
.await
.map_err(anyhow::Error::from)
}
async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
self.run_store
.read_blob(blob_hash)
.await
.map_err(anyhow::Error::from)
}
async fn read_run_log(&self) -> Result<Option<Vec<u8>>> {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use fabro_types::run_event::RunSubmittedProps;
use fabro_types::{EventBody, PetriAdmission, RunEvent, fixtures, test_support};
use object_store::memory::InMemory;
use super::RunStoreHandle;
use crate::event::{Event, append_event};
use crate::records::RunSpec;
async fn test_run_store() -> fabro_store::RunDatabase {
let store = Arc::new(fabro_store::test_support::test_database(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
None,
));
store.create_run(&fixtures::RUN_1).await.unwrap()
}
fn test_run_spec() -> RunSpec {
RunSpec {
workflow_slug: Some("test".to_string()),
source_directory: Some("/tmp/test".to_string()),
..test_support::test_run_spec()
}
}
async fn append_created_event(run_store: &fabro_store::RunDatabase) {
let record = test_run_spec();
append_event(run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&record.settings).unwrap(),
graph: serde_json::to_value(&record.graph).unwrap(),
workflow_source: Some("digraph test {}".to_string()),
labels: std::collections::BTreeMap::new(),
source_directory: Some("/tmp/test".to_string()),
workflow_slug: Some("test".to_string()),
workflow_version_id: None,
target: None,
automation: None,
provenance: test_support::test_run_provenance(),
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
admission: PetriAdmission::default(),
})
.await
.unwrap();
}
#[tokio::test]
async fn local_handle_loads_state_and_events() {
let run_store = test_run_store().await;
append_created_event(&run_store).await;
let handle = RunStoreHandle::local(run_store);
let state = handle.state().await.unwrap();
let events = handle.list_events().await.unwrap();
assert_eq!(state.spec.workflow_slug.as_deref(), Some("test"));
assert_eq!(events.len(), 1);
}
#[tokio::test]
async fn local_handle_appends_events_and_roundtrips_blobs() {
let run_store = test_run_store().await;
append_created_event(&run_store).await;
let handle = RunStoreHandle::local(run_store);
let event = RunEvent {
id: "evt-run-submitted".to_string(),
ts: Utc::now(),
run_id: fixtures::RUN_1,
node_id: None,
node_label: None,
stage_id: None,
parallel_group_id: None,
parallel_branch_id: None,
session_id: None,
parent_session_id: None,
tool_call_id: None,
actor: None,
body: EventBody::RunSubmitted(RunSubmittedProps {
definition_blob: None,
}),
};
handle.append_run_event(&event).await.unwrap();
let blob_hash = handle.write_blob(br#"{"ok":true}"#).await.unwrap();
let blob = handle.read_blob(&blob_hash).await.unwrap().unwrap();
let events = handle.list_events().await.unwrap();
assert_eq!(events.len(), 2);
assert_eq!(blob.as_ref(), br#"{"ok":true}"#);
}
#[tokio::test]
async fn local_handle_returns_no_run_log() {
let run_store = test_run_store().await;
let handle = RunStoreHandle::local(run_store);
assert_eq!(handle.read_run_log().await.unwrap(), None);
}
}

View file

@ -1,22 +0,0 @@
use fabro_types::{ParallelBranchId, StageId};
/// Stage-level scope threaded through event emission to populate
/// `stage_id` / `parallel_group_id` / `parallel_branch_id` on events
/// that happen inside a concrete stage execution.
///
/// `visit` is the 1-based stage execution ordinal — the numeric component of
/// the external `StageId`.
#[derive(Clone, Debug)]
pub struct StageScope {
pub node_id: String,
pub visit: u32,
pub parallel_group_id: Option<StageId>,
pub parallel_branch_id: Option<ParallelBranchId>,
}
impl StageScope {
#[must_use]
pub fn stage_id(&self) -> StageId {
StageId::new(&self.node_id, self.visit)
}
}

View file

@ -1,11 +1,7 @@
use std::sync::Arc;
use fabro_types::ModelRef;
use lithos_llm::catalog::{ModelId, builtin};
use lithos_llm::types::{Cost, CostSource, TokenCounts, Usage};
use crate::event::{Emitter, Event, append_event};
/// Construct a fully-populated `ModelUsage` for tests: `input_tokens` and
/// `output_tokens` on an OpenAI model, priced from the catalog at one micro
/// per token. Centralised so callers don't keep rebuilding the same skeleton.
@ -30,34 +26,3 @@ pub fn test_usage(
},
)
}
/// Append the `RunStartRequested → RunRunnable → RunStarting → RunRunning`
/// sequence so subsequent calls observe the run as live.
pub async fn mark_run_running(run_store: &fabro_store::RunDatabase, run_id: &fabro_types::RunId) {
append_event(run_store, run_id, &Event::RunStartRequested {
resume: false,
actor: None,
})
.await
.expect("seed run.start_requested");
append_event(run_store, run_id, &Event::RunRunnable {
source: fabro_types::RunRunnableSource::StartRequested,
actor: None,
})
.await
.expect("seed run.runnable");
append_event(run_store, run_id, &Event::RunStarting)
.await
.expect("seed run.starting");
append_event(run_store, run_id, &Event::RunRunning)
.await
.expect("seed run.running");
}
/// Record every event the emitter publishes, for assertions after a run.
pub fn collect_events(emitter: &Emitter) -> Arc<std::sync::Mutex<Vec<fabro_types::RunEvent>>> {
let events = Arc::new(std::sync::Mutex::new(Vec::new()));
let captured = Arc::clone(&events);
emitter.on_event(move |event| captured.lock().unwrap().push(event.clone()));
events
}

View file

@ -399,7 +399,7 @@ fn main() {
("PermissionLevel", "fabro_types::PermissionLevel", &[]),
(
"AgentSessionActivatedProps",
"fabro_types::run_event::AgentSessionActivatedProps",
"fabro_types::AgentSessionActivatedProps",
&[],
),
("TodoListProjection", "fabro_types::TodoListProjection", &[]),

View file

@ -19,7 +19,6 @@ pub mod types {
AutomationReplace as ReplaceAutomationRequest, AutomationTrigger,
};
pub use fabro_environment::Environment;
pub use fabro_types::run_event::AgentSessionActivatedProps;
pub use fabro_types::settings::run::{
McpHttpProtocol, RunIntegrationsGithubSettings, RunIntegrationsSettings, RunModelControls,
RunModelSettings,
@ -38,22 +37,22 @@ pub mod types {
BlockedReason, FailureReason, PendingReason, RunControlAction, RunStatus, SuccessReason,
};
pub use fabro_types::{
AgentEventProps, AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BlobHash,
CommandTermination, Conclusion, ContextWindowBreakdownItem, ContextWindowCategory,
ContextWindowCountMethod, ContextWindowSnapshot, ContextWindowStaleness,
ContextWindowWarning, CreateVariableRequest, DiffStats, DiffSummary, DirtyStatus,
ExecOutputTail, FailureCategory, FailureDetail, FailureSignature, GitContext, GitRunTarget,
GitRunTarget as AutomationGitWorkflowSource, IdpIdentity, IntegrationConnectionKind,
IntegrationConnectionState, IntegrationConnectionStatus, IntegrationProvider,
IntegrationStatus, InterviewOption, InterviewQuestionRecord, LlmOutputKind,
McpServerDraft as CreateMcpServerRequest, McpServerReplace as ReplaceMcpServerRequest,
McpServerView as McpServer, McpTransportView, Model, ModelControls, ModelCosts,
ModelFeatures, ModelLimits, ModelRef as UsageModelRef, ModelTestMode, ModelUsage, PairId,
PairMessageId, PairMessageRecord, PairMessageRequest, PairRecord, PairStartRequest,
PairStatus, PairTarget, PairTranscriptEntry, PairTranscriptResponse, ParallelBranchId,
ParallelBranchResult, PendingInterviewRecord, PermissionLevel, PetriAdmission,
PetriGraphRef, Principal, Provider, PullRequest, PullRequestCreation,
PullRequestCreationId, PullRequestCreationStatus, PullRequestDetails,
AgentEventProps, AgentSessionActivatedProps, AgentToolsAvailableProps, AskFabro,
AuthMethod, AutomationRef, BlobHash, CommandTermination, Conclusion,
ContextWindowBreakdownItem, ContextWindowCategory, ContextWindowCountMethod,
ContextWindowSnapshot, ContextWindowStaleness, ContextWindowWarning, CreateVariableRequest,
DiffStats, DiffSummary, DirtyStatus, ExecOutputTail, FailureCategory, FailureDetail,
FailureSignature, GitContext, GitRunTarget, GitRunTarget as AutomationGitWorkflowSource,
IdpIdentity, IntegrationConnectionKind, IntegrationConnectionState,
IntegrationConnectionStatus, IntegrationProvider, IntegrationStatus, InterviewOption,
InterviewQuestionRecord, LlmOutputKind, McpServerDraft as CreateMcpServerRequest,
McpServerReplace as ReplaceMcpServerRequest, McpServerView as McpServer, McpTransportView,
Model, ModelControls, ModelCosts, ModelFeatures, ModelLimits, ModelRef as UsageModelRef,
ModelTestMode, ModelUsage, PairId, PairMessageId, PairMessageRecord, PairMessageRequest,
PairRecord, PairStartRequest, PairStatus, PairTarget, PairTranscriptEntry,
PairTranscriptResponse, ParallelBranchId, ParallelBranchResult, PendingInterviewRecord,
PermissionLevel, PetriAdmission, PetriGraphRef, Principal, Provider, PullRequest,
PullRequestCreation, PullRequestCreationId, PullRequestCreationStatus, PullRequestDetails,
PullRequestDetailsStatus, PullRequestDetailsUnavailableReason, PullRequestLink,
PullRequestMeta, PullRequestResponse, QuestionType, RepositoryRef, ReviewTarget,
ReviewTargetKind, Run, RunApproval, RunApprovalState, RunClientProvenance, RunFailure,

View file

@ -1,8 +1,7 @@
use std::any::{TypeId, type_name};
use fabro_api::types::AgentSessionActivatedProps as ApiAgentSessionActivatedProps;
use fabro_types::run_event::AgentSessionActivatedProps;
use fabro_types::{PermissionLevel, SessionCapability};
use fabro_types::{AgentSessionActivatedProps, PermissionLevel, SessionCapability};
use lithos_llm::types::{ReasoningEffort, Speed};
use serde_json::json;

View file

@ -24,10 +24,7 @@ fn run_projection_round_trips_populated_projection() {
"checkpoint": {
"timestamp": "2026-04-29T12:34:56Z",
"current_node": "build",
"completed_nodes": ["build"],
"node_retries": {},
"context_values": {},
"node_visits": { "build": 2 }
"git_commit_sha": "abc123"
},
"diff": {}
}

View file

@ -1,17 +1,12 @@
//! Agent event bodies.
//!
//! Pebble owns the coding-agent event vocabulary. Every event a coding agent
//! publishes reaches the run event log as one [`AgentEventProps`]: pebble's
//! full [`CodingAgentEvent`] envelope plus the stage and visit fabro adds at
//! the workflow boundary. The remaining structs here are fabro's own lifecycle
//! events around a session: activation, steering delivery, pairing, and MCP
//! server startup, none of which pebble emits.
//! The agent-side shapes the projection and the API keep: a coding agent
//! event placed on a stage, the names Fabro gives pebble's events, a
//! session's activation and tools, and a stage's prompt.
use lithos_llm::types::{ReasoningEffort, Speed};
use pebble_coding_agent::events::{CodingAgentEvent, CodingEvent, ToolSummary};
use serde::{Deserialize, Serialize};
use crate::{PairId, PairMessageId, PairSystemMessageKind, PermissionLevel};
use crate::PermissionLevel;
/// One coding-agent event placed on a workflow stage.
///
@ -181,11 +176,6 @@ pub struct AgentSessionActivatedProps {
pub visit: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentSessionDeactivatedProps {
pub visit: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentToolsAvailableProps {
#[serde(default)]
@ -194,136 +184,17 @@ pub struct AgentToolsAvailableProps {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentPairUserMessageProps {
pub pair_id: PairId,
pub message_id: PairMessageId,
pub struct StagePromptProps {
pub visit: u32,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_message_id: Option<String>,
pub text: String,
pub visit: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentPairSystemMessageProps {
pub pair_id: PairId,
pub kind: PairSystemMessageKind,
pub text: String,
pub visit: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentInterruptInjectedProps {
pub visit: u32,
}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct AgentSteerBufferedProps {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentSteerDroppedReason {
QueueFull,
RunEnded,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentSteerDroppedProps {
pub reason: AgentSteerDroppedReason,
pub count: u32,
}
#[cfg(test)]
mod tests {
use std::time::{Duration, UNIX_EPOCH};
use pebble_coding_agent::events::{ErrorData, ErrorKind, FailoverStop, Usage};
use serde_json::json;
use super::*;
fn envelope(event: CodingEvent) -> CodingAgentEvent {
CodingAgentEvent::new("ses_root", event, UNIX_EPOCH + Duration::from_millis(1_500))
.with_seq(7)
.with_stream_id("ses_root")
}
#[test]
fn agent_event_props_flatten_pebbles_envelope() {
let props = AgentEventProps::new(
"code",
2,
envelope(CodingEvent::ToolCallStarted {
tool_name: "shell".to_string(),
tool_call_id: "call_1".to_string(),
arguments: json!({"command": "ls"}),
})
.with_tool_call_id("call_1"),
);
let value = serde_json::to_value(&props).unwrap();
assert_eq!(
value,
json!({
"stage": "code",
"visit": 2,
"seq": 7,
"stream_id": "ses_root",
"session_id": "ses_root",
"tool_call_id": "call_1",
"timestamp": "1970-01-01T00:00:01.500Z",
"event": {
"ToolCallStarted": {
"tool_name": "shell",
"tool_call_id": "call_1",
"arguments": {"command": "ls"}
}
}
})
);
assert_eq!(props.event_name(), "agent.tool.started");
let parsed: AgentEventProps = serde_json::from_value(value).unwrap();
assert_eq!(parsed, props);
}
#[test]
fn every_derived_name_is_listed() {
let events = vec![
CodingEvent::SessionEnded,
CodingEvent::ProcessingEnd,
CodingEvent::LoopDetected,
CodingEvent::McpServerDisconnected {
server: "github".to_string(),
error: "transport closed".to_string(),
},
CodingEvent::AssistantMessage {
text: String::new(),
model: "gpt-5.4".to_string(),
usage: Usage::default(),
tool_call_count: 0,
context_window: None,
reasoning: None,
},
];
for event in events {
assert!(is_coding_event_name(coding_event_name(&event)));
}
assert!(is_coding_event_name("todo.updated"));
assert!(!is_coding_event_name("agent.session.activated"));
}
#[test]
fn a_stopped_failover_has_its_own_name() {
let stopped = CodingEvent::RouteFailoverStopped {
route: "anthropic/claude-fable-5".to_string(),
attempt: 2,
reason: FailoverStop::Exhausted,
error: ErrorData::new(ErrorKind::Llm, "overloaded"),
};
assert_eq!(coding_event_name(&stopped), "agent.route.failover.stopped");
assert!(is_coding_event_name("agent.route.failover.stopped"));
}
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<Speed>,
}

View file

@ -1,30 +1,12 @@
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::failure_signature::FailureSignature;
use crate::outcome::Outcome;
use crate::usage::ModelUsage;
#[derive(Debug, Clone, Serialize, Deserialize)]
/// A checkpoint Fabro recorded for a run: when, at which node, and the
/// commit the workspace was checkpointed at, if it was committed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Checkpoint {
pub timestamp: DateTime<Utc>,
pub current_node: String,
pub completed_nodes: Vec<String>,
pub node_retries: HashMap<String, u32>,
pub context_values: HashMap<String, Value>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub node_outcomes: HashMap<String, Outcome<Option<ModelUsage>>>,
pub timestamp: DateTime<Utc>,
pub current_node: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub loop_failure_signatures: HashMap<FailureSignature, usize>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub restart_failure_signatures: HashMap<FailureSignature, usize>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub node_visits: HashMap<String, usize>,
pub git_commit_sha: Option<String>,
}

View file

@ -1,140 +0,0 @@
use serde::{Deserialize, Serialize};
use crate::RunEvent;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EventEnvelope {
pub seq: u32,
#[serde(flatten)]
pub event: RunEvent,
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use super::EventEnvelope;
use crate::run_event::RunCompletedProps;
use crate::{
EventBody, ParallelBranchId, Principal, RunEvent, RunTiming, StageId, SuccessReason,
fixtures,
};
#[test]
fn wire_event_envelope_round_trips() {
let event = RunEvent {
id: "evt_1".to_string(),
ts: Utc.with_ymd_and_hms(2026, 4, 9, 12, 0, 0).unwrap(),
run_id: fixtures::RUN_1,
node_id: Some("code".to_string()),
node_label: Some("Code".to_string()),
stage_id: Some(StageId::new("code", 1)),
parallel_group_id: None,
parallel_branch_id: None,
session_id: None,
parent_session_id: None,
tool_call_id: None,
actor: None,
body: EventBody::RunCompleted(RunCompletedProps {
timing: RunTiming::wall_only(42),
artifact_count: 0,
status: "success".to_string(),
reason: SuccessReason::Completed,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
usage: None,
}),
};
let envelope = EventEnvelope { seq: 7, event };
let wire = serde_json::to_value(&envelope).unwrap();
assert_eq!(wire["seq"], 7);
assert_eq!(wire["id"], "evt_1");
assert_eq!(wire["event"], "run.completed");
let parsed: EventEnvelope = serde_json::from_value(wire).unwrap();
assert_eq!(parsed, envelope);
}
#[test]
fn wire_event_envelope_round_trips_with_all_envelope_fields() {
let group = StageId::new("review", 2);
let branch = ParallelBranchId::new(group.clone(), 3);
let event = RunEvent {
id: "evt_2".to_string(),
ts: Utc.with_ymd_and_hms(2026, 4, 9, 13, 0, 0).unwrap(),
run_id: fixtures::RUN_1,
node_id: Some("review".to_string()),
node_label: Some("Review".to_string()),
stage_id: Some(StageId::new("review", 2)),
parallel_group_id: Some(group),
parallel_branch_id: Some(branch),
session_id: Some("ses_42".to_string()),
parent_session_id: Some("ses_root".to_string()),
tool_call_id: Some("tool_call_xyz".to_string()),
actor: Some(Principal::Agent {
session_id: Some("ses_42".to_string()),
parent_session_id: Some("ses_root".to_string()),
model: Some("claude-sonnet".to_string()),
}),
body: EventBody::RunCompleted(RunCompletedProps {
timing: RunTiming::wall_only(100),
artifact_count: 1,
status: "success".to_string(),
reason: SuccessReason::Completed,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
usage: None,
}),
};
let envelope = EventEnvelope { seq: 99, event };
let wire = serde_json::to_value(&envelope).unwrap();
assert_eq!(wire["seq"], 99);
assert_eq!(wire["id"], "evt_2");
assert_eq!(wire["stage_id"], "review@2");
assert_eq!(wire["parallel_group_id"], "review@2");
assert_eq!(wire["parallel_branch_id"], "review@2:3");
assert_eq!(wire["session_id"], "ses_42");
assert_eq!(wire["parent_session_id"], "ses_root");
assert_eq!(wire["tool_call_id"], "tool_call_xyz");
assert_eq!(wire["actor"]["kind"], "agent");
assert_eq!(wire["actor"]["session_id"], "ses_42");
assert_eq!(wire["actor"]["parent_session_id"], "ses_root");
assert_eq!(wire["actor"]["model"], "claude-sonnet");
assert_eq!(wire["event"], "run.completed");
let parsed: EventEnvelope = serde_json::from_value(wire).unwrap();
assert_eq!(parsed, envelope);
}
#[test]
fn preserves_unknown_event_names_and_properties() {
let wire = serde_json::json!({
"seq": 7,
"id": "evt_unknown",
"ts": "2026-04-20T12:00:00.000Z",
"run_id": fixtures::RUN_1,
"event": "vendor.custom.event",
"properties": {
"answer": 42,
"nested": { "ok": true }
}
});
let parsed: EventEnvelope = serde_json::from_value(wire.clone()).unwrap();
let serialized = serde_json::to_value(&parsed).unwrap();
assert_eq!(serialized["seq"], wire["seq"]);
assert_eq!(serialized["id"], wire["id"]);
assert_eq!(serialized["run_id"], wire["run_id"]);
assert_eq!(serialized["event"], wire["event"]);
assert_eq!(serialized["properties"], wire["properties"]);
assert_eq!(
chrono::DateTime::parse_from_rfc3339(serialized["ts"].as_str().unwrap()).unwrap(),
chrono::DateTime::parse_from_rfc3339(wire["ts"].as_str().unwrap()).unwrap(),
);
}
}

View file

@ -2,7 +2,16 @@ use serde::de::Error as _;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::run_event::InterviewOption;
/// One choice a question offers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct InterviewOption {
pub key: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
}
const REVIEW_TARGET_LABEL_MAX_CHARS: usize = 200;
const REVIEW_TARGET_URL_MAX_CHARS: usize = 2048;

View file

@ -1,5 +1,6 @@
extern crate self as fabro_types;
pub mod agent_props;
pub mod artifact;
pub mod auth;
pub mod blob_hash;
@ -12,7 +13,6 @@ pub mod dense;
pub mod diagnostic;
pub mod diff;
pub mod engine;
pub mod event_envelope;
pub mod failure_signature;
pub mod git_identity;
pub mod graph;
@ -23,6 +23,7 @@ pub mod llm_backend;
pub mod manifest_path;
pub mod mcp_store;
pub mod model_test;
pub mod notice;
pub mod outcome;
pub mod pair;
pub mod parallel;
@ -30,7 +31,6 @@ pub mod principal;
pub mod pull_request;
pub mod repository;
pub mod run;
pub mod run_event;
pub mod run_failure;
pub mod run_id;
pub mod run_intent;
@ -65,6 +65,10 @@ pub mod workflow_path;
pub mod workflow_version;
pub mod workflow_version_id;
pub use agent_props::{
AgentEventProps, AgentSessionActivatedProps, AgentToolsAvailableProps, CODING_EVENT_NAMES,
SessionCapability, StagePromptProps, coding_event_name, is_coding_event_name,
};
pub use artifact::ArtifactUpload;
pub use auth::{IdpIdentity, IdpIdentityError};
pub use blob_hash::BlobHash;
@ -76,7 +80,6 @@ pub use conclusion::{Conclusion, StageSummary};
pub use dense::{ServerSettings, UserSettings, WorkflowSettings};
pub use diff::{DiffStats, DiffSummary, RunDiff};
pub use engine::{PetriAdmission, PetriGraphRef};
pub use event_envelope::EventEnvelope;
pub use failure_signature::FailureSignature;
pub use git_identity::{GitIdentity, GitIdentitySource};
pub use graph::{
@ -88,7 +91,8 @@ pub use input_scalar::{
toml_scalar_to_json_value,
};
pub use interview::{
InterviewQuestionRecord, QuestionType, ReviewTarget, ReviewTargetError, ReviewTargetKind,
InterviewOption, InterviewQuestionRecord, QuestionType, ReviewTarget, ReviewTargetError,
ReviewTargetKind,
};
pub use llm_backend::AgentBackend;
pub use manifest_path::{ManifestPath, ManifestPathParseError};
@ -98,6 +102,7 @@ pub use mcp_store::{
validate_mcp_server_fields,
};
pub use model_test::ModelTestMode;
pub use notice::{RunNoticeCode, RunNoticeLevel};
pub use outcome::{
FailureCategory, FailureDetail, NodeResult, Outcome, OutcomeMeta, StageOutcome, StageState,
};
@ -133,12 +138,6 @@ pub use run::{
DirtyStatus, ForkSourceRef, GitContext, RunClientProvenance, RunProvenance,
RunServerProvenance, RunSpec,
};
pub use run_event::{
AgentEventProps, AgentToolsAvailableProps, CODING_EVENT_NAMES, EventBody, FailoverProps,
InterviewOption, MetadataSnapshotFailureKind, MetadataSnapshotPhase, RunEvent, RunNoticeCode,
RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunRunnableSource, SessionCapability,
coding_event_name, is_coding_event_name, sandbox_driver_event_name,
};
pub use run_failure::RunFailure;
pub use run_id::{RunId, fixtures};
pub use run_intent::{
@ -182,8 +181,8 @@ pub use stage_handler::StageHandler;
pub use stage_id::{InvalidStageVisit, ParallelBranchId, StageId};
pub use start::StartRecord;
pub use status::{
BlockedReason, FailureReason, InvalidTransition, PendingReason, RunControlAction, RunStatus,
RunStatusKind, SuccessReason, TerminalStatus,
BlockedReason, FailureReason, InvalidTransition, PendingReason, RunControlAction,
RunRunnableSource, RunStatus, RunStatusKind, SuccessReason, TerminalStatus,
};
pub use steering::SteeringMessage;
pub use system_integrations::{

View file

@ -0,0 +1,61 @@
//! A run notice: something Fabro wants a reader to know about a run that
//! is not the engine's own event, recorded as a `run.notice` platform
//! record.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunNoticeLevel {
Info,
Warn,
Error,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum RunNoticeCode {
ArtifactCollectionFailed,
ArtifactOffloadFailed,
ArtifactSyncFailed,
ArtifactUploadFailed,
CheckpointMetadataDegraded,
CheckpointMetadataPushFailed,
CheckpointMetadataWriteFailed,
DirtyWorktree,
GitDiffFailed,
GitIdentityFallback,
GitPushFailed,
GithubTokenFailed,
GithubTokenRefreshLimited,
ModelFallbackChainEmpty,
ModelFallbackSkipped,
PullRequestFailed,
SandboxCleanupFailed,
SandboxGitUnavailable,
SandboxPreserved,
WorktreeSkippedNoGit,
}
impl RunNoticeCode {
#[must_use]
pub fn is_metadata_snapshot_compat(self) -> bool {
matches!(
self,
Self::CheckpointMetadataWriteFailed | Self::CheckpointMetadataPushFailed
)
}
}

View file

@ -1,263 +0,0 @@
use serde::{Deserialize, Serialize};
use super::ExecOutputTail;
use crate::{GitIdentity, RunSandboxFailure, SandboxProviderKind};
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum RunNoticeCode {
ArtifactCollectionFailed,
ArtifactOffloadFailed,
ArtifactSyncFailed,
ArtifactUploadFailed,
CheckpointMetadataDegraded,
CheckpointMetadataPushFailed,
CheckpointMetadataWriteFailed,
DirtyWorktree,
GitDiffFailed,
GitIdentityFallback,
GitPushFailed,
GithubTokenFailed,
GithubTokenRefreshLimited,
ModelFallbackChainEmpty,
ModelFallbackSkipped,
PullRequestFailed,
SandboxCleanupFailed,
SandboxGitUnavailable,
SandboxPreserved,
WorktreeSkippedNoGit,
}
impl RunNoticeCode {
#[must_use]
pub fn is_metadata_snapshot_compat(self) -> bool {
matches!(
self,
Self::CheckpointMetadataWriteFailed | Self::CheckpointMetadataPushFailed
)
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum MetadataSnapshotPhase {
Init,
Checkpoint,
Finalize,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum MetadataSnapshotFailureKind {
LoadState,
Write,
Push,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MetadataSnapshotStartedProps {
pub phase: MetadataSnapshotPhase,
pub branch: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MetadataSnapshotCompletedProps {
pub phase: MetadataSnapshotPhase,
pub branch: String,
pub duration_ms: u64,
pub entry_count: usize,
pub bytes: u64,
pub commit_sha: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MetadataSnapshotFailedProps {
pub phase: MetadataSnapshotPhase,
pub branch: String,
pub duration_ms: u64,
pub failure_kind: MetadataSnapshotFailureKind,
pub error: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub causes: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_count: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec_output_tail: Option<ExecOutputTail>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SandboxInitializingProps {
pub provider: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SandboxReadyProps {
pub provider: String,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
}
pub type SandboxFailedProps = RunSandboxFailure;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SandboxInitializedProps {
pub working_directory: String,
pub provider: SandboxProviderKind,
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_cloned: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clone_origin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clone_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_root: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repos_root: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_repo_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub primary_repo_link: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SetupStartedProps {
pub command_count: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SetupCommandStartedProps {
pub command: String,
pub index: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SetupCommandCompletedProps {
pub command: String,
pub index: usize,
pub exit_code: i32,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SetupCompletedProps {
pub duration_ms: u64,
}
/// The Git author/committer identity the run resolved for every commit it
/// creates, with the credential it was derived from.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitIdentityResolvedProps {
#[serde(flatten)]
pub identity: GitIdentity,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SetupFailedProps {
pub command: String,
pub index: usize,
pub exit_code: i32,
pub stderr: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec_output_tail: Option<ExecOutputTail>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CliEnsureStartedProps {
pub cli_name: String,
pub provider: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CliEnsureCompletedProps {
pub cli_name: String,
pub provider: String,
pub already_installed: bool,
pub node_installed: bool,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CliEnsureFailedProps {
pub cli_name: String,
pub provider: String,
pub error: String,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec_output_tail: Option<ExecOutputTail>,
}
#[cfg(test)]
mod tests {
use super::ExecOutputTail;
/// The trace summary is expanded into tracing fields, so it must carry
/// sizes and truncation flags only.
#[test]
fn exec_output_tail_trace_summary_exposes_sizes_not_content() {
let tail = ExecOutputTail {
stdout: Some("secret stdout bytes".to_string()),
stderr: Some("secret stderr".to_string()),
stdout_truncated: true,
stderr_truncated: false,
};
let summary = ExecOutputTail::trace_summary(Some(&tail));
assert!(summary.present);
assert_eq!(summary.stdout_bytes, 19);
assert_eq!(summary.stderr_bytes, 13);
assert!(summary.stdout_truncated);
assert!(!summary.stderr_truncated);
let rendered = format!("{summary:?}");
assert!(!rendered.contains("secret"), "got: {rendered}");
}
}

View file

@ -1,357 +0,0 @@
use serde::{Deserialize, Serialize};
use super::ExecOutputTail;
use crate::{
CommandTermination, ParallelBranchResult, PullRequestCreationId, PullRequestLink, ReviewTarget,
StageId, StageOutcome,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct InterviewOption {
pub key: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ParallelStartedProps {
pub visit: u32,
pub branch_count: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ParallelBranchStartedProps {
pub index: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub item_label: Option<String>,
/// Graph visit of the branch target for this dispatch. The envelope
/// `stage_id` ordinal counts executions, so a resumed fan-out's branches
/// keep visit metadata even though their ordinals advanced.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph_visit: Option<u32>,
/// Prior branch execution superseded by this resumed replay.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resumed_from_stage_id: Option<StageId>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ParallelBranchCompletedProps {
pub index: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub item_label: Option<String>,
pub duration_ms: u64,
pub status: StageOutcome,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ParallelCompletedProps {
pub visit: u32,
pub duration_ms: u64,
pub success_count: usize,
pub failure_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub results: Vec<ParallelBranchResult>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InterviewStartedProps {
#[serde(default)]
pub question_id: String,
pub question: String,
#[serde(default)]
pub stage: String,
pub question_type: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<InterviewOption>,
#[serde(default)]
pub allow_freeform: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_display: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_target: Option<ReviewTarget>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InterviewCompletedProps {
#[serde(default)]
pub question_id: String,
pub question: String,
pub answer: String,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InterviewTimeoutProps {
#[serde(default)]
pub question_id: String,
pub question: String,
#[serde(default)]
pub stage: String,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InterviewInterruptedProps {
#[serde(default)]
pub question_id: String,
pub question: String,
#[serde(default)]
pub stage: String,
pub reason: String,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitCommitProps {
pub sha: String,
}
/// Why a failed git push attempt is safe to retry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum GitPushRetryReason {
/// A recently minted token may not have reached every GitHub git endpoint.
TokenReplication,
/// The failure came from transient network or service infrastructure.
TransientInfra,
}
/// Non-secret origin of the token used by a git push attempt.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum GitTokenProvenance {
/// The token source minted the token for this resolve.
Minted,
/// The token source reused an earlier mint.
Reused,
/// The credential cannot be refreshed by Fabro.
Static,
}
/// One attempt of a retried git push, nested inside [`GitPushProps`].
///
/// The durable projection of the sandbox layer's runtime attempt record.
/// Token identity is flattened into the three `token_*` fields — a nested
/// provenance enum never appears in stored events. `classified_reason` is the
/// retry classifier's verdict for a failed attempt (the terminal attempt
/// carries its classification too); whether an attempt was actually retried
/// is positional — every entry except the last.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitPushAttemptProps {
/// 1-based attempt number within this push operation.
pub attempt: u32,
pub started_at: chrono::DateTime<chrono::Utc>,
pub success: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub classified_reason: Option<GitPushRetryReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec_output_tail: Option<ExecOutputTail>,
/// Generation of the token embedded during this attempt (0 for static
/// credentials).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_generation: Option<u64>,
/// `minted`, `reused`, or `static`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_provenance: Option<GitTokenProvenance>,
/// Token age at the attempt; absent for static credentials.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_age_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitPushProps {
pub branch: String,
/// Final outcome of the whole push operation — one `git.push` event per
/// high-level push, so finality is unambiguous.
pub success: bool,
/// The final attempt's output tail, unchanged for existing consumers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec_output_tail: Option<ExecOutputTail>,
/// Per-attempt history. Absent on events stored before attempts were
/// recorded.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attempts: Vec<GitPushAttemptProps>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitFetchProps {
pub branch: String,
pub success: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GitResetProps {
pub sha: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EdgeSelectedProps {
pub from_node: String,
pub to_node: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub condition: Option<String>,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preferred_label: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suggested_next_ids: Vec<String>,
pub stage_status: String,
pub is_jump: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LoopRestartProps {
pub from_node: String,
pub to_node: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SubgraphStartedProps {
pub start_node: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SubgraphCompletedProps {
pub steps_executed: usize,
pub status: String,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StallWatchdogTimeoutProps {
pub idle_seconds: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ArtifactCapturedProps {
pub attempt: u32,
pub node_slug: String,
pub path: String,
pub mime: String,
pub content_md5: String,
pub content_sha256: String,
pub bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SshAccessReadyProps {
pub ssh_command: String,
}
/// A one-shot prompt stage moved to a fallback route. The stage walks its
/// plan itself, so this is fabro's own event; an agent stage's moves are
/// pebble's `agent.route.failover`, stored verbatim.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FailoverProps {
pub from_provider: String,
pub from_model: String,
pub to_provider: String,
pub to_model: String,
/// How many routes the prompt had moved through, this one included.
/// `None` only on events recorded before it was kept.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attempt: Option<u32>,
pub error: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommandStartedProps {
pub script: String,
pub command: String,
pub language: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CommandCompletedProps {
pub output: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
pub duration_ms: u64,
pub termination: CommandTermination,
#[serde(default)]
pub output_bytes: u64,
#[serde(default)]
pub live_streaming: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentAcpStartedProps {
pub visit: u32,
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentAcpCompletedProps {
pub stdout: String,
pub stderr: String,
pub stop_reason: String,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentAcpCancelledProps {
pub stdout: String,
pub stderr: String,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentAcpTimedOutProps {
pub stdout: String,
pub stderr: String,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PullRequestCreationRequestedProps {
pub creation_id: PullRequestCreationId,
pub model: String,
pub force: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PullRequestCreatedProps {
pub pr_url: String,
pub pr_number: u64,
pub owner: String,
pub repo: String,
pub base_branch: String,
pub head_branch: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head_sha: Option<String>,
pub title: String,
pub draft: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PullRequestLinkedProps {
pub pull_request: PullRequestLink,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PullRequestUnlinkedProps {
pub pull_request: PullRequestLink,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PullRequestFailedProps {
/// Set when the failure resolves an explicitly requested creation; absent
/// for pull request failures in the workflow publish stage.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_id: Option<PullRequestCreationId>,
pub error: String,
}

File diff suppressed because it is too large Load diff

View file

@ -1,283 +0,0 @@
use std::collections::BTreeMap;
use lithos_llm::types::Usage;
use serde::{Deserialize, Serialize};
use super::{ExecOutputTail, RunNoticeLevel};
use crate::status::{BlockedReason, PendingReason, SuccessReason};
use crate::{
AutomationRef, BlobHash, DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget,
PetriAdmission, RunControlAction, RunFailure, RunId, RunProvenance, RunTarget, RunTiming,
WorkflowSettings, WorkflowVersionId,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunCreatedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub settings: WorkflowSettings,
pub graph: Graph,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_source: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_slug: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_version_id: Option<WorkflowVersionId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<RunTarget>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub automation: Option<AutomationRef>,
pub provenance: RunProvenance,
/// Unredacted copy of the run spec in the blob store. The settings and
/// graph on this event are redacted at the sink; execution loads the
/// spec from this blob instead.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec_blob: Option<BlobHash>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<GitContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fork_source_ref: Option<ForkSourceRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retried_from: Option<RunId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<RunId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web_url: Option<String>,
/// What Petri admitted for the run at create time.
pub admission: PetriAdmission,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunParentLinkedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_parent_id: Option<RunId>,
pub parent_id: RunId,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunParentUnlinkedProps {
pub previous_parent_id: RunId,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunStartedProps {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<String>,
}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunStatusTransitionProps {}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunStatusEffectProps {}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunInterruptProps {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSteerProps {
pub text: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunPairStartedProps {
pub pair_id: PairId,
pub target: PairTarget,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunPairEndedReason {
UserRequested,
RunEnded,
SessionEnded,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunPairEndedProps {
pub pair_id: PairId,
pub reason: RunPairEndedReason,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunPairFailedReason {
WorkerGone,
RuntimeFailed,
SessionFailed,
RunFailed,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunPairFailedProps {
pub pair_id: PairId,
pub reason: RunPairFailedReason,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSubmittedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub definition_blob: Option<BlobHash>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunStartRequestedProps {
pub resume: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunPendingProps {
pub reason: PendingReason,
}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunApprovedProps {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunDeniedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum RunRunnableSource {
StartRequested,
Approved,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunRunnableProps {
pub source: RunRunnableSource,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunControlRequestedProps {
pub action: RunControlAction,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunBlockedProps {
pub blocked_reason: BlockedReason,
}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunControlEffectProps {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSupersededByProps {
pub new_run_id: RunId,
pub target_checkpoint_ordinal: usize,
pub target_node_id: String,
pub target_visit: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunTitleUpdatedProps {
pub title: String,
}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunArchivedProps {}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."
)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RunUnarchivedProps {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunCompletedProps {
/// Run wall-clock time, with active timing breakdown for the run rollup.
pub timing: RunTiming,
pub artifact_count: usize,
pub status: String,
pub reason: SuccessReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diff_summary: Option<DiffSummary>,
/// The run's usage summed across every stage visit; absent for a run
/// that made no model calls.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunFailedProps {
pub failure: RunFailure,
/// Run wall-clock time at failure, with active timing breakdown.
pub timing: RunTiming,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diff_summary: Option<DiffSummary>,
/// What the run spent before it failed, as on `run.completed`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunNoticeProps {
pub level: RunNoticeLevel,
pub code: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec_output_tail: Option<ExecOutputTail>,
}

View file

@ -1,16 +0,0 @@
//! The legacy event log's names for the session event properties, which
//! live in `crate::session_event`.
pub use crate::session_event::{
SessionAssistantDeltaProps as RunSessionAssistantDeltaProps,
SessionAssistantMessageProps as RunSessionAssistantMessageProps,
SessionCreatedProps as RunSessionCreatedProps,
SessionToolCallCompletedProps as RunSessionToolCallCompletedProps,
SessionToolCallStartedProps as RunSessionToolCallStartedProps,
SessionTurnFailedCode as RunSessionTurnFailedCode,
SessionTurnFailedProps as RunSessionTurnFailedProps,
SessionTurnInterruptedProps as RunSessionTurnInterruptedProps,
SessionTurnStartedProps as RunSessionTurnStartedProps,
SessionTurnSucceededProps as RunSessionTurnSucceededProps,
SessionUserMessageProps as RunSessionUserMessageProps,
};

View file

@ -1,161 +0,0 @@
use std::collections::BTreeMap;
use lithos_llm::types::{ReasoningEffort, Speed};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::ExecOutputTail;
use crate::{DiffSummary, FailureDetail, ModelUsage, Outcome, StageId, StageOutcome, StageTiming};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StageStartedProps {
pub index: usize,
pub handler_type: String,
pub attempt: usize,
pub max_attempts: usize,
/// Graph visit that produced this stage execution. The envelope
/// `stage_id` ordinal counts executions, which diverges from the graph
/// visit when post-checkpoint work is replayed after
/// resume. Absent on events written before stage execution identity.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph_visit: Option<u32>,
/// Prior execution superseded by this resumed replay.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resumed_from_stage_id: Option<StageId>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StageCompletedProps {
pub index: usize,
/// Per-attempt timing breakdown for this stage visit.
pub timing: StageTiming,
pub status: StageOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preferred_label: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suggested_next_ids: Vec<String>,
/// The stage's usage: for an agent stage, the whole session tree's
/// tokens (the root session and every subagent) under the root's route.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<ModelUsage>,
/// `usage` split by model: the root session's route and each subagent's
/// own model, a subagent whose model the catalog does not know priced at
/// the root's. Sums to `usage`. Empty for stages without a coding agent
/// and on events written before it existed.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub usage_by_model: Vec<ModelUsage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<FailureDetail>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files_touched: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_updates: Option<BTreeMap<String, Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jump_to_node: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_values: Option<BTreeMap<String, Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub node_visits: Option<BTreeMap<String, usize>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loop_failure_signatures: Option<BTreeMap<String, usize>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub restart_failure_signatures: Option<BTreeMap<String, usize>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response: Option<String>,
pub attempt: usize,
pub max_attempts: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StageFailedProps {
pub index: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<FailureDetail>,
pub will_retry: bool,
/// Per-attempt timing breakdown for this stage visit.
#[serde(default)]
pub timing: StageTiming,
/// The stage's usage: for an agent stage that failed after spending,
/// the whole session tree's tokens under the root's route.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<ModelUsage>,
/// `usage` split by model, as on `stage.completed`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub usage_by_model: Vec<ModelUsage>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StageRetryingProps {
pub index: usize,
pub attempt: usize,
pub max_attempts: usize,
pub delay_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StagePromptProps {
pub visit: u32,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<ReasoningEffort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<Speed>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PromptCompletedProps {
pub response: String,
pub model: String,
pub provider: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<ModelUsage>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CheckpointCompletedProps {
pub status: String,
pub current_node: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub completed_nodes: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub node_retries: BTreeMap<String, u32>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub context_values: BTreeMap<String, Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub node_outcomes: BTreeMap<String, Outcome<Option<ModelUsage>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub loop_failure_signatures: BTreeMap<String, usize>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub restart_failure_signatures: BTreeMap<String, usize>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub node_visits: BTreeMap<String, usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diff: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub diff_summary: Option<DiffSummary>,
/// Graph visit of the checkpointed stage execution; used when this
/// checkpoint is the event that first materializes a skipped stage.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph_visit: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resumed_from_stage_id: Option<StageId>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CheckpointFailedProps {
pub error: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec_output_tail: Option<ExecOutputTail>,
}

View file

@ -11,7 +11,7 @@ use pebble_coding_agent::events::{
use pebble_coding_agent::projection::SessionProjection;
use strum::{Display, EnumString, IntoStaticStr};
use crate::run_event::{AgentSessionActivatedProps, StagePromptProps};
use crate::agent_props::{AgentSessionActivatedProps, StagePromptProps};
use crate::{
AgentBackend, Checkpoint, Conclusion, GitIdentity, InterviewQuestionRecord, InvalidTransition,
ModelRef, ModelUsage, ParallelBranchId, PullRequestCreation, PullRequestLink, RunApproval,

View file

@ -578,3 +578,23 @@ mod tests {
assert_eq!(err, InvalidTransition { from, to });
}
}
/// What made a run runnable: its start request, or its approval.
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Serialize,
Deserialize,
strum::Display,
strum::EnumString,
strum::IntoStaticStr,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum RunRunnableSource {
StartRequested,
Approved,
}

View file

@ -43,82 +43,53 @@ impl ProjectionUsageRollup {
(self.usage_visit_count > 0).then_some(self.totals)
}
/// Reconstruct the conclusion's per-node summaries from checkpoint and
/// stage events. Repeated visits share one row, ordered by the node's
/// first stage event.
/// The conclusion's per-node summaries: one row per node the run
/// visited, ordered by the node's first stage event, with the usage
/// and timing summed over its visits and the retries counted past the
/// first visit.
#[must_use]
pub fn conclusion_stages(&self, projection: &RunProjection) -> (Vec<StageSummary>, u32) {
let projection_order = stage_projection_order(projection);
// Looping workflows revisit nodes; `completed_nodes` accumulates duplicates
// while the other checkpoint maps are keyed by node_id. Dedupe to one row
// per node so the stages table matches the deduped usage total.
if let Some(cp) = projection.current_checkpoint() {
let usage_by_node = self
.stages
.iter()
.map(|stage| (stage.node_id.as_str(), stage))
.collect::<HashMap<_, _>>();
let mut stage_rows = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut retries_sum: u32 = 0;
let mut stage_order = Vec::new();
for (original_checkpoint_order, node_id) in cp.completed_nodes.iter().enumerate() {
if !seen.insert(node_id.as_str()) {
continue;
}
stage_order.push((original_checkpoint_order, node_id.as_str()));
let usage_by_node = self
.stages
.iter()
.map(|stage| (stage.node_id.as_str(), stage))
.collect::<HashMap<_, _>>();
let mut nodes = projection
.iter_stages()
.map(|(stage_id, _)| stage_id.node_id())
.collect::<Vec<_>>();
nodes.dedup();
let mut seen = std::collections::HashSet::new();
let mut retries_sum: u32 = 0;
let mut stage_rows = Vec::new();
for node_id in nodes {
if !seen.insert(node_id) {
continue;
}
let mut extra_node_outcomes = cp
.node_outcomes
.keys()
.filter(|node_id| !seen.contains(node_id.as_str()))
.map(String::as_str)
.collect::<Vec<_>>();
extra_node_outcomes.sort_unstable();
let extra_offset = stage_order.len();
for (extra_index, node_id) in extra_node_outcomes.into_iter().enumerate() {
seen.insert(node_id);
stage_order.push((extra_offset + extra_index, node_id));
}
for (original_checkpoint_order, node_id) in stage_order {
let retries = cp
.node_retries
.get(node_id)
.copied()
.unwrap_or(1)
.saturating_sub(1);
retries_sum += retries;
let row = usage_by_node.get(node_id);
let summary = StageSummary {
stage_id: node_id.to_string(),
stage_label: node_id.to_string(),
timing: row.map_or_else(StageTiming::default, |stage| stage.timing),
usage: row.map_or_else(Usage::default, |stage| stage.usage),
retries,
};
stage_rows.push((
projection_order.get(node_id).copied().unwrap_or(u32::MAX),
original_checkpoint_order,
summary,
));
}
stage_rows.sort_by(|left, right| {
left.0
.cmp(&right.0)
.then_with(|| left.1.cmp(&right.1))
.then_with(|| left.2.stage_id.cmp(&right.2.stage_id))
});
let stages = stage_rows
.into_iter()
.map(|(_, _, summary)| summary)
.collect();
(stages, retries_sum)
} else {
(vec![], 0)
let visits = projection.list_node_visits(node_id).len();
let retries = u32::try_from(visits.saturating_sub(1)).unwrap_or(u32::MAX);
retries_sum = retries_sum.saturating_add(retries);
let row = usage_by_node.get(node_id);
let summary = StageSummary {
stage_id: node_id.to_string(),
stage_label: node_id.to_string(),
timing: row.map_or_else(StageTiming::default, |stage| stage.timing),
usage: row.map_or_else(Usage::default, |stage| stage.usage),
retries,
};
stage_rows.push((
projection_order.get(node_id).copied().unwrap_or(u32::MAX),
summary,
));
}
stage_rows.sort_by(|left, right| {
left.0
.cmp(&right.0)
.then_with(|| left.1.stage_id.cmp(&right.1.stage_id))
});
let stages = stage_rows.into_iter().map(|(_, summary)| summary).collect();
(stages, retries_sum)
}
}

View file

@ -1,261 +0,0 @@
use std::collections::BTreeMap;
use fabro_types::graph::Graph;
use fabro_types::run::{DirtyStatus, ForkSourceRef, GitContext};
use fabro_types::run_event::run::{RunCreatedProps, RunParentLinkedProps, RunParentUnlinkedProps};
use fabro_types::run_event::{RunSessionTurnFailedCode, RunSessionTurnFailedProps};
use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunGoal;
use fabro_types::test_support::{test_run_provenance, test_workflow_version_id};
use fabro_types::{
AutomationRef, EventBody, GitRunTarget, PetriAdmission, ResolvedAutomationGitWorkflowSource,
RunTarget, TurnId, WorkflowSettings, fixtures,
};
fn templated_settings() -> WorkflowSettings {
let mut settings = WorkflowSettings::default();
settings.run.goal = Some(RunGoal::Inline(InterpString::parse("Ship {{ env.TASK }}")));
settings
}
#[test]
fn run_created_props_round_trip_templated_settings() {
let props = RunCreatedProps {
title: Some("Ship task".to_string()),
settings: templated_settings(),
graph: Graph::new("ship"),
workflow_source: Some("digraph Ship { start -> exit }".to_string()),
labels: BTreeMap::from([("team".to_string(), "platform".to_string())]),
source_directory: Some("/Users/client/project".to_string()),
workflow_slug: Some("demo".to_string()),
workflow_version_id: Some(test_workflow_version_id()),
target: Some(RunTarget::Git(GitRunTarget {
repo: "fabro-sh/fabro".to_string(),
branch: "main".to_string(),
tag: None,
sha: None,
})),
automation: Some(AutomationRef {
id: "nightly".to_string(),
name: Some("Nightly".to_string()),
trigger_id: Some("schedule_1".to_string()),
workflow_source: Some(Box::new(ResolvedAutomationGitWorkflowSource {
repo: "fabro-sh/workflows".to_string(),
branch: "main".to_string(),
tag: Some("v1".to_string()),
sha: None,
resolved_sha: "0123456789abcdef0123456789abcdef01234567".to_string(),
})),
}),
provenance: test_run_provenance(),
spec_blob: None,
git: Some(GitContext {
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
branch: "main".to_string(),
sha: None,
dirty: DirtyStatus::Unknown,
}),
fork_source_ref: Some(ForkSourceRef {
source_run_id: fixtures::RUN_2,
checkpoint_sha: "def456".to_string(),
}),
retried_from: Some(fixtures::RUN_1),
parent_id: Some(fixtures::RUN_2),
web_url: Some(
"http://localhost:3000/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z".to_string(),
),
admission: PetriAdmission::default(),
};
let json = serde_json::to_value(&props).expect("props should serialize");
assert!(json.get("working_directory").is_none());
assert!(json.get("host_repo_path").is_none());
assert_eq!(json["source_directory"], "/Users/client/project");
assert_eq!(
json["git"]["origin_url"],
"https://github.com/fabro-sh/fabro.git"
);
assert_eq!(json["git"]["branch"], "main");
assert_eq!(json["git"]["dirty"], "unknown");
assert!(json["git"].get("push_outcome").is_none());
assert_eq!(
json["web_url"],
"http://localhost:3000/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z"
);
assert_eq!(json["retried_from"], fixtures::RUN_1.to_string());
assert_eq!(json["parent_id"], fixtures::RUN_2.to_string());
assert_eq!(json["automation"]["id"], "nightly");
assert_eq!(json["automation"]["trigger_id"], "schedule_1");
assert_eq!(json["automation"]["workflow_source"]["branch"], "main");
assert_eq!(json["automation"]["workflow_source"]["tag"], "v1");
assert_eq!(
json["automation"]["workflow_source"]["resolved_sha"],
"0123456789abcdef0123456789abcdef01234567"
);
assert_eq!(
json["workflow_version_id"],
test_workflow_version_id().to_string()
);
let round_trip: RunCreatedProps =
serde_json::from_value(json.clone()).expect("props should deserialize");
assert_eq!(
serde_json::to_value(&round_trip).expect("round-trip should serialize"),
json
);
assert_eq!(
round_trip.settings.run.goal,
Some(RunGoal::Inline(InterpString::parse("Ship {{ env.TASK }}")))
);
}
#[test]
fn run_created_props_omits_web_url_when_absent() {
let props = RunCreatedProps {
title: None,
settings: WorkflowSettings::default(),
graph: Graph::new("ship"),
workflow_source: None,
labels: BTreeMap::new(),
source_directory: None,
workflow_slug: None,
workflow_version_id: None,
target: None,
automation: None,
provenance: test_run_provenance(),
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
admission: PetriAdmission::default(),
};
let json = serde_json::to_value(&props).expect("props should serialize");
assert!(
json.get("web_url").is_none(),
"web_url must be omitted when None, got {json}"
);
assert!(
json.get("parent_id").is_none(),
"parent_id must be omitted when None, got {json}"
);
assert!(
json.get("retried_from").is_none(),
"retried_from must be omitted when None, got {json}"
);
assert!(
json.get("workflow_version_id").is_none(),
"workflow_version_id must be omitted when None, got {json}"
);
let round_trip: RunCreatedProps =
serde_json::from_value(json.clone()).expect("props should deserialize");
assert_eq!(round_trip.web_url, None);
assert_eq!(round_trip.parent_id, None);
assert_eq!(round_trip.retried_from, None);
}
#[test]
fn run_created_props_defaults_additive_fields_for_legacy_events() {
let json = serde_json::json!({
"title": null,
"settings": WorkflowSettings::default(),
"graph": Graph::new("ship"),
"labels": {},
"provenance": test_run_provenance()
});
let props: RunCreatedProps =
serde_json::from_value(json).expect("legacy props should deserialize");
assert_eq!(props.retried_from, None);
assert_eq!(props.automation, None);
assert_eq!(props.workflow_version_id, None);
}
#[test]
fn run_created_props_tolerates_legacy_git_push_outcome() {
let json = serde_json::json!({
"title": null,
"settings": WorkflowSettings::default(),
"graph": Graph::new("ship"),
"labels": {},
"provenance": test_run_provenance(),
"git": {
"origin_url": "https://github.com/fabro-sh/fabro.git",
"branch": "main",
"sha": "abc123",
"dirty": "clean",
"push_outcome": {
"type": "failed",
"remote": "origin",
"branch": "main",
"message": "remote rejected"
}
}
});
let props: RunCreatedProps =
serde_json::from_value(json).expect("legacy event with push_outcome should deserialize");
let git = props.git.as_ref().expect("git context should be present");
assert_eq!(git.origin_url, "https://github.com/fabro-sh/fabro.git");
assert_eq!(git.branch, "main");
assert_eq!(git.sha.as_deref(), Some("abc123"));
assert_eq!(git.dirty, DirtyStatus::Clean);
let reserialized = serde_json::to_value(&props).expect("props should reserialize");
assert!(reserialized["git"].get("push_outcome").is_none());
assert_eq!(reserialized["git"]["origin_url"], git.origin_url);
}
#[test]
fn run_parent_events_round_trip_parent_ids() {
let linked = EventBody::RunParentLinked(RunParentLinkedProps {
previous_parent_id: None,
parent_id: fixtures::RUN_2,
});
let linked_json = serde_json::to_value(&linked).expect("linked event should serialize");
assert_eq!(linked_json["event"], "run.parent.linked");
assert_eq!(
linked_json["properties"]["parent_id"],
fixtures::RUN_2.to_string()
);
let linked_round_trip: EventBody =
serde_json::from_value(linked_json).expect("linked event should deserialize");
assert_eq!(linked_round_trip.event_name(), "run.parent.linked");
let unlinked = EventBody::RunParentUnlinked(RunParentUnlinkedProps {
previous_parent_id: fixtures::RUN_2,
});
let unlinked_json = serde_json::to_value(&unlinked).expect("unlinked event should serialize");
assert_eq!(unlinked_json["event"], "run.parent.unlinked");
assert_eq!(
unlinked_json["properties"]["previous_parent_id"],
fixtures::RUN_2.to_string()
);
assert!(unlinked_json["properties"].get("parent_id").is_none());
let unlinked_round_trip: EventBody =
serde_json::from_value(unlinked_json).expect("unlinked event should deserialize");
assert_eq!(unlinked_round_trip.event_name(), "run.parent.unlinked");
}
#[test]
fn run_session_turn_failed_defaults_code_for_old_events() {
let turn_id = TurnId::new();
let props: RunSessionTurnFailedProps = serde_json::from_value(serde_json::json!({
"turn_id": turn_id,
"error": "legacy failure"
}))
.expect("legacy failed props should deserialize");
assert_eq!(props.code, RunSessionTurnFailedCode::AgentError);
assert!(!props.retryable);
let json = serde_json::to_value(props).expect("props should serialize");
assert_eq!(json["code"], "agent_error");
assert_eq!(json["retryable"], false);
}

View file

@ -1,116 +1,75 @@
use fabro_types::run_event::run::RunFailedProps;
use fabro_types::{
Conclusion, EventBody, ExecOutputTail, FailureCategory, FailureDetail, FailureReason,
FailureSignature, RunDiff, RunFailure, RunTiming, StageOutcome, SystemActorKind,
Conclusion, ExecOutputTail, FailureCategory, FailureDetail, FailureReason, FailureSignature,
RunDiff, RunFailure, RunTiming, StageOutcome, SystemActorKind,
};
use serde_json::json;
#[test]
fn run_failed_serializes_nested_failure_contract() {
let body = EventBody::RunFailed(RunFailedProps {
failure: RunFailure {
reason: FailureReason::SandboxInitFailed,
detail: {
let mut detail = FailureDetail::new(
"Failed to initialize sandbox",
FailureCategory::TransientInfra,
);
detail.causes = vec![
"Failed to pull Docker image buildpack-deps:noble".to_string(),
"connection refused".to_string(),
];
detail.system_actor = Some(SystemActorKind::Engine);
detail.signature = Some(FailureSignature(
"init|transient_infra|docker-pull".to_string(),
));
detail.exec_output_tail = Some(ExecOutputTail {
stdout: Some("last stdout line".to_string()),
stderr: Some("last stderr line".to_string()),
stdout_truncated: false,
stderr_truncated: true,
});
detail
},
fn run_failure_serializes_nested_failure_contract() {
let failure = RunFailure {
reason: FailureReason::SandboxInitFailed,
detail: {
let mut detail = FailureDetail::new(
"Failed to initialize sandbox",
FailureCategory::TransientInfra,
);
detail.causes = vec![
"Failed to pull Docker image buildpack-deps:noble".to_string(),
"connection refused".to_string(),
];
detail.system_actor = Some(SystemActorKind::Engine);
detail.signature = Some(FailureSignature(
"init|transient_infra|docker-pull".to_string(),
));
detail.exec_output_tail = Some(ExecOutputTail {
stdout: Some("last stdout line".to_string()),
stderr: Some("last stderr line".to_string()),
stdout_truncated: false,
stderr_truncated: true,
});
detail
},
timing: RunTiming::wall_only(42),
final_git_commit_sha: Some("abc123".to_string()),
final_patch: Some("diff --git a/file b/file".to_string()),
diff_summary: None,
usage: None,
});
};
let value = serde_json::to_value(&body).expect("run.failed body should serialize");
let value = serde_json::to_value(&failure).expect("the failure should serialize");
assert_eq!(value["event"], "run.failed");
assert_eq!(
value["properties"],
value,
json!({
"failure": {
"reason": "sandbox_init_failed",
"detail": {
"message": "Failed to initialize sandbox",
"causes": [
"Failed to pull Docker image buildpack-deps:noble",
"connection refused"
],
"category": "transient_infra",
"system_actor": "engine",
"signature": "init|transient_infra|docker-pull",
"exec_output_tail": {
"stdout": "last stdout line",
"stderr": "last stderr line",
"stderr_truncated": true
}
"reason": "sandbox_init_failed",
"detail": {
"message": "Failed to initialize sandbox",
"causes": [
"Failed to pull Docker image buildpack-deps:noble",
"connection refused"
],
"category": "transient_infra",
"system_actor": "engine",
"signature": "init|transient_infra|docker-pull",
"exec_output_tail": {
"stdout": "last stdout line",
"stderr": "last stderr line",
"stderr_truncated": true
}
},
"timing": {
"wall_time_ms": 42,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
},
"final_git_commit_sha": "abc123",
"final_patch": "diff --git a/file b/file"
}
})
);
assert!(value["properties"].get("error").is_none());
assert!(value["properties"].get("causes").is_none());
assert!(value["properties"].get("reason").is_none());
assert!(value["properties"].get("git_commit_sha").is_none());
}
#[test]
fn run_failed_omits_empty_failure_optional_fields() {
let body = EventBody::RunFailed(RunFailedProps {
failure: RunFailure {
reason: FailureReason::WorkflowError,
detail: FailureDetail::new("boom", FailureCategory::Deterministic),
},
timing: RunTiming::wall_only(1),
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
usage: None,
});
fn run_failure_omits_empty_optional_fields() {
let failure = RunFailure {
reason: FailureReason::WorkflowError,
detail: FailureDetail::new("boom", FailureCategory::Deterministic),
};
let value = serde_json::to_value(&body).expect("run.failed body should serialize");
let value = serde_json::to_value(&failure).expect("the failure should serialize");
assert_eq!(
value["properties"],
value,
json!({
"failure": {
"reason": "workflow_error",
"detail": {
"message": "boom",
"category": "deterministic"
}
},
"timing": {
"wall_time_ms": 1,
"inference_time_ms": 0,
"tool_time_ms": 0,
"active_time_ms": 0
}
"reason": "workflow_error",
"detail": { "message": "boom", "category": "deterministic" }
})
);
}

View file

@ -102,23 +102,3 @@ fn run_spec_round_trips_templated_settings() {
Some(RunGoal::Inline(InterpString::parse("Ship {{ env.TASK }}")))
);
}
#[test]
fn run_spec_defaults_automation_for_legacy_specs() {
let json = serde_json::json!({
"run_id": fixtures::RUN_1,
"settings": WorkflowSettings::default(),
"graph": Graph::new("ship"),
"labels": {},
"provenance": test_run_provenance()
});
let record: RunSpec = serde_json::from_value(json).expect("legacy spec should deserialize");
assert_eq!(record.automation, None);
assert_eq!(record.workflow_version_id, None);
assert_eq!(record.target, None);
let round_trip = serde_json::to_value(&record).expect("record should serialize");
assert!(round_trip.get("workflow_version_id").is_none());
}