mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Make git metadata sandbox-native
This commit is contained in:
parent
fd1087fe2d
commit
cdd46b4fa8
104 changed files with 4572 additions and 5037 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
358
docs/plans/2026-04-27-sandbox-native-git-metadata-plan.md
Normal file
358
docs/plans/2026-04-27-sandbox-native-git-metadata-plan.md
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
---
|
||||
title: "refactor: make git metadata sandbox-native and clarify run paths"
|
||||
type: refactor
|
||||
status: active
|
||||
date: 2026-04-27
|
||||
---
|
||||
|
||||
# refactor: make git metadata sandbox-native and clarify run paths
|
||||
|
||||
## For the engineer picking this up
|
||||
|
||||
Fabro currently mixes three different path concepts under names that imply they are interchangeable:
|
||||
|
||||
- The submitter/client path used to resolve workflow source.
|
||||
- The sandbox execution path.
|
||||
- A repo path the Fabro server process can open with `git2`.
|
||||
|
||||
That assumption is false for Docker and Daytona. Docker and Daytona clone into sandbox-owned workspaces, commonly `/workspace`, while the submitted source path may be something like `/Users/...` from a CLI client and may not exist on the Fabro server host. This causes metadata checkpoint writes to try to open a client path from the server process.
|
||||
|
||||
Greenfield app. No production deploys. Do not add compatibility shims, serde aliases, migration layers, or legacy fallback behavior. Prefer clear renames and direct deletion of stale concepts.
|
||||
|
||||
The metadata branch (`refs/heads/fabro/meta/<run_id>`) is written best-effort as a future-proofing archive. Nothing in this plan reads it at runtime — fork, rewind, and timeline all source their state from the durable run store (events and `RunProjection.checkpoints`). Fork-from-metadata-branch (recovering a run that was deleted from the server but still exists in git) is explicitly deferred to a future plan.
|
||||
|
||||
## Goals
|
||||
|
||||
- `source_directory` means the submitter-side path where run source was resolved. It is provenance/display only and may not exist on the Fabro server.
|
||||
- `working_directory` means the sandbox execution path field on sandbox records/events, e.g. `/workspace`; use "sandbox execution path" for the concept in prose.
|
||||
- Submitter-provided pre-run git context is optional. CLI submitters can report local dirty status, base SHA, and pre-run push outcome. Browser, Slack, webhook, MCP, and scheduled-job submitters usually cannot. The Fabro server never opens a submitter path.
|
||||
- Runtime metadata branch writes happen through `Sandbox::exec_command` and are best-effort.
|
||||
- Runtime fork, rewind, and timeline source state from the durable run store, not the metadata branch. Fork/rewind git setup validates source run-branch reachability through the sandbox clone origin before execution.
|
||||
- Docker, Daytona, and local worktree execution share the same git metadata behavior.
|
||||
- Stale bind-mount vocabulary is removed from records, events, docs, tests, and API schemas.
|
||||
- Local in-place execution is preserved only as an explicit no-checkpoints opt-out with durable state and clear fork/rewind errors.
|
||||
|
||||
## Phase 1: Rename run path vocabulary and move pre-run git context to submitters
|
||||
|
||||
Primary files:
|
||||
|
||||
- `lib/crates/fabro-types/src/run.rs`
|
||||
- `lib/crates/fabro-types/src/run_summary.rs`
|
||||
- `lib/crates/fabro-types/src/run_event/run.rs`
|
||||
- `lib/crates/fabro-workflow/src/event.rs`
|
||||
- `lib/crates/fabro-workflow/src/operations/create.rs`
|
||||
- `lib/crates/fabro-workflow/src/pipeline/initialize.rs`
|
||||
- `lib/crates/fabro-server/src/run_manifest.rs`
|
||||
- `lib/crates/fabro-cli` (CLI submitter pre-run git context)
|
||||
- `docs/public/api-reference/fabro-api.yaml`
|
||||
|
||||
Required changes — vocabulary rename:
|
||||
|
||||
- Rename `RunSpec.working_directory` to `RunSpec.source_directory` and change its type from `PathBuf` to `Option<String>`. Provenance may originate on another machine, may be absent for server-originated runs, and is never opened by the server. `Option<String>` is more honest than `PathBuf` and avoids fake or normalized values.
|
||||
- Remove `RunSpec.host_repo_path`.
|
||||
- Rename `RunCreatedProps.working_directory` to `source_directory`; same `Option<String>` type.
|
||||
- Remove `RunCreatedProps.host_repo_path`.
|
||||
- Add the same durable fields to `RunCreatedProps` and `Event::RunCreated` as needed for event replay: `pre_run_git: Option<PreRunGitContext>`, `fork_source_ref: Option<ForkSourceRef>`, and `checkpoints_disabled: bool`. `RunSpec` is reconstructed from `RunCreatedProps` in `fabro-store`, so no new `RunSpec` field may exist only in memory.
|
||||
- Delete `CreateRunInput.host_repo_path`; `operations::create` should always persist `source_directory` from resolved workflow context.
|
||||
- Rename `PreparedManifest.working_directory` to `source_directory` in `fabro-server::run_manifest`; `PreparedManifest` is the server-side manifest assembled from submitted workflow source before `RunSpec` is persisted.
|
||||
- Rename `RunSummary.host_repo_path` to `source_directory` and switch to `Option<String>`.
|
||||
- Update `RunSummary::repository.name` derivation, end to end. This is needed because `source_directory` is now optional submitter provenance, while `repo_origin_url` is the best server-known repository identity:
|
||||
- Plumb `repo_origin_url` into `RunSummary::new` and into `build_summary` in `lib/crates/fabro-store/src/run_state.rs` alongside `source_directory`. Today both sites only carry the path.
|
||||
- Derivation order: prefer `repo_origin_url` owner/repo or repo basename when present; fall back to `source_directory` basename; fall back to `"unknown"`.
|
||||
- Update CLI output and server summary helpers that currently expose `host_repo_path`.
|
||||
- Regenerate and update API clients after the OpenAPI rename.
|
||||
|
||||
Required changes — submitter pre-run git context:
|
||||
|
||||
- Keep the existing git helper functions in `fabro-workflow::git` for now; move the call sites for dirty detection (`git::sync_status`), base-branch push (`git::branch_needs_push` + `git::push_branch`), and base SHA capture (`git::head_sha`) from `pipeline/initialize.rs` to the CLI submit path.
|
||||
- Add `RunSpec.pre_run_git: Option<PreRunGitContext>`.
|
||||
- Define `PreRunGitContext` as one coherent observation of the submitter checkout. It contains `display_base_sha: Option<String>`, `local_dirty: DirtyStatus` (`Clean`, `Dirty`, `Unknown`), and `push_outcome: PreRunPushOutcome`.
|
||||
- Define `PreRunPushOutcome` as an enum, not an `Option<bool>`: `NotAttempted`, `Succeeded { remote, branch }`, `Failed { remote, branch, message }`, `SkippedNoRemote`, and `SkippedRemoteMismatch { remote, repo_origin_url }`.
|
||||
- Keep `PreRunGitContext` grouped rather than flattening it into `RunSpec`; its fields are meaningful together as submitter-local provenance, and `push_outcome` is only meaningful in the context of the same checkout/base SHA observation.
|
||||
- CLI submissions populate `pre_run_git` from the user's source checkout. Non-CLI submitters leave it `None`; the engine skips local dirty/push reporting and uses sandbox git setup output for display base SHA when available.
|
||||
- For CLI submissions with `repo_origin_url`, use the current checkout's `origin` remote for pre-run push and compare it with `repo_origin_url` using `fabro_github::normalize_repo_origin_url(cli_remote_url) == fabro_github::normalize_repo_origin_url(repo_origin_url)`. If it does not match or cannot be proven, skip the pre-run push, record `SkippedRemoteMismatch` or `SkippedNoRemote`, and let sandbox clone/setup validate the runtime origin later. Fork/upstream layouts where `origin` is a user fork and `repo_origin_url` is upstream are treated as user configuration errors for this plan.
|
||||
- `pipeline/initialize.rs` consumes `pre_run_git` and stops calling `options.sandbox.host_repo_path()` for git work. Delete the `SandboxSpec::host_repo_path()` accessor in this phase.
|
||||
|
||||
Decisions:
|
||||
|
||||
- Do not keep `host_repo_path` anywhere in active run specs, summaries, events, or runtime options. If a test needs a local git path, name that fixture variable `source_directory` or `repo_dir` according to its role.
|
||||
- Delete both host-path entry points: `SandboxSpec::host_repo_path()` currently feeds initialization/pre-run host git behavior, and `RunOptions.host_repo_path` currently feeds runtime metadata/push behavior. Both are symptoms of the same server-local-git assumption and both go away.
|
||||
- The Fabro server never inspects a submitter filesystem path; pre-run git facts arrive as data on `RunSpec`.
|
||||
|
||||
## Phase 2: Remove stale bind-mount fields
|
||||
|
||||
Primary files:
|
||||
|
||||
- `lib/crates/fabro-types/src/sandbox_record.rs`
|
||||
- `lib/crates/fabro-types/src/run_event/infra.rs`
|
||||
- `lib/crates/fabro-workflow/src/event.rs`
|
||||
- `lib/crates/fabro-workflow/src/pipeline/initialize.rs`
|
||||
- `lib/crates/fabro-store/src/run_state.rs`
|
||||
- `lib/crates/fabro-sandbox/src/sandbox_spec.rs`
|
||||
- `apps/fabro-web` and `lib/packages/fabro-api-client` (consumer audit)
|
||||
- `docs/internal/events.md`
|
||||
- `docs/internal/plan-events-as-source-of-truth.md`
|
||||
|
||||
Required changes:
|
||||
|
||||
- Delete `host_working_directory` and `container_mount_point` from `SandboxRecord`.
|
||||
- Delete both fields from `SandboxInitializedProps` and `Event::SandboxInitialized`.
|
||||
- Stop emitting both fields from `pipeline::initialize`.
|
||||
- Stop storing both fields in `fabro-store` projections.
|
||||
- Grep `apps/fabro-web` and `lib/packages/fabro-api-client` for either field name and update consumers; do not condition removal on OpenAPI presence alone.
|
||||
- Update all tests and snapshots that construct sandbox records/events.
|
||||
- Update docs to describe `sandbox.initialized.working_directory` as the sandbox execution path and remove host/container mount terminology.
|
||||
|
||||
Decision:
|
||||
|
||||
- Keep Docker clone metadata (`repo_cloned`, `clone_origin_url`, `clone_branch`) because it describes the sandbox clone source. It does not imply host bind mounts.
|
||||
|
||||
## Phase 3: Make metadata writes sandbox-native
|
||||
|
||||
Primary files:
|
||||
|
||||
- `lib/crates/fabro-workflow/src/sandbox_git.rs`
|
||||
- `lib/crates/fabro-workflow/src/lifecycle/git.rs`
|
||||
- `lib/crates/fabro-workflow/src/pipeline/finalize.rs`
|
||||
- `lib/crates/fabro-workflow/src/run_dump.rs`
|
||||
- `lib/crates/fabro-workflow/src/run_options.rs`
|
||||
- `lib/crates/fabro-sandbox/src/sandbox.rs`
|
||||
- `lib/crates/fabro-checkpoint/src/metadata.rs` (deletion)
|
||||
|
||||
Required changes — writer interface:
|
||||
|
||||
- Delete `RunOptions.host_repo_path`.
|
||||
- Stop constructing `MetadataStore` in workflow runtime lifecycle code.
|
||||
- Add a sandbox metadata writer near `sandbox_git` with an interface equivalent to:
|
||||
- input: sandbox, run id, metadata ref, `RunDump` (which already includes the full `RunProjection` as `run.json`), commit message, git author.
|
||||
- output: metadata commit SHA.
|
||||
|
||||
Required changes — writer implementation:
|
||||
|
||||
- Add a per-run `SandboxGitRuntime` helper near `sandbox_git`, shared by `GitLifecycle`, finalize, and parallel checkpoint code. It owns a `OnceLock`/cached result for the sandbox git capability probe plus metadata degradation state.
|
||||
- Run the shared sandbox git capability probe once per run before the first sandbox git operation. Prefer an actual temp-index plumbing check over version parsing: under a hidden directory inside the sandbox execution path, create a temporary index, run the `read-tree --empty` / `hash-object -w` / `update-index --add --cacheinfo` / `write-tree` sequence against harmless temp data, then clean up. Do not assume `/tmp` or `$TMPDIR` is writable.
|
||||
- Probe failure does not convert a run to `checkpoints_disabled`; that state is reserved for the explicit in-place opt-out. If the probe fails before run-branch checkpointing or fork setup, fail that operation with a clear "sandbox git unavailable" error. Metadata writes use the same cached probe result but remain best-effort: emit one warning, mark metadata degraded for the run, and skip future metadata writes.
|
||||
- The probe is a startup capability check, not a guarantee for the full run lifetime. If an agent removes `git`, fills the disk, or otherwise breaks git after the probe passes, the real git operation fails with its concrete raw error; do not re-probe before every checkpoint.
|
||||
- Create a sandbox temp directory for metadata files under the sandbox execution path.
|
||||
- Write every dump entry into the metadata tree binary-safely. Use local temp files plus `Sandbox::upload_file_from_local` for bytes.
|
||||
- The writer must include `run.json` (full `RunProjection` blob) at every checkpoint commit so that future fork-from-archive (deferred from this plan) can reconstruct the projection from a metadata commit alone.
|
||||
- Build a temporary git index with `GIT_INDEX_FILE`.
|
||||
- Load the previous metadata commit with `git read-tree <old_commit>` when the ref exists, else `git read-tree --empty`.
|
||||
- Write blobs with `git hash-object -w`.
|
||||
- Stage entries with `git update-index --add --cacheinfo`.
|
||||
- Create the tree with `git write-tree`.
|
||||
- Create commits with `git commit-tree`, setting author/committer env from `GitAuthor`.
|
||||
- Update `refs/heads/fabro/meta/<run_id>` with `git update-ref`.
|
||||
- Clean up temp files best-effort.
|
||||
|
||||
Required changes — integration:
|
||||
|
||||
- `GitLifecycle::on_run_start` initializes the metadata ref through this sandbox writer.
|
||||
- `GitLifecycle::on_checkpoint` writes the snapshot through this sandbox writer and passes the returned SHA into `git_checkpoint` so the run-branch commit keeps the `Fabro-Checkpoint` trailer.
|
||||
- After each successful metadata write, the sandbox metadata writer pushes `refs/heads/fabro/meta/<run_id>` best-effort through `git_push_ref`. `write_finalize_commit` only calls the writer for the final snapshot; it does not have a separate metadata push path.
|
||||
- Metadata write failures remain best-effort warnings and must not fail a successful workflow run. They do not affect `Checkpoint.git_commit_sha`; that SHA comes from run-branch checkpoint commits. Fork/rewind require a checkpoint with `git_commit_sha`, so run-branch checkpoint failures affect forkability while metadata failures only affect future fork-from-archive recovery.
|
||||
- Metadata write and metadata push failures share a noise budget: emit the first warning per run, mark metadata degraded, suppress repeated per-checkpoint warnings, and emit an end-of-run summary notice if metadata was degraded.
|
||||
|
||||
Decisions:
|
||||
|
||||
- Use git plumbing with a temporary index. Do not checkout the metadata branch.
|
||||
- Do not mutate the sandbox worktree or real git index while writing metadata.
|
||||
- Rejected alternative: relying on the first real git operation to fail was rejected because run-branch checkpointing, metadata writes, and parallel checkpoint code would each surface different failures. The shared probe gives one clear capability error and one cached best-effort degradation path.
|
||||
- Delete `fabro-checkpoint::metadata::MetadataStore` entirely. The sandbox writer is the only writer. Tests that previously used `MetadataStore` migrate to either a local-sandbox-backed writer fixture or a focused test helper.
|
||||
|
||||
## Phase 4: Converge local git behavior on sandbox exec
|
||||
|
||||
Primary files:
|
||||
|
||||
- `lib/crates/fabro-sandbox/src/local.rs`
|
||||
- `lib/crates/fabro-sandbox/src/worktree.rs`
|
||||
- `lib/crates/fabro-sandbox/src/sandbox.rs`
|
||||
- `lib/crates/fabro-workflow/src/pipeline/initialize.rs`
|
||||
- `lib/crates/fabro-workflow/src/lifecycle/mod.rs`
|
||||
- `lib/crates/fabro-cli` (CLI flag for opt-out)
|
||||
|
||||
Required changes:
|
||||
|
||||
- Runtime git checkpointing always uses `Sandbox::exec_command`.
|
||||
- Make `WorktreeSandbox` the default local provider strategy for all local runs, regardless of submitter. Local runs use an isolated worktree; this is the path that shares behavior with Docker/Daytona.
|
||||
- Add a CLI flag (e.g. `--in-place`) for the explicit opt-out that runs against the user's source tree directly. Require an explicit paired acknowledgement flag such as `--allow-no-checkpoints`; when opted in, disable git checkpointing, persist `checkpoints_disabled: true` on the run, surface that state in summaries/`fabro ps`, and make fork/rewind errors say the run was created without checkpoints. Do not `git checkout -b fabro/run/...` in the user source directory.
|
||||
- Ensure `WorktreeSandbox` supports the same run-branch and metadata-branch git operations as Docker/Daytona through the `Sandbox` trait.
|
||||
- Remove fallback host-side pushes from runtime git lifecycle. Push run and metadata refs from the sandbox.
|
||||
- Replace `Sandbox::setup_git_for_run(&str)` with a setup intent method that returns `GitRunInfo` (`base_sha`, `run_branch`, `base_branch`) when it establishes sandbox git state:
|
||||
- `GitSetupIntent::NewRun { run_id }` creates `refs/heads/fabro/run/<run_id>` from the sandbox clone HEAD and returns the sandbox HEAD SHA as `base_sha`.
|
||||
- `GitSetupIntent::ForkFromCheckpoint { new_run_id, source_run_id, checkpoint_sha }` fetches the source run ref, creates `refs/heads/fabro/run/<new_run_id>` at `checkpoint_sha`, and checks it out before the first stage runs.
|
||||
- Keep `GitSetupIntent` rather than passing full `RunSpec` into the sandbox trait. The workflow layer derives the intent from `RunSpec.fork_source_ref`, and the sandbox trait receives only the git setup data it needs.
|
||||
- `WorktreeSandbox` owns local git setup. Its `initialize`/setup flow should keep the existing `git worktree add` behavior for `NewRun`, and add a fork path that creates the worktree branch at `checkpoint_sha`; it should not delegate git setup to bare `LocalSandbox`.
|
||||
- Delete `Sandbox::host_git_dir` after the worktree migration. No runtime git operation may branch on a host-accessible repository path. Replace the current `host_git_dir` lifecycle gate with an explicit hook cwd rule: when sandbox git checkpointing is enabled, pass the sandbox execution path (`sandbox.working_directory()`) to `HookLifecycle`/`WorkflowRunStarted.worktree_dir`; when checkpoints are disabled, leave hook cwd unset. Do not infer hook behavior from host path accessibility.
|
||||
- Rename `Sandbox::git_push_branch` to `git_push_ref` (in `lib/crates/fabro-sandbox/src/sandbox.rs`, the `delegate_sandbox!` macro, every implementor, and the shared `git_push_via_exec` helper). The method now pushes both run branches and metadata refs; `git_push_branch` is misleading.
|
||||
- Define `git_push_ref` as taking a full refspec (e.g. `refs/heads/fabro/run/<id>:refs/heads/fabro/run/<id>`, `refs/heads/fabro/meta/<id>:refs/heads/fabro/meta/<id>`) so callers control source and destination explicitly. Preserve the existing `refresh_push_credentials()` call. Do not use force refspecs (`+src:dst`) for run or metadata refs; they are append-only/create-only by invariant, and a non-fast-forward push should fail loudly.
|
||||
|
||||
Decisions:
|
||||
|
||||
- The local source tree is never used as a server-side git repo for checkpoint metadata.
|
||||
- `--in-place --allow-no-checkpoints` is the only path where local runs forgo checkpoints. It is opt-in, persisted on the run, and visible in list/detail and fork/rewind error paths.
|
||||
|
||||
## Phase 5: Source fork, rewind, and timeline from the durable run store
|
||||
|
||||
Primary files:
|
||||
|
||||
- `lib/crates/fabro-workflow/src/operations/run_git.rs`
|
||||
- `lib/crates/fabro-workflow/src/operations/timeline.rs`
|
||||
- `lib/crates/fabro-workflow/src/operations/fork.rs`
|
||||
- `lib/crates/fabro-workflow/src/operations/rewind.rs`
|
||||
- `lib/crates/fabro-workflow/src/operations/rebuild_meta.rs` (deletion)
|
||||
- `lib/crates/fabro-workflow/src/operations/mod.rs`
|
||||
- `lib/crates/fabro-server/src/server.rs`
|
||||
- `lib/crates/fabro-sandbox/src/sandbox.rs`
|
||||
|
||||
Required changes — server-local git removal:
|
||||
|
||||
- Stop opening `RunSpec.source_directory` with `git2`. The server never opens any submitter path.
|
||||
- Build checkpoint timelines from `RunProjection.checkpoints` directly. No metadata branch reads.
|
||||
- Replace `TimelineEntry.metadata_commit_oid` (in `lib/crates/fabro-workflow/src/operations/timeline.rs`) with `checkpoint_seq: u32`. Every consumer that previously read `run.json` from a metadata commit (such as fork's historical projection reconstruction in `operations/fork.rs`) instead replays events up to that seq.
|
||||
- Delete `rebuild_meta.rs`, `build_timeline_or_rebuild`, and `rebuild_metadata_branch`. Remove their `operations::mod` re-exports. Recovery from missing metadata branches is out of scope for this plan; a future fork-from-archive plan will reintroduce a recovery utility if needed.
|
||||
|
||||
Required changes — fork mechanics:
|
||||
|
||||
- Reconstruct the historical full `RunProjection` for the source run by composing `store.list_events(source_run_id)` with `RunProjection::apply_events`, stopping at the target `checkpoint_seq`. This becomes the new run's initial projection.
|
||||
- Add `RunSpec.fork_source_ref: Option<ForkSourceRef>` and the matching `RunCreatedProps`/event field. `None` means a normal run. `Some(ForkSourceRef { source_run_id, checkpoint_sha })` means fork/rewind setup must fetch the source run branch and branch the new run from `checkpoint_sha`; this overrides normal clone-branch checkout after the initial clone is available.
|
||||
- Before persisting the forked/rewound run, validate all store-local invariants: the target checkpoint has `git_commit_sha`, the source run did not record `checkpoints_disabled`, and source/new run specs point at the same normalized `repo_origin_url` using `fabro_github::normalize_repo_origin_url` on both sides. If these checks fail, return a validation-style error and do not create a new run row.
|
||||
- Fork-from-running is supported, but only after a checkpoint has a git SHA and its run branch is reachable. Because an in-flight source run may still be pushing the checkpoint commit, sandbox reachability validation should use a short retry/wait window before failing.
|
||||
- Sandbox initialization for a forked run must, before workflow execution starts:
|
||||
1. Fetch `refs/heads/fabro/run/<source_run_id>` from the sandbox clone's `origin` remote. In this plan, `origin` must correspond to `RunSpec.repo_origin_url`; fork/rewind across different origins is unsupported and should fail validation.
|
||||
2. Create the new run-branch `refs/heads/fabro/run/<new_run_id>` pointing at `checkpoint_sha`.
|
||||
3. Check that branch out before the first stage runs.
|
||||
- If the source run ref fetch fails or the `checkpoint_sha` is not reachable from the fetched source run ref after the retry window, fail sandbox initialization with a clear fork setup error and mark the new run as failed during setup. Do not silently fall back to the sandbox default branch. Direct `git fetch origin <sha>` fallback is out of scope for this plan; source run branches must be pushed and reachable.
|
||||
- Rewind continues the existing fork-then-archive behavior: create a new run id, create the new run branch at the checkpoint SHA, then archive/supersede the source run. This plan only changes the git setup mechanics; do not reintroduce rewind-in-place behavior.
|
||||
- If the chosen checkpoint has no `git_commit_sha`, return the existing validation-style error.
|
||||
- Fork creates the new run's branches/refs through the sandbox via `git_push_ref`, not `git2` against any server-local path.
|
||||
|
||||
Required changes — prefix resolution:
|
||||
|
||||
- Removing `host_repo_path` also removes repo/path-scoped prefix matching. Replace prefix-scoping logic in `find_run_id_by_prefix_or_store` with global prefix matching. If exactly one run matches, resolve it. If zero, return "not found." If multiple, return an ambiguity error listing each candidate (full run id, created_at, workflow name, origin URL) so the user can disambiguate.
|
||||
|
||||
Decisions:
|
||||
|
||||
- Server APIs do not require the original source checkout to exist on the Fabro server host.
|
||||
- Events are the canonical source of run history at runtime; metadata branch is a write-only archive in this plan.
|
||||
- Prefix ambiguity is a hard CLI error, not a silent filtering decision.
|
||||
|
||||
## Phase 6: API, generated clients, docs, and cleanup
|
||||
|
||||
Primary files:
|
||||
|
||||
- `docs/public/api-reference/fabro-api.yaml`
|
||||
- `lib/crates/fabro-api/build.rs` and generated output as required
|
||||
- `lib/packages/fabro-api-client`
|
||||
- `apps/fabro-web`
|
||||
- `lib/crates/fabro-cli`
|
||||
- `docs/internal/events.md`
|
||||
- `AGENTS.md` (remove any bind-mount references in agent guidance; otherwise drop from primary files)
|
||||
|
||||
Required changes:
|
||||
|
||||
- Update OpenAPI schemas from `host_repo_path` to `source_directory`.
|
||||
- Add API/event schemas for `pre_run_git`, `fork_source_ref`, and `checkpoints_disabled` wherever `RunSpec`/`RunCreatedProps` are represented.
|
||||
- Remove mount fields from sandbox initialized schemas if present.
|
||||
- Rebuild Rust API types.
|
||||
- Regenerate TypeScript API client.
|
||||
- Update web and CLI consumers to display `source_directory` where submitter provenance is wanted and sandbox `working_directory` where execution path is wanted.
|
||||
- Update CLI fork output to print full run id + workflow name + origin URL when a prefix is ambiguous.
|
||||
- Update `/api/v1/runs/{id}/timeline` OpenAPI prose and regenerated TypeScript comments so they say the endpoint reads durable run-store checkpoints, not the metadata branch.
|
||||
- Update docs to state Docker and Daytona are clone-based providers and never bind-mount the source repo.
|
||||
|
||||
Decision:
|
||||
|
||||
- No API aliases or transitional duplicated fields. Break callers cleanly.
|
||||
|
||||
## Test Plan
|
||||
|
||||
Focused tests:
|
||||
|
||||
- Submitter pre-run git context tests:
|
||||
- dirty detection, base-branch push, and `display_base_sha` are computed CLI-side and arrive on `RunSpec.pre_run_git`.
|
||||
- `pre_run_git` round-trips through `RunCreatedProps` and event replay; it is not only present in the in-memory `RunSpec`.
|
||||
- `PreRunPushOutcome` records success, failure, no-remote, and remote-mismatch cases explicitly.
|
||||
- non-CLI/server-created runs can leave `pre_run_git` as `None`; initialize uses sandbox setup output for display base SHA when available.
|
||||
- CLI pre-run push uses `origin`, compares with `repo_origin_url` via `fabro_github::normalize_repo_origin_url`, and is skipped/recorded when they do not match.
|
||||
- server `pipeline::initialize` no longer calls `git::sync_status` / `branch_needs_push` / `push_branch` / `head_sha`.
|
||||
- Sandbox git capability tests:
|
||||
- shared capability probe exercises temp-index plumbing once per run under the sandbox execution path, not `/tmp`.
|
||||
- probe result is cached by the per-run `SandboxGitRuntime` and shared by lifecycle, finalize, and parallel checkpoint paths.
|
||||
- missing or broken git fails run-branch checkpointing/fork setup clearly.
|
||||
- metadata writer uses the same failed probe result to warn once and skip best-effort writes.
|
||||
- if git or disk availability breaks after a successful probe, the real checkpoint/fork git operation reports the concrete raw error.
|
||||
- Sandbox metadata writer tests:
|
||||
- creates metadata branch from an empty ref.
|
||||
- appends snapshot commits while preserving prior files.
|
||||
- writes `run.json` (full `RunProjection`) at every checkpoint commit.
|
||||
- returns the metadata commit SHA.
|
||||
- does not change current branch, worktree files, or the real index.
|
||||
- handles binary dump entries.
|
||||
- pushes the metadata ref best-effort after each successful metadata write.
|
||||
- metadata write/push degradation emits one warning per run, suppresses repeated checkpoint noise, and emits an end-of-run summary notice.
|
||||
- Workflow lifecycle regression tests:
|
||||
- Docker/Daytona-style run with `source_directory = /Users/client/project` never opens that path on the server.
|
||||
- metadata write success adds `Fabro-Checkpoint` trailer to the run checkpoint commit.
|
||||
- metadata write failure emits a single warning, does not fail the workflow, and does not remove the run-branch `git_commit_sha` needed for fork.
|
||||
- run-branch checkpoint failure leaves no forkable checkpoint and produces a clear fork/rewind validation error.
|
||||
- Sandbox ref push tests:
|
||||
- `git_push_ref` pushes full run and metadata refspecs, preserves credential refresh, and rejects/non-forces non-fast-forward updates.
|
||||
- `git_push_via_exec`, `delegate_sandbox!`, Docker, Daytona, local, and worktree implementations all use the full-refspec method.
|
||||
- Fork via event replay:
|
||||
- fork at checkpoint seq N reconstructs the historical `RunProjection` via `RunProjection::apply_events` over events with `seq <= N`.
|
||||
- fork succeeds without any metadata branch read.
|
||||
- fork's `ForkSourceRef` is optional on `RunSpec` and round-trips through `RunCreatedProps`/event replay: absent for normal runs, present for fork/rewind-created runs, and it overrides normal clone-branch checkout.
|
||||
- fork setup fetches `refs/heads/fabro/run/<source_run_id>` from the sandbox clone origin and creates `refs/heads/fabro/run/<new_run_id>` at the checkpoint SHA before the first stage runs.
|
||||
- fork store-local validation fails without creating a new run row when the source run has `checkpoints_disabled`, no target checkpoint SHA, or mismatched `repo_origin_url`.
|
||||
- fork-from-running retries briefly when the source run ref is not yet reachable, then either succeeds or fails sandbox setup clearly.
|
||||
- sandbox setup fails clearly when the source run ref cannot be fetched or the checkpoint SHA is unreachable from that ref after the retry window.
|
||||
- fork returns a clear error when target checkpoint has no git commit SHA.
|
||||
- Timeline and rewind:
|
||||
- `TimelineEntry` exposes `checkpoint_seq` (not `metadata_commit_oid`).
|
||||
- timeline endpoint works from `RunProjection.checkpoints` without a local git repo.
|
||||
- rewind reuses the fork fetch + branch-from-sha mechanics, creates a new run id, and archives/supersedes the source run.
|
||||
- rewind target parsing works without opening `source_directory`.
|
||||
- Repository summary derivation:
|
||||
- `repo_origin_url` plumbs through `build_summary` and `RunSummary::new`; name prefers origin owner/repo, falls back to `source_directory` basename, then `"unknown"`.
|
||||
- summary derivation works when `source_directory` is `None`.
|
||||
- Prefix resolution:
|
||||
- exact-match prefix resolves to a single run.
|
||||
- ambiguous prefix returns an error listing every candidate.
|
||||
- zero matches returns "not found."
|
||||
- Local strategy:
|
||||
- default local provider run uses `WorktreeSandbox` for CLI, browser, Slack, webhook, MCP, and scheduled-job submissions and produces checkpoints.
|
||||
- `WorktreeSandbox` owns `GitSetupIntent::NewRun` and `ForkFromCheckpoint`; it does not delegate branch setup to bare `LocalSandbox`.
|
||||
- `GitSetupIntent::NewRun` returns `GitRunInfo` with sandbox HEAD SHA as `base_sha`, and fork setup returns `GitRunInfo` for the new run branch.
|
||||
- `--in-place --allow-no-checkpoints` runs persist `checkpoints_disabled: true` through `RunCreatedProps`, surface that state in list/detail output, skip git checkpointing, and do not create a `fabro/run/...` branch in the user source directory.
|
||||
- fork/rewind against a run with `checkpoints_disabled: true` fails with an error that names the disabled-checkpoints cause.
|
||||
- `Sandbox::host_git_dir` is gone or unused by runtime git lifecycle code; `HookLifecycle` still receives the sandbox execution path as cwd when checkpointing is enabled.
|
||||
- Naming and event tests:
|
||||
- run created/projection/summary JSON uses `source_directory`.
|
||||
- sandbox initialized JSON exposes sandbox `working_directory` and no mount fields.
|
||||
- repository summary name is derived from `repo_origin_url` before `source_directory`.
|
||||
- timeline OpenAPI/generated client prose says run-store checkpoints, not metadata branch.
|
||||
|
||||
Commands:
|
||||
|
||||
- `cargo build -p fabro-api`
|
||||
- `cargo nextest run -p fabro-workflow`
|
||||
- `cargo nextest run -p fabro-server`
|
||||
- `cargo nextest run -p fabro-cli`
|
||||
- `cd lib/packages/fabro-api-client && bun run generate`
|
||||
- `cd apps/fabro-web && bun run typecheck`
|
||||
- `cargo +nightly-2026-04-14 fmt --check --all`
|
||||
- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No production data migration.
|
||||
- No compatibility aliases for old JSON fields.
|
||||
- No server-side clone cache for metadata writes.
|
||||
- No Docker bind-mount support.
|
||||
- No branch mutation in the submitter source tree as a fallback.
|
||||
- No fork, rewind, or timeline path that reads the metadata branch. Fork-from-archive (recovering a server-purged run from git alone) is deferred to a future plan.
|
||||
- No prefix scoping by repo or origin. Ambiguous prefixes are user errors.
|
||||
- No fork/rewind across different repository origins.
|
||||
- No direct `git fetch origin <sha>` fallback for fork/rewind in this plan. Source run branches must be pushed and reachable from the sandbox clone origin.
|
||||
- No GitHub repository-ID based origin matching in this plan. Origin equality is normalized URL equality, so repository renames/transfers that change normalized URLs are a known limitation; users should create new runs from the new origin or repair stored metadata in a future recovery tool.
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/";
|
||||
|
|
|
|||
|
|
@ -1,445 +0,0 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_store::RunProjection;
|
||||
use fabro_types::{Checkpoint, RunSpec, StartRecord};
|
||||
use git2::{Repository, Signature};
|
||||
|
||||
use crate::META_BRANCH_PREFIX;
|
||||
use crate::author::GitAuthor;
|
||||
use crate::branch::BranchStore;
|
||||
use crate::error::{Error, MetadataError};
|
||||
use crate::git::Store;
|
||||
|
||||
/// Git-native metadata storage for pipeline runs.
|
||||
///
|
||||
/// Stores a unified `RunProjection` snapshot (plus artifacts) on an orphan
|
||||
/// branch (`fabro/meta/{run_id}`) so that runs can be resumed from git alone.
|
||||
pub struct MetadataStore {
|
||||
repo_path: PathBuf,
|
||||
author: GitAuthor,
|
||||
}
|
||||
|
||||
impl MetadataStore {
|
||||
pub fn new(repo_path: impl Into<PathBuf>, author: &GitAuthor) -> Self {
|
||||
Self {
|
||||
repo_path: repo_path.into(),
|
||||
author: author.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the branch name for a run: `fabro/meta/{run_id}`.
|
||||
pub fn branch_name(run_id: &str) -> String {
|
||||
format!("{META_BRANCH_PREFIX}{run_id}")
|
||||
}
|
||||
|
||||
/// Format a commit message with the standard Fabro footer appended.
|
||||
fn commit_message(&self, subject: &str) -> String {
|
||||
let mut msg = format!("{subject}\n");
|
||||
self.author.append_footer(&mut msg);
|
||||
msg
|
||||
}
|
||||
|
||||
fn open_store(&self) -> Result<(Store, Signature<'static>), MetadataError> {
|
||||
let repo = Repository::discover(&self.repo_path).map_err(Error::from)?;
|
||||
let store = Store::new(repo);
|
||||
let sig = Signature::now(&self.author.name, &self.author.email).map_err(Error::from)?;
|
||||
Ok((store, sig))
|
||||
}
|
||||
|
||||
/// Initialize a run's metadata branch with the given files.
|
||||
pub fn init_run(&self, run_id: &str, files: &[(&str, &[u8])]) -> Result<(), MetadataError> {
|
||||
let (store, sig) = self.open_store()?;
|
||||
let branch = Self::branch_name(run_id);
|
||||
let branch_store = BranchStore::new(&store, &branch, &sig);
|
||||
branch_store.ensure_branch()?;
|
||||
let message = self.commit_message("init run");
|
||||
branch_store.write_entries(files, &message)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write a snapshot commit to the metadata branch and return the new
|
||||
/// commit SHA.
|
||||
pub fn write_snapshot(
|
||||
&self,
|
||||
run_id: &str,
|
||||
entries: &[(&str, &[u8])],
|
||||
message: &str,
|
||||
) -> Result<String, MetadataError> {
|
||||
let (store, sig) = self.open_store()?;
|
||||
let branch = Self::branch_name(run_id);
|
||||
let branch_store = BranchStore::new(&store, &branch, &sig);
|
||||
let message = self.commit_message(message);
|
||||
let oid = branch_store.write_entries(entries, &message)?;
|
||||
Ok(oid.to_string())
|
||||
}
|
||||
|
||||
/// Read a single file from the metadata branch. Returns `None` if branch or
|
||||
/// path doesn't exist.
|
||||
fn read_file(
|
||||
repo_path: &Path,
|
||||
run_id: &str,
|
||||
path: &str,
|
||||
) -> Result<Option<Vec<u8>>, MetadataError> {
|
||||
let Ok(repo) = Repository::discover(repo_path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let store = Store::new(repo);
|
||||
let sig = Signature::now("Fabro", "noreply@fabro.sh").map_err(Error::from)?;
|
||||
let branch = Self::branch_name(run_id);
|
||||
let branch_store = BranchStore::new(&store, &branch, &sig);
|
||||
Ok(branch_store.read_entry(path)?)
|
||||
}
|
||||
|
||||
/// Read the projection snapshot from the metadata branch tip. Returns
|
||||
/// `None` if branch or file doesn't exist.
|
||||
pub fn read_run_projection(
|
||||
repo_path: &Path,
|
||||
run_id: &str,
|
||||
) -> Result<Option<RunProjection>, MetadataError> {
|
||||
let branch = Self::branch_name(run_id);
|
||||
let Some(bytes) = Self::read_file(repo_path, run_id, "run.json")? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let projection: RunProjection =
|
||||
serde_json::from_slice(&bytes).map_err(|source| MetadataError::Deserialize {
|
||||
entity: "run projection",
|
||||
branch,
|
||||
source,
|
||||
})?;
|
||||
Ok(Some(projection))
|
||||
}
|
||||
|
||||
/// Read a checkpoint from the metadata branch. Returns `None` if branch or
|
||||
/// file doesn't exist.
|
||||
pub fn read_checkpoint(
|
||||
repo_path: &Path,
|
||||
run_id: &str,
|
||||
) -> Result<Option<Checkpoint>, MetadataError> {
|
||||
Ok(Self::read_run_projection(repo_path, run_id)?
|
||||
.and_then(|projection| projection.checkpoint))
|
||||
}
|
||||
|
||||
/// Read the run spec from the metadata branch. Returns `None` if not
|
||||
/// found.
|
||||
pub fn read_run_spec(repo_path: &Path, run_id: &str) -> Result<Option<RunSpec>, MetadataError> {
|
||||
Ok(Self::read_run_projection(repo_path, run_id)?.and_then(|projection| projection.spec))
|
||||
}
|
||||
|
||||
/// Read the start record from the metadata branch. Returns `None` if not
|
||||
/// found.
|
||||
pub fn read_start_record(
|
||||
repo_path: &Path,
|
||||
run_id: &str,
|
||||
) -> Result<Option<StartRecord>, MetadataError> {
|
||||
Ok(Self::read_run_projection(repo_path, run_id)?.and_then(|projection| projection.start))
|
||||
}
|
||||
|
||||
/// Read an artifact from the metadata branch. Returns `None` if not found.
|
||||
pub fn read_artifact(
|
||||
repo_path: &Path,
|
||||
run_id: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<Vec<u8>>, MetadataError> {
|
||||
Self::read_file(repo_path, run_id, &format!("artifacts/{key}.json"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "These unit tests use the real git CLI to validate metadata branch behavior."
|
||||
)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fabro_types::{Graph, WorkflowSettings, fixtures};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Create a temporary git repo with an initial commit.
|
||||
fn init_repo(dir: &Path) {
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"user.name=test",
|
||||
"-c",
|
||||
"user.email=test@test",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"init",
|
||||
])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn test_run_spec(run_id: fabro_types::RunId) -> RunSpec {
|
||||
RunSpec {
|
||||
run_id,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
workflow_slug: None,
|
||||
working_directory: PathBuf::from("/tmp"),
|
||||
host_repo_path: None,
|
||||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_checkpoint(
|
||||
current_node: &str,
|
||||
completed_nodes: Vec<String>,
|
||||
next_node_id: Option<String>,
|
||||
) -> Checkpoint {
|
||||
Checkpoint {
|
||||
timestamp: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap(),
|
||||
current_node: current_node.to_string(),
|
||||
completed_nodes,
|
||||
node_retries: HashMap::new(),
|
||||
context_values: HashMap::new(),
|
||||
node_outcomes: HashMap::new(),
|
||||
next_node_id,
|
||||
git_commit_sha: None,
|
||||
loop_failure_signatures: HashMap::new(),
|
||||
restart_failure_signatures: HashMap::new(),
|
||||
node_visits: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_projection(run_id: fabro_types::RunId) -> RunProjection {
|
||||
let mut projection = RunProjection::default();
|
||||
projection.spec = Some(test_run_spec(run_id));
|
||||
projection
|
||||
}
|
||||
|
||||
fn projection_bytes(projection: &RunProjection) -> Vec<u8> {
|
||||
serde_json::to_vec_pretty(projection).unwrap()
|
||||
}
|
||||
|
||||
fn branch_entry(repo_dir: &Path, run_id: &str, path: &str) -> Vec<u8> {
|
||||
let repo = Repository::discover(repo_dir).unwrap();
|
||||
let store = Store::new(repo);
|
||||
let sig = Signature::now("Test", "test@example.com").unwrap();
|
||||
let branch = MetadataStore::branch_name(run_id);
|
||||
let branch_store = BranchStore::new(&store, &branch, &sig);
|
||||
branch_store.read_entry(path).unwrap().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_store_init_run_and_read() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
|
||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||
let run_id = fixtures::RUN_1.to_string();
|
||||
let projection = projection_bytes(&test_projection(fixtures::RUN_1));
|
||||
store
|
||||
.init_run(&run_id, &[("run.json", &projection)])
|
||||
.unwrap();
|
||||
|
||||
let read_spec = MetadataStore::read_run_spec(dir.path(), &run_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(read_spec.run_id, fixtures::RUN_1);
|
||||
assert_eq!(read_spec.graph.name, "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_store_write_and_read_checkpoint() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
|
||||
let run_id = fixtures::RUN_2.to_string();
|
||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||
let init_projection = projection_bytes(&test_projection(fixtures::RUN_2));
|
||||
store
|
||||
.init_run(&run_id, &[("run.json", &init_projection)])
|
||||
.unwrap();
|
||||
|
||||
let mut checkpoint = test_checkpoint(
|
||||
"node_a",
|
||||
vec!["start".to_string()],
|
||||
Some("node_b".to_string()),
|
||||
);
|
||||
checkpoint
|
||||
.context_values
|
||||
.insert("goal".to_string(), serde_json::json!("test"));
|
||||
let mut snapshot = test_projection(fixtures::RUN_2);
|
||||
snapshot.checkpoint = Some(checkpoint);
|
||||
let snapshot_json = projection_bytes(&snapshot);
|
||||
store
|
||||
.write_snapshot(&run_id, &[("run.json", &snapshot_json)], "checkpoint")
|
||||
.unwrap();
|
||||
|
||||
let loaded = MetadataStore::read_checkpoint(dir.path(), &run_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(loaded.current_node, "node_a");
|
||||
assert_eq!(loaded.completed_nodes, vec!["start"]);
|
||||
assert_eq!(loaded.next_node_id.as_deref(), Some("node_b"));
|
||||
assert_eq!(
|
||||
loaded.context_values.get("goal"),
|
||||
Some(&serde_json::json!("test"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_store_write_checkpoint_overwrites() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
|
||||
let run_id = fixtures::RUN_3.to_string();
|
||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||
let init_projection = projection_bytes(&test_projection(fixtures::RUN_3));
|
||||
store
|
||||
.init_run(&run_id, &[("run.json", &init_projection)])
|
||||
.unwrap();
|
||||
|
||||
let mut snapshot_one = test_projection(fixtures::RUN_3);
|
||||
snapshot_one.checkpoint = Some(test_checkpoint("node_a", vec!["start".to_string()], None));
|
||||
let checkpoint_one = projection_bytes(&snapshot_one);
|
||||
store
|
||||
.write_snapshot(&run_id, &[("run.json", &checkpoint_one)], "checkpoint")
|
||||
.unwrap();
|
||||
|
||||
let mut snapshot_two = test_projection(fixtures::RUN_3);
|
||||
snapshot_two.checkpoint = Some(test_checkpoint(
|
||||
"node_b",
|
||||
vec!["start".to_string(), "node_a".to_string()],
|
||||
Some("node_c".to_string()),
|
||||
));
|
||||
let checkpoint_two = projection_bytes(&snapshot_two);
|
||||
store
|
||||
.write_snapshot(&run_id, &[("run.json", &checkpoint_two)], "checkpoint")
|
||||
.unwrap();
|
||||
|
||||
let loaded = MetadataStore::read_checkpoint(dir.path(), &run_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(loaded.current_node, "node_b");
|
||||
assert_eq!(loaded.completed_nodes.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_store_read_checkpoint_missing_branch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
|
||||
let result = MetadataStore::read_checkpoint(dir.path(), "NONEXISTENT").unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_store_artifact_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
|
||||
let run_id = fixtures::RUN_4.to_string();
|
||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||
let init_projection = projection_bytes(&test_projection(fixtures::RUN_4));
|
||||
store
|
||||
.init_run(&run_id, &[("run.json", &init_projection)])
|
||||
.unwrap();
|
||||
|
||||
let artifact_data = br#"{"large_output":"some data"}"#;
|
||||
let mut snapshot = test_projection(fixtures::RUN_4);
|
||||
snapshot.checkpoint = Some(test_checkpoint("node_a", Vec::new(), None));
|
||||
let snapshot_json = projection_bytes(&snapshot);
|
||||
store
|
||||
.write_snapshot(
|
||||
&run_id,
|
||||
&[
|
||||
("run.json", &snapshot_json),
|
||||
("artifacts/response.plan.json", artifact_data.as_slice()),
|
||||
],
|
||||
"checkpoint",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let read_back = MetadataStore::read_artifact(dir.path(), &run_id, "response.plan")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(read_back, artifact_data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_store_write_snapshot_preserves_prior_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
|
||||
let run_id = fixtures::RUN_5.to_string();
|
||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||
let projection = projection_bytes(&test_projection(fixtures::RUN_5));
|
||||
store
|
||||
.init_run(&run_id, &[("run.json", &projection)])
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.write_snapshot(
|
||||
&run_id,
|
||||
&[("stages/retro/prompt.md", b"how did it go?")],
|
||||
"finalize run",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let data = branch_entry(dir.path(), &run_id, "stages/retro/prompt.md");
|
||||
assert_eq!(data, b"how did it go?");
|
||||
|
||||
let spec = MetadataStore::read_run_spec(dir.path(), &run_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(spec.run_id, fixtures::RUN_5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_store_init_run_with_extra_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
|
||||
let run_id = fixtures::RUN_6.to_string();
|
||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||
store
|
||||
.init_run(&run_id, &[("graph.fabro", b"digraph Test {}")])
|
||||
.unwrap();
|
||||
|
||||
let data = branch_entry(dir.path(), &run_id, "graph.fabro");
|
||||
assert_eq!(data, b"digraph Test {}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_store_read_start_record_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
|
||||
let run_id = fixtures::RUN_6.to_string();
|
||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||
let start_record = StartRecord {
|
||||
run_id: fixtures::RUN_6,
|
||||
start_time: Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap(),
|
||||
run_branch: Some("fabro/run/test".to_string()),
|
||||
base_sha: None,
|
||||
};
|
||||
let mut projection = test_projection(fixtures::RUN_6);
|
||||
projection.start = Some(start_record);
|
||||
let bytes = projection_bytes(&projection);
|
||||
store.init_run(&run_id, &[("run.json", &bytes)]).unwrap();
|
||||
|
||||
let loaded = MetadataStore::read_start_record(dir.path(), &run_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(loaded.run_id, fixtures::RUN_6);
|
||||
assert_eq!(loaded.run_branch.as_deref(), Some("fabro/run/test"));
|
||||
}
|
||||
}
|
||||
|
|
@ -236,6 +236,14 @@ pub(crate) struct RunArgs {
|
|||
#[arg(long, value_enum)]
|
||||
pub(crate) sandbox: Option<CliSandboxProvider>,
|
||||
|
||||
/// Run directly in the source checkout without git checkpoints
|
||||
#[arg(long, requires = "allow_no_checkpoints", conflicts_with = "sandbox")]
|
||||
pub(crate) in_place: bool,
|
||||
|
||||
/// Acknowledge that --in-place disables checkpoints
|
||||
#[arg(long, requires = "in_place")]
|
||||
pub(crate) allow_no_checkpoints: bool,
|
||||
|
||||
/// Attach a label to this run (repeatable, format: KEY=VALUE)
|
||||
#[arg(long = "label", value_name = "KEY=VALUE")]
|
||||
pub(crate) label: Vec<String>,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -7,16 +7,15 @@ use std::collections::{HashMap, HashSet};
|
|||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use fabro_api::types;
|
||||
use fabro_api::types::{self, ManifestPreRunPushOutcome, ManifestPreRunPushOutcomeType};
|
||||
use fabro_config::project::{self, discover_project_config, resolve_workflow_path};
|
||||
use fabro_config::run::{resolve_run_goal_from_layer, resolve_run_goal_from_namespace};
|
||||
use fabro_config::{CliLayer, DaytonaDockerfileLayer, RunLayer, WorkflowSettingsBuilder};
|
||||
use fabro_graphviz::graph::AttrValue;
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_sandbox::daytona::detect_repo_info;
|
||||
use fabro_types::settings::run::{ResolvedGoalSource, ResolvedRunGoal};
|
||||
use fabro_types::{RunId, WorkflowSettings};
|
||||
use fabro_workflow::git::{GitSyncStatus, head_sha, sync_status};
|
||||
use fabro_workflow::git::{GitSyncStatus, branch_needs_push, head_sha, push_branch, sync_status};
|
||||
|
||||
use crate::args::{PreflightArgs, RunArgs};
|
||||
|
||||
|
|
@ -162,36 +161,44 @@ pub(crate) fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManif
|
|||
|
||||
pub(crate) fn run_manifest_args(args: &RunArgs) -> Option<types::ManifestArgs> {
|
||||
let payload = types::ManifestArgs {
|
||||
auto_approve: args.auto_approve.then_some(true),
|
||||
dry_run: args.dry_run.then_some(true),
|
||||
label: args.label.clone(),
|
||||
model: args.model.clone(),
|
||||
no_retro: args.no_retro.then_some(true),
|
||||
preserve_sandbox: args.preserve_sandbox.then_some(true),
|
||||
provider: args.provider.clone(),
|
||||
sandbox: args
|
||||
auto_approve: args.auto_approve.then_some(true),
|
||||
dry_run: args.dry_run.then_some(true),
|
||||
label: args.label.clone(),
|
||||
model: args.model.clone(),
|
||||
no_retro: args.no_retro.then_some(true),
|
||||
preserve_sandbox: args.preserve_sandbox.then_some(true),
|
||||
provider: args.provider.clone(),
|
||||
sandbox: args
|
||||
.sandbox
|
||||
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()),
|
||||
docker_image: None,
|
||||
verbose: args.verbose.then_some(true),
|
||||
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string())
|
||||
.or_else(|| {
|
||||
args.in_place
|
||||
.then(|| fabro_sandbox::SandboxProvider::Local.to_string())
|
||||
}),
|
||||
docker_image: None,
|
||||
verbose: args.verbose.then_some(true),
|
||||
in_place: args.in_place.then_some(true),
|
||||
allow_no_checkpoints: args.allow_no_checkpoints.then_some(true),
|
||||
};
|
||||
(!manifest_args_is_empty(&payload)).then_some(payload)
|
||||
}
|
||||
|
||||
pub(crate) fn preflight_manifest_args(args: &PreflightArgs) -> Option<types::ManifestArgs> {
|
||||
let payload = types::ManifestArgs {
|
||||
auto_approve: None,
|
||||
dry_run: None,
|
||||
label: Vec::new(),
|
||||
model: args.model.clone(),
|
||||
no_retro: None,
|
||||
preserve_sandbox: None,
|
||||
provider: args.provider.clone(),
|
||||
sandbox: args
|
||||
auto_approve: None,
|
||||
dry_run: None,
|
||||
label: Vec::new(),
|
||||
model: args.model.clone(),
|
||||
no_retro: None,
|
||||
preserve_sandbox: None,
|
||||
provider: args.provider.clone(),
|
||||
sandbox: args
|
||||
.sandbox
|
||||
.map(|provider| fabro_sandbox::SandboxProvider::from(provider).to_string()),
|
||||
docker_image: None,
|
||||
verbose: args.verbose.then_some(true),
|
||||
docker_image: None,
|
||||
verbose: args.verbose.then_some(true),
|
||||
in_place: None,
|
||||
allow_no_checkpoints: None,
|
||||
};
|
||||
(!manifest_args_is_empty(&payload)).then_some(payload)
|
||||
}
|
||||
|
|
@ -493,18 +500,76 @@ fn resolved_goal_to_manifest(resolved: ResolvedRunGoal) -> types::ManifestGoal {
|
|||
}
|
||||
|
||||
fn build_manifest_git(repo_path: &Path) -> Option<types::ManifestGit> {
|
||||
let (origin_url, branch) = detect_repo_info(repo_path).ok()?;
|
||||
let branch = branch?;
|
||||
let (origin_url, branch) = detect_manifest_repo_info(repo_path)?;
|
||||
let sha = head_sha(repo_path).ok()?;
|
||||
let clean = sync_status(repo_path, "origin", Some(&branch)) != GitSyncStatus::Dirty;
|
||||
let status = sync_status(repo_path, "origin", Some(&branch));
|
||||
let clean = status != GitSyncStatus::Dirty;
|
||||
let push_outcome = build_manifest_push_outcome(repo_path, &branch, origin_url.as_deref());
|
||||
Some(types::ManifestGit {
|
||||
branch,
|
||||
clean,
|
||||
origin_url: fabro_github::normalize_repo_origin_url(&origin_url),
|
||||
origin_url: origin_url
|
||||
.as_deref()
|
||||
.map(fabro_github::normalize_repo_origin_url)
|
||||
.unwrap_or_default(),
|
||||
push_outcome,
|
||||
sha,
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_manifest_repo_info(repo_path: &Path) -> Option<(Option<String>, String)> {
|
||||
let repo = git2::Repository::discover(repo_path).ok()?;
|
||||
let branch = repo.head().ok()?.shorthand().map(ToOwned::to_owned)?;
|
||||
let origin_url = repo
|
||||
.find_remote("origin")
|
||||
.ok()
|
||||
.and_then(|remote| remote.url().map(ToOwned::to_owned));
|
||||
Some((origin_url, branch))
|
||||
}
|
||||
|
||||
fn build_manifest_push_outcome(
|
||||
repo_path: &Path,
|
||||
branch: &str,
|
||||
origin_url: Option<&str>,
|
||||
) -> ManifestPreRunPushOutcome {
|
||||
if origin_url.is_none() {
|
||||
return ManifestPreRunPushOutcome {
|
||||
type_: ManifestPreRunPushOutcomeType::SkippedNoRemote,
|
||||
remote: None,
|
||||
branch: Some(branch.to_string()),
|
||||
message: None,
|
||||
repo_origin_url: None,
|
||||
};
|
||||
}
|
||||
|
||||
if !branch_needs_push(repo_path, "origin", branch) {
|
||||
return ManifestPreRunPushOutcome {
|
||||
type_: ManifestPreRunPushOutcomeType::NotAttempted,
|
||||
remote: None,
|
||||
branch: None,
|
||||
message: None,
|
||||
repo_origin_url: None,
|
||||
};
|
||||
}
|
||||
|
||||
match push_branch(repo_path, "origin", branch) {
|
||||
Ok(()) => ManifestPreRunPushOutcome {
|
||||
type_: ManifestPreRunPushOutcomeType::Succeeded,
|
||||
remote: Some("origin".to_string()),
|
||||
branch: Some(branch.to_string()),
|
||||
message: None,
|
||||
repo_origin_url: None,
|
||||
},
|
||||
Err(err) => ManifestPreRunPushOutcome {
|
||||
type_: ManifestPreRunPushOutcomeType::Failed,
|
||||
remote: Some("origin".to_string()),
|
||||
branch: Some(branch.to_string()),
|
||||
message: Some(err.to_string()),
|
||||
repo_origin_url: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_absolute_path(base_dir: &Path, reference: &str) -> Option<PathBuf> {
|
||||
let path = Path::new(reference);
|
||||
if path.is_absolute() || reference.starts_with('~') {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ fn help() {
|
|||
--provider <PROVIDER> Override default LLM provider
|
||||
-v, --verbose Enable verbose output
|
||||
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona]
|
||||
--in-place Run directly in the source checkout without git checkpoints
|
||||
--allow-no-checkpoints Acknowledge that --in-place disables checkpoints
|
||||
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
|
||||
--no-retro Skip retro generation after the run
|
||||
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -169,6 +169,8 @@ fn help() {
|
|||
--provider <PROVIDER> Override default LLM provider
|
||||
-v, --verbose Enable verbose output
|
||||
--sandbox <SANDBOX> Sandbox for agent tools [possible values: local, docker, daytona]
|
||||
--in-place Run directly in the source checkout without git checkpoints
|
||||
--allow-no-checkpoints Acknowledge that --in-place disables checkpoints
|
||||
--label <KEY=VALUE> Attach a label to this run (repeatable, format: KEY=VALUE)
|
||||
--no-retro Skip retro generation after the run
|
||||
--preserve-sandbox Keep the sandbox alive after the run finishes (for debugging)
|
||||
|
|
@ -672,6 +674,7 @@ fn dry_run_simple() {
|
|||
Goal: Run tests and report results
|
||||
|
||||
Run: [ULID]
|
||||
Warning: Uncommitted changes will not be included in the worktree. [dirty_worktree]
|
||||
Sandbox: local (ready in [TIME])
|
||||
✓ Start [TIME]
|
||||
✓ Run Tests [TIME]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
reason = "These CLI integration test helpers shell out to real git and fabro binaries while constructing fixtures."
|
||||
)]
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Output;
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -120,7 +119,7 @@ pub(crate) fn mock_resolved_run<'a>(
|
|||
"goal": "Nightly run",
|
||||
"title": "Nightly run",
|
||||
"labels": {},
|
||||
"host_repo_path": null,
|
||||
"source_directory": null,
|
||||
"repository": { "name": "unknown" },
|
||||
"start_time": "2026-04-05T12:00:00Z",
|
||||
"created_at": "2026-04-05T12:00:00Z",
|
||||
|
|
@ -755,6 +754,13 @@ pub(crate) fn run_state(run_dir: &Path) -> RunProjection {
|
|||
))
|
||||
}
|
||||
|
||||
pub(crate) fn run_state_by_id(context: &TestContext, run_id: &str) -> RunProjection {
|
||||
block_on(get_server_json_for_storage(
|
||||
&context.storage_dir,
|
||||
&format!("/api/v1/runs/{run_id}/state"),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn run_events(run_dir: &Path) -> Vec<EventEnvelope> {
|
||||
let run_id = infer_run_id(run_dir);
|
||||
let response: serde_json::Value = block_on(get_server_json(
|
||||
|
|
@ -796,28 +802,6 @@ pub(crate) fn git_stdout(repo_dir: &Path, args: &[&str]) -> String {
|
|||
stdout(&git_success(repo_dir, args))
|
||||
}
|
||||
|
||||
pub(crate) fn metadata_run_ids(repo_dir: &Path) -> BTreeSet<String> {
|
||||
git_stdout(repo_dir, &["branch", "--format=%(refname:short)"])
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter_map(|line| line.strip_prefix("fabro/meta/"))
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn run_branch_commits(repo_dir: &Path, run_id: &str) -> Vec<String> {
|
||||
git_stdout(repo_dir, &[
|
||||
"rev-list",
|
||||
"--reverse",
|
||||
&format!("fabro/run/{run_id}"),
|
||||
])
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn run_branch_commits_since_base(
|
||||
repo_dir: &Path,
|
||||
run_id: &str,
|
||||
|
|
@ -835,12 +819,6 @@ pub(crate) fn run_branch_commits_since_base(
|
|||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn git_show_json(repo_dir: &Path, revspec: &str) -> Value {
|
||||
let output = git_success(repo_dir, &["show", revspec]);
|
||||
serde_json::from_str(&stdout(&output))
|
||||
.unwrap_or_else(|err| panic!("failed to parse git show {revspec}: {err}"))
|
||||
}
|
||||
|
||||
pub(crate) fn text_tree(root: &Path) -> Vec<String> {
|
||||
fn visit(root: &Path, dir: &Path, entries: &mut Vec<String>) {
|
||||
let mut children: Vec<_> = std::fs::read_dir(dir)
|
||||
|
|
@ -1041,6 +1019,19 @@ fn setup_git_backed_run(context: &TestContext, workflow: GitWorkflowKind) -> Git
|
|||
|
||||
git_success(&repo_dir, &["add", "story.txt", "flow.fabro"]);
|
||||
git_success(&repo_dir, &["commit", "-qm", "init"]);
|
||||
let remote_dir = context.temp_dir.join(match workflow {
|
||||
GitWorkflowKind::Changed => "git-changed-remote.git",
|
||||
GitWorkflowKind::Noop => "git-noop-remote.git",
|
||||
});
|
||||
let remote_dir_str = remote_dir.display().to_string();
|
||||
git_success(&context.temp_dir, &[
|
||||
"init",
|
||||
"--bare",
|
||||
"-q",
|
||||
&remote_dir_str,
|
||||
]);
|
||||
git_success(&repo_dir, &["remote", "add", "origin", &remote_dir_str]);
|
||||
git_success(&repo_dir, &["push", "-u", "origin", "HEAD:main"]);
|
||||
let base_sha = git_stdout(&repo_dir, &["rev-parse", "HEAD"])
|
||||
.trim()
|
||||
.to_string();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ use std::path::Path;
|
|||
|
||||
use fabro_checkpoint::git::Store as GitStore;
|
||||
use fabro_store::RunProjection;
|
||||
use fabro_test::{fabro_snapshot, test_context};
|
||||
use fabro_types::Checkpoint;
|
||||
use fabro_test::{TestContext, fabro_snapshot, test_context};
|
||||
use fabro_workflow::operations::{RunTimeline, build_timeline};
|
||||
use git2::Repository;
|
||||
|
||||
use crate::cmd::support::output_stdout;
|
||||
use crate::support::unique_run_id;
|
||||
|
||||
fn list_metadata_run_ids(repo_dir: &Path) -> BTreeSet<String> {
|
||||
|
|
@ -28,21 +28,20 @@ fn list_metadata_run_ids(repo_dir: &Path) -> BTreeSet<String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn latest_metadata_checkpoint(repo_dir: &Path, run_id: &str) -> Checkpoint {
|
||||
let repo = Repository::discover(repo_dir).expect("recovery fixture should be a git repo");
|
||||
fn load_metadata_projection(repo_dir: &Path, run_id: &str) -> Result<RunProjection, String> {
|
||||
let repo = Repository::discover(repo_dir)
|
||||
.map_err(|err| format!("recovery fixture should be a git repo: {err}"))?;
|
||||
let store = GitStore::new(repo);
|
||||
let tip = store
|
||||
.resolve_ref(&format!("fabro/meta/{run_id}"))
|
||||
.expect("metadata branch should resolve")
|
||||
.expect("metadata branch tip should exist");
|
||||
.map_err(|err| format!("metadata branch should resolve: {err}"))?
|
||||
.ok_or_else(|| "metadata branch tip should exist".to_string())?;
|
||||
let projection_blob = store
|
||||
.read_blob_at(tip, "run.json")
|
||||
.expect("latest projection blob should load")
|
||||
.expect("latest projection blob should exist");
|
||||
serde_json::from_slice::<RunProjection>(&projection_blob)
|
||||
.expect("latest projection blob should deserialize")
|
||||
.checkpoint
|
||||
.expect("latest projection checkpoint should exist")
|
||||
.map_err(|err| format!("latest projection blob should load: {err}"))?
|
||||
.ok_or_else(|| "latest projection blob should exist".to_string())?;
|
||||
serde_json::from_slice(&projection_blob)
|
||||
.map_err(|err| format!("latest projection blob should deserialize: {err}"))
|
||||
}
|
||||
|
||||
fn timeline_run_shas(repo_dir: &Path, run_id: &str) -> Vec<Option<String>> {
|
||||
|
|
@ -60,9 +59,10 @@ fn timeline_run_shas(repo_dir: &Path, run_id: &str) -> Vec<Option<String>> {
|
|||
fn build_timeline_when_ready(repo_dir: &Path, run_id: &str) -> RunTimeline {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
let repo = Repository::discover(repo_dir).expect("recovery fixture should stay a git repo");
|
||||
let store = GitStore::new(repo);
|
||||
match build_timeline(&store, run_id) {
|
||||
let timeline = load_metadata_projection(repo_dir, run_id)
|
||||
.map_err(anyhow::Error::msg)
|
||||
.and_then(|projection| build_timeline(&projection));
|
||||
match timeline {
|
||||
Ok(timeline) => return timeline,
|
||||
Err(err) => {
|
||||
assert!(
|
||||
|
|
@ -75,6 +75,38 @@ fn build_timeline_when_ready(repo_dir: &Path, run_id: &str) -> RunTimeline {
|
|||
}
|
||||
}
|
||||
|
||||
fn fork_run_json(context: &TestContext, repo_dir: &Path, source_run_id: &str) -> String {
|
||||
let output = context
|
||||
.command()
|
||||
.current_dir(repo_dir)
|
||||
.args(["fork", source_run_id, "--json", "--no-push"])
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.output()
|
||||
.expect("fork command should execute");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"fork should succeed\nstdout:\n{}\nstderr:\n{}",
|
||||
output_stdout(&output),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_str::<serde_json::Value>(&output_stdout(&output))
|
||||
.expect("fork json should parse")
|
||||
.get("new_run_id")
|
||||
.and_then(|value| value.as_str())
|
||||
.expect("fork json should contain new_run_id")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn latest_store_checkpoint_sha(context: &TestContext, run_id: &str) -> Option<String> {
|
||||
let state: RunProjection = super::block_on(super::get_server_json_for_storage(
|
||||
&context.storage_dir,
|
||||
&format!("/api/v1/runs/{run_id}/state"),
|
||||
));
|
||||
state
|
||||
.checkpoint
|
||||
.and_then(|checkpoint| checkpoint.git_commit_sha)
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "This sync git integration helper retries metadata branch deletion until libgit2 releases its lock."
|
||||
|
|
@ -144,6 +176,38 @@ digraph Recovery {
|
|||
.status()
|
||||
.expect("git commit should launch");
|
||||
assert!(commit.success(), "git commit should succeed");
|
||||
|
||||
let remote_dir = repo_dir
|
||||
.parent()
|
||||
.expect("recovery repo should have a parent")
|
||||
.join(format!(
|
||||
"{}-remote.git",
|
||||
repo_dir
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("recovery")
|
||||
));
|
||||
let remote_init = std::process::Command::new("git")
|
||||
.args(["init", "--bare", "-q"])
|
||||
.arg(&remote_dir)
|
||||
.status()
|
||||
.expect("git init --bare should launch");
|
||||
assert!(remote_init.success(), "git init --bare should succeed");
|
||||
|
||||
let remote_add = std::process::Command::new("git")
|
||||
.args(["remote", "add", "origin"])
|
||||
.arg(&remote_dir)
|
||||
.current_dir(repo_dir)
|
||||
.status()
|
||||
.expect("git remote add should launch");
|
||||
assert!(remote_add.success(), "git remote add should succeed");
|
||||
|
||||
let push = std::process::Command::new("git")
|
||||
.args(["push", "-u", "origin", "HEAD:main"])
|
||||
.current_dir(repo_dir)
|
||||
.status()
|
||||
.expect("git push should launch");
|
||||
assert!(push.success(), "git push should succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -187,7 +251,10 @@ fn rewind_list_reports_empty_timeline_when_metadata_branch_is_missing() {
|
|||
exit_code: 0
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
No checkpoints found.
|
||||
@ Node Details
|
||||
@1 start (no run commit)
|
||||
@2 plan
|
||||
@3 build
|
||||
");
|
||||
|
||||
assert!(
|
||||
|
|
@ -225,37 +292,15 @@ fn fork_chain_preserves_checkpoint_metadata() {
|
|||
let build_sha = timeline_shas.last().cloned().flatten();
|
||||
assert!(build_sha.is_some());
|
||||
|
||||
let before_child = list_metadata_run_ids(repo_dir.path());
|
||||
context
|
||||
.command()
|
||||
.current_dir(repo_dir.path())
|
||||
.args(["fork", &source_run_id, "--no-push"])
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.assert()
|
||||
.success();
|
||||
let after_child = list_metadata_run_ids(repo_dir.path());
|
||||
let child_run_ids: Vec<_> = after_child.difference(&before_child).cloned().collect();
|
||||
assert_eq!(child_run_ids.len(), 1, "expected one child run");
|
||||
let child_run_id = &child_run_ids[0];
|
||||
let child_run_id = fork_run_json(&context, repo_dir.path(), &source_run_id);
|
||||
assert_eq!(
|
||||
latest_store_checkpoint_sha(&context, &child_run_id),
|
||||
build_sha
|
||||
);
|
||||
|
||||
let child_checkpoint = latest_metadata_checkpoint(repo_dir.path(), child_run_id);
|
||||
assert_eq!(child_checkpoint.git_commit_sha, build_sha);
|
||||
|
||||
let before_grandchild = list_metadata_run_ids(repo_dir.path());
|
||||
context
|
||||
.command()
|
||||
.current_dir(repo_dir.path())
|
||||
.args(["fork", child_run_id, "--no-push"])
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.assert()
|
||||
.success();
|
||||
let after_grandchild = list_metadata_run_ids(repo_dir.path());
|
||||
let grandchild_run_ids: Vec<_> = after_grandchild
|
||||
.difference(&before_grandchild)
|
||||
.cloned()
|
||||
.collect();
|
||||
assert_eq!(grandchild_run_ids.len(), 1, "expected one grandchild run");
|
||||
|
||||
let grandchild_checkpoint = latest_metadata_checkpoint(repo_dir.path(), &grandchild_run_ids[0]);
|
||||
assert_eq!(grandchild_checkpoint.git_commit_sha, build_sha);
|
||||
let grandchild_run_id = fork_run_json(&context, repo_dir.path(), &child_run_id);
|
||||
assert_eq!(
|
||||
latest_store_checkpoint_sha(&context, &grandchild_run_id),
|
||||
build_sha
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ preserve = false
|
|||
devcontainer = false
|
||||
|
||||
[run.sandbox.local]
|
||||
worktree_mode = "clean"
|
||||
worktree_mode = "always"
|
||||
|
||||
[run.sandbox.docker]
|
||||
image = "buildpack-deps:noble"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -749,11 +749,14 @@ impl Sandbox for DaytonaSandbox {
|
|||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn setup_git_for_run(&self, run_id: &str) -> crate::Result<Option<crate::GitRunInfo>> {
|
||||
async fn setup_git(
|
||||
&self,
|
||||
intent: &crate::GitSetupIntent,
|
||||
) -> crate::Result<Option<crate::GitRunInfo>> {
|
||||
if !self.repo_cloned() {
|
||||
return Ok(None);
|
||||
}
|
||||
crate::setup_git_via_exec(self, run_id).await.map(Some)
|
||||
crate::setup_git_via_exec(self, intent).await.map(Some)
|
||||
}
|
||||
|
||||
fn resume_setup_commands(&self, run_branch: &str) -> Vec<String> {
|
||||
|
|
@ -767,11 +770,11 @@ impl Sandbox for DaytonaSandbox {
|
|||
)]
|
||||
}
|
||||
|
||||
async fn git_push_branch(&self, branch: &str) -> bool {
|
||||
async fn git_push_ref(&self, refspec: &str) -> bool {
|
||||
if !self.repo_cloned() {
|
||||
return false;
|
||||
}
|
||||
crate::git_push_via_exec(self, branch).await
|
||||
crate::git_push_via_exec(self, refspec).await
|
||||
}
|
||||
|
||||
fn parallel_worktree_path(
|
||||
|
|
|
|||
|
|
@ -1184,11 +1184,14 @@ impl Sandbox for DockerSandbox {
|
|||
self.container_id.get().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn setup_git_for_run(&self, run_id: &str) -> crate::Result<Option<crate::GitRunInfo>> {
|
||||
async fn setup_git(
|
||||
&self,
|
||||
intent: &crate::GitSetupIntent,
|
||||
) -> crate::Result<Option<crate::GitRunInfo>> {
|
||||
if !self.repo_cloned() {
|
||||
return Ok(None);
|
||||
}
|
||||
crate::setup_git_via_exec(self, run_id).await.map(Some)
|
||||
crate::setup_git_via_exec(self, intent).await.map(Some)
|
||||
}
|
||||
|
||||
fn resume_setup_commands(&self, run_branch: &str) -> Vec<String> {
|
||||
|
|
@ -1202,11 +1205,11 @@ impl Sandbox for DockerSandbox {
|
|||
)]
|
||||
}
|
||||
|
||||
async fn git_push_branch(&self, branch: &str) -> bool {
|
||||
async fn git_push_ref(&self, refspec: &str) -> bool {
|
||||
if !self.repo_cloned() {
|
||||
return false;
|
||||
}
|
||||
crate::git_push_via_exec(self, branch).await
|
||||
crate::git_push_via_exec(self, refspec).await
|
||||
}
|
||||
|
||||
fn parallel_worktree_path(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ use std::collections::HashMap;
|
|||
use std::fmt::Write;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::time;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Git command prefix that disables background maintenance.
|
||||
|
|
@ -17,6 +19,18 @@ pub struct GitRunInfo {
|
|||
pub base_branch: Option<String>,
|
||||
}
|
||||
|
||||
/// Git setup requested by the workflow layer.
|
||||
pub enum GitSetupIntent {
|
||||
NewRun {
|
||||
run_id: String,
|
||||
},
|
||||
ForkFromCheckpoint {
|
||||
new_run_id: String,
|
||||
source_run_id: String,
|
||||
checkpoint_sha: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Generates an `#[async_trait] impl Sandbox` block for a decorator type
|
||||
/// that wraps an `Arc<dyn Sandbox>`. The caller provides custom method
|
||||
/// implementations; all remaining trait methods delegate to the inner field.
|
||||
|
|
@ -121,20 +135,16 @@ macro_rules! delegate_sandbox {
|
|||
self.$field.set_autostop_interval(minutes).await
|
||||
}
|
||||
|
||||
async fn setup_git_for_run(&self, run_id: &str) -> $crate::Result<Option<$crate::GitRunInfo>> {
|
||||
self.$field.setup_git_for_run(run_id).await
|
||||
async fn setup_git(&self, intent: &$crate::GitSetupIntent) -> $crate::Result<Option<$crate::GitRunInfo>> {
|
||||
self.$field.setup_git(intent).await
|
||||
}
|
||||
|
||||
fn resume_setup_commands(&self, run_branch: &str) -> Vec<String> {
|
||||
self.$field.resume_setup_commands(run_branch)
|
||||
}
|
||||
|
||||
async fn git_push_branch(&self, branch: &str) -> bool {
|
||||
self.$field.git_push_branch(branch).await
|
||||
}
|
||||
|
||||
fn host_git_dir(&self) -> Option<&str> {
|
||||
self.$field.host_git_dir()
|
||||
async fn git_push_ref(&self, refspec: &str) -> bool {
|
||||
self.$field.git_push_ref(refspec).await
|
||||
}
|
||||
|
||||
fn parallel_worktree_path(
|
||||
|
|
@ -452,11 +462,10 @@ pub trait Sandbox: Send + Sync {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Set up git state for a new workflow run.
|
||||
/// Set up git state for a workflow run.
|
||||
/// Sandboxes that manage their own git clone (e.g., remote VMs) should
|
||||
/// create a run branch and return the git info. Local sandboxes return
|
||||
/// `None`.
|
||||
async fn setup_git_for_run(&self, _run_id: &str) -> crate::Result<Option<GitRunInfo>> {
|
||||
/// create a run branch and return the git info.
|
||||
async fn setup_git(&self, _intent: &GitSetupIntent) -> crate::Result<Option<GitRunInfo>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
|
|
@ -466,19 +475,11 @@ pub trait Sandbox: Send + Sync {
|
|||
Vec::new()
|
||||
}
|
||||
|
||||
/// Push a run branch to origin from inside the sandbox.
|
||||
/// Returns `true` if the push was handled. When `false`, the engine will
|
||||
/// attempt a host-side push instead.
|
||||
async fn git_push_branch(&self, _branch: &str) -> bool {
|
||||
/// Push a full refspec to origin from inside the sandbox.
|
||||
async fn git_push_ref(&self, _refspec: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The host-accessible path to this sandbox's git worktree, if applicable.
|
||||
/// When `Some`, the engine runs git operations (add, commit) from the host.
|
||||
fn host_git_dir(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Compute the filesystem path for a parallel branch worktree.
|
||||
fn parallel_worktree_path(
|
||||
&self,
|
||||
|
|
@ -547,7 +548,10 @@ pub fn shell_quote(s: &str) -> String {
|
|||
|
||||
/// Helper for sandbox implementations that manage git internally.
|
||||
/// Executes git commands inside the sandbox to create a run branch.
|
||||
pub async fn setup_git_via_exec(sandbox: &dyn Sandbox, run_id: &str) -> crate::Result<GitRunInfo> {
|
||||
pub async fn setup_git_via_exec(
|
||||
sandbox: &dyn Sandbox,
|
||||
intent: &GitSetupIntent,
|
||||
) -> crate::Result<GitRunInfo> {
|
||||
// Get current branch name
|
||||
let branch_result = sandbox
|
||||
.exec_command("git rev-parse --abbrev-ref HEAD", 10_000, None, None, None)
|
||||
|
|
@ -566,30 +570,45 @@ pub async fn setup_git_via_exec(sandbox: &dyn Sandbox, run_id: &str) -> crate::R
|
|||
None
|
||||
};
|
||||
|
||||
// Get current HEAD as base SHA
|
||||
let sha_result = sandbox
|
||||
.exec_command("git rev-parse HEAD", 10_000, None, None, None)
|
||||
.await
|
||||
.map_err(|e| crate::Error::message(format!("git rev-parse HEAD failed: {e}")))?;
|
||||
if sha_result.exit_code != 0 {
|
||||
return Err(crate::Error::message(format!(
|
||||
"git rev-parse HEAD failed (exit {}): {}",
|
||||
sha_result.exit_code, sha_result.stderr
|
||||
)));
|
||||
}
|
||||
let base_sha = sha_result.stdout.trim().to_string();
|
||||
let (base_sha, branch_name) = match intent {
|
||||
GitSetupIntent::NewRun { run_id } => {
|
||||
let sha_result = sandbox
|
||||
.exec_command("git rev-parse HEAD", 10_000, None, None, None)
|
||||
.await
|
||||
.map_err(|e| crate::Error::message(format!("git rev-parse HEAD failed: {e}")))?;
|
||||
if sha_result.exit_code != 0 {
|
||||
return Err(crate::Error::message(format!(
|
||||
"git rev-parse HEAD failed (exit {}): {}",
|
||||
sha_result.exit_code, sha_result.stderr
|
||||
)));
|
||||
}
|
||||
(
|
||||
sha_result.stdout.trim().to_string(),
|
||||
format!("fabro/run/{run_id}"),
|
||||
)
|
||||
}
|
||||
GitSetupIntent::ForkFromCheckpoint {
|
||||
new_run_id,
|
||||
source_run_id,
|
||||
checkpoint_sha,
|
||||
} => {
|
||||
fetch_source_run_ref(sandbox, source_run_id, checkpoint_sha).await?;
|
||||
(checkpoint_sha.clone(), format!("fabro/run/{new_run_id}"))
|
||||
}
|
||||
};
|
||||
|
||||
let branch_name = format!("fabro/run/{run_id}");
|
||||
|
||||
// Create and checkout a run branch
|
||||
let checkout_cmd = format!("git checkout -b {}", shell_quote(&branch_name));
|
||||
let checkout_cmd = format!(
|
||||
"git checkout -B {} {}",
|
||||
shell_quote(&branch_name),
|
||||
shell_quote(&base_sha)
|
||||
);
|
||||
let checkout_result = sandbox
|
||||
.exec_command(&checkout_cmd, 10_000, None, None, None)
|
||||
.await
|
||||
.map_err(|e| crate::Error::message(format!("git checkout failed: {e}")))?;
|
||||
if checkout_result.exit_code != 0 {
|
||||
return Err(crate::Error::message(format!(
|
||||
"git checkout -b failed (exit {}): {}",
|
||||
"git checkout -B failed (exit {}): {}",
|
||||
checkout_result.exit_code, checkout_result.stderr
|
||||
)));
|
||||
}
|
||||
|
|
@ -601,24 +620,72 @@ pub async fn setup_git_via_exec(sandbox: &dyn Sandbox, run_id: &str) -> crate::R
|
|||
})
|
||||
}
|
||||
|
||||
async fn fetch_source_run_ref(
|
||||
sandbox: &dyn Sandbox,
|
||||
source_run_id: &str,
|
||||
checkpoint_sha: &str,
|
||||
) -> crate::Result<()> {
|
||||
let remote_ref = format!("refs/heads/fabro/run/{source_run_id}");
|
||||
let tracking_ref = format!("refs/remotes/origin/fabro/run/{source_run_id}");
|
||||
let fetch_cmd = format!(
|
||||
"{GIT} fetch origin {}:{}",
|
||||
shell_quote(&remote_ref),
|
||||
shell_quote(&tracking_ref)
|
||||
);
|
||||
let check_cmd = format!(
|
||||
"{GIT} merge-base --is-ancestor {} {}",
|
||||
shell_quote(checkpoint_sha),
|
||||
shell_quote(&tracking_ref)
|
||||
);
|
||||
|
||||
let mut last_error = String::new();
|
||||
for _ in 0..5 {
|
||||
let fetch = sandbox
|
||||
.exec_command(&fetch_cmd, 30_000, None, None, None)
|
||||
.await?;
|
||||
if fetch.exit_code != 0 {
|
||||
last_error = format!(
|
||||
"git fetch source run ref failed (exit {}): {}",
|
||||
fetch.exit_code,
|
||||
fetch.stderr.trim()
|
||||
);
|
||||
} else {
|
||||
let check = sandbox
|
||||
.exec_command(&check_cmd, 10_000, None, None, None)
|
||||
.await?;
|
||||
if check.exit_code == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
last_error = format!(
|
||||
"checkpoint {checkpoint_sha} is not reachable from {remote_ref} (exit {}): {}",
|
||||
check.exit_code,
|
||||
check.stderr.trim()
|
||||
);
|
||||
}
|
||||
time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
Err(crate::Error::message(last_error))
|
||||
}
|
||||
|
||||
/// Helper for sandbox implementations that manage git internally.
|
||||
/// Pushes a branch to origin via exec_command inside the sandbox.
|
||||
pub async fn git_push_via_exec(sandbox: &dyn Sandbox, branch: &str) -> bool {
|
||||
/// Pushes a refspec to origin via exec_command inside the sandbox.
|
||||
pub async fn git_push_via_exec(sandbox: &dyn Sandbox, refspec: &str) -> bool {
|
||||
if let Err(e) = sandbox.refresh_push_credentials().await {
|
||||
tracing::warn!(error = %e, "Failed to refresh push credentials");
|
||||
}
|
||||
let cmd = format!("{GIT} push origin {}", shell_quote(branch));
|
||||
let cmd = format!("{GIT} push origin {}", shell_quote(refspec));
|
||||
match sandbox.exec_command(&cmd, 60_000, None, None, None).await {
|
||||
Ok(r) if r.exit_code == 0 => {
|
||||
tracing::info!(branch, "Pushed run branch to origin");
|
||||
tracing::info!(refspec, "Pushed git ref to origin");
|
||||
true
|
||||
}
|
||||
Ok(r) => {
|
||||
tracing::warn!(branch, exit_code = r.exit_code, "Failed to push run branch");
|
||||
tracing::warn!(refspec, exit_code = r.exit_code, "Failed to push git ref");
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(branch, error = %e, "Failed to push run branch");
|
||||
tracing::warn!(refspec, error = %e, "Failed to push git ref");
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,20 +64,6 @@ impl SandboxSpec {
|
|||
}
|
||||
}
|
||||
|
||||
/// Host-accessible repo path for git status / worktree decisions.
|
||||
/// Only Local has one. Clone-based providers use their persisted clone
|
||||
/// metadata instead of the worker process filesystem.
|
||||
pub fn host_repo_path(&self) -> Option<PathBuf> {
|
||||
match self {
|
||||
Self::Local { working_directory } => Some(working_directory.clone()),
|
||||
#[allow(
|
||||
unreachable_patterns,
|
||||
reason = "Feature-gated variants make this fallback arm reachable on some builds."
|
||||
)]
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a SandboxRecord for persistence.
|
||||
pub fn to_sandbox_record(&self, sandbox: &dyn Sandbox) -> SandboxRecord {
|
||||
let working_directory = sandbox.working_directory().to_string();
|
||||
|
|
@ -97,8 +83,6 @@ impl SandboxSpec {
|
|||
provider: self.provider_name().to_string(),
|
||||
working_directory: working_directory.clone(),
|
||||
identifier,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
repo_cloned: clone_source::repo_cloned_for_record(
|
||||
config.skip_clone,
|
||||
clone_origin_url.as_deref(),
|
||||
|
|
@ -118,8 +102,6 @@ impl SandboxSpec {
|
|||
provider: self.provider_name().to_string(),
|
||||
working_directory: working_directory.clone(),
|
||||
identifier,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
repo_cloned: clone_source::repo_cloned_for_record(
|
||||
config.skip_clone,
|
||||
clone_origin_url.as_deref(),
|
||||
|
|
@ -133,8 +115,6 @@ impl SandboxSpec {
|
|||
provider: self.provider_name().to_string(),
|
||||
working_directory,
|
||||
identifier,
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
repo_cloned: None,
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
|
|
|
|||
|
|
@ -305,20 +305,28 @@ impl Sandbox for WorktreeSandbox {
|
|||
self.inner.set_autostop_interval(minutes).await
|
||||
}
|
||||
|
||||
fn host_git_dir(&self) -> Option<&str> {
|
||||
Some(&self.config.worktree_path)
|
||||
}
|
||||
|
||||
async fn setup_git_for_run(&self, run_id: &str) -> crate::Result<Option<crate::GitRunInfo>> {
|
||||
self.inner.setup_git_for_run(run_id).await
|
||||
async fn setup_git(
|
||||
&self,
|
||||
intent: &crate::GitSetupIntent,
|
||||
) -> crate::Result<Option<crate::GitRunInfo>> {
|
||||
self.inner.setup_git(intent).await
|
||||
}
|
||||
|
||||
fn resume_setup_commands(&self, run_branch: &str) -> Vec<String> {
|
||||
self.inner.resume_setup_commands(run_branch)
|
||||
}
|
||||
|
||||
async fn git_push_branch(&self, branch: &str) -> bool {
|
||||
self.inner.git_push_branch(branch).await
|
||||
async fn git_push_ref(&self, refspec: &str) -> bool {
|
||||
let has_origin = matches!(
|
||||
self.exec_command("git remote get-url origin", 10_000, None, None, None)
|
||||
.await,
|
||||
Ok(result) if result.exit_code == 0
|
||||
);
|
||||
if !has_origin {
|
||||
return true;
|
||||
}
|
||||
|
||||
crate::git_push_via_exec(self, refspec).await
|
||||
}
|
||||
|
||||
fn parallel_worktree_path(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ use fabro_types::settings::run::{
|
|||
ApprovalMode, DaytonaNetworkLayer, DaytonaSettings, DockerSettings, DockerfileSource, RunGoal,
|
||||
RunMode, RunNamespace,
|
||||
};
|
||||
use fabro_types::{RunId, WorkflowSettings};
|
||||
use fabro_types::{DirtyStatus, PreRunGitContext, PreRunPushOutcome, RunId, WorkflowSettings};
|
||||
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
|
||||
use fabro_validate::Severity;
|
||||
use fabro_workflow::Error as WorkflowError;
|
||||
|
|
@ -39,15 +39,16 @@ use crate::server::AppState;
|
|||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PreparedManifest {
|
||||
pub cwd: PathBuf,
|
||||
pub git: Option<types::ManifestGit>,
|
||||
pub root_source: String,
|
||||
pub run_id: Option<RunId>,
|
||||
pub settings: WorkflowSettings,
|
||||
pub target_path: PathBuf,
|
||||
pub workflow_bundle: WorkflowBundle,
|
||||
pub workflow_input: BundledWorkflow,
|
||||
pub working_directory: PathBuf,
|
||||
pub cwd: PathBuf,
|
||||
pub git: Option<types::ManifestGit>,
|
||||
pub root_source: String,
|
||||
pub run_id: Option<RunId>,
|
||||
pub settings: WorkflowSettings,
|
||||
pub target_path: PathBuf,
|
||||
pub workflow_bundle: WorkflowBundle,
|
||||
pub workflow_input: BundledWorkflow,
|
||||
pub source_directory: PathBuf,
|
||||
pub checkpoints_disabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
|
@ -68,6 +69,29 @@ pub(crate) fn prepare_manifest(
|
|||
if manifest.version != 1 {
|
||||
bail!("unsupported manifest version {}", manifest.version);
|
||||
}
|
||||
let (in_place, allow_no_checkpoints) = manifest.args.as_ref().map_or((false, false), |args| {
|
||||
(
|
||||
args.in_place.unwrap_or(false),
|
||||
args.allow_no_checkpoints.unwrap_or(false),
|
||||
)
|
||||
});
|
||||
if in_place && !allow_no_checkpoints {
|
||||
bail!("in_place requires allow_no_checkpoints");
|
||||
}
|
||||
if allow_no_checkpoints && !in_place {
|
||||
bail!("allow_no_checkpoints requires in_place");
|
||||
}
|
||||
if in_place {
|
||||
if let Some(sandbox) = manifest
|
||||
.args
|
||||
.as_ref()
|
||||
.and_then(|args| args.sandbox.as_deref())
|
||||
{
|
||||
if sandbox != "local" {
|
||||
bail!("in_place requires the local sandbox provider");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cwd = PathBuf::from(&manifest.cwd);
|
||||
let target_path = PathBuf::from(&manifest.target.path);
|
||||
|
|
@ -131,7 +155,8 @@ pub(crate) fn prepare_manifest(
|
|||
target_path,
|
||||
workflow_bundle,
|
||||
workflow_input,
|
||||
working_directory: resolve_working_directory(&settings, &cwd),
|
||||
source_directory: resolve_working_directory(&settings, &cwd),
|
||||
checkpoints_disabled: in_place,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -159,17 +184,61 @@ pub(crate) fn create_run_input(
|
|||
workflow_bundle: Some(prepared.workflow_bundle),
|
||||
submitted_manifest_bytes: None,
|
||||
run_id: prepared.run_id,
|
||||
host_repo_path: Some(prepared.working_directory.display().to_string()),
|
||||
repo_origin_url: prepared
|
||||
.git
|
||||
.as_ref()
|
||||
.map(|git| fabro_github::normalize_repo_origin_url(&git.origin_url)),
|
||||
repo_origin_url: prepared.git.as_ref().and_then(|git| {
|
||||
let origin_url = fabro_github::normalize_repo_origin_url(&git.origin_url);
|
||||
(!origin_url.is_empty()).then_some(origin_url)
|
||||
}),
|
||||
base_branch: prepared.git.as_ref().map(|git| git.branch.clone()),
|
||||
pre_run_git: prepared.git.as_ref().map(pre_run_git_from_manifest),
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: prepared.checkpoints_disabled,
|
||||
provenance: None,
|
||||
configured_providers,
|
||||
}
|
||||
}
|
||||
|
||||
fn pre_run_git_from_manifest(git: &types::ManifestGit) -> PreRunGitContext {
|
||||
PreRunGitContext {
|
||||
display_base_sha: Some(git.sha.clone()),
|
||||
local_dirty: if git.clean {
|
||||
DirtyStatus::Clean
|
||||
} else {
|
||||
DirtyStatus::Dirty
|
||||
},
|
||||
push_outcome: pre_run_push_outcome_from_manifest(&git.push_outcome),
|
||||
}
|
||||
}
|
||||
|
||||
fn pre_run_push_outcome_from_manifest(
|
||||
outcome: &types::ManifestPreRunPushOutcome,
|
||||
) -> PreRunPushOutcome {
|
||||
match outcome.type_ {
|
||||
types::ManifestPreRunPushOutcomeType::NotAttempted => PreRunPushOutcome::NotAttempted,
|
||||
types::ManifestPreRunPushOutcomeType::Succeeded => PreRunPushOutcome::Succeeded {
|
||||
remote: outcome
|
||||
.remote
|
||||
.clone()
|
||||
.unwrap_or_else(|| "origin".to_string()),
|
||||
branch: outcome.branch.clone().unwrap_or_default(),
|
||||
},
|
||||
types::ManifestPreRunPushOutcomeType::Failed => PreRunPushOutcome::Failed {
|
||||
remote: outcome
|
||||
.remote
|
||||
.clone()
|
||||
.unwrap_or_else(|| "origin".to_string()),
|
||||
branch: outcome.branch.clone().unwrap_or_default(),
|
||||
message: outcome.message.clone().unwrap_or_default(),
|
||||
},
|
||||
types::ManifestPreRunPushOutcomeType::SkippedNoRemote => PreRunPushOutcome::SkippedNoRemote,
|
||||
types::ManifestPreRunPushOutcomeType::SkippedRemoteMismatch => {
|
||||
PreRunPushOutcome::SkippedRemoteMismatch {
|
||||
remote: outcome.remote.clone().unwrap_or_default(),
|
||||
repo_origin_url: outcome.repo_origin_url.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_preflight(
|
||||
state: &AppState,
|
||||
prepared: &PreparedManifest,
|
||||
|
|
@ -246,17 +315,22 @@ fn manifest_args_overrides(args: Option<&types::ManifestArgs>) -> ManifestSettin
|
|||
name: args.model.as_deref().map(InterpString::parse),
|
||||
fallbacks: Vec::new(),
|
||||
});
|
||||
let sandbox =
|
||||
(args.sandbox.is_some() || args.preserve_sandbox.is_some() || args.docker_image.is_some())
|
||||
.then(|| RunSandboxLayer {
|
||||
provider: args.sandbox.clone(),
|
||||
preserve: args.preserve_sandbox,
|
||||
docker: args.docker_image.as_ref().map(|image| DockerSandboxLayer {
|
||||
image: Some(image.clone()),
|
||||
..DockerSandboxLayer::default()
|
||||
}),
|
||||
..RunSandboxLayer::default()
|
||||
});
|
||||
let sandbox_provider = args
|
||||
.sandbox
|
||||
.clone()
|
||||
.or_else(|| (args.in_place == Some(true)).then(|| "local".to_string()));
|
||||
let sandbox = (sandbox_provider.is_some()
|
||||
|| args.preserve_sandbox.is_some()
|
||||
|| args.docker_image.is_some())
|
||||
.then(|| RunSandboxLayer {
|
||||
provider: sandbox_provider,
|
||||
preserve: args.preserve_sandbox,
|
||||
docker: args.docker_image.as_ref().map(|image| DockerSandboxLayer {
|
||||
image: Some(image.clone()),
|
||||
..DockerSandboxLayer::default()
|
||||
}),
|
||||
..RunSandboxLayer::default()
|
||||
});
|
||||
|
||||
let execution_has_any =
|
||||
args.dry_run.is_some() || args.auto_approve.is_some() || args.no_retro.is_some();
|
||||
|
|
@ -553,7 +627,7 @@ async fn run_sandbox_check(
|
|||
let docker_config = resolve_docker_config(resolved_run);
|
||||
let sandbox_result: Result<Arc<dyn Sandbox>, String> = match sandbox_provider {
|
||||
SandboxProvider::Local => SandboxSpec::Local {
|
||||
working_directory: prepared.working_directory.clone(),
|
||||
working_directory: prepared.source_directory.clone(),
|
||||
}
|
||||
.build(None)
|
||||
.await
|
||||
|
|
@ -1090,16 +1164,18 @@ root = "/srv/fabro"
|
|||
)));
|
||||
let mut manifest = minimal_manifest();
|
||||
manifest.args = Some(types::ManifestArgs {
|
||||
auto_approve: None,
|
||||
dry_run: Some(true),
|
||||
label: Vec::new(),
|
||||
model: None,
|
||||
no_retro: None,
|
||||
preserve_sandbox: None,
|
||||
provider: None,
|
||||
sandbox: None,
|
||||
docker_image: None,
|
||||
verbose: None,
|
||||
auto_approve: None,
|
||||
dry_run: Some(true),
|
||||
label: Vec::new(),
|
||||
model: None,
|
||||
no_retro: None,
|
||||
preserve_sandbox: None,
|
||||
provider: None,
|
||||
sandbox: None,
|
||||
docker_image: None,
|
||||
verbose: None,
|
||||
in_place: None,
|
||||
allow_no_checkpoints: None,
|
||||
});
|
||||
|
||||
let prepared = prepare_manifest(&server_settings, &manifest).unwrap();
|
||||
|
|
|
|||
|
|
@ -7228,7 +7228,7 @@ async fn fork_run(
|
|||
target,
|
||||
push: request.push.unwrap_or(true),
|
||||
};
|
||||
match operations::fork_run(&state.store, &input).await {
|
||||
match Box::pin(operations::fork_run(&state.store, &input)).await {
|
||||
Ok(outcome) => (
|
||||
StatusCode::OK,
|
||||
Json(ForkResponse {
|
||||
|
|
@ -7261,6 +7261,8 @@ async fn run_timeline(
|
|||
node_name: entry.node_name,
|
||||
visit: std::num::NonZeroU64::new(entry.visit as u64)
|
||||
.expect("timeline visits start at 1"),
|
||||
checkpoint_seq: std::num::NonZeroU64::new(u64::from(entry.checkpoint_seq))
|
||||
.expect("checkpoint event sequence starts at 1"),
|
||||
run_commit_sha: entry.run_commit_sha,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
|
|
@ -7936,7 +7938,8 @@ struct GraphParams {
|
|||
async fn load_run_dot_source(state: &AppState, id: &RunId) -> Result<String, Response> {
|
||||
let live_dot_source = {
|
||||
let runs = state.runs.lock().expect("runs lock poisoned");
|
||||
runs.get(id).map(|managed_run| managed_run.dot_source.clone())
|
||||
runs.get(id)
|
||||
.map(|managed_run| managed_run.dot_source.clone())
|
||||
};
|
||||
|
||||
let dot_source = if let Some(dot) = live_dot_source.filter(|d| !d.is_empty()) {
|
||||
|
|
@ -7947,7 +7950,7 @@ async fn load_run_dot_source(state: &AppState, id: &RunId) -> Result<String, Res
|
|||
Ok(run_state) => run_state.graph_source,
|
||||
Err(err) => {
|
||||
return Err(
|
||||
ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response(),
|
||||
ApiError::new(StatusCode::BAD_GATEWAY, err.to_string()).into_response()
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
@ -7955,9 +7958,8 @@ async fn load_run_dot_source(state: &AppState, id: &RunId) -> Result<String, Res
|
|||
}
|
||||
};
|
||||
|
||||
dot_source.ok_or_else(|| {
|
||||
ApiError::new(StatusCode::NOT_FOUND, "Graph not found.").into_response()
|
||||
})
|
||||
dot_source
|
||||
.ok_or_else(|| ApiError::new(StatusCode::NOT_FOUND, "Graph not found.").into_response())
|
||||
}
|
||||
|
||||
async fn get_graph(
|
||||
|
|
@ -9726,14 +9728,16 @@ strategy = "token"
|
|||
settings: fabro_types::WorkflowSettings::default(),
|
||||
graph,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
working_directory: PathBuf::from("/tmp/project"),
|
||||
host_repo_path: Some("/tmp/project".to_string()),
|
||||
source_directory: Some("/tmp/project".to_string()),
|
||||
repo_origin_url: repo_origin_url.map(str::to_string),
|
||||
base_branch: base_branch.map(str::to_string),
|
||||
labels: HashMap::new(),
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
};
|
||||
|
||||
create_durable_run_with_events(state, run_id, &[
|
||||
|
|
@ -9744,15 +9748,17 @@ strategy = "token"
|
|||
workflow_source: None,
|
||||
workflow_config: None,
|
||||
labels: run_spec.labels.clone().into_iter().collect(),
|
||||
run_dir: run_spec.working_directory.display().to_string(),
|
||||
working_directory: run_spec.working_directory.display().to_string(),
|
||||
host_repo_path: run_spec.host_repo_path.clone(),
|
||||
run_dir: run_spec.source_directory.clone().unwrap_or_default(),
|
||||
source_directory: run_spec.source_directory.clone(),
|
||||
repo_origin_url: run_spec.repo_origin_url.clone(),
|
||||
base_branch: run_spec.base_branch.clone(),
|
||||
workflow_slug: run_spec.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
},
|
||||
workflow_event::Event::WorkflowRunStarted {
|
||||
name: "test".to_string(),
|
||||
|
|
@ -13905,14 +13911,12 @@ provider = "local"
|
|||
workflow_event::Event::RunStarting,
|
||||
workflow_event::Event::RunRunning,
|
||||
workflow_event::Event::SandboxInitialized {
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/sandbox/workdir".to_string(),
|
||||
identifier: Some("sb-test".to_string()),
|
||||
host_working_directory: Some("/tmp/repo".to_string()),
|
||||
container_mount_point: None,
|
||||
repo_cloned: None,
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/sandbox/workdir".to_string(),
|
||||
identifier: Some("sb-test".to_string()),
|
||||
repo_cloned: None,
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
},
|
||||
workflow_event::Event::PullRequestCreated {
|
||||
pr_url: "https://github.com/acme/repo/pull/42".to_string(),
|
||||
|
|
@ -13978,14 +13982,12 @@ provider = "local"
|
|||
workflow_event::Event::RunStarting,
|
||||
workflow_event::Event::RunRunning,
|
||||
workflow_event::Event::SandboxInitialized {
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/sandbox/workdir".to_string(),
|
||||
identifier: Some(sandbox_id.to_string()),
|
||||
host_working_directory: Some("/tmp/repo".to_string()),
|
||||
container_mount_point: None,
|
||||
repo_cloned: None,
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/sandbox/workdir".to_string(),
|
||||
identifier: Some(sandbox_id.to_string()),
|
||||
repo_cloned: None,
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
},
|
||||
] {
|
||||
workflow_event::append_event(&run_store, &run_id, &event)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
|
@ -48,21 +47,22 @@ impl RunProjectionReducer for RunProjection {
|
|||
|
||||
match &stored.body {
|
||||
EventBody::RunCreated(props) => {
|
||||
let working_directory = PathBuf::from(&props.working_directory);
|
||||
let labels = props.labels.clone().into_iter().collect::<HashMap<_, _>>();
|
||||
self.spec = Some(RunSpec {
|
||||
run_id,
|
||||
settings: props.settings.clone(),
|
||||
graph: props.graph.clone(),
|
||||
workflow_slug: props.workflow_slug.clone(),
|
||||
working_directory,
|
||||
host_repo_path: props.host_repo_path.clone(),
|
||||
source_directory: props.source_directory.clone(),
|
||||
repo_origin_url: props.repo_origin_url.clone(),
|
||||
base_branch: props.base_branch.clone(),
|
||||
labels,
|
||||
provenance: props.provenance.clone(),
|
||||
manifest_blob: props.manifest_blob,
|
||||
definition_blob: None,
|
||||
pre_run_git: props.pre_run_git.clone(),
|
||||
fork_source_ref: props.fork_source_ref.clone(),
|
||||
checkpoints_disabled: props.checkpoints_disabled,
|
||||
});
|
||||
self.graph_source.clone_from(&props.workflow_source);
|
||||
}
|
||||
|
|
@ -210,14 +210,12 @@ impl RunProjectionReducer for RunProjection {
|
|||
}
|
||||
EventBody::SandboxInitialized(props) => {
|
||||
self.sandbox = Some(SandboxRecord {
|
||||
provider: props.provider.clone(),
|
||||
working_directory: props.working_directory.clone(),
|
||||
identifier: props.identifier.clone(),
|
||||
host_working_directory: props.host_working_directory.clone(),
|
||||
container_mount_point: props.container_mount_point.clone(),
|
||||
repo_cloned: props.repo_cloned,
|
||||
clone_origin_url: props.clone_origin_url.clone(),
|
||||
clone_branch: props.clone_branch.clone(),
|
||||
provider: props.provider.clone(),
|
||||
working_directory: props.working_directory.clone(),
|
||||
identifier: props.identifier.clone(),
|
||||
repo_cloned: props.repo_cloned,
|
||||
clone_origin_url: props.clone_origin_url.clone(),
|
||||
clone_branch: props.clone_branch.clone(),
|
||||
});
|
||||
}
|
||||
EventBody::RetroStarted(props) => {
|
||||
|
|
@ -394,7 +392,15 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary
|
|||
state
|
||||
.spec
|
||||
.as_ref()
|
||||
.and_then(|spec| spec.host_repo_path.clone()),
|
||||
.and_then(|spec| spec.source_directory.clone()),
|
||||
state
|
||||
.spec
|
||||
.as_ref()
|
||||
.is_some_and(|spec| spec.checkpoints_disabled),
|
||||
state
|
||||
.spec
|
||||
.as_ref()
|
||||
.and_then(|spec| spec.repo_origin_url.clone()),
|
||||
state.start.as_ref().map(|start| start.start_time),
|
||||
state.status.unwrap_or(RunStatus::Submitted),
|
||||
state.pending_control,
|
||||
|
|
@ -654,8 +660,7 @@ mod tests {
|
|||
"settings": WorkflowSettings::default(),
|
||||
"graph": { "name": "ship", "nodes": {}, "edges": [], "attrs": {} },
|
||||
"workflow_slug": "demo",
|
||||
"working_directory": "/tmp/project",
|
||||
"host_repo_path": null,
|
||||
"source_directory": "/tmp/project",
|
||||
"repo_origin_url": null,
|
||||
"base_branch": null,
|
||||
"labels": {},
|
||||
|
|
@ -983,18 +988,20 @@ mod tests {
|
|||
fn summary_synthesizes_submitted_when_run_exists_without_status() {
|
||||
let mut state = RunProjection::default();
|
||||
state.spec = Some(fabro_types::RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: fabro_types::Graph::new("test"),
|
||||
workflow_slug: Some("test".to_string()),
|
||||
working_directory: std::path::PathBuf::from("/tmp/run"),
|
||||
host_repo_path: Some("/tmp/repo".to_string()),
|
||||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: fabro_types::Graph::new("test"),
|
||||
workflow_slug: Some("test".to_string()),
|
||||
source_directory: Some("/tmp/repo".to_string()),
|
||||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
});
|
||||
|
||||
let summary_json = serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap();
|
||||
|
|
@ -1024,7 +1031,7 @@ mod tests {
|
|||
},
|
||||
"labels": {},
|
||||
"run_dir": "/tmp/run",
|
||||
"working_directory": "/tmp/run",
|
||||
"source_directory": "/tmp/run",
|
||||
"manifest_blob": manifest_blob
|
||||
}
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -48,28 +47,75 @@ pub struct RunProvenance {
|
|||
pub subject: Option<RunSubjectProvenance>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DirtyStatus {
|
||||
Clean,
|
||||
Dirty,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum PreRunPushOutcome {
|
||||
NotAttempted,
|
||||
Succeeded {
|
||||
remote: String,
|
||||
branch: String,
|
||||
},
|
||||
Failed {
|
||||
remote: String,
|
||||
branch: String,
|
||||
message: String,
|
||||
},
|
||||
SkippedNoRemote,
|
||||
SkippedRemoteMismatch {
|
||||
remote: String,
|
||||
repo_origin_url: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PreRunGitContext {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_base_sha: Option<String>,
|
||||
pub local_dirty: DirtyStatus,
|
||||
pub push_outcome: PreRunPushOutcome,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ForkSourceRef {
|
||||
pub source_run_id: RunId,
|
||||
pub checkpoint_sha: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RunSpec {
|
||||
pub run_id: RunId,
|
||||
pub settings: WorkflowSettings,
|
||||
pub graph: Graph,
|
||||
pub run_id: RunId,
|
||||
pub settings: WorkflowSettings,
|
||||
pub graph: Graph,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workflow_slug: Option<String>,
|
||||
pub working_directory: PathBuf,
|
||||
pub workflow_slug: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host_repo_path: Option<String>,
|
||||
pub source_directory: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repo_origin_url: Option<String>,
|
||||
pub repo_origin_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_branch: Option<String>,
|
||||
pub base_branch: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
||||
pub labels: HashMap<String, String>,
|
||||
pub labels: HashMap<String, String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provenance: Option<RunProvenance>,
|
||||
pub provenance: Option<RunProvenance>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manifest_blob: Option<RunBlobId>,
|
||||
pub manifest_blob: Option<RunBlobId>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub definition_blob: Option<RunBlobId>,
|
||||
pub definition_blob: Option<RunBlobId>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pre_run_git: Option<PreRunGitContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fork_source_ref: Option<ForkSourceRef>,
|
||||
#[serde(default)]
|
||||
pub checkpoints_disabled: bool,
|
||||
}
|
||||
|
||||
impl RunSpec {
|
||||
|
|
@ -94,8 +140,8 @@ impl RunSpec {
|
|||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn working_directory(&self) -> &Path {
|
||||
&self.working_directory
|
||||
pub fn source_directory(&self) -> Option<&str> {
|
||||
self.source_directory.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
|
@ -103,11 +149,6 @@ impl RunSpec {
|
|||
&self.labels
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn host_repo_path(&self) -> Option<&str> {
|
||||
self.host_repo_path.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn repo_origin_url(&self) -> Option<&str> {
|
||||
self.repo_origin_url.as_deref()
|
||||
|
|
@ -117,4 +158,14 @@ impl RunSpec {
|
|||
pub fn base_branch(&self) -> Option<&str> {
|
||||
self.base_branch.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn pre_run_git(&self) -> Option<&PreRunGitContext> {
|
||||
self.pre_run_git.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn fork_source_ref(&self) -> Option<&ForkSourceRef> {
|
||||
self.fork_source_ref.as_ref()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,20 +89,16 @@ pub struct GitCloneFailedProps {
|
|||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SandboxInitializedProps {
|
||||
pub working_directory: String,
|
||||
pub provider: String,
|
||||
pub working_directory: String,
|
||||
pub provider: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identifier: Option<String>,
|
||||
pub identifier: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host_working_directory: Option<String>,
|
||||
pub repo_cloned: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub container_mount_point: Option<String>,
|
||||
pub clone_origin_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repo_cloned: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub clone_origin_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub clone_branch: Option<String>,
|
||||
pub clone_branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,34 +4,42 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
use super::{ActorRef, BilledTokenCounts, RunNoticeLevel};
|
||||
use crate::status::{BlockedReason, FailureReason, SuccessReason};
|
||||
use crate::{Graph, RunBlobId, RunControlAction, RunId, RunProvenance, WorkflowSettings};
|
||||
use crate::{
|
||||
ForkSourceRef, Graph, PreRunGitContext, RunBlobId, RunControlAction, RunId, RunProvenance,
|
||||
WorkflowSettings,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RunCreatedProps {
|
||||
pub settings: WorkflowSettings,
|
||||
pub graph: Graph,
|
||||
pub settings: WorkflowSettings,
|
||||
pub graph: Graph,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workflow_source: Option<String>,
|
||||
pub workflow_source: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workflow_config: Option<String>,
|
||||
pub workflow_config: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub labels: BTreeMap<String, String>,
|
||||
pub run_dir: String,
|
||||
pub working_directory: String,
|
||||
pub labels: BTreeMap<String, String>,
|
||||
pub run_dir: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host_repo_path: Option<String>,
|
||||
pub source_directory: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repo_origin_url: Option<String>,
|
||||
pub repo_origin_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_branch: Option<String>,
|
||||
pub base_branch: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workflow_slug: Option<String>,
|
||||
pub workflow_slug: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub db_prefix: Option<String>,
|
||||
pub db_prefix: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provenance: Option<RunProvenance>,
|
||||
pub provenance: Option<RunProvenance>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub manifest_blob: Option<RunBlobId>,
|
||||
pub manifest_blob: Option<RunBlobId>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pre_run_git: Option<PreRunGitContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fork_source_ref: Option<ForkSourceRef>,
|
||||
#[serde(default)]
|
||||
pub checkpoints_disabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -8,31 +8,35 @@ use crate::{RepositoryReference, RunControlAction, RunId, RunStatus};
|
|||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RunSummary {
|
||||
pub run_id: RunId,
|
||||
pub run_id: RunId,
|
||||
#[serde(default)]
|
||||
pub workflow_name: Option<String>,
|
||||
pub workflow_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub workflow_slug: Option<String>,
|
||||
pub goal: String,
|
||||
pub title: String,
|
||||
pub labels: HashMap<String, String>,
|
||||
pub workflow_slug: Option<String>,
|
||||
pub goal: String,
|
||||
pub title: String,
|
||||
pub labels: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub host_repo_path: Option<String>,
|
||||
pub repository: RepositoryReference,
|
||||
pub source_directory: Option<String>,
|
||||
#[serde(default)]
|
||||
pub start_time: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub status: RunStatus,
|
||||
pub checkpoints_disabled: bool,
|
||||
#[serde(default)]
|
||||
pub pending_control: Option<RunControlAction>,
|
||||
pub repo_origin_url: Option<String>,
|
||||
pub repository: RepositoryReference,
|
||||
#[serde(default)]
|
||||
pub duration_ms: Option<u64>,
|
||||
pub start_time: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub status: RunStatus,
|
||||
#[serde(default)]
|
||||
pub elapsed_secs: Option<f64>,
|
||||
pub pending_control: Option<RunControlAction>,
|
||||
#[serde(default)]
|
||||
pub total_usd_micros: Option<i64>,
|
||||
pub duration_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub superseded_by: Option<RunId>,
|
||||
pub elapsed_secs: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub total_usd_micros: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub superseded_by: Option<RunId>,
|
||||
}
|
||||
|
||||
impl RunSummary {
|
||||
|
|
@ -46,7 +50,9 @@ impl RunSummary {
|
|||
workflow_slug: Option<String>,
|
||||
goal: String,
|
||||
labels: HashMap<String, String>,
|
||||
host_repo_path: Option<String>,
|
||||
source_directory: Option<String>,
|
||||
checkpoints_disabled: bool,
|
||||
repo_origin_url: Option<String>,
|
||||
start_time: Option<DateTime<Utc>>,
|
||||
status: RunStatus,
|
||||
pending_control: Option<RunControlAction>,
|
||||
|
|
@ -56,7 +62,7 @@ impl RunSummary {
|
|||
) -> Self {
|
||||
let title = truncate_goal(&goal);
|
||||
let repository = RepositoryReference {
|
||||
name: repository_name(host_repo_path.as_deref()),
|
||||
name: repository_name(repo_origin_url.as_deref(), source_directory.as_deref()),
|
||||
};
|
||||
let elapsed_secs = elapsed_secs(duration_ms);
|
||||
let created_at = run_id.created_at();
|
||||
|
|
@ -68,7 +74,9 @@ impl RunSummary {
|
|||
goal,
|
||||
title,
|
||||
labels,
|
||||
host_repo_path,
|
||||
source_directory,
|
||||
checkpoints_disabled,
|
||||
repo_origin_url,
|
||||
repository,
|
||||
start_time,
|
||||
created_at,
|
||||
|
|
@ -95,11 +103,49 @@ fn truncate_goal(goal: &str) -> String {
|
|||
format!("{truncated}...")
|
||||
}
|
||||
|
||||
fn repository_name(host_repo_path: Option<&str>) -> String {
|
||||
host_repo_path
|
||||
.and_then(|path| path.rsplit(['/', '\\']).find(|segment| !segment.is_empty()))
|
||||
.unwrap_or("unknown")
|
||||
.to_string()
|
||||
fn repository_name(repo_origin_url: Option<&str>, source_directory: Option<&str>) -> String {
|
||||
repo_origin_url
|
||||
.and_then(repository_name_from_origin)
|
||||
.or_else(|| {
|
||||
source_directory
|
||||
.and_then(path_basename)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "Run summaries parse the origin only to extract an owner/repo label; raw URLs are not logged or returned here."
|
||||
)]
|
||||
fn repository_name_from_origin(origin: &str) -> Option<String> {
|
||||
if let Some(path) = origin
|
||||
.strip_prefix("git@")
|
||||
.and_then(|url| url.split_once(':').map(|(_, path)| path))
|
||||
{
|
||||
return repository_name_from_path(path).map(ToOwned::to_owned);
|
||||
}
|
||||
|
||||
let parsed = url::Url::parse(origin).ok()?;
|
||||
let path = parsed.path().trim_matches('/');
|
||||
repository_name_from_path(path).map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn repository_name_from_path(path: &str) -> Option<&str> {
|
||||
let stripped = path.strip_suffix(".git").unwrap_or(path);
|
||||
let mut segments = stripped.rsplit('/').filter(|segment| !segment.is_empty());
|
||||
let repo = segments.next()?;
|
||||
let owner = segments.next();
|
||||
if let Some(owner) = owner {
|
||||
let start = stripped.len() - owner.len() - repo.len() - 1;
|
||||
stripped.get(start..)
|
||||
} else {
|
||||
Some(repo)
|
||||
}
|
||||
}
|
||||
|
||||
fn path_basename(path: &str) -> Option<&str> {
|
||||
path.rsplit(['/', '\\']).find(|segment| !segment.is_empty())
|
||||
}
|
||||
|
||||
fn elapsed_secs(duration_ms: Option<u64>) -> Option<f64> {
|
||||
|
|
@ -116,14 +162,16 @@ mod tests {
|
|||
use crate::{BlockedReason, RepositoryReference, RunControlAction, RunStatus, fixtures};
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_serde_json() {
|
||||
fn summary_prefers_origin_name_over_submitter_source_directory() {
|
||||
let summary = RunSummary::new(
|
||||
fixtures::RUN_1,
|
||||
Some("workflow".to_string()),
|
||||
Some("workflow".to_string()),
|
||||
"ship it".to_string(),
|
||||
HashMap::from([("team".to_string(), "core".to_string())]),
|
||||
Some("/tmp/repo".to_string()),
|
||||
Some("/Users/client/local-checkout".to_string()),
|
||||
false,
|
||||
Some("https://github.com/fabro-sh/fabro.git".to_string()),
|
||||
Some(Utc.with_ymd_and_hms(2026, 4, 20, 12, 0, 0).unwrap()),
|
||||
RunStatus::Blocked {
|
||||
blocked_reason: BlockedReason::HumanInputRequired,
|
||||
|
|
@ -136,13 +184,62 @@ mod tests {
|
|||
|
||||
assert_eq!(summary.title, "ship it");
|
||||
assert_eq!(summary.repository, RepositoryReference {
|
||||
name: "repo".to_string(),
|
||||
name: "fabro-sh/fabro".to_string(),
|
||||
});
|
||||
assert_eq!(summary.created_at, fixtures::RUN_1.created_at());
|
||||
assert_eq!(summary.elapsed_secs, Some(0.042));
|
||||
assert_eq!(
|
||||
summary.source_directory.as_deref(),
|
||||
Some("/Users/client/local-checkout")
|
||||
);
|
||||
|
||||
let value = serde_json::to_value(&summary).unwrap();
|
||||
assert!(value.get("host_repo_path").is_none());
|
||||
assert_eq!(value["source_directory"], "/Users/client/local-checkout");
|
||||
assert_eq!(
|
||||
value["repo_origin_url"],
|
||||
"https://github.com/fabro-sh/fabro.git"
|
||||
);
|
||||
let parsed: RunSummary = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(parsed, summary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_falls_back_to_source_directory_then_unknown() {
|
||||
let source_only = RunSummary::new(
|
||||
fixtures::RUN_1,
|
||||
None,
|
||||
None,
|
||||
"ship it".to_string(),
|
||||
HashMap::new(),
|
||||
Some("/Users/client/local-checkout".to_string()),
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
RunStatus::Submitted,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(source_only.repository.name, "local-checkout");
|
||||
|
||||
let unknown = RunSummary::new(
|
||||
fixtures::RUN_1,
|
||||
None,
|
||||
None,
|
||||
"ship it".to_string(),
|
||||
HashMap::new(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
RunStatus::Submitted,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(unknown.repository.name, "unknown");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,14 @@ use serde::{Deserialize, Serialize};
|
|||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SandboxRecord {
|
||||
pub provider: String,
|
||||
pub working_directory: String,
|
||||
pub provider: String,
|
||||
pub working_directory: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identifier: Option<String>,
|
||||
pub identifier: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host_working_directory: Option<String>,
|
||||
pub repo_cloned: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub container_mount_point: Option<String>,
|
||||
pub clone_origin_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repo_cloned: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub clone_origin_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub clone_branch: Option<String>,
|
||||
pub clone_branch: Option<String>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ use std::sync::Arc;
|
|||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
|
||||
use ::fabro_types::{
|
||||
ActorRef, BilledTokenCounts, BlockedReason, FailureReason, ParallelBranchId, PullRequestRecord,
|
||||
RunBlobId, RunControlAction, RunEvent, RunId, RunProvenance, StageId, StageStatus,
|
||||
SuccessReason, run_event as fabro_types,
|
||||
ActorRef, BilledTokenCounts, BlockedReason, FailureReason, ForkSourceRef, ParallelBranchId,
|
||||
PreRunGitContext, PullRequestRecord, RunBlobId, RunControlAction, RunEvent, RunId,
|
||||
RunProvenance, StageId, StageStatus, SuccessReason, run_event as fabro_types,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::Utc;
|
||||
|
|
@ -37,30 +37,35 @@ use crate::runtime_store::RunStoreHandle;
|
|||
)]
|
||||
pub enum Event {
|
||||
RunCreated {
|
||||
run_id: RunId,
|
||||
settings: serde_json::Value,
|
||||
graph: serde_json::Value,
|
||||
run_id: RunId,
|
||||
settings: serde_json::Value,
|
||||
graph: serde_json::Value,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
workflow_source: Option<String>,
|
||||
workflow_source: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
workflow_config: Option<String>,
|
||||
labels: BTreeMap<String, String>,
|
||||
run_dir: String,
|
||||
working_directory: String,
|
||||
workflow_config: Option<String>,
|
||||
labels: BTreeMap<String, String>,
|
||||
run_dir: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
host_repo_path: Option<String>,
|
||||
source_directory: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
repo_origin_url: Option<String>,
|
||||
repo_origin_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
base_branch: Option<String>,
|
||||
base_branch: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
workflow_slug: Option<String>,
|
||||
workflow_slug: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
db_prefix: Option<String>,
|
||||
db_prefix: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
provenance: Option<RunProvenance>,
|
||||
provenance: Option<RunProvenance>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
manifest_blob: Option<RunBlobId>,
|
||||
manifest_blob: Option<RunBlobId>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pre_run_git: Option<PreRunGitContext>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
fork_source_ref: Option<ForkSourceRef>,
|
||||
#[serde(default)]
|
||||
checkpoints_disabled: bool,
|
||||
},
|
||||
WorkflowRunStarted {
|
||||
name: String,
|
||||
|
|
@ -387,20 +392,16 @@ pub enum Event {
|
|||
},
|
||||
/// Emitted after the sandbox has been initialized (by engine lifecycle).
|
||||
SandboxInitialized {
|
||||
working_directory: String,
|
||||
provider: String,
|
||||
working_directory: String,
|
||||
provider: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
identifier: Option<String>,
|
||||
identifier: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
host_working_directory: Option<String>,
|
||||
repo_cloned: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
container_mount_point: Option<String>,
|
||||
clone_origin_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
repo_cloned: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
clone_origin_url: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
clone_branch: Option<String>,
|
||||
clone_branch: Option<String>,
|
||||
},
|
||||
SetupStarted {
|
||||
command_count: usize,
|
||||
|
|
@ -1520,31 +1521,35 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
|||
workflow_config,
|
||||
labels,
|
||||
run_dir,
|
||||
working_directory,
|
||||
host_repo_path,
|
||||
source_directory,
|
||||
repo_origin_url,
|
||||
base_branch,
|
||||
workflow_slug,
|
||||
db_prefix,
|
||||
provenance,
|
||||
manifest_blob,
|
||||
pre_run_git,
|
||||
fork_source_ref,
|
||||
checkpoints_disabled,
|
||||
..
|
||||
} => EventBody::RunCreated(fabro_types::RunCreatedProps {
|
||||
settings: serde_json::from_value(settings.clone())
|
||||
settings: serde_json::from_value(settings.clone())
|
||||
.expect("run.created settings"),
|
||||
graph: serde_json::from_value(graph.clone()).expect("run.created graph"),
|
||||
workflow_source: workflow_source.clone(),
|
||||
workflow_config: workflow_config.clone(),
|
||||
labels: labels.clone(),
|
||||
run_dir: run_dir.clone(),
|
||||
working_directory: working_directory.clone(),
|
||||
host_repo_path: host_repo_path.clone(),
|
||||
repo_origin_url: repo_origin_url.clone(),
|
||||
base_branch: base_branch.clone(),
|
||||
workflow_slug: workflow_slug.clone(),
|
||||
db_prefix: db_prefix.clone(),
|
||||
provenance: provenance.clone(),
|
||||
manifest_blob: *manifest_blob,
|
||||
graph: serde_json::from_value(graph.clone()).expect("run.created graph"),
|
||||
workflow_source: workflow_source.clone(),
|
||||
workflow_config: workflow_config.clone(),
|
||||
labels: labels.clone(),
|
||||
run_dir: run_dir.clone(),
|
||||
source_directory: source_directory.clone(),
|
||||
repo_origin_url: repo_origin_url.clone(),
|
||||
base_branch: base_branch.clone(),
|
||||
workflow_slug: workflow_slug.clone(),
|
||||
db_prefix: db_prefix.clone(),
|
||||
provenance: provenance.clone(),
|
||||
manifest_blob: *manifest_blob,
|
||||
pre_run_git: pre_run_git.clone(),
|
||||
fork_source_ref: fork_source_ref.clone(),
|
||||
checkpoints_disabled: *checkpoints_disabled,
|
||||
}),
|
||||
Event::WorkflowRunStarted {
|
||||
name,
|
||||
|
|
@ -2249,20 +2254,16 @@ fn event_body_from_event(event: &Event) -> EventBody {
|
|||
working_directory,
|
||||
provider,
|
||||
identifier,
|
||||
host_working_directory,
|
||||
container_mount_point,
|
||||
repo_cloned,
|
||||
clone_origin_url,
|
||||
clone_branch,
|
||||
} => EventBody::SandboxInitialized(fabro_types::SandboxInitializedProps {
|
||||
working_directory: working_directory.clone(),
|
||||
provider: provider.clone(),
|
||||
identifier: identifier.clone(),
|
||||
host_working_directory: host_working_directory.clone(),
|
||||
container_mount_point: container_mount_point.clone(),
|
||||
repo_cloned: *repo_cloned,
|
||||
clone_origin_url: clone_origin_url.clone(),
|
||||
clone_branch: clone_branch.clone(),
|
||||
working_directory: working_directory.clone(),
|
||||
provider: provider.clone(),
|
||||
identifier: identifier.clone(),
|
||||
repo_cloned: *repo_cloned,
|
||||
clone_origin_url: clone_origin_url.clone(),
|
||||
clone_branch: clone_branch.clone(),
|
||||
}),
|
||||
Event::SetupStarted { command_count } => {
|
||||
EventBody::SetupStarted(fabro_types::SetupStartedProps {
|
||||
|
|
@ -3670,21 +3671,23 @@ mod tests {
|
|||
};
|
||||
|
||||
let stored = to_run_event(&fixtures::RUN_1, &Event::RunCreated {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
|
||||
graph: serde_json::to_value(Graph::new("test")).unwrap(),
|
||||
workflow_source: None,
|
||||
workflow_config: None,
|
||||
labels: BTreeMap::default(),
|
||||
run_dir: "/tmp/run".to_string(),
|
||||
working_directory: "/tmp/run".to_string(),
|
||||
host_repo_path: None,
|
||||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
workflow_slug: None,
|
||||
db_prefix: None,
|
||||
provenance: Some(provenance),
|
||||
manifest_blob: None,
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
|
||||
graph: serde_json::to_value(Graph::new("test")).unwrap(),
|
||||
workflow_source: None,
|
||||
workflow_config: None,
|
||||
labels: BTreeMap::default(),
|
||||
run_dir: "/tmp/run".to_string(),
|
||||
source_directory: Some("/tmp/run".to_string()),
|
||||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
workflow_slug: None,
|
||||
db_prefix: None,
|
||||
provenance: Some(provenance),
|
||||
manifest_blob: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
});
|
||||
let actor = stored.actor.as_ref().expect("actor set");
|
||||
assert_eq!(actor.kind, ActorKind::User);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
|
@ -11,14 +10,14 @@ use fabro_types::RunId;
|
|||
|
||||
use crate::artifact;
|
||||
use crate::event::{Emitter, Event, RunNoticeLevel};
|
||||
use crate::git::MetadataStore;
|
||||
use crate::graph::{WorkflowGraph, WorkflowNode};
|
||||
use crate::lifecycle::event::stage_scope_for;
|
||||
use crate::outcome::BilledModelUsage;
|
||||
use crate::run_dump::RunDump;
|
||||
use crate::run_options::RunOptions;
|
||||
use crate::runtime_store::RunStoreHandle;
|
||||
use crate::sandbox_git::{git_checkpoint, git_diff, git_push_host};
|
||||
use crate::sandbox_git::{git_checkpoint, git_diff};
|
||||
use crate::sandbox_metadata::{SandboxGitRuntime, SandboxMetadataWriter};
|
||||
|
||||
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
|
||||
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
|
||||
|
|
@ -67,6 +66,7 @@ pub(crate) struct GitLifecycle {
|
|||
pub run_id: RunId,
|
||||
pub run_store: RunStoreHandle,
|
||||
pub run_options: Arc<RunOptions>,
|
||||
pub metadata_runtime: Arc<SandboxGitRuntime>,
|
||||
pub start_node_id: Option<String>,
|
||||
// Cross-lifecycle data (shared with EventLifecycle)
|
||||
pub checkpoint_git_result: Arc<Mutex<Option<GitCheckpointResult>>>,
|
||||
|
|
@ -79,33 +79,24 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
|
|||
// Reset last_git_sha (diff base parity)
|
||||
*self.last_git_sha.lock().unwrap() = None;
|
||||
*self.checkpoint_git_result.lock().unwrap() = None;
|
||||
|
||||
// Init metadata branch (best-effort)
|
||||
if let (Some(_), Some(repo_path)) = (
|
||||
self.run_options
|
||||
.git
|
||||
.as_ref()
|
||||
.and_then(|g| g.meta_branch.as_ref()),
|
||||
self.run_options.host_repo_path.as_ref(),
|
||||
) {
|
||||
let git_author = self.run_options.git_author();
|
||||
let store = MetadataStore::new(repo_path, &git_author);
|
||||
let state = self.run_store.state().await.ok();
|
||||
let init_dump = state.as_ref().map(RunDump::from_projection);
|
||||
let init_entries = init_dump
|
||||
.as_ref()
|
||||
.and_then(|dump| dump.git_entries().ok())
|
||||
.unwrap_or_default();
|
||||
let refs: Vec<(&str, &[u8])> = init_entries
|
||||
.iter()
|
||||
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
|
||||
.collect();
|
||||
if let Err(e) = store.init_run(&self.run_id.to_string(), &refs) {
|
||||
tracing::warn!(
|
||||
run_id = %self.run_id,
|
||||
error = %e,
|
||||
"Metadata branch init failed"
|
||||
);
|
||||
if self
|
||||
.run_options
|
||||
.git
|
||||
.as_ref()
|
||||
.and_then(|g| g.meta_branch.as_ref())
|
||||
.is_some()
|
||||
{
|
||||
match self.run_store.state().await {
|
||||
Ok(state) => {
|
||||
let dump = RunDump::from_projection(&state);
|
||||
let _ = self.write_metadata_snapshot(&dump, "init run").await;
|
||||
}
|
||||
Err(err) => {
|
||||
self.emit_metadata_warning(
|
||||
"checkpoint_metadata_write_failed",
|
||||
format!("failed to load run state for metadata init: {err}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,78 +118,28 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
// Shadow commit (best-effort, metadata branch)
|
||||
let shadow_sha: Option<String> = if let (Some(_), Some(repo_path)) = (
|
||||
self.run_options
|
||||
.git
|
||||
.as_ref()
|
||||
.and_then(|g| g.meta_branch.as_ref()),
|
||||
self.run_options.host_repo_path.as_ref(),
|
||||
) {
|
||||
let git_author = self.run_options.git_author();
|
||||
let store = MetadataStore::new(repo_path, &git_author);
|
||||
let checkpoint = build_checkpoint(
|
||||
node,
|
||||
result,
|
||||
next_node_id,
|
||||
state,
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
None,
|
||||
);
|
||||
match self.run_store.state().await {
|
||||
Ok(mut snapshot_state) => {
|
||||
snapshot_state.checkpoint = Some(checkpoint);
|
||||
let dump = RunDump::from_projection(&snapshot_state);
|
||||
match dump.git_entries() {
|
||||
Ok(dump_entries) => {
|
||||
let refs: Vec<(&str, &[u8])> = dump_entries
|
||||
.iter()
|
||||
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
|
||||
.collect();
|
||||
match store.write_snapshot(
|
||||
&self.run_id.to_string(),
|
||||
&refs,
|
||||
"checkpoint",
|
||||
) {
|
||||
Ok(sha) => Some(sha),
|
||||
Err(e) => {
|
||||
self.emitter.emit(&Event::RunNotice {
|
||||
level: RunNoticeLevel::Warn,
|
||||
code: "checkpoint_metadata_write_failed".to_string(),
|
||||
message: format!(
|
||||
"[node: {node_id}] metadata checkpoint write failed: {e}"
|
||||
),
|
||||
});
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.emitter.emit(&Event::RunNotice {
|
||||
level: RunNoticeLevel::Warn,
|
||||
code: "checkpoint_metadata_write_failed".to_string(),
|
||||
message: format!(
|
||||
"[node: {node_id}] metadata checkpoint serialization failed: {e}"
|
||||
),
|
||||
});
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.emitter.emit(&Event::RunNotice {
|
||||
level: RunNoticeLevel::Warn,
|
||||
code: "checkpoint_metadata_write_failed".to_string(),
|
||||
message: format!(
|
||||
"[node: {node_id}] failed to load run state for metadata snapshot: {e}"
|
||||
),
|
||||
});
|
||||
None
|
||||
}
|
||||
let checkpoint = build_checkpoint(
|
||||
node,
|
||||
result,
|
||||
next_node_id,
|
||||
state,
|
||||
std::collections::HashMap::new(),
|
||||
std::collections::HashMap::new(),
|
||||
None,
|
||||
);
|
||||
let shadow_sha = match self.run_store.state().await {
|
||||
Ok(mut projection) => {
|
||||
projection.checkpoint = Some(checkpoint);
|
||||
let dump = RunDump::from_projection(&projection);
|
||||
self.write_metadata_snapshot(&dump, "checkpoint").await
|
||||
}
|
||||
Err(err) => {
|
||||
self.emit_metadata_warning(
|
||||
"checkpoint_metadata_write_failed",
|
||||
format!("failed to load run state for metadata checkpoint: {err}"),
|
||||
);
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Run branch commit via sandbox
|
||||
|
|
@ -232,41 +173,9 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
|
|||
.as_ref()
|
||||
.and_then(|g| g.run_branch.as_ref())
|
||||
{
|
||||
let push_ok = if self.sandbox.git_push_branch(branch).await {
|
||||
true
|
||||
} else if let Some(repo_path) = self.run_options.host_repo_path.as_ref() {
|
||||
let refspec = format!("refs/heads/{branch}");
|
||||
git_push_host(
|
||||
repo_path,
|
||||
&refspec,
|
||||
&self.run_options.github_app,
|
||||
"run branch",
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
false
|
||||
};
|
||||
git_result.push_results.push((branch.clone(), push_ok));
|
||||
}
|
||||
// Push metadata branch (always from host)
|
||||
if let (Some(meta_branch), Some(repo_path)) = (
|
||||
self.run_options
|
||||
.git
|
||||
.as_ref()
|
||||
.and_then(|g| g.meta_branch.as_ref()),
|
||||
self.run_options.host_repo_path.as_ref(),
|
||||
) {
|
||||
let refspec = format!("refs/heads/{meta_branch}");
|
||||
let meta_push_ok = git_push_host(
|
||||
repo_path,
|
||||
&refspec,
|
||||
&self.run_options.github_app,
|
||||
"metadata branch",
|
||||
)
|
||||
.await;
|
||||
git_result
|
||||
.push_results
|
||||
.push((meta_branch.clone(), meta_push_ok));
|
||||
let refspec = format!("refs/heads/{branch}:refs/heads/{branch}");
|
||||
let push_ok = self.sandbox.git_push_ref(&refspec).await;
|
||||
git_result.push_results.push((refspec, push_ok));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -320,3 +229,53 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl GitLifecycle {
|
||||
async fn write_metadata_snapshot(&self, dump: &RunDump, message: &str) -> Option<String> {
|
||||
if self.metadata_runtime.metadata_degraded() {
|
||||
return None;
|
||||
}
|
||||
let meta_branch = self
|
||||
.run_options
|
||||
.git
|
||||
.as_ref()
|
||||
.and_then(|git| git.meta_branch.as_deref())?;
|
||||
|
||||
let run_id = self.run_id.to_string();
|
||||
let writer = SandboxMetadataWriter::new(
|
||||
&*self.sandbox,
|
||||
&self.metadata_runtime,
|
||||
&run_id,
|
||||
meta_branch,
|
||||
self.run_options.git_author(),
|
||||
);
|
||||
match writer.write_snapshot(dump, message).await {
|
||||
Ok(snapshot) => {
|
||||
if !snapshot.pushed {
|
||||
self.emit_metadata_warning(
|
||||
"checkpoint_metadata_push_failed",
|
||||
format!("failed to push metadata ref refs/heads/{meta_branch}"),
|
||||
);
|
||||
}
|
||||
Some(snapshot.commit_sha)
|
||||
}
|
||||
Err(err) => {
|
||||
self.emit_metadata_warning(
|
||||
"checkpoint_metadata_write_failed",
|
||||
format!("failed to write checkpoint metadata: {err}"),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_metadata_warning(&self, code: &str, message: String) {
|
||||
if self.metadata_runtime.mark_metadata_degraded() {
|
||||
self.emitter.emit(&Event::RunNotice {
|
||||
level: RunNoticeLevel::Warn,
|
||||
code: code.to_string(),
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ use crate::outcome::{BilledModelUsage, Outcome, OutcomeExt};
|
|||
use crate::run_control::RunControlState;
|
||||
use crate::run_options::RunOptions;
|
||||
use crate::runtime_store::RunStoreHandle;
|
||||
use crate::sandbox_metadata::SandboxGitRuntime;
|
||||
|
||||
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
|
||||
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
|
||||
|
|
@ -87,6 +88,7 @@ impl WorkflowLifecycle {
|
|||
run_store: &RunStoreHandle,
|
||||
artifact_sink: Option<ArtifactSink>,
|
||||
run_options: &Arc<RunOptions>,
|
||||
metadata_runtime: Arc<SandboxGitRuntime>,
|
||||
is_resume: bool,
|
||||
on_node: crate::OnNodeCallback,
|
||||
run_control: Option<Arc<RunControlState>>,
|
||||
|
|
@ -104,7 +106,7 @@ impl WorkflowLifecycle {
|
|||
.as_ref()
|
||||
.and_then(|g| g.run_branch.as_ref())
|
||||
.is_some();
|
||||
let local_git_checkpoint = has_run_branch && sandbox.host_git_dir().is_some();
|
||||
let local_git_checkpoint = has_run_branch && !run_options.checkpoints_disabled;
|
||||
let working_directory = if local_git_checkpoint {
|
||||
Some(sandbox.working_directory().to_string())
|
||||
} else {
|
||||
|
|
@ -149,6 +151,7 @@ impl WorkflowLifecycle {
|
|||
run_id: run_options.run_id,
|
||||
run_store: run_store.clone(),
|
||||
run_options: Arc::clone(run_options),
|
||||
metadata_runtime,
|
||||
start_node_id,
|
||||
checkpoint_git_result: Arc::clone(&checkpoint_git_result),
|
||||
last_git_sha,
|
||||
|
|
|
|||
|
|
@ -12,11 +12,10 @@ use fabro_config::Storage;
|
|||
use fabro_graphviz::graph::{AttrValue, Graph};
|
||||
use fabro_model::{Catalog, Provider};
|
||||
use fabro_sandbox::SandboxProvider;
|
||||
use fabro_sandbox::daytona::detect_repo_info;
|
||||
use fabro_store::Database;
|
||||
use fabro_template::{TemplateContext, render as render_template};
|
||||
use fabro_types::settings::run::{RunMode, RunNamespace};
|
||||
use fabro_types::{RunId, RunProvenance, WorkflowSettings};
|
||||
use fabro_types::{ForkSourceRef, PreRunGitContext, RunId, RunProvenance, WorkflowSettings};
|
||||
use fabro_util::json::normalize_json_value;
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
|
|
@ -42,9 +41,11 @@ pub struct CreateRunInput {
|
|||
pub workflow_bundle: Option<WorkflowBundle>,
|
||||
pub submitted_manifest_bytes: Option<Vec<u8>>,
|
||||
pub run_id: Option<RunId>,
|
||||
pub host_repo_path: Option<String>,
|
||||
pub repo_origin_url: Option<String>,
|
||||
pub base_branch: Option<String>,
|
||||
pub pre_run_git: Option<PreRunGitContext>,
|
||||
pub fork_source_ref: Option<ForkSourceRef>,
|
||||
pub checkpoints_disabled: bool,
|
||||
pub provenance: Option<RunProvenance>,
|
||||
pub configured_providers: Vec<Provider>,
|
||||
}
|
||||
|
|
@ -64,9 +65,11 @@ struct PersistCreateOptions {
|
|||
workflow_slug: Option<String>,
|
||||
labels: HashMap<String, String>,
|
||||
base_branch: Option<String>,
|
||||
working_directory: PathBuf,
|
||||
host_repo_path: Option<String>,
|
||||
source_directory: Option<String>,
|
||||
repo_origin_url: Option<String>,
|
||||
pre_run_git: Option<PreRunGitContext>,
|
||||
fork_source_ref: Option<ForkSourceRef>,
|
||||
checkpoints_disabled: bool,
|
||||
provenance: Option<RunProvenance>,
|
||||
configured_providers: Vec<Provider>,
|
||||
}
|
||||
|
|
@ -98,9 +101,11 @@ pub async fn create(
|
|||
workflow_bundle,
|
||||
submitted_manifest_bytes,
|
||||
run_id,
|
||||
host_repo_path,
|
||||
repo_origin_url,
|
||||
base_branch,
|
||||
pre_run_git,
|
||||
fork_source_ref,
|
||||
checkpoints_disabled,
|
||||
provenance,
|
||||
configured_providers,
|
||||
} = request;
|
||||
|
|
@ -108,20 +113,7 @@ pub async fn create(
|
|||
let run_id = run_id.unwrap_or_else(RunId::new);
|
||||
let storage = Storage::new(storage_root);
|
||||
let run_dir = storage.run_scratch(&run_id).root().to_path_buf();
|
||||
let working_directory = resolved.working_directory.clone();
|
||||
let host_repo_path =
|
||||
host_repo_path.or_else(|| Some(working_directory.to_string_lossy().to_string()));
|
||||
let detected_repo = detect_repo_info(&working_directory).ok();
|
||||
let repo_origin_url = repo_origin_url.or_else(|| {
|
||||
detected_repo
|
||||
.as_ref()
|
||||
.map(|(origin_url, _)| fabro_github::normalize_repo_origin_url(origin_url))
|
||||
});
|
||||
let base_branch = base_branch.or_else(|| {
|
||||
detected_repo
|
||||
.as_ref()
|
||||
.and_then(|(_, branch)| branch.clone())
|
||||
});
|
||||
let source_directory = Some(resolved.working_directory.to_string_lossy().to_string());
|
||||
|
||||
let goal_override = resolved.goal_override.clone();
|
||||
let current_dir = resolved.current_dir.clone();
|
||||
|
|
@ -147,9 +139,11 @@ pub async fn create(
|
|||
workflow_slug: workflow_slug.or(resolved_workflow_slug),
|
||||
labels,
|
||||
base_branch,
|
||||
working_directory,
|
||||
host_repo_path,
|
||||
source_directory,
|
||||
repo_origin_url,
|
||||
pre_run_git,
|
||||
fork_source_ref,
|
||||
checkpoints_disabled,
|
||||
provenance,
|
||||
configured_providers,
|
||||
},
|
||||
|
|
@ -233,14 +227,16 @@ async fn persist_created_run(
|
|||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
run_dir: persisted.run_dir().display().to_string(),
|
||||
working_directory: record.working_directory.display().to_string(),
|
||||
host_repo_path: record.host_repo_path.clone(),
|
||||
source_directory: record.source_directory.clone(),
|
||||
repo_origin_url: record.repo_origin_url.clone(),
|
||||
base_branch: record.base_branch.clone(),
|
||||
workflow_slug: record.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: record.provenance.clone(),
|
||||
manifest_blob,
|
||||
pre_run_git: record.pre_run_git.clone(),
|
||||
fork_source_ref: record.fork_source_ref.clone(),
|
||||
checkpoints_disabled: record.checkpoints_disabled,
|
||||
},
|
||||
record.run_id.created_at(),
|
||||
None,
|
||||
|
|
@ -355,9 +351,11 @@ fn persist_validated(
|
|||
workflow_slug,
|
||||
labels,
|
||||
base_branch,
|
||||
working_directory,
|
||||
host_repo_path,
|
||||
source_directory,
|
||||
repo_origin_url,
|
||||
pre_run_git,
|
||||
fork_source_ref,
|
||||
checkpoints_disabled,
|
||||
provenance,
|
||||
configured_providers,
|
||||
} = options;
|
||||
|
|
@ -377,14 +375,16 @@ fn persist_validated(
|
|||
settings,
|
||||
graph: validated.graph().clone(),
|
||||
workflow_slug,
|
||||
working_directory,
|
||||
host_repo_path,
|
||||
source_directory,
|
||||
repo_origin_url,
|
||||
base_branch,
|
||||
labels,
|
||||
provenance,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
pre_run_git,
|
||||
fork_source_ref,
|
||||
checkpoints_disabled,
|
||||
};
|
||||
|
||||
pipeline::persist(validated, PersistOptions { run_dir, run_spec })
|
||||
|
|
@ -722,9 +722,11 @@ mod tests {
|
|||
workflow_bundle: None,
|
||||
submitted_manifest_bytes: None,
|
||||
run_id: None,
|
||||
host_repo_path: None,
|
||||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
provenance: None,
|
||||
configured_providers: Vec::new(),
|
||||
},
|
||||
|
|
@ -765,9 +767,11 @@ mod tests {
|
|||
workflow_bundle: None,
|
||||
submitted_manifest_bytes: None,
|
||||
run_id: None,
|
||||
host_repo_path: None,
|
||||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
provenance: None,
|
||||
configured_providers: Vec::new(),
|
||||
},
|
||||
|
|
@ -824,9 +828,11 @@ mod tests {
|
|||
workflow_bundle: None,
|
||||
submitted_manifest_bytes: None,
|
||||
run_id: Some(fixtures::RUN_1),
|
||||
host_repo_path: Some(dir.path().display().to_string()),
|
||||
repo_origin_url: None,
|
||||
base_branch: Some("main".to_string()),
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
provenance: None,
|
||||
configured_providers: Vec::new(),
|
||||
},
|
||||
|
|
@ -902,7 +908,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_resolves_working_directory_and_repo_path_from_request_cwd() {
|
||||
async fn create_persists_submitter_source_directory_from_request_cwd() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("workspace");
|
||||
std::fs::create_dir_all(&workspace).unwrap();
|
||||
|
|
@ -932,9 +938,11 @@ mod tests {
|
|||
workflow_bundle: None,
|
||||
submitted_manifest_bytes: None,
|
||||
run_id: Some(fixtures::RUN_2),
|
||||
host_repo_path: None,
|
||||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
provenance: None,
|
||||
configured_providers: Vec::new(),
|
||||
},
|
||||
|
|
@ -943,17 +951,9 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(created.persisted.run_spec().working_directory, workspace);
|
||||
assert_eq!(
|
||||
created.persisted.run_spec().host_repo_path.as_deref(),
|
||||
Some(
|
||||
created
|
||||
.persisted
|
||||
.run_spec()
|
||||
.working_directory
|
||||
.to_string_lossy()
|
||||
.as_ref()
|
||||
)
|
||||
created.persisted.run_spec().source_directory.as_deref(),
|
||||
Some(workspace.to_string_lossy().as_ref())
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -976,9 +976,11 @@ mod tests {
|
|||
workflow_bundle: None,
|
||||
submitted_manifest_bytes: None,
|
||||
run_id: Some(fixtures::RUN_2),
|
||||
host_repo_path: None,
|
||||
repo_origin_url: Some("https://github.com/acme/widgets".to_string()),
|
||||
base_branch: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
provenance: None,
|
||||
configured_providers: Vec::new(),
|
||||
},
|
||||
|
|
@ -1040,9 +1042,11 @@ mod tests {
|
|||
workflow_bundle: None,
|
||||
submitted_manifest_bytes: None,
|
||||
run_id: Some(fixtures::RUN_3),
|
||||
host_repo_path: None,
|
||||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
provenance: None,
|
||||
configured_providers: Vec::new(),
|
||||
},
|
||||
|
|
@ -1083,9 +1087,11 @@ mod tests {
|
|||
workflow_bundle: None,
|
||||
submitted_manifest_bytes: None,
|
||||
run_id: Some(fixtures::RUN_64),
|
||||
host_repo_path: None,
|
||||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
provenance: Some(fabro_types::RunProvenance {
|
||||
server: Some(fabro_types::RunServerProvenance {
|
||||
version: "0.9.0".to_string(),
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
use anyhow::{Context, Result};
|
||||
use fabro_checkpoint::branch::BranchStore;
|
||||
use fabro_checkpoint::git::Store;
|
||||
use fabro_store::{Database, RunProjection};
|
||||
use fabro_types::RunId;
|
||||
use git2::{Oid, Signature};
|
||||
use anyhow::Result as AnyResult;
|
||||
use fabro_store::{Database, RunProjection, RunProjectionReducer};
|
||||
use fabro_types::{ForkSourceRef, RunId};
|
||||
|
||||
use super::run_git;
|
||||
use super::timeline::{ForkTarget, RunTimeline, TimelineEntry, build_timeline};
|
||||
use crate::error::Error;
|
||||
use crate::event::{self, Event};
|
||||
use crate::git::{MetadataStore, RUN_BRANCH_PREFIX, push_run_branches};
|
||||
use crate::records::{Checkpoint, RunSpec, StartRecord};
|
||||
use crate::run_dump::RunDump;
|
||||
use crate::records::{Checkpoint, RunSpec};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ForkRunInput {
|
||||
|
|
@ -41,60 +35,112 @@ pub struct ForkOutcome {
|
|||
pub target: ResolvedForkTarget,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ForkedRun {
|
||||
new_run_id: RunId,
|
||||
projection: RunProjection,
|
||||
}
|
||||
|
||||
/// Create a new run that branches from an existing run at a specific
|
||||
/// checkpoint.
|
||||
///
|
||||
/// Returns the new run ID.
|
||||
#[cfg(test)]
|
||||
fn fork(store: &Store, input: &ForkRunInput) -> Result<RunId> {
|
||||
let timeline = build_timeline(store, &input.source_run_id.to_string())?;
|
||||
let entry = resolve_fork_entry(&timeline, &input.source_run_id, input.target.as_ref())?;
|
||||
Ok(fork_from_entry(store, &input.source_run_id, entry, input.push)?.new_run_id)
|
||||
}
|
||||
|
||||
pub async fn fork_run(store: &Database, input: &ForkRunInput) -> Result<ForkOutcome, Error> {
|
||||
pub async fn fork_run(
|
||||
store: &Database,
|
||||
input: &ForkRunInput,
|
||||
) -> std::result::Result<ForkOutcome, Error> {
|
||||
let source_run_id = input.source_run_id;
|
||||
let target = input.target.clone();
|
||||
let push = input.push;
|
||||
let run_store = store
|
||||
.open_run(&source_run_id)
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
let state = run_store
|
||||
.state()
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
let timeline = build_timeline(&state).map_err(|err| Error::engine(err.to_string()))?;
|
||||
let entry = resolve_fork_entry(&timeline, &source_run_id, input.target.as_ref())
|
||||
.map_err(|err| Error::Validation(err.to_string()))?;
|
||||
let checkpoint_sha = entry.run_commit_sha.clone().ok_or_else(|| {
|
||||
Error::Validation(format!(
|
||||
"checkpoint @{} has no git_commit_sha; cannot fork",
|
||||
entry.ordinal
|
||||
))
|
||||
})?;
|
||||
|
||||
let (outcome, projection) =
|
||||
run_git::with_run_git_store(store, source_run_id, move |git_store| {
|
||||
let timeline = build_timeline(&git_store, &source_run_id.to_string())
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
let entry = resolve_fork_entry(&timeline, &source_run_id, target.as_ref())
|
||||
.map_err(|err| Error::Validation(err.to_string()))?;
|
||||
let resolved = ResolvedForkTarget {
|
||||
checkpoint_ordinal: entry.ordinal,
|
||||
node_id: entry.node_name.clone(),
|
||||
visit: entry.visit,
|
||||
};
|
||||
let forked = fork_from_entry(&git_store, &source_run_id, entry, push)
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
let outcome = ForkOutcome {
|
||||
source_run_id,
|
||||
new_run_id: forked.new_run_id,
|
||||
target: resolved,
|
||||
};
|
||||
Ok((outcome, forked.projection))
|
||||
})
|
||||
.await?;
|
||||
validate_source_spec(state.spec.as_ref(), &checkpoint_sha)?;
|
||||
|
||||
let events = run_store
|
||||
.list_events()
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
let historical_events = events
|
||||
.into_iter()
|
||||
.filter(|event| event.seq <= entry.checkpoint_seq)
|
||||
.collect::<Vec<_>>();
|
||||
let mut projection = RunProjection::apply_events(&historical_events)
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
let mut run_spec = projection
|
||||
.spec
|
||||
.clone()
|
||||
.ok_or_else(|| Error::engine("source run projection has no spec"))?;
|
||||
|
||||
let new_run_id = RunId::new();
|
||||
run_spec.run_id = new_run_id;
|
||||
run_spec.fork_source_ref = Some(ForkSourceRef {
|
||||
source_run_id,
|
||||
checkpoint_sha: checkpoint_sha.clone(),
|
||||
});
|
||||
projection.spec = Some(run_spec);
|
||||
projection.start = None;
|
||||
projection.sandbox = None;
|
||||
projection.conclusion = None;
|
||||
projection.retro = None;
|
||||
projection.retro_prompt = None;
|
||||
projection.retro_response = None;
|
||||
projection.final_patch = None;
|
||||
projection.pull_request = None;
|
||||
projection.superseded_by = None;
|
||||
if let Some(checkpoint) = projection.checkpoint.as_mut() {
|
||||
checkpoint.git_commit_sha = Some(checkpoint_sha);
|
||||
}
|
||||
|
||||
persist_forked_run(store, &projection).await?;
|
||||
|
||||
Ok(outcome)
|
||||
Ok(ForkOutcome {
|
||||
source_run_id,
|
||||
new_run_id,
|
||||
target: ResolvedForkTarget {
|
||||
checkpoint_ordinal: entry.ordinal,
|
||||
node_id: entry.node_name.clone(),
|
||||
visit: entry.visit,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_source_spec(
|
||||
spec: Option<&RunSpec>,
|
||||
checkpoint_sha: &str,
|
||||
) -> std::result::Result<(), Error> {
|
||||
let spec = spec.ok_or_else(|| Error::engine("source run projection has no spec"))?;
|
||||
if spec.checkpoints_disabled {
|
||||
return Err(Error::Validation(
|
||||
"source run was created with checkpoints disabled; cannot fork".to_string(),
|
||||
));
|
||||
}
|
||||
if checkpoint_sha.trim().is_empty() {
|
||||
return Err(Error::Validation(
|
||||
"target checkpoint has an empty git_commit_sha; cannot fork".to_string(),
|
||||
));
|
||||
}
|
||||
let Some(origin) = spec.repo_origin_url.as_ref() else {
|
||||
return Err(Error::Validation(
|
||||
"source run has no repo_origin_url; cannot validate fork origin".to_string(),
|
||||
));
|
||||
};
|
||||
if fabro_github::normalize_repo_origin_url(origin).is_empty() {
|
||||
return Err(Error::Validation(
|
||||
"source run has an empty repo_origin_url; cannot validate fork origin".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_fork_entry<'a>(
|
||||
timeline: &'a RunTimeline,
|
||||
source_run_id: &RunId,
|
||||
target: Option<&ForkTarget>,
|
||||
) -> Result<&'a TimelineEntry> {
|
||||
) -> AnyResult<&'a TimelineEntry> {
|
||||
match target {
|
||||
Some(target) => timeline.resolve(target),
|
||||
None => timeline
|
||||
|
|
@ -104,145 +150,14 @@ fn resolve_fork_entry<'a>(
|
|||
}
|
||||
}
|
||||
|
||||
fn fork_from_entry(
|
||||
store: &Store,
|
||||
source_run_id: &RunId,
|
||||
entry: &TimelineEntry,
|
||||
push: bool,
|
||||
) -> Result<ForkedRun> {
|
||||
let new_run_id = RunId::new();
|
||||
let sig = Signature::now("Fabro", "noreply@fabro.sh")?;
|
||||
|
||||
let new_run_branch = format!("{RUN_BRANCH_PREFIX}{new_run_id}");
|
||||
match &entry.run_commit_sha {
|
||||
Some(sha) => {
|
||||
let oid =
|
||||
Oid::from_str(sha).with_context(|| format!("invalid run commit SHA: {sha}"))?;
|
||||
store
|
||||
.update_ref(&new_run_branch, oid)
|
||||
.map_err(|e| anyhow::anyhow!("failed to create run branch ref: {e}"))?;
|
||||
}
|
||||
None => {
|
||||
anyhow::bail!(
|
||||
"checkpoint @{} has no git_commit_sha; cannot fork",
|
||||
entry.ordinal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let source_meta_branch = MetadataStore::branch_name(&source_run_id.to_string());
|
||||
let new_meta_branch = MetadataStore::branch_name(&new_run_id.to_string());
|
||||
let source_bs = BranchStore::new(store, &source_meta_branch, &sig);
|
||||
let new_bs = BranchStore::new(store, &new_meta_branch, &sig);
|
||||
|
||||
new_bs
|
||||
.ensure_branch()
|
||||
.map_err(|e| anyhow::anyhow!("failed to create metadata branch: {e}"))?;
|
||||
|
||||
let source_projection = source_bs
|
||||
.read_entry("run.json")
|
||||
.map_err(|e| anyhow::anyhow!("failed to read source metadata: {e}"))?
|
||||
.context("source run has no run.json")
|
||||
.and_then(|bytes| {
|
||||
serde_json::from_slice::<RunProjection>(&bytes)
|
||||
.context("failed to parse source run.json")
|
||||
})?;
|
||||
|
||||
let mut run_spec: RunSpec = source_projection
|
||||
.spec
|
||||
.clone()
|
||||
.context("source run projection has no spec")?;
|
||||
run_spec.run_id = new_run_id;
|
||||
|
||||
let start_record = StartRecord {
|
||||
run_id: new_run_id,
|
||||
start_time: new_run_id.created_at(),
|
||||
run_branch: Some(new_run_branch.clone()),
|
||||
base_sha: entry.run_commit_sha.clone(),
|
||||
};
|
||||
|
||||
let mut init_projection = RunProjection::default();
|
||||
init_projection.spec = Some(run_spec.clone());
|
||||
init_projection
|
||||
.graph_source
|
||||
.clone_from(&source_projection.graph_source);
|
||||
init_projection.start = Some(start_record.clone());
|
||||
init_projection
|
||||
.sandbox
|
||||
.clone_from(&source_projection.sandbox);
|
||||
|
||||
let checkpoint_bytes = store
|
||||
.read_blob_at(entry.metadata_commit_oid, "run.json")
|
||||
.map_err(|e| anyhow::anyhow!("failed to read checkpoint snapshot: {e}"))?
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no run.json at metadata commit {}",
|
||||
entry.metadata_commit_oid
|
||||
)
|
||||
})?;
|
||||
let mut checkpoint_projection: RunProjection = serde_json::from_slice(&checkpoint_bytes)
|
||||
.context("failed to parse source checkpoint snapshot")?;
|
||||
let mut checkpoint: Checkpoint = checkpoint_projection
|
||||
.checkpoint
|
||||
.clone()
|
||||
.context("source checkpoint snapshot has no checkpoint")?;
|
||||
checkpoint.git_commit_sha.clone_from(&entry.run_commit_sha);
|
||||
checkpoint_projection.spec = Some(run_spec);
|
||||
checkpoint_projection.graph_source = source_projection.graph_source;
|
||||
checkpoint_projection.start = Some(start_record);
|
||||
checkpoint_projection.sandbox = source_projection.sandbox;
|
||||
checkpoint_projection.checkpoint = Some(checkpoint);
|
||||
|
||||
let init_entries = RunDump::from_projection(&init_projection)
|
||||
.git_entries()
|
||||
.context("failed to build init metadata snapshot")?;
|
||||
let init_refs: Vec<(&str, &[u8])> = init_entries
|
||||
.iter()
|
||||
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
|
||||
.collect();
|
||||
new_bs
|
||||
.write_entries(&init_refs, "init run")
|
||||
.map_err(|e| anyhow::anyhow!("failed to write init metadata snapshot: {e}"))?;
|
||||
|
||||
let checkpoint_entries = RunDump::from_projection(&checkpoint_projection)
|
||||
.git_entries()
|
||||
.context("failed to build checkpoint metadata snapshot")?;
|
||||
let checkpoint_refs: Vec<(&str, &[u8])> = checkpoint_entries
|
||||
.iter()
|
||||
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
|
||||
.collect();
|
||||
new_bs
|
||||
.write_entries(&checkpoint_refs, "checkpoint")
|
||||
.map_err(|e| anyhow::anyhow!("failed to write metadata snapshot: {e}"))?;
|
||||
|
||||
if push {
|
||||
let source_run_branch = format!("{RUN_BRANCH_PREFIX}{source_run_id}");
|
||||
let run_refspec = format!("refs/heads/{new_run_branch}:refs/heads/{new_run_branch}");
|
||||
let meta_refspec = format!("refs/heads/{new_meta_branch}:refs/heads/{new_meta_branch}");
|
||||
push_run_branches(
|
||||
store,
|
||||
&source_run_branch,
|
||||
Some(&run_refspec),
|
||||
&meta_refspec,
|
||||
"new",
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(ForkedRun {
|
||||
new_run_id,
|
||||
projection: checkpoint_projection,
|
||||
})
|
||||
}
|
||||
|
||||
async fn persist_forked_run(store: &Database, projection: &RunProjection) -> Result<(), Error> {
|
||||
async fn persist_forked_run(
|
||||
store: &Database,
|
||||
projection: &RunProjection,
|
||||
) -> std::result::Result<(), Error> {
|
||||
let spec = projection
|
||||
.spec
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::engine("forked run projection has no spec"))?;
|
||||
let start = projection
|
||||
.start
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::engine("forked run projection has no start record"))?;
|
||||
let checkpoint = projection
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
|
|
@ -254,54 +169,29 @@ async fn persist_forked_run(store: &Database, projection: &RunProjection) -> Res
|
|||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
|
||||
event::append_event(&run_store, &spec.run_id, &Event::RunCreated {
|
||||
run_id: spec.run_id,
|
||||
settings: serde_json::to_value(&spec.settings)
|
||||
run_id: spec.run_id,
|
||||
settings: serde_json::to_value(&spec.settings)
|
||||
.map_err(|err| Error::engine(err.to_string()))?,
|
||||
graph: serde_json::to_value(&spec.graph)
|
||||
graph: serde_json::to_value(&spec.graph)
|
||||
.map_err(|err| Error::engine(err.to_string()))?,
|
||||
workflow_source: projection.graph_source.clone(),
|
||||
workflow_config: None,
|
||||
labels: spec.labels.clone().into_iter().collect(),
|
||||
run_dir: String::new(),
|
||||
working_directory: spec.working_directory.display().to_string(),
|
||||
host_repo_path: spec.host_repo_path.clone(),
|
||||
repo_origin_url: spec.repo_origin_url.clone(),
|
||||
base_branch: spec.base_branch.clone(),
|
||||
workflow_slug: spec.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: spec.provenance.clone(),
|
||||
manifest_blob: spec.manifest_blob,
|
||||
workflow_source: projection.graph_source.clone(),
|
||||
workflow_config: None,
|
||||
labels: spec.labels.clone().into_iter().collect(),
|
||||
run_dir: String::new(),
|
||||
source_directory: spec.source_directory.clone(),
|
||||
repo_origin_url: spec.repo_origin_url.clone(),
|
||||
base_branch: spec.base_branch.clone(),
|
||||
workflow_slug: spec.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: spec.provenance.clone(),
|
||||
manifest_blob: spec.manifest_blob,
|
||||
pre_run_git: spec.pre_run_git.clone(),
|
||||
fork_source_ref: spec.fork_source_ref.clone(),
|
||||
checkpoints_disabled: spec.checkpoints_disabled,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
|
||||
event::append_event(&run_store, &spec.run_id, &Event::WorkflowRunStarted {
|
||||
name: spec.graph.name.clone(),
|
||||
run_id: spec.run_id,
|
||||
base_branch: spec.base_branch.clone(),
|
||||
base_sha: start.base_sha.clone(),
|
||||
run_branch: start.run_branch.clone(),
|
||||
worktree_dir: None,
|
||||
goal: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
|
||||
if let Some(sandbox) = projection.sandbox.as_ref() {
|
||||
event::append_event(&run_store, &spec.run_id, &Event::SandboxInitialized {
|
||||
provider: sandbox.provider.clone(),
|
||||
working_directory: sandbox.working_directory.clone(),
|
||||
identifier: sandbox.identifier.clone(),
|
||||
host_working_directory: sandbox.host_working_directory.clone(),
|
||||
container_mount_point: sandbox.container_mount_point.clone(),
|
||||
repo_cloned: sandbox.repo_cloned,
|
||||
clone_origin_url: sandbox.clone_origin_url.clone(),
|
||||
clone_branch: sandbox.clone_branch.clone(),
|
||||
})
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
}
|
||||
|
||||
event::append_event(
|
||||
&run_store,
|
||||
&spec.run_id,
|
||||
|
|
@ -349,190 +239,3 @@ fn checkpoint_completed_event(checkpoint: &Checkpoint) -> Event {
|
|||
diff: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use fabro_store::RunProjection;
|
||||
use fabro_types::{RunId, WorkflowSettings};
|
||||
use git2::Oid;
|
||||
|
||||
use super::super::test_support::*;
|
||||
use super::*;
|
||||
use crate::operations::find_run_id_by_prefix;
|
||||
|
||||
fn parse_run_id(value: &str) -> RunId {
|
||||
value.parse().unwrap()
|
||||
}
|
||||
|
||||
fn make_run_projection(run_id: &RunId) -> RunProjection {
|
||||
let mut projection = RunProjection::default();
|
||||
let settings = serde_json::to_value(WorkflowSettings::default()).unwrap();
|
||||
projection.spec = Some(
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"run_id": run_id.to_string(),
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"settings": settings,
|
||||
"graph": {
|
||||
"name": "test_workflow",
|
||||
"nodes": {
|
||||
"start": {"id": "start", "attrs": {}},
|
||||
"build": {"id": "build", "attrs": {}},
|
||||
"test": {"id": "test", "attrs": {}}
|
||||
},
|
||||
"edges": [
|
||||
{"from": "start", "to": "build", "attrs": {}},
|
||||
{"from": "build", "to": "test", "attrs": {}}
|
||||
],
|
||||
"attrs": {}
|
||||
},
|
||||
"working_directory": "/tmp/test",
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
projection
|
||||
}
|
||||
|
||||
fn make_start_record_json(run_id: &RunId) -> Vec<u8> {
|
||||
let record = serde_json::json!({
|
||||
"run_id": run_id.to_string(),
|
||||
"start_time": "2025-01-01T00:00:00Z",
|
||||
"run_branch": format!("{}{}", RUN_BRANCH_PREFIX, run_id),
|
||||
});
|
||||
serde_json::to_vec_pretty(&record).unwrap()
|
||||
}
|
||||
|
||||
fn setup_source_run(store: &Store, run_id: &RunId, nodes: &[&str]) -> Vec<Oid> {
|
||||
let sig = test_sig();
|
||||
|
||||
let run_branch = format!("{RUN_BRANCH_PREFIX}{run_id}");
|
||||
let empty_tree = store.write_empty_tree().unwrap();
|
||||
let mut run_oids = Vec::new();
|
||||
let mut parent: Option<Oid> = None;
|
||||
|
||||
for node in nodes {
|
||||
let parents = match parent {
|
||||
Some(p) => vec![p],
|
||||
None => vec![],
|
||||
};
|
||||
let oid = store
|
||||
.write_commit(
|
||||
empty_tree,
|
||||
&parents,
|
||||
&format!("fabro({run_id}): {node} (completed)"),
|
||||
&sig,
|
||||
)
|
||||
.unwrap();
|
||||
store.update_ref(&run_branch, oid).unwrap();
|
||||
run_oids.push(oid);
|
||||
parent = Some(oid);
|
||||
}
|
||||
|
||||
let meta_branch = MetadataStore::branch_name(&run_id.to_string());
|
||||
let bs = BranchStore::new(store, &meta_branch, &sig);
|
||||
bs.ensure_branch().unwrap();
|
||||
|
||||
let mut init_projection = make_run_projection(run_id);
|
||||
init_projection.start =
|
||||
Some(serde_json::from_slice(&make_start_record_json(run_id)).unwrap());
|
||||
let init_json = serde_json::to_vec_pretty(&init_projection).unwrap();
|
||||
bs.write_entries(&[("run.json", &init_json)], "init run")
|
||||
.unwrap();
|
||||
|
||||
for (i, node) in nodes.iter().enumerate() {
|
||||
let mut projection = init_projection.clone();
|
||||
projection.checkpoint = Some(
|
||||
serde_json::from_slice(&make_checkpoint_bytes(
|
||||
node,
|
||||
1,
|
||||
Some(&run_oids[i].to_string()),
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
let projection_json = serde_json::to_vec_pretty(&projection).unwrap();
|
||||
bs.write_entry("run.json", &projection_json, "checkpoint")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
run_oids
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_creates_new_run_and_metadata_branches() {
|
||||
let (_dir, store) = temp_repo();
|
||||
let source_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
|
||||
let run_oids = setup_source_run(&store, &source_run_id, &["start", "build", "test"]);
|
||||
|
||||
let new_run_id = fork(&store, &ForkRunInput {
|
||||
source_run_id,
|
||||
target: Some(ForkTarget::from_str("@2").unwrap()),
|
||||
push: false,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let new_run_branch = format!("{RUN_BRANCH_PREFIX}{new_run_id}");
|
||||
let new_meta_branch = MetadataStore::branch_name(&new_run_id.to_string());
|
||||
|
||||
assert!(store.resolve_ref(&new_run_branch).unwrap().is_some());
|
||||
assert!(store.resolve_ref(&new_meta_branch).unwrap().is_some());
|
||||
|
||||
let sig = test_sig();
|
||||
let bs = BranchStore::new(&store, &new_meta_branch, &sig);
|
||||
let run_json = bs.read_entry("run.json").unwrap().unwrap();
|
||||
let run_spec: RunProjection = serde_json::from_slice(&run_json).unwrap();
|
||||
assert_eq!(
|
||||
run_spec.spec.as_ref().map(|run| run.run_id),
|
||||
Some(new_run_id)
|
||||
);
|
||||
|
||||
let timeline = build_timeline(&store, &new_run_id.to_string()).unwrap();
|
||||
assert_eq!(timeline.entries.len(), 1);
|
||||
assert_eq!(timeline.entries[0].node_name, "build");
|
||||
assert_eq!(
|
||||
timeline.entries[0].run_commit_sha,
|
||||
Some(run_oids[1].to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_rejects_checkpoint_without_run_sha() {
|
||||
let (_dir, store) = temp_repo();
|
||||
let sig = test_sig();
|
||||
let run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAW");
|
||||
let meta_branch = MetadataStore::branch_name(&run_id.to_string());
|
||||
let bs = BranchStore::new(&store, &meta_branch, &sig);
|
||||
bs.ensure_branch().unwrap();
|
||||
let init_projection = serde_json::to_vec_pretty(&make_run_projection(&run_id)).unwrap();
|
||||
bs.write_entry("run.json", &init_projection, "init")
|
||||
.unwrap();
|
||||
|
||||
let mut checkpoint_projection = make_run_projection(&run_id);
|
||||
checkpoint_projection.checkpoint =
|
||||
Some(serde_json::from_slice(&make_checkpoint_bytes("start", 1, None)).unwrap());
|
||||
let cp = serde_json::to_vec_pretty(&checkpoint_projection).unwrap();
|
||||
let oid = bs.write_entry("run.json", &cp, "checkpoint").unwrap();
|
||||
let entry = TimelineEntry {
|
||||
ordinal: 1,
|
||||
node_name: "start".to_string(),
|
||||
visit: 1,
|
||||
metadata_commit_oid: oid,
|
||||
run_commit_sha: None,
|
||||
};
|
||||
|
||||
let err = fork_from_entry(&store, &run_id, &entry, false)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("cannot fork"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_supports_prefix_resolved_source_run_ids() {
|
||||
let (_dir, store) = temp_repo();
|
||||
let source_run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAX");
|
||||
setup_source_run(&store, &source_run_id, &["start", "build"]);
|
||||
|
||||
let resolved = find_run_id_by_prefix(store.repo(), "01ARZ3").unwrap();
|
||||
assert_eq!(resolved, source_run_id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,11 @@
|
|||
mod archive;
|
||||
mod create;
|
||||
mod fork;
|
||||
mod rebuild_meta;
|
||||
mod resume;
|
||||
mod rewind;
|
||||
mod run_git;
|
||||
mod run_store;
|
||||
mod source;
|
||||
mod start;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
mod timeline;
|
||||
mod validate;
|
||||
|
||||
|
|
@ -19,16 +15,11 @@ pub use archive::{
|
|||
};
|
||||
pub use create::{CreateRunInput, CreatedRun, create, make_run_dir};
|
||||
pub use fork::{ForkOutcome, ForkRunInput, ResolvedForkTarget, fork_run};
|
||||
pub use rebuild_meta::{
|
||||
build_timeline_or_rebuild, find_run_id_by_prefix_or_store, rebuild_metadata_branch,
|
||||
};
|
||||
pub use resume::resume;
|
||||
pub use rewind::{RewindInput, RewindOutcome, rewind};
|
||||
pub use source::WorkflowInput;
|
||||
pub use start::{StartServices, Started, start};
|
||||
pub use timeline::{
|
||||
ForkTarget, RunTimeline, TimelineEntry, build_timeline, find_run_id_by_prefix, timeline,
|
||||
};
|
||||
pub use timeline::{ForkTarget, RunTimeline, TimelineEntry, build_timeline, timeline};
|
||||
pub use validate::{ValidateInput, validate};
|
||||
|
||||
pub use crate::pipeline::{DevcontainerSpec, LlmSpec, SandboxEnvSpec};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,9 +2,9 @@ use fabro_store::Database;
|
|||
use fabro_types::{ActorRef, RunId};
|
||||
use tracing::error;
|
||||
|
||||
use super::archive;
|
||||
use super::fork::{self, ForkOutcome, ForkRunInput, ResolvedForkTarget};
|
||||
use super::timeline::ForkTarget;
|
||||
use super::{archive, run_git};
|
||||
use crate::error::Error;
|
||||
use crate::event::{self, Event};
|
||||
|
||||
|
|
@ -35,7 +35,13 @@ pub async fn rewind(
|
|||
input: &RewindInput,
|
||||
actor: Option<ActorRef>,
|
||||
) -> Result<RewindOutcome, Error> {
|
||||
let projection = run_git::load_projection(store, &input.run_id).await?;
|
||||
let projection = store
|
||||
.open_run(&input.run_id)
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?
|
||||
.state()
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
let current = projection.status.ok_or_else(|| {
|
||||
Error::Precondition(format!("run {} has no status; cannot rewind", input.run_id))
|
||||
})?;
|
||||
|
|
@ -48,11 +54,11 @@ pub async fn rewind(
|
|||
)));
|
||||
}
|
||||
|
||||
let forked = fork::fork_run(store, &ForkRunInput {
|
||||
let forked = Box::pin(fork::fork_run(store, &ForkRunInput {
|
||||
source_run_id: input.run_id,
|
||||
target: input.target.clone(),
|
||||
push: input.push,
|
||||
})
|
||||
}))
|
||||
.await?;
|
||||
|
||||
match archive::archive(store, &input.run_id, actor).await {
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
use fabro_checkpoint::git::Store as GitStore;
|
||||
use fabro_store::Database;
|
||||
use fabro_types::{RunId, RunProjection};
|
||||
use git2::Repository;
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
use super::run_store::map_open_run_error;
|
||||
use crate::error::Error;
|
||||
|
||||
pub(crate) async fn load_projection(
|
||||
store: &Database,
|
||||
run_id: &RunId,
|
||||
) -> Result<RunProjection, Error> {
|
||||
let run_store = store
|
||||
.open_run_reader(run_id)
|
||||
.await
|
||||
.map_err(|err| map_open_run_error(run_id, err))?;
|
||||
run_store
|
||||
.state()
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn with_run_git_store<T>(
|
||||
store: &Database,
|
||||
run_id: RunId,
|
||||
operation: impl FnOnce(GitStore) -> Result<T, Error> + Send + 'static,
|
||||
) -> Result<T, Error>
|
||||
where
|
||||
T: Send + 'static,
|
||||
{
|
||||
let projection = load_projection(store, &run_id).await?;
|
||||
let spec = projection
|
||||
.spec
|
||||
.ok_or_else(|| Error::Precondition(format!("run {run_id} has no spec")))?;
|
||||
let working_directory = spec.working_directory;
|
||||
|
||||
spawn_blocking(move || {
|
||||
let repo = Repository::discover(&working_directory).map_err(|err| {
|
||||
Error::Unsupported(format!(
|
||||
"server cannot access run {run_id}'s working_directory {}: {err}",
|
||||
working_directory.display()
|
||||
))
|
||||
})?;
|
||||
operation(GitStore::new(repo))
|
||||
})
|
||||
.await
|
||||
.map_err(|err| Error::engine(format!("git operation task failed: {err}")))?
|
||||
}
|
||||
|
|
@ -37,7 +37,6 @@ use crate::error::Error;
|
|||
use crate::event::{
|
||||
Emitter, Event, EventBody, RunEventLogger, RunEventSink, RunNoticeLevel, append_event_to_sink,
|
||||
};
|
||||
use crate::git::MetadataStore;
|
||||
use crate::handler::HandlerRegistry;
|
||||
use crate::outcome::{Outcome, StageStatus};
|
||||
use crate::pipeline::{
|
||||
|
|
@ -50,6 +49,7 @@ use crate::run_control::RunControlState;
|
|||
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
|
||||
use crate::run_status::{FailureReason, RunStatus};
|
||||
use crate::runtime_store::RunStoreHandle;
|
||||
use crate::sandbox_metadata::metadata_branch_name;
|
||||
use crate::workflow_bundle::{RunDefinition, WorkflowBundle};
|
||||
|
||||
struct RunSession {
|
||||
|
|
@ -281,7 +281,10 @@ impl RunSession {
|
|||
async fn new(persisted: &Persisted, services: StartServices) -> Result<Self, Error> {
|
||||
let record = persisted.run_spec();
|
||||
let settings = &record.settings;
|
||||
let working_directory = record.working_directory.clone();
|
||||
let working_directory = record
|
||||
.source_directory
|
||||
.as_deref()
|
||||
.map_or_else(|| PathBuf::from("."), PathBuf::from);
|
||||
let state = services
|
||||
.run_store
|
||||
.state()
|
||||
|
|
@ -291,7 +294,7 @@ impl RunSession {
|
|||
start.run_branch.as_ref().map(|_| GitCheckpointOptions {
|
||||
base_sha: start.base_sha.clone(),
|
||||
run_branch: start.run_branch.clone(),
|
||||
meta_branch: Some(MetadataStore::branch_name(&record.run_id.to_string())),
|
||||
meta_branch: Some(metadata_branch_name(&record.run_id.to_string())),
|
||||
})
|
||||
});
|
||||
let definition_blob = state.spec.as_ref().and_then(|run| run.definition_blob);
|
||||
|
|
@ -688,17 +691,19 @@ impl RunSession {
|
|||
|
||||
let record = persisted.run_spec();
|
||||
let run_options = RunOptions {
|
||||
settings: record.settings.clone(),
|
||||
run_dir: persisted.run_dir().to_path_buf(),
|
||||
cancel_token: self.cancel_token,
|
||||
run_id: record.run_id,
|
||||
labels: record.labels.clone(),
|
||||
workflow_slug: record.workflow_slug.clone(),
|
||||
github_app: self.github_app.clone(),
|
||||
host_repo_path: record.host_repo_path.as_deref().map(PathBuf::from),
|
||||
base_branch: record.base_branch.clone(),
|
||||
display_base_sha: None,
|
||||
git: self.git.clone(),
|
||||
settings: record.settings.clone(),
|
||||
run_dir: persisted.run_dir().to_path_buf(),
|
||||
cancel_token: self.cancel_token,
|
||||
run_id: record.run_id,
|
||||
labels: record.labels.clone(),
|
||||
workflow_slug: record.workflow_slug.clone(),
|
||||
github_app: self.github_app.clone(),
|
||||
pre_run_git: record.pre_run_git.clone(),
|
||||
fork_source_ref: record.fork_source_ref.clone(),
|
||||
checkpoints_disabled: record.checkpoints_disabled,
|
||||
base_branch: record.base_branch.clone(),
|
||||
display_base_sha: None,
|
||||
git: self.git.clone(),
|
||||
};
|
||||
|
||||
let last_git_sha: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
|
||||
|
|
@ -1070,9 +1075,11 @@ mod tests {
|
|||
workflow_bundle: None,
|
||||
submitted_manifest_bytes: None,
|
||||
run_id: Some(fixtures::RUN_1),
|
||||
host_repo_path: None,
|
||||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
provenance: None,
|
||||
configured_providers: Vec::new(),
|
||||
},
|
||||
|
|
@ -1248,9 +1255,11 @@ mod tests {
|
|||
workflow_bundle: Some(workflow_bundle),
|
||||
submitted_manifest_bytes: None,
|
||||
run_id: Some(fixtures::RUN_1),
|
||||
host_repo_path: None,
|
||||
repo_origin_url: None,
|
||||
base_branch: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
provenance: None,
|
||||
configured_providers: Vec::new(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use fabro_checkpoint::git::Store;
|
||||
use git2::{Repository, Signature};
|
||||
|
||||
pub(super) fn temp_repo() -> (tempfile::TempDir, Store) {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let repo = Repository::init(dir.path()).unwrap();
|
||||
(dir, Store::new(repo))
|
||||
}
|
||||
|
||||
pub(super) fn test_sig() -> Signature<'static> {
|
||||
Signature::now("Test", "test@example.com").unwrap()
|
||||
}
|
||||
|
||||
pub(super) fn make_checkpoint_bytes(
|
||||
current_node: &str,
|
||||
visit: usize,
|
||||
git_sha: Option<&str>,
|
||||
) -> Vec<u8> {
|
||||
let mut node_visits = HashMap::new();
|
||||
node_visits.insert(current_node.to_string(), visit);
|
||||
let cp = serde_json::json!({
|
||||
"timestamp": "2025-01-01T00:00:00Z",
|
||||
"current_node": current_node,
|
||||
"completed_nodes": [current_node],
|
||||
"node_retries": {},
|
||||
"context_values": {},
|
||||
"logs": [],
|
||||
"node_visits": node_visits,
|
||||
"git_commit_sha": git_sha,
|
||||
});
|
||||
serde_json::to_vec(&cp).unwrap()
|
||||
}
|
||||
|
|
@ -1,20 +1,13 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fmt::Write;
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use fabro_checkpoint::META_BRANCH_PREFIX;
|
||||
use fabro_checkpoint::branch::{BranchStore, CommitInfo};
|
||||
use fabro_checkpoint::git::Store;
|
||||
use fabro_graphviz::graph::Graph;
|
||||
use fabro_graphviz::parser;
|
||||
use fabro_store::{Database, RunProjection};
|
||||
use fabro_types::RunId;
|
||||
use git2::{Oid, Repository, Signature};
|
||||
|
||||
use super::run_git;
|
||||
use crate::error::Error;
|
||||
use crate::git::{MetadataStore, RUN_BRANCH_PREFIX};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ForkTarget {
|
||||
|
|
@ -54,11 +47,11 @@ impl FromStr for ForkTarget {
|
|||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TimelineEntry {
|
||||
pub ordinal: usize,
|
||||
pub node_name: String,
|
||||
pub visit: usize,
|
||||
pub metadata_commit_oid: Oid,
|
||||
pub run_commit_sha: Option<String>,
|
||||
pub ordinal: usize,
|
||||
pub node_name: String,
|
||||
pub visit: usize,
|
||||
pub checkpoint_seq: u32,
|
||||
pub run_commit_sha: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -115,112 +108,42 @@ impl RunTimeline {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn build_timeline(store: &Store, run_id: &str) -> Result<RunTimeline> {
|
||||
let branch = MetadataStore::branch_name(run_id);
|
||||
let sig = Signature::now("Fabro", "noreply@fabro.sh")?;
|
||||
let bs = BranchStore::new(store, &branch, &sig);
|
||||
|
||||
let commits = bs
|
||||
.log(10_000)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read metadata branch log: {e}"))?;
|
||||
let commits: Vec<&CommitInfo> = commits.iter().rev().collect();
|
||||
|
||||
let mut timeline = Vec::new();
|
||||
let mut ordinal = 0usize;
|
||||
|
||||
for commit in &commits {
|
||||
if !commit.message.starts_with("checkpoint") {
|
||||
continue;
|
||||
}
|
||||
let Some(projection) = read_projection_at_commit(store, commit.oid)? else {
|
||||
continue;
|
||||
};
|
||||
let cp = projection.checkpoint.with_context(|| {
|
||||
format!(
|
||||
"metadata checkpoint {} is missing projection.checkpoint",
|
||||
commit.oid
|
||||
)
|
||||
})?;
|
||||
|
||||
ordinal += 1;
|
||||
let visit = cp.node_visits.get(&cp.current_node).copied().unwrap_or(1);
|
||||
|
||||
timeline.push(TimelineEntry {
|
||||
pub fn build_timeline(state: &RunProjection) -> Result<RunTimeline> {
|
||||
let mut entries = Vec::new();
|
||||
for (seq, checkpoint) in &state.checkpoints {
|
||||
let ordinal = entries.len() + 1;
|
||||
let visit = checkpoint
|
||||
.node_visits
|
||||
.get(&checkpoint.current_node)
|
||||
.copied()
|
||||
.unwrap_or(1);
|
||||
entries.push(TimelineEntry {
|
||||
ordinal,
|
||||
node_name: cp.current_node.clone(),
|
||||
node_name: checkpoint.current_node.clone(),
|
||||
visit,
|
||||
metadata_commit_oid: commit.oid,
|
||||
run_commit_sha: cp.git_commit_sha.clone(),
|
||||
checkpoint_seq: *seq,
|
||||
run_commit_sha: checkpoint.git_commit_sha.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
backfill_run_shas(store, run_id, &mut timeline);
|
||||
Ok(RunTimeline {
|
||||
entries: timeline,
|
||||
parallel_map: load_parallel_map(store, run_id),
|
||||
entries,
|
||||
parallel_map: load_parallel_map(state),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn timeline(store: &Database, run_id: &RunId) -> Result<Vec<TimelineEntry>, Error> {
|
||||
let run_id = *run_id;
|
||||
run_git::with_run_git_store(store, run_id, move |git_store| {
|
||||
build_timeline(&git_store, &run_id.to_string())
|
||||
.map(|timeline| timeline.entries)
|
||||
.map_err(|err| Error::engine(err.to_string()))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn backfill_run_shas(store: &Store, run_id: &str, timeline: &mut [TimelineEntry]) {
|
||||
if !timeline.iter().any(|e| e.run_commit_sha.is_none()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let node_commits = run_commit_shas_by_node(store, run_id);
|
||||
let mut node_indices: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
for entry in timeline.iter_mut() {
|
||||
if entry.run_commit_sha.is_some() {
|
||||
continue;
|
||||
}
|
||||
if let Some(shas) = node_commits.get(&entry.node_name) {
|
||||
let idx = node_indices.entry(entry.node_name.clone()).or_insert(0);
|
||||
if *idx < shas.len() {
|
||||
entry.run_commit_sha = Some(shas[*idx].clone());
|
||||
*idx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run_commit_shas_by_node(store: &Store, run_id: &str) -> HashMap<String, Vec<String>> {
|
||||
let run_branch = format!("{RUN_BRANCH_PREFIX}{run_id}");
|
||||
let Ok(sig) = Signature::now("Fabro", "noreply@fabro.sh") else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let bs = BranchStore::new(store, &run_branch, &sig);
|
||||
let Ok(run_commits) = bs.log(10_000) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
let prefix = format!("fabro({run_id}): ");
|
||||
let mut node_commits: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for commit in &run_commits {
|
||||
if let Some(rest) = commit.message.strip_prefix(&prefix) {
|
||||
if let Some(node_name) = rest.split_whitespace().next() {
|
||||
node_commits
|
||||
.entry(node_name.to_string())
|
||||
.or_default()
|
||||
.push(commit.oid.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for shas in node_commits.values_mut() {
|
||||
shas.reverse();
|
||||
}
|
||||
|
||||
node_commits
|
||||
let run = store
|
||||
.open_run(run_id)
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
let state = run
|
||||
.state()
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
build_timeline(&state)
|
||||
.map(|timeline| timeline.entries)
|
||||
.map_err(|err| Error::engine(err.to_string()))
|
||||
}
|
||||
|
||||
fn detect_parallel_interior(graph: &Graph) -> HashMap<String, String> {
|
||||
|
|
@ -257,103 +180,51 @@ fn detect_parallel_interior(graph: &Graph) -> HashMap<String, String> {
|
|||
interior_map
|
||||
}
|
||||
|
||||
pub fn find_run_id_by_prefix(repo: &Repository, prefix: &str) -> Result<RunId> {
|
||||
find_run_id_by_prefix_opt(repo, prefix)?
|
||||
.ok_or_else(|| anyhow::anyhow!("no run found matching '{prefix}'"))
|
||||
}
|
||||
|
||||
/// Resolve a run id from the metadata branch refs. `Ok(None)` when no run
|
||||
/// matches; `Err` when the prefix matches more than one.
|
||||
pub(super) fn find_run_id_by_prefix_opt(repo: &Repository, prefix: &str) -> Result<Option<RunId>> {
|
||||
let refs = repo.references()?;
|
||||
let pattern = format!("refs/heads/{META_BRANCH_PREFIX}");
|
||||
let mut matches = Vec::new();
|
||||
|
||||
for reference in refs.flatten() {
|
||||
let Some(name) = reference.name() else {
|
||||
continue;
|
||||
};
|
||||
let Some(run_id) = name.strip_prefix(&pattern) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(run_id) = run_id.parse::<RunId>() else {
|
||||
continue;
|
||||
};
|
||||
if run_id.to_string() == prefix {
|
||||
return Ok(Some(run_id));
|
||||
}
|
||||
if run_id.to_string().starts_with(prefix) {
|
||||
matches.push(run_id);
|
||||
}
|
||||
}
|
||||
|
||||
match matches.len() {
|
||||
0 => Ok(None),
|
||||
1 => Ok(matches.into_iter().next()),
|
||||
_ => {
|
||||
let mut msg = format!("ambiguous run ID prefix '{prefix}', matches:\n");
|
||||
for run_id in &matches {
|
||||
let _ = writeln!(msg, " {run_id}");
|
||||
}
|
||||
bail!("{msg}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_parallel_map(store: &Store, run_id: &str) -> HashMap<String, String> {
|
||||
let Ok(Some(projection)) = MetadataStore::read_run_projection(store.repo_dir(), run_id) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
if let Some(spec) = projection.spec {
|
||||
fn load_parallel_map(state: &RunProjection) -> HashMap<String, String> {
|
||||
if let Some(spec) = state.spec.as_ref() {
|
||||
return detect_parallel_interior(&spec.graph);
|
||||
}
|
||||
|
||||
let Some(dot_source) = projection.graph_source else {
|
||||
let Some(dot_source) = state.graph_source.as_ref() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let Ok(graph) = parser::parse(&dot_source) else {
|
||||
let Ok(graph) = parser::parse(dot_source) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
detect_parallel_interior(&graph)
|
||||
}
|
||||
|
||||
fn read_projection_at_commit(store: &Store, oid: Oid) -> Result<Option<RunProjection>> {
|
||||
let blob = store
|
||||
.read_blob_at(oid, "run.json")
|
||||
.map_err(|e| anyhow::anyhow!("failed to read projection blob: {e}"))?;
|
||||
let Some(bytes) = blob else {
|
||||
return Ok(None);
|
||||
};
|
||||
let projection = serde_json::from_slice(&bytes)
|
||||
.with_context(|| format!("failed to parse projection at {oid}"))?;
|
||||
Ok(Some(projection))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use fabro_store::RunProjection;
|
||||
use fabro_types::RunId;
|
||||
use git2::Oid;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use fabro_types::Checkpoint;
|
||||
|
||||
use super::super::test_support::*;
|
||||
use super::*;
|
||||
|
||||
fn parse_run_id(value: &str) -> RunId {
|
||||
value.parse().unwrap()
|
||||
}
|
||||
|
||||
fn checkpoint_projection_json(
|
||||
fn checkpoint(
|
||||
seq: u32,
|
||||
current_node: &str,
|
||||
visit: usize,
|
||||
git_commit_sha: Option<&str>,
|
||||
) -> Vec<u8> {
|
||||
let mut projection = RunProjection::default();
|
||||
projection.checkpoint = Some(
|
||||
serde_json::from_slice(&make_checkpoint_bytes(current_node, visit, git_commit_sha))
|
||||
.unwrap(),
|
||||
);
|
||||
serde_json::to_vec_pretty(&projection).unwrap()
|
||||
) -> (u32, Checkpoint) {
|
||||
let mut node_visits = HashMap::new();
|
||||
node_visits.insert(current_node.to_string(), visit);
|
||||
let checkpoint = Checkpoint {
|
||||
timestamp: Utc::now(),
|
||||
current_node: current_node.to_string(),
|
||||
completed_nodes: Vec::new(),
|
||||
node_retries: HashMap::new(),
|
||||
context_values: HashMap::new(),
|
||||
node_outcomes: HashMap::new(),
|
||||
next_node_id: None,
|
||||
git_commit_sha: git_commit_sha.map(ToOwned::to_owned),
|
||||
loop_failure_signatures: HashMap::new(),
|
||||
restart_failure_signatures: HashMap::new(),
|
||||
node_visits,
|
||||
};
|
||||
(seq, checkpoint)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -371,21 +242,16 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn build_timeline_simple() {
|
||||
let (_dir, store) = temp_repo();
|
||||
let sig = test_sig();
|
||||
let branch = MetadataStore::branch_name("test-run-1");
|
||||
let bs = BranchStore::new(&store, &branch, &sig);
|
||||
bs.ensure_branch().unwrap();
|
||||
let mut state = RunProjection::default();
|
||||
state.checkpoints = vec![
|
||||
checkpoint(7, "start", 1, Some("aaa")),
|
||||
checkpoint(9, "build", 1, Some("bbb")),
|
||||
];
|
||||
|
||||
bs.write_entry("run.json", b"{}", "init run").unwrap();
|
||||
let cp1 = checkpoint_projection_json("start", 1, Some("aaa"));
|
||||
bs.write_entry("run.json", &cp1, "checkpoint").unwrap();
|
||||
let cp2 = checkpoint_projection_json("build", 1, Some("bbb"));
|
||||
bs.write_entry("run.json", &cp2, "checkpoint").unwrap();
|
||||
|
||||
let timeline = build_timeline(&store, "test-run-1").unwrap();
|
||||
let timeline = build_timeline(&state).unwrap();
|
||||
assert_eq!(timeline.entries.len(), 2);
|
||||
assert_eq!(timeline.entries[0].node_name, "start");
|
||||
assert_eq!(timeline.entries[0].checkpoint_seq, 7);
|
||||
assert_eq!(timeline.entries[1].node_name, "build");
|
||||
}
|
||||
|
||||
|
|
@ -394,25 +260,25 @@ mod tests {
|
|||
let timeline = RunTimeline {
|
||||
entries: vec![
|
||||
TimelineEntry {
|
||||
ordinal: 1,
|
||||
node_name: "start".to_string(),
|
||||
visit: 1,
|
||||
metadata_commit_oid: Oid::zero(),
|
||||
run_commit_sha: Some("aaa".to_string()),
|
||||
ordinal: 1,
|
||||
node_name: "start".to_string(),
|
||||
visit: 1,
|
||||
checkpoint_seq: 7,
|
||||
run_commit_sha: Some("aaa".to_string()),
|
||||
},
|
||||
TimelineEntry {
|
||||
ordinal: 2,
|
||||
node_name: "build".to_string(),
|
||||
visit: 1,
|
||||
metadata_commit_oid: Oid::zero(),
|
||||
run_commit_sha: Some("bbb".to_string()),
|
||||
ordinal: 2,
|
||||
node_name: "build".to_string(),
|
||||
visit: 1,
|
||||
checkpoint_seq: 9,
|
||||
run_commit_sha: Some("bbb".to_string()),
|
||||
},
|
||||
TimelineEntry {
|
||||
ordinal: 3,
|
||||
node_name: "build".to_string(),
|
||||
visit: 2,
|
||||
metadata_commit_oid: Oid::zero(),
|
||||
run_commit_sha: Some("ccc".to_string()),
|
||||
ordinal: 3,
|
||||
node_name: "build".to_string(),
|
||||
visit: 2,
|
||||
checkpoint_seq: 11,
|
||||
run_commit_sha: Some("ccc".to_string()),
|
||||
},
|
||||
],
|
||||
parallel_map: HashMap::new(),
|
||||
|
|
@ -463,17 +329,4 @@ mod tests {
|
|||
assert_eq!(map.get("a"), Some(&"parallel1".to_string()));
|
||||
assert!(!map.contains_key("parallel1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_run_id_prefix_match() {
|
||||
let (_dir, store) = temp_repo();
|
||||
let sig = test_sig();
|
||||
let run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
|
||||
let branch = MetadataStore::branch_name(&run_id.to_string());
|
||||
let bs = BranchStore::new(&store, &branch, &sig);
|
||||
bs.ensure_branch().unwrap();
|
||||
|
||||
let result = find_run_id_by_prefix(store.repo(), "01ARZ3").unwrap();
|
||||
assert_eq!(result, run_id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -90,17 +90,19 @@ fn test_emitter_arc(label: &str) -> Arc<Emitter> {
|
|||
|
||||
fn test_run_options(run_dir: &Path, run_id: &str) -> RunOptions {
|
||||
RunOptions {
|
||||
run_dir: run_dir.to_path_buf(),
|
||||
cancel_token: None,
|
||||
run_id: test_run_id(run_id),
|
||||
settings: WorkflowSettings::default(),
|
||||
git: None,
|
||||
host_repo_path: None,
|
||||
labels: HashMap::new(),
|
||||
github_app: None,
|
||||
base_branch: None,
|
||||
display_base_sha: None,
|
||||
workflow_slug: None,
|
||||
run_dir: run_dir.to_path_buf(),
|
||||
cancel_token: None,
|
||||
run_id: test_run_id(run_id),
|
||||
settings: WorkflowSettings::default(),
|
||||
git: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
labels: HashMap::new(),
|
||||
github_app: None,
|
||||
base_branch: None,
|
||||
display_base_sha: None,
|
||||
workflow_slug: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -138,8 +140,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI
|
|||
settings: WorkflowSettings::default(),
|
||||
graph,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
working_directory: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
||||
host_repo_path: Some(
|
||||
source_directory: Some(
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.display()
|
||||
|
|
@ -151,6 +152,9 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI
|
|||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ use fabro_types::{BilledTokenCounts, EventBody};
|
|||
use super::types::{Concluded, FinalizeOptions, Retroed};
|
||||
use crate::error::Error;
|
||||
use crate::event::{Event, RunNoticeLevel};
|
||||
use crate::git::MetadataStore;
|
||||
use crate::outcome::{Outcome, OutcomeExt, StageStatus};
|
||||
use crate::records::{Checkpoint, Conclusion, StageSummary};
|
||||
use crate::run_dump::RunDump;
|
||||
use crate::run_options::RunOptions;
|
||||
use crate::run_status::{FailureReason, RunStatus, SuccessReason};
|
||||
use crate::runtime_store::RunStoreHandle;
|
||||
use crate::sandbox_git::{git_diff_with_timeout, git_push_host};
|
||||
use crate::sandbox_git::git_diff_with_timeout;
|
||||
use crate::sandbox_metadata::SandboxMetadataWriter;
|
||||
use crate::services::RunServices;
|
||||
|
||||
pub fn classify_engine_result(
|
||||
|
|
@ -139,43 +139,65 @@ fn build_conclusion_from_parts(
|
|||
/// yet — the run store's `projection.conclusion` is still `None` at this point.
|
||||
pub async fn write_finalize_commit(
|
||||
run_options: &RunOptions,
|
||||
run_store: &RunStoreHandle,
|
||||
services: &RunServices,
|
||||
conclusion: &Conclusion,
|
||||
) {
|
||||
let (Some(meta_branch), Some(repo_path)) = (
|
||||
run_options
|
||||
.git
|
||||
.as_ref()
|
||||
.and_then(|g| g.meta_branch.as_ref()),
|
||||
run_options.host_repo_path.as_ref(),
|
||||
) else {
|
||||
if services.metadata_runtime.metadata_degraded() {
|
||||
return;
|
||||
}
|
||||
let Some(meta_branch) = run_options
|
||||
.git
|
||||
.as_ref()
|
||||
.and_then(|git| git.meta_branch.as_deref())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
let git_author = run_options.git_author();
|
||||
let store = MetadataStore::new(repo_path, &git_author);
|
||||
let Ok(mut store_state) = run_store.state().await else {
|
||||
let mut projection = match services.run_store.state().await {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
emit_metadata_warning(
|
||||
services,
|
||||
"checkpoint_metadata_write_failed",
|
||||
format!("failed to load run state for final metadata snapshot: {err}"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
projection.conclusion = Some(conclusion.clone());
|
||||
let dump = RunDump::from_projection(&projection);
|
||||
let Some(spec_run_id) = projection.spec.as_ref().map(|spec| spec.run_id.to_string()) else {
|
||||
return;
|
||||
};
|
||||
if store_state.conclusion.is_none() {
|
||||
store_state.conclusion = Some(conclusion.clone());
|
||||
}
|
||||
let dump = RunDump::from_projection(&store_state);
|
||||
if let Err(e) =
|
||||
dump.write_to_metadata_store(&store, &run_options.run_id.to_string(), "finalize run")
|
||||
{
|
||||
tracing::warn!(error = %e, "Failed to write finalize commit to metadata branch");
|
||||
return;
|
||||
let writer = SandboxMetadataWriter::new(
|
||||
&*services.sandbox,
|
||||
&services.metadata_runtime,
|
||||
&spec_run_id,
|
||||
meta_branch,
|
||||
run_options.git_author(),
|
||||
);
|
||||
match writer.write_snapshot(&dump, "finalize run").await {
|
||||
Ok(snapshot) => {
|
||||
if !snapshot.pushed {
|
||||
emit_metadata_warning(
|
||||
services,
|
||||
"checkpoint_metadata_push_failed",
|
||||
format!("failed to push metadata ref refs/heads/{meta_branch}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => emit_metadata_warning(
|
||||
services,
|
||||
"checkpoint_metadata_write_failed",
|
||||
format!("failed to write final checkpoint metadata: {err}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
let refspec = format!("refs/heads/{meta_branch}");
|
||||
git_push_host(
|
||||
repo_path,
|
||||
&refspec,
|
||||
&run_options.github_app,
|
||||
"finalize metadata",
|
||||
)
|
||||
.await;
|
||||
fn emit_metadata_warning(services: &RunServices, code: &str, message: String) {
|
||||
if services.metadata_runtime.mark_metadata_degraded() {
|
||||
services.emitter.notice(RunNoticeLevel::Warn, code, message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Failed and cancelled runs use a shorter diff timeout so a corrupted
|
||||
|
|
@ -337,9 +359,17 @@ pub async fn finalize(retroed: Retroed, options: &FinalizeOptions) -> Result<Con
|
|||
|
||||
let (final_patch, ()) = tokio::join!(
|
||||
compute_final_patch(&run_options, &services, final_status),
|
||||
write_finalize_commit(&run_options, &services.run_store, &conclusion),
|
||||
write_finalize_commit(&run_options, &services, &conclusion),
|
||||
);
|
||||
|
||||
if services.metadata_runtime.metadata_degraded() {
|
||||
services.emitter.notice(
|
||||
RunNoticeLevel::Warn,
|
||||
"checkpoint_metadata_degraded",
|
||||
"checkpoint metadata archive writes were degraded for this run".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let terminal_event = build_terminal_event(
|
||||
&outcome,
|
||||
duration_ms,
|
||||
|
|
@ -408,17 +438,19 @@ mod tests {
|
|||
|
||||
fn test_run_options(run_dir: &std::path::Path) -> RunOptions {
|
||||
RunOptions {
|
||||
settings: WorkflowSettings::default(),
|
||||
run_dir: run_dir.to_path_buf(),
|
||||
cancel_token: None,
|
||||
run_id: test_run_id(),
|
||||
labels: HashMap::new(),
|
||||
workflow_slug: None,
|
||||
github_app: None,
|
||||
host_repo_path: None,
|
||||
base_branch: None,
|
||||
display_base_sha: None,
|
||||
git: None,
|
||||
settings: WorkflowSettings::default(),
|
||||
run_dir: run_dir.to_path_buf(),
|
||||
cancel_token: None,
|
||||
run_id: test_run_id(),
|
||||
labels: HashMap::new(),
|
||||
workflow_slug: None,
|
||||
github_app: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
base_branch: None,
|
||||
display_base_sha: None,
|
||||
git: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ use fabro_config::RunScratch;
|
|||
use fabro_graphviz::graph;
|
||||
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner};
|
||||
use fabro_sandbox::{
|
||||
ReadBeforeWriteSandbox, SandboxEventCallback, SandboxSpec, WorkdirStrategy, WorktreeOptions,
|
||||
WorktreeSandbox,
|
||||
GitSetupIntent, ReadBeforeWriteSandbox, SandboxEventCallback, SandboxSpec, WorkdirStrategy,
|
||||
WorktreeOptions, WorktreeSandbox,
|
||||
};
|
||||
use fabro_vault::Vault;
|
||||
use futures::future::try_join_all;
|
||||
|
|
@ -21,17 +21,17 @@ use shlex::try_quote;
|
|||
use tokio::process::Command as TokioCommand;
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::sync::RwLock as AsyncRwLock;
|
||||
use tokio::task::spawn_blocking;
|
||||
use tokio::time::timeout as tokio_timeout;
|
||||
|
||||
use super::types::{InitOptions, Initialized, LlmSpec, Persisted, SandboxEnvSpec};
|
||||
use crate::devcontainer_bridge::{devcontainer_to_snapshot_config, run_devcontainer_lifecycle};
|
||||
use crate::error::Error;
|
||||
use crate::event::{Emitter, Event, RunNoticeLevel};
|
||||
use crate::git::{self, GitSyncStatus, MetadataStore};
|
||||
use crate::git::RUN_BRANCH_PREFIX;
|
||||
use crate::handler::llm::{AgentApiBackend, AgentCliBackend, BackendRouter};
|
||||
use crate::handler::{HandlerRegistry, default_registry, sandbox_cancel_token};
|
||||
use crate::run_options::GitCheckpointOptions;
|
||||
use crate::run_options::{GitCheckpointOptions, RunOptions};
|
||||
use crate::sandbox_metadata::metadata_branch_name;
|
||||
use crate::services::{EngineServices, RunServices};
|
||||
|
||||
struct WorktreePlan {
|
||||
|
|
@ -53,44 +53,56 @@ async fn run_hooks(
|
|||
runner.run(hook_context, sandbox, work_dir).await
|
||||
}
|
||||
|
||||
async fn resolve_worktree_plan(options: &mut InitOptions) -> Result<Option<WorktreePlan>, Error> {
|
||||
fn resolve_worktree_plan(options: &mut InitOptions) -> Option<WorktreePlan> {
|
||||
if options.run_options.checkpoints_disabled {
|
||||
options.run_options.display_base_sha = None;
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some(worktree_mode) = options.worktree_mode else {
|
||||
options.run_options.display_base_sha = None;
|
||||
return Ok(None);
|
||||
return None;
|
||||
};
|
||||
|
||||
if options.checkpoint.is_some() && matches!(options.sandbox, SandboxSpec::Local { .. }) {
|
||||
if let Some(fork_source) = options.run_options.fork_source_ref.as_ref() {
|
||||
let base_sha = fork_source.checkpoint_sha.clone();
|
||||
options.run_options.display_base_sha = Some(base_sha.clone());
|
||||
return Some(WorktreePlan {
|
||||
branch_name: format!("{RUN_BRANCH_PREFIX}{}", options.run_id),
|
||||
base_sha,
|
||||
worktree_path: RunScratch::new(&options.run_options.run_dir).worktree_dir(),
|
||||
skip_branch_creation: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if options.checkpoint.is_some() && matches!(options.sandbox, SandboxSpec::Local { .. }) {
|
||||
if let Some(git) = options.run_options.git.as_ref() {
|
||||
if let (Some(run_branch), Some(base_sha)) = (&git.run_branch, &git.base_sha) {
|
||||
options.run_options.display_base_sha = Some(base_sha.clone());
|
||||
return Ok(Some(WorktreePlan {
|
||||
return Some(WorktreePlan {
|
||||
branch_name: run_branch.clone(),
|
||||
base_sha: base_sha.clone(),
|
||||
worktree_path: RunScratch::new(&options.run_options.run_dir)
|
||||
.worktree_dir(),
|
||||
skip_branch_creation: true,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let host_repo_path = options.sandbox.host_repo_path();
|
||||
let git_status = if let Some(path) = host_repo_path.as_ref() {
|
||||
let path = path.clone();
|
||||
let base_branch = options.run_options.base_branch.clone();
|
||||
spawn_blocking(move || git::sync_status(&path, "origin", base_branch.as_deref()))
|
||||
.await
|
||||
.unwrap_or(GitSyncStatus::Dirty)
|
||||
} else {
|
||||
GitSyncStatus::Dirty
|
||||
};
|
||||
let strategy = options.sandbox.workdir_strategy(
|
||||
worktree_mode,
|
||||
git_status.is_clean(),
|
||||
options.checkpoint.is_some(),
|
||||
);
|
||||
let git_is_clean = options
|
||||
.run_options
|
||||
.pre_run_git
|
||||
.as_ref()
|
||||
.is_some_and(|git| matches!(git.local_dirty, fabro_types::DirtyStatus::Clean));
|
||||
let strategy =
|
||||
options
|
||||
.sandbox
|
||||
.workdir_strategy(worktree_mode, git_is_clean, options.checkpoint.is_some());
|
||||
|
||||
if git_status == GitSyncStatus::Dirty {
|
||||
if !git_is_clean {
|
||||
let env_name = match strategy {
|
||||
WorkdirStrategy::LocalWorktree => Some("worktree"),
|
||||
WorkdirStrategy::Cloud => Some("remote sandbox"),
|
||||
|
|
@ -105,97 +117,59 @@ async fn resolve_worktree_plan(options: &mut InitOptions) -> Result<Option<Workt
|
|||
}
|
||||
}
|
||||
|
||||
if !options.dry_run
|
||||
&& matches!(
|
||||
strategy,
|
||||
WorkdirStrategy::LocalWorktree | WorkdirStrategy::Cloud
|
||||
)
|
||||
{
|
||||
if let (Some(repo_path), Some(branch)) = (
|
||||
host_repo_path.as_ref(),
|
||||
options.run_options.base_branch.as_ref(),
|
||||
) {
|
||||
let needs_push = match git_status {
|
||||
GitSyncStatus::Synced => false,
|
||||
GitSyncStatus::Unsynced => true,
|
||||
GitSyncStatus::Dirty => {
|
||||
let repo_path = repo_path.clone();
|
||||
let branch = branch.clone();
|
||||
spawn_blocking(move || git::branch_needs_push(&repo_path, "origin", &branch))
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
}
|
||||
};
|
||||
|
||||
if needs_push {
|
||||
let repo_path = repo_path.clone();
|
||||
let branch = branch.clone();
|
||||
let branch_for_push = branch.clone();
|
||||
match git::blocking_push_with_timeout(60, move || {
|
||||
git::push_branch(&repo_path, "origin", &branch_for_push)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(()) => options.emitter.notice(
|
||||
RunNoticeLevel::Info,
|
||||
"git_push_succeeded",
|
||||
format!("{branch} (synced local commits to remote)"),
|
||||
),
|
||||
Err(e) => options.emitter.notice(
|
||||
RunNoticeLevel::Warn,
|
||||
"git_push_failed",
|
||||
format!("Failed to push {branch} to origin: {e}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match strategy {
|
||||
WorkdirStrategy::LocalWorktree => {
|
||||
let Some(repo_path) = host_repo_path else {
|
||||
options.run_options.display_base_sha = None;
|
||||
return Ok(None);
|
||||
};
|
||||
match spawn_blocking(move || git::head_sha(&repo_path))
|
||||
.await
|
||||
.unwrap_or_else(|_| Err(Error::engine("git head_sha task panicked")))
|
||||
{
|
||||
Ok(base_sha) => {
|
||||
options.run_options.display_base_sha = Some(base_sha.clone());
|
||||
Ok(Some(WorktreePlan {
|
||||
branch_name: format!("{}{}", git::RUN_BRANCH_PREFIX, options.run_id),
|
||||
base_sha,
|
||||
worktree_path: RunScratch::new(&options.run_options.run_dir).worktree_dir(),
|
||||
skip_branch_creation: false,
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
options.emitter.notice(
|
||||
RunNoticeLevel::Warn,
|
||||
"worktree_setup_failed",
|
||||
format!("Git worktree setup failed ({e}), running without worktree."),
|
||||
);
|
||||
options.run_options.display_base_sha = None;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
let (branch_name, base_sha) =
|
||||
if let Some(fork_source) = options.run_options.fork_source_ref.as_ref() {
|
||||
(
|
||||
format!("{RUN_BRANCH_PREFIX}{}", options.run_id),
|
||||
fork_source.checkpoint_sha.clone(),
|
||||
)
|
||||
} else {
|
||||
let Some(base_sha) = options
|
||||
.run_options
|
||||
.pre_run_git
|
||||
.as_ref()
|
||||
.and_then(|git| git.display_base_sha.clone())
|
||||
else {
|
||||
options.run_options.display_base_sha = None;
|
||||
return None;
|
||||
};
|
||||
(format!("{RUN_BRANCH_PREFIX}{}", options.run_id), base_sha)
|
||||
};
|
||||
options.run_options.display_base_sha = Some(base_sha.clone());
|
||||
Some(WorktreePlan {
|
||||
branch_name,
|
||||
base_sha,
|
||||
worktree_path: RunScratch::new(&options.run_options.run_dir).worktree_dir(),
|
||||
skip_branch_creation: false,
|
||||
})
|
||||
}
|
||||
WorkdirStrategy::Cloud => {
|
||||
options.run_options.display_base_sha = if let Some(path) = host_repo_path.as_ref() {
|
||||
let path = path.clone();
|
||||
spawn_blocking(move || git::head_sha(&path))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(std::result::Result::ok)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(None)
|
||||
options.run_options.display_base_sha = options
|
||||
.run_options
|
||||
.pre_run_git
|
||||
.as_ref()
|
||||
.and_then(|git| git.display_base_sha.clone());
|
||||
None
|
||||
}
|
||||
WorkdirStrategy::LocalDirectory => {
|
||||
options.run_options.display_base_sha = None;
|
||||
Ok(None)
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn git_setup_intent(run_options: &RunOptions) -> GitSetupIntent {
|
||||
if let Some(source) = run_options.fork_source_ref.as_ref() {
|
||||
GitSetupIntent::ForkFromCheckpoint {
|
||||
new_run_id: run_options.run_id.to_string(),
|
||||
source_run_id: source.source_run_id.to_string(),
|
||||
checkpoint_sha: source.checkpoint_sha.clone(),
|
||||
}
|
||||
} else {
|
||||
GitSetupIntent::NewRun {
|
||||
run_id: run_options.run_id.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -464,12 +438,12 @@ pub async fn initialize(
|
|||
|
||||
resolve_devcontainer(&mut options).await?;
|
||||
|
||||
let worktree_plan = resolve_worktree_plan(&mut options).await?;
|
||||
let worktree_plan = resolve_worktree_plan(&mut options);
|
||||
if let Some(plan) = worktree_plan.as_ref() {
|
||||
options.run_options.git = Some(GitCheckpointOptions {
|
||||
base_sha: Some(plan.base_sha.clone()),
|
||||
run_branch: Some(plan.branch_name.clone()),
|
||||
meta_branch: Some(MetadataStore::branch_name(&options.run_id.to_string())),
|
||||
meta_branch: Some(metadata_branch_name(&options.run_id.to_string())),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -499,18 +473,7 @@ pub async fn initialize(
|
|||
Arc::new(ReadBeforeWriteSandbox::new(Arc::new(worktree)))
|
||||
}
|
||||
Err(e) => {
|
||||
options.emitter.notice(
|
||||
RunNoticeLevel::Warn,
|
||||
"worktree_setup_failed",
|
||||
format!("Git worktree setup failed ({e}), running without worktree."),
|
||||
);
|
||||
Arc::new(ReadBeforeWriteSandbox::new(
|
||||
options
|
||||
.sandbox
|
||||
.build(Some(Arc::clone(&sandbox_event_callback)))
|
||||
.await
|
||||
.map_err(|e| Error::engine(e.to_string()))?,
|
||||
))
|
||||
return Err(Error::engine(format!("Git worktree setup failed: {e}")));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -557,14 +520,12 @@ pub async fn initialize(
|
|||
|
||||
let sandbox_record = options.sandbox.to_sandbox_record(&*sandbox);
|
||||
options.emitter.emit(&Event::SandboxInitialized {
|
||||
working_directory: sandbox_record.working_directory.clone(),
|
||||
provider: sandbox_record.provider.clone(),
|
||||
identifier: sandbox_record.identifier.clone(),
|
||||
host_working_directory: sandbox_record.host_working_directory.clone(),
|
||||
container_mount_point: sandbox_record.container_mount_point.clone(),
|
||||
repo_cloned: sandbox_record.repo_cloned,
|
||||
clone_origin_url: sandbox_record.clone_origin_url.clone(),
|
||||
clone_branch: sandbox_record.clone_branch.clone(),
|
||||
working_directory: sandbox_record.working_directory.clone(),
|
||||
provider: sandbox_record.provider.clone(),
|
||||
identifier: sandbox_record.identifier.clone(),
|
||||
repo_cloned: sandbox_record.repo_cloned,
|
||||
clone_origin_url: sandbox_record.clone_origin_url.clone(),
|
||||
clone_branch: sandbox_record.clone_branch.clone(),
|
||||
});
|
||||
|
||||
let env = build_sandbox_env(
|
||||
|
|
@ -601,10 +562,8 @@ pub async fn initialize(
|
|||
.and_then(|g| g.run_branch.as_ref())
|
||||
.is_some();
|
||||
if !has_run_branch {
|
||||
match sandbox
|
||||
.setup_git_for_run(&options.run_options.run_id.to_string())
|
||||
.await
|
||||
{
|
||||
let intent = git_setup_intent(&options.run_options);
|
||||
match sandbox.setup_git(&intent).await {
|
||||
Ok(Some(info)) => {
|
||||
let base_sha = options
|
||||
.run_options
|
||||
|
|
@ -616,7 +575,7 @@ pub async fn initialize(
|
|||
options.run_options.git = Some(GitCheckpointOptions {
|
||||
base_sha,
|
||||
run_branch: Some(info.run_branch.clone()),
|
||||
meta_branch: Some(MetadataStore::branch_name(
|
||||
meta_branch: Some(metadata_branch_name(
|
||||
&options.run_options.run_id.to_string(),
|
||||
)),
|
||||
});
|
||||
|
|
@ -626,10 +585,7 @@ pub async fn initialize(
|
|||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
"Sandbox git setup failed, running without git checkpoints"
|
||||
);
|
||||
return Err(Error::engine(format!("Sandbox git setup failed: {e}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -829,17 +785,19 @@ mod tests {
|
|||
|
||||
fn test_settings(run_dir: &std::path::Path) -> RunOptions {
|
||||
RunOptions {
|
||||
settings: WorkflowSettings::default(),
|
||||
run_dir: run_dir.to_path_buf(),
|
||||
cancel_token: None,
|
||||
run_id: test_run_id(),
|
||||
labels: HashMap::new(),
|
||||
workflow_slug: None,
|
||||
github_app: None,
|
||||
host_repo_path: None,
|
||||
base_branch: None,
|
||||
display_base_sha: None,
|
||||
git: None,
|
||||
settings: WorkflowSettings::default(),
|
||||
run_dir: run_dir.to_path_buf(),
|
||||
cancel_token: None,
|
||||
run_id: test_run_id(),
|
||||
labels: HashMap::new(),
|
||||
workflow_slug: None,
|
||||
github_app: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
base_branch: None,
|
||||
display_base_sha: None,
|
||||
git: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -854,14 +812,16 @@ mod tests {
|
|||
settings: WorkflowSettings::default(),
|
||||
graph,
|
||||
workflow_slug: Some("test".to_string()),
|
||||
working_directory: std::env::current_dir().unwrap(),
|
||||
host_repo_path: Some(std::env::current_dir().unwrap().display().to_string()),
|
||||
source_directory: Some(std::env::current_dir().unwrap().display().to_string()),
|
||||
repo_origin_url: None,
|
||||
base_branch: Some("main".to_string()),
|
||||
labels: HashMap::new(),
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ use fabro_store::{EventEnvelope, RunProjection, SerializableProjection, StageId}
|
|||
use fabro_types::{RunBlobId, parse_blob_ref, parse_legacy_blob_file_ref};
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
use crate::git::MetadataStore;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RunDump {
|
||||
entries: Vec<RunDumpEntry>,
|
||||
|
|
@ -217,21 +215,6 @@ impl RunDump {
|
|||
Ok(self.file_count())
|
||||
}
|
||||
|
||||
pub fn write_to_metadata_store(
|
||||
&self,
|
||||
store: &MetadataStore,
|
||||
run_id: &str,
|
||||
message: &str,
|
||||
) -> Result<()> {
|
||||
let git_entries = self.git_entries()?;
|
||||
let refs: Vec<(&str, &[u8])> = git_entries
|
||||
.iter()
|
||||
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
|
||||
.collect();
|
||||
store.write_snapshot(run_id, &refs, message)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn git_entries(&self) -> Result<Vec<(String, Vec<u8>)>> {
|
||||
self.entries
|
||||
.iter()
|
||||
|
|
@ -425,7 +408,6 @@ fn ensure_parent_dir(path: &Path) -> Result<()> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fabro_store::{NodeState, RunProjection, StageId};
|
||||
|
|
@ -441,18 +423,20 @@ mod tests {
|
|||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("ship"),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
working_directory: PathBuf::from("/tmp/project"),
|
||||
host_repo_path: Some("/tmp/project".to_string()),
|
||||
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
|
||||
base_branch: Some("main".to_string()),
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("ship"),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
source_directory: Some("/tmp/project".to_string()),
|
||||
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
|
||||
base_branch: Some("main".to_string()),
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -508,14 +492,12 @@ mod tests {
|
|||
total_retries: 0,
|
||||
});
|
||||
projection.sandbox = Some(SandboxRecord {
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/tmp/project".to_string(),
|
||||
identifier: Some("sandbox-1".to_string()),
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
repo_cloned: None,
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/tmp/project".to_string(),
|
||||
identifier: Some("sandbox-1".to_string()),
|
||||
repo_cloned: None,
|
||||
clone_origin_url: None,
|
||||
clone_branch: None,
|
||||
});
|
||||
projection.retro_prompt = Some("retro prompt".to_string());
|
||||
projection.retro_response = Some("retro response".to_string());
|
||||
|
|
|
|||
|
|
@ -125,10 +125,16 @@ impl RunInfo {
|
|||
.and_then(|summary| summary.total_usd_micros)
|
||||
}
|
||||
|
||||
pub fn host_repo_path(&self) -> Option<&str> {
|
||||
pub fn source_directory(&self) -> Option<&str> {
|
||||
self.summary
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.host_repo_path.as_deref())
|
||||
.and_then(|summary| summary.source_directory.as_deref())
|
||||
}
|
||||
|
||||
pub fn repo_origin_url(&self) -> Option<&str> {
|
||||
self.summary
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.repo_origin_url.as_deref())
|
||||
}
|
||||
|
||||
pub fn goal(&self) -> String {
|
||||
|
|
@ -336,11 +342,19 @@ fn resolve_run_from_infos(runs: &[RunInfo], identifier: &str) -> Result<RunInfo>
|
|||
count if count > 1 => {
|
||||
let ids: Vec<String> = id_matches
|
||||
.iter()
|
||||
.map(|run| run.run_id().to_string())
|
||||
.map(|run| {
|
||||
format!(
|
||||
"{} created_at={} workflow={} origin={}",
|
||||
run.run_id(),
|
||||
run.run_id().created_at().to_rfc3339(),
|
||||
run.workflow_name(),
|
||||
run.repo_origin_url().unwrap_or("-")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
bail!(
|
||||
"Ambiguous prefix '{identifier}': {count} runs match: {}",
|
||||
ids.join(", ")
|
||||
"Ambiguous prefix '{identifier}': {count} runs match:\n{}",
|
||||
ids.join("\n")
|
||||
)
|
||||
}
|
||||
_ => {}
|
||||
|
|
@ -396,7 +410,6 @@ fn run_id_matches(run_id: RunId, prefix: &str) -> bool {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
@ -421,18 +434,20 @@ mod tests {
|
|||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
workflow_slug: Some("test".to_string()),
|
||||
working_directory: PathBuf::from("/tmp/project"),
|
||||
host_repo_path: Some("/tmp/project".to_string()),
|
||||
repo_origin_url: None,
|
||||
base_branch: Some("main".to_string()),
|
||||
labels: HashMap::new(),
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: WorkflowSettings::default(),
|
||||
graph: Graph::new("test"),
|
||||
workflow_slug: Some("test".to_string()),
|
||||
source_directory: Some("/tmp/project".to_string()),
|
||||
repo_origin_url: None,
|
||||
base_branch: Some("main".to_string()),
|
||||
labels: HashMap::new(),
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -446,21 +461,23 @@ mod tests {
|
|||
let run_spec = sample_run_spec();
|
||||
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
|
||||
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: serde_json::to_value(&run_spec.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_spec.graph).unwrap(),
|
||||
workflow_source: None,
|
||||
workflow_config: None,
|
||||
labels: run_spec.labels.clone().into_iter().collect(),
|
||||
run_dir: run_dir.display().to_string(),
|
||||
working_directory: run_spec.working_directory.display().to_string(),
|
||||
host_repo_path: run_spec.host_repo_path.clone(),
|
||||
repo_origin_url: run_spec.repo_origin_url.clone(),
|
||||
base_branch: run_spec.base_branch.clone(),
|
||||
workflow_slug: run_spec.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: serde_json::to_value(&run_spec.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_spec.graph).unwrap(),
|
||||
workflow_source: None,
|
||||
workflow_config: None,
|
||||
labels: run_spec.labels.clone().into_iter().collect(),
|
||||
run_dir: run_dir.display().to_string(),
|
||||
source_directory: run_spec.source_directory.clone(),
|
||||
repo_origin_url: run_spec.repo_origin_url.clone(),
|
||||
base_branch: run_spec.base_branch.clone(),
|
||||
workflow_slug: run_spec.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
pre_run_git: run_spec.pre_run_git.clone(),
|
||||
fork_source_ref: run_spec.fork_source_ref.clone(),
|
||||
checkpoints_disabled: run_spec.checkpoints_disabled,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::sync::Arc;
|
|||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
use fabro_types::settings::run::RunMode;
|
||||
use fabro_types::{RunId, WorkflowSettings};
|
||||
use fabro_types::{ForkSourceRef, PreRunGitContext, RunId, WorkflowSettings};
|
||||
|
||||
use crate::git::{GitAuthor, git_author_from_settings};
|
||||
|
||||
|
|
@ -19,26 +19,30 @@ pub struct GitCheckpointOptions {
|
|||
/// Options for a workflow run.
|
||||
#[derive(Clone)]
|
||||
pub struct RunOptions {
|
||||
pub settings: WorkflowSettings,
|
||||
pub run_dir: PathBuf,
|
||||
pub cancel_token: Option<Arc<AtomicBool>>,
|
||||
pub settings: WorkflowSettings,
|
||||
pub run_dir: PathBuf,
|
||||
pub cancel_token: Option<Arc<AtomicBool>>,
|
||||
/// Unique identifier for this workflow run.
|
||||
pub run_id: RunId,
|
||||
pub run_id: RunId,
|
||||
/// User-defined key-value labels for this run.
|
||||
pub labels: HashMap<String, String>,
|
||||
pub labels: HashMap<String, String>,
|
||||
/// Workflow directory slug (e.g. "smoke" from `.fabro/workflows/smoke/`).
|
||||
pub workflow_slug: Option<String>,
|
||||
pub workflow_slug: Option<String>,
|
||||
/// GitHub credentials for pushing metadata branches to origin.
|
||||
pub github_app: Option<fabro_github::GitHubCredentials>,
|
||||
/// Host repo path for MetadataStore (shadow commits) and host-side pushes.
|
||||
pub host_repo_path: Option<PathBuf>,
|
||||
pub github_app: Option<fabro_github::GitHubCredentials>,
|
||||
/// Submitter-side git context captured before the run was created.
|
||||
pub pre_run_git: Option<PreRunGitContext>,
|
||||
/// Source checkpoint ref used by fork/rewind-created runs.
|
||||
pub fork_source_ref: Option<ForkSourceRef>,
|
||||
/// Explicit no-checkpoints mode for in-place local execution.
|
||||
pub checkpoints_disabled: bool,
|
||||
/// Name of the branch the run was started from (for PR base).
|
||||
pub base_branch: Option<String>,
|
||||
pub base_branch: Option<String>,
|
||||
/// Base commit SHA to display in lifecycle events/UI even when
|
||||
/// checkpointing is disabled.
|
||||
pub display_base_sha: Option<String>,
|
||||
pub display_base_sha: Option<String>,
|
||||
/// Git checkpoint options; `None` means checkpointing disabled.
|
||||
pub git: Option<GitCheckpointOptions>,
|
||||
pub git: Option<GitCheckpointOptions>,
|
||||
}
|
||||
|
||||
impl RunOptions {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
|
||||
use fabro_agent::Sandbox;
|
||||
use fabro_checkpoint::trailer as trailerlink;
|
||||
use fabro_checkpoint::trailer::Trailer;
|
||||
use fabro_sandbox::daytona::detect_repo_info;
|
||||
use fabro_types::RunId;
|
||||
|
||||
use crate::artifact_snapshot;
|
||||
use crate::git::{GitAuthor, blocking_push_with_timeout, push_ref};
|
||||
use crate::git::GitAuthor;
|
||||
|
||||
/// Captured git state for a workflow run, shared with handlers.
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -133,59 +131,6 @@ pub async fn git_checkpoint(
|
|||
}
|
||||
}
|
||||
|
||||
/// Push a refspec from the host repo to origin (best-effort).
|
||||
///
|
||||
/// Authenticates via resolved GitHub credentials so we don't depend on the
|
||||
/// host's ambient git credentials.
|
||||
pub async fn git_push_host(
|
||||
repo_path: &Path,
|
||||
refspec: &str,
|
||||
github_app: &Option<fabro_github::GitHubCredentials>,
|
||||
label: &str,
|
||||
) -> bool {
|
||||
let (origin_url, _) = match detect_repo_info(repo_path) {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, label, "Cannot detect origin for push");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let https_url = fabro_github::ssh_url_to_https(&origin_url);
|
||||
let push_url = if let Some(creds) = github_app {
|
||||
match fabro_github::resolve_authenticated_url(
|
||||
&fabro_github::GitHubContext::new(creds, &fabro_github::github_api_base_url()),
|
||||
&https_url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(url) => url.raw_string(),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, label, "Failed to get token for push");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(label, "No GitHub credentials for push");
|
||||
return false;
|
||||
};
|
||||
|
||||
let rp = repo_path.to_path_buf();
|
||||
let refspec_owned = refspec.to_string();
|
||||
let result =
|
||||
blocking_push_with_timeout(60, move || push_ref(&rp, &push_url, &refspec_owned)).await;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
tracing::info!(label, "Pushed to origin");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, label, "Failed to push");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a git diff via the sandbox (30 s default timeout).
|
||||
pub(crate) async fn git_diff(
|
||||
sandbox: &dyn Sandbox,
|
||||
|
|
@ -833,7 +778,7 @@ mod tests {
|
|||
reason = "These unit tests use the real git CLI to construct sandbox-git fixture repositories and sync-write fixtures to disk."
|
||||
)]
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
|
@ -1174,6 +1119,88 @@ mod tests {
|
|||
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandbox_metadata_writer_preserves_worktree_and_writes_binary_run_dump() {
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
let repo = repo_dir.path();
|
||||
init_git_repo(repo);
|
||||
std::fs::write(repo.join("tracked.txt"), "seed\n").unwrap();
|
||||
let head = git_commit_all(repo, "initial");
|
||||
|
||||
let sandbox = fabro_agent::LocalSandbox::new(repo.to_path_buf());
|
||||
let run_id = fabro_types::fixtures::RUN_1;
|
||||
let mut projection = fabro_store::RunProjection::default();
|
||||
projection.spec = Some(fabro_types::RunSpec {
|
||||
run_id,
|
||||
settings: fabro_types::WorkflowSettings::default(),
|
||||
graph: fabro_types::Graph::new("metadata"),
|
||||
workflow_slug: Some("metadata".to_string()),
|
||||
source_directory: Some("/Users/client/project".to_string()),
|
||||
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
|
||||
base_branch: Some("main".to_string()),
|
||||
labels: HashMap::new(),
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
});
|
||||
let mut dump = crate::run_dump::RunDump::from_projection(&projection);
|
||||
dump.add_file_bytes("binary/payload.bin", vec![0, 159, 146, 150]);
|
||||
|
||||
let runtime = crate::sandbox_metadata::SandboxGitRuntime::new();
|
||||
let run_id_string = run_id.to_string();
|
||||
let branch = crate::sandbox_metadata::metadata_branch_name(&run_id_string);
|
||||
let writer = crate::sandbox_metadata::SandboxMetadataWriter::new(
|
||||
&sandbox,
|
||||
&runtime,
|
||||
&run_id_string,
|
||||
&branch,
|
||||
crate::git::GitAuthor::default(),
|
||||
);
|
||||
|
||||
let snapshot = writer.write_snapshot(&dump, "checkpoint").await.unwrap();
|
||||
let commit_sha = snapshot.commit_sha;
|
||||
|
||||
let current = std::process::Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8(current.stdout).unwrap().trim(), "main");
|
||||
let head_after = std::process::Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(String::from_utf8(head_after.stdout).unwrap().trim(), head);
|
||||
|
||||
let run_json = std::process::Command::new("git")
|
||||
.args(["show", &format!("{commit_sha}:run.json")])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(run_json.status.success());
|
||||
let stored_projection: serde_json::Value =
|
||||
serde_json::from_slice(&run_json.stdout).unwrap();
|
||||
assert!(stored_projection.get("spec").is_some());
|
||||
|
||||
let binary = std::process::Command::new("git")
|
||||
.args(["show", &format!("{commit_sha}:binary/payload.bin")])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(binary.stdout, vec![0, 159, 146, 150]);
|
||||
|
||||
let status = std::process::Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(repo)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8(status.stdout).unwrap().trim().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_changed_files_raw_classifies_add_modify_delete() {
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
352
lib/crates/fabro-workflow/src/sandbox_metadata.rs
Normal file
352
lib/crates/fabro-workflow/src/sandbox_metadata.rs
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use fabro_agent::Sandbox;
|
||||
use tokio::fs;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use crate::git::{GitAuthor, META_BRANCH_PREFIX};
|
||||
use crate::run_dump::RunDump;
|
||||
use crate::sandbox_git::GIT_REMOTE;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum SandboxMetadataError {
|
||||
#[error("sandbox git unavailable: {0}")]
|
||||
GitUnavailable(String),
|
||||
#[error("metadata dump serialization failed: {0}")]
|
||||
Dump(#[from] anyhow::Error),
|
||||
#[error("metadata temp file write failed: {0}")]
|
||||
LocalTemp(std::io::Error),
|
||||
#[error("{0}")]
|
||||
Git(String),
|
||||
#[error("{0}")]
|
||||
Sandbox(String),
|
||||
}
|
||||
|
||||
pub(crate) struct SandboxGitRuntime {
|
||||
probe: OnceCell<Result<(), String>>,
|
||||
metadata_degraded: AtomicBool,
|
||||
metadata_warning_emitted: AtomicBool,
|
||||
}
|
||||
|
||||
impl SandboxGitRuntime {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
probe: OnceCell::new(),
|
||||
metadata_degraded: AtomicBool::new(false),
|
||||
metadata_warning_emitted: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_git_available(&self, sandbox: &dyn Sandbox) -> Result<(), String> {
|
||||
self.probe
|
||||
.get_or_init(|| async { probe_sandbox_git(sandbox).await })
|
||||
.await
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn mark_metadata_degraded(&self) -> bool {
|
||||
self.metadata_degraded.store(true, Ordering::SeqCst);
|
||||
!self.metadata_warning_emitted.swap(true, Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub(crate) fn metadata_degraded(&self) -> bool {
|
||||
self.metadata_degraded.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SandboxGitRuntime {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn metadata_branch_name(run_id: &str) -> String {
|
||||
format!("{META_BRANCH_PREFIX}{run_id}")
|
||||
}
|
||||
|
||||
pub(crate) struct SandboxMetadataWriter<'a> {
|
||||
sandbox: &'a dyn Sandbox,
|
||||
runtime: &'a SandboxGitRuntime,
|
||||
run_id: &'a str,
|
||||
branch: &'a str,
|
||||
git_author: GitAuthor,
|
||||
}
|
||||
|
||||
pub(crate) struct MetadataSnapshot {
|
||||
pub commit_sha: String,
|
||||
pub pushed: bool,
|
||||
}
|
||||
|
||||
impl<'a> SandboxMetadataWriter<'a> {
|
||||
pub(crate) fn new(
|
||||
sandbox: &'a dyn Sandbox,
|
||||
runtime: &'a SandboxGitRuntime,
|
||||
run_id: &'a str,
|
||||
branch: &'a str,
|
||||
git_author: GitAuthor,
|
||||
) -> Self {
|
||||
Self {
|
||||
sandbox,
|
||||
runtime,
|
||||
run_id,
|
||||
branch,
|
||||
git_author,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn write_snapshot(
|
||||
&self,
|
||||
dump: &RunDump,
|
||||
message: &str,
|
||||
) -> Result<MetadataSnapshot, SandboxMetadataError> {
|
||||
self.runtime
|
||||
.ensure_git_available(self.sandbox)
|
||||
.await
|
||||
.map_err(SandboxMetadataError::GitUnavailable)?;
|
||||
|
||||
let entries = dump.git_entries()?;
|
||||
let temp = sandbox_temp_dir(self.sandbox, self.run_id, "metadata");
|
||||
let index = format!("{temp}/index");
|
||||
exec_ok(
|
||||
self.sandbox,
|
||||
&format!(
|
||||
"rm -rf {temp_q} && mkdir -p {temp_q}",
|
||||
temp_q = shell_quote(&temp)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result = self
|
||||
.write_snapshot_in_temp(&entries, message, &temp, &index)
|
||||
.await;
|
||||
let cleanup = exec_ok(
|
||||
self.sandbox,
|
||||
&format!("rm -rf {}", shell_quote(&temp)),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
match (result, cleanup) {
|
||||
(Ok(snapshot), _) => Ok(snapshot),
|
||||
(Err(err), _) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_snapshot_in_temp(
|
||||
&self,
|
||||
entries: &[(String, Vec<u8>)],
|
||||
message: &str,
|
||||
temp: &str,
|
||||
index: &str,
|
||||
) -> Result<MetadataSnapshot, SandboxMetadataError> {
|
||||
let full_ref = format!("refs/heads/{}", self.branch);
|
||||
let env = git_index_env(index, &self.git_author);
|
||||
let old_commit = self.load_previous_tree(&full_ref, &env).await?;
|
||||
|
||||
for (ordinal, (path, bytes)) in entries.iter().enumerate() {
|
||||
validate_metadata_path(path)?;
|
||||
let local = tempfile::NamedTempFile::new().map_err(SandboxMetadataError::LocalTemp)?;
|
||||
fs::write(local.path(), bytes)
|
||||
.await
|
||||
.map_err(SandboxMetadataError::LocalTemp)?;
|
||||
let remote = format!("{temp}/blob-{ordinal}");
|
||||
self.sandbox
|
||||
.upload_file_from_local(local.path(), &remote)
|
||||
.await
|
||||
.map_err(|err| SandboxMetadataError::Sandbox(err.display_with_causes()))?;
|
||||
let hash = exec_stdout(
|
||||
self.sandbox,
|
||||
&format!("{GIT_REMOTE} hash-object -w {}", shell_quote(&remote)),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let cacheinfo = format!("100644,{hash},{path}");
|
||||
exec_ok(
|
||||
self.sandbox,
|
||||
&format!(
|
||||
"{GIT_REMOTE} update-index --add --cacheinfo {}",
|
||||
shell_quote(&cacheinfo)
|
||||
),
|
||||
Some(&env),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let tree = exec_stdout(
|
||||
self.sandbox,
|
||||
&format!("{GIT_REMOTE} write-tree"),
|
||||
Some(&env),
|
||||
)
|
||||
.await?;
|
||||
let message_path = format!("{temp}/message.txt");
|
||||
let mut commit_message = message.to_string();
|
||||
self.git_author.append_footer(&mut commit_message);
|
||||
self.sandbox
|
||||
.write_file(&message_path, &commit_message)
|
||||
.await
|
||||
.map_err(|err| SandboxMetadataError::Sandbox(err.display_with_causes()))?;
|
||||
let parent = old_commit
|
||||
.as_ref()
|
||||
.map_or(String::new(), |sha| format!(" -p {}", shell_quote(sha)));
|
||||
let commit = exec_stdout(
|
||||
self.sandbox,
|
||||
&format!(
|
||||
"{GIT_REMOTE} commit-tree {}{parent} -F {}",
|
||||
shell_quote(&tree),
|
||||
shell_quote(&message_path)
|
||||
),
|
||||
Some(&env),
|
||||
)
|
||||
.await?;
|
||||
exec_ok(
|
||||
self.sandbox,
|
||||
&format!(
|
||||
"{GIT_REMOTE} update-ref {} {}",
|
||||
shell_quote(&full_ref),
|
||||
shell_quote(&commit)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let refspec = format!("{full_ref}:{full_ref}");
|
||||
let pushed = self.sandbox.git_push_ref(&refspec).await;
|
||||
Ok(MetadataSnapshot {
|
||||
commit_sha: commit,
|
||||
pushed,
|
||||
})
|
||||
}
|
||||
|
||||
async fn load_previous_tree(
|
||||
&self,
|
||||
full_ref: &str,
|
||||
env: &HashMap<String, String>,
|
||||
) -> Result<Option<String>, SandboxMetadataError> {
|
||||
let old_commit = exec_stdout(
|
||||
self.sandbox,
|
||||
&format!(
|
||||
"{GIT_REMOTE} rev-parse --verify -q {}^{{commit}} || true",
|
||||
shell_quote(full_ref)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if old_commit.is_empty() {
|
||||
exec_ok(
|
||||
self.sandbox,
|
||||
&format!("{GIT_REMOTE} read-tree --empty"),
|
||||
Some(env),
|
||||
)
|
||||
.await?;
|
||||
Ok(None)
|
||||
} else {
|
||||
exec_ok(
|
||||
self.sandbox,
|
||||
&format!("{GIT_REMOTE} read-tree {}", shell_quote(&old_commit)),
|
||||
Some(env),
|
||||
)
|
||||
.await?;
|
||||
Ok(Some(old_commit))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_sandbox_git(sandbox: &dyn Sandbox) -> Result<(), String> {
|
||||
let temp = sandbox_temp_dir(sandbox, "probe", "git");
|
||||
let index = format!("{temp}/index");
|
||||
let probe_file = format!("{temp}/probe.txt");
|
||||
let command = format!(
|
||||
"set -e\n\
|
||||
rm -rf {temp_q}\n\
|
||||
mkdir -p {temp_q}\n\
|
||||
printf probe > {probe_file_q}\n\
|
||||
GIT_INDEX_FILE={index_q} {git} read-tree --empty\n\
|
||||
blob=$({git} hash-object -w {probe_file_q})\n\
|
||||
GIT_INDEX_FILE={index_q} {git} update-index --add --cacheinfo 100644,$blob,probe.txt\n\
|
||||
GIT_INDEX_FILE={index_q} {git} write-tree >/dev/null\n\
|
||||
rm -rf {temp_q}",
|
||||
temp_q = shell_quote(&temp),
|
||||
probe_file_q = shell_quote(&probe_file),
|
||||
index_q = shell_quote(&index),
|
||||
git = GIT_REMOTE,
|
||||
);
|
||||
exec_ok(sandbox, &command, None)
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn sandbox_temp_dir(sandbox: &dyn Sandbox, run_id: &str, label: &str) -> String {
|
||||
format!(
|
||||
"{}/.fabro/tmp/{label}-{run_id}-{}",
|
||||
sandbox.working_directory().trim_end_matches('/'),
|
||||
uuid::Uuid::new_v4()
|
||||
)
|
||||
}
|
||||
|
||||
fn git_index_env(index: &str, author: &GitAuthor) -> HashMap<String, String> {
|
||||
HashMap::from([
|
||||
("GIT_INDEX_FILE".to_string(), index.to_string()),
|
||||
("GIT_AUTHOR_NAME".to_string(), author.name.clone()),
|
||||
("GIT_AUTHOR_EMAIL".to_string(), author.email.clone()),
|
||||
("GIT_COMMITTER_NAME".to_string(), author.name.clone()),
|
||||
("GIT_COMMITTER_EMAIL".to_string(), author.email.clone()),
|
||||
])
|
||||
}
|
||||
|
||||
async fn exec_stdout(
|
||||
sandbox: &dyn Sandbox,
|
||||
command: &str,
|
||||
env: Option<&HashMap<String, String>>,
|
||||
) -> Result<String, SandboxMetadataError> {
|
||||
let result = sandbox
|
||||
.exec_command(command, 30_000, None, env, None)
|
||||
.await
|
||||
.map_err(|err| SandboxMetadataError::Sandbox(err.display_with_causes()))?;
|
||||
if result.exit_code == 0 {
|
||||
Ok(result.stdout.trim().to_string())
|
||||
} else {
|
||||
Err(SandboxMetadataError::Git(exec_err(command, &result)))
|
||||
}
|
||||
}
|
||||
|
||||
async fn exec_ok(
|
||||
sandbox: &dyn Sandbox,
|
||||
command: &str,
|
||||
env: Option<&HashMap<String, String>>,
|
||||
) -> Result<(), SandboxMetadataError> {
|
||||
exec_stdout(sandbox, command, env).await.map(|_| ())
|
||||
}
|
||||
|
||||
fn exec_err(label: &str, result: &fabro_sandbox::ExecResult) -> String {
|
||||
if result.timed_out {
|
||||
return format!("{label} timed out after {}ms", result.duration_ms);
|
||||
}
|
||||
let detail = format!("{}{}", result.stdout, result.stderr);
|
||||
let detail = detail.trim();
|
||||
if detail.is_empty() {
|
||||
format!("{label} failed with exit {}", result.exit_code)
|
||||
} else {
|
||||
format!("{label} failed with exit {}: {detail}", result.exit_code)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_metadata_path(path: &str) -> Result<(), SandboxMetadataError> {
|
||||
let invalid = path.is_empty()
|
||||
|| path.starts_with('/')
|
||||
|| path
|
||||
.split('/')
|
||||
.any(|segment| segment.is_empty() || segment == "." || segment == "..");
|
||||
if invalid {
|
||||
return Err(SandboxMetadataError::Git(format!(
|
||||
"invalid metadata path: {path}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shell_quote(value: &str) -> String {
|
||||
shlex::try_quote(value).map_or_else(
|
||||
|_| format!("'{}'", value.replace('\'', "'\\''")),
|
||||
|quoted| quoted.to_string(),
|
||||
)
|
||||
}
|
||||
|
|
@ -17,18 +17,20 @@ use crate::event::Emitter;
|
|||
use crate::handler::HandlerRegistry;
|
||||
use crate::runtime_store::RunStoreHandle;
|
||||
use crate::sandbox_git::GitState;
|
||||
use crate::sandbox_metadata::SandboxGitRuntime;
|
||||
use crate::workflow_bundle::WorkflowBundle;
|
||||
|
||||
/// Services shared across workflow phases.
|
||||
#[derive(Clone)]
|
||||
pub struct RunServices {
|
||||
pub run_store: RunStoreHandle,
|
||||
pub emitter: Arc<Emitter>,
|
||||
pub sandbox: Arc<dyn Sandbox>,
|
||||
pub hook_runner: Option<Arc<HookRunner>>,
|
||||
pub cancel_requested: Option<Arc<AtomicBool>>,
|
||||
pub provider: Provider,
|
||||
pub llm_source: Arc<dyn CredentialSource>,
|
||||
pub run_store: RunStoreHandle,
|
||||
pub emitter: Arc<Emitter>,
|
||||
pub sandbox: Arc<dyn Sandbox>,
|
||||
pub hook_runner: Option<Arc<HookRunner>>,
|
||||
pub cancel_requested: Option<Arc<AtomicBool>>,
|
||||
pub provider: Provider,
|
||||
pub llm_source: Arc<dyn CredentialSource>,
|
||||
pub(crate) metadata_runtime: Arc<SandboxGitRuntime>,
|
||||
}
|
||||
|
||||
impl RunServices {
|
||||
|
|
@ -50,6 +52,7 @@ impl RunServices {
|
|||
cancel_requested,
|
||||
provider,
|
||||
llm_source,
|
||||
metadata_runtime: Arc::new(SandboxGitRuntime::new()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -106,31 +106,28 @@ async fn initialized(
|
|||
.expect("failed to create slate-backed test run store");
|
||||
let run_store = inner_store;
|
||||
append_event(&run_store, &run_options.run_id, &Event::RunCreated {
|
||||
run_id: run_options.run_id,
|
||||
settings: serde_json::to_value(&run_options.settings)
|
||||
run_id: run_options.run_id,
|
||||
settings: serde_json::to_value(&run_options.settings)
|
||||
.expect("failed to serialize settings"),
|
||||
graph: serde_json::to_value(graph).expect("failed to serialize graph"),
|
||||
workflow_source: None,
|
||||
workflow_config: None,
|
||||
labels: run_options
|
||||
graph: serde_json::to_value(graph).expect("failed to serialize graph"),
|
||||
workflow_source: None,
|
||||
workflow_config: None,
|
||||
labels: run_options
|
||||
.labels
|
||||
.clone()
|
||||
.into_iter()
|
||||
.collect::<BTreeMap<_, _>>(),
|
||||
run_dir: run_options.run_dir.display().to_string(),
|
||||
working_directory: PathBuf::from(sandbox.working_directory())
|
||||
.display()
|
||||
.to_string(),
|
||||
host_repo_path: run_options
|
||||
.host_repo_path
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string()),
|
||||
repo_origin_url: None,
|
||||
base_branch: run_options.base_branch.clone(),
|
||||
workflow_slug: run_options.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
run_dir: run_options.run_dir.display().to_string(),
|
||||
source_directory: Some(sandbox.working_directory().to_string()),
|
||||
repo_origin_url: None,
|
||||
base_branch: run_options.base_branch.clone(),
|
||||
workflow_slug: run_options.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
pre_run_git: run_options.pre_run_git.clone(),
|
||||
fork_source_ref: run_options.fork_source_ref.clone(),
|
||||
checkpoints_disabled: run_options.checkpoints_disabled,
|
||||
})
|
||||
.await
|
||||
.expect("failed to seed run.created event in run store");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -13,8 +13,8 @@ use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};
|
|||
use fabro_types::{RunEvent, WorkflowSettings, fixtures};
|
||||
use fabro_workflow::event::Emitter;
|
||||
use fabro_workflow::git::{
|
||||
MetadataStore, add_worktree, branch_needs_push, create_branch, push_branch, push_ref,
|
||||
remove_worktree, replace_worktree,
|
||||
add_worktree, branch_needs_push, create_branch, push_branch, push_ref, remove_worktree,
|
||||
replace_worktree,
|
||||
};
|
||||
use fabro_workflow::handler::HandlerRegistry;
|
||||
use fabro_workflow::handler::exit::ExitHandler;
|
||||
|
|
@ -153,17 +153,19 @@ fn make_registry() -> HandlerRegistry {
|
|||
|
||||
fn test_run_options(run_dir: &Path) -> RunOptions {
|
||||
RunOptions {
|
||||
run_dir: run_dir.to_path_buf(),
|
||||
cancel_token: None,
|
||||
run_id: fixtures::RUN_2,
|
||||
settings: WorkflowSettings::default(),
|
||||
git: None,
|
||||
host_repo_path: None,
|
||||
labels: HashMap::new(),
|
||||
github_app: None,
|
||||
base_branch: None,
|
||||
display_base_sha: None,
|
||||
workflow_slug: None,
|
||||
run_dir: run_dir.to_path_buf(),
|
||||
cancel_token: None,
|
||||
run_id: fixtures::RUN_2,
|
||||
settings: WorkflowSettings::default(),
|
||||
git: None,
|
||||
pre_run_git: None,
|
||||
fork_source_ref: None,
|
||||
checkpoints_disabled: false,
|
||||
labels: HashMap::new(),
|
||||
github_app: None,
|
||||
base_branch: None,
|
||||
display_base_sha: None,
|
||||
workflow_slug: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -285,9 +287,8 @@ async fn git_checkpoint_skips_start_node() {
|
|||
run_options.git = Some(GitCheckpointOptions {
|
||||
base_sha: Some(base_sha),
|
||||
run_branch: None,
|
||||
meta_branch: Some(MetadataStore::branch_name(&fixtures::RUN_2.to_string())),
|
||||
meta_branch: Some(format!("fabro/meta/{}", fixtures::RUN_2)),
|
||||
});
|
||||
run_options.host_repo_path = Some(PathBuf::from(repo));
|
||||
|
||||
Box::pin(run_graph(
|
||||
make_registry(),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -30,6 +30,14 @@ export interface ManifestArgs {
|
|||
'auto_approve'?: boolean;
|
||||
'no_retro'?: boolean;
|
||||
'preserve_sandbox'?: boolean;
|
||||
/**
|
||||
* Run against the submitted source directory directly.
|
||||
*/
|
||||
'in_place'?: boolean;
|
||||
/**
|
||||
* Required with `in_place`; disables git checkpointing.
|
||||
*/
|
||||
'allow_no_checkpoints'?: boolean;
|
||||
'label'?: Array<string>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
*/
|
||||
|
||||
|
||||
// May contain unused imports in some cases
|
||||
// @ts-ignore
|
||||
import type { ManifestPreRunPushOutcome } from './manifest-pre-run-push-outcome';
|
||||
|
||||
/**
|
||||
* Observable git state from the CLI working directory.
|
||||
|
|
@ -34,5 +37,6 @@ export interface ManifestGit {
|
|||
* Whether the working tree has uncommitted changes.
|
||||
*/
|
||||
'clean': boolean;
|
||||
'push_outcome': ManifestPreRunPushOutcome;
|
||||
}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue