refactor(workflow): make rewind fork and archive

Rewind now creates a resumable replacement run from the selected checkpoint, archives the source run, and records run.superseded_by for auditability. Fork, rewind, and timeline listing now share server-backed git-store plumbing, with generated API clients and docs updated for the new contract.
This commit is contained in:
Bryan Helmkamp 2026-04-24 11:42:37 -04:00
parent 22f0f8122f
commit 4215ed3c16
No known key found for this signature in database
48 changed files with 4012 additions and 3575 deletions

View file

@ -798,6 +798,142 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/rewind:
post:
operationId: rewindRun
tags: [Runs]
summary: Rewind Run
description: >
Creates a new run from an earlier checkpoint of a terminal source run,
archives the source run, and records `run.superseded_by` on the source
after archive succeeds. Returns 207 when the new run was created but
the source archive step failed.
parameters:
- $ref: "#/components/parameters/RunId"
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/RewindRequest"
responses:
"200":
description: Source archived and new run created
content:
application/json:
schema:
$ref: "#/components/schemas/RewindResponse"
"207":
description: New run created but source archive failed
content:
application/json:
schema:
$ref: "#/components/schemas/RewindResponse"
"400":
description: Invalid rewind target
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Run not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Source run is archived or is not terminal
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"501":
description: Server cannot access the run working directory
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/fork:
post:
operationId: forkRun
tags: [Runs]
summary: Fork Run
description: >
Creates a new run from a checkpoint of the source run. The source run
is left untouched.
parameters:
- $ref: "#/components/parameters/RunId"
requestBody:
required: false
content:
application/json:
schema:
$ref: "#/components/schemas/ForkRequest"
responses:
"200":
description: New run created
content:
application/json:
schema:
$ref: "#/components/schemas/ForkResponse"
"400":
description: Invalid fork target
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Run not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Source run is archived
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"501":
description: Server cannot access the run working directory
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/timeline:
get:
operationId: getRunTimeline
tags: [Runs]
summary: Get Run Timeline
description: >
Returns checkpoint timeline entries read from the run metadata branch.
This endpoint does not rebuild missing metadata branches.
parameters:
- $ref: "#/components/parameters/RunId"
responses:
"200":
description: Run checkpoint timeline
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/TimelineEntryResponse"
"404":
description: Run not found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"501":
description: Server cannot access the run working directory
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/unarchive:
post:
operationId: unarchiveRun
@ -3885,6 +4021,26 @@ components:
additionalProperties: true
additionalProperties: true
RunSupersededByProps:
description: Properties for the `run.superseded_by` audit event emitted on a rewound source run after archive succeeds.
type: object
required:
- new_run_id
- target_checkpoint_ordinal
- target_node_id
- target_visit
properties:
new_run_id:
type: string
target_checkpoint_ordinal:
type: integer
minimum: 1
target_node_id:
type: string
target_visit:
type: integer
minimum: 1
EventSeq:
description: Assigned sequence number component of a stored event envelope.
type: object
@ -4174,6 +4330,8 @@ components:
pull_request:
type: ["object", "null"]
additionalProperties: true
superseded_by:
type: ["string", "null"]
pending_interviews:
type: object
additionalProperties:
@ -4235,6 +4393,84 @@ components:
total_usd_micros:
type: ["integer", "null"]
format: int64
superseded_by:
type: ["string", "null"]
ForkRequest:
description: Request body for creating a new run from a source run checkpoint.
type: object
properties:
target:
type: ["string", "null"]
description: Optional checkpoint target such as `@2`, `build`, or `build@1`. Defaults to the latest checkpoint.
push:
type: ["boolean", "null"]
description: Whether to push the new run branches. Defaults to true.
ForkResponse:
description: Response returned after creating a forked run.
type: object
required:
- source_run_id
- new_run_id
- target
properties:
source_run_id:
type: string
new_run_id:
type: string
target:
type: string
RewindRequest:
description: Request body for creating a replacement run from a source run checkpoint.
type: object
properties:
target:
type: ["string", "null"]
description: Optional checkpoint target such as `@2`, `build`, or `build@1`. Defaults to the latest checkpoint.
push:
type: ["boolean", "null"]
description: Whether to push the new run branches. Defaults to true.
RewindResponse:
description: Response returned after rewind creates a new run.
type: object
required:
- source_run_id
- new_run_id
- target
- archived
properties:
source_run_id:
type: string
new_run_id:
type: string
target:
type: string
archived:
type: boolean
archive_error:
type: ["string", "null"]
TimelineEntryResponse:
description: Checkpoint timeline entry for a run.
type: object
required:
- ordinal
- node_name
- visit
properties:
ordinal:
type: integer
minimum: 1
node_name:
type: string
visit:
type: integer
minimum: 1
run_commit_sha:
type: ["string", "null"]
# ── Run Board Schemas ────────────────────────────────────────────────

View file

@ -0,0 +1,17 @@
---
title: "Server-backed rewind and fork"
date: "2026-04-24"
---
## Server-backed rewind and fork
`fabro rewind` now creates a replacement run at the target checkpoint and archives the source run instead of mutating the source run in place. The command prints the new run ID:
```
fabro rewind <run-id> plan@2
fabro resume <new-run-id>
```
The source run records which run superseded it, so archived run history keeps a clear audit trail. Rewind requires the source to be terminal (`succeeded`, `failed`, or `dead`).
`fabro fork`, `fabro rewind`, and their `--list` modes now use the server API. The server must be able to read the run's recorded working directory. Timeline listing no longer rebuilds a missing metadata branch; if the metadata branch is gone, the timeline is empty until metadata is restored.

View file

