Merge pull request #827 from fabro-sh/codex/run-intent-registration-spine

Add RunIntent registration support
This commit is contained in:
Scott Werner 2026-08-31 13:44:08 -04:00 committed by GitHub
commit b61d309aa3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 482 additions and 31 deletions

View file

@ -9318,6 +9318,21 @@ components:
type: object
additionalProperties:
type: string
dry_run:
type: boolean
description: >-
Overrides `run.execution.mode`: true selects `dry_run`, false
selects `normal`, and omission inherits the lower-precedence setting.
auto_approve:
type: boolean
description: >-
Overrides `run.execution.approval`: true selects `auto`, false
selects `prompt`, and omission inherits the lower-precedence setting.
preserve_sandbox:
type: boolean
description: >-
Overrides `run.environment.lifecycle.preserve`; omission inherits
the lower-precedence setting.
RunTarget:
description: Workspace content and location requested for a run.

View file

@ -18,10 +18,11 @@ use fabro_api::types::{
BoardColumn, ManifestConfigType, ManifestGoalType, RunIntent, RunManifest, SubmitAnswerRequest,
UpdateRunParentRequest, UpdateRunRequest,
};
use fabro_config::{CliLayer, ReplaceMap, RunEnvironmentLayer, RunLayer, RunModelLayer, Storage};
use fabro_config::{CliLayer, RunLayer, Storage};
use fabro_environment::{DEFAULT_ENVIRONMENT_ID, EnvironmentId};
use fabro_interview::AnswerSubmission;
use fabro_llm::client::Client as LlmClient;
use fabro_manifest::RunOverrideInput;
use fabro_static::EnvVars;
use fabro_store::{
RunSummaryListQuery, RunSummarySort, RunSummarySortDirection, RunSummaryVisibility,
@ -701,21 +702,16 @@ pub(crate) async fn create_run_from_intent(
};
input_overrides.insert(name.clone(), value);
}
let mut run_overrides = RunLayer {
environment: Some(RunEnvironmentLayer {
id: Some(environment_id.to_string()),
..RunEnvironmentLayer::default()
}),
metadata: ReplaceMap::from(intent.args.labels),
..RunLayer::default()
};
if intent.args.model.is_some() || intent.args.provider.is_some() {
run_overrides.model = Some(RunModelLayer {
name: intent.args.model,
provider: intent.args.provider,
..RunModelLayer::default()
});
}
let run_overrides = fabro_manifest::build_run_overrides(RunOverrideInput {
goal: None,
model: intent.args.model.as_deref(),
provider: intent.args.provider.as_deref(),
environment: Some(environment_id.as_str()),
preserve_sandbox: intent.args.preserve_sandbox,
dry_run: intent.args.dry_run,
auto_approve: intent.args.auto_approve,
labels: intent.args.labels,
});
let entrypoint = lowered.entrypoint.clone();
let raw_compiler_input = RawRunCompilerInput {

View file

@ -24,7 +24,7 @@ use fabro_llm::types::{Message as LlmMessage, Request as LlmRequest, TokenCounts
use fabro_model::catalog::LlmCatalogSettings;
use fabro_model::{Catalog, ModelRef, ProviderId, ReasoningEffort, Speed};
use fabro_types::settings::ServerAuthMethod;
use fabro_types::settings::run::EnvironmentProvider;
use fabro_types::settings::run::{ApprovalMode, EnvironmentProvider};
use fabro_types::{
AgentBackend, AttrValue, AuthMethod, BlobHash, CommandTermination, FailureCategory,
FailureDetail, GitRunTarget, Graph, InterviewQuestionRecord, Node, Outcome, ParallelBranchId,
@ -3776,6 +3776,141 @@ async fn post_runs_run_intent_creates_submitted_none_target_without_git_projecti
assert!(projection.spec.definition_blob.is_some());
}
#[tokio::test]
async fn post_runs_run_intent_args_true_override_resolved_settings_without_starting() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
let state = TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Local))
.env_lookup(|_| None)
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
let body = post_run_manifest(
&app,
json!({
"workflow_version_id": workflow_version_id,
"target": { "kind": "folder", "path": &workspace },
"args": {
"dry_run": true,
"auto_approve": true,
"preserve_sandbox": true
}
}),
)
.await;
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();
assert_eq!(projection.spec.settings.run.execution.mode, RunMode::DryRun);
assert_eq!(
projection.spec.settings.run.execution.approval,
ApprovalMode::Auto
);
assert!(projection.spec.settings.run.environment.lifecycle.preserve);
}
#[tokio::test]
async fn post_runs_run_intent_args_false_are_distinct_from_omitted_overrides() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("workspace");
std::fs::create_dir(&workspace).unwrap();
let state = TestAppStateBuilder::new()
.runtime_settings(
default_test_server_settings(),
manifest_run_defaults_from_toml(
r#"
[run.execution]
mode = "dry_run"
approval = "auto"
[run.environment.lifecycle]
preserve = true
"#,
),
)
.default_environment_provider(Some(EnvironmentProvider::Local))
.env_lookup(|_| None)
.vault_entries([(EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
let explicit_false = post_run_manifest(
&app,
json!({
"workflow_version_id": workflow_version_id,
"target": { "kind": "folder", "path": &workspace },
"args": {
"dry_run": false,
"auto_approve": false,
"preserve_sandbox": false
}
}),
)
.await;
let omitted = post_run_manifest(
&app,
json!({
"workflow_version_id": workflow_version_id,
"target": { "kind": "folder", "path": &workspace },
"args": {}
}),
)
.await;
assert_eq!(explicit_false["lifecycle"]["status"]["kind"], "submitted");
assert_eq!(omitted["lifecycle"]["status"]["kind"], "submitted");
let explicit_false_id = explicit_false["id"]
.as_str()
.unwrap()
.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();
assert_eq!(
explicit_false.spec.settings.run.execution.mode,
RunMode::Normal
);
assert_eq!(
explicit_false.spec.settings.run.execution.approval,
ApprovalMode::Prompt
);
assert!(
!explicit_false
.spec
.settings
.run
.environment
.lifecycle
.preserve
);
assert_eq!(omitted.spec.settings.run.execution.mode, RunMode::DryRun);
assert_eq!(
omitted.spec.settings.run.execution.approval,
ApprovalMode::Auto
);
assert!(omitted.spec.settings.run.environment.lifecycle.preserve);
}
#[tokio::test]
async fn post_runs_run_intent_canonicalizes_and_persists_a_local_folder_target() {
let dir = tempfile::tempdir().unwrap();
@ -16408,7 +16543,7 @@ level = "debug"
"goal should be persisted from the manifest"
);
assert!(
resolved_run.execution.mode == fabro_types::settings::run::RunMode::DryRun,
resolved_run.execution.mode == RunMode::DryRun,
"run execution mode should inherit from server settings"
);
assert_eq!(

View file

@ -24,13 +24,16 @@ fn run_intent_round_trips_the_openapi_shape() {
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
}),
args: RunIntentArgs {
model: Some("gpt-5.6-sol".to_string()),
provider: Some("openai".to_string()),
inputs: HashMap::from([
model: Some("gpt-5.6-sol".to_string()),
provider: Some("openai".to_string()),
inputs: HashMap::from([
("attempts".to_string(), json!(3)),
("ship".to_string(), json!(true)),
]),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
dry_run: Some(false),
auto_approve: Some(true),
preserve_sandbox: Some(false),
},
environment_id: Some("default".to_string()),
parent_id: None,
@ -41,6 +44,9 @@ fn run_intent_round_trips_the_openapi_shape() {
let value = serde_json::to_value(&intent).unwrap();
let api: ApiRunIntent = serde_json::from_value(value.clone()).unwrap();
assert_eq!(value["args"]["dry_run"], false);
assert_eq!(value["args"]["auto_approve"], true);
assert_eq!(value["args"]["preserve_sandbox"], false);
assert_eq!(serde_json::to_value(api).unwrap(), value);
}

View file

@ -16,6 +16,7 @@ use fabro_types::{
ArtifactUpload, BlobHash, EventEnvelope, PairId, PairMessageRecord, PairMessageRequest,
PairRecord, PairStartRequest, PairTranscriptResponse, Run, RunEvent, RunEventDetailResponse,
RunId, RunPairStatusResponse, RunProjection, SessionId, SessionRecord, StageId,
WorkflowVersion, WorkflowVersionId,
};
use fabro_util::exit::{ErrorExt, ExitClass};
use futures::future::BoxFuture;
@ -700,6 +701,43 @@ impl Client {
self.submit_create_run(manifest.into()).await
}
/// Registers one workflow version and verifies the server assigned the
/// content-derived id, so a mismatched response fails loudly here rather
/// than being trusted downstream.
pub async fn create_workflow_version(
&self,
version: &WorkflowVersion,
) -> Result<WorkflowVersionId> {
let expected_id = version.id()?;
let response = self
.send_api(|client| {
let version = version.clone();
async move { client.create_workflow_version().body(version).send().await }
})
.await?;
let returned_id = response.into_inner().workflow_version_id;
if returned_id != expected_id {
bail!(
"workflow version registration returned {returned_id} for expected {expected_id}"
);
}
Ok(returned_id)
}
/// Registers versions in iteration order, stopping at the first failure.
/// Callers must order dependencies before the versions that reference them.
pub async fn register_workflow_versions<'a>(
&self,
versions: impl IntoIterator<Item = &'a WorkflowVersion>,
) -> Result<()> {
for (index, version) in versions.into_iter().enumerate() {
self.create_workflow_version(version)
.await
.with_context(|| format!("failed to register workflow version at index {index}"))?;
}
Ok(())
}
pub async fn create_run_from_intent(&self, intent: types::RunIntent) -> Result<RunId> {
self.submit_create_run(intent.into()).await
}
@ -2281,13 +2319,16 @@ fn add_pr_upgrade_hint(err: anyhow::Error) -> anyhow::Error {
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use chrono::Duration as ChronoDuration;
use fabro_types::WorkflowPath;
use fabro_util::exit;
use httpmock::Method::{GET, POST};
use httpmock::MockServer;
use httpmock::{HttpMockResponse, MockServer};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
@ -2325,6 +2366,217 @@ mod tests {
})
}
fn test_workflow_version(
name: &str,
workflow_dependencies: BTreeMap<WorkflowPath, WorkflowVersionId>,
) -> WorkflowVersion {
let entrypoint = WorkflowPath::new("workflow.fabro").unwrap();
WorkflowVersion::new(
entrypoint.clone(),
BTreeMap::from([(entrypoint, format!("digraph {name} {{}}"))]),
workflow_dependencies,
)
.unwrap()
}
/// Mocks `POST /api/v1/workflow-versions` for exactly this version body.
async fn mock_create_workflow_version<'a>(
server: &'a MockServer,
version: &WorkflowVersion,
then: impl FnOnce(httpmock::Then) -> httpmock::Then,
) -> httpmock::Mock<'a> {
let body = serde_json::to_value(version).unwrap();
server
.mock_async(|when, respond| {
when.method(POST)
.path("/api/v1/workflow-versions")
.json_body(body);
then(respond);
})
.await
}
fn created_workflow_version(then: httpmock::Then, id: WorkflowVersionId) -> httpmock::Then {
then.status(201)
.header("content-type", "application/json")
.json_body(json!({ "workflow_version_id": id }))
}
fn workflow_version_response(id: WorkflowVersionId) -> HttpMockResponse {
HttpMockResponse::builder()
.status(201)
.header("content-type", "application/json")
.body(json!({ "workflow_version_id": id }).to_string())
.build()
}
#[tokio::test]
async fn create_workflow_version_posts_exact_version_and_returns_server_id() {
let server = MockServer::start_async().await;
let version = test_workflow_version("ExactVersion", BTreeMap::new());
let expected_id = version.id().unwrap();
let mock = mock_create_workflow_version(&server, &version, |then| {
created_workflow_version(then, expected_id)
})
.await;
let client = Client::new_no_proxy(&server.url("")).unwrap();
let actual_id = client.create_workflow_version(&version).await.unwrap();
mock.assert_async().await;
assert_eq!(actual_id, expected_id);
}
#[tokio::test]
async fn create_workflow_version_rejects_returned_id_mismatch() {
let server = MockServer::start_async().await;
let version = test_workflow_version("Expected", BTreeMap::new());
let expected_id = version.id().unwrap();
let returned_id = test_workflow_version("Returned", BTreeMap::new())
.id()
.unwrap();
let mock = mock_create_workflow_version(&server, &version, |then| {
created_workflow_version(then, returned_id)
})
.await;
let client = Client::new_no_proxy(&server.url("")).unwrap();
let message = client
.create_workflow_version(&version)
.await
.unwrap_err()
.to_string();
mock.assert_async().await;
assert!(message.contains(&expected_id.to_string()));
assert!(message.contains(&returned_id.to_string()));
}
#[tokio::test]
async fn register_workflow_versions_preserves_dependency_first_order() {
let server = MockServer::start_async().await;
let child = test_workflow_version("Child", BTreeMap::new());
let child_id = child.id().unwrap();
let parent = test_workflow_version(
"Parent",
BTreeMap::from([(WorkflowPath::new("child").unwrap(), child_id)]),
);
let parent_id = parent.id().unwrap();
let child_response_completed = Arc::new(AtomicBool::new(false));
let child_response_completed_for_child = Arc::clone(&child_response_completed);
let child_mock = mock_create_workflow_version(&server, &child, move |then| {
then.respond_with(move |_| {
let response = workflow_version_response(child_id);
child_response_completed_for_child.store(true, Ordering::SeqCst);
response
})
})
.await;
let parent_mock = mock_create_workflow_version(&server, &parent, move |then| {
then.respond_with(move |_| {
assert!(
child_response_completed.load(Ordering::SeqCst),
"parent registration began before the child response completed"
);
workflow_version_response(parent_id)
})
})
.await;
let client = Client::new_no_proxy(&server.url("")).unwrap();
client
.register_workflow_versions([&child, &parent])
.await
.unwrap();
child_mock.assert_async().await;
parent_mock.assert_async().await;
}
#[tokio::test]
async fn register_workflow_versions_stops_after_request_failure() {
let server = MockServer::start_async().await;
let first = test_workflow_version("First", BTreeMap::new());
let first_id = first.id().unwrap();
let failing = test_workflow_version("Failing", BTreeMap::new());
let later = test_workflow_version("NeverSent", BTreeMap::new());
let later_id = later.id().unwrap();
let first_mock = mock_create_workflow_version(&server, &first, |then| {
created_workflow_version(then, first_id)
})
.await;
let failing_mock = mock_create_workflow_version(&server, &failing, |then| {
then.status(422)
.header("content-type", "application/json")
.json_body(json!({
"errors": [{
"status": "422",
"title": "Unprocessable Entity",
"detail": "workflow dependency was not found",
"code": "workflow_version_dependency_not_found"
}]
}))
})
.await;
let later_mock = mock_create_workflow_version(&server, &later, |then| {
created_workflow_version(then, later_id)
})
.await;
let client = Client::new_no_proxy(&server.url("")).unwrap();
let error = client
.register_workflow_versions([&first, &failing, &later])
.await
.unwrap_err();
first_mock.assert_async().await;
failing_mock.assert_async().await;
later_mock.assert_calls_async(0).await;
assert!(error.to_string().contains("index 1"));
let failure = api_failure_for(&error).expect("API failure metadata should survive context");
assert_eq!(failure.status, fabro_http::StatusCode::UNPROCESSABLE_ENTITY);
assert_eq!(
failure.code.as_deref(),
Some("workflow_version_dependency_not_found")
);
}
#[tokio::test]
async fn register_workflow_versions_accepts_empty_input_without_requests() {
let server = MockServer::start_async().await;
let unexpected = server
.mock_async(|when, then| {
when.method(POST).path("/api/v1/workflow-versions");
then.status(500);
})
.await;
let client = Client::new_no_proxy(&server.url("")).unwrap();
let none: [&WorkflowVersion; 0] = [];
client.register_workflow_versions(none).await.unwrap();
unexpected.assert_calls_async(0).await;
}
#[tokio::test]
async fn create_workflow_version_accepts_repeated_canonical_content() {
let server = MockServer::start_async().await;
let version = test_workflow_version("Repeated", BTreeMap::new());
let expected_id = version.id().unwrap();
let mock = mock_create_workflow_version(&server, &version, |then| {
created_workflow_version(then, expected_id)
})
.await;
let client = Client::new_no_proxy(&server.url("")).unwrap();
let first = client.create_workflow_version(&version).await.unwrap();
let second = client.create_workflow_version(&version).await.unwrap();
mock.assert_calls_async(2).await;
assert_eq!(first, expected_id);
assert_eq!(second, expected_id);
}
#[cfg(unix)]
#[tokio::test]
async fn refresh_access_token_allows_plain_http_targets() {

View file

@ -27,13 +27,19 @@ pub struct RunIntent {
#[serde(deny_unknown_fields)]
pub struct RunIntentArgs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub inputs: HashMap<String, Value>,
pub inputs: HashMap<String, Value>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dry_run: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_approve: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preserve_sandbox: Option<bool>,
}
/// Requested workspace content, independent of sandbox placement.

View file

@ -21,13 +21,16 @@ fn intent() -> RunIntent {
sha: Some("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
}),
args: RunIntentArgs {
model: Some("gpt-5.6".to_string()),
provider: Some("openai".to_string()),
inputs: HashMap::from([
model: Some("gpt-5.6".to_string()),
provider: Some("openai".to_string()),
inputs: HashMap::from([
("attempts".to_string(), json!(3)),
("enabled".to_string(), json!(true)),
]),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
dry_run: Some(false),
auto_approve: Some(true),
preserve_sandbox: None,
},
environment_id: Some("production".to_string()),
parent_id: None,
@ -36,6 +39,32 @@ fn intent() -> RunIntent {
}
}
#[test]
fn run_intent_args_preserve_tri_state_wire_semantics() {
let omitted = serde_json::to_value(RunIntentArgs::default()).expect("args should serialize");
assert_eq!(omitted, json!({}));
let args = RunIntentArgs {
dry_run: Some(false),
auto_approve: Some(true),
preserve_sandbox: Some(false),
..RunIntentArgs::default()
};
let value = serde_json::to_value(&args).expect("args should serialize");
assert_eq!(value["dry_run"], false);
assert_eq!(value["auto_approve"], true);
assert_eq!(value["preserve_sandbox"], false);
assert_eq!(
serde_json::from_value::<RunIntentArgs>(value).expect("args should deserialize"),
args
);
assert!(
serde_json::from_value::<RunIntentArgs>(json!({ "unexpected": true })).is_err(),
"unknown args fields must remain rejected"
);
}
#[test]
fn run_intent_round_trips_the_strict_git_shape() {
let intent = intent();

View file

@ -28,4 +28,16 @@ export interface RunIntentArgs {
'provider'?: string;
'inputs'?: { [key: string]: RunIntentArgsInputsValue; };
'labels'?: { [key: string]: string; };
/**
* Overrides `run.execution.mode`: true selects `dry_run`, false selects `normal`, and omission inherits the lower-precedence setting.
*/
'dry_run'?: boolean;
/**
* Overrides `run.execution.approval`: true selects `auto`, false selects `prompt`, and omission inherits the lower-precedence setting.
*/
'auto_approve'?: boolean;
/**
* Overrides `run.environment.lifecycle.preserve`; omission inherits the lower-precedence setting.
*/
'preserve_sandbox'?: boolean;
}