mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-02 02:13:49 +00:00
Re-pin lithos-llm to 55add4596b861a0623d00c3a54aa5c147c8d504b and
pebble to c91810fe51aece80359b9cd8efea971af0c46925, where token usage
and cost travel together as Usage { tokens: TokenCounts, cost:
Option<Cost> }. Fabro now carries that one type everywhere it used to
carry BilledTokenCounts, BilledModelUsage, UsdMicros, or a token count
beside a cost_usd_micros.
fabro-types: billing.rs is usage.rs with ModelRef, ModelUsage { model,
usage }, sum_usage, and usage_is_empty; billing_rollup.rs is
usage_rollup.rs with ProjectionUsageStage, ProjectionUsageByModel,
ProjectionUsageRollup, and usage_rollup_from_projection. Every usage
field is named usage: StageProjection.usage and usage_by_model,
Outcome<Option<ModelUsage>>, stage.completed and stage.failed usage and
usage_by_model, prompt.completed usage, run.completed and run.failed
usage (total_usd_micros is gone), Conclusion.usage, StageSummary.usage,
Run.usage. RunSize buckets by Cost.
fabro-workflow: model_usage_from_llm prices tokens from the catalog with
a Catalog cost source, with_reported_cost keeps a provider cost, and the
pebble handler's stage_usage groups pebble's accounts by model and sums
rows with Usage::saturating_add, so a total has a cost only when every
priced part was priced. The store fold's live usage is the agent's
usage plus its descendants'.
API: the OpenAPI spec deletes BilledTokenCounts, BilledModelUsage,
CompletionUsage, CompletionCost, TokenUsage, and RunBillingSummary,
adds TokenCounts, Cost, Usage, and ModelUsage, and renames every
billing schema, property, tag, path, and operation to usage. fabro-api
reuses lithos-llm's and fabro-types' types through with_replacement,
with a round-trip test per replacement.
Old stored runs get no migration: their pebble events in the old shape
read back with zero usage, and their rebuilt projections lose agent
usage.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
230 lines
7.9 KiB
Rust
230 lines
7.9 KiB
Rust
// Integration regression for the native run tool using production Git setup.
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
use std::process::Command;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use chrono::{TimeZone, Utc};
|
|
use fabro_tool::fabro_client::ClientBackend;
|
|
use fabro_types::{
|
|
GitRunTarget, Run, RunId, RunLifecycle, RunLinks, RunOrigin, RunProjection, RunStatus,
|
|
RunTarget, RunTimestamps, WorkflowRef, WorkflowVersionId, test_support,
|
|
};
|
|
use httpmock::Method::{GET, POST};
|
|
use httpmock::{HttpMockRequest, HttpMockResponse, MockServer};
|
|
use lithos_llm::types::Usage;
|
|
use serde_json::json;
|
|
use tokio::fs;
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "test fixture setup uses the Git CLI against an isolated temporary repository"
|
|
)]
|
|
fn run_git(cwd: &Path, args: &[&str]) {
|
|
let output = Command::new("git")
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
.output()
|
|
.expect("git command should run");
|
|
assert!(
|
|
output.status.success(),
|
|
"git {args:?} failed: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
fn test_parent(target: Option<RunTarget>) -> RunProjection {
|
|
let mut spec = fabro_types::test_support::test_run_spec();
|
|
spec.target = target;
|
|
RunProjection::new(String::new(), spec, chrono::Utc::now())
|
|
}
|
|
|
|
async fn mock_parent<'a>(server: &'a MockServer, parent: &RunProjection) -> httpmock::Mock<'a> {
|
|
let path = format!("/api/v1/runs/{}/state", parent.spec.id());
|
|
let body = serde_json::to_value(parent).unwrap();
|
|
server
|
|
.mock_async(move |when, then| {
|
|
when.method(GET).path(path);
|
|
then.status(200).json_body(body);
|
|
})
|
|
.await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn run_create_child_checkout_contains_the_parents_pushed_work() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let workspace = temp.path().join("parent");
|
|
let origin = temp.path().join("origin.git");
|
|
fs::create_dir(&workspace).await.unwrap();
|
|
run_git(temp.path(), &[
|
|
"init",
|
|
"--bare",
|
|
"--quiet",
|
|
origin.to_str().unwrap(),
|
|
]);
|
|
run_git(&workspace, &["init", "--quiet", "--initial-branch", "main"]);
|
|
run_git(&workspace, &["config", "user.name", "Fabro Test"]);
|
|
run_git(&workspace, &["config", "user.email", "fabro@example.com"]);
|
|
fs::write(workspace.join("result.txt"), "original")
|
|
.await
|
|
.unwrap();
|
|
run_git(&workspace, &["add", "."]);
|
|
run_git(&workspace, &["commit", "--quiet", "-m", "initial"]);
|
|
run_git(&workspace, &[
|
|
"remote",
|
|
"add",
|
|
"origin",
|
|
origin.to_str().unwrap(),
|
|
]);
|
|
run_git(&workspace, &["push", "--quiet", "origin", "main"]);
|
|
let base_sha = fabro_workflow::git::head_sha(&workspace).unwrap();
|
|
let mut parent = test_parent(Some(RunTarget::Git(GitRunTarget {
|
|
repo: "acme/widgets".to_owned(),
|
|
branch: "main".to_owned(),
|
|
tag: Some("v1.0.0".to_owned()),
|
|
sha: Some(base_sha),
|
|
})));
|
|
let sandbox = fabro_sandbox::local_sandbox(&workspace).await.unwrap();
|
|
// Docker and Daytona use this same setup operation to create the run branch.
|
|
let git = fabro_sandbox::setup_git(&sandbox, &fabro_sandbox::GitSetupIntent::NewRun {
|
|
run_id: parent.spec.id().to_string(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
parent.start = Some(fabro_types::StartRecord {
|
|
start_time: chrono::Utc::now(),
|
|
run_branch: Some(git.run_branch.clone()),
|
|
base_sha: Some(git.base_sha),
|
|
});
|
|
fs::write(workspace.join("result.txt"), "parent implementation")
|
|
.await
|
|
.unwrap();
|
|
run_git(&workspace, &["add", "."]);
|
|
run_git(&workspace, &["commit", "--quiet", "-m", "implement"]);
|
|
run_git(&workspace, &["push", "--quiet", "origin", &git.run_branch]);
|
|
let server = MockServer::start_async().await;
|
|
let state_request = mock_parent(&server, &parent).await;
|
|
let client = fabro_client::Client::new_no_proxy(&server.url("")).unwrap();
|
|
let workflow_version_id: WorkflowVersionId = fabro_types::BlobHash::new(b"stored").into();
|
|
let child_id = RunId::new();
|
|
let submitted = Arc::new(Mutex::new(Vec::<fabro_types::RunIntent>::new()));
|
|
let captured = Arc::clone(&submitted);
|
|
server
|
|
.mock_async(move |when, then| {
|
|
when.method(POST).path("/api/v1/runs");
|
|
then.respond_with(move |request: &HttpMockRequest| {
|
|
captured
|
|
.lock()
|
|
.unwrap()
|
|
.push(serde_json::from_str(&request.body_string()).unwrap());
|
|
HttpMockResponse::builder()
|
|
.status(201)
|
|
.header("content-type", "application/json")
|
|
.body(serde_json::to_string(&run(child_id, None, 0)).unwrap())
|
|
.build()
|
|
});
|
|
})
|
|
.await;
|
|
server
|
|
.mock_async(|when, then| {
|
|
when.method(GET).path(format!("/api/v1/runs/{child_id}"));
|
|
then.status(200)
|
|
.json_body_obj(&run(child_id, Some(parent.spec.id()), 0));
|
|
})
|
|
.await;
|
|
let params = serde_json::from_value(
|
|
json!({"runs":[{"workflow_version_id":workflow_version_id,"start":false}]}),
|
|
)
|
|
.unwrap();
|
|
fabro_tool::create_runs_with_options(
|
|
Arc::new(ClientBackend::new(Arc::new(client))),
|
|
params,
|
|
fabro_tool::CreateRunOptions {
|
|
forced_parent_id: Some(parent.spec.id()),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
state_request.assert_calls_async(1).await;
|
|
let intents = submitted.lock().unwrap().clone();
|
|
assert_eq!(intents.len(), 1);
|
|
assert_eq!(intents[0].workflow_version_id, workflow_version_id);
|
|
assert_eq!(intents[0].parent_id, Some(parent.spec.id()));
|
|
let RunTarget::Git(target) = &intents[0].target else {
|
|
panic!("child should have a Git target")
|
|
};
|
|
assert_eq!(target.repo, "acme/widgets");
|
|
assert_eq!(target.sha, None);
|
|
assert_eq!(target.tag, None);
|
|
let child = temp.path().join("child");
|
|
run_git(temp.path(), &[
|
|
"clone",
|
|
"--quiet",
|
|
"--branch",
|
|
&target.branch,
|
|
origin.to_str().unwrap(),
|
|
child.to_str().unwrap(),
|
|
]);
|
|
assert_eq!(
|
|
fs::read_to_string(child.join("result.txt")).await.unwrap(),
|
|
"parent implementation"
|
|
);
|
|
}
|
|
|
|
fn run(run_id: RunId, parent_id: Option<RunId>, children_count: u64) -> Run {
|
|
run_with_status(run_id, parent_id, children_count, RunStatus::Submitted)
|
|
}
|
|
|
|
fn run_with_status(
|
|
run_id: RunId,
|
|
parent_id: Option<RunId>,
|
|
children_count: u64,
|
|
status: RunStatus,
|
|
) -> Run {
|
|
Run {
|
|
id: run_id,
|
|
parent_id,
|
|
children_count,
|
|
title: "Test run".to_string(),
|
|
goal: "Test run".to_string(),
|
|
workflow: WorkflowRef {
|
|
slug: Some("simple".to_string()),
|
|
name: Some("Simple".to_string()),
|
|
graph_name: None,
|
|
node_count: 0,
|
|
edge_count: 0,
|
|
},
|
|
automation: None,
|
|
repository: None,
|
|
created_by: test_support::test_principal(),
|
|
origin: RunOrigin::default(),
|
|
labels: HashMap::new(),
|
|
lifecycle: RunLifecycle {
|
|
status,
|
|
approval: None,
|
|
pending_control: None,
|
|
queue_position: None,
|
|
error: None,
|
|
archived: false,
|
|
archived_at: None,
|
|
},
|
|
sandbox: None,
|
|
models: Vec::new(),
|
|
source_directory: Some("/srv/repo".to_string()),
|
|
timestamps: RunTimestamps {
|
|
created_at: Utc.with_ymd_and_hms(2026, 4, 5, 12, 0, 0).unwrap(),
|
|
started_at: None,
|
|
last_event_at: None,
|
|
completed_at: None,
|
|
},
|
|
timing: None,
|
|
usage: Usage::default(),
|
|
size: fabro_types::RunSize::default(),
|
|
ask_fabro: fabro_types::AskFabro::default(),
|
|
diff: None,
|
|
pull_request: None,
|
|
current_question: None,
|
|
superseded_by: None,
|
|
retried_from: None,
|
|
links: RunLinks { web: None },
|
|
}
|
|
}
|