@ -139,7 +139,7 @@ git show fabro/meta/01JKXYZ...:run.json | jq .checkpoint.current_node
## Rewinding to an earlier checkpoint
If a later stage goes off-track, you can rewind a run to an earlier checkpoint and resume from there instead of restarting the entire workflow:
If a later stage goes off-track, you can rewind a terminal run to an earlier checkpoint and resume from there instead of restarting the entire workflow. Rewind creates a replacement run at the target checkpoint, archives the source run, and prints the new run ID to resume:
```bash
# List the checkpoint timeline
@ -149,14 +149,16 @@ fabro rewind <RUN_ID> --list
fabro rewind <RUN_ID> plan@2
# Resume from the rewound point
fabro resume <RUN_ID>
fabro resume <NEW_RUN_ID>
```
The source run must already be terminal (`succeeded`, `failed`, or `dead`). If the source is archived, unarchive it first. If archive fails after the replacement run is created, do not retry `fabro rewind`; archive the source run manually.
See [`fabro rewind`](/reference/cli#fabro-rewind) for the full command reference.
## Forking a run
If you want to explore an alternate path from a checkpoint without losing the original run's history, use `fabro fork` instead of `fabro rewind`. Fork creates a new independent run branching from the target checkpoint the original run stays intact.
If you want to explore an alternate path from a checkpoint without archiving the original run, use `fabro fork` instead of `fabro rewind`. Fork creates a new independent run branching from the target checkpoint; the original run stays intact.
```bash
# List checkpoints
@ -169,7 +171,9 @@ fabro fork <RUN_ID> plan@2
fabro resume <NEW_RUN_ID>
```
Use **rewind** when you want to redo a run from an earlier point (destructive — resets the original). Use **fork** when you want to try a different approach while keeping the original run as a reference.
Use **rewind** when a terminal run should be abandoned and replaced from an earlier point. Use **fork** when you want to try a different approach while keeping the original run as a reference.
`fabro rewind --list`, `fabro fork --list`, `fabro rewind`, and `fabro fork` are server-backed. The server must be able to read the run's recorded `working_directory`; otherwise these commands fail. Timeline listing reads the metadata branch as-is and does not rebuild a missing metadata branch, so a missing branch appears as an empty timeline.
See [`fabro fork`](/reference/cli#fabro-fork) for the full command reference.

View file

@ -1,7 +1,7 @@
---
title: "refactor: Converge rewind into fork with archive-after"
type: refactor
status: active
status: completed
date: 2026-04-23
---
@ -37,19 +37,19 @@ This convergence was brainstormed conversationally on 2026-04-23 (no formal `doc
**CLI Behavior (Preserved & Changed)**
- R2. `fabro rewind <ID> <target>` archives the source run and returns a new RunId initialized at the target checkpoint.
- R3. `fabro fork <ID> [target]` continues to leave the source run untouched.
- R4. The `--list` and `--no-push` flags continue to work on both commands with unchanged semantics.
- R4. The `--list` and `--no-push` flags continue to work on both commands. `--no-push` has unchanged semantics (pure flag passthrough). `--list` is preserved as a capability but its implementation moves server-side — the endpoint wraps `build_timeline` (not `build_timeline_or_rebuild`), so a run with a missing metadata branch returns an empty timeline from the server where today's CLI would rebuild it. See Unit 2 for the accepted rebuild regression.
**Cleanup & Deletion**
- R5. `RunRewound` event, `RunRewoundProps`, `reset_for_rewind`, and the rewind-specific `ensure_not_archived` usage are removed from the codebase. The greenfield constraint lets us delete rather than deprecate.
**Regression Prevention**
- R6. No regression in timeline resolution (ordinal `@N`, `node`, `node@N`) or parallel-interior handling.
- R7. User-facing documentation that currently teaches in-place-rewind semantics is updated to match the new behavior (see Unit 5).
- R7. User-facing documentation that currently teaches in-place-rewind semantics is updated to match the new behavior (see Unit 6).
## Scope Boundaries
- **Not** adding provenance fields (`forked_from: Option<RunId>`) on forked runs. Covered for rewind by `RunSupersededBy` on the source; adding symmetric provenance on the new run is a separate follow-up covering both fork and rewind.
- **Not** changing fork's CLI surface. `ForkRunInput` and the `fabro fork` CLI continue to work unchanged. Correction from earlier plan text: **`POST /runs/{id}/fork` does not exist today** — fork is CLI-only, operating directly on the local git `Store`. Unit 2 therefore introduces the first git-touching HTTP endpoint in `fabro-server`; there is no ForkResponse shape to align RewindResponse with.
- Fork's surface IS changing: `POST /runs/{id}/fork` is added in Unit 2 alongside `POST /runs/{id}/rewind`, and `fabro fork` becomes a thin CLI wrapper over the new endpoint (mirrors the rewind split). Rationale: consistency (both are mutating git operations) and remote-client support. Unit 2 introduces the first git-touching HTTP endpoints in `fabro-server` and establishes the shared "open git Store from run's `working_directory`" pattern used by rewind, fork, and timeline endpoints.
- **Not** changing `build_timeline_or_rebuild` behavior or the rebuild-from-events path. The new `GET /runs/{id}/timeline` endpoint wraps `build_timeline`; it does not modify the underlying function.
- **Not** migrating stored `RunRewound` events — greenfield, no deployed instances.
- **Not** widening `operations::archive`'s precondition. Rewind inherits the "terminal status required" rule; non-terminal sources (Paused, Blocked, Running, etc.) must be canceled or allowed to finish before they can be rewound. This is a deliberate narrowing from today's behavior — see User Decisions log.
@ -75,15 +75,20 @@ Not needed. This is an internal refactor with no external contract surfaces; tim
## Key Technical Decisions
- **Rewind becomes a server-side composite endpoint, not a CLI orchestration.** Add `POST /runs/{id}/rewind` to the fabro-api server. The handler:
1. Loads source status from the projection store
2. Pre-checks terminal state (rejects Running/Paused/Blocked/etc. with a clear 409 Conflict before any git work)
3. Calls `operations::fork()` synchronously (git branch creation)
4. Appends `RunSupersededBy { new_run_id }` to the source's event stream (async database write)
5. Transitions source via `operations::archive()` (reuses existing archive logic)
6. Returns `{ source_run_id, new_run_id, target, archived: true }`
- **Both rewind and fork become server-side endpoints.** Add `POST /runs/{id}/rewind` AND `POST /runs/{id}/fork` to the fabro-api server. Rationale for moving fork alongside rewind: architectural consistency (both are mutating git operations; keeping one CLI-only and the other server-side creates a split that the second reviewer rightly flagged) and a single HTTP surface for future web-UI consumers. The rewind handler composes fork + archive + event append; the fork handler is a simpler wrapper around `operations::fork()`. Both share the working_directory/git-Store machinery. **Limitation honestly acknowledged:** both endpoints still require the server to have filesystem access to the run's original `working_directory` (a durable field on `RunSpec`). There is no override mechanism in this plan; 501 is a hard failure for runs whose original path isn't server-accessible. A truly-remote scenario (CLI, server, and repo on different hosts) is NOT solved by this plan — that requires a future follow-up (override mechanism, rebuild endpoint, or server-side checkout).
Rationale: user explicitly chose the server-side composite endpoint over CLI orchestration. Benefits: atomicity from the client's perspective, a single audit event on the source (`RunSupersededBy`) answers "why is this archived?" directly, and a future web UI has a single endpoint to call. The async/sync boundary is internal to the handler — `fork()` stays sync; the event append and archive call are async. Pre-check before fork avoids orphan runs on precondition failure; graceful degradation on post-fork archive failure is handled in Unit 3's error path. Does introduce a new endpoint that needs OpenAPI spec + progenitor regeneration.
Rewind handler flow:
1. Reject if already archived (409 via `reject_if_archived`)
2. Load source status; reject with 409 Conflict if not terminal (Succeeded/Failed/Dead)
3. Open git Store from `spec.working_directory` (501 if inaccessible)
4. spawn_blocking: call `operations::fork()` → new_run_id
5. Call `operations::archive(source)` FIRST
6. On archive OK → append `RunSupersededBy { new_run_id }` to source (only-on-archive-success invariant) → return 200
7. On archive Err → return 207 Multi-Status with `archived: false, archive_error`; do NOT append RunSupersededBy
Fork handler flow: steps 34 only; **emits no source-side event** (source run is untouched per R3). Returns `{ source_run_id, new_run_id, target }`. Provenance on the new run lives implicitly in its branch contents; a future `RunForkedFrom`-style audit event is Deferred to Follow-Up (applies symmetrically to fork + rewind).
Benefits: consistent architecture, remote-client support via HTTP, atomicity (rewind), single audit event on source (rewind). Costs: two new endpoints (request/response types, OpenAPI additions, client wrappers), the 501 failure mode applies to both. Does introduce a new boundary in fabro-server: opening a git Store from inside a handler. Establishes the spawn_blocking + per-run-working_directory pattern for future server-side git work.
- **Add `RunSupersededBy { new_run_id }` event (supersedes deprecated `RunRewound`).** Lives in `fabro-types::EventBody` and the `fabro-workflow::Event` enum. Emitted on the source run only, by the rewind endpoint, AFTER `operations::archive` succeeds. Projection arm on `run_state.rs` sets `superseded_by: Option<RunId>` on `RunProjection` so consumers can answer "what replaced this run?" with a single projection read (no event-log replay). Rationale: audit trail was the primary justification for the server-side endpoint; the projection field makes that audit first-class for UI/CLI consumers.
@ -132,6 +137,19 @@ Not needed. This is an internal refactor with no external contract surfaces; tim
- **Status code convention (412 vs 409)?****Use 409 Conflict** for both archived-source and non-terminal-source rejections. Matches fabro-server's consistent use of `StatusCode::CONFLICT`; error message disambiguates the two cases. No 412 in this plan.
- **Server-side timeline/list endpoint?****Add `GET /runs/{id}/timeline` to Unit 2.** Matches the mutating-rewind server-side move for web-UI parity; shares the working_directory/git-Store machinery with the rewind endpoint. CLI `--list` calls this endpoint instead of reading local git state.
**Decisions from the third external review (2026-04-24):**
- **Retry-after-partial-success mitigation text?****Rewritten to unambiguously forbid rewind-retry.** The risk-table mitigation now explicitly says "run `fabro archive <source>` manually; do NOT retry `fabro rewind`." Fork mints a fresh RunId each call; retry would orphan another run.
- **Key Technical Decisions handler-steps ordering?****Flipped to match the rest of the plan.** High-level summary now shows archive-first, RunSupersededBy-on-success, matching the detailed handler section and the User Decisions log.
- **Timeline rebuild semantics?****Accept the regression; document it.** Server endpoint wraps `build_timeline` only, not `build_timeline_or_rebuild`. Documented in Unit 2's timeline endpoint description and in Unit 6 (user docs). Follow-up issue for either server-side rebuild or an explicit rebuild endpoint.
- **`summary_to_api_run_summary` wire-level serializer?** → **Added to Unit 2 file list.** The function at `server.rs:2611` manually builds JSON for `fabro ps` consumers; without editing it, `superseded_by` would exist in types/OpenAPI but never reach the wire.
- **Fork architectural consistency (CLI-only vs server-side)?****Move fork server-side too.** Adds `POST /runs/{id}/fork` alongside rewind. Resolves the architectural split the reviewer flagged. Expands Unit 2 scope by one endpoint; Unit 3 now rewrites both `fabro rewind` and `fabro fork` as thin wrappers. Note: the original "supports remote CLI + remote server + remote repo scenarios" framing was overstated (see next review); the server still requires filesystem access to the run's stored `working_directory`.
**Decisions from the fourth external review (2026-04-24):**
- **501 recovery mechanism?****Accept as unrecoverable in this plan.** No override, no CLI-local fallback. The server must have filesystem access to the run's stored `working_directory` (a durable `RunSpec` field); if it can't, 501 is a hard failure. The earlier "checkout-to-local-path-and-retry" guidance was wrong — a caller-side checkout at a different path doesn't change the stored value. The "remote CLI/server/repo on different hosts" benefit claim is walked back. Remote/sandbox recovery is a follow-up concern (override mechanism, rebuild endpoint, or server-side checkout).
- **Fork source-side audit event?****None in this plan.** Fork emits no source-side event (source is untouched per R3). Provenance on the new run lives implicitly in its branch contents. `RunForkedFrom` is Deferred to Follow-Up as a symmetric audit event applying to both fork and rewind.
- **Outside-git tests?****Rewrite or delete.** `rewind_outside_git_repo_errors` and `fork_outside_git_repo_errors` asserted a local-git precondition that no longer applies (both mutate paths + `--list` now go through the server). Unit 5 instructs either deletion or rewrite to assert a different failure condition.
- **Scope summary + Unit 6 docs under-scoped?****Expanded.** Fork's user-facing docs (checkpoints.mdx:159, cli.mdx:582) are added to Unit 6's file list; the shared 501 limitation is documented once. API surface parity section now enumerates all three endpoints, both request/response schema pairs, the RunSummary field, and the new event wiring.
### Deferred to Implementation
- **Exact module visibility of timeline helpers.** Some helpers (`run_commit_shas_by_node`, `find_run_id_by_prefix_opt`) are `pub(crate)` or `pub(super)` today. Reclassify during the move based on who imports from outside `operations::`.
@ -169,32 +187,35 @@ After convergence:
fabro rewind <ID> @3 fabro fork <ID> [@3]
| |
v v
rewind CLI handler (thin) fork CLI handler
- --list: client.run_timeline(id) - build_timeline (local git)
- mutate: client.rewind_run(id,...) - fork() op (local git)
| - print "Forked X -> Y"
v
POST /runs/{id}/rewind (server)
- reject_if_archived (409 if archived)
- load RunSpec, check terminal status (409 if non-terminal)
- open git Store at spec.working_directory
- spawn_blocking: fork() op <---------- same fork() op
(501 if working_dir inaccessible)
- operations::archive(source) [FIRST]
- on archive OK: append RunSupersededBy [SECOND, only if archive succeeded]
- return 200 (archive ok) | 207 (archive failed; archived:false, no supersede)
rewind CLI (thin) fork CLI (thin)
- --list: client.run_timeline(id) - --list: client.run_timeline(id)
- mutate: client.rewind_run(...) - mutate: client.fork_run(...)
| |
+----- both CLIs are ----------------+
| thin HTTP clients |
v v
POST /runs/{id}/rewind (server) POST /runs/{id}/fork (server)
- reject_if_archived (409) - reject_if_archived (409)
- check terminal status (409) - open git Store at working_dir
- open git Store at working_dir - spawn_blocking: fork() op (501 if inacc)
- spawn_blocking: fork() op - return 200 { source, new, target }
(501 if working_dir inacc)
- archive(source) [FIRST]
- on archive OK: append RunSupersededBy [SECOND, only on success]
- return 200 (archive ok) | 207 (archive failed; no supersede)
GET /runs/{id}/timeline (server)
- open git Store at spec.working_directory
- spawn_blocking: build_timeline
- return 200 with Vec<TimelineEntryResponse> (501 if working_dir inaccessible)
- open git Store at working_dir (501 if inacc)
- spawn_blocking: build_timeline (NOT _or_rebuild — see Unit 2 note)
- return 200 with Vec<TimelineEntryResponse>
```
The shared `fork()` op is the only code that creates runs, moves refs, or writes metadata snapshots. Rewind's differentiator is a server-side composite endpoint that adds a source-status pre-check, appends `RunSupersededBy` for audit, and archives the source. Fork continues to work exactly as today.
## Implementation Units
- [ ] **Unit 1: Extract timeline module and rename RewindTarget → ForkTarget**
- [x] **Unit 1: Extract timeline module and rename RewindTarget → ForkTarget**
**Goal:** Move all timeline-reading logic out of `operations/rewind.rs` into a new `operations/timeline.rs` module. Rename `RewindTarget` to `ForkTarget` in the same pass so downstream callers update once.
@ -214,7 +235,7 @@ The shared `fork()` op is the only code that creates runs, moves refs, or writes
**Approach:**
- Symbols to move verbatim into `timeline.rs`: `RewindTarget` (renamed `ForkTarget`), `TimelineEntry`, `RunTimeline`, `build_timeline`, `backfill_run_shas`, `run_commit_shas_by_node`, `detect_parallel_interior`, `find_run_id_by_prefix`, `find_run_id_by_prefix_opt`, `load_parallel_map`, `read_projection_at_commit`
- Symbols that stay in `rewind.rs` for Unit 3 deletion: `RewindInput`, `rewind()`, `rewind_to_entry()`
- Symbols that stay in `rewind.rs` (will be rewritten in Unit 2, not deleted): `RewindInput` (repurposed with new fields), `rewind()` (repurposed as async composite). `rewind_to_entry()` is deleted entirely in Unit 4 — it has no equivalent in the new design.
- The existing `#[cfg(test)] mod tests` block in `rewind.rs` splits: timeline-parsing and resolution tests (`parse_target_ordinal`, `parse_target_latest_visit`, `build_timeline_simple`, `resolve_latest_visit`, `parallel_interior_detection`, `find_run_id_prefix_match`) move to `timeline.rs`; rewind-specific tests (`rewind_moves_metadata_ref`, `rewind_rejects_archived_runs`) stay for Unit 3 deletion.
- Visibility: `find_run_id_by_prefix_opt` is `pub(super)` today — keep `pub(super)` so it's reachable from `rebuild_meta.rs`. Adjust if rustc complains.
@ -232,9 +253,9 @@ The shared `fork()` op is the only code that creates runs, moves refs, or writes
- `rg "use .*rewind::(RewindTarget|TimelineEntry|RunTimeline|build_timeline|find_run_id_by_prefix)"` returns no matches — all call sites now import from `timeline`.
- Clippy passes: `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`.
- [ ] **Unit 2: Add `RunSupersededBy` event, `POST /runs/{id}/rewind`, and `GET /runs/{id}/timeline` server endpoints**
- [x] **Unit 2: Add `RunSupersededBy` event and three server endpoints (`POST /runs/{id}/rewind`, `POST /runs/{id}/fork`, `GET /runs/{id}/timeline`)**
**Goal:** Introduce the new audit event and the two server-side endpoints (mutating rewind + read-side timeline). Both endpoints share the "open git Store from run's working_directory" machinery introduced here; solving that once enables the timeline endpoint essentially for free. Web-UI parity requires both.
**Goal:** Introduce the new audit event and the three server-side endpoints. All three share the "open git Store from run's `working_directory` inside `spawn_blocking`" machinery introduced here — solving that once enables all three. Moving both rewind AND fork server-side gives the architecture the consistency the second external review flagged; web UI consumers get a single HTTP surface. This does NOT solve the truly-remote scenario (CLI, server, and repo on different hosts) — the server must still have filesystem access to the run's original `working_directory`. That's a follow-up concern.
**Requirements:** R1 (single codepath), R2 (archive source + new RunId)
@ -246,16 +267,29 @@ The shared `fork()` op is the only code that creates runs, moves refs, or writes
- Modify: `lib/crates/fabro-workflow/src/event.rs` — add `Event::RunSupersededBy { new_run_id, target_checkpoint_ordinal, target_node_id, target_visit }` variant, logging arm, discriminant, and `EventBody` conversion. Model on the existing `Event::RunRewound` shape (being deleted in Unit 5).
- Modify: `lib/crates/fabro-types/src/run_projection.rs` — add `pub superseded_by: Option<RunId>` field to `RunProjection`, serde-defaulted to `None`.
- Modify: `lib/crates/fabro-store/src/run_state.rs` — add `EventBody::RunSupersededBy(props) => self.superseded_by = Some(props.new_run_id);` arm. Single-line projection update; source's archived-status transition still comes from the separate `RunArchived` event per normal lifecycle.
- Modify: `lib/crates/fabro-types/src/run_summary.rs` — add `pub superseded_by: Option<RunId>` field to `RunSummary` (serde-defaulted). This is the type exposed by list endpoints (`fabro ps`, web list views), so plumbing the field here is what makes the "fabro ps shows superseded" claim honest.
- Modify: `lib/crates/fabro-types/src/run_summary.rs` — add `pub superseded_by: Option<RunId>` field to `RunSummary` (serde-defaulted).
- Modify: the projection→summary mapping (exact file TBD — check `fabro-server` or `fabro-store` for where `RunSummary` is built from `RunProjection`; set `summary.superseded_by = projection.superseded_by`).
- Modify: `docs/api-reference/fabro-api.yaml` around the `RunSummary` schema definition (~line 3943) — add the `superseded_by` property. Also add the new `POST /runs/{id}/rewind` path, `RewindRequest`/`RewindResponse` schemas, `RunSupersededByProps` event schema, `"run.superseded_by"` in the event-name enum, and the new `GET /runs/{id}/timeline` path + `TimelineEntryResponse` schema (see Unit 2 timeline endpoint below).
- Modify: `docs/api-reference/fabro-api.yaml` — add a new `RewindRequest` schema (with `target: Option<String>`, `push: Option<bool>` defaulting to true), a new `RewindResponse` schema (`{ source_run_id, new_run_id, target, archived, archive_error?: String }`), and a `POST /runs/{id}/rewind` path. Register `"run.superseded_by"` as an allowable event name in the SSE schema if that enum exists there.
- Create: `pub async fn rewind(...) -> Result<RewindOutcome, Error>` in `lib/crates/fabro-workflow/src/operations/rewind.rs`. This is the file's new contents — replaces the old in-place-rewind function (which is deleted in Unit 4 by virtue of not being reintroduced). Mirror the signature style of `operations::archive`. The function composes `operations::fork` (inside a `spawn_blocking` block) + `operations::archive` + `RunSupersededBy` event append (archive-first-then-supersede, only-on-archive-success).
- Modify: **`lib/crates/fabro-server/src/server.rs` `summary_to_api_run_summary` (line 2611)** — this function manually builds the JSON response shape via `serde_json::json!{...}` and is the wire-level serializer used by `fabro ps` (called from `:2758` and `:4874`). Without adding `"superseded_by": summary.superseded_by` to the emitted JSON, the field exists in storage/types/OpenAPI but never reaches clients. This was missed in the prior plan revision.
- Modify: `docs/api-reference/fabro-api.yaml` — single consolidated set of additions (all three endpoints + shared types):
- `POST /runs/{id}/rewind` path with:
- `RewindRequest` schema: `{ target: Option<String>, push: Option<bool> }` (push defaults to true server-side)
- `RewindResponse` schema: `{ source_run_id, new_run_id, target, archived, archive_error?: String }`
- `POST /runs/{id}/fork` path with:
- `ForkRequest` schema: `{ target: Option<String>, push: Option<bool> }` (matches current CLI which sends `push: !args.no_push` at `lib/crates/fabro-cli/src/commands/run/fork.rs:42`)
- `ForkResponse` schema: `{ source_run_id, new_run_id, target }` (no archived field — fork doesn't archive)
- `GET /runs/{id}/timeline` path with `TimelineEntryResponse` schema matching today's `TimelineEntry` shape (ordinal, node_name, visit, run_commit_sha).
- `RunSupersededByProps` event schema and `"run.superseded_by"` added to the SSE event-name enum.
- `superseded_by: Option<RunId>` property added to the `RunSummary` schema (around line 3943).
- Create: `pub async fn rewind(&Database, &GitStoreFactory, &RewindInput, Option<ActorRef>) -> Result<RewindOutcome, Error>` in `lib/crates/fabro-workflow/src/operations/rewind.rs`. This is the file's new contents — replaces the old in-place-rewind function. Mirror the signature style of `operations::archive`. The function composes `operations::fork` (inside a `spawn_blocking` block) + `operations::archive` + `RunSupersededBy` event append (archive-first-then-supersede, only-on-archive-success).
- Rewrite the `RewindInput` struct in the same file. New fields: `{ run_id: RunId, target: Option<ForkTarget>, push: bool }`. Note the removed field: `current_status` is gone — the composite op loads status from the projection itself. The type name is preserved for call-site stability.
- Add a `RewindOutcome` enum: `Full { new_run_id: RunId, archived: bool }` (archive succeeded) and `Partial { new_run_id: RunId, archive_error: String }` (archive failed post-fork). Handler maps to 200/207 respectively.
- Create: server handler in `lib/crates/fabro-server/src/server.rs` — thin `async fn rewind_run(...)` delegator into `operations::rewind`, matching the 4-line pattern of `archive_run` (line 6448). Add route `.route("/runs/{id}/rewind", post(rewind_run))` next to `archive_run` / `unarchive_run` (see lines 1086-1087).
- Create: `pub async fn timeline(...) -> Result<Vec<TimelineEntry>, Error>` in `lib/crates/fabro-workflow/src/operations/timeline.rs` (the new module from Unit 1). This is an async wrapper around the existing sync `build_timeline` — opens the git Store from the run's working_directory (same pattern as the rewind endpoint) inside `spawn_blocking`.
- Create: `pub async fn fork(...)` in a new `lib/crates/fabro-workflow/src/operations/fork_op.rs` or repurpose existing `operations/fork.rs` into an async wrapper. The sync `fork()` function becomes the inner (called inside `spawn_blocking`) and the new async `fork()` handles store opening + working_directory lookup + error mapping.
- Create: server handler `async fn fork_run(...)` in `server.rs`. Add route `.route("/runs/{id}/fork", post(fork_run))`. Returns `{ source_run_id, new_run_id, target }`. Status codes: 200 success, 400 bad target, 404 unknown run, 409 source archived, 501 working_directory inaccessible. **Fork emits no source-side event** (source is untouched per R3); provenance lives in the new run's branch contents only. A symmetric `RunForkedFrom` audit event is Deferred to Follow-Up.
- Create: `pub async fn timeline(...) -> Result<Vec<TimelineEntry>, Error>` in `lib/crates/fabro-workflow/src/operations/timeline.rs` (the new module from Unit 1). Async wrapper around the existing sync `build_timeline` — opens the git Store from the run's working_directory inside `spawn_blocking`. **Known regression accepted by this plan:** the server endpoint wraps `build_timeline` only, NOT `build_timeline_or_rebuild` (the rebuild-from-events fallback today's CLI uses at `lib/crates/fabro-workflow/src/operations/rebuild_meta.rs:124`). A run with a missing metadata branch will show an empty timeline from the server endpoint where today's CLI would rebuild it. Document in Unit 6 (user docs). Follow-up: either move rebuild server-side too, or add a `POST /runs/{id}/rebuild` endpoint users can call explicitly when they hit "no checkpoints found."
- Create: server handler in `lib/crates/fabro-server/src/server.rs` — thin `async fn run_timeline(...)` delegator. Add route `.route("/runs/{id}/timeline", get(run_timeline))`. Status codes: 200 with `Vec<TimelineEntryResponse>` on success; 404 for unknown run; 501 for inaccessible working_directory.
- Modify: `lib/crates/fabro-workflow/src/event.rs` — append_event support for `RunSupersededBy` via existing event append pathway.
- Modify: `lib/crates/fabro-client/src/client.rs` — add hand-written wrappers for both new endpoints (`rewind_run`, `run_timeline`) following the archive_run wrapper pattern.
- Modify: `lib/crates/fabro-client/src/client.rs` — add hand-written wrappers for all three new endpoints (`rewind_run`, `fork_run`, `run_timeline`) following the archive_run wrapper pattern.
- Test: unit tests for `operations::rewind` and `operations::timeline` in their respective test modules (axum-free, covers composite branches including the 501/working_directory-inaccessible path); plus thin handler tests for HTTP-layer behavior following existing archive/unarchive test patterns.
**Approach:**
@ -263,7 +297,7 @@ The shared `fork()` op is the only code that creates runs, moves refs, or writes
1. Parse run ID from path; reject if archived (via `reject_if_archived`, mirrors archive/unarchive).
2. Read body → `RewindRequest { target: Option<String>, push: Option<bool> }`.
3. Load source status from projection; reject with **409 Conflict** if not `Succeeded/Failed/Dead` (matches fabro-server's consistent use of `StatusCode::CONFLICT` for state preconditions — see multiple callers in `server.rs`). Include the canonical precondition message.
4. **Open the git `Store` by looking up the run's working_directory.** `AppState` has no global `repo_path` — confirmed by grep: `pub struct AppState` at `server.rs:539` has no repo field. The handler loads the run's `RunSpec` from the projection store, reads `spec.working_directory` (`lib/crates/fabro-types/src/run.rs:58`), and opens a git `Store` at that path. **New precondition:** the server process must have filesystem access to the run's `working_directory`. If the path doesn't exist, isn't a git repo, or isn't accessible (e.g., the run was launched in a Daytona sandbox or on a remote worker whose filesystem isn't shared with the server), return **501 Not Implemented** with a message directing the user to the CLI rewind command for non-local runs. Exact error shape deferred to implementation, but this failure mode is documented in the error-path test scenarios. The Store must be Send + 'static so the whole git block can run inside `spawn_blocking`.
4. **Open the git `Store` by looking up the run's working_directory.** `AppState` has no global `repo_path` — confirmed by grep: `pub struct AppState` at `server.rs:539` has no repo field. The handler loads the run's `RunSpec` from the projection store, reads `spec.working_directory` (`lib/crates/fabro-types/src/run.rs:58`), and opens a git `Store` at that path. **New precondition:** the server process must have filesystem access to the run's `working_directory`. If the path doesn't exist, isn't a git repo, or isn't accessible (e.g., the run was launched in a Daytona sandbox or on a remote worker whose filesystem isn't shared with the server), return **501 Not Implemented** with an honest error message explaining the limitation — NOT a "use the CLI" fallback suggestion (the CLI is a thin wrapper around this same endpoint; there is no CLI-local rewind/fork implementation after Unit 3). The Store must be Send + 'static so the whole git block can run inside `spawn_blocking`.
5. **Wrap steps 56 in `tokio::task::spawn_blocking`**`operations::fork` does sync libgit2 work including potential remote push, which can block for seconds. Precedent: `spawn_blocking` is the established pattern in `server.rs` (lines 1291, 1331, 1674, 1711, 4564). Running `fork()` directly on the async runtime stalls Tokio workers under load. The spawn_blocking return should carry the new_run_id back to async context.
6. Inside spawn_blocking: build timeline (sync), resolve target (`None` defaults to latest checkpoint), call `operations::fork(...)``new_run_id`.
7. Back on the async runtime: call `operations::archive(&state.store, &id, actor)` FIRST.
@ -322,7 +356,7 @@ struct RewindResponse {
- Edge case: source status changes between pre-check and archive (TOCTOU race, simulate with a concurrent event append) → archive returns `Err(Precondition)`; endpoint returns 207 (same shape as transport failure), NOT 500. Source event log does NOT carry `RunSupersededBy`.
- Edge case: `RunSupersededBy` append fails after archive succeeds (simulate storage error) → response is still 200 with `archived: true`; source is cleanly archived but provenance is missing in its event log. Log the append failure prominently; this is a repairable degradation.
- Error path: source already archived → `reject_if_archived` returns 409 before handler business logic runs; no fork attempt.
- Error path: source `working_directory` is not accessible (simulate by passing a path the server can't stat) → 501 Not Implemented with guidance to use the CLI rewind command.
- Error path: source `working_directory` is not accessible (simulate by passing a path the server can't stat) → 501 Not Implemented with an honest error message explaining the limitation (no "use the CLI" fallback — the CLI is now a thin HTTP wrapper).
- Integration: full CLI → server → git path in a CLI-level or scenario test (covered in Unit 5).
**Verification:**
@ -331,30 +365,42 @@ struct RewindResponse {
- Conformance test `fabro-server` run-catches-spec-drift (per CLAUDE.md API workflow) passes.
- `rg -n 'run\.superseded_by' lib/crates/ docs/api-reference/` finds matching wire identifiers in at least `fabro-types`, `fabro-workflow`, and `fabro-api.yaml`.
- [ ] **Unit 3: Rewrite `fabro rewind` CLI as a thin wrapper around the new endpoint**
- [x] **Unit 3: Rewrite both `fabro rewind` and `fabro fork` CLIs as thin wrappers around the new server endpoints**
**Goal:** Replace the current in-place rewind logic in the CLI handler with a single call to the new server endpoint, plus timeline-listing and output formatting. Output text continues to use "rewind" vocabulary.
**Goal:** Replace both CLIs' local git logic with calls to the new server endpoints, plus timeline-listing and output formatting. Output text continues to use "rewind"/"fork" vocabulary. Both CLI commands become small adapters: parse args, call the endpoint, render the response.
**Requirements:** R2, R4 (`--list` / `--no-push` unchanged)
**Requirements:** R2 (rewind new behavior), R3 (fork unchanged user-facing behavior), R4 (`--no-push` unchanged; `--list` preserved as a capability with the accepted rebuild regression)
**Dependencies:** Units 1 and 2 (needs `ForkTarget` in scope, needs the server endpoint and generated client method).
**Dependencies:** Units 1 and 2 (needs `ForkTarget`, all three server endpoints, and generated client methods).
**Files:**
- Modify: `lib/crates/fabro-cli/src/commands/run/rewind.rs` (full rewrite)
- Modify: `lib/crates/fabro-client/src/client.rs` — add hand-written wrapper `pub async fn rewind_run(&self, run_id: &RunId, req: &RewindRequest) -> Result<RewindResponse>` matching the style of existing `archive_run`/`unarchive_run` wrappers around the progenitor-generated call.
- Test: `lib/crates/fabro-cli/tests/it/cmd/rewind.rs` (assertions rewritten in Unit 5)
- Modify: `lib/crates/fabro-cli/src/commands/run/fork.rs` (full rewrite — becomes a thin wrapper over `client.fork_run`)
- Modify: `lib/crates/fabro-client/src/client.rs` — wrappers added in Unit 2 (`rewind_run`, `fork_run`, `run_timeline`); Unit 3 just consumes them.
- Test: `lib/crates/fabro-cli/tests/it/cmd/rewind.rs` and `lib/crates/fabro-cli/tests/it/cmd/fork.rs` (assertions rewritten in Unit 5)
**Approach:**
- Mirror the shape of `lib/crates/fabro-cli/src/commands/run/fork.rs` for origin validation, but:
- `--list` path: call `client.run_timeline(&run_id)` (the new endpoint) instead of reading local git state. This matches the mutating rewind's server-side move. Falls back gracefully with a helpful message if the endpoint returns 501 — but the common case (local runs) works through the server.
- Non-list path: parse target, build `RewindRequest`, call `client.rewind_run(&run_id, &req)`, handle response.
- Delete the helpers `reset_rewound_run_state`, `restored_checkpoint_event`, `run_event` (and their `RunRewoundProps`/`CheckpointCompletedProps`/`RunSubmittedProps` imports). They have no consumer after this unit.
- Keep `print_timeline` and `timeline_entries_json``fork.rs` imports them; they now format data that arrived from the server, not data built locally.
- Output text format: `"Rewound {source[:8]}; new run {new[:8]}"` followed by `"To resume: fabro resume {new[:8]}"`. On HTTP 207 (`archived == false`), also print `"Warning: source not archived: {archive_error}. Run `fabro archive {source}` to finish."` so the user knows the source is still terminal-but-not-archived and how to clean up.
- JSON output: echo `response` shape plus the HTTP status code so scripts can branch on 200 vs 207 without re-parsing.
- **Retry posture: single-shot.** The CLI does NOT auto-retry `POST /rewind` on network error, timeout, or 5xx. On any non-response failure, print `"Network error during rewind. Check server state with 'fabro ps' before retrying — the rewind may have succeeded."`. Rationale: fork mints a fresh RunId each call, so naive retry creates orphans. See "Retry semantics" key decision.
- CLI no longer calls `fork()` directly; that's entirely server-side now.
- Git `Store` access stays CLI-side for the `--list` path (timeline display reads local git state). Origin validation (`ensure_matching_repo_origin`) still runs client-side.
**Approach — shared for both commands:**
- Delete the CLI-side helpers `reset_rewound_run_state`, `restored_checkpoint_event`, `run_event` (and their `RunRewoundProps`/`CheckpointCompletedProps`/`RunSubmittedProps` imports) from `rewind.rs`. They have no consumer after this unit.
- Keep `print_timeline` and `timeline_entries_json` — both CLIs use them; they now format data that arrived from the server, not data built locally.
- Both CLIs no longer call `operations::fork` or any git op directly; all git work is server-side.
- `--list` on both commands calls `GET /runs/{id}/timeline` rather than reading local git state.
- Origin validation (`ensure_matching_repo_origin`) still runs client-side when the CLI has local repo access; when it doesn't (fully-remote CLI), origin validation is skipped and the server's view is trusted.
- **Retry posture: single-shot for both mutate paths.** Neither `fabro rewind` nor `fabro fork` auto-retries on network error, timeout, or 5xx. On non-response failure, print a "check server state" message. Rationale: both endpoints mint fresh RunIds on each call; naive retry creates orphans.
**Approach — `fabro rewind` specifics:**
- `--list`: call `client.run_timeline(&run_id)` and format with `print_timeline` / `timeline_entries_json`.
- Mutate: parse target, build `RewindRequest { target, push: !args.no_push }`, call `client.rewind_run(&run_id, &req)`, handle 200 vs 207.
- Output text on 200: `"Rewound {source[:8]}; new run {new[:8]}"` followed by `"To resume: fabro resume {new[:8]}"`.
- Output text on 207: in addition to the above, print `"Warning: source not archived: {archive_error}. Run `fabro archive {source}` to finish."` — single-shot retry policy applies; do NOT auto-retry rewind.
- JSON output: echo the `RewindResponse` shape plus the HTTP status code so scripts can branch on 200 vs 207 without re-parsing.
**Approach — `fabro fork` specifics:**
- `--list`: call `client.run_timeline(&run_id)` (same endpoint as rewind's --list path). Format identically.
- Mutate: parse target, build `ForkRequest { target, push: !args.no_push }`, call `client.fork_run(&run_id, &req)`, handle response.
- Output text on 200: preserve today's fork message pattern — `"Forked {source[:8]} -> {new[:8]}"` followed by `"To resume: fabro resume {new[:8]}"` (matches current snapshot in `tests/it/cmd/fork.rs:57`).
- JSON output: echo the `ForkResponse` shape `{ source_run_id, new_run_id, target }` plus the HTTP status code.
- No partial-success case — fork doesn't archive, so there's no equivalent of the 207 path. Error paths: 400 bad target, 404 unknown run, 409 source archived, 501 working_directory inaccessible.
- `--no-push` translates to `push: false` in the request body (today's CLI does the same via `push: !args.no_push` at `fork.rs:42`); server honors it.
**Patterns to follow:**
- `lib/crates/fabro-cli/src/commands/run/fork.rs` — same shape for `--list` path.
@ -371,16 +417,28 @@ struct RewindResponse {
- Error path: source run is still running or paused → server returns **409 Conflict** with "must be terminal" message; CLI prints it clearly; no new run anywhere.
- Error path: source already archived → server returns 409 Conflict; CLI prints "run is archived; run `fabro unarchive` first and retry"; no new run.
- Edge case: server returns 207 Multi-Status with `archived: false, archive_error: "..."` → CLI prints the new RunId, the archive-failure warning with the `fabro archive <source>` hint, and exits 0 so scripts can still pick up the new RunId.
- Edge case: server returns 501 Not Implemented (working_directory inaccessible) → CLI prints a clear message suggesting checkout-to-local-path-and-retry; exits non-zero.
- Edge case: server returns 501 Not Implemented (working_directory inaccessible) → CLI prints a clear error: `"Server cannot access this run's working_directory. This is a hard limitation in the current release; a future version may support an override or rebuild mechanism."` Exits non-zero. No retry guidance — retrying won't help.
- Edge case: network error or timeout during POST /rewind → CLI exits non-zero with the "check server state" message; does NOT auto-retry.
- Integration: after `rewind <ID> @2`, `fabro ps` shows source as Archived and the new RunId present and resumable.
- Integration: after `rewind <ID> @2`, `fabro ps` shows source as Archived with `superseded_by = new_run_id`, and the new RunId is resumable.
**Test scenarios — `fabro fork`:**
- Happy path: `fabro fork <ID> @2 --no-push` exits 0, stderr contains "Forked {source} -> {new}" and "To resume: fabro resume {new}" — matches today's snapshot at `tests/it/cmd/fork.rs:57`; request body sent to server has `push: false`.
- Happy path (default target): `fabro fork <ID>` with no target resolves to latest checkpoint server-side; CLI renders the response identically.
- Happy path (JSON): `--json` emits `{source_run_id, new_run_id, target}` (no `archived` field) plus HTTP status code.
- Edge case: `fabro fork <ID> --list` prints the timeline via `GET /runs/{id}/timeline` (same behavior as rewind --list); source unchanged.
- Edge case: `--no-push` translates into `push: false` in the ForkRequest body; server honors it.
- Error path: target `@99` out of range → server returns 400; CLI prints the error; no new run.
- Error path: source already archived → server returns 409 Conflict; CLI prints "run is archived; run `fabro unarchive` first" message; no new run.
- Edge case: server returns 501 Not Implemented (working_directory inaccessible) → CLI prints the same hard-limitation error as rewind; exits non-zero.
- Edge case: network error or timeout during POST /fork → CLI exits non-zero with "check server state" message; does NOT auto-retry (same reasoning as rewind — fork mints a fresh RunId per call).
- Integration: after `fork <ID> @2`, source run is unchanged (no `RunSupersededBy`, no archive); new RunId is resumable via `fabro resume <new>`.
**Verification:**
- `cargo nextest run -p fabro-cli` passes with Unit 5's updated assertions.
- `fabro rewind --help` output unchanged (args struct untouched).
- The CLI-snapshot test `rewind_target_updates_metadata_and_resume_hint` passes against new output text.
- Both `fabro rewind --help` and `fabro fork --help` output unchanged (args structs untouched).
- The CLI-snapshot tests `rewind_target_updates_metadata_and_resume_hint` and `fork_latest_prints_new_run_and_resume_hint` pass against new/unchanged output text.
- [ ] **Unit 4: Delete RunRewound event, in-place rewind op, and projection reset plumbing**
- [x] **Unit 4: Delete RunRewound event, in-place rewind op, and projection reset plumbing**
**Goal:** Remove every code path that existed solely to support in-place rewind. Compile cleanly. Note: `rewind.rs` the file STAYS — Unit 2 replaced its contents with the new composite `operations::rewind` function. This unit deletes the old in-place `rewind()` body and associated wire-contract types, not the file.
@ -389,8 +447,8 @@ struct RewindResponse {
**Dependencies:** Units 1, 2, and 3 (nothing should import `rewind()` or reference `RunRewound` after those units; this unit verifies and deletes).
**Files:**
- Modify: `lib/crates/fabro-workflow/src/operations/rewind.rs` — confirm the in-place `rewind()` function, `RewindInput`, `rewind_to_entry`, and the `ensure_not_archived` precondition call are all gone. After Unit 2 the file contains only the new composite `pub async fn rewind(...)` and its helpers.
- Modify: `lib/crates/fabro-workflow/src/operations/mod.rs` — update the `rewind::` re-export block to expose the new composite function (`pub use rewind::{rewind, RewindInput, RewindOutcome};`) rather than the old one. Old `RewindTarget`/`TimelineEntry`/`RunTimeline`/`build_timeline`/`find_run_id_by_prefix` re-exports move to `timeline::` per Unit 1.
- Modify: `lib/crates/fabro-workflow/src/operations/rewind.rs` — confirm the OLD in-place `rewind()` body, the OLD `RewindInput.current_status` field, `rewind_to_entry`, and the `ensure_not_archived` precondition call are all gone. After Unit 2 the file contains only the NEW composite `pub async fn rewind(...)` and its helpers. The `RewindInput` type name survives — its fields are rewritten to `{ run_id, target, push }` (no `current_status`) in Unit 2.
- Modify: `lib/crates/fabro-workflow/src/operations/mod.rs` — update the `rewind::` re-export block to expose the new composite function (`pub use rewind::{rewind, RewindInput, RewindOutcome};`). `RewindInput` is the same name as before but a different struct shape. Old `RewindTarget`/`TimelineEntry`/`RunTimeline`/`build_timeline`/`find_run_id_by_prefix` re-exports move to `timeline::` per Unit 1.
- Modify: `lib/crates/fabro-workflow/src/event.rs` — delete `Event::RunRewound` variant, its logging arm (~line 613), its `"run.rewound"` discriminant (~line 1178), and its `EventBody::RunRewound` conversion (~line 1586)
- Modify: `lib/crates/fabro-types/src/run_event/mod.rs` — delete `EventBody::RunRewound(RunRewoundProps)` variant (~line 128), its `"run.rewound"` discriminant (~line 393), AND the `"run.rewound"` string-match arm at line 524. Confirmed sites: `rg -n 'run\.rewound|RunRewound' lib/crates/fabro-types/src/run_event/mod.rs` returns lines 127, 128, 393, 524 — all four must go.
- Modify: `lib/crates/fabro-types/src/run_event/run.rs` — delete `pub struct RunRewoundProps` (~lines 90-99)
@ -417,9 +475,9 @@ struct RewindResponse {
- `cargo build --workspace` succeeds.
- `cargo nextest run --workspace` passes.
- `rg "RunRewound|reset_for_rewind|RunRewoundProps"` returns zero hits.
- `rg "operations::rewind"` returns zero hits.
- `rg "\\brewind_to_entry\b|ensure_not_archived.*rewind|current_status.*RewindInput"` returns zero hits (the OLD in-place-rewind internals are gone). Notes: `operations::rewind` itself stays — it's the path of the NEW async composite op. `RewindInput` the type name also stays but with new fields `{ run_id, target, push }` — no `current_status`.
- [ ] **Unit 5: Update tests for new rewind semantics**
- [x] **Unit 5: Update tests for new rewind semantics**
**Goal:** Rewrite tests that asserted old in-place rewind behavior to assert the new fork-and-archive semantics. Split the recovery scenario into two focused scenarios. Delete tests for behavior that no longer exists.
@ -429,6 +487,7 @@ struct RewindResponse {
**Files:**
- Modify: `lib/crates/fabro-cli/tests/it/cmd/rewind.rs` (rewrite assertions; preserve `--help` snapshot structure)
- Modify: `lib/crates/fabro-cli/tests/it/cmd/fork.rs` (rewrite assertions — CLI is now a thin HTTP wrapper; tests mock the server endpoint and assert the CLI renders responses correctly; drop local-git assertions)
- Modify: `lib/crates/fabro-cli/tests/it/cmd/resume.rs` — two tests use the old `rewind <source> ... resume <source>` (same RunId) pattern and will break under new semantics:
- `resume_rewound_run_succeeds` (~line 61) — rewrite to capture the new RunId from rewind stderr/JSON and resume *that* id.
- `resume_detached_does_not_create_launcher_record` (~line 125) — same pattern; same rewrite.
@ -439,8 +498,8 @@ struct RewindResponse {
**Approach:**
- In `tests/it/cmd/rewind.rs`:
- `rewind_outside_git_repo_errors`unchanged.
- `rewind_list_prints_timeline_for_completed_git_run`unchanged (list path unmodified).
- `rewind_outside_git_repo_errors`**rewrite or delete.** The current test (`rewind.rs:41`) exercises `fabro rewind <id> --list` outside a git repo and expects a local-git-absent failure. After Unit 3, `--list` calls `GET /runs/{id}/timeline` over HTTP; outside-git is no longer an error for the list path. Decide: (a) delete the test if no meaningful assertion remains, or (b) rewrite to assert the CLI error message surfaced when the server is unreachable / the run ID is unknown. Same applies to `fork_outside_git_repo_errors` at `tests/it/cmd/fork.rs:41` — the fork mutate path is also now server-side, so outside-git isn't the failure mode anymore.
- `rewind_list_prints_timeline_for_completed_git_run`rewrite to mock `GET /runs/{id}/timeline` rather than reading local git; assert the CLI renders the server response identically to today's output.
- `rewind_target_updates_metadata_and_resume_hint` — rewrite. New assertions: (1) command succeeds; (2) stderr includes "Rewound" and "To resume: fabro resume"; (3) the resume hint points at a new RunId (not `setup.run.run_id`); (4) source run is now Archived. Drop the old assertion that the source's metadata ref moved.
- `rewind_preserves_event_history_and_clears_terminal_snapshot_state` — delete. This test asserted `run.rewound` + `checkpoint.completed` + `run.submitted` event append and projection reset, all of which no longer happen. Replace with a test that asserts BOTH sides explicitly: (1) source event log gains exactly two new events in order: `run.archived` then `run.superseded_by` (matches the archive-first ordering and the only-on-archive-success rule); (2) the new run's event log contains the expected init events in order (`run.submitted`, `checkpoint.completed` from the target checkpoint), with the exact expected event count. The original test's event-count-delta assertion is the kind of coverage that catches helper-function run_id-mixup bugs; preserve that discipline in the rewrite.
- In `tests/it/scenario/recovery.rs`:
@ -455,12 +514,25 @@ struct RewindResponse {
**Test scenarios:**
- Happy path: `rewind_target_creates_new_run_and_archives_source` — run rewind, assert new RunId in output, assert source status is Archived, assert source's event log gains exactly two events in order: (1) `RunArchived`, (2) `RunSupersededBy` (archive-first ordering). Assert source `RunProjection.superseded_by == Some(new_run_id)` and `RunSummary.superseded_by == Some(new_run_id)`. Assert new run has init + checkpoint events.
- Edge case: `rewind_list_unchanged` — `--list` still prints timeline without side effects (no server call).
- Edge case: `rewind_list_calls_timeline_endpoint` — `--list` calls `GET /runs/{id}/timeline`; no mutation, no local git access required.
- Edge case: `rewind_with_no_target_prints_timeline` — no-target invocation behaves like `--list`.
- Edge case: `rewind_no_push_skips_remote_but_still_archives``--no-push` translates to `push: false` on the request; source is still archived via the server endpoint.
- Error path: `rewind_target_out_of_range_does_not_archive` — bad target → server 400; source remains in original (non-archived) status; no new run branches created.
- Error path: `rewind_non_terminal_source_rejected` — source is still running/paused → server 409 Conflict with "must be terminal" message; no new run.
- Edge case: `rewind_graceful_degradation_on_archive_failure` — simulate archive failure (e.g., by archiving the source manually first so the precondition short-circuits) → CLI prints new RunId with warning; exit code 0.
- Edge case: `rewind_graceful_degradation_on_archive_failure` — simulate archive failure via fault injection on the archive call path (NOT by pre-archiving the source, which `reject_if_archived` blocks at handler step 1). Expected: server returns 207; CLI prints new RunId with warning; exit code 0; source event log does NOT gain `RunSupersededBy` (only-on-archive-success invariant).
- Error path: `rewind_unknown_run_mutate``fabro rewind <unknown_id> @2` fails at `resolve_run` (prefix/id lookup before the main operation, CLI pattern at `commands/run/rewind.rs:40`). CLI prints "run not found: <unknown_id>" and exits non-zero. No server mutate call is made.
- Error path: `rewind_unknown_run_list``fabro rewind <unknown_id> --list` fails at the same resolution step; CLI prints the same "run not found" message; no timeline endpoint call.
- Happy path: `fork_cli_creates_new_run_and_prints_hint``fabro fork <ID> @2 --no-push` exits 0, stderr matches the "Forked X -> Y" pattern today's test at `tests/it/cmd/fork.rs:57` asserts; source unchanged (no events gained); new RunId is resumable.
- Happy path: `fork_cli_default_target_resolves_to_latest``fabro fork <ID>` (no target) resolves server-side to latest checkpoint; CLI output identical to the explicit `@N` case.
- Happy path (JSON): `fork_cli_json_output``--json` emits `{source_run_id, new_run_id, target}` (no `archived` field, distinguishing it from rewind's response).
- Edge case: `fork_cli_list_calls_timeline_endpoint``fabro fork <ID> --list` calls the shared timeline endpoint; no mutation.
- Edge case: `fork_cli_no_push_passthrough``--no-push` sends `push: false` in the ForkRequest body (mirrors today's `push: !args.no_push` behavior).
- Error path: `fork_cli_target_out_of_range` — bad target → server 400; CLI prints error; no new run.
- Error path: `fork_cli_archived_source_rejected` — source already archived → server 409; CLI prints "unarchive first" message.
- Error path: `fork_501_when_working_directory_inaccessible` — server can't reach the run's working_directory → 501 Not Implemented; CLI prints the same hard-limitation error as rewind; exits non-zero.
- Error path: `fork_cli_network_error_does_not_retry` — network failure during POST /fork → CLI exits non-zero with "check server state" message; no auto-retry.
- Error path: `fork_cli_unknown_run_mutate``fabro fork <unknown_id> @2` fails at `resolve_run` (same CLI resolution pattern at `commands/run/fork.rs:18`); CLI prints "run not found: <unknown_id>" and exits non-zero. No /fork call is made.
- Error path: `fork_cli_unknown_run_list``fabro fork <unknown_id> --list` fails at the same resolution step; same error message; no timeline endpoint call.
- Integration: `recovery.rs` scenarios above — rewind then resume the new RunId; fork chain rebuilds metadata.
**Verification:**
@ -468,7 +540,7 @@ struct RewindResponse {
- `cargo insta pending-snapshots` is empty after acceptance.
- No test references `RunRewound`, `reset_for_rewind`, or `ensure_not_archived` in a rewind-specific context.
- [ ] **Unit 6: Update user-facing documentation for new rewind semantics**
- [x] **Unit 6: Update user-facing documentation for new rewind semantics**
**Goal:** Replace the "in-place destructive rewind" mental model in shipped user docs with the "rewind produces a new run from a prior checkpoint and archives the source" model. Add a changelog entry so users learn of the semantic shift.
@ -477,14 +549,21 @@ struct RewindResponse {
**Dependencies:** Units 1-5 complete and merged. Docs should describe the shipped behavior, not the planned behavior.
**Files:**
- Modify: `docs/execution/checkpoints.mdx` (lines 140-159 describe rewind; rewrite "resume from the same RunId" flow to "resume from the new RunId printed by rewind"; rewrite fork-vs-rewind contrast to "fork keeps both, rewind archives the source")
- Modify: `docs/reference/cli.mdx` (rewind CLI reference entry around line 584; remove "resets the original run in place" language; document the new output format including the `source_run_id` + `new_run_id` JSON fields)
- Create: `docs/changelog/<date>.mdx` — single entry announcing that `fabro rewind` now creates a new run and archives the source, replacing in-place rewind. Include a migration note for any scripts that parse rewind output.
- Modify: `docs/execution/checkpoints.mdx` — two sections need updates:
- Lines 140-159 (rewind): rewrite "resume from the same RunId" flow to "resume from the new RunId printed by rewind"; rewrite fork-vs-rewind contrast to "fork keeps both, rewind archives the source."
- Line 159 (fork): fork now uses server-side HTTP; the "local independent copy" framing still holds semantically, but the new --list path goes through the server; note the shared 501 limitation for inaccessible working_directories.
- Modify: `docs/reference/cli.mdx` — three sections need updates:
- Rewind CLI reference entry around line 584: remove "resets the original run in place" language; document new output format (`source_run_id` + `new_run_id` JSON fields, 207 partial-success response).
- Fork CLI reference entry around line 582: update for server-backed behavior — `--list` now calls the server timeline endpoint; 501 is possible; no longer requires a local git repo for --list.
- Shared 501 limitation note applicable to both commands.
- Create: `docs/changelog/<date>.mdx` — single entry announcing: (1) `fabro rewind` now creates a new run and archives the source (replacing in-place rewind); (2) both `fabro rewind` and `fabro fork` now call server endpoints (architectural change; same output for local runs, but runs whose `working_directory` the server can't see return 501); (3) new `RunSupersededBy` event + `superseded_by` projection field. Include a migration note for any scripts that parse rewind output.
**Approach:**
- Audit first: `rg -i "rewind|rewound" docs/ apps/` to confirm the file list. Ignore changelog history entries (they correctly describe behavior at their own date).
- Keep the `fabro rewind` CLI as the documented verb for "try from earlier checkpoint" — the semantic-name preservation is deliberate. Update the explanation of what it does, not the name.
- Mention in the docs that the source run is archived (not lost) and can be unarchived with `fabro unarchive` if needed.
- Document the accepted timeline regression: runs with a missing metadata branch will show an empty timeline from the server endpoint. If users need the old CLI rebuild behavior, a follow-up issue tracks either server-side rebuild or an explicit `POST /runs/{id}/rebuild` endpoint.
- Document the 501 failure mode honestly: when the server can't access a run's `working_directory` (remote worker, sandbox, missing path), rewind/fork/timeline all return 501. This is a hard limitation — there is no retry or workaround in this release. A follow-up may add an override or rebuild mechanism.
**Patterns to follow:**
- Existing `docs/changelog/*.mdx` format for the new entry.
@ -500,35 +579,35 @@ struct RewindResponse {
## System-Wide Impact
- **Interaction graph:** Rewind is now a single HTTP call from the CLI (`POST /runs/{id}/rewind`) that atomically composes fork + archive server-side. Pre-check before fork eliminates the precondition half-success case; transport-level archive failure is handled by the endpoint returning `archived: false, archive_error: ...` so the CLI can surface the warning while still delivering the new RunId.
- **Error propagation:** Fork errors surface as server 400. Archived source returns 409 (via `reject_if_archived`). Non-terminal source returns 409 (pre-check in handler; disambiguated in error body). Inaccessible `working_directory` returns 501. Post-archive Precondition errors (concurrent-mutation race) return 207 Multi-Status, same as transport failures — NOT 500. Archive errors are degradations, not bugs.
- **Interaction graph:** Rewind AND fork are now HTTP calls from the CLI (`POST /runs/{id}/rewind`, `POST /runs/{id}/fork`); `--list` is also HTTP (`GET /runs/{id}/timeline`). The rewind endpoint atomically composes fork + archive server-side. Pre-check before fork eliminates the precondition half-success case; transport-level archive failure is handled by the endpoint returning `archived: false, archive_error: ...` so the CLI can surface the warning while still delivering the new RunId.
- **Error propagation:** Bad targets surface as server 400 on both `/rewind` and `/fork`. Archived source returns 409 on both (via `reject_if_archived`). **Rewind only**: non-terminal source returns 409 (rewind's pre-check in handler; disambiguated in error body). Fork does NOT pre-check terminal status — it preserves today's behavior of forking any non-archived source regardless of run status (per R3). Inaccessible `working_directory` returns 501 on all three endpoints (rewind/fork/timeline). **Rewind only**: post-archive Precondition errors (concurrent-mutation race) return 207 Multi-Status, same as transport failures — NOT 500. Archive errors are degradations, not bugs.
- **State lifecycle:** Source run transitions `Succeeded/Failed/Dead → Archived` via the existing archive pipeline. On success, `operations::archive` runs FIRST; then the server appends `RunSupersededBy { new_run_id }`. Event log reads `RunArchived, RunSupersededBy`. Ordering rationale: if RunSupersededBy fails after archive, source is cleanly archived with missing provenance (repairable). If we reversed, an archive failure after a supersede-append would leave source "superseded but still Succeeded" — a misleading projection state. Projection captures `superseded_by: Some(new_run_id)` so UIs/CLI can answer "what replaced this?" without event-log replay.
- **Event stream consumers:** `RunRewound` disappears from the event stream; `RunSupersededBy` appears. Any UI element, log filter, or downstream consumer that matched `"run.rewound"` will break. Per memory, this is greenfield with no deployed consumers — confirm during implementation that no docs/web consumers reference the old event name: `rg -i rewound docs/ apps/ lib/packages/` should return only documentation strings destined for update in Unit 6.
- **API surface parity:** `docs/api-reference/fabro-api.yaml` gets two additions (`POST /runs/{id}/rewind` endpoint with `RewindRequest`/`RewindResponse` schemas, and `"run.superseded_by"` event name in the SSE schema) and zero deletions — the spec does not currently reference rewound (verified: `rg -c rewound docs/api-reference/fabro-api.yaml` = 0). Regenerate the Rust client and TypeScript client per CLAUDE.md "API workflow" after spec edits.
- **API surface parity:** `docs/api-reference/fabro-api.yaml` gets several additions: three new paths (`POST /runs/{id}/rewind`, `POST /runs/{id}/fork`, `GET /runs/{id}/timeline`) with their request/response schemas (`RewindRequest`/`RewindResponse`, `ForkRequest`/`ForkResponse`, `TimelineEntryResponse`), `RunSupersededByProps` event schema, `"run.superseded_by"` in the SSE event-name enum, and a `superseded_by` field on the `RunSummary` schema. Zero deletions — the spec does not currently reference rewound (verified: `rg -c rewound docs/api-reference/fabro-api.yaml` = 0). Regenerate the Rust client and TypeScript client per CLAUDE.md "API workflow" after spec edits.
- **Integration coverage:** The `recovery.rs` scenarios (post-split) are the main integration tests that cross the CLI / server / git boundary. Unit 5 covers them.
- **Unchanged invariants:** `operations::fork`, `operations::archive`, `operations::unarchive`, `operations::resume`, and the `ensure_not_archived` guards on non-rewind paths (e.g., resume) stay exactly as they are. The fork op's public signature is unchanged.
- **Unchanged invariants:** `operations::fork` (the sync git function), `operations::archive`, `operations::unarchive`, `operations::resume`, and the `ensure_not_archived` guards on non-rewind paths (e.g., resume) stay exactly as they are. The sync `operations::fork()` signature is unchanged — the new async wrapper/handler composes around it.
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| Users/scripts relying on rewind preserving the source RunId break silently. | Output text explicitly states `"new run <id>"` so the change is loud; `--json` output includes both `source_run_id` and `new_run_id` so scripts can adapt without parsing prose. User-facing docs are updated in Unit 6 so the documented contract matches new behavior. |
| Fork-succeeded-then-archive-failed leaves an extra run on the server. | Pre-check before fork eliminates the precondition-failure case. Transport-level archive failures produce `archived: false` in the response so the CLI can surface a warning while still giving the user the new RunId. Archive is idempotent — retrying the CLI command against the same source archives it cleanly on the second attempt. |
| Users/scripts relying on rewind preserving the source RunId break silently. | Output text explicitly states `new run <id>` so the change is loud; `--json` output includes both `source_run_id` and `new_run_id` so scripts can adapt without parsing prose. User-facing docs are updated in Unit 6 so the documented contract matches new behavior. |
| Fork-succeeded-then-archive-failed leaves an extra run on the server. | Pre-check before fork eliminates the precondition-failure case. Transport-level archive failures produce `archived: false` in the response so the CLI can surface a warning while still giving the user the new RunId. **Cleanup: the user runs `fabro archive <source>` manually** (archive is idempotent). **Do NOT retry `fabro rewind`** — rewind is not idempotent; each call mints a fresh RunId via fork, so a retry would orphan another run. |
| Recovery scenario changes miss a subtle assertion. | Unit 5 splits into two focused scenarios and explicitly asserts new-RunId resumability and the event count delta. Run locally before merging. |
| Stale `insta` snapshots silently accept changed output. | Follow CLAUDE.md discipline: `cargo insta pending-snapshots` before `cargo insta accept`; accept per-file, never globally. |
| Archive precondition rejects non-terminal runs that `ensure_not_archived` used to allow. | Resolved via User Decisions: accept the narrowing. Documented in Scope Boundaries and the CLI error message; users who need to rewind a paused/blocked run cancel-or-kill it first. |
| OpenAPI spec drift after adding the endpoint and event. | `fabro-server` conformance test catches router/spec divergence. Regenerate both Rust and TypeScript clients immediately after spec edits; commit the generated updates in the same commit as the spec changes. |
| New `RunSupersededBy` event shape conflicts with fabro-web or external SSE consumers. | Search `apps/fabro-web` and any external consumer repos for `run\.rewound` and related event-name strings before merging. Currently greenfield, but a one-line grep keeps the assumption honest. |
| Server-side rewind/timeline endpoints reject runs whose `working_directory` isn't server-accessible (501). | Documented explicitly in error-path scenarios. CLI surfaces the 501 clearly and suggests checkout-to-local-path as the workaround. In practice, most runs today are local. Remote/sandbox runs are a future concern that may need a streaming-fork-from-client protocol. |
| Server-side rewind/fork/timeline endpoints reject runs whose `working_directory` isn't server-accessible (501). | Accepted as a hard limitation in this plan. The CLI surfaces a clear, non-misleading error (no retry guidance). In practice most runs today are local and server+CLI share a filesystem; remote/sandbox runs need a follow-up (override mechanism, rebuild endpoint, or streaming-fork-from-client protocol). Not solved here. |
| `RunSupersededBy` omitted on 207 leaves source with no source-side audit trail of the rewind. | Accepted trade-off per event-ordering-invariant decision. Response body still carries `new_run_id`, so forward-direction audit (new→source) is available via the deferred `forked_from` provenance follow-up. Backward direction (source→new) is only available on archive success — which is the common case. |
## Documentation / Operational Notes
- User-facing docs teach the old in-place-rewind model explicitly and must be updated (see Unit 5):
- User-facing docs teach the old in-place-rewind model explicitly and must be updated (see Unit 6):
- `docs/execution/checkpoints.mdx:140-159` — documents `fabro rewind <RUN_ID>` followed by `fabro resume <RUN_ID>` using the same ID; contrasts rewind (destructive, resets original) against fork (independent copy).
- `docs/reference/cli.mdx` — CLI reference entry for `fabro rewind`; lines around 584 contrast rewind vs. fork as in-place-reset vs. independent-copy.
- Changelog entries: `docs/changelog/2026-03-14.mdx:26-34` and `docs/changelog/2026-03-15.mdx:8` are historical and can stay, but a new changelog entry for this semantic change is required.
- **OpenAPI spec + client regeneration.** Unit 2 adds `POST /runs/{id}/rewind` with `RewindRequest`/`RewindResponse` schemas and the `"run.superseded_by"` event name to `docs/api-reference/fabro-api.yaml`. After spec edits, rerun `cargo build -p fabro-api` (progenitor regenerates Rust types + reqwest client) and `cd lib/packages/fabro-api-client && bun run generate` (openapi-generator regenerates TS client). The `fabro-server` conformance test catches spec/router drift — run it locally after the endpoint is wired.
- **OpenAPI spec + client regeneration.** Unit 2 adds three paths (`POST /runs/{id}/rewind`, `POST /runs/{id}/fork`, `GET /runs/{id}/timeline`) with their request/response schemas, plus `RunSupersededByProps` + `"run.superseded_by"` event wiring, plus the `superseded_by` field on `RunSummary`. After spec edits, rerun `cargo build -p fabro-api` (progenitor regenerates Rust types + reqwest client) and `cd lib/packages/fabro-api-client && bun run generate` (openapi-generator regenerates TS client). The `fabro-server` conformance test catches spec/router drift — run it locally after the endpoints are wired.
- No rollout concerns — greenfield, no migration.
## Sources & References

View file

@ -536,7 +536,7 @@ fabro graph run.toml
## `fabro rewind`
Rewind a workflow run to an earlier checkpoint. This resets both the run branch and metadata branch refs so that `fabro resume` continues from the target checkpoint.
Rewind a terminal workflow run to an earlier checkpoint. This creates a replacement run at the target checkpoint, archives the source run, and prints the new run ID for `fabro resume`.
```bash
fabro rewind <RUN_ID> [TARGET]
@ -548,7 +548,7 @@ fabro rewind <RUN_ID> --list
| `<RUN_ID>` | Run ID or unambiguous prefix (required) |
| `[TARGET]` | Checkpoint to rewind to: node name, `node@visit`, or `@ordinal` (1-based). Omit to show the timeline. |
| `--list` | Show the checkpoint timeline instead of rewinding |
| `--no-push` | Skip force-pushing rewound refs to the remote |
| `--no-push` | Skip pushing the replacement run refs to the remote |
Target formats:
@ -558,17 +558,19 @@ Target formats:
| `node@N` | `plan@2` | The 2nd visit of the named node |
| `@N` | `@3` | The 3rd checkpoint in sequence |
After rewinding, resume from the earlier point:
After rewinding, resume the replacement run:
```bash
fabro resume <RUN_ID>
fabro resume <NEW_RUN_ID>
```
The source run must be `succeeded`, `failed`, or `dead`. Archived sources are rejected until you unarchive them. Rewind is not idempotent; if it creates the replacement run but cannot archive the source, archive the source manually instead of retrying rewind.
See [Checkpoints](/execution/checkpoints#rewinding-to-an-earlier-checkpoint) for background on how checkpointing works.
## `fabro fork`
Fork a new run from an existing run's checkpoint. Unlike `fabro rewind`, which resets the original run in place, `fabro fork` creates an independent copy — the original run stays intact.
Fork a new run from an existing run's checkpoint. Unlike `fabro rewind`, which archives the original run after creating a replacement, `fabro fork` creates an independent copy and leaves the original run intact.
```bash
fabro fork <RUN_ID> [TARGET]
@ -588,6 +590,8 @@ Target formats are the same as [`fabro rewind`](#fabro-rewind). After forking, r
fabro resume <NEW_RUN_ID>
```
`fabro rewind`, `fabro fork`, and their `--list` modes run through the server. The server must be able to access the run's recorded working directory. Timeline listing reads the metadata branch without rebuilding it; if the metadata branch is missing, the list can be empty.
See [Checkpoints — Forking a run](/execution/checkpoints#forking-a-run) for when to use fork vs. rewind.
## `fabro logs`

View file

@ -11,7 +11,6 @@ pub(crate) mod parse;
pub(crate) mod pr;
pub(crate) mod preflight;
pub(crate) mod provider;
pub(crate) mod rebuild;
pub(crate) mod render_graph;
pub(crate) mod repo;
pub(crate) mod run;

View file

@ -1,24 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use fabro_store::{Database, EventEnvelope, EventPayload, RunDatabase};
use object_store::memory::InMemory;
pub(crate) async fn rebuild_run_store(
run_id: &fabro_types::RunId,
events: &[EventEnvelope],
) -> Result<RunDatabase> {
let store = Arc::new(Database::new(
Arc::new(InMemory::new()),
"",
Duration::from_millis(1),
None,
));
let run_store = store.create_run(run_id).await?;
for event in events {
let payload = EventPayload::new(event.event.to_value()?, run_id)?;
run_store.append_event(&payload).await?;
}
Ok(run_store)
}

View file

@ -1,71 +1,53 @@
use anyhow::{Context, Result};
use fabro_checkpoint::git::Store;
use anyhow::Result;
use fabro_api::types::ForkRequest;
use fabro_util::terminal::Styles;
use fabro_workflow::operations::{ForkRunInput, RewindTarget, build_timeline_or_rebuild, fork};
use git2::Repository;
use crate::args::ForkArgs;
use crate::command_context::CommandContext;
use crate::commands::rebuild::rebuild_run_store;
use crate::shared::print_json_pretty;
use crate::shared::repo::ensure_matching_repo_origin;
pub(crate) async fn run(args: &ForkArgs, styles: &Styles, base_ctx: &CommandContext) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;
let printer = base_ctx.printer();
let ctx = base_ctx.with_target(&args.server)?;
let client = ctx.server().await?;
let run_id = client.resolve_run(&args.run_id).await?.run_id;
let state = client.get_run_state(&run_id).await?;
let run_spec = state.spec.context("Failed to load run spec from store")?;
ensure_matching_repo_origin(run_spec.repo_origin_url.as_deref(), "fork")?;
let store = Store::new(repo);
let events = client.list_run_events(&run_id, None, None).await?;
let run_store = rebuild_run_store(&run_id, &events).await?;
let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?;
super::rewind::ensure_origin_if_local(client.as_ref(), &run_id, "fork").await?;
if args.list {
let timeline = client.run_timeline(&run_id).await?;
if ctx.json_output() {
print_json_pretty(&super::rewind::timeline_entries_json(&timeline))?;
return Ok(());
}
super::rewind::print_timeline(&timeline, styles, printer);
let entries = super::rewind::timeline_entries_json(&timeline);
super::rewind::print_timeline(&entries, styles, printer);
return Ok(());
}
let target = args
.target
.as_deref()
.map(str::parse::<RewindTarget>)
.transpose()?;
let new_run_id = fork(&store, &ForkRunInput {
source_run_id: run_id,
target,
push: !args.no_push,
})?;
let run_id_string = run_id.to_string();
let new_run_id_string = new_run_id.to_string();
let response = client
.fork_run(&run_id, ForkRequest {
target: args.target.clone(),
push: Some(!args.no_push),
})
.await?;
if ctx.json_output() {
let target = args.target.clone().unwrap_or_else(|| "latest".to_string());
print_json_pretty(&serde_json::json!({
"source_run_id": run_id_string,
"new_run_id": new_run_id_string,
"target": target,
"source_run_id": response.source_run_id,
"new_run_id": response.new_run_id,
"target": response.target,
}))?;
} else {
fabro_util::printerr!(
printer,
"\nForked run {} -> {}",
&run_id_string[..8.min(run_id_string.len())],
&new_run_id_string[..8.min(new_run_id_string.len())]
super::rewind::short_id(&response.source_run_id),
super::rewind::short_id(&response.new_run_id)
);
fabro_util::printerr!(
printer,
"To resume: fabro resume {}",
&new_run_id_string[..8.min(new_run_id_string.len())]
super::rewind::short_id(&response.new_run_id)
);
}

View file

@ -1,21 +1,15 @@
use anyhow::{Context, Result};
use anyhow::Result;
use cli_table::format::{Border, Separator};
use cli_table::{Cell, CellStruct, Color, Style, Table};
use fabro_checkpoint::git::Store;
use fabro_types::run_event::{CheckpointCompletedProps, RunRewoundProps, RunSubmittedProps};
use fabro_types::{EventBody, RunEvent};
use fabro_api::types::{RewindRequest, TimelineEntryResponse};
use fabro_types::RunId;
use fabro_util::printer::Printer;
use fabro_util::terminal::Styles;
use fabro_workflow::git::MetadataStore;
use fabro_workflow::operations::{
RewindInput, RewindTarget, RunTimeline, TimelineEntry, build_timeline_or_rebuild, rewind,
};
use git2::Repository;
use serde::Serialize;
use crate::args::RewindArgs;
use crate::command_context::CommandContext;
use crate::commands::rebuild::rebuild_run_store;
use crate::server_client::Client;
use crate::shared::repo::ensure_matching_repo_origin;
use crate::shared::{color_if, print_json_pretty};
@ -33,191 +27,104 @@ pub(crate) async fn run(
styles: &Styles,
base_ctx: &CommandContext,
) -> Result<()> {
let repo = Repository::discover(".").context("not in a git repository")?;
let printer = base_ctx.printer();
let ctx = base_ctx.with_target(&args.server)?;
let client = ctx.server().await?;
let run_id = client.resolve_run(&args.run_id).await?.run_id;
let state = client.get_run_state(&run_id).await?;
let current_status = state
.status
.context("run has no recorded status — cannot rewind")?;
let run_spec = state.spec.context("Failed to load run spec from store")?;
ensure_matching_repo_origin(run_spec.repo_origin_url.as_deref(), "rewind")?;
let store = Store::new(repo);
let events = client.list_run_events(&run_id, None, None).await?;
let run_store = rebuild_run_store(&run_id, &events).await?;
let timeline = build_timeline_or_rebuild(&store, Some(&run_store), &run_id).await?;
ensure_origin_if_local(client.as_ref(), &run_id, "rewind").await?;
if args.list || args.target.is_none() {
let timeline = client.run_timeline(&run_id).await?;
if ctx.json_output() {
print_json_pretty(&timeline_entries_json(&timeline))?;
return Ok(());
}
print_timeline(&timeline, styles, printer);
print_timeline(&timeline_entries_json(&timeline), styles, printer);
return Ok(());
}
let target_arg = args
let target = args
.target
.as_deref()
.clone()
.expect("rewind target should be present unless listing");
let target = target_arg.parse::<RewindTarget>()?;
rewind(&store, &RewindInput {
run_id,
target: target.clone(),
push: !args.no_push,
current_status,
})?;
let entry = timeline.resolve(&target)?;
reset_rewound_run_state(client.as_ref(), &store, &run_id, entry).await?;
let run_id_string = run_id.to_string();
let result = client
.rewind_run(&run_id, RewindRequest {
target: Some(target),
push: Some(!args.no_push),
})
.await?;
let response = result.response;
if ctx.json_output() {
print_json_pretty(&serde_json::json!({
"run_id": run_id_string,
"target": target_arg,
"source_run_id": response.source_run_id,
"new_run_id": response.new_run_id,
"target": response.target,
"archived": response.archived,
"archive_error": response.archive_error,
"status": result.status,
}))?;
} else {
fabro_util::printerr!(
printer,
"\nTo resume: fabro resume {}",
&run_id_string[..8.min(run_id_string.len())]
"\nRewound {}; new run {}",
short_id(&response.source_run_id),
short_id(&response.new_run_id)
);
fabro_util::printerr!(
printer,
"To resume: fabro resume {}",
short_id(&response.new_run_id)
);
if !response.archived {
let archive_error = response.archive_error.as_deref().unwrap_or("unknown error");
fabro_util::printerr!(
printer,
"Warning: source not archived: {archive_error}. Run `fabro archive {}` to finish.",
short_id(&response.source_run_id)
);
}
}
Ok(())
}
pub(crate) fn timeline_entries_json(timeline: &RunTimeline) -> Vec<TimelineEntryJson> {
timeline
.entries
pub(crate) async fn ensure_origin_if_local(
client: &Client,
run_id: &RunId,
verb: &str,
) -> Result<()> {
if Repository::discover(".").is_err() {
return Ok(());
}
let state = client.get_run_state(run_id).await?;
if let Some(run_spec) = state.spec {
ensure_matching_repo_origin(run_spec.repo_origin_url.as_deref(), verb)?;
}
Ok(())
}
pub(crate) fn timeline_entries_json(entries: &[TimelineEntryResponse]) -> Vec<TimelineEntryJson> {
entries
.iter()
.map(|entry| TimelineEntryJson {
ordinal: entry.ordinal,
ordinal: usize::try_from(entry.ordinal.get())
.expect("timeline ordinal should fit in usize"),
node_name: entry.node_name.clone(),
visit: entry.visit,
visit: usize::try_from(entry.visit.get())
.expect("timeline visit should fit in usize"),
run_commit_sha: entry.run_commit_sha.clone(),
})
.collect()
}
async fn reset_rewound_run_state(
client: &Client,
git_store: &Store,
run_id: &fabro_types::RunId,
entry: &TimelineEntry,
) -> Result<()> {
let state = client.get_run_state(run_id).await.map_err(|err| {
anyhow::anyhow!("failed to load durable store state before rewind: {err}")
})?;
let definition_blob = state.spec.as_ref().and_then(|run| run.definition_blob);
if state.spec.is_none() {
anyhow::bail!("failed to restore run spec after rewind: missing run metadata");
}
let checkpoint = MetadataStore::read_run_projection(git_store.repo_dir(), &run_id.to_string())?
.and_then(|projection| projection.checkpoint)
.context("rewound metadata branch is missing run.json checkpoint state")?;
let previous_status = state.status.map(|status| status.to_string());
client
.append_run_event(
run_id,
&run_event(
*run_id,
None,
EventBody::RunRewound(RunRewoundProps {
target_checkpoint_ordinal: entry.ordinal,
target_node_id: entry.node_name.clone(),
target_visit: entry.visit,
previous_status,
run_commit_sha: entry.run_commit_sha.clone(),
}),
),
)
.await
.map_err(|err| anyhow::anyhow!("failed to append run rewound event: {err}"))?;
client
.append_run_event(run_id, &restored_checkpoint_event(*run_id, &checkpoint))
.await
.map_err(|err| anyhow::anyhow!("failed to append restored checkpoint event: {err}"))?;
client
.append_run_event(
run_id,
&run_event(
*run_id,
None,
EventBody::RunSubmitted(RunSubmittedProps { definition_blob }),
),
)
.await
.map_err(|err| anyhow::anyhow!("failed to append restored run status event: {err}"))?;
Ok(())
pub(crate) fn short_id(run_id: &str) -> &str {
&run_id[..8.min(run_id.len())]
}
fn restored_checkpoint_event(
run_id: fabro_types::RunId,
checkpoint: &fabro_types::Checkpoint,
) -> RunEvent {
let current_status = checkpoint
.node_outcomes
.get(&checkpoint.current_node)
.map_or_else(
|| "success".to_string(),
|outcome| outcome.status.to_string(),
);
run_event(
run_id,
Some(checkpoint.current_node.clone()),
EventBody::CheckpointCompleted(CheckpointCompletedProps {
status: current_status,
current_node: checkpoint.current_node.clone(),
completed_nodes: checkpoint.completed_nodes.clone(),
node_retries: checkpoint.node_retries.clone().into_iter().collect(),
context_values: checkpoint.context_values.clone().into_iter().collect(),
node_outcomes: checkpoint.node_outcomes.clone().into_iter().collect(),
next_node_id: checkpoint.next_node_id.clone(),
git_commit_sha: checkpoint.git_commit_sha.clone(),
loop_failure_signatures: checkpoint
.loop_failure_signatures
.iter()
.map(|(sig, count)| (sig.to_string(), *count))
.collect(),
restart_failure_signatures: checkpoint
.restart_failure_signatures
.iter()
.map(|(sig, count)| (sig.to_string(), *count))
.collect(),
node_visits: checkpoint.node_visits.clone().into_iter().collect(),
diff: None,
}),
)
}
fn run_event(run_id: fabro_types::RunId, node_id: Option<String>, body: EventBody) -> RunEvent {
RunEvent {
id: ulid::Ulid::new().to_string(),
ts: chrono::Utc::now(),
run_id,
node_id,
node_label: None,
stage_id: None,
parallel_group_id: None,
parallel_branch_id: None,
session_id: None,
parent_session_id: None,
tool_call_id: None,
actor: None,
body,
}
}
pub(crate) fn print_timeline(timeline: &RunTimeline, styles: &Styles, printer: Printer) {
if timeline.entries.is_empty() {
pub(crate) fn print_timeline(entries: &[TimelineEntryJson], styles: &Styles, printer: Printer) {
if entries.is_empty() {
fabro_util::printerr!(printer, "No checkpoints found.");
return;
}
@ -229,8 +136,7 @@ pub(crate) fn print_timeline(timeline: &RunTimeline, styles: &Styles, printer: P
"Details".cell().bold(use_color),
];
let rows: Vec<Vec<CellStruct>> = timeline
.entries
let rows: Vec<Vec<CellStruct>> = entries
.iter()
.map(|entry| {
let ordinal_str = format!("@{}", entry.ordinal);
@ -238,9 +144,6 @@ pub(crate) fn print_timeline(timeline: &RunTimeline, styles: &Styles, printer: P
if entry.visit > 1 {
details.push(format!("visit {}, loop", entry.visit));
}
if timeline.parallel_map.contains_key(&entry.node_name) {
details.push("parallel interior".to_string());
}
if entry.run_commit_sha.is_none() {
details.push("no run commit".to_string());
}

View file

@ -48,8 +48,7 @@ fn fork_outside_git_repo_errors() {
exit_code: 1
----- stdout -----
----- stderr -----
error: not in a git repository
> could not find repository at '.'; class=Repository (6); code=NotFound (-3)
error: No run found matching '[ULID]' (tried run ID prefix and workflow name)
");
}

View file

@ -62,27 +62,16 @@ fn resume_rewound_run_succeeds() {
let context = test_context!();
let setup = setup_git_backed_changed_run(&context);
let rewind = context
.command()
.current_dir(&setup.repo_dir)
.args(["rewind", &setup.run.run_id, "@1", "--no-push"])
.output()
.expect("rewind should execute");
assert!(
rewind.status.success(),
"rewind should succeed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&rewind.stdout),
output_stderr(&rewind)
);
let new_run_id = rewind_replacement_run_id(&context, &setup);
let rewound_head = git_stdout(&setup.repo_dir, &[
"rev-parse",
&format!("fabro/run/{}", setup.run.run_id),
&format!("fabro/run/{new_run_id}"),
]);
let mut resume_cmd = context.command();
resume_cmd.current_dir(&setup.repo_dir);
resume_cmd.env("OPENAI_API_KEY", "test");
resume_cmd.args(["resume", &setup.run.run_id]);
resume_cmd.args(["resume", &new_run_id]);
let resume_output = resume_cmd.output().expect("resume should execute");
assert!(
resume_output.status.success(),
@ -91,23 +80,11 @@ fn resume_rewound_run_succeeds() {
output_stderr(&resume_output)
);
let mut wait_filters = context.filters();
wait_filters.push((
r"\b\d+(\.\d+)?(ms|s)\b".to_string(),
"[DURATION]".to_string(),
));
let mut wait_cmd = context.command();
wait_cmd.args(["wait", &setup.run.run_id]);
fabro_snapshot!(wait_filters, wait_cmd, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Succeeded [ULID] [DURATION]
");
assert_eq!(
std::fs::read_to_string(setup.run.run_dir.join("worktree/story.txt")).unwrap(),
git_stdout(&setup.repo_dir, &[
"show",
&format!("fabro/run/{new_run_id}:story.txt")
]),
"line 1\nline 2\nline 3\n"
);
assert_eq!(
@ -116,7 +93,7 @@ fn resume_rewound_run_succeeds() {
);
let resumed_head = git_stdout(&setup.repo_dir, &[
"rev-parse",
&format!("fabro/run/{}", setup.run.run_id),
&format!("fabro/run/{new_run_id}"),
]);
assert_ne!(resumed_head.trim(), rewound_head.trim());
}
@ -126,17 +103,12 @@ fn resume_detached_does_not_create_launcher_record() {
let context = test_context!();
let setup = setup_git_backed_changed_run(&context);
context
.command()
.current_dir(&setup.repo_dir)
.args(["rewind", &setup.run.run_id, "@1", "--no-push"])
.assert()
.success();
let new_run_id = rewind_replacement_run_id(&context, &setup);
let mut resume_cmd = context.command();
resume_cmd.current_dir(&setup.repo_dir);
resume_cmd.env("OPENAI_API_KEY", "test");
resume_cmd.args(["resume", "--detach", &setup.run.run_id]);
resume_cmd.args(["resume", "--detach", &new_run_id]);
let resume_output = resume_cmd.output().expect("resume should execute");
assert!(
resume_output.status.success(),
@ -149,15 +121,45 @@ fn resume_detached_does_not_create_launcher_record() {
!context
.storage_dir
.join("launchers")
.join(format!("{}.json", setup.run.run_id))
.join(format!("{new_run_id}.json"))
.exists(),
"server-owned resume should not create a launcher record"
);
context
.command()
.args(["wait", &setup.run.run_id])
.args(["wait", &new_run_id])
.timeout(SHARED_DAEMON_TIMEOUT)
.assert()
.success();
}
fn rewind_replacement_run_id(
context: &fabro_test::TestContext,
setup: &super::support::GitRunSetup,
) -> String {
let rewind = context
.command()
.current_dir(&setup.repo_dir)
.args(["rewind", &setup.run.run_id, "@1", "--no-push", "--json"])
.output()
.expect("rewind should execute");
assert!(
rewind.status.success(),
"rewind should succeed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&rewind.stdout),
output_stderr(&rewind)
);
let response: serde_json::Value =
serde_json::from_slice(&rewind.stdout).expect("rewind json should parse");
assert_eq!(
response["source_run_id"].as_str(),
Some(setup.run.run_id.as_str())
);
assert_eq!(response["archived"].as_bool(), Some(true));
response["new_run_id"]
.as_str()
.expect("rewind response should include new_run_id")
.to_string()
}

View file

@ -2,8 +2,8 @@ use fabro_test::{fabro_snapshot, run_and_format, test_context};
use insta::assert_snapshot;
use super::support::{
git_filters, git_stdout, output_stderr as support_stderr, run_branch_commits_since_base,
run_events, run_state, setup_git_backed_changed_run,
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,
};
#[test]
@ -48,8 +48,7 @@ fn rewind_outside_git_repo_errors() {
exit_code: 1
----- stdout -----
----- stderr -----
error: not in a git repository
> could not find repository at '.'; class=Repository (6); code=NotFound (-3)
error: No run found matching '[ULID]' (tried run ID prefix and workflow name)
");
}
@ -76,6 +75,7 @@ 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()
@ -92,37 +92,40 @@ fn rewind_target_updates_metadata_and_resume_hint() {
exit_code: 0
----- stdout -----
----- stderr -----
Rewound metadata branch to @1 (step_one)
Rewound run branch fabro/run/[ULID] to [SHA]
Rewound [RUN_PREFIX]; new run [RUN_PREFIX]
To resume: fabro resume [RUN_PREFIX]
");
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/{}", setup.run.run_id),
&format!("fabro/run/{new_run_id}"),
]);
assert_eq!(run_head.trim(), expected_run_head);
let mut list_cmd = context.command();
list_cmd.current_dir(&setup.repo_dir);
list_cmd.args(["rewind", &setup.run.run_id, "--list"]);
let list_output = list_cmd.output().expect("rewind --list should execute");
assert!(list_output.status.success(), "rewind --list should succeed");
let list = support_stderr(&list_output);
assert!(
list.contains("@1"),
"rewound timeline should keep @1: {list}"
);
assert!(
!list.contains("@2"),
"rewound timeline should drop @2: {list}"
let state = run_state(&setup.run.run_dir);
assert!(matches!(
state.status,
Some(fabro_types::RunStatus::Archived { .. })
));
assert_eq!(
state.superseded_by.map(|run_id| run_id.to_string()),
Some(new_run_id.clone())
);
}
#[test]
fn rewind_preserves_event_history_and_clears_terminal_snapshot_state() {
fn rewind_archives_source_and_records_superseded_by() {
let context = test_context!();
let setup = setup_git_backed_changed_run(&context);
let before_events = run_events(&setup.run.run_dir);
@ -147,8 +150,8 @@ fn rewind_preserves_event_history_and_clears_terminal_snapshot_state() {
let after_events = run_events(&setup.run.run_dir);
assert_eq!(
after_events.len(),
before_events.len() + 3,
"rewind should append run.rewound, checkpoint.completed, and run.submitted"
before_events.len() + 2,
"rewind should append run.archived and run.superseded_by"
);
assert_eq!(
after_events[..before_events.len()]
@ -163,38 +166,17 @@ fn rewind_preserves_event_history_and_clears_terminal_snapshot_state() {
);
assert_eq!(
after_events[before_events.len()].event.event_name(),
"run.rewound"
"run.archived"
);
assert_eq!(
after_events[before_events.len() + 1].event.event_name(),
"checkpoint.completed"
);
assert_eq!(
after_events[before_events.len() + 2].event.event_name(),
"run.submitted"
);
assert!(
after_events[before_events.len() + 2]
.event
.properties()
.unwrap()["definition_blob"]
.is_string(),
"rewind should re-emit run.submitted with the definition_blob"
"run.superseded_by"
);
let state = run_state(&setup.run.run_dir);
assert_eq!(state.status, Some(fabro_types::RunStatus::Submitted));
assert!(state.conclusion.is_none(), "rewind should clear conclusion");
assert!(
state.final_patch.is_none(),
"rewind should clear final patch"
);
assert!(
state.pull_request.is_none(),
"rewind should clear pull request"
);
assert!(
state.is_empty(),
"rewind should clear node state that belonged to the prior execution"
);
assert!(matches!(
state.status,
Some(fabro_types::RunStatus::Archived { .. })
));
assert!(state.superseded_by.is_some());
}

View file

@ -578,6 +578,14 @@ pub(crate) fn git_filters(context: &TestContext) -> Vec<(String, String)> {
r"(-> )[0-9A-HJKMNP-TV-Z]{8}\b".to_string(),
"$1[RUN_PREFIX]".to_string(),
));
filters.push((
r"(Rewound )[0-9A-HJKMNP-TV-Z]{8}\b".to_string(),
"$1[RUN_PREFIX]".to_string(),
));
filters.push((
r"(; new run )[0-9A-HJKMNP-TV-Z]{8}\b".to_string(),
"$1[RUN_PREFIX]".to_string(),
));
filters
}

View file

@ -6,13 +6,12 @@
use std::collections::BTreeSet;
use std::path::Path;
use fabro_checkpoint::branch::BranchStore;
use fabro_checkpoint::git::Store as GitStore;
use fabro_store::RunProjection;
use fabro_test::{fabro_snapshot, test_context};
use fabro_types::Checkpoint;
use fabro_workflow::operations::{RunTimeline, build_timeline};
use git2::{Repository, Signature};
use git2::Repository;
use crate::support::unique_run_id;
@ -29,32 +28,6 @@ fn list_metadata_run_ids(repo_dir: &Path) -> BTreeSet<String> {
.collect()
}
fn metadata_checkpoints(repo_dir: &Path, run_id: &str) -> Vec<Checkpoint> {
let repo = Repository::discover(repo_dir).expect("recovery fixture should be a git repo");
let store = GitStore::new(repo);
let sig =
Signature::now("Fabro", "noreply@fabro.sh").expect("test recovery signature should build");
let branch = format!("fabro/meta/{run_id}");
let bs = BranchStore::new(&store, &branch, &sig);
bs.log(100)
.expect("metadata branch log should load")
.iter()
.rev()
.filter(|commit| commit.message.starts_with("checkpoint"))
.map(|commit| {
let projection_blob = store
.read_blob_at(commit.oid, "run.json")
.expect("projection blob should load")
.expect("projection blob should exist");
serde_json::from_slice::<RunProjection>(&projection_blob)
.expect("projection blob should deserialize")
.checkpoint
.expect("projection checkpoint should exist")
})
.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");
let store = GitStore::new(repo);
@ -80,14 +53,6 @@ fn timeline_run_shas(repo_dir: &Path, run_id: &str) -> Vec<Option<String>> {
.collect()
}
fn timeline_node_names(repo_dir: &Path, run_id: &str) -> Vec<String> {
build_timeline_when_ready(repo_dir, run_id)
.entries
.into_iter()
.map(|entry| entry.node_name)
.collect()
}
#[expect(
clippy::disallowed_methods,
reason = "This sync git integration helper polls until metadata commits become readable without requiring Tokio."
@ -182,7 +147,7 @@ digraph Recovery {
}
#[test]
fn rewind_and_fork_recover_missing_metadata_from_real_run_state() {
fn rewind_list_reports_empty_timeline_when_metadata_branch_is_missing() {
let context = test_context!();
context.ensure_home_server_auth_methods();
let repo_dir = tempfile::tempdir().unwrap();
@ -206,10 +171,6 @@ fn rewind_and_fork_recover_missing_metadata_from_real_run_state() {
.assert()
.success();
let mut filters = Vec::new();
filters.push((r"\b[0-9a-f]{7,40}\b".to_string(), "[SHA]".to_string()));
filters.extend(context.filters());
delete_metadata_branch_when_ready(repo_dir.path(), &source_run_id);
assert!(
@ -221,23 +182,44 @@ fn rewind_and_fork_recover_missing_metadata_from_real_run_state() {
rewind_list.current_dir(repo_dir.path());
rewind_list.args(["rewind", &source_run_id, "--list"]);
rewind_list.timeout(std::time::Duration::from_secs(15));
rewind_list.assert().success();
fabro_snapshot!(context.filters(), rewind_list, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
No checkpoints found.
");
let rebuilt_nodes = timeline_node_names(repo_dir.path(), &source_run_id);
assert_eq!(rebuilt_nodes.last().map(String::as_str), Some("build"));
assert!(
rebuilt_nodes.ends_with(&["plan".to_string(), "build".to_string()]),
"expected rebuilt timeline to end with plan -> build, got {rebuilt_nodes:?}"
list_metadata_run_ids(repo_dir.path()).is_empty(),
"server timeline should not rebuild missing metadata"
);
}
let rebuilt_checkpoints = metadata_checkpoints(repo_dir.path(), &source_run_id);
assert_eq!(
rebuilt_checkpoints
.first()
.and_then(|c| c.git_commit_sha.clone()),
None
);
assert!(rebuilt_checkpoints.len() >= 2);
#[test]
fn fork_chain_preserves_checkpoint_metadata() {
let context = test_context!();
context.ensure_home_server_auth_methods();
let repo_dir = tempfile::tempdir().unwrap();
let source_run_id = unique_run_id();
init_repo_with_workflow(repo_dir.path());
context
.command()
.current_dir(repo_dir.path())
.args([
"run",
"--dry-run",
"--no-retro",
"--sandbox",
"local",
"--run-id",
source_run_id.as_str(),
"workflow.fabro",
])
.assert()
.success();
let timeline_shas = timeline_run_shas(repo_dir.path(), &source_run_id);
let build_sha = timeline_shas.last().cloned().flatten();
@ -259,36 +241,11 @@ fn rewind_and_fork_recover_missing_metadata_from_real_run_state() {
let child_checkpoint = latest_metadata_checkpoint(repo_dir.path(), child_run_id);
assert_eq!(child_checkpoint.git_commit_sha, build_sha);
let mut rewind_filters = filters.clone();
rewind_filters.push((
regex::escape(&source_run_id[..8]),
"[RUN_PREFIX]".to_string(),
));
rewind_filters.push((r"@\d+".to_string(), "@[ORDINAL]".to_string()));
let mut source_rewind = context.command();
source_rewind.current_dir(repo_dir.path());
source_rewind.args(["rewind", &source_run_id, "build", "--no-push"]);
source_rewind.timeout(std::time::Duration::from_secs(15));
fabro_snapshot!(rewind_filters, source_rewind, @"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Rewound metadata branch to @[ORDINAL] (build)
Rewound run branch fabro/run/[ULID] to [SHA]
To resume: fabro resume [RUN_PREFIX]
");
let rewound_timeline_shas = timeline_run_shas(repo_dir.path(), &source_run_id);
assert_eq!(rewound_timeline_shas.last().cloned().flatten(), build_sha);
let before_grandchild = list_metadata_run_ids(repo_dir.path());
context
.command()
.current_dir(repo_dir.path())
.args(["fork", &source_run_id, "--no-push"])
.args(["fork", child_run_id, "--no-push"])
.timeout(std::time::Duration::from_secs(15))
.assert()
.success();

View file

@ -39,6 +39,11 @@ pub struct RunEventStream {
buffered_events: VecDeque<EventEnvelope>,
}
pub struct RewindRunResult {
pub status: u16,
pub response: types::RewindResponse,
}
#[derive(Clone)]
struct ClientState {
client: fabro_api::ApiClient,
@ -739,6 +744,59 @@ impl Client {
Ok(())
}
pub async fn rewind_run(
&self,
run_id: &RunId,
request: types::RewindRequest,
) -> Result<RewindRunResult> {
let response = self
.send_api(|client| async move {
client
.rewind_run()
.id(run_id.to_string())
.body(request)
.send()
.await
})
.await?;
let status = response.status().as_u16();
Ok(RewindRunResult {
status,
response: response.into_inner(),
})
}
pub async fn fork_run(
&self,
run_id: &RunId,
request: types::ForkRequest,
) -> Result<types::ForkResponse> {
let response = self
.send_api(|client| async move {
client
.fork_run()
.id(run_id.to_string())
.body(request)
.send()
.await
})
.await?;
Ok(response.into_inner())
}
pub async fn run_timeline(&self, run_id: &RunId) -> Result<Vec<types::TimelineEntryResponse>> {
let response = self
.send_api(|client| async move {
client
.get_run_timeline()
.id(run_id.to_string())
.send()
.await
})
.await?;
Ok(response.into_inner())
}
pub async fn list_store_runs(&self) -> Result<Vec<RunSummary>> {
let mut all_runs = Vec::new();
let mut offset = 0_u64;

View file

@ -800,6 +800,7 @@ mod runs {
start_time: Some(ts(created_at)),
status: parse_run_status(status, status_reason)
.unwrap_or_else(|| panic!("invalid demo run status: {status}")),
superseded_by: None,
title: truncate_goal(goal),
total_usd_micros,
workflow_name: Some(workflow_name.into()),

View file

@ -28,15 +28,15 @@ pub use fabro_api::types::{
CompletionMessageRole, CompletionResponse, CompletionToolChoiceMode, CompletionUsage,
CreateCompletionRequest, CreateRunPullRequestRequest, CreateSecretRequest, DeleteSecretRequest,
DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, EventEnvelope as ApiEventEnvelope,
MergeRunPullRequestRequest, MergeRunPullRequestResponse, ModelReference, PaginatedEventList,
PaginatedRunList, PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse,
PruneRunEntry, PruneRunsRequest, PruneRunsResponse, QuestionType as ApiQuestionType,
RenderWorkflowGraphDirection, RenderWorkflowGraphRequest, RunArtifactEntry,
RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, RunError, RunManifest,
RunStage, RunStatusResponse, SandboxFileEntry, SandboxFileListResponse,
SecretType as ApiSecretType, SshAccessRequest, SshAccessResponse,
StageStatus as ApiStageStatus, StartRunRequest, SubmitAnswerRequest, SystemFeatures,
SystemInfoResponse, SystemRunCounts, WriteBlobResponse,
ForkRequest, ForkResponse, MergeRunPullRequestRequest, MergeRunPullRequestResponse,
ModelReference, PaginatedEventList, PaginatedRunList, PaginationMeta, PreflightResponse,
PreviewUrlRequest, PreviewUrlResponse, PruneRunEntry, PruneRunsRequest, PruneRunsResponse,
QuestionType as ApiQuestionType, RenderWorkflowGraphDirection, RenderWorkflowGraphRequest,
RewindRequest, RewindResponse, RunArtifactEntry, RunArtifactListResponse, RunBilling,
RunBillingStage, RunBillingTotals, RunError, RunManifest, RunStage, RunStatusResponse,
SandboxFileEntry, SandboxFileListResponse, SecretType as ApiSecretType, SshAccessRequest,
SshAccessResponse, StageStatus as ApiStageStatus, StartRunRequest, SubmitAnswerRequest,
SystemFeatures, SystemInfoResponse, SystemRunCounts, TimelineEntryResponse, WriteBlobResponse,
};
use fabro_auth::{
CredentialSource, VaultCredentialSource, auth_issue_message, parse_credential_secret,
@ -1199,6 +1199,9 @@ fn real_routes() -> Router<Arc<AppState>> {
.route("/runs/{id}/pause", post(pause_run))
.route("/runs/{id}/unpause", post(unpause_run))
.route("/runs/{id}/archive", post(archive_run))
.route("/runs/{id}/rewind", post(rewind_run))
.route("/runs/{id}/fork", post(fork_run))
.route("/runs/{id}/timeline", get(run_timeline))
.route("/runs/{id}/unarchive", post(unarchive_run))
.route("/runs/{id}/graph", get(get_graph))
.route("/runs/{id}/stages", get(list_run_stages))
@ -2886,6 +2889,7 @@ fn summary_to_api_run_summary(summary: fabro_types::RunSummary) -> serde_json::V
"duration_ms": summary.duration_ms,
"elapsed_secs": elapsed_secs(summary.duration_ms),
"total_usd_micros": summary.total_usd_micros,
"superseded_by": summary.superseded_by.map(|run_id| run_id.to_string()),
"created_at": created_at,
})
}
@ -3426,7 +3430,7 @@ fn reconcile_live_interview_state_for_event(run: &mut ManagedRun, event: &RunEve
EventBody::InterviewInterrupted(props) => {
run.accepted_questions.remove(&props.question_id);
}
EventBody::RunCompleted(_) | EventBody::RunFailed(_) | EventBody::RunRewound(_) => {
EventBody::RunCompleted(_) | EventBody::RunFailed(_) => {
run.accepted_questions.clear();
}
_ => {}
@ -6662,8 +6666,7 @@ fn actor_from_subject(subject: &AuthenticatedSubject) -> Option<ActorRef> {
/// These endpoints enforce authorization and status-transition preconditions
/// (e.g. "archive only from terminal") that a direct event append would
/// bypass. Other run-lifecycle events flow through this endpoint legitimately:
/// the worker subprocess emits state transitions during execution, and the
/// rewind CLI relays `RunRewound` / `RunSubmitted` here.
/// the worker subprocess emits state transitions during execution.
fn denied_lifecycle_event_name(body: &EventBody) -> Option<&'static str> {
match body {
EventBody::RunArchived(_) => Some("run.archived"),
@ -7084,6 +7087,166 @@ async fn unarchive_run(
run_archive_action(state, subject, id, ArchiveAction::Unarchive).await
}
async fn rewind_run(
subject: AuthenticatedSubject,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
body: Option<Json<RewindRequest>>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
let request = body.map(|Json(body)| body).unwrap_or_default();
let target = match parse_fork_target(request.target) {
Ok(target) => target,
Err(err) => return err.into_response(),
};
let input = operations::RewindInput {
run_id: id,
target,
push: request.push.unwrap_or(true),
};
match Box::pin(operations::rewind(
&state.store,
&input,
actor_from_subject(&subject),
))
.await
{
Ok(operations::RewindOutcome::Full {
source_run_id,
new_run_id,
target,
archived,
}) => (
StatusCode::OK,
Json(RewindResponse {
source_run_id: source_run_id.to_string(),
new_run_id: new_run_id.to_string(),
target: target.response_target(),
archived,
archive_error: None,
}),
)
.into_response(),
Ok(operations::RewindOutcome::Partial {
source_run_id,
new_run_id,
target,
archive_error,
}) => (
StatusCode::MULTI_STATUS,
Json(RewindResponse {
source_run_id: source_run_id.to_string(),
new_run_id: new_run_id.to_string(),
target: target.response_target(),
archived: false,
archive_error: Some(archive_error),
}),
)
.into_response(),
Err(err) => workflow_operation_error_response(err),
}
}
async fn fork_run(
_subject: AuthenticatedSubject,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
body: Option<Json<ForkRequest>>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
return response;
}
let request = body.map(|Json(body)| body).unwrap_or_default();
let target = match parse_fork_target(request.target) {
Ok(target) => target,
Err(err) => return err.into_response(),
};
let input = operations::ForkRunInput {
source_run_id: id,
target,
push: request.push.unwrap_or(true),
};
match operations::fork_run(&state.store, &input).await {
Ok(outcome) => (
StatusCode::OK,
Json(ForkResponse {
source_run_id: outcome.source_run_id.to_string(),
new_run_id: outcome.new_run_id.to_string(),
target: outcome.target.response_target(),
}),
)
.into_response(),
Err(err) => workflow_operation_error_response(err),
}
}
async fn run_timeline(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
match operations::timeline(&state.store, &id).await {
Ok(entries) => Json(
entries
.into_iter()
.map(|entry| TimelineEntryResponse {
ordinal: std::num::NonZeroU64::new(entry.ordinal as u64)
.expect("timeline ordinals start at 1"),
node_name: entry.node_name,
visit: std::num::NonZeroU64::new(entry.visit as u64)
.expect("timeline visits start at 1"),
run_commit_sha: entry.run_commit_sha,
})
.collect::<Vec<_>>(),
)
.into_response(),
Err(err) => workflow_operation_error_response(err),
}
}
fn parse_fork_target(target: Option<String>) -> Result<Option<operations::ForkTarget>, ApiError> {
target
.map(|target| {
target
.parse::<operations::ForkTarget>()
.map_err(|err| ApiError::bad_request(err.to_string()))
})
.transpose()
}
fn workflow_operation_error_response(err: WorkflowError) -> Response {
match err {
WorkflowError::Parse(message) | WorkflowError::Validation(message) => {
ApiError::bad_request(message).into_response()
}
WorkflowError::ValidationFailed { .. } => {
ApiError::bad_request("Validation failed").into_response()
}
WorkflowError::Precondition(message) => {
ApiError::new(StatusCode::CONFLICT, message).into_response()
}
WorkflowError::RunNotFound(_) => ApiError::not_found("Run not found.").into_response(),
WorkflowError::Unsupported(message) => {
ApiError::new(StatusCode::NOT_IMPLEMENTED, message).into_response()
}
err => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
}
}
#[derive(Clone, Copy)]
enum ArchiveAction {
Archive,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -58,7 +58,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-1addt5fg.js"></script>
<script type="module" src="/assets/entry-mmvmsphj.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>
@ -72,8 +72,8 @@
<script type="module" src="/assets/chunk-6ma2q84r.js"></script>
<script type="module" src="/assets/chunk-0tq24xt3.js"></script>
<script type="module" src="/assets/chunk-z512569f.js"></script>
<script type="module" src="/assets/chunk-7fpy4pmb.js"></script>
<script type="module" src="/assets/chunk-nghb2mxb.js"></script>
<script type="module" src="/assets/chunk-q2qv5qr4.js"></script>
<script type="module" src="/assets/chunk-tqzz87j8.js"></script>
<script type="module" src="/assets/chunk-w4txx8sc.js"></script>
<script type="module" src="/assets/chunk-eaexpy25.js"></script>
<script type="module" src="/assets/chunk-zb6gezq1.js"></script>

View file

@ -167,8 +167,8 @@ impl RunProjectionReducer for RunProjection {
self.final_patch.clone_from(&props.final_patch);
self.pending_interviews.clear();
}
EventBody::RunRewound(_) => {
self.reset_for_rewind();
EventBody::RunSupersededBy(props) => {
self.superseded_by = Some(props.new_run_id);
}
EventBody::RunArchived(_props) => {
if let Some(current) = self.status {
@ -404,6 +404,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary
.as_ref()
.and_then(|conclusion| conclusion.billing.as_ref())
.and_then(|billing| billing.total_usd_micros),
superseded_by: state.superseded_by,
}
}
@ -1114,6 +1115,30 @@ mod tests {
);
}
#[test]
fn run_superseded_by_populates_projection_and_summary() {
use fabro_types::run_event::RunSupersededByProps;
let mut state = RunProjection::default();
state
.apply_event(&test_event(
1,
EventBody::RunSupersededBy(RunSupersededByProps {
new_run_id: fixtures::RUN_2,
target_checkpoint_ordinal: 2,
target_node_id: "build".to_string(),
target_visit: 1,
}),
None,
))
.unwrap();
assert_eq!(state.superseded_by, Some(fixtures::RUN_2));
let summary = build_summary(&state, &fixtures::RUN_1);
assert_eq!(summary.superseded_by, Some(fixtures::RUN_2));
}
#[test]
fn run_unarchived_restores_prior_status() {
use fabro_types::run_event::{RunArchivedProps, RunCompletedProps, RunUnarchivedProps};

View file

@ -124,8 +124,8 @@ pub enum EventBody {
RunPaused(RunControlEffectProps),
#[serde(rename = "run.unpaused")]
RunUnpaused(RunControlEffectProps),
#[serde(rename = "run.rewound")]
RunRewound(RunRewoundProps),
#[serde(rename = "run.superseded_by")]
RunSupersededBy(RunSupersededByProps),
#[serde(rename = "run.archived")]
RunArchived(RunArchivedProps),
#[serde(rename = "run.unarchived")]
@ -390,7 +390,7 @@ impl EventBody {
Self::RunUnpauseRequested(_) => "run.unpause.requested",
Self::RunPaused(_) => "run.paused",
Self::RunUnpaused(_) => "run.unpaused",
Self::RunRewound(_) => "run.rewound",
Self::RunSupersededBy(_) => "run.superseded_by",
Self::RunArchived(_) => "run.archived",
Self::RunUnarchived(_) => "run.unarchived",
Self::RunCompleted(_) => "run.completed",
@ -521,7 +521,7 @@ fn is_known_event_name(event: &str) -> bool {
| "run.blocked"
| "run.unblocked"
| "run.removing"
| "run.rewound"
| "run.superseded_by"
| "run.archived"
| "run.unarchived"
| "run.completed"

View file

@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use super::{ActorRef, BilledTokenCounts, RunNoticeLevel};
use crate::status::{BlockedReason, FailureReason, SuccessReason};
use crate::{Graph, RunBlobId, RunControlAction, RunProvenance, WorkflowSettings};
use crate::{Graph, RunBlobId, RunControlAction, RunId, RunProvenance, WorkflowSettings};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunCreatedProps {
@ -87,14 +87,11 @@ pub struct RunBlockedProps {
pub struct RunControlEffectProps {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunRewoundProps {
pub struct RunSupersededByProps {
pub new_run_id: RunId,
pub target_checkpoint_ordinal: usize,
pub target_node_id: String,
pub target_visit: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_commit_sha: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View file

@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
use crate::{
Checkpoint, Conclusion, InterviewQuestionRecord, InvalidTransition, NodeStatusRecord,
PullRequestRecord, Retro, RunControlAction, RunSpec, RunStatus, SandboxRecord, StageId,
PullRequestRecord, Retro, RunControlAction, RunId, RunSpec, RunStatus, SandboxRecord, StageId,
StartRecord,
};
@ -26,6 +26,7 @@ pub struct RunProjection {
pub sandbox: Option<SandboxRecord>,
pub final_patch: Option<String>,
pub pull_request: Option<PullRequestRecord>,
pub superseded_by: Option<RunId>,
pub pending_interviews: BTreeMap<String, PendingInterviewRecord>,
nodes: HashMap<StageId, NodeState>,
}
@ -130,21 +131,4 @@ impl RunProjection {
}
}
}
pub fn reset_for_rewind(&mut self) {
self.status = None;
self.status_updated_at = None;
self.pending_control = None;
self.checkpoint = None;
self.checkpoints.clear();
self.conclusion = None;
self.retro = None;
self.retro_prompt = None;
self.retro_response = None;
self.sandbox = None;
self.final_patch = None;
self.pull_request = None;
self.pending_interviews.clear();
self.nodes.clear();
}
}

View file

@ -18,6 +18,8 @@ pub struct RunSummary {
pub pending_control: Option<RunControlAction>,
pub duration_ms: Option<u64>,
pub total_usd_micros: Option<i64>,
#[serde(default)]
pub superseded_by: Option<RunId>,
}
#[cfg(test)]
@ -45,6 +47,7 @@ mod tests {
pending_control: Some(RunControlAction::Pause),
duration_ms: Some(42),
total_usd_micros: Some(123),
superseded_by: Some(fixtures::RUN_2),
};
let value = serde_json::to_value(&summary).unwrap();

View file

@ -235,6 +235,9 @@ pub enum Error {
#[error("Run not found: {0}")]
RunNotFound(String),
#[error("Unsupported operation: {0}")]
Unsupported(String),
#[error("Pipeline cancelled")]
Cancelled,
}
@ -281,6 +284,7 @@ impl Error {
| Self::Checkpoint(_)
| Self::Precondition(_)
| Self::RunNotFound(_)
| Self::Unsupported(_)
| Self::Cancelled => false,
}
}
@ -296,7 +300,8 @@ impl Error {
| Self::Validation(_)
| Self::ValidationFailed { .. }
| Self::Stylesheet(_)
| Self::Checkpoint(_) => FailureCategory::Deterministic,
| Self::Checkpoint(_)
| Self::Unsupported(_) => FailureCategory::Deterministic,
Self::Precondition(_) | Self::RunNotFound(_) => FailureCategory::Structural,
Self::Handler { failure_class, .. } | Self::Engine { failure_class, .. } => {
*failure_class

View file

@ -102,14 +102,11 @@ pub enum Event {
},
RunPaused,
RunUnpaused,
RunRewound {
RunSupersededBy {
new_run_id: RunId,
target_checkpoint_ordinal: usize,
target_node_id: String,
target_visit: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
previous_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
run_commit_sha: Option<String>,
},
RunArchived {
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -623,20 +620,18 @@ impl Event {
Self::RunUnpaused => {
info!("Run unpaused");
}
Self::RunRewound {
Self::RunSupersededBy {
new_run_id,
target_checkpoint_ordinal,
target_node_id,
target_visit,
previous_status,
run_commit_sha,
} => {
info!(
%new_run_id,
target_checkpoint_ordinal,
target_node_id,
target_visit,
previous_status = previous_status.as_deref().unwrap_or(""),
run_commit_sha = run_commit_sha.as_deref().unwrap_or(""),
"Run rewound"
"Run superseded by new run"
);
}
Self::RunArchived { actor } => {
@ -1188,7 +1183,7 @@ pub fn event_name(event: &Event) -> &'static str {
Event::RunUnpauseRequested { .. } => "run.unpause.requested",
Event::RunPaused => "run.paused",
Event::RunUnpaused => "run.unpaused",
Event::RunRewound { .. } => "run.rewound",
Event::RunSupersededBy { .. } => "run.superseded_by",
Event::RunArchived { .. } => "run.archived",
Event::RunUnarchived { .. } => "run.unarchived",
Event::WorkflowRunCompleted { .. } => "run.completed",
@ -1596,18 +1591,16 @@ fn event_body_from_event(event: &Event) -> EventBody {
}
Event::RunPaused => EventBody::RunPaused(fabro_types::RunControlEffectProps::default()),
Event::RunUnpaused => EventBody::RunUnpaused(fabro_types::RunControlEffectProps::default()),
Event::RunRewound {
Event::RunSupersededBy {
new_run_id,
target_checkpoint_ordinal,
target_node_id,
target_visit,
previous_status,
run_commit_sha,
} => EventBody::RunRewound(fabro_types::RunRewoundProps {
} => EventBody::RunSupersededBy(fabro_types::RunSupersededByProps {
new_run_id: *new_run_id,
target_checkpoint_ordinal: *target_checkpoint_ordinal,
target_node_id: target_node_id.clone(),
target_visit: *target_visit,
previous_status: previous_status.clone(),
run_commit_sha: run_commit_sha.clone(),
}),
Event::RunArchived { actor } => EventBody::RunArchived(fabro_types::RunArchivedProps {
actor: actor.clone(),

View file

@ -1,11 +1,14 @@
use anyhow::{Context, Result};
use fabro_checkpoint::branch::BranchStore;
use fabro_checkpoint::git::Store;
use fabro_store::RunProjection;
use fabro_store::{Database, RunProjection};
use fabro_types::RunId;
use git2::{Oid, Signature};
use super::rewind::{RewindTarget, TimelineEntry, build_timeline};
use super::run_git;
use super::timeline::{ForkTarget, 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;
@ -13,10 +16,37 @@ use crate::run_dump::RunDump;
#[derive(Debug, Clone)]
pub struct ForkRunInput {
pub source_run_id: RunId,
pub target: Option<RewindTarget>,
pub target: Option<ForkTarget>,
pub push: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedForkTarget {
pub checkpoint_ordinal: usize,
pub node_id: String,
pub visit: usize,
}
impl ResolvedForkTarget {
#[must_use]
pub fn response_target(&self) -> String {
format!("@{}", self.checkpoint_ordinal)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForkOutcome {
pub source_run_id: RunId,
pub new_run_id: RunId,
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.
///
@ -29,7 +59,45 @@ pub fn fork(store: &Store, input: &ForkRunInput) -> Result<RunId> {
anyhow::anyhow!("no checkpoints found for run {}", input.source_run_id)
})?,
};
fork_from_entry(store, &input.source_run_id, entry, input.push)
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> {
let source_run_id = input.source_run_id;
let target = input.target.clone();
let push = input.push;
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 = match target.as_ref() {
Some(target) => timeline
.resolve(target)
.map_err(|err| Error::Validation(err.to_string()))?,
None => timeline.entries.last().ok_or_else(|| {
Error::Validation(format!("no checkpoints found for run {source_run_id}"))
})?,
};
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?;
persist_forked_run(store, &projection).await?;
Ok(outcome)
}
fn fork_from_entry(
@ -37,7 +105,7 @@ fn fork_from_entry(
source_run_id: &RunId,
entry: &TimelineEntry,
push: bool,
) -> Result<RunId> {
) -> Result<ForkedRun> {
let new_run_id = RunId::new();
let sig = Signature::now("Fabro", "noreply@fabro.sh")?;
@ -86,7 +154,7 @@ fn fork_from_entry(
run_id: new_run_id,
start_time: new_run_id.created_at(),
run_branch: Some(new_run_branch.clone()),
base_sha: None,
base_sha: entry.run_commit_sha.clone(),
};
let mut init_projection = RunProjection::default();
@ -156,7 +224,123 @@ fn fork_from_entry(
)?;
}
Ok(new_run_id)
Ok(ForkedRun {
new_run_id,
projection: checkpoint_projection,
})
}
async fn persist_forked_run(store: &Database, projection: &RunProjection) -> 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()
.ok_or_else(|| Error::engine("forked run projection has no checkpoint"))?;
let run_store = store
.create_run(&spec.run_id)
.await
.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)
.map_err(|err| Error::engine(err.to_string()))?,
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,
})
.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(),
})
.await
.map_err(|err| Error::engine(err.to_string()))?;
}
event::append_event(
&run_store,
&spec.run_id,
&checkpoint_completed_event(checkpoint),
)
.await
.map_err(|err| Error::engine(err.to_string()))?;
event::append_event(&run_store, &spec.run_id, &Event::RunSubmitted {
definition_blob: spec.definition_blob,
})
.await
.map_err(|err| Error::engine(err.to_string()))
}
fn checkpoint_completed_event(checkpoint: &Checkpoint) -> Event {
let status = checkpoint
.node_outcomes
.get(&checkpoint.current_node)
.map_or_else(
|| "success".to_string(),
|outcome| outcome.status.to_string(),
);
Event::CheckpointCompleted {
node_id: checkpoint.current_node.clone(),
status,
current_node: checkpoint.current_node.clone(),
completed_nodes: checkpoint.completed_nodes.clone(),
node_retries: checkpoint.node_retries.clone().into_iter().collect(),
context_values: checkpoint.context_values.clone().into_iter().collect(),
node_outcomes: checkpoint.node_outcomes.clone().into_iter().collect(),
next_node_id: checkpoint.next_node_id.clone(),
git_commit_sha: checkpoint.git_commit_sha.clone(),
loop_failure_signatures: checkpoint
.loop_failure_signatures
.iter()
.map(|(signature, count)| (signature.to_string(), *count))
.collect(),
restart_failure_signatures: checkpoint
.restart_failure_signatures
.iter()
.map(|(signature, count)| (signature.to_string(), *count))
.collect(),
node_visits: checkpoint.node_visits.clone().into_iter().collect(),
diff: None,
}
}
#[cfg(test)]
@ -275,7 +459,7 @@ mod tests {
let new_run_id = fork(&store, &ForkRunInput {
source_run_id,
target: Some(RewindTarget::from_str("@2").unwrap()),
target: Some(ForkTarget::from_str("@2").unwrap()),
push: false,
})
.unwrap();

View file

@ -4,10 +4,12 @@ mod fork;
mod rebuild_meta;
mod resume;
mod rewind;
mod run_git;
mod source;
mod start;
#[cfg(test)]
mod test_support;
mod timeline;
mod validate;
pub use archive::{
@ -15,17 +17,17 @@ pub use archive::{
unarchive,
};
pub use create::{CreateRunInput, CreatedRun, create, make_run_dir};
pub use fork::{ForkRunInput, fork};
pub use fork::{ForkOutcome, ForkRunInput, ResolvedForkTarget, fork, 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, RewindTarget, RunTimeline, TimelineEntry, build_timeline, find_run_id_by_prefix,
rewind,
};
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 validate::{ValidateInput, validate};
pub use crate::pipeline::{DevcontainerSpec, LlmSpec, SandboxEnvSpec};

View file

@ -13,7 +13,9 @@ use git2::{Repository, Signature};
use tokio::task::spawn_blocking;
use ulid::Ulid;
use super::rewind::{self, RunTimeline, build_timeline};
use super::timeline::{
RunTimeline, build_timeline, find_run_id_by_prefix_opt, run_commit_shas_by_node,
};
use crate::git::MetadataStore;
use crate::records::Checkpoint;
use crate::run_dump::RunDump;
@ -147,7 +149,7 @@ pub async fn find_run_id_by_prefix_or_store(
fabro_store: &DurableStore,
prefix: &str,
) -> Result<RunId> {
if let Some(run_id) = rewind::find_run_id_by_prefix_opt(repo, prefix)? {
if let Some(run_id) = find_run_id_by_prefix_opt(repo, prefix)? {
return Ok(run_id);
}
@ -241,7 +243,7 @@ fn backfill_missing_checkpoint_shas(
return;
}
let node_commits = rewind::run_commit_shas_by_node(git_store, &run_id.to_string());
let node_commits = run_commit_shas_by_node(git_store, &run_id.to_string());
let mut node_indices: HashMap<String, usize> = HashMap::new();
for (_seq, checkpoint) in checkpoints.iter_mut() {

View file

@ -1,593 +1,112 @@
use std::collections::HashMap;
use std::fmt::Write;
use std::str::FromStr;
use fabro_store::Database;
use fabro_types::{ActorRef, RunId, RunStatus};
use tracing::error;
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::RunProjection;
use fabro_types::{RunId, RunStatus};
use git2::{Oid, Repository, Signature};
use super::archive::ensure_not_archived;
use crate::git::{MetadataStore, RUN_BRANCH_PREFIX, push_run_branches};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RewindTarget {
Ordinal(usize),
LatestVisit(String),
SpecificVisit(String, usize),
}
impl FromStr for RewindTarget {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
if let Some(rest) = s.strip_prefix('@') {
let n: usize = rest
.parse()
.with_context(|| format!("invalid ordinal: @{rest}"))?;
if n == 0 {
bail!("ordinal must be >= 1");
}
return Ok(Self::Ordinal(n));
}
if let Some(at_pos) = s.rfind('@') {
let name = &s[..at_pos];
let visit_str = &s[at_pos + 1..];
if !name.is_empty() && !visit_str.is_empty() {
if let Ok(visit) = visit_str.parse::<usize>() {
if visit == 0 {
bail!("visit number must be >= 1");
}
return Ok(Self::SpecificVisit(name.to_string(), visit));
}
}
}
Ok(Self::LatestVisit(s.to_string()))
}
}
#[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>,
}
#[derive(Debug, Clone)]
pub struct RunTimeline {
pub entries: Vec<TimelineEntry>,
pub parallel_map: HashMap<String, String>,
}
impl RunTimeline {
pub fn resolve(&self, target: &RewindTarget) -> Result<&TimelineEntry> {
match target {
RewindTarget::Ordinal(n) => {
self.entries
.iter()
.find(|e| e.ordinal == *n)
.ok_or_else(|| {
anyhow::anyhow!("ordinal @{n} out of range (max @{})", self.entries.len())
})
}
RewindTarget::LatestVisit(name) => {
let effective_name = self.parallel_map.get(name).unwrap_or(name);
self.entries
.iter()
.rev()
.find(|e| e.node_name == *effective_name)
.ok_or_else(|| {
if effective_name == name {
anyhow::anyhow!("no checkpoint found for node '{name}'")
} else {
anyhow::anyhow!(
"node '{name}' is inside parallel '{effective_name}'; \
no checkpoint found for '{effective_name}'"
)
}
})
}
RewindTarget::SpecificVisit(name, visit) => {
let effective_name = self.parallel_map.get(name).unwrap_or(name);
self.entries
.iter()
.find(|e| e.node_name == *effective_name && e.visit == *visit)
.ok_or_else(|| {
if effective_name == name {
anyhow::anyhow!("no visit {visit} found for node '{name}'")
} else {
anyhow::anyhow!(
"node '{name}' is inside parallel '{effective_name}'; \
no visit {visit} found for '{effective_name}'"
)
}
})
}
}
}
}
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};
#[derive(Debug, Clone)]
pub struct RewindInput {
pub run_id: RunId,
pub target: RewindTarget,
pub push: bool,
/// Current durable run status. Callers must load this from the projection
/// store before calling rewind so the archived-run precondition can be
/// enforced here rather than by an upstream check that can drift.
pub current_status: RunStatus,
pub run_id: RunId,
pub target: Option<ForkTarget>,
pub push: bool,
}
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);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RewindOutcome {
Full {
source_run_id: RunId,
new_run_id: RunId,
target: ResolvedForkTarget,
archived: bool,
},
Partial {
source_run_id: RunId,
new_run_id: RunId,
target: ResolvedForkTarget,
archive_error: String,
},
}
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();
pub async fn rewind(
store: &Database,
input: &RewindInput,
actor: Option<ActorRef>,
) -> Result<RewindOutcome, Error> {
let projection = run_git::load_projection(store, &input.run_id).await?;
let current = projection.status.ok_or_else(|| {
Error::Precondition(format!("run {} has no status; cannot rewind", input.run_id))
})?;
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 {
ordinal,
node_name: cp.current_node.clone(),
visit,
metadata_commit_oid: commit.oid,
run_commit_sha: cp.git_commit_sha.clone(),
});
if matches!(current, RunStatus::Archived { .. }) {
return Err(Error::Precondition(archive::archived_rejection_message(
&input.run_id,
)));
}
if !matches!(
current,
RunStatus::Succeeded { .. } | RunStatus::Failed { .. } | RunStatus::Dead
) {
return Err(Error::Precondition(format!(
"run {} must be terminal (succeeded, failed, or dead) to rewind; current status is {current}",
input.run_id
)));
}
backfill_run_shas(store, run_id, &mut timeline);
Ok(RunTimeline {
entries: timeline,
parallel_map: load_parallel_map(store, run_id),
let forked = fork::fork_run(store, &ForkRunInput {
source_run_id: input.run_id,
target: input.target.clone(),
push: input.push,
})
}
.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;
}
match archive::archive(store, &input.run_id, actor).await {
Ok(_) => {
append_superseded_event(store, &forked).await;
Ok(RewindOutcome::Full {
source_run_id: forked.source_run_id,
new_run_id: forked.new_run_id,
target: forked.target,
archived: true,
})
}
Err(err) => Ok(RewindOutcome::Partial {
source_run_id: forked.source_run_id,
new_run_id: forked.new_run_id,
target: forked.target,
archive_error: err.to_string(),
}),
}
}
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
}
fn detect_parallel_interior(graph: &Graph) -> HashMap<String, String> {
let mut interior_map = HashMap::new();
for node in graph.nodes.values() {
if node.handler_type() != Some("parallel") {
continue;
}
let parallel_id = &node.id;
let mut queue: Vec<String> = graph
.outgoing_edges(parallel_id)
.iter()
.map(|e| e.to.clone())
.collect();
let mut visited = std::collections::HashSet::new();
while let Some(current) = queue.pop() {
if !visited.insert(current.clone()) {
continue;
}
if let Some(n) = graph.nodes.get(&current) {
if n.handler_type() == Some("parallel.fan_in") {
continue;
}
}
interior_map.insert(current.clone(), parallel_id.clone());
for edge in graph.outgoing_edges(&current) {
queue.push(edge.to.clone());
}
}
}
interior_map
}
pub fn rewind(store: &Store, input: &RewindInput) -> Result<()> {
ensure_not_archived(Some(input.current_status), &input.run_id)
.map_err(|err| anyhow::anyhow!("{err}"))?;
let timeline = build_timeline(store, &input.run_id.to_string())?;
let entry = timeline.resolve(&input.target)?;
rewind_to_entry(store, &input.run_id, entry, input.push)
}
#[allow(
clippy::print_stderr,
reason = "Git rewind status is operator feedback and should stay off stdout."
)]
fn rewind_to_entry(store: &Store, run_id: &RunId, entry: &TimelineEntry, push: bool) -> Result<()> {
let meta_branch = MetadataStore::branch_name(&run_id.to_string());
store
.update_ref(&meta_branch, entry.metadata_commit_oid)
.map_err(|e| anyhow::anyhow!("failed to update metadata ref: {e}"))?;
eprintln!(
"Rewound metadata branch to @{} ({})",
entry.ordinal, entry.node_name
);
let run_branch = format!("{RUN_BRANCH_PREFIX}{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(&run_branch, oid)
.map_err(|e| anyhow::anyhow!("failed to update run branch ref: {e}"))?;
eprintln!(
"Rewound run branch {}{run_id} to {}",
RUN_BRANCH_PREFIX,
&sha[..8]
async fn append_superseded_event(store: &Database, forked: &ForkOutcome) {
let run_store = match store.open_run(&forked.source_run_id).await {
Ok(run_store) => run_store,
Err(err) => {
error!(
source_run_id = %forked.source_run_id,
new_run_id = %forked.new_run_id,
error = %err,
"failed to open run for RunSupersededBy append after archive"
);
return;
}
None => {
eprintln!(
"Warning: checkpoint @{} has no git_commit_sha; run branch not moved",
entry.ordinal
);
}
}
if push {
let run_refspec = entry
.run_commit_sha
.as_ref()
.map(|_| format!("+refs/heads/{run_branch}:refs/heads/{run_branch}"));
let meta_refspec = format!("+refs/heads/{meta_branch}:refs/heads/{meta_branch}");
push_run_branches(
store,
&run_branch,
run_refspec.as_deref(),
&meta_refspec,
"rewound",
)?;
}
Ok(())
}
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 {
return detect_parallel_interior(&spec.graph);
}
let Some(dot_source) = projection.graph_source else {
return HashMap::new();
let event = Event::RunSupersededBy {
new_run_id: forked.new_run_id,
target_checkpoint_ordinal: forked.target.checkpoint_ordinal,
target_node_id: forked.target.node_id.clone(),
target_visit: forked.target.visit,
};
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, RunStatus, SuccessReason, TerminalStatus, fixtures};
use super::super::test_support::*;
use super::*;
fn parse_run_id(value: &str) -> RunId {
value.parse().unwrap()
}
fn checkpoint_projection_json(
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(),
if let Err(err) = event::append_event(&run_store, &forked.source_run_id, &event).await {
error!(
source_run_id = %forked.source_run_id,
new_run_id = %forked.new_run_id,
error = %err,
"failed to append RunSupersededBy after archive"
);
serde_json::to_vec_pretty(&projection).unwrap()
}
#[test]
fn parse_target_ordinal() {
assert_eq!(
"@4".parse::<RewindTarget>().unwrap(),
RewindTarget::Ordinal(4)
);
}
#[test]
fn parse_target_latest_visit() {
assert_eq!(
"step2".parse::<RewindTarget>().unwrap(),
RewindTarget::LatestVisit("step2".to_string())
);
}
#[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();
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();
assert_eq!(timeline.entries.len(), 2);
assert_eq!(timeline.entries[0].node_name, "start");
assert_eq!(timeline.entries[1].node_name, "build");
}
#[test]
fn resolve_latest_visit() {
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()),
},
TimelineEntry {
ordinal: 2,
node_name: "build".to_string(),
visit: 1,
metadata_commit_oid: Oid::zero(),
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()),
},
],
parallel_map: HashMap::new(),
};
let entry = timeline
.resolve(&RewindTarget::LatestVisit("build".to_string()))
.unwrap();
assert_eq!(entry.ordinal, 3);
}
#[test]
fn parallel_interior_detection() {
let mut graph = Graph::new("test");
let mut parallel_node = fabro_graphviz::graph::Node::new("parallel1");
parallel_node.attrs.insert(
"shape".to_string(),
fabro_graphviz::graph::AttrValue::String("component".to_string()),
);
graph.nodes.insert("parallel1".to_string(), parallel_node);
let mut fan_in = fabro_graphviz::graph::Node::new("fan_in1");
fan_in.attrs.insert(
"shape".to_string(),
fabro_graphviz::graph::AttrValue::String("tripleoctagon".to_string()),
);
graph.nodes.insert("fan_in1".to_string(), fan_in);
let mut a = fabro_graphviz::graph::Node::new("a");
a.attrs.insert(
"shape".to_string(),
fabro_graphviz::graph::AttrValue::String("box".to_string()),
);
graph.nodes.insert("a".to_string(), a);
graph.edges.push(fabro_graphviz::graph::Edge {
from: "parallel1".to_string(),
to: "a".to_string(),
attrs: HashMap::new(),
});
graph.edges.push(fabro_graphviz::graph::Edge {
from: "a".to_string(),
to: "fan_in1".to_string(),
attrs: HashMap::new(),
});
let map = detect_parallel_interior(&graph);
assert_eq!(map.get("a"), Some(&"parallel1".to_string()));
assert!(!map.contains_key("parallel1"));
}
#[test]
fn rewind_moves_metadata_ref() {
let (_dir, store) = temp_repo();
let sig = test_sig();
let branch = MetadataStore::branch_name(&fixtures::RUN_1.to_string());
let bs = BranchStore::new(&store, &branch, &sig);
bs.ensure_branch().unwrap();
bs.write_entry("run.json", b"{}", "init run").unwrap();
let cp1 = checkpoint_projection_json("start", 1, None);
let oid1 = bs.write_entry("run.json", &cp1, "checkpoint").unwrap();
let cp2 = checkpoint_projection_json("build", 1, None);
bs.write_entry("run.json", &cp2, "checkpoint").unwrap();
rewind(&store, &RewindInput {
run_id: fixtures::RUN_1,
target: RewindTarget::Ordinal(1),
push: false,
current_status: RunStatus::Succeeded {
reason: SuccessReason::Completed,
},
})
.unwrap();
let resolved = store.resolve_ref(&branch).unwrap().unwrap();
assert_eq!(resolved, oid1);
}
#[test]
fn rewind_rejects_archived_runs() {
let (_dir, store) = temp_repo();
let err = rewind(&store, &RewindInput {
run_id: fixtures::RUN_1,
target: RewindTarget::Ordinal(1),
push: false,
current_status: RunStatus::Archived {
prior: TerminalStatus::Succeeded {
reason: SuccessReason::Completed,
},
},
})
.unwrap_err();
let message = err.to_string();
assert!(
message.contains("is archived") && message.contains("fabro unarchive"),
"expected archived-rejection message, got: {message}"
);
}
#[test]
fn find_run_id_prefix_match() {
let (_dir, store) = temp_repo();
let sig = test_sig();
let run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
let branch = MetadataStore::branch_name(&run_id.to_string());
let bs = BranchStore::new(&store, &branch, &sig);
bs.ensure_branch().unwrap();
let result = find_run_id_by_prefix(store.repo(), "01ARZ3").unwrap();
assert_eq!(result, run_id);
}
}

View file

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

View file

@ -0,0 +1,479 @@
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 {
Ordinal(usize),
LatestVisit(String),
SpecificVisit(String, usize),
}
impl FromStr for ForkTarget {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
if let Some(rest) = s.strip_prefix('@') {
let n: usize = rest
.parse()
.with_context(|| format!("invalid ordinal: @{rest}"))?;
if n == 0 {
bail!("ordinal must be >= 1");
}
return Ok(Self::Ordinal(n));
}
if let Some(at_pos) = s.rfind('@') {
let name = &s[..at_pos];
let visit_str = &s[at_pos + 1..];
if !name.is_empty() && !visit_str.is_empty() {
if let Ok(visit) = visit_str.parse::<usize>() {
if visit == 0 {
bail!("visit number must be >= 1");
}
return Ok(Self::SpecificVisit(name.to_string(), visit));
}
}
}
Ok(Self::LatestVisit(s.to_string()))
}
}
#[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>,
}
#[derive(Debug, Clone)]
pub struct RunTimeline {
pub entries: Vec<TimelineEntry>,
pub parallel_map: HashMap<String, String>,
}
impl RunTimeline {
pub fn resolve(&self, target: &ForkTarget) -> Result<&TimelineEntry> {
match target {
ForkTarget::Ordinal(n) => {
self.entries
.iter()
.find(|e| e.ordinal == *n)
.ok_or_else(|| {
anyhow::anyhow!("ordinal @{n} out of range (max @{})", self.entries.len())
})
}
ForkTarget::LatestVisit(name) => {
let effective_name = self.parallel_map.get(name).unwrap_or(name);
self.entries
.iter()
.rev()
.find(|e| e.node_name == *effective_name)
.ok_or_else(|| {
if effective_name == name {
anyhow::anyhow!("no checkpoint found for node '{name}'")
} else {
anyhow::anyhow!(
"node '{name}' is inside parallel '{effective_name}'; \
no checkpoint found for '{effective_name}'"
)
}
})
}
ForkTarget::SpecificVisit(name, visit) => {
let effective_name = self.parallel_map.get(name).unwrap_or(name);
self.entries
.iter()
.find(|e| e.node_name == *effective_name && e.visit == *visit)
.ok_or_else(|| {
if effective_name == name {
anyhow::anyhow!("no visit {visit} found for node '{name}'")
} else {
anyhow::anyhow!(
"node '{name}' is inside parallel '{effective_name}'; \
no visit {visit} found for '{effective_name}'"
)
}
})
}
}
}
}
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 {
ordinal,
node_name: cp.current_node.clone(),
visit,
metadata_commit_oid: commit.oid,
run_commit_sha: cp.git_commit_sha.clone(),
});
}
backfill_run_shas(store, run_id, &mut timeline);
Ok(RunTimeline {
entries: timeline,
parallel_map: load_parallel_map(store, run_id),
})
}
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
}
fn detect_parallel_interior(graph: &Graph) -> HashMap<String, String> {
let mut interior_map = HashMap::new();
for node in graph.nodes.values() {
if node.handler_type() != Some("parallel") {
continue;
}
let parallel_id = &node.id;
let mut queue: Vec<String> = graph
.outgoing_edges(parallel_id)
.iter()
.map(|e| e.to.clone())
.collect();
let mut visited = std::collections::HashSet::new();
while let Some(current) = queue.pop() {
if !visited.insert(current.clone()) {
continue;
}
if let Some(n) = graph.nodes.get(&current) {
if n.handler_type() == Some("parallel.fan_in") {
continue;
}
}
interior_map.insert(current.clone(), parallel_id.clone());
for edge in graph.outgoing_edges(&current) {
queue.push(edge.to.clone());
}
}
}
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 {
return detect_parallel_interior(&spec.graph);
}
let Some(dot_source) = projection.graph_source else {
return HashMap::new();
};
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 super::super::test_support::*;
use super::*;
fn parse_run_id(value: &str) -> RunId {
value.parse().unwrap()
}
fn checkpoint_projection_json(
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()
}
#[test]
fn parse_target_ordinal() {
assert_eq!("@4".parse::<ForkTarget>().unwrap(), ForkTarget::Ordinal(4));
}
#[test]
fn parse_target_latest_visit() {
assert_eq!(
"step2".parse::<ForkTarget>().unwrap(),
ForkTarget::LatestVisit("step2".to_string())
);
}
#[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();
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();
assert_eq!(timeline.entries.len(), 2);
assert_eq!(timeline.entries[0].node_name, "start");
assert_eq!(timeline.entries[1].node_name, "build");
}
#[test]
fn resolve_latest_visit() {
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()),
},
TimelineEntry {
ordinal: 2,
node_name: "build".to_string(),
visit: 1,
metadata_commit_oid: Oid::zero(),
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()),
},
],
parallel_map: HashMap::new(),
};
let entry = timeline
.resolve(&ForkTarget::LatestVisit("build".to_string()))
.unwrap();
assert_eq!(entry.ordinal, 3);
}
#[test]
fn parallel_interior_detection() {
let mut graph = Graph::new("test");
let mut parallel_node = fabro_graphviz::graph::Node::new("parallel1");
parallel_node.attrs.insert(
"shape".to_string(),
fabro_graphviz::graph::AttrValue::String("component".to_string()),
);
graph.nodes.insert("parallel1".to_string(), parallel_node);
let mut fan_in = fabro_graphviz::graph::Node::new("fan_in1");
fan_in.attrs.insert(
"shape".to_string(),
fabro_graphviz::graph::AttrValue::String("tripleoctagon".to_string()),
);
graph.nodes.insert("fan_in1".to_string(), fan_in);
let mut a = fabro_graphviz::graph::Node::new("a");
a.attrs.insert(
"shape".to_string(),
fabro_graphviz::graph::AttrValue::String("box".to_string()),
);
graph.nodes.insert("a".to_string(), a);
graph.edges.push(fabro_graphviz::graph::Edge {
from: "parallel1".to_string(),
to: "a".to_string(),
attrs: HashMap::new(),
});
graph.edges.push(fabro_graphviz::graph::Edge {
from: "a".to_string(),
to: "fan_in1".to_string(),
attrs: HashMap::new(),
});
let map = detect_parallel_interior(&graph);
assert_eq!(map.get("a"), Some(&"parallel1".to_string()));
assert!(!map.contains_key("parallel1"));
}
#[test]
fn find_run_id_prefix_match() {
let (_dir, store) = temp_repo();
let sig = test_sig();
let run_id = parse_run_id("01ARZ3NDEKTSV4RRFFQ69G5FAV");
let branch = MetadataStore::branch_name(&run_id.to_string());
let bs = BranchStore::new(&store, &branch, &sig);
bs.ensure_branch().unwrap();
let result = find_run_id_by_prefix(store.repo(), "01ARZ3").unwrap();
assert_eq!(result, run_id);
}
}

View file

@ -71,6 +71,8 @@ models/failure-reason.ts
models/features-namespace.ts
models/file-checkpoint.ts
models/file-diff.ts
models/fork-request.ts
models/fork-response.ts
models/git-hub-meta-hooks-entry.ts
models/github-integration-settings.ts
models/github-integration-strategy.ts
@ -159,6 +161,8 @@ models/render-workflow-graph-request.ts
models/repo-check-response-permissions.ts
models/repo-check-response.ts
models/repository-reference.ts
models/rewind-request.ts
models/rewind-response.ts
models/root-response-urls.ts
models/root-response.ts
models/run-artifact-entry.ts
@ -193,6 +197,7 @@ models/run-status-starting.ts
models/run-status-submitted.ts
models/run-status-succeeded.ts
models/run-status.ts
models/run-superseded-by-props.ts
models/run-timings.ts
models/sandbox-file-entry.ts
models/sandbox-file-list-response.ts
@ -235,6 +240,7 @@ models/system-run-counts.ts
models/system-stage-turn.ts
models/teams-integration-settings.ts
models/terminal-status.ts
models/timeline-entry-response.ts
models/tool-stage-turn.ts
models/tool-use.ts
models/user-response.ts

View file

@ -5,7 +5,7 @@
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
@ -28,6 +28,10 @@ import type { CreateRunPullRequestRequest } from '../models';
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { ForkRequest } from '../models';
// @ts-ignore
import type { ForkResponse } from '../models';
// @ts-ignore
import type { MergeRunPullRequestRequest } from '../models';
// @ts-ignore
import type { MergeRunPullRequestResponse } from '../models';
@ -44,6 +48,10 @@ import type { PullRequestRecord } from '../models';
// @ts-ignore
import type { RenderWorkflowGraphRequest } from '../models';
// @ts-ignore
import type { RewindRequest } from '../models';
// @ts-ignore
import type { RewindResponse } from '../models';
// @ts-ignore
import type { RunManifest } from '../models';
// @ts-ignore
import type { RunStatusResponse } from '../models';
@ -51,13 +59,15 @@ import type { RunStatusResponse } from '../models';
import type { StartRunRequest } from '../models';
// @ts-ignore
import type { StoreRunSummary } from '../models';
// @ts-ignore
import type { TimelineEntryResponse } from '../models';
/**
* RunsApi - axios parameter creator
*/
export const RunsApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* @summary Archive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -179,7 +189,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
/**
* Creates a new workflow run in `submitted` status from a self-contained manifest.
* @summary Create Run
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -221,7 +231,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
* Creates a pull request for a completed run on GitHub and persists the record on the server.
* @summary Create Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -307,6 +317,49 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* @summary Fork Run
* @param {string} id Unique run identifier (ULID).
* @param {ForkRequest} [forkRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
forkRun: async (id: string, forkRequest?: ForkRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('forkRun', 'id', id)
const localVarPath = `/api/v1/runs/{id}/fork`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(forkRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Returns the stored pull request record for a run plus live GitHub details.
* @summary Get Run Pull Request
@ -347,6 +400,46 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
* Returns checkpoint timeline entries read from the run metadata branch. This endpoint does not rebuild missing metadata branches.
* @summary Get Run Timeline
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getRunTimeline: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('getRunTimeline', 'id', id)
const localVarPath = `/api/v1/runs/{id}/timeline`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Temporary board-view list of managed runs. This endpoint is UI-oriented and may change as the app evolves.
* @summary List Board Runs
@ -448,7 +541,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
* Merges the stored pull request for a run on GitHub.
* @summary Merge Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -532,7 +625,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
/**
* Validates and renders a workflow manifest as SVG without creating a run.
* @summary Render Workflow Graph
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -693,10 +786,53 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* @summary Rewind Run
* @param {string} id Unique run identifier (ULID).
* @param {RewindRequest} [rewindRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
rewindRun: async (id: string, rewindRequest?: RewindRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('rewindRun', 'id', id)
const localVarPath = `/api/v1/runs/{id}/rewind`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(rewindRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Validates a workflow manifest without creating a run.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -738,7 +874,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* @summary Start Run
* @param {string} id Unique run identifier (ULID).
* @param {StartRunRequest} [startRunRequest]
* @param {StartRunRequest} [startRunRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -778,7 +914,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
};
},
/**
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* @summary Unarchive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -867,7 +1003,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = RunsApiAxiosParamCreator(configuration)
return {
/**
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* @summary Archive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -908,7 +1044,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
/**
* Creates a new workflow run in `submitted` status from a self-contained manifest.
* @summary Create Run
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -922,7 +1058,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* Creates a pull request for a completed run on GitHub and persists the record on the server.
* @summary Create Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -946,6 +1082,20 @@ export const RunsApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['RunsApi.deleteRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* @summary Fork Run
* @param {string} id Unique run identifier (ULID).
* @param {ForkRequest} [forkRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async forkRun(id: string, forkRequest?: ForkRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ForkResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.forkRun(id, forkRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.forkRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns the stored pull request record for a run plus live GitHub details.
* @summary Get Run Pull Request
@ -959,6 +1109,19 @@ export const RunsApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['RunsApi.getRunPullRequest']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns checkpoint timeline entries read from the run metadata branch. This endpoint does not rebuild missing metadata branches.
* @summary Get Run Timeline
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getRunTimeline(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<TimelineEntryResponse>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getRunTimeline(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.getRunTimeline']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Temporary board-view list of managed runs. This endpoint is UI-oriented and may change as the app evolves.
* @summary List Board Runs
@ -992,7 +1155,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* Merges the stored pull request for a run on GitHub.
* @summary Merge Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1018,7 +1181,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
/**
* Validates and renders a workflow manifest as SVG without creating a run.
* @summary Render Workflow Graph
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1067,10 +1230,24 @@ export const RunsApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['RunsApi.retrieveRunGraph']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* @summary Rewind Run
* @param {string} id Unique run identifier (ULID).
* @param {RewindRequest} [rewindRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async rewindRun(id: string, rewindRequest?: RewindRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RewindResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.rewindRun(id, rewindRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.rewindRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Validates a workflow manifest without creating a run.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1084,7 +1261,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* @summary Start Run
* @param {string} id Unique run identifier (ULID).
* @param {StartRunRequest} [startRunRequest]
* @param {StartRunRequest} [startRunRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1095,7 +1272,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* @summary Unarchive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1130,7 +1307,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
const localVarFp = RunsApiFp(configuration)
return {
/**
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* @summary Archive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1162,7 +1339,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
/**
* Creates a new workflow run in `submitted` status from a self-contained manifest.
* @summary Create Run
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1173,7 +1350,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* Creates a pull request for a completed run on GitHub and persists the record on the server.
* @summary Create Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1191,6 +1368,17 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
deleteRun(id: string, force?: boolean, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.deleteRun(id, force, options).then((request) => request(axios, basePath));
},
/**
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* @summary Fork Run
* @param {string} id Unique run identifier (ULID).
* @param {ForkRequest} [forkRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
forkRun(id: string, forkRequest?: ForkRequest, options?: RawAxiosRequestConfig): AxiosPromise<ForkResponse> {
return localVarFp.forkRun(id, forkRequest, options).then((request) => request(axios, basePath));
},
/**
* Returns the stored pull request record for a run plus live GitHub details.
* @summary Get Run Pull Request
@ -1201,6 +1389,16 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
getRunPullRequest(id: string, options?: RawAxiosRequestConfig): AxiosPromise<PullRequestDetail> {
return localVarFp.getRunPullRequest(id, options).then((request) => request(axios, basePath));
},
/**
* Returns checkpoint timeline entries read from the run metadata branch. This endpoint does not rebuild missing metadata branches.
* @summary Get Run Timeline
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getRunTimeline(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Array<TimelineEntryResponse>> {
return localVarFp.getRunTimeline(id, options).then((request) => request(axios, basePath));
},
/**
* Temporary board-view list of managed runs. This endpoint is UI-oriented and may change as the app evolves.
* @summary List Board Runs
@ -1228,7 +1426,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* Merges the stored pull request for a run on GitHub.
* @summary Merge Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1248,7 +1446,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
/**
* Validates and renders a workflow manifest as SVG without creating a run.
* @summary Render Workflow Graph
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1285,10 +1483,21 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
retrieveRunGraph(id: string, options?: RawAxiosRequestConfig): AxiosPromise<string> {
return localVarFp.retrieveRunGraph(id, options).then((request) => request(axios, basePath));
},
/**
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* @summary Rewind Run
* @param {string} id Unique run identifier (ULID).
* @param {RewindRequest} [rewindRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
rewindRun(id: string, rewindRequest?: RewindRequest, options?: RawAxiosRequestConfig): AxiosPromise<RewindResponse> {
return localVarFp.rewindRun(id, rewindRequest, options).then((request) => request(axios, basePath));
},
/**
* Validates a workflow manifest without creating a run.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1299,7 +1508,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* @summary Start Run
* @param {string} id Unique run identifier (ULID).
* @param {StartRunRequest} [startRunRequest]
* @param {StartRunRequest} [startRunRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1307,7 +1516,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
return localVarFp.startRun(id, startRunRequest, options).then((request) => request(axios, basePath));
},
/**
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* @summary Unarchive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1334,7 +1543,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
*/
export class RunsApi extends BaseAPI {
/**
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* Marks a terminal run (`succeeded`, `failed`, or `dead`) as `archived`. Archived runs are hidden from default listings and are read-only until unarchived. Idempotent on already-archived runs. Returns 409 if the run is not terminal.
* @summary Archive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1369,7 +1578,7 @@ export class RunsApi extends BaseAPI {
/**
* Creates a new workflow run in `submitted` status from a self-contained manifest.
* @summary Create Run
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1381,7 +1590,7 @@ export class RunsApi extends BaseAPI {
* Creates a pull request for a completed run on GitHub and persists the record on the server.
* @summary Create Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {CreateRunPullRequestRequest} createRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1401,6 +1610,18 @@ export class RunsApi extends BaseAPI {
return RunsApiFp(this.configuration).deleteRun(id, force, options).then((request) => request(this.axios, this.basePath));
}
/**
* Creates a new run from a checkpoint of the source run. The source run is left untouched.
* @summary Fork Run
* @param {string} id Unique run identifier (ULID).
* @param {ForkRequest} [forkRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public forkRun(id: string, forkRequest?: ForkRequest, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).forkRun(id, forkRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Returns the stored pull request record for a run plus live GitHub details.
* @summary Get Run Pull Request
@ -1412,6 +1633,17 @@ export class RunsApi extends BaseAPI {
return RunsApiFp(this.configuration).getRunPullRequest(id, options).then((request) => request(this.axios, this.basePath));
}
/**
* Returns checkpoint timeline entries read from the run metadata branch. This endpoint does not rebuild missing metadata branches.
* @summary Get Run Timeline
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public getRunTimeline(id: string, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).getRunTimeline(id, options).then((request) => request(this.axios, this.basePath));
}
/**
* Temporary board-view list of managed runs. This endpoint is UI-oriented and may change as the app evolves.
* @summary List Board Runs
@ -1441,7 +1673,7 @@ export class RunsApi extends BaseAPI {
* Merges the stored pull request for a run on GitHub.
* @summary Merge Run Pull Request
* @param {string} id Unique run identifier (ULID).
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {MergeRunPullRequestRequest} mergeRunPullRequestRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1463,7 +1695,7 @@ export class RunsApi extends BaseAPI {
/**
* Validates and renders a workflow manifest as SVG without creating a run.
* @summary Render Workflow Graph
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {RenderWorkflowGraphRequest} renderWorkflowGraphRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1504,10 +1736,22 @@ export class RunsApi extends BaseAPI {
return RunsApiFp(this.configuration).retrieveRunGraph(id, options).then((request) => request(this.axios, this.basePath));
}
/**
* Creates a new run from an earlier checkpoint of a terminal source run, archives the source run, and records `run.superseded_by` on the source after archive succeeds. Returns 207 when the new run was created but the source archive step failed.
* @summary Rewind Run
* @param {string} id Unique run identifier (ULID).
* @param {RewindRequest} [rewindRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public rewindRun(id: string, rewindRequest?: RewindRequest, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).rewindRun(id, rewindRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Validates a workflow manifest without creating a run.
* @summary Validate Workflow Manifest
* @param {RunManifest} runManifest
* @param {RunManifest} runManifest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1519,7 +1763,7 @@ export class RunsApi extends BaseAPI {
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
* @summary Start Run
* @param {string} id Unique run identifier (ULID).
* @param {StartRunRequest} [startRunRequest]
* @param {StartRunRequest} [startRunRequest]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
@ -1528,7 +1772,7 @@ export class RunsApi extends BaseAPI {
}
/**
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* Restores an archived run to its prior terminal status. Idempotent on runs that are terminal but not archived (returns the current status without emitting an event). Returns 409 if the run is active.
* @summary Unarchive Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Request body for creating a new run from a source run checkpoint.
*/
export interface ForkRequest {
/**
* Optional checkpoint target such as `@2`, `build`, or `build@1`. Defaults to the latest checkpoint.
*/
'target'?: string | null;
/**
* Whether to push the new run branches. Defaults to true.
*/
'push'?: boolean | null;
}

View file

@ -0,0 +1,24 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Response returned after creating a forked run.
*/
export interface ForkResponse {
'source_run_id': string;
'new_run_id': string;
'target': string;
}

View file

@ -51,6 +51,8 @@ export * from './failure-reason';
export * from './features-namespace';
export * from './file-checkpoint';
export * from './file-diff';
export * from './fork-request';
export * from './fork-response';
export * from './git-hub-meta-hooks-entry';
export * from './github-integration-settings';
export * from './github-integration-strategy';
@ -138,6 +140,8 @@ export * from './render-workflow-graph-request';
export * from './repo-check-response';
export * from './repo-check-response-permissions';
export * from './repository-reference';
export * from './rewind-request';
export * from './rewind-response';
export * from './root-response';
export * from './root-response-urls';
export * from './run-artifact-entry';
@ -172,6 +176,7 @@ export * from './run-status-running';
export * from './run-status-starting';
export * from './run-status-submitted';
export * from './run-status-succeeded';
export * from './run-superseded-by-props';
export * from './run-timings';
export * from './sandbox-file-entry';
export * from './sandbox-file-list-response';
@ -214,6 +219,7 @@ export * from './system-run-counts';
export * from './system-stage-turn';
export * from './teams-integration-settings';
export * from './terminal-status';
export * from './timeline-entry-response';
export * from './tool-stage-turn';
export * from './tool-use';
export * from './user-response';

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Request body for creating a replacement run from a source run checkpoint.
*/
export interface RewindRequest {
/**
* Optional checkpoint target such as `@2`, `build`, or `build@1`. Defaults to the latest checkpoint.
*/
'target'?: string | null;
/**
* Whether to push the new run branches. Defaults to true.
*/
'push'?: boolean | null;
}

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Response returned after rewind creates a new run.
*/
export interface RewindResponse {
'source_run_id': string;
'new_run_id': string;
'target': string;
'archived': boolean;
'archive_error'?: string | null;
}

View file

@ -50,6 +50,7 @@ export interface RunProjection {
'sandbox'?: { [key: string]: any; } | null;
'final_patch'?: string | null;
'pull_request'?: { [key: string]: any; } | null;
'superseded_by'?: string | null;
'pending_interviews'?: { [key: string]: PendingInterviewRecord; };
/**
* Map from StageId (`node_id@visit`) to NodeState.

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Properties for the `run.superseded_by` audit event emitted on a rewound source run after archive succeeds.
*/
export interface RunSupersededByProps {
'new_run_id': string;
'target_checkpoint_ordinal': number;
'target_node_id': string;
'target_visit': number;
}

View file

@ -42,6 +42,7 @@ export interface StoreRunSummary {
'duration_ms'?: number | null;
'elapsed_secs'?: number | null;
'total_usd_micros'?: number | null;
'superseded_by'?: string | null;
}

View file

@ -0,0 +1,25 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Checkpoint timeline entry for a run.
*/
export interface TimelineEntryResponse {
'ordinal': number;
'node_name': string;
'visit': number;
'run_commit_sha'?: string | null;
}