mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Merge pull request #789 from fabro-sh/codex/run-intent-none-target
Add empty workspace run target
This commit is contained in:
commit
0001cfba02
20 changed files with 723 additions and 129 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
28
lib/packages/fabro-api-client/src/models/none-run-target.ts
generated
Normal file
28
lib/packages/fabro-api-client/src/models/none-run-target.ts
generated
Normal 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];
|
||||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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; };
|
||||
|
|
|
|||
27
lib/packages/fabro-api-client/src/models/run-target.ts
generated
Normal file
27
lib/packages/fabro-api-client/src/models/run-target.ts
generated
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue