Make git metadata sandbox-native

This commit is contained in:
Bryan Helmkamp 2026-04-27 21:43:15 -07:00
parent fd1087fe2d
commit cdd46b4fa8
No known key found for this signature in database
104 changed files with 4572 additions and 5037 deletions

View file

@ -16,7 +16,7 @@ describe("mapRunListItem", () => {
title: "Server supplied title",
workflow_slug: "fix_build",
workflow_name: "Fix Build",
host_repo_path: "/home/user/myrepo",
source_directory: "/home/user/myrepo",
repository: { name: "myrepo" },
status: { kind: "paused", prior_block: null },
labels: {},
@ -45,7 +45,7 @@ describe("mapRunListItem", () => {
title: "",
workflow_slug: "fix_build",
workflow_name: "Fix Build",
host_repo_path: "/home/user/myrepo",
source_directory: "/home/user/myrepo",
repository: { name: "myrepo" },
status: { kind: "running" },
labels: {},
@ -70,7 +70,7 @@ describe("mapRunSummaryToRunItem", () => {
title: "Fix the build",
workflow_slug: "fix_build",
workflow_name: "Fix Build",
host_repo_path: "/home/user/myrepo",
source_directory: "/home/user/myrepo",
repository: { name: "myrepo" },
status: { kind: "running" },
duration_ms: 65000,
@ -97,7 +97,7 @@ describe("mapRunSummaryToRunItem", () => {
title: "",
workflow_slug: null,
workflow_name: null,
host_repo_path: null,
source_directory: null,
repository: { name: "unknown" },
status: { kind: "submitted" },
duration_ms: null,

View file

@ -16,7 +16,7 @@
| Path | Direct dependency | Why it still exists | Required remediation track |
| --- | --- | --- | --- |
| `lib/crates/fabro-cli/src/commands/run/fork.rs` | `operations::{ForkRunInput, RewindTarget, build_timeline_or_rebuild, fork}` | User-facing CLI still reconstructs run timelines and mutates rewind/fork metadata locally. | Replace with a server API for timeline inspection and fork execution. |
| `lib/crates/fabro-cli/src/commands/run/rewind.rs` | `git::MetadataStore`, `operations::{RewindInput, RewindTarget, RunTimeline, TimelineEntry, build_timeline_or_rebuild, rewind}` | User-facing CLI still performs rewind timeline resolution and metadata mutation locally. | Replace with a server API for rewind preview and rewind execution. |
| `lib/crates/fabro-cli/src/commands/run/rewind.rs` | `operations::{RewindInput, RewindTarget, RunTimeline, TimelineEntry, build_timeline_or_rebuild, rewind}` | User-facing CLI still performs rewind timeline resolution and mutation locally. | Replace with a server API for rewind preview and rewind execution. |
| `lib/crates/fabro-cli/src/commands/pr/create.rs` | `outcome::StageStatus`, `pull_request::maybe_open_pull_request` | CLI still reconstructs store state and runs PR creation logic from the workflow pipeline directly. | Replace with a server API, or extract PR orchestration into a non-engine shared service crate plus API. |
| `lib/crates/fabro-cli/src/commands/run/runner.rs` | `artifact_snapshot::CapturedArtifactInfo`, `artifact_upload::{ArtifactSink, StageArtifactUploader}`, `event::{Emitter, RunEventSink}`, `operations::{self, StartServices}`, `run_control::RunControlState`, `runtime_store::{RunStoreBackend, RunStoreHandle}` | Hidden worker subprocess path still lives inside the CLI crate and embeds the workflow engine directly. | Re-home worker/runtime code outside the user CLI surface, ideally into a dedicated worker crate or binary. |
| `lib/crates/fabro-cli/src/manifest_builder.rs` | `git::{GitSyncStatus, head_sha, sync_status}` | Manifest submission still relies on git helper logic that happens to live in `fabro_workflow`. | Extract git-sync inspection helpers into a non-workflow shared crate/module. |

View file

@ -1449,9 +1449,7 @@ Emitted after the engine completes sandbox initialization (distinct from `sandbo
"properties": {
"working_directory": "/workspace/my-project",
"provider": "daytona",
"identifier": "sandbox-123",
"host_working_directory": "/tmp/fabro-run/worktree",
"container_mount_point": "/workspace"
"identifier": "sandbox-123"
}
}
```
@ -1461,8 +1459,6 @@ Emitted after the engine completes sandbox initialization (distinct from `sandbo
| `working_directory` | string | Working directory inside sandbox |
| `provider` | string | Sandbox provider |
| `identifier` | string? | Provider-specific sandbox identifier |
| `host_working_directory` | string? | Host-side working directory |
| `container_mount_point` | string? | Container mount point inside the sandbox |
### `sandbox.cleanup.started`

View file

@ -27,7 +27,7 @@ Make all run directory key data derivable from events in `progress.jsonl`.
|-------|------------|
| `stage.started` | Remove `script` (moved to `command.started`), make `handler_type` non-optional |
| `stage.completed` | `context_updates`, `jump_to_node`, `context_values`, `node_visits`, `loop_failure_signatures`, `restart_failure_signatures`, `response` |
| `sandbox.initialized` | `provider`, `identifier`, `host_working_directory`, `container_mount_point` |
| `sandbox.initialized` | `provider`, `identifier`, `working_directory`, `repo_cloned`, `clone_origin_url`, `clone_branch` |
| `checkpoint.completed` | `diff` |
| `parallel.branch.completed` | `head_sha` |
| `agent.session.started` | `mode`, `provider`, `model` |
@ -55,7 +55,7 @@ Make all run directory key data derivable from events in `progress.jsonl`.
**Decision:** Emit a `run.created` event at the end of the CREATE operation (before START). Carries everything needed to persist the run:
- From `_init.json`: `created_at`, `db_prefix`, `run_dir`
- From `run.json`: `settings`, `graph`, `workflow_slug`, `working_directory`, `host_repo_path`, `base_branch`, `labels`
- From `run.json`: `settings`, `graph`, `workflow_slug`, `source_directory`, `base_branch`, `labels`
- From `workflow.fabro`/`workflow.toml`: `workflow_source` (raw dot text), `workflow_config` (raw TOML text)
The existing `run.started` stays lightweight — it signals execution has begun. The CREATE→START boundary is: `run.created` persists the run definition, `run.started` marks execution start.
@ -93,7 +93,7 @@ Derivation details:
### 5. `sandbox.json` — missing fields
**Decision:** Add `host_working_directory`, `container_mount_point`, `provider`, and `identifier` to `sandbox.initialized` so it alone is sufficient to reconstruct `sandbox.json`.
**Decision:** Add sandbox-native identity and clone metadata to `sandbox.initialized` so it alone is sufficient to reconstruct `sandbox.json`. Host paths and container mount points are not part of the durable event model; clone-based providers report their sandbox working directory plus repository clone state instead.
### 6. `workflow.fabro` + `workflow.toml` — raw source files

View file

@ -0,0 +1,358 @@
---
title: "refactor: make git metadata sandbox-native and clarify run paths"
type: refactor
status: active
date: 2026-04-27
---
# refactor: make git metadata sandbox-native and clarify run paths
## For the engineer picking this up
Fabro currently mixes three different path concepts under names that imply they are interchangeable:
- The submitter/client path used to resolve workflow source.
- The sandbox execution path.
- A repo path the Fabro server process can open with `git2`.
That assumption is false for Docker and Daytona. Docker and Daytona clone into sandbox-owned workspaces, commonly `/workspace`, while the submitted source path may be something like `/Users/...` from a CLI client and may not exist on the Fabro server host. This causes metadata checkpoint writes to try to open a client path from the server process.
Greenfield app. No production deploys. Do not add compatibility shims, serde aliases, migration layers, or legacy fallback behavior. Prefer clear renames and direct deletion of stale concepts.
The metadata branch (`refs/heads/fabro/meta/<run_id>`) is written best-effort as a future-proofing archive. Nothing in this plan reads it at runtime — fork, rewind, and timeline all source their state from the durable run store (events and `RunProjection.checkpoints`). Fork-from-metadata-branch (recovering a run that was deleted from the server but still exists in git) is explicitly deferred to a future plan.
## Goals
- `source_directory` means the submitter-side path where run source was resolved. It is provenance/display only and may not exist on the Fabro server.
- `working_directory` means the sandbox execution path field on sandbox records/events, e.g. `/workspace`; use "sandbox execution path" for the concept in prose.
- Submitter-provided pre-run git context is optional. CLI submitters can report local dirty status, base SHA, and pre-run push outcome. Browser, Slack, webhook, MCP, and scheduled-job submitters usually cannot. The Fabro server never opens a submitter path.
- Runtime metadata branch writes happen through `Sandbox::exec_command` and are best-effort.
- Runtime fork, rewind, and timeline source state from the durable run store, not the metadata branch. Fork/rewind git setup validates source run-branch reachability through the sandbox clone origin before execution.
- Docker, Daytona, and local worktree execution share the same git metadata behavior.
- Stale bind-mount vocabulary is removed from records, events, docs, tests, and API schemas.
- Local in-place execution is preserved only as an explicit no-checkpoints opt-out with durable state and clear fork/rewind errors.
## Phase 1: Rename run path vocabulary and move pre-run git context to submitters
Primary files:
- `lib/crates/fabro-types/src/run.rs`
- `lib/crates/fabro-types/src/run_summary.rs`
- `lib/crates/fabro-types/src/run_event/run.rs`
- `lib/crates/fabro-workflow/src/event.rs`
- `lib/crates/fabro-workflow/src/operations/create.rs`
- `lib/crates/fabro-workflow/src/pipeline/initialize.rs`
- `lib/crates/fabro-server/src/run_manifest.rs`
- `lib/crates/fabro-cli` (CLI submitter pre-run git context)
- `docs/public/api-reference/fabro-api.yaml`
Required changes — vocabulary rename:
- Rename `RunSpec.working_directory` to `RunSpec.source_directory` and change its type from `PathBuf` to `Option<String>`. Provenance may originate on another machine, may be absent for server-originated runs, and is never opened by the server. `Option<String>` is more honest than `PathBuf` and avoids fake or normalized values.
- Remove `RunSpec.host_repo_path`.
- Rename `RunCreatedProps.working_directory` to `source_directory`; same `Option<String>` type.
- Remove `RunCreatedProps.host_repo_path`.
- Add the same durable fields to `RunCreatedProps` and `Event::RunCreated` as needed for event replay: `pre_run_git: Option<PreRunGitContext>`, `fork_source_ref: Option<ForkSourceRef>`, and `checkpoints_disabled: bool`. `RunSpec` is reconstructed from `RunCreatedProps` in `fabro-store`, so no new `RunSpec` field may exist only in memory.
- Delete `CreateRunInput.host_repo_path`; `operations::create` should always persist `source_directory` from resolved workflow context.
- Rename `PreparedManifest.working_directory` to `source_directory` in `fabro-server::run_manifest`; `PreparedManifest` is the server-side manifest assembled from submitted workflow source before `RunSpec` is persisted.
- Rename `RunSummary.host_repo_path` to `source_directory` and switch to `Option<String>`.
- Update `RunSummary::repository.name` derivation, end to end. This is needed because `source_directory` is now optional submitter provenance, while `repo_origin_url` is the best server-known repository identity:
- Plumb `repo_origin_url` into `RunSummary::new` and into `build_summary` in `lib/crates/fabro-store/src/run_state.rs` alongside `source_directory`. Today both sites only carry the path.
- Derivation order: prefer `repo_origin_url` owner/repo or repo basename when present; fall back to `source_directory` basename; fall back to `"unknown"`.
- Update CLI output and server summary helpers that currently expose `host_repo_path`.
- Regenerate and update API clients after the OpenAPI rename.
Required changes — submitter pre-run git context:
- Keep the existing git helper functions in `fabro-workflow::git` for now; move the call sites for dirty detection (`git::sync_status`), base-branch push (`git::branch_needs_push` + `git::push_branch`), and base SHA capture (`git::head_sha`) from `pipeline/initialize.rs` to the CLI submit path.
- Add `RunSpec.pre_run_git: Option<PreRunGitContext>`.
- Define `PreRunGitContext` as one coherent observation of the submitter checkout. It contains `display_base_sha: Option<String>`, `local_dirty: DirtyStatus` (`Clean`, `Dirty`, `Unknown`), and `push_outcome: PreRunPushOutcome`.
- Define `PreRunPushOutcome` as an enum, not an `Option<bool>`: `NotAttempted`, `Succeeded { remote, branch }`, `Failed { remote, branch, message }`, `SkippedNoRemote`, and `SkippedRemoteMismatch { remote, repo_origin_url }`.
- Keep `PreRunGitContext` grouped rather than flattening it into `RunSpec`; its fields are meaningful together as submitter-local provenance, and `push_outcome` is only meaningful in the context of the same checkout/base SHA observation.
- CLI submissions populate `pre_run_git` from the user's source checkout. Non-CLI submitters leave it `None`; the engine skips local dirty/push reporting and uses sandbox git setup output for display base SHA when available.
- For CLI submissions with `repo_origin_url`, use the current checkout's `origin` remote for pre-run push and compare it with `repo_origin_url` using `fabro_github::normalize_repo_origin_url(cli_remote_url) == fabro_github::normalize_repo_origin_url(repo_origin_url)`. If it does not match or cannot be proven, skip the pre-run push, record `SkippedRemoteMismatch` or `SkippedNoRemote`, and let sandbox clone/setup validate the runtime origin later. Fork/upstream layouts where `origin` is a user fork and `repo_origin_url` is upstream are treated as user configuration errors for this plan.
- `pipeline/initialize.rs` consumes `pre_run_git` and stops calling `options.sandbox.host_repo_path()` for git work. Delete the `SandboxSpec::host_repo_path()` accessor in this phase.
Decisions:
- Do not keep `host_repo_path` anywhere in active run specs, summaries, events, or runtime options. If a test needs a local git path, name that fixture variable `source_directory` or `repo_dir` according to its role.
- Delete both host-path entry points: `SandboxSpec::host_repo_path()` currently feeds initialization/pre-run host git behavior, and `RunOptions.host_repo_path` currently feeds runtime metadata/push behavior. Both are symptoms of the same server-local-git assumption and both go away.
- The Fabro server never inspects a submitter filesystem path; pre-run git facts arrive as data on `RunSpec`.
## Phase 2: Remove stale bind-mount fields
Primary files:
- `lib/crates/fabro-types/src/sandbox_record.rs`
- `lib/crates/fabro-types/src/run_event/infra.rs`
- `lib/crates/fabro-workflow/src/event.rs`
- `lib/crates/fabro-workflow/src/pipeline/initialize.rs`
- `lib/crates/fabro-store/src/run_state.rs`
- `lib/crates/fabro-sandbox/src/sandbox_spec.rs`
- `apps/fabro-web` and `lib/packages/fabro-api-client` (consumer audit)
- `docs/internal/events.md`
- `docs/internal/plan-events-as-source-of-truth.md`
Required changes:
- Delete `host_working_directory` and `container_mount_point` from `SandboxRecord`.
- Delete both fields from `SandboxInitializedProps` and `Event::SandboxInitialized`.
- Stop emitting both fields from `pipeline::initialize`.
- Stop storing both fields in `fabro-store` projections.
- Grep `apps/fabro-web` and `lib/packages/fabro-api-client` for either field name and update consumers; do not condition removal on OpenAPI presence alone.
- Update all tests and snapshots that construct sandbox records/events.
- Update docs to describe `sandbox.initialized.working_directory` as the sandbox execution path and remove host/container mount terminology.
Decision:
- Keep Docker clone metadata (`repo_cloned`, `clone_origin_url`, `clone_branch`) because it describes the sandbox clone source. It does not imply host bind mounts.
## Phase 3: Make metadata writes sandbox-native
Primary files:
- `lib/crates/fabro-workflow/src/sandbox_git.rs`
- `lib/crates/fabro-workflow/src/lifecycle/git.rs`
- `lib/crates/fabro-workflow/src/pipeline/finalize.rs`
- `lib/crates/fabro-workflow/src/run_dump.rs`
- `lib/crates/fabro-workflow/src/run_options.rs`
- `lib/crates/fabro-sandbox/src/sandbox.rs`
- `lib/crates/fabro-checkpoint/src/metadata.rs` (deletion)
Required changes — writer interface:
- Delete `RunOptions.host_repo_path`.
- Stop constructing `MetadataStore` in workflow runtime lifecycle code.
- Add a sandbox metadata writer near `sandbox_git` with an interface equivalent to:
- input: sandbox, run id, metadata ref, `RunDump` (which already includes the full `RunProjection` as `run.json`), commit message, git author.
- output: metadata commit SHA.
Required changes — writer implementation:
- Add a per-run `SandboxGitRuntime` helper near `sandbox_git`, shared by `GitLifecycle`, finalize, and parallel checkpoint code. It owns a `OnceLock`/cached result for the sandbox git capability probe plus metadata degradation state.
- Run the shared sandbox git capability probe once per run before the first sandbox git operation. Prefer an actual temp-index plumbing check over version parsing: under a hidden directory inside the sandbox execution path, create a temporary index, run the `read-tree --empty` / `hash-object -w` / `update-index --add --cacheinfo` / `write-tree` sequence against harmless temp data, then clean up. Do not assume `/tmp` or `$TMPDIR` is writable.
- Probe failure does not convert a run to `checkpoints_disabled`; that state is reserved for the explicit in-place opt-out. If the probe fails before run-branch checkpointing or fork setup, fail that operation with a clear "sandbox git unavailable" error. Metadata writes use the same cached probe result but remain best-effort: emit one warning, mark metadata degraded for the run, and skip future metadata writes.
- The probe is a startup capability check, not a guarantee for the full run lifetime. If an agent removes `git`, fills the disk, or otherwise breaks git after the probe passes, the real git operation fails with its concrete raw error; do not re-probe before every checkpoint.
- Create a sandbox temp directory for metadata files under the sandbox execution path.
- Write every dump entry into the metadata tree binary-safely. Use local temp files plus `Sandbox::upload_file_from_local` for bytes.
- The writer must include `run.json` (full `RunProjection` blob) at every checkpoint commit so that future fork-from-archive (deferred from this plan) can reconstruct the projection from a metadata commit alone.
- Build a temporary git index with `GIT_INDEX_FILE`.
- Load the previous metadata commit with `git read-tree <old_commit>` when the ref exists, else `git read-tree --empty`.
- Write blobs with `git hash-object -w`.
- Stage entries with `git update-index --add --cacheinfo`.
- Create the tree with `git write-tree`.
- Create commits with `git commit-tree`, setting author/committer env from `GitAuthor`.
- Update `refs/heads/fabro/meta/<run_id>` with `git update-ref`.
- Clean up temp files best-effort.
Required changes — integration:
- `GitLifecycle::on_run_start` initializes the metadata ref through this sandbox writer.
- `GitLifecycle::on_checkpoint` writes the snapshot through this sandbox writer and passes the returned SHA into `git_checkpoint` so the run-branch commit keeps the `Fabro-Checkpoint` trailer.
- After each successful metadata write, the sandbox metadata writer pushes `refs/heads/fabro/meta/<run_id>` best-effort through `git_push_ref`. `write_finalize_commit` only calls the writer for the final snapshot; it does not have a separate metadata push path.
- Metadata write failures remain best-effort warnings and must not fail a successful workflow run. They do not affect `Checkpoint.git_commit_sha`; that SHA comes from run-branch checkpoint commits. Fork/rewind require a checkpoint with `git_commit_sha`, so run-branch checkpoint failures affect forkability while metadata failures only affect future fork-from-archive recovery.
- Metadata write and metadata push failures share a noise budget: emit the first warning per run, mark metadata degraded, suppress repeated per-checkpoint warnings, and emit an end-of-run summary notice if metadata was degraded.
Decisions:
- Use git plumbing with a temporary index. Do not checkout the metadata branch.
- Do not mutate the sandbox worktree or real git index while writing metadata.
- Rejected alternative: relying on the first real git operation to fail was rejected because run-branch checkpointing, metadata writes, and parallel checkpoint code would each surface different failures. The shared probe gives one clear capability error and one cached best-effort degradation path.
- Delete `fabro-checkpoint::metadata::MetadataStore` entirely. The sandbox writer is the only writer. Tests that previously used `MetadataStore` migrate to either a local-sandbox-backed writer fixture or a focused test helper.
## Phase 4: Converge local git behavior on sandbox exec
Primary files:
- `lib/crates/fabro-sandbox/src/local.rs`
- `lib/crates/fabro-sandbox/src/worktree.rs`
- `lib/crates/fabro-sandbox/src/sandbox.rs`
- `lib/crates/fabro-workflow/src/pipeline/initialize.rs`
- `lib/crates/fabro-workflow/src/lifecycle/mod.rs`
- `lib/crates/fabro-cli` (CLI flag for opt-out)
Required changes:
- Runtime git checkpointing always uses `Sandbox::exec_command`.
- Make `WorktreeSandbox` the default local provider strategy for all local runs, regardless of submitter. Local runs use an isolated worktree; this is the path that shares behavior with Docker/Daytona.
- Add a CLI flag (e.g. `--in-place`) for the explicit opt-out that runs against the user's source tree directly. Require an explicit paired acknowledgement flag such as `--allow-no-checkpoints`; when opted in, disable git checkpointing, persist `checkpoints_disabled: true` on the run, surface that state in summaries/`fabro ps`, and make fork/rewind errors say the run was created without checkpoints. Do not `git checkout -b fabro/run/...` in the user source directory.
- Ensure `WorktreeSandbox` supports the same run-branch and metadata-branch git operations as Docker/Daytona through the `Sandbox` trait.
- Remove fallback host-side pushes from runtime git lifecycle. Push run and metadata refs from the sandbox.
- Replace `Sandbox::setup_git_for_run(&str)` with a setup intent method that returns `GitRunInfo` (`base_sha`, `run_branch`, `base_branch`) when it establishes sandbox git state:
- `GitSetupIntent::NewRun { run_id }` creates `refs/heads/fabro/run/<run_id>` from the sandbox clone HEAD and returns the sandbox HEAD SHA as `base_sha`.
- `GitSetupIntent::ForkFromCheckpoint { new_run_id, source_run_id, checkpoint_sha }` fetches the source run ref, creates `refs/heads/fabro/run/<new_run_id>` at `checkpoint_sha`, and checks it out before the first stage runs.
- Keep `GitSetupIntent` rather than passing full `RunSpec` into the sandbox trait. The workflow layer derives the intent from `RunSpec.fork_source_ref`, and the sandbox trait receives only the git setup data it needs.
- `WorktreeSandbox` owns local git setup. Its `initialize`/setup flow should keep the existing `git worktree add` behavior for `NewRun`, and add a fork path that creates the worktree branch at `checkpoint_sha`; it should not delegate git setup to bare `LocalSandbox`.
- Delete `Sandbox::host_git_dir` after the worktree migration. No runtime git operation may branch on a host-accessible repository path. Replace the current `host_git_dir` lifecycle gate with an explicit hook cwd rule: when sandbox git checkpointing is enabled, pass the sandbox execution path (`sandbox.working_directory()`) to `HookLifecycle`/`WorkflowRunStarted.worktree_dir`; when checkpoints are disabled, leave hook cwd unset. Do not infer hook behavior from host path accessibility.
- Rename `Sandbox::git_push_branch` to `git_push_ref` (in `lib/crates/fabro-sandbox/src/sandbox.rs`, the `delegate_sandbox!` macro, every implementor, and the shared `git_push_via_exec` helper). The method now pushes both run branches and metadata refs; `git_push_branch` is misleading.
- Define `git_push_ref` as taking a full refspec (e.g. `refs/heads/fabro/run/<id>:refs/heads/fabro/run/<id>`, `refs/heads/fabro/meta/<id>:refs/heads/fabro/meta/<id>`) so callers control source and destination explicitly. Preserve the existing `refresh_push_credentials()` call. Do not use force refspecs (`+src:dst`) for run or metadata refs; they are append-only/create-only by invariant, and a non-fast-forward push should fail loudly.
Decisions:
- The local source tree is never used as a server-side git repo for checkpoint metadata.
- `--in-place --allow-no-checkpoints` is the only path where local runs forgo checkpoints. It is opt-in, persisted on the run, and visible in list/detail and fork/rewind error paths.
## Phase 5: Source fork, rewind, and timeline from the durable run store
Primary files:
- `lib/crates/fabro-workflow/src/operations/run_git.rs`
- `lib/crates/fabro-workflow/src/operations/timeline.rs`
- `lib/crates/fabro-workflow/src/operations/fork.rs`
- `lib/crates/fabro-workflow/src/operations/rewind.rs`
- `lib/crates/fabro-workflow/src/operations/rebuild_meta.rs` (deletion)
- `lib/crates/fabro-workflow/src/operations/mod.rs`
- `lib/crates/fabro-server/src/server.rs`
- `lib/crates/fabro-sandbox/src/sandbox.rs`
Required changes — server-local git removal:
- Stop opening `RunSpec.source_directory` with `git2`. The server never opens any submitter path.
- Build checkpoint timelines from `RunProjection.checkpoints` directly. No metadata branch reads.
- Replace `TimelineEntry.metadata_commit_oid` (in `lib/crates/fabro-workflow/src/operations/timeline.rs`) with `checkpoint_seq: u32`. Every consumer that previously read `run.json` from a metadata commit (such as fork's historical projection reconstruction in `operations/fork.rs`) instead replays events up to that seq.
- Delete `rebuild_meta.rs`, `build_timeline_or_rebuild`, and `rebuild_metadata_branch`. Remove their `operations::mod` re-exports. Recovery from missing metadata branches is out of scope for this plan; a future fork-from-archive plan will reintroduce a recovery utility if needed.
Required changes — fork mechanics:
- Reconstruct the historical full `RunProjection` for the source run by composing `store.list_events(source_run_id)` with `RunProjection::apply_events`, stopping at the target `checkpoint_seq`. This becomes the new run's initial projection.
- Add `RunSpec.fork_source_ref: Option<ForkSourceRef>` and the matching `RunCreatedProps`/event field. `None` means a normal run. `Some(ForkSourceRef { source_run_id, checkpoint_sha })` means fork/rewind setup must fetch the source run branch and branch the new run from `checkpoint_sha`; this overrides normal clone-branch checkout after the initial clone is available.
- Before persisting the forked/rewound run, validate all store-local invariants: the target checkpoint has `git_commit_sha`, the source run did not record `checkpoints_disabled`, and source/new run specs point at the same normalized `repo_origin_url` using `fabro_github::normalize_repo_origin_url` on both sides. If these checks fail, return a validation-style error and do not create a new run row.
- Fork-from-running is supported, but only after a checkpoint has a git SHA and its run branch is reachable. Because an in-flight source run may still be pushing the checkpoint commit, sandbox reachability validation should use a short retry/wait window before failing.
- Sandbox initialization for a forked run must, before workflow execution starts:
1. Fetch `refs/heads/fabro/run/<source_run_id>` from the sandbox clone's `origin` remote. In this plan, `origin` must correspond to `RunSpec.repo_origin_url`; fork/rewind across different origins is unsupported and should fail validation.
2. Create the new run-branch `refs/heads/fabro/run/<new_run_id>` pointing at `checkpoint_sha`.
3. Check that branch out before the first stage runs.
- If the source run ref fetch fails or the `checkpoint_sha` is not reachable from the fetched source run ref after the retry window, fail sandbox initialization with a clear fork setup error and mark the new run as failed during setup. Do not silently fall back to the sandbox default branch. Direct `git fetch origin <sha>` fallback is out of scope for this plan; source run branches must be pushed and reachable.
- Rewind continues the existing fork-then-archive behavior: create a new run id, create the new run branch at the checkpoint SHA, then archive/supersede the source run. This plan only changes the git setup mechanics; do not reintroduce rewind-in-place behavior.
- If the chosen checkpoint has no `git_commit_sha`, return the existing validation-style error.
- Fork creates the new run's branches/refs through the sandbox via `git_push_ref`, not `git2` against any server-local path.
Required changes — prefix resolution:
- Removing `host_repo_path` also removes repo/path-scoped prefix matching. Replace prefix-scoping logic in `find_run_id_by_prefix_or_store` with global prefix matching. If exactly one run matches, resolve it. If zero, return "not found." If multiple, return an ambiguity error listing each candidate (full run id, created_at, workflow name, origin URL) so the user can disambiguate.
Decisions:
- Server APIs do not require the original source checkout to exist on the Fabro server host.
- Events are the canonical source of run history at runtime; metadata branch is a write-only archive in this plan.
- Prefix ambiguity is a hard CLI error, not a silent filtering decision.
## Phase 6: API, generated clients, docs, and cleanup
Primary files:
- `docs/public/api-reference/fabro-api.yaml`
- `lib/crates/fabro-api/build.rs` and generated output as required
- `lib/packages/fabro-api-client`
- `apps/fabro-web`
- `lib/crates/fabro-cli`
- `docs/internal/events.md`
- `AGENTS.md` (remove any bind-mount references in agent guidance; otherwise drop from primary files)
Required changes:
- Update OpenAPI schemas from `host_repo_path` to `source_directory`.
- Add API/event schemas for `pre_run_git`, `fork_source_ref`, and `checkpoints_disabled` wherever `RunSpec`/`RunCreatedProps` are represented.
- Remove mount fields from sandbox initialized schemas if present.
- Rebuild Rust API types.
- Regenerate TypeScript API client.
- Update web and CLI consumers to display `source_directory` where submitter provenance is wanted and sandbox `working_directory` where execution path is wanted.
- Update CLI fork output to print full run id + workflow name + origin URL when a prefix is ambiguous.
- Update `/api/v1/runs/{id}/timeline` OpenAPI prose and regenerated TypeScript comments so they say the endpoint reads durable run-store checkpoints, not the metadata branch.
- Update docs to state Docker and Daytona are clone-based providers and never bind-mount the source repo.
Decision:
- No API aliases or transitional duplicated fields. Break callers cleanly.
## Test Plan
Focused tests:
- Submitter pre-run git context tests:
- dirty detection, base-branch push, and `display_base_sha` are computed CLI-side and arrive on `RunSpec.pre_run_git`.
- `pre_run_git` round-trips through `RunCreatedProps` and event replay; it is not only present in the in-memory `RunSpec`.
- `PreRunPushOutcome` records success, failure, no-remote, and remote-mismatch cases explicitly.
- non-CLI/server-created runs can leave `pre_run_git` as `None`; initialize uses sandbox setup output for display base SHA when available.
- CLI pre-run push uses `origin`, compares with `repo_origin_url` via `fabro_github::normalize_repo_origin_url`, and is skipped/recorded when they do not match.
- server `pipeline::initialize` no longer calls `git::sync_status` / `branch_needs_push` / `push_branch` / `head_sha`.
- Sandbox git capability tests:
- shared capability probe exercises temp-index plumbing once per run under the sandbox execution path, not `/tmp`.
- probe result is cached by the per-run `SandboxGitRuntime` and shared by lifecycle, finalize, and parallel checkpoint paths.
- missing or broken git fails run-branch checkpointing/fork setup clearly.
- metadata writer uses the same failed probe result to warn once and skip best-effort writes.
- if git or disk availability breaks after a successful probe, the real checkpoint/fork git operation reports the concrete raw error.
- Sandbox metadata writer tests:
- creates metadata branch from an empty ref.
- appends snapshot commits while preserving prior files.
- writes `run.json` (full `RunProjection`) at every checkpoint commit.
- returns the metadata commit SHA.
- does not change current branch, worktree files, or the real index.
- handles binary dump entries.
- pushes the metadata ref best-effort after each successful metadata write.
- metadata write/push degradation emits one warning per run, suppresses repeated checkpoint noise, and emits an end-of-run summary notice.
- Workflow lifecycle regression tests:
- Docker/Daytona-style run with `source_directory = /Users/client/project` never opens that path on the server.
- metadata write success adds `Fabro-Checkpoint` trailer to the run checkpoint commit.
- metadata write failure emits a single warning, does not fail the workflow, and does not remove the run-branch `git_commit_sha` needed for fork.
- run-branch checkpoint failure leaves no forkable checkpoint and produces a clear fork/rewind validation error.
- Sandbox ref push tests:
- `git_push_ref` pushes full run and metadata refspecs, preserves credential refresh, and rejects/non-forces non-fast-forward updates.
- `git_push_via_exec`, `delegate_sandbox!`, Docker, Daytona, local, and worktree implementations all use the full-refspec method.
- Fork via event replay:
- fork at checkpoint seq N reconstructs the historical `RunProjection` via `RunProjection::apply_events` over events with `seq <= N`.
- fork succeeds without any metadata branch read.
- fork's `ForkSourceRef` is optional on `RunSpec` and round-trips through `RunCreatedProps`/event replay: absent for normal runs, present for fork/rewind-created runs, and it overrides normal clone-branch checkout.
- fork setup fetches `refs/heads/fabro/run/<source_run_id>` from the sandbox clone origin and creates `refs/heads/fabro/run/<new_run_id>` at the checkpoint SHA before the first stage runs.
- fork store-local validation fails without creating a new run row when the source run has `checkpoints_disabled`, no target checkpoint SHA, or mismatched `repo_origin_url`.
- fork-from-running retries briefly when the source run ref is not yet reachable, then either succeeds or fails sandbox setup clearly.
- sandbox setup fails clearly when the source run ref cannot be fetched or the checkpoint SHA is unreachable from that ref after the retry window.
- fork returns a clear error when target checkpoint has no git commit SHA.
- Timeline and rewind:
- `TimelineEntry` exposes `checkpoint_seq` (not `metadata_commit_oid`).
- timeline endpoint works from `RunProjection.checkpoints` without a local git repo.
- rewind reuses the fork fetch + branch-from-sha mechanics, creates a new run id, and archives/supersedes the source run.
- rewind target parsing works without opening `source_directory`.
- Repository summary derivation:
- `repo_origin_url` plumbs through `build_summary` and `RunSummary::new`; name prefers origin owner/repo, falls back to `source_directory` basename, then `"unknown"`.
- summary derivation works when `source_directory` is `None`.
- Prefix resolution:
- exact-match prefix resolves to a single run.
- ambiguous prefix returns an error listing every candidate.
- zero matches returns "not found."
- Local strategy:
- default local provider run uses `WorktreeSandbox` for CLI, browser, Slack, webhook, MCP, and scheduled-job submissions and produces checkpoints.
- `WorktreeSandbox` owns `GitSetupIntent::NewRun` and `ForkFromCheckpoint`; it does not delegate branch setup to bare `LocalSandbox`.
- `GitSetupIntent::NewRun` returns `GitRunInfo` with sandbox HEAD SHA as `base_sha`, and fork setup returns `GitRunInfo` for the new run branch.
- `--in-place --allow-no-checkpoints` runs persist `checkpoints_disabled: true` through `RunCreatedProps`, surface that state in list/detail output, skip git checkpointing, and do not create a `fabro/run/...` branch in the user source directory.
- fork/rewind against a run with `checkpoints_disabled: true` fails with an error that names the disabled-checkpoints cause.
- `Sandbox::host_git_dir` is gone or unused by runtime git lifecycle code; `HookLifecycle` still receives the sandbox execution path as cwd when checkpointing is enabled.
- Naming and event tests:
- run created/projection/summary JSON uses `source_directory`.
- sandbox initialized JSON exposes sandbox `working_directory` and no mount fields.
- repository summary name is derived from `repo_origin_url` before `source_directory`.
- timeline OpenAPI/generated client prose says run-store checkpoints, not metadata branch.
Commands:
- `cargo build -p fabro-api`
- `cargo nextest run -p fabro-workflow`
- `cargo nextest run -p fabro-server`
- `cargo nextest run -p fabro-cli`
- `cd lib/packages/fabro-api-client && bun run generate`
- `cd apps/fabro-web && bun run typecheck`
- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`
## Non-goals
- No production data migration.
- No compatibility aliases for old JSON fields.
- No server-side clone cache for metadata writes.
- No Docker bind-mount support.
- No branch mutation in the submitter source tree as a fallback.
- No fork, rewind, or timeline path that reads the metadata branch. Fork-from-archive (recovering a server-purged run from git alone) is deferred to a future plan.
- No prefix scoping by repo or origin. Ambiguous prefixes are user errors.
- No fork/rewind across different repository origins.
- No direct `git fetch origin <sha>` fallback for fork/rewind in this plan. Source run branches must be pushed and reachable from the sandbox clone origin.
- No GitHub repository-ID based origin matching in this plan. Origin equality is normalized URL equality, so repository renames/transfers that change normalized URLs are a known limitation; users should create new runs from the new origin or repair stored metadata in a future recovery tool.

View file

@ -3607,6 +3607,7 @@ components:
- branch
- sha
- clean
- push_outcome
properties:
origin_url:
type: string
@ -3623,6 +3624,31 @@ components:
clean:
type: boolean
description: Whether the working tree has uncommitted changes.
push_outcome:
$ref: "#/components/schemas/ManifestPreRunPushOutcome"
ManifestPreRunPushOutcome:
description: Outcome of the CLI's best-effort pre-run push.
type: object
required:
- type
properties:
type:
type: string
enum:
- not_attempted
- succeeded
- failed
- skipped_no_remote
- skipped_remote_mismatch
remote:
type: ["string", "null"]
branch:
type: ["string", "null"]
message:
type: ["string", "null"]
repo_origin_url:
type: ["string", "null"]
ManifestGoal:
description: Resolved goal with provenance.
@ -3667,6 +3693,12 @@ components:
type: boolean
preserve_sandbox:
type: boolean
in_place:
type: boolean
description: Run against the submitted source directory directly.
allow_no_checkpoints:
type: boolean
description: Required with `in_place`; disables git checkpointing.
label:
type: array
items:
@ -4522,7 +4554,11 @@ components:
type: object
additionalProperties:
type: string
host_repo_path:
source_directory:
type: ["string", "null"]
checkpoints_disabled:
type: boolean
repo_origin_url:
type: ["string", "null"]
repository:
$ref: "#/components/schemas/RepositoryReference"
@ -4614,6 +4650,7 @@ components:
- ordinal
- node_name
- visit
- checkpoint_seq
properties:
ordinal:
type: integer
@ -4623,6 +4660,9 @@ components:
visit:
type: integer
minimum: 1
checkpoint_seq:
type: integer
minimum: 1
run_commit_sha:
type: ["string", "null"]
@ -5173,7 +5213,11 @@ components:
type: object
additionalProperties:
type: string
host_repo_path:
source_directory:
type: ["string", "null"]
checkpoints_disabled:
type: boolean
repo_origin_url:
type: ["string", "null"]
start_time:
type: ["string", "null"]

View file

@ -63,7 +63,7 @@ The Docker sandbox runs all tool operations inside a Docker container. The host
### How it works
- **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** — The host working directory is bind-mounted at `/workspace` inside the container. All relative paths resolve against this mount point.
- **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.
- **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.
@ -75,7 +75,6 @@ The Docker sandbox is configured through the `DockerSandboxConfig`:
| Setting | Default | Description |
|---|---|---|
| `image` | `fabro-agent:latest` | Docker image to use |
| `container_mount_point` | `/workspace` | Mount point inside the container |
| `network_mode` | `bridge` | Docker network mode |
| `extra_mounts` | `[]` | Additional `host:container` bind mounts |
| `memory_limit` | unlimited | Memory limit in bytes |

View file

@ -146,7 +146,7 @@ pub trait Sandbox: Send + Sync {
fn working_directory(&self) -> &str;
fn platform(&self) -> &str;
fn os_version(&self) -> String;
// ... optional methods with defaults: setup_git_for_run(), git_push_branch(), etc.
// ... optional methods with defaults: setup_git(), git_push_ref(), etc.
}
```

View file

@ -27,6 +27,8 @@ fn run_summary_json_matches_openapi_shape() {
String::new(),
HashMap::from([("team".to_string(), "core".to_string())]),
Some("/tmp/fabro".to_string()),
false,
None,
Some(created_at),
RunStatus::Archived {
prior: TerminalStatus::Succeeded {
@ -50,7 +52,9 @@ fn run_summary_json_matches_openapi_shape() {
"labels": {
"team": "core"
},
"host_repo_path": "/tmp/fabro",
"source_directory": "/tmp/fabro",
"checkpoints_disabled": false,
"repo_origin_url": null,
"repository": {
"name": "fabro"
},
@ -97,7 +101,7 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
assert_eq!(summary.goal, "ship it");
assert_eq!(summary.title, "ship it");
assert_eq!(summary.labels, HashMap::new());
assert_eq!(summary.host_repo_path, None);
assert_eq!(summary.source_directory, None);
assert_eq!(summary.repository, RepositoryReference {
name: "fabro".to_string(),
});

View file

@ -2,7 +2,6 @@ pub mod author;
pub mod branch;
pub mod error;
pub mod git;
pub mod metadata;
pub mod trailer;
pub const META_BRANCH_PREFIX: &str = "fabro/meta/";

View file

@ -1,445 +0,0 @@
use std::path::{Path, PathBuf};
use fabro_store::RunProjection;
use fabro_types::{Checkpoint, RunSpec, StartRecord};
use git2::{Repository, Signature};
use crate::META_BRANCH_PREFIX;
use crate::author::GitAuthor;
use crate::branch::BranchStore;
use crate::error::{Error, MetadataError};
use crate::git::Store;
/// Git-native metadata storage for pipeline runs.
///
/// Stores a unified `RunProjection` snapshot (plus artifacts) on an orphan
/// branch (`fabro/meta/{run_id}`) so that runs can be resumed from git alone.
pub struct MetadataStore {
repo_path: PathBuf,
author: GitAuthor,
}
impl MetadataStore {
pub fn new(repo_path: impl Into<PathBuf>, author: &GitAuthor) -> Self {
Self {
repo_path: repo_path.into(),
author: author.clone(),
}
}
/// Returns the branch name for a run: `fabro/meta/{run_id}`.
pub fn branch_name(run_id: &str) -> String {
format!("{META_BRANCH_PREFIX}{run_id}")
}
/// Format a commit message with the standard Fabro footer appended.
fn commit_message(&self, subject: &str) -> String {
let mut msg = format!("{subject}\n");
self.author.append_footer(&mut msg);
msg
}
fn open_store(&self) -> Result<(Store, Signature<'static>), MetadataError> {
let repo = Repository::discover(&self.repo_path).map_err(Error::from)?;
let store = Store::new(repo);
let sig = Signature::now(&self.author.name, &self.author.email).map_err(Error::from)?;
Ok((store, sig))
}
/// Initialize a run's metadata branch with the given files.
pub fn init_run(&self, run_id: &str, files: &[(&str, &[u8])]) -> Result<(), MetadataError> {
let (store, sig) = self.open_store()?;
let branch = Self::branch_name(run_id);
let branch_store = BranchStore::new(&store, &branch, &sig);
branch_store.ensure_branch()?;
let message = self.commit_message("init run");
branch_store.write_entries(files, &message)?;
Ok(())
}
/// Write a snapshot commit to the metadata branch and return the new
/// commit SHA.
pub fn write_snapshot(
&self,
run_id: &str,
entries: &[(&str, &[u8])],
message: &str,
) -> Result<String, MetadataError> {
let (store, sig) = self.open_store()?;
let branch = Self::branch_name(run_id);
let branch_store = BranchStore::new(&store, &branch, &sig);
let message = self.commit_message(message);
let oid = branch_store.write_entries(entries, &message)?;
Ok(oid.to_string())
}
/// Read a single file from the metadata branch. Returns `None` if branch or
/// path doesn't exist.
fn read_file(
repo_path: &Path,
run_id: &str,
path: &str,
) -> Result<Option<Vec<u8>>, MetadataError> {
let Ok(repo) = Repository::discover(repo_path) else {
return Ok(None);
};
let store = Store::new(repo);
let sig = Signature::now("Fabro", "noreply@fabro.sh").map_err(Error::from)?;
let branch = Self::branch_name(run_id);
let branch_store = BranchStore::new(&store, &branch, &sig);
Ok(branch_store.read_entry(path)?)
}
/// Read the projection snapshot from the metadata branch tip. Returns
/// `None` if branch or file doesn't exist.
pub fn read_run_projection(
repo_path: &Path,
run_id: &str,
) -> Result<Option<RunProjection>, MetadataError> {
let branch = Self::branch_name(run_id);
let Some(bytes) = Self::read_file(repo_path, run_id, "run.json")? else {
return Ok(None);
};
let projection: RunProjection =
serde_json::from_slice(&bytes).map_err(|source| MetadataError::Deserialize {
entity: "run projection",
branch,
source,
})?;
Ok(Some(projection))
}
/// Read a checkpoint from the metadata branch. Returns `None` if branch or
/// file doesn't exist.
pub fn read_checkpoint(
repo_path: &Path,
run_id: &str,
) -> Result<Option<Checkpoint>, MetadataError> {
Ok(Self::read_run_projection(repo_path, run_id)?
.and_then(|projection| projection.checkpoint))
}
/// Read the run spec from the metadata branch. Returns `None` if not
/// found.
pub fn read_run_spec(repo_path: &Path, run_id: &str) -> Result<Option<RunSpec>, MetadataError> {
Ok(Self::read_run_projection(repo_path, run_id)?.and_then(|projection| projection.spec))
}
/// Read the start record from the metadata branch. Returns `None` if not
/// found.
pub fn read_start_record(
repo_path: &Path,
run_id: &str,
) -> Result<Option<StartRecord>, MetadataError> {
Ok(Self::read_run_projection(repo_path, run_id)?.and_then(|projection| projection.start))
}
/// Read an artifact from the metadata branch. Returns `None` if not found.
pub fn read_artifact(
repo_path: &Path,
run_id: &str,
key: &str,
) -> Result<Option<Vec<u8>>, MetadataError> {
Self::read_file(repo_path, run_id, &format!("artifacts/{key}.json"))
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::disallowed_methods,
reason = "These unit tests use the real git CLI to validate metadata branch behavior."
)]
use std::collections::HashMap;
use chrono::{TimeZone, Utc};
use fabro_types::{Graph, WorkflowSettings, fixtures};
use super::*;
/// Create a temporary git repo with an initial commit.
fn init_repo(dir: &Path) {
std::process::Command::new("git")
.args(["init"])
.current_dir(dir)
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(dir)
.output()
.unwrap();
}
fn test_run_spec(run_id: fabro_types::RunId) -> RunSpec {
RunSpec {
run_id,
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
workflow_slug: None,
working_directory: PathBuf::from("/tmp"),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
}
}
fn test_checkpoint(
current_node: &str,
completed_nodes: Vec<String>,
next_node_id: Option<String>,
) -> Checkpoint {
Checkpoint {
timestamp: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap(),
current_node: current_node.to_string(),
completed_nodes,
node_retries: HashMap::new(),
context_values: HashMap::new(),
node_outcomes: HashMap::new(),
next_node_id,
git_commit_sha: None,
loop_failure_signatures: HashMap::new(),
restart_failure_signatures: HashMap::new(),
node_visits: HashMap::new(),
}
}
fn test_projection(run_id: fabro_types::RunId) -> RunProjection {
let mut projection = RunProjection::default();
projection.spec = Some(test_run_spec(run_id));
projection
}
fn projection_bytes(projection: &RunProjection) -> Vec<u8> {
serde_json::to_vec_pretty(projection).unwrap()
}
fn branch_entry(repo_dir: &Path, run_id: &str, path: &str) -> Vec<u8> {
let repo = Repository::discover(repo_dir).unwrap();
let store = Store::new(repo);
let sig = Signature::now("Test", "test@example.com").unwrap();
let branch = MetadataStore::branch_name(run_id);
let branch_store = BranchStore::new(&store, &branch, &sig);
branch_store.read_entry(path).unwrap().unwrap()
}
#[test]
fn metadata_store_init_run_and_read() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
let run_id = fixtures::RUN_1.to_string();
let projection = projection_bytes(&test_projection(fixtures::RUN_1));
store
.init_run(&run_id, &[("run.json", &projection)])
.unwrap();
let read_spec = MetadataStore::read_run_spec(dir.path(), &run_id)
.unwrap()
.unwrap();
assert_eq!(read_spec.run_id, fixtures::RUN_1);
assert_eq!(read_spec.graph.name, "test");
}
#[test]
fn metadata_store_write_and_read_checkpoint() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let run_id = fixtures::RUN_2.to_string();
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
let init_projection = projection_bytes(&test_projection(fixtures::RUN_2));
store
.init_run(&run_id, &[("run.json", &init_projection)])
.unwrap();
let mut checkpoint = test_checkpoint(
"node_a",
vec!["start".to_string()],
Some("node_b".to_string()),
);
checkpoint
.context_values
.insert("goal".to_string(), serde_json::json!("test"));
let mut snapshot = test_projection(fixtures::RUN_2);
snapshot.checkpoint = Some(checkpoint);
let snapshot_json = projection_bytes(&snapshot);
store
.write_snapshot(&run_id, &[("run.json", &snapshot_json)], "checkpoint")
.unwrap();
let loaded = MetadataStore::read_checkpoint(dir.path(), &run_id)
.unwrap()
.unwrap();
assert_eq!(loaded.current_node, "node_a");
assert_eq!(loaded.completed_nodes, vec!["start"]);
assert_eq!(loaded.next_node_id.as_deref(), Some("node_b"));
assert_eq!(
loaded.context_values.get("goal"),
Some(&serde_json::json!("test"))
);
}
#[test]
fn metadata_store_write_checkpoint_overwrites() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let run_id = fixtures::RUN_3.to_string();
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
let init_projection = projection_bytes(&test_projection(fixtures::RUN_3));
store
.init_run(&run_id, &[("run.json", &init_projection)])
.unwrap();
let mut snapshot_one = test_projection(fixtures::RUN_3);
snapshot_one.checkpoint = Some(test_checkpoint("node_a", vec!["start".to_string()], None));
let checkpoint_one = projection_bytes(&snapshot_one);
store
.write_snapshot(&run_id, &[("run.json", &checkpoint_one)], "checkpoint")
.unwrap();
let mut snapshot_two = test_projection(fixtures::RUN_3);
snapshot_two.checkpoint = Some(test_checkpoint(
"node_b",
vec!["start".to_string(), "node_a".to_string()],
Some("node_c".to_string()),
));
let checkpoint_two = projection_bytes(&snapshot_two);
store
.write_snapshot(&run_id, &[("run.json", &checkpoint_two)], "checkpoint")
.unwrap();
let loaded = MetadataStore::read_checkpoint(dir.path(), &run_id)
.unwrap()
.unwrap();
assert_eq!(loaded.current_node, "node_b");
assert_eq!(loaded.completed_nodes.len(), 2);
}
#[test]
fn metadata_store_read_checkpoint_missing_branch() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let result = MetadataStore::read_checkpoint(dir.path(), "NONEXISTENT").unwrap();
assert!(result.is_none());
}
#[test]
fn metadata_store_artifact_roundtrip() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let run_id = fixtures::RUN_4.to_string();
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
let init_projection = projection_bytes(&test_projection(fixtures::RUN_4));
store
.init_run(&run_id, &[("run.json", &init_projection)])
.unwrap();
let artifact_data = br#"{"large_output":"some data"}"#;
let mut snapshot = test_projection(fixtures::RUN_4);
snapshot.checkpoint = Some(test_checkpoint("node_a", Vec::new(), None));
let snapshot_json = projection_bytes(&snapshot);
store
.write_snapshot(
&run_id,
&[
("run.json", &snapshot_json),
("artifacts/response.plan.json", artifact_data.as_slice()),
],
"checkpoint",
)
.unwrap();
let read_back = MetadataStore::read_artifact(dir.path(), &run_id, "response.plan")
.unwrap()
.unwrap();
assert_eq!(read_back, artifact_data);
}
#[test]
fn metadata_store_write_snapshot_preserves_prior_files() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let run_id = fixtures::RUN_5.to_string();
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
let projection = projection_bytes(&test_projection(fixtures::RUN_5));
store
.init_run(&run_id, &[("run.json", &projection)])
.unwrap();
store
.write_snapshot(
&run_id,
&[("stages/retro/prompt.md", b"how did it go?")],
"finalize run",
)
.unwrap();
let data = branch_entry(dir.path(), &run_id, "stages/retro/prompt.md");
assert_eq!(data, b"how did it go?");
let spec = MetadataStore::read_run_spec(dir.path(), &run_id)
.unwrap()
.unwrap();
assert_eq!(spec.run_id, fixtures::RUN_5);
}
#[test]
fn metadata_store_init_run_with_extra_files() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let run_id = fixtures::RUN_6.to_string();
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
store
.init_run(&run_id, &[("graph.fabro", b"digraph Test {}")])
.unwrap();
let data = branch_entry(dir.path(), &run_id, "graph.fabro");
assert_eq!(data, b"digraph Test {}");
}
#[test]
fn metadata_store_read_start_record_roundtrip() {
let dir = tempfile::tempdir().unwrap();
init_repo(dir.path());
let run_id = fixtures::RUN_6.to_string();
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
let start_record = StartRecord {
run_id: fixtures::RUN_6,
start_time: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap(),
run_branch: Some("fabro/run/test".to_string()),
base_sha: None,
};
let mut projection = test_projection(fixtures::RUN_6);
projection.start = Some(start_record);
let bytes = projection_bytes(&projection);
store.init_run(&run_id, &[("run.json", &bytes)]).unwrap();
let loaded = MetadataStore::read_start_record(dir.path(), &run_id)
.unwrap()
.unwrap();
assert_eq!(loaded.run_id, fixtures::RUN_6);
assert_eq!(loaded.run_branch.as_deref(), Some("fabro/run/test"));
}
}

View file

@ -236,6 +236,14 @@ pub(crate) struct RunArgs {
#[arg(long, value_enum)]
pub(crate) sandbox: Option<CliSandboxProvider>,
/// Run directly in the source checkout without git checkpoints
#[arg(long, requires = "allow_no_checkpoints", conflicts_with = "sandbox")]
pub(crate) in_place: bool,
/// Acknowledge that --in-place disables checkpoints
#[arg(long, requires = "in_place")]
pub(crate) allow_no_checkpoints: bool,
/// Attach a label to this run (repeatable, format: KEY=VALUE)
#[arg(long = "label", value_name = "KEY=VALUE")]
pub(crate) label: Vec<String>,

View file

@ -113,6 +113,8 @@ pub(crate) fn print_timeline(entries: &[TimelineEntryJson], styles: &Styles, pri
reason = "The checkpoint timeline table is operator feedback, not command output."
)]
if let Ok(display) = table.display() {
eprintln!("{display}");
for line in display.to_string().lines() {
eprintln!("{}", line.trim_end());
}
}
}

View file

@ -673,14 +673,12 @@ mod tests {
let events = vec![
stage_started("code", "Code"),
Event::SandboxInitialized {
working_directory: "/home/daytona/workspace".into(),
provider: "daytona".into(),
identifier: None,
host_working_directory: None,
container_mount_point: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
working_directory: "/home/daytona/workspace".into(),
provider: "daytona".into(),
identifier: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
},
agent_event("code", AgentEvent::ToolCallStarted {
tool_name: "read_file".into(),
@ -867,14 +865,12 @@ mod tests {
emit(&mut ui, stage_started("code", "Code"));
emit(&mut ui, Event::SandboxInitialized {
working_directory: "/home/daytona/workspace".into(),
provider: "daytona".into(),
identifier: None,
host_working_directory: None,
container_mount_point: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
working_directory: "/home/daytona/workspace".into(),
provider: "daytona".into(),
identifier: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
});
emit(
&mut ui,

View file

@ -44,7 +44,9 @@ pub(crate) async fn list_command(
"labels": run.labels(),
"duration_ms": run.duration_ms(),
"total_usd_micros": run.total_usd_micros(),
"host_repo_path": run.host_repo_path(),
"source_directory": run.source_directory(),
"repo_origin_url": run.repo_origin_url(),
"checkpoints_disabled": run.checkpoints_disabled(),
"goal": run.goal(),
})
})
@ -81,6 +83,7 @@ pub(crate) async fn list_command(
"RUN ID".cell().bold(use_color),
"WORKFLOW".cell().bold(use_color),
"STATUS".cell().bold(use_color),
"CHECKPOINTS".cell().bold(use_color),
"DIRECTORY".cell().bold(use_color),
"DURATION".cell().bold(use_color),
"GOAL".cell().bold(use_color),
@ -100,7 +103,7 @@ pub(crate) async fn list_command(
},
};
let dir_display = run
.host_repo_path()
.source_directory()
.map_or_else(|| "-".to_string(), |p| tilde_path(Path::new(p)));
let run_id = run.run_id().to_string();
@ -110,6 +113,7 @@ pub(crate) async fn list_command(
.foreground_color(color_if(use_color, Color::Ansi256(8))),
run.workflow_name().cell(),
status_cell(run.status(), use_color),
checkpoints_cell(run.checkpoints_disabled(), use_color),
dir_display.cell(),
duration_display.cell(),
truncate_goal(&run.goal(), 50)
@ -136,6 +140,17 @@ pub(crate) async fn list_command(
Ok(())
}
fn checkpoints_cell(disabled: bool, use_color: bool) -> CellStruct {
if disabled {
return "disabled"
.cell()
.foreground_color(color_if(use_color, Color::Yellow));
}
"enabled"
.cell()
.foreground_color(color_if(use_color, Color::Ansi256(8)))
}
fn status_cell(status: RunStatus, use_color: bool) -> CellStruct {
let text = run_status_kind(status);
let color = match status {

View file

@ -7,16 +7,15 @@ use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use fabro_api::types;
use fabro_api::types::{self, ManifestPreRunPushOutcome, ManifestPreRunPushOutcomeType};
use fabro_config::project::{self, discover_project_config, resolve_workflow_path};
use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_namespace};
use fabro_config::{CliLayer, DaytonaDockerfileLayer, RunLayer, WorkflowSettingsBuilder};
use fabro_graphviz::graph::AttrValue;
use fabro_graphviz::parser;
use fabro_sandbox::daytona::detect_repo_info;
use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal};
use fabro_types::{RunId, WorkflowSettings};
use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status};
use fabro_workflow::git::{GitSyncStatus, branch_needs_push, head_sha, push_branch, sync_status};
use crate::args::{PreflightArgs, RunArgs};
@ -162,36 +161,44 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
pub(crate) fn run_manifest_args(args: &RunArgs) -> Option<types::ManifestArgs> {
let payload = types::ManifestArgs {
auto_approve: args.auto_approve.then_some(true),
dry_run: args.dry_run.then_some(true),
label: args.label.clone(),
model: args.model.clone(),
no_retro: args.no_retro.then_some(true),
preserve_sandbox: args.preserve_sandbox.then_some(true),
provider: args.provider.clone(),
sandbox: args
auto_approve: args.auto_approve.then_some(true),
dry_run: args.dry_run.then_some(true),
label: args.label.clone(),
model: args.model.clone(),
no_retro: args.no_retro.then_some(true),
preserve_sandbox: args.preserve_sandbox.then_some(true),
provider: args.provider.clone(),
sandbox: args
.sandbox
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()),
docker_image: None,
verbose: args.verbose.then_some(true),
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string())
.or_else(|| {
args.in_place
.then(|| fabro_sandbox::SandboxProvider::Local.to_string())
}),
docker_image: None,
verbose: args.verbose.then_some(true),
in_place: args.in_place.then_some(true),
allow_no_checkpoints: args.allow_no_checkpoints.then_some(true),
};
(!manifest_args_is_empty(&payload)).then_some(payload)
}
pub(crate) fn preflight_manifest_args(args: &PreflightArgs) -> Option<types::ManifestArgs> {
let payload = types::ManifestArgs {
auto_approve: None,
dry_run: None,
label: Vec::new(),
model: args.model.clone(),
no_retro: None,
preserve_sandbox: None,
provider: args.provider.clone(),
sandbox: args
auto_approve: None,
dry_run: None,
label: Vec::new(),
model: args.model.clone(),
no_retro: None,
preserve_sandbox: None,
provider: args.provider.clone(),
sandbox: args
.sandbox
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()),
docker_image: None,
verbose: args.verbose.then_some(true),
docker_image: None,
verbose: args.verbose.then_some(true),
in_place: None,
allow_no_checkpoints: None,
};
(!manifest_args_is_empty(&payload)).then_some(payload)
}
@ -493,18 +500,76 @@ fn resolved_goal_to_manifest(resolved: ResolvedRunGoal) -> types::ManifestGoal {
}
fn build_manifest_git(repo_path: &Path) -> Option<types::ManifestGit> {
let (origin_url, branch) = detect_repo_info(repo_path).ok()?;
let branch = branch?;
let (origin_url, branch) = detect_manifest_repo_info(repo_path)?;
let sha = head_sha(repo_path).ok()?;
let clean = sync_status(repo_path, "origin", Some(&branch)) != GitSyncStatus::Dirty;
let status = sync_status(repo_path, "origin", Some(&branch));
let clean = status != GitSyncStatus::Dirty;
let push_outcome = build_manifest_push_outcome(repo_path, &branch, origin_url.as_deref());
Some(types::ManifestGit {
branch,
clean,
origin_url: fabro_github::normalize_repo_origin_url(&origin_url),
origin_url: origin_url
.as_deref()
.map(fabro_github::normalize_repo_origin_url)
.unwrap_or_default(),
push_outcome,
sha,
})
}
fn detect_manifest_repo_info(repo_path: &Path) -> Option<(Option<String>, String)> {
let repo = git2::Repository::discover(repo_path).ok()?;
let branch = repo.head().ok()?.shorthand().map(ToOwned::to_owned)?;
let origin_url = repo
.find_remote("origin")
.ok()
.and_then(|remote| remote.url().map(ToOwned::to_owned));
Some((origin_url, branch))
}
fn build_manifest_push_outcome(
repo_path: &Path,
branch: &str,
origin_url: Option<&str>,
) -> ManifestPreRunPushOutcome {
if origin_url.is_none() {
return ManifestPreRunPushOutcome {
type_: ManifestPreRunPushOutcomeType::SkippedNoRemote,
remote: None,
branch: Some(branch.to_string()),
message: None,
repo_origin_url: None,
};
}
if !branch_needs_push(repo_path, "origin", branch) {
return ManifestPreRunPushOutcome {
type_: ManifestPreRunPushOutcomeType::NotAttempted,
remote: None,
branch: None,
message: None,
repo_origin_url: None,
};
}
match push_branch(repo_path, "origin", branch) {
Ok(()) => ManifestPreRunPushOutcome {
type_: ManifestPreRunPushOutcomeType::Succeeded,
remote: Some("origin".to_string()),
branch: Some(branch.to_string()),
message: None,
repo_origin_url: None,
},
Err(err) => ManifestPreRunPushOutcome {
type_: ManifestPreRunPushOutcomeType::Failed,
remote: Some("origin".to_string()),
branch: Some(branch.to_string()),
message: Some(err.to_string()),
repo_origin_url: None,
},
}
}
fn normalize_absolute_path(base_dir: &Path, reference: &str) -> Option<PathBuf> {
let path = Path::new(reference);
if path.is_absolute() || reference.starts_with('~') {

View file

@ -60,8 +60,16 @@ impl ServerRunSummaryInfo {
self.summary.total_usd_micros
}
pub(crate) fn host_repo_path(&self) -> Option<&str> {
self.summary.host_repo_path.as_deref()
pub(crate) fn source_directory(&self) -> Option<&str> {
self.summary.source_directory.as_deref()
}
pub(crate) fn repo_origin_url(&self) -> Option<&str> {
self.summary.repo_origin_url.as_deref()
}
pub(crate) fn checkpoints_disabled(&self) -> bool {
self.summary.checkpoints_disabled
}
pub(crate) fn goal(&self) -> String {

View file

@ -221,7 +221,7 @@ fn archive_resolves_selector_via_server_endpoint() {
"goal": "Nightly run",
"title": "Nightly run",
"labels": {},
"host_repo_path": null,
"source_directory": null,
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",

View file

@ -159,6 +159,7 @@ fn attach_replays_completed_detached_run() {
exit_code: 0
----- stdout -----
----- stderr -----
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
Start [TIME]
Run Tests [TIME]
@ -265,6 +266,7 @@ fn attach_before_completion_streams_to_finished_state() {
exit_code: 0
----- stdout -----
----- stderr -----
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
start [DURATION]
wait [DURATION]
@ -453,6 +455,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
"event": "run.created",
"id": "[EVENT_ID]",
"properties": {
"checkpoints_disabled": false,
"graph": {
"attrs": {
"goal": {
@ -553,7 +556,6 @@ fn attach_json_errors_without_prompting_for_human_input() {
}
}
},
"host_repo_path": "[TEMP_DIR]",
"manifest_blob": "[BLOB_ID]",
"provenance": {
"client": {
@ -633,7 +635,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
},
"env": {},
"local": {
"worktree_mode": "clean"
"worktree_mode": "always"
},
"preserve": false,
"provider": "local"
@ -653,9 +655,9 @@ fn attach_json_errors_without_prompting_for_human_input() {
"name": null
}
},
"source_directory": "[TEMP_DIR]",
"workflow_slug": "human-gate",
"workflow_source": "digraph HumanGate {/n graph [goal=\"Wait for approval\"]/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n approve [shape=hexagon, label=\"Approve?\"]/n ship [shape=parallelogram, script=\"echo shipped\"]/n revise [shape=parallelogram, script=\"echo revised\"]/n start -> approve/n approve -> ship [label=\"[A] Approve\"]/n approve -> revise [label=\"[R] Revise\"]/n ship -> exit/n revise -> exit/n}/n",
"working_directory": "[TEMP_DIR]"
"workflow_source": "digraph HumanGate {/n graph [goal=\"Wait for approval\"]/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n approve [shape=hexagon, label=\"Approve?\"]/n ship [shape=parallelogram, script=\"echo shipped\"]/n revise [shape=parallelogram, script=\"echo revised\"]/n start -> approve/n approve -> ship [label=\"[A] Approve\"]/n approve -> revise [label=\"[R] Revise\"]/n ship -> exit/n revise -> exit/n}/n"
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
@ -688,6 +690,22 @@ fn attach_json_errors_without_prompting_for_human_input() {
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"actor": {
"display": "system:worker",
"id": "worker",
"kind": "system"
},
"event": "run.notice",
"id": "[EVENT_ID]",
"properties": {
"code": "dirty_worktree",
"level": "warn",
"message": "Uncommitted changes will not be included in the worktree."
},
"run_id": "[ULID]",
"ts": "[TIMESTAMP]"
},
{
"actor": {
"display": "system:worker",

View file

@ -52,6 +52,8 @@ fn help() {
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona]
--in-place Run directly in the source checkout without git checkpoints
--allow-no-checkpoints Acknowledge that --in-place disables checkpoints
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--no-retro Skip retro generation after the run
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)

View file

@ -270,9 +270,9 @@ fn dump_exports_completed_run_snapshot() {
");
assert_snapshot!(dump_file_summary(&output_dir), @"
checkpoints/0013.json
checkpoints/0017.json
checkpoints/0021.json
checkpoints/0014.json
checkpoints/0018.json
checkpoints/0022.json
events.jsonl
graph.fabro
run.json

View file

@ -2,8 +2,8 @@ use fabro_test::{fabro_snapshot, run_and_format, test_context};
use insta::assert_snapshot;
use super::support::{
git_filters, git_show_json, git_stdout, metadata_run_ids, run_branch_commits,
run_branch_commits_since_base, setup_git_backed_changed_run,
git_filters, output_stdout, run_branch_commits_since_base, run_state_by_id,
setup_git_backed_changed_run,
};
#[test]
@ -56,7 +56,6 @@ fn fork_outside_git_repo_errors() {
fn fork_latest_prints_new_run_and_resume_hint() {
let context = test_context!();
let setup = setup_git_backed_changed_run(&context);
let before = metadata_run_ids(&setup.repo_dir);
let mut cmd = context.command();
cmd.current_dir(&setup.repo_dir);
@ -73,32 +72,12 @@ fn fork_latest_prints_new_run_and_resume_hint() {
To resume: fabro resume [RUN_PREFIX]
");
assert!(output.status.success(), "fork should succeed");
let after = metadata_run_ids(&setup.repo_dir);
let new_run_ids: Vec<_> = after.difference(&before).cloned().collect();
assert_eq!(
new_run_ids.len(),
1,
"fork should create one new run branch"
);
let new_run_id = &new_run_ids[0];
let new_head = git_stdout(&setup.repo_dir, &[
"rev-parse",
&format!("fabro/run/{new_run_id}"),
]);
let expected_head = run_branch_commits(&setup.repo_dir, &setup.run.run_id)
.into_iter()
.last()
.expect("source run should have a last run commit");
assert_eq!(new_head.trim(), expected_head);
}
#[test]
fn fork_from_earlier_checkpoint_uses_expected_sha() {
let context = test_context!();
let setup = setup_git_backed_changed_run(&context);
let before = metadata_run_ids(&setup.repo_dir);
let expected_head =
run_branch_commits_since_base(&setup.repo_dir, &setup.run.run_id, &setup.base_sha)
.into_iter()
@ -108,7 +87,7 @@ fn fork_from_earlier_checkpoint_uses_expected_sha() {
let output = context
.command()
.current_dir(&setup.repo_dir)
.args(["fork", &setup.run.run_id, "@1", "--no-push"])
.args(["fork", &setup.run.run_id, "@2", "--json", "--no-push"])
.output()
.expect("fork should execute");
assert!(
@ -118,37 +97,41 @@ fn fork_from_earlier_checkpoint_uses_expected_sha() {
String::from_utf8_lossy(&output.stderr)
);
let after = metadata_run_ids(&setup.repo_dir);
let new_run_ids: Vec<_> = after.difference(&before).cloned().collect();
let fork_response: serde_json::Value =
serde_json::from_str(&output_stdout(&output)).expect("fork json should parse");
let new_run_id = fork_response["new_run_id"]
.as_str()
.expect("fork json should include new_run_id");
let run_snapshot = run_state_by_id(&context, new_run_id);
assert_eq!(
new_run_ids.len(),
1,
"fork should create one new run branch"
);
let new_run_id = &new_run_ids[0];
let new_head = git_stdout(&setup.repo_dir, &[
"rev-parse",
&format!("fabro/run/{new_run_id}"),
]);
assert_eq!(new_head.trim(), expected_head);
let run_snapshot = git_show_json(
&setup.repo_dir,
&format!("fabro/meta/{new_run_id}:run.json"),
);
assert_eq!(
run_snapshot["checkpoint"]["current_node"].as_str(),
run_snapshot
.checkpoint
.as_ref()
.map(|checkpoint| checkpoint.current_node.as_str()),
Some("step_one")
);
assert_eq!(
run_snapshot["checkpoint"]["git_commit_sha"].as_str(),
run_snapshot
.checkpoint
.as_ref()
.and_then(|checkpoint| checkpoint.git_commit_sha.as_deref()),
Some(expected_head.as_str())
);
let expected_branch = format!("fabro/run/{new_run_id}");
assert_eq!(
run_snapshot["start"]["run_branch"].as_str(),
Some(expected_branch.as_str())
run_snapshot
.spec
.as_ref()
.and_then(|spec| spec.fork_source_ref.as_ref())
.map(|source| source.checkpoint_sha.as_str()),
Some(expected_head.as_str())
);
assert_eq!(
run_snapshot
.spec
.as_ref()
.and_then(|spec| spec.fork_source_ref.as_ref())
.map(|source| source.source_run_id.to_string()),
Some(setup.run.run_id.clone())
);
}

View file

@ -17,7 +17,7 @@ fn remote_run_summary(run_id: &str, status: &serde_json::Value) -> serde_json::V
"goal": "Inspect remote state",
"title": "Inspect remote state",
"labels": {},
"host_repo_path": "/srv/repo",
"source_directory": "/srv/repo",
"repository": { "name": "repo" },
"start_time": "2026-04-19T12:00:00Z",
"created_at": "2026-04-19T12:00:00Z",

View file

@ -184,10 +184,11 @@ fn logs_pretty_formats_small_run() {
let mut cmd = context.command();
cmd.args(["logs", "--pretty", &run.run_id]);
fabro_snapshot!(filters, cmd, @r#"
fabro_snapshot!(filters, cmd, @"
success: true
exit_code: 0
----- stdout -----
[CLOCK] Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
[CLOCK] Sandbox: local [DURATION]
[CLOCK] Simple [ULID]
Run tests and report results
@ -205,7 +206,7 @@ fn logs_pretty_formats_small_run() {
[CLOCK] Exit [DURATION]
[CLOCK] SUCCESS [DURATION]
----- stderr -----
"#);
");
}
#[test]

View file

@ -351,7 +351,7 @@ fn ps_uses_configured_server_target_without_server_flag() {
"labels": {
"suite": "remote"
},
"host_repo_path": "/srv/repo",
"source_directory": "/srv/repo",
"repository": { "name": "repo" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
@ -386,7 +386,7 @@ fn ps_uses_configured_server_target_without_server_flag() {
mock.assert();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0]["workflow_name"], "Remote Workflow");
assert_eq!(runs[0]["host_repo_path"], "/srv/repo");
assert_eq!(runs[0]["source_directory"], "/srv/repo");
}
#[test]
@ -407,7 +407,7 @@ fn ps_explicit_remote_target_ignores_broken_local_storage_settings() {
"goal": "Remote goal",
"title": "Remote goal",
"labels": {},
"host_repo_path": "/srv/repo",
"source_directory": "/srv/repo",
"repository": { "name": "repo" },
"start_time": "2026-04-20T12:00:00Z",
"created_at": "2026-04-20T12:00:00Z",

View file

@ -5,7 +5,7 @@
use fabro_test::{fabro_snapshot, test_context};
use super::support::{git_stdout, output_stderr, setup_git_backed_changed_run};
use super::support::{git_stdout, output_stderr, run_state_by_id, setup_git_backed_changed_run};
const SHARED_DAEMON_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
@ -63,10 +63,10 @@ fn resume_rewound_run_succeeds() {
let setup = setup_git_backed_changed_run(&context);
let new_run_id = rewind_replacement_run_id(&context, &setup);
let rewound_head = git_stdout(&setup.repo_dir, &[
"rev-parse",
&format!("fabro/run/{new_run_id}"),
]);
let rewound_head = run_state_by_id(&context, &new_run_id)
.checkpoint
.and_then(|checkpoint| checkpoint.git_commit_sha)
.expect("rewound run should record checkpoint sha");
let mut resume_cmd = context.command();
resume_cmd.current_dir(&setup.repo_dir);
@ -95,7 +95,7 @@ fn resume_rewound_run_succeeds() {
"rev-parse",
&format!("fabro/run/{new_run_id}"),
]);
assert_ne!(resumed_head.trim(), rewound_head.trim());
assert_ne!(resumed_head.trim(), rewound_head);
}
#[test]
@ -141,7 +141,7 @@ fn rewind_replacement_run_id(
let rewind = context
.command()
.current_dir(&setup.repo_dir)
.args(["rewind", &setup.run.run_id, "@1", "--no-push", "--json"])
.args(["rewind", &setup.run.run_id, "@2", "--no-push", "--json"])
.output()
.expect("rewind should execute");
assert!(

View file

@ -2,8 +2,8 @@ use fabro_test::{fabro_snapshot, run_and_format, test_context};
use insta::assert_snapshot;
use super::support::{
git_filters, git_stdout, metadata_run_ids, output_stderr as support_stderr,
run_branch_commits_since_base, run_events, run_state, setup_git_backed_changed_run,
git_filters, output_stderr as support_stderr, run_branch_commits_since_base, run_events,
run_state, run_state_by_id, setup_git_backed_changed_run,
};
#[test]
@ -65,9 +65,10 @@ fn rewind_list_prints_timeline_for_completed_git_run() {
exit_code: 0
----- stdout -----
----- stderr -----
@ Node Details
@1 step_one
@2 step_two
@ Node Details
@1 start (no run commit)
@2 step_one
@3 step_two
");
}
@ -75,7 +76,6 @@ fn rewind_list_prints_timeline_for_completed_git_run() {
fn rewind_target_updates_metadata_and_resume_hint() {
let context = test_context!();
let setup = setup_git_backed_changed_run(&context);
let before = metadata_run_ids(&setup.repo_dir);
let expected_run_head =
run_branch_commits_since_base(&setup.repo_dir, &setup.run.run_id, &setup.base_sha)
.into_iter()
@ -84,7 +84,7 @@ fn rewind_target_updates_metadata_and_resume_hint() {
let mut cmd = context.command();
cmd.current_dir(&setup.repo_dir);
cmd.args(["rewind", &setup.run.run_id, "@1", "--no-push"]);
cmd.args(["rewind", &setup.run.run_id, "@2", "--no-push"]);
let (snapshot, output) = run_and_format(&mut cmd, &git_filters(&context));
assert_snapshot!(snapshot, @"
@ -98,29 +98,20 @@ fn rewind_target_updates_metadata_and_resume_hint() {
");
assert!(output.status.success(), "rewind should succeed");
let after = metadata_run_ids(&setup.repo_dir);
let new_run_ids: Vec<_> = after.difference(&before).cloned().collect();
assert_eq!(
new_run_ids.len(),
1,
"rewind should create one replacement run"
);
let new_run_id = &new_run_ids[0];
let run_head = git_stdout(&setup.repo_dir, &[
"rev-parse",
&format!("fabro/run/{new_run_id}"),
]);
assert_eq!(run_head.trim(), expected_run_head);
let state = run_state(&setup.run.run_dir);
assert!(matches!(
state.status,
Some(fabro_types::RunStatus::Archived { .. })
));
let new_run_id = state
.superseded_by
.expect("rewind should record replacement run");
let replacement = run_state_by_id(&context, &new_run_id.to_string());
assert_eq!(
state.superseded_by.map(|run_id| run_id.to_string()),
Some(new_run_id.clone())
replacement
.checkpoint
.and_then(|checkpoint| checkpoint.git_commit_sha),
Some(expected_run_head)
);
}
@ -138,7 +129,7 @@ fn rewind_archives_source_and_records_superseded_by() {
let mut cmd = context.command();
cmd.current_dir(&setup.repo_dir);
cmd.args(["rewind", &setup.run.run_id, "@1", "--no-push"]);
cmd.args(["rewind", &setup.run.run_id, "@2", "--no-push"]);
let output = cmd.output().expect("rewind should execute");
assert!(
output.status.success(),

View file

@ -166,7 +166,7 @@ fn rm_force_removes_active_run() {
"goal": "Active goal",
"title": "Active goal",
"labels": {},
"host_repo_path": null,
"source_directory": null,
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
@ -232,7 +232,7 @@ fn rm_without_force_uses_resolve_then_surfaces_server_conflict() {
"goal": "Active goal",
"title": "Active goal",
"labels": {},
"host_repo_path": null,
"source_directory": null,
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
@ -366,7 +366,7 @@ fn rm_uses_configured_server_target_without_local_run_dir() {
"goal": "Remote goal",
"title": "Remote goal",
"labels": {},
"host_repo_path": null,
"source_directory": null,
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",

View file

@ -169,6 +169,8 @@ fn help() {
--provider <PROVIDER> Override default LLM provider
-v, --verbose Enable verbose output
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona]
--in-place Run directly in the source checkout without git checkpoints
--allow-no-checkpoints Acknowledge that --in-place disables checkpoints
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
--no-retro Skip retro generation after the run
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
@ -672,6 +674,7 @@ fn dry_run_simple() {
Goal: Run tests and report results
Run: [ULID]
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
Start [TIME]
Run Tests [TIME]

View file

@ -13,7 +13,7 @@ fn remote_run_summary(run_id: &str) -> serde_json::Value {
"goal": "Preview test",
"title": "Preview test",
"labels": {},
"host_repo_path": "/srv/repo",
"source_directory": "/srv/repo",
"repository": { "name": "repo" },
"start_time": "2026-04-19T12:00:00Z",
"created_at": "2026-04-19T12:00:00Z",

View file

@ -9,7 +9,6 @@
reason = "These CLI integration test helpers shell out to real git and fabro binaries while constructing fixtures."
)]
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Output;
use std::time::{Duration, Instant};
@ -120,7 +119,7 @@ pub(crate) fn mock_resolved_run<'a>(
"goal": "Nightly run",
"title": "Nightly run",
"labels": {},
"host_repo_path": null,
"source_directory": null,
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
@ -755,6 +754,13 @@ pub(crate) fn run_state(run_dir: &Path) -> RunProjection {
))
}
pub(crate) fn run_state_by_id(context: &TestContext, run_id: &str) -> RunProjection {
block_on(get_server_json_for_storage(
&context.storage_dir,
&format!("/api/v1/runs/{run_id}/state"),
))
}
pub(crate) fn run_events(run_dir: &Path) -> Vec<EventEnvelope> {
let run_id = infer_run_id(run_dir);
let response: serde_json::Value = block_on(get_server_json(
@ -796,28 +802,6 @@ pub(crate) fn git_stdout(repo_dir: &Path, args: &[&str]) -> String {
stdout(&git_success(repo_dir, args))
}
pub(crate) fn metadata_run_ids(repo_dir: &Path) -> BTreeSet<String> {
git_stdout(repo_dir, &["branch", "--format=%(refname:short)"])
.lines()
.map(str::trim)
.filter_map(|line| line.strip_prefix("fabro/meta/"))
.map(ToOwned::to_owned)
.collect()
}
pub(crate) fn run_branch_commits(repo_dir: &Path, run_id: &str) -> Vec<String> {
git_stdout(repo_dir, &[
"rev-list",
"--reverse",
&format!("fabro/run/{run_id}"),
])
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToOwned::to_owned)
.collect()
}
pub(crate) fn run_branch_commits_since_base(
repo_dir: &Path,
run_id: &str,
@ -835,12 +819,6 @@ pub(crate) fn run_branch_commits_since_base(
.collect()
}
pub(crate) fn git_show_json(repo_dir: &Path, revspec: &str) -> Value {
let output = git_success(repo_dir, &["show", revspec]);
serde_json::from_str(&stdout(&output))
.unwrap_or_else(|err| panic!("failed to parse git show {revspec}: {err}"))
}
pub(crate) fn text_tree(root: &Path) -> Vec<String> {
fn visit(root: &Path, dir: &Path, entries: &mut Vec<String>) {
let mut children: Vec<_> = std::fs::read_dir(dir)
@ -1041,6 +1019,19 @@ fn setup_git_backed_run(context: &TestContext, workflow: GitWorkflowKind) -> Git
git_success(&repo_dir, &["add", "story.txt", "flow.fabro"]);
git_success(&repo_dir, &["commit", "-qm", "init"]);
let remote_dir = context.temp_dir.join(match workflow {
GitWorkflowKind::Changed => "git-changed-remote.git",
GitWorkflowKind::Noop => "git-noop-remote.git",
});
let remote_dir_str = remote_dir.display().to_string();
git_success(&context.temp_dir, &[
"init",
"--bare",
"-q",
&remote_dir_str,
]);
git_success(&repo_dir, &["remote", "add", "origin", &remote_dir_str]);
git_success(&repo_dir, &["push", "-u", "origin", "HEAD:main"]);
let base_sha = git_stdout(&repo_dir, &["rev-parse", "HEAD"])
.trim()
.to_string();

View file

@ -226,7 +226,7 @@ fn unarchive_resolves_selector_via_server_endpoint() {
"goal": "Nightly run",
"title": "Nightly run",
"labels": {},
"host_repo_path": null,
"source_directory": null,
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",

View file

@ -13,7 +13,7 @@ fn remote_run_summary(run_id: &str, status: &serde_json::Value) -> serde_json::V
"goal": "Wait for approval",
"title": "Wait for approval",
"labels": {},
"host_repo_path": "/srv/repo",
"source_directory": "/srv/repo",
"repository": { "name": "repo" },
"start_time": "2026-04-19T12:00:00Z",
"created_at": "2026-04-19T12:00:00Z",

View file

@ -8,11 +8,11 @@ use std::path::Path;
use fabro_checkpoint::git::Store as GitStore;
use fabro_store::RunProjection;
use fabro_test::{fabro_snapshot, test_context};
use fabro_types::Checkpoint;
use fabro_test::{TestContext, fabro_snapshot, test_context};
use fabro_workflow::operations::{RunTimeline, build_timeline};
use git2::Repository;
use crate::cmd::support::output_stdout;
use crate::support::unique_run_id;
fn list_metadata_run_ids(repo_dir: &Path) -> BTreeSet<String> {
@ -28,21 +28,20 @@ fn list_metadata_run_ids(repo_dir: &Path) -> BTreeSet<String> {
.collect()
}
fn latest_metadata_checkpoint(repo_dir: &Path, run_id: &str) -> Checkpoint {
let repo = Repository::discover(repo_dir).expect("recovery fixture should be a git repo");
fn load_metadata_projection(repo_dir: &Path, run_id: &str) -> Result<RunProjection, String> {
let repo = Repository::discover(repo_dir)
.map_err(|err| format!("recovery fixture should be a git repo: {err}"))?;
let store = GitStore::new(repo);
let tip = store
.resolve_ref(&format!("fabro/meta/{run_id}"))
.expect("metadata branch should resolve")
.expect("metadata branch tip should exist");
.map_err(|err| format!("metadata branch should resolve: {err}"))?
.ok_or_else(|| "metadata branch tip should exist".to_string())?;
let projection_blob = store
.read_blob_at(tip, "run.json")
.expect("latest projection blob should load")
.expect("latest projection blob should exist");
serde_json::from_slice::<RunProjection>(&projection_blob)
.expect("latest projection blob should deserialize")
.checkpoint
.expect("latest projection checkpoint should exist")
.map_err(|err| format!("latest projection blob should load: {err}"))?
.ok_or_else(|| "latest projection blob should exist".to_string())?;
serde_json::from_slice(&projection_blob)
.map_err(|err| format!("latest projection blob should deserialize: {err}"))
}
fn timeline_run_shas(repo_dir: &Path, run_id: &str) -> Vec<Option<String>> {
@ -60,9 +59,10 @@ fn timeline_run_shas(repo_dir: &Path, run_id: &str) -> Vec<Option<String>> {
fn build_timeline_when_ready(repo_dir: &Path, run_id: &str) -> RunTimeline {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let repo = Repository::discover(repo_dir).expect("recovery fixture should stay a git repo");
let store = GitStore::new(repo);
match build_timeline(&store, run_id) {
let timeline = load_metadata_projection(repo_dir, run_id)
.map_err(anyhow::Error::msg)
.and_then(|projection| build_timeline(&projection));
match timeline {
Ok(timeline) => return timeline,
Err(err) => {
assert!(
@ -75,6 +75,38 @@ fn build_timeline_when_ready(repo_dir: &Path, run_id: &str) -> RunTimeline {
}
}
fn fork_run_json(context: &TestContext, repo_dir: &Path, source_run_id: &str) -> String {
let output = context
.command()
.current_dir(repo_dir)
.args(["fork", source_run_id, "--json", "--no-push"])
.timeout(std::time::Duration::from_secs(15))
.output()
.expect("fork command should execute");
assert!(
output.status.success(),
"fork should succeed\nstdout:\n{}\nstderr:\n{}",
output_stdout(&output),
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_str::<serde_json::Value>(&output_stdout(&output))
.expect("fork json should parse")
.get("new_run_id")
.and_then(|value| value.as_str())
.expect("fork json should contain new_run_id")
.to_string()
}
fn latest_store_checkpoint_sha(context: &TestContext, run_id: &str) -> Option<String> {
let state: RunProjection = super::block_on(super::get_server_json_for_storage(
&context.storage_dir,
&format!("/api/v1/runs/{run_id}/state"),
));
state
.checkpoint
.and_then(|checkpoint| checkpoint.git_commit_sha)
}
#[expect(
clippy::disallowed_methods,
reason = "This sync git integration helper retries metadata branch deletion until libgit2 releases its lock."
@ -144,6 +176,38 @@ digraph Recovery {
.status()
.expect("git commit should launch");
assert!(commit.success(), "git commit should succeed");
let remote_dir = repo_dir
.parent()
.expect("recovery repo should have a parent")
.join(format!(
"{}-remote.git",
repo_dir
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("recovery")
));
let remote_init = std::process::Command::new("git")
.args(["init", "--bare", "-q"])
.arg(&remote_dir)
.status()
.expect("git init --bare should launch");
assert!(remote_init.success(), "git init --bare should succeed");
let remote_add = std::process::Command::new("git")
.args(["remote", "add", "origin"])
.arg(&remote_dir)
.current_dir(repo_dir)
.status()
.expect("git remote add should launch");
assert!(remote_add.success(), "git remote add should succeed");
let push = std::process::Command::new("git")
.args(["push", "-u", "origin", "HEAD:main"])
.current_dir(repo_dir)
.status()
.expect("git push should launch");
assert!(push.success(), "git push should succeed");
}
#[test]
@ -187,7 +251,10 @@ fn rewind_list_reports_empty_timeline_when_metadata_branch_is_missing() {
exit_code: 0
----- stdout -----
----- stderr -----
No checkpoints found.
@ Node Details
@1 start (no run commit)
@2 plan
@3 build
");
assert!(
@ -225,37 +292,15 @@ fn fork_chain_preserves_checkpoint_metadata() {
let build_sha = timeline_shas.last().cloned().flatten();
assert!(build_sha.is_some());
let before_child = list_metadata_run_ids(repo_dir.path());
context
.command()
.current_dir(repo_dir.path())
.args(["fork", &source_run_id, "--no-push"])
.timeout(std::time::Duration::from_secs(15))
.assert()
.success();
let after_child = list_metadata_run_ids(repo_dir.path());
let child_run_ids: Vec<_> = after_child.difference(&before_child).cloned().collect();
assert_eq!(child_run_ids.len(), 1, "expected one child run");
let child_run_id = &child_run_ids[0];
let child_run_id = fork_run_json(&context, repo_dir.path(), &source_run_id);
assert_eq!(
latest_store_checkpoint_sha(&context, &child_run_id),
build_sha
);
let child_checkpoint = latest_metadata_checkpoint(repo_dir.path(), child_run_id);
assert_eq!(child_checkpoint.git_commit_sha, build_sha);
let before_grandchild = list_metadata_run_ids(repo_dir.path());
context
.command()
.current_dir(repo_dir.path())
.args(["fork", child_run_id, "--no-push"])
.timeout(std::time::Duration::from_secs(15))
.assert()
.success();
let after_grandchild = list_metadata_run_ids(repo_dir.path());
let grandchild_run_ids: Vec<_> = after_grandchild
.difference(&before_grandchild)
.cloned()
.collect();
assert_eq!(grandchild_run_ids.len(), 1, "expected one grandchild run");
let grandchild_checkpoint = latest_metadata_checkpoint(repo_dir.path(), &grandchild_run_ids[0]);
assert_eq!(grandchild_checkpoint.git_commit_sha, build_sha);
let grandchild_run_id = fork_run_json(&context, repo_dir.path(), &child_run_id);
assert_eq!(
latest_store_checkpoint_sha(&context, &grandchild_run_id),
build_sha
);
}

View file

@ -243,7 +243,7 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() {
"goal": "Remote output",
"title": "Remote output",
"labels": {},
"host_repo_path": null,
"source_directory": null,
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",
@ -347,7 +347,7 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() {
"goal": "Remote output",
"title": "Remote output",
"labels": {},
"host_repo_path": null,
"source_directory": null,
"repository": { "name": "unknown" },
"start_time": "2026-04-05T12:00:00Z",
"created_at": "2026-04-05T12:00:00Z",

View file

@ -20,6 +20,7 @@ fn dry_run_branching() {
warning [node: implement]: Node 'implement' has goal_gate=true but no retry_target or fallback_retry_target (goal_gate_has_retry)
Run: [ULID]
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
Start [TIME]
Plan [TIME]
@ -55,6 +56,7 @@ fn dry_run_conditions() {
Goal: Test condition evaluation with OR and parentheses
Run: [ULID]
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
start [TIME]
Decide [TIME]
@ -88,6 +90,7 @@ fn dry_run_parallel() {
Goal: Test parallel and fan-in execution
Run: [ULID]
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
start [TIME]
Fork Work [TIME]
@ -122,6 +125,7 @@ fn dry_run_styled() {
Goal: Build a styled pipeline
Run: [ULID]
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
start [TIME]
Plan [TIME]
@ -156,6 +160,7 @@ fn dry_run_legacy_tool() {
Goal: Verify backwards compatibility with old tool naming
Run: [ULID]
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
Sandbox: local (ready in [TIME])
Start [TIME]
Echo [TIME]

View file

@ -22,7 +22,7 @@ preserve = false
devcontainer = false
[run.sandbox.local]
worktree_mode = "clean"
worktree_mode = "always"
[run.sandbox.docker]
image = "buildpack-deps:noble"

View file

@ -83,7 +83,7 @@ fn apply_builtin_defaults_materializes_expected_layer() {
.and_then(|run| run.sandbox.as_ref())
.and_then(|sandbox| sandbox.local.as_ref())
.and_then(|local| local.worktree_mode),
Some(WorktreeMode::Clean)
Some(WorktreeMode::Always)
);
assert_eq!(
layer

View file

@ -14,7 +14,7 @@ fn resolves_run_defaults_from_empty_settings() {
assert!(settings.execution.retros);
assert_eq!(settings.prepare.timeout_ms, 300_000);
assert_eq!(settings.sandbox.provider, "docker");
assert_eq!(settings.sandbox.local.worktree_mode, WorktreeMode::Clean);
assert_eq!(settings.sandbox.local.worktree_mode, WorktreeMode::Always);
let docker = settings
.sandbox
.docker

View file

@ -749,11 +749,14 @@ impl Sandbox for DaytonaSandbox {
.unwrap_or_default()
}
async fn setup_git_for_run(&self, run_id: &str) -> crate::Result<Option<crate::GitRunInfo>> {
async fn setup_git(
&self,
intent: &crate::GitSetupIntent,
) -> crate::Result<Option<crate::GitRunInfo>> {
if !self.repo_cloned() {
return Ok(None);
}
crate::setup_git_via_exec(self, run_id).await.map(Some)
crate::setup_git_via_exec(self, intent).await.map(Some)
}
fn resume_setup_commands(&self, run_branch: &str) -> Vec<String> {
@ -767,11 +770,11 @@ impl Sandbox for DaytonaSandbox {
)]
}
async fn git_push_branch(&self, branch: &str) -> bool {
async fn git_push_ref(&self, refspec: &str) -> bool {
if !self.repo_cloned() {
return false;
}
crate::git_push_via_exec(self, branch).await
crate::git_push_via_exec(self, refspec).await
}
fn parallel_worktree_path(

View file

@ -1184,11 +1184,14 @@ impl Sandbox for DockerSandbox {
self.container_id.get().cloned().unwrap_or_default()
}
async fn setup_git_for_run(&self, run_id: &str) -> crate::Result<Option<crate::GitRunInfo>> {
async fn setup_git(
&self,
intent: &crate::GitSetupIntent,
) -> crate::Result<Option<crate::GitRunInfo>> {
if !self.repo_cloned() {
return Ok(None);
}
crate::setup_git_via_exec(self, run_id).await.map(Some)
crate::setup_git_via_exec(self, intent).await.map(Some)
}
fn resume_setup_commands(&self, run_branch: &str) -> Vec<String> {
@ -1202,11 +1205,11 @@ impl Sandbox for DockerSandbox {
)]
}
async fn git_push_branch(&self, branch: &str) -> bool {
async fn git_push_ref(&self, refspec: &str) -> bool {
if !self.repo_cloned() {
return false;
}
crate::git_push_via_exec(self, branch).await
crate::git_push_via_exec(self, refspec).await
}
fn parallel_worktree_path(

View file

@ -33,8 +33,9 @@ pub use error::{Error, Result};
pub use local::LocalSandbox;
pub use read_guard::ReadBeforeWriteSandbox;
pub use sandbox::{
DirEntry, ExecResult, GitRunInfo, GrepOptions, Sandbox, SandboxEvent, SandboxEventCallback,
format_lines_numbered, git_push_via_exec, setup_git_via_exec, shell_quote,
DirEntry, ExecResult, GitRunInfo, GitSetupIntent, GrepOptions, Sandbox, SandboxEvent,
SandboxEventCallback, format_lines_numbered, git_push_via_exec, setup_git_via_exec,
shell_quote,
};
pub use sandbox_provider::SandboxProvider;
pub use sandbox_record::SandboxRecord;

View file

@ -491,6 +491,19 @@ impl Sandbox for LocalSandbox {
result
}
async fn git_push_ref(&self, refspec: &str) -> bool {
let has_origin = matches!(
self.exec_command("git remote get-url origin", 10_000, None, None, None)
.await,
Ok(result) if result.exit_code == 0
);
if !has_origin {
return true;
}
crate::git_push_via_exec(self, refspec).await
}
async fn cleanup(&self) -> crate::Result<()> {
self.emit(SandboxEvent::CleanupStarted {
provider: "local".into(),

View file

@ -2,9 +2,11 @@ use std::collections::HashMap;
use std::fmt::Write;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use tokio::time;
use tokio_util::sync::CancellationToken;
/// Git command prefix that disables background maintenance.
@ -17,6 +19,18 @@ pub struct GitRunInfo {
pub base_branch: Option<String>,
}
/// Git setup requested by the workflow layer.
pub enum GitSetupIntent {
NewRun {
run_id: String,
},
ForkFromCheckpoint {
new_run_id: String,
source_run_id: String,
checkpoint_sha: String,
},
}
/// Generates an `#[async_trait] impl Sandbox` block for a decorator type
/// that wraps an `Arc<dyn Sandbox>`. The caller provides custom method
/// implementations; all remaining trait methods delegate to the inner field.
@ -121,20 +135,16 @@ macro_rules! delegate_sandbox {
self.$field.set_autostop_interval(minutes).await
}
async fn setup_git_for_run(&self, run_id: &str) -> $crate::Result<Option<$crate::GitRunInfo>> {
self.$field.setup_git_for_run(run_id).await
async fn setup_git(&self, intent: &$crate::GitSetupIntent) -> $crate::Result<Option<$crate::GitRunInfo>> {
self.$field.setup_git(intent).await
}
fn resume_setup_commands(&self, run_branch: &str) -> Vec<String> {
self.$field.resume_setup_commands(run_branch)
}
async fn git_push_branch(&self, branch: &str) -> bool {
self.$field.git_push_branch(branch).await
}
fn host_git_dir(&self) -> Option<&str> {
self.$field.host_git_dir()
async fn git_push_ref(&self, refspec: &str) -> bool {
self.$field.git_push_ref(refspec).await
}
fn parallel_worktree_path(
@ -452,11 +462,10 @@ pub trait Sandbox: Send + Sync {
Ok(())
}
/// Set up git state for a new workflow run.
/// Set up git state for a workflow run.
/// Sandboxes that manage their own git clone (e.g., remote VMs) should
/// create a run branch and return the git info. Local sandboxes return
/// `None`.
async fn setup_git_for_run(&self, _run_id: &str) -> crate::Result<Option<GitRunInfo>> {
/// create a run branch and return the git info.
async fn setup_git(&self, _intent: &GitSetupIntent) -> crate::Result<Option<GitRunInfo>> {
Ok(None)
}
@ -466,19 +475,11 @@ pub trait Sandbox: Send + Sync {
Vec::new()
}
/// Push a run branch to origin from inside the sandbox.
/// Returns `true` if the push was handled. When `false`, the engine will
/// attempt a host-side push instead.
async fn git_push_branch(&self, _branch: &str) -> bool {
/// Push a full refspec to origin from inside the sandbox.
async fn git_push_ref(&self, _refspec: &str) -> bool {
false
}
/// The host-accessible path to this sandbox's git worktree, if applicable.
/// When `Some`, the engine runs git operations (add, commit) from the host.
fn host_git_dir(&self) -> Option<&str> {
None
}
/// Compute the filesystem path for a parallel branch worktree.
fn parallel_worktree_path(
&self,
@ -547,7 +548,10 @@ pub fn shell_quote(s: &str) -> String {
/// Helper for sandbox implementations that manage git internally.
/// Executes git commands inside the sandbox to create a run branch.
pub async fn setup_git_via_exec(sandbox: &dyn Sandbox, run_id: &str) -> crate::Result<GitRunInfo> {
pub async fn setup_git_via_exec(
sandbox: &dyn Sandbox,
intent: &GitSetupIntent,
) -> crate::Result<GitRunInfo> {
// Get current branch name
let branch_result = sandbox
.exec_command("git rev-parse --abbrev-ref HEAD", 10_000, None, None, None)
@ -566,30 +570,45 @@ pub async fn setup_git_via_exec(sandbox: &dyn Sandbox, run_id: &str) -> crate::R
None
};
// Get current HEAD as base SHA
let sha_result = sandbox
.exec_command("git rev-parse HEAD", 10_000, None, None, None)
.await
.map_err(|e| crate::Error::message(format!("git rev-parse HEAD failed: {e}")))?;
if sha_result.exit_code != 0 {
return Err(crate::Error::message(format!(
"git rev-parse HEAD failed (exit {}): {}",
sha_result.exit_code, sha_result.stderr
)));
}
let base_sha = sha_result.stdout.trim().to_string();
let (base_sha, branch_name) = match intent {
GitSetupIntent::NewRun { run_id } => {
let sha_result = sandbox
.exec_command("git rev-parse HEAD", 10_000, None, None, None)
.await
.map_err(|e| crate::Error::message(format!("git rev-parse HEAD failed: {e}")))?;
if sha_result.exit_code != 0 {
return Err(crate::Error::message(format!(
"git rev-parse HEAD failed (exit {}): {}",
sha_result.exit_code, sha_result.stderr
)));
}
(
sha_result.stdout.trim().to_string(),
format!("fabro/run/{run_id}"),
)
}
GitSetupIntent::ForkFromCheckpoint {
new_run_id,
source_run_id,
checkpoint_sha,
} => {
fetch_source_run_ref(sandbox, source_run_id, checkpoint_sha).await?;
(checkpoint_sha.clone(), format!("fabro/run/{new_run_id}"))
}
};
let branch_name = format!("fabro/run/{run_id}");
// Create and checkout a run branch
let checkout_cmd = format!("git checkout -b {}", shell_quote(&branch_name));
let checkout_cmd = format!(
"git checkout -B {} {}",
shell_quote(&branch_name),
shell_quote(&base_sha)
);
let checkout_result = sandbox
.exec_command(&checkout_cmd, 10_000, None, None, None)
.await
.map_err(|e| crate::Error::message(format!("git checkout failed: {e}")))?;
if checkout_result.exit_code != 0 {
return Err(crate::Error::message(format!(
"git checkout -b failed (exit {}): {}",
"git checkout -B failed (exit {}): {}",
checkout_result.exit_code, checkout_result.stderr
)));
}
@ -601,24 +620,72 @@ pub async fn setup_git_via_exec(sandbox: &dyn Sandbox, run_id: &str) -> crate::R
})
}
async fn fetch_source_run_ref(
sandbox: &dyn Sandbox,
source_run_id: &str,
checkpoint_sha: &str,
) -> crate::Result<()> {
let remote_ref = format!("refs/heads/fabro/run/{source_run_id}");
let tracking_ref = format!("refs/remotes/origin/fabro/run/{source_run_id}");
let fetch_cmd = format!(
"{GIT} fetch origin {}:{}",
shell_quote(&remote_ref),
shell_quote(&tracking_ref)
);
let check_cmd = format!(
"{GIT} merge-base --is-ancestor {} {}",
shell_quote(checkpoint_sha),
shell_quote(&tracking_ref)
);
let mut last_error = String::new();
for _ in 0..5 {
let fetch = sandbox
.exec_command(&fetch_cmd, 30_000, None, None, None)
.await?;
if fetch.exit_code != 0 {
last_error = format!(
"git fetch source run ref failed (exit {}): {}",
fetch.exit_code,
fetch.stderr.trim()
);
} else {
let check = sandbox
.exec_command(&check_cmd, 10_000, None, None, None)
.await?;
if check.exit_code == 0 {
return Ok(());
}
last_error = format!(
"checkpoint {checkpoint_sha} is not reachable from {remote_ref} (exit {}): {}",
check.exit_code,
check.stderr.trim()
);
}
time::sleep(Duration::from_millis(500)).await;
}
Err(crate::Error::message(last_error))
}
/// Helper for sandbox implementations that manage git internally.
/// Pushes a branch to origin via exec_command inside the sandbox.
pub async fn git_push_via_exec(sandbox: &dyn Sandbox, branch: &str) -> bool {
/// Pushes a refspec to origin via exec_command inside the sandbox.
pub async fn git_push_via_exec(sandbox: &dyn Sandbox, refspec: &str) -> bool {
if let Err(e) = sandbox.refresh_push_credentials().await {
tracing::warn!(error = %e, "Failed to refresh push credentials");
}
let cmd = format!("{GIT} push origin {}", shell_quote(branch));
let cmd = format!("{GIT} push origin {}", shell_quote(refspec));
match sandbox.exec_command(&cmd, 60_000, None, None, None).await {
Ok(r) if r.exit_code == 0 => {
tracing::info!(branch, "Pushed run branch to origin");
tracing::info!(refspec, "Pushed git ref to origin");
true
}
Ok(r) => {
tracing::warn!(branch, exit_code = r.exit_code, "Failed to push run branch");
tracing::warn!(refspec, exit_code = r.exit_code, "Failed to push git ref");
false
}
Err(e) => {
tracing::warn!(branch, error = %e, "Failed to push run branch");
tracing::warn!(refspec, error = %e, "Failed to push git ref");
false
}
}

View file

@ -64,20 +64,6 @@ impl SandboxSpec {
}
}
/// Host-accessible repo path for git status / worktree decisions.
/// Only Local has one. Clone-based providers use their persisted clone
/// metadata instead of the worker process filesystem.
pub fn host_repo_path(&self) -> Option<PathBuf> {
match self {
Self::Local { working_directory } => Some(working_directory.clone()),
#[allow(
unreachable_patterns,
reason = "Feature-gated variants make this fallback arm reachable on some builds."
)]
_ => None,
}
}
/// Build a SandboxRecord for persistence.
pub fn to_sandbox_record(&self, sandbox: &dyn Sandbox) -> SandboxRecord {
let working_directory = sandbox.working_directory().to_string();
@ -97,8 +83,6 @@ impl SandboxSpec {
provider: self.provider_name().to_string(),
working_directory: working_directory.clone(),
identifier,
host_working_directory: None,
container_mount_point: None,
repo_cloned: clone_source::repo_cloned_for_record(
config.skip_clone,
clone_origin_url.as_deref(),
@ -118,8 +102,6 @@ impl SandboxSpec {
provider: self.provider_name().to_string(),
working_directory: working_directory.clone(),
identifier,
host_working_directory: None,
container_mount_point: None,
repo_cloned: clone_source::repo_cloned_for_record(
config.skip_clone,
clone_origin_url.as_deref(),
@ -133,8 +115,6 @@ impl SandboxSpec {
provider: self.provider_name().to_string(),
working_directory,
identifier,
host_working_directory: None,
container_mount_point: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,

View file

@ -305,20 +305,28 @@ impl Sandbox for WorktreeSandbox {
self.inner.set_autostop_interval(minutes).await
}
fn host_git_dir(&self) -> Option<&str> {
Some(&self.config.worktree_path)
}
async fn setup_git_for_run(&self, run_id: &str) -> crate::Result<Option<crate::GitRunInfo>> {
self.inner.setup_git_for_run(run_id).await
async fn setup_git(
&self,
intent: &crate::GitSetupIntent,
) -> crate::Result<Option<crate::GitRunInfo>> {
self.inner.setup_git(intent).await
}
fn resume_setup_commands(&self, run_branch: &str) -> Vec<String> {
self.inner.resume_setup_commands(run_branch)
}
async fn git_push_branch(&self, branch: &str) -> bool {
self.inner.git_push_branch(branch).await
async fn git_push_ref(&self, refspec: &str) -> bool {
let has_origin = matches!(
self.exec_command("git remote get-url origin", 10_000, None, None, None)
.await,
Ok(result) if result.exit_code == 0
);
if !has_origin {
return true;
}
crate::git_push_via_exec(self, refspec).await
}
fn parallel_worktree_path(

View file

@ -831,6 +831,8 @@ mod runs {
goal.into(),
labels(entries),
Some(format!("/demo/{repo_name}")),
false,
Some(format!("https://github.com/demo/{repo_name}.git")),
Some(created_at),
parse_run_status(status, status_reason)
.unwrap_or_else(|| panic!("invalid demo run status: {status}")),
@ -913,7 +915,9 @@ mod runs {
duration_ms: summary.duration_ms.and_then(|ms| i64::try_from(ms).ok()),
elapsed_secs: summary.elapsed_secs,
goal: summary.goal,
host_repo_path: summary.host_repo_path,
source_directory: summary.source_directory,
checkpoints_disabled: Some(summary.checkpoints_disabled),
repo_origin_url: summary.repo_origin_url,
labels: summary.labels,
pending_control: summary.pending_control,
pull_request,

View file

@ -26,7 +26,7 @@ use fabro_types::settings::run::{
ApprovalMode, DaytonaNetworkLayer, DaytonaSettings, DockerSettings, DockerfileSource, RunGoal,
RunMode, RunNamespace,
};
use fabro_types::{RunId, WorkflowSettings};
use fabro_types::{DirtyStatus, PreRunGitContext, PreRunPushOutcome, RunId, WorkflowSettings};
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
use fabro_validate::Severity;
use fabro_workflow::Error as WorkflowError;
@ -39,15 +39,16 @@ use crate::server::AppState;
#[derive(Clone)]
pub(crate) struct PreparedManifest {
pub cwd: PathBuf,
pub git: Option<types::ManifestGit>,
pub root_source: String,
pub run_id: Option<RunId>,
pub settings: WorkflowSettings,
pub target_path: PathBuf,
pub workflow_bundle: WorkflowBundle,
pub workflow_input: BundledWorkflow,
pub working_directory: PathBuf,
pub cwd: PathBuf,
pub git: Option<types::ManifestGit>,
pub root_source: String,
pub run_id: Option<RunId>,
pub settings: WorkflowSettings,
pub target_path: PathBuf,
pub workflow_bundle: WorkflowBundle,
pub workflow_input: BundledWorkflow,
pub source_directory: PathBuf,
pub checkpoints_disabled: bool,
}
#[derive(Clone, Debug, Default)]
@ -68,6 +69,29 @@ pub(crate) fn prepare_manifest(
if manifest.version != 1 {
bail!("unsupported manifest version {}", manifest.version);
}
let (in_place, allow_no_checkpoints) = manifest.args.as_ref().map_or((false, false), |args| {
(
args.in_place.unwrap_or(false),
args.allow_no_checkpoints.unwrap_or(false),
)
});
if in_place && !allow_no_checkpoints {
bail!("in_place requires allow_no_checkpoints");
}
if allow_no_checkpoints && !in_place {
bail!("allow_no_checkpoints requires in_place");
}
if in_place {
if let Some(sandbox) = manifest
.args
.as_ref()
.and_then(|args| args.sandbox.as_deref())
{
if sandbox != "local" {
bail!("in_place requires the local sandbox provider");
}
}
}
let cwd = PathBuf::from(&manifest.cwd);
let target_path = PathBuf::from(&manifest.target.path);
@ -131,7 +155,8 @@ pub(crate) fn prepare_manifest(
target_path,
workflow_bundle,
workflow_input,
working_directory: resolve_working_directory(&settings, &cwd),
source_directory: resolve_working_directory(&settings, &cwd),
checkpoints_disabled: in_place,
})
}
@ -159,17 +184,61 @@ pub(crate) fn create_run_input(
workflow_bundle: Some(prepared.workflow_bundle),
submitted_manifest_bytes: None,
run_id: prepared.run_id,
host_repo_path: Some(prepared.working_directory.display().to_string()),
repo_origin_url: prepared
.git
.as_ref()
.map(|git| fabro_github::normalize_repo_origin_url(&git.origin_url)),
repo_origin_url: prepared.git.as_ref().and_then(|git| {
let origin_url = fabro_github::normalize_repo_origin_url(&git.origin_url);
(!origin_url.is_empty()).then_some(origin_url)
}),
base_branch: prepared.git.as_ref().map(|git| git.branch.clone()),
pre_run_git: prepared.git.as_ref().map(pre_run_git_from_manifest),
fork_source_ref: None,
checkpoints_disabled: prepared.checkpoints_disabled,
provenance: None,
configured_providers,
}
}
fn pre_run_git_from_manifest(git: &types::ManifestGit) -> PreRunGitContext {
PreRunGitContext {
display_base_sha: Some(git.sha.clone()),
local_dirty: if git.clean {
DirtyStatus::Clean
} else {
DirtyStatus::Dirty
},
push_outcome: pre_run_push_outcome_from_manifest(&git.push_outcome),
}
}
fn pre_run_push_outcome_from_manifest(
outcome: &types::ManifestPreRunPushOutcome,
) -> PreRunPushOutcome {
match outcome.type_ {
types::ManifestPreRunPushOutcomeType::NotAttempted => PreRunPushOutcome::NotAttempted,
types::ManifestPreRunPushOutcomeType::Succeeded => PreRunPushOutcome::Succeeded {
remote: outcome
.remote
.clone()
.unwrap_or_else(|| "origin".to_string()),
branch: outcome.branch.clone().unwrap_or_default(),
},
types::ManifestPreRunPushOutcomeType::Failed => PreRunPushOutcome::Failed {
remote: outcome
.remote
.clone()
.unwrap_or_else(|| "origin".to_string()),
branch: outcome.branch.clone().unwrap_or_default(),
message: outcome.message.clone().unwrap_or_default(),
},
types::ManifestPreRunPushOutcomeType::SkippedNoRemote => PreRunPushOutcome::SkippedNoRemote,
types::ManifestPreRunPushOutcomeType::SkippedRemoteMismatch => {
PreRunPushOutcome::SkippedRemoteMismatch {
remote: outcome.remote.clone().unwrap_or_default(),
repo_origin_url: outcome.repo_origin_url.clone().unwrap_or_default(),
}
}
}
}
pub(crate) async fn run_preflight(
state: &AppState,
prepared: &PreparedManifest,
@ -246,17 +315,22 @@ fn manifest_args_overrides(args: Option<&types::ManifestArgs>) -> ManifestSettin
name: args.model.as_deref().map(InterpString::parse),
fallbacks: Vec::new(),
});
let sandbox =
(args.sandbox.is_some() || args.preserve_sandbox.is_some() || args.docker_image.is_some())
.then(|| RunSandboxLayer {
provider: args.sandbox.clone(),
preserve: args.preserve_sandbox,
docker: args.docker_image.as_ref().map(|image| DockerSandboxLayer {
image: Some(image.clone()),
..DockerSandboxLayer::default()
}),
..RunSandboxLayer::default()
});
let sandbox_provider = args
.sandbox
.clone()
.or_else(|| (args.in_place == Some(true)).then(|| "local".to_string()));
let sandbox = (sandbox_provider.is_some()
|| args.preserve_sandbox.is_some()
|| args.docker_image.is_some())
.then(|| RunSandboxLayer {
provider: sandbox_provider,
preserve: args.preserve_sandbox,
docker: args.docker_image.as_ref().map(|image| DockerSandboxLayer {
image: Some(image.clone()),
..DockerSandboxLayer::default()
}),
..RunSandboxLayer::default()
});
let execution_has_any =
args.dry_run.is_some() || args.auto_approve.is_some() || args.no_retro.is_some();
@ -553,7 +627,7 @@ async fn run_sandbox_check(
let docker_config = resolve_docker_config(resolved_run);
let sandbox_result: Result<Arc<dyn Sandbox>, String> = match sandbox_provider {
SandboxProvider::Local => SandboxSpec::Local {
working_directory: prepared.working_directory.clone(),
working_directory: prepared.source_directory.clone(),
}
.build(None)
.await
@ -1090,16 +1164,18 @@ root = "/srv/fabro"
)));
let mut manifest = minimal_manifest();
manifest.args = Some(types::ManifestArgs {
auto_approve: None,
dry_run: Some(true),
label: Vec::new(),
model: None,
no_retro: None,
preserve_sandbox: None,
provider: None,
sandbox: None,
docker_image: None,
verbose: None,
auto_approve: None,
dry_run: Some(true),
label: Vec::new(),
model: None,
no_retro: None,
preserve_sandbox: None,
provider: None,
sandbox: None,
docker_image: None,
verbose: None,
in_place: None,
allow_no_checkpoints: None,
});
let prepared = prepare_manifest(&server_settings, &manifest).unwrap();

View file

@ -7228,7 +7228,7 @@ async fn fork_run(
target,
push: request.push.unwrap_or(true),
};
match operations::fork_run(&state.store, &input).await {
match Box::pin(operations::fork_run(&state.store, &input)).await {
Ok(outcome) => (
StatusCode::OK,
Json(ForkResponse {
@ -7261,6 +7261,8 @@ async fn run_timeline(
node_name: entry.node_name,
visit: std::num::NonZeroU64::new(entry.visit as u64)
.expect("timeline visits start at 1"),
checkpoint_seq: std::num::NonZeroU64::new(u64::from(entry.checkpoint_seq))
.expect("checkpoint event sequence starts at 1"),
run_commit_sha: entry.run_commit_sha,
})
.collect::<Vec<_>>(),
@ -7936,7 +7938,8 @@ struct GraphParams {
async fn load_run_dot_source(state: &AppState, id: &RunId) -> Result<String, Response> {
let live_dot_source = {
let runs = state.runs.lock().expect("runs lock poisoned");
runs.get(id).map(|managed_run| managed_run.dot_source.clone())
runs.get(id)
.map(|managed_run| managed_run.dot_source.clone())
};
let dot_source = if let Some(dot) = live_dot_source.filter(|d| !d.is_empty()) {
@ -7947,7 +7950,7 @@ async fn load_run_dot_source(state: &AppState, id: &RunId) -> Result<String, Res
Ok(run_state) => run_state.graph_source,
Err(err) => {
return Err(
ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response(),
ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response()
);
}
},
@ -7955,9 +7958,8 @@ async fn load_run_dot_source(state: &AppState, id: &RunId) -> Result<String, Res
}
};
dot_source.ok_or_else(|| {
ApiError::new(StatusCode::NOT_FOUND, "Graph not found.").into_response()
})
dot_source
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "Graph not found.").into_response())
}
async fn get_graph(
@ -9726,14 +9728,16 @@ strategy = "token"
settings: fabro_types::WorkflowSettings::default(),
graph,
workflow_slug: Some("test".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
source_directory: Some("/tmp/project".to_string()),
repo_origin_url: repo_origin_url.map(str::to_string),
base_branch: base_branch.map(str::to_string),
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
};
create_durable_run_with_events(state, run_id, &[
@ -9744,15 +9748,17 @@ strategy = "token"
workflow_source: None,
workflow_config: None,
labels: run_spec.labels.clone().into_iter().collect(),
run_dir: run_spec.working_directory.display().to_string(),
working_directory: run_spec.working_directory.display().to_string(),
host_repo_path: run_spec.host_repo_path.clone(),
run_dir: run_spec.source_directory.clone().unwrap_or_default(),
source_directory: run_spec.source_directory.clone(),
repo_origin_url: run_spec.repo_origin_url.clone(),
base_branch: run_spec.base_branch.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
db_prefix: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
},
workflow_event::Event::WorkflowRunStarted {
name: "test".to_string(),
@ -13905,14 +13911,12 @@ provider = "local"
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
workflow_event::Event::SandboxInitialized {
provider: "local".to_string(),
working_directory: "/sandbox/workdir".to_string(),
identifier: Some("sb-test".to_string()),
host_working_directory: Some("/tmp/repo".to_string()),
container_mount_point: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
provider: "local".to_string(),
working_directory: "/sandbox/workdir".to_string(),
identifier: Some("sb-test".to_string()),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
},
workflow_event::Event::PullRequestCreated {
pr_url: "https://github.com/acme/repo/pull/42".to_string(),
@ -13978,14 +13982,12 @@ provider = "local"
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
workflow_event::Event::SandboxInitialized {
provider: "local".to_string(),
working_directory: "/sandbox/workdir".to_string(),
identifier: Some(sandbox_id.to_string()),
host_working_directory: Some("/tmp/repo".to_string()),
container_mount_point: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
provider: "local".to_string(),
working_directory: "/sandbox/workdir".to_string(),
identifier: Some(sandbox_id.to_string()),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
},
] {
workflow_event::append_event(&run_store, &run_id, &event)

View file

@ -1,5 +1,4 @@
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::str::FromStr;
use chrono::{DateTime, Utc};
@ -48,21 +47,22 @@ impl RunProjectionReducer for RunProjection {
match &stored.body {
EventBody::RunCreated(props) => {
let working_directory = PathBuf::from(&props.working_directory);
let labels = props.labels.clone().into_iter().collect::<HashMap<_, _>>();
self.spec = Some(RunSpec {
run_id,
settings: props.settings.clone(),
graph: props.graph.clone(),
workflow_slug: props.workflow_slug.clone(),
working_directory,
host_repo_path: props.host_repo_path.clone(),
source_directory: props.source_directory.clone(),
repo_origin_url: props.repo_origin_url.clone(),
base_branch: props.base_branch.clone(),
labels,
provenance: props.provenance.clone(),
manifest_blob: props.manifest_blob,
definition_blob: None,
pre_run_git: props.pre_run_git.clone(),
fork_source_ref: props.fork_source_ref.clone(),
checkpoints_disabled: props.checkpoints_disabled,
});
self.graph_source.clone_from(&props.workflow_source);
}
@ -210,14 +210,12 @@ impl RunProjectionReducer for RunProjection {
}
EventBody::SandboxInitialized(props) => {
self.sandbox = Some(SandboxRecord {
provider: props.provider.clone(),
working_directory: props.working_directory.clone(),
identifier: props.identifier.clone(),
host_working_directory: props.host_working_directory.clone(),
container_mount_point: props.container_mount_point.clone(),
repo_cloned: props.repo_cloned,
clone_origin_url: props.clone_origin_url.clone(),
clone_branch: props.clone_branch.clone(),
provider: props.provider.clone(),
working_directory: props.working_directory.clone(),
identifier: props.identifier.clone(),
repo_cloned: props.repo_cloned,
clone_origin_url: props.clone_origin_url.clone(),
clone_branch: props.clone_branch.clone(),
});
}
EventBody::RetroStarted(props) => {
@ -394,7 +392,15 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary
state
.spec
.as_ref()
.and_then(|spec| spec.host_repo_path.clone()),
.and_then(|spec| spec.source_directory.clone()),
state
.spec
.as_ref()
.is_some_and(|spec| spec.checkpoints_disabled),
state
.spec
.as_ref()
.and_then(|spec| spec.repo_origin_url.clone()),
state.start.as_ref().map(|start| start.start_time),
state.status.unwrap_or(RunStatus::Submitted),
state.pending_control,
@ -654,8 +660,7 @@ mod tests {
"settings": WorkflowSettings::default(),
"graph": { "name": "ship", "nodes": {}, "edges": [], "attrs": {} },
"workflow_slug": "demo",
"working_directory": "/tmp/project",
"host_repo_path": null,
"source_directory": "/tmp/project",
"repo_origin_url": null,
"base_branch": null,
"labels": {},
@ -983,18 +988,20 @@ mod tests {
fn summary_synthesizes_submitted_when_run_exists_without_status() {
let mut state = RunProjection::default();
state.spec = Some(fabro_types::RunSpec {
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: fabro_types::Graph::new("test"),
workflow_slug: Some("test".to_string()),
working_directory: std::path::PathBuf::from("/tmp/run"),
host_repo_path: Some("/tmp/repo".to_string()),
repo_origin_url: None,
base_branch: None,
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: fabro_types::Graph::new("test"),
workflow_slug: Some("test".to_string()),
source_directory: Some("/tmp/repo".to_string()),
repo_origin_url: None,
base_branch: None,
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
});
let summary_json = serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap();
@ -1024,7 +1031,7 @@ mod tests {
},
"labels": {},
"run_dir": "/tmp/run",
"working_directory": "/tmp/run",
"source_directory": "/tmp/run",
"manifest_blob": manifest_blob
}
}))

View file

@ -299,8 +299,6 @@ pub(crate) fn normalize_base_prefix(prefix: String) -> String {
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use fabro_types::{
AttrValue, FailureReason, Graph, RunControlAction, RunSpec, RunStatus, SuccessReason,
@ -358,14 +356,16 @@ mod tests {
settings: WorkflowSettings::default(),
graph,
workflow_slug: Some("night-sky".to_string()),
working_directory: PathBuf::from(format!("/tmp/{label}")),
host_repo_path: Some("github.com/fabro-sh/fabro".to_string()),
source_directory: Some(format!("/tmp/{label}")),
repo_origin_url: Some("https://github.com/fabro-sh/fabro".to_string()),
base_branch: Some("main".to_string()),
labels: std::collections::HashMap::from([("team".to_string(), "infra".to_string())]),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
}
}
@ -398,9 +398,9 @@ mod tests {
"settings": run_spec.settings,
"graph": run_spec.graph,
"workflow_slug": run_spec.workflow_slug,
"working_directory": run_spec.working_directory,
"source_directory": run_spec.source_directory,
"run_dir": format!("/tmp/{label}"),
"host_repo_path": run_spec.host_repo_path,
"repo_origin_url": run_spec.repo_origin_url,
"base_branch": run_spec.base_branch,
"labels": run_spec.labels,
}),

View file

@ -1,5 +1,4 @@
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use chrono::{TimeZone, Utc};
use fabro_store::{NodeState, RunProjection, SerializableProjection, StageId};
@ -13,18 +12,20 @@ use serde_json::json;
fn sample_run_spec() -> RunSpec {
RunSpec {
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("ship"),
workflow_slug: Some("demo".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None,
manifest_blob: None,
definition_blob: None,
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("ship"),
workflow_slug: Some("demo".to_string()),
source_directory: Some("/tmp/project".to_string()),
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
}
}
@ -64,14 +65,12 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() {
projection.status = Some(RunStatus::Running);
projection.checkpoint = Some(sample_checkpoint());
projection.sandbox = Some(SandboxRecord {
provider: "local".to_string(),
working_directory: "/tmp/project".to_string(),
identifier: Some("sandbox-1".to_string()),
host_working_directory: None,
container_mount_point: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
provider: "local".to_string(),
working_directory: "/tmp/project".to_string(),
identifier: Some("sandbox-1".to_string()),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
});
projection.pending_interviews = BTreeMap::new();
projection.set_node(stage_id.clone(), NodeState {

View file

@ -57,8 +57,8 @@ pub use retro::{
OpenItemKind, Retro, RetroNarrative, SmoothnessRating, StageRetro,
};
pub use run::{
RunAuthMethod, RunClientProvenance, RunProvenance, RunServerProvenance, RunSpec,
RunSubjectProvenance,
DirtyStatus, ForkSourceRef, PreRunGitContext, PreRunPushOutcome, RunAuthMethod,
RunClientProvenance, RunProvenance, RunServerProvenance, RunSpec, RunSubjectProvenance,
};
pub use run_blob_id::RunBlobId;
pub use run_event::{ActorKind, ActorRef, EventBody, RunEvent, RunNoticeLevel};

View file

@ -1,5 +1,4 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
@ -48,28 +47,75 @@ pub struct RunProvenance {
pub subject: Option<RunSubjectProvenance>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DirtyStatus {
Clean,
Dirty,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PreRunPushOutcome {
NotAttempted,
Succeeded {
remote: String,
branch: String,
},
Failed {
remote: String,
branch: String,
message: String,
},
SkippedNoRemote,
SkippedRemoteMismatch {
remote: String,
repo_origin_url: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PreRunGitContext {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_base_sha: Option<String>,
pub local_dirty: DirtyStatus,
pub push_outcome: PreRunPushOutcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForkSourceRef {
pub source_run_id: RunId,
pub checkpoint_sha: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunSpec {
pub run_id: RunId,
pub settings: WorkflowSettings,
pub graph: Graph,
pub run_id: RunId,
pub settings: WorkflowSettings,
pub graph: Graph,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_slug: Option<String>,
pub working_directory: PathBuf,
pub workflow_slug: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_repo_path: Option<String>,
pub source_directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_origin_url: Option<String>,
pub repo_origin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_branch: Option<String>,
pub base_branch: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<RunProvenance>,
pub provenance: Option<RunProvenance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_blob: Option<RunBlobId>,
pub manifest_blob: Option<RunBlobId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub definition_blob: Option<RunBlobId>,
pub definition_blob: Option<RunBlobId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pre_run_git: Option<PreRunGitContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fork_source_ref: Option<ForkSourceRef>,
#[serde(default)]
pub checkpoints_disabled: bool,
}
impl RunSpec {
@ -94,8 +140,8 @@ impl RunSpec {
}
#[must_use]
pub fn working_directory(&self) -> &Path {
&self.working_directory
pub fn source_directory(&self) -> Option<&str> {
self.source_directory.as_deref()
}
#[must_use]
@ -103,11 +149,6 @@ impl RunSpec {
&self.labels
}
#[must_use]
pub fn host_repo_path(&self) -> Option<&str> {
self.host_repo_path.as_deref()
}
#[must_use]
pub fn repo_origin_url(&self) -> Option<&str> {
self.repo_origin_url.as_deref()
@ -117,4 +158,14 @@ impl RunSpec {
pub fn base_branch(&self) -> Option<&str> {
self.base_branch.as_deref()
}
#[must_use]
pub fn pre_run_git(&self) -> Option<&PreRunGitContext> {
self.pre_run_git.as_ref()
}
#[must_use]
pub fn fork_source_ref(&self) -> Option<&ForkSourceRef> {
self.fork_source_ref.as_ref()
}
}

View file

@ -89,20 +89,16 @@ pub struct GitCloneFailedProps {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SandboxInitializedProps {
pub working_directory: String,
pub provider: String,
pub working_directory: String,
pub provider: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
pub identifier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_working_directory: Option<String>,
pub repo_cloned: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_mount_point: Option<String>,
pub clone_origin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_cloned: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clone_origin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clone_branch: Option<String>,
pub clone_branch: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View file

@ -884,7 +884,7 @@ mod tests {
"graph": graph,
"labels": {},
"run_dir": "/tmp/run",
"working_directory": "/tmp/run"
"source_directory": "/tmp/run"
}
});
@ -904,7 +904,7 @@ mod tests {
"graph": Graph::new("test"),
"labels": {},
"run_dir": "/tmp/run",
"working_directory": "/tmp/run",
"source_directory": "/tmp/run",
"manifest_blob": RunBlobId::new(br#"{"version":1}"#).to_string()
}
});

View file

@ -4,34 +4,42 @@ use serde::{Deserialize, Serialize};
use super::{ActorRef, BilledTokenCounts, RunNoticeLevel};
use crate::status::{BlockedReason, FailureReason, SuccessReason};
use crate::{Graph, RunBlobId, RunControlAction, RunId, RunProvenance, WorkflowSettings};
use crate::{
ForkSourceRef, Graph, PreRunGitContext, RunBlobId, RunControlAction, RunId, RunProvenance,
WorkflowSettings,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunCreatedProps {
pub settings: WorkflowSettings,
pub graph: Graph,
pub settings: WorkflowSettings,
pub graph: Graph,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_source: Option<String>,
pub workflow_source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_config: Option<String>,
pub workflow_config: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
pub run_dir: String,
pub working_directory: String,
pub labels: BTreeMap<String, String>,
pub run_dir: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_repo_path: Option<String>,
pub source_directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_origin_url: Option<String>,
pub repo_origin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_branch: Option<String>,
pub base_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_slug: Option<String>,
pub workflow_slug: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub db_prefix: Option<String>,
pub db_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<RunProvenance>,
pub provenance: Option<RunProvenance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_blob: Option<RunBlobId>,
pub manifest_blob: Option<RunBlobId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pre_run_git: Option<PreRunGitContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fork_source_ref: Option<ForkSourceRef>,
#[serde(default)]
pub checkpoints_disabled: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View file

@ -8,31 +8,35 @@ use crate::{RepositoryReference, RunControlAction, RunId, RunStatus};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunSummary {
pub run_id: RunId,
pub run_id: RunId,
#[serde(default)]
pub workflow_name: Option<String>,
pub workflow_name: Option<String>,
#[serde(default)]
pub workflow_slug: Option<String>,
pub goal: String,
pub title: String,
pub labels: HashMap<String, String>,
pub workflow_slug: Option<String>,
pub goal: String,
pub title: String,
pub labels: HashMap<String, String>,
#[serde(default)]
pub host_repo_path: Option<String>,
pub repository: RepositoryReference,
pub source_directory: Option<String>,
#[serde(default)]
pub start_time: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub status: RunStatus,
pub checkpoints_disabled: bool,
#[serde(default)]
pub pending_control: Option<RunControlAction>,
pub repo_origin_url: Option<String>,
pub repository: RepositoryReference,
#[serde(default)]
pub duration_ms: Option<u64>,
pub start_time: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub status: RunStatus,
#[serde(default)]
pub elapsed_secs: Option<f64>,
pub pending_control: Option<RunControlAction>,
#[serde(default)]
pub total_usd_micros: Option<i64>,
pub duration_ms: Option<u64>,
#[serde(default)]
pub superseded_by: Option<RunId>,
pub elapsed_secs: Option<f64>,
#[serde(default)]
pub total_usd_micros: Option<i64>,
#[serde(default)]
pub superseded_by: Option<RunId>,
}
impl RunSummary {
@ -46,7 +50,9 @@ impl RunSummary {
workflow_slug: Option<String>,
goal: String,
labels: HashMap<String, String>,
host_repo_path: Option<String>,
source_directory: Option<String>,
checkpoints_disabled: bool,
repo_origin_url: Option<String>,
start_time: Option<DateTime<Utc>>,
status: RunStatus,
pending_control: Option<RunControlAction>,
@ -56,7 +62,7 @@ impl RunSummary {
) -> Self {
let title = truncate_goal(&goal);
let repository = RepositoryReference {
name: repository_name(host_repo_path.as_deref()),
name: repository_name(repo_origin_url.as_deref(), source_directory.as_deref()),
};
let elapsed_secs = elapsed_secs(duration_ms);
let created_at = run_id.created_at();
@ -68,7 +74,9 @@ impl RunSummary {
goal,
title,
labels,
host_repo_path,
source_directory,
checkpoints_disabled,
repo_origin_url,
repository,
start_time,
created_at,
@ -95,11 +103,49 @@ fn truncate_goal(goal: &str) -> String {
format!("{truncated}...")
}
fn repository_name(host_repo_path: Option<&str>) -> String {
host_repo_path
.and_then(|path| path.rsplit(['/', '\\']).find(|segment| !segment.is_empty()))
.unwrap_or("unknown")
.to_string()
fn repository_name(repo_origin_url: Option<&str>, source_directory: Option<&str>) -> String {
repo_origin_url
.and_then(repository_name_from_origin)
.or_else(|| {
source_directory
.and_then(path_basename)
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| "unknown".to_string())
}
#[expect(
clippy::disallowed_types,
reason = "Run summaries parse the origin only to extract an owner/repo label; raw URLs are not logged or returned here."
)]
fn repository_name_from_origin(origin: &str) -> Option<String> {
if let Some(path) = origin
.strip_prefix("git@")
.and_then(|url| url.split_once(':').map(|(_, path)| path))
{
return repository_name_from_path(path).map(ToOwned::to_owned);
}
let parsed = url::Url::parse(origin).ok()?;
let path = parsed.path().trim_matches('/');
repository_name_from_path(path).map(ToOwned::to_owned)
}
fn repository_name_from_path(path: &str) -> Option<&str> {
let stripped = path.strip_suffix(".git").unwrap_or(path);
let mut segments = stripped.rsplit('/').filter(|segment| !segment.is_empty());
let repo = segments.next()?;
let owner = segments.next();
if let Some(owner) = owner {
let start = stripped.len() - owner.len() - repo.len() - 1;
stripped.get(start..)
} else {
Some(repo)
}
}
fn path_basename(path: &str) -> Option<&str> {
path.rsplit(['/', '\\']).find(|segment| !segment.is_empty())
}
fn elapsed_secs(duration_ms: Option<u64>) -> Option<f64> {
@ -116,14 +162,16 @@ mod tests {
use crate::{BlockedReason, RepositoryReference, RunControlAction, RunStatus, fixtures};
#[test]
fn round_trips_through_serde_json() {
fn summary_prefers_origin_name_over_submitter_source_directory() {
let summary = RunSummary::new(
fixtures::RUN_1,
Some("workflow".to_string()),
Some("workflow".to_string()),
"ship it".to_string(),
HashMap::from([("team".to_string(), "core".to_string())]),
Some("/tmp/repo".to_string()),
Some("/Users/client/local-checkout".to_string()),
false,
Some("https://github.com/fabro-sh/fabro.git".to_string()),
Some(Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 0).unwrap()),
RunStatus::Blocked {
blocked_reason: BlockedReason::HumanInputRequired,
@ -136,13 +184,62 @@ mod tests {
assert_eq!(summary.title, "ship it");
assert_eq!(summary.repository, RepositoryReference {
name: "repo".to_string(),
name: "fabro-sh/fabro".to_string(),
});
assert_eq!(summary.created_at, fixtures::RUN_1.created_at());
assert_eq!(summary.elapsed_secs, Some(0.042));
assert_eq!(
summary.source_directory.as_deref(),
Some("/Users/client/local-checkout")
);
let value = serde_json::to_value(&summary).unwrap();
assert!(value.get("host_repo_path").is_none());
assert_eq!(value["source_directory"], "/Users/client/local-checkout");
assert_eq!(
value["repo_origin_url"],
"https://github.com/fabro-sh/fabro.git"
);
let parsed: RunSummary = serde_json::from_value(value).unwrap();
assert_eq!(parsed, summary);
}
#[test]
fn summary_falls_back_to_source_directory_then_unknown() {
let source_only = RunSummary::new(
fixtures::RUN_1,
None,
None,
"ship it".to_string(),
HashMap::new(),
Some("/Users/client/local-checkout".to_string()),
false,
None,
None,
RunStatus::Submitted,
None,
None,
None,
None,
);
assert_eq!(source_only.repository.name, "local-checkout");
let unknown = RunSummary::new(
fixtures::RUN_1,
None,
None,
"ship it".to_string(),
HashMap::new(),
None,
false,
None,
None,
RunStatus::Submitted,
None,
None,
None,
None,
);
assert_eq!(unknown.repository.name, "unknown");
}
}

View file

@ -2,18 +2,14 @@ use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SandboxRecord {
pub provider: String,
pub working_directory: String,
pub provider: String,
pub working_directory: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
pub identifier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_working_directory: Option<String>,
pub repo_cloned: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container_mount_point: Option<String>,
pub clone_origin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_cloned: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clone_origin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clone_branch: Option<String>,
pub clone_branch: Option<String>,
}

View file

@ -1,10 +1,11 @@
use std::collections::BTreeMap;
use fabro_types::WorkflowSettings;
use fabro_types::graph::Graph;
use fabro_types::run::{DirtyStatus, ForkSourceRef, PreRunGitContext, PreRunPushOutcome};
use fabro_types::run_event::run::RunCreatedProps;
use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunGoal;
use fabro_types::{WorkflowSettings, fixtures};
fn templated_settings() -> WorkflowSettings {
let mut settings = WorkflowSettings::default();
@ -15,23 +16,41 @@ fn templated_settings() -> WorkflowSettings {
#[test]
fn run_created_props_round_trip_templated_settings() {
let props = RunCreatedProps {
settings: templated_settings(),
graph: Graph::new("ship"),
workflow_source: Some("digraph Ship { start -> exit }".to_string()),
workflow_config: Some("[run]\ngoal = \"Ship {{ env.TASK }}\"".to_string()),
labels: BTreeMap::from([("team".to_string(), "platform".to_string())]),
run_dir: "/tmp/run".to_string(),
working_directory: "/tmp/project".to_string(),
host_repo_path: Some("/tmp/project".to_string()),
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
base_branch: Some("main".to_string()),
workflow_slug: Some("demo".to_string()),
db_prefix: Some("run_".to_string()),
provenance: None,
manifest_blob: None,
settings: templated_settings(),
graph: Graph::new("ship"),
workflow_source: Some("digraph Ship { start -> exit }".to_string()),
workflow_config: Some("[run]\ngoal = \"Ship {{ env.TASK }}\"".to_string()),
labels: BTreeMap::from([("team".to_string(), "platform".to_string())]),
run_dir: "/tmp/run".to_string(),
source_directory: Some("/Users/client/project".to_string()),
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
base_branch: Some("main".to_string()),
workflow_slug: Some("demo".to_string()),
db_prefix: Some("run_".to_string()),
provenance: None,
manifest_blob: None,
pre_run_git: Some(PreRunGitContext {
display_base_sha: Some("abc123".to_string()),
local_dirty: DirtyStatus::Unknown,
push_outcome: PreRunPushOutcome::SkippedNoRemote,
}),
fork_source_ref: Some(ForkSourceRef {
source_run_id: fixtures::RUN_2,
checkpoint_sha: "def456".to_string(),
}),
checkpoints_disabled: true,
};
let json = serde_json::to_value(&props).expect("props should serialize");
assert!(json.get("working_directory").is_none());
assert!(json.get("host_repo_path").is_none());
assert_eq!(json["source_directory"], "/Users/client/project");
assert_eq!(
json["pre_run_git"]["push_outcome"]["type"],
"skipped_no_remote"
);
assert_eq!(json["checkpoints_disabled"], true);
let round_trip: RunCreatedProps =
serde_json::from_value(json.clone()).expect("props should deserialize");

View file

@ -1,24 +1,32 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use fabro_types::graph::Graph;
use fabro_types::run::RunSpec;
use fabro_types::run::{DirtyStatus, PreRunGitContext, PreRunPushOutcome, RunSpec};
use fabro_types::{WorkflowSettings, fixtures};
fn sample_run_spec() -> RunSpec {
RunSpec {
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("ship"),
workflow_slug: Some("demo".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None,
manifest_blob: None,
definition_blob: None,
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("ship"),
workflow_slug: Some("demo".to_string()),
source_directory: Some("/Users/client/project".to_string()),
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: Some(PreRunGitContext {
display_base_sha: Some("abc123".to_string()),
local_dirty: DirtyStatus::Dirty,
push_outcome: PreRunPushOutcome::SkippedRemoteMismatch {
remote: "https://github.com/user/fork.git".to_string(),
repo_origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
},
}),
fork_source_ref: None,
checkpoints_disabled: false,
}
}
@ -30,12 +38,17 @@ fn run_spec_getters_return_declared_fields() {
assert_eq!(run_spec.graph().name, "ship");
assert_eq!(run_spec.settings(), &WorkflowSettings::default());
assert_eq!(run_spec.workflow_slug(), Some("demo"));
assert_eq!(run_spec.working_directory(), Path::new("/tmp/project"));
assert_eq!(run_spec.source_directory(), Some("/Users/client/project"));
assert_eq!(
run_spec.labels().get("team").map(String::as_str),
Some("platform")
);
assert_eq!(run_spec.host_repo_path(), Some("/tmp/project"));
assert_eq!(
run_spec
.pre_run_git()
.and_then(|ctx| ctx.display_base_sha.as_deref()),
Some("abc123")
);
assert_eq!(
run_spec.repo_origin_url(),
Some("https://github.com/fabro-sh/fabro.git")

View file

@ -1,8 +1,7 @@
use std::collections::HashMap;
use std::path::PathBuf;
use fabro_types::graph::Graph;
use fabro_types::run::RunSpec;
use fabro_types::run::{DirtyStatus, ForkSourceRef, PreRunGitContext, PreRunPushOutcome, RunSpec};
use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunGoal;
use fabro_types::{WorkflowSettings, fixtures};
@ -16,21 +15,41 @@ fn templated_settings() -> WorkflowSettings {
#[test]
fn run_spec_round_trips_templated_settings() {
let record = RunSpec {
run_id: fixtures::RUN_1,
settings: templated_settings(),
graph: Graph::new("ship"),
workflow_slug: Some("demo".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None,
manifest_blob: None,
definition_blob: None,
run_id: fixtures::RUN_1,
settings: templated_settings(),
graph: Graph::new("ship"),
workflow_slug: Some("demo".to_string()),
source_directory: Some("/Users/client/project".to_string()),
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: Some(PreRunGitContext {
display_base_sha: Some("abc123".to_string()),
local_dirty: DirtyStatus::Clean,
push_outcome: PreRunPushOutcome::Succeeded {
remote: "origin".to_string(),
branch: "main".to_string(),
},
}),
fork_source_ref: Some(ForkSourceRef {
source_run_id: fixtures::RUN_2,
checkpoint_sha: "def456".to_string(),
}),
checkpoints_disabled: false,
};
let json = serde_json::to_value(&record).expect("record should serialize");
assert!(json.get("working_directory").is_none());
assert!(json.get("host_repo_path").is_none());
assert_eq!(json["source_directory"], "/Users/client/project");
assert_eq!(json["pre_run_git"]["local_dirty"], "clean");
assert_eq!(json["pre_run_git"]["push_outcome"]["type"], "succeeded");
assert_eq!(json["fork_source_ref"]["checkpoint_sha"], "def456");
assert_eq!(json["checkpoints_disabled"], false);
let round_trip: RunSpec =
serde_json::from_value(json.clone()).expect("record should deserialize");

View file

@ -5,9 +5,9 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use ::fabro_types::{
ActorRef, BilledTokenCounts, BlockedReason, FailureReason, ParallelBranchId, PullRequestRecord,
RunBlobId, RunControlAction, RunEvent, RunId, RunProvenance, StageId, StageStatus,
SuccessReason, run_event as fabro_types,
ActorRef, BilledTokenCounts, BlockedReason, FailureReason, ForkSourceRef, ParallelBranchId,
PreRunGitContext, PullRequestRecord, RunBlobId, RunControlAction, RunEvent, RunId,
RunProvenance, StageId, StageStatus, SuccessReason, run_event as fabro_types,
};
use anyhow::{Context, Result};
use chrono::Utc;
@ -37,30 +37,35 @@ use crate::runtime_store::RunStoreHandle;
)]
pub enum Event {
RunCreated {
run_id: RunId,
settings: serde_json::Value,
graph: serde_json::Value,
run_id: RunId,
settings: serde_json::Value,
graph: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
workflow_source: Option<String>,
workflow_source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
workflow_config: Option<String>,
labels: BTreeMap<String, String>,
run_dir: String,
working_directory: String,
workflow_config: Option<String>,
labels: BTreeMap<String, String>,
run_dir: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
host_repo_path: Option<String>,
source_directory: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
repo_origin_url: Option<String>,
repo_origin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
base_branch: Option<String>,
base_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
workflow_slug: Option<String>,
workflow_slug: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
db_prefix: Option<String>,
db_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provenance: Option<RunProvenance>,
provenance: Option<RunProvenance>,
#[serde(default, skip_serializing_if = "Option::is_none")]
manifest_blob: Option<RunBlobId>,
manifest_blob: Option<RunBlobId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pre_run_git: Option<PreRunGitContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
fork_source_ref: Option<ForkSourceRef>,
#[serde(default)]
checkpoints_disabled: bool,
},
WorkflowRunStarted {
name: String,
@ -387,20 +392,16 @@ pub enum Event {
},
/// Emitted after the sandbox has been initialized (by engine lifecycle).
SandboxInitialized {
working_directory: String,
provider: String,
working_directory: String,
provider: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
identifier: Option<String>,
identifier: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
host_working_directory: Option<String>,
repo_cloned: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
container_mount_point: Option<String>,
clone_origin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
repo_cloned: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
clone_origin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
clone_branch: Option<String>,
clone_branch: Option<String>,
},
SetupStarted {
command_count: usize,
@ -1520,31 +1521,35 @@ fn event_body_from_event(event: &Event) -> EventBody {
workflow_config,
labels,
run_dir,
working_directory,
host_repo_path,
source_directory,
repo_origin_url,
base_branch,
workflow_slug,
db_prefix,
provenance,
manifest_blob,
pre_run_git,
fork_source_ref,
checkpoints_disabled,
..
} => EventBody::RunCreated(fabro_types::RunCreatedProps {
settings: serde_json::from_value(settings.clone())
settings: serde_json::from_value(settings.clone())
.expect("run.created settings"),
graph: serde_json::from_value(graph.clone()).expect("run.created graph"),
workflow_source: workflow_source.clone(),
workflow_config: workflow_config.clone(),
labels: labels.clone(),
run_dir: run_dir.clone(),
working_directory: working_directory.clone(),
host_repo_path: host_repo_path.clone(),
repo_origin_url: repo_origin_url.clone(),
base_branch: base_branch.clone(),
workflow_slug: workflow_slug.clone(),
db_prefix: db_prefix.clone(),
provenance: provenance.clone(),
manifest_blob: *manifest_blob,
graph: serde_json::from_value(graph.clone()).expect("run.created graph"),
workflow_source: workflow_source.clone(),
workflow_config: workflow_config.clone(),
labels: labels.clone(),
run_dir: run_dir.clone(),
source_directory: source_directory.clone(),
repo_origin_url: repo_origin_url.clone(),
base_branch: base_branch.clone(),
workflow_slug: workflow_slug.clone(),
db_prefix: db_prefix.clone(),
provenance: provenance.clone(),
manifest_blob: *manifest_blob,
pre_run_git: pre_run_git.clone(),
fork_source_ref: fork_source_ref.clone(),
checkpoints_disabled: *checkpoints_disabled,
}),
Event::WorkflowRunStarted {
name,
@ -2249,20 +2254,16 @@ fn event_body_from_event(event: &Event) -> EventBody {
working_directory,
provider,
identifier,
host_working_directory,
container_mount_point,
repo_cloned,
clone_origin_url,
clone_branch,
} => EventBody::SandboxInitialized(fabro_types::SandboxInitializedProps {
working_directory: working_directory.clone(),
provider: provider.clone(),
identifier: identifier.clone(),
host_working_directory: host_working_directory.clone(),
container_mount_point: container_mount_point.clone(),
repo_cloned: *repo_cloned,
clone_origin_url: clone_origin_url.clone(),
clone_branch: clone_branch.clone(),
working_directory: working_directory.clone(),
provider: provider.clone(),
identifier: identifier.clone(),
repo_cloned: *repo_cloned,
clone_origin_url: clone_origin_url.clone(),
clone_branch: clone_branch.clone(),
}),
Event::SetupStarted { command_count } => {
EventBody::SetupStarted(fabro_types::SetupStartedProps {
@ -3670,21 +3671,23 @@ mod tests {
};
let stored = to_run_event(&fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(Graph::new("test")).unwrap(),
workflow_source: None,
workflow_config: None,
labels: BTreeMap::default(),
run_dir: "/tmp/run".to_string(),
working_directory: "/tmp/run".to_string(),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
workflow_slug: None,
db_prefix: None,
provenance: Some(provenance),
manifest_blob: None,
run_id: fixtures::RUN_1,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(Graph::new("test")).unwrap(),
workflow_source: None,
workflow_config: None,
labels: BTreeMap::default(),
run_dir: "/tmp/run".to_string(),
source_directory: Some("/tmp/run".to_string()),
repo_origin_url: None,
base_branch: None,
workflow_slug: None,
db_prefix: None,
provenance: Some(provenance),
manifest_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
});
let actor = stored.actor.as_ref().expect("actor set");
assert_eq!(actor.kind, ActorKind::User);

View file

@ -4,7 +4,6 @@ use std::process::Command;
pub use fabro_checkpoint::META_BRANCH_PREFIX;
pub use fabro_checkpoint::author::GitAuthor;
use fabro_checkpoint::git::Store;
pub use fabro_checkpoint::metadata::MetadataStore;
use fabro_types::WorkflowSettings;
use tokio::task::{JoinError, spawn_blocking};
use tokio::time::timeout;
@ -600,14 +599,4 @@ mod tests {
// No remote at all — should return true (safe default)
assert!(branch_needs_push(repo_dir, "origin", "main"));
}
#[test]
fn metadata_branch_name_uses_meta_prefix() {
assert_eq!(MetadataStore::branch_name("abc-123"), "fabro/meta/abc-123");
}
#[test]
fn meta_branch_prefix_constant() {
assert!(MetadataStore::branch_name("x").starts_with(META_BRANCH_PREFIX));
}
}

View file

@ -202,18 +202,20 @@ impl Handler for SubWorkflowHandler {
let child_cancel = Arc::clone(&cancel_token);
let child_run_options = RunOptions {
settings: WorkflowSettings::default(),
run_dir: child_logs,
cancel_token: Some(cancel_token),
settings: WorkflowSettings::default(),
run_dir: child_logs,
cancel_token: Some(cancel_token),
// Child workflows are part of the parent run's event stream.
run_id: services.run.emitter.run_id(),
labels: HashMap::new(),
workflow_slug: None,
github_app: None,
base_branch: None,
display_base_sha: None,
host_repo_path: None,
git: None,
run_id: services.run.emitter.run_id(),
labels: HashMap::new(),
workflow_slug: None,
github_app: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: true,
base_branch: None,
display_base_sha: None,
git: None,
};
// Clone parent context for child; inject parent preamble

View file

@ -150,6 +150,7 @@ pub mod run_options;
pub mod run_status;
pub mod runtime_store;
pub mod sandbox_git;
pub(crate) mod sandbox_metadata;
pub mod services;
#[doc(hidden)]
pub mod test_support;

View file

@ -1,4 +1,3 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
@ -11,14 +10,14 @@ use fabro_types::RunId;
use crate::artifact;
use crate::event::{Emitter, Event, RunNoticeLevel};
use crate::git::MetadataStore;
use crate::graph::{WorkflowGraph, WorkflowNode};
use crate::lifecycle::event::stage_scope_for;
use crate::outcome::BilledModelUsage;
use crate::run_dump::RunDump;
use crate::run_options::RunOptions;
use crate::runtime_store::RunStoreHandle;
use crate::sandbox_git::{git_checkpoint, git_diff, git_push_host};
use crate::sandbox_git::{git_checkpoint, git_diff};
use crate::sandbox_metadata::{SandboxGitRuntime, SandboxMetadataWriter};
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
@ -67,6 +66,7 @@ pub(crate) struct GitLifecycle {
pub run_id: RunId,
pub run_store: RunStoreHandle,
pub run_options: Arc<RunOptions>,
pub metadata_runtime: Arc<SandboxGitRuntime>,
pub start_node_id: Option<String>,
// Cross-lifecycle data (shared with EventLifecycle)
pub checkpoint_git_result: Arc<Mutex<Option<GitCheckpointResult>>>,
@ -79,33 +79,24 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
// Reset last_git_sha (diff base parity)
*self.last_git_sha.lock().unwrap() = None;
*self.checkpoint_git_result.lock().unwrap() = None;
// Init metadata branch (best-effort)
if let (Some(_), Some(repo_path)) = (
self.run_options
.git
.as_ref()
.and_then(|g| g.meta_branch.as_ref()),
self.run_options.host_repo_path.as_ref(),
) {
let git_author = self.run_options.git_author();
let store = MetadataStore::new(repo_path, &git_author);
let state = self.run_store.state().await.ok();
let init_dump = state.as_ref().map(RunDump::from_projection);
let init_entries = init_dump
.as_ref()
.and_then(|dump| dump.git_entries().ok())
.unwrap_or_default();
let refs: Vec<(&str, &[u8])> = init_entries
.iter()
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
.collect();
if let Err(e) = store.init_run(&self.run_id.to_string(), &refs) {
tracing::warn!(
run_id = %self.run_id,
error = %e,
"Metadata branch init failed"
);
if self
.run_options
.git
.as_ref()
.and_then(|g| g.meta_branch.as_ref())
.is_some()
{
match self.run_store.state().await {
Ok(state) => {
let dump = RunDump::from_projection(&state);
let _ = self.write_metadata_snapshot(&dump, "init run").await;
}
Err(err) => {
self.emit_metadata_warning(
"checkpoint_metadata_write_failed",
format!("failed to load run state for metadata init: {err}"),
);
}
}
}
@ -127,78 +118,28 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
return Ok(());
}
// Shadow commit (best-effort, metadata branch)
let shadow_sha: Option<String> = if let (Some(_), Some(repo_path)) = (
self.run_options
.git
.as_ref()
.and_then(|g| g.meta_branch.as_ref()),
self.run_options.host_repo_path.as_ref(),
) {
let git_author = self.run_options.git_author();
let store = MetadataStore::new(repo_path, &git_author);
let checkpoint = build_checkpoint(
node,
result,
next_node_id,
state,
HashMap::new(),
HashMap::new(),
None,
);
match self.run_store.state().await {
Ok(mut snapshot_state) => {
snapshot_state.checkpoint = Some(checkpoint);
let dump = RunDump::from_projection(&snapshot_state);
match dump.git_entries() {
Ok(dump_entries) => {
let refs: Vec<(&str, &[u8])> = dump_entries
.iter()
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
.collect();
match store.write_snapshot(
&self.run_id.to_string(),
&refs,
"checkpoint",
) {
Ok(sha) => Some(sha),
Err(e) => {
self.emitter.emit(&Event::RunNotice {
level: RunNoticeLevel::Warn,
code: "checkpoint_metadata_write_failed".to_string(),
message: format!(
"[node: {node_id}] metadata checkpoint write failed: {e}"
),
});
None
}
}
}
Err(e) => {
self.emitter.emit(&Event::RunNotice {
level: RunNoticeLevel::Warn,
code: "checkpoint_metadata_write_failed".to_string(),
message: format!(
"[node: {node_id}] metadata checkpoint serialization failed: {e}"
),
});
None
}
}
}
Err(e) => {
self.emitter.emit(&Event::RunNotice {
level: RunNoticeLevel::Warn,
code: "checkpoint_metadata_write_failed".to_string(),
message: format!(
"[node: {node_id}] failed to load run state for metadata snapshot: {e}"
),
});
None
}
let checkpoint = build_checkpoint(
node,
result,
next_node_id,
state,
std::collections::HashMap::new(),
std::collections::HashMap::new(),
None,
);
let shadow_sha = match self.run_store.state().await {
Ok(mut projection) => {
projection.checkpoint = Some(checkpoint);
let dump = RunDump::from_projection(&projection);
self.write_metadata_snapshot(&dump, "checkpoint").await
}
Err(err) => {
self.emit_metadata_warning(
"checkpoint_metadata_write_failed",
format!("failed to load run state for metadata checkpoint: {err}"),
);
None
}
} else {
None
};
// Run branch commit via sandbox
@ -232,41 +173,9 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
.as_ref()
.and_then(|g| g.run_branch.as_ref())
{
let push_ok = if self.sandbox.git_push_branch(branch).await {
true
} else if let Some(repo_path) = self.run_options.host_repo_path.as_ref() {
let refspec = format!("refs/heads/{branch}");
git_push_host(
repo_path,
&refspec,
&self.run_options.github_app,
"run branch",
)
.await
} else {
false
};
git_result.push_results.push((branch.clone(), push_ok));
}
// Push metadata branch (always from host)
if let (Some(meta_branch), Some(repo_path)) = (
self.run_options
.git
.as_ref()
.and_then(|g| g.meta_branch.as_ref()),
self.run_options.host_repo_path.as_ref(),
) {
let refspec = format!("refs/heads/{meta_branch}");
let meta_push_ok = git_push_host(
repo_path,
&refspec,
&self.run_options.github_app,
"metadata branch",
)
.await;
git_result
.push_results
.push((meta_branch.clone(), meta_push_ok));
let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
let push_ok = self.sandbox.git_push_ref(&refspec).await;
git_result.push_results.push((refspec, push_ok));
}
}
@ -320,3 +229,53 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
Ok(())
}
}
impl GitLifecycle {
async fn write_metadata_snapshot(&self, dump: &RunDump, message: &str) -> Option<String> {
if self.metadata_runtime.metadata_degraded() {
return None;
}
let meta_branch = self
.run_options
.git
.as_ref()
.and_then(|git| git.meta_branch.as_deref())?;
let run_id = self.run_id.to_string();
let writer = SandboxMetadataWriter::new(
&*self.sandbox,
&self.metadata_runtime,
&run_id,
meta_branch,
self.run_options.git_author(),
);
match writer.write_snapshot(dump, message).await {
Ok(snapshot) => {
if !snapshot.pushed {
self.emit_metadata_warning(
"checkpoint_metadata_push_failed",
format!("failed to push metadata ref refs/heads/{meta_branch}"),
);
}
Some(snapshot.commit_sha)
}
Err(err) => {
self.emit_metadata_warning(
"checkpoint_metadata_write_failed",
format!("failed to write checkpoint metadata: {err}"),
);
None
}
}
}
fn emit_metadata_warning(&self, code: &str, message: String) {
if self.metadata_runtime.mark_metadata_degraded() {
self.emitter.emit(&Event::RunNotice {
level: RunNoticeLevel::Warn,
code: code.to_string(),
message,
});
}
}
}

View file

@ -41,6 +41,7 @@ use crate::outcome::{BilledModelUsage, Outcome, OutcomeExt};
use crate::run_control::RunControlState;
use crate::run_options::RunOptions;
use crate::runtime_store::RunStoreHandle;
use crate::sandbox_metadata::SandboxGitRuntime;
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
@ -87,6 +88,7 @@ impl WorkflowLifecycle {
run_store: &RunStoreHandle,
artifact_sink: Option<ArtifactSink>,
run_options: &Arc<RunOptions>,
metadata_runtime: Arc<SandboxGitRuntime>,
is_resume: bool,
on_node: crate::OnNodeCallback,
run_control: Option<Arc<RunControlState>>,
@ -104,7 +106,7 @@ impl WorkflowLifecycle {
.as_ref()
.and_then(|g| g.run_branch.as_ref())
.is_some();
let local_git_checkpoint = has_run_branch && sandbox.host_git_dir().is_some();
let local_git_checkpoint = has_run_branch && !run_options.checkpoints_disabled;
let working_directory = if local_git_checkpoint {
Some(sandbox.working_directory().to_string())
} else {
@ -149,6 +151,7 @@ impl WorkflowLifecycle {
run_id: run_options.run_id,
run_store: run_store.clone(),
run_options: Arc::clone(run_options),
metadata_runtime,
start_node_id,
checkpoint_git_result: Arc::clone(&checkpoint_git_result),
last_git_sha,

View file

@ -12,11 +12,10 @@ use fabro_config::Storage;
use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_model::{Catalog, Provider};
use fabro_sandbox::SandboxProvider;
use fabro_sandbox::daytona::detect_repo_info;
use fabro_store::Database;
use fabro_template::{TemplateContext, render as render_template};
use fabro_types::settings::run::{RunMode, RunNamespace};
use fabro_types::{RunId, RunProvenance, WorkflowSettings};
use fabro_types::{ForkSourceRef, PreRunGitContext, RunId, RunProvenance, WorkflowSettings};
use fabro_util::json::normalize_json_value;
use tokio::task::spawn_blocking;
@ -42,9 +41,11 @@ pub struct CreateRunInput {
pub workflow_bundle: Option<WorkflowBundle>,
pub submitted_manifest_bytes: Option<Vec<u8>>,
pub run_id: Option<RunId>,
pub host_repo_path: Option<String>,
pub repo_origin_url: Option<String>,
pub base_branch: Option<String>,
pub pre_run_git: Option<PreRunGitContext>,
pub fork_source_ref: Option<ForkSourceRef>,
pub checkpoints_disabled: bool,
pub provenance: Option<RunProvenance>,
pub configured_providers: Vec<Provider>,
}
@ -64,9 +65,11 @@ struct PersistCreateOptions {
workflow_slug: Option<String>,
labels: HashMap<String, String>,
base_branch: Option<String>,
working_directory: PathBuf,
host_repo_path: Option<String>,
source_directory: Option<String>,
repo_origin_url: Option<String>,
pre_run_git: Option<PreRunGitContext>,
fork_source_ref: Option<ForkSourceRef>,
checkpoints_disabled: bool,
provenance: Option<RunProvenance>,
configured_providers: Vec<Provider>,
}
@ -98,9 +101,11 @@ pub async fn create(
workflow_bundle,
submitted_manifest_bytes,
run_id,
host_repo_path,
repo_origin_url,
base_branch,
pre_run_git,
fork_source_ref,
checkpoints_disabled,
provenance,
configured_providers,
} = request;
@ -108,20 +113,7 @@ pub async fn create(
let run_id = run_id.unwrap_or_else(RunId::new);
let storage = Storage::new(storage_root);
let run_dir = storage.run_scratch(&run_id).root().to_path_buf();
let working_directory = resolved.working_directory.clone();
let host_repo_path =
host_repo_path.or_else(|| Some(working_directory.to_string_lossy().to_string()));
let detected_repo = detect_repo_info(&working_directory).ok();
let repo_origin_url = repo_origin_url.or_else(|| {
detected_repo
.as_ref()
.map(|(origin_url, _)| fabro_github::normalize_repo_origin_url(origin_url))
});
let base_branch = base_branch.or_else(|| {
detected_repo
.as_ref()
.and_then(|(_, branch)| branch.clone())
});
let source_directory = Some(resolved.working_directory.to_string_lossy().to_string());
let goal_override = resolved.goal_override.clone();
let current_dir = resolved.current_dir.clone();
@ -147,9 +139,11 @@ pub async fn create(
workflow_slug: workflow_slug.or(resolved_workflow_slug),
labels,
base_branch,
working_directory,
host_repo_path,
source_directory,
repo_origin_url,
pre_run_git,
fork_source_ref,
checkpoints_disabled,
provenance,
configured_providers,
},
@ -233,14 +227,16 @@ async fn persist_created_run(
.into_iter()
.collect::<BTreeMap<_, _>>(),
run_dir: persisted.run_dir().display().to_string(),
working_directory: record.working_directory.display().to_string(),
host_repo_path: record.host_repo_path.clone(),
source_directory: record.source_directory.clone(),
repo_origin_url: record.repo_origin_url.clone(),
base_branch: record.base_branch.clone(),
workflow_slug: record.workflow_slug.clone(),
db_prefix: None,
provenance: record.provenance.clone(),
manifest_blob,
pre_run_git: record.pre_run_git.clone(),
fork_source_ref: record.fork_source_ref.clone(),
checkpoints_disabled: record.checkpoints_disabled,
},
record.run_id.created_at(),
None,
@ -355,9 +351,11 @@ fn persist_validated(
workflow_slug,
labels,
base_branch,
working_directory,
host_repo_path,
source_directory,
repo_origin_url,
pre_run_git,
fork_source_ref,
checkpoints_disabled,
provenance,
configured_providers,
} = options;
@ -377,14 +375,16 @@ fn persist_validated(
settings,
graph: validated.graph().clone(),
workflow_slug,
working_directory,
host_repo_path,
source_directory,
repo_origin_url,
base_branch,
labels,
provenance,
manifest_blob: None,
definition_blob: None,
pre_run_git,
fork_source_ref,
checkpoints_disabled,
};
pipeline::persist(validated, PersistOptions { run_dir, run_spec })
@ -722,9 +722,11 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: None,
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
provenance: None,
configured_providers: Vec::new(),
},
@ -765,9 +767,11 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: None,
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
provenance: None,
configured_providers: Vec::new(),
},
@ -824,9 +828,11 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_1),
host_repo_path: Some(dir.path().display().to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
provenance: None,
configured_providers: Vec::new(),
},
@ -902,7 +908,7 @@ mod tests {
}
#[tokio::test]
async fn create_resolves_working_directory_and_repo_path_from_request_cwd() {
async fn create_persists_submitter_source_directory_from_request_cwd() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
@ -932,9 +938,11 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_2),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
provenance: None,
configured_providers: Vec::new(),
},
@ -943,17 +951,9 @@ mod tests {
.await
.unwrap();
assert_eq!(created.persisted.run_spec().working_directory, workspace);
assert_eq!(
created.persisted.run_spec().host_repo_path.as_deref(),
Some(
created
.persisted
.run_spec()
.working_directory
.to_string_lossy()
.as_ref()
)
created.persisted.run_spec().source_directory.as_deref(),
Some(workspace.to_string_lossy().as_ref())
);
}
@ -976,9 +976,11 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_2),
host_repo_path: None,
repo_origin_url: Some("https://github.com/acme/widgets".to_string()),
base_branch: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
provenance: None,
configured_providers: Vec::new(),
},
@ -1040,9 +1042,11 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_3),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
provenance: None,
configured_providers: Vec::new(),
},
@ -1083,9 +1087,11 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_64),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
provenance: Some(fabro_types::RunProvenance {
server: Some(fabro_types::RunServerProvenance {
version: "0.9.0".to_string(),

View file

@ -1,17 +1,11 @@
use anyhow::{Context, Result};
use fabro_checkpoint::branch::BranchStore;
use fabro_checkpoint::git::Store;
use fabro_store::{Database, RunProjection};
use fabro_types::RunId;
use git2::{Oid, Signature};
use anyhow::Result as AnyResult;
use fabro_store::{Database, RunProjection, RunProjectionReducer};
use fabro_types::{ForkSourceRef, RunId};
use super::run_git;
use super::timeline::{ForkTarget, RunTimeline, TimelineEntry, build_timeline};
use crate::error::Error;
use crate::event::{self, Event};
use crate::git::{MetadataStore, RUN_BRANCH_PREFIX, push_run_branches};
use crate::records::{Checkpoint, RunSpec, StartRecord};
use crate::run_dump::RunDump;
use crate::records::{Checkpoint, RunSpec};
#[derive(Debug, Clone)]
pub struct ForkRunInput {
@ -41,60 +35,112 @@ pub struct ForkOutcome {
pub target: ResolvedForkTarget,
}
#[derive(Debug)]
struct ForkedRun {
new_run_id: RunId,
projection: RunProjection,
}
/// Create a new run that branches from an existing run at a specific
/// checkpoint.
///
/// Returns the new run ID.
#[cfg(test)]
fn fork(store: &Store, input: &ForkRunInput) -> Result<RunId> {
let timeline = build_timeline(store, &input.source_run_id.to_string())?;
let entry = resolve_fork_entry(&timeline, &input.source_run_id, input.target.as_ref())?;
Ok(fork_from_entry(store, &input.source_run_id, entry, input.push)?.new_run_id)
}
pub async fn fork_run(store: &Database, input: &ForkRunInput) -> Result<ForkOutcome, Error> {
pub async fn fork_run(
store: &Database,
input: &ForkRunInput,
) -> std::result::Result<ForkOutcome, Error> {
let source_run_id = input.source_run_id;
let target = input.target.clone();
let push = input.push;
let run_store = store
.open_run(&source_run_id)
.await
.map_err(|err| Error::engine(err.to_string()))?;
let state = run_store
.state()
.await
.map_err(|err| Error::engine(err.to_string()))?;
let timeline = build_timeline(&state).map_err(|err| Error::engine(err.to_string()))?;
let entry = resolve_fork_entry(&timeline, &source_run_id, input.target.as_ref())
.map_err(|err| Error::Validation(err.to_string()))?;
let checkpoint_sha = entry.run_commit_sha.clone().ok_or_else(|| {
Error::Validation(format!(
"checkpoint @{} has no git_commit_sha; cannot fork",
entry.ordinal
))
})?;
let (outcome, projection) =
run_git::with_run_git_store(store, source_run_id, move |git_store| {
let timeline = build_timeline(&git_store, &source_run_id.to_string())
.map_err(|err| Error::engine(err.to_string()))?;
let entry = resolve_fork_entry(&timeline, &source_run_id, target.as_ref())
.map_err(|err| Error::Validation(err.to_string()))?;
let resolved = ResolvedForkTarget {
checkpoint_ordinal: entry.ordinal,
node_id: entry.node_name.clone(),
visit: entry.visit,
};
let forked = fork_from_entry(&git_store, &source_run_id, entry, push)
.map_err(|err| Error::engine(err.to_string()))?;
let outcome = ForkOutcome {
source_run_id,
new_run_id: forked.new_run_id,
target: resolved,
};
Ok((outcome, forked.projection))
})
.await?;
validate_source_spec(state.spec.as_ref(), &checkpoint_sha)?;
let events = run_store
.list_events()
.await
.map_err(|err| Error::engine(err.to_string()))?;
let historical_events = events
.into_iter()
.filter(|event| event.seq <= entry.checkpoint_seq)
.collect::<Vec<_>>();
let mut projection = RunProjection::apply_events(&historical_events)
.map_err(|err| Error::engine(err.to_string()))?;
let mut run_spec = projection
.spec
.clone()
.ok_or_else(|| Error::engine("source run projection has no spec"))?;
let new_run_id = RunId::new();
run_spec.run_id = new_run_id;
run_spec.fork_source_ref = Some(ForkSourceRef {
source_run_id,
checkpoint_sha: checkpoint_sha.clone(),
});
projection.spec = Some(run_spec);
projection.start = None;
projection.sandbox = None;
projection.conclusion = None;
projection.retro = None;
projection.retro_prompt = None;
projection.retro_response = None;
projection.final_patch = None;
projection.pull_request = None;
projection.superseded_by = None;
if let Some(checkpoint) = projection.checkpoint.as_mut() {
checkpoint.git_commit_sha = Some(checkpoint_sha);
}
persist_forked_run(store, &projection).await?;
Ok(outcome)
Ok(ForkOutcome {
source_run_id,
new_run_id,
target: ResolvedForkTarget {
checkpoint_ordinal: entry.ordinal,
node_id: entry.node_name.clone(),
visit: entry.visit,
},
})
}
fn validate_source_spec(
spec: Option<&RunSpec>,
checkpoint_sha: &str,
) -> std::result::Result<(), Error> {
let spec = spec.ok_or_else(|| Error::engine("source run projection has no spec"))?;
if spec.checkpoints_disabled {
return Err(Error::Validation(
"source run was created with checkpoints disabled; cannot fork".to_string(),
));
}
if checkpoint_sha.trim().is_empty() {
return Err(Error::Validation(
"target checkpoint has an empty git_commit_sha; cannot fork".to_string(),
));
}
let Some(origin) = spec.repo_origin_url.as_ref() else {
return Err(Error::Validation(
"source run has no repo_origin_url; cannot validate fork origin".to_string(),
));
};
if fabro_github::normalize_repo_origin_url(origin).is_empty() {
return Err(Error::Validation(
"source run has an empty repo_origin_url; cannot validate fork origin".to_string(),
));
}
Ok(())
}
fn resolve_fork_entry<'a>(
timeline: &'a RunTimeline,
source_run_id: &RunId,
target: Option<&ForkTarget>,
) -> Result<&'a TimelineEntry> {
) -> AnyResult<&'a TimelineEntry> {
match target {
Some(target) => timeline.resolve(target),
None => timeline
@ -104,145 +150,14 @@ fn resolve_fork_entry<'a>(
}
}
fn fork_from_entry(
store: &Store,
source_run_id: &RunId,
entry: &TimelineEntry,
push: bool,
) -> Result<ForkedRun> {
let new_run_id = RunId::new();
let sig = Signature::now("Fabro", "noreply@fabro.sh")?;
let new_run_branch = format!("{RUN_BRANCH_PREFIX}{new_run_id}");
match &entry.run_commit_sha {
Some(sha) => {
let oid =
Oid::from_str(sha).with_context(|| format!("invalid run commit SHA: {sha}"))?;
store
.update_ref(&new_run_branch, oid)
.map_err(|e| anyhow::anyhow!("failed to create run branch ref: {e}"))?;
}
None => {
anyhow::bail!(
"checkpoint @{} has no git_commit_sha; cannot fork",
entry.ordinal
);
}
}
let source_meta_branch = MetadataStore::branch_name(&source_run_id.to_string());
let new_meta_branch = MetadataStore::branch_name(&new_run_id.to_string());
let source_bs = BranchStore::new(store, &source_meta_branch, &sig);
let new_bs = BranchStore::new(store, &new_meta_branch, &sig);
new_bs
.ensure_branch()
.map_err(|e| anyhow::anyhow!("failed to create metadata branch: {e}"))?;
let source_projection = source_bs
.read_entry("run.json")
.map_err(|e| anyhow::anyhow!("failed to read source metadata: {e}"))?
.context("source run has no run.json")
.and_then(|bytes| {
serde_json::from_slice::<RunProjection>(&bytes)
.context("failed to parse source run.json")
})?;
let mut run_spec: RunSpec = source_projection
.spec
.clone()
.context("source run projection has no spec")?;
run_spec.run_id = new_run_id;
let start_record = StartRecord {
run_id: new_run_id,
start_time: new_run_id.created_at(),
run_branch: Some(new_run_branch.clone()),
base_sha: entry.run_commit_sha.clone(),
};
let mut init_projection = RunProjection::default();
init_projection.spec = Some(run_spec.clone());
init_projection
.graph_source
.clone_from(&source_projection.graph_source);
init_projection.start = Some(start_record.clone());
init_projection
.sandbox
.clone_from(&source_projection.sandbox);
let checkpoint_bytes = store
.read_blob_at(entry.metadata_commit_oid, "run.json")
.map_err(|e| anyhow::anyhow!("failed to read checkpoint snapshot: {e}"))?
.ok_or_else(|| {
anyhow::anyhow!(
"no run.json at metadata commit {}",
entry.metadata_commit_oid
)
})?;
let mut checkpoint_projection: RunProjection = serde_json::from_slice(&checkpoint_bytes)
.context("failed to parse source checkpoint snapshot")?;
let mut checkpoint: Checkpoint = checkpoint_projection
.checkpoint
.clone()
.context("source checkpoint snapshot has no checkpoint")?;
checkpoint.git_commit_sha.clone_from(&entry.run_commit_sha);
checkpoint_projection.spec = Some(run_spec);
checkpoint_projection.graph_source = source_projection.graph_source;
checkpoint_projection.start = Some(start_record);
checkpoint_projection.sandbox = source_projection.sandbox;
checkpoint_projection.checkpoint = Some(checkpoint);
let init_entries = RunDump::from_projection(&init_projection)
.git_entries()
.context("failed to build init metadata snapshot")?;
let init_refs: Vec<(&str, &[u8])> = init_entries
.iter()
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
.collect();
new_bs
.write_entries(&init_refs, "init run")
.map_err(|e| anyhow::anyhow!("failed to write init metadata snapshot: {e}"))?;
let checkpoint_entries = RunDump::from_projection(&checkpoint_projection)
.git_entries()
.context("failed to build checkpoint metadata snapshot")?;
let checkpoint_refs: Vec<(&str, &[u8])> = checkpoint_entries
.iter()
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
.collect();
new_bs
.write_entries(&checkpoint_refs, "checkpoint")
.map_err(|e| anyhow::anyhow!("failed to write metadata snapshot: {e}"))?;
if push {
let source_run_branch = format!("{RUN_BRANCH_PREFIX}{source_run_id}");
let run_refspec = format!("refs/heads/{new_run_branch}:refs/heads/{new_run_branch}");
let meta_refspec = format!("refs/heads/{new_meta_branch}:refs/heads/{new_meta_branch}");
push_run_branches(
store,
&source_run_branch,
Some(&run_refspec),
&meta_refspec,
"new",
)?;
}
Ok(ForkedRun {
new_run_id,
projection: checkpoint_projection,
})
}
async fn persist_forked_run(store: &Database, projection: &RunProjection) -> Result<(), Error> {
async fn persist_forked_run(
store: &Database,
projection: &RunProjection,
) -> std::result::Result<(), Error> {
let spec = projection
.spec
.as_ref()
.ok_or_else(|| Error::engine("forked run projection has no spec"))?;
let start = projection
.start
.as_ref()
.ok_or_else(|| Error::engine("forked run projection has no start record"))?;
let checkpoint = projection
.checkpoint
.as_ref()
@ -254,54 +169,29 @@ async fn persist_forked_run(store: &Database, projection: &RunProjection) -> Res
.map_err(|err| Error::engine(err.to_string()))?;
event::append_event(&run_store, &spec.run_id, &Event::RunCreated {
run_id: spec.run_id,
settings: serde_json::to_value(&spec.settings)
run_id: spec.run_id,
settings: serde_json::to_value(&spec.settings)
.map_err(|err| Error::engine(err.to_string()))?,
graph: serde_json::to_value(&spec.graph)
graph: serde_json::to_value(&spec.graph)
.map_err(|err| Error::engine(err.to_string()))?,
workflow_source: projection.graph_source.clone(),
workflow_config: None,
labels: spec.labels.clone().into_iter().collect(),
run_dir: String::new(),
working_directory: spec.working_directory.display().to_string(),
host_repo_path: spec.host_repo_path.clone(),
repo_origin_url: spec.repo_origin_url.clone(),
base_branch: spec.base_branch.clone(),
workflow_slug: spec.workflow_slug.clone(),
db_prefix: None,
provenance: spec.provenance.clone(),
manifest_blob: spec.manifest_blob,
workflow_source: projection.graph_source.clone(),
workflow_config: None,
labels: spec.labels.clone().into_iter().collect(),
run_dir: String::new(),
source_directory: spec.source_directory.clone(),
repo_origin_url: spec.repo_origin_url.clone(),
base_branch: spec.base_branch.clone(),
workflow_slug: spec.workflow_slug.clone(),
db_prefix: None,
provenance: spec.provenance.clone(),
manifest_blob: spec.manifest_blob,
pre_run_git: spec.pre_run_git.clone(),
fork_source_ref: spec.fork_source_ref.clone(),
checkpoints_disabled: spec.checkpoints_disabled,
})
.await
.map_err(|err| Error::engine(err.to_string()))?;
event::append_event(&run_store, &spec.run_id, &Event::WorkflowRunStarted {
name: spec.graph.name.clone(),
run_id: spec.run_id,
base_branch: spec.base_branch.clone(),
base_sha: start.base_sha.clone(),
run_branch: start.run_branch.clone(),
worktree_dir: None,
goal: None,
})
.await
.map_err(|err| Error::engine(err.to_string()))?;
if let Some(sandbox) = projection.sandbox.as_ref() {
event::append_event(&run_store, &spec.run_id, &Event::SandboxInitialized {
provider: sandbox.provider.clone(),
working_directory: sandbox.working_directory.clone(),
identifier: sandbox.identifier.clone(),
host_working_directory: sandbox.host_working_directory.clone(),
container_mount_point: sandbox.container_mount_point.clone(),
repo_cloned: sandbox.repo_cloned,
clone_origin_url: sandbox.clone_origin_url.clone(),
clone_branch: sandbox.clone_branch.clone(),
})
.await
.map_err(|err| Error::engine(err.to_string()))?;
}
event::append_event(
&run_store,
&spec.run_id,
@ -349,190 +239,3 @@ fn checkpoint_completed_event(checkpoint: &Checkpoint) -> Event {
diff: None,
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use fabro_store::RunProjection;
use fabro_types::{RunId, WorkflowSettings};
use git2::Oid;
use super::super::test_support::*;
use super::*;
use crate::operations::find_run_id_by_prefix;
fn parse_run_id(value: &str) -> RunId {
value.parse().unwrap()
}
fn make_run_projection(run_id: &RunId) -> RunProjection {
let mut projection = RunProjection::default();
let settings = serde_json::to_value(WorkflowSettings::default()).unwrap();
projection.spec = Some(
serde_json::from_value(serde_json::json!({
"run_id": run_id.to_string(),
"created_at": "2025-01-01T00:00:00Z",
"settings": settings,
"graph": {
"name": "test_workflow",
"nodes": {
"start": {"id": "start", "attrs": {}},
"build": {"id": "build", "attrs": {}},
"test": {"id": "test", "attrs": {}}
},
"edges": [
{"from": "start", "to": "build", "attrs": {}},
{"from": "build", "to": "test", "attrs": {}}
],
"attrs": {}
},
"working_directory": "/tmp/test",
}))
.unwrap(),
);
projection
}
fn make_start_record_json(run_id: &RunId) -> Vec<u8> {
let record = serde_json::json!({
"run_id": run_id.to_string(),
"start_time": "2025-01-01T00:00:00Z",
"run_branch": format!("{}{}", RUN_BRANCH_PREFIX, run_id),
});
serde_json::to_vec_pretty(&record).unwrap()
}
fn setup_source_run(store: &Store, run_id: &RunId, nodes: &[&str]) -> Vec<Oid> {
let sig = test_sig();
let run_branch = format!("{RUN_BRANCH_PREFIX}{run_id}");
let empty_tree = store.write_empty_tree().unwrap();
let mut run_oids = Vec::new();
let mut parent: Option<Oid> = None;
for node in nodes {
let parents = match parent {
Some(p) => vec![p],
None => vec![],
};
let oid = store
.write_commit(
empty_tree,
&parents,
&format!("fabro({run_id}): {node} (completed)"),
&sig,
)
.unwrap();
store.update_ref(&run_branch, oid).unwrap();
run_oids.push(oid);
parent = Some(oid);
}
let meta_branch = MetadataStore::branch_name(&run_id.to_string());
let bs = BranchStore::new(store, &meta_branch, &sig);
bs.ensure_branch().unwrap();
let mut init_projection = make_run_projection(run_id);
init_projection.start =
Some(serde_json::from_slice(&make_start_record_json(run_id)).unwrap());
let init_json = serde_json::to_vec_pretty(&init_projection).unwrap();
bs.write_entries(&[("run.json", &init_json)], "init run")
.unwrap();
for (i, node) in nodes.iter().enumerate() {
let mut projection = init_projection.clone();
projection.checkpoint = Some(
serde_json::from_slice(&make_checkpoint_bytes(
node,
1,
Some(&run_oids[i].to_string()),
))
.unwrap(),
);
let projection_json = serde_json::to_vec_pretty(&projection).unwrap();
bs.write_entry("run.json", &projection_json, "checkpoint")
.unwrap();
}
run_oids
}
#[test]
fn fork_creates_new_run_and_metadata_branches() {
let (_dir, store) = temp_repo();
let source_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
let run_oids = setup_source_run(&store, &source_run_id, &["start", "build", "test"]);
let new_run_id = fork(&store, &ForkRunInput {
source_run_id,
target: Some(ForkTarget::from_str("@2").unwrap()),
push: false,
})
.unwrap();
let new_run_branch = format!("{RUN_BRANCH_PREFIX}{new_run_id}");
let new_meta_branch = MetadataStore::branch_name(&new_run_id.to_string());
assert!(store.resolve_ref(&new_run_branch).unwrap().is_some());
assert!(store.resolve_ref(&new_meta_branch).unwrap().is_some());
let sig = test_sig();
let bs = BranchStore::new(&store, &new_meta_branch, &sig);
let run_json = bs.read_entry("run.json").unwrap().unwrap();
let run_spec: RunProjection = serde_json::from_slice(&run_json).unwrap();
assert_eq!(
run_spec.spec.as_ref().map(|run| run.run_id),
Some(new_run_id)
);
let timeline = build_timeline(&store, &new_run_id.to_string()).unwrap();
assert_eq!(timeline.entries.len(), 1);
assert_eq!(timeline.entries[0].node_name, "build");
assert_eq!(
timeline.entries[0].run_commit_sha,
Some(run_oids[1].to_string())
);
}
#[test]
fn fork_rejects_checkpoint_without_run_sha() {
let (_dir, store) = temp_repo();
let sig = test_sig();
let run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAW");
let meta_branch = MetadataStore::branch_name(&run_id.to_string());
let bs = BranchStore::new(&store, &meta_branch, &sig);
bs.ensure_branch().unwrap();
let init_projection = serde_json::to_vec_pretty(&make_run_projection(&run_id)).unwrap();
bs.write_entry("run.json", &init_projection, "init")
.unwrap();
let mut checkpoint_projection = make_run_projection(&run_id);
checkpoint_projection.checkpoint =
Some(serde_json::from_slice(&make_checkpoint_bytes("start", 1, None)).unwrap());
let cp = serde_json::to_vec_pretty(&checkpoint_projection).unwrap();
let oid = bs.write_entry("run.json", &cp, "checkpoint").unwrap();
let entry = TimelineEntry {
ordinal: 1,
node_name: "start".to_string(),
visit: 1,
metadata_commit_oid: oid,
run_commit_sha: None,
};
let err = fork_from_entry(&store, &run_id, &entry, false)
.unwrap_err()
.to_string();
assert!(err.contains("cannot fork"));
}
#[test]
fn fork_supports_prefix_resolved_source_run_ids() {
let (_dir, store) = temp_repo();
let source_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAX");
setup_source_run(&store, &source_run_id, &["start", "build"]);
let resolved = find_run_id_by_prefix(store.repo(), "01ARZ3").unwrap();
assert_eq!(resolved, source_run_id);
}
}

View file

@ -1,15 +1,11 @@
mod archive;
mod create;
mod fork;
mod rebuild_meta;
mod resume;
mod rewind;
mod run_git;
mod run_store;
mod source;
mod start;
#[cfg(test)]
mod test_support;
mod timeline;
mod validate;
@ -19,16 +15,11 @@ pub use archive::{
};
pub use create::{CreateRunInput, CreatedRun, create, make_run_dir};
pub use fork::{ForkOutcome, ForkRunInput, ResolvedForkTarget, fork_run};
pub use rebuild_meta::{
build_timeline_or_rebuild, find_run_id_by_prefix_or_store, rebuild_metadata_branch,
};
pub use resume::resume;
pub use rewind::{RewindInput, RewindOutcome, rewind};
pub use source::WorkflowInput;
pub use start::{StartServices, Started, start};
pub use timeline::{
ForkTarget, RunTimeline, TimelineEntry, build_timeline, find_run_id_by_prefix, timeline,
};
pub use timeline::{ForkTarget, RunTimeline, TimelineEntry, build_timeline, timeline};
pub use validate::{ValidateInput, validate};
pub use crate::pipeline::{DevcontainerSpec, LlmSpec, SandboxEnvSpec};

File diff suppressed because it is too large Load diff

View file

@ -2,9 +2,9 @@ use fabro_store::Database;
use fabro_types::{ActorRef, RunId};
use tracing::error;
use super::archive;
use super::fork::{self, ForkOutcome, ForkRunInput, ResolvedForkTarget};
use super::timeline::ForkTarget;
use super::{archive, run_git};
use crate::error::Error;
use crate::event::{self, Event};
@ -35,7 +35,13 @@ pub async fn rewind(
input: &RewindInput,
actor: Option<ActorRef>,
) -> Result<RewindOutcome, Error> {
let projection = run_git::load_projection(store, &input.run_id).await?;
let projection = store
.open_run(&input.run_id)
.await
.map_err(|err| Error::engine(err.to_string()))?
.state()
.await
.map_err(|err| Error::engine(err.to_string()))?;
let current = projection.status.ok_or_else(|| {
Error::Precondition(format!("run {} has no status; cannot rewind", input.run_id))
})?;
@ -48,11 +54,11 @@ pub async fn rewind(
)));
}
let forked = fork::fork_run(store, &ForkRunInput {
let forked = Box::pin(fork::fork_run(store, &ForkRunInput {
source_run_id: input.run_id,
target: input.target.clone(),
push: input.push,
})
}))
.await?;
match archive::archive(store, &input.run_id, actor).await {

View file

@ -1,49 +0,0 @@
use fabro_checkpoint::git::Store as GitStore;
use fabro_store::Database;
use fabro_types::{RunId, RunProjection};
use git2::Repository;
use tokio::task::spawn_blocking;
use super::run_store::map_open_run_error;
use crate::error::Error;
pub(crate) async fn load_projection(
store: &Database,
run_id: &RunId,
) -> Result<RunProjection, Error> {
let run_store = store
.open_run_reader(run_id)
.await
.map_err(|err| map_open_run_error(run_id, err))?;
run_store
.state()
.await
.map_err(|err| Error::engine(err.to_string()))
}
pub(crate) async fn with_run_git_store<T>(
store: &Database,
run_id: RunId,
operation: impl FnOnce(GitStore) -> Result<T, Error> + Send + 'static,
) -> Result<T, Error>
where
T: Send + 'static,
{
let projection = load_projection(store, &run_id).await?;
let spec = projection
.spec
.ok_or_else(|| Error::Precondition(format!("run {run_id} has no spec")))?;
let working_directory = spec.working_directory;
spawn_blocking(move || {
let repo = Repository::discover(&working_directory).map_err(|err| {
Error::Unsupported(format!(
"server cannot access run {run_id}'s working_directory {}: {err}",
working_directory.display()
))
})?;
operation(GitStore::new(repo))
})
.await
.map_err(|err| Error::engine(format!("git operation task failed: {err}")))?
}

View file

@ -37,7 +37,6 @@ use crate::error::Error;
use crate::event::{
Emitter, Event, EventBody, RunEventLogger, RunEventSink, RunNoticeLevel, append_event_to_sink,
};
use crate::git::MetadataStore;
use crate::handler::HandlerRegistry;
use crate::outcome::{Outcome, StageStatus};
use crate::pipeline::{
@ -50,6 +49,7 @@ use crate::run_control::RunControlState;
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
use crate::run_status::{FailureReason, RunStatus};
use crate::runtime_store::RunStoreHandle;
use crate::sandbox_metadata::metadata_branch_name;
use crate::workflow_bundle::{RunDefinition, WorkflowBundle};
struct RunSession {
@ -281,7 +281,10 @@ impl RunSession {
async fn new(persisted: &Persisted, services: StartServices) -> Result<Self, Error> {
let record = persisted.run_spec();
let settings = &record.settings;
let working_directory = record.working_directory.clone();
let working_directory = record
.source_directory
.as_deref()
.map_or_else(|| PathBuf::from("."), PathBuf::from);
let state = services
.run_store
.state()
@ -291,7 +294,7 @@ impl RunSession {
start.run_branch.as_ref().map(|_| GitCheckpointOptions {
base_sha: start.base_sha.clone(),
run_branch: start.run_branch.clone(),
meta_branch: Some(MetadataStore::branch_name(&record.run_id.to_string())),
meta_branch: Some(metadata_branch_name(&record.run_id.to_string())),
})
});
let definition_blob = state.spec.as_ref().and_then(|run| run.definition_blob);
@ -688,17 +691,19 @@ impl RunSession {
let record = persisted.run_spec();
let run_options = RunOptions {
settings: record.settings.clone(),
run_dir: persisted.run_dir().to_path_buf(),
cancel_token: self.cancel_token,
run_id: record.run_id,
labels: record.labels.clone(),
workflow_slug: record.workflow_slug.clone(),
github_app: self.github_app.clone(),
host_repo_path: record.host_repo_path.as_deref().map(PathBuf::from),
base_branch: record.base_branch.clone(),
display_base_sha: None,
git: self.git.clone(),
settings: record.settings.clone(),
run_dir: persisted.run_dir().to_path_buf(),
cancel_token: self.cancel_token,
run_id: record.run_id,
labels: record.labels.clone(),
workflow_slug: record.workflow_slug.clone(),
github_app: self.github_app.clone(),
pre_run_git: record.pre_run_git.clone(),
fork_source_ref: record.fork_source_ref.clone(),
checkpoints_disabled: record.checkpoints_disabled,
base_branch: record.base_branch.clone(),
display_base_sha: None,
git: self.git.clone(),
};
let last_git_sha: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
@ -1070,9 +1075,11 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_1),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
provenance: None,
configured_providers: Vec::new(),
},
@ -1248,9 +1255,11 @@ mod tests {
workflow_bundle: Some(workflow_bundle),
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_1),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
provenance: None,
configured_providers: Vec::new(),
},

View file

@ -1,34 +0,0 @@
use std::collections::HashMap;
use fabro_checkpoint::git::Store;
use git2::{Repository, Signature};
pub(super) fn temp_repo() -> (tempfile::TempDir, Store) {
let dir = tempfile::TempDir::new().unwrap();
let repo = Repository::init(dir.path()).unwrap();
(dir, Store::new(repo))
}
pub(super) fn test_sig() -> Signature<'static> {
Signature::now("Test", "test@example.com").unwrap()
}
pub(super) fn make_checkpoint_bytes(
current_node: &str,
visit: usize,
git_sha: Option<&str>,
) -> Vec<u8> {
let mut node_visits = HashMap::new();
node_visits.insert(current_node.to_string(), visit);
let cp = serde_json::json!({
"timestamp": "2025-01-01T00:00:00Z",
"current_node": current_node,
"completed_nodes": [current_node],
"node_retries": {},
"context_values": {},
"logs": [],
"node_visits": node_visits,
"git_commit_sha": git_sha,
});
serde_json::to_vec(&cp).unwrap()
}

View file

@ -1,20 +1,13 @@
use std::collections::HashMap;
use std::fmt::Write;
use std::str::FromStr;
use anyhow::{Context, Result, bail};
use fabro_checkpoint::META_BRANCH_PREFIX;
use fabro_checkpoint::branch::{BranchStore, CommitInfo};
use fabro_checkpoint::git::Store;
use fabro_graphviz::graph::Graph;
use fabro_graphviz::parser;
use fabro_store::{Database, RunProjection};
use fabro_types::RunId;
use git2::{Oid, Repository, Signature};
use super::run_git;
use crate::error::Error;
use crate::git::{MetadataStore, RUN_BRANCH_PREFIX};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ForkTarget {
@ -54,11 +47,11 @@ impl FromStr for ForkTarget {
#[derive(Debug, Clone)]
pub struct TimelineEntry {
pub ordinal: usize,
pub node_name: String,
pub visit: usize,
pub metadata_commit_oid: Oid,
pub run_commit_sha: Option<String>,
pub ordinal: usize,
pub node_name: String,
pub visit: usize,
pub checkpoint_seq: u32,
pub run_commit_sha: Option<String>,
}
#[derive(Debug, Clone)]
@ -115,112 +108,42 @@ impl RunTimeline {
}
}
pub fn build_timeline(store: &Store, run_id: &str) -> Result<RunTimeline> {
let branch = MetadataStore::branch_name(run_id);
let sig = Signature::now("Fabro", "noreply@fabro.sh")?;
let bs = BranchStore::new(store, &branch, &sig);
let commits = bs
.log(10_000)
.map_err(|e| anyhow::anyhow!("failed to read metadata branch log: {e}"))?;
let commits: Vec<&CommitInfo> = commits.iter().rev().collect();
let mut timeline = Vec::new();
let mut ordinal = 0usize;
for commit in &commits {
if !commit.message.starts_with("checkpoint") {
continue;
}
let Some(projection) = read_projection_at_commit(store, commit.oid)? else {
continue;
};
let cp = projection.checkpoint.with_context(|| {
format!(
"metadata checkpoint {} is missing projection.checkpoint",
commit.oid
)
})?;
ordinal += 1;
let visit = cp.node_visits.get(&cp.current_node).copied().unwrap_or(1);
timeline.push(TimelineEntry {
pub fn build_timeline(state: &RunProjection) -> Result<RunTimeline> {
let mut entries = Vec::new();
for (seq, checkpoint) in &state.checkpoints {
let ordinal = entries.len() + 1;
let visit = checkpoint
.node_visits
.get(&checkpoint.current_node)
.copied()
.unwrap_or(1);
entries.push(TimelineEntry {
ordinal,
node_name: cp.current_node.clone(),
node_name: checkpoint.current_node.clone(),
visit,
metadata_commit_oid: commit.oid,
run_commit_sha: cp.git_commit_sha.clone(),
checkpoint_seq: *seq,
run_commit_sha: checkpoint.git_commit_sha.clone(),
});
}
backfill_run_shas(store, run_id, &mut timeline);
Ok(RunTimeline {
entries: timeline,
parallel_map: load_parallel_map(store, run_id),
entries,
parallel_map: load_parallel_map(state),
})
}
pub async fn timeline(store: &Database, run_id: &RunId) -> Result<Vec<TimelineEntry>, Error> {
let run_id = *run_id;
run_git::with_run_git_store(store, run_id, move |git_store| {
build_timeline(&git_store, &run_id.to_string())
.map(|timeline| timeline.entries)
.map_err(|err| Error::engine(err.to_string()))
})
.await
}
fn backfill_run_shas(store: &Store, run_id: &str, timeline: &mut [TimelineEntry]) {
if !timeline.iter().any(|e| e.run_commit_sha.is_none()) {
return;
}
let node_commits = run_commit_shas_by_node(store, run_id);
let mut node_indices: HashMap<String, usize> = HashMap::new();
for entry in timeline.iter_mut() {
if entry.run_commit_sha.is_some() {
continue;
}
if let Some(shas) = node_commits.get(&entry.node_name) {
let idx = node_indices.entry(entry.node_name.clone()).or_insert(0);
if *idx < shas.len() {
entry.run_commit_sha = Some(shas[*idx].clone());
*idx += 1;
}
}
}
}
pub(crate) fn run_commit_shas_by_node(store: &Store, run_id: &str) -> HashMap<String, Vec<String>> {
let run_branch = format!("{RUN_BRANCH_PREFIX}{run_id}");
let Ok(sig) = Signature::now("Fabro", "noreply@fabro.sh") else {
return HashMap::new();
};
let bs = BranchStore::new(store, &run_branch, &sig);
let Ok(run_commits) = bs.log(10_000) else {
return HashMap::new();
};
let prefix = format!("fabro({run_id}): ");
let mut node_commits: HashMap<String, Vec<String>> = HashMap::new();
for commit in &run_commits {
if let Some(rest) = commit.message.strip_prefix(&prefix) {
if let Some(node_name) = rest.split_whitespace().next() {
node_commits
.entry(node_name.to_string())
.or_default()
.push(commit.oid.to_string());
}
}
}
for shas in node_commits.values_mut() {
shas.reverse();
}
node_commits
let run = store
.open_run(run_id)
.await
.map_err(|err| Error::engine(err.to_string()))?;
let state = run
.state()
.await
.map_err(|err| Error::engine(err.to_string()))?;
build_timeline(&state)
.map(|timeline| timeline.entries)
.map_err(|err| Error::engine(err.to_string()))
}
fn detect_parallel_interior(graph: &Graph) -> HashMap<String, String> {
@ -257,103 +180,51 @@ fn detect_parallel_interior(graph: &Graph) -> HashMap<String, String> {
interior_map
}
pub fn find_run_id_by_prefix(repo: &Repository, prefix: &str) -> Result<RunId> {
find_run_id_by_prefix_opt(repo, prefix)?
.ok_or_else(|| anyhow::anyhow!("no run found matching '{prefix}'"))
}
/// Resolve a run id from the metadata branch refs. `Ok(None)` when no run
/// matches; `Err` when the prefix matches more than one.
pub(super) fn find_run_id_by_prefix_opt(repo: &Repository, prefix: &str) -> Result<Option<RunId>> {
let refs = repo.references()?;
let pattern = format!("refs/heads/{META_BRANCH_PREFIX}");
let mut matches = Vec::new();
for reference in refs.flatten() {
let Some(name) = reference.name() else {
continue;
};
let Some(run_id) = name.strip_prefix(&pattern) else {
continue;
};
let Ok(run_id) = run_id.parse::<RunId>() else {
continue;
};
if run_id.to_string() == prefix {
return Ok(Some(run_id));
}
if run_id.to_string().starts_with(prefix) {
matches.push(run_id);
}
}
match matches.len() {
0 => Ok(None),
1 => Ok(matches.into_iter().next()),
_ => {
let mut msg = format!("ambiguous run ID prefix '{prefix}', matches:\n");
for run_id in &matches {
let _ = writeln!(msg, " {run_id}");
}
bail!("{msg}")
}
}
}
fn load_parallel_map(store: &Store, run_id: &str) -> HashMap<String, String> {
let Ok(Some(projection)) = MetadataStore::read_run_projection(store.repo_dir(), run_id) else {
return HashMap::new();
};
if let Some(spec) = projection.spec {
fn load_parallel_map(state: &RunProjection) -> HashMap<String, String> {
if let Some(spec) = state.spec.as_ref() {
return detect_parallel_interior(&spec.graph);
}
let Some(dot_source) = projection.graph_source else {
let Some(dot_source) = state.graph_source.as_ref() else {
return HashMap::new();
};
let Ok(graph) = parser::parse(&dot_source) else {
let Ok(graph) = parser::parse(dot_source) else {
return HashMap::new();
};
detect_parallel_interior(&graph)
}
fn read_projection_at_commit(store: &Store, oid: Oid) -> Result<Option<RunProjection>> {
let blob = store
.read_blob_at(oid, "run.json")
.map_err(|e| anyhow::anyhow!("failed to read projection blob: {e}"))?;
let Some(bytes) = blob else {
return Ok(None);
};
let projection = serde_json::from_slice(&bytes)
.with_context(|| format!("failed to parse projection at {oid}"))?;
Ok(Some(projection))
}
#[cfg(test)]
mod tests {
use fabro_store::RunProjection;
use fabro_types::RunId;
use git2::Oid;
use std::collections::HashMap;
use chrono::Utc;
use fabro_types::Checkpoint;
use super::super::test_support::*;
use super::*;
fn parse_run_id(value: &str) -> RunId {
value.parse().unwrap()
}
fn checkpoint_projection_json(
fn checkpoint(
seq: u32,
current_node: &str,
visit: usize,
git_commit_sha: Option<&str>,
) -> Vec<u8> {
let mut projection = RunProjection::default();
projection.checkpoint = Some(
serde_json::from_slice(&make_checkpoint_bytes(current_node, visit, git_commit_sha))
.unwrap(),
);
serde_json::to_vec_pretty(&projection).unwrap()
) -> (u32, Checkpoint) {
let mut node_visits = HashMap::new();
node_visits.insert(current_node.to_string(), visit);
let checkpoint = Checkpoint {
timestamp: Utc::now(),
current_node: current_node.to_string(),
completed_nodes: Vec::new(),
node_retries: HashMap::new(),
context_values: HashMap::new(),
node_outcomes: HashMap::new(),
next_node_id: None,
git_commit_sha: git_commit_sha.map(ToOwned::to_owned),
loop_failure_signatures: HashMap::new(),
restart_failure_signatures: HashMap::new(),
node_visits,
};
(seq, checkpoint)
}
#[test]
@ -371,21 +242,16 @@ mod tests {
#[test]
fn build_timeline_simple() {
let (_dir, store) = temp_repo();
let sig = test_sig();
let branch = MetadataStore::branch_name("test-run-1");
let bs = BranchStore::new(&store, &branch, &sig);
bs.ensure_branch().unwrap();
let mut state = RunProjection::default();
state.checkpoints = vec![
checkpoint(7, "start", 1, Some("aaa")),
checkpoint(9, "build", 1, Some("bbb")),
];
bs.write_entry("run.json", b"{}", "init run").unwrap();
let cp1 = checkpoint_projection_json("start", 1, Some("aaa"));
bs.write_entry("run.json", &cp1, "checkpoint").unwrap();
let cp2 = checkpoint_projection_json("build", 1, Some("bbb"));
bs.write_entry("run.json", &cp2, "checkpoint").unwrap();
let timeline = build_timeline(&store, "test-run-1").unwrap();
let timeline = build_timeline(&state).unwrap();
assert_eq!(timeline.entries.len(), 2);
assert_eq!(timeline.entries[0].node_name, "start");
assert_eq!(timeline.entries[0].checkpoint_seq, 7);
assert_eq!(timeline.entries[1].node_name, "build");
}
@ -394,25 +260,25 @@ mod tests {
let timeline = RunTimeline {
entries: vec![
TimelineEntry {
ordinal: 1,
node_name: "start".to_string(),
visit: 1,
metadata_commit_oid: Oid::zero(),
run_commit_sha: Some("aaa".to_string()),
ordinal: 1,
node_name: "start".to_string(),
visit: 1,
checkpoint_seq: 7,
run_commit_sha: Some("aaa".to_string()),
},
TimelineEntry {
ordinal: 2,
node_name: "build".to_string(),
visit: 1,
metadata_commit_oid: Oid::zero(),
run_commit_sha: Some("bbb".to_string()),
ordinal: 2,
node_name: "build".to_string(),
visit: 1,
checkpoint_seq: 9,
run_commit_sha: Some("bbb".to_string()),
},
TimelineEntry {
ordinal: 3,
node_name: "build".to_string(),
visit: 2,
metadata_commit_oid: Oid::zero(),
run_commit_sha: Some("ccc".to_string()),
ordinal: 3,
node_name: "build".to_string(),
visit: 2,
checkpoint_seq: 11,
run_commit_sha: Some("ccc".to_string()),
},
],
parallel_map: HashMap::new(),
@ -463,17 +329,4 @@ mod tests {
assert_eq!(map.get("a"), Some(&"parallel1".to_string()));
assert!(!map.contains_key("parallel1"));
}
#[test]
fn find_run_id_prefix_match() {
let (_dir, store) = temp_repo();
let sig = test_sig();
let run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
let branch = MetadataStore::branch_name(&run_id.to_string());
let bs = BranchStore::new(&store, &branch, &sig);
bs.ensure_branch().unwrap();
let result = find_run_id_by_prefix(store.repo(), "01ARZ3").unwrap();
assert_eq!(result, run_id);
}
}

View file

@ -84,6 +84,7 @@ pub async fn execute(init: Initialized) -> Executed {
&engine.run.run_store,
artifact_sink,
&settings_arc,
Arc::clone(&engine.run.metadata_runtime),
checkpoint.is_some(),
on_node,
run_control,

View file

@ -90,17 +90,19 @@ fn test_emitter_arc(label: &str) -> Arc<Emitter> {
fn test_run_options(run_dir: &Path, run_id: &str) -> RunOptions {
RunOptions {
run_dir: run_dir.to_path_buf(),
cancel_token: None,
run_id: test_run_id(run_id),
settings: WorkflowSettings::default(),
git: None,
host_repo_path: None,
labels: HashMap::new(),
github_app: None,
base_branch: None,
display_base_sha: None,
workflow_slug: None,
run_dir: run_dir.to_path_buf(),
cancel_token: None,
run_id: test_run_id(run_id),
settings: WorkflowSettings::default(),
git: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
labels: HashMap::new(),
github_app: None,
base_branch: None,
display_base_sha: None,
workflow_slug: None,
}
}
@ -138,8 +140,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI
settings: WorkflowSettings::default(),
graph,
workflow_slug: Some("test".to_string()),
working_directory: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
host_repo_path: Some(
source_directory: Some(
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.display()
@ -151,6 +152,9 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
},
)
}

View file

@ -4,14 +4,14 @@ use fabro_types::{BilledTokenCounts, EventBody};
use super::types::{Concluded, FinalizeOptions, Retroed};
use crate::error::Error;
use crate::event::{Event, RunNoticeLevel};
use crate::git::MetadataStore;
use crate::outcome::{Outcome, OutcomeExt, StageStatus};
use crate::records::{Checkpoint, Conclusion, StageSummary};
use crate::run_dump::RunDump;
use crate::run_options::RunOptions;
use crate::run_status::{FailureReason, RunStatus, SuccessReason};
use crate::runtime_store::RunStoreHandle;
use crate::sandbox_git::{git_diff_with_timeout, git_push_host};
use crate::sandbox_git::git_diff_with_timeout;
use crate::sandbox_metadata::SandboxMetadataWriter;
use crate::services::RunServices;
pub fn classify_engine_result(
@ -139,43 +139,65 @@ fn build_conclusion_from_parts(
/// yet — the run store's `projection.conclusion` is still `None` at this point.
pub async fn write_finalize_commit(
run_options: &RunOptions,
run_store: &RunStoreHandle,
services: &RunServices,
conclusion: &Conclusion,
) {
let (Some(meta_branch), Some(repo_path)) = (
run_options
.git
.as_ref()
.and_then(|g| g.meta_branch.as_ref()),
run_options.host_repo_path.as_ref(),
) else {
if services.metadata_runtime.metadata_degraded() {
return;
}
let Some(meta_branch) = run_options
.git
.as_ref()
.and_then(|git| git.meta_branch.as_deref())
else {
return;
};
let git_author = run_options.git_author();
let store = MetadataStore::new(repo_path, &git_author);
let Ok(mut store_state) = run_store.state().await else {
let mut projection = match services.run_store.state().await {
Ok(state) => state,
Err(err) => {
emit_metadata_warning(
services,
"checkpoint_metadata_write_failed",
format!("failed to load run state for final metadata snapshot: {err}"),
);
return;
}
};
projection.conclusion = Some(conclusion.clone());
let dump = RunDump::from_projection(&projection);
let Some(spec_run_id) = projection.spec.as_ref().map(|spec| spec.run_id.to_string()) else {
return;
};
if store_state.conclusion.is_none() {
store_state.conclusion = Some(conclusion.clone());
}
let dump = RunDump::from_projection(&store_state);
if let Err(e) =
dump.write_to_metadata_store(&store, &run_options.run_id.to_string(), "finalize run")
{
tracing::warn!(error = %e, "Failed to write finalize commit to metadata branch");
return;
let writer = SandboxMetadataWriter::new(
&*services.sandbox,
&services.metadata_runtime,
&spec_run_id,
meta_branch,
run_options.git_author(),
);
match writer.write_snapshot(&dump, "finalize run").await {
Ok(snapshot) => {
if !snapshot.pushed {
emit_metadata_warning(
services,
"checkpoint_metadata_push_failed",
format!("failed to push metadata ref refs/heads/{meta_branch}"),
);
}
}
Err(err) => emit_metadata_warning(
services,
"checkpoint_metadata_write_failed",
format!("failed to write final checkpoint metadata: {err}"),
),
}
}
let refspec = format!("refs/heads/{meta_branch}");
git_push_host(
repo_path,
&refspec,
&run_options.github_app,
"finalize metadata",
)
.await;
fn emit_metadata_warning(services: &RunServices, code: &str, message: String) {
if services.metadata_runtime.mark_metadata_degraded() {
services.emitter.notice(RunNoticeLevel::Warn, code, message);
}
}
/// Failed and cancelled runs use a shorter diff timeout so a corrupted
@ -337,9 +359,17 @@ pub async fn finalize(retroed: Retroed, options: &FinalizeOptions) -> Result<Con
let (final_patch, ()) = tokio::join!(
compute_final_patch(&run_options, &services, final_status),
write_finalize_commit(&run_options, &services.run_store, &conclusion),
write_finalize_commit(&run_options, &services, &conclusion),
);
if services.metadata_runtime.metadata_degraded() {
services.emitter.notice(
RunNoticeLevel::Warn,
"checkpoint_metadata_degraded",
"checkpoint metadata archive writes were degraded for this run".to_string(),
);
}
let terminal_event = build_terminal_event(
&outcome,
duration_ms,
@ -408,17 +438,19 @@ mod tests {
fn test_run_options(run_dir: &std::path::Path) -> RunOptions {
RunOptions {
settings: WorkflowSettings::default(),
run_dir: run_dir.to_path_buf(),
cancel_token: None,
run_id: test_run_id(),
labels: HashMap::new(),
workflow_slug: None,
github_app: None,
host_repo_path: None,
base_branch: None,
display_base_sha: None,
git: None,
settings: WorkflowSettings::default(),
run_dir: run_dir.to_path_buf(),
cancel_token: None,
run_id: test_run_id(),
labels: HashMap::new(),
workflow_slug: None,
github_app: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
base_branch: None,
display_base_sha: None,
git: None,
}
}

View file

@ -12,8 +12,8 @@ use fabro_config::RunScratch;
use fabro_graphviz::graph;
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner};
use fabro_sandbox::{
ReadBeforeWriteSandbox, SandboxEventCallback, SandboxSpec, WorkdirStrategy, WorktreeOptions,
WorktreeSandbox,
GitSetupIntent, ReadBeforeWriteSandbox, SandboxEventCallback, SandboxSpec, WorkdirStrategy,
WorktreeOptions, WorktreeSandbox,
};
use fabro_vault::Vault;
use futures::future::try_join_all;
@ -21,17 +21,17 @@ use shlex::try_quote;
use tokio::process::Command as TokioCommand;
use tokio::runtime::Handle;
use tokio::sync::RwLock as AsyncRwLock;
use tokio::task::spawn_blocking;
use tokio::time::timeout as tokio_timeout;
use super::types::{InitOptions, Initialized, LlmSpec, Persisted, SandboxEnvSpec};
use crate::devcontainer_bridge::{devcontainer_to_snapshot_config, run_devcontainer_lifecycle};
use crate::error::Error;
use crate::event::{Emitter, Event, RunNoticeLevel};
use crate::git::{self, GitSyncStatus, MetadataStore};
use crate::git::RUN_BRANCH_PREFIX;
use crate::handler::llm::{AgentApiBackend, AgentCliBackend, BackendRouter};
use crate::handler::{HandlerRegistry, default_registry, sandbox_cancel_token};
use crate::run_options::GitCheckpointOptions;
use crate::run_options::{GitCheckpointOptions, RunOptions};
use crate::sandbox_metadata::metadata_branch_name;
use crate::services::{EngineServices, RunServices};
struct WorktreePlan {
@ -53,44 +53,56 @@ async fn run_hooks(
runner.run(hook_context, sandbox, work_dir).await
}
async fn resolve_worktree_plan(options: &mut InitOptions) -> Result<Option<WorktreePlan>, Error> {
fn resolve_worktree_plan(options: &mut InitOptions) -> Option<WorktreePlan> {
if options.run_options.checkpoints_disabled {
options.run_options.display_base_sha = None;
return None;
}
let Some(worktree_mode) = options.worktree_mode else {
options.run_options.display_base_sha = None;
return Ok(None);
return None;
};
if options.checkpoint.is_some() && matches!(options.sandbox, SandboxSpec::Local { .. }) {
if let Some(fork_source) = options.run_options.fork_source_ref.as_ref() {
let base_sha = fork_source.checkpoint_sha.clone();
options.run_options.display_base_sha = Some(base_sha.clone());
return Some(WorktreePlan {
branch_name: format!("{RUN_BRANCH_PREFIX}{}", options.run_id),
base_sha,
worktree_path: RunScratch::new(&options.run_options.run_dir).worktree_dir(),
skip_branch_creation: false,
});
}
}
if options.checkpoint.is_some() && matches!(options.sandbox, SandboxSpec::Local { .. }) {
if let Some(git) = options.run_options.git.as_ref() {
if let (Some(run_branch), Some(base_sha)) = (&git.run_branch, &git.base_sha) {
options.run_options.display_base_sha = Some(base_sha.clone());
return Ok(Some(WorktreePlan {
return Some(WorktreePlan {
branch_name: run_branch.clone(),
base_sha: base_sha.clone(),
worktree_path: RunScratch::new(&options.run_options.run_dir)
.worktree_dir(),
skip_branch_creation: true,
}));
});
}
}
}
let host_repo_path = options.sandbox.host_repo_path();
let git_status = if let Some(path) = host_repo_path.as_ref() {
let path = path.clone();
let base_branch = options.run_options.base_branch.clone();
spawn_blocking(move || git::sync_status(&path, "origin", base_branch.as_deref()))
.await
.unwrap_or(GitSyncStatus::Dirty)
} else {
GitSyncStatus::Dirty
};
let strategy = options.sandbox.workdir_strategy(
worktree_mode,
git_status.is_clean(),
options.checkpoint.is_some(),
);
let git_is_clean = options
.run_options
.pre_run_git
.as_ref()
.is_some_and(|git| matches!(git.local_dirty, fabro_types::DirtyStatus::Clean));
let strategy =
options
.sandbox
.workdir_strategy(worktree_mode, git_is_clean, options.checkpoint.is_some());
if git_status == GitSyncStatus::Dirty {
if !git_is_clean {
let env_name = match strategy {
WorkdirStrategy::LocalWorktree => Some("worktree"),
WorkdirStrategy::Cloud => Some("remote sandbox"),
@ -105,97 +117,59 @@ async fn resolve_worktree_plan(options: &mut InitOptions) -> Result<Option<Workt
}
}
if !options.dry_run
&& matches!(
strategy,
WorkdirStrategy::LocalWorktree | WorkdirStrategy::Cloud
)
{
if let (Some(repo_path), Some(branch)) = (
host_repo_path.as_ref(),
options.run_options.base_branch.as_ref(),
) {
let needs_push = match git_status {
GitSyncStatus::Synced => false,
GitSyncStatus::Unsynced => true,
GitSyncStatus::Dirty => {
let repo_path = repo_path.clone();
let branch = branch.clone();
spawn_blocking(move || git::branch_needs_push(&repo_path, "origin", &branch))
.await
.unwrap_or(true)
}
};
if needs_push {
let repo_path = repo_path.clone();
let branch = branch.clone();
let branch_for_push = branch.clone();
match git::blocking_push_with_timeout(60, move || {
git::push_branch(&repo_path, "origin", &branch_for_push)
})
.await
{
Ok(()) => options.emitter.notice(
RunNoticeLevel::Info,
"git_push_succeeded",
format!("{branch} (synced local commits to remote)"),
),
Err(e) => options.emitter.notice(
RunNoticeLevel::Warn,
"git_push_failed",
format!("Failed to push {branch} to origin: {e}"),
),
}
}
}
}
match strategy {
WorkdirStrategy::LocalWorktree => {
let Some(repo_path) = host_repo_path else {
options.run_options.display_base_sha = None;
return Ok(None);
};
match spawn_blocking(move || git::head_sha(&repo_path))
.await
.unwrap_or_else(|_| Err(Error::engine("git head_sha task panicked")))
{
Ok(base_sha) => {
options.run_options.display_base_sha = Some(base_sha.clone());
Ok(Some(WorktreePlan {
branch_name: format!("{}{}", git::RUN_BRANCH_PREFIX, options.run_id),
base_sha,
worktree_path: RunScratch::new(&options.run_options.run_dir).worktree_dir(),
skip_branch_creation: false,
}))
}
Err(e) => {
options.emitter.notice(
RunNoticeLevel::Warn,
"worktree_setup_failed",
format!("Git worktree setup failed ({e}), running without worktree."),
);
options.run_options.display_base_sha = None;
Ok(None)
}
}
let (branch_name, base_sha) =
if let Some(fork_source) = options.run_options.fork_source_ref.as_ref() {
(
format!("{RUN_BRANCH_PREFIX}{}", options.run_id),
fork_source.checkpoint_sha.clone(),
)
} else {
let Some(base_sha) = options
.run_options
.pre_run_git
.as_ref()
.and_then(|git| git.display_base_sha.clone())
else {
options.run_options.display_base_sha = None;
return None;
};
(format!("{RUN_BRANCH_PREFIX}{}", options.run_id), base_sha)
};
options.run_options.display_base_sha = Some(base_sha.clone());
Some(WorktreePlan {
branch_name,
base_sha,
worktree_path: RunScratch::new(&options.run_options.run_dir).worktree_dir(),
skip_branch_creation: false,
})
}
WorkdirStrategy::Cloud => {
options.run_options.display_base_sha = if let Some(path) = host_repo_path.as_ref() {
let path = path.clone();
spawn_blocking(move || git::head_sha(&path))
.await
.ok()
.and_then(std::result::Result::ok)
} else {
None
};
Ok(None)
options.run_options.display_base_sha = options
.run_options
.pre_run_git
.as_ref()
.and_then(|git| git.display_base_sha.clone());
None
}
WorkdirStrategy::LocalDirectory => {
options.run_options.display_base_sha = None;
Ok(None)
None
}
}
}
fn git_setup_intent(run_options: &RunOptions) -> GitSetupIntent {
if let Some(source) = run_options.fork_source_ref.as_ref() {
GitSetupIntent::ForkFromCheckpoint {
new_run_id: run_options.run_id.to_string(),
source_run_id: source.source_run_id.to_string(),
checkpoint_sha: source.checkpoint_sha.clone(),
}
} else {
GitSetupIntent::NewRun {
run_id: run_options.run_id.to_string(),
}
}
}
@ -464,12 +438,12 @@ pub async fn initialize(
resolve_devcontainer(&mut options).await?;
let worktree_plan = resolve_worktree_plan(&mut options).await?;
let worktree_plan = resolve_worktree_plan(&mut options);
if let Some(plan) = worktree_plan.as_ref() {
options.run_options.git = Some(GitCheckpointOptions {
base_sha: Some(plan.base_sha.clone()),
run_branch: Some(plan.branch_name.clone()),
meta_branch: Some(MetadataStore::branch_name(&options.run_id.to_string())),
meta_branch: Some(metadata_branch_name(&options.run_id.to_string())),
});
}
@ -499,18 +473,7 @@ pub async fn initialize(
Arc::new(ReadBeforeWriteSandbox::new(Arc::new(worktree)))
}
Err(e) => {
options.emitter.notice(
RunNoticeLevel::Warn,
"worktree_setup_failed",
format!("Git worktree setup failed ({e}), running without worktree."),
);
Arc::new(ReadBeforeWriteSandbox::new(
options
.sandbox
.build(Some(Arc::clone(&sandbox_event_callback)))
.await
.map_err(|e| Error::engine(e.to_string()))?,
))
return Err(Error::engine(format!("Git worktree setup failed: {e}")));
}
}
} else {
@ -557,14 +520,12 @@ pub async fn initialize(
let sandbox_record = options.sandbox.to_sandbox_record(&*sandbox);
options.emitter.emit(&Event::SandboxInitialized {
working_directory: sandbox_record.working_directory.clone(),
provider: sandbox_record.provider.clone(),
identifier: sandbox_record.identifier.clone(),
host_working_directory: sandbox_record.host_working_directory.clone(),
container_mount_point: sandbox_record.container_mount_point.clone(),
repo_cloned: sandbox_record.repo_cloned,
clone_origin_url: sandbox_record.clone_origin_url.clone(),
clone_branch: sandbox_record.clone_branch.clone(),
working_directory: sandbox_record.working_directory.clone(),
provider: sandbox_record.provider.clone(),
identifier: sandbox_record.identifier.clone(),
repo_cloned: sandbox_record.repo_cloned,
clone_origin_url: sandbox_record.clone_origin_url.clone(),
clone_branch: sandbox_record.clone_branch.clone(),
});
let env = build_sandbox_env(
@ -601,10 +562,8 @@ pub async fn initialize(
.and_then(|g| g.run_branch.as_ref())
.is_some();
if !has_run_branch {
match sandbox
.setup_git_for_run(&options.run_options.run_id.to_string())
.await
{
let intent = git_setup_intent(&options.run_options);
match sandbox.setup_git(&intent).await {
Ok(Some(info)) => {
let base_sha = options
.run_options
@ -616,7 +575,7 @@ pub async fn initialize(
options.run_options.git = Some(GitCheckpointOptions {
base_sha,
run_branch: Some(info.run_branch.clone()),
meta_branch: Some(MetadataStore::branch_name(
meta_branch: Some(metadata_branch_name(
&options.run_options.run_id.to_string(),
)),
});
@ -626,10 +585,7 @@ pub async fn initialize(
}
Ok(None) => {}
Err(e) => {
tracing::warn!(
error = %e,
"Sandbox git setup failed, running without git checkpoints"
);
return Err(Error::engine(format!("Sandbox git setup failed: {e}")));
}
}
}
@ -829,17 +785,19 @@ mod tests {
fn test_settings(run_dir: &std::path::Path) -> RunOptions {
RunOptions {
settings: WorkflowSettings::default(),
run_dir: run_dir.to_path_buf(),
cancel_token: None,
run_id: test_run_id(),
labels: HashMap::new(),
workflow_slug: None,
github_app: None,
host_repo_path: None,
base_branch: None,
display_base_sha: None,
git: None,
settings: WorkflowSettings::default(),
run_dir: run_dir.to_path_buf(),
cancel_token: None,
run_id: test_run_id(),
labels: HashMap::new(),
workflow_slug: None,
github_app: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
base_branch: None,
display_base_sha: None,
git: None,
}
}
@ -854,14 +812,16 @@ mod tests {
settings: WorkflowSettings::default(),
graph,
workflow_slug: Some("test".to_string()),
working_directory: std::env::current_dir().unwrap(),
host_repo_path: Some(std::env::current_dir().unwrap().display().to_string()),
source_directory: Some(std::env::current_dir().unwrap().display().to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
},
)
}

View file

@ -56,7 +56,6 @@ pub(crate) async fn load_from_store(
#[expect(clippy::disallowed_methods, reason = "tests stage pipeline fixtures")]
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@ -137,8 +136,7 @@ mod tests {
},
graph,
workflow_slug: Some("ship".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
source_directory: Some("/tmp/project".to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::from([
@ -148,6 +146,9 @@ mod tests {
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
}
}
@ -155,21 +156,23 @@ mod tests {
let store = memory_store();
let run_store = store.create_run(&record.run_id).await.unwrap();
append_event(&run_store, &record.run_id, &Event::RunCreated {
run_id: record.run_id,
settings: serde_json::to_value(&record.settings).unwrap(),
graph: serde_json::to_value(&record.graph).unwrap(),
workflow_source: source.map(ToOwned::to_owned),
workflow_config: None,
labels: record.labels.clone().into_iter().collect(),
run_dir: run_dir.to_string_lossy().to_string(),
working_directory: record.working_directory.display().to_string(),
host_repo_path: record.host_repo_path.clone(),
repo_origin_url: record.repo_origin_url.clone(),
base_branch: record.base_branch.clone(),
workflow_slug: record.workflow_slug.clone(),
db_prefix: None,
provenance: record.provenance.clone(),
manifest_blob: None,
run_id: record.run_id,
settings: serde_json::to_value(&record.settings).unwrap(),
graph: serde_json::to_value(&record.graph).unwrap(),
workflow_source: source.map(ToOwned::to_owned),
workflow_config: None,
labels: record.labels.clone().into_iter().collect(),
run_dir: run_dir.to_string_lossy().to_string(),
source_directory: record.source_directory.clone(),
repo_origin_url: record.repo_origin_url.clone(),
base_branch: record.base_branch.clone(),
workflow_slug: record.workflow_slug.clone(),
db_prefix: None,
provenance: record.provenance.clone(),
manifest_blob: None,
pre_run_git: record.pre_run_git.clone(),
fork_source_ref: record.fork_source_ref.clone(),
checkpoints_disabled: record.checkpoints_disabled,
})
.await
.unwrap();
@ -261,8 +264,7 @@ mod tests {
serde_json::to_value(&expected.graph).unwrap()
);
assert_eq!(loaded_record.workflow_slug, expected.workflow_slug);
assert_eq!(loaded_record.working_directory, expected.working_directory);
assert_eq!(loaded_record.host_repo_path, expected.host_repo_path);
assert_eq!(loaded_record.source_directory, expected.source_directory);
assert_eq!(loaded_record.base_branch, expected.base_branch);
assert_eq!(loaded_record.labels, expected.labels);
assert_eq!(loaded.source(), source);

View file

@ -622,7 +622,6 @@ pub async fn pull_request(concluded: Concluded, options: &PullRequestOptions) ->
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@ -1168,35 +1167,39 @@ mod tests {
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let run_spec = RunSpec {
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
workflow_slug: Some("test".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
workflow_slug: Some("test".to_string()),
source_directory: Some("/tmp/project".to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
workflow_config: None,
labels: run_spec.labels.clone().into_iter().collect(),
run_dir: run_spec.working_directory.display().to_string(),
working_directory: run_spec.working_directory.display().to_string(),
host_repo_path: run_spec.host_repo_path.clone(),
repo_origin_url: run_spec.repo_origin_url.clone(),
base_branch: run_spec.base_branch.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
db_prefix: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
run_id: fixtures::RUN_1,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
workflow_config: None,
labels: run_spec.labels.clone().into_iter().collect(),
run_dir: "/tmp/project".to_string(),
source_directory: run_spec.source_directory.clone(),
repo_origin_url: run_spec.repo_origin_url.clone(),
base_branch: run_spec.base_branch.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
db_prefix: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
})
.await
.unwrap();
@ -1231,35 +1234,39 @@ mod tests {
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let run_spec = RunSpec {
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
workflow_slug: Some("test".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
workflow_slug: Some("test".to_string()),
source_directory: Some("/tmp/project".to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
workflow_config: None,
labels: run_spec.labels.clone().into_iter().collect(),
run_dir: run_spec.working_directory.display().to_string(),
working_directory: run_spec.working_directory.display().to_string(),
host_repo_path: run_spec.host_repo_path.clone(),
repo_origin_url: run_spec.repo_origin_url.clone(),
base_branch: run_spec.base_branch.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
db_prefix: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
run_id: fixtures::RUN_1,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
workflow_config: None,
labels: run_spec.labels.clone().into_iter().collect(),
run_dir: "/tmp/project".to_string(),
source_directory: run_spec.source_directory.clone(),
repo_origin_url: run_spec.repo_origin_url.clone(),
base_branch: run_spec.base_branch.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
db_prefix: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
})
.await
.unwrap();
@ -1527,35 +1534,39 @@ mod tests {
let store = test_store();
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
let run_spec = RunSpec {
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
workflow_slug: None,
working_directory: tmp.path().to_path_buf(),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
labels: std::collections::HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
run_id: fixtures::RUN_1,
settings: fabro_types::WorkflowSettings::default(),
graph: Graph::new("test"),
workflow_slug: None,
source_directory: Some(tmp.path().display().to_string()),
repo_origin_url: None,
base_branch: None,
labels: std::collections::HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,
workflow_config: None,
labels: run_spec.labels.clone().into_iter().collect(),
run_dir: run_spec.working_directory.display().to_string(),
working_directory: tmp.path().display().to_string(),
host_repo_path: None,
repo_origin_url: run_spec.repo_origin_url.clone(),
base_branch: None,
workflow_slug: None,
db_prefix: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
run_id: fixtures::RUN_1,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,
workflow_config: None,
labels: run_spec.labels.clone().into_iter().collect(),
run_dir: tmp.path().display().to_string(),
source_directory: run_spec.source_directory.clone(),
repo_origin_url: run_spec.repo_origin_url.clone(),
base_branch: None,
workflow_slug: None,
db_prefix: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
})
.await
.unwrap();

View file

@ -211,35 +211,39 @@ mod tests {
let inner = test_store().create_run(&test_run_id()).await.unwrap();
let run_store = inner;
let run_spec = RunSpec {
run_id: test_run_id(),
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
workflow_slug: None,
working_directory: run_dir.to_path_buf(),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
labels: std::collections::HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
run_id: test_run_id(),
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
workflow_slug: None,
source_directory: Some(run_dir.to_string_lossy().to_string()),
repo_origin_url: None,
base_branch: None,
labels: std::collections::HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
};
append_event(&run_store, &test_run_id(), &Event::RunCreated {
run_id: test_run_id(),
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,
workflow_config: None,
labels: run_spec.labels.clone().into_iter().collect(),
run_dir: run_dir.to_string_lossy().to_string(),
working_directory: run_dir.to_string_lossy().to_string(),
host_repo_path: None,
repo_origin_url: run_spec.repo_origin_url.clone(),
base_branch: None,
workflow_slug: None,
db_prefix: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
run_id: test_run_id(),
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,
workflow_config: None,
labels: run_spec.labels.clone().into_iter().collect(),
run_dir: run_dir.to_string_lossy().to_string(),
source_directory: run_spec.source_directory.clone(),
repo_origin_url: run_spec.repo_origin_url.clone(),
base_branch: None,
workflow_slug: None,
db_prefix: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
})
.await
.unwrap();
@ -275,17 +279,19 @@ mod tests {
fn test_run_options(run_dir: &std::path::Path) -> RunOptions {
RunOptions {
settings: WorkflowSettings::default(),
run_dir: run_dir.to_path_buf(),
cancel_token: None,
run_id: test_run_id(),
labels: HashMap::new(),
workflow_slug: None,
github_app: None,
host_repo_path: None,
base_branch: None,
display_base_sha: None,
git: None,
settings: WorkflowSettings::default(),
run_dir: run_dir.to_path_buf(),
cancel_token: None,
run_id: test_run_id(),
labels: HashMap::new(),
workflow_slug: None,
github_app: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
base_branch: None,
display_base_sha: None,
git: None,
}
}

View file

@ -17,8 +17,6 @@ use fabro_store::{EventEnvelope, RunProjection, SerializableProjection, StageId}
use fabro_types::{RunBlobId, parse_blob_ref, parse_legacy_blob_file_ref};
use futures::future::BoxFuture;
use crate::git::MetadataStore;
#[derive(Debug, Clone)]
pub struct RunDump {
entries: Vec<RunDumpEntry>,
@ -217,21 +215,6 @@ impl RunDump {
Ok(self.file_count())
}
pub fn write_to_metadata_store(
&self,
store: &MetadataStore,
run_id: &str,
message: &str,
) -> Result<()> {
let git_entries = self.git_entries()?;
let refs: Vec<(&str, &[u8])> = git_entries
.iter()
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
.collect();
store.write_snapshot(run_id, &refs, message)?;
Ok(())
}
pub fn git_entries(&self) -> Result<Vec<(String, Vec<u8>)>> {
self.entries
.iter()
@ -425,7 +408,6 @@ fn ensure_parent_dir(path: &Path) -> Result<()> {
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use chrono::{TimeZone, Utc};
use fabro_store::{NodeState, RunProjection, StageId};
@ -441,18 +423,20 @@ mod tests {
fn sample_run_spec() -> RunSpec {
RunSpec {
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("ship"),
workflow_slug: Some("demo".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None,
manifest_blob: None,
definition_blob: None,
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("ship"),
workflow_slug: Some("demo".to_string()),
source_directory: Some("/tmp/project".to_string()),
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
}
}
@ -508,14 +492,12 @@ mod tests {
total_retries: 0,
});
projection.sandbox = Some(SandboxRecord {
provider: "local".to_string(),
working_directory: "/tmp/project".to_string(),
identifier: Some("sandbox-1".to_string()),
host_working_directory: None,
container_mount_point: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
provider: "local".to_string(),
working_directory: "/tmp/project".to_string(),
identifier: Some("sandbox-1".to_string()),
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
});
projection.retro_prompt = Some("retro prompt".to_string());
projection.retro_response = Some("retro response".to_string());

View file

@ -125,10 +125,16 @@ impl RunInfo {
.and_then(|summary| summary.total_usd_micros)
}
pub fn host_repo_path(&self) -> Option<&str> {
pub fn source_directory(&self) -> Option<&str> {
self.summary
.as_ref()
.and_then(|summary| summary.host_repo_path.as_deref())
.and_then(|summary| summary.source_directory.as_deref())
}
pub fn repo_origin_url(&self) -> Option<&str> {
self.summary
.as_ref()
.and_then(|summary| summary.repo_origin_url.as_deref())
}
pub fn goal(&self) -> String {
@ -336,11 +342,19 @@ fn resolve_run_from_infos(runs: &[RunInfo], identifier: &str) -> Result<RunInfo>
count if count > 1 => {
let ids: Vec<String> = id_matches
.iter()
.map(|run| run.run_id().to_string())
.map(|run| {
format!(
"{} created_at={} workflow={} origin={}",
run.run_id(),
run.run_id().created_at().to_rfc3339(),
run.workflow_name(),
run.repo_origin_url().unwrap_or("-")
)
})
.collect();
bail!(
"Ambiguous prefix '{identifier}': {count} runs match: {}",
ids.join(", ")
"Ambiguous prefix '{identifier}': {count} runs match:\n{}",
ids.join("\n")
)
}
_ => {}
@ -396,7 +410,6 @@ fn run_id_matches(run_id: RunId, prefix: &str) -> bool {
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@ -421,18 +434,20 @@ mod tests {
fn sample_run_spec() -> RunSpec {
RunSpec {
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
workflow_slug: Some("test".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
workflow_slug: Some("test".to_string()),
source_directory: Some("/tmp/project".to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
}
}
@ -446,21 +461,23 @@ mod tests {
let run_spec = sample_run_spec();
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,
workflow_config: None,
labels: run_spec.labels.clone().into_iter().collect(),
run_dir: run_dir.display().to_string(),
working_directory: run_spec.working_directory.display().to_string(),
host_repo_path: run_spec.host_repo_path.clone(),
repo_origin_url: run_spec.repo_origin_url.clone(),
base_branch: run_spec.base_branch.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
db_prefix: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
run_id: fixtures::RUN_1,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,
workflow_config: None,
labels: run_spec.labels.clone().into_iter().collect(),
run_dir: run_dir.display().to_string(),
source_directory: run_spec.source_directory.clone(),
repo_origin_url: run_spec.repo_origin_url.clone(),
base_branch: run_spec.base_branch.clone(),
workflow_slug: run_spec.workflow_slug.clone(),
db_prefix: None,
provenance: run_spec.provenance.clone(),
manifest_blob: None,
pre_run_git: run_spec.pre_run_git.clone(),
fork_source_ref: run_spec.fork_source_ref.clone(),
checkpoints_disabled: run_spec.checkpoints_disabled,
})
.await
.unwrap();

View file

@ -4,7 +4,7 @@ use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use fabro_types::settings::run::RunMode;
use fabro_types::{RunId, WorkflowSettings};
use fabro_types::{ForkSourceRef, PreRunGitContext, RunId, WorkflowSettings};
use crate::git::{GitAuthor, git_author_from_settings};
@ -19,26 +19,30 @@ pub struct GitCheckpointOptions {
/// Options for a workflow run.
#[derive(Clone)]
pub struct RunOptions {
pub settings: WorkflowSettings,
pub run_dir: PathBuf,
pub cancel_token: Option<Arc<AtomicBool>>,
pub settings: WorkflowSettings,
pub run_dir: PathBuf,
pub cancel_token: Option<Arc<AtomicBool>>,
/// Unique identifier for this workflow run.
pub run_id: RunId,
pub run_id: RunId,
/// User-defined key-value labels for this run.
pub labels: HashMap<String, String>,
pub labels: HashMap<String, String>,
/// Workflow directory slug (e.g. "smoke" from `.fabro/workflows/smoke/`).
pub workflow_slug: Option<String>,
pub workflow_slug: Option<String>,
/// GitHub credentials for pushing metadata branches to origin.
pub github_app: Option<fabro_github::GitHubCredentials>,
/// Host repo path for MetadataStore (shadow commits) and host-side pushes.
pub host_repo_path: Option<PathBuf>,
pub github_app: Option<fabro_github::GitHubCredentials>,
/// Submitter-side git context captured before the run was created.
pub pre_run_git: Option<PreRunGitContext>,
/// Source checkpoint ref used by fork/rewind-created runs.
pub fork_source_ref: Option<ForkSourceRef>,
/// Explicit no-checkpoints mode for in-place local execution.
pub checkpoints_disabled: bool,
/// Name of the branch the run was started from (for PR base).
pub base_branch: Option<String>,
pub base_branch: Option<String>,
/// Base commit SHA to display in lifecycle events/UI even when
/// checkpointing is disabled.
pub display_base_sha: Option<String>,
pub display_base_sha: Option<String>,
/// Git checkpoint options; `None` means checkpointing disabled.
pub git: Option<GitCheckpointOptions>,
pub git: Option<GitCheckpointOptions>,
}
impl RunOptions {

View file

@ -104,7 +104,6 @@ impl RunStoreBackend for LocalRunStoreBackend {
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
@ -131,18 +130,20 @@ mod tests {
fn test_run_spec() -> RunSpec {
RunSpec {
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
workflow_slug: Some("test".to_string()),
working_directory: PathBuf::from("/tmp/test"),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: Graph::new("test"),
workflow_slug: Some("test".to_string()),
source_directory: Some("/tmp/test".to_string()),
repo_origin_url: None,
base_branch: None,
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
}
}
@ -151,21 +152,23 @@ mod tests {
let run_store = test_run_store().await;
let record = test_run_spec();
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
settings: serde_json::to_value(&record.settings).unwrap(),
graph: serde_json::to_value(&record.graph).unwrap(),
workflow_source: Some("digraph test {}".to_string()),
workflow_config: None,
labels: std::collections::BTreeMap::new(),
run_dir: "/tmp/test".to_string(),
working_directory: "/tmp/test".to_string(),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
workflow_slug: Some("test".to_string()),
db_prefix: None,
provenance: None,
manifest_blob: None,
run_id: fixtures::RUN_1,
settings: serde_json::to_value(&record.settings).unwrap(),
graph: serde_json::to_value(&record.graph).unwrap(),
workflow_source: Some("digraph test {}".to_string()),
workflow_config: None,
labels: std::collections::BTreeMap::new(),
run_dir: "/tmp/test".to_string(),
source_directory: Some("/tmp/test".to_string()),
repo_origin_url: None,
base_branch: None,
workflow_slug: Some("test".to_string()),
db_prefix: None,
provenance: None,
manifest_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
})
.await
.unwrap();

View file

@ -1,14 +1,12 @@
use std::collections::{HashMap, HashSet};
use std::path::Path;
use fabro_agent::Sandbox;
use fabro_checkpoint::trailer as trailerlink;
use fabro_checkpoint::trailer::Trailer;
use fabro_sandbox::daytona::detect_repo_info;
use fabro_types::RunId;
use crate::artifact_snapshot;
use crate::git::{GitAuthor, blocking_push_with_timeout, push_ref};
use crate::git::GitAuthor;
/// Captured git state for a workflow run, shared with handlers.
#[derive(Debug, Clone)]
@ -133,59 +131,6 @@ pub async fn git_checkpoint(
}
}
/// Push a refspec from the host repo to origin (best-effort).
///
/// Authenticates via resolved GitHub credentials so we don't depend on the
/// host's ambient git credentials.
pub async fn git_push_host(
repo_path: &Path,
refspec: &str,
github_app: &Option<fabro_github::GitHubCredentials>,
label: &str,
) -> bool {
let (origin_url, _) = match detect_repo_info(repo_path) {
Ok(info) => info,
Err(e) => {
tracing::warn!(error = %e, label, "Cannot detect origin for push");
return false;
}
};
let https_url = fabro_github::ssh_url_to_https(&origin_url);
let push_url = if let Some(creds) = github_app {
match fabro_github::resolve_authenticated_url(
&fabro_github::GitHubContext::new(creds, &fabro_github::github_api_base_url()),
&https_url,
)
.await
{
Ok(url) => url.raw_string(),
Err(e) => {
tracing::warn!(error = %e, label, "Failed to get token for push");
return false;
}
}
} else {
tracing::warn!(label, "No GitHub credentials for push");
return false;
};
let rp = repo_path.to_path_buf();
let refspec_owned = refspec.to_string();
let result =
blocking_push_with_timeout(60, move || push_ref(&rp, &push_url, &refspec_owned)).await;
match result {
Ok(()) => {
tracing::info!(label, "Pushed to origin");
true
}
Err(e) => {
tracing::warn!(error = %e, label, "Failed to push");
false
}
}
}
/// Run a git diff via the sandbox (30 s default timeout).
pub(crate) async fn git_diff(
sandbox: &dyn Sandbox,
@ -833,7 +778,7 @@ mod tests {
reason = "These unit tests use the real git CLI to construct sandbox-git fixture repositories and sync-write fixtures to disk."
)]
use std::collections::VecDeque;
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use async_trait::async_trait;
@ -1174,6 +1119,88 @@ mod tests {
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
#[tokio::test]
async fn sandbox_metadata_writer_preserves_worktree_and_writes_binary_run_dump() {
let repo_dir = tempfile::tempdir().unwrap();
let repo = repo_dir.path();
init_git_repo(repo);
std::fs::write(repo.join("tracked.txt"), "seed\n").unwrap();
let head = git_commit_all(repo, "initial");
let sandbox = fabro_agent::LocalSandbox::new(repo.to_path_buf());
let run_id = fabro_types::fixtures::RUN_1;
let mut projection = fabro_store::RunProjection::default();
projection.spec = Some(fabro_types::RunSpec {
run_id,
settings: fabro_types::WorkflowSettings::default(),
graph: fabro_types::Graph::new("metadata"),
workflow_slug: Some("metadata".to_string()),
source_directory: Some("/Users/client/project".to_string()),
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
base_branch: Some("main".to_string()),
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
});
let mut dump = crate::run_dump::RunDump::from_projection(&projection);
dump.add_file_bytes("binary/payload.bin", vec![0, 159, 146, 150]);
let runtime = crate::sandbox_metadata::SandboxGitRuntime::new();
let run_id_string = run_id.to_string();
let branch = crate::sandbox_metadata::metadata_branch_name(&run_id_string);
let writer = crate::sandbox_metadata::SandboxMetadataWriter::new(
&sandbox,
&runtime,
&run_id_string,
&branch,
crate::git::GitAuthor::default(),
);
let snapshot = writer.write_snapshot(&dump, "checkpoint").await.unwrap();
let commit_sha = snapshot.commit_sha;
let current = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(repo)
.output()
.unwrap();
assert_eq!(String::from_utf8(current.stdout).unwrap().trim(), "main");
let head_after = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap();
assert_eq!(String::from_utf8(head_after.stdout).unwrap().trim(), head);
let run_json = std::process::Command::new("git")
.args(["show", &format!("{commit_sha}:run.json")])
.current_dir(repo)
.output()
.unwrap();
assert!(run_json.status.success());
let stored_projection: serde_json::Value =
serde_json::from_slice(&run_json.stdout).unwrap();
assert!(stored_projection.get("spec").is_some());
let binary = std::process::Command::new("git")
.args(["show", &format!("{commit_sha}:binary/payload.bin")])
.current_dir(repo)
.output()
.unwrap();
assert_eq!(binary.stdout, vec![0, 159, 146, 150]);
let status = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(repo)
.output()
.unwrap();
assert!(String::from_utf8(status.stdout).unwrap().trim().is_empty());
}
#[tokio::test]
async fn list_changed_files_raw_classifies_add_modify_delete() {
let repo_dir = tempfile::tempdir().unwrap();

View file

@ -0,0 +1,352 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use fabro_agent::Sandbox;
use tokio::fs;
use tokio::sync::OnceCell;
use crate::git::{GitAuthor, META_BRANCH_PREFIX};
use crate::run_dump::RunDump;
use crate::sandbox_git::GIT_REMOTE;
#[derive(Debug, thiserror::Error)]
pub(crate) enum SandboxMetadataError {
#[error("sandbox git unavailable: {0}")]
GitUnavailable(String),
#[error("metadata dump serialization failed: {0}")]
Dump(#[from] anyhow::Error),
#[error("metadata temp file write failed: {0}")]
LocalTemp(std::io::Error),
#[error("{0}")]
Git(String),
#[error("{0}")]
Sandbox(String),
}
pub(crate) struct SandboxGitRuntime {
probe: OnceCell<Result<(), String>>,
metadata_degraded: AtomicBool,
metadata_warning_emitted: AtomicBool,
}
impl SandboxGitRuntime {
pub(crate) fn new() -> Self {
Self {
probe: OnceCell::new(),
metadata_degraded: AtomicBool::new(false),
metadata_warning_emitted: AtomicBool::new(false),
}
}
pub(crate) async fn ensure_git_available(&self, sandbox: &dyn Sandbox) -> Result<(), String> {
self.probe
.get_or_init(|| async { probe_sandbox_git(sandbox).await })
.await
.clone()
}
pub(crate) fn mark_metadata_degraded(&self) -> bool {
self.metadata_degraded.store(true, Ordering::SeqCst);
!self.metadata_warning_emitted.swap(true, Ordering::SeqCst)
}
pub(crate) fn metadata_degraded(&self) -> bool {
self.metadata_degraded.load(Ordering::SeqCst)
}
}
impl Default for SandboxGitRuntime {
fn default() -> Self {
Self::new()
}
}
pub(crate) fn metadata_branch_name(run_id: &str) -> String {
format!("{META_BRANCH_PREFIX}{run_id}")
}
pub(crate) struct SandboxMetadataWriter<'a> {
sandbox: &'a dyn Sandbox,
runtime: &'a SandboxGitRuntime,
run_id: &'a str,
branch: &'a str,
git_author: GitAuthor,
}
pub(crate) struct MetadataSnapshot {
pub commit_sha: String,
pub pushed: bool,
}
impl<'a> SandboxMetadataWriter<'a> {
pub(crate) fn new(
sandbox: &'a dyn Sandbox,
runtime: &'a SandboxGitRuntime,
run_id: &'a str,
branch: &'a str,
git_author: GitAuthor,
) -> Self {
Self {
sandbox,
runtime,
run_id,
branch,
git_author,
}
}
pub(crate) async fn write_snapshot(
&self,
dump: &RunDump,
message: &str,
) -> Result<MetadataSnapshot, SandboxMetadataError> {
self.runtime
.ensure_git_available(self.sandbox)
.await
.map_err(SandboxMetadataError::GitUnavailable)?;
let entries = dump.git_entries()?;
let temp = sandbox_temp_dir(self.sandbox, self.run_id, "metadata");
let index = format!("{temp}/index");
exec_ok(
self.sandbox,
&format!(
"rm -rf {temp_q} && mkdir -p {temp_q}",
temp_q = shell_quote(&temp)
),
None,
)
.await?;
let result = self
.write_snapshot_in_temp(&entries, message, &temp, &index)
.await;
let cleanup = exec_ok(
self.sandbox,
&format!("rm -rf {}", shell_quote(&temp)),
None,
)
.await;
match (result, cleanup) {
(Ok(snapshot), _) => Ok(snapshot),
(Err(err), _) => Err(err),
}
}
async fn write_snapshot_in_temp(
&self,
entries: &[(String, Vec<u8>)],
message: &str,
temp: &str,
index: &str,
) -> Result<MetadataSnapshot, SandboxMetadataError> {
let full_ref = format!("refs/heads/{}", self.branch);
let env = git_index_env(index, &self.git_author);
let old_commit = self.load_previous_tree(&full_ref, &env).await?;
for (ordinal, (path, bytes)) in entries.iter().enumerate() {
validate_metadata_path(path)?;
let local = tempfile::NamedTempFile::new().map_err(SandboxMetadataError::LocalTemp)?;
fs::write(local.path(), bytes)
.await
.map_err(SandboxMetadataError::LocalTemp)?;
let remote = format!("{temp}/blob-{ordinal}");
self.sandbox
.upload_file_from_local(local.path(), &remote)
.await
.map_err(|err| SandboxMetadataError::Sandbox(err.display_with_causes()))?;
let hash = exec_stdout(
self.sandbox,
&format!("{GIT_REMOTE} hash-object -w {}", shell_quote(&remote)),
None,
)
.await?;
let cacheinfo = format!("100644,{hash},{path}");
exec_ok(
self.sandbox,
&format!(
"{GIT_REMOTE} update-index --add --cacheinfo {}",
shell_quote(&cacheinfo)
),
Some(&env),
)
.await?;
}
let tree = exec_stdout(
self.sandbox,
&format!("{GIT_REMOTE} write-tree"),
Some(&env),
)
.await?;
let message_path = format!("{temp}/message.txt");
let mut commit_message = message.to_string();
self.git_author.append_footer(&mut commit_message);
self.sandbox
.write_file(&message_path, &commit_message)
.await
.map_err(|err| SandboxMetadataError::Sandbox(err.display_with_causes()))?;
let parent = old_commit
.as_ref()
.map_or(String::new(), |sha| format!(" -p {}", shell_quote(sha)));
let commit = exec_stdout(
self.sandbox,
&format!(
"{GIT_REMOTE} commit-tree {}{parent} -F {}",
shell_quote(&tree),
shell_quote(&message_path)
),
Some(&env),
)
.await?;
exec_ok(
self.sandbox,
&format!(
"{GIT_REMOTE} update-ref {} {}",
shell_quote(&full_ref),
shell_quote(&commit)
),
None,
)
.await?;
let refspec = format!("{full_ref}:{full_ref}");
let pushed = self.sandbox.git_push_ref(&refspec).await;
Ok(MetadataSnapshot {
commit_sha: commit,
pushed,
})
}
async fn load_previous_tree(
&self,
full_ref: &str,
env: &HashMap<String, String>,
) -> Result<Option<String>, SandboxMetadataError> {
let old_commit = exec_stdout(
self.sandbox,
&format!(
"{GIT_REMOTE} rev-parse --verify -q {}^{{commit}} || true",
shell_quote(full_ref)
),
None,
)
.await?;
if old_commit.is_empty() {
exec_ok(
self.sandbox,
&format!("{GIT_REMOTE} read-tree --empty"),
Some(env),
)
.await?;
Ok(None)
} else {
exec_ok(
self.sandbox,
&format!("{GIT_REMOTE} read-tree {}", shell_quote(&old_commit)),
Some(env),
)
.await?;
Ok(Some(old_commit))
}
}
}
async fn probe_sandbox_git(sandbox: &dyn Sandbox) -> Result<(), String> {
let temp = sandbox_temp_dir(sandbox, "probe", "git");
let index = format!("{temp}/index");
let probe_file = format!("{temp}/probe.txt");
let command = format!(
"set -e\n\
rm -rf {temp_q}\n\
mkdir -p {temp_q}\n\
printf probe > {probe_file_q}\n\
GIT_INDEX_FILE={index_q} {git} read-tree --empty\n\
blob=$({git} hash-object -w {probe_file_q})\n\
GIT_INDEX_FILE={index_q} {git} update-index --add --cacheinfo 100644,$blob,probe.txt\n\
GIT_INDEX_FILE={index_q} {git} write-tree >/dev/null\n\
rm -rf {temp_q}",
temp_q = shell_quote(&temp),
probe_file_q = shell_quote(&probe_file),
index_q = shell_quote(&index),
git = GIT_REMOTE,
);
exec_ok(sandbox, &command, None)
.await
.map_err(|err| err.to_string())
}
fn sandbox_temp_dir(sandbox: &dyn Sandbox, run_id: &str, label: &str) -> String {
format!(
"{}/.fabro/tmp/{label}-{run_id}-{}",
sandbox.working_directory().trim_end_matches('/'),
uuid::Uuid::new_v4()
)
}
fn git_index_env(index: &str, author: &GitAuthor) -> HashMap<String, String> {
HashMap::from([
("GIT_INDEX_FILE".to_string(), index.to_string()),
("GIT_AUTHOR_NAME".to_string(), author.name.clone()),
("GIT_AUTHOR_EMAIL".to_string(), author.email.clone()),
("GIT_COMMITTER_NAME".to_string(), author.name.clone()),
("GIT_COMMITTER_EMAIL".to_string(), author.email.clone()),
])
}
async fn exec_stdout(
sandbox: &dyn Sandbox,
command: &str,
env: Option<&HashMap<String, String>>,
) -> Result<String, SandboxMetadataError> {
let result = sandbox
.exec_command(command, 30_000, None, env, None)
.await
.map_err(|err| SandboxMetadataError::Sandbox(err.display_with_causes()))?;
if result.exit_code == 0 {
Ok(result.stdout.trim().to_string())
} else {
Err(SandboxMetadataError::Git(exec_err(command, &result)))
}
}
async fn exec_ok(
sandbox: &dyn Sandbox,
command: &str,
env: Option<&HashMap<String, String>>,
) -> Result<(), SandboxMetadataError> {
exec_stdout(sandbox, command, env).await.map(|_| ())
}
fn exec_err(label: &str, result: &fabro_sandbox::ExecResult) -> String {
if result.timed_out {
return format!("{label} timed out after {}ms", result.duration_ms);
}
let detail = format!("{}{}", result.stdout, result.stderr);
let detail = detail.trim();
if detail.is_empty() {
format!("{label} failed with exit {}", result.exit_code)
} else {
format!("{label} failed with exit {}: {detail}", result.exit_code)
}
}
fn validate_metadata_path(path: &str) -> Result<(), SandboxMetadataError> {
let invalid = path.is_empty()
|| path.starts_with('/')
|| path
.split('/')
.any(|segment| segment.is_empty() || segment == "." || segment == "..");
if invalid {
return Err(SandboxMetadataError::Git(format!(
"invalid metadata path: {path}"
)));
}
Ok(())
}
fn shell_quote(value: &str) -> String {
shlex::try_quote(value).map_or_else(
|_| format!("'{}'", value.replace('\'', "'\\''")),
|quoted| quoted.to_string(),
)
}

View file

@ -17,18 +17,20 @@ use crate::event::Emitter;
use crate::handler::HandlerRegistry;
use crate::runtime_store::RunStoreHandle;
use crate::sandbox_git::GitState;
use crate::sandbox_metadata::SandboxGitRuntime;
use crate::workflow_bundle::WorkflowBundle;
/// Services shared across workflow phases.
#[derive(Clone)]
pub struct RunServices {
pub run_store: RunStoreHandle,
pub emitter: Arc<Emitter>,
pub sandbox: Arc<dyn Sandbox>,
pub hook_runner: Option<Arc<HookRunner>>,
pub cancel_requested: Option<Arc<AtomicBool>>,
pub provider: Provider,
pub llm_source: Arc<dyn CredentialSource>,
pub run_store: RunStoreHandle,
pub emitter: Arc<Emitter>,
pub sandbox: Arc<dyn Sandbox>,
pub hook_runner: Option<Arc<HookRunner>>,
pub cancel_requested: Option<Arc<AtomicBool>>,
pub provider: Provider,
pub llm_source: Arc<dyn CredentialSource>,
pub(crate) metadata_runtime: Arc<SandboxGitRuntime>,
}
impl RunServices {
@ -50,6 +52,7 @@ impl RunServices {
cancel_requested,
provider,
llm_source,
metadata_runtime: Arc::new(SandboxGitRuntime::new()),
})
}

View file

@ -106,31 +106,28 @@ async fn initialized(
.expect("failed to create slate-backed test run store");
let run_store = inner_store;
append_event(&run_store, &run_options.run_id, &Event::RunCreated {
run_id: run_options.run_id,
settings: serde_json::to_value(&run_options.settings)
run_id: run_options.run_id,
settings: serde_json::to_value(&run_options.settings)
.expect("failed to serialize settings"),
graph: serde_json::to_value(graph).expect("failed to serialize graph"),
workflow_source: None,
workflow_config: None,
labels: run_options
graph: serde_json::to_value(graph).expect("failed to serialize graph"),
workflow_source: None,
workflow_config: None,
labels: run_options
.labels
.clone()
.into_iter()
.collect::<BTreeMap<_, _>>(),
run_dir: run_options.run_dir.display().to_string(),
working_directory: PathBuf::from(sandbox.working_directory())
.display()
.to_string(),
host_repo_path: run_options
.host_repo_path
.as_ref()
.map(|path| path.display().to_string()),
repo_origin_url: None,
base_branch: run_options.base_branch.clone(),
workflow_slug: run_options.workflow_slug.clone(),
db_prefix: None,
provenance: None,
manifest_blob: None,
run_dir: run_options.run_dir.display().to_string(),
source_directory: Some(sandbox.working_directory().to_string()),
repo_origin_url: None,
base_branch: run_options.base_branch.clone(),
workflow_slug: run_options.workflow_slug.clone(),
db_prefix: None,
provenance: None,
manifest_blob: None,
pre_run_git: run_options.pre_run_git.clone(),
fork_source_ref: run_options.fork_source_ref.clone(),
checkpoints_disabled: run_options.checkpoints_disabled,
})
.await
.expect("failed to seed run.created event in run store");

View file

@ -23,14 +23,12 @@ use fabro_sandbox::reconnect::reconnect;
fn local_record(working_directory: &std::path::Path) -> SandboxRecord {
SandboxRecord {
provider: "local".to_string(),
working_directory: working_directory.to_string_lossy().to_string(),
identifier: None,
host_working_directory: None,
container_mount_point: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
provider: "local".to_string(),
working_directory: working_directory.to_string_lossy().to_string(),
identifier: None,
repo_cloned: None,
clone_origin_url: None,
clone_branch: None,
}
}
@ -128,14 +126,12 @@ async fn local_cp_creates_parent_dirs() {
fn docker_record(container_id: &str) -> SandboxRecord {
SandboxRecord {
provider: "docker".to_string(),
working_directory: "/workspace".to_string(),
identifier: Some(container_id.to_string()),
host_working_directory: None,
container_mount_point: None,
repo_cloned: Some(false),
clone_origin_url: None,
clone_branch: None,
provider: "docker".to_string(),
working_directory: "/workspace".to_string(),
identifier: Some(container_id.to_string()),
repo_cloned: Some(false),
clone_origin_url: None,
clone_branch: None,
}
}

View file

@ -511,17 +511,19 @@ async fn daytona_pipeline_artifact_offload_and_sync() {
let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env.clone());
let run_options = RunOptions {
settings: WorkflowSettings::default(),
run_dir: dir.path().to_path_buf(),
cancel_token: None,
run_id: test_run_id("test-run"),
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
base_branch: None,
display_base_sha: None,
host_repo_path: None,
git: None,
settings: WorkflowSettings::default(),
run_dir: dir.path().to_path_buf(),
cancel_token: None,
run_id: test_run_id("test-run"),
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
base_branch: None,
display_base_sha: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
git: None,
};
let outcome = engine
.run(&graph, &run_options)
@ -690,17 +692,19 @@ async fn daytona_git_checkpoint_remote_emits_events() {
let engine = WorkflowRunner::new(registry, Arc::new(emitter), env.clone());
let run_options = RunOptions {
settings: WorkflowSettings::default(),
run_dir: dir.path().to_path_buf(),
cancel_token: None,
run_id: test_run_id("git-cp-test"),
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
base_branch: None,
display_base_sha: None,
host_repo_path: Some(dir.path().to_path_buf()),
git: Some(GitCheckpointOptions {
settings: WorkflowSettings::default(),
run_dir: dir.path().to_path_buf(),
cancel_token: None,
run_id: test_run_id("git-cp-test"),
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
base_branch: None,
display_base_sha: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
git: Some(GitCheckpointOptions {
base_sha: Some(base_sha),
run_branch: Some(branch_name),
meta_branch: None,
@ -870,7 +874,9 @@ async fn daytona_parallel_git_branching_e2e() {
github_app: None,
base_branch: None,
display_base_sha: None,
host_repo_path: Some(run_tmp.path().to_path_buf()),
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
git: Some(GitCheckpointOptions {
base_sha: Some(base_sha),
run_branch: Some(branch_name),
@ -1113,13 +1119,11 @@ async fn daytona_cli_gemini() {
}
// ---------------------------------------------------------------------------
// Daytona shadow commit E2E with MetadataStore
// Daytona shadow commit E2E with sandbox-native metadata
// ---------------------------------------------------------------------------
use fabro_workflow::git::MetadataStore;
/// End-to-end test: pipeline with git checkpointing enabled + `meta_branch`
/// writes shadow branch on the host repo and includes `Fabro-Checkpoint`
/// writes shadow branch in the sandbox repo and includes `Fabro-Checkpoint`
/// trailer in sandbox commits.
#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))]
async fn daytona_git_checkpoint_with_shadow_branch() {
@ -1152,28 +1156,6 @@ async fn daytona_git_checkpoint_with_shadow_branch() {
// Set up git in the sandbox
let (run_id, base_sha, branch_name) = setup_daytona_git(&*env).await;
// Create a temp git repo on the host for MetadataStore
let host_repo = tempfile::tempdir().unwrap();
std::process::Command::new("git")
.args(["init"])
.current_dir(host_repo.path())
.output()
.unwrap();
std::process::Command::new("git")
.args([
"-c",
"user.name=test",
"-c",
"user.email=test@test",
"commit",
"--allow-empty",
"-m",
"init",
])
.current_dir(host_repo.path())
.output()
.unwrap();
// Pipeline: start -> work -> exit
let mut graph = Graph::new("DaytonaShadowBranch");
graph.attrs.insert(
@ -1211,7 +1193,7 @@ async fn daytona_git_checkpoint_with_shadow_branch() {
registry.register("start", Box::new(StartHandler));
registry.register("exit", Box::new(ExitHandler));
let meta_branch = MetadataStore::branch_name(&run_id.to_string());
let meta_branch = format!("fabro/meta/{run_id}");
let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env.clone());
let run_options = RunOptions {
settings: WorkflowSettings::default(),
@ -1223,11 +1205,13 @@ async fn daytona_git_checkpoint_with_shadow_branch() {
github_app: None,
base_branch: None,
display_base_sha: None,
host_repo_path: Some(host_repo.path().to_path_buf()),
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
git: Some(GitCheckpointOptions {
base_sha: Some(base_sha),
run_branch: Some(branch_name),
meta_branch: Some(meta_branch),
meta_branch: Some(meta_branch.clone()),
}),
};
let outcome = engine
@ -1236,9 +1220,22 @@ async fn daytona_git_checkpoint_with_shadow_branch() {
.expect("pipeline should succeed");
assert_eq!(outcome.status, StageStatus::Success);
// Assert shadow branch on host has checkpoint data
let checkpoint = MetadataStore::read_checkpoint(host_repo.path(), &run_id.to_string())
.expect("read_checkpoint should not error")
// Assert shadow branch in the sandbox has checkpoint data
let run_json = env
.exec_command(
&format!("git show refs/heads/{meta_branch}:run.json"),
10_000,
None,
None,
None,
)
.await
.expect("git show should succeed");
assert_eq!(run_json.exit_code, 0, "{}", run_json.stderr);
let projection: fabro_store::RunProjection =
serde_json::from_slice(run_json.stdout.as_bytes()).expect("run.json should parse");
let checkpoint = projection
.checkpoint
.expect("shadow branch should contain checkpoint data");
assert!(
!checkpoint.completed_nodes.is_empty(),
@ -1349,7 +1346,7 @@ async fn daytona_asset_collection() {
graph.edges.push(Edge::new("create_assets", "exit"));
let run_options = RunOptions {
settings: WorkflowSettings {
settings: WorkflowSettings {
run: fabro_types::settings::RunNamespace {
artifacts: fabro_types::settings::run::ArtifactsSettings {
include: vec!["test-results/**".to_string()],
@ -1358,16 +1355,18 @@ async fn daytona_asset_collection() {
},
..WorkflowSettings::default()
},
run_dir: dir.path().to_path_buf(),
cancel_token: None,
run_id: test_run_id("artifact-test-daytona"),
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
base_branch: None,
display_base_sha: None,
host_repo_path: None,
git: None,
run_dir: dir.path().to_path_buf(),
cancel_token: None,
run_id: test_run_id("artifact-test-daytona"),
labels: std::collections::HashMap::new(),
workflow_slug: None,
github_app: None,
base_branch: None,
display_base_sha: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
git: None,
};
let outcome = engine
.run(&graph, &run_options)
@ -1623,7 +1622,9 @@ async fn daytona_git_push_run_branch_to_origin() {
github_app: None,
base_branch: None,
display_base_sha: None,
host_repo_path: Some(dir.path().to_path_buf()),
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
git: Some(GitCheckpointOptions {
base_sha: Some(base_sha),
run_branch: Some(branch_name.clone()),
@ -1829,14 +1830,12 @@ async fn daytona_cp_upload_download_round_trip() {
// 2. Build a SandboxRecord (same as `fabro run` would persist)
let record = SandboxRecord {
provider: "daytona".to_string(),
working_directory: env.working_directory().to_string(),
identifier: Some(sandbox_name.clone()),
host_working_directory: None,
container_mount_point: None,
repo_cloned: Some(false),
clone_origin_url: None,
clone_branch: None,
provider: "daytona".to_string(),
working_directory: env.working_directory().to_string(),
identifier: Some(sandbox_name.clone()),
repo_cloned: Some(false),
clone_origin_url: None,
clone_branch: None,
};
// 3. Reconnect via the real cp::reconnect path

View file

@ -4,7 +4,7 @@
)]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::process::{Command, Output};
use std::sync::Arc;
@ -13,8 +13,8 @@ use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
use fabro_types::{RunEvent, WorkflowSettings, fixtures};
use fabro_workflow::event::Emitter;
use fabro_workflow::git::{
MetadataStore, add_worktree, branch_needs_push, create_branch, push_branch, push_ref,
remove_worktree, replace_worktree,
add_worktree, branch_needs_push, create_branch, push_branch, push_ref, remove_worktree,
replace_worktree,
};
use fabro_workflow::handler::HandlerRegistry;
use fabro_workflow::handler::exit::ExitHandler;
@ -153,17 +153,19 @@ fn make_registry() -> HandlerRegistry {
fn test_run_options(run_dir: &Path) -> RunOptions {
RunOptions {
run_dir: run_dir.to_path_buf(),
cancel_token: None,
run_id: fixtures::RUN_2,
settings: WorkflowSettings::default(),
git: None,
host_repo_path: None,
labels: HashMap::new(),
github_app: None,
base_branch: None,
display_base_sha: None,
workflow_slug: None,
run_dir: run_dir.to_path_buf(),
cancel_token: None,
run_id: fixtures::RUN_2,
settings: WorkflowSettings::default(),
git: None,
pre_run_git: None,
fork_source_ref: None,
checkpoints_disabled: false,
labels: HashMap::new(),
github_app: None,
base_branch: None,
display_base_sha: None,
workflow_slug: None,
}
}
@ -285,9 +287,8 @@ async fn git_checkpoint_skips_start_node() {
run_options.git = Some(GitCheckpointOptions {
base_sha: Some(base_sha),
run_branch: None,
meta_branch: Some(MetadataStore::branch_name(&fixtures::RUN_2.to_string())),
meta_branch: Some(format!("fabro/meta/{}", fixtures::RUN_2)),
});
run_options.host_repo_path = Some(PathBuf::from(repo));
Box::pin(run_graph(
make_registry(),

File diff suppressed because it is too large Load diff

View file

@ -113,6 +113,7 @@ models/manifest-file-entry.ts
models/manifest-file-ref.ts
models/manifest-git.ts
models/manifest-goal.ts
models/manifest-pre-run-push-outcome.ts
models/manifest-target.ts
models/manifest-workflow-config.ts
models/manifest-workflow.ts

View file

@ -92,6 +92,7 @@ export * from './manifest-file-entry';
export * from './manifest-file-ref';
export * from './manifest-git';
export * from './manifest-goal';
export * from './manifest-pre-run-push-outcome';
export * from './manifest-target';
export * from './manifest-workflow';
export * from './manifest-workflow-config';

View file

@ -30,6 +30,14 @@ export interface ManifestArgs {
'auto_approve'?: boolean;
'no_retro'?: boolean;
'preserve_sandbox'?: boolean;
/**
* Run against the submitted source directory directly.
*/
'in_place'?: boolean;
/**
* Required with `in_place`; disables git checkpointing.
*/
'allow_no_checkpoints'?: boolean;
'label'?: Array<string>;
}

View file

@ -13,6 +13,9 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { ManifestPreRunPushOutcome } from './manifest-pre-run-push-outcome';
/**
* Observable git state from the CLI working directory.
@ -34,5 +37,6 @@ export interface ManifestGit {
* Whether the working tree has uncommitted changes.
*/
'clean': boolean;
'push_outcome': ManifestPreRunPushOutcome;
}

Some files were not shown because too many files have changed in this diff Show more