From cdd46b4fa826db7ea5b3a41b88213909be4ed22b Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 27 Apr 2026 21:43:15 -0700 Subject: [PATCH] Make git metadata sandbox-native --- apps/fabro-web/app/data/runs.test.ts | 8 +- docs/internal/cli-workflow-coupling-audit.md | 2 +- docs/internal/events.md | 6 +- .../plan-events-as-source-of-truth.md | 6 +- ...-04-27-sandbox-native-git-metadata-plan.md | 358 +++ docs/public/api-reference/fabro-api.yaml | 48 +- docs/public/execution/environments.mdx | 3 +- docs/public/reference/sdk.mdx | 2 +- .../fabro-api/tests/run_summary_round_trip.rs | 8 +- lib/crates/fabro-checkpoint/src/lib.rs | 1 - lib/crates/fabro-checkpoint/src/metadata.rs | 445 --- lib/crates/fabro-cli/src/args.rs | 8 + .../fabro-cli/src/commands/run/checkpoints.rs | 4 +- .../src/commands/run/run_progress/mod.rs | 28 +- .../fabro-cli/src/commands/runs/list.rs | 19 +- lib/crates/fabro-cli/src/manifest_builder.rs | 121 +- lib/crates/fabro-cli/src/server_runs.rs | 12 +- lib/crates/fabro-cli/tests/it/cmd/archive.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/attach.rs | 26 +- lib/crates/fabro-cli/tests/it/cmd/create.rs | 2 + lib/crates/fabro-cli/tests/it/cmd/dump.rs | 6 +- lib/crates/fabro-cli/tests/it/cmd/fork.rs | 79 +- lib/crates/fabro-cli/tests/it/cmd/inspect.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/logs.rs | 5 +- lib/crates/fabro-cli/tests/it/cmd/ps.rs | 6 +- lib/crates/fabro-cli/tests/it/cmd/resume.rs | 14 +- lib/crates/fabro-cli/tests/it/cmd/rewind.rs | 41 +- lib/crates/fabro-cli/tests/it/cmd/rm.rs | 6 +- lib/crates/fabro-cli/tests/it/cmd/run.rs | 3 + .../fabro-cli/tests/it/cmd/sandbox_preview.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/support.rs | 51 +- .../fabro-cli/tests/it/cmd/unarchive.rs | 2 +- lib/crates/fabro-cli/tests/it/cmd/wait.rs | 2 +- .../fabro-cli/tests/it/scenario/recovery.rs | 141 +- .../fabro-cli/tests/it/scenario/smoke.rs | 4 +- .../tests/it/workflow/dry_run_examples.rs | 5 + lib/crates/fabro-config/src/defaults.toml | 2 +- lib/crates/fabro-config/src/tests/defaults.rs | 2 +- .../fabro-config/src/tests/resolve_run.rs | 2 +- lib/crates/fabro-sandbox/src/daytona/mod.rs | 11 +- lib/crates/fabro-sandbox/src/docker.rs | 11 +- lib/crates/fabro-sandbox/src/lib.rs | 5 +- lib/crates/fabro-sandbox/src/local.rs | 13 + lib/crates/fabro-sandbox/src/sandbox.rs | 159 +- lib/crates/fabro-sandbox/src/sandbox_spec.rs | 20 - lib/crates/fabro-sandbox/src/worktree.rs | 24 +- lib/crates/fabro-server/src/demo/mod.rs | 6 +- lib/crates/fabro-server/src/run_manifest.rs | 152 +- lib/crates/fabro-server/src/server.rs | 56 +- lib/crates/fabro-store/src/run_state.rs | 63 +- lib/crates/fabro-store/src/slate/mod.rs | 12 +- .../tests/serializable_projection.rs | 41 +- lib/crates/fabro-types/src/lib.rs | 4 +- lib/crates/fabro-types/src/run.rs | 91 +- lib/crates/fabro-types/src/run_event/infra.rs | 16 +- lib/crates/fabro-types/src/run_event/mod.rs | 4 +- lib/crates/fabro-types/src/run_event/run.rs | 38 +- lib/crates/fabro-types/src/run_summary.rs | 151 +- lib/crates/fabro-types/src/sandbox_record.rs | 16 +- .../fabro-types/tests/run_event_serde.rs | 49 +- .../fabro-types/tests/run_spec_methods.rs | 45 +- .../fabro-types/tests/run_spec_serde.rs | 47 +- lib/crates/fabro-workflow/src/event.rs | 141 +- lib/crates/fabro-workflow/src/git.rs | 11 - .../src/handler/manager_loop.rs | 24 +- lib/crates/fabro-workflow/src/lib.rs | 1 + .../fabro-workflow/src/lifecycle/git.rs | 231 +- .../fabro-workflow/src/lifecycle/mod.rs | 5 +- .../fabro-workflow/src/operations/create.rs | 98 +- .../fabro-workflow/src/operations/fork.rs | 537 +--- .../fabro-workflow/src/operations/mod.rs | 11 +- .../src/operations/rebuild_meta.rs | 1053 ------- .../fabro-workflow/src/operations/rewind.rs | 14 +- .../fabro-workflow/src/operations/run_git.rs | 49 - .../fabro-workflow/src/operations/start.rs | 41 +- .../src/operations/test_support.rs | 34 - .../fabro-workflow/src/operations/timeline.rs | 307 +- .../fabro-workflow/src/pipeline/execute.rs | 1 + .../src/pipeline/execute/tests.rs | 30 +- .../fabro-workflow/src/pipeline/finalize.rs | 116 +- .../fabro-workflow/src/pipeline/initialize.rs | 266 +- .../fabro-workflow/src/pipeline/persist.rs | 42 +- .../src/pipeline/pull_request.rs | 175 +- .../fabro-workflow/src/pipeline/retro.rs | 82 +- lib/crates/fabro-workflow/src/run_dump.rs | 58 +- lib/crates/fabro-workflow/src/run_lookup.rs | 83 +- lib/crates/fabro-workflow/src/run_options.rs | 30 +- .../fabro-workflow/src/runtime_store.rs | 59 +- lib/crates/fabro-workflow/src/sandbox_git.rs | 141 +- .../fabro-workflow/src/sandbox_metadata.rs | 352 +++ lib/crates/fabro-workflow/src/services.rs | 17 +- lib/crates/fabro-workflow/src/test_support.rs | 37 +- .../fabro-workflow/tests/it/cp_integration.rs | 28 +- .../tests/it/daytona_integration.rs | 149 +- .../tests/it/git_integration.rs | 33 +- .../fabro-workflow/tests/it/integration.rs | 2774 +++++++++-------- .../src/.openapi-generator/FILES | 1 + .../fabro-api-client/src/models/index.ts | 1 + .../src/models/manifest-args.ts | 8 + .../src/models/manifest-git.ts | 4 + .../models/manifest-pre-run-push-outcome.ts | 38 + .../src/models/run-list-item.ts | 4 +- .../src/models/run-summary.ts | 4 +- .../src/models/timeline-entry-response.ts | 1 + 104 files changed, 4572 insertions(+), 5037 deletions(-) create mode 100644 docs/plans/2026-04-27-sandbox-native-git-metadata-plan.md delete mode 100644 lib/crates/fabro-checkpoint/src/metadata.rs delete mode 100644 lib/crates/fabro-workflow/src/operations/rebuild_meta.rs delete mode 100644 lib/crates/fabro-workflow/src/operations/run_git.rs delete mode 100644 lib/crates/fabro-workflow/src/operations/test_support.rs create mode 100644 lib/crates/fabro-workflow/src/sandbox_metadata.rs create mode 100644 lib/packages/fabro-api-client/src/models/manifest-pre-run-push-outcome.ts diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index 3b6c6cb92..16dfecbe7 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -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, diff --git a/docs/internal/cli-workflow-coupling-audit.md b/docs/internal/cli-workflow-coupling-audit.md index a58c2c828..e8411955d 100644 --- a/docs/internal/cli-workflow-coupling-audit.md +++ b/docs/internal/cli-workflow-coupling-audit.md @@ -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. | diff --git a/docs/internal/events.md b/docs/internal/events.md index 71174e0d7..b47fb3393 100644 --- a/docs/internal/events.md +++ b/docs/internal/events.md @@ -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` diff --git a/docs/internal/plan-events-as-source-of-truth.md b/docs/internal/plan-events-as-source-of-truth.md index c9bedb45f..3c2319930 100644 --- a/docs/internal/plan-events-as-source-of-truth.md +++ b/docs/internal/plan-events-as-source-of-truth.md @@ -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 diff --git a/docs/plans/2026-04-27-sandbox-native-git-metadata-plan.md b/docs/plans/2026-04-27-sandbox-native-git-metadata-plan.md new file mode 100644 index 000000000..fe797cf28 --- /dev/null +++ b/docs/plans/2026-04-27-sandbox-native-git-metadata-plan.md @@ -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/`) 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`. Provenance may originate on another machine, may be absent for server-originated runs, and is never opened by the server. `Option` 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` 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`, `fork_source_ref: Option`, 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`. +- 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`. +- Define `PreRunGitContext` as one coherent observation of the submitter checkout. It contains `display_base_sha: Option`, `local_dirty: DirtyStatus` (`Clean`, `Dirty`, `Unknown`), and `push_outcome: PreRunPushOutcome`. +- Define `PreRunPushOutcome` as an enum, not an `Option`: `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 ` 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/` 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/` 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/` 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/` 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/:refs/heads/fabro/run/`, `refs/heads/fabro/meta/:refs/heads/fabro/meta/`) 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` 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/` 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/` 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 ` 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/` from the sandbox clone origin and creates `refs/heads/fabro/run/` 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 ` 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. diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 3625b4606..97ad62e83 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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"] diff --git a/docs/public/execution/environments.mdx b/docs/public/execution/environments.mdx index ca70264e2..aec78773e 100644 --- a/docs/public/execution/environments.mdx +++ b/docs/public/execution/environments.mdx @@ -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 | diff --git a/docs/public/reference/sdk.mdx b/docs/public/reference/sdk.mdx index c23e2232a..4e907270b 100644 --- a/docs/public/reference/sdk.mdx +++ b/docs/public/reference/sdk.mdx @@ -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. } ``` diff --git a/lib/crates/fabro-api/tests/run_summary_round_trip.rs b/lib/crates/fabro-api/tests/run_summary_round_trip.rs index c5d6e8a5d..3b6adb52f 100644 --- a/lib/crates/fabro-api/tests/run_summary_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_summary_round_trip.rs @@ -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(), }); diff --git a/lib/crates/fabro-checkpoint/src/lib.rs b/lib/crates/fabro-checkpoint/src/lib.rs index 7bdc82a5e..b9157d900 100644 --- a/lib/crates/fabro-checkpoint/src/lib.rs +++ b/lib/crates/fabro-checkpoint/src/lib.rs @@ -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/"; diff --git a/lib/crates/fabro-checkpoint/src/metadata.rs b/lib/crates/fabro-checkpoint/src/metadata.rs deleted file mode 100644 index 9998abcf8..000000000 --- a/lib/crates/fabro-checkpoint/src/metadata.rs +++ /dev/null @@ -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, 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 { - 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>, 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, 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, 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, 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, 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>, 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, - next_node_id: Option, - ) -> 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 { - serde_json::to_vec_pretty(projection).unwrap() - } - - fn branch_entry(repo_dir: &Path, run_id: &str, path: &str) -> Vec { - 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")); - } -} diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index e076f5d20..530e4afdf 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -236,6 +236,14 @@ pub(crate) struct RunArgs { #[arg(long, value_enum)] pub(crate) sandbox: Option, + /// 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, diff --git a/lib/crates/fabro-cli/src/commands/run/checkpoints.rs b/lib/crates/fabro-cli/src/commands/run/checkpoints.rs index 02e65721d..2ca5cf190 100644 --- a/lib/crates/fabro-cli/src/commands/run/checkpoints.rs +++ b/lib/crates/fabro-cli/src/commands/run/checkpoints.rs @@ -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()); + } } } diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs index 82ebbd2a4..6d33477da 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -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, diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index 1eec81398..2f63758c5 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -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 { diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index f486c628c..1b8ad95cc 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -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 Option { 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 { 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 { - 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)> { + 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 { let path = Path::new(reference); if path.is_absolute() || reference.starts_with('~') { diff --git a/lib/crates/fabro-cli/src/server_runs.rs b/lib/crates/fabro-cli/src/server_runs.rs index f0832beb1..500534bb1 100644 --- a/lib/crates/fabro-cli/src/server_runs.rs +++ b/lib/crates/fabro-cli/src/server_runs.rs @@ -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 { diff --git a/lib/crates/fabro-cli/tests/it/cmd/archive.rs b/lib/crates/fabro-cli/tests/it/cmd/archive.rs index e80f717dd..2825c043e 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/archive.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/archive.rs @@ -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", diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 531416bea..328efb39a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -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", diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index abbd1edc7..4818b9745 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -52,6 +52,8 @@ fn help() { --provider Override default LLM provider -v, --verbose Enable verbose output --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 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) diff --git a/lib/crates/fabro-cli/tests/it/cmd/dump.rs b/lib/crates/fabro-cli/tests/it/cmd/dump.rs index 9224ab842..2c99b692b 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/dump.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/dump.rs @@ -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 diff --git a/lib/crates/fabro-cli/tests/it/cmd/fork.rs b/lib/crates/fabro-cli/tests/it/cmd/fork.rs index 14dc873a7..bafcfd127 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fork.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fork.rs @@ -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()) ); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs index 111866e17..2a8f239cb 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/inspect.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/inspect.rs @@ -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", diff --git a/lib/crates/fabro-cli/tests/it/cmd/logs.rs b/lib/crates/fabro-cli/tests/it/cmd/logs.rs index 75fff236a..141dd2dc1 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/logs.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/logs.rs @@ -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] diff --git a/lib/crates/fabro-cli/tests/it/cmd/ps.rs b/lib/crates/fabro-cli/tests/it/cmd/ps.rs index 670b50af1..e7bd850db 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/ps.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/ps.rs @@ -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", diff --git a/lib/crates/fabro-cli/tests/it/cmd/resume.rs b/lib/crates/fabro-cli/tests/it/cmd/resume.rs index d5e0f9df7..82bb30882 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/resume.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/resume.rs @@ -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!( diff --git a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs b/lib/crates/fabro-cli/tests/it/cmd/rewind.rs index 93f0d5e77..854594f7b 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rewind.rs @@ -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(), diff --git a/lib/crates/fabro-cli/tests/it/cmd/rm.rs b/lib/crates/fabro-cli/tests/it/cmd/rm.rs index 0a71804c7..a1a86577f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rm.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rm.rs @@ -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", diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index fea96671e..6755943dc 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -169,6 +169,8 @@ fn help() { --provider Override default LLM provider -v, --verbose Enable verbose output --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 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] diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs index 5d6295f39..3e68944e5 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs @@ -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", diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 5ae4f8789..b7d499d12 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -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 { 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 { - 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 { - 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 { fn visit(root: &Path, dir: &Path, entries: &mut Vec) { 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(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs b/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs index de5c500b3..25fc4b011 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs @@ -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", diff --git a/lib/crates/fabro-cli/tests/it/cmd/wait.rs b/lib/crates/fabro-cli/tests/it/cmd/wait.rs index c44ec1e05..b4a96f78d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/wait.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/wait.rs @@ -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", diff --git a/lib/crates/fabro-cli/tests/it/scenario/recovery.rs b/lib/crates/fabro-cli/tests/it/scenario/recovery.rs index 7cfa42790..2513ec692 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/recovery.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/recovery.rs @@ -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 { @@ -28,21 +28,20 @@ fn list_metadata_run_ids(repo_dir: &Path) -> BTreeSet { .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 { + 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::(&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> { @@ -60,9 +59,10 @@ fn timeline_run_shas(repo_dir: &Path, run_id: &str) -> Vec> { 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::(&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 { + 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 + ); } diff --git a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs index 3d0dd66b5..8d6616915 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs @@ -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", diff --git a/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs b/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs index a44ef957d..ae7022563 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs @@ -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] diff --git a/lib/crates/fabro-config/src/defaults.toml b/lib/crates/fabro-config/src/defaults.toml index 99a0de6b0..d673e64a9 100644 --- a/lib/crates/fabro-config/src/defaults.toml +++ b/lib/crates/fabro-config/src/defaults.toml @@ -22,7 +22,7 @@ preserve = false devcontainer = false [run.sandbox.local] -worktree_mode = "clean" +worktree_mode = "always" [run.sandbox.docker] image = "buildpack-deps:noble" diff --git a/lib/crates/fabro-config/src/tests/defaults.rs b/lib/crates/fabro-config/src/tests/defaults.rs index f6057e9d7..5aecf5f68 100644 --- a/lib/crates/fabro-config/src/tests/defaults.rs +++ b/lib/crates/fabro-config/src/tests/defaults.rs @@ -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 diff --git a/lib/crates/fabro-config/src/tests/resolve_run.rs b/lib/crates/fabro-config/src/tests/resolve_run.rs index 55535a7a8..15ab2dce9 100644 --- a/lib/crates/fabro-config/src/tests/resolve_run.rs +++ b/lib/crates/fabro-config/src/tests/resolve_run.rs @@ -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 diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index b7357e8f8..d8818d131 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -749,11 +749,14 @@ impl Sandbox for DaytonaSandbox { .unwrap_or_default() } - async fn setup_git_for_run(&self, run_id: &str) -> crate::Result> { + async fn setup_git( + &self, + intent: &crate::GitSetupIntent, + ) -> crate::Result> { 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 { @@ -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( diff --git a/lib/crates/fabro-sandbox/src/docker.rs b/lib/crates/fabro-sandbox/src/docker.rs index 5672fb489..7f08931f8 100644 --- a/lib/crates/fabro-sandbox/src/docker.rs +++ b/lib/crates/fabro-sandbox/src/docker.rs @@ -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> { + async fn setup_git( + &self, + intent: &crate::GitSetupIntent, + ) -> crate::Result> { 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 { @@ -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( diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs index 83945b081..fae5f8d03 100644 --- a/lib/crates/fabro-sandbox/src/lib.rs +++ b/lib/crates/fabro-sandbox/src/lib.rs @@ -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; diff --git a/lib/crates/fabro-sandbox/src/local.rs b/lib/crates/fabro-sandbox/src/local.rs index 2f3fd8719..ad97bb966 100644 --- a/lib/crates/fabro-sandbox/src/local.rs +++ b/lib/crates/fabro-sandbox/src/local.rs @@ -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(), diff --git a/lib/crates/fabro-sandbox/src/sandbox.rs b/lib/crates/fabro-sandbox/src/sandbox.rs index 2c23a05b1..29cb71d60 100644 --- a/lib/crates/fabro-sandbox/src/sandbox.rs +++ b/lib/crates/fabro-sandbox/src/sandbox.rs @@ -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, } +/// 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`. 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> { - self.$field.setup_git_for_run(run_id).await + async fn setup_git(&self, intent: &$crate::GitSetupIntent) -> $crate::Result> { + self.$field.setup_git(intent).await } fn resume_setup_commands(&self, run_branch: &str) -> Vec { 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> { + /// create a run branch and return the git info. + async fn setup_git(&self, _intent: &GitSetupIntent) -> crate::Result> { 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 { +pub async fn setup_git_via_exec( + sandbox: &dyn Sandbox, + intent: &GitSetupIntent, +) -> crate::Result { // 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 } } diff --git a/lib/crates/fabro-sandbox/src/sandbox_spec.rs b/lib/crates/fabro-sandbox/src/sandbox_spec.rs index dee38b8a2..bc594cbf4 100644 --- a/lib/crates/fabro-sandbox/src/sandbox_spec.rs +++ b/lib/crates/fabro-sandbox/src/sandbox_spec.rs @@ -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 { - 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, diff --git a/lib/crates/fabro-sandbox/src/worktree.rs b/lib/crates/fabro-sandbox/src/worktree.rs index 691a3f7db..6bee6cf8a 100644 --- a/lib/crates/fabro-sandbox/src/worktree.rs +++ b/lib/crates/fabro-sandbox/src/worktree.rs @@ -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> { - self.inner.setup_git_for_run(run_id).await + async fn setup_git( + &self, + intent: &crate::GitSetupIntent, + ) -> crate::Result> { + self.inner.setup_git(intent).await } fn resume_setup_commands(&self, run_branch: &str) -> Vec { 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( diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index c40840fe8..e75fe00be 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -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, diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 4f57d4e3d..5bd597589 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -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, - pub root_source: String, - pub run_id: Option, - 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, + pub root_source: String, + pub run_id: Option, + 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, 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(); diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index a95ab6c98..524a27658 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -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::>(), @@ -7936,7 +7938,8 @@ struct GraphParams { async fn load_run_dot_source(state: &AppState, id: &RunId) -> Result { 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 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 { - let working_directory = PathBuf::from(&props.working_directory); let labels = props.labels.clone().into_iter().collect::>(); 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 } })) diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index b5ebe48d6..8ed90c953 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -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, }), diff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs index 6ba15bbab..f99720559 100644 --- a/lib/crates/fabro-store/tests/serializable_projection.rs +++ b/lib/crates/fabro-store/tests/serializable_projection.rs @@ -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 { diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 55f98592d..f0a69b29b 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -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}; diff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs index 104547330..37170550a 100644 --- a/lib/crates/fabro-types/src/run.rs +++ b/lib/crates/fabro-types/src/run.rs @@ -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, } +#[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, + 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, - pub working_directory: PathBuf, + pub workflow_slug: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub host_repo_path: Option, + pub source_directory: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub repo_origin_url: Option, + pub repo_origin_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_branch: Option, + pub base_branch: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub labels: HashMap, + pub labels: HashMap, #[serde(default, skip_serializing_if = "Option::is_none")] - pub provenance: Option, + pub provenance: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub manifest_blob: Option, + pub manifest_blob: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub definition_blob: Option, + pub definition_blob: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pre_run_git: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork_source_ref: Option, + #[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() + } } diff --git a/lib/crates/fabro-types/src/run_event/infra.rs b/lib/crates/fabro-types/src/run_event/infra.rs index d4c40ce13..3d842b589 100644 --- a/lib/crates/fabro-types/src/run_event/infra.rs +++ b/lib/crates/fabro-types/src/run_event/infra.rs @@ -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, + pub identifier: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub host_working_directory: Option, + pub repo_cloned: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub container_mount_point: Option, + pub clone_origin_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub repo_cloned: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub clone_origin_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub clone_branch: Option, + pub clone_branch: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index 9b012b334..d036d429b 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -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() } }); diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs index d8bc96866..0927c7543 100644 --- a/lib/crates/fabro-types/src/run_event/run.rs +++ b/lib/crates/fabro-types/src/run_event/run.rs @@ -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, + pub workflow_source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow_config: Option, + pub workflow_config: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub labels: BTreeMap, - pub run_dir: String, - pub working_directory: String, + pub labels: BTreeMap, + pub run_dir: String, #[serde(default, skip_serializing_if = "Option::is_none")] - pub host_repo_path: Option, + pub source_directory: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub repo_origin_url: Option, + pub repo_origin_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_branch: Option, + pub base_branch: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub workflow_slug: Option, + pub workflow_slug: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub db_prefix: Option, + pub db_prefix: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub provenance: Option, + pub provenance: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub manifest_blob: Option, + pub manifest_blob: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pre_run_git: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork_source_ref: Option, + #[serde(default)] + pub checkpoints_disabled: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/lib/crates/fabro-types/src/run_summary.rs b/lib/crates/fabro-types/src/run_summary.rs index 7a0a1340b..06e5fd3fa 100644 --- a/lib/crates/fabro-types/src/run_summary.rs +++ b/lib/crates/fabro-types/src/run_summary.rs @@ -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, + pub workflow_name: Option, #[serde(default)] - pub workflow_slug: Option, - pub goal: String, - pub title: String, - pub labels: HashMap, + pub workflow_slug: Option, + pub goal: String, + pub title: String, + pub labels: HashMap, #[serde(default)] - pub host_repo_path: Option, - pub repository: RepositoryReference, + pub source_directory: Option, #[serde(default)] - pub start_time: Option>, - pub created_at: DateTime, - pub status: RunStatus, + pub checkpoints_disabled: bool, #[serde(default)] - pub pending_control: Option, + pub repo_origin_url: Option, + pub repository: RepositoryReference, #[serde(default)] - pub duration_ms: Option, + pub start_time: Option>, + pub created_at: DateTime, + pub status: RunStatus, #[serde(default)] - pub elapsed_secs: Option, + pub pending_control: Option, #[serde(default)] - pub total_usd_micros: Option, + pub duration_ms: Option, #[serde(default)] - pub superseded_by: Option, + pub elapsed_secs: Option, + #[serde(default)] + pub total_usd_micros: Option, + #[serde(default)] + pub superseded_by: Option, } impl RunSummary { @@ -46,7 +50,9 @@ impl RunSummary { workflow_slug: Option, goal: String, labels: HashMap, - host_repo_path: Option, + source_directory: Option, + checkpoints_disabled: bool, + repo_origin_url: Option, start_time: Option>, status: RunStatus, pending_control: Option, @@ -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 { + 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) -> Option { @@ -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"); + } } diff --git a/lib/crates/fabro-types/src/sandbox_record.rs b/lib/crates/fabro-types/src/sandbox_record.rs index f85aaf29c..117235112 100644 --- a/lib/crates/fabro-types/src/sandbox_record.rs +++ b/lib/crates/fabro-types/src/sandbox_record.rs @@ -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, + pub identifier: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub host_working_directory: Option, + pub repo_cloned: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub container_mount_point: Option, + pub clone_origin_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub repo_cloned: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub clone_origin_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub clone_branch: Option, + pub clone_branch: Option, } diff --git a/lib/crates/fabro-types/tests/run_event_serde.rs b/lib/crates/fabro-types/tests/run_event_serde.rs index 8077807c8..4dbb56070 100644 --- a/lib/crates/fabro-types/tests/run_event_serde.rs +++ b/lib/crates/fabro-types/tests/run_event_serde.rs @@ -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"); diff --git a/lib/crates/fabro-types/tests/run_spec_methods.rs b/lib/crates/fabro-types/tests/run_spec_methods.rs index 3d8078688..2a12c956f 100644 --- a/lib/crates/fabro-types/tests/run_spec_methods.rs +++ b/lib/crates/fabro-types/tests/run_spec_methods.rs @@ -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") diff --git a/lib/crates/fabro-types/tests/run_spec_serde.rs b/lib/crates/fabro-types/tests/run_spec_serde.rs index da1f5e2e0..9688a5172 100644 --- a/lib/crates/fabro-types/tests/run_spec_serde.rs +++ b/lib/crates/fabro-types/tests/run_spec_serde.rs @@ -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"); diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index bf7e8dafb..5488eb396 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -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, + workflow_source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - workflow_config: Option, - labels: BTreeMap, - run_dir: String, - working_directory: String, + workflow_config: Option, + labels: BTreeMap, + run_dir: String, #[serde(default, skip_serializing_if = "Option::is_none")] - host_repo_path: Option, + source_directory: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - repo_origin_url: Option, + repo_origin_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - base_branch: Option, + base_branch: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - workflow_slug: Option, + workflow_slug: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - db_prefix: Option, + db_prefix: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - provenance: Option, + provenance: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - manifest_blob: Option, + manifest_blob: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pre_run_git: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + fork_source_ref: Option, + #[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, + identifier: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - host_working_directory: Option, + repo_cloned: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - container_mount_point: Option, + clone_origin_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - repo_cloned: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - clone_origin_url: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - clone_branch: Option, + clone_branch: Option, }, 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); diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index a435de4d5..258560fb8 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -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)); - } } diff --git a/lib/crates/fabro-workflow/src/handler/manager_loop.rs b/lib/crates/fabro-workflow/src/handler/manager_loop.rs index 95463fb34..05eba3c87 100644 --- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs @@ -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 diff --git a/lib/crates/fabro-workflow/src/lib.rs b/lib/crates/fabro-workflow/src/lib.rs index 77483e746..52e51fda7 100644 --- a/lib/crates/fabro-workflow/src/lib.rs +++ b/lib/crates/fabro-workflow/src/lib.rs @@ -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; diff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs index 457fa9a91..2296c0bcd 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/git.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs @@ -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>; type WfNodeResult = NodeResult>; @@ -67,6 +66,7 @@ pub(crate) struct GitLifecycle { pub run_id: RunId, pub run_store: RunStoreHandle, pub run_options: Arc, + pub metadata_runtime: Arc, pub start_node_id: Option, // Cross-lifecycle data (shared with EventLifecycle) pub checkpoint_git_result: Arc>>, @@ -79,33 +79,24 @@ impl RunLifecycle 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 for GitLifecycle { return Ok(()); } - // Shadow commit (best-effort, metadata branch) - let shadow_sha: Option = 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 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 for GitLifecycle { Ok(()) } } + +impl GitLifecycle { + async fn write_metadata_snapshot(&self, dump: &RunDump, message: &str) -> Option { + 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, + }); + } + } +} diff --git a/lib/crates/fabro-workflow/src/lifecycle/mod.rs b/lib/crates/fabro-workflow/src/lifecycle/mod.rs index cae2cd322..ff6e1f503 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/mod.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/mod.rs @@ -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>; type WfNodeResult = NodeResult>; @@ -87,6 +88,7 @@ impl WorkflowLifecycle { run_store: &RunStoreHandle, artifact_sink: Option, run_options: &Arc, + metadata_runtime: Arc, is_resume: bool, on_node: crate::OnNodeCallback, run_control: Option>, @@ -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, diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 4d75e0961..90739dbc4 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -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, pub submitted_manifest_bytes: Option>, pub run_id: Option, - pub host_repo_path: Option, pub repo_origin_url: Option, pub base_branch: Option, + pub pre_run_git: Option, + pub fork_source_ref: Option, + pub checkpoints_disabled: bool, pub provenance: Option, pub configured_providers: Vec, } @@ -64,9 +65,11 @@ struct PersistCreateOptions { workflow_slug: Option, labels: HashMap, base_branch: Option, - working_directory: PathBuf, - host_repo_path: Option, + source_directory: Option, repo_origin_url: Option, + pre_run_git: Option, + fork_source_ref: Option, + checkpoints_disabled: bool, provenance: Option, configured_providers: Vec, } @@ -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::>(), 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(), diff --git a/lib/crates/fabro-workflow/src/operations/fork.rs b/lib/crates/fabro-workflow/src/operations/fork.rs index 8e88669bd..d8c9a09ec 100644 --- a/lib/crates/fabro-workflow/src/operations/fork.rs +++ b/lib/crates/fabro-workflow/src/operations/fork.rs @@ -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 { - 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 { +pub async fn fork_run( + store: &Database, + input: &ForkRunInput, +) -> std::result::Result { 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::>(); + 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 { - 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::(&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 { - 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 { - 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 = 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); - } -} diff --git a/lib/crates/fabro-workflow/src/operations/mod.rs b/lib/crates/fabro-workflow/src/operations/mod.rs index 368b1c4e9..abf9e5bbd 100644 --- a/lib/crates/fabro-workflow/src/operations/mod.rs +++ b/lib/crates/fabro-workflow/src/operations/mod.rs @@ -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}; diff --git a/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs b/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs deleted file mode 100644 index 13c118517..000000000 --- a/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs +++ /dev/null @@ -1,1053 +0,0 @@ -use std::collections::HashMap; -use std::fmt::Write; -use std::path::PathBuf; - -use anyhow::{Context, Result, bail}; -use fabro_checkpoint::branch::BranchStore; -use fabro_checkpoint::git::Store as GitStore; -use fabro_store::{ - Database as DurableStore, RunDatabase as DurableRunStore, RunProjection, RunProjectionReducer, -}; -use fabro_types::{EventBody, RunId}; -use git2::{Repository, Signature}; -use tokio::task::spawn_blocking; -use ulid::Ulid; - -use super::timeline::{ - RunTimeline, build_timeline, find_run_id_by_prefix_opt, run_commit_shas_by_node, -}; -use crate::git::MetadataStore; -use crate::records::Checkpoint; -use crate::run_dump::RunDump; - -pub async fn rebuild_metadata_branch( - git_store: &GitStore, - run_store: &DurableRunStore, - run_id: &RunId, -) -> Result<()> { - let branch = MetadataStore::branch_name(&run_id.to_string()); - if git_store.resolve_ref(&branch)?.is_some() { - bail!("metadata branch already exists for run {run_id}"); - } - - let events = run_store.list_events().await?; - if events.is_empty() { - bail!("run spec not found for {run_id}"); - } - - let sig = Signature::now("Fabro", "noreply@fabro.sh")?; - let scratch_branch = format!("fabro/meta-rebuild/{run_id}/{}", Ulid::new()); - let bs = BranchStore::new(git_store, &scratch_branch, &sig); - - let result = async { - bs.ensure_branch()?; - let mut projection = RunProjection::default(); - let mut latest_init_snapshot = None; - let mut init_written = false; - let mut checkpoint_snapshots: Vec<(u32, RunProjection)> = Vec::new(); - - for event in &events { - let stored = &event.event; - let is_checkpoint = matches!(stored.body, EventBody::CheckpointCompleted(_)); - - if !init_written && !is_checkpoint && projection.spec.is_some() { - latest_init_snapshot = Some(projection.clone()); - } - - projection.apply_event(event)?; - - if is_checkpoint { - if !init_written { - let init_snapshot = latest_init_snapshot.take().unwrap_or_else(|| { - let mut snapshot = projection.clone(); - snapshot.checkpoint = None; - snapshot.checkpoints.clear(); - snapshot - }); - write_projection_snapshot(&bs, &init_snapshot, "init run")?; - init_written = true; - } - checkpoint_snapshots.push((event.seq, projection.clone())); - } - } - - if projection.spec.is_none() { - bail!("run spec not found for {run_id}"); - } - - if !init_written { - write_projection_snapshot(&bs, &projection, "init run")?; - } - - let mut checkpoints: Vec<(u32, Checkpoint)> = checkpoint_snapshots - .iter() - .map(|(seq, snapshot)| { - let checkpoint = snapshot - .checkpoint - .clone() - .expect("checkpoint snapshots must include projection.checkpoint"); - (*seq, checkpoint) - }) - .collect(); - backfill_missing_checkpoint_shas(git_store, run_id, &mut checkpoints); - - for ((_, snapshot), (_, checkpoint)) in checkpoint_snapshots.iter_mut().zip(checkpoints) { - snapshot.checkpoint = Some(checkpoint); - write_projection_snapshot(&bs, snapshot, "checkpoint")?; - } - - if projection.conclusion.is_some() - || projection.retro.is_some() - || projection.retro_prompt.is_some() - || projection.retro_response.is_some() - { - write_projection_snapshot(&bs, &projection, "finalize run")?; - } - - Ok::<(), anyhow::Error>(()) - } - .await; - - let final_result = match result { - Ok(()) => { - let scratch_tip = git_store - .resolve_ref(&scratch_branch)? - .ok_or_else(|| anyhow::anyhow!("scratch metadata branch missing after rebuild"))?; - git_store.update_ref(&branch, scratch_tip)?; - Ok(()) - } - Err(err) => Err(err), - }; - - let _ = git_store.delete_ref(&scratch_branch); - final_result -} - -pub async fn build_timeline_or_rebuild( - git_store: &GitStore, - run_store: Option<&DurableRunStore>, - run_id: &RunId, -) -> Result { - let branch = MetadataStore::branch_name(&run_id.to_string()); - if git_store.resolve_ref(&branch)?.is_some() { - return build_timeline(git_store, &run_id.to_string()); - } - - if let Some(run_store) = run_store { - rebuild_metadata_branch(git_store, run_store, run_id).await?; - return build_timeline(git_store, &run_id.to_string()); - } - - Ok(RunTimeline { - entries: Vec::new(), - parallel_map: HashMap::new(), - }) -} - -pub async fn find_run_id_by_prefix_or_store( - repo: &Repository, - fabro_store: &DurableStore, - prefix: &str, -) -> Result { - if let Some(run_id) = find_run_id_by_prefix_opt(repo, prefix)? { - return Ok(run_id); - } - - let current_repo_root = { - let repo_root = repo_root_path(repo); - spawn_blocking(move || canonical_repo_root(&repo_root)) - .await - .map_err(|err| anyhow::anyhow!("repo root canonicalize task failed: {err}"))?? - }; - let mut matches = Vec::new(); - for summary in fabro_store - .list_runs(&fabro_store::ListRunsQuery::default()) - .await? - { - if summary.run_id.to_string() == prefix { - if summary.host_repo_path.is_none() { - return Ok(summary.run_id); - } - - let Some(host_repo_path) = summary.host_repo_path.as_deref() else { - continue; - }; - let host_repo_path = host_repo_path.to_string(); - let host_repo_root = - match spawn_blocking(move || canonical_repo_root_for_path(&host_repo_path)).await { - Ok(Ok(root)) => root, - Ok(Err(_)) => continue, - Err(err) => { - return Err(anyhow::anyhow!("host repo canonicalize task failed: {err}")); - } - }; - if host_repo_root == current_repo_root { - return Ok(summary.run_id); - } - continue; - } - - let Some(host_repo_path) = summary.host_repo_path.as_deref() else { - continue; - }; - let host_repo_path = host_repo_path.to_string(); - let host_repo_root = - match spawn_blocking(move || canonical_repo_root_for_path(&host_repo_path)).await { - Ok(Ok(root)) => root, - Ok(Err(_)) => continue, - Err(err) => { - return Err(anyhow::anyhow!("host repo canonicalize task failed: {err}")); - } - }; - if host_repo_root == current_repo_root && summary.run_id.to_string().starts_with(prefix) { - matches.push(summary.run_id); - } - } - - resolve_prefix_matches(prefix, matches) -} - -fn write_entries( - branch_store: &BranchStore<'_>, - entries: &[(String, Vec)], - message: &str, -) -> Result<()> { - let refs: Vec<(&str, &[u8])> = entries - .iter() - .map(|(path, bytes)| (path.as_str(), bytes.as_slice())) - .collect(); - branch_store.write_entries(&refs, message)?; - Ok(()) -} - -fn write_projection_snapshot( - branch_store: &BranchStore<'_>, - projection: &RunProjection, - message: &str, -) -> Result<()> { - let entries = RunDump::from_projection(projection) - .git_entries() - .context("failed to serialize metadata projection snapshot")?; - write_entries(branch_store, &entries, message) -} - -fn backfill_missing_checkpoint_shas( - git_store: &GitStore, - run_id: &RunId, - checkpoints: &mut [(u32, Checkpoint)], -) { - if !checkpoints - .iter() - .any(|(_, checkpoint)| checkpoint.git_commit_sha.is_none()) - { - return; - } - - let node_commits = run_commit_shas_by_node(git_store, &run_id.to_string()); - let mut node_indices: HashMap = HashMap::new(); - - for (_seq, checkpoint) in checkpoints.iter_mut() { - if checkpoint.git_commit_sha.is_some() { - continue; - } - - if let Some(shas) = node_commits.get(&checkpoint.current_node) { - let idx = node_indices - .entry(checkpoint.current_node.clone()) - .or_insert(0); - if *idx < shas.len() { - checkpoint.git_commit_sha = Some(shas[*idx].clone()); - *idx += 1; - } - } - } -} - -fn repo_root_path(repo: &Repository) -> PathBuf { - repo.workdir() - .or_else(|| repo.path().parent()) - .unwrap_or(repo.path()) - .to_path_buf() -} - -#[expect( - clippy::disallowed_methods, - reason = "sync repo-root canonicalize helper; async callers wrap it in spawn_blocking" -)] -fn canonical_repo_root(root: &std::path::Path) -> Result { - std::fs::canonicalize(root) - .with_context(|| format!("failed to canonicalize repo root {}", root.display())) -} - -fn canonical_repo_root_for_path(path: &str) -> Result { - let repo = Repository::discover(path)?; - let root = repo_root_path(&repo); - canonical_repo_root(&root) -} - -fn resolve_prefix_matches(prefix: &str, matches: Vec) -> Result { - match matches.len() { - 0 => bail!("no run found matching '{prefix}'"), - 1 => Ok(matches - .into_iter() - .next() - .expect("exactly one run should match when len is 1")), - _ => { - let mut msg = format!("ambiguous run ID prefix '{prefix}', matches:\n"); - for run_id in &matches { - let _ = writeln!(msg, " {run_id}"); - } - bail!("{msg}") - } - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::path::PathBuf; - use std::sync::Arc; - use std::time::Duration; - - use chrono::{TimeZone, Utc}; - use fabro_graphviz::graph::Graph; - use fabro_store::{Database, RunProjection, StageId}; - use fabro_types::{RunId, RunSpec, SandboxRecord, StartRecord, WorkflowSettings, fixtures}; - use object_store::memory::InMemory; - - use super::*; - use crate::event::{Event, append_event}; - use crate::operations::test_support::{temp_repo, test_sig}; - use crate::records::Checkpoint; - - fn created_at() -> chrono::DateTime { - Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap() - } - - fn parse_run_id(value: &str) -> RunId { - value.parse().unwrap() - } - - fn test_run_id() -> RunId { - fixtures::RUN_1 - } - - fn memory_store() -> Arc { - Arc::new(Database::new( - Arc::new(InMemory::new()), - "", - Duration::from_millis(1), - None, - )) - } - - fn sample_run_spec(run_id: RunId, host_repo_path: Option<&str>) -> RunSpec { - RunSpec { - run_id, - settings: WorkflowSettings::default(), - graph: Graph::new("test"), - workflow_slug: None, - working_directory: PathBuf::from("/tmp/project"), - host_repo_path: host_repo_path.map(ToOwned::to_owned), - repo_origin_url: None, - base_branch: None, - labels: HashMap::new(), - provenance: None, - manifest_blob: None, - definition_blob: None, - } - } - - fn sample_start_record(run_id: RunId) -> StartRecord { - StartRecord { - run_id, - start_time: created_at(), - run_branch: Some(format!("fabro/run/{run_id}")), - base_sha: Some("base-sha".to_string()), - } - } - - fn sample_sandbox_record() -> SandboxRecord { - SandboxRecord { - provider: "local".to_string(), - working_directory: "/tmp/project".to_string(), - identifier: None, - host_working_directory: None, - container_mount_point: None, - repo_cloned: None, - clone_origin_url: None, - clone_branch: None, - } - } - - fn sample_checkpoint( - current_node: &str, - completed_nodes: &[&str], - node_visits: &[(&str, usize)], - git_commit_sha: Option<&str>, - ) -> Checkpoint { - Checkpoint { - timestamp: created_at(), - current_node: current_node.to_string(), - completed_nodes: completed_nodes - .iter() - .map(|node| (*node).to_string()) - .collect(), - 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: node_visits - .iter() - .map(|(node, visit)| ((*node).to_string(), *visit)) - .collect(), - } - } - - async fn create_run_store( - store: &Database, - run_id: RunId, - host_repo_path: Option<&str>, - ) -> DurableRunStore { - let run_store = store.create_run(&run_id).await.unwrap(); - let run_spec = sample_run_spec(run_id, host_repo_path); - append_event(&run_store, &run_id, &Event::RunCreated { - 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: String::new(), - 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, - }) - .await - .unwrap(); - run_store - } - - async fn append_start_event(run_store: &DurableRunStore, run_id: RunId) { - let start = sample_start_record(run_id); - append_event(run_store, &run_id, &Event::WorkflowRunStarted { - name: "test".to_string(), - run_id, - base_branch: None, - base_sha: start.base_sha, - run_branch: start.run_branch, - worktree_dir: None, - goal: None, - }) - .await - .unwrap(); - } - - async fn append_sandbox_event(run_store: &DurableRunStore, run_id: RunId) { - let sandbox = sample_sandbox_record(); - append_event(run_store, &run_id, &Event::SandboxInitialized { - provider: sandbox.provider, - working_directory: sandbox.working_directory, - identifier: sandbox.identifier, - host_working_directory: sandbox.host_working_directory, - container_mount_point: sandbox.container_mount_point, - repo_cloned: sandbox.repo_cloned, - clone_origin_url: sandbox.clone_origin_url, - clone_branch: sandbox.clone_branch, - }) - .await - .unwrap(); - } - - async fn append_checkpoint_event( - run_store: &DurableRunStore, - run_id: RunId, - checkpoint: Checkpoint, - ) { - append_event(run_store, &run_id, &Event::CheckpointCompleted { - node_id: checkpoint.current_node.clone(), - status: "success".to_string(), - current_node: checkpoint.current_node.clone(), - completed_nodes: checkpoint.completed_nodes.clone(), - node_retries: checkpoint.node_retries.clone().into_iter().collect(), - context_values: checkpoint.context_values.clone().into_iter().collect(), - node_outcomes: checkpoint.node_outcomes.clone().into_iter().collect(), - next_node_id: checkpoint.next_node_id.clone(), - git_commit_sha: checkpoint.git_commit_sha.clone(), - loop_failure_signatures: checkpoint - .loop_failure_signatures - .clone() - .into_iter() - .map(|(signature, count)| (signature.to_string(), count)) - .collect(), - restart_failure_signatures: checkpoint - .restart_failure_signatures - .clone() - .into_iter() - .map(|(signature, count)| (signature.to_string(), count)) - .collect(), - node_visits: checkpoint.node_visits.clone().into_iter().collect(), - diff: None, - }) - .await - .unwrap(); - } - - async fn append_prompt_event( - run_store: &DurableRunStore, - run_id: RunId, - node: &StageId, - text: &str, - ) { - append_event(run_store, &run_id, &Event::Prompt { - stage: node.node_id().to_string(), - visit: node.visit(), - text: text.to_string(), - mode: None, - provider: None, - model: None, - }) - .await - .unwrap(); - } - - fn seed_run_branch(git_store: &GitStore, run_id: RunId, nodes: &[&str]) -> Vec { - let sig = test_sig(); - let run_branch = format!("fabro/run/{run_id}"); - let empty_tree = git_store.write_empty_tree().unwrap(); - let mut shas = Vec::new(); - let mut parent = None; - - for node in nodes { - let parents = parent.into_iter().collect::>(); - let oid = git_store - .write_commit( - empty_tree, - &parents, - &format!("fabro({run_id}): {node} (completed)"), - &sig, - ) - .unwrap(); - git_store.update_ref(&run_branch, oid).unwrap(); - shas.push(oid.to_string()); - parent = Some(oid); - } - - shas - } - - #[tokio::test] - async fn rebuild_metadata_branch_round_trips_timeline() { - let (_dir, git_store) = temp_repo(); - let durable_store = memory_store(); - let run_store = create_run_store(&durable_store, test_run_id(), None).await; - append_start_event(&run_store, test_run_id()).await; - append_sandbox_event(&run_store, test_run_id()).await; - - append_checkpoint_event( - &run_store, - test_run_id(), - sample_checkpoint("start", &["start"], &[("start", 1)], Some("aaa")), - ) - .await; - append_checkpoint_event( - &run_store, - test_run_id(), - sample_checkpoint( - "build", - &["start", "build"], - &[("start", 1), ("build", 1)], - Some("bbb"), - ), - ) - .await; - append_checkpoint_event( - &run_store, - test_run_id(), - sample_checkpoint( - "build", - &["start", "build"], - &[("start", 1), ("build", 2)], - Some("ccc"), - ), - ) - .await; - - rebuild_metadata_branch(&git_store, &run_store, &test_run_id()) - .await - .unwrap(); - - let timeline = build_timeline(&git_store, &test_run_id().to_string()).unwrap(); - assert_eq!(timeline.entries.len(), 3); - assert_eq!(timeline.entries[0].node_name, "start"); - assert_eq!(timeline.entries[0].visit, 1); - assert_eq!(timeline.entries[0].run_commit_sha.as_deref(), Some("aaa")); - assert_eq!(timeline.entries[1].node_name, "build"); - assert_eq!(timeline.entries[1].visit, 1); - assert_eq!(timeline.entries[1].run_commit_sha.as_deref(), Some("bbb")); - assert_eq!(timeline.entries[2].node_name, "build"); - assert_eq!(timeline.entries[2].visit, 2); - assert_eq!(timeline.entries[2].run_commit_sha.as_deref(), Some("ccc")); - } - - #[tokio::test] - async fn rebuild_metadata_branch_preserves_historical_node_visits() { - let (_dir, git_store) = temp_repo(); - let durable_store = memory_store(); - let run_store = create_run_store(&durable_store, test_run_id(), None).await; - - let build_v1 = StageId::new("build", 1); - append_prompt_event(&run_store, test_run_id(), &build_v1, "visit one").await; - - let build_v2 = StageId::new("build", 2); - append_prompt_event(&run_store, test_run_id(), &build_v2, "visit two").await; - - append_checkpoint_event( - &run_store, - test_run_id(), - sample_checkpoint("build", &["build"], &[("build", 1)], Some("aaa")), - ) - .await; - append_checkpoint_event( - &run_store, - test_run_id(), - sample_checkpoint("build", &["build"], &[("build", 2)], Some("bbb")), - ) - .await; - - rebuild_metadata_branch(&git_store, &run_store, &test_run_id()) - .await - .unwrap(); - - let sig = test_sig(); - let branch = MetadataStore::branch_name(&test_run_id().to_string()); - let bs = BranchStore::new(&git_store, &branch, &sig); - let checkpoint_commits: Vec<_> = bs - .log(100) - .unwrap() - .iter() - .rev() - .filter(|commit| commit.message.starts_with("checkpoint")) - .map(|commit| commit.oid) - .collect(); - - assert_eq!(checkpoint_commits.len(), 2); - assert_eq!( - git_store - .read_blob_at(checkpoint_commits[0], "stages/build@1/prompt.md") - .unwrap() - .as_deref(), - Some("visit one".as_bytes()) - ); - let first_projection: RunProjection = serde_json::from_slice( - &git_store - .read_blob_at(checkpoint_commits[0], "run.json") - .unwrap() - .unwrap(), - ) - .unwrap(); - assert_eq!( - first_projection - .checkpoint - .as_ref() - .and_then(|checkpoint| checkpoint.node_visits.get("build")) - .copied(), - Some(1) - ); - assert_eq!( - git_store - .read_blob_at(checkpoint_commits[1], "stages/build@1/prompt.md") - .unwrap() - .as_deref(), - Some("visit one".as_bytes()) - ); - assert_eq!( - git_store - .read_blob_at(checkpoint_commits[1], "stages/build@2/prompt.md") - .unwrap() - .as_deref(), - Some("visit two".as_bytes()) - ); - let second_projection: RunProjection = serde_json::from_slice( - &git_store - .read_blob_at(checkpoint_commits[1], "run.json") - .unwrap() - .unwrap(), - ) - .unwrap(); - assert_eq!( - second_projection - .checkpoint - .as_ref() - .and_then(|checkpoint| checkpoint.node_visits.get("build")) - .copied(), - Some(2) - ); - } - - #[tokio::test] - async fn rebuild_metadata_branch_refuses_to_overwrite_existing_branch() { - let (_dir, git_store) = temp_repo(); - let durable_store = memory_store(); - let run_store = create_run_store(&durable_store, test_run_id(), None).await; - - let sig = test_sig(); - let branch = MetadataStore::branch_name(&test_run_id().to_string()); - let bs = BranchStore::new(&git_store, &branch, &sig); - bs.ensure_branch().unwrap(); - bs.write_entry("run.json", b"{}", "init run").unwrap(); - - let err = rebuild_metadata_branch(&git_store, &run_store, &test_run_id()) - .await - .unwrap_err(); - assert!(err.to_string().contains("metadata branch already exists")); - } - - #[tokio::test] - async fn build_timeline_or_rebuild_rebuilds_missing_branch() { - let (_dir, git_store) = temp_repo(); - let durable_store = memory_store(); - let run_store = create_run_store(&durable_store, test_run_id(), None).await; - append_checkpoint_event( - &run_store, - test_run_id(), - sample_checkpoint("start", &["start"], &[("start", 1)], Some("aaa")), - ) - .await; - - let timeline = build_timeline_or_rebuild(&git_store, Some(&run_store), &test_run_id()) - .await - .unwrap(); - - assert_eq!(timeline.entries.len(), 1); - assert_eq!(timeline.entries[0].node_name, "start"); - } - - #[tokio::test] - async fn build_timeline_or_rebuild_preserves_existing_branch() { - let (_dir, git_store) = temp_repo(); - let durable_store = memory_store(); - let run_store = create_run_store(&durable_store, test_run_id(), None).await; - append_checkpoint_event( - &run_store, - test_run_id(), - sample_checkpoint("start", &["start"], &[("start", 1)], Some("aaa")), - ) - .await; - append_checkpoint_event( - &run_store, - test_run_id(), - sample_checkpoint( - "build", - &["start", "build"], - &[("start", 1), ("build", 1)], - Some("bbb"), - ), - ) - .await; - append_checkpoint_event( - &run_store, - test_run_id(), - sample_checkpoint( - "test", - &["start", "build", "test"], - &[("start", 1), ("build", 1), ("test", 1)], - Some("ccc"), - ), - ) - .await; - - let sig = test_sig(); - let branch = MetadataStore::branch_name(&test_run_id().to_string()); - let bs = BranchStore::new(&git_store, &branch, &sig); - bs.ensure_branch().unwrap(); - let mut init_projection = RunProjection::default(); - init_projection.spec = Some(sample_run_spec(test_run_id(), None)); - init_projection.start = Some(sample_start_record(test_run_id())); - bs.write_entry( - "run.json", - &serde_json::to_vec_pretty(&init_projection).unwrap(), - "init run", - ) - .unwrap(); - - let mut first_checkpoint_projection = init_projection.clone(); - first_checkpoint_projection.checkpoint = Some(sample_checkpoint( - "start", - &["start"], - &[("start", 1)], - Some("aaa"), - )); - bs.write_entry( - "run.json", - &serde_json::to_vec_pretty(&first_checkpoint_projection).unwrap(), - "checkpoint", - ) - .unwrap(); - - let mut second_checkpoint_projection = init_projection; - second_checkpoint_projection.checkpoint = Some(sample_checkpoint( - "build", - &["start", "build"], - &[("start", 1), ("build", 1)], - Some("bbb"), - )); - bs.write_entry( - "run.json", - &serde_json::to_vec_pretty(&second_checkpoint_projection).unwrap(), - "checkpoint", - ) - .unwrap(); - - let timeline = build_timeline_or_rebuild(&git_store, Some(&run_store), &test_run_id()) - .await - .unwrap(); - - assert_eq!(timeline.entries.len(), 2); - assert_eq!(timeline.entries[0].node_name, "start"); - assert_eq!(timeline.entries[1].node_name, "build"); - } - - #[tokio::test] - async fn build_timeline_or_rebuild_returns_empty_without_store() { - let (_dir, git_store) = temp_repo(); - let timeline = build_timeline_or_rebuild(&git_store, None, &test_run_id()) - .await - .unwrap(); - assert!(timeline.entries.is_empty()); - assert!(timeline.parallel_map.is_empty()); - } - - #[tokio::test] - async fn rebuild_metadata_branch_errors_when_run_spec_is_missing() { - let (_dir, git_store) = temp_repo(); - let durable_store = memory_store(); - let run_store = durable_store.create_run(&test_run_id()).await.unwrap(); - - let err = rebuild_metadata_branch(&git_store, &run_store, &test_run_id()) - .await - .unwrap_err(); - assert!(err.to_string().contains("run spec not found")); - } - - #[tokio::test] - async fn find_run_id_by_prefix_or_store_falls_back_to_store() { - let (dir, git_store) = temp_repo(); - let durable_store = memory_store(); - let repo_path = dir.path().to_string_lossy().to_string(); - let repo_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV"); - let _run_store = create_run_store(&durable_store, repo_run_id, Some(&repo_path)).await; - let prefix = &repo_run_id.to_string()[..6]; - - let run_id = find_run_id_by_prefix_or_store(git_store.repo(), &durable_store, prefix) - .await - .unwrap(); - - assert_eq!(run_id, repo_run_id); - } - - #[tokio::test] - async fn find_run_id_by_prefix_or_store_excludes_other_repos() { - let (_dir, git_store) = temp_repo(); - let (other_dir, _other_git_store) = temp_repo(); - let durable_store = memory_store(); - let other_repo_path = other_dir.path().to_string_lossy().to_string(); - let other_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV"); - let _run_store = - create_run_store(&durable_store, other_run_id, Some(&other_repo_path)).await; - let prefix = &other_run_id.to_string()[..6]; - - let err = find_run_id_by_prefix_or_store(git_store.repo(), &durable_store, prefix) - .await - .unwrap_err(); - - assert!(err.to_string().contains("no run found matching")); - } - - #[tokio::test] - async fn find_run_id_by_prefix_or_store_requires_exact_match_without_repo_path() { - let (_dir, git_store) = temp_repo(); - let durable_store = memory_store(); - let repo_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV"); - let _run_store = create_run_store(&durable_store, repo_run_id, None).await; - let prefix = &repo_run_id.to_string()[..6]; - - let prefix_err = find_run_id_by_prefix_or_store(git_store.repo(), &durable_store, prefix) - .await - .unwrap_err(); - assert!(prefix_err.to_string().contains("no run found matching")); - - let exact = find_run_id_by_prefix_or_store( - git_store.repo(), - &durable_store, - &repo_run_id.to_string(), - ) - .await - .unwrap(); - assert_eq!(exact, repo_run_id); - } - - #[tokio::test] - async fn exact_match_wins_over_prefix_ambiguity() { - let (_dir, git_store) = temp_repo(); - let durable_store = memory_store(); - let repo_path = git_store.repo_dir().to_string_lossy().to_string(); - let exact_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV"); - let other_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAW"); - let _exact = create_run_store(&durable_store, exact_run_id, Some(&repo_path)).await; - let _other = create_run_store(&durable_store, other_run_id, Some(&repo_path)).await; - - let from_store = find_run_id_by_prefix_or_store( - git_store.repo(), - &durable_store, - &exact_run_id.to_string(), - ) - .await - .unwrap(); - assert_eq!(from_store, exact_run_id); - - let sig = test_sig(); - let exact_branch = BranchStore::new( - &git_store, - &MetadataStore::branch_name(&exact_run_id.to_string()), - &sig, - ); - exact_branch.ensure_branch().unwrap(); - - let other_branch = BranchStore::new( - &git_store, - &MetadataStore::branch_name(&other_run_id.to_string()), - &sig, - ); - other_branch.ensure_branch().unwrap(); - - let from_refs = find_run_id_by_prefix_or_store( - git_store.repo(), - &durable_store, - &exact_run_id.to_string(), - ) - .await - .unwrap(); - assert_eq!(from_refs, exact_run_id); - } - - #[tokio::test] - async fn rebuild_metadata_branch_persists_backfilled_run_shas_in_checkpoint_blobs() { - let (_dir, git_store) = temp_repo(); - let durable_store = memory_store(); - let run_store = create_run_store(&durable_store, test_run_id(), None).await; - - append_checkpoint_event( - &run_store, - test_run_id(), - sample_checkpoint("start", &["start"], &[("start", 1)], None), - ) - .await; - append_checkpoint_event( - &run_store, - test_run_id(), - sample_checkpoint( - "build", - &["start", "build"], - &[("start", 1), ("build", 1)], - None, - ), - ) - .await; - - let expected_shas = seed_run_branch(&git_store, test_run_id(), &["start", "build"]); - - rebuild_metadata_branch(&git_store, &run_store, &test_run_id()) - .await - .unwrap(); - - let sig = test_sig(); - let branch = MetadataStore::branch_name(&test_run_id().to_string()); - let bs = BranchStore::new(&git_store, &branch, &sig); - let checkpoint_commits: Vec<_> = bs - .log(100) - .unwrap() - .iter() - .rev() - .filter(|commit| commit.message.starts_with("checkpoint")) - .map(|commit| commit.oid) - .collect(); - - let first_projection: RunProjection = serde_json::from_slice( - &git_store - .read_blob_at(checkpoint_commits[0], "run.json") - .unwrap() - .unwrap(), - ) - .unwrap(); - let second_projection: RunProjection = serde_json::from_slice( - &git_store - .read_blob_at(checkpoint_commits[1], "run.json") - .unwrap() - .unwrap(), - ) - .unwrap(); - let first = first_projection.checkpoint.unwrap(); - let second = second_projection.checkpoint.unwrap(); - - assert_eq!( - first.git_commit_sha.as_deref(), - Some(expected_shas[0].as_str()) - ); - assert_eq!( - second.git_commit_sha.as_deref(), - Some(expected_shas[1].as_str()) - ); - } - - #[tokio::test] - async fn rebuild_metadata_branch_is_atomic_on_failure() { - let (_dir, git_store) = temp_repo(); - let durable_store = memory_store(); - let run_store = create_run_store(&durable_store, test_run_id(), None).await; - - let bad_node = "bad\0node"; - let bad_visit = StageId::new(bad_node, 1); - append_prompt_event(&run_store, test_run_id(), &bad_visit, "prompt").await; - append_checkpoint_event( - &run_store, - test_run_id(), - sample_checkpoint(bad_node, &[bad_node], &[(bad_node, 1)], None), - ) - .await; - - let err = rebuild_metadata_branch(&git_store, &run_store, &test_run_id()) - .await - .unwrap_err(); - assert!(err.to_string().contains("nul") || err.to_string().contains("NUL")); - assert!( - git_store - .resolve_ref(&MetadataStore::branch_name(&test_run_id().to_string())) - .unwrap() - .is_none() - ); - - let scratch_refs: Vec<_> = git_store - .repo() - .references() - .unwrap() - .flatten() - .filter_map(|reference| reference.name().map(ToOwned::to_owned)) - .filter(|name| { - name.starts_with(&format!("refs/heads/fabro/meta-rebuild/{}/", test_run_id())) - }) - .collect(); - assert!( - scratch_refs.is_empty(), - "leftover scratch refs: {scratch_refs:?}" - ); - } -} diff --git a/lib/crates/fabro-workflow/src/operations/rewind.rs b/lib/crates/fabro-workflow/src/operations/rewind.rs index 69883b4d4..d83b1a6ec 100644 --- a/lib/crates/fabro-workflow/src/operations/rewind.rs +++ b/lib/crates/fabro-workflow/src/operations/rewind.rs @@ -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, ) -> Result { - 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 { diff --git a/lib/crates/fabro-workflow/src/operations/run_git.rs b/lib/crates/fabro-workflow/src/operations/run_git.rs deleted file mode 100644 index b35c7c252..000000000 --- a/lib/crates/fabro-workflow/src/operations/run_git.rs +++ /dev/null @@ -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 { - 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( - store: &Database, - run_id: RunId, - operation: impl FnOnce(GitStore) -> Result + Send + 'static, -) -> Result -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}")))? -} diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 0441b5a32..74ed34682 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -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 { 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>> = 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(), }, diff --git a/lib/crates/fabro-workflow/src/operations/test_support.rs b/lib/crates/fabro-workflow/src/operations/test_support.rs deleted file mode 100644 index 1065d20b8..000000000 --- a/lib/crates/fabro-workflow/src/operations/test_support.rs +++ /dev/null @@ -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 { - 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() -} diff --git a/lib/crates/fabro-workflow/src/operations/timeline.rs b/lib/crates/fabro-workflow/src/operations/timeline.rs index 71331cf87..dd759d9da 100644 --- a/lib/crates/fabro-workflow/src/operations/timeline.rs +++ b/lib/crates/fabro-workflow/src/operations/timeline.rs @@ -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, + pub ordinal: usize, + pub node_name: String, + pub visit: usize, + pub checkpoint_seq: u32, + pub run_commit_sha: Option, } #[derive(Debug, Clone)] @@ -115,112 +108,42 @@ impl RunTimeline { } } -pub fn build_timeline(store: &Store, run_id: &str) -> Result { - 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 { + 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, 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 = 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> { - 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> = 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 { @@ -257,103 +180,51 @@ fn detect_parallel_interior(graph: &Graph) -> HashMap { interior_map } -pub fn find_run_id_by_prefix(repo: &Repository, prefix: &str) -> Result { - 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> { - 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::() 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 { - 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 { + 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> { - 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 { - 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); - } } diff --git a/lib/crates/fabro-workflow/src/pipeline/execute.rs b/lib/crates/fabro-workflow/src/pipeline/execute.rs index b6b045a80..52a4e3007 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index e8a97bd94..66b9e0a4f 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -90,17 +90,19 @@ fn test_emitter_arc(label: &str) -> Arc { 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, }, ) } diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index cee2fb1c8..59e1f6735 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -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 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, } } diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index c8b10b384..15cd48201 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -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, Error> { +fn resolve_worktree_plan(options: &mut InitOptions) -> Option { + 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 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, }, ) } diff --git a/lib/crates/fabro-workflow/src/pipeline/persist.rs b/lib/crates/fabro-workflow/src/pipeline/persist.rs index 604bd05a4..7094efec1 100644 --- a/lib/crates/fabro-workflow/src/pipeline/persist.rs +++ b/lib/crates/fabro-workflow/src/pipeline/persist.rs @@ -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); diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index c340cfbe1..d6842645b 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -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(); diff --git a/lib/crates/fabro-workflow/src/pipeline/retro.rs b/lib/crates/fabro-workflow/src/pipeline/retro.rs index b21975b10..86b1e6789 100644 --- a/lib/crates/fabro-workflow/src/pipeline/retro.rs +++ b/lib/crates/fabro-workflow/src/pipeline/retro.rs @@ -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, } } diff --git a/lib/crates/fabro-workflow/src/run_dump.rs b/lib/crates/fabro-workflow/src/run_dump.rs index 66b91ce28..7b20e8581 100644 --- a/lib/crates/fabro-workflow/src/run_dump.rs +++ b/lib/crates/fabro-workflow/src/run_dump.rs @@ -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, @@ -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)>> { 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()); diff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/crates/fabro-workflow/src/run_lookup.rs index c28096f5a..cc6dc48a9 100644 --- a/lib/crates/fabro-workflow/src/run_lookup.rs +++ b/lib/crates/fabro-workflow/src/run_lookup.rs @@ -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 count if count > 1 => { let ids: Vec = 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(); diff --git a/lib/crates/fabro-workflow/src/run_options.rs b/lib/crates/fabro-workflow/src/run_options.rs index bdab44985..48ca2f2cf 100644 --- a/lib/crates/fabro-workflow/src/run_options.rs +++ b/lib/crates/fabro-workflow/src/run_options.rs @@ -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>, + pub settings: WorkflowSettings, + pub run_dir: PathBuf, + pub cancel_token: Option>, /// 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, + pub labels: HashMap, /// Workflow directory slug (e.g. "smoke" from `.fabro/workflows/smoke/`). - pub workflow_slug: Option, + pub workflow_slug: Option, /// GitHub credentials for pushing metadata branches to origin. - pub github_app: Option, - /// Host repo path for MetadataStore (shadow commits) and host-side pushes. - pub host_repo_path: Option, + pub github_app: Option, + /// Submitter-side git context captured before the run was created. + pub pre_run_git: Option, + /// Source checkpoint ref used by fork/rewind-created runs. + pub fork_source_ref: Option, + /// 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, + pub base_branch: Option, /// Base commit SHA to display in lifecycle events/UI even when /// checkpointing is disabled. - pub display_base_sha: Option, + pub display_base_sha: Option, /// Git checkpoint options; `None` means checkpointing disabled. - pub git: Option, + pub git: Option, } impl RunOptions { diff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/crates/fabro-workflow/src/runtime_store.rs index bdec544fc..f8aba75f6 100644 --- a/lib/crates/fabro-workflow/src/runtime_store.rs +++ b/lib/crates/fabro-workflow/src/runtime_store.rs @@ -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(); diff --git a/lib/crates/fabro-workflow/src/sandbox_git.rs b/lib/crates/fabro-workflow/src/sandbox_git.rs index 93ad1c9e2..4d38920ad 100644 --- a/lib/crates/fabro-workflow/src/sandbox_git.rs +++ b/lib/crates/fabro-workflow/src/sandbox_git.rs @@ -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, - 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(); diff --git a/lib/crates/fabro-workflow/src/sandbox_metadata.rs b/lib/crates/fabro-workflow/src/sandbox_metadata.rs new file mode 100644 index 000000000..3f1b0f6fe --- /dev/null +++ b/lib/crates/fabro-workflow/src/sandbox_metadata.rs @@ -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>, + 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 { + 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)], + message: &str, + temp: &str, + index: &str, + ) -> Result { + 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, + ) -> Result, 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 { + 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>, +) -> Result { + 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>, +) -> 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(), + ) +} diff --git a/lib/crates/fabro-workflow/src/services.rs b/lib/crates/fabro-workflow/src/services.rs index 90c968935..6c4d4db63 100644 --- a/lib/crates/fabro-workflow/src/services.rs +++ b/lib/crates/fabro-workflow/src/services.rs @@ -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, - pub sandbox: Arc, - pub hook_runner: Option>, - pub cancel_requested: Option>, - pub provider: Provider, - pub llm_source: Arc, + pub run_store: RunStoreHandle, + pub emitter: Arc, + pub sandbox: Arc, + pub hook_runner: Option>, + pub cancel_requested: Option>, + pub provider: Provider, + pub llm_source: Arc, + pub(crate) metadata_runtime: Arc, } impl RunServices { @@ -50,6 +52,7 @@ impl RunServices { cancel_requested, provider, llm_source, + metadata_runtime: Arc::new(SandboxGitRuntime::new()), }) } diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index 57271354d..bd57f2649 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -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::>(), - 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"); diff --git a/lib/crates/fabro-workflow/tests/it/cp_integration.rs b/lib/crates/fabro-workflow/tests/it/cp_integration.rs index 973bd5768..f85ebd9d5 100644 --- a/lib/crates/fabro-workflow/tests/it/cp_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/cp_integration.rs @@ -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, } } diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index c7f05506f..8159e91ed 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -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 diff --git a/lib/crates/fabro-workflow/tests/it/git_integration.rs b/lib/crates/fabro-workflow/tests/it/git_integration.rs index ad09a6af9..2356bd856 100644 --- a/lib/crates/fabro-workflow/tests/it/git_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/git_integration.rs @@ -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(), diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index 35ccbf424..017f3a1e1 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -341,17 +341,19 @@ async fn end_to_end_linear_pipeline() { local_env(), ); 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, state) = engine .run_with_state(&graph, &run_options) @@ -470,17 +472,19 @@ async fn end_to_end_branching_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -589,17 +593,19 @@ async fn end_to_end_human_gate_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -684,17 +690,19 @@ async fn human_gate_interrupted_input_fails_closed_without_fail_route() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -794,17 +802,19 @@ async fn human_gate_interrupted_input_routes_via_outcome_fail_condition() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -907,17 +917,19 @@ async fn goal_gate_routes_to_retry_target_on_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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 result = engine.run(&graph, &run_options).await; assert!( @@ -1027,17 +1039,19 @@ async fn goal_gate_routes_to_retry_target_when_present() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -1341,17 +1355,19 @@ async fn retry_on_failure_then_succeed() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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) @@ -1415,17 +1431,19 @@ async fn pipeline_with_many_nodes() { local_env(), ); 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, state) = engine .run_with_state(&graph, &run_options) @@ -1761,17 +1779,19 @@ async fn smoke_test_with_mock_codergen_backend() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -1862,17 +1882,19 @@ async fn end_to_end_parallel_fan_out_fan_in() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -1974,17 +1996,19 @@ async fn resume_from_checkpoint_completes_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_from_checkpoint_with_state(&graph, &run_options, &checkpoint) @@ -2072,17 +2096,19 @@ async fn resume_from_checkpoint_preserves_goal_gate_outcomes() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; // This should succeed because goal gate for gated_work is satisfied // via restored outcomes @@ -2114,17 +2140,19 @@ async fn graph_goal_in_context() { local_env(), ); 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, state) = engine .run_with_state(&graph, &run_options) @@ -2152,17 +2180,19 @@ async fn event_streaming_lifecycle() { let events = collect_events(&emitter); let engine = WorkflowRunner::new(make_linear_registry(), Arc::new(emitter), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -2231,17 +2261,19 @@ async fn context_flow_between_stages() { local_env(), ); 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, state) = engine .run_with_state(&graph, &run_options) @@ -2286,17 +2318,19 @@ async fn tool_handler_e2e() { local_env(), ); 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, _state) = engine .run_with_state(&graph, &run_options) @@ -2360,17 +2394,19 @@ async fn auto_approve_interviewer_e2e() { local_env(), ); 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, _state) = engine .run_with_state(&graph, &run_options) @@ -2399,17 +2435,19 @@ async fn codergen_without_backend_simulated() { local_env(), ); 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, state) = engine .run_with_state(&graph, &run_options) @@ -2503,17 +2541,19 @@ async fn branching_loop_back_on_failure() { ); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, _state) = engine .run_with_state(&graph, &run_options) @@ -2588,17 +2628,19 @@ async fn human_gate_loops_back() { registry.register("human", Box::new(HumanHandler::new(interviewer))); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, _state) = engine .run_with_state(&graph, &run_options) @@ -2652,17 +2694,19 @@ async fn scenario_ship_a_feature() { local_env(), ); 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, _state) = engine .run_with_state(&graph, &run_options) @@ -2736,17 +2780,19 @@ async fn scenario_parallel_expert_review() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -2822,17 +2868,19 @@ async fn scenario_node_retries_on_retry_status() { ); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -2886,17 +2934,19 @@ async fn scenario_loop_restart_resets_context() { ); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -2953,17 +3003,19 @@ async fn scenario_bug_triage_router() { registry.register("conditional", Box::new(ConditionalHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -3014,17 +3066,19 @@ async fn scenario_crash_recovery() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_from_checkpoint_with_state(&graph, &run_options, &checkpoint) @@ -3123,17 +3177,19 @@ async fn manager_loop_stop_condition_satisfied_e2e() { registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -3204,17 +3260,19 @@ async fn manager_loop_max_cycles_exceeded_e2e() { registry.register("stack.manager_loop", Box::new(SubWorkflowHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -3344,17 +3402,19 @@ async fn conditional_branching_success_fail_paths() { registry.register("always_fail", Box::new(AlwaysFailHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -3399,17 +3459,19 @@ async fn edge_selection_condition_match_wins_over_weight() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -3448,17 +3510,19 @@ async fn edge_selection_weight_breaks_ties() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -3489,17 +3553,19 @@ async fn edge_selection_lexical_tiebreak() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -3549,17 +3615,19 @@ async fn context_updates_visible_across_nodes() { registry.register("context_setter", Box::new(ContextSetterHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -3595,17 +3663,19 @@ async fn stylesheet_applies_model_override() { local_env(), ); 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).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -3650,17 +3720,19 @@ async fn custom_handler_registration_and_execution() { registry.register("my_custom", Box::new(CustomHandler)); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -3727,17 +3799,19 @@ async fn integration_smoke_plan_implement_review_done() { local_env(), ); 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, state) = engine .run_with_state(&graph, &run_options) @@ -3818,17 +3892,19 @@ async fn manager_loop_runs_child_engine_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -3951,17 +4027,19 @@ async fn manager_loop_context_flows_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -4026,17 +4104,19 @@ async fn manager_loop_child_dotfile_e2e() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); @@ -4129,17 +4209,19 @@ async fn import_e2e_through_engine() { local_env(), ); 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, state) = engine .run_with_state(&graph, &run_options) @@ -4283,17 +4365,19 @@ async fn fidelity_default_is_compact() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -4339,17 +4423,19 @@ async fn fidelity_graph_default_applied() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -4391,17 +4477,19 @@ async fn fidelity_node_overrides_graph_default() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -4449,17 +4537,19 @@ async fn fidelity_edge_overrides_node_and_graph() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -4497,17 +4587,19 @@ async fn fidelity_full_produces_empty_preamble() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -4555,17 +4647,19 @@ async fn fidelity_truncate_preamble_minimal() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -4626,17 +4720,19 @@ async fn fidelity_summary_low_mode() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -4692,17 +4788,19 @@ async fn fidelity_summary_medium_mode() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -4758,17 +4856,19 @@ async fn fidelity_summary_high_mode() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -4817,17 +4917,19 @@ async fn fidelity_full_sets_thread_id_in_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -4887,17 +4989,19 @@ async fn fidelity_full_nodes_share_thread_id() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -4967,17 +5071,19 @@ async fn fidelity_resume_degrades_full_to_summary_high() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine .run_from_checkpoint(&graph, &run_options, &checkpoint) @@ -5063,17 +5169,19 @@ async fn fidelity_resume_degrade_only_affects_first_hop() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine .run_from_checkpoint(&graph, &run_options, &checkpoint) @@ -5146,17 +5254,19 @@ async fn fidelity_resume_no_degrade_when_not_full() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine .run_from_checkpoint(&graph, &run_options, &checkpoint) @@ -5187,17 +5297,19 @@ async fn fidelity_stored_in_checkpoint_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -5279,17 +5391,19 @@ async fn fidelity_precedence_multi_node_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -5346,17 +5460,19 @@ async fn fidelity_compact_preamble_includes_completed_stages_and_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -5420,17 +5536,19 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { ); let engine_low = WorkflowRunner::new(registry_low, Arc::new(Emitter::default()), local_env()); let run_options_low = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir_low.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_low.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, }; engine_low .run(&graph_low, &run_options_low) @@ -5486,17 +5604,19 @@ async fn fidelity_summary_low_excludes_context_values_in_pipeline() { ); let engine_med = WorkflowRunner::new(registry_med, Arc::new(Emitter::default()), local_env()); let run_options_med = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir_med.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_med.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, }; engine_med .run(&graph_med, &run_options_med) @@ -5557,17 +5677,19 @@ async fn fidelity_thread_id_fallback_to_previous_node_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -5610,17 +5732,19 @@ async fn fidelity_thread_id_from_node_class_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -5666,17 +5790,19 @@ async fn fidelity_edge_thread_id_override_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -5723,17 +5849,19 @@ async fn fidelity_full_without_explicit_thread_id_uses_previous_node() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -5790,17 +5918,19 @@ async fn fidelity_from_parsed_dot_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -5838,17 +5968,19 @@ async fn fidelity_checkpoint_roundtrip_preserves_fidelity() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -5910,17 +6042,19 @@ async fn fidelity_node_thread_id_overrides_edge_thread_id_in_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, }; engine.run(&graph, &run_options).await.expect("run"); @@ -5996,17 +6130,19 @@ async fn fidelity_resume_preserves_context_values_across_checkpoint() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_from_checkpoint_with_state(&graph, &run_options, &checkpoint) @@ -6231,17 +6367,19 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = tokio::time::timeout( std::time::Duration::from_mins(2), @@ -6339,17 +6477,19 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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 = tokio::time::timeout( std::time::Duration::from_mins(2), @@ -6471,17 +6611,19 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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 = tokio::time::timeout( std::time::Duration::from_mins(2), @@ -6571,17 +6713,19 @@ mod real_llm { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = tokio::time::timeout( std::time::Duration::from_secs(30), @@ -6708,17 +6852,19 @@ async fn workflow_run_with_vault_only_openai_codex_builds_pr_body() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("vault-only-openai-codex-pr-body"), - 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("vault-only-openai-codex-pr-body"), + 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_with_state_and_llm_source(&graph, &run_options, Arc::clone(&llm_source)) @@ -6819,17 +6965,19 @@ async fn human_gate_freeform_only_routes_text() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -6949,17 +7097,19 @@ async fn human_gate_freeform_with_fixed_choice_match() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -7065,17 +7215,19 @@ async fn human_gate_freeform_fallback_on_unmatched_text() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -7192,17 +7344,19 @@ async fn human_gate_freeform_sets_allow_freeform_on_question() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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) @@ -7300,17 +7454,19 @@ async fn human_gate_without_freeform_sets_allow_freeform_false() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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) @@ -7603,17 +7759,19 @@ fn engine_with_hooks_and_events( fn make_run_options(dir: &std::path::Path) -> RunOptions { RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.to_path_buf(), - cancel_token: None, - run_id: test_run_id("hook-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.to_path_buf(), + cancel_token: None, + run_id: test_run_id("hook-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, } } @@ -8541,17 +8699,19 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -8742,17 +8902,19 @@ async fn large_context_values_are_offloaded_to_artifact_store() { let events = collect_events(&emitter); let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); 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, _state) = engine .run_with_state(&graph, &run_options) @@ -8946,17 +9108,19 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() { let remote_env = Arc::new(RemoteMockEnv::new("/sandbox")); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), remote_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, _state) = engine .run_with_state(&graph, &run_options) @@ -9033,17 +9197,19 @@ async fn downstream_local_execution_materializes_blob_refs_to_runtime_files() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, _state) = engine .run_with_state(&graph, &run_options) @@ -9120,17 +9286,19 @@ async fn downstream_remote_execution_materializes_blob_refs_to_sandbox_files() { let remote_env = Arc::new(RemoteMockEnv::new("/sandbox")); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), remote_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, _state) = engine .run_with_state(&graph, &run_options) @@ -9250,17 +9418,19 @@ async fn node_dir_uses_visit_count_on_revisit() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); 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, state) = engine .run_with_state(&graph, &run_options) @@ -10140,17 +10310,19 @@ async fn full_pipeline_with_cli_backend_node() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env); 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, state) = engine .run_with_state(&graph, &run_options) @@ -10258,17 +10430,19 @@ async fn stylesheet_backend_property_routes_to_cli() { let dir = tempfile::tempdir().unwrap(); let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), env); 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, state) = engine .run_with_state(&graph, &run_options) @@ -10454,17 +10628,19 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: run_dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("test-docker"), - labels: std::collections::HashMap::new(), - workflow_slug: None, - github_app: None, - base_branch: None, - display_base_sha: None, - host_repo_path: Some(worktree_path.clone()), - git: Some(GitCheckpointOptions { + settings: WorkflowSettings::default(), + run_dir: run_dir.path().to_path_buf(), + cancel_token: None, + run_id: test_run_id("test-docker"), + 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.clone()), run_branch: Some(run_branch), meta_branch: None, @@ -10530,8 +10706,6 @@ async fn git_checkpoint_host_emits_events_and_diff_patch() { /// trailer in run-branch commits. #[tokio::test] async fn git_checkpoint_host_writes_shadow_branch() { - use fabro_workflow::git::MetadataStore; - // 1. Create a temporary git repo with an initial commit let repo = tempfile::tempdir().unwrap(); std::process::Command::new("git") @@ -10619,7 +10793,7 @@ async fn git_checkpoint_host_writes_shadow_branch() { registry.register("exit", Box::new(ExitHandler)); let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); - let meta_branch = MetadataStore::branch_name(&run_id.to_string()); + let meta_branch = format!("fabro/meta/{run_id}"); let run_options = RunOptions { settings: WorkflowSettings::default(), run_dir: run_dir.path().to_path_buf(), @@ -10630,11 +10804,13 @@ async fn git_checkpoint_host_writes_shadow_branch() { github_app: None, base_branch: None, display_base_sha: None, - host_repo_path: Some(worktree_path.clone()), + pre_run_git: None, + fork_source_ref: None, + checkpoints_disabled: false, git: Some(GitCheckpointOptions { base_sha: Some(base_sha), run_branch: Some(format!("fabro/run/{run_id}")), - meta_branch: Some(meta_branch), + meta_branch: Some(meta_branch.clone()), }), }; // 5. Run pipeline @@ -10644,9 +10820,21 @@ async fn git_checkpoint_host_writes_shadow_branch() { .expect("pipeline should succeed"); assert_eq!(outcome.status, StageStatus::Success); - // 6. Assert shadow branch has checkpoint data on the host repo - let checkpoint = MetadataStore::read_checkpoint(repo.path(), &run_id.to_string()) - .expect("read_checkpoint should not error") + // 6. Assert shadow branch has checkpoint data in run.json + let run_json = std::process::Command::new("git") + .args(["show", &format!("refs/heads/{meta_branch}:run.json")]) + .current_dir(repo.path()) + .output() + .expect("git show should run"); + assert!( + run_json.status.success(), + "metadata run.json should exist: {}", + String::from_utf8_lossy(&run_json.stderr) + ); + let projection: fabro_store::RunProjection = + serde_json::from_slice(&run_json.stdout).expect("run.json should parse"); + let checkpoint = projection + .checkpoint .expect("shadow branch should contain checkpoint data"); assert!( !checkpoint.completed_nodes.is_empty(), @@ -10678,9 +10866,9 @@ async fn git_checkpoint_host_writes_shadow_branch() { "run-branch commit should have Fabro-Completed trailer, got:\n{commit_msg}" ); - // 8. Verify round-trip: shadow checkpoint's completed_nodes matches expected - let run_spec = MetadataStore::read_run_spec(repo.path(), &run_id.to_string()) - .expect("read_run_spec should not error") + // 8. Verify round-trip: shadow run.json contains the run spec + let run_spec = projection + .spec .expect("shadow branch should contain run spec"); assert_eq!(run_spec.run_id, run_id); @@ -10829,7 +11017,9 @@ async fn parallel_git_branching_host_e2e() { github_app: None, base_branch: None, display_base_sha: None, - host_repo_path: Some(worktree_path.clone()), + pre_run_git: None, + fork_source_ref: None, + checkpoints_disabled: false, git: Some(GitCheckpointOptions { base_sha: Some(base_sha.clone()), run_branch: Some(run_branch.clone()), @@ -11069,17 +11259,19 @@ async fn git_checkpoint_host_skips_empty_diff_patch() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: run_dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("empty-diff"), - labels: std::collections::HashMap::new(), - workflow_slug: None, - github_app: None, - base_branch: None, - display_base_sha: None, - host_repo_path: Some(worktree_path.clone()), - git: Some(GitCheckpointOptions { + settings: WorkflowSettings::default(), + run_dir: run_dir.path().to_path_buf(), + cancel_token: None, + run_id: test_run_id("empty-diff"), + 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.clone()), run_branch: Some(run_branch), meta_branch: None, @@ -11439,17 +11631,19 @@ async fn e2e_circuit_breaker_deterministic_self_loop() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-circuit-breaker"), - 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("e2e-circuit-breaker"), + 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 result = engine.run(&graph, &run_options).await; assert!(result.is_err(), "pipeline should abort, not loop forever"); @@ -11485,17 +11679,19 @@ async fn e2e_circuit_breaker_custom_limit() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-custom-limit"), - 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("e2e-custom-limit"), + 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 result = engine.run(&graph, &run_options).await; assert!(result.is_err()); @@ -11524,17 +11720,19 @@ async fn e2e_circuit_breaker_ignores_transient_failures() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-transient-no-breaker"), - 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("e2e-transient-no-breaker"), + 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 result = engine.run(&graph, &run_options).await; assert!(result.is_err()); @@ -11570,17 +11768,19 @@ async fn e2e_circuit_breaker_different_reasons_separate_counters() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-varying-reasons"), - 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("e2e-varying-reasons"), + 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 result = engine.run(&graph, &run_options).await; assert!(result.is_err()); @@ -11609,17 +11809,19 @@ async fn e2e_circuit_breaker_loop_restart() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-restart-breaker"), - 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("e2e-restart-breaker"), + 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 result = engine.run(&graph, &run_options).await; assert!( @@ -11671,17 +11873,19 @@ async fn e2e_failure_signature_persisted_in_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-sig-context"), - 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("e2e-sig-context"), + 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, state) = engine.run_with_state(&graph, &run_options).await.unwrap(); // Pipeline reaches exit (terminal) with goal gates satisfied. @@ -11734,17 +11938,19 @@ async fn e2e_failure_signature_hint_overrides_reason_in_context() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-sig-hint"), - 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("e2e-sig-hint"), + 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, state) = engine.run_with_state(&graph, &run_options).await.unwrap(); @@ -11791,17 +11997,19 @@ async fn e2e_signature_maps_persist_in_checkpoint() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-sig-persist"), - 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("e2e-sig-persist"), + 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, state) = engine.run_with_state(&graph, &run_options).await.unwrap(); assert_eq!(outcome.status, StageStatus::Success); @@ -11918,17 +12126,19 @@ async fn e2e_circuit_breaker_emits_events_before_abort() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-events"), - 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("e2e-events"), + 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 result = engine.run(&graph, &run_options).await; assert!(result.is_err()); @@ -11984,17 +12194,19 @@ async fn e2e_circuit_breaker_does_not_fire_below_limit() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-below-limit"), - 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("e2e-below-limit"), + 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, state) = engine.run_with_state(&graph, &run_options).await.unwrap(); assert_eq!( @@ -12079,17 +12291,19 @@ async fn e2e_circuit_breaker_multi_stage_impl_verify_cycle() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-impl-verify-cycle"), - 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("e2e-impl-verify-cycle"), + 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 result = engine.run(&graph, &run_options).await; assert!( @@ -12176,17 +12390,19 @@ async fn e2e_loop_restart_blocked_for_deterministic_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-restart-blocked-det"), - 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("e2e-restart-blocked-det"), + 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 result = engine.run(&graph, &run_options).await; assert!( @@ -12215,17 +12431,19 @@ async fn e2e_loop_restart_blocked_for_structural_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-restart-blocked-struct"), - 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("e2e-restart-blocked-struct"), + 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 result = engine.run(&graph, &run_options).await; assert!( @@ -12254,17 +12472,19 @@ async fn e2e_loop_restart_blocked_for_budget_exhausted_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-restart-blocked-budget"), - 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("e2e-restart-blocked-budget"), + 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 result = engine.run(&graph, &run_options).await; assert!( @@ -12293,17 +12513,19 @@ async fn e2e_loop_restart_blocked_for_canceled_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-restart-blocked-canceled"), - 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("e2e-restart-blocked-canceled"), + 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 result = engine.run(&graph, &run_options).await; assert!(result.is_err(), "canceled failure should not loop_restart"); @@ -12329,17 +12551,19 @@ async fn e2e_loop_restart_blocked_for_compilation_loop_failure() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-restart-blocked-comploop"), - 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("e2e-restart-blocked-comploop"), + 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 result = engine.run(&graph, &run_options).await; assert!( @@ -12369,17 +12593,19 @@ async fn e2e_loop_restart_allowed_for_transient_infra() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("e2e-restart-allowed-transient"), - 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("e2e-restart-allowed-transient"), + 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 result = engine.run(&graph, &run_options).await; assert!( @@ -12476,17 +12702,19 @@ async fn e2e_stall_watchdog_triggers_from_dot_parsed_pipeline() { let engine = WorkflowRunner::new(registry, Arc::new(emitter), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("stall-e2e"), - 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("stall-e2e"), + 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 result = engine.run(&graph, &run_options).await; assert!(result.is_err(), "expected stall watchdog error"); @@ -12531,17 +12759,19 @@ async fn e2e_stall_watchdog_kept_alive_by_handler_events() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("stall-alive-e2e"), - 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("stall-alive-e2e"), + 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) @@ -12576,17 +12806,19 @@ async fn e2e_stall_watchdog_disabled_with_zero_timeout() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("stall-disabled-e2e"), - 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("stall-disabled-e2e"), + 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) @@ -12641,17 +12873,19 @@ async fn e2e_stall_watchdog_with_explicit_timeout_override() { let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); let run_options = RunOptions { - settings: WorkflowSettings::default(), - run_dir: dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("stall-override-e2e"), - 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("stall-override-e2e"), + 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 start = std::time::Instant::now(); let result = engine.run(&graph, &run_options).await; @@ -12774,7 +13008,7 @@ async fn asset_collection_local_sandbox_success() { 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()], @@ -12783,16 +13017,18 @@ async fn asset_collection_local_sandbox_success() { }, ..WorkflowSettings::default() }, - run_dir: run_dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("artifact-test-local"), - 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: run_dir.path().to_path_buf(), + cancel_token: None, + run_id: test_run_id("artifact-test-local"), + 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) @@ -12906,7 +13142,7 @@ async fn asset_collection_local_sandbox_on_failure() { 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()], @@ -12915,16 +13151,18 @@ async fn asset_collection_local_sandbox_on_failure() { }, ..WorkflowSettings::default() }, - run_dir: run_dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("artifact-test-fail"), - 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: run_dir.path().to_path_buf(), + cancel_token: None, + run_id: test_run_id("artifact-test-fail"), + 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) @@ -13012,7 +13250,7 @@ async fn asset_collection_docker_sandbox() { 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()], @@ -13021,16 +13259,18 @@ async fn asset_collection_docker_sandbox() { }, ..WorkflowSettings::default() }, - run_dir: run_dir.path().to_path_buf(), - cancel_token: None, - run_id: test_run_id("artifact-test-docker"), - 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: run_dir.path().to_path_buf(), + cancel_token: None, + run_id: test_run_id("artifact-test-docker"), + 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) @@ -13088,17 +13328,19 @@ async fn wait_timer_e2e() { local_env(), ); 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).await.expect("run"); assert_eq!(outcome.status, StageStatus::Success); diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index eddc1bd89..caa02b130 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -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 diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 4d3fc82ac..78fbb39ce 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/models/manifest-args.ts b/lib/packages/fabro-api-client/src/models/manifest-args.ts index 15e9322fd..6543a1a60 100644 --- a/lib/packages/fabro-api-client/src/models/manifest-args.ts +++ b/lib/packages/fabro-api-client/src/models/manifest-args.ts @@ -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; } diff --git a/lib/packages/fabro-api-client/src/models/manifest-git.ts b/lib/packages/fabro-api-client/src/models/manifest-git.ts index c800b7fb7..7fde59ee6 100644 --- a/lib/packages/fabro-api-client/src/models/manifest-git.ts +++ b/lib/packages/fabro-api-client/src/models/manifest-git.ts @@ -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; } diff --git a/lib/packages/fabro-api-client/src/models/manifest-pre-run-push-outcome.ts b/lib/packages/fabro-api-client/src/models/manifest-pre-run-push-outcome.ts new file mode 100644 index 000000000..7824aed7a --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/manifest-pre-run-push-outcome.ts @@ -0,0 +1,38 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Outcome of the CLI\'s best-effort pre-run push. + */ +export interface ManifestPreRunPushOutcome { + 'type': ManifestPreRunPushOutcomeTypeEnum; + 'remote'?: string | null; + 'branch'?: string | null; + 'message'?: string | null; + 'repo_origin_url'?: string | null; +} + +export const ManifestPreRunPushOutcomeTypeEnum = { + NOT_ATTEMPTED: 'not_attempted', + SUCCEEDED: 'succeeded', + FAILED: 'failed', + SKIPPED_NO_REMOTE: 'skipped_no_remote', + SKIPPED_REMOTE_MISMATCH: 'skipped_remote_mismatch' +} as const; + +export type ManifestPreRunPushOutcomeTypeEnum = typeof ManifestPreRunPushOutcomeTypeEnum[keyof typeof ManifestPreRunPushOutcomeTypeEnum]; + + diff --git a/lib/packages/fabro-api-client/src/models/run-list-item.ts b/lib/packages/fabro-api-client/src/models/run-list-item.ts index e65d1dbe7..8a551d798 100644 --- a/lib/packages/fabro-api-client/src/models/run-list-item.ts +++ b/lib/packages/fabro-api-client/src/models/run-list-item.ts @@ -53,7 +53,9 @@ export interface RunListItem { 'title': string; 'status': RunStatus; 'labels': { [key: string]: string; }; - 'host_repo_path'?: string | null; + 'source_directory'?: string | null; + 'checkpoints_disabled'?: boolean; + 'repo_origin_url'?: string | null; 'start_time'?: string | null; 'pending_control'?: RunControlAction | null; 'duration_ms'?: number | null; diff --git a/lib/packages/fabro-api-client/src/models/run-summary.ts b/lib/packages/fabro-api-client/src/models/run-summary.ts index 57eb9750d..5b33c5a51 100644 --- a/lib/packages/fabro-api-client/src/models/run-summary.ts +++ b/lib/packages/fabro-api-client/src/models/run-summary.ts @@ -33,7 +33,9 @@ export interface RunSummary { 'goal': string; 'title': string; 'labels': { [key: string]: string; }; - 'host_repo_path'?: string | null; + 'source_directory'?: string | null; + 'checkpoints_disabled'?: boolean; + 'repo_origin_url'?: string | null; 'repository': RepositoryReference; 'start_time'?: string | null; 'created_at': string; diff --git a/lib/packages/fabro-api-client/src/models/timeline-entry-response.ts b/lib/packages/fabro-api-client/src/models/timeline-entry-response.ts index e0d90c6c9..07e5215c2 100644 --- a/lib/packages/fabro-api-client/src/models/timeline-entry-response.ts +++ b/lib/packages/fabro-api-client/src/models/timeline-entry-response.ts @@ -21,6 +21,7 @@ export interface TimelineEntryResponse { 'ordinal': number; 'node_name': string; 'visit': number; + 'checkpoint_seq': number; 'run_commit_sha'?: string | null; }