mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-14 23:22:51 +00:00
Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
0d2be41e57
78 changed files with 2158 additions and 1447 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1583,6 +1583,7 @@ name = "fabro-checkpoint"
|
|||
version = "0.208.0-nightly.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"fabro-store",
|
||||
"fabro-types",
|
||||
"git2",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ When an agent or prompt node finishes, Fabro captures its response text and prod
|
|||
|
||||
## Response capture
|
||||
|
||||
After an agent or prompt node completes, Fabro captures the full response text and writes it to the run logs at `{run_dir}/nodes/{node_id}/response.md`. It also writes the final outcome (status, context updates, routing directives) to `{run_dir}/nodes/{node_id}/status.json`.
|
||||
After an agent or prompt node completes, Fabro captures the full response text and persists it to `stages/{node_id}@{visit}/response.md` in metadata snapshots and `fabro store dump` output. It also writes the final outcome (status, context updates, routing directives) to `stages/{node_id}@{visit}/status.json`.
|
||||
|
||||
## Context updates
|
||||
|
||||
|
|
@ -92,7 +92,7 @@ review -> approve [label="Approve"]
|
|||
|
||||
## Output logging
|
||||
|
||||
Fabro writes several files per stage to `{run_dir}/nodes/{node_id}/`:
|
||||
Fabro writes several files per stage to `stages/{node_id}@{visit}/` in metadata snapshots and `fabro store dump` output:
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
|
|
@ -100,7 +100,7 @@ Fabro writes several files per stage to `{run_dir}/nodes/{node_id}/`:
|
|||
| `response.md` | The full LLM response text |
|
||||
| `status.json` | The outcome: status, context updates, routing directives, usage stats |
|
||||
|
||||
These files are written for every agent and prompt node execution, including retries (visit count is appended to the directory name for repeat visits). Use them for debugging unexpected agent behavior or verifying that routing directives were extracted correctly.
|
||||
These files are written for every agent and prompt node execution, including retries. Use them for debugging unexpected agent behavior or verifying that routing directives were extracted correctly.
|
||||
|
||||
## File tracking
|
||||
|
||||
|
|
|
|||
|
|
@ -295,4 +295,4 @@ Use prompt nodes for analysis, classification, and summarization tasks where too
|
|||
|
||||
## Prompt logging
|
||||
|
||||
Fabro writes the assembled prompt to `{run_dir}/nodes/{node_id}/prompt.md` for every agent and prompt stage. This includes the preamble (if any) and the expanded prompt text. Use these files for debugging when an agent behaves unexpectedly.
|
||||
Fabro persists the assembled prompt to `stages/{node_id}@{visit}/prompt.md` in metadata snapshots and `fabro store dump` output for every agent and prompt stage. This includes the preamble (if any) and the expanded prompt text. Use these files for debugging when an agent behaves unexpectedly.
|
||||
|
|
|
|||
|
|
@ -3714,7 +3714,7 @@ components:
|
|||
required:
|
||||
- nodes
|
||||
properties:
|
||||
run:
|
||||
spec:
|
||||
type: ["object", "null"]
|
||||
additionalProperties: true
|
||||
graph_source:
|
||||
|
|
|
|||
|
|
@ -43,18 +43,18 @@ The `Fabro-Checkpoint` trailer links each run branch commit to its metadata bran
|
|||
|
||||
The metadata branch (`fabro/meta/{run_id}`) is an orphan branch that stores structured run data using Git's object storage directly (via `git2`). It is initialized at run start with:
|
||||
|
||||
- **`run.json`** — Run record: run ID, created_at, config, graph, workflow slug, working directory, host repo path, base branch, labels
|
||||
- **`start.json`** — Start record: run ID, start time, run branch, base SHA
|
||||
- **`run.json`** — Current projection snapshot: run spec, start/status records, current checkpoint, conclusion, sandbox, retro state, and other run-level metadata
|
||||
- **`graph.fabro`** — Workflow source for the run
|
||||
|
||||
After each node, the metadata branch is updated with:
|
||||
|
||||
- **`checkpoint.json`** — Full execution state (see below)
|
||||
- **`artifacts/*.json`** — Any offloaded artifact data (large context values over 100KB)
|
||||
- **`nodes/{node_id}/`** — Per-node execution trace files (prompts, responses, status, diffs — files under 512KB from an allowlist)
|
||||
- **`run.json`** — Refreshed projection snapshot with the new current checkpoint
|
||||
- **`stages/{node_id}@{visit}/...`** — Per-stage execution trace files (prompts, responses, status, diffs, stdout/stderr, and tool metadata)
|
||||
- **`retro/*.md`** — Retro prompt/response text when present
|
||||
|
||||
## What's in a checkpoint
|
||||
|
||||
The `checkpoint.json` captures everything needed to resume a run:
|
||||
The `run.json.checkpoint` snapshot captures everything needed to resume a run:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
|
|
@ -134,7 +134,7 @@ git show fabro/run/01JKXYZ...
|
|||
git diff main..fabro/run/01JKXYZ...
|
||||
|
||||
# Read checkpoint data from the metadata branch
|
||||
git show fabro/meta/01JKXYZ...:checkpoint.json | jq .current_node
|
||||
git show fabro/meta/01JKXYZ...:run.json | jq .checkpoint.current_node
|
||||
```
|
||||
|
||||
## Rewinding to an earlier checkpoint
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ Retro generation happens in two phases after a run completes:
|
|||
|
||||
1. **Derive** — Fabro extracts stage durations from durable run events and builds a retro from the checkpoint data. This is deterministic, fast, and produces the quantitative layer.
|
||||
|
||||
2. **Narrate** — An LLM agent session analyzes the run data. The agent receives temp files named `progress.jsonl`, `checkpoint.json`, `run.json`, and `start.json` inside its sandbox so it can grep and read the event stream and run state. The narrative fields are merged back into durable retro state.
|
||||
2. **Narrate** — An LLM agent session analyzes the run data. The agent receives `progress.jsonl`, `run.json`, `graph.fabro`, and per-stage files under `stages/{node_id}@{visit}/...` inside its sandbox so it can grep and read the event stream, run snapshot, workflow source, and full stage payloads. The narrative fields are merged back into durable retro state.
|
||||
|
||||
Both phases run automatically at the end of every CLI run. The API server derives the quantitative layer but does not currently run the narrative agent.
|
||||
|
||||
|
|
@ -143,4 +143,4 @@ Retros are also available via the REST API. See the [list retros](/api-reference
|
|||
|
||||
## Storage
|
||||
|
||||
Retros are stored in durable run state. If you need files on disk, `fabro store dump` materializes the retro as `retro.json` alongside other exported run data.
|
||||
Retros are stored in durable run state. If you need files on disk, `fabro store dump` materializes retro text under `retro/` alongside `run.json`, stage files, and the rest of the exported run data.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,354 @@
|
|||
---
|
||||
title: "refactor: unify run vocabulary and metadata snapshot layout"
|
||||
type: refactor
|
||||
status: active
|
||||
date: 2026-04-20
|
||||
origin: /Users/bhelmkamp/.claude/plans/make-a-full-plan-pure-wombat.md
|
||||
deepened: 2026-04-20
|
||||
---
|
||||
|
||||
# refactor: unify run vocabulary and metadata snapshot layout
|
||||
|
||||
## Overview
|
||||
|
||||
Align the run domain vocabulary and metadata-branch layout around the event-sourced projection the code already maintains in memory. The refactor renames `RunRecord` to `RunSpec`, collapses metadata snapshots into a trimmed `RunProjection` in `run.json`, normalizes per-stage files to `stages/{node_id}@{visit}/...`, and updates every metadata-branch consumer to read that unified shape.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The durable write path is already projection-oriented, but the git metadata branch still presents the same run through multiple accidental shapes:
|
||||
|
||||
- `run.json` is only the spec slice (`RunRecord`)
|
||||
- run lifecycle state is split across `start.json`, `status.json`, `checkpoint.json`, `sandbox.json`, `retro.json`, and `conclusion.json`
|
||||
- node payloads use multiple incompatible path conventions under `nodes/`
|
||||
- fork, rewind, rebuild, retro upload, and CLI dump/export all read or write those legacy files directly
|
||||
|
||||
That mismatch leaks implementation history into the domain model and makes every consumer reason about special cases. The current tree on `main` still shows the old design in `RunDump`, `MetadataStore`, fork/rewind/rebuild operations, CLI rewind recovery, and `fabro store dump`. This plan makes the metadata branch a true serialized projection snapshot and removes the split-file vocabulary drift.
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- R1. Rename `RunRecord` to `RunSpec` and rename `RunProjection.run` to `RunProjection.spec` everywhere in Rust code and tests. Do not ship alias shims.
|
||||
- R2. Introduce a metadata-only serializer that writes `run.json` as a trimmed `RunProjection`, stripping bulky `NodeState` text fields (`prompt`, `response`, `diff`, `stdout`, `stderr`) while preserving all other projection data.
|
||||
- R3. Standardize metadata-branch and export layout around `run.json`, `graph.fabro`, `retro/*.md`, `events.jsonl`, `checkpoints/*.json`, artifact exports, and `stages/{node_id}@{visit}/...`. Stop writing top-level `start.json`, `status.json`, `checkpoint.json`, `sandbox.json`, `retro.json`, and `conclusion.json`.
|
||||
- R4. Replace metadata helpers and writers that special-case `checkpoint.json` with snapshot-oriented helpers that can write a full projection commit and still return the metadata-branch commit SHA when checkpoint flows need it.
|
||||
- R5. Update metadata consumers (`fork`, `rewind`, `rebuild_meta`, CLI rewind recovery, retro upload, store dump/export) to read the unified projection layout without changing user-visible behavior.
|
||||
- R6. Add additive query methods on `RunSpec` and `RunProjection` for common reads while keeping existing public-field access valid.
|
||||
- R7. Update crate tests, CLI integration tests, and snapshots to the new layout with no coverage regression.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- No backward-compatibility read path for old metadata branches. This repo is still pre-launch greenfield.
|
||||
- No OpenAPI or generated TypeScript client change. Server APIs expose run state independently of metadata-branch file layout.
|
||||
- Keep `graph.fabro`, `retro/prompt.md`, `retro/response.md`, `events.jsonl`, `checkpoints/*.json`, and artifact export support.
|
||||
- Do not move artifact exports away from `artifacts/nodes/{node_id}/visit-{n}/...` unless implementation proves a hard blocker; artifact path cleanup is not the point of this refactor.
|
||||
- Do not convert fork/rewind to read events directly from durable storage; they continue to operate from metadata branches.
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- `lib/crates/fabro-types/src/run.rs` and `lib/crates/fabro-store/src/run_state.rs` define the core vocabulary and projection shape that this refactor renames and extends.
|
||||
- `lib/crates/fabro-workflow/src/run_dump.rs` and `lib/crates/fabro-cli/src/commands/store/run_export.rs` currently duplicate layout/serialization logic and already drift on node path format.
|
||||
- `lib/crates/fabro-workflow/src/lifecycle/git.rs` and `lib/crates/fabro-workflow/src/pipeline/finalize.rs` still use phase-specific `RunDump` constructors and a `checkpoint.json`-oriented metadata helper.
|
||||
- `lib/crates/fabro-workflow/src/operations/{fork.rs,rewind.rs,rebuild_meta.rs}` plus `lib/crates/fabro-cli/src/commands/run/rewind.rs` are the critical metadata readers/writers that must switch from standalone `checkpoint.json` and `start.json` reads to projection reads.
|
||||
- `lib/crates/fabro-types/src/stage_id.rs` already defines `Display` as `{node_id}@{visit}`, which should become the on-disk stage directory name.
|
||||
- `files-internal/testing-strategy.md` says CLI integration tests should remain command-driven and black-box; layout-specific assertions belong in the right layer rather than by planting run internals by hand.
|
||||
|
||||
### Institutional Learnings
|
||||
|
||||
- No matching `docs/solutions/` entries were present in this repo at planning time, so this plan is grounded in current code and test patterns rather than prior internal solution notes.
|
||||
|
||||
### External References
|
||||
|
||||
- None. This is an internal Rust refactor with sufficient local context.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- Hard rename `RunRecord` to `RunSpec` and `RunProjection.run` to `RunProjection.spec`.
|
||||
Rationale: the current names are the main source of spec/projection confusion, and a greenfield codebase does not benefit from preserving legacy aliases.
|
||||
- `run.json` becomes the single top-level serialized projection snapshot for metadata branches and exports, including `conclusion`.
|
||||
Rationale: leaving `conclusion.json` behind would preserve the accidental fragmentation this refactor is trying to remove.
|
||||
- Use a dedicated metadata serializer wrapper instead of changing `RunProjection`'s canonical serde implementation.
|
||||
Rationale: ordinary projection serde remains valuable for tests and internal round-trips, while metadata snapshots need one specific trimmed representation.
|
||||
- Normalize per-stage paths to `stages/{stage_id}/{filename}` using `StageId::Display`.
|
||||
Rationale: this removes visit-1 special cases and aligns the on-disk layout with the stage identifier already exposed in APIs and logs.
|
||||
- Replace `MetadataStore::write_checkpoint` with a snapshot-oriented commit helper rather than passing renamed data through a stale `checkpoint_json` API.
|
||||
Rationale: checkpoint commits still need a returned SHA, but the helper should describe the new snapshot semantics instead of the deleted file.
|
||||
- Delete the CLI-only `StoreRunExport` duplication and reuse the workflow dump builder.
|
||||
Rationale: this refactor changes layout semantics in one place; keeping two near-identical serializers would make future drift likely.
|
||||
- Keep artifact exports under `artifacts/nodes/{node_id}/visit-{n}/...` in this unit.
|
||||
Rationale: artifact lookup is already keyed by `StageId` at API boundaries, but changing artifact paths would widen scope without addressing the metadata-vocabulary problem.
|
||||
- Query methods remain additive.
|
||||
Rationale: field privacy is a follow-up concern, and this refactor already changes many call sites.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
|
||||
- Should `conclusion.json` survive as a separate top-level file?
|
||||
No. It should collapse into `run.json` with the rest of the projection.
|
||||
- Should CLI export keep its own serializer?
|
||||
No. Reuse the workflow dump/export builder so metadata branches and `fabro store dump` cannot diverge again.
|
||||
- Does the layout change need to cover CLI rewind recovery as well as workflow operations?
|
||||
Yes. `lib/crates/fabro-cli/src/commands/run/rewind.rs` currently reads `checkpoint.json` from the metadata branch and must switch with the rest of the readers.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- Exact helper names for the new metadata commit writer (`write_snapshot`, `write_projection_commit`, etc.). The plan fixes the API shape and intent, but the final Rust name can be chosen during implementation.
|
||||
- Whether the shared export builder stays in `lib/crates/fabro-workflow/src/run_dump.rs` or moves to a nearby module. The key constraint is one authoritative layout builder, not a specific file name.
|
||||
- Whether any low-value tests should move layers while being updated. Follow `files-internal/testing-strategy.md` if implementation reveals a better layer, but do not turn this refactor into a broad test reorganization.
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
|
||||
|
||||
```text
|
||||
Durable event store
|
||||
-> RunProjection { spec, start, status, checkpoint, conclusion, retro, sandbox, nodes, ... }
|
||||
-> Metadata serializer (trim bulky node text fields)
|
||||
-> Metadata/export tree:
|
||||
run.json # trimmed RunProjection snapshot
|
||||
graph.fabro # readable workflow source
|
||||
stages/<node@visit>/... # prompt.md, response.md, status.json, provider_used.json,
|
||||
# diff.patch, script_invocation.json, script_timing.json,
|
||||
# parallel_results.json, stdout.log, stderr.log
|
||||
retro/prompt.md
|
||||
retro/response.md
|
||||
events.jsonl
|
||||
checkpoints/<seq>.json
|
||||
artifacts/nodes/<id>/visit-<n>/...
|
||||
```
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [ ] **Unit 1: Rename run vocabulary to spec/projection**
|
||||
|
||||
**Goal:** Replace the legacy `RunRecord`/`run` vocabulary with `RunSpec`/`spec` across the domain model and its consumers.
|
||||
|
||||
**Requirements:** R1
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-types/src/run.rs`
|
||||
- Modify: `lib/crates/fabro-types/src/lib.rs`
|
||||
- Modify: `lib/crates/fabro-workflow/src/records/{run.rs,mod.rs}`
|
||||
- Modify: `lib/crates/fabro-store/src/run_state.rs`
|
||||
- Modify: `lib/crates/fabro-workflow/src/{runtime_store.rs,run_lookup.rs}`
|
||||
- Modify: `lib/crates/fabro-workflow/src/pipeline/{pull_request.rs,retro.rs,types.rs,execute/tests.rs}`
|
||||
- Modify: `lib/crates/fabro-workflow/src/operations/{create.rs,start.rs,fork.rs,rebuild_meta.rs}`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/{run/create.rs,run/fork.rs,run/rewind.rs,runs/inspect.rs,pr/create.rs,store/dump.rs}`
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs`
|
||||
- Test: `lib/crates/fabro-types/tests/run_record_serde.rs` (rename to `run_spec_serde.rs`)
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/create.rs`
|
||||
|
||||
**Approach:**
|
||||
- Make this a pure mechanical rename first so later layout changes can focus on behavior rather than symbol churn.
|
||||
- Rename `Persisted::run_record` and other outward-facing internal helpers to `spec`-oriented names in the same pass.
|
||||
- Keep the data shape unchanged in this unit; only names move.
|
||||
|
||||
**Execution note:** Land as a mechanical rename before touching metadata serialization or file layout.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `lib/crates/fabro-types/src/stage_id.rs` accessor style for the later query-method unit.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `run_spec_serde.rs` round-trips a `RunSpec` with templated settings and blob refs exactly as the old `RunRecord` test did.
|
||||
- Happy path: `RunProjection::apply_event` stores `spec` on `RunCreated` and updates the spec's `definition_blob` on `RunSubmitted`.
|
||||
- Edge case: workspace code compiles with no lingering `RunRecord` or `run_record` identifiers in Rust source.
|
||||
|
||||
**Verification:**
|
||||
- The workspace compiles after the rename with no alias shims.
|
||||
- Rust source no longer contains `RunRecord` or `run_record` identifiers.
|
||||
|
||||
- [ ] **Unit 2: Add trimmed projection serialization and additive query methods**
|
||||
|
||||
**Goal:** Define the metadata snapshot serialization contract and expose additive readers on `RunSpec` and `RunProjection`.
|
||||
|
||||
**Requirements:** R2, R6
|
||||
|
||||
**Dependencies:** Unit 1
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/crates/fabro-store/src/serializable_projection.rs`
|
||||
- Modify: `lib/crates/fabro-store/src/lib.rs`
|
||||
- Modify: `lib/crates/fabro-store/src/run_state.rs`
|
||||
- Modify: `lib/crates/fabro-types/src/run.rs`
|
||||
- Test: `lib/crates/fabro-store/src/serializable_projection.rs`
|
||||
- Test: `lib/crates/fabro-store/src/run_state.rs`
|
||||
- Test: `lib/crates/fabro-types/tests/run_spec_methods.rs`
|
||||
|
||||
**Approach:**
|
||||
- Add a metadata-only serializer wrapper around `RunProjection` that strips `NodeState.prompt`, `response`, `diff`, `stdout`, and `stderr` from `run.json` while preserving top-level fields and the non-bulky node metadata.
|
||||
- Keep ordinary `RunProjection` serde untouched so existing test helpers and internal round-trips keep working.
|
||||
- Add query methods such as `RunSpec::id()`, `RunSpec::graph()`, `RunProjection::spec()`, `RunProjection::status()`, and `RunProjection::current_checkpoint()` without changing field visibility.
|
||||
|
||||
**Execution note:** Start with failing round-trip tests before wiring the new serializer into metadata writers.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing `RunProjection::node`, `iter_nodes`, and `list_node_visits` helpers in `lib/crates/fabro-store/src/run_state.rs`
|
||||
- `StageId` accessor methods in `lib/crates/fabro-types/src/stage_id.rs`
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: a projection with full top-level state and one populated node round-trips through the metadata serializer, deserializes back, and keeps all non-bulky fields intact while clearing the bulky text fields.
|
||||
- Happy path: `RunSpec` getters expose `run_id`, `graph`, `settings`, `workflow_slug`, `working_directory`, and labels from a representative fixture.
|
||||
- Edge case: an empty projection round-trips unchanged.
|
||||
- Edge case: projections containing `foo@1` and `foo@2` nodes preserve both `StageId` keys across the round-trip.
|
||||
- Edge case: `RunProjection::status()` returns `None` when no status record exists and the correct enum when one does.
|
||||
|
||||
**Verification:**
|
||||
- The metadata serializer can round-trip a projection into the trimmed wire shape and back.
|
||||
- New accessors compile without forcing existing field access call sites to change.
|
||||
|
||||
- [ ] **Unit 3: Unify metadata and export writers around one snapshot layout**
|
||||
|
||||
**Goal:** Make one authoritative dump/export builder produce the unified `run.json` + `stages/` layout for both metadata branches and CLI export.
|
||||
|
||||
**Requirements:** R2, R3, R4
|
||||
|
||||
**Dependencies:** Unit 2
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-workflow/src/run_dump.rs`
|
||||
- Modify: `lib/crates/fabro-workflow/src/lifecycle/git.rs`
|
||||
- Modify: `lib/crates/fabro-workflow/src/pipeline/finalize.rs`
|
||||
- Modify: `lib/crates/fabro-workflow/src/git.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/store/{dump.rs,run_export.rs}`
|
||||
- Test: `lib/crates/fabro-workflow/src/git.rs`
|
||||
- Test: `lib/crates/fabro-workflow/src/pipeline/finalize.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/store_dump.rs`
|
||||
|
||||
**Approach:**
|
||||
- Replace `RunDump::metadata_init`, `metadata_checkpoint`, `metadata_finalize`, and the CLI-only `StoreRunExport::from_store_state_and_events` path with one authoritative builder that starts from a `RunProjection`.
|
||||
- Have metadata snapshots always emit `run.json` through the trimmed serializer, `graph.fabro` when present, and stage files under `stages/{stage_id}/...`.
|
||||
- Keep export-only concerns (`events.jsonl`, `checkpoints/*.json`, hydrated blobs, artifact bytes) as opt-in helpers on the shared builder rather than as a second serializer.
|
||||
- Remove top-level split JSON files, including `conclusion.json`, from both metadata branches and CLI export.
|
||||
- Update checkpoint persistence in lifecycle code to use the new generic snapshot commit helper instead of a `checkpoint.json`-specific API.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing `RunDumpEntry` helpers in `lib/crates/fabro-workflow/src/run_dump.rs`
|
||||
- `StageId::Display` in `lib/crates/fabro-types/src/stage_id.rs`
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: an init-state projection writes only `run.json` and `graph.fabro` when no stages or retro data exist.
|
||||
- Happy path: a checkpoint-state projection writes `run.json` plus `stages/<id>@1/` files for prompt, response, status, provider, diff, script metadata, stdout, and stderr when present.
|
||||
- Happy path: CLI dump/export uses the same builder and still emits `events.jsonl`, `checkpoints/*.json`, `retro/*.md`, and artifact payloads.
|
||||
- Edge case: a node with multiple visits writes both `stages/build@1/...` and `stages/build@2/...` with no visit-1 special case.
|
||||
- Edge case: `run.json` contains `start`, `status`, `checkpoint`, `sandbox`, `retro`, and `conclusion`, but not bulky node text payloads.
|
||||
|
||||
**Verification:**
|
||||
- Writer/export code no longer contains legacy `nodes/` metadata stage paths or top-level split-file emission logic.
|
||||
- Shared writer tests prove metadata branches and CLI export emit the same projection layout.
|
||||
|
||||
- [ ] **Unit 4: Update metadata readers, recovery flows, and rebuild logic**
|
||||
|
||||
**Goal:** Move every metadata-branch consumer from standalone file reads to projection reads, including the rebuild and rewind recovery paths.
|
||||
|
||||
**Requirements:** R4, R5
|
||||
|
||||
**Dependencies:** Unit 3
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-checkpoint/src/metadata.rs`
|
||||
- Modify: `lib/crates/fabro-workflow/src/operations/{fork.rs,rewind.rs,rebuild_meta.rs}`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/rewind.rs`
|
||||
- Test: `lib/crates/fabro-checkpoint/src/metadata.rs`
|
||||
- Test: `lib/crates/fabro-workflow/src/operations/{fork.rs,rewind.rs,rebuild_meta.rs}`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/fork.rs`
|
||||
- Test: `lib/crates/fabro-cli/tests/it/scenario/recovery.rs`
|
||||
- Test: `lib/crates/fabro-workflow/tests/it/{integration.rs,daytona_integration.rs}`
|
||||
|
||||
**Approach:**
|
||||
- Add `MetadataStore::read_run_projection` and `read_run_spec`; either delete `read_checkpoint`/`read_start_record` or demote them to projection-field extractors after callers switch.
|
||||
- Update `fork` to read the source projection, clone the spec/start/sandbox slices it intentionally carries forward, inject the new run ID, and write the new run's metadata branch through the unified snapshot writer.
|
||||
- Update rewind parallel detection to read `projection.spec.graph`, and update CLI rewind recovery to pull the restored checkpoint from the projection snapshot instead of `checkpoint.json`.
|
||||
- Rewrite `rebuild_meta` to emit one snapshot commit per metadata commit (init/checkpoint/finalize) through the shared writer while preserving `git_commit_sha` backfill semantics inside `projection.checkpoint`.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing timeline and run-SHA backfill helpers in `lib/crates/fabro-workflow/src/operations/{rewind.rs,rebuild_meta.rs}`
|
||||
- `RunStoreHandle::state()` projection access in `lib/crates/fabro-workflow/src/runtime_store.rs`
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: a forked run gets a new `run.json` projection with the new run ID, inherited sandbox/start context, and no top-level split JSON files.
|
||||
- Error path: forking still fails cleanly when the source metadata branch lacks `run.json` or the target checkpoint lacks a run commit SHA.
|
||||
- Happy path: rewind parallel detection still recognizes interior parallel groups from `projection.spec.graph`.
|
||||
- Happy path: CLI rewind recovery reads the checkpoint from the projection snapshot and replays `RunRewound` plus restored checkpoint events correctly.
|
||||
- Integration: rebuild-meta emits one `run.json` snapshot per metadata commit, and each snapshot contains the expected checkpoint payload and backfilled `git_commit_sha`.
|
||||
- Error path: rebuild-meta remains atomic on failure and still refuses to overwrite an existing metadata branch.
|
||||
|
||||
**Verification:**
|
||||
- Metadata consumers no longer require top-level `checkpoint.json`, `start.json`, or `sandbox.json`.
|
||||
- Fork, rewind, and rebuild tests pass against the unified layout.
|
||||
|
||||
- [ ] **Unit 5: Sweep downstream docs, retro prompts, and snapshots**
|
||||
|
||||
**Goal:** Align retro tooling, integration tests, and snapshots with the unified metadata vocabulary and file layout.
|
||||
|
||||
**Requirements:** R3, R5, R7
|
||||
|
||||
**Dependencies:** Unit 4
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-retro/src/retro_agent.rs`
|
||||
- Modify: `lib/crates/fabro-cli/tests/it/cmd/{store_dump.rs,start.rs,fork.rs}`
|
||||
- Modify: `lib/crates/fabro-cli/tests/it/scenario/recovery.rs`
|
||||
- Modify: `lib/crates/fabro-workflow/src/git.rs`
|
||||
- Modify: `lib/crates/fabro-workflow/src/pipeline/finalize.rs`
|
||||
- Modify: `lib/crates/fabro-workflow/tests/it/{integration.rs,daytona_integration.rs}`
|
||||
- Test: the files above
|
||||
|
||||
**Approach:**
|
||||
- Update retro agent instructions and sandbox uploads so the agent reads `run.json` projection data plus `graph.fabro` and stage files instead of `checkpoint.json` and `start.json`.
|
||||
- Rename or replace tests that currently assert `conclusion.json` or old `nodes/...` layouts so they assert conclusion presence inside `run.json` and stage files under `stages/`.
|
||||
- Keep CLI integration tests black-box per `files-internal/testing-strategy.md`; layout assertions should come from public command behavior or crate-level tests, not hand-planted run internals.
|
||||
- Review snapshot diffs before accepting them because this refactor intentionally changes many file paths and exported filenames.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Snapshot discipline in `files-internal/testing-strategy.md`
|
||||
- Existing retro upload flow in `lib/crates/fabro-retro/src/retro_agent.rs`
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: retro sandbox upload includes `run.json` projection data, and the prompt tells the retro agent to inspect `run.json` plus `graph.fabro`/stage files rather than `checkpoint.json`.
|
||||
- Happy path: `fabro store dump` snapshots show `run.json`, `graph.fabro`, `stages/...`, `retro/*.md`, `events.jsonl`, and `checkpoints/*.json`, with no legacy split JSON files.
|
||||
- Happy path: integration and Daytona tests read run spec and checkpoint data through the new projection helpers and still observe correct `git_commit_sha` behavior.
|
||||
- Edge case: tests that previously referred to missing `status.json` or `sandbox.json` continue to assert the public command behavior without relying on those internal filenames existing.
|
||||
|
||||
**Verification:**
|
||||
- Snapshot and integration tests reference only the new layout.
|
||||
- Retro tooling and test names no longer describe deleted files such as `conclusion.json` or `checkpoint.json` as metadata-branch invariants.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** metadata snapshots are written from lifecycle init/checkpoint/finalize and rebuild-meta; they are read by fork, rewind, CLI rewind recovery, retro upload, store dump/export, and metadata-focused tests.
|
||||
- **Error propagation:** deserialization errors shift from file-specific entities (`checkpoint`, `run record`) to projection parsing plus field-extraction errors; reader helpers should preserve branch/path context so failures stay diagnosable.
|
||||
- **State lifecycle risks:** partial migration of writers/readers would silently break metadata-driven flows; the refactor must switch readers and writers in the same series and preserve checkpoint commit SHA capture.
|
||||
- **API surface parity:** `StageId` already uses `node@visit`, so metadata paths, CLI exports, and test fixtures should align on the same identifier format. Artifact exports are the deliberate exception in this unit and remain on `artifacts/nodes/...`.
|
||||
- **Integration coverage:** the highest-value end-to-end paths are checkpoint persistence, fork from checkpoint, rewind + resume recovery, rebuild metadata from durable state, and `fabro store dump`.
|
||||
- **Unchanged invariants:** event semantics, durable-store state accumulation, `graph.fabro` export, and artifact export support remain intact; the refactor changes metadata serialization shape, not workflow execution behavior.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| A reader still depends on `checkpoint.json`, `start.json`, or `sandbox.json` after those files stop being written | Exhaustively update metadata helper call sites and keep dedicated fork/rewind/recovery integration coverage in the same series |
|
||||
| `run.json` trimming accidentally drops state consumers still need | Add round-trip tests that prove all non-bulky top-level and node metadata survives the trimmed serializer |
|
||||
| Shared writer migration leaves CLI export and metadata branches on subtly different layouts | Delete or subsume `StoreRunExport` in the same series rather than maintaining parallel serializers |
|
||||
| `git_commit_sha` handling regresses during fork or rebuild | Preserve dedicated tests for missing-SHA errors, backfilled SHAs, and forked checkpoint snapshots |
|
||||
| Large mechanical rename obscures behavioral regressions in review | Land the rename first, keep later units behavior-focused, and use targeted tests for each behavior-bearing unit |
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- Update inline comments, test names, and docstrings that still describe `run.json` as a run record or refer to `checkpoint.json`, `status.json`, `sandbox.json`, or `nodes/...` as metadata-branch invariants.
|
||||
- No rollout or migration plan is needed for existing branches because the repo is still pre-launch; local stale metadata branches can be regenerated or discarded.
|
||||
- Snapshot updates should follow the repo's `cargo insta pending-snapshots` discipline rather than bulk-accepting blindly.
|
||||
|
||||
## Sources & References
|
||||
|
||||
- **Source plan:** `/Users/bhelmkamp/.claude/plans/make-a-full-plan-pure-wombat.md`
|
||||
- Related code:
|
||||
- `lib/crates/fabro-types/src/run.rs`
|
||||
- `lib/crates/fabro-store/src/run_state.rs`
|
||||
- `lib/crates/fabro-workflow/src/run_dump.rs`
|
||||
- `lib/crates/fabro-checkpoint/src/metadata.rs`
|
||||
- `lib/crates/fabro-workflow/src/operations/{fork.rs,rewind.rs,rebuild_meta.rs}`
|
||||
- `lib/crates/fabro-cli/src/commands/{store/dump.rs,store/run_export.rs,run/rewind.rs}`
|
||||
- Related guidance: `files-internal/testing-strategy.md`
|
||||
|
|
@ -444,7 +444,7 @@ Manage GitHub pull requests created by workflow runs. Requires GitHub access to
|
|||
|
||||
### `fabro pr create`
|
||||
|
||||
Create a GitHub pull request from a completed workflow run. Uses the run's persisted run record, conclusion, and diff.
|
||||
Create a GitHub pull request from a completed workflow run. Uses the run's persisted run spec, conclusion, and diff.
|
||||
|
||||
```bash
|
||||
fabro pr create <run-id>
|
||||
|
|
@ -619,7 +619,7 @@ fabro logs -f my-workflow -p
|
|||
|
||||
## `fabro inspect`
|
||||
|
||||
Show detailed JSON data for a workflow run, including its run record, start record, conclusion, checkpoint, and sandbox record.
|
||||
Show detailed JSON data for a workflow run, including its run spec, start record, conclusion, checkpoint, and sandbox record.
|
||||
|
||||
```bash
|
||||
fabro inspect <RUN>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ The naming format is `YYYYMMDD-{run_id}`, where `run_id` is the ULID assigned to
|
|||
|
||||
## Local-only directories
|
||||
|
||||
These paths are local runtime state and caches, not the canonical run record.
|
||||
These paths are local runtime state and caches, not the canonical run state.
|
||||
|
||||
- **`worktree/`** — When running in worktree mode, Fabro creates a Git worktree here as the working directory for agents and commands.
|
||||
- **`runtime/`** — Local runtime files. Today this is mainly materialized blob payloads under `runtime/blobs/`.
|
||||
|
|
@ -34,11 +34,18 @@ Large durable values, event streams, checkpoints, diffs, conclusions, and retros
|
|||
|
||||
## Reconstructed and export-only layouts
|
||||
|
||||
Some file names you may have seen in older runs or older docs still exist in reconstructed metadata branches or `fabro store dump` exports:
|
||||
Reconstructed metadata branches and `fabro store dump` exports now use the same core layout:
|
||||
|
||||
- `run.json`, `start.json`, and `checkpoint.json` on metadata branches for rewind and fork
|
||||
- `run.json`, `start.json`, `checkpoint.json`, `conclusion.json`, `retro.json`, and `events.jsonl` in `fabro store dump` output
|
||||
- Per-node prompt, response, status, stdout, and stderr files in `fabro store dump` output and metadata rebuilds
|
||||
- `run.json` for the current projection snapshot, including the current checkpoint
|
||||
- `graph.fabro` for workflow source
|
||||
- `retro/*.md` for retro prompt/response text
|
||||
- `stages/{node_id}@{visit}/...` for per-stage prompt, response, status, diff, stdout, and stderr files
|
||||
|
||||
`fabro store dump` adds export-only history surfaces on top of that shared layout:
|
||||
|
||||
- `events.jsonl` for the durable event stream
|
||||
- `checkpoints/*.json` for checkpoint history snapshots
|
||||
- `artifacts/nodes/{node_id}/visit-{n}/...` for exported artifact payloads
|
||||
|
||||
## Browsing runs
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ doctest = false
|
|||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
fabro-store = { path = "../fabro-store" }
|
||||
fabro-types = { path = "../fabro-types" }
|
||||
git2.workspace = true
|
||||
serde.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_types::{Checkpoint, RunRecord, StartRecord};
|
||||
use fabro_store::RunProjection;
|
||||
use fabro_types::{Checkpoint, RunSpec, StartRecord};
|
||||
use git2::{Repository, Signature};
|
||||
|
||||
use crate::META_BRANCH_PREFIX;
|
||||
|
|
@ -11,8 +12,8 @@ use crate::git::Store;
|
|||
|
||||
/// Git-native metadata storage for pipeline runs.
|
||||
///
|
||||
/// Stores checkpoint data, run records, and metadata on an orphan branch
|
||||
/// (`fabro/meta/{run_id}`) so that runs can be resumed from git alone.
|
||||
/// Stores a unified `RunProjection` snapshot (plus artifacts) on an orphan
|
||||
/// branch (`fabro/meta/{run_id}`) so that runs can be resumed from git alone.
|
||||
pub struct MetadataStore {
|
||||
repo_path: PathBuf,
|
||||
author: GitAuthor,
|
||||
|
|
@ -46,9 +47,6 @@ impl MetadataStore {
|
|||
}
|
||||
|
||||
/// Initialize a run's metadata branch with the given files.
|
||||
///
|
||||
/// Callers pass all files (run.json, start.json, sandbox.json, etc.)
|
||||
/// via the `files` slice.
|
||||
pub fn init_run(&self, run_id: &str, files: &[(&str, &[u8])]) -> Result<(), MetadataError> {
|
||||
let (store, sig) = self.open_store()?;
|
||||
let branch = Self::branch_name(run_id);
|
||||
|
|
@ -59,37 +57,19 @@ impl MetadataStore {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Write arbitrary files to the metadata branch without overwriting
|
||||
/// checkpoint.json.
|
||||
pub fn write_files(
|
||||
/// Write a snapshot commit to the metadata branch and return the new
|
||||
/// commit SHA.
|
||||
pub fn write_snapshot(
|
||||
&self,
|
||||
run_id: &str,
|
||||
entries: &[(&str, &[u8])],
|
||||
message: &str,
|
||||
) -> Result<(), MetadataError> {
|
||||
let (store, sig) = self.open_store()?;
|
||||
let branch = Self::branch_name(run_id);
|
||||
let branch_store = BranchStore::new(&store, &branch, &sig);
|
||||
let message = self.commit_message(message);
|
||||
branch_store.write_entries(entries, &message)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write checkpoint data (and optional artifacts) to the metadata branch.
|
||||
/// Returns the SHA of the new commit on the shadow branch.
|
||||
pub fn write_checkpoint(
|
||||
&self,
|
||||
run_id: &str,
|
||||
checkpoint_json: &[u8],
|
||||
artifacts: &[(&str, &[u8])],
|
||||
) -> Result<String, MetadataError> {
|
||||
let (store, sig) = self.open_store()?;
|
||||
let branch = Self::branch_name(run_id);
|
||||
let branch_store = BranchStore::new(&store, &branch, &sig);
|
||||
let mut entries: Vec<(&str, &[u8])> = vec![("checkpoint.json", checkpoint_json)];
|
||||
entries.extend_from_slice(artifacts);
|
||||
let message = self.commit_message("checkpoint");
|
||||
let oid = branch_store.write_entries(&entries, &message)?;
|
||||
let message = self.commit_message(message);
|
||||
let oid = branch_store.write_entries(entries, &message)?;
|
||||
Ok(oid.to_string())
|
||||
}
|
||||
|
||||
|
|
@ -110,42 +90,39 @@ impl MetadataStore {
|
|||
Ok(branch_store.read_entry(path)?)
|
||||
}
|
||||
|
||||
/// Read the projection snapshot from the metadata branch tip. Returns
|
||||
/// `None` if branch or file doesn't exist.
|
||||
pub fn read_run_projection(
|
||||
repo_path: &Path,
|
||||
run_id: &str,
|
||||
) -> Result<Option<RunProjection>, MetadataError> {
|
||||
let branch = Self::branch_name(run_id);
|
||||
let Some(bytes) = Self::read_file(repo_path, run_id, "run.json")? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let projection: RunProjection =
|
||||
serde_json::from_slice(&bytes).map_err(|source| MetadataError::Deserialize {
|
||||
entity: "run projection",
|
||||
branch,
|
||||
source,
|
||||
})?;
|
||||
Ok(Some(projection))
|
||||
}
|
||||
|
||||
/// Read a checkpoint from the metadata branch. Returns `None` if branch or
|
||||
/// file doesn't exist.
|
||||
pub fn read_checkpoint(
|
||||
repo_path: &Path,
|
||||
run_id: &str,
|
||||
) -> Result<Option<Checkpoint>, MetadataError> {
|
||||
let branch = Self::branch_name(run_id);
|
||||
match Self::read_file(repo_path, run_id, "checkpoint.json")? {
|
||||
Some(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|source| {
|
||||
MetadataError::Deserialize {
|
||||
entity: "checkpoint",
|
||||
branch,
|
||||
source,
|
||||
}
|
||||
}),
|
||||
None => Ok(None),
|
||||
}
|
||||
Ok(Self::read_run_projection(repo_path, run_id)?
|
||||
.and_then(|projection| projection.checkpoint))
|
||||
}
|
||||
|
||||
/// Read the run record from the metadata branch. Returns `None` if not
|
||||
/// Read the run spec from the metadata branch. Returns `None` if not
|
||||
/// found.
|
||||
pub fn read_run_record(
|
||||
repo_path: &Path,
|
||||
run_id: &str,
|
||||
) -> Result<Option<RunRecord>, MetadataError> {
|
||||
let branch = Self::branch_name(run_id);
|
||||
match Self::read_file(repo_path, run_id, "run.json")? {
|
||||
Some(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|source| {
|
||||
MetadataError::Deserialize {
|
||||
entity: "run record",
|
||||
branch,
|
||||
source,
|
||||
}
|
||||
}),
|
||||
None => Ok(None),
|
||||
}
|
||||
pub fn read_run_spec(repo_path: &Path, run_id: &str) -> Result<Option<RunSpec>, MetadataError> {
|
||||
Ok(Self::read_run_projection(repo_path, run_id)?.and_then(|projection| projection.spec))
|
||||
}
|
||||
|
||||
/// Read the start record from the metadata branch. Returns `None` if not
|
||||
|
|
@ -154,17 +131,7 @@ impl MetadataStore {
|
|||
repo_path: &Path,
|
||||
run_id: &str,
|
||||
) -> Result<Option<StartRecord>, MetadataError> {
|
||||
let branch = Self::branch_name(run_id);
|
||||
match Self::read_file(repo_path, run_id, "start.json")? {
|
||||
Some(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(|source| {
|
||||
MetadataError::Deserialize {
|
||||
entity: "start record",
|
||||
branch,
|
||||
source,
|
||||
}
|
||||
}),
|
||||
None => Ok(None),
|
||||
}
|
||||
Ok(Self::read_run_projection(repo_path, run_id)?.and_then(|projection| projection.start))
|
||||
}
|
||||
|
||||
/// Read an artifact from the metadata branch. Returns `None` if not found.
|
||||
|
|
@ -215,8 +182,8 @@ mod tests {
|
|||
.unwrap();
|
||||
}
|
||||
|
||||
fn test_run_record(run_id: fabro_types::RunId) -> RunRecord {
|
||||
RunRecord {
|
||||
fn test_run_spec(run_id: fabro_types::RunId) -> RunSpec {
|
||||
RunSpec {
|
||||
run_id,
|
||||
settings: SettingsLayer::default(),
|
||||
graph: Graph::new("test"),
|
||||
|
|
@ -252,6 +219,16 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn test_projection(run_id: fabro_types::RunId) -> RunProjection {
|
||||
let mut projection = RunProjection::default();
|
||||
projection.spec = Some(test_run_spec(run_id));
|
||||
projection
|
||||
}
|
||||
|
||||
fn projection_bytes(projection: &RunProjection) -> Vec<u8> {
|
||||
serde_json::to_vec_pretty(projection).unwrap()
|
||||
}
|
||||
|
||||
fn branch_entry(repo_dir: &Path, run_id: &str, path: &str) -> Vec<u8> {
|
||||
let repo = Repository::discover(repo_dir).unwrap();
|
||||
let store = Store::new(repo);
|
||||
|
|
@ -268,16 +245,16 @@ mod tests {
|
|||
|
||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||
let run_id = fixtures::RUN_1.to_string();
|
||||
let run_record = serde_json::to_vec_pretty(&test_run_record(fixtures::RUN_1)).unwrap();
|
||||
let projection = projection_bytes(&test_projection(fixtures::RUN_1));
|
||||
store
|
||||
.init_run(&run_id, &[("run.json", &run_record)])
|
||||
.init_run(&run_id, &[("run.json", &projection)])
|
||||
.unwrap();
|
||||
|
||||
let read_record = MetadataStore::read_run_record(dir.path(), &run_id)
|
||||
let read_spec = MetadataStore::read_run_spec(dir.path(), &run_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(read_record.run_id, fixtures::RUN_1);
|
||||
assert_eq!(read_record.graph.name, "test");
|
||||
assert_eq!(read_spec.run_id, fixtures::RUN_1);
|
||||
assert_eq!(read_spec.graph.name, "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -287,7 +264,10 @@ mod tests {
|
|||
|
||||
let run_id = fixtures::RUN_2.to_string();
|
||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||
store.init_run(&run_id, &[]).unwrap();
|
||||
let init_projection = projection_bytes(&test_projection(fixtures::RUN_2));
|
||||
store
|
||||
.init_run(&run_id, &[("run.json", &init_projection)])
|
||||
.unwrap();
|
||||
|
||||
let mut checkpoint = test_checkpoint(
|
||||
"node_a",
|
||||
|
|
@ -297,9 +277,11 @@ mod tests {
|
|||
checkpoint
|
||||
.context_values
|
||||
.insert("goal".to_string(), serde_json::json!("test"));
|
||||
let checkpoint_json = serde_json::to_vec_pretty(&checkpoint).unwrap();
|
||||
let mut snapshot = test_projection(fixtures::RUN_2);
|
||||
snapshot.checkpoint = Some(checkpoint);
|
||||
let snapshot_json = projection_bytes(&snapshot);
|
||||
store
|
||||
.write_checkpoint(&run_id, &checkpoint_json, &[])
|
||||
.write_snapshot(&run_id, &[("run.json", &snapshot_json)], "checkpoint")
|
||||
.unwrap();
|
||||
|
||||
let loaded = MetadataStore::read_checkpoint(dir.path(), &run_id)
|
||||
|
|
@ -321,23 +303,27 @@ mod tests {
|
|||
|
||||
let run_id = fixtures::RUN_3.to_string();
|
||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||
store.init_run(&run_id, &[]).unwrap();
|
||||
|
||||
let checkpoint_one =
|
||||
serde_json::to_vec_pretty(&test_checkpoint("node_a", vec!["start".to_string()], None))
|
||||
.unwrap();
|
||||
let init_projection = projection_bytes(&test_projection(fixtures::RUN_3));
|
||||
store
|
||||
.write_checkpoint(&run_id, &checkpoint_one, &[])
|
||||
.init_run(&run_id, &[("run.json", &init_projection)])
|
||||
.unwrap();
|
||||
|
||||
let checkpoint_two = serde_json::to_vec_pretty(&test_checkpoint(
|
||||
let mut snapshot_one = test_projection(fixtures::RUN_3);
|
||||
snapshot_one.checkpoint = Some(test_checkpoint("node_a", vec!["start".to_string()], None));
|
||||
let checkpoint_one = projection_bytes(&snapshot_one);
|
||||
store
|
||||
.write_snapshot(&run_id, &[("run.json", &checkpoint_one)], "checkpoint")
|
||||
.unwrap();
|
||||
|
||||
let mut snapshot_two = test_projection(fixtures::RUN_3);
|
||||
snapshot_two.checkpoint = Some(test_checkpoint(
|
||||
"node_b",
|
||||
vec!["start".to_string(), "node_a".to_string()],
|
||||
Some("node_c".to_string()),
|
||||
))
|
||||
.unwrap();
|
||||
));
|
||||
let checkpoint_two = projection_bytes(&snapshot_two);
|
||||
store
|
||||
.write_checkpoint(&run_id, &checkpoint_two, &[])
|
||||
.write_snapshot(&run_id, &[("run.json", &checkpoint_two)], "checkpoint")
|
||||
.unwrap();
|
||||
|
||||
let loaded = MetadataStore::read_checkpoint(dir.path(), &run_id)
|
||||
|
|
@ -363,16 +349,24 @@ mod tests {
|
|||
|
||||
let run_id = fixtures::RUN_4.to_string();
|
||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||
store.init_run(&run_id, &[]).unwrap();
|
||||
let init_projection = projection_bytes(&test_projection(fixtures::RUN_4));
|
||||
store
|
||||
.init_run(&run_id, &[("run.json", &init_projection)])
|
||||
.unwrap();
|
||||
|
||||
let artifact_data = br#"{"large_output":"some data"}"#;
|
||||
let checkpoint_json =
|
||||
serde_json::to_vec_pretty(&test_checkpoint("node_a", Vec::new(), None)).unwrap();
|
||||
let mut snapshot = test_projection(fixtures::RUN_4);
|
||||
snapshot.checkpoint = Some(test_checkpoint("node_a", Vec::new(), None));
|
||||
let snapshot_json = projection_bytes(&snapshot);
|
||||
store
|
||||
.write_checkpoint(&run_id, &checkpoint_json, &[(
|
||||
"artifacts/response.plan.json",
|
||||
artifact_data.as_slice(),
|
||||
)])
|
||||
.write_snapshot(
|
||||
&run_id,
|
||||
&[
|
||||
("run.json", &snapshot_json),
|
||||
("artifacts/response.plan.json", artifact_data.as_slice()),
|
||||
],
|
||||
"checkpoint",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let read_back = MetadataStore::read_artifact(dir.path(), &run_id, "response.plan")
|
||||
|
|
@ -382,32 +376,32 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_store_write_files() {
|
||||
fn metadata_store_write_snapshot_preserves_prior_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
init_repo(dir.path());
|
||||
|
||||
let run_id = fixtures::RUN_5.to_string();
|
||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||
let run_record = serde_json::to_vec_pretty(&test_run_record(fixtures::RUN_5)).unwrap();
|
||||
let projection = projection_bytes(&test_projection(fixtures::RUN_5));
|
||||
store
|
||||
.init_run(&run_id, &[("run.json", &run_record)])
|
||||
.init_run(&run_id, &[("run.json", &projection)])
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.write_files(
|
||||
.write_snapshot(
|
||||
&run_id,
|
||||
&[("retro.json", b"{\"status\":\"ok\"}")],
|
||||
&[("retro/prompt.md", b"how did it go?")],
|
||||
"finalize run",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let data = branch_entry(dir.path(), &run_id, "retro.json");
|
||||
assert_eq!(data, b"{\"status\":\"ok\"}");
|
||||
let data = branch_entry(dir.path(), &run_id, "retro/prompt.md");
|
||||
assert_eq!(data, b"how did it go?");
|
||||
|
||||
let record = MetadataStore::read_run_record(dir.path(), &run_id)
|
||||
let spec = MetadataStore::read_run_spec(dir.path(), &run_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(record.run_id, fixtures::RUN_5);
|
||||
assert_eq!(spec.run_id, fixtures::RUN_5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -418,11 +412,11 @@ mod tests {
|
|||
let run_id = fixtures::RUN_6.to_string();
|
||||
let store = MetadataStore::new(dir.path(), &GitAuthor::default());
|
||||
store
|
||||
.init_run(&run_id, &[("sandbox.json", b"{\"type\":\"local\"}")])
|
||||
.init_run(&run_id, &[("graph.fabro", b"digraph Test {}")])
|
||||
.unwrap();
|
||||
|
||||
let data = branch_entry(dir.path(), &run_id, "sandbox.json");
|
||||
assert_eq!(data, b"{\"type\":\"local\"}");
|
||||
let data = branch_entry(dir.path(), &run_id, "graph.fabro");
|
||||
assert_eq!(data, b"digraph Test {}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -438,8 +432,10 @@ mod tests {
|
|||
run_branch: Some("fabro/run/test".to_string()),
|
||||
base_sha: None,
|
||||
};
|
||||
let bytes = serde_json::to_vec_pretty(&start_record).unwrap();
|
||||
store.init_run(&run_id, &[("start.json", &bytes)]).unwrap();
|
||||
let mut projection = test_projection(fixtures::RUN_6);
|
||||
projection.start = Some(start_record);
|
||||
let bytes = projection_bytes(&projection);
|
||||
store.init_run(&run_id, &[("run.json", &bytes)]).unwrap();
|
||||
|
||||
let loaded = MetadataStore::read_start_record(dir.path(), &run_id)
|
||||
.unwrap()
|
||||
|
|
|
|||
|
|
@ -34,9 +34,9 @@ pub(super) async fn create_command(
|
|||
let run_store = rebuild_run_store(&run_id, &events).await?;
|
||||
let state = run_store.state().await?;
|
||||
|
||||
let record = state.run.context("Failed to load run record from store")?;
|
||||
let run_spec = state.spec.context("Failed to load run spec from store")?;
|
||||
ensure_matching_repo_origin(
|
||||
record.repo_origin_url.as_deref(),
|
||||
run_spec.repo_origin_url.as_deref(),
|
||||
"create a pull request for",
|
||||
)?;
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ pub(super) async fn create_command(
|
|||
let (origin_url, detected_branch) =
|
||||
detect_repo_info(&cwd).map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
let base_branch = record
|
||||
let base_branch = run_spec
|
||||
.base_branch
|
||||
.as_deref()
|
||||
.or(detected_branch.as_deref())
|
||||
|
|
@ -113,12 +113,12 @@ pub(super) async fn create_command(
|
|||
.clone()
|
||||
});
|
||||
|
||||
let record = maybe_open_pull_request(
|
||||
let pull_request = maybe_open_pull_request(
|
||||
&creds,
|
||||
&origin_url,
|
||||
base_branch,
|
||||
run_branch,
|
||||
record.graph.goal(),
|
||||
run_spec.graph.goal(),
|
||||
&diff,
|
||||
&model,
|
||||
true,
|
||||
|
|
@ -129,7 +129,7 @@ pub(super) async fn create_command(
|
|||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
|
||||
match record {
|
||||
match pull_request {
|
||||
Some(record) => {
|
||||
info!(pr_url = %record.html_url, "Pull request created");
|
||||
if cli.output.format == OutputFormat::Json {
|
||||
|
|
|
|||
|
|
@ -57,14 +57,14 @@ pub(crate) async fn attach_run(
|
|||
|
||||
if let (Some(storage_dir), Some(run_id)) = (storage_dir.as_deref(), run_id.as_ref()) {
|
||||
let client = server_client::connect_server(storage_dir).await?;
|
||||
return attach_run_with_client(
|
||||
return Box::pin(attach_run_with_client(
|
||||
&client,
|
||||
run_id,
|
||||
kill_on_detach,
|
||||
styles,
|
||||
json_output,
|
||||
Printer::Default,
|
||||
)
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
|
|
@ -82,11 +82,11 @@ pub(crate) async fn attach_run_with_client(
|
|||
printer: Printer,
|
||||
) -> Result<ExitCode> {
|
||||
let state = client.get_run_state(run_id).await?;
|
||||
let auto_approve = state.run.as_ref().is_some_and(|record| {
|
||||
let auto_approve = state.spec.as_ref().is_some_and(|record| {
|
||||
fabro_config::resolve_run_from_file(&record.settings)
|
||||
.is_ok_and(|settings| settings.execution.approval == ApprovalMode::Auto)
|
||||
});
|
||||
let verbose = state.run.as_ref().is_some_and(|record| {
|
||||
let verbose = state.spec.as_ref().is_some_and(|record| {
|
||||
fabro_config::resolve_cli_from_file(&record.settings)
|
||||
.is_ok_and(|settings| settings.output.verbosity == OutputVerbosity::Verbose)
|
||||
});
|
||||
|
|
@ -498,7 +498,7 @@ mod tests {
|
|||
|
||||
fn terminal_run_state_response() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"run": null,
|
||||
"spec": null,
|
||||
"graph_source": null,
|
||||
"start": null,
|
||||
"status": {
|
||||
|
|
@ -535,9 +535,16 @@ mod tests {
|
|||
async fn attach_errors_without_store_context() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let err = attach_run(dir.path(), None, None, false, no_color_styles(), false)
|
||||
.await
|
||||
.unwrap_err();
|
||||
let err = Box::pin(attach_run(
|
||||
dir.path(),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
no_color_styles(),
|
||||
false,
|
||||
))
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
|
|
|
|||
|
|
@ -58,14 +58,14 @@ pub(crate) async fn execute(
|
|||
fabro_util::printout!(printer, "{}", created_run.run_id);
|
||||
}
|
||||
} else {
|
||||
let exit_code = super::attach::attach_run_with_client(
|
||||
let exit_code = Box::pin(super::attach::attach_run_with_client(
|
||||
&client,
|
||||
&created_run.run_id,
|
||||
true,
|
||||
styles,
|
||||
json,
|
||||
printer,
|
||||
)
|
||||
))
|
||||
.await?;
|
||||
if !json {
|
||||
super::output::print_run_summary_with_client(
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ pub(crate) struct CreatedRun {
|
|||
pub(crate) local_run_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Create a workflow run: allocate run directory, persist RunRecord, return
|
||||
/// Create a workflow run: allocate run directory, persist RunSpec, return
|
||||
/// (run_id, run_dir).
|
||||
///
|
||||
/// This does NOT execute the workflow — it only prepares the run directory.
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ pub(crate) async fn run(
|
|||
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 record = state.run.context("Failed to load run record from store")?;
|
||||
ensure_matching_repo_origin(record.repo_origin_url.as_deref(), "fork")?;
|
||||
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?;
|
||||
|
|
|
|||
|
|
@ -71,14 +71,14 @@ pub(crate) async fn dispatch(
|
|||
let ctx = CommandContext::for_target(&server, printer, cli.clone(), cli_layer)?;
|
||||
let client = ctx.server().await?;
|
||||
let run_id = client.resolve_run(&run).await?.run_id;
|
||||
let exit_code = attach::attach_run_with_client(
|
||||
let exit_code = Box::pin(attach::attach_run_with_client(
|
||||
client.as_ref(),
|
||||
&run_id,
|
||||
false,
|
||||
styles,
|
||||
cli.output.format == OutputFormat::Json,
|
||||
printer,
|
||||
)
|
||||
))
|
||||
.await?;
|
||||
if exit_code != std::process::ExitCode::SUCCESS {
|
||||
std::process::exit(1);
|
||||
|
|
@ -116,7 +116,10 @@ pub(crate) async fn dispatch(
|
|||
CommandContext::for_target(&args.server, printer, cli.clone(), cli_layer)?;
|
||||
crate::sleep_inhibitor::guard(ctx.cli_settings().exec.prevent_idle_sleep)
|
||||
};
|
||||
resume::resume_command(args, styles, cli, cli_layer, printer).await
|
||||
Box::pin(resume::resume_command(
|
||||
args, styles, cli, cli_layer, printer,
|
||||
))
|
||||
.await
|
||||
}
|
||||
RunCommands::Rewind(args) => {
|
||||
let styles = Styles::detect_stderr();
|
||||
|
|
|
|||
|
|
@ -33,14 +33,14 @@ pub(crate) async fn resume_command(
|
|||
fabro_util::printout!(printer, "{run_id}");
|
||||
}
|
||||
} else {
|
||||
let exit_code = super::attach::attach_run_with_client(
|
||||
let exit_code = Box::pin(super::attach::attach_run_with_client(
|
||||
client.as_ref(),
|
||||
&run_id,
|
||||
true,
|
||||
styles,
|
||||
json,
|
||||
printer,
|
||||
)
|
||||
))
|
||||
.await?;
|
||||
if !json {
|
||||
super::output::print_run_summary_with_client(
|
||||
|
|
|
|||
|
|
@ -47,8 +47,8 @@ pub(crate) async fn run(
|
|||
.as_ref()
|
||||
.map(|record| record.status)
|
||||
.context("run has no recorded status — cannot rewind")?;
|
||||
let record = state.run.context("Failed to load run record from store")?;
|
||||
ensure_matching_repo_origin(record.repo_origin_url.as_deref(), "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?;
|
||||
|
|
@ -120,12 +120,13 @@ async fn reset_rewound_run_state(
|
|||
anyhow::anyhow!("failed to load durable store state before rewind: {err}")
|
||||
})?;
|
||||
|
||||
let definition_blob = state.run.as_ref().and_then(|run| run.definition_blob);
|
||||
let _run_record = state
|
||||
.run
|
||||
.context("failed to restore run record after rewind: missing run metadata")?;
|
||||
let checkpoint = MetadataStore::read_checkpoint(git_store.repo_dir(), &run_id.to_string())?
|
||||
.context("rewound metadata branch is missing checkpoint.json")?;
|
||||
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.status.to_string());
|
||||
|
||||
client
|
||||
|
|
|
|||
|
|
@ -69,10 +69,10 @@ pub(crate) async fn execute(
|
|||
.state()
|
||||
.await
|
||||
.with_context(|| format!("failed to load run state for {run_id}"))?;
|
||||
let run_record = run_state
|
||||
.run
|
||||
let run_spec = run_state
|
||||
.spec
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow!("Run {run_id} has no run record in store"))?;
|
||||
.ok_or_else(|| anyhow!("Run {run_id} has no run spec in store"))?;
|
||||
let artifact_sink = Some(ArtifactSink::Uploader(build_artifact_uploader(
|
||||
run_id,
|
||||
client.clone_for_reuse(),
|
||||
|
|
@ -89,7 +89,7 @@ pub(crate) async fn execute(
|
|||
Some(arc) => Some(arc.read().await),
|
||||
None => None,
|
||||
};
|
||||
maybe_build_github_credentials(&run_record.settings, vault_guard.as_deref())?
|
||||
maybe_build_github_credentials(&run_spec.settings, vault_guard.as_deref())?
|
||||
};
|
||||
let services = StartServices {
|
||||
run_id,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use crate::server_runs::ServerRunSummaryInfo;
|
|||
pub(crate) struct InspectOutput {
|
||||
pub run_id: String,
|
||||
pub status: RunStatus,
|
||||
pub run_record: Option<serde_json::Value>,
|
||||
pub run_spec: Option<serde_json::Value>,
|
||||
pub start_record: Option<serde_json::Value>,
|
||||
pub conclusion: Option<serde_json::Value>,
|
||||
pub checkpoint: Option<serde_json::Value>,
|
||||
|
|
@ -45,8 +45,8 @@ fn inspect_run_state(run: &ServerRunSummaryInfo, state: RunProjection) -> Inspec
|
|||
.status
|
||||
.as_ref()
|
||||
.map_or(run.status(), |record| record.status),
|
||||
run_record: state
|
||||
.run
|
||||
run_spec: state
|
||||
.spec
|
||||
.and_then(|record| serde_json::to_value(record).ok()),
|
||||
start_record: state
|
||||
.start
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ pub(crate) async fn export_run(
|
|||
) -> Result<usize> {
|
||||
let state = run_store.state().await?;
|
||||
let run_id = state
|
||||
.run
|
||||
.spec
|
||||
.as_ref()
|
||||
.map(|run| run.run_id)
|
||||
.context("run has no data in the store")?;
|
||||
|
|
@ -317,7 +317,7 @@ mod tests {
|
|||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_types::{
|
||||
AggregateStats, AttrValue, BilledTokenCounts, Checkpoint, Conclusion, Graph,
|
||||
NodeStatusRecord, Retro, RunId, RunRecord, RunStatus, RunStatusRecord, SandboxRecord,
|
||||
NodeStatusRecord, Retro, RunId, RunSpec, RunStatus, RunStatusRecord, SandboxRecord,
|
||||
StageStatus, StartRecord, StatusReason, fixtures,
|
||||
};
|
||||
use fabro_workflow::event::{Event, append_event};
|
||||
|
|
@ -348,13 +348,13 @@ mod tests {
|
|||
(store, artifact_store)
|
||||
}
|
||||
|
||||
fn sample_run_record(run_id: RunId, _created_at: DateTime<Utc>) -> RunRecord {
|
||||
fn sample_run_spec(run_id: RunId, _created_at: DateTime<Utc>) -> RunSpec {
|
||||
let mut graph = Graph::new("night-sky");
|
||||
graph.attrs.insert(
|
||||
"goal".to_string(),
|
||||
AttrValue::String("map the constellations".to_string()),
|
||||
);
|
||||
RunRecord {
|
||||
RunSpec {
|
||||
run_id,
|
||||
settings: SettingsLayer::default(),
|
||||
graph,
|
||||
|
|
@ -478,7 +478,7 @@ mod tests {
|
|||
let created_at = dt("2026-03-27T12:00:00Z");
|
||||
let run_id = test_run_id();
|
||||
let run = store.create_run(&run_id).await.unwrap();
|
||||
let run_record = sample_run_record(run_id, created_at);
|
||||
let run_spec = sample_run_spec(run_id, created_at);
|
||||
let start_record = sample_start_record(run_id, created_at);
|
||||
let status_record = sample_status();
|
||||
let mut first_checkpoint = sample_checkpoint("plan", 1);
|
||||
|
|
@ -500,19 +500,19 @@ mod tests {
|
|||
let node = StageId::new("code", 2);
|
||||
append_event(&run, &run_id, &Event::RunCreated {
|
||||
run_id,
|
||||
settings: serde_json::to_value(&run_record.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_record.graph).unwrap(),
|
||||
settings: serde_json::to_value(&run_spec.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_spec.graph).unwrap(),
|
||||
workflow_source: Some("digraph night_sky {}".to_string()),
|
||||
workflow_config: None,
|
||||
labels: run_record.labels.clone().into_iter().collect(),
|
||||
labels: run_spec.labels.clone().into_iter().collect(),
|
||||
run_dir: "/tmp/night-sky-run".to_string(),
|
||||
working_directory: run_record.working_directory.display().to_string(),
|
||||
host_repo_path: run_record.host_repo_path.clone(),
|
||||
repo_origin_url: run_record.repo_origin_url.clone(),
|
||||
base_branch: run_record.base_branch.clone(),
|
||||
workflow_slug: run_record.workflow_slug.clone(),
|
||||
working_directory: run_spec.working_directory.display().to_string(),
|
||||
host_repo_path: run_spec.host_repo_path.clone(),
|
||||
repo_origin_url: run_spec.repo_origin_url.clone(),
|
||||
base_branch: run_spec.base_branch.clone(),
|
||||
workflow_slug: run_spec.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: run_record.provenance.clone(),
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
})
|
||||
.await
|
||||
|
|
@ -520,7 +520,7 @@ mod tests {
|
|||
append_event(&run, &run_id, &Event::WorkflowRunStarted {
|
||||
name: "night-sky".to_string(),
|
||||
run_id,
|
||||
base_branch: run_record.base_branch.clone(),
|
||||
base_branch: run_spec.base_branch.clone(),
|
||||
base_sha: start_record.base_sha.clone(),
|
||||
run_branch: start_record.run_branch.clone(),
|
||||
worktree_dir: None,
|
||||
|
|
@ -707,47 +707,80 @@ mod tests {
|
|||
let file_count = export_run(&run, &artifact_store, output.path())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(file_count, 20);
|
||||
assert_eq!(file_count, 16);
|
||||
|
||||
let exported_run: RunRecord = read_json(&output.path().join("run.json"));
|
||||
assert_eq!(exported_run.run_id, run_id);
|
||||
|
||||
let exported_start: StartRecord = read_json(&output.path().join("start.json"));
|
||||
assert_eq!(exported_start.run_id, run_id);
|
||||
|
||||
let exported_status: RunStatusRecord = read_json(&output.path().join("status.json"));
|
||||
assert_eq!(exported_status.status, RunStatus::Succeeded);
|
||||
|
||||
let exported_checkpoint: Checkpoint = read_json(&output.path().join("checkpoint.json"));
|
||||
assert_eq!(exported_checkpoint.current_node, "code");
|
||||
let exported_run: RunProjection = read_json(&output.path().join("run.json"));
|
||||
assert_eq!(
|
||||
exported_checkpoint.context_values.get("artifact"),
|
||||
exported_run.spec.as_ref().map(|run| run.run_id),
|
||||
Some(run_id)
|
||||
);
|
||||
assert_eq!(
|
||||
exported_run.start.as_ref().map(|start| start.run_id),
|
||||
Some(run_id)
|
||||
);
|
||||
assert_eq!(
|
||||
exported_run.status.as_ref().map(|status| status.status),
|
||||
Some(RunStatus::Succeeded)
|
||||
);
|
||||
assert_eq!(
|
||||
exported_run
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.map(|checkpoint| checkpoint.current_node.as_str()),
|
||||
Some("code")
|
||||
);
|
||||
assert_eq!(
|
||||
exported_run
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.and_then(|checkpoint| checkpoint.context_values.get("artifact")),
|
||||
Some(&serde_json::json!({"done": true}))
|
||||
);
|
||||
assert!(exported_run.conclusion.is_some());
|
||||
assert!(exported_run.sandbox.is_some());
|
||||
assert!(exported_run.retro.is_some());
|
||||
assert!(!output.path().join("start.json").exists());
|
||||
assert!(!output.path().join("status.json").exists());
|
||||
assert!(!output.path().join("checkpoint.json").exists());
|
||||
assert!(!output.path().join("sandbox.json").exists());
|
||||
assert!(!output.path().join("retro.json").exists());
|
||||
assert!(!output.path().join("conclusion.json").exists());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(output.path().join("graph.fabro")).unwrap(),
|
||||
"digraph night_sky {}"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(output.path().join("nodes/code/visit-2/prompt.md")).unwrap(),
|
||||
std::fs::read_to_string(output.path().join("stages/code@2/prompt.md")).unwrap(),
|
||||
"Plan the fix"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(output.path().join("nodes/code/visit-2/response.md")).unwrap(),
|
||||
std::fs::read_to_string(output.path().join("stages/code@2/response.md")).unwrap(),
|
||||
"Implemented"
|
||||
);
|
||||
let node_status: NodeStatusRecord =
|
||||
read_json(&output.path().join("nodes/code/visit-2/status.json"));
|
||||
read_json(&output.path().join("stages/code@2/status.json"));
|
||||
assert_eq!(node_status.status, StageStatus::Success);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(output.path().join("nodes/code/visit-2/stdout.log")).unwrap(),
|
||||
std::fs::read_to_string(output.path().join("stages/code@2/stdout.log")).unwrap(),
|
||||
"stdout line"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(output.path().join("nodes/code/visit-2/stderr.log")).unwrap(),
|
||||
std::fs::read_to_string(output.path().join("stages/code@2/stderr.log")).unwrap(),
|
||||
""
|
||||
);
|
||||
assert!(
|
||||
output
|
||||
.path()
|
||||
.join("stages/code@2/script_invocation.json")
|
||||
.is_file()
|
||||
);
|
||||
assert!(
|
||||
output
|
||||
.path()
|
||||
.join("stages/code@2/script_timing.json")
|
||||
.is_file()
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(output.path().join("retro/prompt.md")).unwrap(),
|
||||
|
|
@ -799,7 +832,7 @@ mod tests {
|
|||
.unwrap(),
|
||||
b"hello"
|
||||
);
|
||||
assert!(!output.path().join("nodes/artifact-only").exists());
|
||||
assert!(!output.path().join("stages/artifact-only@7").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -1,372 +1 @@
|
|||
#![expect(
|
||||
clippy::disallowed_methods,
|
||||
reason = "CLI-owned export writer uses sync std::fs for final local materialization"
|
||||
)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
#[expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "in-memory Vec<u8>::write_all for jsonl serialization; no filesystem or network I/O"
|
||||
)]
|
||||
use std::io::Write;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use bytes::Bytes;
|
||||
use fabro_store::{EventEnvelope, RunProjection, StageId};
|
||||
use fabro_types::{RunBlobId, parse_blob_ref, parse_legacy_blob_file_ref};
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct StoreRunExport {
|
||||
entries: Vec<StoreRunExportEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct StoreRunExportEntry {
|
||||
path: String,
|
||||
contents: StoreRunExportContents,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum StoreRunExportContents {
|
||||
Text(String),
|
||||
Json(serde_json::Value),
|
||||
Bytes(Vec<u8>),
|
||||
}
|
||||
|
||||
impl StoreRunExport {
|
||||
pub(super) fn from_store_state_and_events(
|
||||
state: &RunProjection,
|
||||
events: &[EventEnvelope],
|
||||
) -> Result<Self> {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
if let Some(record) = state.run.as_ref() {
|
||||
push_json_entry(&mut entries, "run.json", record);
|
||||
}
|
||||
if let Some(record) = state.start.as_ref() {
|
||||
push_json_entry(&mut entries, "start.json", record);
|
||||
}
|
||||
if let Some(record) = state.status.as_ref() {
|
||||
push_json_entry(&mut entries, "status.json", record);
|
||||
}
|
||||
if let Some(record) = state.checkpoint.as_ref() {
|
||||
push_json_entry(&mut entries, "checkpoint.json", record);
|
||||
}
|
||||
if let Some(record) = state.conclusion.as_ref() {
|
||||
push_json_entry(&mut entries, "conclusion.json", record);
|
||||
}
|
||||
if let Some(record) = state.retro.as_ref() {
|
||||
push_json_entry(&mut entries, "retro.json", record);
|
||||
}
|
||||
if let Some(graph_source) = state.graph_source.as_ref() {
|
||||
entries.push(StoreRunExportEntry::text(
|
||||
"graph.fabro",
|
||||
graph_source.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(record) = state.sandbox.as_ref() {
|
||||
push_json_entry(&mut entries, "sandbox.json", record);
|
||||
}
|
||||
|
||||
let mut node_keys: Vec<_> = state.iter_nodes().map(|(node, _)| node.clone()).collect();
|
||||
node_keys.sort();
|
||||
for node_key in &node_keys {
|
||||
let node = state
|
||||
.node(node_key)
|
||||
.with_context(|| format!("missing node {node_key:?} in projection"))?;
|
||||
let node_id_segment = validate_single_path_segment("node id", node_key.node_id())?;
|
||||
let base = PathBuf::from("nodes")
|
||||
.join(node_id_segment)
|
||||
.join(format!("visit-{}", node_key.visit()));
|
||||
|
||||
if let Some(prompt) = node.prompt.as_ref() {
|
||||
entries.push(StoreRunExportEntry::text_path(
|
||||
&base.join("prompt.md"),
|
||||
prompt.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(response) = node.response.as_ref() {
|
||||
entries.push(StoreRunExportEntry::text_path(
|
||||
&base.join("response.md"),
|
||||
response.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(status) = node.status.as_ref() {
|
||||
push_json_entry_path(&mut entries, &base.join("status.json"), status);
|
||||
}
|
||||
if let Some(stdout) = node.stdout.as_ref() {
|
||||
entries.push(StoreRunExportEntry::text_path(
|
||||
&base.join("stdout.log"),
|
||||
stdout.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(stderr) = node.stderr.as_ref() {
|
||||
entries.push(StoreRunExportEntry::text_path(
|
||||
&base.join("stderr.log"),
|
||||
stderr.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(prompt) = state.retro_prompt.as_ref() {
|
||||
entries.push(StoreRunExportEntry::text("retro/prompt.md", prompt.clone()));
|
||||
}
|
||||
if let Some(response) = state.retro_response.as_ref() {
|
||||
entries.push(StoreRunExportEntry::text(
|
||||
"retro/response.md",
|
||||
response.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut events_jsonl = Vec::new();
|
||||
for event in events {
|
||||
serde_json::to_writer(&mut events_jsonl, event)?;
|
||||
events_jsonl.write_all(b"\n")?;
|
||||
}
|
||||
entries.push(StoreRunExportEntry::bytes("events.jsonl", events_jsonl));
|
||||
|
||||
for (seq, checkpoint) in &state.checkpoints {
|
||||
push_json_entry_path(
|
||||
&mut entries,
|
||||
&PathBuf::from("checkpoints").join(format!("{seq:04}.json")),
|
||||
checkpoint,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self { entries })
|
||||
}
|
||||
|
||||
pub(super) fn add_artifact_bytes(
|
||||
&mut self,
|
||||
stage_id: &StageId,
|
||||
filename: &str,
|
||||
data: Vec<u8>,
|
||||
) -> Result<()> {
|
||||
let path = artifact_dump_path(stage_id, filename)?;
|
||||
self.entries
|
||||
.push(StoreRunExportEntry::bytes_path(&path, data));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn hydrate_referenced_blobs_with_reader<'a, F>(
|
||||
&mut self,
|
||||
mut read_blob: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnMut(RunBlobId) -> BoxFuture<'a, Result<Option<Bytes>>>,
|
||||
{
|
||||
let mut cache = HashMap::new();
|
||||
for entry in &mut self.entries {
|
||||
if let StoreRunExportContents::Json(value) = &mut entry.contents {
|
||||
let mut blob_ids = Vec::new();
|
||||
collect_blob_refs_in_value(value, &mut blob_ids);
|
||||
for blob_id in blob_ids {
|
||||
if cache.contains_key(&blob_id) {
|
||||
continue;
|
||||
}
|
||||
let blob = read_blob(blob_id)
|
||||
.await?
|
||||
.with_context(|| format!("blob {blob_id:?} is missing from the store"))?;
|
||||
let hydrated: serde_json::Value = serde_json::from_slice(&blob)
|
||||
.with_context(|| format!("blob {blob_id:?} is not valid JSON"))?;
|
||||
cache.insert(blob_id, hydrated);
|
||||
}
|
||||
replace_blob_refs_in_value(value, &cache)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn write_to_dir(&self, root: &Path) -> Result<usize> {
|
||||
for entry in &self.entries {
|
||||
entry.write_to_dir(root)?;
|
||||
}
|
||||
Ok(self.entries.len())
|
||||
}
|
||||
}
|
||||
|
||||
impl StoreRunExportEntry {
|
||||
fn text(path: impl Into<String>, contents: String) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
contents: StoreRunExportContents::Text(contents),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_path(path: &Path, contents: String) -> Self {
|
||||
Self {
|
||||
path: path_to_string(path),
|
||||
contents: StoreRunExportContents::Text(contents),
|
||||
}
|
||||
}
|
||||
|
||||
fn json(path: impl Into<String>, contents: serde_json::Value) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
contents: StoreRunExportContents::Json(contents),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_path(path: &Path, contents: serde_json::Value) -> Self {
|
||||
Self {
|
||||
path: path_to_string(path),
|
||||
contents: StoreRunExportContents::Json(contents),
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes(path: impl Into<String>, contents: Vec<u8>) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
contents: StoreRunExportContents::Bytes(contents),
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes_path(path: &Path, contents: Vec<u8>) -> Self {
|
||||
Self {
|
||||
path: path_to_string(path),
|
||||
contents: StoreRunExportContents::Bytes(contents),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_to_dir(&self, root: &Path) -> Result<()> {
|
||||
let relative = validate_relative_path("run dump path", &self.path)?;
|
||||
let path = root.join(relative);
|
||||
ensure_parent_dir(&path)?;
|
||||
std::fs::write(&path, self.contents.to_bytes()?)
|
||||
.with_context(|| format!("failed to write {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl StoreRunExportContents {
|
||||
fn to_bytes(&self) -> Result<Vec<u8>> {
|
||||
match self {
|
||||
Self::Text(value) => Ok(value.as_bytes().to_vec()),
|
||||
Self::Json(value) => Ok(serde_json::to_vec_pretty(value)?),
|
||||
Self::Bytes(value) => Ok(value.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_json_entry<T>(entries: &mut Vec<StoreRunExportEntry>, path: &str, value: &T)
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
if let Ok(value) = serde_json::to_value(value) {
|
||||
entries.push(StoreRunExportEntry::json(path, value));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_json_entry_path<T>(entries: &mut Vec<StoreRunExportEntry>, path: &Path, value: &T)
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
if let Ok(value) = serde_json::to_value(value) {
|
||||
entries.push(StoreRunExportEntry::json_path(path, value));
|
||||
}
|
||||
}
|
||||
|
||||
fn path_to_string(path: &Path) -> String {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn validate_single_path_segment(kind: &str, value: &str) -> Result<PathBuf> {
|
||||
let path = validate_relative_path(kind, value)?;
|
||||
if path.components().count() != 1 {
|
||||
bail!("{kind} {value:?} must be a single path segment");
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn validate_relative_path(kind: &str, value: &str) -> Result<PathBuf> {
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in Path::new(value).components() {
|
||||
match component {
|
||||
Component::Normal(part) => normalized.push(part),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
|
||||
bail!("{kind} {value:?} must be a relative path without '..'");
|
||||
}
|
||||
}
|
||||
}
|
||||
if normalized.as_os_str().is_empty() {
|
||||
bail!("{kind} {value:?} must not be empty");
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn collect_blob_refs_in_value(value: &serde_json::Value, blob_ids: &mut Vec<RunBlobId>) {
|
||||
match value {
|
||||
serde_json::Value::String(current) => {
|
||||
if let Some(blob_id) =
|
||||
parse_blob_ref(current).or_else(|| parse_legacy_blob_file_ref(current))
|
||||
{
|
||||
blob_ids.push(blob_id);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_blob_refs_in_value(item, blob_ids);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
for item in map.values() {
|
||||
collect_blob_refs_in_value(item, blob_ids);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_blob_refs_in_value(
|
||||
value: &mut serde_json::Value,
|
||||
cache: &HashMap<RunBlobId, serde_json::Value>,
|
||||
) -> Result<()> {
|
||||
match value {
|
||||
serde_json::Value::String(current) => {
|
||||
let Some(blob_id) =
|
||||
parse_blob_ref(current).or_else(|| parse_legacy_blob_file_ref(current))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let hydrated = cache
|
||||
.get(&blob_id)
|
||||
.cloned()
|
||||
.with_context(|| format!("blob {blob_id:?} is missing from the hydration cache"))?;
|
||||
*value = hydrated;
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
replace_blob_refs_in_value(item, cache)?;
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
for item in map.values_mut() {
|
||||
replace_blob_refs_in_value(item, cache)?;
|
||||
}
|
||||
}
|
||||
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn artifact_dump_path(stage_id: &StageId, filename: &str) -> Result<PathBuf> {
|
||||
let node_id_segment = validate_single_path_segment("node id", stage_id.node_id())?;
|
||||
let filename_path = validate_relative_path("artifact filename", filename)?;
|
||||
Ok(PathBuf::from("artifacts")
|
||||
.join("nodes")
|
||||
.join(node_id_segment)
|
||||
.join(format!("visit-{}", stage_id.visit()))
|
||||
.join(filename_path))
|
||||
}
|
||||
|
||||
fn ensure_parent_dir(path: &Path) -> Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.with_context(|| format!("path {} has no parent", path.display()))?;
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
pub(super) use fabro_workflow::run_dump::RunDump as StoreRunExport;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ impl ServerRunSummaryInfo {
|
|||
self.summary
|
||||
.workflow_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| "[no run record]".to_string())
|
||||
.unwrap_or_else(|| "[no run spec]".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn workflow_slug(&self) -> Option<&str> {
|
||||
|
|
|
|||
|
|
@ -573,27 +573,27 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
|
|||
});
|
||||
|
||||
let state = run_state(&run_dir);
|
||||
let run_record =
|
||||
serde_json::to_value(state.run.as_ref().expect("run record should exist")).unwrap();
|
||||
let run_spec =
|
||||
serde_json::to_value(state.spec.as_ref().expect("run spec should exist")).unwrap();
|
||||
assert_eq!(
|
||||
run_record["settings"]["run"]["execution"]["approval"].as_str(),
|
||||
run_spec["settings"]["run"]["execution"]["approval"].as_str(),
|
||||
Some("auto")
|
||||
);
|
||||
assert_eq!(
|
||||
run_record["settings"]["server"]["storage"]["root"].as_str(),
|
||||
run_spec["settings"]["server"]["storage"]["root"].as_str(),
|
||||
Some(storage_dir.to_str().unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
run_record["settings"]["run"]["sandbox"]["preserve"].as_bool(),
|
||||
run_spec["settings"]["run"]["sandbox"]["preserve"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
run_record["settings"]["run"]["model"]["name"].as_str(),
|
||||
run_spec["settings"]["run"]["model"]["name"].as_str(),
|
||||
Some("gpt-5.2")
|
||||
);
|
||||
// v2 R30: run.prepare.steps replaces the whole ordered list across layers.
|
||||
assert_eq!(
|
||||
run_record["settings"]["run"]["prepare"]["steps"],
|
||||
run_spec["settings"]["run"]["prepare"]["steps"],
|
||||
serde_json::json!([{"script": "workflow-setup"}])
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ digraph BarBaz {
|
|||
|
||||
let run_dir = context.find_run_dir(&run_id);
|
||||
let state = run_state(&run_dir);
|
||||
let run = state.run.as_ref().expect("run record should exist");
|
||||
let run = state.spec.as_ref().expect("run spec should exist");
|
||||
fabro_json_snapshot!(
|
||||
context,
|
||||
serde_json::json!({
|
||||
|
|
@ -284,7 +284,7 @@ digraph FooWorkflow {
|
|||
|
||||
let run_dir = context.find_run_dir(&run_id);
|
||||
let state = run_state(&run_dir);
|
||||
let run = state.run.as_ref().expect("run record should exist");
|
||||
let run = state.spec.as_ref().expect("run spec should exist");
|
||||
fabro_json_snapshot!(
|
||||
context,
|
||||
serde_json::json!({
|
||||
|
|
@ -351,16 +351,16 @@ fn create_persists_requested_overrides_into_store() {
|
|||
.to_string();
|
||||
let run = resolve_run(&context, &run_id);
|
||||
let state = run_state(&run.run_dir);
|
||||
let run_record = state.run.as_ref().expect("run record should exist");
|
||||
let run_spec = state.spec.as_ref().expect("run spec should exist");
|
||||
let labels = json!({
|
||||
"env": run_record.labels.get("env"),
|
||||
"team": run_record.labels.get("team"),
|
||||
"env": run_spec.labels.get("env"),
|
||||
"team": run_spec.labels.get("team"),
|
||||
});
|
||||
let settings = &run_record.settings;
|
||||
let settings = &run_spec.settings;
|
||||
let resolved_run = resolved_run(settings);
|
||||
let cli_settings = fabro_config::resolve_cli_from_file(settings).expect("cli settings");
|
||||
let compact = json!({
|
||||
"workflow_slug": run_record.workflow_slug,
|
||||
"workflow_slug": run_spec.workflow_slug,
|
||||
"settings": {
|
||||
"goal": match resolved_run.goal.as_ref() {
|
||||
Some(fabro_types::settings::run::RunGoal::Inline(value)) => Some(value.as_source()),
|
||||
|
|
@ -435,9 +435,9 @@ fn create_json_does_not_imply_auto_approve() {
|
|||
assert!(
|
||||
resolved_run(
|
||||
&run_state(&run.run_dir)
|
||||
.run
|
||||
.spec
|
||||
.as_ref()
|
||||
.expect("run record should exist")
|
||||
.expect("run spec should exist")
|
||||
.settings,
|
||||
)
|
||||
.execution
|
||||
|
|
|
|||
|
|
@ -134,20 +134,22 @@ fn fork_from_earlier_checkpoint_uses_expected_sha() {
|
|||
]);
|
||||
assert_eq!(new_head.trim(), expected_head);
|
||||
|
||||
let checkpoint = git_show_json(
|
||||
let run_snapshot = git_show_json(
|
||||
&setup.repo_dir,
|
||||
&format!("fabro/meta/{new_run_id}:checkpoint.json"),
|
||||
&format!("fabro/meta/{new_run_id}:run.json"),
|
||||
);
|
||||
assert_eq!(checkpoint["current_node"].as_str(), Some("step_one"));
|
||||
assert_eq!(
|
||||
checkpoint["git_commit_sha"].as_str(),
|
||||
run_snapshot["checkpoint"]["current_node"].as_str(),
|
||||
Some("step_one")
|
||||
);
|
||||
assert_eq!(
|
||||
run_snapshot["checkpoint"]["git_commit_sha"].as_str(),
|
||||
Some(expected_head.as_str())
|
||||
);
|
||||
|
||||
let start = git_show_json(
|
||||
&setup.repo_dir,
|
||||
&format!("fabro/meta/{new_run_id}:start.json"),
|
||||
);
|
||||
let expected_branch = format!("fabro/run/{new_run_id}");
|
||||
assert_eq!(start["run_branch"].as_str(), Some(expected_branch.as_str()));
|
||||
assert_eq!(
|
||||
run_snapshot["start"]["run_branch"].as_str(),
|
||||
Some(expected_branch.as_str())
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ fn inspect_resolves_selector_via_server_endpoint() {
|
|||
{
|
||||
"run_id": "[ULID]",
|
||||
"status": "succeeded",
|
||||
"run_record": null,
|
||||
"run_spec": null,
|
||||
"start_record": null,
|
||||
"conclusion": null,
|
||||
"checkpoint": null,
|
||||
|
|
@ -113,7 +113,7 @@ fn inspect_resolves_selector_via_server_endpoint() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_created_run_shows_run_record_without_start_or_conclusion() {
|
||||
fn inspect_created_run_shows_run_spec_without_start_or_conclusion() {
|
||||
let context = test_context!();
|
||||
let run = setup_created_fast_dry_run(&context);
|
||||
let output = run_success(&context, &["inspect", &run.run_id]);
|
||||
|
|
@ -123,7 +123,7 @@ fn inspect_created_run_shows_run_record_without_start_or_conclusion() {
|
|||
{
|
||||
"run_id": "[ULID]",
|
||||
"status": "submitted",
|
||||
"run_record": {
|
||||
"run_spec": {
|
||||
"goal": "Run tests and report results",
|
||||
"workflow_name": "Simple",
|
||||
"workflow_slug": "simple",
|
||||
|
|
@ -156,7 +156,7 @@ fn inspect_completed_run_shows_run_start_conclusion_checkpoint() {
|
|||
{
|
||||
"run_id": "[ULID]",
|
||||
"status": "succeeded",
|
||||
"run_record": {
|
||||
"run_spec": {
|
||||
"goal": "Run tests and report results",
|
||||
"workflow_name": "Simple",
|
||||
"workflow_slug": "simple",
|
||||
|
|
@ -222,7 +222,7 @@ fn inspect_completed_run_reads_store_without_disk_metadata_files() {
|
|||
{
|
||||
"run_id": "[ULID]",
|
||||
"status": "succeeded",
|
||||
"run_record": {
|
||||
"run_spec": {
|
||||
"goal": "Run tests and report results",
|
||||
"workflow_name": "Simple",
|
||||
"workflow_slug": "simple",
|
||||
|
|
@ -273,7 +273,7 @@ fn inspect_git_backed_run_exposes_checkpoint_and_sandbox_state() {
|
|||
{
|
||||
"run_id": "[ULID]",
|
||||
"status": "succeeded",
|
||||
"run_record": {
|
||||
"run_spec": {
|
||||
"goal": "Edit a tracked file",
|
||||
"workflow_name": "Flow",
|
||||
"workflow_slug": "flow",
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ fn pr_create_completed_dry_run_without_run_branch_errors() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn pr_create_uses_store_run_record_without_run_json() {
|
||||
fn pr_create_uses_store_run_spec_without_run_json() {
|
||||
let context = test_context!();
|
||||
let run = setup_completed_fast_dry_run(&context);
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ fn preflight_response() -> serde_json::Value {
|
|||
|
||||
fn remote_run_state_response() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"run": null,
|
||||
"spec": null,
|
||||
"graph_source": null,
|
||||
"start": null,
|
||||
"status": null,
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ digraph GitHubApp {
|
|||
|
||||
let run_dir = context.find_run_dir(&run_id);
|
||||
let state = run_state(&run_dir);
|
||||
let run = state.run.as_ref().expect("run record should exist");
|
||||
let run = state.spec.as_ref().expect("run spec should exist");
|
||||
let resolved_server = fabro_config::resolve_server_from_file(&run.settings).unwrap();
|
||||
fabro_json_snapshot!(
|
||||
context,
|
||||
|
|
@ -465,7 +465,7 @@ digraph Test {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn runner_reports_missing_run_record_without_prefetching_events() {
|
||||
fn runner_reports_missing_run_spec_without_prefetching_events() {
|
||||
let context = test_context!();
|
||||
let server = MockServer::start();
|
||||
let run_id = unique_run_id();
|
||||
|
|
@ -478,7 +478,7 @@ fn runner_reports_missing_run_record_without_prefetching_events() {
|
|||
.header("Content-Type", "application/json")
|
||||
.body(
|
||||
serde_json::json!({
|
||||
"run": null,
|
||||
"spec": null,
|
||||
"graph_source": null,
|
||||
"start": null,
|
||||
"status": null,
|
||||
|
|
@ -523,14 +523,14 @@ fn runner_reports_missing_run_record_without_prefetching_events() {
|
|||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"worker should fail when run record is missing:\nstdout:\n{}\nstderr:\n{}",
|
||||
"worker should fail when run spec is missing:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
state_mock.assert();
|
||||
events_mock.assert_calls(0);
|
||||
assert!(
|
||||
output_stderr(&output).contains("has no run record in store"),
|
||||
output_stderr(&output).contains("has no run spec in store"),
|
||||
"{}",
|
||||
output_stderr(&output)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ fn store_dump_accepts_server_target_from_separate_home() {
|
|||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(output_dir.join("checkpoint.json").is_file());
|
||||
assert!(output_dir.join("run.json").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -145,10 +145,10 @@ fn store_dump_exports_large_command_output_backed_by_blob_refs() {
|
|||
String::from_utf8_lossy(&dump_output.stderr)
|
||||
);
|
||||
|
||||
let checkpoint = fs::read_to_string(output_dir.join("checkpoint.json")).unwrap();
|
||||
let run_json = fs::read_to_string(output_dir.join("run.json")).unwrap();
|
||||
assert!(
|
||||
!checkpoint.contains("blob://sha256/"),
|
||||
"checkpoint export should hydrate blob refs\n{checkpoint}"
|
||||
!run_json.contains("blob://sha256/"),
|
||||
"run export should hydrate blob refs\n{run_json}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -248,10 +248,10 @@ include = ["assets/**"]
|
|||
String::from_utf8_lossy(&dump_output.stderr)
|
||||
);
|
||||
|
||||
let checkpoint = fs::read_to_string(output_dir.join("checkpoint.json")).unwrap();
|
||||
let run_json = fs::read_to_string(output_dir.join("run.json")).unwrap();
|
||||
assert!(
|
||||
!checkpoint.contains("blob://sha256/"),
|
||||
"checkpoint export should hydrate blob refs\n{checkpoint}"
|
||||
!run_json.contains("blob://sha256/"),
|
||||
"run export should hydrate blob refs\n{run_json}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(output_dir.join("artifacts/nodes/big/visit-1/assets/shared/report.txt"))
|
||||
|
|
@ -278,28 +278,23 @@ fn store_dump_exports_completed_run_snapshot() {
|
|||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
Exported 17 files for run [ULID] to [TEMP_DIR]/export
|
||||
Exported 12 files for run [ULID] to [TEMP_DIR]/export
|
||||
----- stderr -----
|
||||
");
|
||||
|
||||
assert_snapshot!(dump_file_summary(&output_dir), @"
|
||||
checkpoint.json
|
||||
checkpoints/0013.json
|
||||
checkpoints/0017.json
|
||||
checkpoints/0021.json
|
||||
conclusion.json
|
||||
events.jsonl
|
||||
graph.fabro
|
||||
nodes/exit/visit-1/status.json
|
||||
nodes/report/visit-1/response.md
|
||||
nodes/report/visit-1/status.json
|
||||
nodes/run_tests/visit-1/response.md
|
||||
nodes/run_tests/visit-1/status.json
|
||||
nodes/start/visit-1/status.json
|
||||
run.json
|
||||
sandbox.json
|
||||
start.json
|
||||
status.json
|
||||
stages/exit@1/status.json
|
||||
stages/report@1/response.md
|
||||
stages/report@1/status.json
|
||||
stages/run_tests@1/response.md
|
||||
stages/run_tests@1/status.json
|
||||
stages/start@1/status.json
|
||||
");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -897,29 +897,29 @@ pub(crate) fn compact_inspect(output: &Output) -> Value {
|
|||
Value::Array(
|
||||
items.into_iter()
|
||||
.map(|item| {
|
||||
let run_record = item["run_record"].clone();
|
||||
let run_spec = item["run_spec"].clone();
|
||||
let checkpoint = item["checkpoint"].clone();
|
||||
let conclusion = item["conclusion"].clone();
|
||||
let sandbox = item["sandbox"].clone();
|
||||
let dry_run = run_record
|
||||
let dry_run = run_spec
|
||||
.pointer("/settings/run/execution/mode")
|
||||
.and_then(Value::as_str)
|
||||
.map(|mode| Value::Bool(mode == "dry_run"));
|
||||
serde_json::json!({
|
||||
"run_id": "[ULID]",
|
||||
"status": item["status"],
|
||||
"run_record": {
|
||||
"goal": run_record.pointer("/settings/run/goal"),
|
||||
"workflow_name": run_record.pointer("/graph/name"),
|
||||
"workflow_slug": run_record.pointer("/workflow_slug"),
|
||||
"sandbox_provider": run_record.pointer("/settings/run/sandbox/provider"),
|
||||
"run_spec": {
|
||||
"goal": run_spec.pointer("/settings/run/goal"),
|
||||
"workflow_name": run_spec.pointer("/graph/name"),
|
||||
"workflow_slug": run_spec.pointer("/workflow_slug"),
|
||||
"sandbox_provider": run_spec.pointer("/settings/run/sandbox/provider"),
|
||||
"dry_run": dry_run,
|
||||
"provenance": run_record.pointer("/provenance").as_ref().map(|_| {
|
||||
"provenance": run_spec.pointer("/provenance").as_ref().map(|_| {
|
||||
serde_json::json!({
|
||||
"server_version": "[VERSION]",
|
||||
"client_name": run_record.pointer("/provenance/client/name"),
|
||||
"client_name": run_spec.pointer("/provenance/client/name"),
|
||||
"client_version": "[VERSION]",
|
||||
"subject_auth_method": run_record.pointer("/provenance/subject/auth_method"),
|
||||
"subject_auth_method": run_spec.pointer("/provenance/subject/auth_method"),
|
||||
})
|
||||
}),
|
||||
},
|
||||
|
|
@ -959,7 +959,7 @@ pub(crate) fn compact_git_inspect(output: &Output) -> Value {
|
|||
Value::Array(
|
||||
items.into_iter()
|
||||
.map(|item| {
|
||||
let run_record = item["run_record"].clone();
|
||||
let run_spec = item["run_spec"].clone();
|
||||
let start_record = item["start_record"].clone();
|
||||
let checkpoint = item["checkpoint"].clone();
|
||||
let conclusion = item["conclusion"].clone();
|
||||
|
|
@ -967,18 +967,18 @@ pub(crate) fn compact_git_inspect(output: &Output) -> Value {
|
|||
serde_json::json!({
|
||||
"run_id": "[ULID]",
|
||||
"status": item["status"],
|
||||
"run_record": {
|
||||
"goal": run_record.pointer("/settings/run/goal"),
|
||||
"workflow_name": run_record.pointer("/graph/name"),
|
||||
"workflow_slug": run_record.pointer("/workflow_slug"),
|
||||
"llm_provider": run_record.pointer("/settings/run/model/provider"),
|
||||
"sandbox_provider": run_record.pointer("/settings/run/sandbox/provider"),
|
||||
"provenance": run_record.pointer("/provenance").as_ref().map(|_| {
|
||||
"run_spec": {
|
||||
"goal": run_spec.pointer("/settings/run/goal"),
|
||||
"workflow_name": run_spec.pointer("/graph/name"),
|
||||
"workflow_slug": run_spec.pointer("/workflow_slug"),
|
||||
"llm_provider": run_spec.pointer("/settings/run/model/provider"),
|
||||
"sandbox_provider": run_spec.pointer("/settings/run/sandbox/provider"),
|
||||
"provenance": run_spec.pointer("/provenance").as_ref().map(|_| {
|
||||
serde_json::json!({
|
||||
"server_version": "[VERSION]",
|
||||
"client_name": run_record.pointer("/provenance/client/name"),
|
||||
"client_name": run_spec.pointer("/provenance/client/name"),
|
||||
"client_version": "[VERSION]",
|
||||
"subject_auth_method": run_record.pointer("/provenance/subject/auth_method"),
|
||||
"subject_auth_method": run_spec.pointer("/provenance/subject/auth_method"),
|
||||
})
|
||||
}),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -53,15 +53,15 @@ fn local_run_lifecycle() {
|
|||
"workflow_name should be CommandPipeline"
|
||||
);
|
||||
|
||||
// 3. inspect <run_id> — JSON array with run_record and conclusion
|
||||
// 3. inspect <run_id> — JSON array with run_spec and conclusion
|
||||
let inspect_out = cmd(&["inspect", &run_id]).success();
|
||||
let inspect_stdout = String::from_utf8(inspect_out.get_output().stdout.clone()).unwrap();
|
||||
let items: Vec<Value> =
|
||||
serde_json::from_str(&inspect_stdout).expect("inspect should produce a JSON array");
|
||||
assert!(!items.is_empty(), "inspect should return at least one item");
|
||||
assert!(
|
||||
items[0]["run_record"].is_object(),
|
||||
"inspect should include run_record"
|
||||
items[0]["run_spec"].is_object(),
|
||||
"inspect should include run_spec"
|
||||
);
|
||||
assert!(
|
||||
items[0]["conclusion"].is_object(),
|
||||
|
|
@ -317,14 +317,14 @@ digraph FooWorkflow {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_record = run_state(&context.find_run_dir(&run_id))
|
||||
.run
|
||||
.expect("run record should exist");
|
||||
let run_spec = run_state(&context.find_run_dir(&run_id))
|
||||
.spec
|
||||
.expect("run spec should exist");
|
||||
fabro_json_snapshot!(
|
||||
context,
|
||||
serde_json::json!({
|
||||
"graph_name": run_record.graph.name,
|
||||
"workflow_slug": run_record.workflow_slug,
|
||||
"graph_name": run_spec.graph.name,
|
||||
"workflow_slug": run_spec.workflow_slug,
|
||||
}),
|
||||
@r#"
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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};
|
||||
|
|
@ -42,12 +43,14 @@ fn metadata_checkpoints(repo_dir: &Path, run_id: &str) -> Vec<Checkpoint> {
|
|||
.rev()
|
||||
.filter(|commit| commit.message.starts_with("checkpoint"))
|
||||
.map(|commit| {
|
||||
let checkpoint_blob = store
|
||||
.read_blob_at(commit.oid, "checkpoint.json")
|
||||
.expect("checkpoint blob should load")
|
||||
.expect("checkpoint blob should exist");
|
||||
serde_json::from_slice::<Checkpoint>(&checkpoint_blob)
|
||||
.expect("checkpoint blob should deserialize")
|
||||
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()
|
||||
}
|
||||
|
|
@ -59,11 +62,14 @@ fn latest_metadata_checkpoint(repo_dir: &Path, run_id: &str) -> Checkpoint {
|
|||
.resolve_ref(&format!("fabro/meta/{run_id}"))
|
||||
.expect("metadata branch should resolve")
|
||||
.expect("metadata branch tip should exist");
|
||||
let checkpoint_blob = store
|
||||
.read_blob_at(tip, "checkpoint.json")
|
||||
.expect("latest checkpoint blob should load")
|
||||
.expect("latest checkpoint blob should exist");
|
||||
serde_json::from_slice(&checkpoint_blob).expect("latest checkpoint blob should deserialize")
|
||||
let projection_blob = store
|
||||
.read_blob_at(tip, "run.json")
|
||||
.expect("latest projection blob should load")
|
||||
.expect("latest projection blob should exist");
|
||||
serde_json::from_slice::<RunProjection>(&projection_blob)
|
||||
.expect("latest projection blob should deserialize")
|
||||
.checkpoint
|
||||
.expect("latest projection checkpoint should exist")
|
||||
}
|
||||
|
||||
fn timeline_run_shas(repo_dir: &Path, run_id: &str) -> Vec<Option<String>> {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use crate::support::{LightweightCli, unique_run_id};
|
|||
|
||||
fn live_run_state_response() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"run": null,
|
||||
"spec": null,
|
||||
"graph_source": null,
|
||||
"start": null,
|
||||
"status": {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ fn scenario_command_agent_mixed(sandbox: &str) {
|
|||
);
|
||||
|
||||
let export_dir = store_dump_export(&context, &run_id_for(&run_dir));
|
||||
let stdout = std::fs::read_to_string(export_dir.join("nodes/verify/visit-1/stdout.log"))
|
||||
let stdout = std::fs::read_to_string(export_dir.join("stages/verify@1/stdout.log"))
|
||||
.expect("verify stdout.log should exist");
|
||||
assert!(
|
||||
stdout.contains("SCENARIO_FLAG_42"),
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ fn scenario_command_pipeline(sandbox: &str) {
|
|||
);
|
||||
|
||||
let export_dir = store_dump_export(&context, &run_id_for(&run_dir));
|
||||
let stdout1 = std::fs::read_to_string(export_dir.join("nodes/step1/visit-1/stdout.log"))
|
||||
let stdout1 = std::fs::read_to_string(export_dir.join("stages/step1@1/stdout.log"))
|
||||
.expect("step1 stdout.log should exist");
|
||||
assert!(
|
||||
stdout1.contains("hello-from-step1"),
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
use fabro_test::test_context;
|
||||
|
||||
use super::{
|
||||
completed_nodes, find_run_dir, fixture, has_event, read_conclusion, read_run_record,
|
||||
run_id_for, sandbox_tests, store_dump_export, timeout_for,
|
||||
completed_nodes, find_run_dir, fixture, has_event, read_conclusion, read_run_spec, run_id_for,
|
||||
sandbox_tests, store_dump_export, timeout_for,
|
||||
};
|
||||
|
||||
sandbox_tests!(full_stack, keys = ["ANTHROPIC_API_KEY"]);
|
||||
|
|
@ -42,15 +42,15 @@ fn scenario_full_stack(sandbox: &str) {
|
|||
"duration_ms should be > 0"
|
||||
);
|
||||
|
||||
// RunRecord should have key fields
|
||||
let run_record = read_run_record(&run_dir);
|
||||
// RunSpec should have key fields
|
||||
let run_spec = read_run_spec(&run_dir);
|
||||
assert!(
|
||||
run_record["run_id"].as_str().is_some(),
|
||||
"run record should have run_id"
|
||||
run_spec["run_id"].as_str().is_some(),
|
||||
"run spec should have run_id"
|
||||
);
|
||||
assert!(
|
||||
run_record["graph"]["name"].as_str().is_some(),
|
||||
"run record should have graph.name"
|
||||
run_spec["graph"]["name"].as_str().is_some(),
|
||||
"run spec should have graph.name"
|
||||
);
|
||||
|
||||
// Progress events
|
||||
|
|
@ -74,7 +74,7 @@ fn scenario_full_stack(sandbox: &str) {
|
|||
|
||||
// Verify node stdout should contain PASS
|
||||
let export_dir = store_dump_export(&context, &run_id_for(&run_dir));
|
||||
let stdout = std::fs::read_to_string(export_dir.join("nodes/verify/visit-1/stdout.log"))
|
||||
let stdout = std::fs::read_to_string(export_dir.join("stages/verify@1/stdout.log"))
|
||||
.expect("verify stdout.log should exist");
|
||||
assert!(
|
||||
stdout.contains("PASS"),
|
||||
|
|
|
|||
|
|
@ -37,13 +37,13 @@ pub(super) fn read_conclusion(run_dir: &Path) -> Value {
|
|||
.expect("conclusion should serialize")
|
||||
}
|
||||
|
||||
pub(super) fn read_run_record(run_dir: &Path) -> Value {
|
||||
pub(super) fn read_run_spec(run_dir: &Path) -> Value {
|
||||
serde_json::to_value(
|
||||
run_state(run_dir)
|
||||
.run
|
||||
.expect("run store run record should exist"),
|
||||
.spec
|
||||
.expect("run store run spec should exist"),
|
||||
)
|
||||
.expect("run record should serialize")
|
||||
.expect("run spec should serialize")
|
||||
}
|
||||
|
||||
pub(super) fn completed_nodes(run_dir: &Path) -> Vec<String> {
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ fn strip_owner_domains(file: &mut SettingsLayer) {
|
|||
/// the server's local `~/.fabro/settings.toml` when the corresponding client
|
||||
/// value is absent. Run-shaped defaults (model, prepare, sandbox, checkpoint,
|
||||
/// hooks, agent mcps, etc.) also flow from server to client so the persisted
|
||||
/// run record matches the server's local configuration.
|
||||
/// run spec matches the server's local configuration.
|
||||
fn apply_server_defaults(mut settings: SettingsLayer, server: &SettingsLayer) -> SettingsLayer {
|
||||
// Server-owned domains: server-side always wins when client left blank.
|
||||
// Use the v2 merge matrix with the server layer in lower precedence so
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use fabro_agent::tool_registry::RegisteredTool;
|
||||
use fabro_agent::{
|
||||
AgentProfile, AnthropicProfile, GeminiProfile, OpenAiProfile, Sandbox, Session, SessionEvent,
|
||||
SessionOptions, Turn,
|
||||
SessionOptions, Turn, shell_quote,
|
||||
};
|
||||
use fabro_llm::client::Client;
|
||||
use fabro_llm::provider::Provider;
|
||||
use fabro_llm::types::ToolDefinition;
|
||||
use fabro_store::{EventEnvelope, RunProjection};
|
||||
use fabro_store::{EventEnvelope, RunProjection, SerializableProjection};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::retro::{RetroNarrative, SmoothnessRating};
|
||||
|
|
@ -19,9 +19,9 @@ const RETRO_SYSTEM_PROMPT: &str = r"You are a workflow run retrospective analyst
|
|||
|
||||
You have access to the run's data files:
|
||||
- `progress.jsonl` — the full event stream (stage starts/completions, agent tool calls, errors, retries)
|
||||
- `checkpoint.json` — final execution state with node outcomes
|
||||
- `run.json` — run record with config, graph, and metadata
|
||||
- `start.json` — start record with start time and git info
|
||||
- `run.json` — serialized run projection with the run spec, checkpoint state, conclusion, retro data, and other metadata
|
||||
- `graph.fabro` — the workflow source for the run
|
||||
- `stages/{node_id}@{visit}/...` — per-stage prompt, response, status, diff, stdout/stderr, and tool metadata files
|
||||
|
||||
## Your task
|
||||
|
||||
|
|
@ -30,6 +30,7 @@ You have access to the run's data files:
|
|||
- Check agent tool call patterns for wrong approaches or pivots
|
||||
- Note which stages took longest or had issues
|
||||
- Look for patterns indicating friction (repeated similar tool calls, error recovery)
|
||||
- Use `run.json` for the run-level snapshot, `graph.fabro` for workflow intent, and `stages/` for full per-stage payloads
|
||||
|
||||
2. **Call the `submit_retro` tool** with your structured analysis.
|
||||
|
||||
|
|
@ -124,7 +125,8 @@ pub fn build_retro_prompt(retro_data_dir: &str) -> String {
|
|||
format!(
|
||||
"Analyze the workflow run data at `{retro_data_dir}/` and generate a retrospective. \
|
||||
The key file is `{retro_data_dir}/progress.jsonl` which contains the full event stream. \
|
||||
Also check `{retro_data_dir}/checkpoint.json` for stage outcomes. \
|
||||
Use `{retro_data_dir}/run.json` for the run-level snapshot, `{retro_data_dir}/graph.fabro` \
|
||||
for the workflow source, and `{retro_data_dir}/stages/` for full per-stage payloads. \
|
||||
Use grep to search for interesting signals (failures, retries, errors, approach changes) \
|
||||
rather than reading the entire file. When done, call the `submit_retro` tool with your analysis."
|
||||
)
|
||||
|
|
@ -299,71 +301,187 @@ async fn upload_data_files(
|
|||
_run_dir: &Path,
|
||||
target_dir: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
// Create target directory
|
||||
sandbox
|
||||
.exec_command(&format!("mkdir -p {target_dir}"), 10_000, None, None, None)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create retro data dir: {e}"))?;
|
||||
|
||||
let progress_content = {
|
||||
let lines: Vec<String> = events
|
||||
.iter()
|
||||
.filter_map(|env| serde_json::to_string(&env.event).ok())
|
||||
.collect();
|
||||
if lines.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(lines.join("\n") + "\n")
|
||||
let progress_content = (!events.is_empty()).then(|| {
|
||||
let mut buf = String::new();
|
||||
for env in events {
|
||||
if let Ok(line) = serde_json::to_string(&env.event) {
|
||||
buf.push_str(&line);
|
||||
buf.push('\n');
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Some(content) = progress_content {
|
||||
sandbox
|
||||
.write_file(&format!("{target_dir}/progress.jsonl"), &content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to upload progress.jsonl: {e}"))?;
|
||||
buf
|
||||
});
|
||||
upload_file(
|
||||
sandbox,
|
||||
target_dir,
|
||||
Path::new("progress.jsonl"),
|
||||
progress_content,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let run_content = serde_json::to_string_pretty(&SerializableProjection(state))?;
|
||||
upload_file(
|
||||
sandbox,
|
||||
target_dir,
|
||||
Path::new("run.json"),
|
||||
Some(run_content),
|
||||
)
|
||||
.await?;
|
||||
upload_file(
|
||||
sandbox,
|
||||
target_dir,
|
||||
Path::new("graph.fabro"),
|
||||
state.graph_source.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut stage_ids: Vec<_> = state
|
||||
.iter_nodes()
|
||||
.map(|(stage_id, _)| stage_id.clone())
|
||||
.collect();
|
||||
stage_ids.sort();
|
||||
|
||||
for stage_id in stage_ids {
|
||||
let Some(node) = state.node(&stage_id) else {
|
||||
continue;
|
||||
};
|
||||
let base = PathBuf::from("stages").join(stage_id.to_string());
|
||||
upload_file(
|
||||
sandbox,
|
||||
target_dir,
|
||||
&base.join("prompt.md"),
|
||||
node.prompt.clone(),
|
||||
)
|
||||
.await?;
|
||||
upload_file(
|
||||
sandbox,
|
||||
target_dir,
|
||||
&base.join("response.md"),
|
||||
node.response.clone(),
|
||||
)
|
||||
.await?;
|
||||
upload_json_file(
|
||||
sandbox,
|
||||
target_dir,
|
||||
&base.join("status.json"),
|
||||
node.status.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
upload_json_file(
|
||||
sandbox,
|
||||
target_dir,
|
||||
&base.join("provider_used.json"),
|
||||
node.provider_used.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
upload_file(
|
||||
sandbox,
|
||||
target_dir,
|
||||
&base.join("diff.patch"),
|
||||
node.diff.clone(),
|
||||
)
|
||||
.await?;
|
||||
upload_json_file(
|
||||
sandbox,
|
||||
target_dir,
|
||||
&base.join("script_invocation.json"),
|
||||
node.script_invocation.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
upload_json_file(
|
||||
sandbox,
|
||||
target_dir,
|
||||
&base.join("script_timing.json"),
|
||||
node.script_timing.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
upload_json_file(
|
||||
sandbox,
|
||||
target_dir,
|
||||
&base.join("parallel_results.json"),
|
||||
node.parallel_results.as_ref(),
|
||||
)
|
||||
.await?;
|
||||
upload_file(
|
||||
sandbox,
|
||||
target_dir,
|
||||
&base.join("stdout.log"),
|
||||
node.stdout.clone(),
|
||||
)
|
||||
.await?;
|
||||
upload_file(
|
||||
sandbox,
|
||||
target_dir,
|
||||
&base.join("stderr.log"),
|
||||
node.stderr.clone(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let checkpoint_content = state
|
||||
.checkpoint
|
||||
.clone()
|
||||
.map(|cp| serde_json::to_string_pretty(&cp))
|
||||
.transpose()?;
|
||||
upload_file(sandbox, target_dir, "checkpoint.json", checkpoint_content).await?;
|
||||
|
||||
let run_content = state
|
||||
.run
|
||||
.clone()
|
||||
.map(|run| serde_json::to_string_pretty(&run))
|
||||
.transpose()?;
|
||||
upload_file(sandbox, target_dir, "run.json", run_content).await?;
|
||||
|
||||
let start_content = state
|
||||
.start
|
||||
.clone()
|
||||
.map(|start| serde_json::to_string_pretty(&start))
|
||||
.transpose()?;
|
||||
upload_file(sandbox, target_dir, "start.json", start_content).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_file(
|
||||
sandbox: &Arc<dyn Sandbox>,
|
||||
target_dir: &str,
|
||||
filename: &str,
|
||||
relative: &Path,
|
||||
content: Option<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(content) = content {
|
||||
sandbox
|
||||
.write_file(&format!("{target_dir}/{filename}"), &content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to upload {filename}: {e}"))?;
|
||||
let Some(content) = content else {
|
||||
return Ok(());
|
||||
};
|
||||
let path = Path::new(target_dir).join(relative);
|
||||
let remote_path = path.to_string_lossy().into_owned();
|
||||
ensure_remote_dir(sandbox, &path).await?;
|
||||
sandbox
|
||||
.write_file(&remote_path, &content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to upload {}: {e}", relative.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_json_file<T>(
|
||||
sandbox: &Arc<dyn Sandbox>,
|
||||
target_dir: &str,
|
||||
relative: &Path,
|
||||
value: Option<&T>,
|
||||
) -> anyhow::Result<()>
|
||||
where
|
||||
T: serde::Serialize,
|
||||
{
|
||||
let content = value.map(serde_json::to_string_pretty).transpose()?;
|
||||
upload_file(sandbox, target_dir, relative, content).await
|
||||
}
|
||||
|
||||
async fn ensure_remote_dir(sandbox: &Arc<dyn Sandbox>, path: &Path) -> anyhow::Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("Retro upload path has no parent: {}", path.display()))?;
|
||||
let command = format!("mkdir -p {}", shell_quote(&parent.to_string_lossy()));
|
||||
let result = sandbox
|
||||
.exec_command(&command, 10_000, None, None, None)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create retro upload dir: {e}"))?;
|
||||
if result.exit_code != 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to create retro upload dir {}: {}",
|
||||
parent.display(),
|
||||
result.stderr
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fabro_agent::LocalSandbox;
|
||||
use fabro_store::{NodeState, StageId};
|
||||
use fabro_types::{NodeStatusRecord, StageStatus};
|
||||
use tokio::fs;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
|
@ -414,4 +532,94 @@ mod tests {
|
|||
assert!(narrative.friction_points.is_empty());
|
||||
assert!(narrative.open_items.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retro_prompt_mentions_graph_and_stage_files() {
|
||||
let prompt = build_retro_prompt(RETRO_DATA_DIR);
|
||||
|
||||
assert!(prompt.contains("run.json"));
|
||||
assert!(prompt.contains("graph.fabro"));
|
||||
assert!(prompt.contains("stages/"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_data_files_writes_projection_graph_and_stage_files() {
|
||||
let sandbox_root = tempfile::tempdir().expect("sandbox tempdir should exist");
|
||||
let sandbox: Arc<dyn Sandbox> =
|
||||
Arc::new(LocalSandbox::new(sandbox_root.path().to_path_buf()));
|
||||
let output_dir = tempfile::tempdir().expect("retro tempdir should exist");
|
||||
let target_dir = output_dir.path().join("retro");
|
||||
let target_dir_str = target_dir.to_string_lossy().to_string();
|
||||
|
||||
let stage_id = StageId::new("build", 2);
|
||||
let mut state = RunProjection::default();
|
||||
state.graph_source = Some("digraph Ship {}".to_string());
|
||||
state.set_node(stage_id, NodeState {
|
||||
prompt: Some("plan".to_string()),
|
||||
response: Some("done".to_string()),
|
||||
status: Some(NodeStatusRecord {
|
||||
status: StageStatus::Success,
|
||||
notes: Some("ok".to_string()),
|
||||
failure_reason: None,
|
||||
timestamp: Utc
|
||||
.with_ymd_and_hms(2026, 4, 20, 12, 1, 0)
|
||||
.single()
|
||||
.unwrap(),
|
||||
}),
|
||||
provider_used: Some(serde_json::json!({ "provider": "openai" })),
|
||||
diff: Some("diff --git a/a b/a".to_string()),
|
||||
script_invocation: Some(serde_json::json!({ "command": "cargo test" })),
|
||||
script_timing: Some(serde_json::json!({ "duration_ms": 10 })),
|
||||
parallel_results: Some(serde_json::json!([{ "stage": "fanout@1" }])),
|
||||
stdout: Some("stdout".to_string()),
|
||||
stderr: Some("stderr".to_string()),
|
||||
});
|
||||
|
||||
upload_data_files(&sandbox, &state, &[], output_dir.path(), &target_dir_str)
|
||||
.await
|
||||
.expect("retro files should upload");
|
||||
|
||||
let run_json: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(target_dir.join("run.json"))
|
||||
.await
|
||||
.expect("run.json should exist"),
|
||||
)
|
||||
.expect("run.json should parse");
|
||||
assert!(run_json.get("spec").is_some());
|
||||
assert!(run_json.get("run").is_none());
|
||||
assert!(run_json["nodes"]["build@2"]["prompt"].is_null());
|
||||
assert!(run_json["nodes"]["build@2"]["diff"].is_null());
|
||||
assert_eq!(
|
||||
fs::read_to_string(target_dir.join("graph.fabro"))
|
||||
.await
|
||||
.expect("graph.fabro should exist"),
|
||||
"digraph Ship {}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(target_dir.join("stages/build@2/prompt.md"))
|
||||
.await
|
||||
.expect("prompt file should exist"),
|
||||
"plan"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(target_dir.join("stages/build@2/response.md"))
|
||||
.await
|
||||
.expect("response file should exist"),
|
||||
"done"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(target_dir.join("stages/build@2/stdout.log"))
|
||||
.await
|
||||
.expect("stdout file should exist"),
|
||||
"stdout"
|
||||
);
|
||||
assert!(
|
||||
target_dir.join("stages/build@2/status.json").exists(),
|
||||
"status file should exist"
|
||||
);
|
||||
assert!(
|
||||
!target_dir.join("progress.jsonl").exists(),
|
||||
"progress file should be omitted when there are no events"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4366,14 +4366,14 @@ async fn start_run(
|
|||
}
|
||||
}
|
||||
|
||||
let Some(run_record) = run_state.run.as_ref() else {
|
||||
let Some(run_spec) = run_state.spec.as_ref() else {
|
||||
return ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"run record missing from store",
|
||||
"run spec missing from store",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
let run_dir = match resolved_storage_dir(&run_record.settings) {
|
||||
let run_dir = match resolved_storage_dir(&run_spec.settings) {
|
||||
Ok(storage_dir) => Storage::new(storage_dir)
|
||||
.run_scratch(&id)
|
||||
.root()
|
||||
|
|
@ -4550,7 +4550,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
|
|||
return;
|
||||
}
|
||||
};
|
||||
let github_settings = match resolved_github_settings(&persisted.run_record().settings) {
|
||||
let github_settings = match resolved_github_settings(&persisted.run_spec().settings) {
|
||||
Ok(settings) => settings,
|
||||
Err(err) => {
|
||||
tracing::error!(run_id = %run_id, error = %err, "Invalid GitHub integration config");
|
||||
|
|
@ -4565,7 +4565,7 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
|
|||
}
|
||||
};
|
||||
let github_app_result = match fabro_config::resolve_run_from_file(
|
||||
&persisted.run_record().settings,
|
||||
&persisted.run_spec().settings,
|
||||
) {
|
||||
Ok(settings) => {
|
||||
let required_github_credentials = (settings.execution.mode != RunMode::DryRun
|
||||
|
|
@ -5089,10 +5089,10 @@ async fn get_run_settings(
|
|||
.into_response();
|
||||
}
|
||||
};
|
||||
let Some(run_record) = run_state.run else {
|
||||
let Some(run_spec) = run_state.spec else {
|
||||
return ApiError::not_found("Run not found.").into_response();
|
||||
};
|
||||
let redacted = settings_view::redact_for_api(&run_record.settings);
|
||||
let redacted = settings_view::redact_for_api(&run_spec.settings);
|
||||
let mut value = match serde_json::to_value(&redacted) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
|
|
@ -5491,10 +5491,7 @@ async fn read_run_blob(
|
|||
}
|
||||
}
|
||||
|
||||
async fn load_run_record(
|
||||
state: &AppState,
|
||||
run_id: &RunId,
|
||||
) -> Result<fabro_types::RunRecord, Response> {
|
||||
async fn load_run_spec(state: &AppState, run_id: &RunId) -> Result<fabro_types::RunSpec, Response> {
|
||||
let run_store = state
|
||||
.store
|
||||
.open_run_reader(run_id)
|
||||
|
|
@ -5503,10 +5500,10 @@ async fn load_run_record(
|
|||
let run_state = run_store.state().await.map_err(|err| {
|
||||
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
||||
})?;
|
||||
run_state.run.ok_or_else(|| {
|
||||
run_state.spec.ok_or_else(|| {
|
||||
ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"run record missing from store",
|
||||
"run spec missing from store",
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
|
|
@ -5521,7 +5518,7 @@ async fn list_run_artifacts(
|
|||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if let Err(response) = load_run_record(state.as_ref(), &id).await {
|
||||
if let Err(response) = load_run_spec(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
|
||||
|
|
@ -5558,7 +5555,7 @@ async fn list_stage_artifacts(
|
|||
Ok(stage_id) => stage_id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if let Err(response) = load_run_record(state.as_ref(), &id).await {
|
||||
if let Err(response) = load_run_spec(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
|
||||
|
|
@ -5950,7 +5947,7 @@ async fn put_stage_artifact(
|
|||
if let Some(response) = reject_if_archived(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
if let Err(response) = load_run_record(state.as_ref(), &id).await.map(|_| ()) {
|
||||
if let Err(response) = load_run_spec(state.as_ref(), &id).await.map(|_| ()) {
|
||||
return response;
|
||||
}
|
||||
|
||||
|
|
@ -6008,7 +6005,7 @@ async fn get_stage_artifact(
|
|||
Ok(path) => path,
|
||||
Err(response) => return response,
|
||||
};
|
||||
if let Err(response) = load_run_record(state.as_ref(), &id).await {
|
||||
if let Err(response) = load_run_spec(state.as_ref(), &id).await {
|
||||
return response;
|
||||
}
|
||||
|
||||
|
|
@ -8643,20 +8640,20 @@ slug = "fabro"
|
|||
let response = app.oneshot(req).await.unwrap();
|
||||
let body = response_json!(response, StatusCode::OK).await;
|
||||
assert_eq!(
|
||||
body["run"]["provenance"]["server"]["version"],
|
||||
body["spec"]["provenance"]["server"]["version"],
|
||||
FABRO_VERSION
|
||||
);
|
||||
assert_eq!(
|
||||
body["run"]["provenance"]["client"]["user_agent"],
|
||||
body["spec"]["provenance"]["client"]["user_agent"],
|
||||
"fabro-cli/1.2.3"
|
||||
);
|
||||
assert_eq!(body["run"]["provenance"]["client"]["name"], "fabro-cli");
|
||||
assert_eq!(body["run"]["provenance"]["client"]["version"], "1.2.3");
|
||||
assert_eq!(body["spec"]["provenance"]["client"]["name"], "fabro-cli");
|
||||
assert_eq!(body["spec"]["provenance"]["client"]["version"], "1.2.3");
|
||||
assert_eq!(
|
||||
body["run"]["provenance"]["subject"]["auth_method"],
|
||||
body["spec"]["provenance"]["subject"]["auth_method"],
|
||||
"disabled"
|
||||
);
|
||||
assert!(body["run"]["provenance"]["subject"]["login"].is_null());
|
||||
assert!(body["spec"]["provenance"]["subject"]["login"].is_null());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -8729,10 +8726,10 @@ slug = "fabro"
|
|||
.unwrap();
|
||||
let state_body = response_json!(state_response, StatusCode::OK).await;
|
||||
assert_eq!(
|
||||
state_body["run"]["provenance"]["subject"]["auth_method"],
|
||||
state_body["spec"]["provenance"]["subject"]["auth_method"],
|
||||
"dev_token"
|
||||
);
|
||||
assert_eq!(state_body["run"]["provenance"]["subject"]["login"], "dev");
|
||||
assert_eq!(state_body["spec"]["provenance"]["subject"]["login"], "dev");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -8997,7 +8994,7 @@ slug = "fabro"
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_run_persists_run_record() {
|
||||
async fn create_run_persists_run_spec() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
||||
|
||||
|
|
@ -9014,7 +9011,7 @@ slug = "fabro"
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(run_state.run.is_some());
|
||||
assert!(run_state.spec.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -9861,7 +9858,7 @@ level = "debug"
|
|||
.and_then(|run| run.run_dir.clone())
|
||||
.expect("run_dir should be recorded")
|
||||
};
|
||||
let run_record = state
|
||||
let run_spec = state
|
||||
.store
|
||||
.open_run_reader(&run_id)
|
||||
.await
|
||||
|
|
@ -9869,10 +9866,10 @@ level = "debug"
|
|||
.state()
|
||||
.await
|
||||
.unwrap()
|
||||
.run
|
||||
.expect("run record should exist");
|
||||
let resolved_run = fabro_config::resolve_run_from_file(&run_record.settings).unwrap();
|
||||
let resolved_server = fabro_config::resolve_server_from_file(&run_record.settings).unwrap();
|
||||
.spec
|
||||
.expect("run spec should exist");
|
||||
let resolved_run = fabro_config::resolve_run_from_file(&run_spec.settings).unwrap();
|
||||
let resolved_server = fabro_config::resolve_server_from_file(&run_spec.settings).unwrap();
|
||||
|
||||
// Verify a sampling of the persisted v2 settings, including inherited
|
||||
// run execution mode from server settings.
|
||||
|
|
|
|||
|
|
@ -6,16 +6,18 @@ mod keyed_mutex;
|
|||
mod keys;
|
||||
mod record;
|
||||
mod run_state;
|
||||
mod serializable_projection;
|
||||
mod slate;
|
||||
mod types;
|
||||
|
||||
pub use artifact_store::{ArtifactStore, NodeArtifact};
|
||||
pub use error::{Error, Result};
|
||||
pub use fabro_types::{
|
||||
EventEnvelope, NodeState, PendingInterviewRecord, RunBlobId, RunProjection, StageId,
|
||||
EventEnvelope, NodeState, PendingInterviewRecord, RunBlobId, RunProjection, RunSummary, StageId,
|
||||
};
|
||||
pub(crate) use keyed_mutex::KeyedMutex;
|
||||
pub use run_state::RunProjectionReducer;
|
||||
pub use serializable_projection::SerializableProjection;
|
||||
pub use slate::{
|
||||
AuthCode, AuthCodeStore, Blob, BlobStore, ConsumeOutcome, Database, RefreshToken,
|
||||
RefreshTokenStore, RunCatalogIndex, RunDatabase, Runs,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ use fabro_types::run_event::{
|
|||
use fabro_types::{
|
||||
BilledModelUsage, BlockedReason, Checkpoint, Conclusion, EventBody, FailureSignature,
|
||||
InterviewQuestionRecord, InterviewQuestionType, NodeStatusRecord, Outcome,
|
||||
PendingInterviewRecord, PullRequestRecord, RunControlAction, RunId, RunProjection, RunRecord,
|
||||
PendingInterviewRecord, PullRequestRecord, RunControlAction, RunId, RunProjection, RunSpec,
|
||||
RunStatus, RunStatusRecord, RunSummary, SandboxRecord, StageStatus, StartRecord, StatusReason,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
|
@ -49,7 +49,7 @@ impl RunProjectionReducer for RunProjection {
|
|||
EventBody::RunCreated(props) => {
|
||||
let working_directory = PathBuf::from(&props.working_directory);
|
||||
let labels = props.labels.clone().into_iter().collect::<HashMap<_, _>>();
|
||||
self.run = Some(RunRecord {
|
||||
self.spec = Some(RunSpec {
|
||||
run_id,
|
||||
settings: props.settings.clone(),
|
||||
graph: props.graph.clone(),
|
||||
|
|
@ -74,8 +74,8 @@ impl RunProjectionReducer for RunProjection {
|
|||
});
|
||||
}
|
||||
EventBody::RunSubmitted(props) => {
|
||||
if let Some(run) = self.run.as_mut() {
|
||||
run.definition_blob = props.definition_blob;
|
||||
if let Some(spec) = self.spec.as_mut() {
|
||||
spec.definition_blob = props.definition_blob;
|
||||
}
|
||||
self.status = Some(run_status_record(RunStatus::Submitted, props.reason, ts));
|
||||
}
|
||||
|
|
@ -387,31 +387,34 @@ impl RunProjectionReducer for RunProjection {
|
|||
}
|
||||
|
||||
pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary {
|
||||
let workflow_name = state.run.as_ref().map(|run| {
|
||||
if run.graph.name.is_empty() {
|
||||
let workflow_name = state.spec.as_ref().map(|spec| {
|
||||
if spec.graph.name.is_empty() {
|
||||
"unnamed".to_string()
|
||||
} else {
|
||||
run.graph.name.clone()
|
||||
spec.graph.name.clone()
|
||||
}
|
||||
});
|
||||
let goal = state.run.as_ref().and_then(|run| {
|
||||
let goal = run.graph.goal();
|
||||
let goal = state.spec.as_ref().and_then(|spec| {
|
||||
let goal = spec.graph.goal();
|
||||
(!goal.is_empty()).then(|| goal.to_string())
|
||||
});
|
||||
RunSummary {
|
||||
run_id: *run_id,
|
||||
workflow_name,
|
||||
workflow_slug: state.run.as_ref().and_then(|run| run.workflow_slug.clone()),
|
||||
workflow_slug: state
|
||||
.spec
|
||||
.as_ref()
|
||||
.and_then(|spec| spec.workflow_slug.clone()),
|
||||
goal,
|
||||
labels: state
|
||||
.run
|
||||
.spec
|
||||
.as_ref()
|
||||
.map(|run| run.labels.clone())
|
||||
.map(|spec| spec.labels.clone())
|
||||
.unwrap_or_default(),
|
||||
host_repo_path: state
|
||||
.run
|
||||
.spec
|
||||
.as_ref()
|
||||
.and_then(|run| run.host_repo_path.clone()),
|
||||
.and_then(|spec| spec.host_repo_path.clone()),
|
||||
start_time: state.start.as_ref().map(|start| start.start_time),
|
||||
status: state
|
||||
.status
|
||||
|
|
@ -677,6 +680,20 @@ mod tests {
|
|||
#[test]
|
||||
fn deserialize_and_round_trip_projection_preserves_stage_ids_and_pending_control() {
|
||||
let state: RunProjection = serde_json::from_value(serde_json::json!({
|
||||
"spec": {
|
||||
"run_id": "01JW6A7VNFZSFF0SKXJG29Z2M3",
|
||||
"settings": { "_version": 1 },
|
||||
"graph": { "name": "ship", "nodes": {}, "edges": [], "attrs": {} },
|
||||
"workflow_slug": "demo",
|
||||
"working_directory": "/tmp/project",
|
||||
"host_repo_path": null,
|
||||
"repo_origin_url": null,
|
||||
"base_branch": null,
|
||||
"labels": {},
|
||||
"provenance": null,
|
||||
"manifest_blob": null,
|
||||
"definition_blob": null
|
||||
},
|
||||
"pending_control": "cancel",
|
||||
"checkpoints": [[
|
||||
0,
|
||||
|
|
@ -709,6 +726,7 @@ mod tests {
|
|||
|
||||
let round_tripped: RunProjection =
|
||||
serde_json::from_value(serde_json::to_value(&state).unwrap()).unwrap();
|
||||
let serialized = serde_json::to_value(&state).unwrap();
|
||||
let round_tripped_node = round_tripped.node(&stage_id).unwrap();
|
||||
assert_eq!(round_tripped_node.stdout.as_deref(), Some("done"));
|
||||
assert_eq!(round_tripped.list_node_visits("build"), vec![2]);
|
||||
|
|
@ -716,6 +734,8 @@ mod tests {
|
|||
round_tripped.pending_control,
|
||||
Some(RunControlAction::Cancel)
|
||||
);
|
||||
assert!(serialized.get("spec").is_some());
|
||||
assert!(serialized.get("run").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -959,7 +979,7 @@ mod tests {
|
|||
#[test]
|
||||
fn summary_synthesizes_submitted_when_run_exists_without_status() {
|
||||
let mut state = RunProjection::default();
|
||||
state.run = Some(fabro_types::RunRecord {
|
||||
state.spec = Some(fabro_types::RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: SettingsLayer::default(),
|
||||
graph: fabro_types::Graph::new("test"),
|
||||
|
|
@ -1026,11 +1046,11 @@ mod tests {
|
|||
let value = serde_json::to_value(&state).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
value["run"]["manifest_blob"],
|
||||
value["spec"]["manifest_blob"],
|
||||
events[0].event.properties().unwrap()["manifest_blob"]
|
||||
);
|
||||
assert_eq!(
|
||||
value["run"]["definition_blob"],
|
||||
value["spec"]["definition_blob"],
|
||||
events[1].event.properties().unwrap()["definition_blob"]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
34
lib/crates/fabro-store/src/serializable_projection.rs
Normal file
34
lib/crates/fabro-store/src/serializable_projection.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
use serde::{Serialize, Serializer};
|
||||
|
||||
use crate::RunProjection;
|
||||
|
||||
pub struct SerializableProjection<'a>(pub &'a RunProjection);
|
||||
|
||||
impl Serialize for SerializableProjection<'_> {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let mut projection = self.0.clone();
|
||||
let stage_ids: Vec<_> = projection
|
||||
.iter_nodes()
|
||||
.map(|(stage_id, _)| stage_id.clone())
|
||||
.collect();
|
||||
|
||||
for stage_id in stage_ids {
|
||||
let Some(node) = projection.node(&stage_id).cloned() else {
|
||||
continue;
|
||||
};
|
||||
projection.set_node(stage_id, crate::NodeState {
|
||||
prompt: None,
|
||||
response: None,
|
||||
diff: None,
|
||||
stdout: None,
|
||||
stderr: None,
|
||||
..node
|
||||
});
|
||||
}
|
||||
|
||||
projection.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
|
@ -303,7 +303,7 @@ mod tests {
|
|||
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_types::{AttrValue, Graph, RunControlAction, RunRecord, RunStatus, StatusReason};
|
||||
use fabro_types::{AttrValue, Graph, RunControlAction, RunSpec, RunStatus, StatusReason};
|
||||
use futures::TryStreamExt;
|
||||
use object_store::memory::InMemory;
|
||||
use object_store::path::Path;
|
||||
|
|
@ -345,13 +345,13 @@ mod tests {
|
|||
(object_store, store)
|
||||
}
|
||||
|
||||
fn sample_run_record(label: &str) -> RunRecord {
|
||||
fn sample_run_spec(label: &str) -> RunSpec {
|
||||
let mut graph = Graph::new("night-sky");
|
||||
graph.attrs.insert(
|
||||
"goal".to_string(),
|
||||
AttrValue::String("map the constellations".to_string()),
|
||||
);
|
||||
RunRecord {
|
||||
RunSpec {
|
||||
run_id: test_run_id(label),
|
||||
settings: SettingsLayer::default(),
|
||||
graph,
|
||||
|
|
@ -387,20 +387,20 @@ mod tests {
|
|||
}
|
||||
|
||||
async fn append_created(run: &RunDatabase, label: &str, created_at: DateTime<Utc>) {
|
||||
let run_record = sample_run_record(label);
|
||||
let run_spec = sample_run_spec(label);
|
||||
run.append_event(&event_payload(
|
||||
label,
|
||||
&created_at.to_rfc3339(),
|
||||
"run.created",
|
||||
&serde_json::json!({
|
||||
"settings": run_record.settings,
|
||||
"graph": run_record.graph,
|
||||
"workflow_slug": run_record.workflow_slug,
|
||||
"working_directory": run_record.working_directory,
|
||||
"settings": run_spec.settings,
|
||||
"graph": run_spec.graph,
|
||||
"workflow_slug": run_spec.workflow_slug,
|
||||
"working_directory": run_spec.working_directory,
|
||||
"run_dir": format!("/tmp/{label}"),
|
||||
"host_repo_path": run_record.host_repo_path,
|
||||
"base_branch": run_record.base_branch,
|
||||
"labels": run_record.labels,
|
||||
"host_repo_path": run_spec.host_repo_path,
|
||||
"base_branch": run_spec.base_branch,
|
||||
"labels": run_spec.labels,
|
||||
}),
|
||||
))
|
||||
.await
|
||||
|
|
@ -466,7 +466,7 @@ mod tests {
|
|||
assert_eq!(summary[1].status_reason, Some(StatusReason::Completed));
|
||||
|
||||
let reopened = store.open_run(&test_run_id("run-1")).await.unwrap();
|
||||
let stored = reopened.state().await.unwrap().run.unwrap();
|
||||
let stored = reopened.state().await.unwrap().spec.unwrap();
|
||||
assert_eq!(stored.run_id, test_run_id("run-1"));
|
||||
|
||||
store.delete_run(&test_run_id("run-1")).await.unwrap();
|
||||
|
|
@ -609,7 +609,7 @@ mod tests {
|
|||
|
||||
let reader = store.open_run_reader(&test_run_id("run-1")).await.unwrap();
|
||||
let state = reader.state().await.unwrap();
|
||||
assert_eq!(state.run.unwrap().run_id, test_run_id("run-1"));
|
||||
assert_eq!(state.spec.unwrap().run_id, test_run_id("run-1"));
|
||||
|
||||
run.append_event(&event_payload(
|
||||
"run-1",
|
||||
|
|
|
|||
156
lib/crates/fabro-store/tests/serializable_projection.rs
Normal file
156
lib/crates/fabro-store/tests/serializable_projection.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fabro_store::{NodeState, RunProjection, SerializableProjection, StageId};
|
||||
use fabro_types::graph::Graph;
|
||||
use fabro_types::run::RunSpec;
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_types::{
|
||||
Checkpoint, NodeStatusRecord, RunStatus, RunStatusRecord, SandboxRecord, StageStatus,
|
||||
StartRecord, fixtures,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: SettingsLayer::default(),
|
||||
graph: Graph::new("ship"),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
working_directory: PathBuf::from("/tmp/project"),
|
||||
host_repo_path: Some("/tmp/project".to_string()),
|
||||
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
|
||||
base_branch: Some("main".to_string()),
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_checkpoint() -> Checkpoint {
|
||||
Checkpoint {
|
||||
timestamp: Utc
|
||||
.with_ymd_and_hms(2026, 4, 20, 12, 0, 0)
|
||||
.single()
|
||||
.expect("timestamp should be representable"),
|
||||
current_node: "build".to_string(),
|
||||
completed_nodes: vec!["build".to_string()],
|
||||
node_retries: HashMap::new(),
|
||||
context_values: HashMap::new(),
|
||||
node_outcomes: HashMap::new(),
|
||||
next_node_id: Some("ship".to_string()),
|
||||
git_commit_sha: Some("abc123".to_string()),
|
||||
loop_failure_signatures: HashMap::new(),
|
||||
restart_failure_signatures: HashMap::new(),
|
||||
node_visits: HashMap::from([("build".to_string(), 2usize)]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializable_projection_round_trips_and_trims_bulky_node_fields() {
|
||||
let stage_id = StageId::new("build", 2);
|
||||
let mut projection = RunProjection::default();
|
||||
projection.spec = Some(sample_run_spec());
|
||||
projection.start = Some(StartRecord {
|
||||
run_id: fixtures::RUN_1,
|
||||
start_time: Utc
|
||||
.with_ymd_and_hms(2026, 4, 20, 12, 0, 0)
|
||||
.single()
|
||||
.expect("start_time should be representable"),
|
||||
run_branch: Some("fabro/run/demo".to_string()),
|
||||
base_sha: Some("deadbeef".to_string()),
|
||||
});
|
||||
projection.status = Some(RunStatusRecord::new(RunStatus::Running, None));
|
||||
projection.checkpoint = Some(sample_checkpoint());
|
||||
projection.sandbox = Some(SandboxRecord {
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/tmp/project".to_string(),
|
||||
identifier: Some("sandbox-1".to_string()),
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
});
|
||||
projection.pending_interviews = BTreeMap::new();
|
||||
projection.set_node(stage_id.clone(), NodeState {
|
||||
prompt: Some("plan the work".to_string()),
|
||||
response: Some("done".to_string()),
|
||||
status: Some(NodeStatusRecord {
|
||||
status: StageStatus::Success,
|
||||
notes: Some("ok".to_string()),
|
||||
failure_reason: None,
|
||||
timestamp: Utc
|
||||
.with_ymd_and_hms(2026, 4, 20, 12, 1, 0)
|
||||
.single()
|
||||
.expect("timestamp should be representable"),
|
||||
}),
|
||||
provider_used: Some(json!({ "provider": "openai", "model": "gpt-5.4" })),
|
||||
diff: Some("diff --git a/a b/a".to_string()),
|
||||
script_invocation: Some(json!({ "command": "cargo test" })),
|
||||
script_timing: Some(json!({ "duration_ms": 10 })),
|
||||
parallel_results: Some(json!([{ "stage": "fanout@1" }])),
|
||||
stdout: Some("stdout".to_string()),
|
||||
stderr: Some("stderr".to_string()),
|
||||
});
|
||||
|
||||
let serialized = serde_json::to_value(SerializableProjection(&projection))
|
||||
.expect("projection should serialize");
|
||||
let round_tripped: RunProjection =
|
||||
serde_json::from_value(serialized).expect("serialized projection should deserialize");
|
||||
let node = round_tripped.node(&stage_id).expect("node should remain");
|
||||
|
||||
assert_eq!(round_tripped.spec().map(RunSpec::id), Some(fixtures::RUN_1));
|
||||
assert_eq!(
|
||||
round_tripped
|
||||
.current_checkpoint()
|
||||
.expect("checkpoint should remain")
|
||||
.current_node,
|
||||
"build"
|
||||
);
|
||||
assert_eq!(round_tripped.status(), Some(RunStatus::Running));
|
||||
assert!(!round_tripped.is_terminal());
|
||||
assert_eq!(node.prompt, None);
|
||||
assert_eq!(node.response, None);
|
||||
assert_eq!(node.diff, None);
|
||||
assert_eq!(node.stdout, None);
|
||||
assert_eq!(node.stderr, None);
|
||||
assert_eq!(
|
||||
node.provider_used,
|
||||
Some(json!({ "provider": "openai", "model": "gpt-5.4" }))
|
||||
);
|
||||
assert_eq!(
|
||||
node.script_invocation,
|
||||
Some(json!({ "command": "cargo test" }))
|
||||
);
|
||||
assert_eq!(node.script_timing, Some(json!({ "duration_ms": 10 })));
|
||||
assert_eq!(
|
||||
node.parallel_results,
|
||||
Some(json!([{ "stage": "fanout@1" }]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_query_methods_expose_common_state() {
|
||||
let mut projection = RunProjection::default();
|
||||
projection.spec = Some(sample_run_spec());
|
||||
projection.status = Some(RunStatusRecord::new(RunStatus::Archived, None));
|
||||
projection.checkpoint = Some(sample_checkpoint());
|
||||
projection.pending_interviews = BTreeMap::from([(
|
||||
"q-1".to_string(),
|
||||
fabro_store::PendingInterviewRecord::default(),
|
||||
)]);
|
||||
|
||||
assert_eq!(
|
||||
projection.spec().map(RunSpec::workflow_slug),
|
||||
Some(Some("demo"))
|
||||
);
|
||||
assert_eq!(projection.status(), Some(RunStatus::Archived));
|
||||
assert!(projection.is_terminal());
|
||||
assert_eq!(
|
||||
projection
|
||||
.current_checkpoint()
|
||||
.map(|checkpoint| checkpoint.current_node.as_str()),
|
||||
Some("build")
|
||||
);
|
||||
assert!(projection.pending_interviews().contains_key("q-1"));
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ pub use retro::{
|
|||
OpenItemKind, Retro, RetroNarrative, SmoothnessRating, StageRetro,
|
||||
};
|
||||
pub use run::{
|
||||
RunAuthMethod, RunClientProvenance, RunProvenance, RunRecord, RunServerProvenance,
|
||||
RunAuthMethod, RunClientProvenance, RunProvenance, RunServerProvenance, RunSpec,
|
||||
RunSubjectProvenance,
|
||||
};
|
||||
pub use run_blob_id::RunBlobId;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ pub struct RunProvenance {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RunRecord {
|
||||
pub struct RunSpec {
|
||||
pub run_id: RunId,
|
||||
pub settings: SettingsLayer,
|
||||
pub graph: Graph,
|
||||
|
|
@ -71,3 +71,50 @@ pub struct RunRecord {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub definition_blob: Option<RunBlobId>,
|
||||
}
|
||||
|
||||
impl RunSpec {
|
||||
#[must_use]
|
||||
pub fn id(&self) -> RunId {
|
||||
self.run_id
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn graph(&self) -> &Graph {
|
||||
&self.graph
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn settings(&self) -> &SettingsLayer {
|
||||
&self.settings
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn workflow_slug(&self) -> Option<&str> {
|
||||
self.workflow_slug.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn working_directory(&self) -> &Path {
|
||||
&self.working_directory
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn labels(&self) -> &HashMap<String, String> {
|
||||
&self.labels
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn host_repo_path(&self) -> Option<&str> {
|
||||
self.host_repo_path.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn repo_origin_url(&self) -> Option<&str> {
|
||||
self.repo_origin_url.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn base_branch(&self) -> Option<&str> {
|
||||
self.base_branch.as_deref()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ use chrono::{DateTime, Utc};
|
|||
|
||||
use crate::{
|
||||
Checkpoint, Conclusion, InterviewQuestionRecord, NodeStatusRecord, PullRequestRecord, Retro,
|
||||
RunControlAction, RunRecord, RunStatus, RunStatusRecord, SandboxRecord, StageId, StartRecord,
|
||||
RunControlAction, RunSpec, RunStatus, RunStatusRecord, SandboxRecord, StageId, StartRecord,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct RunProjection {
|
||||
pub run: Option<RunRecord>,
|
||||
pub spec: Option<RunSpec>,
|
||||
pub graph_source: Option<String>,
|
||||
pub start: Option<StartRecord>,
|
||||
pub status: Option<RunStatusRecord>,
|
||||
|
|
@ -79,6 +79,26 @@ impl RunProjection {
|
|||
visits
|
||||
}
|
||||
|
||||
pub fn spec(&self) -> Option<&RunSpec> {
|
||||
self.spec.as_ref()
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Option<RunStatus> {
|
||||
self.status.as_ref().map(|status| status.status)
|
||||
}
|
||||
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
self.status().is_some_and(RunStatus::is_terminal)
|
||||
}
|
||||
|
||||
pub fn current_checkpoint(&self) -> Option<&Checkpoint> {
|
||||
self.checkpoint.as_ref()
|
||||
}
|
||||
|
||||
pub fn pending_interviews(&self) -> &BTreeMap<String, PendingInterviewRecord> {
|
||||
&self.pending_interviews
|
||||
}
|
||||
|
||||
pub fn node_mut(&mut self, node_id: &str, visit: u32) -> &mut NodeState {
|
||||
self.nodes.entry(StageId::new(node_id, visit)).or_default()
|
||||
}
|
||||
|
|
|
|||
45
lib/crates/fabro-types/tests/run_spec_methods.rs
Normal file
45
lib/crates/fabro-types/tests/run_spec_methods.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_types::fixtures;
|
||||
use fabro_types::graph::Graph;
|
||||
use fabro_types::run::RunSpec;
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: SettingsLayer::default(),
|
||||
graph: Graph::new("ship"),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
working_directory: PathBuf::from("/tmp/project"),
|
||||
host_repo_path: Some("/tmp/project".to_string()),
|
||||
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
|
||||
base_branch: Some("main".to_string()),
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_spec_getters_return_declared_fields() {
|
||||
let run_spec = sample_run_spec();
|
||||
|
||||
assert_eq!(run_spec.id(), fixtures::RUN_1);
|
||||
assert_eq!(run_spec.graph().name, "ship");
|
||||
assert_eq!(run_spec.settings(), &SettingsLayer::default());
|
||||
assert_eq!(run_spec.workflow_slug(), Some("demo"));
|
||||
assert_eq!(run_spec.working_directory(), Path::new("/tmp/project"));
|
||||
assert_eq!(
|
||||
run_spec.labels().get("team").map(String::as_str),
|
||||
Some("platform")
|
||||
);
|
||||
assert_eq!(run_spec.host_repo_path(), Some("/tmp/project"));
|
||||
assert_eq!(
|
||||
run_spec.repo_origin_url(),
|
||||
Some("https://github.com/fabro-sh/fabro.git")
|
||||
);
|
||||
assert_eq!(run_spec.base_branch(), Some("main"));
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ use std::path::PathBuf;
|
|||
|
||||
use fabro_types::fixtures;
|
||||
use fabro_types::graph::Graph;
|
||||
use fabro_types::run::RunRecord;
|
||||
use fabro_types::run::RunSpec;
|
||||
use fabro_types::settings::run::{RunGoalLayer, RunLayer};
|
||||
use fabro_types::settings::server::{
|
||||
GithubIntegrationLayer, ServerIntegrationsLayer, ServerLayer, ServerStorageLayer,
|
||||
|
|
@ -37,8 +37,8 @@ fn templated_settings() -> SettingsLayer {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn run_record_round_trips_templated_settings() {
|
||||
let record = RunRecord {
|
||||
fn run_spec_round_trips_templated_settings() {
|
||||
let record = RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: templated_settings(),
|
||||
graph: Graph::new("ship"),
|
||||
|
|
@ -54,7 +54,7 @@ fn run_record_round_trips_templated_settings() {
|
|||
};
|
||||
|
||||
let json = serde_json::to_value(&record).expect("record should serialize");
|
||||
let round_trip: RunRecord =
|
||||
let round_trip: RunSpec =
|
||||
serde_json::from_value(json.clone()).expect("record should deserialize");
|
||||
|
||||
assert_eq!(
|
||||
|
|
@ -474,14 +474,14 @@ mod tests {
|
|||
let source = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
|
||||
let source_message = source.to_string();
|
||||
let fabro_error = Error::from(MetadataError::Deserialize {
|
||||
entity: "run record",
|
||||
entity: "run spec",
|
||||
branch: "fabro/meta/run-1".to_string(),
|
||||
source,
|
||||
});
|
||||
|
||||
assert!(matches!(fabro_error, Error::Engine { .. }));
|
||||
let message = fabro_error.to_string();
|
||||
assert!(message.contains("deserialize run record on branch fabro/meta/run-1"));
|
||||
assert!(message.contains("deserialize run spec on branch fabro/meta/run-1"));
|
||||
assert!(message.contains(&source_message));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -531,15 +531,15 @@ mod tests {
|
|||
.unwrap();
|
||||
|
||||
let state = run.state().await.unwrap();
|
||||
let files = RunDump::metadata_checkpoint(&state).git_entries().unwrap();
|
||||
let files = RunDump::from_projection(&state).git_entries().unwrap();
|
||||
let paths: Vec<&str> = files.iter().map(|(path, _)| path.as_str()).collect();
|
||||
assert!(paths.contains(&"nodes/work-visit_2/prompt.md"));
|
||||
assert!(paths.contains(&"nodes/work-visit_2/response.md"));
|
||||
assert!(paths.contains(&"nodes/work-visit_2/status.json"));
|
||||
assert!(paths.contains(&"nodes/work-visit_2/provider_used.json"));
|
||||
assert!(paths.contains(&"nodes/work-visit_2/script_invocation.json"));
|
||||
assert!(paths.contains(&"nodes/work-visit_2/script_timing.json"));
|
||||
assert!(paths.contains(&"nodes/work-visit_2/parallel_results.json"));
|
||||
assert!(paths.contains(&"stages/work@2/prompt.md"));
|
||||
assert!(paths.contains(&"stages/work@2/response.md"));
|
||||
assert!(paths.contains(&"stages/work@2/status.json"));
|
||||
assert!(paths.contains(&"stages/work@2/provider_used.json"));
|
||||
assert!(paths.contains(&"stages/work@2/script_invocation.json"));
|
||||
assert!(paths.contains(&"stages/work@2/script_timing.json"));
|
||||
assert!(paths.contains(&"stages/work@2/parallel_results.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
|
|||
let git_author = self.run_options.git_author();
|
||||
let store = MetadataStore::new(repo_path, &git_author);
|
||||
let state = self.run_store.state().await.ok();
|
||||
let init_dump = state.as_ref().map(RunDump::metadata_init);
|
||||
let init_dump = state.as_ref().map(RunDump::from_projection);
|
||||
let init_entries = init_dump
|
||||
.as_ref()
|
||||
.and_then(|dump| dump.git_entries().ok())
|
||||
|
|
@ -148,34 +148,56 @@ impl RunLifecycle<WorkflowGraph> for GitLifecycle {
|
|||
HashMap::new(),
|
||||
None,
|
||||
);
|
||||
if let Ok(cp_json) = serde_json::to_vec_pretty(&checkpoint) {
|
||||
let mut extra_entries: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
if let Ok(store_state) = self.run_store.state().await {
|
||||
if let Ok(mut dump_entries) =
|
||||
RunDump::metadata_checkpoint(&store_state).git_entries()
|
||||
{
|
||||
extra_entries.append(&mut dump_entries);
|
||||
match self.run_store.state().await {
|
||||
Ok(mut snapshot_state) => {
|
||||
snapshot_state.checkpoint = Some(checkpoint);
|
||||
let dump = RunDump::from_projection(&snapshot_state);
|
||||
match dump.git_entries() {
|
||||
Ok(dump_entries) => {
|
||||
let refs: Vec<(&str, &[u8])> = dump_entries
|
||||
.iter()
|
||||
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
|
||||
.collect();
|
||||
match store.write_snapshot(
|
||||
&self.run_id.to_string(),
|
||||
&refs,
|
||||
"checkpoint",
|
||||
) {
|
||||
Ok(sha) => Some(sha),
|
||||
Err(e) => {
|
||||
self.emitter.emit(&Event::RunNotice {
|
||||
level: RunNoticeLevel::Warn,
|
||||
code: "checkpoint_metadata_write_failed".to_string(),
|
||||
message: format!(
|
||||
"[node: {node_id}] metadata checkpoint write failed: {e}"
|
||||
),
|
||||
});
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.emitter.emit(&Event::RunNotice {
|
||||
level: RunNoticeLevel::Warn,
|
||||
code: "checkpoint_metadata_write_failed".to_string(),
|
||||
message: format!(
|
||||
"[node: {node_id}] metadata checkpoint serialization failed: {e}"
|
||||
),
|
||||
});
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
let extra_refs: Vec<(&str, &[u8])> = extra_entries
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_slice()))
|
||||
.collect();
|
||||
match store.write_checkpoint(&self.run_id.to_string(), &cp_json, &extra_refs) {
|
||||
Ok(sha) => Some(sha),
|
||||
Err(e) => {
|
||||
self.emitter.emit(&Event::RunNotice {
|
||||
level: RunNoticeLevel::Warn,
|
||||
code: "checkpoint_metadata_write_failed".to_string(),
|
||||
message: format!(
|
||||
"[node: {node_id}] metadata checkpoint write failed: {e}"
|
||||
),
|
||||
});
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
self.emitter.emit(&Event::RunNotice {
|
||||
level: RunNoticeLevel::Warn,
|
||||
code: "checkpoint_metadata_write_failed".to_string(),
|
||||
message: format!(
|
||||
"[node: {node_id}] failed to load run state for metadata snapshot: {e}"
|
||||
),
|
||||
});
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ use crate::event::{Event, append_event, to_run_event_at};
|
|||
use crate::file_resolver::FileResolver;
|
||||
use crate::pipeline::types::PersistOptions;
|
||||
use crate::pipeline::{self, Persisted, TransformOptions, Validated};
|
||||
use crate::records::RunRecord;
|
||||
use crate::records::RunSpec;
|
||||
use crate::run_lookup::default_scratch_base;
|
||||
use crate::run_materialization::materialize_run;
|
||||
use crate::transforms::Transform;
|
||||
|
|
@ -202,7 +202,7 @@ async fn persist_created_run(
|
|||
submitted_manifest_bytes: Option<&[u8]>,
|
||||
accepted_definition: Option<&RunDefinition>,
|
||||
) -> Result<(), Error> {
|
||||
let record = persisted.run_record();
|
||||
let record = persisted.run_spec();
|
||||
let run_store = match store.create_run(&record.run_id).await {
|
||||
Ok(run_store) => run_store,
|
||||
Err(err) => store
|
||||
|
|
@ -409,7 +409,7 @@ fn persist_validated(
|
|||
let run_id = run_id.unwrap_or_else(RunId::new);
|
||||
let run_dir = run_dir.unwrap_or_else(|| default_run_dir(&run_id));
|
||||
|
||||
let run_record = RunRecord {
|
||||
let run_spec = RunSpec {
|
||||
run_id,
|
||||
settings,
|
||||
graph: validated.graph().clone(),
|
||||
|
|
@ -424,10 +424,7 @@ fn persist_validated(
|
|||
definition_blob: None,
|
||||
};
|
||||
|
||||
pipeline::persist(validated, PersistOptions {
|
||||
run_dir,
|
||||
run_record,
|
||||
})
|
||||
pipeline::persist(validated, PersistOptions { run_dir, run_spec })
|
||||
}
|
||||
|
||||
pub(crate) fn default_run_dir(run_id: &RunId) -> PathBuf {
|
||||
|
|
@ -812,9 +809,9 @@ mod tests {
|
|||
.unwrap();
|
||||
|
||||
assert_eq!(created.run_id, fixtures::RUN_1);
|
||||
assert_eq!(created.persisted.run_record().graph.goal(), "override goal");
|
||||
assert_eq!(created.persisted.run_spec().graph.goal(), "override goal");
|
||||
assert_eq!(
|
||||
fabro_config::resolve_run_from_file(&created.persisted.run_record().settings)
|
||||
fabro_config::resolve_run_from_file(&created.persisted.run_spec().settings)
|
||||
.unwrap()
|
||||
.model
|
||||
.name
|
||||
|
|
@ -824,7 +821,7 @@ mod tests {
|
|||
Some("claude-sonnet-4-6")
|
||||
);
|
||||
assert_eq!(
|
||||
fabro_config::resolve_run_from_file(&created.persisted.run_record().settings)
|
||||
fabro_config::resolve_run_from_file(&created.persisted.run_spec().settings)
|
||||
.unwrap()
|
||||
.model
|
||||
.provider
|
||||
|
|
@ -834,7 +831,7 @@ mod tests {
|
|||
Some("anthropic")
|
||||
);
|
||||
assert_eq!(
|
||||
match fabro_config::resolve_run_from_file(&created.persisted.run_record().settings)
|
||||
match fabro_config::resolve_run_from_file(&created.persisted.run_spec().settings)
|
||||
.unwrap()
|
||||
.goal
|
||||
{
|
||||
|
|
@ -847,13 +844,13 @@ mod tests {
|
|||
Some("override goal")
|
||||
);
|
||||
assert!(
|
||||
fabro_config::resolve_run_from_file(&created.persisted.run_record().settings)
|
||||
fabro_config::resolve_run_from_file(&created.persisted.run_spec().settings)
|
||||
.unwrap()
|
||||
.pull_request
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
created.persisted.run_record().workflow_slug.as_deref(),
|
||||
created.persisted.run_spec().workflow_slug.as_deref(),
|
||||
Some("slug")
|
||||
);
|
||||
let run_store = store.open_run(&fixtures::RUN_1).await.unwrap();
|
||||
|
|
@ -906,13 +903,13 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(created.persisted.run_record().working_directory, workspace);
|
||||
assert_eq!(created.persisted.run_spec().working_directory, workspace);
|
||||
assert_eq!(
|
||||
created.persisted.run_record().host_repo_path.as_deref(),
|
||||
created.persisted.run_spec().host_repo_path.as_deref(),
|
||||
Some(
|
||||
created
|
||||
.persisted
|
||||
.run_record()
|
||||
.run_spec()
|
||||
.working_directory
|
||||
.to_string_lossy()
|
||||
.as_ref()
|
||||
|
|
@ -946,7 +943,7 @@ mod tests {
|
|||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
created.persisted.run_record().repo_origin_url.as_deref(),
|
||||
created.persisted.run_spec().repo_origin_url.as_deref(),
|
||||
Some("https://github.com/acme/widgets")
|
||||
);
|
||||
}
|
||||
|
|
@ -1074,7 +1071,7 @@ mod tests {
|
|||
|
||||
let run_store = store.open_run_reader(&created.run_id).await.unwrap();
|
||||
let state = run_store.state().await.unwrap();
|
||||
let run = state.run.expect("run should be projected");
|
||||
let run = state.spec.expect("run should be projected");
|
||||
let provenance = run.provenance.expect("provenance should be projected");
|
||||
|
||||
assert_eq!(provenance.server.unwrap().version, "0.9.0");
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
use anyhow::{Context, Result};
|
||||
use fabro_checkpoint::branch::BranchStore;
|
||||
use fabro_checkpoint::git::Store;
|
||||
use fabro_store::RunProjection;
|
||||
use fabro_types::RunId;
|
||||
use git2::{Oid, Signature};
|
||||
|
||||
use super::rewind::{RewindTarget, TimelineEntry, build_timeline};
|
||||
use crate::git::{MetadataStore, RUN_BRANCH_PREFIX, push_run_branches};
|
||||
use crate::records::{Checkpoint, RunRecord, StartRecord};
|
||||
use crate::records::{Checkpoint, RunSpec, StartRecord};
|
||||
use crate::run_dump::RunDump;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ForkRunInput {
|
||||
|
|
@ -65,67 +67,81 @@ fn fork_from_entry(
|
|||
.ensure_branch()
|
||||
.map_err(|e| anyhow::anyhow!("failed to create metadata branch: {e}"))?;
|
||||
|
||||
let source_entries = source_bs
|
||||
.read_entries(&["run.json", "start.json", "sandbox.json"])
|
||||
.map_err(|e| anyhow::anyhow!("failed to read source metadata: {e}"))?;
|
||||
let source_projection = source_bs
|
||||
.read_entry("run.json")
|
||||
.map_err(|e| anyhow::anyhow!("failed to read source metadata: {e}"))?
|
||||
.context("source run has no run.json")
|
||||
.and_then(|bytes| {
|
||||
serde_json::from_slice::<RunProjection>(&bytes)
|
||||
.context("failed to parse source run.json")
|
||||
})?;
|
||||
|
||||
let mut run_record_bytes = None;
|
||||
let mut sandbox_bytes = None;
|
||||
for (path, data) in source_entries {
|
||||
match path {
|
||||
"run.json" => run_record_bytes = Some(data),
|
||||
"sandbox.json" => sandbox_bytes = Some(data),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let run_record_bytes =
|
||||
run_record_bytes.ok_or_else(|| anyhow::anyhow!("source run has no run.json"))?;
|
||||
let mut run_spec: RunSpec = source_projection
|
||||
.spec
|
||||
.clone()
|
||||
.context("source run projection has no spec")?;
|
||||
run_spec.run_id = new_run_id;
|
||||
|
||||
let mut run_record: RunRecord =
|
||||
serde_json::from_slice(&run_record_bytes).context("failed to parse source run.json")?;
|
||||
run_record.run_id = new_run_id;
|
||||
let new_run_record_bytes =
|
||||
serde_json::to_vec_pretty(&run_record).context("failed to serialize new run.json")?;
|
||||
|
||||
let now = new_run_id.created_at();
|
||||
let start_record = StartRecord {
|
||||
run_id: new_run_id,
|
||||
start_time: now,
|
||||
start_time: new_run_id.created_at(),
|
||||
run_branch: Some(new_run_branch.clone()),
|
||||
base_sha: None,
|
||||
};
|
||||
let new_start_record_bytes =
|
||||
serde_json::to_vec_pretty(&start_record).context("failed to serialize new start.json")?;
|
||||
|
||||
let mut init_projection = RunProjection::default();
|
||||
init_projection.spec = Some(run_spec.clone());
|
||||
init_projection
|
||||
.graph_source
|
||||
.clone_from(&source_projection.graph_source);
|
||||
init_projection.start = Some(start_record.clone());
|
||||
init_projection
|
||||
.sandbox
|
||||
.clone_from(&source_projection.sandbox);
|
||||
|
||||
let checkpoint_bytes = store
|
||||
.read_blob_at(entry.metadata_commit_oid, "checkpoint.json")
|
||||
.map_err(|e| anyhow::anyhow!("failed to read checkpoint blob: {e}"))?
|
||||
.read_blob_at(entry.metadata_commit_oid, "run.json")
|
||||
.map_err(|e| anyhow::anyhow!("failed to read checkpoint snapshot: {e}"))?
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no checkpoint.json at metadata commit {}",
|
||||
"no run.json at metadata commit {}",
|
||||
entry.metadata_commit_oid
|
||||
)
|
||||
})?;
|
||||
let mut checkpoint: Checkpoint = serde_json::from_slice(&checkpoint_bytes)
|
||||
.context("failed to parse source checkpoint.json")?;
|
||||
let mut checkpoint_projection: RunProjection = serde_json::from_slice(&checkpoint_bytes)
|
||||
.context("failed to parse source checkpoint snapshot")?;
|
||||
let mut checkpoint: Checkpoint = checkpoint_projection
|
||||
.checkpoint
|
||||
.clone()
|
||||
.context("source checkpoint snapshot has no checkpoint")?;
|
||||
checkpoint.git_commit_sha.clone_from(&entry.run_commit_sha);
|
||||
let checkpoint_bytes =
|
||||
serde_json::to_vec_pretty(&checkpoint).context("failed to serialize checkpoint.json")?;
|
||||
checkpoint_projection.spec = Some(run_spec);
|
||||
checkpoint_projection.graph_source = source_projection.graph_source;
|
||||
checkpoint_projection.start = Some(start_record);
|
||||
checkpoint_projection.sandbox = source_projection.sandbox;
|
||||
checkpoint_projection.checkpoint = Some(checkpoint);
|
||||
|
||||
let mut init_entries: Vec<(&str, &[u8])> = vec![("run.json", &new_run_record_bytes)];
|
||||
init_entries.push(("start.json", &new_start_record_bytes));
|
||||
if let Some(ref sandbox) = sandbox_bytes {
|
||||
init_entries.push(("sandbox.json", sandbox));
|
||||
}
|
||||
let init_entries = RunDump::from_projection(&init_projection)
|
||||
.git_entries()
|
||||
.context("failed to build init metadata snapshot")?;
|
||||
let init_refs: Vec<(&str, &[u8])> = init_entries
|
||||
.iter()
|
||||
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
|
||||
.collect();
|
||||
new_bs
|
||||
.write_entries(&init_refs, "init run")
|
||||
.map_err(|e| anyhow::anyhow!("failed to write init metadata snapshot: {e}"))?;
|
||||
|
||||
let checkpoint_entries = RunDump::from_projection(&checkpoint_projection)
|
||||
.git_entries()
|
||||
.context("failed to build checkpoint metadata snapshot")?;
|
||||
let checkpoint_refs: Vec<(&str, &[u8])> = checkpoint_entries
|
||||
.iter()
|
||||
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
|
||||
.collect();
|
||||
new_bs
|
||||
.write_entries(&init_entries, "init run")
|
||||
.map_err(|e| anyhow::anyhow!("failed to write init metadata entries: {e}"))?;
|
||||
let mut checkpoint_entries: Vec<(&str, &[u8])> = vec![("checkpoint.json", &checkpoint_bytes)];
|
||||
checkpoint_entries.extend(init_entries.iter().copied());
|
||||
new_bs
|
||||
.write_entries(&checkpoint_entries, "checkpoint")
|
||||
.map_err(|e| anyhow::anyhow!("failed to write metadata entries: {e}"))?;
|
||||
.write_entries(&checkpoint_refs, "checkpoint")
|
||||
.map_err(|e| anyhow::anyhow!("failed to write metadata snapshot: {e}"))?;
|
||||
|
||||
if push {
|
||||
let source_run_branch = format!("{RUN_BRANCH_PREFIX}{source_run_id}");
|
||||
|
|
@ -147,6 +163,7 @@ fn fork_from_entry(
|
|||
mod tests {
|
||||
use std::str::FromStr;
|
||||
|
||||
use fabro_store::RunProjection;
|
||||
use fabro_types::RunId;
|
||||
use git2::Oid;
|
||||
|
||||
|
|
@ -158,27 +175,31 @@ mod tests {
|
|||
value.parse().unwrap()
|
||||
}
|
||||
|
||||
fn make_run_record_json(run_id: &RunId) -> Vec<u8> {
|
||||
let record = serde_json::json!({
|
||||
"run_id": run_id.to_string(),
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"settings": {},
|
||||
"graph": {
|
||||
"name": "test_workflow",
|
||||
"nodes": {
|
||||
"start": {"id": "start", "attrs": {}},
|
||||
"build": {"id": "build", "attrs": {}},
|
||||
"test": {"id": "test", "attrs": {}}
|
||||
fn make_run_projection(run_id: &RunId) -> RunProjection {
|
||||
let mut projection = RunProjection::default();
|
||||
projection.spec = Some(
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"run_id": run_id.to_string(),
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"settings": {},
|
||||
"graph": {
|
||||
"name": "test_workflow",
|
||||
"nodes": {
|
||||
"start": {"id": "start", "attrs": {}},
|
||||
"build": {"id": "build", "attrs": {}},
|
||||
"test": {"id": "test", "attrs": {}}
|
||||
},
|
||||
"edges": [
|
||||
{"from": "start", "to": "build", "attrs": {}},
|
||||
{"from": "build", "to": "test", "attrs": {}}
|
||||
],
|
||||
"attrs": {}
|
||||
},
|
||||
"edges": [
|
||||
{"from": "start", "to": "build", "attrs": {}},
|
||||
{"from": "build", "to": "test", "attrs": {}}
|
||||
],
|
||||
"attrs": {}
|
||||
},
|
||||
"working_directory": "/tmp/test",
|
||||
});
|
||||
serde_json::to_vec_pretty(&record).unwrap()
|
||||
"working_directory": "/tmp/test",
|
||||
}))
|
||||
.unwrap(),
|
||||
);
|
||||
projection
|
||||
}
|
||||
|
||||
fn make_start_record_json(run_id: &RunId) -> Vec<u8> {
|
||||
|
|
@ -220,17 +241,25 @@ mod tests {
|
|||
let bs = BranchStore::new(store, &meta_branch, &sig);
|
||||
bs.ensure_branch().unwrap();
|
||||
|
||||
let run_record = make_run_record_json(run_id);
|
||||
let start_record = make_start_record_json(run_id);
|
||||
bs.write_entries(
|
||||
&[("run.json", &run_record), ("start.json", &start_record)],
|
||||
"init run",
|
||||
)
|
||||
.unwrap();
|
||||
let mut init_projection = make_run_projection(run_id);
|
||||
init_projection.start =
|
||||
Some(serde_json::from_slice(&make_start_record_json(run_id)).unwrap());
|
||||
let init_json = serde_json::to_vec_pretty(&init_projection).unwrap();
|
||||
bs.write_entries(&[("run.json", &init_json)], "init run")
|
||||
.unwrap();
|
||||
|
||||
for (i, node) in nodes.iter().enumerate() {
|
||||
let cp = make_checkpoint_json(node, 1, Some(&run_oids[i].to_string()));
|
||||
bs.write_entry("checkpoint.json", &cp, "checkpoint")
|
||||
let mut projection = init_projection.clone();
|
||||
projection.checkpoint = Some(
|
||||
serde_json::from_slice(&make_checkpoint_bytes(
|
||||
node,
|
||||
1,
|
||||
Some(&run_oids[i].to_string()),
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
let projection_json = serde_json::to_vec_pretty(&projection).unwrap();
|
||||
bs.write_entry("run.json", &projection_json, "checkpoint")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
|
|
@ -259,8 +288,11 @@ mod tests {
|
|||
let sig = test_sig();
|
||||
let bs = BranchStore::new(&store, &new_meta_branch, &sig);
|
||||
let run_json = bs.read_entry("run.json").unwrap().unwrap();
|
||||
let run_record: RunRecord = serde_json::from_slice(&run_json).unwrap();
|
||||
assert_eq!(run_record.run_id, new_run_id);
|
||||
let run_spec: RunProjection = serde_json::from_slice(&run_json).unwrap();
|
||||
assert_eq!(
|
||||
run_spec.spec.as_ref().map(|run| run.run_id),
|
||||
Some(new_run_id)
|
||||
);
|
||||
|
||||
let timeline = build_timeline(&store, &new_run_id.to_string()).unwrap();
|
||||
assert_eq!(timeline.entries.len(), 1);
|
||||
|
|
@ -279,13 +311,15 @@ mod tests {
|
|||
let meta_branch = MetadataStore::branch_name(&run_id.to_string());
|
||||
let bs = BranchStore::new(&store, &meta_branch, &sig);
|
||||
bs.ensure_branch().unwrap();
|
||||
bs.write_entry("run.json", &make_run_record_json(&run_id), "init")
|
||||
let init_projection = serde_json::to_vec_pretty(&make_run_projection(&run_id)).unwrap();
|
||||
bs.write_entry("run.json", &init_projection, "init")
|
||||
.unwrap();
|
||||
|
||||
let cp = make_checkpoint_json("start", 1, None);
|
||||
let oid = bs
|
||||
.write_entry("checkpoint.json", &cp, "checkpoint")
|
||||
.unwrap();
|
||||
let mut checkpoint_projection = make_run_projection(&run_id);
|
||||
checkpoint_projection.checkpoint =
|
||||
Some(serde_json::from_slice(&make_checkpoint_bytes("start", 1, None)).unwrap());
|
||||
let cp = serde_json::to_vec_pretty(&checkpoint_projection).unwrap();
|
||||
let oid = bs.write_entry("run.json", &cp, "checkpoint").unwrap();
|
||||
let entry = TimelineEntry {
|
||||
ordinal: 1,
|
||||
node_name: "start".to_string(),
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ use std::path::PathBuf;
|
|||
use anyhow::{Context, Result, bail};
|
||||
use fabro_checkpoint::branch::BranchStore;
|
||||
use fabro_checkpoint::git::Store as GitStore;
|
||||
use fabro_store::{Database as DurableStore, RunDatabase as DurableRunStore};
|
||||
use fabro_types::{RunId, StageId};
|
||||
use fabro_store::{
|
||||
Database as DurableStore, RunDatabase as DurableRunStore, RunProjection, RunProjectionReducer,
|
||||
};
|
||||
use fabro_types::{EventBody, RunId};
|
||||
use git2::{Repository, Signature};
|
||||
use tokio::task::spawn_blocking;
|
||||
use ulid::Ulid;
|
||||
|
|
@ -14,6 +16,7 @@ use ulid::Ulid;
|
|||
use super::rewind::{self, RunTimeline, build_timeline};
|
||||
use crate::git::MetadataStore;
|
||||
use crate::records::Checkpoint;
|
||||
use crate::run_dump::RunDump;
|
||||
|
||||
pub async fn rebuild_metadata_branch(
|
||||
git_store: &GitStore,
|
||||
|
|
@ -25,11 +28,10 @@ pub async fn rebuild_metadata_branch(
|
|||
bail!("metadata branch already exists for run {run_id}");
|
||||
}
|
||||
|
||||
let state = run_store.state().await?;
|
||||
let run_record = state
|
||||
.run
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("run record not found for {run_id}"))?;
|
||||
let events = run_store.list_events().await?;
|
||||
if events.is_empty() {
|
||||
bail!("run spec not found for {run_id}");
|
||||
}
|
||||
|
||||
let sig = Signature::now("Fabro", "noreply@fabro.sh")?;
|
||||
let scratch_branch = format!("fabro/meta-rebuild/{run_id}/{}", Ulid::new());
|
||||
|
|
@ -37,99 +39,67 @@ pub async fn rebuild_metadata_branch(
|
|||
|
||||
let result = async {
|
||||
bs.ensure_branch()?;
|
||||
let mut projection = RunProjection::default();
|
||||
let mut latest_init_snapshot = None;
|
||||
let mut init_written = false;
|
||||
let mut checkpoint_snapshots: Vec<(u32, RunProjection)> = Vec::new();
|
||||
|
||||
let mut init_entries = Vec::new();
|
||||
init_entries.push((
|
||||
"run.json".to_string(),
|
||||
serde_json::to_vec_pretty(&run_record)?,
|
||||
));
|
||||
if let Some(start) = state.start.clone() {
|
||||
init_entries.push(("start.json".to_string(), serde_json::to_vec_pretty(&start)?));
|
||||
}
|
||||
if let Some(sandbox) = state.sandbox.clone() {
|
||||
init_entries.push((
|
||||
"sandbox.json".to_string(),
|
||||
serde_json::to_vec_pretty(&sandbox)?,
|
||||
));
|
||||
}
|
||||
write_entries(&bs, &init_entries, "init run")?;
|
||||
for event in &events {
|
||||
let stored = &event.event;
|
||||
let is_checkpoint = matches!(stored.body, EventBody::CheckpointCompleted(_));
|
||||
|
||||
let mut checkpoints = state.checkpoints.clone();
|
||||
backfill_missing_checkpoint_shas(git_store, run_id, &mut checkpoints);
|
||||
|
||||
for (_seq, checkpoint) in checkpoints {
|
||||
let mut entries = Vec::new();
|
||||
entries.push((
|
||||
"checkpoint.json".to_string(),
|
||||
serde_json::to_vec_pretty(&checkpoint)?,
|
||||
));
|
||||
|
||||
for node_id in &checkpoint.completed_nodes {
|
||||
let max_visit = checkpoint.node_visits.get(node_id).copied().unwrap_or(1);
|
||||
for visit in 1..=max_visit {
|
||||
let visit = u32::try_from(visit)
|
||||
.with_context(|| format!("visit {visit} for node {node_id} exceeds u32"))?;
|
||||
let Some(node) = state.node(&StageId::new(node_id, visit)).cloned() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(prompt) = node.prompt {
|
||||
entries.push((
|
||||
node_file_path(node_id, visit, "prompt.md"),
|
||||
prompt.into_bytes(),
|
||||
));
|
||||
}
|
||||
if let Some(response) = node.response {
|
||||
entries.push((
|
||||
node_file_path(node_id, visit, "response.md"),
|
||||
response.into_bytes(),
|
||||
));
|
||||
}
|
||||
if let Some(status) = node.status {
|
||||
entries.push((
|
||||
node_file_path(node_id, visit, "status.json"),
|
||||
serde_json::to_vec_pretty(&status)?,
|
||||
));
|
||||
}
|
||||
if let Some(provider_used) = node.provider_used {
|
||||
entries.push((
|
||||
node_file_path(node_id, visit, "provider_used.json"),
|
||||
serde_json::to_vec_pretty(&provider_used)?,
|
||||
));
|
||||
}
|
||||
if let Some(diff) = node.diff {
|
||||
entries.push((
|
||||
node_file_path(node_id, visit, "diff.patch"),
|
||||
diff.into_bytes(),
|
||||
));
|
||||
}
|
||||
if let Some(script_invocation) = node.script_invocation {
|
||||
entries.push((
|
||||
node_file_path(node_id, visit, "script_invocation.json"),
|
||||
serde_json::to_vec_pretty(&script_invocation)?,
|
||||
));
|
||||
}
|
||||
if let Some(script_timing) = node.script_timing {
|
||||
entries.push((
|
||||
node_file_path(node_id, visit, "script_timing.json"),
|
||||
serde_json::to_vec_pretty(&script_timing)?,
|
||||
));
|
||||
}
|
||||
if let Some(parallel_results) = node.parallel_results {
|
||||
entries.push((
|
||||
node_file_path(node_id, visit, "parallel_results.json"),
|
||||
serde_json::to_vec_pretty(¶llel_results)?,
|
||||
));
|
||||
}
|
||||
}
|
||||
if !init_written && !is_checkpoint && projection.spec.is_some() {
|
||||
latest_init_snapshot = Some(projection.clone());
|
||||
}
|
||||
|
||||
write_entries(&bs, &entries, "checkpoint")?;
|
||||
projection.apply_event(event)?;
|
||||
|
||||
if is_checkpoint {
|
||||
if !init_written {
|
||||
let init_snapshot = latest_init_snapshot.take().unwrap_or_else(|| {
|
||||
let mut snapshot = projection.clone();
|
||||
snapshot.checkpoint = None;
|
||||
snapshot.checkpoints.clear();
|
||||
snapshot
|
||||
});
|
||||
write_projection_snapshot(&bs, &init_snapshot, "init run")?;
|
||||
init_written = true;
|
||||
}
|
||||
checkpoint_snapshots.push((event.seq, projection.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(retro) = state.retro.clone() {
|
||||
let entries = vec![("retro.json".to_string(), serde_json::to_vec_pretty(&retro)?)];
|
||||
write_entries(&bs, &entries, "finalize run")?;
|
||||
if projection.spec.is_none() {
|
||||
bail!("run spec not found for {run_id}");
|
||||
}
|
||||
|
||||
if !init_written {
|
||||
write_projection_snapshot(&bs, &projection, "init run")?;
|
||||
}
|
||||
|
||||
let mut checkpoints: Vec<(u32, Checkpoint)> = checkpoint_snapshots
|
||||
.iter()
|
||||
.map(|(seq, snapshot)| {
|
||||
let checkpoint = snapshot
|
||||
.checkpoint
|
||||
.clone()
|
||||
.expect("checkpoint snapshots must include projection.checkpoint");
|
||||
(*seq, checkpoint)
|
||||
})
|
||||
.collect();
|
||||
backfill_missing_checkpoint_shas(git_store, run_id, &mut checkpoints);
|
||||
|
||||
for ((_, snapshot), (_, checkpoint)) in checkpoint_snapshots.iter_mut().zip(checkpoints) {
|
||||
snapshot.checkpoint = Some(checkpoint);
|
||||
write_projection_snapshot(&bs, snapshot, "checkpoint")?;
|
||||
}
|
||||
|
||||
if projection.conclusion.is_some()
|
||||
|| projection.retro.is_some()
|
||||
|| projection.retro_prompt.is_some()
|
||||
|| projection.retro_response.is_some()
|
||||
{
|
||||
write_projection_snapshot(&bs, &projection, "finalize run")?;
|
||||
}
|
||||
|
||||
Ok::<(), anyhow::Error>(())
|
||||
|
|
@ -177,7 +147,7 @@ pub async fn find_run_id_by_prefix_or_store(
|
|||
fabro_store: &DurableStore,
|
||||
prefix: &str,
|
||||
) -> Result<RunId> {
|
||||
if let Some(run_id) = find_run_id_by_prefix_in_refs(repo, prefix)? {
|
||||
if let Some(run_id) = rewind::find_run_id_by_prefix_opt(repo, prefix)? {
|
||||
return Ok(run_id);
|
||||
}
|
||||
|
||||
|
|
@ -248,6 +218,17 @@ fn write_entries(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn write_projection_snapshot(
|
||||
branch_store: &BranchStore<'_>,
|
||||
projection: &RunProjection,
|
||||
message: &str,
|
||||
) -> Result<()> {
|
||||
let entries = RunDump::from_projection(projection)
|
||||
.git_entries()
|
||||
.context("failed to serialize metadata projection snapshot")?;
|
||||
write_entries(branch_store, &entries, message)
|
||||
}
|
||||
|
||||
fn backfill_missing_checkpoint_shas(
|
||||
git_store: &GitStore,
|
||||
run_id: &RunId,
|
||||
|
|
@ -280,45 +261,6 @@ fn backfill_missing_checkpoint_shas(
|
|||
}
|
||||
}
|
||||
|
||||
fn node_file_path(node_id: &str, visit: u32, filename: &str) -> String {
|
||||
if visit <= 1 {
|
||||
format!("nodes/{node_id}/{filename}")
|
||||
} else {
|
||||
format!("nodes/{node_id}-visit_{visit}/{filename}")
|
||||
}
|
||||
}
|
||||
|
||||
fn find_run_id_by_prefix_in_refs(repo: &Repository, prefix: &str) -> Result<Option<RunId>> {
|
||||
let refs = repo.references()?;
|
||||
let pattern = "refs/heads/fabro/meta/";
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if matches.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
resolve_prefix_matches(prefix, matches).map(Some)
|
||||
}
|
||||
|
||||
fn repo_root_path(repo: &Repository) -> PathBuf {
|
||||
repo.workdir()
|
||||
.or_else(|| repo.path().parent())
|
||||
|
|
@ -367,14 +309,14 @@ mod tests {
|
|||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fabro_graphviz::graph::Graph;
|
||||
use fabro_store::{Database, StageId};
|
||||
use fabro_store::{Database, RunProjection, StageId};
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_types::{RunId, RunRecord, SandboxRecord, StartRecord, fixtures};
|
||||
use fabro_types::{RunId, RunSpec, SandboxRecord, StartRecord, fixtures};
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
use super::*;
|
||||
use crate::event::{Event, append_event};
|
||||
use crate::operations::test_support::{make_checkpoint_json, temp_repo, test_sig};
|
||||
use crate::operations::test_support::{temp_repo, test_sig};
|
||||
use crate::records::Checkpoint;
|
||||
|
||||
fn created_at() -> chrono::DateTime<Utc> {
|
||||
|
|
@ -398,8 +340,8 @@ mod tests {
|
|||
))
|
||||
}
|
||||
|
||||
fn sample_run_record(run_id: RunId, host_repo_path: Option<&str>) -> RunRecord {
|
||||
RunRecord {
|
||||
fn sample_run_spec(run_id: RunId, host_repo_path: Option<&str>) -> RunSpec {
|
||||
RunSpec {
|
||||
run_id,
|
||||
settings: SettingsLayer::default(),
|
||||
graph: Graph::new("test"),
|
||||
|
|
@ -467,22 +409,22 @@ mod tests {
|
|||
host_repo_path: Option<&str>,
|
||||
) -> DurableRunStore {
|
||||
let run_store = store.create_run(&run_id).await.unwrap();
|
||||
let run_record = sample_run_record(run_id, host_repo_path);
|
||||
let run_spec = sample_run_spec(run_id, host_repo_path);
|
||||
append_event(&run_store, &run_id, &Event::RunCreated {
|
||||
run_id,
|
||||
settings: serde_json::to_value(&run_record.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_record.graph).unwrap(),
|
||||
settings: serde_json::to_value(&run_spec.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_spec.graph).unwrap(),
|
||||
workflow_source: None,
|
||||
workflow_config: None,
|
||||
labels: run_record.labels.clone().into_iter().collect(),
|
||||
labels: run_spec.labels.clone().into_iter().collect(),
|
||||
run_dir: String::new(),
|
||||
working_directory: run_record.working_directory.display().to_string(),
|
||||
host_repo_path: run_record.host_repo_path.clone(),
|
||||
repo_origin_url: run_record.repo_origin_url.clone(),
|
||||
base_branch: run_record.base_branch.clone(),
|
||||
workflow_slug: run_record.workflow_slug.clone(),
|
||||
working_directory: run_spec.working_directory.display().to_string(),
|
||||
host_repo_path: run_spec.host_repo_path.clone(),
|
||||
repo_origin_url: run_spec.repo_origin_url.clone(),
|
||||
base_branch: run_spec.base_branch.clone(),
|
||||
workflow_slug: run_spec.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: run_record.provenance.clone(),
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
})
|
||||
.await
|
||||
|
|
@ -693,31 +635,55 @@ mod tests {
|
|||
assert_eq!(checkpoint_commits.len(), 2);
|
||||
assert_eq!(
|
||||
git_store
|
||||
.read_blob_at(checkpoint_commits[0], "nodes/build/prompt.md")
|
||||
.read_blob_at(checkpoint_commits[0], "stages/build@1/prompt.md")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("visit one".as_bytes())
|
||||
);
|
||||
assert!(
|
||||
git_store
|
||||
.read_blob_at(checkpoint_commits[0], "nodes/build-visit_2/prompt.md")
|
||||
let first_projection: RunProjection = serde_json::from_slice(
|
||||
&git_store
|
||||
.read_blob_at(checkpoint_commits[0], "run.json")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
first_projection
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.and_then(|checkpoint| checkpoint.node_visits.get("build"))
|
||||
.copied(),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
git_store
|
||||
.read_blob_at(checkpoint_commits[1], "nodes/build/prompt.md")
|
||||
.read_blob_at(checkpoint_commits[1], "stages/build@1/prompt.md")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("visit one".as_bytes())
|
||||
);
|
||||
assert_eq!(
|
||||
git_store
|
||||
.read_blob_at(checkpoint_commits[1], "nodes/build-visit_2/prompt.md")
|
||||
.read_blob_at(checkpoint_commits[1], "stages/build@2/prompt.md")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("visit two".as_bytes())
|
||||
);
|
||||
let second_projection: RunProjection = serde_json::from_slice(
|
||||
&git_store
|
||||
.read_blob_at(checkpoint_commits[1], "run.json")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
second_projection
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.and_then(|checkpoint| checkpoint.node_visits.get("build"))
|
||||
.copied(),
|
||||
Some(2)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -796,16 +762,40 @@ mod tests {
|
|||
let branch = MetadataStore::branch_name(&test_run_id().to_string());
|
||||
let bs = BranchStore::new(&git_store, &branch, &sig);
|
||||
bs.ensure_branch().unwrap();
|
||||
bs.write_entry("run.json", b"{}", "init run").unwrap();
|
||||
let mut init_projection = RunProjection::default();
|
||||
init_projection.spec = Some(sample_run_spec(test_run_id(), None));
|
||||
init_projection.start = Some(sample_start_record(test_run_id()));
|
||||
bs.write_entry(
|
||||
"checkpoint.json",
|
||||
&make_checkpoint_json("start", 1, Some("aaa")),
|
||||
"run.json",
|
||||
&serde_json::to_vec_pretty(&init_projection).unwrap(),
|
||||
"init run",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut first_checkpoint_projection = init_projection.clone();
|
||||
first_checkpoint_projection.checkpoint = Some(sample_checkpoint(
|
||||
"start",
|
||||
&["start"],
|
||||
&[("start", 1)],
|
||||
Some("aaa"),
|
||||
));
|
||||
bs.write_entry(
|
||||
"run.json",
|
||||
&serde_json::to_vec_pretty(&first_checkpoint_projection).unwrap(),
|
||||
"checkpoint",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut second_checkpoint_projection = init_projection;
|
||||
second_checkpoint_projection.checkpoint = Some(sample_checkpoint(
|
||||
"build",
|
||||
&["start", "build"],
|
||||
&[("start", 1), ("build", 1)],
|
||||
Some("bbb"),
|
||||
));
|
||||
bs.write_entry(
|
||||
"checkpoint.json",
|
||||
&make_checkpoint_json("build", 1, Some("bbb")),
|
||||
"run.json",
|
||||
&serde_json::to_vec_pretty(&second_checkpoint_projection).unwrap(),
|
||||
"checkpoint",
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -830,7 +820,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rebuild_metadata_branch_errors_when_run_record_is_missing() {
|
||||
async fn rebuild_metadata_branch_errors_when_run_spec_is_missing() {
|
||||
let (_dir, git_store) = temp_repo();
|
||||
let durable_store = memory_store();
|
||||
let run_store = durable_store.create_run(&test_run_id()).await.unwrap();
|
||||
|
|
@ -838,7 +828,7 @@ mod tests {
|
|||
let err = rebuild_metadata_branch(&git_store, &run_store, &test_run_id())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("run record not found"));
|
||||
assert!(err.to_string().contains("run spec not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -984,20 +974,22 @@ mod tests {
|
|||
.map(|commit| commit.oid)
|
||||
.collect();
|
||||
|
||||
let first: Checkpoint = serde_json::from_slice(
|
||||
let first_projection: RunProjection = serde_json::from_slice(
|
||||
&git_store
|
||||
.read_blob_at(checkpoint_commits[0], "checkpoint.json")
|
||||
.read_blob_at(checkpoint_commits[0], "run.json")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let second: Checkpoint = serde_json::from_slice(
|
||||
let second_projection: RunProjection = serde_json::from_slice(
|
||||
&git_store
|
||||
.read_blob_at(checkpoint_commits[1], "checkpoint.json")
|
||||
.read_blob_at(checkpoint_commits[1], "run.json")
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let first = first_projection.checkpoint.unwrap();
|
||||
let second = second_projection.checkpoint.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
first.git_commit_sha.as_deref(),
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ pub async fn resume(run_dir: &Path, services: StartServices) -> Result<Started,
|
|||
let checkpoint = state
|
||||
.checkpoint
|
||||
.ok_or_else(|| Error::Precondition("no checkpoint to resume from".to_string()))?;
|
||||
let definition_blob = state.run.as_ref().and_then(|run| run.definition_blob);
|
||||
let definition_blob = state.spec.as_ref().and_then(|run| run.definition_blob);
|
||||
|
||||
cleanup_resume_artifacts(run_dir);
|
||||
append_event_to_sink(
|
||||
|
|
|
|||
|
|
@ -3,16 +3,17 @@ 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::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};
|
||||
use crate::records::{Checkpoint, RunRecord};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RewindTarget {
|
||||
|
|
@ -141,12 +142,15 @@ pub fn build_timeline(store: &Store, run_id: &str) -> Result<RunTimeline> {
|
|||
if !commit.message.starts_with("checkpoint") {
|
||||
continue;
|
||||
}
|
||||
let blob = store
|
||||
.read_blob_at(commit.oid, "checkpoint.json")
|
||||
.map_err(|e| anyhow::anyhow!("failed to read checkpoint blob: {e}"))?;
|
||||
let Some(bytes) = blob else { continue };
|
||||
let cp: Checkpoint = serde_json::from_slice(&bytes)
|
||||
.with_context(|| format!("failed to parse checkpoint at {}", commit.oid))?;
|
||||
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);
|
||||
|
|
@ -316,37 +320,42 @@ fn rewind_to_entry(store: &Store, run_id: &RunId, entry: &TimelineEntry, push: b
|
|||
}
|
||||
|
||||
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 = "refs/heads/fabro/meta/";
|
||||
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;
|
||||
};
|
||||
if let Some(run_id) = name.strip_prefix(pattern) {
|
||||
let Ok(run_id) = run_id.parse::<RunId>() else {
|
||||
continue;
|
||||
};
|
||||
if run_id.to_string() == prefix {
|
||||
return Ok(run_id);
|
||||
}
|
||||
if run_id.to_string().starts_with(prefix) {
|
||||
matches.push(run_id);
|
||||
}
|
||||
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 => bail!("no run found matching '{prefix}'"),
|
||||
1 => Ok(matches
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("exactly one run should match when len is 1")),
|
||||
0 => Ok(None),
|
||||
1 => Ok(matches.into_iter().next()),
|
||||
_ => {
|
||||
let mut msg = format!("ambiguous run ID prefix '{prefix}', matches:\n");
|
||||
for m in &matches {
|
||||
let _ = writeln!(msg, " {m}");
|
||||
for run_id in &matches {
|
||||
let _ = writeln!(msg, " {run_id}");
|
||||
}
|
||||
bail!("{msg}")
|
||||
}
|
||||
|
|
@ -354,34 +363,38 @@ pub fn find_run_id_by_prefix(repo: &Repository, prefix: &str) -> Result<RunId> {
|
|||
}
|
||||
|
||||
fn load_parallel_map(store: &Store, run_id: &str) -> HashMap<String, String> {
|
||||
let branch = MetadataStore::branch_name(run_id);
|
||||
let Ok(sig) = Signature::now("Fabro", "noreply@fabro.sh") else {
|
||||
let Ok(Some(projection)) = MetadataStore::read_run_projection(store.repo_dir(), run_id) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let bs = BranchStore::new(store, &branch, &sig);
|
||||
|
||||
if let Ok(Some(run_bytes)) = bs.read_entry("run.json") {
|
||||
if let Ok(record) = serde_json::from_slice::<RunRecord>(&run_bytes) {
|
||||
return detect_parallel_interior(&record.graph);
|
||||
}
|
||||
if let Some(spec) = projection.spec {
|
||||
return detect_parallel_interior(&spec.graph);
|
||||
}
|
||||
|
||||
let graph_bytes = match bs.read_entry("workflow.fabro") {
|
||||
Ok(Some(bytes)) => bytes,
|
||||
_ => match bs.read_entry("graph.fabro") {
|
||||
Ok(Some(bytes)) => bytes,
|
||||
_ => return HashMap::new(),
|
||||
},
|
||||
let Some(dot_source) = projection.graph_source else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let dot_source = String::from_utf8_lossy(&graph_bytes);
|
||||
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, fixtures};
|
||||
|
||||
use super::super::test_support::*;
|
||||
|
|
@ -391,6 +404,19 @@ mod tests {
|
|||
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!(
|
||||
|
|
@ -416,12 +442,10 @@ mod tests {
|
|||
bs.ensure_branch().unwrap();
|
||||
|
||||
bs.write_entry("run.json", b"{}", "init run").unwrap();
|
||||
let cp1 = make_checkpoint_json("start", 1, Some("aaa"));
|
||||
bs.write_entry("checkpoint.json", &cp1, "checkpoint")
|
||||
.unwrap();
|
||||
let cp2 = make_checkpoint_json("build", 1, Some("bbb"));
|
||||
bs.write_entry("checkpoint.json", &cp2, "checkpoint")
|
||||
.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);
|
||||
|
|
@ -513,13 +537,10 @@ mod tests {
|
|||
bs.ensure_branch().unwrap();
|
||||
|
||||
bs.write_entry("run.json", b"{}", "init run").unwrap();
|
||||
let cp1 = make_checkpoint_json("start", 1, None);
|
||||
let oid1 = bs
|
||||
.write_entry("checkpoint.json", &cp1, "checkpoint")
|
||||
.unwrap();
|
||||
let cp2 = make_checkpoint_json("build", 1, None);
|
||||
bs.write_entry("checkpoint.json", &cp2, "checkpoint")
|
||||
.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,
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ async fn persist_terminal_engine_failure(
|
|||
|
||||
impl RunSession {
|
||||
async fn new(persisted: &Persisted, services: StartServices) -> Result<Self, Error> {
|
||||
let record = persisted.run_record();
|
||||
let record = persisted.run_spec();
|
||||
let settings = &record.settings;
|
||||
let working_directory = record.working_directory.clone();
|
||||
let state = services
|
||||
|
|
@ -292,7 +292,7 @@ impl RunSession {
|
|||
meta_branch: Some(MetadataStore::branch_name(&record.run_id.to_string())),
|
||||
})
|
||||
});
|
||||
let definition_blob = state.run.as_ref().and_then(|run| run.definition_blob);
|
||||
let definition_blob = state.spec.as_ref().and_then(|run| run.definition_blob);
|
||||
let accepted_definition = match definition_blob {
|
||||
Some(blob_id) => {
|
||||
Some(load_accepted_run_definition(&services.run_store, blob_id).await?)
|
||||
|
|
@ -669,7 +669,7 @@ impl RunSession {
|
|||
let preserve_sandbox = self.preserve_sandbox;
|
||||
let on_node = self.on_node.clone();
|
||||
|
||||
let record = persisted.run_record();
|
||||
let record = persisted.run_spec();
|
||||
let run_options = RunOptions {
|
||||
settings: record.settings.clone(),
|
||||
run_dir: persisted.run_dir().to_path_buf(),
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ pub(super) fn test_sig() -> Signature<'static> {
|
|||
Signature::now("Test", "test@example.com").unwrap()
|
||||
}
|
||||
|
||||
pub(super) fn make_checkpoint_json(
|
||||
pub(super) fn make_checkpoint_bytes(
|
||||
current_node: &str,
|
||||
visit: usize,
|
||||
git_sha: Option<&str>,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ use crate::handler::{Handler as HandlerTrait, HandlerRegistry};
|
|||
use crate::outcome::{Outcome, OutcomeExt, StageStatus};
|
||||
use crate::pipeline::initialize;
|
||||
use crate::pipeline::types::{InitOptions, LlmSpec, Persisted, SandboxEnvSpec};
|
||||
use crate::records::RunRecord;
|
||||
use crate::records::RunSpec;
|
||||
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
|
||||
use crate::run_status::{RunStatus, StatusReason};
|
||||
use crate::test_support::run_graph;
|
||||
|
|
@ -135,7 +135,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI
|
|||
source,
|
||||
vec![],
|
||||
run_dir.to_path_buf(),
|
||||
RunRecord {
|
||||
RunSpec {
|
||||
run_id,
|
||||
settings: SettingsLayer::default(),
|
||||
graph,
|
||||
|
|
@ -645,7 +645,7 @@ async fn execute_conditional_routing_uses_unconditional_success_path() {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_writes_start_json_and_node_status() {
|
||||
async fn execute_persists_start_record_and_node_status() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut run_options = test_run_options(dir.path(), "test-run");
|
||||
run_options.git = Some(GitCheckpointOptions {
|
||||
|
|
|
|||
|
|
@ -149,11 +149,10 @@ fn build_conclusion_from_parts(
|
|||
}
|
||||
}
|
||||
|
||||
/// Write a finalize commit to the shadow branch with retro.json and final node
|
||||
/// files.
|
||||
/// Write a finalize projection snapshot commit to the metadata branch.
|
||||
///
|
||||
/// This captures the last diff.patch (written after the final checkpoint) and
|
||||
/// retro.json. Best-effort: errors are logged as warnings.
|
||||
/// This captures the final `run.json` projection state, including conclusion
|
||||
/// and retro data. Best-effort: errors are logged as warnings.
|
||||
pub async fn write_finalize_commit(run_options: &RunOptions, run_store: &RunStoreHandle) {
|
||||
let (Some(meta_branch), Some(repo_path)) = (
|
||||
run_options
|
||||
|
|
@ -170,7 +169,7 @@ pub async fn write_finalize_commit(run_options: &RunOptions, run_store: &RunStor
|
|||
let Ok(store_state) = run_store.state().await else {
|
||||
return;
|
||||
};
|
||||
let dump = RunDump::metadata_finalize(&store_state);
|
||||
let dump = RunDump::from_projection(&store_state);
|
||||
if let Err(e) =
|
||||
dump.write_to_metadata_store(&store, &run_options.run_id.to_string(), "finalize run")
|
||||
{
|
||||
|
|
@ -343,7 +342,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finalize_writes_conclusion_json() {
|
||||
async fn finalize_persists_conclusion_in_projection() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let run_dir = temp.path().join("run");
|
||||
std::fs::create_dir_all(&run_dir).unwrap();
|
||||
|
|
|
|||
|
|
@ -465,7 +465,7 @@ pub async fn initialize(
|
|||
persisted: Persisted,
|
||||
mut options: InitOptions,
|
||||
) -> Result<Initialized, Error> {
|
||||
let (graph, source, _diagnostics, run_dir, _run_record) = persisted.into_parts();
|
||||
let (graph, source, _diagnostics, run_dir, _run_spec) = persisted.into_parts();
|
||||
options.run_options.run_dir = run_dir.clone();
|
||||
options.run_options.git = options.git.clone();
|
||||
|
||||
|
|
@ -770,7 +770,7 @@ mod tests {
|
|||
use super::*;
|
||||
use crate::event::StoreProgressLogger;
|
||||
use crate::pipeline::types::InitOptions;
|
||||
use crate::records::RunRecord;
|
||||
use crate::records::RunSpec;
|
||||
use crate::run_options::RunOptions;
|
||||
|
||||
fn test_run_id() -> RunId {
|
||||
|
|
@ -864,7 +864,7 @@ mod tests {
|
|||
source,
|
||||
vec![],
|
||||
run_dir.to_path_buf(),
|
||||
RunRecord {
|
||||
RunSpec {
|
||||
run_id: test_run_id(),
|
||||
settings: SettingsLayer::default(),
|
||||
graph,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ pub(crate) fn persist(
|
|||
mut options: PersistOptions,
|
||||
) -> Result<Persisted, Error> {
|
||||
let (graph, source, diagnostics) = validated.into_parts();
|
||||
options.run_record.graph = graph.clone();
|
||||
options.run_spec.graph = graph.clone();
|
||||
|
||||
std::fs::create_dir_all(&options.run_dir).map_err(|err| {
|
||||
Error::Io(format!(
|
||||
|
|
@ -25,7 +25,7 @@ pub(crate) fn persist(
|
|||
source,
|
||||
diagnostics,
|
||||
options.run_dir,
|
||||
options.run_record,
|
||||
options.run_spec,
|
||||
))
|
||||
}
|
||||
|
||||
|
|
@ -37,10 +37,10 @@ pub(crate) async fn load_from_store(
|
|||
.state()
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?;
|
||||
let run_record = state
|
||||
.run
|
||||
.ok_or_else(|| Error::Precondition("run record missing from store".to_string()))?;
|
||||
let graph = run_record.graph.clone();
|
||||
let run_spec = state
|
||||
.spec
|
||||
.ok_or_else(|| Error::Precondition("run spec missing from store".to_string()))?;
|
||||
let graph = run_spec.graph.clone();
|
||||
let source = state.graph_source.unwrap_or_default();
|
||||
|
||||
Ok(Persisted::new(
|
||||
|
|
@ -48,7 +48,7 @@ pub(crate) async fn load_from_store(
|
|||
source,
|
||||
Vec::new(),
|
||||
run_dir.to_path_buf(),
|
||||
run_record,
|
||||
run_spec,
|
||||
))
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ mod tests {
|
|||
|
||||
use super::*;
|
||||
use crate::event::{Event, append_event};
|
||||
use crate::records::RunRecord;
|
||||
use crate::records::RunSpec;
|
||||
|
||||
fn memory_store() -> Arc<Database> {
|
||||
Arc::new(Database::new(
|
||||
|
|
@ -125,8 +125,8 @@ mod tests {
|
|||
graph
|
||||
}
|
||||
|
||||
fn sample_record(graph: Graph) -> RunRecord {
|
||||
RunRecord {
|
||||
fn sample_record(graph: Graph) -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: SettingsLayer {
|
||||
run: Some(RunLayer {
|
||||
|
|
@ -161,7 +161,7 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
async fn seeded_store(run_dir: &Path, record: &RunRecord, source: Option<&str>) -> RunDatabase {
|
||||
async fn seeded_store(run_dir: &Path, record: &RunSpec, source: Option<&str>) -> RunDatabase {
|
||||
let store = memory_store();
|
||||
let run_store = store.create_run(&record.run_id).await.unwrap();
|
||||
append_event(&run_store, &record.run_id, &Event::RunCreated {
|
||||
|
|
@ -194,8 +194,8 @@ mod tests {
|
|||
let persisted = persist(
|
||||
Validated::new(graph.clone(), source, vec![]),
|
||||
PersistOptions {
|
||||
run_dir: run_dir.clone(),
|
||||
run_record: sample_record(different_graph()),
|
||||
run_dir: run_dir.clone(),
|
||||
run_spec: sample_record(different_graph()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -207,13 +207,13 @@ mod tests {
|
|||
);
|
||||
assert_eq!(persisted.run_dir(), run_dir.as_path());
|
||||
assert_eq!(
|
||||
serde_json::to_value(persisted.run_record().graph.clone()).unwrap(),
|
||||
serde_json::to_value(persisted.run_spec().graph.clone()).unwrap(),
|
||||
serde_json::to_value(graph).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persist_overwrites_run_record_graph_with_validated_graph() {
|
||||
fn persist_overwrites_run_spec_graph_with_validated_graph() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let run_dir = temp.path().join("run");
|
||||
let (graph, source) = graph_and_source();
|
||||
|
|
@ -221,22 +221,22 @@ mod tests {
|
|||
let persisted = persist(
|
||||
Validated::new(graph.clone(), source, vec![]),
|
||||
PersistOptions {
|
||||
run_dir: run_dir.clone(),
|
||||
run_record: sample_record(different_graph()),
|
||||
run_dir: run_dir.clone(),
|
||||
run_spec: sample_record(different_graph()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(persisted.run_record().graph.name, graph.name);
|
||||
assert!(persisted.run_record().graph.nodes.contains_key("exit"));
|
||||
assert_eq!(persisted.run_spec().graph.name, graph.name);
|
||||
assert!(persisted.run_spec().graph.nodes.contains_key("exit"));
|
||||
assert_eq!(
|
||||
serde_json::to_value(persisted.run_record().graph.clone()).unwrap(),
|
||||
serde_json::to_value(persisted.run_spec().graph.clone()).unwrap(),
|
||||
serde_json::to_value(graph).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_from_store_roundtrips_full_run_record_fields() {
|
||||
async fn load_from_store_roundtrips_full_run_spec_fields() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let run_dir = temp.path().join("run");
|
||||
let (graph, source) = graph_and_source();
|
||||
|
|
@ -246,8 +246,8 @@ mod tests {
|
|||
persist(
|
||||
Validated::new(graph, source.clone(), vec![]),
|
||||
PersistOptions {
|
||||
run_dir: run_dir.clone(),
|
||||
run_record: expected.clone(),
|
||||
run_dir: run_dir.clone(),
|
||||
run_spec: expected.clone(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
|
@ -257,7 +257,7 @@ mod tests {
|
|||
.await
|
||||
.unwrap();
|
||||
|
||||
let loaded_record = loaded.run_record();
|
||||
let loaded_record = loaded.run_spec();
|
||||
assert_eq!(loaded_record.run_id, expected.run_id);
|
||||
assert!(
|
||||
(loaded_record.run_id.created_at().timestamp_millis()
|
||||
|
|
@ -288,7 +288,7 @@ mod tests {
|
|||
|
||||
let err = persist(Validated::new(graph, source, vec![]), PersistOptions {
|
||||
run_dir,
|
||||
run_record: sample_record(different_graph()),
|
||||
run_spec: sample_record(different_graph()),
|
||||
})
|
||||
.unwrap_err();
|
||||
|
||||
|
|
@ -313,7 +313,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_from_store_reads_graph_from_run_record_and_source_from_store() {
|
||||
async fn load_from_store_reads_graph_from_run_spec_and_source_from_store() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let run_dir = temp.path().join("run");
|
||||
std::fs::create_dir_all(&run_dir).unwrap();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use tracing::{debug, info};
|
|||
use super::types::{Concluded, Finalized, PullRequestOptions};
|
||||
use crate::event::{Emitter, Event, RunNoticeLevel};
|
||||
use crate::outcome::{StageStatus, format_cost as outcome_format_cost};
|
||||
use crate::records::{Conclusion, RunRecord};
|
||||
use crate::records::{Conclusion, RunSpec};
|
||||
use crate::runtime_store::RunStoreHandle;
|
||||
|
||||
/// Derive a PR title from the workflow goal.
|
||||
|
|
@ -107,7 +107,7 @@ fn format_retro_section(retro: &Retro) -> String {
|
|||
/// optionally a workflow graph summary in another `<details>` block.
|
||||
fn format_arc_details_section(
|
||||
conclusion: &Conclusion,
|
||||
run_record: Option<&RunRecord>,
|
||||
run_spec: Option<&RunSpec>,
|
||||
dot_source: Option<&str>,
|
||||
) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
|
@ -143,8 +143,8 @@ fn format_arc_details_section(
|
|||
parts.push(String::new());
|
||||
parts.push("</details>".to_string());
|
||||
|
||||
// Workflow graph summary — prefer RunRecord's graph, fall back to DOT parsing
|
||||
if let Some(record) = run_record {
|
||||
// Workflow graph summary — prefer RunSpec's graph, fall back to DOT parsing
|
||||
if let Some(record) = run_spec {
|
||||
let workflow_name = if record.graph.name.is_empty() {
|
||||
"unnamed"
|
||||
} else {
|
||||
|
|
@ -328,7 +328,7 @@ pub async fn build_pr_body(
|
|||
.ok();
|
||||
let plan_text = run_state.as_ref().and_then(read_plan_text);
|
||||
let retro = run_state.as_ref().and_then(|state| state.retro.clone());
|
||||
let run_record = run_state.as_ref().and_then(|state| state.run.clone());
|
||||
let run_spec = run_state.as_ref().and_then(|state| state.spec.clone());
|
||||
let dot_source = run_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.graph_source.clone());
|
||||
|
|
@ -374,7 +374,7 @@ pub async fn build_pr_body(
|
|||
let retro_section = retro.as_ref().map(format_retro_section).unwrap_or_default();
|
||||
let arc_details_section = conclusion
|
||||
.as_ref()
|
||||
.map(|c| format_arc_details_section(c, run_record.as_ref(), dot_source.as_deref()))
|
||||
.map(|c| format_arc_details_section(c, run_spec.as_ref(), dot_source.as_deref()))
|
||||
.unwrap_or_default();
|
||||
|
||||
let body = assemble_pr_body(
|
||||
|
|
@ -597,7 +597,7 @@ mod tests {
|
|||
};
|
||||
use fabro_store::Database;
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_types::{BilledTokenCounts, RunRecord, fixtures};
|
||||
use fabro_types::{BilledTokenCounts, RunSpec, fixtures};
|
||||
use futures::stream;
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
|
|
@ -1086,7 +1086,7 @@ mod tests {
|
|||
let store = test_store();
|
||||
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
|
||||
|
||||
let run_record = RunRecord {
|
||||
let run_spec = RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: SettingsLayer::default(),
|
||||
graph: Graph::new("test"),
|
||||
|
|
@ -1102,19 +1102,19 @@ mod tests {
|
|||
};
|
||||
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: serde_json::to_value(&run_record.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_record.graph).unwrap(),
|
||||
settings: serde_json::to_value(&run_spec.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_spec.graph).unwrap(),
|
||||
workflow_source: Some("digraph test { plan -> code }".to_string()),
|
||||
workflow_config: None,
|
||||
labels: run_record.labels.clone().into_iter().collect(),
|
||||
run_dir: run_record.working_directory.display().to_string(),
|
||||
working_directory: run_record.working_directory.display().to_string(),
|
||||
host_repo_path: run_record.host_repo_path.clone(),
|
||||
repo_origin_url: run_record.repo_origin_url.clone(),
|
||||
base_branch: run_record.base_branch.clone(),
|
||||
workflow_slug: run_record.workflow_slug.clone(),
|
||||
labels: run_spec.labels.clone().into_iter().collect(),
|
||||
run_dir: run_spec.working_directory.display().to_string(),
|
||||
working_directory: run_spec.working_directory.display().to_string(),
|
||||
host_repo_path: run_spec.host_repo_path.clone(),
|
||||
repo_origin_url: run_spec.repo_origin_url.clone(),
|
||||
base_branch: run_spec.base_branch.clone(),
|
||||
workflow_slug: run_spec.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: run_record.provenance.clone(),
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
})
|
||||
.await
|
||||
|
|
@ -1151,7 +1151,7 @@ mod tests {
|
|||
let store = test_store();
|
||||
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
|
||||
|
||||
let run_record = RunRecord {
|
||||
let run_spec = RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: SettingsLayer::default(),
|
||||
graph: Graph::new("test"),
|
||||
|
|
@ -1167,19 +1167,19 @@ mod tests {
|
|||
};
|
||||
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: serde_json::to_value(&run_record.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_record.graph).unwrap(),
|
||||
settings: serde_json::to_value(&run_spec.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_spec.graph).unwrap(),
|
||||
workflow_source: Some("digraph test { plan -> code }".to_string()),
|
||||
workflow_config: None,
|
||||
labels: run_record.labels.clone().into_iter().collect(),
|
||||
run_dir: run_record.working_directory.display().to_string(),
|
||||
working_directory: run_record.working_directory.display().to_string(),
|
||||
host_repo_path: run_record.host_repo_path.clone(),
|
||||
repo_origin_url: run_record.repo_origin_url.clone(),
|
||||
base_branch: run_record.base_branch.clone(),
|
||||
workflow_slug: run_record.workflow_slug.clone(),
|
||||
labels: run_spec.labels.clone().into_iter().collect(),
|
||||
run_dir: run_spec.working_directory.display().to_string(),
|
||||
working_directory: run_spec.working_directory.display().to_string(),
|
||||
host_repo_path: run_spec.host_repo_path.clone(),
|
||||
repo_origin_url: run_spec.repo_origin_url.clone(),
|
||||
base_branch: run_spec.base_branch.clone(),
|
||||
workflow_slug: run_spec.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: run_record.provenance.clone(),
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
})
|
||||
.await
|
||||
|
|
@ -1369,7 +1369,7 @@ mod tests {
|
|||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store = test_store();
|
||||
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
|
||||
let run_record = RunRecord {
|
||||
let run_spec = RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: SettingsLayer::default(),
|
||||
graph: Graph::new("test"),
|
||||
|
|
@ -1385,19 +1385,19 @@ mod tests {
|
|||
};
|
||||
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: serde_json::to_value(&run_record.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_record.graph).unwrap(),
|
||||
settings: serde_json::to_value(&run_spec.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_spec.graph).unwrap(),
|
||||
workflow_source: None,
|
||||
workflow_config: None,
|
||||
labels: run_record.labels.clone().into_iter().collect(),
|
||||
run_dir: run_record.working_directory.display().to_string(),
|
||||
labels: run_spec.labels.clone().into_iter().collect(),
|
||||
run_dir: run_spec.working_directory.display().to_string(),
|
||||
working_directory: tmp.path().display().to_string(),
|
||||
host_repo_path: None,
|
||||
repo_origin_url: run_record.repo_origin_url.clone(),
|
||||
repo_origin_url: run_spec.repo_origin_url.clone(),
|
||||
base_branch: None,
|
||||
workflow_slug: None,
|
||||
db_prefix: None,
|
||||
provenance: run_record.provenance.clone(),
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ mod tests {
|
|||
use crate::context::Context;
|
||||
use crate::event::{Emitter, Event, StoreProgressLogger, append_event};
|
||||
use crate::pipeline::types::Executed;
|
||||
use crate::records::{Checkpoint, CheckpointExt, RunRecord};
|
||||
use crate::records::{Checkpoint, CheckpointExt, RunSpec};
|
||||
use crate::run_options::RunOptions;
|
||||
|
||||
fn test_run_id() -> RunId {
|
||||
|
|
@ -232,7 +232,7 @@ mod tests {
|
|||
) -> fabro_store::RunDatabase {
|
||||
let inner = test_store().create_run(&test_run_id()).await.unwrap();
|
||||
let run_store = inner;
|
||||
let run_record = RunRecord {
|
||||
let run_spec = RunSpec {
|
||||
run_id: test_run_id(),
|
||||
settings: SettingsLayer::default(),
|
||||
graph: Graph::new("test"),
|
||||
|
|
@ -248,19 +248,19 @@ mod tests {
|
|||
};
|
||||
append_event(&run_store, &test_run_id(), &Event::RunCreated {
|
||||
run_id: test_run_id(),
|
||||
settings: serde_json::to_value(&run_record.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_record.graph).unwrap(),
|
||||
settings: serde_json::to_value(&run_spec.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_spec.graph).unwrap(),
|
||||
workflow_source: None,
|
||||
workflow_config: None,
|
||||
labels: run_record.labels.clone().into_iter().collect(),
|
||||
labels: run_spec.labels.clone().into_iter().collect(),
|
||||
run_dir: run_dir.to_string_lossy().to_string(),
|
||||
working_directory: run_dir.to_string_lossy().to_string(),
|
||||
host_repo_path: None,
|
||||
repo_origin_url: run_record.repo_origin_url.clone(),
|
||||
repo_origin_url: run_spec.repo_origin_url.clone(),
|
||||
base_branch: None,
|
||||
workflow_slug: None,
|
||||
db_prefix: None,
|
||||
provenance: run_record.provenance.clone(),
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
})
|
||||
.await
|
||||
|
|
@ -312,7 +312,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retro_phase_writes_retro_json() {
|
||||
async fn retro_phase_persists_retro_in_projection() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let run_dir = temp.path().join("run");
|
||||
std::fs::create_dir_all(&run_dir).unwrap();
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ use crate::event::Emitter;
|
|||
use crate::file_resolver::FileResolver;
|
||||
use crate::handler::HandlerRegistry;
|
||||
use crate::outcome::Outcome;
|
||||
use crate::records::{Checkpoint, Conclusion, RunRecord};
|
||||
use crate::records::{Checkpoint, Conclusion, RunSpec};
|
||||
use crate::run_control::RunControlState;
|
||||
use crate::run_options::{GitCheckpointOptions, LifecycleOptions, RunOptions};
|
||||
use crate::runtime_store::RunStoreHandle;
|
||||
|
|
@ -113,12 +113,12 @@ impl Validated {
|
|||
|
||||
/// Options for the PERSIST phase.
|
||||
pub(crate) struct PersistOptions {
|
||||
pub run_dir: PathBuf,
|
||||
pub run_record: RunRecord,
|
||||
pub run_dir: PathBuf,
|
||||
pub run_spec: RunSpec,
|
||||
}
|
||||
|
||||
/// Output of the PERSIST phase. Run directory created and the validated
|
||||
/// workflow is persisted into the durable run record.
|
||||
/// workflow is persisted into the durable run spec.
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub struct Persisted {
|
||||
|
|
@ -126,7 +126,7 @@ pub struct Persisted {
|
|||
source: String,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
run_dir: PathBuf,
|
||||
run_record: RunRecord,
|
||||
run_spec: RunSpec,
|
||||
}
|
||||
|
||||
impl Persisted {
|
||||
|
|
@ -136,14 +136,14 @@ impl Persisted {
|
|||
source: String,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
run_dir: PathBuf,
|
||||
run_record: RunRecord,
|
||||
run_spec: RunSpec,
|
||||
) -> Self {
|
||||
Self {
|
||||
graph,
|
||||
source,
|
||||
diagnostics,
|
||||
run_dir,
|
||||
run_record,
|
||||
run_spec,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,8 +163,8 @@ impl Persisted {
|
|||
&self.run_dir
|
||||
}
|
||||
|
||||
pub fn run_record(&self) -> &RunRecord {
|
||||
&self.run_record
|
||||
pub fn run_spec(&self) -> &RunSpec {
|
||||
&self.run_spec
|
||||
}
|
||||
|
||||
/// True if any diagnostic has Error severity.
|
||||
|
|
@ -191,14 +191,14 @@ impl Persisted {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Consume into owned graph, source, diagnostics, run dir, and run record.
|
||||
pub fn into_parts(self) -> (Graph, String, Vec<Diagnostic>, PathBuf, RunRecord) {
|
||||
/// Consume into owned graph, source, diagnostics, run dir, and run spec.
|
||||
pub fn into_parts(self) -> (Graph, String, Vec<Diagnostic>, PathBuf, RunSpec) {
|
||||
(
|
||||
self.graph,
|
||||
self.source,
|
||||
self.diagnostics,
|
||||
self.run_dir,
|
||||
self.run_record,
|
||||
self.run_spec,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@ mod start;
|
|||
|
||||
pub use checkpoint::{Checkpoint, CheckpointExt};
|
||||
pub use conclusion::{Conclusion, StageSummary};
|
||||
pub use run::RunRecord;
|
||||
pub use run::RunSpec;
|
||||
pub use start::StartRecord;
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
pub use fabro_types::run::RunRecord;
|
||||
pub use fabro_types::run::RunSpec;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use std::path::{Component, Path, PathBuf};
|
|||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use bytes::Bytes;
|
||||
use fabro_store::{EventEnvelope, RunProjection, StageId};
|
||||
use fabro_store::{EventEnvelope, RunProjection, SerializableProjection, StageId};
|
||||
use fabro_types::{RunBlobId, parse_blob_ref, parse_legacy_blob_file_ref};
|
||||
use futures::future::BoxFuture;
|
||||
|
||||
|
|
@ -39,137 +39,26 @@ pub enum RunDumpContents {
|
|||
|
||||
impl RunDump {
|
||||
#[must_use]
|
||||
pub fn metadata_init(state: &RunProjection) -> Self {
|
||||
let mut entries = Vec::new();
|
||||
if let Some(record) = state.run.as_ref() {
|
||||
push_json_entry(&mut entries, "run.json", record);
|
||||
}
|
||||
if let Some(record) = state.start.as_ref() {
|
||||
push_json_entry(&mut entries, "start.json", record);
|
||||
}
|
||||
if let Some(record) = state.sandbox.as_ref() {
|
||||
push_json_entry(&mut entries, "sandbox.json", record);
|
||||
}
|
||||
Self { entries }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn metadata_checkpoint(state: &RunProjection) -> Self {
|
||||
let mut entries = Vec::new();
|
||||
let mut keys: Vec<_> = state.iter_nodes().map(|(node, _)| node.clone()).collect();
|
||||
keys.sort();
|
||||
|
||||
for node_key in keys {
|
||||
let Some(node) = state.node(&node_key) else {
|
||||
continue;
|
||||
};
|
||||
let node_id = node_key.node_id();
|
||||
let visit = node_key.visit();
|
||||
|
||||
if let Some(prompt) = node.prompt.as_ref() {
|
||||
entries.push(RunDumpEntry::text(
|
||||
metadata_node_file_path(node_id, visit, "prompt.md"),
|
||||
prompt.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(response) = node.response.as_ref() {
|
||||
entries.push(RunDumpEntry::text(
|
||||
metadata_node_file_path(node_id, visit, "response.md"),
|
||||
response.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(status) = node.status.as_ref() {
|
||||
push_json_entry_path(
|
||||
&mut entries,
|
||||
&PathBuf::from(metadata_node_file_path(node_id, visit, "status.json")),
|
||||
status,
|
||||
);
|
||||
}
|
||||
if let Some(provider_used) = node.provider_used.as_ref() {
|
||||
entries.push(RunDumpEntry::json(
|
||||
metadata_node_file_path(node_id, visit, "provider_used.json"),
|
||||
provider_used.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(diff) = node.diff.as_ref() {
|
||||
entries.push(RunDumpEntry::text(
|
||||
metadata_node_file_path(node_id, visit, "diff.patch"),
|
||||
diff.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(script_invocation) = node.script_invocation.as_ref() {
|
||||
entries.push(RunDumpEntry::json(
|
||||
metadata_node_file_path(node_id, visit, "script_invocation.json"),
|
||||
script_invocation.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(script_timing) = node.script_timing.as_ref() {
|
||||
entries.push(RunDumpEntry::json(
|
||||
metadata_node_file_path(node_id, visit, "script_timing.json"),
|
||||
script_timing.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(parallel_results) = node.parallel_results.as_ref() {
|
||||
entries.push(RunDumpEntry::json(
|
||||
metadata_node_file_path(node_id, visit, "parallel_results.json"),
|
||||
parallel_results.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Self { entries }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn metadata_finalize(state: &RunProjection) -> Self {
|
||||
let mut dump = Self::metadata_checkpoint(state);
|
||||
if let Some(retro) = state.retro.as_ref() {
|
||||
push_json_entry(&mut dump.entries, "retro.json", retro);
|
||||
}
|
||||
dump
|
||||
}
|
||||
|
||||
pub fn from_store_state_and_events(
|
||||
state: &RunProjection,
|
||||
events: &[EventEnvelope],
|
||||
) -> Result<Self> {
|
||||
pub fn from_projection(state: &RunProjection) -> Self {
|
||||
let mut entries = Vec::new();
|
||||
|
||||
if let Some(record) = state.run.as_ref() {
|
||||
push_json_entry(&mut entries, "run.json", record);
|
||||
}
|
||||
if let Some(record) = state.start.as_ref() {
|
||||
push_json_entry(&mut entries, "start.json", record);
|
||||
}
|
||||
if let Some(record) = state.status.as_ref() {
|
||||
push_json_entry(&mut entries, "status.json", record);
|
||||
}
|
||||
if let Some(record) = state.checkpoint.as_ref() {
|
||||
push_json_entry(&mut entries, "checkpoint.json", record);
|
||||
}
|
||||
if let Some(record) = state.conclusion.as_ref() {
|
||||
push_json_entry(&mut entries, "conclusion.json", record);
|
||||
}
|
||||
if let Some(record) = state.retro.as_ref() {
|
||||
push_json_entry(&mut entries, "retro.json", record);
|
||||
}
|
||||
push_json_entry(&mut entries, "run.json", &SerializableProjection(state));
|
||||
|
||||
if let Some(graph_source) = state.graph_source.as_ref() {
|
||||
entries.push(RunDumpEntry::text("graph.fabro", graph_source.clone()));
|
||||
}
|
||||
if let Some(record) = state.sandbox.as_ref() {
|
||||
push_json_entry(&mut entries, "sandbox.json", record);
|
||||
}
|
||||
|
||||
let mut node_keys: Vec<_> = state.iter_nodes().map(|(node, _)| node.clone()).collect();
|
||||
node_keys.sort();
|
||||
for node_key in &node_keys {
|
||||
let node = state
|
||||
.node(node_key)
|
||||
.with_context(|| format!("missing node {node_key:?} in projection"))?;
|
||||
let node_id_segment = validate_single_path_segment("node id", node_key.node_id())?;
|
||||
let base = PathBuf::from("nodes")
|
||||
.join(node_id_segment)
|
||||
.join(format!("visit-{}", node_key.visit()));
|
||||
let mut stage_ids: Vec<_> = state
|
||||
.iter_nodes()
|
||||
.map(|(stage_id, _)| stage_id.clone())
|
||||
.collect();
|
||||
stage_ids.sort();
|
||||
|
||||
for stage_id in stage_ids {
|
||||
let Some(node) = state.node(&stage_id) else {
|
||||
continue;
|
||||
};
|
||||
let base = PathBuf::from("stages").join(stage_id.to_string());
|
||||
|
||||
if let Some(prompt) = node.prompt.as_ref() {
|
||||
entries.push(RunDumpEntry::text_path(
|
||||
|
|
@ -186,6 +75,36 @@ impl RunDump {
|
|||
if let Some(status) = node.status.as_ref() {
|
||||
push_json_entry_path(&mut entries, &base.join("status.json"), status);
|
||||
}
|
||||
if let Some(provider_used) = node.provider_used.as_ref() {
|
||||
entries.push(RunDumpEntry::json_path(
|
||||
&base.join("provider_used.json"),
|
||||
provider_used.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(diff) = node.diff.as_ref() {
|
||||
entries.push(RunDumpEntry::text_path(
|
||||
&base.join("diff.patch"),
|
||||
diff.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(script_invocation) = node.script_invocation.as_ref() {
|
||||
entries.push(RunDumpEntry::json_path(
|
||||
&base.join("script_invocation.json"),
|
||||
script_invocation.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(script_timing) = node.script_timing.as_ref() {
|
||||
entries.push(RunDumpEntry::json_path(
|
||||
&base.join("script_timing.json"),
|
||||
script_timing.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(parallel_results) = node.parallel_results.as_ref() {
|
||||
entries.push(RunDumpEntry::json_path(
|
||||
&base.join("parallel_results.json"),
|
||||
parallel_results.clone(),
|
||||
));
|
||||
}
|
||||
if let Some(stdout) = node.stdout.as_ref() {
|
||||
entries.push(RunDumpEntry::text_path(
|
||||
&base.join("stdout.log"),
|
||||
|
|
@ -207,22 +126,32 @@ impl RunDump {
|
|||
entries.push(RunDumpEntry::text("retro/response.md", response.clone()));
|
||||
}
|
||||
|
||||
Self { entries }
|
||||
}
|
||||
|
||||
pub fn from_store_state_and_events(
|
||||
state: &RunProjection,
|
||||
events: &[EventEnvelope],
|
||||
) -> Result<Self> {
|
||||
let mut dump = Self::from_projection(state);
|
||||
|
||||
let mut events_jsonl = Vec::new();
|
||||
for event in events {
|
||||
serde_json::to_writer(&mut events_jsonl, event)?;
|
||||
events_jsonl.write_all(b"\n")?;
|
||||
}
|
||||
entries.push(RunDumpEntry::bytes("events.jsonl", events_jsonl));
|
||||
dump.entries
|
||||
.push(RunDumpEntry::bytes("events.jsonl", events_jsonl));
|
||||
|
||||
for (seq, checkpoint) in &state.checkpoints {
|
||||
push_json_entry_path(
|
||||
&mut entries,
|
||||
&mut dump.entries,
|
||||
&PathBuf::from("checkpoints").join(format!("{seq:04}.json")),
|
||||
checkpoint,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Self { entries })
|
||||
Ok(dump)
|
||||
}
|
||||
|
||||
pub fn add_artifact_bytes(
|
||||
|
|
@ -292,7 +221,7 @@ impl RunDump {
|
|||
.iter()
|
||||
.map(|(path, bytes)| (path.as_str(), bytes.as_slice()))
|
||||
.collect();
|
||||
store.write_files(run_id, &refs, message)?;
|
||||
store.write_snapshot(run_id, &refs, message)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -385,14 +314,6 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
fn metadata_node_file_path(node_id: &str, visit: u32, filename: &str) -> String {
|
||||
if visit <= 1 {
|
||||
format!("nodes/{node_id}/{filename}")
|
||||
} else {
|
||||
format!("nodes/{node_id}-visit_{visit}/{filename}")
|
||||
}
|
||||
}
|
||||
|
||||
fn path_to_string(path: &Path) -> String {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
|
@ -495,3 +416,174 @@ fn ensure_parent_dir(path: &Path) -> Result<()> {
|
|||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use fabro_store::{NodeState, RunProjection, StageId};
|
||||
use fabro_types::graph::Graph;
|
||||
use fabro_types::run::RunSpec;
|
||||
use fabro_types::settings::SettingsLayer;
|
||||
use fabro_types::{
|
||||
Checkpoint, Conclusion, NodeStatusRecord, RunStatus, RunStatusRecord, SandboxRecord,
|
||||
StageStatus, StartRecord, fixtures,
|
||||
};
|
||||
|
||||
use super::RunDump;
|
||||
use crate::run_dump::RunDumpContents;
|
||||
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: SettingsLayer::default(),
|
||||
graph: Graph::new("ship"),
|
||||
workflow_slug: Some("demo".to_string()),
|
||||
working_directory: PathBuf::from("/tmp/project"),
|
||||
host_repo_path: Some("/tmp/project".to_string()),
|
||||
repo_origin_url: Some("https://github.com/fabro-sh/fabro.git".to_string()),
|
||||
base_branch: Some("main".to_string()),
|
||||
labels: HashMap::from([("team".to_string(), "platform".to_string())]),
|
||||
provenance: None,
|
||||
manifest_blob: None,
|
||||
definition_blob: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_checkpoint() -> Checkpoint {
|
||||
Checkpoint {
|
||||
timestamp: Utc
|
||||
.with_ymd_and_hms(2026, 4, 20, 12, 0, 0)
|
||||
.single()
|
||||
.unwrap(),
|
||||
current_node: "build".to_string(),
|
||||
completed_nodes: vec!["build".to_string()],
|
||||
node_retries: HashMap::new(),
|
||||
context_values: HashMap::new(),
|
||||
node_outcomes: HashMap::new(),
|
||||
next_node_id: Some("ship".to_string()),
|
||||
git_commit_sha: Some("abc123".to_string()),
|
||||
loop_failure_signatures: HashMap::new(),
|
||||
restart_failure_signatures: HashMap::new(),
|
||||
node_visits: HashMap::from([("build".to_string(), 2usize)]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_projection_uses_stages_layout_and_collapses_top_level_metadata_files() {
|
||||
let stage_id = StageId::new("build", 2);
|
||||
let mut projection = RunProjection::default();
|
||||
projection.spec = Some(sample_run_spec());
|
||||
projection.graph_source = Some("digraph Ship {}".to_string());
|
||||
projection.start = Some(StartRecord {
|
||||
run_id: fixtures::RUN_1,
|
||||
start_time: Utc
|
||||
.with_ymd_and_hms(2026, 4, 20, 12, 0, 0)
|
||||
.single()
|
||||
.unwrap(),
|
||||
run_branch: Some("fabro/run/demo".to_string()),
|
||||
base_sha: Some("deadbeef".to_string()),
|
||||
});
|
||||
projection.status = Some(RunStatusRecord::new(RunStatus::Succeeded, None));
|
||||
projection.checkpoint = Some(sample_checkpoint());
|
||||
projection.conclusion = Some(Conclusion {
|
||||
timestamp: Utc
|
||||
.with_ymd_and_hms(2026, 4, 20, 12, 5, 0)
|
||||
.single()
|
||||
.unwrap(),
|
||||
status: StageStatus::Success,
|
||||
duration_ms: 5,
|
||||
failure_reason: None,
|
||||
final_git_commit_sha: Some("abc123".to_string()),
|
||||
stages: Vec::new(),
|
||||
billing: None,
|
||||
total_retries: 0,
|
||||
});
|
||||
projection.sandbox = Some(SandboxRecord {
|
||||
provider: "local".to_string(),
|
||||
working_directory: "/tmp/project".to_string(),
|
||||
identifier: Some("sandbox-1".to_string()),
|
||||
host_working_directory: None,
|
||||
container_mount_point: None,
|
||||
});
|
||||
projection.retro_prompt = Some("retro prompt".to_string());
|
||||
projection.retro_response = Some("retro response".to_string());
|
||||
projection.set_node(stage_id.clone(), NodeState {
|
||||
prompt: Some("plan".to_string()),
|
||||
response: Some("done".to_string()),
|
||||
status: Some(NodeStatusRecord {
|
||||
status: StageStatus::Success,
|
||||
notes: Some("ok".to_string()),
|
||||
failure_reason: None,
|
||||
timestamp: Utc
|
||||
.with_ymd_and_hms(2026, 4, 20, 12, 1, 0)
|
||||
.single()
|
||||
.unwrap(),
|
||||
}),
|
||||
provider_used: Some(serde_json::json!({ "provider": "openai" })),
|
||||
diff: Some("diff --git a/a b/a".to_string()),
|
||||
script_invocation: Some(serde_json::json!({ "command": "cargo test" })),
|
||||
script_timing: Some(serde_json::json!({ "duration_ms": 10 })),
|
||||
parallel_results: Some(serde_json::json!([{ "stage": "fanout@1" }])),
|
||||
stdout: Some("stdout".to_string()),
|
||||
stderr: Some("stderr".to_string()),
|
||||
});
|
||||
|
||||
let dump = RunDump::from_projection(&projection);
|
||||
let paths: Vec<&str> = dump
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|entry| entry.path.as_str())
|
||||
.collect();
|
||||
|
||||
assert!(paths.contains(&"run.json"));
|
||||
assert!(paths.contains(&"graph.fabro"));
|
||||
assert!(paths.contains(&"retro/prompt.md"));
|
||||
assert!(paths.contains(&"retro/response.md"));
|
||||
assert!(paths.contains(&"stages/build@2/prompt.md"));
|
||||
assert!(paths.contains(&"stages/build@2/response.md"));
|
||||
assert!(paths.contains(&"stages/build@2/status.json"));
|
||||
assert!(paths.contains(&"stages/build@2/provider_used.json"));
|
||||
assert!(paths.contains(&"stages/build@2/diff.patch"));
|
||||
assert!(paths.contains(&"stages/build@2/script_invocation.json"));
|
||||
assert!(paths.contains(&"stages/build@2/script_timing.json"));
|
||||
assert!(paths.contains(&"stages/build@2/parallel_results.json"));
|
||||
assert!(paths.contains(&"stages/build@2/stdout.log"));
|
||||
assert!(paths.contains(&"stages/build@2/stderr.log"));
|
||||
assert!(!paths.contains(&"start.json"));
|
||||
assert!(!paths.contains(&"status.json"));
|
||||
assert!(!paths.contains(&"checkpoint.json"));
|
||||
assert!(!paths.contains(&"sandbox.json"));
|
||||
assert!(!paths.contains(&"retro.json"));
|
||||
assert!(!paths.contains(&"conclusion.json"));
|
||||
|
||||
let run_json = dump
|
||||
.entries()
|
||||
.iter()
|
||||
.find(|entry| entry.path == "run.json")
|
||||
.expect("run.json should be emitted");
|
||||
let RunDumpContents::Json(value) = &run_json.contents else {
|
||||
panic!("run.json should be json");
|
||||
};
|
||||
let round_tripped: RunProjection = serde_json::from_value(value.clone()).unwrap();
|
||||
let node = round_tripped.node(&stage_id).expect("node should exist");
|
||||
|
||||
assert!(round_tripped.spec.is_some());
|
||||
assert!(round_tripped.start.is_some());
|
||||
assert!(round_tripped.status.is_some());
|
||||
assert!(round_tripped.checkpoint.is_some());
|
||||
assert!(round_tripped.conclusion.is_some());
|
||||
assert!(round_tripped.sandbox.is_some());
|
||||
assert_eq!(node.prompt, None);
|
||||
assert_eq!(node.response, None);
|
||||
assert_eq!(node.diff, None);
|
||||
assert_eq!(node.stdout, None);
|
||||
assert_eq!(node.stderr, None);
|
||||
assert_eq!(
|
||||
node.provider_used,
|
||||
Some(serde_json::json!({ "provider": "openai" }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ impl RunInfo {
|
|||
self.summary
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.workflow_name.clone())
|
||||
.unwrap_or_else(|| "[no run record]".to_string())
|
||||
.unwrap_or_else(|| "[no run spec]".to_string())
|
||||
}
|
||||
|
||||
pub fn workflow_slug(&self) -> Option<&str> {
|
||||
|
|
@ -407,7 +407,7 @@ mod tests {
|
|||
use super::scan_runs_combined;
|
||||
use crate::event::{Event, append_event};
|
||||
use crate::operations::make_run_dir;
|
||||
use crate::records::RunRecord;
|
||||
use crate::records::RunSpec;
|
||||
|
||||
fn memory_store() -> Arc<Database> {
|
||||
Arc::new(Database::new(
|
||||
|
|
@ -418,8 +418,8 @@ mod tests {
|
|||
))
|
||||
}
|
||||
|
||||
fn sample_run_record() -> RunRecord {
|
||||
RunRecord {
|
||||
fn sample_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: SettingsLayer::default(),
|
||||
graph: Graph::new("test"),
|
||||
|
|
@ -442,23 +442,23 @@ mod tests {
|
|||
std::fs::create_dir_all(&run_dir).unwrap();
|
||||
|
||||
let store = memory_store();
|
||||
let run_record = sample_run_record();
|
||||
let run_spec = sample_run_spec();
|
||||
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
|
||||
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: serde_json::to_value(&run_record.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_record.graph).unwrap(),
|
||||
settings: serde_json::to_value(&run_spec.settings).unwrap(),
|
||||
graph: serde_json::to_value(&run_spec.graph).unwrap(),
|
||||
workflow_source: None,
|
||||
workflow_config: None,
|
||||
labels: run_record.labels.clone().into_iter().collect(),
|
||||
labels: run_spec.labels.clone().into_iter().collect(),
|
||||
run_dir: run_dir.display().to_string(),
|
||||
working_directory: run_record.working_directory.display().to_string(),
|
||||
host_repo_path: run_record.host_repo_path.clone(),
|
||||
repo_origin_url: run_record.repo_origin_url.clone(),
|
||||
base_branch: run_record.base_branch.clone(),
|
||||
workflow_slug: run_record.workflow_slug.clone(),
|
||||
working_directory: run_spec.working_directory.display().to_string(),
|
||||
host_repo_path: run_spec.host_repo_path.clone(),
|
||||
repo_origin_url: run_spec.repo_origin_url.clone(),
|
||||
base_branch: run_spec.base_branch.clone(),
|
||||
workflow_slug: run_spec.workflow_slug.clone(),
|
||||
db_prefix: None,
|
||||
provenance: run_record.provenance.clone(),
|
||||
provenance: run_spec.provenance.clone(),
|
||||
manifest_blob: None,
|
||||
})
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ mod tests {
|
|||
|
||||
use super::RunStoreHandle;
|
||||
use crate::event::{Event, append_event};
|
||||
use crate::records::RunRecord;
|
||||
use crate::records::RunSpec;
|
||||
|
||||
async fn test_run_store() -> fabro_store::RunDatabase {
|
||||
let store = Arc::new(Database::new(
|
||||
|
|
@ -130,8 +130,8 @@ mod tests {
|
|||
store.create_run(&fixtures::RUN_1).await.unwrap()
|
||||
}
|
||||
|
||||
fn test_run_record() -> RunRecord {
|
||||
RunRecord {
|
||||
fn test_run_spec() -> RunSpec {
|
||||
RunSpec {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: SettingsLayer::default(),
|
||||
graph: Graph::new("test"),
|
||||
|
|
@ -150,7 +150,7 @@ mod tests {
|
|||
#[tokio::test]
|
||||
async fn local_handle_loads_state_and_events() {
|
||||
let run_store = test_run_store().await;
|
||||
let record = test_run_record();
|
||||
let record = test_run_spec();
|
||||
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
|
||||
run_id: fixtures::RUN_1,
|
||||
settings: serde_json::to_value(&record.settings).unwrap(),
|
||||
|
|
@ -175,7 +175,7 @@ mod tests {
|
|||
let state = handle.state().await.unwrap();
|
||||
let events = handle.list_events().await.unwrap();
|
||||
|
||||
assert_eq!(state.run.unwrap().workflow_slug.as_deref(), Some("test"));
|
||||
assert_eq!(state.spec.unwrap().workflow_slug.as_deref(), Some("test"));
|
||||
assert_eq!(events.len(), 1);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -743,7 +743,7 @@ async fn daytona_git_checkpoint_remote_emits_events() {
|
|||
);
|
||||
}
|
||||
|
||||
// Verify checkpoint.json has git_commit_sha
|
||||
// Verify the persisted checkpoint snapshot has git_commit_sha
|
||||
let checkpoint = load_run_checkpoint(dir.path()).expect("checkpoint should load");
|
||||
assert!(
|
||||
checkpoint.git_commit_sha.is_some(),
|
||||
|
|
|
|||
|
|
@ -71,16 +71,6 @@ fn test_run_id(label: &str) -> RunId {
|
|||
}
|
||||
|
||||
fn load_checkpoint(path: &Path) -> Result<Checkpoint, Box<dyn std::error::Error>> {
|
||||
if !path.exists()
|
||||
&& path
|
||||
.file_name()
|
||||
.is_some_and(|name| name == "checkpoint.json")
|
||||
{
|
||||
let run_dir = path
|
||||
.parent()
|
||||
.ok_or("checkpoint path should have a parent")?;
|
||||
return load_run_checkpoint(run_dir);
|
||||
}
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
Ok(serde_json::from_str(&data)?)
|
||||
}
|
||||
|
|
@ -188,9 +178,9 @@ fn load_run_checkpoint(run_dir: &Path) -> Result<Checkpoint, Box<dyn std::error:
|
|||
}
|
||||
|
||||
fn save_checkpoint(path: &Path, checkpoint: &Checkpoint) {
|
||||
let checkpoint_json =
|
||||
let serialized_checkpoint =
|
||||
serde_json::to_string_pretty(checkpoint).expect("checkpoint should serialize to JSON");
|
||||
std::fs::write(path, checkpoint_json).expect("checkpoint file should be written");
|
||||
std::fs::write(path, serialized_checkpoint).expect("checkpoint file should be written");
|
||||
}
|
||||
|
||||
fn test_artifact_store(run_dir: &Path) -> ArtifactStore {
|
||||
|
|
@ -1462,7 +1452,7 @@ async fn pipeline_with_many_nodes() {
|
|||
#[test]
|
||||
fn checkpoint_save_and_resume_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("checkpoint.json");
|
||||
let path = dir.path().join("checkpoint_state.json");
|
||||
|
||||
let ctx = Context::new();
|
||||
ctx.set("goal", serde_json::json!("Test checkpoint"));
|
||||
|
|
@ -9024,8 +9014,8 @@ async fn downstream_remote_execution_materializes_blob_refs_to_sandbox_files() {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Verify that revisited nodes get distinct stage directories:
|
||||
/// visit 1 → `nodes/{id}/`
|
||||
/// visit 2 → `nodes/{id}-attempt_2/`
|
||||
/// visit 1 → `stages/{id}@1/`
|
||||
/// visit 2 → `stages/{id}@2/`
|
||||
#[tokio::test]
|
||||
async fn node_dir_uses_visit_count_on_revisit() {
|
||||
// Handler that fails on first call, succeeds on second.
|
||||
|
|
@ -10515,10 +10505,10 @@ async fn git_checkpoint_host_writes_shadow_branch() {
|
|||
);
|
||||
|
||||
// 8. Verify round-trip: shadow checkpoint's completed_nodes matches expected
|
||||
let run_record = MetadataStore::read_run_record(repo.path(), &run_id.to_string())
|
||||
.expect("read_run_record should not error")
|
||||
.expect("shadow branch should contain run record");
|
||||
assert_eq!(run_record.run_id, run_id);
|
||||
let run_spec = MetadataStore::read_run_spec(repo.path(), &run_id.to_string())
|
||||
.expect("read_run_spec should not error")
|
||||
.expect("shadow branch should contain run spec");
|
||||
assert_eq!(run_spec.run_id, run_id);
|
||||
|
||||
// Cleanup worktree
|
||||
let _ = std::process::Command::new("git")
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import type { RunStatusRecord } from './run-status-record';
|
|||
* Raw internal run projection derived from the event log.
|
||||
*/
|
||||
export interface RunProjection {
|
||||
'run'?: { [key: string]: any; } | null;
|
||||
'spec'?: { [key: string]: any; } | null;
|
||||
'graph_source'?: string | null;
|
||||
'start'?: { [key: string]: any; } | null;
|
||||
'status'?: RunStatusRecord | null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue