diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index d2ac2b151..59ea3cac0 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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 diff --git a/docs/public/changelog/2026-08-23.mdx b/docs/public/changelog/2026-08-23.mdx index 7c3053349..16955967b 100644 --- a/docs/public/changelog/2026-08-23.mdx +++ b/docs/public/changelog/2026-08-23.mdx @@ -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 diff --git a/docs/public/execution/environments.mdx b/docs/public/execution/environments.mdx index a97509bd9..c7a8b3dc1 100644 --- a/docs/public/execution/environments.mdx +++ b/docs/public/execution/environments.mdx @@ -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. diff --git a/lib/apps/fabro-server/src/run_intent.rs b/lib/apps/fabro-server/src/run_intent.rs index c122ceb69..e9dcf4d97 100644 --- a/lib/apps/fabro-server/src/run_intent.rs +++ b/lib/apps/fabro-server/src/run_intent.rs @@ -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, diff --git a/lib/apps/fabro-server/src/server/handler/runs.rs b/lib/apps/fabro-server/src/server/handler/runs.rs index ecf7100f5..321606e78 100644 --- a/lib/apps/fabro-server/src/server/handler/runs.rs +++ b/lib/apps/fabro-server/src/server/handler/runs.rs @@ -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) diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 43b1e434a..2bed9431f 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -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::().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!["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::().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) { + 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] diff --git a/lib/components/fabro-workflow/src/operations/create.rs b/lib/components/fabro-workflow/src/operations/create.rs index d29224204..e15016c61 100644 --- a/lib/components/fabro-workflow/src/operations/create.rs +++ b/lib/components/fabro-workflow/src/operations/create.rs @@ -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(); diff --git a/lib/components/fabro-workflow/src/operations/retry.rs b/lib/components/fabro-workflow/src/operations/retry.rs index 5d58e9cc1..12354b185 100644 --- a/lib/components/fabro-workflow/src/operations/retry.rs +++ b/lib/components/fabro-workflow/src/operations/retry.rs @@ -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(); diff --git a/lib/components/fabro-workflow/src/operations/start.rs b/lib/components/fabro-workflow/src/operations/start.rs index d7a79aad7..86bb2dcdc 100644 --- a/lib/components/fabro-workflow/src/operations/start.rs +++ b/lib/components/fabro-workflow/src/operations/start.rs @@ -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 = 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, branch: Option, commit_sha: Option, + /// 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 { @@ -583,6 +598,7 @@ fn clone_source_for_run(record: &RunSpec) -> Result { 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 { 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) { + 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, ) -> (Persisted, Arc) { 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(); diff --git a/lib/foundation/fabro-api/tests/run_intent_round_trip.rs b/lib/foundation/fabro-api/tests/run_intent_round_trip.rs index ba02cf50a..c065c7114 100644 --- a/lib/foundation/fabro-api/tests/run_intent_round_trip.rs +++ b/lib/foundation/fabro-api/tests/run_intent_round_trip.rs @@ -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() { assert_eq!( TypeId::of::(), diff --git a/lib/foundation/fabro-types/src/lib.rs b/lib/foundation/fabro-types/src/lib.rs index cf4fe31d6..1ac3ddae5 100644 --- a/lib/foundation/fabro-types/src/lib.rs +++ b/lib/foundation/fabro-types/src/lib.rs @@ -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, diff --git a/lib/foundation/fabro-types/src/run_intent.rs b/lib/foundation/fabro-types/src/run_intent.rs index e7e7db0de..42327cb88 100644 --- a/lib/foundation/fabro-types/src/run_intent.rs +++ b/lib/foundation/fabro-types/src/run_intent.rs @@ -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, }, + 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 { + /// Git targets include their derived operational Git projection. Targets + /// without a repository return no projection. + pub fn validate(self) -> Result { 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, } /// A [`RunTarget`] that failed grammar validation. diff --git a/lib/foundation/fabro-types/tests/run_intent.rs b/lib/foundation/fabro-types/tests/run_intent.rs index 6e11a5275..b2b3f75fa 100644 --- a/lib/foundation/fabro-types/tests/run_intent.rs +++ b/lib/foundation/fabro-types/tests/run_intent.rs @@ -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::(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::(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; diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 832e7ff8c..b77cd33c3 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -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 diff --git a/lib/packages/fabro-api-client/src/models/create-run-request.ts b/lib/packages/fabro-api-client/src/models/create-run-request.ts index d362f85f0..376302b5a 100644 --- a/lib/packages/fabro-api-client/src/models/create-run-request.ts +++ b/lib/packages/fabro-api-client/src/models/create-run-request.ts @@ -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 diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 5d05c73d9..601c25686 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/models/none-run-target.ts b/lib/packages/fabro-api-client/src/models/none-run-target.ts new file mode 100644 index 000000000..fe9461100 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/none-run-target.ts @@ -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]; diff --git a/lib/packages/fabro-api-client/src/models/run-intent.ts b/lib/packages/fabro-api-client/src/models/run-intent.ts index 3cc1637cb..531af244a 100644 --- a/lib/packages/fabro-api-client/src/models/run-intent.ts +++ b/lib/packages/fabro-api-client/src/models/run-intent.ts @@ -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`. diff --git a/lib/packages/fabro-api-client/src/models/run-spec.ts b/lib/packages/fabro-api-client/src/models/run-spec.ts index b05916427..2907d939d 100644 --- a/lib/packages/fabro-api-client/src/models/run-spec.ts +++ b/lib/packages/fabro-api-client/src/models/run-spec.ts @@ -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; }; diff --git a/lib/packages/fabro-api-client/src/models/run-target.ts b/lib/packages/fabro-api-client/src/models/run-target.ts new file mode 100644 index 000000000..1a36b5624 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-target.ts @@ -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;