Keep Git checkpoints for runs on host workspaces

Checkpoints were only enabled for runs with a GitHub source, so local
folder runs, empty Local runs and dry runs stopped committing. `fabro
diff` then failed for them, and their checkpoint, run branch and diff
records disappeared from the event stream.

A run whose workspace is on the host now commits checkpoints there
again, without pushing, as on main. Docker and Daytona runs with no
GitHub source still record execution checkpoints without Git commits,
so a sandbox image without `git` cannot fail the run.

The scenario tests for crash recovery go back to asserting commits. A
workspace deleted while the run is down now fails the resumed run,
since the server keeps no copy to restore it from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Scott Werner 2026-10-01 12:37:11 -04:00
parent 23591d550f
commit 6e0ef2c402
6 changed files with 225 additions and 136 deletions

View file

@ -40,9 +40,11 @@ macOS note: if `cargo nextest run` fails with `Too many open files (os error 24)
retries the push and opens the configured pull request; a failure fails the
run with `publish_failed` (`fabro-cli/src/commands/run/publish.rs`). Ordinary
resume requires the retained workspace. A GitHub-backed fork fetches the
source run branch inside its new sandbox. Empty and local-folder targets
keep execution metadata without automatic Git checkpoints; retry starts a
fresh execution from the saved spec. `CloneRequest` still
source run branch inside its new sandbox. Runs on host workspaces (local
folders, empty Local targets, dry runs) still commit checkpoints in that
workspace, without pushing; Docker and Daytona runs with no GitHub target
keep execution metadata without Git commits. Retry starts a fresh execution
from the saved spec. `CloneRequest` still
travels beside the sandbox spec so the run record names the origin and
branch; the sandbox layer refuses a request that asks it to clone.
Preflight and `fabro exec` initialize sandboxes with `CloneRequest::none()`,
@ -162,9 +164,11 @@ Fabro is an AI-powered workflow orchestration platform. Workflows are defined as
retries the push and opens the configured pull request; a failure fails the
run with `publish_failed` (`fabro-cli/src/commands/run/publish.rs`). Ordinary
resume requires the retained workspace. A GitHub-backed fork fetches the
source run branch inside its new sandbox. Empty and local-folder targets
keep execution metadata without automatic Git checkpoints; retry starts a
fresh execution from the saved spec. `CloneRequest` still
source run branch inside its new sandbox. Runs on host workspaces (local
folders, empty Local targets, dry runs) still commit checkpoints in that
workspace, without pushing; Docker and Daytona runs with no GitHub target
keep execution metadata without Git commits. Retry starts a fresh execution
from the saved spec. `CloneRequest` still
travels beside the sandbox spec so the run record names the origin and
branch; the sandbox layer refuses a request that asks it to clone.
Preflight and `fabro exec` initialize sandboxes with `CloneRequest::none()`,

View file

@ -3,7 +3,7 @@ title: "Checkpoints"
description: "How Fabro uses Git to checkpoint and resume workflow runs"
---
Fabro records every workflow run in the durable run store. For GitHub targets with run branches enabled, it also commits file changes inside the run workspace after each node completes. Empty and local-folder targets keep execution records without automatic Git checkpoint commits, even when the supplied folder contains a Git repository.
Fabro records every workflow run in the durable run store. With run branches enabled, it also commits file changes inside the run workspace after each node completes. GitHub targets push those commits to the run branch; local runs keep them in their workspace. A local-folder run works in a clone of the folder's committed `HEAD`, so uncommitted changes are not included, and a folder that is not a Git repository starts as an empty workspace.
## Code and execution history
@ -142,14 +142,12 @@ fabro dump 01JKXYZ --output ./run-dump
## When checkpointing is active
Git checkpointing activates automatically when:
Git checkpointing activates automatically when run branches are enabled and either:
- The run targets a GitHub repository with cloning enabled
- Run branches are enabled
- The run is not a dry run
- The run targets a GitHub repository with cloning enabled, or
- The run's workspace is on the host: a Local environment, or any `--dry-run`
It is skipped when:
- The target is an empty workspace or a local folder
- The run uses `--dry-run`
- Cloning or run branches are disabled
- Run branches are disabled
- A Docker or Daytona run has no GitHub target, or its cloning is disabled

View file

@ -286,15 +286,38 @@ impl RunningServer {
}
}
/// The one host workspace, without assuming the run uses Git.
fn workspace_path(&self, run_id: &str) -> PathBuf {
/// The one host workspace of the run, and the commits on its run
/// branch, oldest first, as `(subject, key)`.
fn workspace_commits(&self, run_id: &str) -> (PathBuf, Vec<(String, Option<CheckpointKey>)>) {
let scopes = self.petri_run_dir(run_id).join("scopes");
let workspaces: Vec<_> = std::fs::read_dir(scopes)
.expect("the scopes directory exists")
.map(|entry| entry.expect("the scope entry reads").path().join("work"))
let mut workspaces: Vec<PathBuf> = std::fs::read_dir(&scopes)
.expect("the scopes directory lists")
.map(|entry| entry.expect("an entry reads").path().join("work"))
.collect();
assert_eq!(workspaces.len(), 1);
workspaces[0].clone()
assert_eq!(workspaces.len(), 1, "one workspace: {workspaces:?}");
let workspace = workspaces.remove(0);
let output = Command::new("git")
.args(["log", "--reverse", "--format=%s%x00%B%x1e"])
.current_dir(&workspace)
.output()
.expect("git runs");
assert!(
output.status.success(),
"git log failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let log = String::from_utf8_lossy(&output.stdout).into_owned();
let commits = log
.split('\u{1e}')
.filter(|entry| !entry.trim().is_empty())
.map(|entry| {
let mut parts = entry.trim_start().splitn(2, '\0');
let subject = parts.next().unwrap_or_default().to_string();
let body = parts.next().unwrap_or_default();
(subject, CheckpointKey::from_message(body))
})
.collect();
(workspace, commits)
}
/// The run's checkpoint records, in seq order, as `(node position, sha)`.
@ -1138,16 +1161,19 @@ async fn a_finished_petri_run_reads_back_through_the_cli() {
[CLOCK] ▶ start
[CLOCK] │ checkout: [TEMP_DIR]/petri-workspace is not a Git repository; the workspace starts empty
[CLOCK] ✓ start [DURATION]
[CLOCK] ⎘ Checkpoint (no commit)
[CLOCK] Branch: fabro/run/[ULID] from [SHA]
[CLOCK] Git identity: Fabro <noreply@fabro.sh> default
[CLOCK] ⎘ Checkpoint [SHA]
[CLOCK] ▶ say
[CLOCK] start → say continue
[CLOCK] │ hello from petri
[CLOCK] ✓ say [DURATION]
[CLOCK] ⎘ Checkpoint (no commit)
[CLOCK] ⎘ Checkpoint [SHA]
[CLOCK] ▶ exit
[CLOCK] say → exit continue
[CLOCK] ✓ exit [DURATION]
[CLOCK] ⎘ Checkpoint (no commit)
[CLOCK] ⎘ Checkpoint [SHA]
[CLOCK] Diff: +0 -0 in 0 file(s)
[CLOCK] ✓ SUCCEEDED [DURATION]
[CLOCK] · succeeded
----- stderr -----
@ -1363,10 +1389,28 @@ pub(super) async fn wait_for_success(server: &RunningServer, run_id: &str) {
);
}
/// Durable execution finishes survive restart. The in-flight stage reruns
/// in its surviving workspace, including its partial non-Git output.
/// The subjects of the commits on the run branch.
fn subjects(commits: &[(String, Option<CheckpointKey>)]) -> Vec<&str> {
commits
.iter()
.map(|(subject, _)| subject.as_str())
.collect()
}
/// The commit subjects one run of the three-stage bundle produces; its goal
/// gate on `three` adds the `goal_check` stage.
fn three_stage_subjects(run_id: &str) -> Vec<String> {
["start", "one", "two", "three", "goal_check", "exit"]
.iter()
.map(|node| format!("fabro({run_id}): {node} (success)"))
.collect()
}
/// A worker killed after a stage's finish is durable: on the restart the
/// stage's commit is not repeated, the stage in flight reruns on the
/// snapshot (its partial output gone), and the next stage sees both.
#[tokio::test(flavor = "multi_thread")]
async fn a_non_git_crash_resumes_execution_in_the_surviving_workspace() {
async fn a_crash_after_a_durable_finish_keeps_its_one_commit() {
let context = test_context!();
let mut server = RunningServer::start().await;
let gate = context.temp_dir.join("two.gate");
@ -1385,45 +1429,49 @@ async fn a_non_git_crash_resumes_execution_in_the_surviving_workspace() {
std::fs::write(&gate, "go").expect("the gate opens");
wait_for_success(&server, &run_id).await;
let path = server.workspace_path(&run_id);
assert!(!path.join(".git").exists());
let (path, commits) = server.workspace_commits(&run_id);
assert_eq!(subjects(&commits), three_stage_subjects(&run_id));
assert_eq!(
std::fs::read_to_string(path.join("three.log")).expect("three copied the log"),
"run\nrun\n",
"non-Git recovery preserves the interrupted attempt's files; server log:\n{}",
"run\n",
"the crashed attempt's partial output was reset before the rerun; server log:\n{}",
server.stderr_text()
);
assert!(server.checkpoints(&run_id).await.is_empty());
assert!(!server.petri_run_dir(&run_id).join("snapshots").exists());
let checkpoints = server.checkpoints(&run_id).await;
assert_eq!(checkpoints.len(), 6, "{checkpoints:?}");
let keys: Vec<Option<CheckpointKey>> = checkpoints.iter().map(|(key, _)| Some(*key)).collect();
let committed: Vec<Option<CheckpointKey>> = commits.iter().map(|(_, key)| *key).collect();
assert_eq!(keys, committed);
server.shutdown();
}
/// A worker killed before its durable finish reruns that stage once.
/// A worker killed in `prepare_result` before the commit lands: the finish
/// is not durable, the stage reruns once, and one commit exists for it.
#[tokio::test(flavor = "multi_thread")]
async fn a_crash_before_a_durable_finish_reruns_the_stage_once() {
async fn a_crash_before_the_commit_lands_reruns_the_stage_once() {
let context = test_context!();
let mut server = RunningServer::start().await;
let gate = context.temp_dir.join("two.gate");
std::fs::write(&gate, "open").expect("the script gate is open from the start");
let workspace = three_stage_bundle(&context, &gate);
server.hold("prepare", "two");
server.hold("commit", "two");
let run_id = run_detached(&context, &server, &workspace);
wait_for_status(&server, &run_id, &["running"]).await;
let worker = wait_for_worker(&run_id);
server.wait_until_held(&run_id, "prepare", "two");
server.wait_until_held(&run_id, "commit", "two");
crash(&mut server, worker, None);
server.release("prepare", "two");
server.release("commit", "two");
server.launch().await;
wait_for_success(&server, &run_id).await;
let path = server.workspace_path(&run_id);
assert!(!path.join(".git").exists());
let (path, commits) = server.workspace_commits(&run_id);
assert_eq!(subjects(&commits), three_stage_subjects(&run_id));
assert_eq!(
std::fs::read_to_string(path.join("three.log")).expect("three copied the log"),
"run\nrun\n",
"the stage reran once in the existing workspace"
"run\n",
"the stage reran once, on the snapshot before it"
);
let store = server.petri_store().await;
let outcome = engine::outcome_of(&store, &run_id)
@ -1434,10 +1482,11 @@ async fn a_crash_before_a_durable_finish_reruns_the_stage_once() {
server.shutdown();
}
/// A durable finish before its platform record does not rerun the stage;
/// the resumed transition writes the missing metadata.
/// A worker killed after the commit and its durable finish but before the
/// platform record: the restart reconciles the record from the snapshot
/// repository, the stage does not rerun, and one commit exists for it.
#[tokio::test(flavor = "multi_thread")]
async fn a_crash_before_the_platform_record_replays_the_record() {
async fn a_crash_before_the_record_reconciles_it_from_the_run_branch() {
let context = test_context!();
let mut server = RunningServer::start().await;
let gate = context.temp_dir.join("two.gate");
@ -1450,30 +1499,35 @@ async fn a_crash_before_the_platform_record_replays_the_record() {
let worker = wait_for_worker(&run_id);
server.wait_until_held(&run_id, "record", "two");
let before = server.checkpoints(&run_id).await;
assert!(
before.is_empty(),
"non-Git checkpoints do not carry commit SHAs"
);
assert_eq!(before.len(), 2, "start and one are recorded: {before:?}");
crash(&mut server, worker, None);
server.release("record", "two");
server.launch().await;
wait_for_success(&server, &run_id).await;
let path = server.workspace_path(&run_id);
assert!(!path.join(".git").exists());
let (path, commits) = server.workspace_commits(&run_id);
assert_eq!(subjects(&commits), three_stage_subjects(&run_id));
assert_eq!(
std::fs::read_to_string(path.join("three.log")).expect("three copied the log"),
"run\n",
"the stage with a durable finish did not rerun"
);
assert!(server.checkpoints(&run_id).await.is_empty());
let checkpoints = server.checkpoints(&run_id).await;
assert_eq!(checkpoints.len(), 6, "{checkpoints:?}");
let keys: Vec<Option<CheckpointKey>> = checkpoints.iter().map(|(key, _)| Some(*key)).collect();
let committed: Vec<Option<CheckpointKey>> = commits.iter().map(|(_, key)| *key).collect();
assert_eq!(
keys, committed,
"the reconciled record names the one commit"
);
server.shutdown();
}
/// Execution records cannot reconstruct a deleted non-Git workspace.
/// A workspace deleted while the run is down is not reconstructed: the
/// server keeps no copy of the repository, so the resumed run fails.
#[tokio::test(flavor = "multi_thread")]
async fn a_non_git_run_cannot_recover_deleted_files() {
async fn a_deleted_workspace_fails_the_resumed_run() {
let context = test_context!();
let mut server = RunningServer::start().await;
let gate = context.temp_dir.join("two.gate");
@ -1484,27 +1538,24 @@ async fn a_non_git_run_cannot_recover_deleted_files() {
let worker = wait_for_worker(&run_id);
wait_until_gate_is_polled(&gate);
crash(&mut server, worker, Some(&gate));
let path = server.workspace_path(&run_id);
assert!(path.join("one.txt").exists());
let (path, commits) = server.workspace_commits(&run_id);
assert_eq!(subjects(&commits), three_stage_subjects(&run_id)[..2]);
std::fs::remove_dir_all(&path).expect("the workspace is deleted");
server.launch().await;
wait_until_gate_is_polled(&gate);
std::fs::write(&gate, "go").expect("the gate opens");
assert_eq!(
wait_for_status(&server, &run_id, &["failed", "succeeded"]).await,
"failed"
);
assert!(
!path.join("one.txt").exists(),
"deleted files cannot be reconstructed"
);
assert!(!path.exists(), "the deleted workspace is not recreated");
server.shutdown();
}
/// A failure route resumes with the files left in the existing workspace.
/// A stage that fails on its own terms routes to its failure edge on the
/// committed files, and after a crash once the failure is durable the
/// route reruns on the same files.
#[tokio::test(flavor = "multi_thread")]
async fn a_failure_route_uses_the_surviving_files_after_a_crash() {
async fn a_failure_route_sees_the_same_committed_files_after_a_crash() {
let context = test_context!();
let mut server = RunningServer::start().await;
let gate = context.temp_dir.join("fix.gate");
@ -1533,52 +1584,94 @@ async fn a_failure_route_uses_the_surviving_files_after_a_crash() {
std::fs::write(&gate, "go").expect("the gate opens");
wait_for_success(&server, &run_id).await;
let path = server.workspace_path(&run_id);
let (path, commits) = server.workspace_commits(&run_id);
assert_eq!(subjects(&commits), vec![
format!("fabro({run_id}): start (success)"),
format!("fabro({run_id}): work (failure)"),
format!("fabro({run_id}): fix (success)"),
format!("fabro({run_id}): exit (success)"),
]);
assert_eq!(
std::fs::read_to_string(path.join("out.txt")).expect("out.txt"),
"partial\nfixed\n"
);
assert_eq!(
std::fs::read_to_string(path.join("fix.log")).expect("fix.log"),
"run\nrun\n",
"the route continued with the existing files, including partial output"
"run\n",
"the route saw the failed stage's files, not its own interrupted attempt's"
);
server.shutdown();
}
/// A failed non-Git execution stays terminal across a server restart.
/// A checkpoint commit that fails ends the run: `checkpoint_failed` is
/// recorded, no route runs, the run is reported failed, and a restart
/// leaves it failed without launching a worker.
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_non_git_run_stays_failed_after_a_restart() {
async fn a_failed_checkpoint_fails_the_run_and_a_restart_leaves_it_failed() {
let context = test_context!();
let mut server = RunningServer::start().await;
let workspace = write_petri_workflow(
&context,
r#"digraph Failure {
graph [goal="Fail", default_max_retries=0]
start [shape=Mdiamond]
fail [shape=parallelogram, script="exit 1", goal_gate=true]
exit [shape=Msquare]
start -> fail -> exit
}"#,
"digraph Wreck {\n graph [goal=\"Wreck the repository\", default_max_retries=0]\n start \
[shape=Mdiamond]\n exit [shape=Msquare]\n wreck [shape=parallelogram, script=\"rm -rf \
.git && echo garbage > .git\"]\n next [shape=parallelogram, script=\"echo next > \
next.txt\"]\n fix [shape=parallelogram, script=\"echo fix > fix.txt\"]\n start -> wreck \
-> next -> exit\n wreck -> fix [condition=\"outcome=failed\"]\n fix -> exit\n}\n",
);
let run_id = run_detached(&context, &server, &workspace);
assert_eq!(
wait_for_status(&server, &run_id, &["succeeded", "failed"]).await,
"failed"
let status = wait_for_status(&server, &run_id, &["succeeded", "failed"]).await;
let run = run_json(&server, &format!("runs/{run_id}")).await;
assert_eq!(status, "failed", "run: {run}");
// The run's failure travels on the stream as the platform record of
// its terminal lifecycle transition, with the failure's message as the
// reason.
let failures: Vec<String> = settled_stream(&server, &run_id)
.await
.iter()
.filter_map(|line| {
let record = &line["item"]["record"];
(line["kind"] == "platform"
&& record["kind"] == "run.lifecycle"
&& record["transition"] == "failed")
.then(|| record["reason"].as_str().unwrap_or_default().to_string())
})
.collect();
assert_eq!(failures.len(), 1, "{failures:?}");
assert!(
failures[0].contains("checkpoint commit of `wreck` failed"),
"{failures:?}"
);
assert!(server.checkpoints(&run_id).await.is_empty());
let deadline = Instant::now() + Duration::from_secs(10);
while worker_pid(&run_id).is_some() {
assert!(
Instant::now() < deadline,
"the terminal worker did not exit"
);
tokio::time::sleep(POLL).await;
}
let scopes = server.petri_run_dir(&run_id).join("scopes");
let work = std::fs::read_dir(&scopes)
.expect("the scopes directory lists")
.map(|entry| entry.expect("an entry reads").path().join("work"))
.next()
.expect("one workspace");
assert!(!work.join("next.txt").exists(), "no route ran");
assert!(!work.join("fix.txt").exists(), "no route ran");
let store = server.petri_store().await;
let outcome = engine::outcome_of(&store, &run_id)
.await
.expect("the run's Petri record inspects");
assert_ne!(outcome.status, RunStatus::Success, "{outcome:?}");
let checkpoints = server.checkpoints(&run_id).await;
assert_eq!(
checkpoints.len(),
1,
"only start was recorded: {checkpoints:?}"
);
// The restart finds the run terminal and launches nothing for it.
server.kill();
server.launch().await;
assert_eq!(run_status(&server, &run_id).await, "failed");
assert_eq!(worker_pid(&run_id), None);
std::thread::sleep(Duration::from_secs(1));
assert_eq!(
worker_pid(&run_id),
None,
"no worker was launched for the failed run"
);
server.shutdown();
}

View file

@ -1,8 +1,9 @@
//! Fork, rewind, retry and the timeline over Petri runs, through a real
//! server and its worker subprocess.
//!
//! Non-Git runs have execution checkpoints, can retry from the start, and
//! refuse fork/rewind because no published checkpoint can seed a workspace.
//! Local-folder runs are checkpointed in their workspace and can retry from
//! the start, but refuse fork and rewind: only a published run branch can
//! seed a new workspace.
//! Git-backed forks are covered by fabro-petri's sandbox/remote integration
//! test.
@ -139,7 +140,7 @@ fn read(workspace: &Path, name: &str) -> String {
/// A local-folder run cannot fork without a published checkpoint.
#[tokio::test(flavor = "multi_thread")]
async fn a_non_git_fork_is_refused_without_creating_a_run() {
async fn a_local_folder_fork_is_refused_without_creating_a_run() {
let context = test_context!();
let server = RunningServer::start().await;
let bundle = write_petri_workflow(&context, &three_stage_dot());
@ -202,7 +203,7 @@ async fn a_retry_starts_over_and_succeeds_when_the_failure_was_transient() {
/// Refusing rewind must leave the original run available and unarchived.
#[tokio::test(flavor = "multi_thread")]
async fn a_non_git_rewind_is_refused_without_archiving_its_source() {
async fn a_local_folder_rewind_is_refused_without_archiving_its_source() {
let context = test_context!();
let server = RunningServer::start().await;
let bundle = write_petri_workflow(&context, &three_stage_dot());
@ -220,20 +221,28 @@ async fn a_non_git_rewind_is_refused_without_archiving_its_source() {
}
/// The timeline lists every checkpoint the run recorded, in order, with
/// its position and optional commit, in both the CLI and API.
/// its position and commit, as the CLI prints it and as the API serves it.
#[tokio::test(flavor = "multi_thread")]
async fn the_timeline_lists_non_git_checkpoints_without_commit_shas() {
async fn the_timeline_lists_every_checkpoint_with_its_commit() {
let context = test_context!();
let server = RunningServer::start().await;
let bundle = write_petri_workflow(&context, &three_stage_dot());
let run_id = run_detached(&context, &server, &bundle);
wait_for_success(&server, &run_id).await;
assert!(server.checkpoints(&run_id).await.is_empty());
let recorded: Vec<(u64, u64, u64, Option<String>)> = server
.checkpoints(&run_id)
.await
.into_iter()
.map(|(key, sha)| (key.execution, key.firing, u64::from(key.attempt), Some(sha)))
.collect();
let listed = cli_json(&context, &server, &["timeline", &run_id, "--json"]);
let listed_entries = entries(&listed);
let listed_entries: Vec<(u64, u64, u64, Option<String>)> = entries(&listed)
.into_iter()
.map(|(_, execution, firing, attempt, sha)| (execution, firing, attempt, sha))
.collect();
assert_eq!(listed_entries, recorded);
assert_eq!(listed_entries.len(), 5);
assert!(listed_entries.iter().all(|entry| entry.4.is_none()));
let ordinals: Vec<u64> = listed["entries"]
.as_array()
.expect("entries")
@ -268,7 +277,7 @@ async fn the_timeline_lists_non_git_checkpoints_without_commit_shas() {
/// A checkpoint inside a parallel branch is not a fork position: Petri
/// refuses it, and the refusal says why. The join, in the root, is.
#[tokio::test(flavor = "multi_thread")]
async fn a_non_git_parallel_run_cannot_be_forked() {
async fn a_local_folder_parallel_run_cannot_be_forked() {
let context = test_context!();
let server = RunningServer::start().await;
let bundle = write_petri_workflow(&context, &parallel_dot());

View file

@ -72,8 +72,10 @@
//! all use this environment. Only execution metadata, patches and selected
//! artifacts are persisted on the server. Normal resume requires the original
//! workspace; a fork acquires a new sandbox and fetches its checkpoint from
//! GitHub. Empty and local-folder targets record execution checkpoints without
//! Git commits.
//! GitHub. A run with no GitHub source commits only when its workspace is on
//! the host (a local-folder, empty or dry run); its checkpoints stay in that
//! workspace. Docker and Daytona runs with no GitHub source record execution
//! checkpoints without Git commits.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
@ -485,7 +487,11 @@ impl FabroHooks {
email: spec.git.author.email.clone(),
source: spec.git.identity_source,
};
let checkpoint_enabled = spec.source.is_some() && spec.git.enabled;
// A host workspace commits checkpoints without a GitHub source (local
// folders, empty Local targets, dry runs); a sandbox without one does
// not, so an image without `git` cannot fail the run.
let checkpoint_enabled =
spec.git.enabled && (spec.source.is_some() || spec.git.host_workspaces);
let workspaces = RunWorkspaces::new(
run_dir,
run_id.to_string(),

View file

@ -336,7 +336,7 @@ fn stages(inspection: &RunInspection) -> Vec<(String, String)> {
/// A dry run keeps checkpointable local workspaces even when the selected
/// environment would require Docker or Daytona. Its command is simulated.
#[tokio::test]
async fn dry_runs_use_local_workspaces_without_git_or_sandbox_credentials() {
async fn dry_runs_use_local_workspaces_and_checkpoint_without_sandbox_credentials() {
for provider in [SandboxProviderKind::DOCKER, SandboxProviderKind::DAYTONA] {
let mut harness = Harness::new().await;
harness.source = None;
@ -383,10 +383,10 @@ async fn dry_runs_use_local_workspaces_without_git_or_sandbox_credentials() {
);
assert_eq!(
harness.checkpoints().len(),
0,
"{provider}: dry runs do not create Git checkpoints"
3,
"{provider}: every stage checkpoints"
);
assert!(!harness.workspace_path(&workspace).join(".git").exists());
assert_eq!(commits(&harness.workspace_path(&workspace)).await.len(), 3);
}
}
@ -1477,8 +1477,10 @@ async fn assert_shallow_fork(checkpoint_index: usize) {
assert!(!forked.run_dir.join("snapshots").exists());
}
/// A host workspace with no repository is initialized and checkpointed: every
/// stage commits on the run branch, and nothing is kept on the server.
#[tokio::test]
async fn an_empty_workspace_keeps_execution_records_without_git_checkpoints() {
async fn an_empty_host_workspace_is_initialized_and_checkpointed() {
let mut harness = Harness::new().await;
harness.source = None;
let graph = workflow(
@ -1487,15 +1489,13 @@ async fn an_empty_workspace_keeps_execution_records_without_git_checkpoints() {
);
let outcome = harness.run(&graph, SETTINGS).await;
assert_eq!(outcome.status, RunStatus::Success);
assert!(harness.checkpoints().is_empty());
assert_eq!(harness.checkpoints().len(), 3);
let path = harness.workspace_path(&harness.workspace().await);
assert!(!path.join(".git").exists());
let records = harness
.records
.read_kind(&harness.run_id, PlatformRecordKind::Checkpoint)
.await
.unwrap();
assert_eq!(records.len(), 3);
assert_eq!(commits(&path).await.len(), 3);
assert_eq!(
git(&path, &["rev-parse", "--abbrev-ref", "HEAD"]).await,
format!("fabro/run/{}", harness.run_id)
);
assert!(!harness.run_dir.join("snapshots").exists());
}
@ -1571,24 +1571,3 @@ async fn a_run_diff_failure_cannot_silently_skip_publication() {
assert!(outcome.failure.unwrap().contains("run diff unavailable"));
assert!(publisher.published.lock().unwrap().is_empty());
}
#[tokio::test]
async fn a_local_repository_without_a_git_target_gets_no_automatic_commits() {
let mut harness = Harness::new().await;
harness.source = None;
let graph = workflow(
r#" write [shape=parallelogram, script="git init -q && echo data > result.txt && git add result.txt && git -c user.name=User -c user.email=user@example.com commit -qm user-commit"]"#,
" start -> write -> exit",
);
assert_eq!(
harness.run(&graph, SETTINGS).await.status,
RunStatus::Success
);
let path = harness.workspace_path(&harness.workspace().await);
assert_eq!(git(&path, &["rev-list", "--count", "HEAD"]).await, "1");
assert_eq!(
git(&path, &["log", "--format=%s", "-1"]).await,
"user-commit"
);
assert!(harness.checkpoints().is_empty());
}