mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Add local folder run target
This commit is contained in:
parent
679d20cb52
commit
c396a6cf6f
21 changed files with 1303 additions and 36 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3069,6 +3069,7 @@ dependencies = [
|
|||
"fabro-workflow",
|
||||
"fabro-workflow-version",
|
||||
"futures-util",
|
||||
"git2",
|
||||
"globset",
|
||||
"hex",
|
||||
"hkdf 0.12.4",
|
||||
|
|
|
|||
|
|
@ -9299,11 +9299,13 @@ components:
|
|||
oneOf:
|
||||
- $ref: "#/components/schemas/GitRunTarget"
|
||||
- $ref: "#/components/schemas/NoneRunTarget"
|
||||
- $ref: "#/components/schemas/FolderRunTarget"
|
||||
discriminator:
|
||||
propertyName: kind
|
||||
mapping:
|
||||
git: "#/components/schemas/GitRunTarget"
|
||||
none: "#/components/schemas/NoneRunTarget"
|
||||
folder: "#/components/schemas/FolderRunTarget"
|
||||
|
||||
GitRunTarget:
|
||||
description: Public github.com repository target.
|
||||
|
|
@ -9347,6 +9349,30 @@ components:
|
|||
type: string
|
||||
enum: [none]
|
||||
|
||||
FolderRunTarget:
|
||||
description: >-
|
||||
Existing directory on the Fabro server, executed in place by a Local
|
||||
environment. The submitted path must be absolute and name an existing
|
||||
directory; Fabro resolves symlinks and persists its canonical UTF-8
|
||||
path. This target is intended for trusted single-tenant deployments.
|
||||
Docker and Daytona environments always reject it. This target does not
|
||||
add Local Git cloning or Local scratch workspaces. Folder runs execute
|
||||
in place without Fabro Git checkpoints, so fork and rewind are
|
||||
unavailable.
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- kind
|
||||
- path
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum: [folder]
|
||||
path:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Absolute path on the Fabro server, not on the API caller's machine.
|
||||
|
||||
RunManifest:
|
||||
description: Self-contained workflow run manifest.
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -11,6 +11,15 @@ settings enable it.
|
|||
Local environments reject the `none` target. Server-managed Local scratch
|
||||
workspaces remain a separate future capability.
|
||||
|
||||
Run intents can also use
|
||||
`{ "kind": "folder", "path": "/absolute/server/path" }` with a Local
|
||||
environment to execute in an existing server directory. Fabro resolves the
|
||||
submitted path to an existing canonical directory, persists that path, and
|
||||
uses it instead of the environment's `cwd`. Folder targets are intended for
|
||||
trusted single-tenant deployments and are rejected by Docker and Daytona.
|
||||
They execute in place without Fabro Git checkpoints, so retries retain the
|
||||
folder target while fork and rewind remain unavailable.
|
||||
|
||||
## More
|
||||
|
||||
<Accordion title="Fixes">
|
||||
|
|
|
|||
|
|
@ -231,6 +231,18 @@ Install seeds a `default` environment into SQLite. It is a normal persisted envi
|
|||
|
||||
Create a server-managed local-provider environment through the environments API when you need a host `cwd`.
|
||||
|
||||
A version-backed run intent can submit
|
||||
`{ "kind": "folder", "path": "/absolute/server/path" }` to run in an existing
|
||||
server directory. Fabro accepts this target only with a Local environment,
|
||||
resolves symlinks and `..`, requires an existing directory, and persists the
|
||||
canonical UTF-8 path. The target path takes precedence over the environment's
|
||||
`cwd`. Because the run executes in place with the Local provider's unrestricted
|
||||
host access, use folder targets only in trusted single-tenant deployments.
|
||||
Docker and Daytona always reject folder targets. This does not add Local Git
|
||||
cloning or Local scratch workspaces for the `none` target. Local folder runs
|
||||
execute in place without Fabro Git checkpoints: retries retain the canonical
|
||||
folder target, but fork and rewind are unavailable for these runs.
|
||||
|
||||
When `cwd` is set, local runs execute commands from that absolute server-side
|
||||
path. When it is unset, Fabro keeps same-host compatibility by using the
|
||||
submitted source directory only if that path exists on the server. If neither is
|
||||
|
|
@ -258,7 +270,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 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.
|
||||
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, while the Local-only `folder` target is rejected by Docker and Daytona. 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ chrono = { workspace = true }
|
|||
|
||||
[dev-dependencies]
|
||||
fabro-auth = { path = "../../foundation/fabro-auth", features = ["test-support"] }
|
||||
git2.workspace = true
|
||||
tokio = { workspace = true, features = ["test-util", "macros"] }
|
||||
tower = "0.5"
|
||||
http-body-util = "0.1"
|
||||
|
|
|
|||
|
|
@ -171,6 +171,11 @@ impl PreparedRun {
|
|||
&self.layered.settings
|
||||
}
|
||||
|
||||
pub(crate) fn with_git(mut self, git: Option<GitContext>) -> Self {
|
||||
self.layered.metadata.git = git;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_identity(
|
||||
mut self,
|
||||
run_id: Option<RunId>,
|
||||
|
|
|
|||
|
|
@ -6,11 +6,14 @@ use fabro_config::{EnvironmentLayer, RunEnvironmentLayer, RunGoalLayer, Settings
|
|||
use fabro_environment::{EnvironmentId, EnvironmentValidationError};
|
||||
use fabro_types::settings::InterpString;
|
||||
use fabro_types::{
|
||||
ManifestPath, SandboxProviderKind, TargetValidationError, WorkflowPath, WorkflowVersionId,
|
||||
GitContext, ManifestPath, RunTarget, SandboxProviderKind, TargetValidationError, WorkflowPath,
|
||||
WorkflowVersionId,
|
||||
};
|
||||
use fabro_workflow::git;
|
||||
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig, WorkflowBundle};
|
||||
use fabro_workflow_version::{LoadedWorkflowVersionClosure, ValidatedWorkflowVersion};
|
||||
use thiserror::Error;
|
||||
use tokio::{fs, task};
|
||||
|
||||
use crate::run_compiler::{RunCompilerError, settings_layer_with_resolved_dockerfiles};
|
||||
|
||||
|
|
@ -26,6 +29,8 @@ pub(crate) enum RunIntentAdmissionError {
|
|||
#[error(transparent)]
|
||||
Target(#[from] TargetValidationError),
|
||||
#[error(transparent)]
|
||||
FolderTarget(#[from] FolderTargetValidationError),
|
||||
#[error(transparent)]
|
||||
Environment(#[from] EnvironmentSelectionError),
|
||||
#[error(transparent)]
|
||||
Compiler(#[from] RunCompilerError),
|
||||
|
|
@ -36,6 +41,99 @@ pub(crate) enum RunIntentAdmissionError {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum FolderTargetValidationError {
|
||||
#[error("folder target path must be absolute")]
|
||||
Relative,
|
||||
#[error("folder target path does not name an accessible filesystem entry")]
|
||||
Canonicalize {
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("folder target path must name a directory")]
|
||||
NotDirectory,
|
||||
#[error("folder target canonical path must be valid UTF-8")]
|
||||
NonUtf8,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PreparedIntentTarget {
|
||||
pub(crate) target: RunTarget,
|
||||
pub(crate) git: Option<GitContext>,
|
||||
}
|
||||
|
||||
/// Materialize filesystem-backed target facts before any workflow-version
|
||||
/// store reads or run allocation. Folder targets are canonicalized once for
|
||||
/// durable identity. Optional Git observation is deferred until the effective
|
||||
/// environment has been admitted as Local.
|
||||
pub(crate) async fn prepare_intent_target(
|
||||
target: RunTarget,
|
||||
git: Option<GitContext>,
|
||||
) -> Result<PreparedIntentTarget, FolderTargetValidationError> {
|
||||
let RunTarget::Folder { path } = target else {
|
||||
return Ok(PreparedIntentTarget { target, git });
|
||||
};
|
||||
let submitted = PathBuf::from(path);
|
||||
if !submitted.is_absolute() {
|
||||
return Err(FolderTargetValidationError::Relative);
|
||||
}
|
||||
let canonical = fs::canonicalize(&submitted)
|
||||
.await
|
||||
.map_err(|source| FolderTargetValidationError::Canonicalize { source })?;
|
||||
let metadata = fs::metadata(&canonical)
|
||||
.await
|
||||
.map_err(|source| FolderTargetValidationError::Canonicalize { source })?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(FolderTargetValidationError::NotDirectory);
|
||||
}
|
||||
let canonical_text = canonical_folder_text(&canonical)?;
|
||||
|
||||
Ok(PreparedIntentTarget {
|
||||
target: RunTarget::Folder {
|
||||
path: canonical_text,
|
||||
},
|
||||
git,
|
||||
})
|
||||
}
|
||||
|
||||
/// Observe optional Git metadata only after provider policy has admitted the
|
||||
/// folder target. This keeps rejected requests from scanning host repositories.
|
||||
pub(crate) async fn observe_folder_git_context(target: &RunTarget) -> Option<GitContext> {
|
||||
let RunTarget::Folder { path } = target else {
|
||||
return None;
|
||||
};
|
||||
let canonical = PathBuf::from(path);
|
||||
let observed_path = canonical.clone();
|
||||
let observed = task::spawn_blocking(move || git::observe_git_context(&observed_path)).await;
|
||||
let git = match observed {
|
||||
Ok(Ok(git)) => git,
|
||||
Ok(Err(error)) => {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
path = %canonical.display(),
|
||||
"Failed to observe optional Git metadata for folder target"
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
path = %canonical.display(),
|
||||
"Folder target Git observation task failed"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
git
|
||||
}
|
||||
|
||||
fn canonical_folder_text(path: &Path) -> Result<String, FolderTargetValidationError> {
|
||||
path.to_str()
|
||||
.map(str::to_string)
|
||||
.ok_or(FolderTargetValidationError::NonUtf8)
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum EnvironmentSelectionError {
|
||||
#[error("invalid environment ID `{value}`")]
|
||||
|
|
@ -377,6 +475,81 @@ mod tests {
|
|||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepares_a_canonical_folder_target_without_git_projection() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let target_dir = dir.path().join("target");
|
||||
std::fs::create_dir(&target_dir).unwrap();
|
||||
std::fs::create_dir(dir.path().join("nested")).unwrap();
|
||||
let submitted = dir.path().join("nested").join("..").join("target");
|
||||
|
||||
let prepared = prepare_intent_target(
|
||||
RunTarget::Folder {
|
||||
path: submitted.to_string_lossy().to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let canonical = target_dir.canonicalize().unwrap();
|
||||
|
||||
assert_eq!(prepared.git, None);
|
||||
assert_eq!(prepared.target, RunTarget::Folder {
|
||||
path: canonical.to_string_lossy().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_relative_missing_and_file_folder_targets() {
|
||||
let relative = prepare_intent_target(
|
||||
RunTarget::Folder {
|
||||
path: "relative/path".to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(relative, FolderTargetValidationError::Relative));
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let missing = prepare_intent_target(
|
||||
RunTarget::Folder {
|
||||
path: dir.path().join("missing").to_string_lossy().to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
missing,
|
||||
FolderTargetValidationError::Canonicalize { .. }
|
||||
));
|
||||
|
||||
let file = dir.path().join("file");
|
||||
fs::write(&file, "not a directory").await.unwrap();
|
||||
let file = prepare_intent_target(
|
||||
RunTarget::Folder {
|
||||
path: file.to_string_lossy().to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(file, FolderTargetValidationError::NotDirectory));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_a_non_utf8_canonical_folder_target() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
|
||||
let path = PathBuf::from(OsString::from_vec(vec![b'f', b'o', 0x80]));
|
||||
let error = canonical_folder_text(&path).unwrap_err();
|
||||
|
||||
assert!(matches!(error, FolderTargetValidationError::NonUtf8));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lowers_nested_entrypoints_and_inlines_goal_files() {
|
||||
let (database, _) = crate::test_support::test_store_bundle();
|
||||
|
|
|
|||
|
|
@ -60,8 +60,9 @@ use crate::run_compiler::{
|
|||
};
|
||||
use crate::run_files::{list_run_commits, list_run_files};
|
||||
use crate::run_intent::{
|
||||
EnvironmentSelectionError, RunIntentAdmissionError, lower_workflow_closure,
|
||||
pin_workflow_environment_authority,
|
||||
EnvironmentSelectionError, PreparedIntentTarget, RunIntentAdmissionError,
|
||||
lower_workflow_closure, observe_folder_git_context, pin_workflow_environment_authority,
|
||||
prepare_intent_target,
|
||||
};
|
||||
use crate::run_manifest;
|
||||
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
|
||||
|
|
@ -615,6 +616,10 @@ async fn create_run_from_intent(
|
|||
Ok(validated) => validated,
|
||||
Err(error) => return run_intent_admission_error(error.into()),
|
||||
};
|
||||
let PreparedIntentTarget { target, git } = match prepare_intent_target(target, git).await {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => return run_intent_admission_error(error.into()),
|
||||
};
|
||||
let environment_id = match select_intent_environment_id(
|
||||
&state,
|
||||
intent
|
||||
|
|
@ -697,6 +702,9 @@ async fn create_run_from_intent(
|
|||
let raw_compiler_input = RawRunCompilerInput {
|
||||
workflow_bundle: lowered.workflow_bundle,
|
||||
entrypoint: lowered.entrypoint,
|
||||
// Intent compilation is isolated from target-project content. Folder
|
||||
// identity is projected to `source_directory` during persistence and
|
||||
// must never become a compiler lookup root.
|
||||
cwd: PathBuf::from("/workspace"),
|
||||
server_run_defaults: state.manifest_run_defaults().as_ref().clone(),
|
||||
server_environment_defaults: state.environment_store().catalog_layer().as_ref().clone(),
|
||||
|
|
@ -738,13 +746,17 @@ async fn create_run_from_intent(
|
|||
});
|
||||
}
|
||||
};
|
||||
let prepared = match run_compiler::apply_run_variables(layered, vars) {
|
||||
let mut prepared = match run_compiler::apply_run_variables(layered, vars) {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => return run_intent_admission_error(error.into()),
|
||||
};
|
||||
if let Err(error) = validate_intent_environment(&state, prepared.settings(), &target).await {
|
||||
return run_intent_admission_error(error.into());
|
||||
}
|
||||
if matches!(target, RunTarget::Folder { .. }) {
|
||||
let git = observe_folder_git_context(&target).await;
|
||||
prepared = prepared.with_git(git);
|
||||
}
|
||||
let (prepared, run_id) = prepared.resolve_run_id();
|
||||
if let Err(response) = validate_optional_parent(&state, run_id, prepared.parent_id()).await {
|
||||
return response;
|
||||
|
|
@ -978,7 +990,9 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
|
|||
"Run intent admission rejected"
|
||||
);
|
||||
}
|
||||
RunIntentAdmissionError::Target(_) | RunIntentAdmissionError::Environment(_) => {}
|
||||
RunIntentAdmissionError::Target(_)
|
||||
| RunIntentAdmissionError::FolderTarget(_)
|
||||
| RunIntentAdmissionError::Environment(_) => {}
|
||||
}
|
||||
|
||||
match error {
|
||||
|
|
@ -997,6 +1011,11 @@ fn run_intent_admission_error(error: RunIntentAdmissionError) -> Response {
|
|||
error.to_string(),
|
||||
"target_invalid",
|
||||
),
|
||||
RunIntentAdmissionError::FolderTarget(error) => intent_error(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
error.to_string(),
|
||||
"target_invalid",
|
||||
),
|
||||
RunIntentAdmissionError::Environment(error) => match error {
|
||||
EnvironmentSelectionError::InvalidId { source, .. } => intent_error(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
|
|
@ -1082,6 +1101,10 @@ async fn validate_intent_environment(
|
|||
provider == SandboxProviderKind::Local,
|
||||
"none targets require a compatible Docker or Daytona environment",
|
||||
),
|
||||
RunTarget::Folder { .. } => (
|
||||
provider != SandboxProviderKind::Local,
|
||||
"folder targets require a Local environment",
|
||||
),
|
||||
};
|
||||
if image_incompatible || target_incompatible {
|
||||
return Err(EnvironmentSelectionError::TargetUnsupported { detail });
|
||||
|
|
|
|||
|
|
@ -3544,6 +3544,20 @@ async fn post_run_manifest(app: &Router, manifest: serde_json::Value) -> serde_j
|
|||
response_json!(response, StatusCode::CREATED).await
|
||||
}
|
||||
|
||||
async fn post_run_intent_response(app: &Router, intent: serde_json::Value) -> 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()
|
||||
}
|
||||
|
||||
async fn store_workflow_version(
|
||||
state: &AppState,
|
||||
graph: &str,
|
||||
|
|
@ -3759,6 +3773,261 @@ async fn post_runs_run_intent_creates_submitted_none_target_without_git_projecti
|
|||
assert!(projection.spec.definition_blob.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_canonicalizes_and_persists_a_local_folder_target() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("workspace");
|
||||
let hop = dir.path().join("hop");
|
||||
std::fs::create_dir(&workspace).unwrap();
|
||||
std::fs::create_dir(&hop).unwrap();
|
||||
// Target-project files are not compiler inputs for a version-backed run.
|
||||
std::fs::write(workspace.join("workflow.toml"), "not valid TOML").unwrap();
|
||||
std::fs::write(workspace.join("goal.md"), "Goal from target folder").unwrap();
|
||||
let submitted = hop.join("..").join("workspace");
|
||||
let canonical = workspace
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let state = TestAppStateBuilder::new()
|
||||
.default_environment_provider(Some(EnvironmentProvider::Local))
|
||||
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(
|
||||
&state,
|
||||
MINIMAL_DOT,
|
||||
Some("_version = 1\n[run.goal]\nfile = \"goal.md\"\n"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let body = post_run_manifest(
|
||||
&app,
|
||||
json!({
|
||||
"workflow_version_id": workflow_version_id,
|
||||
"target": {
|
||||
"kind": "folder",
|
||||
"path": submitted.to_string_lossy()
|
||||
},
|
||||
"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::Folder {
|
||||
path: canonical.clone(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
projection.spec.source_directory.as_deref(),
|
||||
Some(canonical.as_str())
|
||||
);
|
||||
assert_eq!(projection.spec.git, None);
|
||||
assert_eq!(
|
||||
projection.spec.graph.goal(),
|
||||
"Goal loaded from immutable version bytes"
|
||||
);
|
||||
assert_eq!(
|
||||
projection.spec.settings.run.environment.provider,
|
||||
EnvironmentProvider::Local
|
||||
);
|
||||
assert_eq!(projection.spec.manifest_blob, None);
|
||||
assert!(projection.spec.definition_blob.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_observes_folder_git_metadata_without_a_remote_call() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let repo = git2::Repository::init(dir.path()).unwrap();
|
||||
let mut index = repo.index().unwrap();
|
||||
let tree_id = index.write_tree().unwrap();
|
||||
drop(index);
|
||||
let tree = repo.find_tree(tree_id).unwrap();
|
||||
let signature = git2::Signature::now("Fabro Test", "fabro@example.com").unwrap();
|
||||
let commit = repo
|
||||
.commit(Some("HEAD"), &signature, &signature, "initial", &tree, &[])
|
||||
.unwrap();
|
||||
let commit = commit.to_string();
|
||||
drop(tree);
|
||||
repo.remote("origin", "https://github.com/acme/widgets.git")
|
||||
.unwrap();
|
||||
drop(repo);
|
||||
let canonical = dir
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let state = TestAppStateBuilder::new()
|
||||
.default_environment_provider(Some(EnvironmentProvider::Local))
|
||||
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
|
||||
|
||||
let body = post_run_manifest(
|
||||
&app,
|
||||
json!({
|
||||
"workflow_version_id": workflow_version_id,
|
||||
"target": { "kind": "folder", "path": canonical },
|
||||
"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();
|
||||
let git = projection.spec.git.unwrap();
|
||||
|
||||
assert_eq!(git.origin_url, "https://github.com/acme/widgets");
|
||||
assert!(!git.branch.is_empty());
|
||||
assert_eq!(git.sha.as_deref(), Some(commit.as_str()));
|
||||
assert_eq!(git.dirty, fabro_types::DirtyStatus::Clean);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_rejects_invalid_folder_paths_before_persistence() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join("file");
|
||||
std::fs::write(&file, "not a directory").unwrap();
|
||||
let state = TestAppStateBuilder::new()
|
||||
.default_environment_provider(Some(EnvironmentProvider::Local))
|
||||
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
|
||||
let invalid_paths = [
|
||||
String::new(),
|
||||
"relative/path".to_string(),
|
||||
dir.path().join("missing").to_string_lossy().to_string(),
|
||||
file.to_string_lossy().to_string(),
|
||||
];
|
||||
|
||||
for path in invalid_paths {
|
||||
let response = post_run_intent_response(
|
||||
&app,
|
||||
json!({
|
||||
"workflow_version_id": workflow_version_id,
|
||||
"target": { "kind": "folder", "path": path },
|
||||
"args": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
|
||||
assert_eq!(body["errors"][0]["code"], "target_invalid");
|
||||
}
|
||||
|
||||
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_applies_the_folder_target_environment_matrix() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let target = dir.path().to_string_lossy().to_string();
|
||||
|
||||
for state in [
|
||||
test_app_state(),
|
||||
TestAppStateBuilder::new()
|
||||
.default_environment_provider(Some(EnvironmentProvider::Daytona))
|
||||
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build(),
|
||||
] {
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
let workflow_version_id = store_workflow_version(&state, MINIMAL_DOT, None).await;
|
||||
let response = post_run_intent_response(
|
||||
&app,
|
||||
json!({
|
||||
"workflow_version_id": workflow_version_id,
|
||||
"target": { "kind": "folder", "path": target },
|
||||
"args": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let body = response_json!(response, StatusCode::UNPROCESSABLE_ENTITY).await;
|
||||
assert_eq!(body["errors"][0]["code"], "target_environment_unsupported");
|
||||
assert!(
|
||||
state
|
||||
.stores
|
||||
.run_summaries
|
||||
.list_identities()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
let disabled_state = TestAppStateBuilder::new()
|
||||
.runtime_settings(
|
||||
server_settings_from_toml(
|
||||
r#"
|
||||
_version = 1
|
||||
|
||||
[server.auth]
|
||||
methods = ["dev-token"]
|
||||
|
||||
[server.sandbox.providers.local]
|
||||
enabled = false
|
||||
"#,
|
||||
),
|
||||
RunLayer::default(),
|
||||
)
|
||||
.default_environment_provider(Some(EnvironmentProvider::Local))
|
||||
.vault_entries([(fabro_static::EnvVars::OPENAI_API_KEY, "test-openai-api-key")])
|
||||
.build();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&disabled_state));
|
||||
let workflow_version_id = store_workflow_version(&disabled_state, MINIMAL_DOT, None).await;
|
||||
let response = post_run_intent_response(
|
||||
&app,
|
||||
json!({
|
||||
"workflow_version_id": workflow_version_id,
|
||||
"target": { "kind": "folder", "path": target },
|
||||
"args": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let body = response_json!(response, StatusCode::SERVICE_UNAVAILABLE).await;
|
||||
assert_eq!(body["errors"][0]["code"], "integration_unavailable");
|
||||
assert!(
|
||||
disabled_state
|
||||
.stores
|
||||
.run_summaries
|
||||
.list_identities()
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_runs_run_intent_accepts_none_target_with_ready_daytona_environment() {
|
||||
let state = TestAppStateBuilder::new()
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ use anyhow::Context as _;
|
|||
pub use fabro_checkpoint::META_BRANCH_PREFIX;
|
||||
pub use fabro_checkpoint::author::GitAuthor;
|
||||
use fabro_checkpoint::git::Store;
|
||||
use fabro_types::WorkflowSettings;
|
||||
use fabro_redact::DisplaySafeUrl;
|
||||
use fabro_types::{DirtyStatus, GitContext, WorkflowSettings};
|
||||
use tokio::task::{JoinError, spawn_blocking};
|
||||
use tokio::time::timeout;
|
||||
|
||||
|
|
@ -14,6 +15,106 @@ use crate::error::{Error, Result};
|
|||
/// Branch prefix for workflow run branches (e.g. `fabro/run/{run_id}`).
|
||||
pub const RUN_BRANCH_PREFIX: &str = "fabro/run/";
|
||||
|
||||
/// A local checkout could not be inspected without changing it.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GitObservationError {
|
||||
#[error("failed to discover the local Git repository")]
|
||||
Discover {
|
||||
#[source]
|
||||
source: git2::Error,
|
||||
},
|
||||
#[error("failed to read the local Git repository HEAD")]
|
||||
Head {
|
||||
#[source]
|
||||
source: git2::Error,
|
||||
},
|
||||
#[error("failed to read the local Git repository origin")]
|
||||
Origin {
|
||||
#[source]
|
||||
source: git2::Error,
|
||||
},
|
||||
#[error("failed to read the local Git repository status")]
|
||||
Status {
|
||||
#[source]
|
||||
source: git2::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Observe the current branch, commit, origin, and dirty state of a local
|
||||
/// checkout without invoking Git commands, contacting a remote, or mutating
|
||||
/// the repository. Non-repositories, unborn repositories, and detached HEADs
|
||||
/// have no usable [`GitContext`] and return `Ok(None)`.
|
||||
pub fn observe_git_context(
|
||||
path: &Path,
|
||||
) -> std::result::Result<Option<GitContext>, GitObservationError> {
|
||||
let repo = match git2::Repository::discover(path) {
|
||||
Ok(repo) => repo,
|
||||
Err(source) if source.code() == git2::ErrorCode::NotFound => return Ok(None),
|
||||
Err(source) => return Err(GitObservationError::Discover { source }),
|
||||
};
|
||||
let head = match repo.head() {
|
||||
Ok(head) => head,
|
||||
Err(source)
|
||||
if matches!(
|
||||
source.code(),
|
||||
git2::ErrorCode::NotFound | git2::ErrorCode::UnbornBranch
|
||||
) =>
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Err(source) => return Err(GitObservationError::Head { source }),
|
||||
};
|
||||
if !head.is_branch() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(branch) = head.shorthand().filter(|branch| !branch.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let branch = branch.to_string();
|
||||
let sha = head.target().map(|oid| oid.to_string());
|
||||
drop(head);
|
||||
|
||||
let origin_url = match repo.find_remote("origin") {
|
||||
Ok(remote) => remote.url().map(sanitized_origin_url).unwrap_or_default(),
|
||||
Err(source) if source.code() == git2::ErrorCode::NotFound => String::new(),
|
||||
Err(source) => return Err(GitObservationError::Origin { source }),
|
||||
};
|
||||
let mut status_options = git2::StatusOptions::new();
|
||||
status_options
|
||||
.include_untracked(true)
|
||||
.no_refresh(true)
|
||||
.update_index(false);
|
||||
let statuses = repo
|
||||
.statuses(Some(&mut status_options))
|
||||
.map_err(|source| GitObservationError::Status { source })?;
|
||||
let dirty = if statuses
|
||||
.iter()
|
||||
.any(|entry| entry.status() != git2::Status::CURRENT)
|
||||
{
|
||||
DirtyStatus::Dirty
|
||||
} else {
|
||||
DirtyStatus::Clean
|
||||
};
|
||||
|
||||
Ok(Some(GitContext {
|
||||
origin_url,
|
||||
branch,
|
||||
sha,
|
||||
dirty,
|
||||
}))
|
||||
}
|
||||
|
||||
fn sanitized_origin_url(value: &str) -> String {
|
||||
let normalized = fabro_github::normalize_repo_origin_url(value);
|
||||
let Ok(url) = DisplaySafeUrl::parse(&normalized) else {
|
||||
return String::new();
|
||||
};
|
||||
let mut url = url.without_credentials().into_owned();
|
||||
url.set_query(None);
|
||||
url.set_fragment(None);
|
||||
fabro_github::normalize_repo_origin_url(url.as_str())
|
||||
}
|
||||
|
||||
pub fn git_author_from_settings(settings: &WorkflowSettings) -> GitAuthor {
|
||||
settings
|
||||
.run
|
||||
|
|
@ -304,6 +405,84 @@ mod tests {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_git_context_is_read_only_and_reports_local_state() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
let repo = git2::Repository::open(dir.path()).unwrap();
|
||||
repo.remote("origin", "git@github.com:fabro-sh/fabro.git")
|
||||
.unwrap();
|
||||
drop(repo);
|
||||
let git_dir = dir.path().join(".git");
|
||||
let head_before = fs::read(git_dir.join("HEAD")).unwrap();
|
||||
let index_before = fs::read(git_dir.join("index")).unwrap();
|
||||
let config_before = fs::read(git_dir.join("config")).unwrap();
|
||||
|
||||
let observed = observe_git_context(dir.path()).unwrap().unwrap();
|
||||
assert!(!observed.branch.is_empty());
|
||||
assert_eq!(observed.origin_url, "https://github.com/fabro-sh/fabro");
|
||||
assert_eq!(observed.sha.as_deref().map(str::len), Some(40));
|
||||
assert_eq!(observed.dirty, DirtyStatus::Clean);
|
||||
assert_eq!(fs::read(git_dir.join("HEAD")).unwrap(), head_before);
|
||||
assert_eq!(fs::read(git_dir.join("index")).unwrap(), index_before);
|
||||
assert_eq!(fs::read(git_dir.join("config")).unwrap(), config_before);
|
||||
assert!(!git_dir.join("HEAD.lock").exists());
|
||||
assert!(!git_dir.join("index.lock").exists());
|
||||
assert!(!git_dir.join("config.lock").exists());
|
||||
|
||||
fs::write(dir.path().join("untracked.txt"), "changed").unwrap();
|
||||
let observed = observe_git_context(dir.path()).unwrap().unwrap();
|
||||
assert_eq!(observed.dirty, DirtyStatus::Dirty);
|
||||
assert_eq!(fs::read(git_dir.join("HEAD")).unwrap(), head_before);
|
||||
assert_eq!(fs::read(git_dir.join("index")).unwrap(), index_before);
|
||||
assert_eq!(fs::read(git_dir.join("config")).unwrap(), config_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_git_context_never_persists_remote_credentials() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
let repo = git2::Repository::open(dir.path()).unwrap();
|
||||
repo.remote(
|
||||
"origin",
|
||||
"http://run-user:secret@example.com/acme/widgets.git?token=secret#fragment",
|
||||
)
|
||||
.unwrap();
|
||||
drop(repo);
|
||||
|
||||
let observed = observe_git_context(dir.path()).unwrap().unwrap();
|
||||
|
||||
assert_eq!(observed.origin_url, "http://example.com/acme/widgets");
|
||||
assert!(!observed.origin_url.contains("secret"));
|
||||
assert!(!observed.origin_url.contains("token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_git_context_accepts_a_non_repository() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(observe_git_context(dir.path()).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observe_git_context_handles_unborn_detached_and_nested_checkouts() {
|
||||
let unborn = tempfile::tempdir().unwrap();
|
||||
git2::Repository::init(unborn.path()).unwrap();
|
||||
assert_eq!(observe_git_context(unborn.path()).unwrap(), None);
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
let nested = dir.path().join("nested");
|
||||
fs::create_dir(&nested).unwrap();
|
||||
let observed = observe_git_context(&nested).unwrap().unwrap();
|
||||
assert!(observed.origin_url.is_empty());
|
||||
assert!(!observed.branch.is_empty());
|
||||
|
||||
let repo = git2::Repository::open(dir.path()).unwrap();
|
||||
let head = repo.head().unwrap().target().unwrap();
|
||||
repo.set_head_detached(head).unwrap();
|
||||
assert_eq!(observe_git_context(dir.path()).unwrap(), None);
|
||||
}
|
||||
|
||||
fn test_store() -> Arc<Database> {
|
||||
Arc::new(fabro_store::test_support::test_database(
|
||||
Arc::new(InMemory::new()),
|
||||
|
|
|
|||
|
|
@ -475,10 +475,11 @@ 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 (source_directory, git) = match target.as_ref() {
|
||||
Some(RunTarget::None {}) => (None, None),
|
||||
Some(RunTarget::Folder { path }) => (Some(path.clone()), git),
|
||||
Some(RunTarget::Git { .. }) | None => (Some(source_directory), git),
|
||||
};
|
||||
let persisted_run_dir = run_dir.clone();
|
||||
let persisted = spawn_blocking(move || {
|
||||
let run_spec = RunSpec {
|
||||
|
|
@ -2319,6 +2320,70 @@ reasoning = false
|
|||
assert_eq!(created.persisted.run_spec().git, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_folder_target_projects_its_path_over_the_compiler_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("workspace");
|
||||
std::fs::create_dir(&workspace).unwrap();
|
||||
let canonical = workspace
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let storage_root = dir.path().join("storage");
|
||||
let store = memory_store();
|
||||
let git = fabro_types::GitContext {
|
||||
origin_url: "https://github.com/fabro-sh/fabro".to_string(),
|
||||
branch: "main".to_string(),
|
||||
sha: None,
|
||||
dirty: fabro_types::DirtyStatus::Clean,
|
||||
};
|
||||
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::Folder {
|
||||
path: canonical.clone(),
|
||||
}),
|
||||
submitted_manifest_bytes: None,
|
||||
run_id: Some(fixtures::RUN_2),
|
||||
title: None,
|
||||
automation: None,
|
||||
git: Some(git.clone()),
|
||||
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().target,
|
||||
Some(RunTarget::Folder {
|
||||
path: canonical.clone(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
created.persisted.run_spec().source_directory.as_deref(),
|
||||
Some(canonical.as_str())
|
||||
);
|
||||
assert_eq!(created.persisted.run_spec().git, Some(git));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_persists_repo_origin_url_from_request() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use anyhow::Result as AnyResult;
|
||||
use fabro_store::{Database, RunProjection, RunProjectionReducer};
|
||||
use fabro_types::{EventBody, EventEnvelope, ForkSourceRef, RunId};
|
||||
use fabro_types::{EventBody, EventEnvelope, ForkSourceRef, RunId, RunTarget};
|
||||
|
||||
use super::timeline::{ForkTarget, RunTimeline, TimelineEntry, build_timeline};
|
||||
use crate::error::Error;
|
||||
|
|
@ -47,6 +47,7 @@ pub async fn fork_run(
|
|||
.state()
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
validate_target_support(state.spec.target.as_ref())?;
|
||||
let timeline = build_timeline(&state).map_err(|err| Error::engine(err.to_string()))?;
|
||||
let entry = resolve_fork_entry(&timeline, &source_run_id, input.target.as_ref())
|
||||
.map_err(|err| Error::Validation(err.to_string()))?;
|
||||
|
|
@ -100,6 +101,16 @@ pub async fn fork_run(
|
|||
})
|
||||
}
|
||||
|
||||
fn validate_target_support(target: Option<&RunTarget>) -> std::result::Result<(), Error> {
|
||||
if matches!(target, Some(RunTarget::Folder { .. })) {
|
||||
return Err(Error::Validation(
|
||||
"Local folder runs execute in place without Git checkpoints; cannot fork or rewind"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_source_spec(spec: &RunSpec, checkpoint_sha: &str) -> std::result::Result<(), Error> {
|
||||
if checkpoint_sha.trim().is_empty() {
|
||||
return Err(Error::Validation(
|
||||
|
|
@ -302,6 +313,17 @@ mod tests {
|
|||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_targets_report_that_fork_and_rewind_are_unsupported() {
|
||||
let target = RunTarget::Folder {
|
||||
path: "/canonical/project".to_string(),
|
||||
};
|
||||
|
||||
let error = validate_target_support(Some(&target)).unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("cannot fork or rewind"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_replay_keeps_stage_scoped_session_activation_only() {
|
||||
assert!(replay_event_for_fork_projection(
|
||||
|
|
|
|||
|
|
@ -515,6 +515,63 @@ mod tests {
|
|||
assert_eq!(retry_state.spec.source_directory, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_preserves_folder_target_and_source_directory_without_git() {
|
||||
let store = memory_store();
|
||||
let source_run_id = fixtures::RUN_1;
|
||||
let source_store = store.create_run(&source_run_id).await.unwrap();
|
||||
let path = "/canonical/local/folder".to_string();
|
||||
let target = RunTarget::Folder { path: path.clone() };
|
||||
event::append_event(&source_store, &source_run_id, &Event::RunCreated {
|
||||
run_id: source_run_id,
|
||||
title: Some("Folder target".to_string()),
|
||||
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
|
||||
graph: serde_json::to_value(Graph::new("folder_target_retry")).unwrap(),
|
||||
workflow_source: Some("digraph folder_target_retry { start -> exit }".to_string()),
|
||||
labels: BTreeMap::new(),
|
||||
source_directory: Some(path.clone()),
|
||||
workflow_slug: Some("folder-target-retry".to_string()),
|
||||
workflow_version_id: Some(test_support::test_workflow_version_id()),
|
||||
target: Some(target.clone()),
|
||||
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 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_state = retry_store.state().await.unwrap();
|
||||
assert_eq!(retry_state.status, RunStatus::Submitted);
|
||||
assert_eq!(retry_state.spec.target, Some(target));
|
||||
assert_eq!(
|
||||
retry_state.spec.source_directory.as_deref(),
|
||||
Some(path.as_str())
|
||||
);
|
||||
assert_eq!(retry_state.spec.git, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_creates_fresh_run_from_succeeded_source() {
|
||||
let store = memory_store();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
|
@ -22,13 +22,14 @@ use fabro_types::settings::run::{
|
|||
RunPrepareSettings as ResolvedRunPrepareSettings,
|
||||
};
|
||||
use fabro_types::{
|
||||
ManifestPath, RunId, RunRunnableSource, RunSpec, SandboxProviderKind, TargetValidationError,
|
||||
ManifestPath, RunId, RunRunnableSource, RunSpec, RunTarget, SandboxProviderKind,
|
||||
TargetValidationError,
|
||||
};
|
||||
use fabro_util::error::collect_chain;
|
||||
use fabro_vault::Vault;
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
use tokio::time;
|
||||
use tokio::{fs, time};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::artifact_upload::ArtifactSink;
|
||||
|
|
@ -461,26 +462,40 @@ impl RunSession {
|
|||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let sandbox = match sandbox_provider {
|
||||
SandboxProviderKind::Local => {
|
||||
if let Some(target) = &record.target {
|
||||
return Err(Error::engine(format!(
|
||||
"persisted {} run targets require a clone-based sandbox provider",
|
||||
target.kind_name()
|
||||
)));
|
||||
SandboxProviderKind::Local => match record.target.as_ref() {
|
||||
Some(RunTarget::Git { .. }) => {
|
||||
return Err(Error::engine(
|
||||
"persisted Git run targets require a clone-based sandbox provider",
|
||||
));
|
||||
}
|
||||
let working_directory = local_working_directory_from_environment(
|
||||
&resolved.environment,
|
||||
record.source_directory.as_deref().map(Path::new),
|
||||
)
|
||||
.map_err(|err| {
|
||||
Error::engine_with_source(
|
||||
"Failed to resolve local environment working directory",
|
||||
err,
|
||||
Some(RunTarget::None {}) => {
|
||||
return Err(Error::engine(
|
||||
"persisted none run targets require a clone-based sandbox provider",
|
||||
));
|
||||
}
|
||||
Some(RunTarget::Folder { path }) => SandboxSpec::Local {
|
||||
working_directory: folder_working_directory_from_record(record, path).await?,
|
||||
},
|
||||
None => {
|
||||
let working_directory = local_working_directory_from_environment(
|
||||
&resolved.environment,
|
||||
record.source_directory.as_deref().map(Path::new),
|
||||
)
|
||||
})?;
|
||||
SandboxSpec::Local { working_directory }
|
||||
}
|
||||
.map_err(|err| {
|
||||
Error::engine_with_source(
|
||||
"Failed to resolve local environment working directory",
|
||||
err,
|
||||
)
|
||||
})?;
|
||||
SandboxSpec::Local { working_directory }
|
||||
}
|
||||
},
|
||||
SandboxProviderKind::Docker => {
|
||||
if matches!(record.target.as_ref(), Some(RunTarget::Folder { .. })) {
|
||||
return Err(Error::engine(
|
||||
"persisted folder run targets require the Local sandbox provider",
|
||||
));
|
||||
}
|
||||
let mut config = resolve_docker_config(resolved, secret_lookup)?;
|
||||
config.skip_clone |= clone_source.skip_clone;
|
||||
SandboxSpec::Docker {
|
||||
|
|
@ -493,6 +508,11 @@ impl RunSession {
|
|||
}
|
||||
}
|
||||
SandboxProviderKind::Daytona => {
|
||||
if matches!(record.target.as_ref(), Some(RunTarget::Folder { .. })) {
|
||||
return Err(Error::engine(
|
||||
"persisted folder run targets require the Local sandbox provider",
|
||||
));
|
||||
}
|
||||
let api_key = vault_guard
|
||||
.get(EnvVars::DAYTONA_API_KEY)
|
||||
.map(str::to_string);
|
||||
|
|
@ -592,6 +612,61 @@ struct CloneSourceForRun {
|
|||
skip_clone: bool,
|
||||
}
|
||||
|
||||
async fn folder_working_directory_from_record(
|
||||
record: &RunSpec,
|
||||
target_path: &str,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let target = Path::new(target_path);
|
||||
if !target.is_absolute() {
|
||||
return Err(Error::engine(
|
||||
"persisted folder run target path must be absolute",
|
||||
));
|
||||
}
|
||||
|
||||
let source_directory = record.source_directory.as_deref().ok_or_else(|| {
|
||||
Error::engine("persisted folder run target is missing its source-directory projection")
|
||||
})?;
|
||||
if source_directory != target_path {
|
||||
return Err(Error::engine(
|
||||
"persisted folder run target disagrees with its source-directory projection",
|
||||
));
|
||||
}
|
||||
|
||||
let canonical = fs::canonicalize(target).await.map_err(|source| {
|
||||
Error::engine_with_source(
|
||||
"persisted folder run target path could not be canonicalized",
|
||||
source,
|
||||
)
|
||||
})?;
|
||||
let canonical_text = canonical.to_str().ok_or_else(|| {
|
||||
Error::engine("persisted folder run target canonical path is not valid UTF-8")
|
||||
})?;
|
||||
if canonical_text != target_path {
|
||||
return Err(Error::engine(
|
||||
"persisted folder run target path is no longer canonical",
|
||||
));
|
||||
}
|
||||
|
||||
let metadata = fs::symlink_metadata(&canonical).await.map_err(|source| {
|
||||
Error::engine_with_source(
|
||||
"persisted folder run target path could not be inspected",
|
||||
source,
|
||||
)
|
||||
})?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(Error::engine(
|
||||
"persisted folder run target path was redirected during startup",
|
||||
));
|
||||
}
|
||||
if !metadata.is_dir() {
|
||||
return Err(Error::engine(
|
||||
"persisted folder run target path is not a directory",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn clone_source_for_run(record: &RunSpec) -> Result<CloneSourceForRun, Error> {
|
||||
let Some(target) = &record.target else {
|
||||
return Ok(CloneSourceForRun {
|
||||
|
|
@ -616,7 +691,9 @@ fn clone_source_for_run(record: &RunSpec) -> Result<CloneSourceForRun, Error> {
|
|||
TargetValidationError::Sha => "persisted Git run target has an invalid SHA",
|
||||
})
|
||||
})?;
|
||||
// A target with no Git projection (`none`) asks for an empty workspace.
|
||||
// A target with no Git projection (`none` or `folder`) supplies no clone
|
||||
// source. Folder targets only reach the Local provider, where `skip_clone`
|
||||
// is unused.
|
||||
Ok(match validated.git {
|
||||
Some(git) => CloneSourceForRun {
|
||||
origin_url: Some(git.origin_url),
|
||||
|
|
@ -1950,6 +2027,213 @@ reasoning = false
|
|||
assert!(error.to_string().contains("none run targets require"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_session_new_folder_target_uses_canonical_path_over_environment_cwd() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (storage_root, _run_dir) = storage_root_and_run_dir(&temp);
|
||||
let folder = temp.path().join("folder-target");
|
||||
let environment_cwd = temp.path().join("environment-cwd");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
std::fs::create_dir_all(&environment_cwd).unwrap();
|
||||
let canonical_folder = folder.canonicalize().unwrap();
|
||||
let canonical_text = canonical_folder.to_str().unwrap().to_string();
|
||||
let mut settings = settings_from_run_layer(RunLayer::default());
|
||||
settings.run.environment.provider = EnvironmentProvider::Local;
|
||||
settings.run.environment.cwd = Some(environment_cwd.to_string_lossy().into_owned());
|
||||
let (persisted, store) =
|
||||
persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;
|
||||
let persisted = persisted_with_target_projection(
|
||||
persisted,
|
||||
RunTarget::Folder {
|
||||
path: canonical_text.clone(),
|
||||
},
|
||||
Some(canonical_text),
|
||||
);
|
||||
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 SandboxSpec::Local { working_directory } = session.sandbox else {
|
||||
panic!("folder target should retain the selected Local provider");
|
||||
};
|
||||
assert_eq!(working_directory, canonical_folder);
|
||||
assert_ne!(working_directory, environment_cwd);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_session_new_folder_target_rejects_clone_based_providers() {
|
||||
for provider in [EnvironmentProvider::Docker, EnvironmentProvider::Daytona] {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (storage_root, _run_dir) = storage_root_and_run_dir(&temp);
|
||||
let folder = temp.path().join("folder-target");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let canonical_folder = folder.canonicalize().unwrap();
|
||||
let canonical_text = canonical_folder.to_str().unwrap().to_string();
|
||||
let mut settings = settings_from_run_layer(RunLayer::default());
|
||||
settings.run.environment.provider = provider;
|
||||
settings.run.environment.image.docker = match provider {
|
||||
EnvironmentProvider::Docker => Some("buildpack-deps:noble".to_string()),
|
||||
EnvironmentProvider::Daytona | EnvironmentProvider::Local => None,
|
||||
};
|
||||
let (persisted, store) =
|
||||
persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).await;
|
||||
let persisted = persisted_with_target_projection(
|
||||
persisted,
|
||||
RunTarget::Folder {
|
||||
path: canonical_text.clone(),
|
||||
},
|
||||
Some(canonical_text),
|
||||
);
|
||||
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!("folder target with a clone-based provider should fail closed");
|
||||
};
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("folder run targets require the Local sandbox provider")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_session_new_legacy_local_run_still_prefers_environment_cwd() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let (storage_root, _run_dir) = storage_root_and_run_dir(&temp);
|
||||
let environment_cwd = temp.path().join("environment-cwd");
|
||||
std::fs::create_dir_all(&environment_cwd).unwrap();
|
||||
let mut settings = settings_from_run_layer(RunLayer::default());
|
||||
settings.run.environment.provider = EnvironmentProvider::Local;
|
||||
settings.run.environment.cwd = Some(environment_cwd.to_string_lossy().into_owned());
|
||||
let (persisted, store) =
|
||||
persisted_workflow_with_settings(MINIMAL_DOT, &storage_root, settings).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 SandboxSpec::Local { working_directory } = session.sandbox else {
|
||||
panic!("legacy Local run should retain the selected Local provider");
|
||||
};
|
||||
assert_eq!(working_directory, environment_cwd);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_target_start_rejects_projection_drift() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let folder = temp.path().join("folder-target");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let canonical_folder = folder.canonicalize().unwrap();
|
||||
let canonical_text = canonical_folder.to_str().unwrap().to_string();
|
||||
let mut record = test_folder_run_spec(&canonical_text);
|
||||
|
||||
record.source_directory = None;
|
||||
let missing_error = folder_working_directory_from_record(&record, &canonical_text)
|
||||
.await
|
||||
.expect_err("missing source-directory projection should fail");
|
||||
assert!(missing_error.to_string().contains("missing"));
|
||||
|
||||
record.source_directory = Some(temp.path().to_string_lossy().into_owned());
|
||||
let drift_error = folder_working_directory_from_record(&record, &canonical_text)
|
||||
.await
|
||||
.expect_err("mismatched source-directory projection should fail");
|
||||
assert!(drift_error.to_string().contains("disagrees"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_target_start_rejects_relative_and_noncanonical_paths() {
|
||||
let relative = "relative/folder";
|
||||
let relative_record = test_folder_run_spec(relative);
|
||||
let relative_error = folder_working_directory_from_record(&relative_record, relative)
|
||||
.await
|
||||
.expect_err("relative persisted target should fail");
|
||||
assert!(relative_error.to_string().contains("must be absolute"));
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let folder = temp.path().join("folder-target");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let canonical_folder = folder.canonicalize().unwrap();
|
||||
let noncanonical = canonical_folder
|
||||
.join("..")
|
||||
.join(canonical_folder.file_name().unwrap());
|
||||
let noncanonical_text = noncanonical.to_str().unwrap();
|
||||
let noncanonical_record = test_folder_run_spec(noncanonical_text);
|
||||
|
||||
let error = folder_working_directory_from_record(&noncanonical_record, noncanonical_text)
|
||||
.await
|
||||
.expect_err("noncanonical persisted target should fail");
|
||||
assert!(error.to_string().contains("no longer canonical"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn folder_target_start_rejects_disappeared_or_retyped_path() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let folder = temp.path().join("folder-target");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let canonical_folder = folder.canonicalize().unwrap();
|
||||
let canonical_text = canonical_folder.to_str().unwrap().to_string();
|
||||
let record = test_folder_run_spec(&canonical_text);
|
||||
|
||||
std::fs::remove_dir(&canonical_folder).unwrap();
|
||||
let missing_error = folder_working_directory_from_record(&record, &canonical_text)
|
||||
.await
|
||||
.expect_err("disappeared folder target should fail");
|
||||
assert!(
|
||||
missing_error
|
||||
.to_string()
|
||||
.contains("could not be canonicalized")
|
||||
);
|
||||
assert!(!missing_error.causes().is_empty());
|
||||
|
||||
fs::write(&canonical_folder, "not a directory")
|
||||
.await
|
||||
.unwrap();
|
||||
let file_error = folder_working_directory_from_record(&record, &canonical_text)
|
||||
.await
|
||||
.expect_err("folder target replaced by a file should fail");
|
||||
assert!(file_error.to_string().contains("is not a directory"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn folder_target_start_rejects_redirected_path() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let folder = temp.path().join("folder-target");
|
||||
let redirected = temp.path().join("redirected-target");
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let canonical_folder = folder.canonicalize().unwrap();
|
||||
let canonical_text = canonical_folder.to_str().unwrap().to_string();
|
||||
let record = test_folder_run_spec(&canonical_text);
|
||||
std::fs::rename(&canonical_folder, &redirected).unwrap();
|
||||
symlink(&redirected, &canonical_folder).unwrap();
|
||||
|
||||
let error = folder_working_directory_from_record(&record, &canonical_text)
|
||||
.await
|
||||
.expect_err("redirected folder target should fail");
|
||||
assert!(error.to_string().contains("no longer canonical"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_docker_config_maps_environment_hints() {
|
||||
let settings = settings_from_run_layer(RunLayer {
|
||||
|
|
@ -2069,6 +2353,26 @@ reasoning = false
|
|||
(created.persisted, store)
|
||||
}
|
||||
|
||||
fn persisted_with_target_projection(
|
||||
persisted: Persisted,
|
||||
target: RunTarget,
|
||||
source_directory: Option<String>,
|
||||
) -> Persisted {
|
||||
let (graph, source, diagnostics, run_dir, mut run_spec) = persisted.into_parts();
|
||||
run_spec.target = Some(target);
|
||||
run_spec.source_directory = source_directory;
|
||||
Persisted::new(graph, source, diagnostics, run_dir, run_spec)
|
||||
}
|
||||
|
||||
fn test_folder_run_spec(path: &str) -> RunSpec {
|
||||
let mut record = test_support::test_run_spec();
|
||||
record.target = Some(RunTarget::Folder {
|
||||
path: path.to_string(),
|
||||
});
|
||||
record.source_directory = Some(path.to_string());
|
||||
record
|
||||
}
|
||||
|
||||
async fn persisted_workflow(dot: &str, storage_root: &Path) -> (Persisted, Arc<Database>) {
|
||||
persisted_workflow_with_settings(
|
||||
dot,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,33 @@ fn run_intent_none_target_round_trips_the_openapi_shape() {
|
|||
assert_eq!(serde_json::to_value(api).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_intent_folder_target_round_trips_the_openapi_shape() {
|
||||
let intent = RunIntent {
|
||||
workflow_version_id: test_support::test_workflow_version_id(),
|
||||
target: RunTarget::Folder {
|
||||
path: "/srv/fabro/workspaces/example".to_string(),
|
||||
},
|
||||
args: RunIntentArgs::default(),
|
||||
environment_id: Some("local".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": "folder",
|
||||
"path": "/srv/fabro/workspaces/example",
|
||||
})
|
||||
);
|
||||
assert_eq!(serde_json::to_value(api).unwrap(), value);
|
||||
}
|
||||
|
||||
fn assert_same_type<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
|
|
|
|||
|
|
@ -52,10 +52,14 @@ pub enum RunTarget {
|
|||
sha: Option<String>,
|
||||
},
|
||||
None {},
|
||||
Folder {
|
||||
path: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl RunTarget {
|
||||
/// The wire `kind` discriminator (`git`, `none`), for diagnostics.
|
||||
/// The wire `kind` discriminator (`git`, `none`, or `folder`), for
|
||||
/// diagnostics.
|
||||
pub fn kind_name(&self) -> &'static str {
|
||||
self.into()
|
||||
}
|
||||
|
|
@ -63,7 +67,8 @@ impl RunTarget {
|
|||
/// Validates and canonicalizes the target without any network resolution.
|
||||
///
|
||||
/// Git targets include their derived operational Git projection. Targets
|
||||
/// without a repository return no projection.
|
||||
/// without a repository return no projection. Folder paths require
|
||||
/// filesystem validation and canonicalization during provider admission.
|
||||
pub fn validate(self) -> Result<ValidatedRunTarget, TargetValidationError> {
|
||||
match self {
|
||||
Self::Git { repo, branch, sha } => {
|
||||
|
|
@ -101,6 +106,10 @@ impl RunTarget {
|
|||
target: Self::None {},
|
||||
git: None,
|
||||
}),
|
||||
Self::Folder { path } => Ok(ValidatedRunTarget {
|
||||
target: Self::Folder { path },
|
||||
git: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,6 +71,42 @@ fn run_intent_none_target_rejects_unknown_fields() {
|
|||
assert!(serde_json::from_value::<RunIntent>(value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_intent_round_trips_the_strict_folder_shape() {
|
||||
let mut intent = intent();
|
||||
intent.target = RunTarget::Folder {
|
||||
path: "/srv/fabro/workspaces/example".to_string(),
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&intent).expect("intent should serialize");
|
||||
|
||||
assert_eq!(
|
||||
value["target"],
|
||||
json!({
|
||||
"kind": "folder",
|
||||
"path": "/srv/fabro/workspaces/example",
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<RunIntent>(value).expect("intent should deserialize"),
|
||||
intent
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_intent_folder_target_rejects_unknown_and_missing_fields() {
|
||||
let mut value = serde_json::to_value(intent()).expect("intent should serialize");
|
||||
value["target"] = json!({
|
||||
"kind": "folder",
|
||||
"path": "/srv/fabro/workspaces/example",
|
||||
"unexpected": true,
|
||||
});
|
||||
assert!(serde_json::from_value::<RunIntent>(value.clone()).is_err());
|
||||
|
||||
value["target"] = json!({ "kind": "folder" });
|
||||
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");
|
||||
|
|
@ -152,6 +188,18 @@ fn run_intent_none_target_validates_without_a_git_projection() {
|
|||
assert_eq!(validated.git, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_intent_folder_target_is_preserved_for_provider_admission() {
|
||||
let target = RunTarget::Folder {
|
||||
path: "/srv/fabro/workspaces/example".to_string(),
|
||||
};
|
||||
|
||||
let validated = target.clone().validate().unwrap();
|
||||
|
||||
assert_eq!(validated.target, target);
|
||||
assert_eq!(validated.git, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_validation_rejects_invalid_grammar() {
|
||||
use fabro_types::TargetValidationError;
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ models/failure-detail.ts
|
|||
models/failure-reason.ts
|
||||
models/file-checkpoint.ts
|
||||
models/file-diff.ts
|
||||
models/folder-run-target.ts
|
||||
models/fork-request.ts
|
||||
models/fork-response.ts
|
||||
models/fork-source-ref.ts
|
||||
|
|
|
|||
32
lib/packages/fabro-api-client/src/models/folder-run-target.ts
generated
Normal file
32
lib/packages/fabro-api-client/src/models/folder-run-target.ts
generated
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/* 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.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Existing directory on the Fabro server, executed in place by a Local environment. The submitted path must be absolute and name an existing directory; Fabro resolves symlinks and persists its canonical UTF-8 path. This target is intended for trusted single-tenant deployments. Docker and Daytona environments always reject it. This target does not add Local Git cloning or Local scratch workspaces. Folder runs execute in place without Fabro Git checkpoints, so fork and rewind are unavailable.
|
||||
*/
|
||||
export interface FolderRunTarget {
|
||||
'kind': FolderRunTargetKindEnum;
|
||||
/**
|
||||
* Absolute path on the Fabro server, not on the API caller\'s machine.
|
||||
*/
|
||||
'path': string;
|
||||
}
|
||||
|
||||
export const FolderRunTargetKindEnum = {
|
||||
FOLDER: 'folder'
|
||||
} as const;
|
||||
|
||||
export type FolderRunTargetKindEnum = typeof FolderRunTargetKindEnum[keyof typeof FolderRunTargetKindEnum];
|
||||
|
|
@ -123,6 +123,7 @@ export * from './failure-detail';
|
|||
export * from './failure-reason';
|
||||
export * from './file-checkpoint';
|
||||
export * from './file-diff';
|
||||
export * from './folder-run-target';
|
||||
export * from './fork-request';
|
||||
export * from './fork-response';
|
||||
export * from './fork-source-ref';
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { FolderRunTarget } from './folder-run-target';
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { GitRunTarget } from './git-run-target';
|
||||
|
|
@ -24,4 +27,4 @@ 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;
|
||||
export type RunTarget = { kind: 'folder' } & FolderRunTarget | { kind: 'git' } & GitRunTarget | { kind: 'none' } & NoneRunTarget;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue