Merge pull request #789 from fabro-sh/codex/run-intent-none-target

Add empty workspace run target
This commit is contained in:
Scott Werner 2026-08-25 11:25:41 -04:00 committed by GitHub
commit 0001cfba02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 723 additions and 129 deletions

View file

@ -9298,10 +9298,12 @@ components:
description: Workspace content and location requested for a run.
oneOf:
- $ref: "#/components/schemas/GitRunTarget"
- $ref: "#/components/schemas/NoneRunTarget"
discriminator:
propertyName: kind
mapping:
git: "#/components/schemas/GitRunTarget"
none: "#/components/schemas/NoneRunTarget"
GitRunTarget:
description: Public github.com repository target.
@ -9330,6 +9332,21 @@ components:
Optional exact commit. The server lowercase-normalizes its syntax
but does not resolve it or prove branch ancestry.
NoneRunTarget:
description: >-
Empty workspace with no repository. Docker and Daytona accept this
target and suppress cloning even when workflow settings enable it.
Local environments reject it; Local scratch allocation is a separate
future capability.
type: object
additionalProperties: false
required:
- kind
properties:
kind:
type: string
enum: [none]
RunManifest:
description: Self-contained workflow run manifest.
type: object

View file

@ -1,8 +1,16 @@
---
title: "More reliable Daytona snapshot activation"
title: "Empty run workspaces and more reliable Daytona activation"
date: "2026-08-23"
---
Version-backed run intents can now use `{ "kind": "none" }` when a workflow
should start without a repository. The target creates an empty Docker or
Daytona workspace and suppresses cloning even when the resolved workflow
settings enable it.
Local environments reject the `none` target. Server-managed Local scratch
workspaces remain a separate future capability.
## More
<Accordion title="Fixes">

View file

@ -258,7 +258,7 @@ memory = "4GB"
mode = "block"
```
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start with an empty workspace. Set `[run.clone] depth = 0` to clone full history. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
Docker and Daytona are clone-based providers. When a run has a GitHub origin, Fabro clones it into the provider workspace with a history depth of 100. Set `[run.clone] enabled = false` to start a manifest-backed run with an empty workspace. Set `[run.clone] depth = 0` to clone full history. A version-backed run intent can instead submit the explicit `{ "kind": "none" }` target, which forces an empty provider workspace regardless of the workflow's clone setting. The `none` target is not supported by Local environments. Docker and Daytona ignore `cwd`; use the provider-owned workspace layout and `run.working_dir` for repository-relative commands.
The image must provide `/bin/bash`; Fabro evaluates every sandbox command with it and has no `sh` fallback. Commands run in a **non-login** shell, so login profiles (`/etc/profile.d/*.sh`, `~/.bash_profile`, and `nvm`/`rbenv`/`sdkman` initializers) are not sourced — put anything they set into the Dockerfile's `ENV` instead. Fabro verifies Bash during initialization and again on resume, and fails with remediation rather than reporting the sandbox ready.

View file

@ -46,8 +46,8 @@ pub(crate) enum EnvironmentSelectionError {
},
#[error("environment `{id}` not found")]
NotFound { id: EnvironmentId },
#[error("Git targets require a compatible clone-enabled Docker or Daytona environment")]
TargetUnsupported,
#[error("{detail}")]
TargetUnsupported { detail: &'static str },
#[error("{detail}")]
ProviderDisabled {
provider: SandboxProviderKind,

View file

@ -28,9 +28,10 @@ use fabro_store::{
};
use fabro_types::{
AutomationRef, ManifestPath, Principal, Run, RunClientProvenance, RunId, RunProvenance,
RunServerProvenance, RunStatusKind, SandboxProviderKind, StageContextWindow,
RunServerProvenance, RunStatusKind, RunTarget, SandboxProviderKind, StageContextWindow,
StageContextWindowStaleness, StageContextWindowUnavailableReason, StageHandler,
StageModelUsage, StageProjection, SystemActorKind, json_scalar_to_toml_value, parse_blob_ref,
StageModelUsage, StageProjection, SystemActorKind, ValidatedRunTarget,
json_scalar_to_toml_value, parse_blob_ref,
};
use fabro_util::error as error_util;
use fabro_util::version::FABRO_VERSION;
@ -610,8 +611,8 @@ async fn create_run_from_intent(
) -> Response {
// Validate the pure, in-memory request facts before paying for
// blob-store reads and closure lowering.
let validated_target = match intent.target.validate() {
Ok(target) => target,
let ValidatedRunTarget { target, git } = match intent.target.validate() {
Ok(validated) => validated,
Err(error) => return run_intent_admission_error(error.into()),
};
let environment_id = match select_intent_environment_id(
@ -711,11 +712,11 @@ async fn create_run_from_intent(
run_id: None,
title,
parent_id: intent.parent_id,
git: Some(validated_target.git),
git,
storage_root: state.server_storage_dir(),
workflow_slug: None,
workflow_version_id: Some(intent.workflow_version_id),
target: Some(validated_target.target),
target: Some(target.clone()),
provenance: run_provenance(&headers, &actor),
web_url: None,
submitted_manifest_bytes: None,
@ -741,7 +742,7 @@ async fn create_run_from_intent(
Ok(prepared) => prepared,
Err(error) => return run_intent_admission_error(error.into()),
};
if let Err(error) = validate_intent_environment(&state, prepared.settings()).await {
if let Err(error) = validate_intent_environment(&state, prepared.settings(), &target).await {
return run_intent_admission_error(error.into());
}
let (prepared, run_id) = prepared.resolve_run_id();
@ -1007,7 +1008,7 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
error.to_string(),
"environment_not_found",
),
EnvironmentSelectionError::TargetUnsupported => intent_error(
EnvironmentSelectionError::TargetUnsupported { .. } => intent_error(
StatusCode::UNPROCESSABLE_ENTITY,
error.to_string(),
"target_environment_unsupported",
@ -1063,16 +1064,27 @@ fn select_intent_environment_id(
async fn validate_intent_environment(
state: &AppState,
settings: &fabro_types::WorkflowSettings,
target: &RunTarget,
) -> Result<(), EnvironmentSelectionError> {
let provider = run_manifest::effective_sandbox_provider(&settings.run);
let image = &settings.run.environment.image;
let incompatible = match provider {
SandboxProviderKind::Local => true,
let image_incompatible = match provider {
SandboxProviderKind::Local => false,
SandboxProviderKind::Docker => image.docker.is_none() && image.dockerfile.is_some(),
SandboxProviderKind::Daytona => image.docker.is_some(),
};
if incompatible || !settings.run.clone.enabled {
return Err(EnvironmentSelectionError::TargetUnsupported);
let (target_incompatible, detail) = match target {
RunTarget::Git { .. } => (
provider == SandboxProviderKind::Local || !settings.run.clone.enabled,
"Git targets require a compatible clone-enabled Docker or Daytona environment",
),
RunTarget::None {} => (
provider == SandboxProviderKind::Local,
"none targets require a compatible Docker or Daytona environment",
),
};
if image_incompatible || target_incompatible {
return Err(EnvironmentSelectionError::TargetUnsupported { detail });
}
if let Some(detail) =
run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider)

View file

@ -3717,6 +3717,94 @@ docker = "workflow-owned:latest"
);
}
#[tokio::test]
async fn post_runs_run_intent_creates_submitted_none_target_without_git_projection() {
let state = test_app_state();
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": "none" },
"args": {}
}),
)
.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 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_eq!(
projection.spec.target,
Some(fabro_types::RunTarget::None {})
);
assert_eq!(
projection.spec.workflow_version_id,
Some(workflow_version_id)
);
assert_eq!(projection.spec.source_directory, None);
assert_eq!(projection.spec.git, None);
assert!(projection.spec.settings.run.clone.enabled);
assert_eq!(projection.spec.manifest_blob, None);
assert!(projection.spec.definition_blob.is_some());
}
#[tokio::test]
async fn post_runs_run_intent_accepts_none_target_with_ready_daytona_environment() {
let state = TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Daytona))
.vault_entries([
(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key"),
(
fabro_static::EnvVars::DAYTONA_API_KEY,
"test-daytona-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": "none" },
"args": {}
}),
)
.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();
assert_eq!(
projection.spec.target,
Some(fabro_types::RunTarget::None {})
);
assert_eq!(
projection.spec.settings.run.environment.provider,
EnvironmentProvider::Daytona
);
assert_eq!(projection.spec.source_directory, None);
assert_eq!(projection.spec.git, None);
}
#[tokio::test]
async fn post_runs_run_intent_dispatches_errors_without_changing_legacy_lane() {
let state = test_app_state();
@ -3875,6 +3963,92 @@ async fn post_runs_run_intent_maps_missing_version_environment_and_target_errors
assert!(state.runs.lock().expect("runs lock poisoned").is_empty());
}
#[tokio::test]
async fn post_runs_run_intent_rejects_none_target_with_local_environment_before_persistence() {
let state = TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Local))
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build();
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
let app = crate::test_support::build_test_router(Arc::clone(&state));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(
json!({
"workflow_version_id": workflow_version_id,
"target": { "kind": "none" },
"args": {}
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
assert_eq!(body["errors"][0]["code"], "target_environment_unsupported");
assert!(state.runs.lock().expect("runs lock poisoned").is_empty());
assert!(
state
.stores
.run_summaries
.list_identities()
.await
.unwrap()
.is_empty()
);
}
/// Posts a Git and a `none` run intent against `state` and asserts both are
/// rejected as `integration_unavailable` without persisting anything.
async fn assert_run_intent_targets_unavailable(state: &Arc<AppState>) {
let version_id = store_workflow_version(state, MINIMAL_DOT, None).await;
let app = crate::test_support::build_test_router(Arc::clone(state));
for target in [
json!({
"kind": "git",
"repo": "fabro-sh/fabro",
"branch": "feature/run-intent"
}),
json!({ "kind": "none" }),
] {
let intent = json!({
"workflow_version_id": version_id,
"target": target,
"args": {}
});
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(intent.to_string()))
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await;
assert_eq!(body["errors"][0]["code"], "integration_unavailable");
}
assert!(state.runs.lock().expect("runs lock poisoned").is_empty());
assert!(
state
.stores
.run_summaries
.list_identities()
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn post_runs_run_intent_rejects_disabled_or_unready_sandbox_integrations() {
let disabled_state = test_app_state_with_options(
@ -3892,73 +4066,13 @@ enabled = false
RunLayer::default(),
5,
);
let disabled_version_id = store_workflow_version(&disabled_state, MINIMAL_DOT, None).await;
let disabled_app = crate::test_support::build_test_router(Arc::clone(&disabled_state));
let intent = json!({
"workflow_version_id": disabled_version_id,
"target": {
"kind": "git",
"repo": "fabro-sh/fabro",
"branch": "feature/run-intent"
},
"args": {}
});
let response = disabled_app
.oneshot(
Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(intent.to_string()))
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await;
assert_eq!(body["errors"][0]["code"], "integration_unavailable");
assert!(
disabled_state
.runs
.lock()
.expect("runs lock poisoned")
.is_empty()
);
assert_run_intent_targets_unavailable(&disabled_state).await;
let daytona_state = TestAppStateBuilder::new()
.default_environment_provider(Some(EnvironmentProvider::Daytona))
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
.build();
let daytona_version_id = store_workflow_version(&daytona_state, MINIMAL_DOT, None).await;
let daytona_app = crate::test_support::build_test_router(Arc::clone(&daytona_state));
let intent = json!({
"workflow_version_id": daytona_version_id,
"target": {
"kind": "git",
"repo": "fabro-sh/fabro",
"branch": "feature/run-intent"
},
"args": {}
});
let response = daytona_app
.oneshot(
Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(intent.to_string()))
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await;
assert_eq!(body["errors"][0]["code"], "integration_unavailable");
assert!(
daytona_state
.runs
.lock()
.expect("runs lock poisoned")
.is_empty()
);
assert_run_intent_targets_unavailable(&daytona_state).await;
}
#[tokio::test]

View file

@ -475,6 +475,10 @@ pub async fn persist_create_run(
source_directory,
labels,
} = materialized;
// An empty-workspace target has no submitter cwd to project; `git` is
// already `None` for it because admission derives it from the target.
let source_directory =
(!matches!(target.as_ref(), Some(RunTarget::None {}))).then_some(source_directory);
let persisted_run_dir = run_dir.clone();
let persisted = spawn_blocking(move || {
let run_spec = RunSpec {
@ -486,7 +490,7 @@ pub async fn persist_create_run(
workflow_version_id,
target,
automation,
source_directory: Some(source_directory),
source_directory,
labels,
provenance,
manifest_blob: None,
@ -2271,6 +2275,50 @@ reasoning = false
);
}
#[tokio::test]
async fn create_none_target_omits_the_source_directory_projection() {
let dir = tempfile::tempdir().unwrap();
let storage_root = dir.path().join("storage");
let store = memory_store();
let created = create(
&store,
CreateRunInput {
workflow: WorkflowInput::DotSource {
source: MINIMAL_DOT.to_string(),
base_dir: None,
},
settings: test_default_settings(),
vars: HashMap::new(),
cwd: dir.path().to_path_buf(),
workflow_slug: None,
workflow_path: None,
workflow_bundle: None,
target: Some(RunTarget::None {}),
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_2),
title: None,
automation: None,
git: None,
fork_source_ref: None,
parent_id: None,
provenance: test_support::test_run_provenance(),
configured_providers: test_provider_ids(),
web_url: None,
},
storage_root,
test_catalog(),
)
.await
.unwrap();
assert_eq!(created.persisted.run_spec().source_directory, None);
assert_eq!(
created.persisted.run_spec().target,
Some(RunTarget::None {})
);
assert_eq!(created.persisted.run_spec().git, None);
}
#[tokio::test]
async fn create_persists_repo_origin_url_from_request() {
let dir = tempfile::tempdir().unwrap();

View file

@ -452,6 +452,69 @@ mod tests {
);
}
#[tokio::test]
async fn retry_preserves_none_target_without_git_or_source_directory() {
let store = memory_store();
let source_run_id = fixtures::RUN_1;
let source_store = store.create_run(&source_run_id).await.unwrap();
event::append_event(&source_store, &source_run_id, &Event::RunCreated {
run_id: source_run_id,
title: Some("None target".to_string()),
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(Graph::new("none_target_retry")).unwrap(),
workflow_source: Some("digraph none_target_retry { start -> exit }".to_string()),
labels: BTreeMap::new(),
source_directory: None,
workflow_slug: Some("none-target-retry".to_string()),
workflow_version_id: Some(test_support::test_workflow_version_id()),
target: Some(RunTarget::None {}),
automation: None,
provenance: provenance("source-user"),
manifest_blob: None,
spec_blob: None,
git: None,
fork_source_ref: None,
retried_from: None,
parent_id: None,
web_url: None,
})
.await
.unwrap();
event::append_event(&source_store, &source_run_id, &Event::RunSubmitted {
definition_blob: None,
})
.await
.unwrap();
append_failed(&source_store, source_run_id, FailureReason::WorkflowError).await;
let source_state = source_store.state().await.unwrap();
assert_eq!(source_state.status, RunStatus::Failed {
reason: FailureReason::WorkflowError,
});
assert_eq!(source_state.spec.target, Some(RunTarget::None {}));
assert_eq!(source_state.spec.git, None);
assert_eq!(source_state.spec.source_directory, None);
let outcome = retry_run(&store, &RetryRunInput {
source_run_id,
new_run_id: RunId::new(),
provenance: provenance("retry-user"),
web_url: None,
})
.await
.unwrap();
let retry_store = store.open_run(&outcome.new_run_id).await.unwrap();
let retry_events = retry_store.list_events().await.unwrap();
let retry_state = fabro_store::RunProjection::apply_events(&retry_events).unwrap();
assert_eq!(retry_events.len(), 2);
assert_eq!(retry_state.status, RunStatus::Submitted);
assert_eq!(retry_state.retried_from, Some(source_run_id));
assert_eq!(retry_state.spec.target, Some(RunTarget::None {}));
assert_eq!(retry_state.spec.git, None);
assert_eq!(retry_state.spec.source_directory, None);
}
#[tokio::test]
async fn retry_creates_fresh_run_from_succeeded_source() {
let store = memory_store();

View file

@ -418,6 +418,11 @@ impl RunSession {
let sandbox_provider =
resolve_sandbox_provider(resolved).effective_for(resolved.execution.mode);
let clone_source = clone_source_for_run(record)?;
// An empty-workspace run has no repository for PR creation or the
// sandbox environment, regardless of any persisted Git metadata.
let runtime_origin_url = (!clone_source.skip_clone)
.then(|| record.repo_origin_url().map(str::to_string))
.flatten();
let catalog = Arc::clone(&services.catalog);
let configured =
configured_providers_for_start(&services.vault, Arc::clone(&catalog)).await;
@ -457,10 +462,11 @@ impl RunSession {
let sandbox = match sandbox_provider {
SandboxProviderKind::Local => {
if record.target.is_some() {
return Err(Error::engine(
"persisted Git run targets require a clone-based sandbox provider",
));
if let Some(target) = &record.target {
return Err(Error::engine(format!(
"persisted {} run targets require a clone-based sandbox provider",
target.kind_name()
)));
}
let working_directory = local_working_directory_from_environment(
&resolved.environment,
@ -474,20 +480,26 @@ impl RunSession {
})?;
SandboxSpec::Local { working_directory }
}
SandboxProviderKind::Docker => SandboxSpec::Docker {
config: resolve_docker_config(resolved, secret_lookup)?,
github_app: services.github_app.clone(),
run_id: Some(record.run_id),
clone_origin_url: clone_source.origin_url,
clone_branch: clone_source.branch,
clone_commit_sha: clone_source.commit_sha,
},
SandboxProviderKind::Docker => {
let mut config = resolve_docker_config(resolved, secret_lookup)?;
config.skip_clone |= clone_source.skip_clone;
SandboxSpec::Docker {
config,
github_app: services.github_app.clone(),
run_id: Some(record.run_id),
clone_origin_url: clone_source.origin_url,
clone_branch: clone_source.branch,
clone_commit_sha: clone_source.commit_sha,
}
}
SandboxProviderKind::Daytona => {
let api_key = vault_guard
.get(EnvVars::DAYTONA_API_KEY)
.map(str::to_string);
let mut config = resolve_daytona_config(resolved);
config.skip_clone |= clone_source.skip_clone;
SandboxSpec::Daytona {
config: Box::new(resolve_daytona_config(resolved)),
config: Box::new(config),
github_app: services.github_app.clone(),
run_id: Some(record.run_id),
clone_origin_url: clone_source.origin_url,
@ -509,7 +521,7 @@ impl RunSession {
let sandbox_env = SandboxEnvSpec {
toml_env,
github_integration,
origin_url: record.repo_origin_url().map(str::to_string),
origin_url: runtime_origin_url.clone(),
};
let interviewer: Arc<dyn Interviewer> = if resolved.execution.approval == ApprovalMode::Auto
@ -559,7 +571,7 @@ impl RunSession {
stop_on_terminal: resolved.environment.lifecycle.stop_on_terminal,
pr_config,
pr_github_app: services.github_app,
pr_origin_url: record.repo_origin_url().map(str::to_string),
pr_origin_url: runtime_origin_url,
pr_model: llm.model,
workflow_path,
workflow_bundle,
@ -575,6 +587,9 @@ struct CloneSourceForRun {
origin_url: Option<String>,
branch: Option<String>,
commit_sha: Option<String>,
/// The target asked for an empty workspace, so the provider must not
/// clone even when it would otherwise inherit an origin.
skip_clone: bool,
}
fn clone_source_for_run(record: &RunSpec) -> Result<CloneSourceForRun, Error> {
@ -583,6 +598,7 @@ fn clone_source_for_run(record: &RunSpec) -> Result<CloneSourceForRun, Error> {
origin_url: record.repo_origin_url().map(str::to_string),
branch: record.base_branch().map(str::to_string),
commit_sha: None,
skip_clone: false,
});
};
@ -600,10 +616,20 @@ fn clone_source_for_run(record: &RunSpec) -> Result<CloneSourceForRun, Error> {
TargetValidationError::Sha => "persisted Git run target has an invalid SHA",
})
})?;
Ok(CloneSourceForRun {
origin_url: Some(validated.git.origin_url),
branch: Some(validated.git.branch),
commit_sha: validated.git.sha,
// A target with no Git projection (`none`) asks for an empty workspace.
Ok(match validated.git {
Some(git) => CloneSourceForRun {
origin_url: Some(git.origin_url),
branch: Some(git.branch),
commit_sha: git.sha,
skip_clone: false,
},
None => CloneSourceForRun {
origin_url: None,
branch: None,
commit_sha: None,
skip_clone: true,
},
})
}
@ -1176,11 +1202,12 @@ mod tests {
EnvironmentImageLayer, EnvironmentNetworkLayer, EnvironmentResourcesLayer, RunCloneLayer,
RunEnvironmentLayer, RunExecutionLayer, RunLayer, StickyMap, WorkflowSettingsBuilder,
};
use fabro_sandbox::test_support::MockSandbox;
use fabro_store::Database;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::{
McpTransport as ResolvedMcpTransport, PreparedStep, PreparedStepRun, RunMode,
RunPrepareSettings,
EnvironmentProvider, McpTransport as ResolvedMcpTransport, PreparedStep, PreparedStepRun,
RunMode, RunPrepareSettings,
};
use fabro_types::{
BilledModelUsage, ManifestPath, RunTarget, StageTiming, WorkflowSettings, fixtures,
@ -1766,6 +1793,163 @@ reasoning = false
assert!(err.causes()[0].contains("DEPLOY_TOKEN"));
}
#[tokio::test]
async fn run_session_new_none_target_forces_empty_docker_workspace() {
let temp = tempfile::tempdir().unwrap();
let (storage_root, _run_dir) = storage_root_and_run_dir(&temp);
let mut settings = settings_from_run_layer(RunLayer {
clone: Some(RunCloneLayer {
enabled: Some(true),
depth: None,
}),
..RunLayer::default()
});
settings.run.environment.provider = EnvironmentProvider::Docker;
settings.run.environment.image.docker = Some("buildpack-deps:noble".to_string());
let (persisted, store) = persisted_workflow_with_settings_and_target(
MINIMAL_DOT,
&storage_root,
settings,
Some(RunTarget::None {}),
)
.await;
let emitter = Arc::new(Emitter::new(fixtures::RUN_1));
let registry = Arc::new(test_registry());
let session = RunSession::new(
&persisted,
test_start_services(&store, &storage_root, emitter, registry).await,
)
.await
.unwrap();
let RunSession {
sandbox,
sandbox_env,
pr_origin_url,
..
} = session;
let runtime = sandbox
.to_run_sandbox_instance(&MockSandbox::linux(), fixtures::RUN_1)
.runtime;
assert_eq!(runtime.repo_cloned, Some(false));
assert_eq!(runtime.clone_origin_url, None);
assert_eq!(runtime.clone_branch, None);
assert_eq!(runtime.primary_repo_path, None);
assert_eq!(runtime.primary_repo_link, None);
let SandboxSpec::Docker {
config,
clone_origin_url,
clone_branch,
clone_commit_sha,
..
} = sandbox
else {
panic!("none target should retain the selected Docker provider");
};
assert!(config.skip_clone);
assert_eq!(clone_origin_url, None);
assert_eq!(clone_branch, None);
assert_eq!(clone_commit_sha, None);
assert_eq!(sandbox_env.origin_url, None);
assert_eq!(pr_origin_url, None);
}
#[tokio::test]
async fn run_session_new_none_target_forces_empty_daytona_workspace() {
let temp = tempfile::tempdir().unwrap();
let (storage_root, _run_dir) = storage_root_and_run_dir(&temp);
let mut settings = settings_from_run_layer(RunLayer {
clone: Some(RunCloneLayer {
enabled: Some(true),
depth: None,
}),
..RunLayer::default()
});
settings.run.environment.provider = EnvironmentProvider::Daytona;
settings.run.environment.image.docker = None;
let (persisted, store) = persisted_workflow_with_settings_and_target(
MINIMAL_DOT,
&storage_root,
settings,
Some(RunTarget::None {}),
)
.await;
let emitter = Arc::new(Emitter::new(fixtures::RUN_1));
let registry = Arc::new(test_registry());
let vault = Arc::new(AsyncRwLock::new(start_vault(&[(
EnvVars::DAYTONA_API_KEY,
"test-daytona-key",
SecretType::Token,
)])));
let session = RunSession::new(&persisted, StartServices {
vault,
..test_start_services(&store, &storage_root, emitter, registry).await
})
.await
.unwrap();
let RunSession {
sandbox,
sandbox_env,
pr_origin_url,
..
} = session;
let runtime = sandbox
.to_run_sandbox_instance(&MockSandbox::linux(), fixtures::RUN_1)
.runtime;
assert_eq!(runtime.repo_cloned, Some(false));
assert_eq!(runtime.clone_origin_url, None);
assert_eq!(runtime.clone_branch, None);
assert_eq!(runtime.primary_repo_path, None);
assert_eq!(runtime.primary_repo_link, None);
let SandboxSpec::Daytona {
config,
clone_origin_url,
clone_branch,
clone_commit_sha,
..
} = sandbox
else {
panic!("none target should retain the selected Daytona provider");
};
assert!(config.skip_clone);
assert_eq!(clone_origin_url, None);
assert_eq!(clone_branch, None);
assert_eq!(clone_commit_sha, None);
assert_eq!(sandbox_env.origin_url, None);
assert_eq!(pr_origin_url, None);
}
#[tokio::test]
async fn run_session_new_rejects_persisted_none_target_with_local_provider() {
let temp = tempfile::tempdir().unwrap();
let (storage_root, _run_dir) = storage_root_and_run_dir(&temp);
let mut settings = settings_from_run_layer(RunLayer::default());
settings.run.environment.provider = EnvironmentProvider::Local;
let (persisted, store) = persisted_workflow_with_settings_and_target(
MINIMAL_DOT,
&storage_root,
settings,
Some(RunTarget::None {}),
)
.await;
let emitter = Arc::new(Emitter::new(fixtures::RUN_1));
let registry = Arc::new(test_registry());
let Err(error) = RunSession::new(
&persisted,
test_start_services(&store, &storage_root, emitter, registry).await,
)
.await
else {
panic!("persisted none target with Local should fail before sandbox creation");
};
assert!(error.to_string().contains("none run targets require"));
}
#[test]
fn runtime_docker_config_maps_environment_hints() {
let settings = settings_from_run_layer(RunLayer {
@ -1838,6 +2022,15 @@ reasoning = false
dot: &str,
storage_root: &Path,
settings: WorkflowSettings,
) -> (Persisted, Arc<Database>) {
persisted_workflow_with_settings_and_target(dot, storage_root, settings, None).await
}
async fn persisted_workflow_with_settings_and_target(
dot: &str,
storage_root: &Path,
settings: WorkflowSettings,
target: Option<RunTarget>,
) -> (Persisted, Arc<Database>) {
let store = memory_store();
let created = crate::operations::create(
@ -1856,7 +2049,7 @@ reasoning = false
workflow_slug: Some("test".to_string()),
workflow_path: None,
workflow_bundle: None,
target: None,
target,
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_1),
title: None,
@ -2677,6 +2870,25 @@ reasoning = false
assert_eq!(source.branch.as_deref(), Some("main"));
}
#[test]
fn none_target_forces_an_empty_clone_source_and_workspace() {
let mut spec = test_support::test_run_spec();
spec.target = Some(RunTarget::None {});
spec.git = Some(fabro_types::GitContext {
origin_url: "https://github.com/fabro-sh/fabro".to_string(),
branch: "main".to_string(),
sha: Some("abcdef0123456789abcdef0123456789abcdef01".to_string()),
dirty: fabro_types::DirtyStatus::Clean,
});
let source = clone_source_for_run(&spec).unwrap();
assert_eq!(source.origin_url, None);
assert_eq!(source.branch, None);
assert_eq!(source.commit_sha, None);
assert!(source.skip_clone);
}
#[test]
fn clone_commit_persisted_git_target_activates_exact_branch_and_sha() {
let mut spec = test_support::test_run_spec();

View file

@ -42,6 +42,25 @@ fn run_intent_round_trips_the_openapi_shape() {
assert_eq!(serde_json::to_value(api).unwrap(), value);
}
#[test]
fn run_intent_none_target_round_trips_the_openapi_shape() {
let intent = RunIntent {
workflow_version_id: test_support::test_workflow_version_id(),
target: RunTarget::None {},
args: RunIntentArgs::default(),
environment_id: Some("default".to_string()),
parent_id: None,
title: None,
goal: None,
};
let value = serde_json::to_value(&intent).unwrap();
let api: ApiRunIntent = serde_json::from_value(value.clone()).unwrap();
assert_eq!(value["target"], json!({ "kind": "none" }));
assert_eq!(serde_json::to_value(api).unwrap(), value);
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),

View file

@ -131,7 +131,7 @@ pub use run_event::{
pub use run_failure::RunFailure;
pub use run_id::{RunId, fixtures};
pub use run_intent::{
RunIntent, RunIntentArgs, RunTarget, TargetValidationError, ValidatedGitTarget,
RunIntent, RunIntentArgs, RunTarget, TargetValidationError, ValidatedRunTarget,
};
pub use run_projection::{
ActivatedSkill, AgentControlState, CheckpointRecord, McpServerProjection, McpServerStatus,

View file

@ -37,8 +37,13 @@ pub struct RunIntentArgs {
}
/// Requested workspace content, independent of sandbox placement.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
///
/// `None` is an empty struct variant rather than a unit variant so that the
/// derived deserializer enforces `deny_unknown_fields` on `{"kind": "none"}`
/// (serde ignores sibling fields on internally tagged unit variants).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, strum::IntoStaticStr)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
#[strum(serialize_all = "snake_case")]
pub enum RunTarget {
Git {
repo: String,
@ -46,16 +51,20 @@ pub enum RunTarget {
#[serde(default, skip_serializing_if = "Option::is_none")]
sha: Option<String>,
},
None {},
}
impl RunTarget {
/// Validates the target's grammar without any network resolution and
/// derives its operational Git projection.
/// The wire `kind` discriminator (`git`, `none`), for diagnostics.
pub fn kind_name(&self) -> &'static str {
self.into()
}
/// Validates and canonicalizes the target without any network resolution.
///
/// This is the single owner of the Git-target grammar: admission uses it
/// to reject invalid targets, and sandbox start re-derives the clone
/// source from the persisted target through the same rules.
pub fn validate(self) -> Result<ValidatedGitTarget, TargetValidationError> {
/// Git targets include their derived operational Git projection. Targets
/// without a repository return no projection.
pub fn validate(self) -> Result<ValidatedRunTarget, TargetValidationError> {
match self {
Self::Git { repo, branch, sha } => {
let slug = GitHubRepositorySlug::try_new(&repo)
@ -83,21 +92,25 @@ impl RunTarget {
sha: sha.clone(),
dirty: DirtyStatus::Clean,
};
Ok(ValidatedGitTarget {
Ok(ValidatedRunTarget {
target: Self::Git { repo, branch, sha },
git,
git: Some(git),
})
}
Self::None {} => Ok(ValidatedRunTarget {
target: Self::None {},
git: None,
}),
}
}
}
/// A [`RunTarget`] whose grammar has been validated, together with the
/// operational Git projection derived from it.
/// A [`RunTarget`] whose grammar has been validated, together with its
/// optional operational Git projection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidatedGitTarget {
pub struct ValidatedRunTarget {
pub target: RunTarget,
pub git: GitContext,
pub git: Option<GitContext>,
}
/// A [`RunTarget`] that failed grammar validation.

View file

@ -49,6 +49,28 @@ fn run_intent_round_trips_the_strict_git_shape() {
);
}
#[test]
fn run_intent_round_trips_the_strict_none_shape() {
let mut intent = intent();
intent.target = RunTarget::None {};
let value = serde_json::to_value(&intent).expect("intent should serialize");
assert_eq!(value["target"], json!({ "kind": "none" }));
assert_eq!(
serde_json::from_value::<RunIntent>(value).expect("intent should deserialize"),
intent
);
}
#[test]
fn run_intent_none_target_rejects_unknown_fields() {
let mut value = serde_json::to_value(intent()).expect("intent should serialize");
value["target"] = json!({ "kind": "none", "unexpected": true });
assert!(serde_json::from_value::<RunIntent>(value).is_err());
}
#[test]
fn run_intent_rejects_unknown_fields_at_every_object_boundary() {
let value = serde_json::to_value(intent()).expect("intent should serialize");
@ -107,15 +129,15 @@ fn target_validation_normalizes_sha_without_network_resolution() {
}
.validate()
.unwrap();
let git = validated
.git
.expect("Git target should produce a Git projection");
assert_eq!(
validated.git.sha.as_deref(),
git.sha.as_deref(),
Some("abcdef0123456789abcdef0123456789abcdef01")
);
assert_eq!(
validated.git.origin_url,
"https://github.com/fabro-sh/fabro"
);
assert_eq!(git.origin_url, "https://github.com/fabro-sh/fabro");
assert_eq!(validated.target, RunTarget::Git {
repo: "fabro-sh/fabro".to_string(),
branch: "feature/run-intent".to_string(),
@ -123,6 +145,13 @@ fn target_validation_normalizes_sha_without_network_resolution() {
});
}
#[test]
fn run_intent_none_target_validates_without_a_git_projection() {
let validated = RunTarget::None {}.validate().unwrap();
assert_eq!(validated.target, RunTarget::None {});
assert_eq!(validated.git, None);
}
#[test]
fn target_validation_rejects_invalid_grammar() {
use fabro_types::TargetValidationError;

View file

@ -238,6 +238,7 @@ models/model-reference.ts
models/model-test-mode.ts
models/model-test-result.ts
models/model.ts
models/none-run-target.ts
models/notification-provider-settings.ts
models/notification-route-settings.ts
models/object-store-local-settings.ts
@ -420,6 +421,7 @@ models/run-status-submitted.ts
models/run-status-succeeded.ts
models/run-status.ts
models/run-superseded-by-props.ts
models/run-target.ts
models/run-timestamps.ts
models/run-timing.ts
models/run.ts

View file

@ -18,9 +18,6 @@
import type { GitContext } from './git-context';
// May contain unused imports in some cases
// @ts-ignore
import type { GitRunTarget } from './git-run-target';
// May contain unused imports in some cases
// @ts-ignore
import type { ManifestConfig } from './manifest-config';
// May contain unused imports in some cases
// @ts-ignore
@ -34,6 +31,9 @@ import type { RunIntentArgs } from './run-intent-args';
// May contain unused imports in some cases
// @ts-ignore
import type { RunManifest } from './run-manifest';
// May contain unused imports in some cases
// @ts-ignore
import type { RunTarget } from './run-target';
/**
* @type CreateRunRequest

View file

@ -207,6 +207,7 @@ export * from './model-limits';
export * from './model-reference';
export * from './model-test-mode';
export * from './model-test-result';
export * from './none-run-target';
export * from './notification-provider-settings';
export * from './notification-route-settings';
export * from './object-store-local-settings';
@ -390,6 +391,7 @@ export * from './run-status-starting';
export * from './run-status-submitted';
export * from './run-status-succeeded';
export * from './run-superseded-by-props';
export * from './run-target';
export * from './run-timestamps';
export * from './run-timing';
export * from './sandbox-details';

View file

@ -0,0 +1,28 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.2.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Empty workspace with no repository. Docker and Daytona accept this target and suppress cloning even when workflow settings enable it. Local environments reject it; Local scratch allocation is a separate future capability.
*/
export interface NoneRunTarget {
'kind': NoneRunTargetKindEnum;
}
export const NoneRunTargetKindEnum = {
NONE: 'none'
} as const;
export type NoneRunTargetKindEnum = typeof NoneRunTargetKindEnum[keyof typeof NoneRunTargetKindEnum];

