feat(run): add managed branch controls (#243)

## Summary

Adds run-level controls for clone behavior, managed run branch
setup/pushes, and metadata branch writes/pushes so workflows can opt out
of Fabro-managed Git behavior without relying on provider-specific
`skip_clone` settings. This closes fabro-sh/fabro#240.

## What Changed

- Introduced `[run.clone]`, `[run.run_branch]`, and `[run.meta_branch]`
settings with defaults that preserve current behavior.
- Removed user-facing `skip_clone` from Docker/Daytona config while
mapping the new run-level clone setting into the internal sandbox
runtime options.
- Gated run branch setup/push, metadata branch writer creation/push, and
PR branch output on the new settings.
- Enforced invalid combinations: pull requests require an enabled pushed
run branch, and disabling the run branch also disables metadata branch
behavior.
- Updated OpenAPI, the generated TypeScript API client, frontend fixture
data, and docs for the new configuration shape.

## Testing

- `cargo nextest run -p fabro-config -p fabro-types -p fabro-workflow -p
fabro-server`
- `cargo build -p fabro-api`
- `cd lib/packages/fabro-api-client && bun run generate`
- `cd lib/packages/fabro-api-client && bun run typecheck`
- `cd apps/fabro-web && bun run typecheck`
- `cd apps/fabro-web && bun test`
- `cargo build --workspace`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D
warnings`
- `cargo insta pending-snapshots`
- `git diff --check`

## Post-Deploy Monitoring & Validation

- Validation window: first 24 hours after release; owner: release
owner/on-call engineer.
- Log queries/search terms: `run_branch`, `meta_branch`,
`clone.enabled`, `skip_clone`, `pull request requires an enabled pushed
run branch`, `metadata branch`.
- Healthy signals: runs without custom branch config continue creating
and pushing run/meta branches; runs with `[run.clone] enabled = false`
start provider sandboxes without cloning; runs with branch pushes
disabled complete without Git push errors.
- Failure signals: increased run startup failures for Docker/Daytona,
unexpected PR creation conflicts, missing metadata for default-config
runs, or validation errors for configurations that previously used
default settings.
- Mitigation trigger: if default-config runs stop producing expected
branch/metadata artifacts or sandbox startup failures increase, roll
back the release or temporarily restore previous defaults while
investigating the run-level setting resolution path.

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 via [Codex](https://openai.com/codex)

Co-authored-by: Haroldo Olivieri <6575718+haroldolivieri@users.noreply.github.com>
This commit is contained in:
Bryan Helmkamp 2026-05-12 09:00:30 -07:00 • committed by GitHub
parent 762ac19649
commit a33d17c88d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 811 additions and 149 deletions

View file

@ -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: {},

View file

@ -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

View file

@ -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.
</Warning>
@ -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.

View file

@ -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"

View file

@ -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/<id>` 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/<id>` to the remote. |
### `[run.meta_branch]`
Configure Fabro's managed `fabro/meta/<id>` 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/<id>` 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

View file

@ -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:

View file

@ -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,

View file

@ -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,

View file

@ -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"

View file

@ -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,

View file

@ -36,6 +36,12 @@ pub struct RunLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkpoint: Option<RunCheckpointLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clone: Option<RunCloneLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_branch: Option<RunRunBranchLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meta_branch: Option<RunMetaBranchLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<RunSandboxLayer>,
#[serde(default, skip_serializing_if = "MergeMap::is_empty")]
pub notifications: MergeMap<NotificationRouteLayer>,
@ -278,6 +284,34 @@ pub struct RunCheckpointLayer {
pub exclude_globs: Vec<String>,
}
/// `[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<bool>,
}
/// `[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<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub push: Option<bool>,
}
/// `[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<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub push: Option<bool>,
}
/// `[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<i64>,
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub env_vars: StickyMap<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_clone: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
@ -328,8 +360,6 @@ pub struct DaytonaSandboxLayer {
pub snapshot: Option<DaytonaSnapshotLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<DaytonaNetworkLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_clone: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]

View file

@ -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};

View file

@ -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<ResolveError>) -> 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<ResolveError>,
@ -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),
}
}

View file

@ -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::<SettingsLayer>()
.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(

View file

@ -1675,7 +1675,6 @@ mod runs {
dockerfile: None,
}),
network: None,
skip_clone: false,
}),
},
..RunNamespace::default()

View file

@ -564,16 +564,26 @@ fn resolve_sandbox_provider(settings: &RunNamespace) -> Result<SandboxProvider>
.unwrap_or_default())
}
fn resolve_daytona_config(settings: &RunNamespace) -> Option<DaytonaConfig> {
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<DockerSandboxOptions> {
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<types::GitContext>,
) -> (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")),
);

View file

@ -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<RunGoal>,
pub working_dir: Option<InterpString>,
@ -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<String, NotificationRouteSettings>,
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<String>,
}
#[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<i64>,
pub cpu_quota: Option<i64>,
pub env_vars: HashMap<String, InterpString>,
pub skip_clone: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
@ -251,7 +326,6 @@ pub struct DaytonaSettings {
pub labels: HashMap<String, String>,
pub snapshot: Option<DaytonaSnapshotSettings>,
pub network: Option<DaytonaNetworkLayer>,
pub skip_clone: bool,
}
#[derive(Debug, Clone, PartialEq)]

View file

@ -290,7 +290,9 @@ impl RunLifecycle<WorkflowGraph> 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();

View file

@ -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<fabro_types::StartRecord>,
) -> Option<GitCheckpointOptions> {
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<SandboxPro
.map_or_else(|| Ok(SandboxProvider::default()), Ok)
}
fn resolve_daytona_config(settings: &ResolvedRunSettings) -> Option<DaytonaConfig> {
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<DockerSandboxOptions> {
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<Database>) {
let store = memory_store();
let created = crate::operations::create(

View file

@ -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;

View file

@ -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]

View file

@ -153,7 +153,7 @@ impl RunMetadataWriterHandle {
author: GitAuthor,
fetch_depth: Option<i32>,
) -> Result<Self, RunMetadataError> {
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<Option<RunMetadataWriterHandle>, 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<i32>,
parent_oid: Option<Oid>,
discovered: bool,
store: Store,
tempdir: tempfile::TempDir,
remote_url: String,
branch: String,
author: GitAuthor,
fetch_depth: Option<i32>,
push_enabled: bool,
parent_oid: Option<Oid>,
discovered: bool,
}
impl RunMetadataWriter {
@ -282,6 +287,7 @@ impl RunMetadataWriter {
branch: String,
author: GitAuthor,
fetch_depth: Option<i32>,
push_enabled: bool,
) -> Result<Self, RunMetadataError> {
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());
}
}

View file

@ -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

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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';

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}