diff --git a/apps/fabro-web/app/routes/automation-detail.tsx b/apps/fabro-web/app/routes/automation-detail.tsx index be7c7dcc5..fe2ce49a1 100644 --- a/apps/fabro-web/app/routes/automation-detail.tsx +++ b/apps/fabro-web/app/routes/automation-detail.tsx @@ -62,6 +62,9 @@ function sampleSettings({ prepare: { commands: prepareCommands, timeout_ms: 120_000 }, execution: { mode: "normal", approval: "prompt" }, checkpoint: { exclude_globs: [] }, + clone: { enabled: true }, + run_branch: { enabled: true, push: true }, + meta_branch: { enabled: true, push: true }, sandbox: { provider: "daytona", preserve: false, @@ -80,7 +83,6 @@ function sampleSettings({ dockerfile: null, }, network: null, - skip_clone: false, }, }, notifications: {}, diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 1c766be53..c4ee7d38b 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -8654,6 +8654,9 @@ components: - prepare - execution - checkpoint + - clone + - run_branch + - meta_branch - sandbox - notifications - interviews @@ -8688,6 +8691,12 @@ components: $ref: "#/components/schemas/RunExecutionSettings" checkpoint: $ref: "#/components/schemas/RunCheckpointSettings" + clone: + $ref: "#/components/schemas/RunCloneSettings" + run_branch: + $ref: "#/components/schemas/RunBranchSettings" + meta_branch: + $ref: "#/components/schemas/RunMetaBranchSettings" sandbox: $ref: "#/components/schemas/RunSandboxSettings" notifications: @@ -8826,6 +8835,31 @@ components: items: type: string + RunCloneSettings: + type: object + required: [enabled] + properties: + enabled: + type: boolean + + RunBranchSettings: + type: object + required: [enabled, push] + properties: + enabled: + type: boolean + push: + type: boolean + + RunMetaBranchSettings: + type: object + required: [enabled, push] + properties: + enabled: + type: boolean + push: + type: boolean + RunSandboxSettings: type: object required: [provider, preserve, stop_on_terminal, devcontainer, env, docker, daytona] @@ -8853,7 +8887,7 @@ components: DockerSettings: type: object - required: [image, network_mode, memory_limit, cpu_quota, env_vars, skip_clone] + required: [image, network_mode, memory_limit, cpu_quota, env_vars] properties: image: type: string @@ -8869,12 +8903,10 @@ components: type: object additionalProperties: $ref: "#/components/schemas/InterpString" - skip_clone: - type: boolean DaytonaSettings: type: object - required: [auto_stop_interval, labels, snapshot, network, skip_clone] + required: [auto_stop_interval, labels, snapshot, network] properties: auto_stop_interval: type: ["integer", "null"] @@ -8889,8 +8921,6 @@ components: oneOf: - $ref: "#/components/schemas/DaytonaNetworkLayer" - type: "null" - skip_clone: - type: boolean DaytonaSnapshotSettings: type: object diff --git a/docs/public/changelog/2026-04-26.mdx b/docs/public/changelog/2026-04-26.mdx index c6e930405..100334f56 100644 --- a/docs/public/changelog/2026-04-26.mdx +++ b/docs/public/changelog/2026-04-26.mdx @@ -8,7 +8,7 @@ date: "2026-04-26" To migrate: 1. Use `--sandbox local` or `[run.sandbox] provider = "local"` when a run must operate directly on the host working tree. -2. For Docker or Daytona runs without a GitHub origin, set `skip_clone = true` under the provider-specific sandbox section. +2. For Docker or Daytona runs without a GitHub origin, set `[run.clone] enabled = false`. 3. Make sure private GitHub repositories have GitHub App credentials configured before running clone-based sandboxes. @@ -20,8 +20,8 @@ This is a breaking sandbox change: - Docker no longer bind-mounts host workspaces. - Docker and Daytona accept GitHub origins only when cloning. -- Present non-GitHub origins fail unless `skip_clone = true`. -- Absent origins and `skip_clone = true` runs create empty workspaces without repository files. +- Present non-GitHub origins fail unless `[run.clone] enabled = false`. +- Absent origins and `[run.clone] enabled = false` runs create empty workspaces without repository files. The packaged Docker Compose service mounts the host Docker socket so the server can create sibling run containers. Operators remain responsible for Docker socket permissions, Docker Desktop behavior, `DOCKER_HOST`, TLS, and remote daemon security. diff --git a/docs/public/execution/environments.mdx b/docs/public/execution/environments.mdx index 2a988d564..b29cbbe8a 100644 --- a/docs/public/execution/environments.mdx +++ b/docs/public/execution/environments.mdx @@ -65,7 +65,7 @@ The Docker sandbox runs all tool operations inside a Docker container. Docker is - **Container lifecycle** — On `initialize()`, Fabro pulls the image (if needed), creates a container with `sleep infinity`, and starts it. On `cleanup()`, Fabro stops and removes the container. - **Working directory** — Docker runs use a provider-owned workspace inside the container. When a run has a GitHub origin, Fabro clones that repository into the workspace instead of bind-mounting the host source tree. -- **Git clone** — Docker and Daytona accept GitHub origins for automatic cloning. If the run has a present non-GitHub origin, set `skip_clone = true` or switch to `local`. +- **Git clone** — Docker and Daytona accept GitHub origins for automatic cloning. If the run has a present non-GitHub origin, set `[run.clone] enabled = false` or switch to `local`. - **Commands** — Executed via `docker exec` with `/bin/bash -c` inside the container. Timeout and cancellation are supported. - **File writes** — Use the Docker API's tar upload to avoid shell escaping issues with special characters. - **Platform detection** — The container's `uname -r` is cached at startup. @@ -83,7 +83,6 @@ image = "buildpack-deps:noble" network_mode = "bridge" memory_limit = "4GB" cpu_quota = 200000 -skip_clone = false ``` | Setting | Default | Description | @@ -92,9 +91,8 @@ skip_clone = false | `network_mode` | `bridge` | Docker network mode | | `memory_limit` | `4GB` | Memory limit | | `cpu_quota` | `200000` | CPU quota (microseconds per 100ms period) | -| `skip_clone` | `false` | Create an empty workspace instead of cloning the run's GitHub origin | -When `skip_clone = true`, the sandbox starts with an empty provider workspace. Use [prepare steps](/execution/run-configuration#runprepare) to clone or create any files the workflow needs. +Set `[run.clone] enabled = false` to start with an empty provider workspace instead of cloning the run's GitHub origin. Use [prepare steps](/execution/run-configuration#runprepare) to clone or create any files the workflow needs. ### Preserving the container @@ -129,7 +127,7 @@ The Daytona API key must include `write:snapshots`, `delete:snapshots`, `write:s - **Sandbox lifecycle** — On `initialize()`, Fabro creates a Daytona sandbox (from an image or a snapshot), clones the run's GitHub origin into it when one is available, and waits until it's ready. On `cleanup()`, the sandbox is deleted. - **Working directory** — Fixed at `/home/daytona/workspace`. GitHub-origin runs clone the repository there automatically. -- **Git clone** — Fabro detects the run manifest's GitHub origin URL and branch, converts SSH URLs to HTTPS, and clones into the sandbox. For private repositories, Fabro uses a GitHub App Installation Access Token scoped to the specific repository. Public repositories are cloned without credentials. Set `skip_clone = true` to create an empty workspace instead. +- **Git clone** — Fabro detects the run manifest's GitHub origin URL and branch, converts SSH URLs to HTTPS, and clones into the sandbox. For private repositories, Fabro uses a GitHub App Installation Access Token scoped to the specific repository. Public repositories are cloned without credentials. Set `[run.clone] enabled = false` to create an empty workspace instead. - **Commands** — Executed via the Daytona process API. Commands are base64-encoded and piped through `sh` to support pipes, environment variables, and other shell features. - **Ephemeral** — Sandboxes are created with `ephemeral: true` and a unique timestamped name (e.g. `fabro-20260305-142301-a3f2`). @@ -143,7 +141,6 @@ provider = "daytona" [run.sandbox.daytona] auto_stop_interval = 60 -skip_clone = false [run.sandbox.daytona.snapshot] name = "rust-dev" diff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx index aceef5f3a..ee42f7a25 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -157,6 +157,47 @@ script = "npm install" Each step must exit with status 0. If any step fails, the run aborts before the workflow starts. Prepare steps replace across layers — the higher-precedence layer wins wholesale. +### `[run.clone]` + +Configure whether clone-based sandboxes clone the run's GitHub origin before execution. + +```toml title="run.toml" +[run.clone] +enabled = true +``` + +Set `enabled = false` to start Docker and Daytona runs with an empty provider workspace. Use [prepare steps](#runprepare) to clone or create any files the workflow needs. + +### `[run.run_branch]` + +Configure Fabro's managed `fabro/run/` checkpoint branch. + +```toml title="run.toml" +[run.run_branch] +enabled = true +push = true +``` + +| Field | Description | +|---|---| +| `enabled` | When `false`, Fabro does not create the managed run branch or checkpoint commits. This also disables metadata branch writes. | +| `push` | When `false`, Fabro creates local checkpoint commits but does not push `fabro/run/` to the remote. | + +### `[run.meta_branch]` + +Configure Fabro's managed `fabro/meta/` metadata branch. + +```toml title="run.toml" +[run.meta_branch] +enabled = true +push = true +``` + +| Field | Description | +|---|---| +| `enabled` | When `false`, Fabro skips metadata branch snapshots. | +| `push` | When `false`, Fabro writes metadata snapshots locally but does not push `fabro/meta/` to the remote. | + ### `[run.sandbox]` Configure how agent tools (bash, file edits) are executed. @@ -186,7 +227,6 @@ image = "buildpack-deps:noble" network_mode = "bridge" memory_limit = "4GB" cpu_quota = 200000 -skip_clone = false ``` | Field | Description | @@ -196,7 +236,6 @@ skip_clone = false | `memory_limit` | Memory limit using human-readable units such as `"4GB"` or `"512MiB"`. | | `cpu_quota` | Docker CPU quota in microseconds per 100ms period. Defaults to `200000`. | | `env_vars` | Provider-level environment variables passed to the Docker container. For workflow tool execution, prefer `[run.sandbox.env]`. | -| `skip_clone` | When `true`, start with an empty container workspace instead of cloning the run's GitHub origin. | #### `[run.sandbox.daytona]` @@ -230,7 +269,6 @@ dockerfile = "FROM rust:1.85-slim-bookworm\nRUN apt-get update" | `snapshot.disk` | Disk size using the same units as `memory`. | | `snapshot.dockerfile` | Dockerfile content (inline string) or path (`{ path = "..." }`) for building the snapshot image. Paths are resolved relative to the TOML file's directory. | | `network` | Network access mode: `"allow_all"` (default), `"block"`, or `{ allow_list = ["..."] }`. See [Sandboxing](/administration/sandboxing#network-access-control). | -| `skip_clone` | When `true`, start with an empty Daytona workspace instead of cloning the run's GitHub origin. | #### Local sandbox diff --git a/docs/public/integrations/daytona.mdx b/docs/public/integrations/daytona.mdx index b5d5c39d7..b84c1c108 100644 --- a/docs/public/integrations/daytona.mdx +++ b/docs/public/integrations/daytona.mdx @@ -44,7 +44,6 @@ preserve = false [run.sandbox.daytona] auto_stop_interval = 60 -skip_clone = false [run.sandbox.daytona.labels] project = "fabro" @@ -104,14 +103,14 @@ If a snapshot is configured by name but doesn't exist and no `dockerfile` is pro Fabro automatically clones the run's GitHub origin into the sandbox at `/home/daytona/workspace`. Public repositories work without extra configuration. Private repositories require GitHub access. In `token` mode, Fabro uses the stored token directly. In `app` mode, Fabro uses short-lived Installation Access Tokens scoped to the specific repository. -Set `skip_clone = true` in `[run.sandbox.daytona]` when a workflow should start with an empty Daytona workspace instead of cloning the run origin: +Set `[run.clone] enabled = false` when a workflow should start with an empty Daytona workspace instead of cloning the run origin: ```toml title="run.toml" [run.sandbox] provider = "daytona" -[run.sandbox.daytona] -skip_clone = true +[run.clone] +enabled = false ``` If the clone fails without GitHub access configured, Fabro suggests running the setup flow: diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index c1c568ab0..0995ae15d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -926,6 +926,9 @@ fn attach_json_errors_without_prompting_for_human_input() { "checkpoint": { "exclude_globs": [] }, + "clone": { + "enabled": true + }, "execution": { "approval": "prompt", "mode": "normal" @@ -948,6 +951,10 @@ fn attach_json_errors_without_prompting_for_human_input() { "provider": null, "slack": null }, + "meta_branch": { + "enabled": true, + "push": true + }, "metadata": {}, "model": { "controls": { @@ -964,6 +971,10 @@ fn attach_json_errors_without_prompting_for_human_input() { "timeout_ms": 300000 }, "pull_request": null, + "run_branch": { + "enabled": true, + "push": true + }, "sandbox": { "daytona": null, "devcontainer": false, @@ -972,8 +983,7 @@ fn attach_json_errors_without_prompting_for_human_input() { "env_vars": {}, "image": "buildpack-deps:noble", "memory_limit": 4000000000, - "network_mode": null, - "skip_clone": false + "network_mode": null }, "env": {}, "preserve": false, diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs index 141790bef..844104f09 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs @@ -148,6 +148,17 @@ fn inspect_resolves_selector_via_server_endpoint() { "checkpoint": { "exclude_globs": [] }, + "clone": { + "enabled": true + }, + "run_branch": { + "enabled": true, + "push": true + }, + "meta_branch": { + "enabled": true, + "push": true + }, "sandbox": { "provider": "local", "preserve": false, diff --git a/lib/crates/fabro-config/src/defaults.toml b/lib/crates/fabro-config/src/defaults.toml index 718046eed..c7e360a65 100644 --- a/lib/crates/fabro-config/src/defaults.toml +++ b/lib/crates/fabro-config/src/defaults.toml @@ -12,6 +12,17 @@ approval = "prompt" [run.prepare] timeout = "5m" +[run.clone] +enabled = true + +[run.run_branch] +enabled = true +push = true + +[run.meta_branch] +enabled = true +push = true + [run.sandbox] provider = "docker" preserve = false @@ -22,7 +33,6 @@ devcontainer = false image = "buildpack-deps:noble" memory_limit = "4GB" cpu_quota = 200000 -skip_clone = false [cli.output] format = "text" diff --git a/lib/crates/fabro-config/src/layers/mod.rs b/lib/crates/fabro-config/src/layers/mod.rs index 4ff4cc4c2..ab739b75a 100644 --- a/lib/crates/fabro-config/src/layers/mod.rs +++ b/lib/crates/fabro-config/src/layers/mod.rs @@ -30,9 +30,10 @@ pub use run::{ GitAuthorLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, InterviewsLayer, McpEntryLayer, ModelRefOrSplice, NotificationProviderLayer, NotificationRouteLayer, PrepareStep, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, - RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, - RunLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, - RunSandboxLayer, RunScmLayer, ScmGitHubLayer, StringOrSplice, + RunCloneLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, + RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, + RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunSandboxLayer, RunScmLayer, + ScmGitHubLayer, StringOrSplice, }; pub use server::{ GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, diff --git a/lib/crates/fabro-config/src/layers/run.rs b/lib/crates/fabro-config/src/layers/run.rs index 65fa7434d..f2b5a2fe2 100644 --- a/lib/crates/fabro-config/src/layers/run.rs +++ b/lib/crates/fabro-config/src/layers/run.rs @@ -36,6 +36,12 @@ pub struct RunLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub checkpoint: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub clone: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub meta_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub sandbox: Option, #[serde(default, skip_serializing_if = "MergeMap::is_empty")] pub notifications: MergeMap, @@ -278,6 +284,34 @@ pub struct RunCheckpointLayer { pub exclude_globs: Vec, } +/// `[run.clone]` — source workspace clone policy. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct RunCloneLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, +} + +/// `[run.run_branch]` — Fabro-managed checkpoint branch policy. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct RunRunBranchLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub push: Option, +} + +/// `[run.meta_branch]` — Fabro-managed checkpoint metadata branch policy. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct RunMetaBranchLayer { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub push: Option, +} + /// `[run.sandbox]` — sandbox selection and execution-environment surface. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] @@ -312,8 +346,6 @@ pub struct DockerSandboxLayer { pub cpu_quota: Option, #[serde(default, skip_serializing_if = "StickyMap::is_empty")] pub env_vars: StickyMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub skip_clone: Option, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] @@ -328,8 +360,6 @@ pub struct DaytonaSandboxLayer { pub snapshot: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub network: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub skip_clone: Option, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 7e4800e28..c3e67da69 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -47,13 +47,14 @@ pub use layers::{ ModelCostTable, ModelRefOrSplice, ModelSettings, NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, PrepareStep, ProjectLayer, ProviderSettings, ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, - RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, - RunLayer, RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, - RunSandboxLayer, RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer, - ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer, - ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer, - ServerSchedulerLayer, ServerSlateDbLayer, ServerStorageLayer, ServerWebLayer, - SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer, + RunCloneLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, + RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, + RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunSandboxLayer, RunScmLayer, + ScmGitHubLayer, ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer, + ServerIntegrationsLayer, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerLayer, + ServerListenLayer, ServerLoggingLayer, ServerSchedulerLayer, ServerSlateDbLayer, + ServerStorageLayer, ServerWebLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, + WorkflowLayer, }; pub(crate) use layers::{Combine, SettingsLayer}; pub use logging::{resolve_log_destination, resolve_log_destination_with_env}; diff --git a/lib/crates/fabro-config/src/resolve/run.rs b/lib/crates/fabro-config/src/resolve/run.rs index 2debd724d..454ad5611 100644 --- a/lib/crates/fabro-config/src/resolve/run.rs +++ b/lib/crates/fabro-config/src/resolve/run.rs @@ -3,10 +3,11 @@ use fabro_types::settings::run::{ ArtifactsSettings, DaytonaSettings, DaytonaSnapshotSettings, DockerSettings, DockerfileSource, GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings, McpServerSettings, McpTransport, MergeStrategy, NotificationProviderSettings, NotificationRouteSettings, - PullRequestSettings, RunAgentSettings, RunCheckpointSettings, RunExecutionSettings, - RunGitSettings, RunGoal, RunIntegrationsGithubSettings, RunIntegrationsSettings, - RunInterviewsSettings, RunModelControls, RunModelSettings, RunNamespace, RunPrepareSettings, - RunSandboxSettings, RunScmSettings, ScmGitHubSettings, TlsMode, + PullRequestSettings, RunAgentSettings, RunBranchSettings, RunCheckpointSettings, + RunCloneSettings, RunExecutionSettings, RunGitSettings, RunGoal, RunIntegrationsGithubSettings, + RunIntegrationsSettings, RunInterviewsSettings, RunMetaBranchSettings, RunModelControls, + RunModelSettings, RunNamespace, RunPrepareSettings, RunSandboxSettings, RunScmSettings, + ScmGitHubSettings, TlsMode, }; use super::ResolveError; @@ -14,40 +15,68 @@ use crate::{ DaytonaDockerfileLayer, DaytonaSandboxLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, InterviewsLayer, McpEntryLayer, ModelRefOrSplice, NotificationProviderLayer, NotificationRouteLayer, RunAgentLayer, RunArtifactsLayer, - RunCheckpointLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsLayer, - RunLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunSandboxLayer, RunScmLayer, - StringOrSplice, + RunCheckpointLayer, RunCloneLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, + RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelLayer, RunPrepareLayer, + RunPullRequestLayer, RunRunBranchLayer, RunSandboxLayer, RunScmLayer, StringOrSplice, }; pub fn resolve_run(layer: &RunLayer, errors: &mut Vec) -> RunNamespace { + let clone = resolve_clone(layer.clone.as_ref()); + let run_branch = resolve_run_branch(layer.run_branch.as_ref()); + let mut meta_branch = resolve_meta_branch(layer.meta_branch.as_ref()); + if !run_branch.enabled { + if meta_branch.enabled || meta_branch.push { + tracing::debug!( + run_branch_enabled = run_branch.enabled, + "Disabling metadata branch because run branch is disabled" + ); + } + meta_branch = RunMetaBranchSettings { + enabled: false, + push: false, + }; + } + let pull_request = resolve_pull_request(layer.pull_request.as_ref()); + if pull_request.is_some() && (!run_branch.enabled || !run_branch.push) { + errors.push(ResolveError::Invalid { + path: "run.pull_request".to_string(), + reason: "run.pull_request.enabled requires run.run_branch.enabled and \ + run.run_branch.push" + .to_string(), + }); + } + RunNamespace { - goal: resolve_goal(layer.goal.as_ref()), - working_dir: layer.working_dir.clone(), - metadata: layer.metadata.clone().into_inner(), - inputs: layer.inputs.clone().unwrap_or_default(), - model: resolve_model(layer.model.as_ref()), - git: resolve_git(layer.git.as_ref()), - prepare: resolve_prepare(layer.prepare.as_ref(), errors), - execution: resolve_execution(layer.execution.as_ref()), - checkpoint: resolve_checkpoint(layer.checkpoint.as_ref()), - sandbox: resolve_sandbox(layer.sandbox.as_ref(), errors), + goal: resolve_goal(layer.goal.as_ref()), + working_dir: layer.working_dir.clone(), + metadata: layer.metadata.clone().into_inner(), + inputs: layer.inputs.clone().unwrap_or_default(), + model: resolve_model(layer.model.as_ref()), + git: resolve_git(layer.git.as_ref()), + prepare: resolve_prepare(layer.prepare.as_ref(), errors), + execution: resolve_execution(layer.execution.as_ref()), + checkpoint: resolve_checkpoint(layer.checkpoint.as_ref()), + clone, + run_branch, + meta_branch, + sandbox: resolve_sandbox(layer.sandbox.as_ref(), errors), notifications: layer .notifications .iter() .map(|(name, route)| (name.clone(), resolve_notification_route(route))) .collect(), - interviews: resolve_interviews(layer.interviews.as_ref()), - agent: resolve_agent(layer.agent.as_ref()), - hooks: layer + interviews: resolve_interviews(layer.interviews.as_ref()), + agent: resolve_agent(layer.agent.as_ref()), + hooks: layer .hooks .iter() .enumerate() .map(|(index, hook)| resolve_hook(hook, index, errors)) .collect(), - scm: resolve_scm(layer.scm.as_ref()), - pull_request: resolve_pull_request(layer.pull_request.as_ref()), - artifacts: resolve_artifacts(layer.artifacts.as_ref()), - integrations: resolve_integrations(layer.integrations.as_ref()), + scm: resolve_scm(layer.scm.as_ref()), + pull_request, + artifacts: resolve_artifacts(layer.artifacts.as_ref()), + integrations: resolve_integrations(layer.integrations.as_ref()), } } @@ -161,6 +190,34 @@ fn resolve_checkpoint(checkpoint: Option<&RunCheckpointLayer>) -> RunCheckpointS } } +fn resolve_clone(clone: Option<&RunCloneLayer>) -> RunCloneSettings { + RunCloneSettings { + enabled: clone.and_then(|clone| clone.enabled).unwrap_or(true), + } +} + +fn resolve_run_branch(run_branch: Option<&RunRunBranchLayer>) -> RunBranchSettings { + RunBranchSettings { + enabled: run_branch + .and_then(|run_branch| run_branch.enabled) + .unwrap_or(true), + push: run_branch + .and_then(|run_branch| run_branch.push) + .unwrap_or(true), + } +} + +fn resolve_meta_branch(meta_branch: Option<&RunMetaBranchLayer>) -> RunMetaBranchSettings { + RunMetaBranchSettings { + enabled: meta_branch + .and_then(|meta_branch| meta_branch.enabled) + .unwrap_or(true), + push: meta_branch + .and_then(|meta_branch| meta_branch.push) + .unwrap_or(true), + } +} + fn resolve_sandbox( sandbox: Option<&RunSandboxLayer>, errors: &mut Vec, @@ -205,7 +262,6 @@ fn resolve_docker(docker: &crate::DockerSandboxLayer) -> DockerSettings { .and_then(|size| i64::try_from(size.as_bytes()).ok()), cpu_quota: docker.cpu_quota, env_vars: docker.env_vars.clone().into_inner(), - skip_clone: docker.skip_clone.unwrap_or(false), } } @@ -233,7 +289,6 @@ fn resolve_daytona(daytona: &DaytonaSandboxLayer) -> DaytonaSettings { }) }), network: daytona.network.clone(), - skip_clone: daytona.skip_clone.unwrap_or(false), } } diff --git a/lib/crates/fabro-config/src/tests/resolve_run.rs b/lib/crates/fabro-config/src/tests/resolve_run.rs index 7f3def838..9024c0d5f 100644 --- a/lib/crates/fabro-config/src/tests/resolve_run.rs +++ b/lib/crates/fabro-config/src/tests/resolve_run.rs @@ -53,10 +53,120 @@ fn resolves_run_defaults_from_empty_settings() { assert_eq!(docker.image, "buildpack-deps:noble"); assert_eq!(docker.memory_limit, Some(4_000_000_000)); assert_eq!(docker.cpu_quota, Some(200_000)); - assert!(!docker.skip_clone); + assert!(settings.clone.enabled); + assert!(settings.run_branch.enabled); + assert!(settings.run_branch.push); + assert!(settings.meta_branch.enabled); + assert!(settings.meta_branch.push); assert!(settings.pull_request.is_none()); } +#[test] +fn resolves_run_level_clone_branch_controls() { + let settings = WorkflowSettingsBuilder::from_toml( + r" +_version = 1 + +[run.clone] +enabled = false + +[run.run_branch] +enabled = true +push = false + +[run.meta_branch] +enabled = true +push = false +", + ) + .expect("run branch controls should resolve") + .run; + + assert!(!settings.clone.enabled); + assert!(settings.run_branch.enabled); + assert!(!settings.run_branch.push); + assert!(settings.meta_branch.enabled); + assert!(!settings.meta_branch.push); +} + +#[test] +fn disabling_run_branch_forces_meta_branch_off() { + let settings = WorkflowSettingsBuilder::from_toml( + r" +_version = 1 + +[run.run_branch] +enabled = false + +[run.meta_branch] +enabled = true +push = true +", + ) + .expect("run branch disabled should resolve") + .run; + + assert!(!settings.run_branch.enabled); + assert!(!settings.meta_branch.enabled); + assert!(!settings.meta_branch.push); +} + +#[test] +fn pull_request_requires_pushed_run_branch() { + let disabled_branch = WorkflowSettingsBuilder::from_toml( + r" +_version = 1 + +[run.run_branch] +enabled = false + +[run.pull_request] +enabled = true +", + ) + .expect_err("pull requests require an enabled pushed run branch"); + let message = disabled_branch.to_string(); + assert!( + message.contains("run.pull_request.enabled requires run.run_branch.enabled"), + "expected run branch validation error, got: {message}" + ); + + let disabled_push = WorkflowSettingsBuilder::from_toml( + r" +_version = 1 + +[run.run_branch] +push = false + +[run.pull_request] +enabled = true +", + ) + .expect_err("pull requests require run branch push"); + let message = disabled_push.to_string(); + assert!( + message.contains("run.pull_request.enabled requires run.run_branch.enabled"), + "expected run branch push validation error, got: {message}" + ); +} + +#[test] +fn provider_skip_clone_is_rejected() { + let err = r" +_version = 1 + +[run.sandbox.docker] +skip_clone = true +" + .parse::() + .expect_err("provider-level skip_clone should be unknown"); + let message = err.to_string(); + assert!( + message.contains("skip_clone") || message.contains("unknown field"), + "expected unknown-field error mentioning skip_clone, got: {message}" + ); +} + #[test] fn resolved_run_chat_surfaces_are_slack_only() { let settings = WorkflowSettingsBuilder::from_toml( diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index ced4bf19a..43ef520f7 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -1675,7 +1675,6 @@ mod runs { dockerfile: None, }), network: None, - skip_clone: false, }), }, ..RunNamespace::default() diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 2178196ee..e5cd9f51f 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -564,16 +564,26 @@ fn resolve_sandbox_provider(settings: &RunNamespace) -> Result .unwrap_or_default()) } -fn resolve_daytona_config(settings: &RunNamespace) -> Option { - settings +fn resolve_daytona_config(settings: &RunNamespace) -> DaytonaConfig { + let mut config = settings .sandbox .daytona .as_ref() - .map(runtime_daytona_config) + .map(|daytona| runtime_daytona_config(daytona, !settings.clone.enabled)) + .unwrap_or_default(); + config.skip_clone = !settings.clone.enabled; + config } -fn resolve_docker_config(settings: &RunNamespace) -> Option { - settings.sandbox.docker.as_ref().map(runtime_docker_config) +fn resolve_docker_config(settings: &RunNamespace) -> DockerSandboxOptions { + let mut config = settings + .sandbox + .docker + .as_ref() + .map(|docker| runtime_docker_config(docker, !settings.clone.enabled)) + .unwrap_or_default(); + config.skip_clone = !settings.clone.enabled; + config } #[derive(Clone, Debug, PartialEq, Eq)] @@ -584,16 +594,7 @@ struct GitRemoteRefCheck { fn clone_disabled_for_provider(provider: SandboxProvider, resolved_run: &RunNamespace) -> bool { match provider { - SandboxProvider::Docker => resolved_run - .sandbox - .docker - .as_ref() - .is_some_and(|docker| docker.skip_clone), - SandboxProvider::Daytona => resolved_run - .sandbox - .daytona - .as_ref() - .is_some_and(|daytona| daytona.skip_clone), + SandboxProvider::Docker | SandboxProvider::Daytona => !resolved_run.clone.enabled, SandboxProvider::Local => false, } } @@ -759,7 +760,7 @@ fn preflight_sandbox_spec( working_directory: prepared.source_directory.clone(), }, SandboxProvider::Docker => { - let mut config = resolve_docker_config(resolved_run).unwrap_or_default(); + let mut config = resolve_docker_config(resolved_run); config.skip_clone = true; SandboxSpec::Docker { config, @@ -770,7 +771,7 @@ fn preflight_sandbox_spec( } } SandboxProvider::Daytona => { - let mut config = resolve_daytona_config(resolved_run).unwrap_or_default(); + let mut config = resolve_daytona_config(resolved_run); config.skip_clone = true; SandboxSpec::Daytona { config: Box::new(config), @@ -1083,11 +1084,11 @@ fn resolve_model_provider( } } -fn runtime_daytona_config(settings: &DaytonaSettings) -> DaytonaConfig { +fn runtime_daytona_config(settings: &DaytonaSettings, skip_clone: bool) -> DaytonaConfig { DaytonaConfig { auto_stop_interval: settings.auto_stop_interval, - labels: (!settings.labels.is_empty()).then_some(settings.labels.clone()), - snapshot: settings + labels: (!settings.labels.is_empty()).then_some(settings.labels.clone()), + snapshot: settings .snapshot .as_ref() .map(|snapshot| DaytonaSnapshotSettings { @@ -1107,18 +1108,18 @@ fn runtime_daytona_config(settings: &DaytonaSettings) -> DaytonaConfig { } }), }), - network: settings.network.as_ref().map(|network| match network { + network: settings.network.as_ref().map(|network| match network { DaytonaNetworkLayer::Block => DaytonaNetwork::Block, DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll, DaytonaNetworkLayer::AllowList { allow_list } => { DaytonaNetwork::AllowList(allow_list.clone()) } }), - skip_clone: settings.skip_clone, + skip_clone, } } -fn runtime_docker_config(settings: &DockerSettings) -> DockerSandboxOptions { +fn runtime_docker_config(settings: &DockerSettings, skip_clone: bool) -> DockerSandboxOptions { let mut env_vars = settings .env_vars .iter() @@ -1132,7 +1133,7 @@ fn runtime_docker_config(settings: &DockerSettings) -> DockerSandboxOptions { memory_limit: settings.memory_limit, cpu_quota: settings.cpu_quota, env_vars, - skip_clone: settings.skip_clone, + skip_clone, ..DockerSandboxOptions::default() } } @@ -1377,7 +1378,7 @@ mod tests { fn prepared_and_resolved_for_sandbox( provider: SandboxProvider, - skip_clone: bool, + clone_enabled: bool, git: Option, ) -> (PreparedManifest, RunNamespace) { let mut manifest = minimal_manifest(); @@ -1391,8 +1392,8 @@ _version = 1 [run.sandbox] provider = "{provider}" -[run.sandbox.{provider}] -skip_clone = {skip_clone} +[run.clone] +enabled = {clone_enabled} "# )), type_: types::ManifestConfigType::Project, @@ -1419,7 +1420,7 @@ skip_clone = {skip_clone} async fn repository_access_check_skips_when_clone_is_disabled() { let (prepared, resolved) = prepared_and_resolved_for_sandbox( SandboxProvider::Docker, - true, + false, Some(git_context("https://github.com/acme/widgets", "main")), ); let calls = Arc::new(std::sync::Mutex::new(Vec::new())); @@ -1448,7 +1449,7 @@ skip_clone = {skip_clone} async fn repository_access_check_rejects_non_github_origins_before_remote_probe() { let (prepared, resolved) = prepared_and_resolved_for_sandbox( SandboxProvider::Docker, - false, + true, Some(git_context("https://gitlab.com/acme/widgets", "main")), ); let calls = Arc::new(std::sync::Mutex::new(Vec::new())); @@ -1486,7 +1487,7 @@ skip_clone = {skip_clone} async fn repository_access_check_probes_normalized_github_branch() { let (prepared, resolved) = prepared_and_resolved_for_sandbox( SandboxProvider::Docker, - false, + true, Some(git_context( "git@github.com:acme/widgets.git", "feature/demo", @@ -1523,7 +1524,7 @@ skip_clone = {skip_clone} async fn repository_access_check_surfaces_remote_probe_failure() { let (prepared, resolved) = prepared_and_resolved_for_sandbox( SandboxProvider::Docker, - false, + true, Some(git_context("https://github.com/acme/widgets", "missing")), ); let mut checks = Vec::new(); @@ -1555,7 +1556,7 @@ skip_clone = {skip_clone} fn preflight_sandbox_spec_disables_docker_clone_but_preserves_clone_metadata() { let (prepared, resolved) = prepared_and_resolved_for_sandbox( SandboxProvider::Docker, - false, + true, Some(git_context("https://github.com/acme/widgets", "main")), ); diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index 0650e6b39..d95a75226 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -17,7 +17,7 @@ use super::interp::InterpString; use super::model_ref::ModelRef; /// A structurally resolved `[run]` view for consumers. -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunNamespace { pub goal: Option, pub working_dir: Option, @@ -28,6 +28,9 @@ pub struct RunNamespace { pub prepare: RunPrepareSettings, pub execution: RunExecutionSettings, pub checkpoint: RunCheckpointSettings, + pub clone: RunCloneSettings, + pub run_branch: RunBranchSettings, + pub meta_branch: RunMetaBranchSettings, pub sandbox: RunSandboxSettings, pub notifications: HashMap, pub interviews: RunInterviewsSettings, @@ -39,6 +42,38 @@ pub struct RunNamespace { pub integrations: RunIntegrationsSettings, } +#[expect( + clippy::derivable_impls, + reason = "run defaults are product policy; keep the true-valued branch defaults visible here" +)] +impl Default for RunNamespace { + fn default() -> Self { + Self { + goal: None, + working_dir: None, + metadata: HashMap::new(), + inputs: HashMap::new(), + model: RunModelSettings::default(), + git: RunGitSettings::default(), + prepare: RunPrepareSettings::default(), + execution: RunExecutionSettings::default(), + checkpoint: RunCheckpointSettings::default(), + clone: RunCloneSettings::default(), + run_branch: RunBranchSettings::default(), + meta_branch: RunMetaBranchSettings::default(), + sandbox: RunSandboxSettings::default(), + notifications: HashMap::new(), + interviews: RunInterviewsSettings::default(), + agent: RunAgentSettings::default(), + hooks: Vec::new(), + scm: RunScmSettings::default(), + pull_request: None, + artifacts: ArtifactsSettings::default(), + integrations: RunIntegrationsSettings::default(), + } + } +} + /// `[run.integrations]` — run-level integration knobs. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct RunIntegrationsSettings { @@ -205,6 +240,47 @@ pub struct RunCheckpointSettings { pub exclude_globs: Vec, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunCloneSettings { + pub enabled: bool, +} + +impl Default for RunCloneSettings { + fn default() -> Self { + Self { enabled: true } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunBranchSettings { + pub enabled: bool, + pub push: bool, +} + +impl Default for RunBranchSettings { + fn default() -> Self { + Self { + enabled: true, + push: true, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunMetaBranchSettings { + pub enabled: bool, + pub push: bool, +} + +impl Default for RunMetaBranchSettings { + fn default() -> Self { + Self { + enabled: true, + push: true, + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunSandboxSettings { pub provider: String, @@ -242,7 +318,6 @@ pub struct DockerSettings { pub memory_limit: Option, pub cpu_quota: Option, pub env_vars: HashMap, - pub skip_clone: bool, } #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] @@ -251,7 +326,6 @@ pub struct DaytonaSettings { pub labels: HashMap, pub snapshot: Option, pub network: Option, - pub skip_clone: bool, } #[derive(Debug, Clone, PartialEq)] diff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs index c661c6bd5..a2a95c422 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/git.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs @@ -290,7 +290,9 @@ impl RunLifecycle for GitLifecycle { }; // Push run branch (skip in dry-run mode) - if !self.run_options.dry_run_enabled() { + if !self.run_options.dry_run_enabled() + && self.run_options.settings.run.run_branch.push + { if let Some(branch) = self .run_options .git @@ -1095,6 +1097,51 @@ mod tests { assert_eq!(diff_summary.deletions, 0); } + #[tokio::test] + async fn checkpoint_git_result_omits_push_when_run_branch_push_disabled() { + let repo_dir = tempfile::tempdir().unwrap(); + let repo = repo_dir.path(); + init_git_repo(repo); + tokio::fs::write(repo.join("notes.txt"), "checkpoint\n") + .await + .unwrap(); + + let mut options = run_options(repo, "fabro/metadata/run").as_ref().clone(); + options.settings.run.run_branch.push = false; + options.git = Some(GitCheckpointOptions { + base_sha: None, + run_branch: Some("fabro/run/test".to_string()), + meta_branch: None, + }); + let lifecycle = git_lifecycle_with_writer( + repo, + Arc::new(Emitter::new(fixtures::RUN_1)), + RunStoreHandle::local(run_store(fixtures::RUN_1).await), + Arc::new(options), + Arc::new(RunMetadataRuntime::new()), + None, + ); + let graph = workflow_graph(); + let node = graph.get_node("build").unwrap(); + let mut state = ExecutionState::new(&graph).unwrap(); + state.increment_visits("build"); + let result = WfNodeResult::new(Outcome::success(), Duration::from_millis(10), 1, 1); + + lifecycle + .on_checkpoint(&node, &result, Some("exit"), &state) + .await + .unwrap(); + + let git_result = lifecycle + .checkpoint_git_result + .lock() + .unwrap() + .clone() + .unwrap(); + assert!(git_result.commit_sha.is_some()); + assert!(git_result.push_results.is_empty()); + } + #[tokio::test] async fn degraded_metadata_runtime_skips_snapshot_events() { let repo_dir = tempfile::tempdir().unwrap(); diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 0cee89dbc..b69f33c08 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -287,13 +287,7 @@ impl RunSession { .state() .await .map_err(|err| Error::engine(err.to_string()))?; - let git = state.start.and_then(|start| { - start.run_branch.as_ref().map(|_| GitCheckpointOptions { - base_sha: start.base_sha.clone(), - run_branch: start.run_branch.clone(), - meta_branch: Some(metadata_branch_name(&record.run_id.to_string())), - }) - }); + let git = git_checkpoint_options_from_start(settings, &record.run_id, state.start); let definition_blob = state.spec.definition_blob; let accepted_definition = match definition_blob { Some(blob_id) => { @@ -353,7 +347,7 @@ impl RunSession { working_directory: working_directory.clone(), }, SandboxProvider::Docker => SandboxSpec::Docker { - config: resolve_docker_config(resolved).unwrap_or_default(), + config: resolve_docker_config(resolved), github_app: services.github_app.clone(), run_id: Some(record.run_id), clone_origin_url: record.repo_origin_url().map(str::to_string), @@ -369,7 +363,7 @@ impl RunSession { None => None, }; SandboxSpec::Daytona { - config: Box::new(resolve_daytona_config(resolved).unwrap_or_default()), + config: Box::new(resolve_daytona_config(resolved)), github_app: services.github_app.clone(), run_id: Some(record.run_id), clone_origin_url: record.repo_origin_url().map(str::to_string), @@ -459,6 +453,27 @@ fn resolve_interp(value: &InterpString) -> String { .map_or_else(|_| value.as_source(), |resolved| resolved.value) } +fn git_checkpoint_options_from_start( + settings: &fabro_types::WorkflowSettings, + run_id: &RunId, + start: Option, +) -> Option { + if !settings.run.run_branch.enabled { + return None; + } + + let start = start?; + start.run_branch.as_ref().map(|_| GitCheckpointOptions { + base_sha: start.base_sha.clone(), + run_branch: start.run_branch.clone(), + meta_branch: settings + .run + .meta_branch + .enabled + .then(|| metadata_branch_name(&run_id.to_string())), + }) +} + #[expect( clippy::disallowed_methods, reason = "Run startup interpolation owns a process-env lookup facade for {{ env.* }} values." @@ -492,16 +507,26 @@ fn resolve_sandbox_provider(settings: &ResolvedRunSettings) -> Result Option { - settings +fn resolve_daytona_config(settings: &ResolvedRunSettings) -> DaytonaConfig { + let mut config = settings .sandbox .daytona .as_ref() - .map(runtime_daytona_config) + .map(|daytona| runtime_daytona_config(daytona, !settings.clone.enabled)) + .unwrap_or_default(); + config.skip_clone = !settings.clone.enabled; + config } -fn resolve_docker_config(settings: &ResolvedRunSettings) -> Option { - settings.sandbox.docker.as_ref().map(runtime_docker_config) +fn resolve_docker_config(settings: &ResolvedRunSettings) -> DockerSandboxOptions { + let mut config = settings + .sandbox + .docker + .as_ref() + .map(|docker| runtime_docker_config(docker, !settings.clone.enabled)) + .unwrap_or_default(); + config.skip_clone = !settings.clone.enabled; + config } fn resolve_fallback_chain( @@ -554,11 +579,11 @@ fn runtime_mcp_server(settings: &ResolvedMcpServerSettings) -> McpServerSettings } } -fn runtime_daytona_config(settings: &DaytonaSettings) -> DaytonaConfig { +fn runtime_daytona_config(settings: &DaytonaSettings, skip_clone: bool) -> DaytonaConfig { DaytonaConfig { auto_stop_interval: settings.auto_stop_interval, - labels: (!settings.labels.is_empty()).then_some(settings.labels.clone()), - snapshot: settings + labels: (!settings.labels.is_empty()).then_some(settings.labels.clone()), + snapshot: settings .snapshot .as_ref() .map(|snapshot| DaytonaSnapshotSettings { @@ -578,18 +603,18 @@ fn runtime_daytona_config(settings: &DaytonaSettings) -> DaytonaConfig { } }), }), - network: settings.network.as_ref().map(|network| match network { + network: settings.network.as_ref().map(|network| match network { DaytonaNetworkLayer::Block => DaytonaNetwork::Block, DaytonaNetworkLayer::AllowAll => DaytonaNetwork::AllowAll, DaytonaNetworkLayer::AllowList { allow_list } => { DaytonaNetwork::AllowList(allow_list.clone()) } }), - skip_clone: settings.skip_clone, + skip_clone, } } -fn runtime_docker_config(settings: &DockerSettings) -> DockerSandboxOptions { +fn runtime_docker_config(settings: &DockerSettings, skip_clone: bool) -> DockerSandboxOptions { let mut env_vars = settings .env_vars .iter() @@ -603,7 +628,7 @@ fn runtime_docker_config(settings: &DockerSettings) -> DockerSandboxOptions { memory_limit: settings.memory_limit, cpu_quota: settings.cpu_quota, env_vars, - skip_clone: settings.skip_clone, + skip_clone, ..DockerSandboxOptions::default() } } @@ -992,7 +1017,7 @@ mod tests { use std::time::Duration; use chrono::Utc; - use fabro_config::{RunExecutionLayer, RunLayer, WorkflowSettingsBuilder}; + use fabro_config::{RunCloneLayer, RunExecutionLayer, RunLayer, WorkflowSettingsBuilder}; use fabro_store::Database; use fabro_types::settings::run::RunMode; use fabro_types::{WorkflowSettings, fixtures}; @@ -1043,6 +1068,52 @@ mod tests { .expect("settings should resolve") } + #[test] + fn runtime_clone_config_uses_run_level_clone_policy() { + let settings = settings_from_run_layer(RunLayer { + clone: Some(RunCloneLayer { + enabled: Some(false), + }), + ..RunLayer::default() + }); + + assert!(resolve_docker_config(&settings.run).skip_clone); + assert!(resolve_daytona_config(&settings.run).skip_clone); + } + + #[test] + fn start_record_git_options_honor_disabled_run_branch() { + let mut settings = WorkflowSettings::default(); + settings.run.run_branch.enabled = false; + let start = fabro_types::StartRecord { + start_time: Utc::now(), + run_branch: Some("fabro/run/test".to_string()), + base_sha: Some("abc123".to_string()), + }; + + assert!( + git_checkpoint_options_from_start(&settings, &fixtures::RUN_1, Some(start)).is_none() + ); + } + + #[test] + fn start_record_git_options_honor_disabled_meta_branch() { + let mut settings = WorkflowSettings::default(); + settings.run.meta_branch.enabled = false; + let start = fabro_types::StartRecord { + start_time: Utc::now(), + run_branch: Some("fabro/run/test".to_string()), + base_sha: Some("abc123".to_string()), + }; + + let git = git_checkpoint_options_from_start(&settings, &fixtures::RUN_1, Some(start)) + .expect("run branch should remain enabled"); + + assert_eq!(git.run_branch.as_deref(), Some("fabro/run/test")); + assert_eq!(git.base_sha.as_deref(), Some("abc123")); + assert_eq!(git.meta_branch, None); + } + async fn persisted_workflow(dot: &str, storage_root: &Path) -> (Persisted, Arc) { let store = memory_store(); let created = crate::operations::create( diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index 538d2103d..77aa2061f 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -519,7 +519,7 @@ pub async fn initialize( .as_ref() .and_then(|g| g.run_branch.as_ref()) .is_some(); - if !has_run_branch { + if options.run_options.settings.run.run_branch.enabled && !has_run_branch { let intent = git_setup_intent(&options.run_options); let sandbox_has_origin = sandbox.origin_url().is_some(); if sandbox_has_origin { @@ -540,9 +540,13 @@ pub async fn initialize( options.run_options.git = Some(GitCheckpointOptions { base_sha, run_branch: Some(info.run_branch.clone()), - meta_branch: Some(metadata_branch_name( - &options.run_options.run_id.to_string(), - )), + meta_branch: options + .run_options + .settings + .run + .meta_branch + .enabled + .then(|| metadata_branch_name(&options.run_options.run_id.to_string())), }); if options.run_options.base_branch.is_none() { options.run_options.base_branch = info.base_branch; diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 8d9dfa982..4ece30d4c 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -634,7 +634,13 @@ pub async fn pull_request(concluded: Concluded, options: &PullRequestOptions) -> run_id: run_options.run_id, outcome, conclusion, - pushed_branch: run_options.run_branch().map(str::to_string), + pushed_branch: run_options + .settings + .run + .run_branch + .push + .then(|| run_options.run_branch().map(str::to_string)) + .flatten(), pr_url, } } @@ -665,10 +671,14 @@ mod tests { use httpmock::MockServer; use object_store::memory::InMemory; use tokio::sync::RwLock as AsyncRwLock; + use tokio_util::sync::CancellationToken; use super::*; use crate::event::{Event, append_event}; + use crate::outcome::Outcome; use crate::records::StageSummary; + use crate::run_options::{GitCheckpointOptions, RunOptions}; + use crate::services::EngineServices; struct MockProvider { name: String, @@ -869,6 +879,48 @@ mod tests { } } + #[tokio::test] + async fn pull_request_omits_pushed_branch_when_run_branch_push_disabled() { + let temp = tempfile::tempdir().unwrap(); + let mut settings = WorkflowSettings::default(); + settings.run.run_branch.push = false; + let run_options = RunOptions { + settings, + run_dir: temp.path().to_path_buf(), + cancel_token: CancellationToken::new(), + run_id: fixtures::RUN_1, + labels: HashMap::new(), + workflow_slug: None, + github_app: None, + pre_run_git: None, + fork_source_ref: None, + base_branch: None, + display_base_sha: None, + git: Some(GitCheckpointOptions { + base_sha: None, + run_branch: Some("fabro/run/test".to_string()), + meta_branch: None, + }), + }; + let concluded = Concluded { + outcome: Ok(Outcome::success()), + conclusion: make_test_conclusion(), + graph: Graph::new("test"), + run_options, + services: EngineServices::test_default().run, + }; + + let finalized = pull_request(concluded, &PullRequestOptions { + pr_config: None, + github_app: None, + origin_url: None, + model: "test-model".to_string(), + }) + .await; + + assert_eq!(finalized.pushed_branch, None); + } + // ── format_arc_details_section tests ──────────────────────────────── #[test] diff --git a/lib/crates/fabro-workflow/src/run_metadata.rs b/lib/crates/fabro-workflow/src/run_metadata.rs index 3892d8690..9d679d0b4 100644 --- a/lib/crates/fabro-workflow/src/run_metadata.rs +++ b/lib/crates/fabro-workflow/src/run_metadata.rs @@ -153,7 +153,7 @@ impl RunMetadataWriterHandle { author: GitAuthor, fetch_depth: Option, ) -> Result { - let writer = RunMetadataWriter::new(remote_url, branch, author, fetch_depth)?; + let writer = RunMetadataWriter::new(remote_url, branch, author, fetch_depth, true)?; Ok(Self::new(writer, Arc::new(NoAuth))) } @@ -208,6 +208,9 @@ impl RunMetadataWriterHandle { pub(crate) fn build_metadata_writer( run_options: &RunOptions, ) -> Result, RunMetadataError> { + if !run_options.settings.run.meta_branch.enabled { + return Ok(None); + } let Some(git) = run_options.pre_run_git.as_ref() else { return Ok(None); }; @@ -239,6 +242,7 @@ pub(crate) fn build_metadata_writer( meta_branch.clone(), run_options.git_author(), Some(1), + run_options.settings.run.meta_branch.push, )?; Ok(Some(RunMetadataWriterHandle::new(writer, auth))) } @@ -266,14 +270,15 @@ pub(crate) async fn mint_token( } pub(crate) struct RunMetadataWriter { - store: Store, - tempdir: tempfile::TempDir, - remote_url: String, - branch: String, - author: GitAuthor, - fetch_depth: Option, - parent_oid: Option, - discovered: bool, + store: Store, + tempdir: tempfile::TempDir, + remote_url: String, + branch: String, + author: GitAuthor, + fetch_depth: Option, + push_enabled: bool, + parent_oid: Option, + discovered: bool, } impl RunMetadataWriter { @@ -282,6 +287,7 @@ impl RunMetadataWriter { branch: String, author: GitAuthor, fetch_depth: Option, + push_enabled: bool, ) -> Result { let tempdir = tempfile::tempdir() .map_err(|_| RunMetadataError::Init("failed to create writer tempdir".to_string()))?; @@ -298,6 +304,7 @@ impl RunMetadataWriter { branch, author, fetch_depth, + push_enabled, parent_oid: None, discovered: false, }) @@ -341,7 +348,11 @@ impl RunMetadataWriter { .map_err(|err| RunMetadataError::Commit(self.redact_checkpoint(err)))?; self.parent_oid = Some(commit_oid); - let push_error = self.push(token).err(); + let push_error = if self.push_enabled { + self.push(token).err() + } else { + None + }; Ok(MetadataSnapshot { commit_sha: commit_oid.to_string(), push_error, @@ -689,6 +700,7 @@ mod tests { branch.to_string(), GitAuthor::default(), None, + true, ) .unwrap(); let handle = RunMetadataWriterHandle::new(writer, Arc::new(NoAuth)); @@ -838,6 +850,34 @@ mod tests { assert!(push_error.contains("non-bare repos"), "{push_error}"); } + #[tokio::test] + async fn metadata_writer_can_commit_without_pushing() { + let remote = tempfile::tempdir().unwrap(); + init_git_repo(remote.path()); + let branch_before = run_git(remote.path(), &["rev-parse", "main"]); + let writer = RunMetadataWriter::new( + file_url(remote.path()), + "main".to_string(), + GitAuthor::default(), + None, + false, + ) + .unwrap(); + let handle = RunMetadataWriterHandle::new(writer, Arc::new(NoAuth)); + + let snapshot = handle + .write_snapshot(&metadata_dump(), "checkpoint") + .await + .unwrap(); + + assert!(!snapshot.commit_sha.is_empty()); + assert_eq!(snapshot.push_error, None); + assert_eq!( + run_git(remote.path(), &["rev-parse", "main"]), + branch_before + ); + } + #[tokio::test] #[expect( clippy::disallowed_methods, @@ -983,4 +1023,12 @@ mod tests { .is_none() ); } + + #[test] + fn metadata_writer_factory_skips_disabled_meta_branch() { + let mut options = run_options_for_origin("https://github.com/owner/repo.git"); + options.settings.run.meta_branch.enabled = false; + + assert!(build_metadata_writer(&options).unwrap().is_none()); + } } diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 74a7a8594..217121839 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -238,9 +238,11 @@ models/run-billing-stage.ts models/run-billing-summary.ts models/run-billing-totals.ts models/run-billing.ts +models/run-branch-settings.ts models/run-checkpoint-settings.ts models/run-checkpoint.ts models/run-client-provenance.ts +models/run-clone-settings.ts models/run-commit-parent.ts models/run-commit-person.ts models/run-commit.ts @@ -261,6 +263,7 @@ models/run-interviews-settings.ts models/run-lifecycle.ts models/run-links.ts models/run-manifest.ts +models/run-meta-branch-settings.ts models/run-mode.ts models/run-model-settings.ts models/run-model.ts diff --git a/lib/packages/fabro-api-client/src/models/daytona-settings.ts b/lib/packages/fabro-api-client/src/models/daytona-settings.ts index dae48daa5..4e3127313 100644 --- a/lib/packages/fabro-api-client/src/models/daytona-settings.ts +++ b/lib/packages/fabro-api-client/src/models/daytona-settings.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -25,6 +25,4 @@ export interface DaytonaSettings { 'labels': { [key: string]: string; }; 'snapshot': DaytonaSnapshotSettings | null; 'network': DaytonaNetworkLayer | null; - 'skip_clone': boolean; } - diff --git a/lib/packages/fabro-api-client/src/models/docker-settings.ts b/lib/packages/fabro-api-client/src/models/docker-settings.ts index 000a6deea..0b9b74593 100644 --- a/lib/packages/fabro-api-client/src/models/docker-settings.ts +++ b/lib/packages/fabro-api-client/src/models/docker-settings.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -20,6 +20,4 @@ export interface DockerSettings { 'memory_limit': number | null; 'cpu_quota': number | null; 'env_vars': { [key: string]: string; }; - 'skip_clone': boolean; } - diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index be6ada97e..49ebf500d 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -216,9 +216,11 @@ export * from './run-billing'; export * from './run-billing-stage'; export * from './run-billing-summary'; export * from './run-billing-totals'; +export * from './run-branch-settings'; export * from './run-checkpoint'; export * from './run-checkpoint-settings'; export * from './run-client-provenance'; +export * from './run-clone-settings'; export * from './run-commit'; export * from './run-commit-parent'; export * from './run-commit-person'; @@ -239,6 +241,7 @@ export * from './run-interviews-settings'; export * from './run-lifecycle'; export * from './run-links'; export * from './run-manifest'; +export * from './run-meta-branch-settings'; export * from './run-mode'; export * from './run-model'; export * from './run-model-settings'; diff --git a/lib/packages/fabro-api-client/src/models/run-branch-settings.ts b/lib/packages/fabro-api-client/src/models/run-branch-settings.ts new file mode 100644 index 000000000..1805c3f2b --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-branch-settings.ts @@ -0,0 +1,20 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface RunBranchSettings { + 'enabled': boolean; + 'push': boolean; +} diff --git a/lib/packages/fabro-api-client/src/models/run-clone-settings.ts b/lib/packages/fabro-api-client/src/models/run-clone-settings.ts new file mode 100644 index 000000000..4c4b6a657 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-clone-settings.ts @@ -0,0 +1,19 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface RunCloneSettings { + 'enabled': boolean; +} diff --git a/lib/packages/fabro-api-client/src/models/run-meta-branch-settings.ts b/lib/packages/fabro-api-client/src/models/run-meta-branch-settings.ts new file mode 100644 index 000000000..950734a44 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-meta-branch-settings.ts @@ -0,0 +1,20 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +export interface RunMetaBranchSettings { + 'enabled': boolean; + 'push': boolean; +} diff --git a/lib/packages/fabro-api-client/src/models/run-namespace.ts b/lib/packages/fabro-api-client/src/models/run-namespace.ts index ef2cf3d1d..655714f13 100644 --- a/lib/packages/fabro-api-client/src/models/run-namespace.ts +++ b/lib/packages/fabro-api-client/src/models/run-namespace.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -30,9 +30,15 @@ import type { PullRequestSettings } from './pull-request-settings'; import type { RunAgentSettings } from './run-agent-settings'; // May contain unused imports in some cases // @ts-ignore +import type { RunBranchSettings } from './run-branch-settings'; +// May contain unused imports in some cases +// @ts-ignore import type { RunCheckpointSettings } from './run-checkpoint-settings'; // May contain unused imports in some cases // @ts-ignore +import type { RunCloneSettings } from './run-clone-settings'; +// May contain unused imports in some cases +// @ts-ignore import type { RunExecutionSettings } from './run-execution-settings'; // May contain unused imports in some cases // @ts-ignore @@ -48,6 +54,9 @@ import type { RunIntegrationsSettings } from './run-integrations-settings'; import type { RunInterviewsSettings } from './run-interviews-settings'; // May contain unused imports in some cases // @ts-ignore +import type { RunMetaBranchSettings } from './run-meta-branch-settings'; +// May contain unused imports in some cases +// @ts-ignore import type { RunModelSettings } from './run-model-settings'; // May contain unused imports in some cases // @ts-ignore @@ -72,6 +81,9 @@ export interface RunNamespace { 'prepare': RunPrepareSettings; 'execution': RunExecutionSettings; 'checkpoint': RunCheckpointSettings; + 'clone': RunCloneSettings; + 'run_branch': RunBranchSettings; + 'meta_branch': RunMetaBranchSettings; 'sandbox': RunSandboxSettings; 'notifications': { [key: string]: NotificationRouteSettings; }; 'interviews': RunInterviewsSettings; @@ -82,4 +94,3 @@ export interface RunNamespace { 'artifacts': ArtifactsSettings; 'integrations': RunIntegrationsSettings; } -