View file

@ -15,10 +15,10 @@
// May contain unused imports in some cases
// @ts-ignore
import type { GitRunTarget } from './git-run-target';
import type { RunIntentArgs } from './run-intent-args';
// May contain unused imports in some cases
// @ts-ignore
import type { RunIntentArgs } from './run-intent-args';
import type { RunTarget } from './run-target';
/**
* A request to create, but not start, one run from an immutable workflow version and an explicit workspace target.
@ -28,7 +28,7 @@ export interface RunIntent {
* SHA-256 identity of validated canonical workflow-version bytes. Hex input is case-insensitive; Fabro emits the canonical lowercase form.
*/
'workflow_version_id': string;
'target': GitRunTarget;
'target': RunTarget;
'args': RunIntentArgs;
/**
* Server environment catalog ID. Omission selects `default`.

View file

@ -24,10 +24,10 @@ import type { ForkSourceRef } from './fork-source-ref';
import type { GitContext } from './git-context';
// May contain unused imports in some cases
// @ts-ignore
import type { GitRunTarget } from './git-run-target';
import type { RunProvenance } from './run-provenance';
// May contain unused imports in some cases
// @ts-ignore
import type { RunProvenance } from './run-provenance';
import type { RunTarget } from './run-target';
// May contain unused imports in some cases
// @ts-ignore
import type { WorkflowSettings } from './workflow-settings';
@ -45,7 +45,7 @@ export interface RunSpec {
* SHA-256 identity of validated canonical workflow-version bytes. Hex input is case-insensitive; Fabro emits the canonical lowercase form.
*/
'workflow_version_id'?: string | null;
'target'?: GitRunTarget | null;
'target'?: RunTarget | null;
'automation'?: AutomationRef | null;
'source_directory'?: string | null;
'labels'?: { [key: string]: string; };

View file

@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.2.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { GitRunTarget } from './git-run-target';
// May contain unused imports in some cases
// @ts-ignore
import type { NoneRunTarget } from './none-run-target';
/**
* @type RunTarget
* Workspace content and location requested for a run.
*/
export type RunTarget = { kind: 'git' } & GitRunTarget | { kind: 'none' } & NoneRunTarget;