mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-01 02:04:24 +00:00
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:
parent
bc31b8d23a
commit
60b503322c
81 changed files with 1127 additions and 25057 deletions
843
Cargo.lock
generated
843
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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 ─────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::path::Path;
|
||||
|
||||
use fabro_workflow::event::RunNoticeLevel;
|
||||
use fabro_types::RunNoticeLevel;
|
||||
|
||||
use super::renderer::ProgressRenderer;
|
||||
use super::styles;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
65
lib/components/fabro-store/src/database.rs
Normal file
65
lib/components/fabro-store/src/database.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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}")]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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])))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
143
lib/components/fabro-store/src/run_summary.rs
Normal file
143
lib/components/fabro-store/src/run_summary.rs
Normal 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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}")))
|
||||
}
|
||||
}
|
||||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}")),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -399,7 +399,7 @@ fn main() {
|
|||
("PermissionLevel", "fabro_types::PermissionLevel", &[]),
|
||||
(
|
||||
"AgentSessionActivatedProps",
|
||||
"fabro_types::run_event::AgentSessionActivatedProps",
|
||||
"fabro_types::AgentSessionActivatedProps",
|
||||
&[],
|
||||
),
|
||||
("TodoListProjection", "fabro_types::TodoListProjection", &[]),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
61
lib/foundation/fabro-types/src/notice.rs
Normal file
61
lib/foundation/fabro-types/src/notice.rs
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>,
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>,
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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" }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue