mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-15 23:32:46 +00:00
refactor(cli): route run state through the shared server daemon
Move durable run access and execution control onto the server-backed client, canonicalize run APIs under /api/v1/runs, and switch CLI integration tests to a shared test daemon/storage model with shared-state-safe assertions.
This commit is contained in:
parent
39980b5404
commit
2af2ff0068
75 changed files with 3082 additions and 1449 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
|
@ -1536,6 +1536,7 @@ dependencies = [
|
|||
"dirs",
|
||||
"dotenvy",
|
||||
"fabro-agent",
|
||||
"fabro-api",
|
||||
"fabro-checkpoint",
|
||||
"fabro-config",
|
||||
"fabro-devcontainer",
|
||||
|
|
@ -1569,6 +1570,7 @@ dependencies = [
|
|||
"open",
|
||||
"paste",
|
||||
"predicates",
|
||||
"progenitor-client",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest 0.13.2",
|
||||
|
|
@ -1977,9 +1979,11 @@ version = "0.176.2"
|
|||
dependencies = [
|
||||
"assert_cmd",
|
||||
"axum",
|
||||
"fabro-proc",
|
||||
"insta",
|
||||
"regex",
|
||||
"reqwest 0.13.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const tabs = [
|
|||
export const handle = { hideHeader: true };
|
||||
|
||||
export async function loader({ request, params }: any) {
|
||||
const response = await apiJson<PaginatedRunList>("/runs", { request });
|
||||
const response = await apiJson<PaginatedRunList>("/boards/runs", { request });
|
||||
const apiRun = response.data.find((r) => r.id === params.id);
|
||||
if (!apiRun) return { run: null };
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ interface Stage {
|
|||
export async function loader({ request, params }: any) {
|
||||
const [{ data: apiStages }, response] = await Promise.all([
|
||||
apiJson<PaginatedRunStageList>(`/runs/${params.id}/stages`, { request }),
|
||||
apiJson<PaginatedRunList>("/runs", { request }),
|
||||
apiJson<PaginatedRunList>("/boards/runs", { request }),
|
||||
]);
|
||||
const stages: Stage[] = apiStages.map((s) => ({
|
||||
id: s.id,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const columnConfig: {
|
|||
];
|
||||
|
||||
export async function loader({ request }: any) {
|
||||
const response = await apiJson<PaginatedRunList>("/runs", { request });
|
||||
const response = await apiJson<PaginatedRunList>("/boards/runs", { request });
|
||||
const apiRuns = response.data;
|
||||
|
||||
const grouped = new Map<ColumnStatus, RunItem[]>();
|
||||
|
|
|
|||
|
|
@ -113,22 +113,21 @@ paths:
|
|||
operationId: listRuns
|
||||
tags: [Runs]
|
||||
summary: List Runs
|
||||
description: Returns a paginated list of runs for the board view, ordered by recency.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/PageLimit"
|
||||
- $ref: "#/components/parameters/PageOffset"
|
||||
description: Returns durable run summaries from the backing store, including runs persisted before the current server boot.
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated list of runs for the board view
|
||||
description: Durable run summaries
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PaginatedRunList"
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/StoreRunSummary"
|
||||
post:
|
||||
operationId: createRun
|
||||
tags: [Runs]
|
||||
summary: Create Run
|
||||
description: Creates a new workflow run from a Graphviz graph source. The run is created in `submitted` status. Use `POST /api/v1/runs/{id}/start` to begin execution.
|
||||
description: Creates a new workflow run in `submitted` status. Callers may either provide `dot_source` directly or provide `workflow_path`, `cwd`, and `settings_json` so the server can load a local workflow path for trusted CLI execution.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
|
@ -154,16 +153,32 @@ paths:
|
|||
operationId: retrieveRun
|
||||
tags: [Runs]
|
||||
summary: Retrieve Run
|
||||
description: Returns the current status of a run, including error details and queue position if applicable.
|
||||
description: Returns the durable run summary for a run.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
responses:
|
||||
"200":
|
||||
description: Run status
|
||||
description: Durable run summary
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RunStatusResponse"
|
||||
$ref: "#/components/schemas/StoreRunSummary"
|
||||
"404":
|
||||
description: Run not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
delete:
|
||||
operationId: deleteRun
|
||||
tags: [Runs]
|
||||
summary: Delete Run
|
||||
description: Deletes durable store state for a run. This does not remove any local run directory.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
responses:
|
||||
"204":
|
||||
description: Run deleted or already absent
|
||||
"404":
|
||||
description: Run not found
|
||||
content:
|
||||
|
|
@ -204,9 +219,15 @@ paths:
|
|||
operationId: startRun
|
||||
tags: [Runs]
|
||||
summary: Start Run
|
||||
description: Starts a submitted run, queuing it for execution. Returns 409 if the run is not in `submitted` status.
|
||||
description: Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/StartRunRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Run started
|
||||
|
|
@ -335,6 +356,23 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/boards/runs:
|
||||
get:
|
||||
operationId: listBoardRuns
|
||||
tags: [Runs]
|
||||
summary: List Board Runs
|
||||
description: Temporary board-view list of managed runs. This endpoint is UI-oriented and may change as the app evolves.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/PageLimit"
|
||||
- $ref: "#/components/parameters/PageOffset"
|
||||
responses:
|
||||
"200":
|
||||
description: Paginated list of runs for the board view
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PaginatedRunList"
|
||||
|
||||
/api/v1/runs/{id}/state:
|
||||
get:
|
||||
operationId: getRunState
|
||||
|
|
@ -2189,15 +2227,42 @@ components:
|
|||
- paused
|
||||
|
||||
CreateRunRequest:
|
||||
description: Request body for creating a new run from a Graphviz graph source.
|
||||
description: Request body for creating a new run, either from inline Graphviz source or from a local workflow path plus resolved settings.
|
||||
type: object
|
||||
required:
|
||||
- dot_source
|
||||
properties:
|
||||
dot_source:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Graphviz DOT language source defining the workflow graph.
|
||||
example: 'digraph { start [shape=Mdiamond]; exit [shape=Msquare]; start -> exit }'
|
||||
workflow_path:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Absolute or relative path to the workflow file to load on the local machine.
|
||||
example: "/tmp/project/fabro/workflows/simple/workflow.fabro"
|
||||
cwd:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Working directory used to resolve the workflow path.
|
||||
example: "/tmp/project"
|
||||
settings_json:
|
||||
type: string
|
||||
nullable: true
|
||||
description: JSON-serialized `fabro_types::Settings` payload resolved by the CLI.
|
||||
run_id:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Optional pre-generated run ID to use instead of allocating a new ULID.
|
||||
example: "01HV6D7S5YF4Z4B2M7K4N0Q6T9"
|
||||
|
||||
StartRunRequest:
|
||||
description: Request body for starting or resuming a run.
|
||||
type: object
|
||||
properties:
|
||||
resume:
|
||||
type: boolean
|
||||
description: Resume from checkpoint instead of starting from submitted state.
|
||||
default: false
|
||||
|
||||
RunStatusResponse:
|
||||
description: Current status of a run with optional error and queue position.
|
||||
|
|
@ -2620,6 +2685,51 @@ components:
|
|||
additionalProperties:
|
||||
$ref: "#/components/schemas/NodeState"
|
||||
|
||||
StoreRunSummary:
|
||||
description: Durable run summary derived from the backing store.
|
||||
type: object
|
||||
required:
|
||||
- run_id
|
||||
- labels
|
||||
properties:
|
||||
run_id:
|
||||
type: string
|
||||
workflow_name:
|
||||
type: string
|
||||
nullable: true
|
||||
workflow_slug:
|
||||
type: string
|
||||
nullable: true
|
||||
goal:
|
||||
type: string
|
||||
nullable: true
|
||||
labels:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
host_repo_path:
|
||||
type: string
|
||||
nullable: true
|
||||
start_time:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
status:
|
||||
type: string
|
||||
nullable: true
|
||||
status_reason:
|
||||
type: string
|
||||
nullable: true
|
||||
duration_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
nullable: true
|
||||
total_cost:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
|
||||
# ── Run Board Schemas ────────────────────────────────────────────────
|
||||
|
||||
BoardColumn:
|
||||
|
|
|
|||
|
|
@ -1,501 +1,566 @@
|
|||
---
|
||||
title: "feat: HTTP store client with CLI auto-start"
|
||||
title: "feat: auto-start server and route CLI store access over HTTP"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-04-02
|
||||
origin: docs/ideation/2026-04-02-slatedb-consolidation-ideation.md
|
||||
deepened: 2026-04-02
|
||||
deepened: 2026-04-04
|
||||
---
|
||||
|
||||
# feat: HTTP store client with CLI auto-start
|
||||
# feat: auto-start server and route CLI store access over HTTP
|
||||
|
||||
## Overview
|
||||
|
||||
Replace direct SlateDB access from CLI processes with an HTTP-backed `Store`/`RunStore` implementation that routes all operations through the `fabro server` over a Unix socket. Add transparent auto-start so the server daemon launches on demand when any CLI command needs store access.
|
||||
Phase 1 replaces direct CLI access to SlateDB with a Unix-socket HTTP client that talks to `fabro server`, and auto-starts that daemon whenever CLI store access is needed. Use the generated `fabro-api` Rust client as the transport surface where the existing API already matches the needed operations.
|
||||
|
||||
Phase 2 is an explicit follow-on decision: if we require strict single-owner semantics for all workflow execution writes, detached/start/resume execution must also move under server ownership rather than remaining in CLI-spawned engine processes.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Currently every CLI command opens its own `SlateStore` via `build_store()` (~25 call sites). This means each process opens its own SlateDB instance, creating contention, reader polling latency, and multiple database handles. The consolidation to a single SlateDB instance owned by the server requires all CLI store access to go through HTTP.
|
||||
The old plan assumed most of the HTTP surface and daemon infrastructure still needed to be built. That is no longer true.
|
||||
|
||||
Without auto-start, requiring a running server for every CLI command would be a DX regression. The server daemon infrastructure (start/stop/status, flock locking, readiness polling) is already complete.
|
||||
Current state in the repo:
|
||||
|
||||
**Assumption:** Events-First Server Architecture is already complete. Events are the sole write path, the server materializes state from events, and SSE pushes events to subscribers.
|
||||
- Server daemon management is already implemented in `fabro-cli`:
|
||||
- `lib/crates/fabro-cli/src/commands/server/start.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/server/record.rs`
|
||||
- Unix socket bind support is already implemented in `fabro-server`:
|
||||
- `lib/crates/fabro-server/src/serve.rs`
|
||||
- `lib/crates/fabro-server/src/bind.rs`
|
||||
- The server already owns a single `SlateStore` instance and already exposes store-backed endpoints for:
|
||||
- run state
|
||||
- events
|
||||
- live event attach over SSE
|
||||
- blobs
|
||||
- checkpoint
|
||||
- retro
|
||||
- stage artifacts
|
||||
- The generated `fabro-api` client is reqwest-based and can be constructed with a custom `reqwest::Client`.
|
||||
- Reqwest 0.13 in this repo supports Unix sockets via `ClientBuilder::unix_socket(...)`.
|
||||
|
||||
The real remaining work is narrower:
|
||||
|
||||
1. CLI store access still opens `SlateStore` directly from local disk.
|
||||
2. CLI does not auto-start the server when store access is needed.
|
||||
3. Some API gaps remain, especially durable run listing and durable run deletion.
|
||||
4. Several CLI command paths still assume local engine processes own workflow execution and store writes.
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- R1. An HTTP-backed `Store` + `RunStore` implementation connects to the server over Unix socket
|
||||
- R2. CLI auto-starts the server daemon when store access is needed and no server is running
|
||||
- R3. All ~25 CLI `build_store()` call sites transparently switch to the HTTP-backed store
|
||||
- R4. `watch_events_from` returns a live `Stream` via SSE subscription
|
||||
- R5. `InMemoryStore` tests are completely unaffected
|
||||
- R6. Binary assets transfer efficiently over HTTP (raw bytes, not base64)
|
||||
- R7. Clear error messages when server fails to start or becomes unreachable mid-operation
|
||||
- R1. CLI commands that need store access auto-start `fabro server` when no active daemon is available.
|
||||
- R2. CLI store reads and writes stop opening SlateDB directly and instead use the server over HTTP via Unix socket.
|
||||
- R3. `fabro server` becomes the only process that opens SlateDB for migrated CLI store-access flows in phase 1, with full execution-path consolidation tracked separately in Unit 6 if strict single-owner semantics remain required.
|
||||
- R4. The generated `fabro-api` Rust client is used for server communication where its current API surface applies.
|
||||
- R5. Existing `InMemoryStore`-based tests and server-internal `SlateStore` usage remain unaffected.
|
||||
- R6. Event streaming remains live and efficient for attach/log-follow workflows.
|
||||
- R7. Binary transfer for blobs and artifacts remains raw bytes over HTTP.
|
||||
- R8. Error messages for server startup and server-unreachable cases are explicit and actionable.
|
||||
- R9. The plan must reflect the current repo state rather than the assumptions in the original 2026-04-02 draft.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- Server-side Events-First architecture (assumed complete)
|
||||
- Web UI or TypeScript API client changes (separate concern)
|
||||
- Cloud/S3 object store configuration (orthogonal)
|
||||
- Changes to the `RunStore` trait interface (trait stays as-is; the HTTP client implements it fully)
|
||||
- Removing the `SlateStore` implementation (kept for server-internal use)
|
||||
In scope:
|
||||
|
||||
## Context & Research
|
||||
- CLI auto-start of the local server daemon
|
||||
- HTTP-backed client access for CLI store consumers
|
||||
- Server API additions needed for store parity
|
||||
- Migration of CLI run discovery, logs/attach, blob/artifact access, and delete flows to server-backed access
|
||||
|
||||
### Relevant Code and Patterns
|
||||
Out of scope for the first pass:
|
||||
|
||||
- `lib/crates/fabro-store/src/lib.rs` -- `Store` (5 methods) and `RunStore` (~53 methods) trait definitions
|
||||
- `lib/crates/fabro-store/src/memory.rs` -- `InMemoryStore` reference impl using BTreeMap
|
||||
- `lib/crates/fabro-cli/src/store.rs` -- `build_store()` factory, returns `Arc<SlateStore>`
|
||||
- `lib/crates/fabro-cli/src/commands/server/record.rs` -- `ServerRecord`, `active_server_record()`, path helpers
|
||||
- `lib/crates/fabro-cli/src/commands/server/start.rs` -- `execute_daemon()`, `acquire_lock()`, `try_connect()`, readiness polling
|
||||
- `lib/crates/fabro-server/src/server.rs` -- Axum router, existing REST + SSE endpoints
|
||||
- `lib/crates/fabro-server/src/bind.rs` -- `Bind` enum (Unix | Tcp)
|
||||
- `lib/crates/fabro-llm/src/providers/fabro_server.rs` -- existing pattern for consuming SSE from server (LineReader + parse_sse_block)
|
||||
- `lib/crates/fabro-store/src/types.rs` -- `RunSnapshot`, `EventEnvelope`, `EventPayload`, `RunSummary`
|
||||
- TypeScript/web client changes
|
||||
- Replacing server-internal `SlateStore`
|
||||
- Removing local run directories or runtime files
|
||||
- Re-architecting workflow execution to be fully server-owned in the same change set
|
||||
- Full trait refactors across `fabro-workflow` unless they are required to unblock the CLI migration
|
||||
|
||||
### Institutional Learnings
|
||||
Follow-on scope, likely separate plan or addressed by Unit 6's decision fork:
|
||||
|
||||
No `docs/solutions/` exists. Key codebase patterns serve as institutional knowledge:
|
||||
- Launcher/ServerRecord pattern for daemon discovery and stale cleanup
|
||||
- SSE consumption pattern in fabro-llm (LineReader + parse_sse_block)
|
||||
- All CLI commands follow the same `build_store() -> resolve_run_combined()` flow
|
||||
- Consolidating detached/resume/start execution so workflow engine writes are also server-owned end-to-end
|
||||
|
||||
## Current-State Audit
|
||||
|
||||
### Already Implemented
|
||||
|
||||
- Daemon lifecycle:
|
||||
- `lib/crates/fabro-cli/src/commands/server/start.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/server/status.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/server/stop.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/server/record.rs`
|
||||
- Unix socket server binding:
|
||||
- `lib/crates/fabro-server/src/serve.rs`
|
||||
- `lib/crates/fabro-server/src/bind.rs`
|
||||
- Existing server routes relevant to store access:
|
||||
- `GET /api/v1/runs/{id}/state`
|
||||
- `GET /api/v1/runs/{id}/events`
|
||||
- `GET /api/v1/runs/{id}/attach`
|
||||
- `POST /api/v1/runs/{id}/events`
|
||||
- `POST /api/v1/runs/{id}/blobs`
|
||||
- `GET /api/v1/runs/{id}/blobs/{blobId}`
|
||||
- `GET|POST /api/v1/runs/{id}/stages/{stageId}/artifacts`
|
||||
- `GET /api/v1/runs/{id}/stages/{stageId}/artifacts/download`
|
||||
- `GET /api/v1/runs/{id}/checkpoint`
|
||||
- `GET /api/v1/runs/{id}/retro`
|
||||
- `RunProjection` already contains much more than the old plan assumed:
|
||||
- checkpoint and checkpoint history
|
||||
- retro and retro prompt/response
|
||||
- sandbox
|
||||
- final patch
|
||||
- pull request
|
||||
|
||||
### Still Missing or Mismatched
|
||||
|
||||
- `lib/crates/fabro-cli/src/store.rs` still builds a local `SlateStore`
|
||||
- CLI commands are typed against concrete `SlateStore` / `SlateRunStore`
|
||||
- Server `GET /api/v1/runs` is not durable store-backed; it serves in-memory managed runs only
|
||||
- No durable delete endpoint exists for runs
|
||||
- Artifact listing CLI still reads local artifact directories directly rather than using the server
|
||||
- Detached CLI execution still opens the store directly and runs workflow engine code locally
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **New crate `fabro-store-client`**: Implements `Store` + `RunStore` over HTTP. Separate crate avoids circular dependencies (depends on `fabro-store` for traits and `hyper`/`http-body-util` for HTTP transport; does NOT depend on `fabro-server` or `fabro-cli`).
|
||||
- **Use `fabro-api::Client` over Unix socket, not a custom hyper-only client.**
|
||||
- The previous plan's "reqwest cannot do Unix sockets" assumption is stale.
|
||||
- The generated client already covers `state`, `events`, `attach`, `blobs`, and artifact endpoints.
|
||||
- Build a thin `fabro-cli` client wrapper around:
|
||||
- `fabro_api::Client`
|
||||
- `reqwest::ClientBuilder::unix_socket(socket_path)`
|
||||
- For any operation not yet present in the OpenAPI spec, add the endpoint to the spec and regenerate `fabro-api`.
|
||||
|
||||
- **hyper over Unix socket, not reqwest**: Use `hyper` + `hyper-util` + `tokio::net::UnixStream` directly for the Unix socket transport. `reqwest` doesn't natively support Unix sockets. This is a deliberate divergence from the codebase pattern where `reqwest` is used in 17 crates for TCP HTTP — justified because Unix socket transport requires a lower-level connector, and the client only talks to one known server (no redirects, cookies, or TLS needed). The SSE parser in `fabro-llm` (`LineReader`) is built on `reqwest::Response` — the new SSE module must implement its own parser over hyper's byte stream rather than reusing `LineReader` directly.
|
||||
- **Do not introduce a new transport crate until there is a clear reuse case.**
|
||||
- The immediate consumers are in `fabro-cli`.
|
||||
- A small `fabro-cli::server_client` module is enough for the first migration.
|
||||
- If a second Rust crate later needs the same client, extract then.
|
||||
|
||||
- **Coarse-grained API, not trait mirroring**: The server exposes a small set of endpoints. The `HttpRunStore` translates between the fine-grained trait and the coarse API:
|
||||
- **Reads (snapshot-covered)**: `GET /api/v1/runs/{id}/snapshot` returns full `RunSnapshot`. Methods covered: `get_run`, `get_start`, `get_status`, `get_checkpoint`, `get_conclusion`, `get_retro`, `get_graph`, `get_sandbox`, `get_node`, `list_node_visits`, `list_node_ids`, `get_final_patch`, `get_pull_request`.
|
||||
- **Reads (dedicated endpoints needed)**: `RunSnapshot` does NOT include: `retro_prompt`, `retro_response`, artifacts, assets, events, or checkpoint history. These need dedicated GET endpoints: `GET /runs/{id}/retro-prompt`, `GET /runs/{id}/artifacts`, `GET /runs/{id}/assets`, `GET /runs/{id}/events` (JSON), `GET /runs/{id}/checkpoints`.
|
||||
- **Writes**: `POST /api/v1/runs/{id}/events` for event append. Other write methods forward as typed store operations to a generic `POST /api/v1/runs/{id}/store` endpoint.
|
||||
- **Streaming**: `GET /api/v1/runs/{id}/events` SSE for `watch_events_from`.
|
||||
- **Auto-start belongs in CLI store/bootstrap code, not in the generated client.**
|
||||
- The generated client should stay transport-only.
|
||||
- Server lifecycle discovery/start remains in `fabro-cli`.
|
||||
|
||||
- **`create_run` vs `start_run` endpoint distinction**: The existing `POST /api/v1/runs` endpoint (`start_run`) starts a full workflow execution. The `Store::create_run` trait method is a lower-level catalog operation. Unit 3 must add a store-level create endpoint (e.g., `POST /api/v1/store/runs`) distinct from the workflow-level start endpoint.
|
||||
- **Treat `RunProjection` as the primary read snapshot.**
|
||||
- The existing `/runs/{id}/state` endpoint already returns the coarse-grained shape most CLI reads need.
|
||||
- This should replace many fine-grained local store reads without widening the API.
|
||||
|
||||
- **Authentication bypass for local Unix socket**: When the server is auto-started for local daemon mode, it uses `AuthMode::Disabled`. The HTTP store client does not send auth headers. This is safe because Unix sockets are local-only and filesystem permissions control access.
|
||||
- **Split the migration into two layers.**
|
||||
- Layer 1: CLI read/write operations that are naturally expressible against the current server API
|
||||
- Layer 2: execution-path consolidation for detached/resume/start flows if we want the server, not CLI subprocesses, to be the sole write owner
|
||||
|
||||
- **`open_run` is lightweight**: `HttpStore::open_run(run_id)` just constructs an `HttpRunStore` with the run_id and shared HTTP client. No server round trip needed — existence check happens lazily on first operation (404 → `Ok(None)` at the Store level).
|
||||
|
||||
- **`open_run` and `open_run_reader` return the same type**: No Writer/Reader distinction in the HTTP model. Both return `HttpRunStore`. The server decides write permissions internally.
|
||||
|
||||
- **Auto-start reuses existing daemon infrastructure**: `ensure_server_running()` is a sibling function to `execute_daemon()`, not a refactoring of it. Shared logic: acquire_lock, spawn child with `pre_exec_setsid`, try_connect readiness polling. Distinct behavior: does NOT bail on "already running" (that's the success fast path), does NOT rotate logs (the running server's logs are fine), does NOT print "Server started..." to stderr (prints "Starting server..." instead for DX).
|
||||
|
||||
- **`server` feature becomes effectively required**: `ensure_server_running` depends on `fabro-server` (for `Bind`, `ServeArgs`), which is gated behind `fabro-cli`'s optional `server` feature. Since `get_or_start_store` calls `ensure_server_running`, all CLI commands that use the store now require the `server` feature. This is acceptable — the `server` feature should be enabled by default in the CLI binary build.
|
||||
|
||||
- **Auto-start is in `fabro-cli`, not in `fabro-store-client`**: The HTTP store client is transport-only. It doesn't know about daemons, flock, or process management. The CLI's `store.rs` orchestrates: check server → auto-start → create `HttpStore`.
|
||||
- **Preserve local run-directory access where it is orthogonal to SlateDB.**
|
||||
- Run discovery still needs local run-dir paths for UI output and fallback/orphan detection.
|
||||
- Runtime interview files and launcher metadata remain file-based unless separately redesigned.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
### Resolved for This Plan
|
||||
|
||||
- **Where does auto-start live?** In `fabro-cli/src/commands/server/start.rs` as a new `ensure_server_running()` function, called from `fabro-cli/src/store.rs`. The store client crate is purely transport.
|
||||
- **How does the client discover the socket?** Reads `ServerRecord.bind` from `server.json`. Same discovery mechanism as `server status`/`server stop`.
|
||||
- **Should the client cache snapshot data?** Yes, per-request. A single CLI command (e.g., `fabro runs inspect`) may call multiple `get_*` methods. Fetch snapshot once, serve subsequent reads from cache. No cross-command caching.
|
||||
- **Should we use the generated `fabro-api` client?**
|
||||
- Yes. It matches the repo direction and now works with Unix socket transport via reqwest.
|
||||
|
||||
- **Do we need a brand-new HTTP store crate first?**
|
||||
- No. Start with a thin CLI-side server client and only extract if a second consumer appears.
|
||||
|
||||
- **Does the server already expose enough snapshot data?**
|
||||
- Mostly yes. `RunProjection` already covers much more than the earlier plan assumed.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- Exact set of server endpoints needed vs what events-first already provides (inventory during Unit 3)
|
||||
- Whether `POST /api/v1/runs/{id}/store` generic write endpoint is better than individual write endpoints (decide based on actual write patterns the CLI uses)
|
||||
- HTTP client connection timeout and retry values
|
||||
- Whether `hyper` or a lighter HTTP approach (raw HTTP/1.1 over `tokio::io`) is the right fit — start with hyper, simplify if warranted. Do NOT consider `ureq` — it is a blocking client incompatible with the async `RunStore` trait and SSE streaming
|
||||
- Whether to model the new CLI-side access layer as:
|
||||
- a direct "server client" API, or
|
||||
- a local wrapper that mimics `SlateStore` / `SlateRunStore`
|
||||
- Whether artifact-list CLI should remain filesystem-based for local-only debugging or migrate fully to server-backed listing in phase 1
|
||||
- Whether detached engine execution should be migrated in the same branch or explicitly deferred behind a feature boundary
|
||||
|
||||
## High-Level Technical Design
|
||||
## Relevant Code and Patterns
|
||||
|
||||
> *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.*
|
||||
### Daemon and Unix Socket Patterns
|
||||
|
||||
```
|
||||
CLI command (e.g., `fabro runs list`)
|
||||
|
|
||||
store::get_or_start_store(storage_dir)
|
||||
|
|
||||
├── record::active_server_record(storage_dir)
|
||||
│ ├── Some(record) → server is running
|
||||
│ └── None → ensure_server_running(storage_dir)
|
||||
│ ├── acquire_lock(server.lock)
|
||||
│ ├── re-check active_server_record (may have started between checks)
|
||||
│ ├── execute_daemon (spawn fabro server __serve ...)
|
||||
│ ├── poll try_connect(fabro.sock)
|
||||
│ └── return bind address
|
||||
|
|
||||
├── HttpStore::connect(socket_path)
|
||||
│ └── creates hyper client with Unix socket connector
|
||||
|
|
||||
└── return Arc<dyn Store>
|
||||
|
|
||||
store.list_runs(query)
|
||||
|
|
||||
HttpStore → GET /api/v1/runs?start=...&end=...
|
||||
|
|
||||
server (Axum) → SlateStore.list_runs(query) → response
|
||||
|
|
||||
HttpStore ← JSON response → Vec<RunSummary>
|
||||
```
|
||||
- `lib/crates/fabro-cli/src/commands/server/start.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/server/record.rs`
|
||||
- `lib/crates/fabro-server/src/serve.rs`
|
||||
- `lib/crates/fabro-server/src/bind.rs`
|
||||
|
||||
```
|
||||
watch_events_from(seq) flow:
|
||||
### Existing Generated Client
|
||||
|
||||
HttpRunStore → GET /api/v1/runs/{id}/events?from={seq} (Accept: text/event-stream)
|
||||
|
|
||||
Server → SSE stream (broadcast channel + catch-up from store)
|
||||
|
|
||||
HttpRunStore ← parse SSE blocks → yield EventEnvelope items
|
||||
|
|
||||
Returns Pin<Box<dyn Stream<Item = Result<EventEnvelope>> + Send>>
|
||||
```
|
||||
- `lib/crates/fabro-api/src/lib.rs`
|
||||
- `lib/crates/fabro-api/build.rs`
|
||||
- `docs/api-reference/fabro-api.yaml`
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
U1[Unit 1: fabro-store-client crate foundation]
|
||||
U2[Unit 2: Implement Store trait]
|
||||
U3[Unit 3: Server-side API additions]
|
||||
U4[Unit 4: Implement RunStore trait]
|
||||
U5[Unit 5: SSE streaming for watch_events_from]
|
||||
U6[Unit 6: Auto-start server daemon]
|
||||
U7[Unit 7: Migrate CLI to HTTP store]
|
||||
### Store-Backed Server Routes
|
||||
|
||||
U1 --> U2
|
||||
U1 --> U5
|
||||
U2 --> U4
|
||||
U3 --> U4
|
||||
U5 --> U4
|
||||
U4 --> U7
|
||||
U6 --> U7
|
||||
```
|
||||
- `lib/crates/fabro-server/src/server.rs`
|
||||
|
||||
Units 1, 3, and 6 can start in parallel. Unit 2 depends on 1. Unit 5 depends on 1. Unit 4 depends on 1, 2, 3, and 5. Unit 7 depends on 4 and 6.
|
||||
### CLI Entry Points That Must Migrate
|
||||
|
||||
- `lib/crates/fabro-cli/src/store.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/runs/list.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/run/logs.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/run/attach.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/run/diff.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/run/ssh.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/run/output.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/store/dump.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/pr/create.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/pr/list.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/pr/view.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/runs/rm.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/system/df.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/artifact/list.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/artifact/cp.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/run/wait.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/run/preview.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/run/rewind.rs`
|
||||
|
||||
### Run Discovery Coupling
|
||||
|
||||
- `lib/crates/fabro-workflow/src/run_lookup.rs`
|
||||
|
||||
### Execution-Path Coupling
|
||||
|
||||
- `lib/crates/fabro-cli/src/commands/run/create.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/run/start.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/run/detached.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/run/resume.rs`
|
||||
|
||||
## High-Level Design
|
||||
|
||||
### Layer 1: CLI server-backed store access
|
||||
|
||||
`fabro-cli` gains a small client/bootstrap layer:
|
||||
|
||||
1. Resolve active server from `server.json`
|
||||
2. If absent or stale, auto-start daemon using existing lock/spawn/readiness logic
|
||||
3. Construct `reqwest::Client` bound to the Unix socket
|
||||
4. Construct `fabro_api::Client` with base URL like `http://fabro`
|
||||
5. Expose helper methods for:
|
||||
- run state
|
||||
- run events list
|
||||
- run events attach SSE stream
|
||||
- blob read/write
|
||||
- stage artifact list/read/write
|
||||
- durable run list
|
||||
- durable run delete
|
||||
|
||||
CLI command handlers stop calling `build_store()` and instead call a new helper such as:
|
||||
|
||||
`store::connect_server(storage_dir) -> Result<ServerStoreClient>`
|
||||
|
||||
### Layer 2: API parity for run discovery and deletion
|
||||
|
||||
The server adds durable endpoints for:
|
||||
|
||||
- listing runs from the store catalog rather than only in-memory managed runs
|
||||
- deleting run store state
|
||||
|
||||
The CLI keeps local run-dir fallback/orphan detection logic from `run_lookup.rs`, but its durable run summary source becomes the server.
|
||||
|
||||
### Layer 3: Optional execution consolidation
|
||||
|
||||
If we want strict compliance with "server is the only process accessing SlateDB", then detached/resume/start flows must stop opening run stores locally. That likely means:
|
||||
|
||||
- CLI creates/starts runs by calling server APIs
|
||||
- server-owned scheduler/executor performs writes
|
||||
- attach/logs become purely server-backed observers
|
||||
|
||||
This is separable from the read-path migration and should be treated as a deliberate second stage.
|
||||
|
||||
### Artifact handling boundary in phase 1
|
||||
|
||||
Artifact metadata and binary reads can move to server-backed endpoints in phase 1, but local run-directory artifact scanning may remain temporarily filesystem-based where commands are acting as local debugging tools rather than store clients. Implementation should make that boundary explicit rather than leaving a silent mixed-mode design.
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [ ] **Unit 1: Create fabro-store-client crate with Unix socket HTTP foundation**
|
||||
- [ ] **Unit 1: Add CLI server bootstrap and generated-client construction**
|
||||
|
||||
**Goal:** New crate with an HTTP client that communicates over Unix sockets. Provides typed request helpers the Store/RunStore impls will build on.
|
||||
**Goal:** Replace raw local `SlateStore` bootstrap in `fabro-cli` with "find or start server, then connect over Unix socket".
|
||||
|
||||
**Requirements:** R1
|
||||
|
||||
**Dependencies:** None
|
||||
**Requirements:** R1, R2, R4, R5, R8
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/crates/fabro-store-client/Cargo.toml`
|
||||
- Create: `lib/crates/fabro-store-client/src/lib.rs`
|
||||
- Create: `lib/crates/fabro-store-client/src/http.rs`
|
||||
- Modify: `lib/crates/fabro-store/src/error.rs` (add `Http`/`Transport` variant to `StoreError`)
|
||||
- Test: `lib/crates/fabro-store-client/tests/it/main.rs`
|
||||
|
||||
Note: No workspace `Cargo.toml` modification needed — the `lib/crates/*` glob member pattern already covers the new crate.
|
||||
- Modify: `lib/crates/fabro-cli/src/store.rs`
|
||||
- Create: `lib/crates/fabro-cli/src/server_client.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/main.rs`
|
||||
- Modify: `lib/crates/fabro-cli/Cargo.toml`
|
||||
|
||||
**Approach:**
|
||||
- `HttpClient` struct holds a hyper client configured for Unix socket transport and the socket path
|
||||
- `HttpClient::connect(socket_path: PathBuf) -> Self` constructor
|
||||
- Helper methods: `get<T: DeserializeOwned>(&self, path) -> Result<T>`, `post<T, R>(&self, path, body: &T) -> Result<R>`, `delete(&self, path) -> Result<()>`, `get_bytes(&self, path) -> Result<Option<Bytes>>`, `put_bytes(&self, path, data: &[u8]) -> Result<()>`
|
||||
- All helpers prepend `/api/v1` to paths
|
||||
- Map HTTP status codes: 404 → Ok(None) for Option-returning endpoints, 4xx/5xx → StoreError::Http
|
||||
- Map transport errors (connection refused, timeout, broken pipe) → StoreError::Transport
|
||||
- Use `tokio::net::UnixStream` as the transport layer
|
||||
- Add `StoreError::Http { status: u16, message: String }` and `StoreError::Transport(String)` variants to `fabro-store/src/error.rs`
|
||||
- Add `ensure_server_running(storage_dir: &Path) -> Result<Bind>` in `server/start.rs` or a sibling helper module.
|
||||
- Reuse:
|
||||
- `acquire_lock`
|
||||
- `active_server_record`
|
||||
- daemon spawn path
|
||||
- readiness polling
|
||||
- Fast path: if `active_server_record()` exists, return its bind.
|
||||
- If no record exists, start the daemon on the default Unix socket path and wait for readiness.
|
||||
- In a new `server_client.rs`, build:
|
||||
- `reqwest::ClientBuilder::new().unix_socket(socket_path)`
|
||||
- `fabro_api::Client::new_with_client("http://fabro", reqwest_client)`
|
||||
|
||||
**Patterns to follow:**
|
||||
- `lib/crates/fabro-server/src/bind.rs` -- Bind enum for address representation
|
||||
- `lib/crates/fabro-store/src/error.rs` -- existing StoreError enum for variant style
|
||||
- `lib/crates/fabro-cli/src/commands/server/start.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/server/record.rs`
|
||||
- `lib/crates/fabro-api/src/lib.rs`
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: HttpClient connects to a Unix socket and makes a GET request, receives JSON response
|
||||
- Happy path: POST with JSON body returns expected response
|
||||
- Error path: connection refused when socket doesn't exist → clear error
|
||||
- Error path: server returns 500 → mapped to StoreError
|
||||
- Edge case: server returns 404 → mapped to Ok(None) for option helpers
|
||||
- Existing active server record returns immediately without spawning
|
||||
- Missing server record triggers daemon start and waits for readiness
|
||||
- Stale server record is ignored and replaced by a working daemon
|
||||
- Unix socket connection errors produce a clear CLI-facing error
|
||||
|
||||
**Verification:**
|
||||
- `cargo build -p fabro-store-client` compiles
|
||||
- `cargo nextest run -p fabro-store-client` passes
|
||||
- `cargo build -p fabro-cli`
|
||||
- targeted tests for server start/status helpers in `fabro-cli`
|
||||
|
||||
- [ ] **Unit 2: Implement Store trait on HttpStore**
|
||||
- [ ] **Unit 2: Add durable server endpoints needed for CLI parity**
|
||||
|
||||
**Goal:** `HttpStore` implements the `Store` trait, routing catalog operations (create, open, list, delete runs) through the server HTTP API.
|
||||
**Goal:** Close the durable API gaps that block CLI migration.
|
||||
|
||||
**Requirements:** R1, R3
|
||||
|
||||
**Dependencies:** Unit 1
|
||||
**Requirements:** R2, R3, R4, R5
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/crates/fabro-store-client/src/store.rs`
|
||||
- Modify: `lib/crates/fabro-store-client/src/lib.rs` (pub mod store, re-export HttpStore)
|
||||
- Modify: `lib/crates/fabro-store-client/Cargo.toml` (add fabro-store dependency for trait defs)
|
||||
- Test: `lib/crates/fabro-store-client/tests/it/store.rs`
|
||||
- Modify: `docs/api-reference/fabro-api.yaml`
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs`
|
||||
- Modify: `lib/crates/fabro-api/build.rs` only if codegen constraints require it
|
||||
- Regenerate: `lib/crates/fabro-api` via normal build
|
||||
|
||||
**Approach:**
|
||||
- `HttpStore` wraps `Arc<HttpClient>` and implements `Store`
|
||||
- `create_run(run_id, created_at, run_dir)` → `POST /store/runs` with JSON body, returns `Arc<dyn RunStore>` (HttpRunStore). Note: this is the store-level create endpoint, distinct from the workflow-level `POST /runs` that starts execution.
|
||||
- `open_run(run_id)` → constructs `HttpRunStore` directly (no server call; existence checked lazily)
|
||||
- `open_run_reader(run_id)` → same as `open_run` (no distinction in HTTP model)
|
||||
- `list_runs(query)` → `GET /runs` with query params for start/end dates, deserializes `Vec<RunSummary>`
|
||||
- `delete_run(run_id)` → `DELETE /runs/{id}`
|
||||
- `HttpRunStore` struct: holds `Arc<HttpClient>` + `RunId`
|
||||
**Required endpoints:**
|
||||
- `GET /api/v1/store/runs`
|
||||
- durable run summaries from `state.store.list_runs(...)`
|
||||
- `DELETE /api/v1/store/runs/{id}`
|
||||
- durable store deletion for a run
|
||||
|
||||
**Contract guardrail:**
|
||||
- Do not change existing `GET /api/v1/runs` response semantics in this unit.
|
||||
- The existing `/runs` route remains the board-oriented runtime view backed by `state.runs`.
|
||||
- Durable catalog access belongs on the distinct `/api/v1/store/runs` surface unless a separate reviewed plan intentionally merges the concepts.
|
||||
|
||||
**Optional endpoint for phase-1 cleanup if needed:**
|
||||
- `GET /api/v1/runs/{id}/artifacts`
|
||||
- if we decide to stop using local artifact directory scanning for listing/copy
|
||||
|
||||
**Patterns to follow:**
|
||||
- `lib/crates/fabro-store/src/memory.rs` -- InMemoryStore's Store impl for trait contract reference
|
||||
- Existing store-backed handlers in `lib/crates/fabro-server/src/server.rs`
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: list_runs with no filter returns deserialized run summaries
|
||||
- Happy path: create_run returns an HttpRunStore that can be used for subsequent operations
|
||||
- Happy path: delete_run sends DELETE and succeeds
|
||||
- Edge case: open_run for non-existent run — first operation on the HttpRunStore returns None/error (lazy check)
|
||||
- Error path: server unreachable → StoreError with clear message
|
||||
- Durable run list returns runs persisted before current server boot
|
||||
- Durable run delete removes store state for existing run
|
||||
- Deleting missing run is idempotent or returns a clearly documented 404 behavior
|
||||
- New endpoints are represented in `fabro-api` codegen output
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-store-client` passes
|
||||
- HttpStore satisfies `Store: Send + Sync` bounds
|
||||
- `cargo build -p fabro-api`
|
||||
- `cargo nextest run -p fabro-server`
|
||||
|
||||
- [ ] **Unit 3: Server-side API endpoint additions**
|
||||
- [ ] **Unit 3: Migrate run discovery to server-backed durable summaries**
|
||||
|
||||
**Goal:** Add server endpoints that the HTTP store client needs but don't exist yet. Update OpenAPI spec.
|
||||
**Goal:** Stop CLI run discovery from reading store summaries via direct `SlateStore`.
|
||||
|
||||
**Requirements:** R1, R6
|
||||
|
||||
**Dependencies:** None (parallel with Units 1-2)
|
||||
**Requirements:** R2, R3, R4
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-server/src/server.rs` (add handlers and routes)
|
||||
- Modify: `docs/api-reference/fabro-api.yaml` (add endpoint specs)
|
||||
- Test: `lib/crates/fabro-server/tests/it/api.rs`
|
||||
- Modify: `lib/crates/fabro-workflow/src/run_lookup.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/runs/list.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/system/df.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/pr/list.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/mod.rs`
|
||||
- Add tests in:
|
||||
- `lib/crates/fabro-cli/tests/it/cmd/`
|
||||
- `lib/crates/fabro-workflow` tests if `run_lookup` signatures change
|
||||
|
||||
**Approach:**
|
||||
- Inventory the existing endpoints against what the HTTP client needs. Known gaps (verify at implementation time):
|
||||
- `POST /api/v1/store/runs` — store-level create_run (distinct from existing `POST /api/v1/runs` which starts a full workflow execution). Takes `run_id`, `created_at`, `run_dir`
|
||||
- `DELETE /api/v1/runs/{id}` — delete a run (calls `store.delete_run`)
|
||||
- `GET /api/v1/runs/{id}/snapshot` — returns full `RunSnapshot` as JSON
|
||||
- `GET /api/v1/runs/{id}/events` with `Accept: application/json` — returns `Vec<EventEnvelope>` as JSON (vs SSE for `text/event-stream`). Support `?from={seq}` query param
|
||||
- `POST /api/v1/runs/{id}/events` — append an `EventPayload`, returns the assigned sequence number
|
||||
- `POST /api/v1/runs/{id}/rewind` — calls `reset_for_rewind`
|
||||
- `GET /api/v1/runs/{id}/retro-prompt` — returns retro prompt text (not in RunSnapshot)
|
||||
- `GET /api/v1/runs/{id}/retro-response` — returns retro response text (not in RunSnapshot)
|
||||
- `GET /api/v1/runs/{id}/artifacts` — lists artifact values (not in RunSnapshot)
|
||||
- `GET /api/v1/runs/{id}/artifacts/{id}` — gets single artifact value
|
||||
- `GET /api/v1/runs/{id}/checkpoints` — lists checkpoint history (not in RunSnapshot)
|
||||
- `PUT /api/v1/runs/{id}/assets/{node_id}/{visit}/{filename}` — binary body, calls `put_asset`
|
||||
- `GET /api/v1/runs/{id}/assets/{node_id}/{visit}/{filename}` — returns raw bytes, calls `get_asset`
|
||||
- `GET /api/v1/runs/{id}/assets` — lists all assets
|
||||
- `POST /api/v1/runs/{id}/store` — generic write endpoint for put_* methods not covered by events
|
||||
- Content negotiation on `/events`: `Accept: text/event-stream` → SSE (existing behavior), `Accept: application/json` → JSON array
|
||||
- Asset endpoints use `application/octet-stream` content type for binary transfer (R6)
|
||||
- Add to OpenAPI spec, rebuild types: `cargo build -p fabro-api-types`
|
||||
- Decouple `run_lookup` from concrete `SlateStore` inputs.
|
||||
- Introduce a smaller input shape for durable summaries, likely:
|
||||
- a plain `Vec<RunSummary>`, or
|
||||
- a small trait implemented by both local tests and the new CLI client wrapper
|
||||
- Preserve local run-dir fallback/orphan detection from filesystem scanning.
|
||||
- Use server-provided durable summaries as the authoritative store source.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing handlers in `server.rs` (e.g., `get_run_status`, `get_checkpoint`, `get_graph`)
|
||||
- `extract::Path(run_id)` + `State(state)` pattern for route handlers
|
||||
- `AppState.store` for store access
|
||||
- `lib/crates/fabro-workflow/src/run_lookup.rs`
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: DELETE /runs/{id} removes the run, subsequent GET returns 404
|
||||
- Happy path: GET /runs/{id}/snapshot returns complete RunSnapshot JSON
|
||||
- Happy path: POST /runs/{id}/events appends event and returns sequence number
|
||||
- Happy path: GET /runs/{id}/events with Accept: application/json returns JSON array
|
||||
- Happy path: PUT then GET asset round-trips binary data correctly
|
||||
- Happy path: POST /runs/{id}/rewind resets the run state
|
||||
- Edge case: DELETE /runs/{nonexistent} returns 404
|
||||
- Edge case: GET /runs/{id}/events?from=5 returns only events with seq >= 5
|
||||
- Edge case: PUT asset with empty body succeeds (zero-length asset)
|
||||
- Persisted run appears in list even after server restart
|
||||
- Local orphan run still appears when store summary is absent
|
||||
- Prefix resolution still works against durable summaries plus local paths
|
||||
- `runs list` behavior remains unchanged for filters and JSON output
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-server` passes
|
||||
- `cargo build -p fabro-api-types` regenerates without errors
|
||||
- OpenAPI spec validates
|
||||
- `cargo nextest run -p fabro-cli runs_list`
|
||||
- targeted `run_lookup` tests
|
||||
|
||||
- [ ] **Unit 4: Implement RunStore trait on HttpRunStore**
|
||||
- [ ] **Unit 4: Migrate read-heavy CLI commands to server-backed state/events/blob/artifact access**
|
||||
|
||||
**Goal:** `HttpRunStore` implements the full `RunStore` trait, mapping ~48 methods to server HTTP endpoints.
|
||||
**Goal:** Move the majority of CLI read flows off direct SlateDB access.
|
||||
|
||||
**Requirements:** R1, R6
|
||||
|
||||
**Dependencies:** Units 1, 2, 3, 5
|
||||
**Requirements:** R2, R3, R4, R6, R7, R8
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/crates/fabro-store-client/src/run_store.rs`
|
||||
- Modify: `lib/crates/fabro-store-client/src/lib.rs`
|
||||
- Test: `lib/crates/fabro-store-client/tests/it/run_store.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/logs.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/attach.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/diff.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/ssh.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/output.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/store/dump.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/pr/create.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/pr/view.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/wait.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/preview.rs`
|
||||
|
||||
**Approach:**
|
||||
- **Read methods** (`get_*`): fetch `RunSnapshot` via `GET /runs/{id}/snapshot`, cache it in an `OnceCell` or `Mutex<Option<RunSnapshot>>` on the `HttpRunStore`. Individual `get_run`, `get_status`, `get_checkpoint`, `get_node`, etc. extract from the cached snapshot. Targeted endpoints for frequently-changing data: `get_status` → `GET /runs/{id}` (status endpoint already exists), event listing → `GET /runs/{id}/events`
|
||||
- **Write methods** (`put_*`): forward to server. Two approaches to evaluate:
|
||||
- (a) Generic: `POST /runs/{id}/store` with `{ "op": "put_status", "data": {...} }`
|
||||
- (b) Event-based: translate the put into the corresponding event and `POST /runs/{id}/events`
|
||||
- Decision deferred to implementation — start with (a) for simplicity since events-first handles the actual persistence
|
||||
- **Node methods**: `get_node(NodeVisitRef)` extracts from snapshot. `list_node_visits`, `list_node_ids` likewise
|
||||
- **Event methods**: `append_event` → `POST /runs/{id}/events`. `list_events` / `list_events_from` → `GET /runs/{id}/events?from={seq}` (JSON mode). `watch_events_from` → delegates to SSE stream (Unit 5)
|
||||
- **Asset methods**: `put_asset` → `PUT /runs/{id}/assets/{node}/{visit}/{filename}` (raw bytes). `get_asset` → `GET /runs/{id}/assets/{node}/{visit}/{filename}`. `list_assets` / `list_all_assets` → `GET /runs/{id}/assets`
|
||||
- **Artifact methods**: `put_artifact_value` / `get_artifact_value` / `list_artifact_values` → can use snapshot for reads, POST for writes
|
||||
- **`reset_for_rewind`** → `POST /runs/{id}/rewind`
|
||||
- **`get_snapshot`** → `GET /runs/{id}/snapshot` (same as the caching endpoint, but returns the full value)
|
||||
- Invalidate snapshot cache after any write operation
|
||||
- Replace direct `open_run_reader()` usage with calls to:
|
||||
- `get_run_state`
|
||||
- `list_run_events`
|
||||
- `attach_run_events`
|
||||
- `read_run_blob`
|
||||
- `list_stage_artifacts`
|
||||
- artifact download endpoint
|
||||
- Build one SSE parsing helper in CLI for `attach_run_events()` byte streams.
|
||||
- Continue using `RunProjection` as the coarse-grained read model.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `lib/crates/fabro-store/src/memory.rs` -- InMemoryStore's RunStore impl as trait contract reference
|
||||
- `lib/crates/fabro-store/src/types.rs` -- RunSnapshot, NodeSnapshot field structure
|
||||
- `lib/crates/fabro-server/src/server.rs` attach SSE response shape
|
||||
- Existing CLI attach/log rendering logic
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: get_status returns deserialized RunStatusRecord from server
|
||||
- Happy path: put_status sends to server and subsequent get_status reflects the change
|
||||
- Happy path: append_event returns sequence number
|
||||
- Happy path: list_events_from(5) returns only events with seq >= 5
|
||||
- Happy path: get_node returns correct NodeSnapshot for a given visit
|
||||
- Happy path: put_asset / get_asset round-trips binary data
|
||||
- Happy path: get_snapshot returns full RunSnapshot
|
||||
- Edge case: get_checkpoint when no checkpoint exists returns None
|
||||
- Edge case: snapshot cache is invalidated after a write
|
||||
- Error path: server returns 500 on write → StoreError propagated
|
||||
- `logs --follow` continues to stream events to completion
|
||||
- `attach` replays existing events then follows live events
|
||||
- state-driven commands still read final patch, PR data, sandbox, checkpoint, and retro from `RunProjection`
|
||||
- blob and artifact download remain raw bytes
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-store-client` passes
|
||||
- HttpRunStore satisfies `RunStore: Send + Sync` bounds
|
||||
- targeted `fabro-cli` command tests:
|
||||
- logs
|
||||
- attach
|
||||
- diff
|
||||
- pr view/create
|
||||
- store dump
|
||||
|
||||
- [ ] **Unit 5: SSE streaming for watch_events_from**
|
||||
- [ ] **Unit 5: Migrate write/delete CLI operations that should hit the server**
|
||||
|
||||
**Goal:** Implement `watch_events_from` by subscribing to the server's SSE event stream, returning a `Pin<Box<dyn Stream>>`.
|
||||
**Goal:** Stop CLI deletion and similar store mutations from touching SlateDB directly.
|
||||
|
||||
**Requirements:** R4
|
||||
|
||||
**Dependencies:** Unit 1
|
||||
**Requirements:** R2, R3, R4, R8
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/crates/fabro-store-client/src/sse.rs`
|
||||
- Test: `lib/crates/fabro-store-client/tests/it/sse.rs`
|
||||
|
||||
Note: Wiring `sse::subscribe_events` into `HttpRunStore::watch_events_from` happens in Unit 4 (which owns `run_store.rs`), not here.
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/runs/rm.rs`
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/start.rs` if status validation moves to server reads only
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/run/rewind.rs` only if it currently depends on direct store writes in the targeted path
|
||||
|
||||
**Approach:**
|
||||
- `subscribe_events(client, run_id, from_seq) -> impl Stream<Item = Result<EventEnvelope>>`
|
||||
- Opens HTTP connection to `GET /runs/{id}/events?from={seq}` with `Accept: text/event-stream`
|
||||
- Implements its own SSE parser over hyper's byte stream (cannot reuse `LineReader` from `fabro-llm` which is tied to `reqwest::Response`)
|
||||
- Parses SSE format: `data:` lines containing JSON `EventEnvelope`, delimited by `\n\n`
|
||||
- Returns an async `Stream` that yields deserialized `EventEnvelope` items
|
||||
- Stream ends when server closes the connection (run completed) or on error
|
||||
- Reconnection logic: if connection drops unexpectedly, reconnect from last seen sequence number
|
||||
|
||||
**Patterns to follow:**
|
||||
- `lib/crates/fabro-llm/src/providers/common.rs` -- `LineReader` + `parse_sse_block` as a reference for SSE parsing logic (but implemented over hyper byte stream, not reqwest)
|
||||
- `lib/crates/fabro-server/src/server.rs` get_events handler -- the server-side SSE format to match
|
||||
- Replace direct `store.delete_run()` calls with server API delete.
|
||||
- Replace direct event append for `RunRemoving` with `POST /runs/{id}/events`.
|
||||
- Keep local run-dir deletion and sandbox cleanup local unless a server-owned deletion flow is explicitly introduced.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: subscribe to event stream, receive events in order, each deserializes to EventEnvelope
|
||||
- Happy path: stream ends cleanly when server closes connection
|
||||
- Edge case: from_seq > 0 skips earlier events
|
||||
- Edge case: reconnect after connection drop resumes from last sequence
|
||||
- Error path: invalid SSE data → StoreError in stream item
|
||||
- removing a completed run deletes local run dir and durable store state
|
||||
- removing a run with missing store state still behaves predictably
|
||||
- server-unreachable deletion path returns actionable error text
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-store-client` passes
|
||||
- Stream satisfies `Pin<Box<dyn Stream<Item = Result<EventEnvelope>> + Send>>` return type
|
||||
- `cargo nextest run -p fabro-cli runs_rm`
|
||||
|
||||
- [ ] **Unit 6: Auto-start server daemon**
|
||||
- [ ] **Unit 6: Decide and document the execution-ownership boundary**
|
||||
|
||||
**Goal:** Add `ensure_server_running()` that starts the server daemon if not already running, reusing existing infrastructure. Returns the socket path for the HTTP client.
|
||||
**Goal:** Explicitly close the gap between "CLI reads via server" and "server is the only process accessing SlateDB".
|
||||
|
||||
**Requirements:** R2, R7
|
||||
|
||||
**Dependencies:** None (parallel with Units 1-5)
|
||||
**Requirements:** R3, R9
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-cli/src/commands/server/start.rs` (extract `ensure_server_running`)
|
||||
- Test: `lib/crates/fabro-cli/tests/it/cmd/server_start.rs` (add auto-start tests)
|
||||
- Modify: this plan document after implementation decision, or create follow-on plan
|
||||
- Review:
|
||||
- `lib/crates/fabro-cli/src/commands/run/create.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/run/start.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/run/detached.rs`
|
||||
- `lib/crates/fabro-cli/src/commands/run/resume.rs`
|
||||
|
||||
**Approach:**
|
||||
- Extract a `pub(crate) fn ensure_server_running(storage_dir: &Path) -> Result<Bind>` from `execute_daemon`:
|
||||
1. Check `active_server_record(storage_dir)` — if running, return `record.bind` immediately (the success case)
|
||||
2. Acquire flock on `server.lock`
|
||||
3. Re-check `active_server_record` (another process may have started the server between step 1 and lock acquisition)
|
||||
4. If still not running, call `execute_daemon` logic (spawn child, write record, poll readiness)
|
||||
5. Return the bind address
|
||||
- Key difference from `execute_daemon`: does NOT bail on "already running" — that's the fast path
|
||||
- On failure: return error with message "Failed to start server: {reason}. Start manually with `fabro server start`"
|
||||
- The function is idempotent and race-safe (flock serializes concurrent callers)
|
||||
**Decision fork:**
|
||||
|
||||
**Patterns to follow:**
|
||||
- `lib/crates/fabro-cli/src/commands/server/start.rs` -- `execute_daemon()` for the full spawn + readiness logic
|
||||
- `lib/crates/fabro-cli/src/commands/server/record.rs` -- `active_server_record()` for discovery
|
||||
Option A: **Phase-1 complete means CLI read/write store access is server-backed, but local engine subprocesses remain**
|
||||
- Faster
|
||||
- Leaves a strict reading of R3 partially unmet
|
||||
|
||||
Option B: **Phase-2 also migrates execution ownership to the server**
|
||||
- CLI create/start/resume become HTTP calls
|
||||
- Detached local engine path is retired or reduced to server-only launch
|
||||
- Strictly satisfies "server is the only process accessing SlateDB"
|
||||
|
||||
**Recommendation:**
|
||||
- Treat Option A as the first implementation milestone
|
||||
- Open a follow-on plan immediately for Option B if the requirement remains strict
|
||||
|
||||
**Implementation note (2026-04-04):**
|
||||
- This branch follows Option A.
|
||||
- CLI durable run discovery, wait/logs/diff/preview/PR listing, and run deletion are moving behind the server-backed store client.
|
||||
- Detached/create/start/resume execution ownership remains local for now and still needs a follow-on server-owned execution plan if strict single-owner SlateDB semantics remain required.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: ensure_server_running when server is already running returns bind address immediately
|
||||
- Happy path: ensure_server_running when server is not running starts it and returns bind address
|
||||
- Happy path: concurrent calls to ensure_server_running — only one starts the daemon, others wait and find it running
|
||||
- Edge case: stale server record (dead PID) — cleaned up, fresh server started
|
||||
- Error path: server fails to start — clear error message suggesting manual start
|
||||
- explicit documentation of whichever boundary we choose
|
||||
- no silent mixing of local-store and server-store code paths remains after the chosen milestone
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-cli` passes
|
||||
- Manual: running a CLI command without a server auto-starts one
|
||||
## Sequencing
|
||||
|
||||
- [ ] **Unit 7: Migrate CLI to HTTP-backed store**
|
||||
1. Unit 1 first: auto-start and client bootstrap
|
||||
2. Unit 2 second: add missing durable API surface
|
||||
3. Unit 3 third: migrate run discovery and durable summary reads
|
||||
4. Unit 4 fourth: migrate read-heavy commands
|
||||
5. Unit 5 fifth: migrate store mutations that still happen in CLI
|
||||
6. Unit 6 last: finalize the execution-ownership boundary and either defer or continue
|
||||
|
||||
**Goal:** Replace `build_store()` with `get_or_start_store()` that returns an HTTP-backed store connected through the server. All ~25 CLI call sites get the new behavior.
|
||||
## Risks and Mitigations
|
||||
|
||||
**Requirements:** R2, R3, R4, R5, R7
|
||||
- **Risk: `/api/v1/runs` semantics are runtime-board state, not durable store state**
|
||||
- Mitigation: add distinct `/api/v1/store/runs` routes rather than silently repurposing `/runs`
|
||||
|
||||
**Dependencies:** Units 4, 5, 6
|
||||
- **Risk: `run_lookup` is coupled to concrete `SlateStore`**
|
||||
- Mitigation: extract a smaller durable-summary input shape before touching multiple commands
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/crates/fabro-cli/src/store.rs` (new `get_or_start_store`, keep `build_store` for server-internal use)
|
||||
- Modify: `lib/crates/fabro-cli/Cargo.toml` (add fabro-store-client dependency)
|
||||
- Modify: ~25 CLI command files (change `build_store` calls to `get_or_start_store`)
|
||||
- Test: `lib/crates/fabro-cli/tests/it/scenario/` (integration test for CLI-through-server flow)
|
||||
- **Risk: SSE parsing via generated client is lower-level than current in-process stream usage**
|
||||
- Mitigation: centralize byte-stream-to-event parsing in one helper and cover it with focused tests
|
||||
|
||||
**Approach:**
|
||||
- New function `pub(crate) async fn get_or_start_store(storage_dir: &Path) -> Result<Arc<dyn Store>>`:
|
||||
1. Call `ensure_server_running(storage_dir)` → get bind address
|
||||
2. Extract socket path from `Bind::Unix(path)` (error if TCP — auto-start always uses Unix socket)
|
||||
3. Construct `HttpStore::connect(socket_path)`
|
||||
4. Return as `Arc<dyn Store>`
|
||||
- Keep `build_store()` as `pub(crate)` for server-internal use (the server still opens SlateDB directly)
|
||||
- Rename to make the distinction clear: `build_local_store()` (server use) vs `get_or_start_store()` (CLI use)
|
||||
- `open_run_reader()` helper in store.rs also migrates to use `get_or_start_store`
|
||||
- All CLI call sites: mechanical replacement of `store::build_store(&storage_dir)?` → `store::get_or_start_store(&storage_dir).await?`
|
||||
- All ~25 callers are already in `async fn` contexts, so the migration is adding `.await` at each site. No `block_on()` needed.
|
||||
- **Risk: some CLI commands still depend on local runtime files rather than store data**
|
||||
- Mitigation: migrate only true store access in this plan; do not conflate run-dir filesystem concerns with SlateDB consolidation
|
||||
|
||||
**Patterns to follow:**
|
||||
- Current `build_store()` usage pattern across all CLI commands
|
||||
- `lib/crates/fabro-cli/src/commands/server/start.rs` -- daemon management functions
|
||||
- **Risk: execution paths still open the store directly after read-path migration**
|
||||
- Mitigation: explicitly treat execution consolidation as a tracked decision point, not an accidental omission
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `fabro runs list` with no running server auto-starts server and returns results
|
||||
- Happy path: `fabro runs list` with running server connects and returns results (no extra start)
|
||||
- Happy path: `fabro run logs <id>` streams events via SSE through the server
|
||||
- Integration: full lifecycle — auto-start → create run → list runs → inspect run → delete run
|
||||
- Error path: server fails to auto-start — CLI prints clear error with suggestion
|
||||
- Edge case: multiple CLI commands in quick succession — first starts server, subsequent reuse it
|
||||
## Verification Strategy
|
||||
|
||||
**Verification:**
|
||||
- `cargo nextest run -p fabro-cli` passes (including existing tests)
|
||||
- `cargo clippy --workspace -- -D warnings` clean
|
||||
- Manual: `fabro server stop && fabro runs list` auto-starts and succeeds
|
||||
Per-unit targeted verification:
|
||||
|
||||
## System-Wide Impact
|
||||
- `cargo build -p fabro-api`
|
||||
- `cargo nextest run -p fabro-server`
|
||||
- `cargo nextest run -p fabro-cli`
|
||||
|
||||
- **Interaction graph:** `build_store()` in `fabro-cli/src/store.rs` is the sole injection point for store access in all CLI commands. Replacing it with `get_or_start_store()` transparently routes all ~25 commands through the server. The server's `serve.rs` constructs its own `SlateStore` inline (not via `build_store`) — no change to server internals. The `build_store` function in `fabro-cli` is renamed to `build_local_store` to clarify it's only for server-internal use.
|
||||
- **Error propagation:** HTTP errors from the store client propagate as `StoreError` through existing error handling. New `StoreError::Http` and `StoreError::Transport` variants (Unit 1) carry structured error context. Auto-start failures include actionable messages.
|
||||
- **State lifecycle risks:** The server process may crash between CLI operations. The HTTP client should handle connection errors gracefully (not panic). The auto-start function is idempotent — re-running after a crash starts a fresh server.
|
||||
- **API surface parity:** New server endpoints (Unit 3) extend the REST API. The OpenAPI spec and `fabro-api-types` must be updated. The TypeScript API client (`fabro-api-client`) will gain these endpoints on next generation but is not required for this plan.
|
||||
- **Feature flag impact:** `ensure_server_running` depends on `fabro-server` (optional dep in `fabro-cli`). Since all CLI store access routes through auto-start, the `server` feature effectively becomes required for the CLI binary. Ensure `server` is in `default` features for `fabro-cli`.
|
||||
- **Integration coverage:** End-to-end tests must verify the full CLI → auto-start → HTTP → server → SlateDB path. Tests for `HttpStore`/`HttpRunStore` should use an embedded Axum test server (matching the `tower::ServiceExt::oneshot` pattern in `fabro-server/tests/it/`) backed by `InMemoryStore`.
|
||||
- **Unchanged invariants:** The `Store`/`RunStore` trait interfaces are unchanged (only `StoreError` gains new variants). `InMemoryStore` is unchanged. Server-internal store access is unchanged. All existing server tests continue to use `InMemoryStore`.
|
||||
Focused command coverage should include:
|
||||
|
||||
## Risks & Dependencies
|
||||
- server start/status/stop
|
||||
- runs list
|
||||
- logs
|
||||
- attach
|
||||
- diff
|
||||
- pr view/create/list
|
||||
- runs rm
|
||||
- store dump
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Server endpoint inventory incomplete — events-first may not provide all needed endpoints | Unit 3 inventories gaps at implementation time. The plan lists known gaps; additional ones are handled as they're discovered |
|
||||
| Unix socket HTTP client adds latency vs direct SlateDB | Benchmark critical paths (list_runs, watch_events). Unix socket IPC is typically <1ms overhead. Snapshot caching reduces round trips |
|
||||
| Auto-start adds ~1-2s to first CLI command in a session | Acceptable tradeoff. Subsequent commands are instant. Print "Starting server..." to stderr so user knows what's happening |
|
||||
| Concurrent CLI commands during auto-start race | flock serializes. Second caller waits for lock, then finds server running. Already proven in daemon management |
|
||||
| Large RunSnapshot for runs with many nodes/events | Snapshot endpoint should support partial responses in the future. For now, full snapshot is acceptable — most runs are <10MB |
|
||||
| CLI commands that are currently sync need async for HTTP | `build_store()` is currently sync (`fn`, not `async fn`). `get_or_start_store()` is async. All ~25 callers already run inside a tokio runtime (CLI entry point sets up runtime). The migration is mechanical: add `.await` at each call site. Do NOT use `block_on()` inside an async context (will panic) — instead ensure each caller is already in an async fn |
|
||||
| `server` feature becomes effectively mandatory | Acceptable — make it a default feature for the CLI binary. Non-server builds (e.g., embedded use) can opt out |
|
||||
Manual smoke flow after Units 1-5:
|
||||
|
||||
## Sources & References
|
||||
1. stop any running server
|
||||
2. run a CLI command that needs store access
|
||||
3. verify server auto-starts
|
||||
4. verify the command succeeds through the Unix socket path
|
||||
5. verify no CLI path in the tested flow opens local SlateDB directly
|
||||
|
||||
- **Origin document:** [docs/ideation/2026-04-02-slatedb-consolidation-ideation.md](docs/ideation/2026-04-02-slatedb-consolidation-ideation.md)
|
||||
- Related plan: [docs/plans/2026-04-02-001-feat-server-daemon-management-plan.md](docs/plans/2026-04-02-001-feat-server-daemon-management-plan.md) (completed — this plan builds on it)
|
||||
- Related code: `lib/crates/fabro-store/src/lib.rs` (Store + RunStore traits)
|
||||
- Related code: `lib/crates/fabro-cli/src/store.rs` (build_store factory)
|
||||
- Related code: `lib/crates/fabro-cli/src/commands/server/` (daemon management)
|
||||
- Related code: `lib/crates/fabro-server/src/server.rs` (API handlers)
|
||||
- Related docs: `docs-internal/events-strategy.md` (event system architecture)
|
||||
Durability smoke flow:
|
||||
|
||||
1. create or identify a persisted run
|
||||
2. stop the server
|
||||
3. start the server again
|
||||
4. verify durable run listing still finds the run through the HTTP path
|
||||
5. verify run state for that run is still readable through the HTTP path
|
||||
|
||||
## Change Summary
|
||||
|
||||
The old plan is no longer the right implementation guide. The repo already has daemon management, Unix socket support, store-backed server endpoints, and a generated Rust client that can be used over UDS. The remaining plan is to:
|
||||
|
||||
- auto-start the server from CLI store bootstrap
|
||||
- use `fabro-api::Client` over Unix socket
|
||||
- add the missing durable run-list/delete endpoints
|
||||
- migrate CLI store consumers off direct `SlateStore`
|
||||
- then explicitly decide whether execution ownership also moves fully into the server
|
||||
|
|
|
|||
126
docs/plans/2026-04-04-shared-nextest-test-daemon-plan.md
Normal file
126
docs/plans/2026-04-04-shared-nextest-test-daemon-plan.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Shared Test Storage + Production Auto-Start for CLI Tests
|
||||
|
||||
## Summary
|
||||
Use one shared test `storage_dir` per test session and rely on the existing production auto-start behavior to converge on a single shared daemon for that storage root.
|
||||
|
||||
Session identity rules:
|
||||
- when `NEXTEST_RUN_ID` is present, use one shared storage dir for the full `cargo nextest run`
|
||||
- when `NEXTEST_RUN_ID` is absent, use one shared storage dir per test process
|
||||
|
||||
The test harness will not implement its own daemon bootstrap protocol. Instead:
|
||||
- every test process points `FABRO_STORAGE_DIR` at the same test-run storage root
|
||||
- the first CLI command that needs the daemon triggers normal production auto-start
|
||||
- existing `server.lock`, `server.json`, and `fabro.sock` behavior prevents duplicate servers for that shared storage dir
|
||||
- the test harness is responsible only for:
|
||||
- selecting the shared test storage root
|
||||
- making test assertions/helpers safe under shared storage
|
||||
- cleaning up the shared daemon and temp root at the end of the test run
|
||||
|
||||
This keeps test behavior aligned with production daemon identity semantics.
|
||||
|
||||
## Implementation Changes
|
||||
### 1. Shared `storage_dir` in `fabro-test`
|
||||
In `lib/crates/fabro-test/src/lib.rs`:
|
||||
- Change `TestContext::new` to detect `NEXTEST_RUN_ID`.
|
||||
- Derive a shared test root under temp:
|
||||
- if `NEXTEST_RUN_ID` is present: `$TMPDIR/fabro-nextest/<NEXTEST_RUN_ID>/`
|
||||
- otherwise: `$TMPDIR/fabro-test-process/<pid>/`
|
||||
- shared storage dir: `<root>/storage`
|
||||
- Keep `temp_dir` and `home_dir` per context; only `storage_dir` becomes shared for the session.
|
||||
- Do not explicitly start the daemon from the harness.
|
||||
- Keep using the normal CLI command path so the first server-backed command triggers production auto-start against the shared storage dir.
|
||||
- Add concrete session cleanup coordination using marker files:
|
||||
- under the session root, create `clients/<pid>` marker files
|
||||
- protect create/remove/scan operations with a session lock file
|
||||
- on `TestContext` init:
|
||||
- acquire lock
|
||||
- create or refresh this process marker
|
||||
- remove stale markers for dead PIDs
|
||||
- release lock
|
||||
- on process teardown:
|
||||
- acquire lock
|
||||
- remove this process marker
|
||||
- remove any other stale dead markers
|
||||
- if no markers remain, call `fabro server stop --storage-dir <shared>` and remove the shared temp root
|
||||
- release lock
|
||||
- Crash behavior:
|
||||
- crashed processes may leave stale markers behind
|
||||
- future init/teardown paths reap dead markers under the same lock
|
||||
- teardown cleanup is best-effort; if `fabro server stop` or root removal fails, later harness initialization remains responsible for authoritative stale-session reaping
|
||||
- Add stale-run cleanup on harness initialization:
|
||||
- scan old `fabro-nextest/*` roots
|
||||
- if all tracked PIDs for a root are dead, stop any server tied to that root and remove it
|
||||
- likewise scan old `fabro-test-process/*` roots and reap dead process-owned sessions
|
||||
|
||||
The harness should only coordinate test-run ownership and cleanup, not daemon startup.
|
||||
|
||||
### 2. Per-test labeling for shared-state safety
|
||||
Add a per-`TestContext` test case ULID and expose:
|
||||
- `fabro_test_run=<NEXTEST_RUN_ID>`
|
||||
- `fabro_test_case=<test-case-ulid>`
|
||||
|
||||
Update run-creation helpers to append these labels to created runs:
|
||||
- `run`
|
||||
- `create`
|
||||
- detached/create-start helpers
|
||||
- any workflow fixture helpers that create runs internally
|
||||
|
||||
Use existing production `--label KEY=VALUE` support; do not invent a new namespacing mechanism.
|
||||
|
||||
### 3. Replace isolated-storage helper assumptions
|
||||
Update helpers in:
|
||||
- `lib/crates/fabro-cli/tests/it/cmd/support.rs`
|
||||
- `lib/crates/fabro-cli/tests/it/workflow/mod.rs`
|
||||
|
||||
Specifically:
|
||||
- remove helpers that assume there is exactly one run in `storage_dir/runs`
|
||||
- replace `only_run(context)`-style logic with:
|
||||
- exact run-id lookup when the helper already has the run id, or
|
||||
- test-case-label-based lookup when the helper needs to discover “the run created by this test”
|
||||
- keep direct store inspection helpers, but always resolve a concrete run first
|
||||
|
||||
### 4. Make shared-daemon tests robust
|
||||
Update tests to match the shared-daemon model:
|
||||
- exact-run tests remain strict and use ULIDs directly
|
||||
- global/listing tests (`ps`, `runs list`, `system df`, workflow-slug/recency lookup) should assert the presence/properties of the current test’s run(s), not exact global emptiness/counts unless explicitly scoped
|
||||
- when a command offers structured output, prefer parsing that output and filtering to the current test’s run(s) over broad transcript snapshots or loose substring matching
|
||||
- destructive tests must always be scoped:
|
||||
- exact run IDs when possible
|
||||
- otherwise use existing `--label` filters
|
||||
- broad destructive operations like “delete everything” are not allowed in shared-daemon tests
|
||||
|
||||
Rewrite current tests that depend on ambient exclusivity, especially:
|
||||
- helpers asserting a single run in storage
|
||||
- `ps` tests expecting global count equality
|
||||
- `system prune` tests filtering only by common workflow names like `Simple`
|
||||
|
||||
## Important Interface / Behavior Changes
|
||||
- `fabro-test::TestContext` uses a shared `storage_dir` per nextest run when `NEXTEST_RUN_ID` is set.
|
||||
- Test-created runs gain deterministic labels:
|
||||
- `fabro_test_run`
|
||||
- `fabro_test_case`
|
||||
- Test code must treat shared storage as normal under nextest and avoid assumptions based on storage exclusivity.
|
||||
|
||||
## Test Plan
|
||||
- `fabro-test` coverage:
|
||||
- multiple contexts in one nextest run resolve to the same shared storage dir
|
||||
- multiple contexts without `NEXTEST_RUN_ID` but in the same process resolve to the same shared storage dir
|
||||
- contexts still get distinct `temp_dir` and `home_dir`
|
||||
- marker-file cleanup removes stale prior session roots
|
||||
- last-process cleanup stops the daemon and removes the shared root
|
||||
- CLI integration updates:
|
||||
- helper coverage for resolving runs by exact ULID or test-case label
|
||||
- `ps`, `runs list`, and `system prune` tests updated to shared-storage-safe assertions
|
||||
- destructive tests verified to target only test-owned runs
|
||||
- End-to-end acceptance:
|
||||
- a full `cargo nextest run -p fabro-cli` should converge on one daemon per `NEXTEST_RUN_ID` storage root
|
||||
- a `cargo test --workspace` invocation should converge on one daemon per test process, not per `TestContext`
|
||||
- after the run, no test-owned `fabro.sock` daemon remains for that root
|
||||
- add an early validation test or harness check that concurrent auto-start against the same shared `storage_dir` converges on one daemon under parallel load
|
||||
|
||||
## Assumptions and Defaults
|
||||
- Production auto-start semantics for a single `storage_dir` are the source of truth and already provide duplicate-server protection via the existing lock/record path.
|
||||
- `NEXTEST_RUN_ID` is used when available to derive the shared nextest-run root; otherwise the current process PID is used to derive a shared per-process root.
|
||||
- The shared test storage root is fully separate from production storage in both modes.
|
||||
- No separate harness-managed daemon bootstrap protocol is added.
|
||||
- No changes to production daemon lifetime semantics are part of this plan.
|
||||
|
|
@ -209,7 +209,7 @@ Use the test helpers that reinforce the rules above.
|
|||
|
||||
### `TestContext`
|
||||
|
||||
Use `TestContext` for CLI integration tests so each test gets isolated home, storage, and temp directories.
|
||||
Use `TestContext` for CLI integration tests so each test gets isolated home and temp directories, with storage shared per nextest run or per test process depending on the harness mode.
|
||||
|
||||
Prefer helpers like:
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ fabro-graphviz = { path = "../fabro-graphviz" }
|
|||
fabro-validate = { path = "../fabro-validate" }
|
||||
fabro-workflow = { path = "../fabro-workflow" }
|
||||
fabro-server = { path = "../fabro-server" }
|
||||
fabro-api = { path = "../fabro-api" }
|
||||
fabro-telemetry = { path = "../fabro-telemetry" }
|
||||
fabro-store = { path = "../fabro-store" }
|
||||
fabro-types = { path = "../fabro-types" }
|
||||
|
|
@ -60,6 +61,7 @@ futures.workspace = true
|
|||
regex.workspace = true
|
||||
semver.workspace = true
|
||||
reqwest.workspace = true
|
||||
progenitor-client = "0.13"
|
||||
async-trait.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
base64.workspace = true
|
||||
|
|
|
|||
|
|
@ -3,19 +3,20 @@ use std::path::{Path, PathBuf};
|
|||
use anyhow::{Context, Result, bail};
|
||||
use fabro_store::RuntimeState;
|
||||
use fabro_workflow::artifacts::{ArtifactEntry, scan_artifacts};
|
||||
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
|
||||
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
|
||||
|
||||
use crate::args::{ArtifactCpArgs, GlobalArgs};
|
||||
use crate::server_client;
|
||||
use crate::shared::{print_json_pretty, split_run_path};
|
||||
use crate::store;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
pub(super) async fn cp_command(args: &ArtifactCpArgs, globals: &GlobalArgs) -> Result<()> {
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
let (run_id, asset_path) = parse_source(&args.source);
|
||||
let run = resolve_run_combined(store.as_ref(), &base, run_id).await?;
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run = resolve_run_from_summaries(&summaries, &base, run_id)?;
|
||||
let runtime_state = RuntimeState::new(&run.path);
|
||||
let entries = scan_artifacts(
|
||||
&runtime_state.artifacts_dir(),
|
||||
|
|
|
|||
|
|
@ -1,18 +1,19 @@
|
|||
use anyhow::Result;
|
||||
use fabro_store::RuntimeState;
|
||||
use fabro_workflow::artifacts::scan_artifacts;
|
||||
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
|
||||
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
|
||||
|
||||
use crate::args::{ArtifactListArgs, GlobalArgs};
|
||||
use crate::server_client;
|
||||
use crate::shared::format_size;
|
||||
use crate::store;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
pub(super) async fn list_command(args: &ArtifactListArgs, globals: &GlobalArgs) -> Result<()> {
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
let run = resolve_run_combined(store.as_ref(), &base, &args.run_id).await?;
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run = resolve_run_from_summaries(&summaries, &base, &args.run_id)?;
|
||||
let runtime_state = RuntimeState::new(&run.path);
|
||||
let entries = scan_artifacts(
|
||||
&runtime_state.artifacts_dir(),
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ use anyhow::{Context, Result, bail};
|
|||
use fabro_model::Catalog;
|
||||
use fabro_sandbox::daytona::detect_repo_info;
|
||||
use fabro_workflow::outcome::StageStatus;
|
||||
use fabro_workflow::pull_request::maybe_open_pull_request;
|
||||
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
|
||||
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::{GlobalArgs, PrCreateArgs};
|
||||
use crate::commands::store::rebuild::rebuild_run_store;
|
||||
use crate::shared::print_json_pretty;
|
||||
use crate::store;
|
||||
use crate::server_client;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
pub(super) async fn create_command(
|
||||
|
|
@ -30,9 +30,12 @@ async fn create_from(
|
|||
globals: &GlobalArgs,
|
||||
) -> Result<()> {
|
||||
let storage_dir = base.parent().unwrap_or(base);
|
||||
let store = store::build_store(storage_dir)?;
|
||||
let run = resolve_run_combined(store.as_ref(), base, &args.run_id).await?;
|
||||
let run_store = store::open_run_reader(storage_dir, &run.run_id()).await?;
|
||||
let client = server_client::connect_server(storage_dir).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run = resolve_run_from_summaries(&summaries, base, &args.run_id)?;
|
||||
let run_id = run.run_id();
|
||||
let events = client.list_run_events(&run_id, None, None).await?;
|
||||
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")?;
|
||||
|
|
@ -101,7 +104,7 @@ async fn create_from(
|
|||
.model
|
||||
.unwrap_or_else(|| Catalog::builtin().default_from_env().id.clone());
|
||||
|
||||
let record = maybe_open_pull_request(
|
||||
let record = fabro_workflow::pull_request::maybe_open_pull_request(
|
||||
&creds,
|
||||
&origin_url,
|
||||
base_branch,
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@ use std::path::Path;
|
|||
|
||||
use anyhow::{Context, Result};
|
||||
use fabro_types::PullRequestRecord;
|
||||
use fabro_workflow::run_lookup::{runs_base, scan_runs_combined};
|
||||
use fabro_workflow::run_lookup::{runs_base, scan_runs_with_summaries};
|
||||
use futures::future::join_all;
|
||||
use serde::Serialize;
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::{GlobalArgs, PrListArgs};
|
||||
use crate::shared::print_json_pretty;
|
||||
use crate::store;
|
||||
use crate::server_client;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -28,12 +28,14 @@ pub(super) async fn list_command(
|
|||
) -> Result<()> {
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
list_from(store.as_ref(), &base, args, github_app, globals).await
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
list_from(&client, &summaries, &base, args, github_app, globals).await
|
||||
}
|
||||
|
||||
async fn list_from(
|
||||
store: &fabro_store::SlateStore,
|
||||
client: &server_client::ServerStoreClient,
|
||||
summaries: &[fabro_store::RunSummary],
|
||||
base: &Path,
|
||||
args: PrListArgs,
|
||||
github_app: Option<fabro_github::GitHubAppCredentials>,
|
||||
|
|
@ -43,17 +45,13 @@ async fn list_from(
|
|||
"GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id",
|
||||
)?;
|
||||
|
||||
let runs = scan_runs_combined(store, base)
|
||||
.await
|
||||
.context("Failed to scan runs")?;
|
||||
let runs = scan_runs_with_summaries(summaries, base).context("Failed to scan runs")?;
|
||||
|
||||
let mut entries: Vec<(String, PullRequestRecord)> = Vec::new();
|
||||
for run in &runs {
|
||||
if let Ok(run_store) = store.open_run_reader(&run.run_id()).await {
|
||||
if let Ok(state) = run_store.state().await {
|
||||
if let Some(record) = state.pull_request {
|
||||
entries.push((run.run_id().to_string(), record));
|
||||
}
|
||||
if let Ok(state) = client.get_run_state(&run.run_id()).await {
|
||||
if let Some(record) = state.pull_request {
|
||||
entries.push((run.run_id().to_string(), record));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ use std::path::{Path, PathBuf};
|
|||
use anyhow::{Context, Result};
|
||||
|
||||
use fabro_types::PullRequestRecord;
|
||||
use fabro_workflow::run_lookup::resolve_run_combined;
|
||||
use fabro_workflow::run_lookup::resolve_run_from_summaries;
|
||||
|
||||
use crate::args::{GlobalArgs, PrCommand, PrNamespace};
|
||||
use crate::shared::github::build_github_app_credentials;
|
||||
use crate::store;
|
||||
use crate::server_client;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
pub(crate) async fn dispatch(ns: PrNamespace, globals: &GlobalArgs) -> Result<()> {
|
||||
|
|
@ -36,12 +36,12 @@ pub(crate) async fn load_pr_record(
|
|||
run_id: &str,
|
||||
) -> Result<(PullRequestRecord, PathBuf)> {
|
||||
let storage_dir = base.parent().unwrap_or(base);
|
||||
let store = store::build_store(storage_dir)?;
|
||||
let run = resolve_run_combined(store.as_ref(), base, run_id).await?;
|
||||
let client = server_client::connect_server(storage_dir).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run = resolve_run_from_summaries(&summaries, base, run_id)?;
|
||||
let run_id = run.run_id();
|
||||
let run_dir = run.path;
|
||||
let run_store = store::open_run_reader(storage_dir, &run_id).await?;
|
||||
let state = run_store.state().await?;
|
||||
let state = client.get_run_state(&run_id).await?;
|
||||
let record = state.pull_request.with_context(|| {
|
||||
format!("No pull request found in store. Create one first with: fabro pr create {run_id}")
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -7,19 +7,18 @@ use std::time::{Duration, Instant};
|
|||
|
||||
use anyhow::Result;
|
||||
use fabro_types::RunId;
|
||||
use futures::StreamExt;
|
||||
|
||||
use fabro_interview::{AnswerValue, ConsoleInterviewer};
|
||||
use fabro_store::{EventEnvelope, RuntimeState, SlateRunStore};
|
||||
use fabro_store::{EventEnvelope, RuntimeState};
|
||||
use fabro_util::json::normalize_json_value;
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_workflow::outcome::StageStatus;
|
||||
use fabro_workflow::run_status::RunStatus;
|
||||
use tokio::signal::ctrl_c;
|
||||
use tokio::time::{self, sleep};
|
||||
use tokio::time::sleep;
|
||||
|
||||
use super::run_progress;
|
||||
use crate::store;
|
||||
use crate::server_client;
|
||||
|
||||
#[cfg(test)]
|
||||
const ATTACH_STARTUP_GRACE: Duration = Duration::from_millis(200);
|
||||
|
|
@ -28,6 +27,9 @@ const ATTACH_STARTUP_GRACE: Duration = Duration::from_secs(3);
|
|||
const INTERVIEW_UNANSWERED_MESSAGE: &str =
|
||||
"Interview ended without an answer. The run is still waiting for input; reattach to answer it.";
|
||||
const JSON_INTERVIEW_MESSAGE: &str = "This run is waiting for human input, but --json is non-interactive. Reattach without --json to answer it.";
|
||||
#[cfg(test)]
|
||||
const ATTACH_FINAL_STATUS_GRACE: Duration = Duration::from_millis(250);
|
||||
#[cfg(not(test))]
|
||||
const ATTACH_FINAL_STATUS_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Attach to a running (or finished) workflow run, rendering progress live.
|
||||
|
|
@ -48,44 +50,30 @@ pub(crate) async fn attach_run(
|
|||
let run_id = run_id.copied().or(inferred_run_id);
|
||||
|
||||
if let (Some(storage_dir), Some(run_id)) = (storage_dir.as_deref(), run_id.as_ref()) {
|
||||
match store::open_run_reader(storage_dir, run_id).await {
|
||||
Ok(run_store) => match run_store.list_events().await {
|
||||
Ok(events) => {
|
||||
let verbose = run_store
|
||||
.state()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|state| state.run)
|
||||
.is_some_and(|record| record.settings.verbose_enabled());
|
||||
let event_lines = events
|
||||
.iter()
|
||||
.map(event_payload_line)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
return attach_run_store(
|
||||
run_dir,
|
||||
&run_store,
|
||||
verbose,
|
||||
event_lines,
|
||||
events.last().map_or(0, |event| event.seq),
|
||||
kill_on_detach,
|
||||
styles,
|
||||
engine_child,
|
||||
json_output,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to list events from SlateDB for run {run_id}: {err}"
|
||||
));
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to open SlateDB reader for run {run_id}: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
let client = server_client::connect_server(storage_dir).await?;
|
||||
let state = client.get_run_state(run_id).await?;
|
||||
let verbose = state
|
||||
.run
|
||||
.as_ref()
|
||||
.is_some_and(|record| record.settings.verbose_enabled());
|
||||
let events = client.list_run_events(run_id, None, None).await?;
|
||||
let event_lines = events
|
||||
.iter()
|
||||
.map(event_payload_line)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
return attach_run_server(
|
||||
run_dir,
|
||||
&client,
|
||||
run_id,
|
||||
verbose,
|
||||
event_lines,
|
||||
events.last().map_or(0, |event| event.seq),
|
||||
kill_on_detach,
|
||||
styles,
|
||||
engine_child,
|
||||
json_output,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
|
|
@ -93,9 +81,10 @@ pub(crate) async fn attach_run(
|
|||
))
|
||||
}
|
||||
|
||||
async fn attach_run_store(
|
||||
async fn attach_run_server(
|
||||
run_dir: &Path,
|
||||
run_store: &SlateRunStore,
|
||||
client: &server_client::ServerStoreClient,
|
||||
run_id: &RunId,
|
||||
verbose: bool,
|
||||
existing_events: Vec<String>,
|
||||
last_seq: u32,
|
||||
|
|
@ -126,24 +115,26 @@ async fn attach_run_store(
|
|||
emit_progress_line(&mut progress_ui, line, json_output)?;
|
||||
}
|
||||
|
||||
let mut stream = run_store.watch_events_from(if last_seq == 0 { 1 } else { last_seq + 1 })?;
|
||||
let mut next_seq = if last_seq == 0 { 1 } else { last_seq + 1 };
|
||||
let mut cached_pid: Option<u32> = None;
|
||||
let attach_started = Instant::now();
|
||||
|
||||
loop {
|
||||
let server_owned = engine_guard.is_none() && read_launcher_pid(run_dir).is_none();
|
||||
if cancelled.load(Ordering::Relaxed) {
|
||||
if kill_on_detach {
|
||||
if let Some(guard) = engine_guard.as_mut() {
|
||||
if let Some(child) = guard.inner() {
|
||||
let _ = child.kill();
|
||||
}
|
||||
} else if server_owned {
|
||||
let _ = client.cancel_run(run_id).await;
|
||||
} else {
|
||||
kill_engine(run_dir);
|
||||
}
|
||||
// Wait briefly for a terminal status or conclusion
|
||||
for _ in 0..20 {
|
||||
if run_store.state().await.ok().is_some_and(|state| {
|
||||
if client.get_run_state(run_id).await.ok().is_some_and(|state| {
|
||||
state.conclusion.is_some()
|
||||
|| state
|
||||
.status
|
||||
|
|
@ -163,15 +154,12 @@ async fn attach_run_store(
|
|||
}
|
||||
|
||||
let mut saw_event = false;
|
||||
match time::timeout(Duration::from_millis(100), stream.next()).await {
|
||||
Ok(Some(Ok(event))) => {
|
||||
let line = event_payload_line(&event)?;
|
||||
emit_progress_line(&mut progress_ui, &line, json_output)?;
|
||||
next_seq = event.seq.saturating_add(1);
|
||||
saw_event = true;
|
||||
}
|
||||
Ok(Some(Err(err))) => return Err(err.into()),
|
||||
Ok(None) | Err(_) => {}
|
||||
let events = client.list_run_events(run_id, Some(next_seq), None).await?;
|
||||
for event in events {
|
||||
let line = event_payload_line(&event)?;
|
||||
emit_progress_line(&mut progress_ui, &line, json_output)?;
|
||||
next_seq = event.seq.saturating_add(1);
|
||||
saw_event = true;
|
||||
}
|
||||
|
||||
// Check for interview request
|
||||
|
|
@ -220,8 +208,8 @@ async fn attach_run_store(
|
|||
}
|
||||
}
|
||||
|
||||
let terminal_status = run_store
|
||||
.state()
|
||||
let terminal_status = client
|
||||
.get_run_state(run_id)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|state| state.status.map(|record| record.status))
|
||||
|
|
@ -236,43 +224,52 @@ async fn attach_run_store(
|
|||
|
||||
if let Some(child_alive) = child_alive_via_handle {
|
||||
if !child_alive && !saw_event {
|
||||
flush_remaining_store_events(run_store, next_seq, &mut progress_ui, json_output)
|
||||
flush_remaining_server_events(client, run_id, next_seq, &mut progress_ui, json_output)
|
||||
.await?;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if terminal_status.is_some() && !saw_event {
|
||||
flush_remaining_store_events(run_store, next_seq, &mut progress_ui, json_output)
|
||||
flush_remaining_server_events(client, run_id, next_seq, &mut progress_ui, json_output)
|
||||
.await?;
|
||||
break;
|
||||
}
|
||||
|
||||
let engine_alive = match cached_pid {
|
||||
Some(pid) => process_alive(pid),
|
||||
None => {
|
||||
if let Some(pid) = read_launcher_pid(run_dir) {
|
||||
cached_pid = Some(pid);
|
||||
process_alive(pid)
|
||||
} else {
|
||||
attach_started.elapsed() < ATTACH_STARTUP_GRACE
|
||||
let engine_alive = if server_owned {
|
||||
true
|
||||
} else {
|
||||
match cached_pid {
|
||||
Some(pid) => process_alive(pid),
|
||||
None => {
|
||||
if let Some(pid) = read_launcher_pid(run_dir) {
|
||||
cached_pid = Some(pid);
|
||||
process_alive(pid)
|
||||
} else {
|
||||
attach_started.elapsed() < ATTACH_STARTUP_GRACE
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if !engine_alive {
|
||||
flush_remaining_store_events(run_store, next_seq, &mut progress_ui, json_output)
|
||||
flush_remaining_server_events(client, run_id, next_seq, &mut progress_ui, json_output)
|
||||
.await?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !saw_event {
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
finish_progress(&mut progress_ui, json_output);
|
||||
|
||||
Ok(determine_exit_code_with_store(run_store).await)
|
||||
Ok(determine_exit_code_with_server(client, run_id).await)
|
||||
}
|
||||
|
||||
async fn flush_remaining_store_events(
|
||||
run_store: &SlateRunStore,
|
||||
async fn flush_remaining_server_events(
|
||||
client: &server_client::ServerStoreClient,
|
||||
run_id: &RunId,
|
||||
mut next_seq: u32,
|
||||
progress_ui: &mut run_progress::ProgressUI,
|
||||
json_output: bool,
|
||||
|
|
@ -280,12 +277,7 @@ async fn flush_remaining_store_events(
|
|||
let deadline = Instant::now() + ATTACH_FINAL_STATUS_GRACE;
|
||||
loop {
|
||||
let mut saw_new_event = false;
|
||||
let events = run_store
|
||||
.list_events()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|e| e.seq >= next_seq)
|
||||
.collect::<Vec<_>>();
|
||||
let events = client.list_run_events(run_id, Some(next_seq), None).await?;
|
||||
for event in events {
|
||||
let line = event_payload_line(&event)?;
|
||||
emit_progress_line(progress_ui, &line, json_output)?;
|
||||
|
|
@ -339,8 +331,30 @@ fn show_progress(progress_ui: &mut run_progress::ProgressUI, json_output: bool)
|
|||
}
|
||||
|
||||
fn event_payload_line(event: &EventEnvelope) -> Result<String> {
|
||||
serde_json::to_string(&normalize_json_value(event.payload.as_value().clone()))
|
||||
.map_err(Into::into)
|
||||
let mut value = normalize_json_value(event.payload.as_value().clone());
|
||||
restore_empty_run_properties(&mut value);
|
||||
serde_json::to_string(&value).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn restore_empty_run_properties(value: &mut serde_json::Value) {
|
||||
let Some(object) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(event_name) = object.get("event").and_then(serde_json::Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
if matches!(event_name, "run.submitted" | "run.running") && !object.contains_key("properties")
|
||||
{
|
||||
let run_id = object.remove("run_id");
|
||||
let ts = object.remove("ts");
|
||||
object.insert("properties".to_string(), serde_json::json!({}));
|
||||
if let Some(run_id) = run_id {
|
||||
object.insert("run_id".to_string(), run_id);
|
||||
}
|
||||
if let Some(ts) = ts {
|
||||
object.insert("ts".to_string(), ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_launcher_pid(run_dir: &Path) -> Option<u32> {
|
||||
|
|
@ -485,10 +499,13 @@ fn write_interview_response_atomically(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn determine_exit_code_with_store(run_store: &SlateRunStore) -> ExitCode {
|
||||
async fn determine_exit_code_with_server(
|
||||
client: &server_client::ServerStoreClient,
|
||||
run_id: &RunId,
|
||||
) -> ExitCode {
|
||||
let deadline = Instant::now() + ATTACH_FINAL_STATUS_GRACE;
|
||||
loop {
|
||||
if let Ok(state) = run_store.state().await {
|
||||
if let Ok(state) = client.get_run_state(run_id).await {
|
||||
if let Some(conclusion) = state.conclusion {
|
||||
let success = matches!(
|
||||
conclusion.status,
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ pub(crate) async fn execute(mut args: RunArgs, globals: &GlobalArgs) -> Result<(
|
|||
#[cfg(not(feature = "sleep_inhibitor"))]
|
||||
let _ = prevent_idle_sleep;
|
||||
|
||||
let child =
|
||||
super::start::start_run(&run_dir, &run_id, &cli_settings.storage_dir(), false).await?;
|
||||
super::start::start_run(&run_id, &cli_settings.storage_dir(), false).await?;
|
||||
|
||||
if args.detach {
|
||||
if globals.json {
|
||||
|
|
@ -37,7 +36,7 @@ pub(crate) async fn execute(mut args: RunArgs, globals: &GlobalArgs) -> Result<(
|
|||
Some(&run_id),
|
||||
true,
|
||||
styles,
|
||||
Some(child),
|
||||
None,
|
||||
globals.json,
|
||||
)
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -3,13 +3,13 @@ use std::path::{Path, PathBuf};
|
|||
use anyhow::{Context, Result, bail};
|
||||
use fabro_agent::sandbox::Sandbox;
|
||||
use fabro_sandbox::reconnect::reconnect;
|
||||
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
|
||||
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
|
||||
use tokio::fs;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::args::{CpArgs, GlobalArgs};
|
||||
use crate::server_client;
|
||||
use crate::shared::{print_json_pretty, split_run_path};
|
||||
use crate::store;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
enum CopyDirection {
|
||||
|
|
@ -128,11 +128,11 @@ async fn load_sandbox(
|
|||
base: &Path,
|
||||
run_prefix: &str,
|
||||
) -> Result<Box<dyn Sandbox>> {
|
||||
let store = store::build_store(storage_dir)?;
|
||||
let run = resolve_run_combined(store.as_ref(), base, run_prefix).await?;
|
||||
let run_store = store::open_run_reader(storage_dir, &run.run_id()).await?;
|
||||
let record = run_store
|
||||
.state()
|
||||
let client = server_client::connect_server(storage_dir).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run = resolve_run_from_summaries(&summaries, base, run_prefix)?;
|
||||
let record = client
|
||||
.get_run_state(&run.run_id())
|
||||
.await?
|
||||
.sandbox
|
||||
.context("Failed to load sandbox record from store")?;
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@ use crate::args::RunArgs;
|
|||
use fabro_config::ConfigLayer;
|
||||
use fabro_types::{RunId, Settings};
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_workflow::error::FabroError;
|
||||
use fabro_workflow::operations::{CreateRunInput, WorkflowInput, create};
|
||||
use fabro_workflow::operations::{ValidateInput, WorkflowInput, make_run_dir, validate};
|
||||
|
||||
use super::output::{print_diagnostics_from_error, print_workflow_report_from_persisted};
|
||||
use crate::store;
|
||||
use super::output::print_workflow_report;
|
||||
use crate::server_client;
|
||||
|
||||
/// Create a workflow run: allocate run directory, persist RunRecord, return (run_id, run_dir).
|
||||
///
|
||||
|
|
@ -37,39 +36,25 @@ pub(crate) async fn create_run(
|
|||
.transpose()
|
||||
.map_err(|err| anyhow::anyhow!("invalid run ID: {err}"))?;
|
||||
|
||||
let store = store::build_store(settings.storage_dir().as_path())?;
|
||||
|
||||
let created = match Box::pin(create(
|
||||
store.as_ref(),
|
||||
CreateRunInput {
|
||||
workflow: WorkflowInput::Path(workflow_path.clone()),
|
||||
settings,
|
||||
cwd,
|
||||
workflow_slug: None,
|
||||
run_id,
|
||||
base_branch: None,
|
||||
host_repo_path: None,
|
||||
},
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(created) => created,
|
||||
Err(FabroError::ValidationFailed { diagnostics }) => {
|
||||
if !quiet {
|
||||
print_diagnostics_from_error(&diagnostics, styles);
|
||||
}
|
||||
anyhow::bail!("Validation failed");
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
if !quiet {
|
||||
print_workflow_report_from_persisted(
|
||||
&created.persisted,
|
||||
created.dot_path.as_deref(),
|
||||
styles,
|
||||
);
|
||||
let validated = validate(ValidateInput {
|
||||
workflow: WorkflowInput::Path(workflow_path.clone()),
|
||||
settings: settings.clone(),
|
||||
cwd: cwd.clone(),
|
||||
custom_transforms: Vec::new(),
|
||||
});
|
||||
if let Ok(validated) = validated {
|
||||
if !validated.has_errors() {
|
||||
print_workflow_report(&validated, Some(workflow_path.as_path()), styles);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok((created.run_id, created.run_dir))
|
||||
let client = server_client::connect_server(settings.storage_dir().as_path()).await?;
|
||||
let created_run_id = client
|
||||
.create_run_from_workflow_path(workflow_path, &cwd, &settings, run_id.as_ref())
|
||||
.await?;
|
||||
let run_dir = make_run_dir(&settings.storage_dir().join("runs"), &created_run_id);
|
||||
|
||||
Ok((created_run_id, run_dir))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,15 @@
|
|||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use fabro_interview::FileInterviewer;
|
||||
use fabro_store::RuntimeState;
|
||||
use fabro_types::RunId;
|
||||
use fabro_workflow::event::Emitter;
|
||||
use fabro_workflow::operations::{StartServices, resume as resume_run, start as start_run};
|
||||
use fabro_types::{RunId, RunStatus};
|
||||
|
||||
use crate::shared;
|
||||
use crate::store;
|
||||
use crate::server_client;
|
||||
use crate::user_config::load_user_settings;
|
||||
|
||||
pub(crate) async fn execute(
|
||||
run_id: RunId,
|
||||
run_dir: PathBuf,
|
||||
_run_dir: PathBuf,
|
||||
storage_dir: Option<PathBuf>,
|
||||
launcher_path: PathBuf,
|
||||
resume: bool,
|
||||
|
|
@ -28,45 +24,28 @@ pub(crate) async fn execute(
|
|||
Some(storage_dir) => storage_dir,
|
||||
None => load_user_settings()?.storage_dir(),
|
||||
};
|
||||
let store = store::build_store(&storage_dir)?;
|
||||
let run_store = store.open_run(&run_id).await?;
|
||||
let run_record = run_store
|
||||
.state()
|
||||
.await?
|
||||
.run
|
||||
.ok_or_else(|| anyhow!("Run {run_id} has no run record in store"))?;
|
||||
let on_node: fabro_workflow::OnNodeCallback = Some({
|
||||
let run_id = run_record.run_id.to_string();
|
||||
let short_id = super::short_run_id(&run_id).to_string();
|
||||
fabro_proc::title_set(&format!("fabro: {short_id}"));
|
||||
Arc::new(move |node_id: &str| {
|
||||
fabro_proc::title_set(&format!("fabro: {short_id} {node_id}"));
|
||||
}) as Arc<dyn Fn(&str) + Send + Sync>
|
||||
});
|
||||
|
||||
let github_app = shared::github::build_github_app_credentials(run_record.settings.app_id())?;
|
||||
let runtime_state = RuntimeState::new(&run_dir);
|
||||
let client = server_client::connect_server(&storage_dir).await?;
|
||||
client.start_run(&run_id, resume).await?;
|
||||
|
||||
let services = StartServices {
|
||||
run_id: run_record.run_id,
|
||||
cancel_token: None,
|
||||
emitter: Arc::new(Emitter::new(run_record.run_id)),
|
||||
interviewer: Arc::new(FileInterviewer::new(
|
||||
runtime_state.interview_request_path(),
|
||||
runtime_state.interview_response_path(),
|
||||
runtime_state.interview_claim_path(),
|
||||
)),
|
||||
run_store,
|
||||
github_app,
|
||||
on_node,
|
||||
registry_override: None,
|
||||
};
|
||||
loop {
|
||||
let state = client.get_run_state(&run_id).await?;
|
||||
let Some(status) = state.status.as_ref().map(|record| record.status) else {
|
||||
return Err(anyhow!("Run {run_id} has no status record in store"));
|
||||
};
|
||||
|
||||
if resume {
|
||||
let _ = resume_run(&run_dir, services).await?;
|
||||
} else {
|
||||
let _ = start_run(&run_dir, services).await?;
|
||||
match status {
|
||||
RunStatus::Succeeded => return Ok(()),
|
||||
RunStatus::Failed | RunStatus::Dead => {
|
||||
return Err(anyhow!("Run {run_id} finished with status {status}"));
|
||||
}
|
||||
RunStatus::Submitted
|
||||
| RunStatus::Starting
|
||||
| RunStatus::Running
|
||||
| RunStatus::Paused
|
||||
| RunStatus::Removing => {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,25 +3,26 @@ use std::path::Path;
|
|||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use fabro_sandbox::reconnect::reconnect;
|
||||
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
|
||||
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
|
||||
use fabro_workflow::sandbox_git::GIT_REMOTE;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::args::{DiffArgs, GlobalArgs};
|
||||
use crate::shared::print_json_pretty;
|
||||
use crate::store;
|
||||
use crate::server_client;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
pub(crate) async fn run(args: DiffArgs, globals: &GlobalArgs) -> Result<()> {
|
||||
info!(run_id = %args.run, "Showing diff");
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?;
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run = resolve_run_from_summaries(&summaries, &base, &args.run)?;
|
||||
let run_id = run.run_id();
|
||||
let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run_id).await?;
|
||||
let state = client.get_run_state(&run_id).await?;
|
||||
|
||||
let patch = resolve_diff(&run.path, &run_store, &args).await?;
|
||||
let patch = resolve_diff(&run.path, &state, &args).await?;
|
||||
|
||||
if globals.json {
|
||||
let mut value = serde_json::json!({
|
||||
|
|
@ -53,10 +54,9 @@ pub(crate) async fn run(args: DiffArgs, globals: &GlobalArgs) -> Result<()> {
|
|||
|
||||
async fn resolve_diff(
|
||||
_run_dir: &Path,
|
||||
run_store: &fabro_store::SlateRunStore,
|
||||
state: &crate::server_client::RunProjection,
|
||||
args: &DiffArgs,
|
||||
) -> Result<String> {
|
||||
let state = run_store.state().await?;
|
||||
if let Some(ref node_id) = args.node {
|
||||
if let Some(visit) = state.list_node_visits(node_id).into_iter().max() {
|
||||
if let Some(node) = state.node(&fabro_store::StageId::new(node_id, visit)) {
|
||||
|
|
@ -72,6 +72,7 @@ async fn resolve_diff(
|
|||
|
||||
let start = state
|
||||
.start
|
||||
.clone()
|
||||
.context("Failed to load start record from store")?;
|
||||
|
||||
let base_sha = start
|
||||
|
|
@ -79,7 +80,7 @@ async fn resolve_diff(
|
|||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("This run was not git-checkpointed; no diff available"))?;
|
||||
|
||||
if let Some(patch) = state.final_patch {
|
||||
if let Some(patch) = state.final_patch.clone() {
|
||||
debug!("Reading final.patch from store");
|
||||
return Ok(patch);
|
||||
}
|
||||
|
|
@ -94,6 +95,7 @@ async fn resolve_diff(
|
|||
debug!("No final.patch found; attempting live diff from sandbox");
|
||||
let record = state
|
||||
.sandbox
|
||||
.clone()
|
||||
.context("Failed to load sandbox record from store")?;
|
||||
|
||||
info!(provider = %record.provider, "Reconnecting to sandbox for live diff");
|
||||
|
|
|
|||
|
|
@ -1,28 +1,26 @@
|
|||
use std::fmt::Write as _;
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use chrono::{DateTime, Utc};
|
||||
use fabro_store::SlateRunStore;
|
||||
use fabro_util::json::normalize_json_value;
|
||||
use fabro_util::redact::redact_jsonl_line;
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
|
||||
use futures::StreamExt;
|
||||
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
|
||||
use tokio::time;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::args::{GlobalArgs, LogsArgs};
|
||||
use crate::store;
|
||||
use crate::server_client;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
pub(crate) async fn run(args: &LogsArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> {
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?;
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run = resolve_run_from_summaries(&summaries, &base, &args.run)?;
|
||||
|
||||
let run_id = run.run_id();
|
||||
info!(run_id = %run_id, "Showing logs");
|
||||
|
|
@ -32,20 +30,15 @@ pub(crate) async fn run(args: &LogsArgs, styles: &Styles, globals: &GlobalArgs)
|
|||
None => None,
|
||||
};
|
||||
|
||||
let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run_id).await?;
|
||||
let (all_lines, last_seq) = match run_store.list_events().await {
|
||||
Ok(events) => {
|
||||
let last_seq = events.last().map_or(0, |event| event.seq);
|
||||
let lines = events
|
||||
.iter()
|
||||
.map(event_payload_line)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
(lines, last_seq)
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(err).context("Failed to list store-backed run events");
|
||||
}
|
||||
};
|
||||
let events = client
|
||||
.list_run_events(&run_id, None, None)
|
||||
.await
|
||||
.context("Failed to list server-backed run events")?;
|
||||
let last_seq = events.last().map_or(0, |event| event.seq);
|
||||
let all_lines = events
|
||||
.iter()
|
||||
.map(event_payload_line)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let filtered = apply_filters(&all_lines, since_cutoff.as_ref(), args.tail);
|
||||
|
||||
let stdout = io::stdout();
|
||||
|
|
@ -65,8 +58,8 @@ pub(crate) async fn run(args: &LogsArgs, styles: &Styles, globals: &GlobalArgs)
|
|||
|
||||
if args.follow {
|
||||
follow_store_logs(
|
||||
&run_store,
|
||||
&run.path,
|
||||
&client,
|
||||
&run_id,
|
||||
if last_seq == 0 { 1 } else { last_seq + 1 },
|
||||
pretty,
|
||||
styles,
|
||||
|
|
@ -139,23 +132,21 @@ fn try_parse_relative_duration(s: &str) -> Option<chrono::Duration> {
|
|||
}
|
||||
|
||||
async fn follow_store_logs(
|
||||
run_store: &SlateRunStore,
|
||||
run_dir: &Path,
|
||||
client: &server_client::ServerStoreClient,
|
||||
run_id: &fabro_types::RunId,
|
||||
seq: u32,
|
||||
pretty: bool,
|
||||
styles: &Styles,
|
||||
_is_tty: bool,
|
||||
) -> Result<()> {
|
||||
let mut stream = run_store
|
||||
.watch_events_from(seq)
|
||||
.context("Failed to watch store-backed run events")?;
|
||||
let stdout = io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
let mut next_seq = seq;
|
||||
|
||||
loop {
|
||||
match time::timeout(Duration::from_millis(200), stream.next()).await {
|
||||
Ok(Some(Ok(event))) => {
|
||||
match time::timeout(Duration::from_millis(200), client.list_run_events(run_id, Some(next_seq), None)).await {
|
||||
Ok(Ok(events)) => {
|
||||
for event in events {
|
||||
let line = event_payload_line(&event)?;
|
||||
if pretty {
|
||||
if let Some(formatted) = format_event_pretty(&line, styles) {
|
||||
|
|
@ -167,31 +158,30 @@ async fn follow_store_logs(
|
|||
out.flush()?;
|
||||
next_seq = event.seq.saturating_add(1);
|
||||
}
|
||||
Ok(Some(Err(err))) => return Err(err.into()),
|
||||
Ok(None) => {
|
||||
if run_concluded(run_store, run_dir).await? {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
if run_concluded(run_store, run_dir).await? {
|
||||
flush_remaining_store_events(run_store, next_seq, pretty, styles, &mut out)
|
||||
if run_concluded(client, run_id).await? {
|
||||
flush_remaining_store_events(client, run_id, next_seq, pretty, styles, &mut out)
|
||||
.await?;
|
||||
debug!("Run reached terminal status, stopping follow");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_concluded(run_store: &SlateRunStore, _run_dir: &Path) -> Result<bool> {
|
||||
let state = run_store
|
||||
.state()
|
||||
async fn run_concluded(
|
||||
client: &server_client::ServerStoreClient,
|
||||
run_id: &fabro_types::RunId,
|
||||
) -> Result<bool> {
|
||||
let state = client
|
||||
.get_run_state(run_id)
|
||||
.await
|
||||
.context("Failed to read run state from store while following logs")?;
|
||||
.context("Failed to read run state from server while following logs")?;
|
||||
Ok(state.conclusion.is_some()
|
||||
|| state
|
||||
.status
|
||||
|
|
@ -199,18 +189,19 @@ async fn run_concluded(run_store: &SlateRunStore, _run_dir: &Path) -> Result<boo
|
|||
}
|
||||
|
||||
async fn flush_remaining_store_events(
|
||||
run_store: &SlateRunStore,
|
||||
client: &server_client::ServerStoreClient,
|
||||
run_id: &fabro_types::RunId,
|
||||
next_seq: u32,
|
||||
pretty: bool,
|
||||
styles: &Styles,
|
||||
out: &mut dyn Write,
|
||||
) -> Result<()> {
|
||||
let events = run_store
|
||||
.list_events()
|
||||
let events = client
|
||||
.list_run_events(run_id, Some(next_seq), None)
|
||||
.await
|
||||
.context("Failed to list store-backed run events while finalizing follow")?;
|
||||
.context("Failed to list server-backed run events while finalizing follow")?;
|
||||
|
||||
for event in events.into_iter().filter(|event| event.seq >= next_seq) {
|
||||
for event in events {
|
||||
let line = event_payload_line(&event)?;
|
||||
if pretty {
|
||||
if let Some(formatted) = format_event_pretty(&line, styles) {
|
||||
|
|
@ -225,10 +216,33 @@ async fn flush_remaining_store_events(
|
|||
}
|
||||
|
||||
fn event_payload_line(event: &fabro_store::EventEnvelope) -> Result<String> {
|
||||
let line = serde_json::to_string(&normalize_json_value(event.payload.as_value().clone()))?;
|
||||
let mut value = normalize_json_value(event.payload.as_value().clone());
|
||||
restore_empty_run_properties(&mut value);
|
||||
let line = serde_json::to_string(&value)?;
|
||||
Ok(redact_jsonl_line(&line))
|
||||
}
|
||||
|
||||
fn restore_empty_run_properties(value: &mut serde_json::Value) {
|
||||
let Some(object) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(event_name) = object.get("event").and_then(serde_json::Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
if matches!(event_name, "run.submitted" | "run.running") && !object.contains_key("properties")
|
||||
{
|
||||
let run_id = object.remove("run_id");
|
||||
let ts = object.remove("ts");
|
||||
object.insert("properties".to_string(), serde_json::json!({}));
|
||||
if let Some(run_id) = run_id {
|
||||
object.insert("run_id".to_string(), run_id);
|
||||
}
|
||||
if let Some(ts) = ts {
|
||||
object.insert("ts".to_string(), ts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_indented_markdown(styles: &Styles, text: &str, indent: &str) -> String {
|
||||
let term_width = Styles::terminal_width();
|
||||
let wrap_width = term_width.saturating_sub(indent.len());
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
use anyhow::Result;
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
|
||||
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
|
||||
|
||||
use crate::args::{GlobalArgs, RunArgs, RunCommands};
|
||||
use crate::server_client;
|
||||
use crate::shared::print_json_pretty;
|
||||
use crate::store;
|
||||
use crate::user_config::{load_user_settings_with_globals, user_layer_with_globals};
|
||||
|
||||
pub(crate) mod attach;
|
||||
|
|
@ -57,16 +57,13 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<(
|
|||
RunCommands::Start { run } => {
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
let run_info = resolve_run_combined(store.as_ref(), &base, &run).await?;
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run_info = resolve_run_from_summaries(&summaries, &base, &run)?;
|
||||
let run_id = run_info.run_id();
|
||||
let child =
|
||||
start::start_run(&run_info.path, &run_id, &cli_settings.storage_dir(), false)
|
||||
.await?;
|
||||
start::start_run(&run_id, &cli_settings.storage_dir(), false).await?;
|
||||
if globals.json {
|
||||
print_json_pretty(&serde_json::json!({ "run_id": run_id }))?;
|
||||
} else {
|
||||
eprintln!("Started engine process (PID {})", child.id());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -74,8 +71,9 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<(
|
|||
let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr()));
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
let run_info = resolve_run_combined(store.as_ref(), &base, &run).await?;
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run_info = resolve_run_from_summaries(&summaries, &base, &run)?;
|
||||
let run_id = run_info.run_id();
|
||||
let exit_code = attach::attach_run(
|
||||
&run_info.path,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use fabro_workflow::records::Conclusion;
|
|||
use indicatif::HumanDuration;
|
||||
|
||||
use crate::shared::{format_tokens_human, print_diagnostics, relative_path, tilde_path};
|
||||
use crate::store;
|
||||
use crate::server_client;
|
||||
|
||||
fn print_workflow_header(
|
||||
graph: &Graph,
|
||||
|
|
@ -78,16 +78,17 @@ pub(crate) async fn print_run_summary(
|
|||
styles: &Styles,
|
||||
) -> Result<()> {
|
||||
let run_id = run_id.to_string();
|
||||
let (run_store, conclusion, pr_url) = match run_id.parse() {
|
||||
let (checkpoint, conclusion, pr_url) = match run_id.parse() {
|
||||
Ok(parsed_run_id) => {
|
||||
let run_store = store::open_run_reader(storage_dir, &parsed_run_id).await?;
|
||||
let run_state = run_store.state().await?;
|
||||
let client = server_client::connect_server(storage_dir).await?;
|
||||
let run_state = client.get_run_state(&parsed_run_id).await?;
|
||||
let checkpoint = run_state.checkpoint.clone();
|
||||
let conclusion = run_state.conclusion.clone();
|
||||
let pr_url = run_state
|
||||
.pull_request
|
||||
.as_ref()
|
||||
.map(|record: &PullRequestRecord| record.html_url.clone());
|
||||
(Some(run_store), conclusion, pr_url)
|
||||
(checkpoint, conclusion, pr_url)
|
||||
}
|
||||
Err(_) => (None, None, None),
|
||||
};
|
||||
|
|
@ -103,7 +104,7 @@ pub(crate) async fn print_run_summary(
|
|||
pr_url.as_deref(),
|
||||
styles,
|
||||
);
|
||||
print_final_output(run_store.as_ref(), run_dir, styles).await;
|
||||
print_final_output(checkpoint.as_ref(), run_dir, styles).await;
|
||||
print_assets(run_dir, styles);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -198,18 +199,10 @@ pub(crate) fn print_run_conclusion(
|
|||
}
|
||||
|
||||
pub(crate) async fn print_final_output(
|
||||
run_store: Option<&fabro_store::SlateRunStore>,
|
||||
checkpoint: Option<&fabro_types::Checkpoint>,
|
||||
_run_dir: &Path,
|
||||
styles: &Styles,
|
||||
) {
|
||||
let checkpoint = match run_store {
|
||||
Some(run_store) => run_store
|
||||
.state()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|state| state.checkpoint),
|
||||
None => None,
|
||||
};
|
||||
let Some(checkpoint) = checkpoint else {
|
||||
return;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
use anyhow::{Context, Result};
|
||||
use fabro_sandbox::daytona::DaytonaSandbox;
|
||||
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
|
||||
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::{GlobalArgs, PreviewArgs};
|
||||
use crate::shared::{print_json_pretty, validate_daytona_provider};
|
||||
use crate::store;
|
||||
use crate::server_client;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
pub(crate) async fn run(args: PreviewArgs, globals: &GlobalArgs) -> Result<()> {
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?;
|
||||
let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run.run_id()).await?;
|
||||
let record = run_store
|
||||
.state()
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run = resolve_run_from_summaries(&summaries, &base, &args.run)?;
|
||||
let record = client
|
||||
.get_run_state(&run.run_id())
|
||||
.await?
|
||||
.sandbox
|
||||
.context("Failed to load sandbox record from store")?;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
use anyhow::bail;
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
|
||||
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
|
||||
|
||||
use crate::args::{GlobalArgs, ResumeArgs};
|
||||
use crate::server_client;
|
||||
use crate::shared::print_json_pretty;
|
||||
use crate::store;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
/// Resume an interrupted workflow run.
|
||||
|
|
@ -19,17 +18,13 @@ pub(crate) async fn resume_command(
|
|||
) -> anyhow::Result<()> {
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?;
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run = resolve_run_from_summaries(&summaries, &base, &args.run)?;
|
||||
let run_id = run.run_id();
|
||||
let run_dir = run.path;
|
||||
|
||||
if launcher_pid_alive(&run_dir) {
|
||||
bail!("an engine process is still running for this run — cannot resume");
|
||||
}
|
||||
|
||||
let child =
|
||||
super::start::start_run(&run_dir, &run_id, &cli_settings.storage_dir(), true).await?;
|
||||
super::start::start_run(&run_id, &cli_settings.storage_dir(), true).await?;
|
||||
|
||||
if args.detach {
|
||||
if globals.json {
|
||||
|
|
@ -44,7 +39,7 @@ pub(crate) async fn resume_command(
|
|||
Some(&run_id),
|
||||
true,
|
||||
styles,
|
||||
Some(child),
|
||||
None,
|
||||
globals.json,
|
||||
)
|
||||
.await?;
|
||||
|
|
@ -63,19 +58,3 @@ pub(crate) async fn resume_command(
|
|||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn launcher_pid_alive(run_dir: &std::path::Path) -> bool {
|
||||
super::launcher::active_launcher_record_for_run(run_dir)
|
||||
.is_some_and(|record| fabro_proc::process_alive(record.pid))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn launcher_pid_alive_returns_false_for_missing_record() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(!launcher_pid_alive(dir.path()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use fabro_sandbox::daytona::DaytonaSandbox;
|
||||
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
|
||||
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::{GlobalArgs, SshArgs};
|
||||
use crate::server_client;
|
||||
use crate::shared::{print_json_pretty, validate_daytona_provider};
|
||||
use crate::store;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
pub(crate) async fn run(args: SshArgs, globals: &GlobalArgs) -> Result<()> {
|
||||
|
|
@ -15,12 +15,12 @@ pub(crate) async fn run(args: SshArgs, globals: &GlobalArgs) -> Result<()> {
|
|||
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?;
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run = resolve_run_from_summaries(&summaries, &base, &args.run)?;
|
||||
let run_id = run.run_id();
|
||||
let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run_id).await?;
|
||||
let record = run_store
|
||||
.state()
|
||||
let record = client
|
||||
.get_run_state(&run_id)
|
||||
.await?
|
||||
.sandbox
|
||||
.context("Failed to load sandbox record from store")?;
|
||||
|
|
|
|||
|
|
@ -1,102 +1,12 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use chrono::Utc;
|
||||
use anyhow::Result;
|
||||
use fabro_types::RunId;
|
||||
use fabro_workflow::run_status::RunStatus;
|
||||
|
||||
use super::launcher::{
|
||||
LauncherRecord, active_launcher_record, launcher_log_path, launcher_record_path,
|
||||
remove_launcher_record, write_launcher_record,
|
||||
};
|
||||
use crate::store;
|
||||
use crate::server_client;
|
||||
|
||||
/// Spawn a detached engine process for the given run.
|
||||
///
|
||||
/// Returns the child process handle (use `.id()` for the PID).
|
||||
pub(crate) async fn start_run(
|
||||
run_dir: &Path,
|
||||
run_id: &RunId,
|
||||
storage_dir: &Path,
|
||||
resume: bool,
|
||||
) -> Result<std::process::Child> {
|
||||
if !resume {
|
||||
ensure_startable_run(storage_dir, run_id).await?;
|
||||
}
|
||||
let launcher_path = launcher_record_path(storage_dir, run_id);
|
||||
let log_path = launcher_log_path(storage_dir, run_id);
|
||||
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let log_file = std::fs::File::create(&log_path)?;
|
||||
let stdout_log = log_file.try_clone()?;
|
||||
let exe = std::env::current_exe()?;
|
||||
|
||||
let mut cmd = std::process::Command::new(&exe);
|
||||
cmd.args(["__detached", "--run-id"])
|
||||
.arg(run_id.to_string())
|
||||
.args(["--run-dir"])
|
||||
.arg(run_dir)
|
||||
.args(["--storage-dir"])
|
||||
.arg(storage_dir)
|
||||
.args(["--launcher-path"])
|
||||
.arg(&launcher_path);
|
||||
if resume {
|
||||
cmd.arg("--resume");
|
||||
}
|
||||
cmd.env_remove("FABRO_JSON");
|
||||
cmd.stdout(stdout_log)
|
||||
.stderr(log_file)
|
||||
.stdin(std::process::Stdio::null());
|
||||
|
||||
#[cfg(unix)]
|
||||
fabro_proc::pre_exec_setsid(&mut cmd);
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
|
||||
if let Err(err) = write_launcher_record(
|
||||
&launcher_path,
|
||||
&LauncherRecord {
|
||||
run_id: *run_id,
|
||||
run_dir: run_dir.to_path_buf(),
|
||||
pid: child.id(),
|
||||
resume,
|
||||
log_path,
|
||||
started_at: Utc::now(),
|
||||
},
|
||||
) {
|
||||
kill_child_best_effort(&mut child);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
remove_launcher_record(&launcher_path);
|
||||
}
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
async fn ensure_startable_run(storage_dir: &Path, run_id: &RunId) -> Result<()> {
|
||||
if active_launcher_record(storage_dir, run_id).is_some() {
|
||||
bail!("an engine process is still running for this run — cannot start");
|
||||
}
|
||||
|
||||
let run_store = store::open_run_reader(storage_dir, run_id).await?;
|
||||
if let Some(record) = run_store.state().await?.status {
|
||||
if !matches!(record.status, RunStatus::Submitted | RunStatus::Starting) {
|
||||
bail!(
|
||||
"cannot start run: status is {:?}, expected submitted",
|
||||
record.status
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn kill_child_best_effort(child: &mut std::process::Child) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
/// Queue a run for server-owned execution.
|
||||
pub(crate) async fn start_run(run_id: &RunId, storage_dir: &Path, resume: bool) -> Result<()> {
|
||||
let client = server_client::connect_server(storage_dir).await?;
|
||||
client.start_run(run_id, resume).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ use anyhow::{Result, bail};
|
|||
use fabro_types::RunId;
|
||||
use fabro_util::terminal::Styles;
|
||||
use fabro_workflow::records::Conclusion;
|
||||
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
|
||||
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
|
||||
use fabro_workflow::run_status::RunStatus;
|
||||
use tracing::info;
|
||||
|
||||
use crate::args::{GlobalArgs, WaitArgs};
|
||||
use crate::shared::format_duration_ms;
|
||||
use crate::store;
|
||||
use crate::server_client;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -21,8 +21,9 @@ const WAIT_STARTUP_GRACE: std::time::Duration = std::time::Duration::from_secs(3
|
|||
pub(crate) async fn run(args: &WaitArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> {
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
let run_info = resolve_run_combined(store.as_ref(), &base, &args.run).await?;
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run_info = resolve_run_from_summaries(&summaries, &base, &args.run)?;
|
||||
|
||||
let run_id = run_info.run_id();
|
||||
info!(run_id = %run_id, "Waiting for run to complete");
|
||||
|
|
@ -34,8 +35,11 @@ pub(crate) async fn run(args: &WaitArgs, styles: &Styles, globals: &GlobalArgs)
|
|||
let started_waiting_at = std::time::Instant::now();
|
||||
|
||||
let final_status = loop {
|
||||
let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run_id).await?;
|
||||
let status = run_store.state().await?.status.map(|record| record.status);
|
||||
let status = client
|
||||
.get_run_state(&run_id)
|
||||
.await?
|
||||
.status
|
||||
.map(|record| record.status);
|
||||
let status = status.unwrap_or_else(|| {
|
||||
if started_waiting_at.elapsed() < WAIT_STARTUP_GRACE {
|
||||
RunStatus::Submitted
|
||||
|
|
@ -63,8 +67,7 @@ pub(crate) async fn run(args: &WaitArgs, styles: &Styles, globals: &GlobalArgs)
|
|||
}
|
||||
};
|
||||
|
||||
let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run_id).await?;
|
||||
let conclusion = run_store.state().await?.conclusion;
|
||||
let conclusion = client.get_run_state(&run_id).await?.conclusion;
|
||||
|
||||
if globals.json {
|
||||
let json_value = build_json_output(final_status, &run_id, conclusion.as_ref());
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ use anyhow::Result;
|
|||
use fabro_types::RunId;
|
||||
use serde::Serialize;
|
||||
|
||||
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
|
||||
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
|
||||
use fabro_workflow::run_status::RunStatus;
|
||||
|
||||
use crate::args::{GlobalArgs, InspectArgs};
|
||||
use crate::store;
|
||||
use crate::server_client;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -26,53 +26,37 @@ pub(crate) struct InspectOutput {
|
|||
pub(crate) async fn run(args: &InspectArgs, globals: &GlobalArgs) -> Result<()> {
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?;
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run = resolve_run_from_summaries(&summaries, &base, &args.run)?;
|
||||
let run_id = run.run_id();
|
||||
let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run_id).await?;
|
||||
let output = inspect_run_store(&run_id, &run.path, run.status(), &run_store).await;
|
||||
let state = client.get_run_state(&run_id).await?;
|
||||
let output = inspect_run_state(&run_id, &run.path, run.status(), state);
|
||||
let json = serde_json::to_string_pretty(&[output])?;
|
||||
println!("{json}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn inspect_run_store(
|
||||
fn inspect_run_state(
|
||||
run_id: &RunId,
|
||||
run_dir: &Path,
|
||||
status: RunStatus,
|
||||
run_store: &fabro_store::SlateRunStore,
|
||||
state: crate::server_client::RunProjection,
|
||||
) -> InspectOutput {
|
||||
if let Ok(state) = run_store.state().await {
|
||||
return InspectOutput {
|
||||
run_id: run_id.to_string(),
|
||||
run_dir: run_dir.to_path_buf(),
|
||||
status: state.status.as_ref().map_or(status, |record| record.status),
|
||||
run_record: state
|
||||
.run
|
||||
.and_then(|record| serde_json::to_value(record).ok()),
|
||||
start_record: state
|
||||
.start
|
||||
.and_then(|record| serde_json::to_value(record).ok()),
|
||||
conclusion: state
|
||||
.conclusion
|
||||
.and_then(|record| serde_json::to_value(record).ok()),
|
||||
checkpoint: state
|
||||
.checkpoint
|
||||
.and_then(|record| serde_json::to_value(record).ok()),
|
||||
sandbox: state
|
||||
.sandbox
|
||||
.and_then(|record| serde_json::to_value(record).ok()),
|
||||
};
|
||||
}
|
||||
|
||||
InspectOutput {
|
||||
run_id: run_id.to_string(),
|
||||
run_dir: run_dir.to_path_buf(),
|
||||
status,
|
||||
run_record: None,
|
||||
start_record: None,
|
||||
conclusion: None,
|
||||
checkpoint: None,
|
||||
sandbox: None,
|
||||
status: state.status.as_ref().map_or(status, |record| record.status),
|
||||
run_record: state.run.and_then(|record| serde_json::to_value(record).ok()),
|
||||
start_record: state.start.and_then(|record| serde_json::to_value(record).ok()),
|
||||
conclusion: state
|
||||
.conclusion
|
||||
.and_then(|record| serde_json::to_value(record).ok()),
|
||||
checkpoint: state
|
||||
.checkpoint
|
||||
.and_then(|record| serde_json::to_value(record).ok()),
|
||||
sandbox: state
|
||||
.sandbox
|
||||
.and_then(|record| serde_json::to_value(record).ok()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ use cli_table::{Cell, CellStruct, Color, Style, Table};
|
|||
use fabro_util::terminal::Styles;
|
||||
|
||||
use fabro_util::text::strip_goal_decoration;
|
||||
use fabro_workflow::run_lookup::{StatusFilter, filter_runs, runs_base, scan_runs_combined};
|
||||
use fabro_workflow::run_lookup::{StatusFilter, filter_runs, runs_base, scan_runs_with_summaries};
|
||||
use fabro_workflow::run_status::RunStatus;
|
||||
|
||||
use crate::args::{GlobalArgs, RunsListArgs};
|
||||
use crate::shared::{color_if, format_duration_ms, tilde_path};
|
||||
use crate::store;
|
||||
use crate::server_client;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
use super::short_run_id;
|
||||
|
|
@ -25,8 +25,9 @@ pub(crate) async fn list_command(
|
|||
) -> Result<()> {
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
let runs = scan_runs_combined(store.as_ref(), &base).await?;
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let runs = scan_runs_with_summaries(&summaries, &base)?;
|
||||
let label_filters = parse_label_filters(&args.filter.label);
|
||||
let filtered = filter_runs(
|
||||
&runs,
|
||||
|
|
|
|||
|
|
@ -1,30 +1,31 @@
|
|||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use fabro_store::SlateStore;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::args::{GlobalArgs, RunsRemoveArgs};
|
||||
use crate::server_client;
|
||||
use crate::shared::print_json_pretty;
|
||||
use crate::store;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
use fabro_sandbox::reconnect::reconnect as reconnect_sandbox;
|
||||
use fabro_workflow::event::{Event, append_event};
|
||||
use fabro_workflow::event::{Event, to_run_event};
|
||||
use fabro_workflow::run_lookup::RunInfo;
|
||||
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
|
||||
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
|
||||
|
||||
use super::short_run_id;
|
||||
|
||||
pub(crate) async fn remove_command(args: &RunsRemoveArgs, globals: &GlobalArgs) -> Result<()> {
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
remove_from(args, store.as_ref(), &base, globals).await
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
remove_from(args, &client, &summaries, &base, globals).await
|
||||
}
|
||||
|
||||
async fn remove_from(
|
||||
args: &RunsRemoveArgs,
|
||||
store: &SlateStore,
|
||||
client: &server_client::ServerStoreClient,
|
||||
summaries: &[fabro_store::RunSummary],
|
||||
base: &Path,
|
||||
globals: &GlobalArgs,
|
||||
) -> Result<()> {
|
||||
|
|
@ -33,7 +34,7 @@ async fn remove_from(
|
|||
let mut errors = Vec::new();
|
||||
|
||||
for identifier in &args.runs {
|
||||
let run = match resolve_run_combined(store, base, identifier).await {
|
||||
let run = match resolve_run_from_summaries(summaries, base, identifier) {
|
||||
Ok(run) => run,
|
||||
Err(err) => {
|
||||
if !globals.json {
|
||||
|
|
@ -67,7 +68,7 @@ async fn remove_from(
|
|||
}
|
||||
|
||||
let run_id = run.run_id().to_string();
|
||||
if let Err(err) = remove_run_dir_with_cleanup(store, &run).await {
|
||||
if let Err(err) = remove_run_dir_with_cleanup(client, &run).await {
|
||||
if !globals.json {
|
||||
eprintln!("error: {identifier}: {err}");
|
||||
}
|
||||
|
|
@ -82,7 +83,7 @@ async fn remove_from(
|
|||
if !globals.json {
|
||||
eprintln!("{}", short_run_id(&run_id));
|
||||
}
|
||||
if let Err(err) = delete_run_store_state(store, &run).await {
|
||||
if let Err(err) = delete_run_store_state(client, &run).await {
|
||||
if !globals.json {
|
||||
eprintln!("error: {identifier}: {err}");
|
||||
}
|
||||
|
|
@ -107,15 +108,21 @@ async fn remove_from(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_run_with_cleanup(store: &SlateStore, run: &RunInfo) -> Result<()> {
|
||||
remove_run_dir_with_cleanup(store, run).await?;
|
||||
delete_run_store_state(store, run).await
|
||||
pub(crate) async fn remove_run_with_cleanup(
|
||||
client: &server_client::ServerStoreClient,
|
||||
run: &RunInfo,
|
||||
) -> Result<()> {
|
||||
remove_run_dir_with_cleanup(client, run).await?;
|
||||
delete_run_store_state(client, run).await
|
||||
}
|
||||
|
||||
async fn remove_run_dir_with_cleanup(store: &SlateStore, run: &RunInfo) -> Result<()> {
|
||||
async fn remove_run_dir_with_cleanup(
|
||||
client: &server_client::ServerStoreClient,
|
||||
run: &RunInfo,
|
||||
) -> Result<()> {
|
||||
let run_id = run.run_id();
|
||||
let run_store = match store.open_run_reader(&run_id).await {
|
||||
Ok(run_store) => Some(run_store),
|
||||
let run_state = match client.get_run_state(&run_id).await {
|
||||
Ok(run_state) => Some(run_state),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
run_id = %run_id,
|
||||
|
|
@ -125,9 +132,11 @@ async fn remove_run_dir_with_cleanup(store: &SlateStore, run: &RunInfo) -> Resul
|
|||
None
|
||||
}
|
||||
};
|
||||
if let Some(run_store) = run_store.as_ref() {
|
||||
if let Err(err) =
|
||||
append_event(run_store, &run_id, &Event::RunRemoving { reason: None }).await
|
||||
if run_state.is_some() {
|
||||
let run_event = to_run_event(&run_id, &Event::RunRemoving { reason: None });
|
||||
if let Err(err) = client
|
||||
.append_run_event(&run_id, &run_event)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
run_id = %run_id,
|
||||
|
|
@ -137,7 +146,7 @@ async fn remove_run_dir_with_cleanup(store: &SlateStore, run: &RunInfo) -> Resul
|
|||
}
|
||||
}
|
||||
|
||||
if let Some(record) = load_sandbox_record(&run.path, run_store.as_ref()).await {
|
||||
if let Some(record) = load_sandbox_record(run_state.as_ref()).await {
|
||||
if record.provider != "local" {
|
||||
match reconnect_sandbox(&record).await {
|
||||
Ok(sandbox) => {
|
||||
|
|
@ -156,24 +165,21 @@ async fn remove_run_dir_with_cleanup(store: &SlateStore, run: &RunInfo) -> Resul
|
|||
.with_context(|| format!("failed to delete {}", run.path.display()))
|
||||
}
|
||||
|
||||
async fn delete_run_store_state(store: &SlateStore, run: &RunInfo) -> Result<()> {
|
||||
store
|
||||
.delete_run(&run.run_id())
|
||||
async fn delete_run_store_state(
|
||||
client: &server_client::ServerStoreClient,
|
||||
run: &RunInfo,
|
||||
) -> Result<()> {
|
||||
client
|
||||
.delete_store_run(&run.run_id())
|
||||
.await
|
||||
.with_context(|| format!("failed to delete store state for {}", run.run_id()))
|
||||
}
|
||||
|
||||
async fn load_sandbox_record(
|
||||
_run_dir: &Path,
|
||||
run_store: Option<&fabro_store::SlateRunStore>,
|
||||
run_state: Option<&crate::server_client::RunProjection>,
|
||||
) -> Option<fabro_sandbox::SandboxRecord> {
|
||||
if let Some(run_store) = run_store {
|
||||
match run_store.state().await {
|
||||
Ok(state) => return state.sandbox,
|
||||
Err(err) => {
|
||||
warn!(error = %err, "failed to load sandbox record from store");
|
||||
}
|
||||
}
|
||||
if let Some(run_state) = run_state {
|
||||
return run_state.sandbox.clone();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,10 +23,45 @@ pub(crate) async fn execute(
|
|||
if foreground {
|
||||
execute_foreground(bind, serve_args, storage_dir, styles).await
|
||||
} else {
|
||||
execute_daemon(&bind, &serve_args, &storage_dir)
|
||||
execute_daemon(&bind, &serve_args, &storage_dir, true)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_server_running(storage_dir: &Path) -> Result<Bind> {
|
||||
if let Some(existing) = record::active_server_record(storage_dir) {
|
||||
return Ok(existing.bind);
|
||||
}
|
||||
|
||||
let bind = Bind::Unix(storage_dir.join("fabro.sock"));
|
||||
let serve_args = ServeArgs {
|
||||
bind: None,
|
||||
model: None,
|
||||
provider: None,
|
||||
dry_run: false,
|
||||
sandbox: None,
|
||||
max_concurrent_runs: server_max_concurrent_runs_override(),
|
||||
config: None,
|
||||
};
|
||||
|
||||
match execute_daemon(&bind, &serve_args, storage_dir, false) {
|
||||
Ok(()) => Ok(bind),
|
||||
Err(err) => {
|
||||
if let Some(existing) = record::active_server_record(storage_dir) {
|
||||
Ok(existing.bind)
|
||||
} else {
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn server_max_concurrent_runs_override() -> Option<usize> {
|
||||
std::env::var("FABRO_SERVER_MAX_CONCURRENT_RUNS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Foreground mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -79,16 +114,24 @@ async fn execute_foreground(
|
|||
// Daemon mode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn execute_daemon(bind: &Bind, serve_args: &ServeArgs, storage_dir: &Path) -> Result<()> {
|
||||
fn execute_daemon(
|
||||
bind: &Bind,
|
||||
serve_args: &ServeArgs,
|
||||
storage_dir: &Path,
|
||||
announce: bool,
|
||||
) -> Result<()> {
|
||||
let lock_file = acquire_lock(storage_dir)?;
|
||||
let _lock_file = lock_file; // keep alive until function returns
|
||||
|
||||
if let Some(existing) = record::active_server_record(storage_dir) {
|
||||
bail!(
|
||||
"Server already running (pid {}) on {}",
|
||||
existing.pid,
|
||||
existing.bind
|
||||
);
|
||||
if announce {
|
||||
bail!(
|
||||
"Server already running (pid {}) on {}",
|
||||
existing.pid,
|
||||
existing.bind
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Rotate logs
|
||||
|
|
@ -128,6 +171,9 @@ fn execute_daemon(bind: &Bind, serve_args: &ServeArgs, storage_dir: &Path) -> Re
|
|||
}
|
||||
|
||||
cmd.arg("--storage-dir").arg(storage_dir);
|
||||
if matches!(bind, Bind::Unix(_)) {
|
||||
cmd.env("FABRO_LOCAL_NO_AUTH", "1");
|
||||
}
|
||||
|
||||
cmd.env_remove("FABRO_JSON");
|
||||
cmd.stdout(stdout_log)
|
||||
|
|
@ -164,7 +210,9 @@ fn execute_daemon(bind: &Bind, serve_args: &ServeArgs, storage_dir: &Path) -> Re
|
|||
|
||||
while elapsed < timeout {
|
||||
if try_connect(bind) {
|
||||
eprintln!("Server started (pid {}) on {bind}", child.id());
|
||||
if announce {
|
||||
eprintln!("Server started (pid {}) on {bind}", child.id());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +1,29 @@
|
|||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
#[cfg(test)]
|
||||
use fabro_store::StageId;
|
||||
use fabro_store::{RunProjection, SlateRunStore};
|
||||
use fabro_workflow::run_dump::RunDump;
|
||||
use fabro_workflow::run_lookup::{resolve_run_combined, runs_base};
|
||||
use fabro_workflow::run_lookup::{resolve_run_from_summaries, runs_base};
|
||||
#[cfg(test)]
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::args::{GlobalArgs, StoreDumpArgs};
|
||||
use crate::commands::store::rebuild::rebuild_run_store;
|
||||
use crate::server_client;
|
||||
use crate::shared::{absolute_or_current, print_json_pretty};
|
||||
use crate::store;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
pub(crate) async fn dump_command(args: &StoreDumpArgs, globals: &GlobalArgs) -> Result<()> {
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
let run = resolve_run_combined(store.as_ref(), &base, &args.run).await?;
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
let run = resolve_run_from_summaries(&summaries, &base, &args.run)?;
|
||||
let run_id = run.run_id();
|
||||
let run_store = store::open_run_reader(&cli_settings.storage_dir(), &run_id).await?;
|
||||
let events = client.list_run_events(&run_id, None, None).await?;
|
||||
let run_store = rebuild_run_store(&run_id, &events).await?;
|
||||
|
||||
let file_count = export_run(&run_store, &args.output).await?;
|
||||
if globals.json {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
mod dump;
|
||||
pub(crate) mod dump;
|
||||
pub(crate) mod rebuild;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
|
|
|
|||
23
lib/crates/fabro-cli/src/commands/store/rebuild.rs
Normal file
23
lib/crates/fabro-cli/src/commands/store/rebuild.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use fabro_store::{EventEnvelope, EventPayload, SlateRunStore, SlateStore};
|
||||
use object_store::memory::InMemory;
|
||||
|
||||
pub(crate) async fn rebuild_run_store(
|
||||
run_id: &fabro_types::RunId,
|
||||
events: &[EventEnvelope],
|
||||
) -> Result<SlateRunStore> {
|
||||
let store = Arc::new(SlateStore::new(
|
||||
Arc::new(InMemory::new()),
|
||||
"",
|
||||
Duration::from_millis(1),
|
||||
));
|
||||
let run_store = store.create_run(run_id).await?;
|
||||
for event in events {
|
||||
let payload = EventPayload::new(event.payload.as_value().clone(), run_id)?;
|
||||
run_store.append_event(&payload).await?;
|
||||
}
|
||||
Ok(run_store)
|
||||
}
|
||||
|
|
@ -6,12 +6,12 @@ use cli_table::format::{Border, Justify, Separator};
|
|||
use cli_table::{Cell, CellStruct, Style, Table};
|
||||
use serde::Serialize;
|
||||
|
||||
use fabro_workflow::run_lookup::{logs_base, runs_base, scan_runs_combined};
|
||||
use fabro_workflow::run_lookup::{logs_base, runs_base, scan_runs_with_summaries};
|
||||
use fabro_workflow::run_status::RunStatus;
|
||||
|
||||
use crate::args::{DfArgs, GlobalArgs};
|
||||
use crate::shared::{format_size, print_json_pretty};
|
||||
use crate::store;
|
||||
use crate::server_client;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -49,10 +49,11 @@ pub(super) async fn df_command(args: &DfArgs, globals: &GlobalArgs) -> Result<()
|
|||
let data_dir = cli_settings.storage_dir();
|
||||
let runs_base_dir = runs_base(&data_dir);
|
||||
let logs_base_dir = logs_base(&data_dir);
|
||||
let store = store::build_store(&data_dir)?;
|
||||
let client = server_client::connect_server(&data_dir).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
df_from(
|
||||
args,
|
||||
store.as_ref(),
|
||||
&summaries,
|
||||
&data_dir,
|
||||
&runs_base_dir,
|
||||
&logs_base_dir,
|
||||
|
|
@ -64,7 +65,7 @@ pub(super) async fn df_command(args: &DfArgs, globals: &GlobalArgs) -> Result<()
|
|||
#[allow(clippy::print_stdout)]
|
||||
async fn df_from(
|
||||
args: &DfArgs,
|
||||
store: &fabro_store::SlateStore,
|
||||
summaries: &[fabro_store::RunSummary],
|
||||
data_dir: &Path,
|
||||
runs_base: &Path,
|
||||
logs_base: &Path,
|
||||
|
|
@ -79,7 +80,7 @@ async fn df_from(
|
|||
size: u64,
|
||||
}
|
||||
|
||||
let runs = scan_runs_combined(store, runs_base).await?;
|
||||
let runs = scan_runs_with_summaries(summaries, runs_base)?;
|
||||
let mut active_count = 0u64;
|
||||
let mut total_run_size = 0u64;
|
||||
let mut reclaimable_run_size = 0u64;
|
||||
|
|
|
|||
|
|
@ -2,16 +2,15 @@ use std::path::Path;
|
|||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use chrono::Utc;
|
||||
use fabro_store::SlateStore;
|
||||
use serde::Serialize;
|
||||
use tracing::{debug, info};
|
||||
|
||||
use fabro_workflow::run_lookup::{StatusFilter, filter_runs, runs_base, scan_runs_combined};
|
||||
use fabro_workflow::run_lookup::{StatusFilter, filter_runs, runs_base, scan_runs_with_summaries};
|
||||
|
||||
use crate::args::{GlobalArgs, RunsPruneArgs};
|
||||
use crate::commands::runs::rm::remove_run_with_cleanup;
|
||||
use crate::server_client;
|
||||
use crate::shared::{format_size, print_json_pretty};
|
||||
use crate::store;
|
||||
use crate::user_config::load_user_settings_with_globals;
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -25,8 +24,9 @@ struct PruneRunRow {
|
|||
pub(super) async fn prune_command(args: &RunsPruneArgs, globals: &GlobalArgs) -> Result<()> {
|
||||
let cli_settings = load_user_settings_with_globals(globals)?;
|
||||
let base = runs_base(&cli_settings.storage_dir());
|
||||
let store = store::build_store(&cli_settings.storage_dir())?;
|
||||
prune_from(args, store.as_ref(), &base, globals).await
|
||||
let client = server_client::connect_server(&cli_settings.storage_dir()).await?;
|
||||
let summaries = client.list_store_runs().await?;
|
||||
prune_from(args, &client, &summaries, &base, globals).await
|
||||
}
|
||||
|
||||
pub(crate) fn parse_duration(s: &str) -> Result<chrono::Duration> {
|
||||
|
|
@ -47,11 +47,12 @@ pub(crate) fn parse_duration(s: &str) -> Result<chrono::Duration> {
|
|||
|
||||
async fn prune_from(
|
||||
args: &RunsPruneArgs,
|
||||
store: &SlateStore,
|
||||
client: &server_client::ServerStoreClient,
|
||||
summaries: &[fabro_store::RunSummary],
|
||||
base: &Path,
|
||||
globals: &GlobalArgs,
|
||||
) -> Result<()> {
|
||||
let runs = scan_runs_combined(store, base).await?;
|
||||
let runs = scan_runs_with_summaries(summaries, base)?;
|
||||
let label_filters = parse_label_filters(&args.filter.label);
|
||||
let mut filtered = filter_runs(
|
||||
&runs,
|
||||
|
|
@ -120,7 +121,7 @@ async fn prune_from(
|
|||
if args.yes {
|
||||
for run in &filtered {
|
||||
info!(run_id = %run.run_id(), path = %run.path.display(), "deleting run");
|
||||
remove_run_with_cleanup(store, run).await?;
|
||||
remove_run_with_cleanup(client, run).await?;
|
||||
}
|
||||
if globals.json {
|
||||
print_json_pretty(&serde_json::json!({
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ mod logging;
|
|||
mod shared;
|
||||
#[cfg(feature = "sleep_inhibitor")]
|
||||
mod sleep_inhibitor;
|
||||
mod server_client;
|
||||
mod store;
|
||||
mod user_config;
|
||||
|
||||
|
|
|
|||
301
lib/crates/fabro-cli/src/server_client.rs
Normal file
301
lib/crates/fabro-cli/src/server_client.rs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
use std::collections::HashMap;
|
||||
use std::num::NonZeroU64;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
use fabro_api::types;
|
||||
use fabro_store::{EventEnvelope, RunSummary, StageId};
|
||||
use fabro_types::{
|
||||
Checkpoint, Conclusion, NodeStatusRecord, PullRequestRecord, Retro, RunEvent, RunId,
|
||||
RunRecord, RunStatusRecord, SandboxRecord, Settings, StartRecord,
|
||||
};
|
||||
|
||||
use crate::commands::server::start;
|
||||
|
||||
pub(crate) struct ServerStoreClient {
|
||||
client: fabro_api::Client,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Default, serde::Deserialize)]
|
||||
pub(crate) struct RunProjection {
|
||||
#[serde(default)]
|
||||
pub run: Option<RunRecord>,
|
||||
#[serde(default)]
|
||||
pub graph_source: Option<String>,
|
||||
#[serde(default)]
|
||||
pub start: Option<StartRecord>,
|
||||
#[serde(default)]
|
||||
pub status: Option<RunStatusRecord>,
|
||||
#[serde(default)]
|
||||
pub checkpoint: Option<Checkpoint>,
|
||||
#[serde(default)]
|
||||
pub checkpoints: Vec<(u32, Checkpoint)>,
|
||||
#[serde(default)]
|
||||
pub conclusion: Option<Conclusion>,
|
||||
#[serde(default)]
|
||||
pub retro: Option<Retro>,
|
||||
#[serde(default)]
|
||||
pub retro_prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub retro_response: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sandbox: Option<SandboxRecord>,
|
||||
#[serde(default)]
|
||||
pub final_patch: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pull_request: Option<PullRequestRecord>,
|
||||
#[serde(default)]
|
||||
nodes: HashMap<String, NodeState>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Default, serde::Deserialize)]
|
||||
pub(crate) struct NodeState {
|
||||
#[serde(default)]
|
||||
pub prompt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub response: Option<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<NodeStatusRecord>,
|
||||
#[serde(default)]
|
||||
pub provider_used: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub diff: Option<String>,
|
||||
#[serde(default)]
|
||||
pub script_invocation: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub script_timing: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub parallel_results: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
pub stdout: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stderr: Option<String>,
|
||||
}
|
||||
|
||||
impl RunProjection {
|
||||
pub(crate) fn list_node_visits(&self, node_id: &str) -> Vec<u32> {
|
||||
let mut visits = self
|
||||
.nodes
|
||||
.keys()
|
||||
.filter_map(|key| key.parse::<StageId>().ok())
|
||||
.filter(|stage_id| stage_id.node_id() == node_id)
|
||||
.map(|stage_id| stage_id.visit())
|
||||
.collect::<Vec<_>>();
|
||||
visits.sort_unstable();
|
||||
visits.dedup();
|
||||
visits
|
||||
}
|
||||
|
||||
pub(crate) fn node(&self, stage_id: &StageId) -> Option<&NodeState> {
|
||||
self.nodes.get(&stage_id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn connect_server(storage_dir: &Path) -> Result<ServerStoreClient> {
|
||||
let bind = start::ensure_server_running(storage_dir)
|
||||
.with_context(|| format!("Failed to start fabro server for {}", storage_dir.display()))?;
|
||||
let socket_path = match bind {
|
||||
fabro_server::bind::Bind::Unix(path) => path,
|
||||
fabro_server::bind::Bind::Tcp(addr) => {
|
||||
return Err(anyhow!(
|
||||
"Unsupported server bind for store client auto-connect: {addr}"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let http_client = reqwest::ClientBuilder::new()
|
||||
.unix_socket(socket_path)
|
||||
.build()
|
||||
.context("Failed to build Unix-socket HTTP client for fabro server")?;
|
||||
wait_for_server_ready(&http_client).await?;
|
||||
|
||||
Ok(ServerStoreClient {
|
||||
client: fabro_api::Client::new_with_client("http://fabro", http_client),
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_server_ready(http_client: &reqwest::Client) -> Result<()> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
let mut last_error = None;
|
||||
|
||||
while std::time::Instant::now() < deadline {
|
||||
match http_client.get("http://fabro/health").send().await {
|
||||
Ok(response) if response.status().is_success() => return Ok(()),
|
||||
Ok(response) => {
|
||||
last_error = Some(anyhow!(
|
||||
"server health check returned status {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
Err(err) => last_error = Some(anyhow!(err)),
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| anyhow!("server did not become ready in time")))
|
||||
}
|
||||
|
||||
impl ServerStoreClient {
|
||||
pub(crate) async fn create_run_from_workflow_path(
|
||||
&self,
|
||||
workflow_path: &Path,
|
||||
cwd: &Path,
|
||||
settings: &Settings,
|
||||
run_id: Option<&RunId>,
|
||||
) -> Result<RunId> {
|
||||
let response = self
|
||||
.client
|
||||
.create_run()
|
||||
.body(types::CreateRunRequest {
|
||||
dot_source: None,
|
||||
workflow_path: Some(workflow_path.display().to_string()),
|
||||
cwd: Some(cwd.display().to_string()),
|
||||
settings_json: Some(serde_json::to_string(settings)?),
|
||||
run_id: run_id.map(ToString::to_string),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
let status = response.into_inner();
|
||||
status
|
||||
.id
|
||||
.parse()
|
||||
.map_err(|err| anyhow!("invalid run ID from server: {err}"))
|
||||
}
|
||||
|
||||
pub(crate) async fn start_run(&self, run_id: &RunId, resume: bool) -> Result<()> {
|
||||
self.client
|
||||
.start_run()
|
||||
.id(run_id.to_string())
|
||||
.body(types::StartRunRequest { resume })
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn cancel_run(&self, run_id: &RunId) -> Result<()> {
|
||||
self.client
|
||||
.cancel_run()
|
||||
.id(run_id.to_string())
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_store_runs(&self) -> Result<Vec<RunSummary>> {
|
||||
let response = self
|
||||
.client
|
||||
.list_runs()
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
response
|
||||
.into_inner()
|
||||
.into_iter()
|
||||
.map(convert_type)
|
||||
.collect::<Result<Vec<_>>>()
|
||||
}
|
||||
|
||||
pub(crate) async fn get_run_state(&self, run_id: &RunId) -> Result<RunProjection> {
|
||||
let response = self
|
||||
.client
|
||||
.get_run_state()
|
||||
.id(run_id.to_string())
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
convert_type(response.into_inner())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_run_events(
|
||||
&self,
|
||||
run_id: &RunId,
|
||||
since_seq: Option<u32>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<EventEnvelope>> {
|
||||
let mut request = self.client.list_run_events().id(run_id.to_string());
|
||||
if let Some(seq) = since_seq.and_then(non_zero_u64_from_u32) {
|
||||
request = request.since_seq(seq);
|
||||
}
|
||||
if let Some(limit) = limit.and_then(non_zero_u64_from_usize) {
|
||||
request = request.limit(limit);
|
||||
}
|
||||
let response = request.send().await.map_err(map_api_error)?;
|
||||
response
|
||||
.into_inner()
|
||||
.data
|
||||
.into_iter()
|
||||
.map(convert_type)
|
||||
.collect::<Result<Vec<_>>>()
|
||||
}
|
||||
|
||||
pub(crate) async fn append_run_event(&self, run_id: &RunId, event: &RunEvent) -> Result<()> {
|
||||
let body: types::RunEvent = convert_type(event)?;
|
||||
self.client
|
||||
.append_run_event()
|
||||
.id(run_id.to_string())
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_store_run(&self, run_id: &RunId) -> Result<()> {
|
||||
self.client
|
||||
.delete_run()
|
||||
.id(run_id.to_string())
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_api_error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn map_api_error<E>(err: progenitor_client::Error<E>) -> anyhow::Error
|
||||
where
|
||||
E: serde::Serialize + std::fmt::Debug,
|
||||
{
|
||||
match err {
|
||||
progenitor_client::Error::ErrorResponse(response) => {
|
||||
let status = response.status();
|
||||
if let Ok(value) = serde_json::to_value(response.into_inner()) {
|
||||
if let Some(detail) = value
|
||||
.get("errors")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.and_then(|errors| errors.first())
|
||||
.and_then(|entry| entry.get("detail"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
return anyhow!("{detail}");
|
||||
}
|
||||
}
|
||||
anyhow!("request failed with status {status}")
|
||||
}
|
||||
progenitor_client::Error::UnexpectedResponse(response) => {
|
||||
anyhow!("request failed with status {}", response.status())
|
||||
}
|
||||
other => anyhow!("{other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_type<TInput, TOutput>(value: TInput) -> Result<TOutput>
|
||||
where
|
||||
TInput: serde::Serialize,
|
||||
TOutput: serde::de::DeserializeOwned,
|
||||
{
|
||||
serde_json::from_value(serde_json::to_value(value)?).map_err(Into::into)
|
||||
}
|
||||
|
||||
fn non_zero_u64_from_u32(value: u32) -> Option<NonZeroU64> {
|
||||
NonZeroU64::new(u64::from(value))
|
||||
}
|
||||
|
||||
fn non_zero_u64_from_usize(value: usize) -> Option<NonZeroU64> {
|
||||
u64::try_from(value).ok().and_then(NonZeroU64::new)
|
||||
}
|
||||
|
|
@ -3,10 +3,12 @@ use std::time::Duration;
|
|||
use fabro_test::{fabro_snapshot, run_and_format, test_context};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::support::{example_fixture, fabro_json_snapshot, run_output_filters};
|
||||
use crate::support::{example_fixture, fabro_json_snapshot, run_output_filters, unique_run_id};
|
||||
|
||||
use super::support::{output_stdout, resolve_run, wait_for_status, write_gated_workflow};
|
||||
|
||||
const SHARED_DAEMON_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
let context = test_context!();
|
||||
|
|
@ -58,7 +60,7 @@ fn attach_requires_run_arg() {
|
|||
#[test]
|
||||
fn attach_replays_completed_detached_run() {
|
||||
let context = test_context!();
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAQ";
|
||||
let run_id = unique_run_id();
|
||||
|
||||
context
|
||||
.command()
|
||||
|
|
@ -69,7 +71,7 @@ fn attach_replays_completed_detached_run() {
|
|||
"--no-retro",
|
||||
"--detach",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
example_fixture("simple.fabro").to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
|
|
@ -77,14 +79,14 @@ fn attach_replays_completed_detached_run() {
|
|||
|
||||
context
|
||||
.command()
|
||||
.args(["wait", run_id])
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.args(["wait", &run_id])
|
||||
.timeout(SHARED_DAEMON_TIMEOUT)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["attach", run_id]);
|
||||
cmd.timeout(std::time::Duration::from_secs(10));
|
||||
cmd.args(["attach", &run_id]);
|
||||
cmd.timeout(SHARED_DAEMON_TIMEOUT);
|
||||
fabro_snapshot!(run_output_filters(&context), cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
|
|
@ -101,7 +103,7 @@ fn attach_replays_completed_detached_run() {
|
|||
#[test]
|
||||
fn attach_replays_from_store_without_run_json_or_progress_jsonl() {
|
||||
let context = test_context!();
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAQ";
|
||||
let run_id = unique_run_id();
|
||||
|
||||
context
|
||||
.command()
|
||||
|
|
@ -112,7 +114,7 @@ fn attach_replays_from_store_without_run_json_or_progress_jsonl() {
|
|||
"--no-retro",
|
||||
"--detach",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
example_fixture("simple.fabro").to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
|
|
@ -120,18 +122,18 @@ fn attach_replays_from_store_without_run_json_or_progress_jsonl() {
|
|||
|
||||
context
|
||||
.command()
|
||||
.args(["wait", run_id])
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.args(["wait", &run_id])
|
||||
.timeout(SHARED_DAEMON_TIMEOUT)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let run = resolve_run(&context, run_id);
|
||||
let run = resolve_run(&context, &run_id);
|
||||
let _ = std::fs::remove_file(run.run_dir.join("run.json"));
|
||||
let _ = std::fs::remove_file(run.run_dir.join("progress.jsonl"));
|
||||
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["attach", run_id]);
|
||||
cmd.timeout(std::time::Duration::from_secs(10));
|
||||
cmd.args(["attach", &run_id]);
|
||||
cmd.timeout(SHARED_DAEMON_TIMEOUT);
|
||||
fabro_snapshot!(run_output_filters(&context), cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
|
|
@ -251,7 +253,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
let run_dir = context.find_run_dir(&run_id);
|
||||
|
||||
let request_path = run_dir.join("runtime/interview_request.json");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
let deadline = std::time::Instant::now() + SHARED_DAEMON_TIMEOUT;
|
||||
while !request_path.exists() {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
|
|
@ -263,7 +265,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
let output = context
|
||||
.command()
|
||||
.args(["--json", "attach", &run_id])
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.timeout(SHARED_DAEMON_TIMEOUT)
|
||||
.output()
|
||||
.expect("attach should execute");
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use fabro_types::Settings;
|
|||
use predicates::prelude::*;
|
||||
|
||||
use super::support::run_state;
|
||||
use crate::support::unique_run_id;
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
|
|
@ -407,7 +408,7 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
|
|||
let (project, storage_dir) = setup_external_workflow_fixture(&context);
|
||||
let cwd = tempfile::tempdir().unwrap();
|
||||
let workflow = project.path().join("workflow.toml");
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FB8";
|
||||
let run_id = unique_run_id();
|
||||
|
||||
// Remove FABRO_STORAGE_DIR so the CLI uses storage_dir from user.toml
|
||||
context
|
||||
|
|
@ -420,7 +421,7 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
|
|||
"--model",
|
||||
"gpt-5.2",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
workflow.to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
|
|
@ -435,7 +436,7 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() {
|
|||
path.is_dir()
|
||||
&& path
|
||||
.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().ends_with(run_id))
|
||||
.is_some_and(|name| name.to_string_lossy().ends_with(&run_id))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
|
|
@ -500,13 +501,18 @@ fn settings_missing_run_config_errors() {
|
|||
let mut cmd = context.settings();
|
||||
cmd.current_dir(project.path());
|
||||
cmd.args(["missing.toml"]);
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: false
|
||||
exit_code: 1
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
error: Workflow not found: missing.toml
|
||||
");
|
||||
let output = cmd.output().expect("command should execute");
|
||||
assert!(!output.status.success());
|
||||
assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("error: Workflow not found:"),
|
||||
"stderr should report missing workflow path, got:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stderr.contains("missing.toml"),
|
||||
"stderr should include missing workflow filename, got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ use serde_json::json;
|
|||
|
||||
use fabro_test::{fabro_snapshot, test_context};
|
||||
|
||||
use crate::support::fabro_json_snapshot;
|
||||
use crate::support::{fabro_json_snapshot, unique_run_id};
|
||||
|
||||
use super::support::{fixture, output_stdout, resolve_run, run_state};
|
||||
use super::support::{fixture, output_stdout, resolve_run, run_count_for_test_case, run_state};
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
|
|
@ -50,7 +50,7 @@ fn help() {
|
|||
#[test]
|
||||
fn create_persists_directory_workflow_slug_and_cached_graph() {
|
||||
let context = test_context!();
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAA";
|
||||
let run_id = unique_run_id();
|
||||
let workflow_path = context.temp_dir.join("sluggy/workflow.fabro");
|
||||
|
||||
context.write_temp(
|
||||
|
|
@ -71,13 +71,13 @@ digraph BarBaz {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
workflow_path.to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = context.find_run_dir(run_id);
|
||||
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");
|
||||
fabro_json_snapshot!(
|
||||
|
|
@ -106,7 +106,7 @@ digraph BarBaz {
|
|||
#[test]
|
||||
fn create_persists_file_stem_slug_for_standalone_file() {
|
||||
let context = test_context!();
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAB";
|
||||
let run_id = unique_run_id();
|
||||
let workflow_path = context.temp_dir.join("alpha.fabro");
|
||||
|
||||
context.write_temp(
|
||||
|
|
@ -127,13 +127,13 @@ digraph FooWorkflow {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
workflow_path.to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = context.find_run_dir(run_id);
|
||||
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");
|
||||
fabro_json_snapshot!(
|
||||
|
|
@ -292,8 +292,9 @@ fn create_json_implies_auto_approve() {
|
|||
fn create_invalid_workflow_fails_without_creating_run() {
|
||||
let context = test_context!();
|
||||
let workflow = fixture("invalid.fabro");
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["create", workflow.to_str().unwrap()]);
|
||||
let initial_run_count = run_count_for_test_case(&context);
|
||||
let mut cmd = context.create_cmd();
|
||||
cmd.arg(workflow.to_str().unwrap());
|
||||
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: false
|
||||
|
|
@ -303,10 +304,9 @@ fn create_invalid_workflow_fails_without_creating_run() {
|
|||
error: Validation failed
|
||||
");
|
||||
|
||||
let runs_dir = context.storage_dir.join("runs");
|
||||
let run_count = std::fs::read_dir(&runs_dir)
|
||||
.ok()
|
||||
.map(|entries| entries.flatten().count())
|
||||
.unwrap_or(0);
|
||||
assert_eq!(run_count, 0, "invalid create should not persist a run");
|
||||
let run_count = run_count_for_test_case(&context);
|
||||
assert_eq!(
|
||||
run_count, initial_run_count,
|
||||
"invalid create should not persist a run for this test case"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
use fabro_test::{fabro_snapshot, test_context};
|
||||
|
||||
use super::support::run_state;
|
||||
use crate::support::fabro_json_snapshot;
|
||||
use crate::support::{fabro_json_snapshot, unique_run_id};
|
||||
|
||||
const SHARED_DAEMON_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
|
|
@ -43,7 +45,7 @@ fn launcher_path(context: &fabro_test::TestContext, run_id: &str) -> std::path::
|
|||
#[test]
|
||||
fn detached_uses_cached_graph_after_source_deleted() {
|
||||
let context = test_context!();
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAF";
|
||||
let run_id = unique_run_id();
|
||||
let workflow_path = context.temp_dir.join("workflow.fabro");
|
||||
|
||||
context.write_temp(
|
||||
|
|
@ -64,13 +66,13 @@ digraph CachedGraph {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
workflow_path.to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = context.find_run_dir(run_id);
|
||||
let run_dir = context.find_run_dir(&run_id);
|
||||
std::fs::remove_file(&workflow_path).unwrap();
|
||||
|
||||
context
|
||||
|
|
@ -78,13 +80,13 @@ digraph CachedGraph {
|
|||
.args([
|
||||
"__detached",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
"--run-dir",
|
||||
run_dir.to_str().unwrap(),
|
||||
"--launcher-path",
|
||||
launcher_path(&context, run_id).to_str().unwrap(),
|
||||
launcher_path(&context, &run_id).to_str().unwrap(),
|
||||
])
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.timeout(SHARED_DAEMON_TIMEOUT)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
|
|
@ -110,7 +112,7 @@ digraph CachedGraph {
|
|||
#[test]
|
||||
fn detached_uses_snapshotted_app_id_for_github_credentials() {
|
||||
let context = test_context!();
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAG";
|
||||
let run_id = unique_run_id();
|
||||
let workflow_path = context.temp_dir.join("workflow.fabro");
|
||||
|
||||
context.write_home(
|
||||
|
|
@ -140,13 +142,13 @@ digraph GitHubApp {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
workflow_path.to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = context.find_run_dir(run_id);
|
||||
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");
|
||||
fabro_json_snapshot!(
|
||||
|
|
@ -168,26 +170,25 @@ digraph GitHubApp {
|
|||
cmd.args([
|
||||
"__detached",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
"--run-dir",
|
||||
run_dir.to_str().unwrap(),
|
||||
"--launcher-path",
|
||||
launcher_path(&context, run_id).to_str().unwrap(),
|
||||
launcher_path(&context, &run_id).to_str().unwrap(),
|
||||
]);
|
||||
cmd.timeout(std::time::Duration::from_secs(10));
|
||||
cmd.timeout(SHARED_DAEMON_TIMEOUT);
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: false
|
||||
exit_code: 1
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
error: GITHUB_APP_PRIVATE_KEY is not valid PEM or base64: Invalid symbol 37, offset 0.
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detached_runs_without_run_json_when_run_id_is_explicit() {
|
||||
let context = test_context!();
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAJ";
|
||||
let run_id = unique_run_id();
|
||||
let workflow_path = context.temp_dir.join("workflow.fabro");
|
||||
|
||||
context.write_temp(
|
||||
|
|
@ -208,25 +209,25 @@ digraph DetachedStoreOnly {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
workflow_path.to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = context.find_run_dir(run_id);
|
||||
let run_dir = context.find_run_dir(&run_id);
|
||||
context
|
||||
.command()
|
||||
.args([
|
||||
"__detached",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
"--run-dir",
|
||||
run_dir.to_str().unwrap(),
|
||||
"--launcher-path",
|
||||
launcher_path(&context, run_id).to_str().unwrap(),
|
||||
launcher_path(&context, &run_id).to_str().unwrap(),
|
||||
])
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.timeout(SHARED_DAEMON_TIMEOUT)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
|
|
@ -283,7 +284,7 @@ digraph Test {
|
|||
context
|
||||
.command()
|
||||
.args(["wait", &run_id])
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(SHARED_DAEMON_TIMEOUT)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
|
|
@ -321,13 +322,12 @@ digraph Test {
|
|||
launcher_path(&context, &run_id).to_str().unwrap(),
|
||||
"--resume",
|
||||
]);
|
||||
cmd.timeout(std::time::Duration::from_secs(10));
|
||||
cmd.timeout(SHARED_DAEMON_TIMEOUT);
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: false
|
||||
exit_code: 1
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
----- stderr -----
|
||||
error: Precondition failed: run already finished successfully — nothing to resume
|
||||
");
|
||||
|
||||
let inspect_after = context
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ fn logs_completed_run_outputs_raw_ndjson() {
|
|||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
{"event":"run.created","id":"[EVENT_ID]","properties":{"graph":{"attrs":{"goal":{"String":"Run tests and report results"},"rankdir":{"String":"LR"}},"edges":[{"attrs":{},"from":"start","to":"run_tests"},{"attrs":{},"from":"run_tests","to":"report"},{"attrs":{},"from":"report","to":"exit"}],"name":"Simple","nodes":{"exit":{"attrs":{"label":{"String":"Exit"},"shape":{"String":"Msquare"}},"id":"exit"},"report":{"attrs":{"label":{"String":"Report"},"prompt":{"String":"Summarize the test results"}},"id":"report"},"run_tests":{"attrs":{"label":{"String":"Run Tests"},"prompt":{"String":"Run the test suite and report results"}},"id":"run_tests"},"start":{"attrs":{"label":{"String":"Start"},"shape":{"String":"Mdiamond"}},"id":"start"}}},"host_repo_path":"[TEMP_DIR]","run_dir":"[STORAGE_DIR]/runs/20260404-[ULID]","settings":{"auto_approve":true,"dry_run":true,"fabro":{"root":"fabro/"},"features":{"retros":false,"session_sandboxes":false},"goal":"Run tests and report results","hooks":[{"blocking":true,"command":"cargo fmt","event":"post_tool_use","matcher":"write_file|edit_file|apply_patch","name":"cargo-fmt","sandbox":null,"timeout_ms":null}],"llm":{"fallbacks":null,"model":"claude-sonnet-4-6","provider":"anthropic"},"mode":"standalone","no_retro":true,"pull_request":{"auto_merge":false,"draft":false,"enabled":true,"merge_strategy":"squash"},"sandbox":{"daytona":{"auto_stop_interval":30,"labels":{"repo":"fabro-sh/fabro"},"network":null,"skip_clone":false,"snapshot":{"cpu":4,"disk":20,"dockerfile":"FROM ubuntu:24.04/n/nRUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates build-essential pkg-config libssl-dev unzip python3 && rm -rf /var/lib/apt/lists/*/n/n# GitHub CLI/nRUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && apt-get update && apt-get install -y --no-install-recommends gh && rm -rf /var/lib/apt/lists/*/n/n# Rust/nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y/nENV PATH=\"/root/.cargo/bin:${PATH}\"/nRUN cargo install cargo-nextest --locked/nENV CARGO_INCREMENTAL=0/n/n# Bun/nRUN curl -fsSL https://bun.sh/install | bash/nENV PATH=\"/root/.bun/bin:${PATH}\"/n/nWORKDIR /root/n","memory":8,"name":"fabro-v6"}},"devcontainer":null,"env":null,"local":null,"preserve":null,"provider":"local"},"storage_dir":"[STORAGE_DIR]","version":1},"workflow_slug":"simple","workflow_source":"digraph Simple {/n graph [goal=\"Run tests and report results\"]/n rankdir=LR/n/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n/n run_tests [label=\"Run Tests\", prompt=\"Run the test suite and report results\"]/n report [label=\"Report\", prompt=\"Summarize the test results\"]/n/n start -> run_tests -> report -> exit/n}/n","working_directory":"[TEMP_DIR]"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
{"event":"run.created","id":"[EVENT_ID]","properties":{"graph":{"attrs":{"goal":{"String":"Run tests and report results"},"rankdir":{"String":"LR"}},"edges":[{"attrs":{},"from":"start","to":"run_tests"},{"attrs":{},"from":"run_tests","to":"report"},{"attrs":{},"from":"report","to":"exit"}],"name":"Simple","nodes":{"exit":{"attrs":{"label":{"String":"Exit"},"shape":{"String":"Msquare"}},"id":"exit"},"report":{"attrs":{"label":{"String":"Report"},"prompt":{"String":"Summarize the test results"}},"id":"report"},"run_tests":{"attrs":{"label":{"String":"Run Tests"},"prompt":{"String":"Run the test suite and report results"}},"id":"run_tests"},"start":{"attrs":{"label":{"String":"Start"},"shape":{"String":"Mdiamond"}},"id":"start"}}},"host_repo_path":"[TEMP_DIR]","labels":{"fabro_test_case":"[TEST_CASE]","fabro_test_run":"[TEST_RUN]"},"run_dir":"[STORAGE_DIR]/runs/20260404-[ULID]","settings":{"auto_approve":true,"dry_run":true,"fabro":{"root":"fabro/"},"features":{"retros":false,"session_sandboxes":false},"goal":"Run tests and report results","hooks":[{"blocking":true,"command":"cargo fmt","event":"post_tool_use","matcher":"write_file|edit_file|apply_patch","name":"cargo-fmt","sandbox":null,"timeout_ms":null}],"labels":{"fabro_test_case":"[TEST_CASE]","fabro_test_run":"[TEST_RUN]"},"llm":{"fallbacks":null,"model":"claude-sonnet-4-6","provider":"anthropic"},"mode":"standalone","no_retro":true,"pull_request":{"auto_merge":false,"draft":false,"enabled":true,"merge_strategy":"squash"},"sandbox":{"daytona":{"auto_stop_interval":30,"labels":{"repo":"fabro-sh/fabro"},"network":null,"skip_clone":false,"snapshot":{"cpu":4,"disk":20,"dockerfile":"FROM ubuntu:24.04/n/nRUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates build-essential pkg-config libssl-dev unzip python3 && rm -rf /var/lib/apt/lists/*/n/n# GitHub CLI/nRUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && apt-get update && apt-get install -y --no-install-recommends gh && rm -rf /var/lib/apt/lists/*/n/n# Rust/nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y/nENV PATH=\"/root/.cargo/bin:${PATH}\"/nRUN cargo install cargo-nextest --locked/nENV CARGO_INCREMENTAL=0/n/n# Bun/nRUN curl -fsSL https://bun.sh/install | bash/nENV PATH=\"/root/.bun/bin:${PATH}\"/n/nWORKDIR /root/n","memory":8,"name":"fabro-v6"}},"devcontainer":null,"env":null,"local":null,"preserve":null,"provider":"local"},"storage_dir":"[STORAGE_DIR]","version":1},"workflow_slug":"simple","workflow_source":"digraph Simple {/n graph [goal=\"Run tests and report results\"]/n rankdir=LR/n/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n/n run_tests [label=\"Run Tests\", prompt=\"Run the test suite and report results\"]/n report [label=\"Report\", prompt=\"Summarize the test results\"]/n/n start -> run_tests -> report -> exit/n}/n","working_directory":"[TEMP_DIR]"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
{"event":"run.submitted","id":"[EVENT_ID]","properties":{},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
{"event":"run.starting","id":"[EVENT_ID]","properties":{"reason":"sandbox_initializing"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
{"event":"sandbox.initializing","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
|
|
@ -228,7 +228,7 @@ fn logs_follow_detached_run_streams_until_completion() {
|
|||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
{"event":"run.created","id":"[EVENT_ID]","properties":{"graph":{"attrs":{"goal":{"String":"Run tests and report results"},"rankdir":{"String":"LR"}},"edges":[{"attrs":{},"from":"start","to":"run_tests"},{"attrs":{},"from":"run_tests","to":"report"},{"attrs":{},"from":"report","to":"exit"}],"name":"Simple","nodes":{"exit":{"attrs":{"label":{"String":"Exit"},"shape":{"String":"Msquare"}},"id":"exit"},"report":{"attrs":{"label":{"String":"Report"},"prompt":{"String":"Summarize the test results"}},"id":"report"},"run_tests":{"attrs":{"label":{"String":"Run Tests"},"prompt":{"String":"Run the test suite and report results"}},"id":"run_tests"},"start":{"attrs":{"label":{"String":"Start"},"shape":{"String":"Mdiamond"}},"id":"start"}}},"host_repo_path":"[TEMP_DIR]","run_dir":"[STORAGE_DIR]/runs/20260404-[ULID]","settings":{"auto_approve":true,"dry_run":true,"fabro":{"root":"fabro/"},"features":{"retros":false,"session_sandboxes":false},"goal":"Run tests and report results","hooks":[{"blocking":true,"command":"cargo fmt","event":"post_tool_use","matcher":"write_file|edit_file|apply_patch","name":"cargo-fmt","sandbox":null,"timeout_ms":null}],"llm":{"fallbacks":null,"model":"claude-sonnet-4-6","provider":"anthropic"},"mode":"standalone","no_retro":true,"pull_request":{"auto_merge":false,"draft":false,"enabled":true,"merge_strategy":"squash"},"sandbox":{"daytona":{"auto_stop_interval":30,"labels":{"repo":"fabro-sh/fabro"},"network":null,"skip_clone":false,"snapshot":{"cpu":4,"disk":20,"dockerfile":"FROM ubuntu:24.04/n/nRUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates build-essential pkg-config libssl-dev unzip python3 && rm -rf /var/lib/apt/lists/*/n/n# GitHub CLI/nRUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && apt-get update && apt-get install -y --no-install-recommends gh && rm -rf /var/lib/apt/lists/*/n/n# Rust/nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y/nENV PATH=\"/root/.cargo/bin:${PATH}\"/nRUN cargo install cargo-nextest --locked/nENV CARGO_INCREMENTAL=0/n/n# Bun/nRUN curl -fsSL https://bun.sh/install | bash/nENV PATH=\"/root/.bun/bin:${PATH}\"/n/nWORKDIR /root/n","memory":8,"name":"fabro-v6"}},"devcontainer":null,"env":null,"local":null,"preserve":null,"provider":"local"},"storage_dir":"[STORAGE_DIR]","version":1},"workflow_slug":"simple","workflow_source":"digraph Simple {/n graph [goal=\"Run tests and report results\"]/n rankdir=LR/n/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n/n run_tests [label=\"Run Tests\", prompt=\"Run the test suite and report results\"]/n report [label=\"Report\", prompt=\"Summarize the test results\"]/n/n start -> run_tests -> report -> exit/n}/n","working_directory":"[TEMP_DIR]"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
{"event":"run.created","id":"[EVENT_ID]","properties":{"graph":{"attrs":{"goal":{"String":"Run tests and report results"},"rankdir":{"String":"LR"}},"edges":[{"attrs":{},"from":"start","to":"run_tests"},{"attrs":{},"from":"run_tests","to":"report"},{"attrs":{},"from":"report","to":"exit"}],"name":"Simple","nodes":{"exit":{"attrs":{"label":{"String":"Exit"},"shape":{"String":"Msquare"}},"id":"exit"},"report":{"attrs":{"label":{"String":"Report"},"prompt":{"String":"Summarize the test results"}},"id":"report"},"run_tests":{"attrs":{"label":{"String":"Run Tests"},"prompt":{"String":"Run the test suite and report results"}},"id":"run_tests"},"start":{"attrs":{"label":{"String":"Start"},"shape":{"String":"Mdiamond"}},"id":"start"}}},"host_repo_path":"[TEMP_DIR]","labels":{"fabro_test_case":"[TEST_CASE]","fabro_test_run":"[TEST_RUN]"},"run_dir":"[STORAGE_DIR]/runs/20260404-[ULID]","settings":{"auto_approve":true,"dry_run":true,"fabro":{"root":"fabro/"},"features":{"retros":false,"session_sandboxes":false},"goal":"Run tests and report results","hooks":[{"blocking":true,"command":"cargo fmt","event":"post_tool_use","matcher":"write_file|edit_file|apply_patch","name":"cargo-fmt","sandbox":null,"timeout_ms":null}],"labels":{"fabro_test_case":"[TEST_CASE]","fabro_test_run":"[TEST_RUN]"},"llm":{"fallbacks":null,"model":"claude-sonnet-4-6","provider":"anthropic"},"mode":"standalone","no_retro":true,"pull_request":{"auto_merge":false,"draft":false,"enabled":true,"merge_strategy":"squash"},"sandbox":{"daytona":{"auto_stop_interval":30,"labels":{"repo":"fabro-sh/fabro"},"network":null,"skip_clone":false,"snapshot":{"cpu":4,"disk":20,"dockerfile":"FROM ubuntu:24.04/n/nRUN apt-get update && apt-get install -y --no-install-recommends curl git ca-certificates build-essential pkg-config libssl-dev unzip python3 && rm -rf /var/lib/apt/lists/*/n/n# GitHub CLI/nRUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && apt-get update && apt-get install -y --no-install-recommends gh && rm -rf /var/lib/apt/lists/*/n/n# Rust/nRUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y/nENV PATH=\"/root/.cargo/bin:${PATH}\"/nRUN cargo install cargo-nextest --locked/nENV CARGO_INCREMENTAL=0/n/n# Bun/nRUN curl -fsSL https://bun.sh/install | bash/nENV PATH=\"/root/.bun/bin:${PATH}\"/n/nWORKDIR /root/n","memory":8,"name":"fabro-v6"}},"devcontainer":null,"env":null,"local":null,"preserve":null,"provider":"local"},"storage_dir":"[STORAGE_DIR]","version":1},"workflow_slug":"simple","workflow_source":"digraph Simple {/n graph [goal=\"Run tests and report results\"]/n rankdir=LR/n/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n/n run_tests [label=\"Run Tests\", prompt=\"Run the test suite and report results\"]/n report [label=\"Report\", prompt=\"Summarize the test results\"]/n/n start -> run_tests -> report -> exit/n}/n","working_directory":"[TEMP_DIR]"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
{"event":"run.submitted","id":"[EVENT_ID]","properties":{},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
{"event":"run.starting","id":"[EVENT_ID]","properties":{"reason":"sandbox_initializing"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
{"event":"sandbox.initializing","id":"[EVENT_ID]","properties":{"provider":"local"},"run_id":"[ULID]","ts":"[TIMESTAMP]"}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
use fabro_test::{fabro_snapshot, test_context};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::support::{fixture, run_success, setup_completed_dry_run, setup_created_dry_run};
|
||||
use super::support::{fixture, setup_completed_dry_run, setup_created_dry_run};
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
|
|
@ -37,7 +38,8 @@ fn help() {
|
|||
fn ps_default_excludes_non_running_runs() {
|
||||
let context = test_context!();
|
||||
setup_completed_dry_run(&context);
|
||||
let cmd = context.ps();
|
||||
let mut cmd = context.ps();
|
||||
cmd.args(["--label", &context.test_case_label()]);
|
||||
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: true
|
||||
|
|
@ -53,55 +55,35 @@ fn ps_all_json_lists_created_and_completed_runs() {
|
|||
let context = test_context!();
|
||||
setup_completed_dry_run(&context);
|
||||
setup_created_dry_run(&context);
|
||||
let mut filters = context.filters();
|
||||
filters.push((
|
||||
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})".to_string(),
|
||||
"[TIMESTAMP]".to_string(),
|
||||
));
|
||||
filters.push((
|
||||
r#""duration_ms":\s*\d+"#.to_string(),
|
||||
r#""duration_ms": [DURATION_MS]"#.to_string(),
|
||||
));
|
||||
filters.push((r"\d{8}-dry-run-".to_string(), "[DATE]-dry-run-".to_string()));
|
||||
let mut cmd = context.ps();
|
||||
cmd.args(["-a", "--json"]);
|
||||
let output = context
|
||||
.ps()
|
||||
.args(["-a", "--json", "--label", &context.test_case_label()])
|
||||
.output()
|
||||
.expect("ps should run");
|
||||
|
||||
fabro_snapshot!(filters, cmd, @r#"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
[
|
||||
{
|
||||
"run_id": "[ULID]",
|
||||
"dir_name": "20260404-[ULID]",
|
||||
"workflow_name": "Simple",
|
||||
"workflow_slug": "simple",
|
||||
"status": "submitted",
|
||||
"status_reason": null,
|
||||
"start_time": "[TIMESTAMP]",
|
||||
"labels": {},
|
||||
"duration_ms": null,
|
||||
"total_cost": null,
|
||||
"host_repo_path": "[TEMP_DIR]",
|
||||
"goal": "Run tests and report results"
|
||||
},
|
||||
{
|
||||
"run_id": "[ULID]",
|
||||
"dir_name": "20260404-[ULID]",
|
||||
"workflow_name": "Simple",
|
||||
"workflow_slug": "simple",
|
||||
"status": "succeeded",
|
||||
"status_reason": "completed",
|
||||
"start_time": "[TIMESTAMP]",
|
||||
"labels": {},
|
||||
"duration_ms": [DURATION_MS],
|
||||
"total_cost": null,
|
||||
"host_repo_path": "[TEMP_DIR]",
|
||||
"goal": "Run tests and report results"
|
||||
}
|
||||
]
|
||||
----- stderr -----
|
||||
"#);
|
||||
assert!(output.status.success(), "ps should succeed");
|
||||
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
|
||||
assert_eq!(runs.len(), 2, "expected submitted + completed runs");
|
||||
assert!(
|
||||
runs.iter().all(|run| run["workflow_name"] == "Simple"),
|
||||
"all runs should belong to the Simple workflow: {runs:#?}"
|
||||
);
|
||||
assert!(
|
||||
runs.iter().all(|run| run["labels"]["fabro_test_case"] == context.test_case_id()),
|
||||
"all runs should be scoped to the current test case: {runs:#?}"
|
||||
);
|
||||
assert!(
|
||||
runs.iter().all(|run| run["labels"]["fabro_test_run"] == context.test_run_id()),
|
||||
"all runs should be scoped to the current test session: {runs:#?}"
|
||||
);
|
||||
assert!(
|
||||
runs.iter().any(|run| run["status"] == "submitted"),
|
||||
"ps should include the created run: {runs:#?}"
|
||||
);
|
||||
assert!(
|
||||
runs.iter().any(|run| run["status"] == "succeeded"),
|
||||
"ps should include the completed run: {runs:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -110,7 +92,7 @@ fn ps_quiet_outputs_run_ids_only() {
|
|||
setup_completed_dry_run(&context);
|
||||
setup_created_dry_run(&context);
|
||||
let mut cmd = context.ps();
|
||||
cmd.args(["-a", "--quiet"]);
|
||||
cmd.args(["-a", "--quiet", "--label", &context.test_case_label()]);
|
||||
|
||||
fabro_snapshot!(context.filters(), cmd, @r###"
|
||||
success: true
|
||||
|
|
@ -128,73 +110,53 @@ fn ps_filters_by_workflow_and_label() {
|
|||
let simple = fixture("simple.fabro");
|
||||
let branching = fixture("branching.fabro");
|
||||
|
||||
run_success(
|
||||
&context,
|
||||
&[
|
||||
"run",
|
||||
context
|
||||
.run_cmd()
|
||||
.args([
|
||||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--no-retro",
|
||||
"--label",
|
||||
"suite=alpha",
|
||||
simple.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
run_success(
|
||||
&context,
|
||||
&[
|
||||
"run",
|
||||
])
|
||||
.arg(&simple)
|
||||
.assert()
|
||||
.success();
|
||||
context
|
||||
.run_cmd()
|
||||
.args([
|
||||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--no-retro",
|
||||
"--label",
|
||||
"suite=beta",
|
||||
branching.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
])
|
||||
.arg(&branching)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let mut filters = context.filters();
|
||||
filters.push((
|
||||
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})".to_string(),
|
||||
"[TIMESTAMP]".to_string(),
|
||||
));
|
||||
filters.push((
|
||||
r#""duration_ms":\s*\d+"#.to_string(),
|
||||
r#""duration_ms": [DURATION_MS]"#.to_string(),
|
||||
));
|
||||
filters.push((r"\d{8}-dry-run-".to_string(), "[DATE]-dry-run-".to_string()));
|
||||
let mut cmd = context.ps();
|
||||
cmd.args([
|
||||
"-a",
|
||||
"--json",
|
||||
"--workflow",
|
||||
"Simple",
|
||||
"--label",
|
||||
"suite=alpha",
|
||||
]);
|
||||
let output = context
|
||||
.ps()
|
||||
.args([
|
||||
"-a",
|
||||
"--json",
|
||||
"--workflow",
|
||||
"Simple",
|
||||
"--label",
|
||||
"suite=alpha",
|
||||
"--label",
|
||||
&context.test_case_label(),
|
||||
])
|
||||
.output()
|
||||
.expect("ps should run");
|
||||
|
||||
fabro_snapshot!(filters, cmd, @r#"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
[
|
||||
{
|
||||
"run_id": "[ULID]",
|
||||
"dir_name": "20260404-[ULID]",
|
||||
"workflow_name": "Simple",
|
||||
"workflow_slug": "simple",
|
||||
"status": "succeeded",
|
||||
"status_reason": "completed",
|
||||
"start_time": "[TIMESTAMP]",
|
||||
"labels": {
|
||||
"suite": "alpha"
|
||||
},
|
||||
"duration_ms": [DURATION_MS],
|
||||
"total_cost": null,
|
||||
"host_repo_path": "[TEMP_DIR]",
|
||||
"goal": "Run tests and report results"
|
||||
}
|
||||
]
|
||||
----- stderr -----
|
||||
"#);
|
||||
assert!(output.status.success(), "ps should succeed");
|
||||
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
|
||||
assert_eq!(runs.len(), 1, "workflow+label filter should isolate one run");
|
||||
let run = &runs[0];
|
||||
assert_eq!(run["workflow_name"], "Simple");
|
||||
assert_eq!(run["status"], "succeeded");
|
||||
assert_eq!(run["labels"]["suite"], "alpha");
|
||||
assert_eq!(run["labels"]["fabro_test_case"], context.test_case_id());
|
||||
assert_eq!(run["labels"]["fabro_test_run"], context.test_run_id());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ fn rm_deletes_completed_run() {
|
|||
assert!(!run.run_dir.exists(), "run directory should be deleted");
|
||||
|
||||
let mut ps = context.ps();
|
||||
ps.args(["-a", "--json"]);
|
||||
ps.args(["-a", "--json", "--label", &context.test_case_label()]);
|
||||
fabro_snapshot!(context.filters(), ps, @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
|
|
@ -109,7 +109,7 @@ fn rm_force_deletes_submitted_run() {
|
|||
assert!(!run.run_dir.exists(), "run directory should be deleted");
|
||||
|
||||
let mut ps = context.ps();
|
||||
ps.args(["-a", "--json"]);
|
||||
ps.args(["-a", "--json", "--label", &context.test_case_label()]);
|
||||
fabro_snapshot!(context.filters(), ps, @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
use fabro_test::{fabro_snapshot, test_context};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::support::{example_fixture, fabro_json_snapshot, run_output_filters};
|
||||
use crate::support::{example_fixture, fabro_json_snapshot, run_output_filters, unique_run_id};
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
|
|
@ -78,7 +78,7 @@ fn dry_run_simple() {
|
|||
#[test]
|
||||
fn dry_run_persists_event_history_in_store() {
|
||||
let context = test_context!();
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FB8";
|
||||
let run_id = unique_run_id();
|
||||
|
||||
context
|
||||
.command()
|
||||
|
|
@ -89,16 +89,16 @@ fn dry_run_persists_event_history_in_store() {
|
|||
"--sandbox",
|
||||
"local",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
example_fixture("simple.fabro").to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
context.find_run_dir(run_id);
|
||||
context.find_run_dir(&run_id);
|
||||
let output = context
|
||||
.command()
|
||||
.args(["logs", run_id])
|
||||
.args(["logs", &run_id])
|
||||
.output()
|
||||
.expect("logs command should execute");
|
||||
assert!(
|
||||
|
|
@ -135,7 +135,7 @@ fn dry_run_persists_event_history_in_store() {
|
|||
|
||||
let tail_output = context
|
||||
.command()
|
||||
.args(["logs", "--tail", "1", run_id])
|
||||
.args(["logs", "--tail", "1", &run_id])
|
||||
.output()
|
||||
.expect("tail logs command should execute");
|
||||
assert!(
|
||||
|
|
@ -169,7 +169,7 @@ fn dry_run_persists_event_history_in_store() {
|
|||
#[test]
|
||||
fn run_id_passthrough_uses_provided_ulid() {
|
||||
let context = test_context!();
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
|
||||
let run_id = unique_run_id();
|
||||
|
||||
context
|
||||
.command()
|
||||
|
|
@ -178,13 +178,13 @@ fn run_id_passthrough_uses_provided_ulid() {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
example_fixture("simple.fabro").to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
context.find_run_dir(run_id);
|
||||
context.find_run_dir(&run_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -916,7 +916,7 @@ fn detach_prints_ulid_and_exits() {
|
|||
#[test]
|
||||
fn detach_creates_run_dir_with_detach_log() {
|
||||
let context = test_context!();
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FB9";
|
||||
let run_id = unique_run_id();
|
||||
|
||||
context
|
||||
.run_cmd()
|
||||
|
|
@ -925,13 +925,13 @@ fn detach_creates_run_dir_with_detach_log() {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
example_fixture("simple.fabro").to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = context.find_run_dir(run_id);
|
||||
let run_dir = context.find_run_dir(&run_id);
|
||||
fabro_json_snapshot!(
|
||||
context,
|
||||
serde_json::json!({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
use fabro_test::{fabro_snapshot, test_context};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
|
|
@ -83,3 +86,111 @@ fn start_already_running_exits_with_error() {
|
|||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_autostart_converges_on_one_shared_daemon_and_cleans_up() {
|
||||
fn run_ps_json(home_dir: &std::path::Path, temp_dir: &std::path::Path, storage_dir: &std::path::Path) -> std::process::Output {
|
||||
std::process::Command::new(env!("CARGO_BIN_EXE_fabro"))
|
||||
.current_dir(temp_dir)
|
||||
.env("NO_COLOR", "1")
|
||||
.env("HOME", home_dir)
|
||||
.env("FABRO_NO_UPGRADE_CHECK", "true")
|
||||
.env("FABRO_STORAGE_DIR", storage_dir)
|
||||
.args(["ps", "-a", "--json"])
|
||||
.output()
|
||||
.expect("ps command should execute")
|
||||
}
|
||||
|
||||
fn daemon_match_count(socket_path: &str) -> usize {
|
||||
let output = std::process::Command::new("ps")
|
||||
.args(["-ww", "-axo", "command="])
|
||||
.stdout(Stdio::piped())
|
||||
.output()
|
||||
.expect("ps should execute");
|
||||
assert!(output.status.success(), "ps should succeed");
|
||||
String::from_utf8(output.stdout)
|
||||
.expect("ps output should be UTF-8")
|
||||
.lines()
|
||||
.filter(|line| line.contains("fabro: server") && line.contains(socket_path))
|
||||
.count()
|
||||
}
|
||||
|
||||
let storage_dir;
|
||||
let socket_path;
|
||||
{
|
||||
let context_a = test_context!();
|
||||
let context_b = test_context!();
|
||||
assert_eq!(context_a.storage_dir, context_b.storage_dir);
|
||||
storage_dir = context_a.storage_dir.clone();
|
||||
socket_path = storage_dir.join("fabro.sock").display().to_string();
|
||||
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let home_a = context_a.home_dir.clone();
|
||||
let temp_a = context_a.temp_dir.clone();
|
||||
let storage_a = context_a.storage_dir.clone();
|
||||
let barrier_a = Arc::clone(&barrier);
|
||||
let thread_a = std::thread::spawn(move || {
|
||||
barrier_a.wait();
|
||||
run_ps_json(&home_a, &temp_a, &storage_a)
|
||||
});
|
||||
|
||||
let home_b = context_b.home_dir.clone();
|
||||
let temp_b = context_b.temp_dir.clone();
|
||||
let storage_b = context_b.storage_dir.clone();
|
||||
let barrier_b = Arc::clone(&barrier);
|
||||
let thread_b = std::thread::spawn(move || {
|
||||
barrier_b.wait();
|
||||
run_ps_json(&home_b, &temp_b, &storage_b)
|
||||
});
|
||||
|
||||
barrier.wait();
|
||||
let output_a = thread_a.join().expect("thread A should join");
|
||||
let output_b = thread_b.join().expect("thread B should join");
|
||||
assert!(
|
||||
output_a.status.success(),
|
||||
"first concurrent ps should succeed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output_a.stdout),
|
||||
String::from_utf8_lossy(&output_a.stderr)
|
||||
);
|
||||
assert!(
|
||||
output_b.status.success(),
|
||||
"second concurrent ps should succeed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output_b.stdout),
|
||||
String::from_utf8_lossy(&output_b.stderr)
|
||||
);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
if storage_dir.join("server.json").exists() && daemon_match_count(&socket_path) == 1 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
assert!(
|
||||
storage_dir.join("server.json").exists(),
|
||||
"shared storage should have an active server record"
|
||||
);
|
||||
assert_eq!(
|
||||
daemon_match_count(&socket_path),
|
||||
1,
|
||||
"concurrent auto-start should converge on one daemon"
|
||||
);
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
if !storage_dir.join("server.json").exists() && daemon_match_count(&socket_path) == 0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
assert!(
|
||||
!storage_dir.join("server.json").exists(),
|
||||
"last TestContext drop should remove the server record"
|
||||
);
|
||||
assert_eq!(
|
||||
daemon_match_count(&socket_path),
|
||||
0,
|
||||
"last TestContext drop should clean up the shared daemon"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
use fabro_test::{fabro_snapshot, test_context};
|
||||
|
||||
use crate::support::{example_fixture, fabro_json_snapshot};
|
||||
use crate::support::{example_fixture, fabro_json_snapshot, unique_run_id};
|
||||
|
||||
use super::support::{output_stdout, resolve_run, wait_for_status, write_gated_workflow};
|
||||
|
||||
const SHARED_DAEMON_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
#[test]
|
||||
fn help() {
|
||||
let context = test_context!();
|
||||
|
|
@ -36,7 +38,7 @@ fn help() {
|
|||
#[test]
|
||||
fn start_by_run_id_starts_created_run() {
|
||||
let context = test_context!();
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAC";
|
||||
let run_id = unique_run_id();
|
||||
|
||||
context
|
||||
.command()
|
||||
|
|
@ -45,23 +47,23 @@ fn start_by_run_id_starts_created_run() {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
example_fixture("simple.fabro").to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
context.command().args(["start", run_id]).assert().success();
|
||||
context.command().args(["start", &run_id]).assert().success();
|
||||
context
|
||||
.command()
|
||||
.args(["wait", run_id])
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.args(["wait", &run_id])
|
||||
.timeout(SHARED_DAEMON_TIMEOUT)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let output = context
|
||||
.command()
|
||||
.args(["wait", "--json", run_id])
|
||||
.args(["wait", "--json", &run_id])
|
||||
.output()
|
||||
.expect("wait should execute");
|
||||
assert!(output.status.success(), "wait should succeed");
|
||||
|
|
@ -82,7 +84,7 @@ fn start_by_run_id_starts_created_run() {
|
|||
#[test]
|
||||
fn start_by_run_id_starts_created_run_without_run_json_or_status_json() {
|
||||
let context = test_context!();
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAH";
|
||||
let run_id = unique_run_id();
|
||||
|
||||
context
|
||||
.command()
|
||||
|
|
@ -91,20 +93,20 @@ fn start_by_run_id_starts_created_run_without_run_json_or_status_json() {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
example_fixture("simple.fabro").to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = context.find_run_dir(run_id);
|
||||
let run_dir = context.find_run_dir(&run_id);
|
||||
let _ = std::fs::remove_file(run_dir.join("run.json"));
|
||||
|
||||
context.command().args(["start", run_id]).assert().success();
|
||||
context.command().args(["start", &run_id]).assert().success();
|
||||
let output = context
|
||||
.command()
|
||||
.args(["wait", "--json", run_id])
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.args(["wait", "--json", &run_id])
|
||||
.timeout(SHARED_DAEMON_TIMEOUT)
|
||||
.output()
|
||||
.expect("wait should execute");
|
||||
assert!(output.status.success(), "wait should succeed");
|
||||
|
|
@ -126,8 +128,8 @@ fn start_by_run_id_starts_created_run_without_run_json_or_status_json() {
|
|||
fn start_by_workflow_name_prefers_newly_created_submitted_run() {
|
||||
let context = test_context!();
|
||||
let workflow_path = context.temp_dir.join("smoke/workflow.fabro");
|
||||
let old_run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAD";
|
||||
let new_run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAE";
|
||||
let old_run_id = unique_run_id();
|
||||
let new_run_id = unique_run_id();
|
||||
|
||||
context.write_temp(
|
||||
"smoke/workflow.fabro",
|
||||
|
|
@ -148,20 +150,20 @@ digraph Smoke {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
old_run_id,
|
||||
old_run_id.as_str(),
|
||||
workflow_path.to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
context
|
||||
.command()
|
||||
.args(["start", old_run_id])
|
||||
.args(["start", &old_run_id])
|
||||
.assert()
|
||||
.success();
|
||||
context
|
||||
.command()
|
||||
.args(["wait", old_run_id])
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.args(["wait", &old_run_id])
|
||||
.timeout(SHARED_DAEMON_TIMEOUT)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
|
|
@ -172,7 +174,7 @@ digraph Smoke {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
new_run_id,
|
||||
new_run_id.as_str(),
|
||||
workflow_path.to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
|
|
@ -185,14 +187,14 @@ digraph Smoke {
|
|||
.success();
|
||||
context
|
||||
.command()
|
||||
.args(["attach", new_run_id])
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.args(["attach", &new_run_id])
|
||||
.timeout(SHARED_DAEMON_TIMEOUT)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let output = context
|
||||
.command()
|
||||
.args(["wait", "--json", new_run_id])
|
||||
.args(["wait", "--json", &new_run_id])
|
||||
.output()
|
||||
.expect("wait should execute");
|
||||
assert!(output.status.success(), "wait should succeed");
|
||||
|
|
@ -264,3 +266,57 @@ fn start_rejects_already_active_or_completed_run() {
|
|||
error: cannot start run: status is Succeeded, expected submitted
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_runs_under_server_ownership_without_launcher_record() {
|
||||
let context = test_context!();
|
||||
let gate = write_gated_workflow(
|
||||
&context.temp_dir.join("owned-by-server.fabro"),
|
||||
"owned-by-server",
|
||||
"Run under daemon ownership",
|
||||
);
|
||||
|
||||
let output = context
|
||||
.command()
|
||||
.args([
|
||||
"create",
|
||||
"--provider",
|
||||
"openai",
|
||||
"--sandbox",
|
||||
"local",
|
||||
"--no-retro",
|
||||
"owned-by-server.fabro",
|
||||
])
|
||||
.env("OPENAI_API_KEY", "test")
|
||||
.output()
|
||||
.expect("create should execute");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"create failed:\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let run_id = output_stdout(&output).trim().to_string();
|
||||
let run = resolve_run(&context, &run_id);
|
||||
|
||||
context
|
||||
.command()
|
||||
.args(["start", &run_id])
|
||||
.env("OPENAI_API_KEY", "test")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
wait_for_status(&run.run_dir, &["running"]);
|
||||
assert!(
|
||||
!context
|
||||
.storage_dir
|
||||
.join("launchers")
|
||||
.join(format!("{run_id}.json"))
|
||||
.exists(),
|
||||
"server-owned execution should not create a launcher record"
|
||||
);
|
||||
|
||||
gate.release();
|
||||
wait_for_status(&run.run_dir, &["succeeded"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,37 +95,39 @@ fn run_success_in(context: &TestContext, args: &[&str], cwd: &Path) -> Output {
|
|||
|
||||
pub(crate) fn setup_completed_dry_run(context: &TestContext) -> RunSetup {
|
||||
let workflow = fixture("simple.fabro");
|
||||
run_success_in(
|
||||
context,
|
||||
&[
|
||||
"run",
|
||||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--no-retro",
|
||||
"--sandbox",
|
||||
"local",
|
||||
workflow.to_str().unwrap(),
|
||||
],
|
||||
&context.temp_dir,
|
||||
);
|
||||
let mut cmd = context.run_cmd();
|
||||
cmd.current_dir(&context.temp_dir);
|
||||
cmd.timeout(COMMAND_TIMEOUT);
|
||||
cmd.args(["--dry-run", "--auto-approve", "--no-retro", "--sandbox", "local"]);
|
||||
cmd.arg(workflow);
|
||||
let output = cmd.output().expect("command should execute");
|
||||
if !output.status.success() {
|
||||
panic!(
|
||||
"command failed: fabro run --dry-run --auto-approve --no-retro --sandbox local {}\nstdout:\n{}\nstderr:\n{}",
|
||||
fixture("simple.fabro").display(),
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
}
|
||||
only_run(context)
|
||||
}
|
||||
|
||||
pub(crate) fn setup_created_dry_run(context: &TestContext) -> RunSetup {
|
||||
let workflow = fixture("simple.fabro");
|
||||
let output = run_success_in(
|
||||
context,
|
||||
&[
|
||||
"create",
|
||||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--no-retro",
|
||||
"--sandbox",
|
||||
"local",
|
||||
workflow.to_str().unwrap(),
|
||||
],
|
||||
&context.temp_dir,
|
||||
);
|
||||
let mut cmd = context.create_cmd();
|
||||
cmd.current_dir(&context.temp_dir);
|
||||
cmd.timeout(COMMAND_TIMEOUT);
|
||||
cmd.args(["--dry-run", "--auto-approve", "--no-retro", "--sandbox", "local"]);
|
||||
cmd.arg(workflow);
|
||||
let output = cmd.output().expect("command should execute");
|
||||
if !output.status.success() {
|
||||
panic!(
|
||||
"command failed: fabro create --dry-run --auto-approve --no-retro --sandbox local {}\nstdout:\n{}\nstderr:\n{}",
|
||||
fixture("simple.fabro").display(),
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
}
|
||||
let run_id = stdout(&output)
|
||||
.lines()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
|
|
@ -137,20 +139,27 @@ pub(crate) fn setup_created_dry_run(context: &TestContext) -> RunSetup {
|
|||
|
||||
pub(crate) fn setup_detached_dry_run(context: &TestContext) -> RunSetup {
|
||||
let workflow = fixture("simple.fabro");
|
||||
let output = run_success_in(
|
||||
context,
|
||||
&[
|
||||
"run",
|
||||
"--detach",
|
||||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--no-retro",
|
||||
"--sandbox",
|
||||
"local",
|
||||
workflow.to_str().unwrap(),
|
||||
],
|
||||
&context.temp_dir,
|
||||
);
|
||||
let mut cmd = context.run_cmd();
|
||||
cmd.current_dir(&context.temp_dir);
|
||||
cmd.timeout(COMMAND_TIMEOUT);
|
||||
cmd.args([
|
||||
"--detach",
|
||||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--no-retro",
|
||||
"--sandbox",
|
||||
"local",
|
||||
]);
|
||||
cmd.arg(workflow);
|
||||
let output = cmd.output().expect("command should execute");
|
||||
if !output.status.success() {
|
||||
panic!(
|
||||
"command failed: fabro run --detach --dry-run --auto-approve --no-retro --sandbox local {}\nstdout:\n{}\nstderr:\n{}",
|
||||
fixture("simple.fabro").display(),
|
||||
stdout(&output),
|
||||
stderr(&output)
|
||||
);
|
||||
}
|
||||
let run_id = stdout(&output)
|
||||
.lines()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
|
|
@ -294,12 +303,11 @@ worktree_mode = "never"
|
|||
}
|
||||
|
||||
fn run_local_workflow(context: &TestContext, workspace_dir: &Path, workflow: &str) -> RunSetup {
|
||||
let mut cmd = context.command();
|
||||
let mut cmd = context.run_cmd();
|
||||
cmd.current_dir(workspace_dir);
|
||||
cmd.timeout(COMMAND_TIMEOUT);
|
||||
cmd.env("OPENAI_API_KEY", "test");
|
||||
cmd.args([
|
||||
"run",
|
||||
"--auto-approve",
|
||||
"--no-retro",
|
||||
"--sandbox",
|
||||
|
|
@ -393,24 +401,45 @@ pub(crate) fn wait_for_status(run_dir: &Path, expected: &[&str]) -> String {
|
|||
}
|
||||
|
||||
pub(crate) fn only_run(context: &TestContext) -> RunSetup {
|
||||
let entries = run_dirs_for_test_case(context);
|
||||
let runs_dir = context.storage_dir.join("runs");
|
||||
let entries: Vec<_> = std::fs::read_dir(&runs_dir)
|
||||
.unwrap_or_else(|err| panic!("failed to read {}: {err}", runs_dir.display()))
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
1,
|
||||
"expected exactly one run under {}",
|
||||
runs_dir.display()
|
||||
"expected exactly one run for fabro_test_case={} under {}",
|
||||
context.test_case_id(),
|
||||
runs_dir.display(),
|
||||
);
|
||||
let run_dir = entries[0].clone();
|
||||
let run_id = infer_run_id(&run_dir);
|
||||
RunSetup { run_id, run_dir }
|
||||
}
|
||||
|
||||
pub(crate) fn run_count_for_test_case(context: &TestContext) -> usize {
|
||||
run_dirs_for_test_case(context).len()
|
||||
}
|
||||
|
||||
fn run_dirs_for_test_case(context: &TestContext) -> Vec<PathBuf> {
|
||||
let runs_dir = context.storage_dir.join("runs");
|
||||
let entries = match std::fs::read_dir(&runs_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
|
||||
Err(err) => panic!("failed to read {}: {err}", runs_dir.display()),
|
||||
};
|
||||
entries
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.filter(|path| {
|
||||
std::panic::catch_unwind(|| run_state(path)).ok().and_then(|state| state.run).is_some_and(|run| {
|
||||
run.labels
|
||||
.get("fabro_test_case")
|
||||
.is_some_and(|value| value == context.test_case_id())
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn git_filters(context: &TestContext) -> Vec<(String, String)> {
|
||||
let mut filters = context.filters();
|
||||
filters.push((r"\b[0-9a-f]{7,40}\b".to_string(), "[SHA]".to_string()));
|
||||
|
|
@ -742,11 +771,10 @@ fn setup_git_backed_run(context: &TestContext, workflow: GitWorkflowKind) -> Git
|
|||
.trim()
|
||||
.to_string();
|
||||
|
||||
let mut cmd = context.command();
|
||||
let mut cmd = context.run_cmd();
|
||||
cmd.current_dir(&repo_dir);
|
||||
cmd.env("OPENAI_API_KEY", "test");
|
||||
cmd.args([
|
||||
"run",
|
||||
"--sandbox",
|
||||
"local",
|
||||
"--no-retro",
|
||||
|
|
|
|||
|
|
@ -36,61 +36,61 @@ fn system_df_summarizes_runs_and_logs() {
|
|||
std::fs::create_dir_all(context.storage_dir.join("logs")).unwrap();
|
||||
std::fs::write(context.storage_dir.join("logs/cli.log"), b"log line\n").unwrap();
|
||||
|
||||
let mut filters = context.filters();
|
||||
filters.push((
|
||||
r"\b\d+(\.\d+)?\s(?:[KMGT]?B|B)\b".to_string(),
|
||||
"[SIZE]".to_string(),
|
||||
));
|
||||
let output = context
|
||||
.command()
|
||||
.args(["system", "df"])
|
||||
.output()
|
||||
.expect("command should run");
|
||||
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["system", "df"]);
|
||||
fabro_snapshot!(filters, cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
TYPE COUNT ACTIVE SIZE RECLAIMABLE
|
||||
Runs 1 0 [SIZE] [SIZE] (0%)
|
||||
Logs 1 - [SIZE] [SIZE] (100%)
|
||||
|
||||
Data directory: [STORAGE_DIR]
|
||||
----- stderr -----
|
||||
");
|
||||
assert!(output.status.success(), "system df failed");
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be valid UTF-8");
|
||||
assert!(
|
||||
stdout.contains("Runs"),
|
||||
"system df should summarize runs: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("Logs"),
|
||||
"system df should summarize logs: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("Data directory:"),
|
||||
"system df should print the storage directory: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_df_verbose_lists_runs_with_reclaimable_marker() {
|
||||
let context = test_context!();
|
||||
setup_completed_dry_run(&context);
|
||||
let run = setup_completed_dry_run(&context);
|
||||
|
||||
let mut filters = context.filters();
|
||||
filters.push((
|
||||
r"\b\d+(\.\d+)?\s(?:[KMGT]?B|B)\b".to_string(),
|
||||
"[SIZE]".to_string(),
|
||||
));
|
||||
filters.push((
|
||||
r"\b[0-9A-HJKMNP-TV-Z]{12}\b".to_string(),
|
||||
"[RUN_PREFIX]".to_string(),
|
||||
));
|
||||
filters.push((r"\b\d+[mhd]\b".to_string(), "[AGE]".to_string()));
|
||||
let output = context
|
||||
.command()
|
||||
.args(["system", "df", "-v"])
|
||||
.output()
|
||||
.expect("command should run");
|
||||
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["system", "df", "-v"]);
|
||||
fabro_snapshot!(filters, cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
----- stdout -----
|
||||
TYPE COUNT ACTIVE SIZE RECLAIMABLE
|
||||
Runs 1 0 [SIZE] [SIZE] (0%)
|
||||
Logs 0 - [SIZE] [SIZE] (0%)
|
||||
|
||||
Data directory: [STORAGE_DIR]
|
||||
|
||||
RUN ID WORKFLOW STATUS AGE SIZE
|
||||
[RUN_PREFIX] Simple succeeded [AGE] [SIZE] *
|
||||
|
||||
* = reclaimable
|
||||
----- stderr -----
|
||||
");
|
||||
assert!(output.status.success(), "system df -v failed");
|
||||
let stdout = String::from_utf8(output.stdout).expect("stdout should be valid UTF-8");
|
||||
assert!(
|
||||
stdout.contains("RUN ID"),
|
||||
"verbose system df should print the run table: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains(&run.run_id[..12]),
|
||||
"verbose system df should include the current test run: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("Simple"),
|
||||
"verbose system df should include the workflow name: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("succeeded"),
|
||||
"verbose system df should include the run status: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("* = reclaimable"),
|
||||
"verbose system df should include the reclaimable marker legend: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -46,7 +46,14 @@ fn system_prune_dry_run_lists_matching_runs_without_deleting() {
|
|||
));
|
||||
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["system", "prune", "--workflow", "Simple"]);
|
||||
cmd.args([
|
||||
"system",
|
||||
"prune",
|
||||
"--workflow",
|
||||
"Simple",
|
||||
"--label",
|
||||
&context.test_case_label(),
|
||||
]);
|
||||
fabro_snapshot!(filters, cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
|
|
@ -73,7 +80,15 @@ fn system_prune_yes_deletes_matching_runs() {
|
|||
));
|
||||
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["system", "prune", "--workflow", "Simple", "--yes"]);
|
||||
cmd.args([
|
||||
"system",
|
||||
"prune",
|
||||
"--workflow",
|
||||
"Simple",
|
||||
"--label",
|
||||
&context.test_case_label(),
|
||||
"--yes",
|
||||
]);
|
||||
fabro_snapshot!(filters, cmd, @"
|
||||
success: true
|
||||
exit_code: 0
|
||||
|
|
@ -84,7 +99,7 @@ fn system_prune_yes_deletes_matching_runs() {
|
|||
assert!(!run.run_dir.exists(), "matching run should be deleted");
|
||||
|
||||
let mut ps = context.ps();
|
||||
ps.args(["-a", "--json"]);
|
||||
ps.args(["-a", "--json", "--label", &context.test_case_label()]);
|
||||
fabro_snapshot!(context.filters(), ps, @r###"
|
||||
success: true
|
||||
exit_code: 0
|
||||
|
|
@ -99,7 +114,15 @@ fn system_prune_does_not_delete_active_or_submitted_runs() {
|
|||
let context = test_context!();
|
||||
let run = setup_created_dry_run(&context);
|
||||
let mut cmd = context.command();
|
||||
cmd.args(["system", "prune", "--workflow", "Simple", "--yes"]);
|
||||
cmd.args([
|
||||
"system",
|
||||
"prune",
|
||||
"--workflow",
|
||||
"Simple",
|
||||
"--label",
|
||||
&context.test_case_label(),
|
||||
"--yes",
|
||||
]);
|
||||
|
||||
fabro_snapshot!(context.filters(), cmd, @"
|
||||
success: true
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use fabro_test::test_context;
|
|||
use serde_json::Value;
|
||||
|
||||
use super::{fixture, run_state, timeout_for};
|
||||
use crate::support::{example_fixture, fabro_json_snapshot};
|
||||
use crate::support::{example_fixture, fabro_json_snapshot, unique_run_id};
|
||||
|
||||
#[fabro_macros::e2e_test()]
|
||||
fn local_run_lifecycle() {
|
||||
|
|
@ -28,7 +28,8 @@ fn local_run_lifecycle() {
|
|||
.success();
|
||||
|
||||
// 2. ps -a --json — should list exactly one run
|
||||
let ps_out = cmd(&["ps", "-a", "--json"]).success();
|
||||
let label = context.test_case_label();
|
||||
let ps_out = cmd(&["ps", "-a", "--json", "--label", &label]).success();
|
||||
let ps_stdout = String::from_utf8(ps_out.get_output().stdout.clone()).unwrap();
|
||||
let runs: Vec<Value> =
|
||||
serde_json::from_str(&ps_stdout).expect("ps --json should produce a JSON array");
|
||||
|
|
@ -90,7 +91,7 @@ fn local_run_lifecycle() {
|
|||
cmd(&["rm", &run_id]).success();
|
||||
|
||||
// 8. ps -a --json — should be empty
|
||||
let ps_out2 = cmd(&["ps", "-a", "--json"]).success();
|
||||
let ps_out2 = cmd(&["ps", "-a", "--json", "--label", &label]).success();
|
||||
let ps_stdout2 = String::from_utf8(ps_out2.get_output().stdout.clone()).unwrap();
|
||||
let runs2: Vec<Value> =
|
||||
serde_json::from_str(&ps_stdout2).expect("ps --json should produce a JSON array");
|
||||
|
|
@ -103,7 +104,7 @@ fn local_run_lifecycle() {
|
|||
#[test]
|
||||
fn dry_run_create_start_attach_works_with_default_run_lookup() {
|
||||
let context = test_context!();
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAJ";
|
||||
let run_id = unique_run_id();
|
||||
|
||||
context
|
||||
.command()
|
||||
|
|
@ -112,21 +113,21 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
example_fixture("simple.fabro").to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
context.command().args(["start", run_id]).assert().success();
|
||||
context.command().args(["start", &run_id]).assert().success();
|
||||
context
|
||||
.command()
|
||||
.args(["attach", run_id])
|
||||
.args(["attach", &run_id])
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = context.find_run_dir(run_id);
|
||||
let run_dir = context.find_run_dir(&run_id);
|
||||
fabro_json_snapshot!(
|
||||
context,
|
||||
serde_json::json!({
|
||||
|
|
@ -145,7 +146,7 @@ fn dry_run_create_start_attach_works_with_default_run_lookup() {
|
|||
#[test]
|
||||
fn dry_run_detach_attach_works_with_default_run_lookup() {
|
||||
let context = test_context!();
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAK";
|
||||
let run_id = unique_run_id();
|
||||
|
||||
context
|
||||
.command()
|
||||
|
|
@ -155,7 +156,7 @@ fn dry_run_detach_attach_works_with_default_run_lookup() {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
example_fixture("simple.fabro").to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
|
|
@ -163,12 +164,12 @@ fn dry_run_detach_attach_works_with_default_run_lookup() {
|
|||
|
||||
context
|
||||
.command()
|
||||
.args(["attach", run_id])
|
||||
.args(["attach", &run_id])
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = context.find_run_dir(run_id);
|
||||
let run_dir = context.find_run_dir(&run_id);
|
||||
fabro_json_snapshot!(
|
||||
context,
|
||||
serde_json::json!({
|
||||
|
|
@ -190,7 +191,7 @@ fn completed_run_can_be_attached_by_workflow_slug() {
|
|||
let project = tempfile::tempdir().unwrap();
|
||||
let workflow_dir = project.path().join("workflows").join("sluggy");
|
||||
let workflow_path = workflow_dir.join("workflow.fabro");
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAQ";
|
||||
let run_id = unique_run_id();
|
||||
|
||||
std::fs::create_dir_all(&workflow_dir).unwrap();
|
||||
std::fs::write(
|
||||
|
|
@ -213,7 +214,7 @@ digraph BarBaz {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
workflow_path.to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
|
|
@ -227,7 +228,7 @@ digraph BarBaz {
|
|||
context
|
||||
.command()
|
||||
.current_dir(project.path())
|
||||
.args(["attach", run_id])
|
||||
.args(["attach", &run_id])
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.assert()
|
||||
.success();
|
||||
|
|
@ -239,7 +240,7 @@ digraph BarBaz {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_record = run_state(&context.find_run_dir(run_id))
|
||||
let run_record = run_state(&context.find_run_dir(&run_id))
|
||||
.run
|
||||
.expect("run record should exist");
|
||||
fabro_json_snapshot!(
|
||||
|
|
@ -262,7 +263,7 @@ fn completed_run_can_be_attached_by_file_stem() {
|
|||
let context = test_context!();
|
||||
let workflow_dir = tempfile::tempdir().unwrap();
|
||||
let workflow_path = workflow_dir.path().join("alpha.fabro");
|
||||
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAM";
|
||||
let run_id = unique_run_id();
|
||||
|
||||
std::fs::write(
|
||||
&workflow_path,
|
||||
|
|
@ -283,7 +284,7 @@ digraph FooWorkflow {
|
|||
"--dry-run",
|
||||
"--auto-approve",
|
||||
"--run-id",
|
||||
run_id,
|
||||
run_id.as_str(),
|
||||
workflow_path.to_str().unwrap(),
|
||||
])
|
||||
.assert()
|
||||
|
|
@ -300,7 +301,7 @@ digraph FooWorkflow {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_record = run_state(&context.find_run_dir(run_id))
|
||||
let run_record = run_state(&context.find_run_dir(&run_id))
|
||||
.run
|
||||
.expect("run record should exist");
|
||||
fabro_json_snapshot!(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ use fabro_test::{fabro_snapshot, test_context};
|
|||
use fabro_types::Checkpoint;
|
||||
use git2::{Repository, Signature};
|
||||
|
||||
use crate::support::unique_run_id;
|
||||
|
||||
fn list_metadata_run_ids(repo_dir: &Path) -> BTreeSet<String> {
|
||||
let repo = Repository::discover(repo_dir).unwrap();
|
||||
repo.references()
|
||||
|
|
@ -104,7 +106,7 @@ digraph Recovery {
|
|||
fn rewind_and_fork_recover_missing_metadata_from_real_run_state() {
|
||||
let context = test_context!();
|
||||
let repo_dir = tempfile::tempdir().unwrap();
|
||||
let source_run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAN";
|
||||
let source_run_id = unique_run_id();
|
||||
|
||||
init_repo_with_workflow(repo_dir.path());
|
||||
|
||||
|
|
@ -118,7 +120,7 @@ fn rewind_and_fork_recover_missing_metadata_from_real_run_state() {
|
|||
"--sandbox",
|
||||
"local",
|
||||
"--run-id",
|
||||
source_run_id,
|
||||
source_run_id.as_str(),
|
||||
"workflow.fabro",
|
||||
])
|
||||
.assert()
|
||||
|
|
@ -142,7 +144,7 @@ fn rewind_and_fork_recover_missing_metadata_from_real_run_state() {
|
|||
|
||||
let mut rewind_list = context.command();
|
||||
rewind_list.current_dir(repo_dir.path());
|
||||
rewind_list.args(["rewind", source_run_id, "--list"]);
|
||||
rewind_list.args(["rewind", &source_run_id, "--list"]);
|
||||
rewind_list.timeout(std::time::Duration::from_secs(15));
|
||||
fabro_snapshot!(filters.clone(), rewind_list, @"
|
||||
success: true
|
||||
|
|
@ -155,7 +157,7 @@ fn rewind_and_fork_recover_missing_metadata_from_real_run_state() {
|
|||
@3 build
|
||||
");
|
||||
|
||||
let rebuilt_checkpoints = metadata_checkpoints(repo_dir.path(), source_run_id);
|
||||
let rebuilt_checkpoints = metadata_checkpoints(repo_dir.path(), &source_run_id);
|
||||
assert_eq!(
|
||||
rebuilt_checkpoints
|
||||
.first()
|
||||
|
|
@ -175,7 +177,7 @@ fn rewind_and_fork_recover_missing_metadata_from_real_run_state() {
|
|||
context
|
||||
.command()
|
||||
.current_dir(repo_dir.path())
|
||||
.args(["fork", source_run_id, "--no-push"])
|
||||
.args(["fork", &source_run_id, "--no-push"])
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.assert()
|
||||
.success();
|
||||
|
|
@ -195,7 +197,7 @@ fn rewind_and_fork_recover_missing_metadata_from_real_run_state() {
|
|||
|
||||
let mut source_rewind = context.command();
|
||||
source_rewind.current_dir(repo_dir.path());
|
||||
source_rewind.args(["rewind", source_run_id, "@2", "--no-push"]);
|
||||
source_rewind.args(["rewind", &source_run_id, "@2", "--no-push"]);
|
||||
source_rewind.timeout(std::time::Duration::from_secs(15));
|
||||
fabro_snapshot!(rewind_filters, source_rewind, @"
|
||||
success: true
|
||||
|
|
@ -208,14 +210,14 @@ fn rewind_and_fork_recover_missing_metadata_from_real_run_state() {
|
|||
To resume: fabro resume [RUN_PREFIX]
|
||||
");
|
||||
|
||||
let rewound_child = latest_metadata_checkpoint(repo_dir.path(), source_run_id);
|
||||
let rewound_child = latest_metadata_checkpoint(repo_dir.path(), &source_run_id);
|
||||
assert_eq!(rewound_child.git_commit_sha, plan_sha);
|
||||
|
||||
let before_grandchild = list_metadata_run_ids(repo_dir.path());
|
||||
context
|
||||
.command()
|
||||
.current_dir(repo_dir.path())
|
||||
.args(["fork", source_run_id, "--no-push"])
|
||||
.args(["fork", &source_run_id, "--no-push"])
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.assert()
|
||||
.success();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fabro_types::RunId;
|
||||
use fabro_test::TestContext;
|
||||
macro_rules! fabro_json_snapshot {
|
||||
($context:expr, $value:expr, @$snapshot:literal) => {{
|
||||
|
|
@ -45,3 +46,7 @@ pub(crate) fn run_output_filters(context: &TestContext) -> Vec<(String, String)>
|
|||
filters.push((r"\b\d+ms\b".to_string(), "[TIME]".to_string()));
|
||||
filters
|
||||
}
|
||||
|
||||
pub(crate) fn unique_run_id() -> String {
|
||||
RunId::new().to_string()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ fn scenario_agent_linear(sandbox: &str) {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = find_run_dir(&context.storage_dir);
|
||||
let run_dir = find_run_dir(&context);
|
||||
let conclusion = read_conclusion(&run_dir);
|
||||
assert_eq!(conclusion["status"].as_str(), Some("success"));
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ fn scenario_command_agent_mixed(sandbox: &str) {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = find_run_dir(&context.storage_dir);
|
||||
let run_dir = find_run_dir(&context);
|
||||
let conclusion = read_conclusion(&run_dir);
|
||||
assert_eq!(conclusion["status"].as_str(), Some("success"));
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ fn scenario_command_pipeline(sandbox: &str) {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = find_run_dir(&context.storage_dir);
|
||||
let run_dir = find_run_dir(&context);
|
||||
let conclusion = read_conclusion(&run_dir);
|
||||
assert_eq!(
|
||||
conclusion["status"].as_str(),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ fn scenario_conditional_branching(sandbox: &str) {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = find_run_dir(&context.storage_dir);
|
||||
let run_dir = find_run_dir(&context);
|
||||
let conclusion = read_conclusion(&run_dir);
|
||||
assert_eq!(conclusion["status"].as_str(), Some("success"));
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ fn scenario_full_stack(sandbox: &str) {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = find_run_dir(&context.storage_dir);
|
||||
let run_dir = find_run_dir(&context);
|
||||
let conclusion = read_conclusion(&run_dir);
|
||||
assert_eq!(
|
||||
conclusion["status"].as_str(),
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ fn configure_hook_env(cmd: &mut assert_cmd::Command, hook_model: &str) {
|
|||
}
|
||||
|
||||
fn conclusion_status(context: &fabro_test::TestContext) -> String {
|
||||
let run_dir = find_run_dir(&context.storage_dir);
|
||||
let run_dir = find_run_dir(&context);
|
||||
read_conclusion(&run_dir)["status"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ fn scenario_human_gate(sandbox: &str) {
|
|||
.assert()
|
||||
.success();
|
||||
|
||||
let run_dir = find_run_dir(&context.storage_dir);
|
||||
let run_dir = find_run_dir(&context);
|
||||
let conclusion = read_conclusion(&run_dir);
|
||||
assert_eq!(conclusion["status"].as_str(), Some("success"));
|
||||
|
||||
|
|
|
|||
|
|
@ -71,18 +71,29 @@ pub(super) fn store_dump_export(context: &TestContext, run_id: &str) -> PathBuf
|
|||
output_dir
|
||||
}
|
||||
|
||||
/// Find the single run directory under `storage_dir/runs/`.
|
||||
pub(super) fn find_run_dir(storage_dir: &Path) -> PathBuf {
|
||||
let runs_base = storage_dir.join("runs");
|
||||
/// Find the single run directory for this test context.
|
||||
pub(super) fn find_run_dir(context: &TestContext) -> PathBuf {
|
||||
let runs_base = context.storage_dir.join("runs");
|
||||
let entries: Vec<_> = std::fs::read_dir(&runs_base)
|
||||
.unwrap_or_else(|e| panic!("failed to read {}: {e}", runs_base.display()))
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter(|entry| {
|
||||
run_state(&entry.path())
|
||||
.run
|
||||
.as_ref()
|
||||
.is_some_and(|run| {
|
||||
run.labels
|
||||
.get("fabro_test_case")
|
||||
.is_some_and(|value| value == context.test_case_id())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
1,
|
||||
"expected exactly one run directory under {}",
|
||||
"expected exactly one run directory for fabro_test_case={} under {}",
|
||||
context.test_case_id(),
|
||||
runs_base.display()
|
||||
);
|
||||
entries[0].path()
|
||||
|
|
|
|||
|
|
@ -163,8 +163,17 @@ fn resolve_workflow_arg_impl(
|
|||
user_workflows: Option<&Path>,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
if arg.extension().is_some() {
|
||||
tracing::debug!(arg = %arg.display(), "Workflow arg has extension, returning as-is");
|
||||
return Ok(arg.to_path_buf());
|
||||
let resolved = if arg.is_absolute() {
|
||||
arg.to_path_buf()
|
||||
} else {
|
||||
start_dir.join(arg)
|
||||
};
|
||||
tracing::debug!(
|
||||
arg = %arg.display(),
|
||||
resolved = %resolved.display(),
|
||||
"Workflow arg has extension, resolving relative to start dir"
|
||||
);
|
||||
return Ok(resolved);
|
||||
}
|
||||
|
||||
let name = arg.to_string_lossy();
|
||||
|
|
@ -648,17 +657,25 @@ model = "claude-sonnet-4-6"
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_toml_extension_returned_as_is() {
|
||||
fn resolve_workflow_arg_toml_extension_resolves_relative_to_start_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = resolve_workflow_arg_from(Path::new("my-workflow.toml"), tmp.path()).unwrap();
|
||||
assert_eq!(result, Path::new("my-workflow.toml"));
|
||||
assert_eq!(result, tmp.path().join("my-workflow.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_fabro_extension_returned_as_is() {
|
||||
fn resolve_workflow_arg_fabro_extension_resolves_relative_to_start_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let result = resolve_workflow_arg_from(Path::new("my-workflow.fabro"), tmp.path()).unwrap();
|
||||
assert_eq!(result, Path::new("my-workflow.fabro"));
|
||||
assert_eq!(result, tmp.path().join("my-workflow.fabro"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_workflow_arg_absolute_extension_preserves_absolute_path() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("my-workflow.toml");
|
||||
let result = resolve_workflow_arg_from(&path, Path::new("/tmp")).unwrap();
|
||||
assert_eq!(result, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -74,6 +74,13 @@ pub fn decode_pem_env(name: &str, value: &str) -> String {
|
|||
pub fn resolve_auth_mode(api_settings: &ApiSettings, allowed_usernames: &[String]) -> AuthMode {
|
||||
use fabro_config::server::ApiAuthStrategy;
|
||||
|
||||
if api_settings.authentication_strategies.is_empty()
|
||||
&& std::env::var("FABRO_LOCAL_NO_AUTH").ok().as_deref() == Some("1")
|
||||
{
|
||||
warn!("No authentication strategies configured; allowing unauthenticated local daemon access");
|
||||
return AuthMode::Disabled;
|
||||
}
|
||||
|
||||
if api_settings.authentication_strategies.is_empty() {
|
||||
warn!("No authentication strategies configured; all requests will be rejected");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,9 +58,9 @@ pub use fabro_api::types::{
|
|||
ArtifactListResponse, CompletionContentPart, CompletionMessage, CompletionMessageRole,
|
||||
CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,
|
||||
CreateRunRequest, EventEnvelope as ApiEventEnvelope, ModelReference, PaginatedEventList,
|
||||
PaginatedRunList, PaginationMeta, QuestionType as ApiQuestionType, RunError, RunEvent as ApiRunEvent,
|
||||
RunStatus, RunStatusResponse, SubmitAnswerRequest, TokenUsage, UsageByModel,
|
||||
WriteBlobResponse,
|
||||
PaginatedRunList, PaginationMeta, QuestionType as ApiQuestionType, RunError,
|
||||
RunEvent as ApiRunEvent, RunStatus, RunStatusResponse, StartRunRequest,
|
||||
SubmitAnswerRequest, TokenUsage, UsageByModel, WriteBlobResponse,
|
||||
};
|
||||
|
||||
pub fn default_page_limit() -> u32 {
|
||||
|
|
@ -134,6 +134,13 @@ struct ManagedRun {
|
|||
cancel_tx: Option<oneshot::Sender<()>>,
|
||||
cancel_token: Option<Arc<AtomicBool>>,
|
||||
run_dir: Option<std::path::PathBuf>,
|
||||
execution_mode: RunExecutionMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum RunExecutionMode {
|
||||
Start,
|
||||
Resume,
|
||||
}
|
||||
|
||||
/// Per-model usage totals.
|
||||
|
|
@ -317,7 +324,8 @@ fn demo_routes() -> Router<Arc<AppState>> {
|
|||
fn real_routes() -> Router<Arc<AppState>> {
|
||||
Router::new()
|
||||
.route("/runs", get(list_runs).post(create_run))
|
||||
.route("/runs/{id}", get(get_run_status))
|
||||
.route("/boards/runs", get(list_board_runs))
|
||||
.route("/runs/{id}", get(get_run_status).delete(delete_run))
|
||||
.route("/runs/{id}/questions", get(get_questions))
|
||||
.route("/runs/{id}/questions/{qid}/answer", post(submit_answer))
|
||||
.route("/runs/{id}/state", get(get_run_state))
|
||||
|
|
@ -525,7 +533,7 @@ fn build_app_state(
|
|||
})
|
||||
}
|
||||
|
||||
async fn list_runs(
|
||||
async fn list_board_runs(
|
||||
_auth: AuthenticatedService,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(pagination): Query<PaginationParams>,
|
||||
|
|
@ -559,6 +567,40 @@ async fn list_runs(
|
|||
.into_response()
|
||||
}
|
||||
|
||||
async fn list_runs(
|
||||
_auth: AuthenticatedService,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Response {
|
||||
match state
|
||||
.store
|
||||
.list_runs(&fabro_store::ListRunsQuery::default())
|
||||
.await
|
||||
{
|
||||
Ok(runs) => (StatusCode::OK, Json(runs)).into_response(),
|
||||
Err(err) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_run(
|
||||
_auth: AuthenticatedService,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
let id = match parse_run_id_path(&id) {
|
||||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
|
||||
if let Ok(mut runs) = state.runs.lock() {
|
||||
runs.remove(&id);
|
||||
}
|
||||
|
||||
match state.store.delete_run(&id).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_queue_positions(runs: &HashMap<RunId, ManagedRun>) -> HashMap<RunId, i64> {
|
||||
let mut queued: Vec<(&RunId, &ManagedRun)> = runs
|
||||
.iter()
|
||||
|
|
@ -633,46 +675,100 @@ fn clear_live_run_state(run: &mut ManagedRun) {
|
|||
run.cancel_token = None;
|
||||
}
|
||||
|
||||
fn managed_run(
|
||||
dot_source: String,
|
||||
status: RunStatus,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
run_dir: std::path::PathBuf,
|
||||
execution_mode: RunExecutionMode,
|
||||
) -> ManagedRun {
|
||||
ManagedRun {
|
||||
dot_source,
|
||||
status,
|
||||
error: None,
|
||||
created_at,
|
||||
interviewer: None,
|
||||
event_tx: None,
|
||||
checkpoint: None,
|
||||
cancel_tx: None,
|
||||
cancel_token: None,
|
||||
run_dir: Some(run_dir),
|
||||
execution_mode,
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_run(
|
||||
_auth: AuthenticatedService,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<CreateRunRequest>,
|
||||
) -> Response {
|
||||
let run_id = RunId::new();
|
||||
let run_id = match req.run_id.as_deref() {
|
||||
Some(raw) => match raw.parse::<RunId>() {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => return ApiError::bad_request("Invalid run ID.").into_response(),
|
||||
},
|
||||
None => RunId::new(),
|
||||
};
|
||||
info!(run_id = %run_id, "Run created");
|
||||
let settings = state.settings.read().unwrap().clone();
|
||||
let created = match Box::pin(operations::create(
|
||||
state.store.as_ref(),
|
||||
|
||||
let using_dot_source = req.dot_source.as_ref().is_some_and(|value| !value.is_empty());
|
||||
let using_local_workflow = req
|
||||
.workflow_path
|
||||
.as_ref()
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
|
||||
if using_dot_source == using_local_workflow {
|
||||
return ApiError::bad_request(
|
||||
"Provide exactly one of dot_source or workflow_path/cwd/settings_json.",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let create_input = if let Some(dot_source) = req.dot_source.clone() {
|
||||
CreateRunInput {
|
||||
workflow: WorkflowInput::DotSource {
|
||||
source: req.dot_source.clone(),
|
||||
source: dot_source,
|
||||
base_dir: None,
|
||||
},
|
||||
settings,
|
||||
settings: state.settings.read().unwrap().clone(),
|
||||
cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()),
|
||||
workflow_slug: None,
|
||||
run_id: Some(run_id),
|
||||
host_repo_path: None,
|
||||
base_branch: None,
|
||||
},
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(created) => created,
|
||||
Err(ref err @ FabroError::ValidationFailed { ref diagnostics }) => {
|
||||
let message = if diagnostics.is_empty() {
|
||||
err.to_string()
|
||||
} else {
|
||||
diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| diagnostic.message.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
};
|
||||
return ApiError::bad_request(message).into_response();
|
||||
}
|
||||
Err(err @ FabroError::Parse(_)) => {
|
||||
return ApiError::bad_request(err.to_string()).into_response();
|
||||
} else {
|
||||
let Some(workflow_path) = req.workflow_path.as_ref() else {
|
||||
return ApiError::bad_request("workflow_path is required").into_response();
|
||||
};
|
||||
let Some(cwd) = req.cwd.as_ref() else {
|
||||
return ApiError::bad_request("cwd is required").into_response();
|
||||
};
|
||||
let Some(settings_json) = req.settings_json.as_ref() else {
|
||||
return ApiError::bad_request("settings_json is required").into_response();
|
||||
};
|
||||
let settings = match serde_json::from_str::<Settings>(settings_json) {
|
||||
Ok(settings) => settings,
|
||||
Err(err) => {
|
||||
return ApiError::bad_request(format!("Invalid settings_json payload: {err}"))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
CreateRunInput {
|
||||
workflow: WorkflowInput::Path(std::path::PathBuf::from(workflow_path)),
|
||||
settings,
|
||||
cwd: std::path::PathBuf::from(cwd),
|
||||
workflow_slug: None,
|
||||
run_id: Some(run_id),
|
||||
host_repo_path: None,
|
||||
base_branch: None,
|
||||
}
|
||||
};
|
||||
|
||||
let created = match Box::pin(operations::create(state.store.as_ref(), create_input)).await {
|
||||
Ok(created) => created,
|
||||
Err(FabroError::ValidationFailed { .. } | FabroError::Parse(_)) => {
|
||||
return ApiError::bad_request("Validation failed").into_response();
|
||||
}
|
||||
Err(err) => {
|
||||
return ApiError::new(
|
||||
|
|
@ -682,25 +778,19 @@ async fn create_run(
|
|||
.into_response();
|
||||
}
|
||||
};
|
||||
let created_at = run_id.created_at();
|
||||
let run_dir = created.run_dir;
|
||||
let created_at = created.run_id.created_at();
|
||||
|
||||
{
|
||||
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
||||
runs.insert(
|
||||
run_id,
|
||||
ManagedRun {
|
||||
dot_source: req.dot_source,
|
||||
status: RunStatus::Submitted,
|
||||
error: None,
|
||||
created.run_id,
|
||||
managed_run(
|
||||
created.persisted.source().to_string(),
|
||||
RunStatus::Submitted,
|
||||
created_at,
|
||||
interviewer: None,
|
||||
event_tx: None,
|
||||
checkpoint: None,
|
||||
cancel_tx: None,
|
||||
cancel_token: None,
|
||||
run_dir: Some(run_dir),
|
||||
},
|
||||
created.run_dir,
|
||||
RunExecutionMode::Start,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -714,49 +804,115 @@ async fn create_run(
|
|||
created_at,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn start_run(
|
||||
_auth: AuthenticatedService,
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
body: Option<Json<StartRunRequest>>,
|
||||
) -> Response {
|
||||
let id = match parse_run_id_path(&id) {
|
||||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
||||
match runs.get_mut(&id) {
|
||||
Some(managed_run) => {
|
||||
if managed_run.status != RunStatus::Submitted {
|
||||
return ApiError::new(StatusCode::CONFLICT, "Run is not in submitted status.")
|
||||
.into_response();
|
||||
}
|
||||
managed_run.status = RunStatus::Queued;
|
||||
let response = (
|
||||
StatusCode::OK,
|
||||
Json(RunStatusResponse {
|
||||
id: id.to_string(),
|
||||
status: RunStatus::Queued,
|
||||
error: None,
|
||||
queue_position: None,
|
||||
created_at: managed_run.created_at,
|
||||
}),
|
||||
)
|
||||
let resume = body.map(|Json(req)| req.resume).unwrap_or(false);
|
||||
|
||||
{
|
||||
let runs = state.runs.lock().expect("runs lock poisoned");
|
||||
if let Some(managed_run) = runs.get(&id) {
|
||||
if matches!(
|
||||
managed_run.status,
|
||||
RunStatus::Queued | RunStatus::Starting | RunStatus::Running
|
||||
) {
|
||||
return ApiError::new(
|
||||
StatusCode::CONFLICT,
|
||||
if resume {
|
||||
"an engine process is still running for this run — cannot resume"
|
||||
} else {
|
||||
"an engine process is still running for this run — cannot start"
|
||||
},
|
||||
)
|
||||
.into_response();
|
||||
drop(runs);
|
||||
state.scheduler_notify.notify_one();
|
||||
response
|
||||
}
|
||||
}
|
||||
None => ApiError::not_found("Run not found.").into_response(),
|
||||
}
|
||||
|
||||
let run_store = match state.store.open_run(&id).await {
|
||||
Ok(run_store) => run_store,
|
||||
Err(_) => return ApiError::not_found("Run not found.").into_response(),
|
||||
};
|
||||
let run_state = match run_store.state().await {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
return ApiError::new(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to load run state: {err}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if resume {
|
||||
if run_state.checkpoint.is_none() {
|
||||
return ApiError::new(StatusCode::CONFLICT, "no checkpoint to resume from")
|
||||
.into_response();
|
||||
}
|
||||
} else if let Some(record) = run_state.status.as_ref() {
|
||||
if !matches!(record.status, fabro_workflow::run_status::RunStatus::Submitted | fabro_workflow::run_status::RunStatus::Starting)
|
||||
{
|
||||
return ApiError::new(
|
||||
StatusCode::CONFLICT,
|
||||
format!("cannot start run: status is {:?}, expected submitted", record.status),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let Some(run_record) = run_state.run.as_ref() else {
|
||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "run record missing from store")
|
||||
.into_response();
|
||||
};
|
||||
let run_dir = operations::make_run_dir(&run_record.settings.storage_dir().join("runs"), &id);
|
||||
let dot_source = run_state.graph_source.unwrap_or_default();
|
||||
|
||||
{
|
||||
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
||||
runs.insert(
|
||||
id,
|
||||
managed_run(
|
||||
dot_source,
|
||||
RunStatus::Queued,
|
||||
id.created_at(),
|
||||
run_dir,
|
||||
if resume {
|
||||
RunExecutionMode::Resume
|
||||
} else {
|
||||
RunExecutionMode::Start
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
state.scheduler_notify.notify_one();
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(RunStatusResponse {
|
||||
id: id.to_string(),
|
||||
status: RunStatus::Queued,
|
||||
error: None,
|
||||
queue_position: None,
|
||||
created_at: id.created_at(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// Execute a single run: transitions queued → starting → running → completed/failed/cancelled.
|
||||
async fn execute_run(state: Arc<AppState>, run_id: RunId) {
|
||||
// Transition to Starting and set up cancel infrastructure
|
||||
let (cancel_rx, run_dir, event_tx, cancel_token) = {
|
||||
let (cancel_rx, run_dir, event_tx, cancel_token, execution_mode) = {
|
||||
let mut runs = state.runs.lock().expect("runs lock poisoned");
|
||||
let managed_run = match runs.get_mut(&run_id) {
|
||||
Some(r) if r.status == RunStatus::Queued => r,
|
||||
|
|
@ -780,6 +936,7 @@ async fn execute_run(state: Arc<AppState>, run_id: RunId) {
|
|||
run_dir,
|
||||
managed_run.event_tx.clone(),
|
||||
cancel_token,
|
||||
managed_run.execution_mode,
|
||||
)
|
||||
};
|
||||
|
||||
|
|
@ -867,8 +1024,15 @@ async fn execute_run(state: Arc<AppState>, run_id: RunId) {
|
|||
registry_override,
|
||||
};
|
||||
|
||||
let execution = async {
|
||||
match execution_mode {
|
||||
RunExecutionMode::Start => operations::start(&run_dir, services).await,
|
||||
RunExecutionMode::Resume => operations::resume(&run_dir, services).await,
|
||||
}
|
||||
};
|
||||
|
||||
let result = tokio::select! {
|
||||
result = operations::start(&run_dir, services) => result,
|
||||
result = execution => result,
|
||||
_ = cancel_rx => {
|
||||
cancel_token.store(true, Ordering::SeqCst);
|
||||
Err(FabroError::Cancelled)
|
||||
|
|
@ -996,30 +1160,16 @@ async fn get_run_status(
|
|||
Ok(id) => id,
|
||||
Err(response) => return response,
|
||||
};
|
||||
let runs = state.runs.lock().expect("runs lock poisoned");
|
||||
match runs.get(&id) {
|
||||
Some(managed_run) => {
|
||||
let queue_position = if managed_run.status == RunStatus::Queued {
|
||||
let positions = compute_queue_positions(&runs);
|
||||
positions.get(&id).copied()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(RunStatusResponse {
|
||||
id: id.to_string(),
|
||||
status: managed_run.status,
|
||||
error: managed_run.error.as_ref().map(|msg| RunError {
|
||||
message: msg.clone(),
|
||||
}),
|
||||
created_at: managed_run.created_at,
|
||||
queue_position,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
None => ApiError::not_found("Run not found.").into_response(),
|
||||
match state
|
||||
.store
|
||||
.list_runs(&fabro_store::ListRunsQuery::default())
|
||||
.await
|
||||
{
|
||||
Ok(runs) => match runs.into_iter().find(|run| run.run_id == id) {
|
||||
Some(run) => (StatusCode::OK, Json(run)).into_response(),
|
||||
None => ApiError::not_found("Run not found.").into_response(),
|
||||
},
|
||||
Err(err) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2293,15 +2443,8 @@ mod tests {
|
|||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let body = body_json(response.into_body()).await;
|
||||
assert_eq!(body["id"].as_str().unwrap(), run_id);
|
||||
let status = body["status"].as_str().unwrap();
|
||||
assert!(
|
||||
status == "queued"
|
||||
|| status == "starting"
|
||||
|| status == "running"
|
||||
|| status == "completed",
|
||||
"unexpected status: {status}"
|
||||
);
|
||||
assert_eq!(body["run_id"].as_str().unwrap(), run_id);
|
||||
assert!(body["labels"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -2892,8 +3035,7 @@ mod tests {
|
|||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_json(response.into_body()).await;
|
||||
assert_eq!(body["data"].as_array().unwrap().len(), 0);
|
||||
assert!(!body["meta"]["has_more"].as_bool().unwrap());
|
||||
assert_eq!(body.as_array().unwrap().len(), 0);
|
||||
|
||||
// Start a run
|
||||
let req = Request::builder()
|
||||
|
|
@ -2919,11 +3061,45 @@ mod tests {
|
|||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_json(response.into_body()).await;
|
||||
let items = body["data"].as_array().unwrap();
|
||||
let items = body.as_array().unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0]["id"].as_str().unwrap(), run_id.to_string());
|
||||
assert_eq!(items[0]["run_id"].as_str().unwrap(), run_id.to_string());
|
||||
assert!(items[0]["status"].as_str().is_some());
|
||||
assert!(!body["meta"]["has_more"].as_bool().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_run_removes_durable_run() {
|
||||
let state = create_app_state();
|
||||
let app = build_router(Arc::clone(&state), AuthMode::Disabled);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri(api("/runs"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
let body = body_json(response.into_body()).await;
|
||||
let run_id = body["id"].as_str().unwrap();
|
||||
|
||||
let req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(api(&format!("/runs/{run_id}")))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api(&format!("/runs/{run_id}")))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@ workspace = true
|
|||
[dependencies]
|
||||
assert_cmd = "2"
|
||||
axum = { workspace = true }
|
||||
fabro-proc = { path = "../fabro-proc" }
|
||||
insta = { workspace = true, features = ["filters"] }
|
||||
regex = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Output;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use assert_cmd::Command;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// Walk up from `start` to find the repo-level `test/` fixtures directory.
|
||||
|
|
@ -83,17 +88,272 @@ pub fn require_env(name: &str) -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// An isolated test context for running fabro CLI commands.
|
||||
/// A test context for running fabro CLI commands.
|
||||
///
|
||||
/// Creates temporary directories for home, storage, and working directory,
|
||||
/// and provides methods to build commands with proper isolation env vars.
|
||||
/// Each context gets isolated home/temp directories. The storage directory is
|
||||
/// shared per nextest run when `NEXTEST_RUN_ID` is present, otherwise shared
|
||||
/// per test process.
|
||||
pub struct TestContext {
|
||||
pub temp_dir: PathBuf,
|
||||
pub home_dir: PathBuf,
|
||||
pub storage_dir: PathBuf,
|
||||
test_case_id: String,
|
||||
test_run_id: String,
|
||||
session_root: PathBuf,
|
||||
fabro_bin: PathBuf,
|
||||
filters: Vec<(String, String)>,
|
||||
_root: tempfile::TempDir,
|
||||
_context_root: tempfile::TempDir,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SessionPaths {
|
||||
root: PathBuf,
|
||||
storage_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum SessionMode {
|
||||
Nextest,
|
||||
Process,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ClientMarker {
|
||||
pid: u32,
|
||||
touched_at_ms: u128,
|
||||
}
|
||||
|
||||
static SESSION_REFS: OnceLock<Mutex<HashMap<PathBuf, usize>>> = OnceLock::new();
|
||||
|
||||
fn session_refs() -> &'static Mutex<HashMap<PathBuf, usize>> {
|
||||
SESSION_REFS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn test_case_id() -> String {
|
||||
let ulid = std::process::Command::new("uuidgen")
|
||||
.arg("-r")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|output| output.status.success().then_some(output.stdout))
|
||||
.and_then(|stdout| String::from_utf8(stdout).ok())
|
||||
.map(|value| value.trim().replace('-', ""))
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time should be after unix epoch")
|
||||
.as_nanos();
|
||||
format!("{nanos:032x}")
|
||||
});
|
||||
ulid
|
||||
}
|
||||
|
||||
fn current_timestamp_ms() -> u128 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system time should be after unix epoch")
|
||||
.as_millis()
|
||||
}
|
||||
|
||||
fn current_pid() -> u32 {
|
||||
std::process::id()
|
||||
}
|
||||
|
||||
fn session_paths() -> (SessionMode, String, SessionPaths) {
|
||||
let base_dir = short_session_base_dir();
|
||||
if let Ok(run_id) = std::env::var("NEXTEST_RUN_ID") {
|
||||
if !run_id.trim().is_empty() {
|
||||
let short_id = shorten_session_id(&run_id);
|
||||
let root = base_dir.join(format!("n-{short_id}"));
|
||||
return (
|
||||
SessionMode::Nextest,
|
||||
run_id,
|
||||
SessionPaths {
|
||||
storage_dir: root.join("storage"),
|
||||
root,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let process_id = format!("process-{}", current_pid());
|
||||
let root = base_dir.join(format!("p-{}", current_pid()));
|
||||
(
|
||||
SessionMode::Process,
|
||||
process_id,
|
||||
SessionPaths {
|
||||
storage_dir: root.join("storage"),
|
||||
root,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn short_session_base_dir() -> PathBuf {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
PathBuf::from("/tmp/fx")
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::env::temp_dir().join("fabro-test")
|
||||
}
|
||||
}
|
||||
|
||||
fn shorten_session_id(id: &str) -> String {
|
||||
let trimmed = id.trim();
|
||||
let shortened: String = trimmed
|
||||
.chars()
|
||||
.filter(|ch| ch.is_ascii_alphanumeric())
|
||||
.take(12)
|
||||
.collect();
|
||||
if shortened.is_empty() {
|
||||
"session".to_string()
|
||||
} else {
|
||||
shortened
|
||||
}
|
||||
}
|
||||
|
||||
fn session_lock_path(root: &Path) -> PathBuf {
|
||||
root.join("session.lock")
|
||||
}
|
||||
|
||||
fn session_clients_dir(root: &Path) -> PathBuf {
|
||||
root.join("clients")
|
||||
}
|
||||
|
||||
fn session_marker_path(root: &Path, pid: u32) -> PathBuf {
|
||||
session_clients_dir(root).join(pid.to_string())
|
||||
}
|
||||
|
||||
fn ensure_parent_dir(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.unwrap_or_else(|err| panic!("failed to create {}: {err}", parent.display()));
|
||||
}
|
||||
}
|
||||
|
||||
fn with_session_lock<T>(root: &Path, f: impl FnOnce() -> T) -> T {
|
||||
std::fs::create_dir_all(root)
|
||||
.unwrap_or_else(|err| panic!("failed to create {}: {err}", root.display()));
|
||||
let lock_path = session_lock_path(root);
|
||||
ensure_parent_dir(&lock_path);
|
||||
let lock_file = File::create(&lock_path)
|
||||
.unwrap_or_else(|err| panic!("failed to create {}: {err}", lock_path.display()));
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while !fabro_proc::try_flock_exclusive(&lock_file)
|
||||
.unwrap_or_else(|err| panic!("failed to lock {}: {err}", lock_path.display()))
|
||||
{
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"timed out waiting for session lock {}",
|
||||
lock_path.display()
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
let result = f();
|
||||
fabro_proc::flock_unlock(&lock_file)
|
||||
.unwrap_or_else(|err| panic!("failed to unlock {}: {err}", lock_path.display()));
|
||||
result
|
||||
}
|
||||
|
||||
fn live_marker_count(root: &Path) -> usize {
|
||||
let clients_dir = session_clients_dir(root);
|
||||
let Ok(entries) = std::fs::read_dir(&clients_dir) else {
|
||||
return 0;
|
||||
};
|
||||
entries
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.map(|pid| (pid, entry.path()))
|
||||
})
|
||||
.filter(|(pid, path)| {
|
||||
if fabro_proc::process_alive(*pid) {
|
||||
true
|
||||
} else {
|
||||
let _ = std::fs::remove_file(path);
|
||||
false
|
||||
}
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
fn write_marker(root: &Path) {
|
||||
let marker = ClientMarker {
|
||||
pid: current_pid(),
|
||||
touched_at_ms: current_timestamp_ms(),
|
||||
};
|
||||
let marker_path = session_marker_path(root, marker.pid);
|
||||
ensure_parent_dir(&marker_path);
|
||||
let contents =
|
||||
serde_json::to_vec(&marker).expect("client marker should serialize to JSON bytes");
|
||||
std::fs::write(&marker_path, contents)
|
||||
.unwrap_or_else(|err| panic!("failed to write {}: {err}", marker_path.display()));
|
||||
}
|
||||
|
||||
fn stop_session_server(fabro_bin: &Path, storage_dir: &Path) {
|
||||
let record_path = storage_dir.join("server.json");
|
||||
if !record_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = std::process::Command::new(fabro_bin)
|
||||
.arg("server")
|
||||
.arg("stop")
|
||||
.arg("--storage-dir")
|
||||
.arg(storage_dir)
|
||||
.arg("--no-upgrade-check")
|
||||
.env("NO_COLOR", "1")
|
||||
.env("FABRO_NO_UPGRADE_CHECK", "true")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
fn cleanup_session_root(fabro_bin: &Path, root: &Path, storage_dir: &Path) {
|
||||
with_session_lock(root, || {
|
||||
let marker_path = session_marker_path(root, current_pid());
|
||||
let _ = std::fs::remove_file(&marker_path);
|
||||
let live_count = live_marker_count(root);
|
||||
if live_count == 0 {
|
||||
stop_session_server(fabro_bin, storage_dir);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn reap_stale_session_roots(fabro_bin: &Path, mode: SessionMode) {
|
||||
let base_dir = short_session_base_dir();
|
||||
let Ok(entries) = std::fs::read_dir(&base_dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for entry in entries.filter_map(Result::ok) {
|
||||
let root = entry.path();
|
||||
if !root.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let file_name = root.file_name().and_then(|name| name.to_str()).unwrap_or("");
|
||||
let expected_prefix = match mode {
|
||||
SessionMode::Nextest => "n-",
|
||||
SessionMode::Process => "p-",
|
||||
};
|
||||
if !file_name.starts_with(expected_prefix) {
|
||||
continue;
|
||||
}
|
||||
with_session_lock(&root, || {
|
||||
if live_marker_count(&root) == 0 {
|
||||
let storage_dir = root.join("storage");
|
||||
stop_session_server(fabro_bin, &storage_dir);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl TestContext {
|
||||
|
|
@ -102,16 +362,36 @@ impl TestContext {
|
|||
/// `fabro_bin` should be the path to the compiled `fabro` binary,
|
||||
/// typically obtained via `env!("CARGO_BIN_EXE_fabro")`.
|
||||
pub fn new(fabro_bin: PathBuf) -> Self {
|
||||
let root = tempfile::tempdir().expect("failed to create temp dir");
|
||||
let root_path = root.path().to_path_buf();
|
||||
let context_root = tempfile::tempdir().expect("failed to create temp dir");
|
||||
let root_path = context_root.path().to_path_buf();
|
||||
let (_, test_run_id, session_paths) = session_paths();
|
||||
reap_stale_session_roots(&fabro_bin, SessionMode::Nextest);
|
||||
reap_stale_session_roots(&fabro_bin, SessionMode::Process);
|
||||
with_session_lock(&session_paths.root, || {
|
||||
std::fs::create_dir_all(session_clients_dir(&session_paths.root)).unwrap_or_else(
|
||||
|err| {
|
||||
panic!(
|
||||
"failed to create {}: {err}",
|
||||
session_clients_dir(&session_paths.root).display()
|
||||
)
|
||||
},
|
||||
);
|
||||
std::fs::create_dir_all(&session_paths.storage_dir).unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"failed to create {}: {err}",
|
||||
session_paths.storage_dir.display()
|
||||
)
|
||||
});
|
||||
write_marker(&session_paths.root);
|
||||
});
|
||||
|
||||
let temp_dir = root_path.join("temp");
|
||||
let home_dir = root_path.join("home");
|
||||
let storage_dir = root_path.join("storage");
|
||||
let storage_dir = session_paths.storage_dir.clone();
|
||||
let test_case_id = test_case_id();
|
||||
|
||||
std::fs::create_dir_all(&temp_dir).expect("failed to create temp_dir");
|
||||
std::fs::create_dir_all(&home_dir).expect("failed to create home_dir");
|
||||
std::fs::create_dir_all(&storage_dir).expect("failed to create storage_dir");
|
||||
|
||||
let filters = vec![
|
||||
(
|
||||
|
|
@ -138,15 +418,25 @@ impl TestContext {
|
|||
regex::escape(storage_dir.to_str().unwrap()),
|
||||
"[STORAGE_DIR]".to_string(),
|
||||
),
|
||||
(regex::escape(&test_case_id), "[TEST_CASE]".to_string()),
|
||||
(regex::escape(&test_run_id), "[TEST_RUN]".to_string()),
|
||||
];
|
||||
|
||||
{
|
||||
let mut refs = session_refs().lock().expect("session refs lock poisoned");
|
||||
*refs.entry(session_paths.root.clone()).or_default() += 1;
|
||||
}
|
||||
|
||||
Self {
|
||||
temp_dir,
|
||||
home_dir,
|
||||
storage_dir,
|
||||
test_case_id,
|
||||
test_run_id,
|
||||
session_root: session_paths.root,
|
||||
fabro_bin,
|
||||
filters,
|
||||
_root: root,
|
||||
_context_root: context_root,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,6 +457,29 @@ impl TestContext {
|
|||
filters
|
||||
}
|
||||
|
||||
pub fn test_run_id(&self) -> &str {
|
||||
&self.test_run_id
|
||||
}
|
||||
|
||||
pub fn test_case_id(&self) -> &str {
|
||||
&self.test_case_id
|
||||
}
|
||||
|
||||
pub fn test_run_label(&self) -> String {
|
||||
format!("fabro_test_run={}", self.test_run_id)
|
||||
}
|
||||
|
||||
pub fn test_case_label(&self) -> String {
|
||||
format!("fabro_test_case={}", self.test_case_id)
|
||||
}
|
||||
|
||||
fn append_test_labels(&self, cmd: &mut Command) {
|
||||
cmd.arg("--label");
|
||||
cmd.arg(self.test_run_label());
|
||||
cmd.arg("--label");
|
||||
cmd.arg(self.test_case_label());
|
||||
}
|
||||
|
||||
/// Build a base `Command` with all isolation env vars set.
|
||||
///
|
||||
/// The working directory defaults to `self.temp_dir` (a non-git temp
|
||||
|
|
@ -180,6 +493,10 @@ impl TestContext {
|
|||
cmd.env("HOME", &self.home_dir);
|
||||
cmd.env("FABRO_NO_UPGRADE_CHECK", "true");
|
||||
cmd.env("FABRO_STORAGE_DIR", &self.storage_dir);
|
||||
cmd.env(
|
||||
"FABRO_SERVER_MAX_CONCURRENT_RUNS",
|
||||
"64",
|
||||
);
|
||||
cmd
|
||||
}
|
||||
|
||||
|
|
@ -194,6 +511,15 @@ impl TestContext {
|
|||
pub fn run_cmd(&self) -> Command {
|
||||
let mut cmd = self.command();
|
||||
cmd.arg("run");
|
||||
self.append_test_labels(&mut cmd);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Build a `create` subcommand with per-test labels attached.
|
||||
pub fn create_cmd(&self) -> Command {
|
||||
let mut cmd = self.command();
|
||||
cmd.arg("create");
|
||||
self.append_test_labels(&mut cmd);
|
||||
cmd
|
||||
}
|
||||
|
||||
|
|
@ -380,17 +706,58 @@ impl TestContext {
|
|||
.flatten()
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.filter(|path| {
|
||||
let Ok(contents) = std::fs::read_to_string(path.join("run.json")) else {
|
||||
return false;
|
||||
};
|
||||
serde_json::from_str::<Value>(&contents)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("labels")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|labels| labels.get("fabro_test_case"))
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value == self.test_case_id())
|
||||
})
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
1,
|
||||
"expected exactly one run directory under {}",
|
||||
"expected exactly one run directory for fabro_test_case={} under {}",
|
||||
self.test_case_id(),
|
||||
runs_dir.display()
|
||||
);
|
||||
entries.into_iter().next().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestContext {
|
||||
fn drop(&mut self) {
|
||||
let is_last_ref = {
|
||||
let mut refs = session_refs().lock().expect("session refs lock poisoned");
|
||||
let Some(count) = refs.get_mut(&self.session_root) else {
|
||||
return;
|
||||
};
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
refs.remove(&self.session_root);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !is_last_ref {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup_session_root(&self.fabro_bin, &self.session_root, &self.storage_dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a command and format the output for snapshot testing.
|
||||
///
|
||||
/// Returns the formatted string and the raw `Output`.
|
||||
|
|
@ -829,6 +1196,13 @@ macro_rules! e2e_openai {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
ENV_LOCK.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twin_admin_url_removes_v1_suffix() {
|
||||
|
|
@ -887,4 +1261,75 @@ mod tests {
|
|||
fn twin_scenario_rejects_retry_after_on_success() {
|
||||
let _ = TwinScenario::responses("gpt-5.4-mini").retry_after("30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_paths_share_nextest_storage_dir() {
|
||||
let _lock = env_lock().lock().expect("env lock poisoned");
|
||||
let _guard = EnvGuard::set("NEXTEST_RUN_ID", Some("nextest-run-123"));
|
||||
let (_, run_id, paths) = session_paths();
|
||||
assert_eq!(run_id, "nextest-run-123");
|
||||
assert!(paths.root.ends_with(Path::new("fx").join("n-nextestrun12")));
|
||||
assert_eq!(paths.storage_dir, paths.root.join("storage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_paths_fall_back_to_process_storage_dir() {
|
||||
let _lock = env_lock().lock().expect("env lock poisoned");
|
||||
let _guard = EnvGuard::set("NEXTEST_RUN_ID", None);
|
||||
let (_, run_id, paths) = session_paths();
|
||||
assert_eq!(run_id, format!("process-{}", current_pid()));
|
||||
assert!(paths.root.ends_with(Path::new("fx").join(format!("p-{}", current_pid()))));
|
||||
assert_eq!(paths.storage_dir, paths.root.join("storage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_and_create_commands_include_test_labels() {
|
||||
let _lock = env_lock().lock().expect("env lock poisoned");
|
||||
let _guard = EnvGuard::set("NEXTEST_RUN_ID", Some("run-cmd-labels"));
|
||||
let context = TestContext::new(PathBuf::from("/tmp/fabro"));
|
||||
|
||||
let run_args = context
|
||||
.run_cmd()
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(run_args[0], "run");
|
||||
assert!(run_args.contains(&"--label".to_string()));
|
||||
assert!(run_args.contains(&context.test_run_label()));
|
||||
assert!(run_args.contains(&context.test_case_label()));
|
||||
|
||||
let create_args = context
|
||||
.create_cmd()
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(create_args[0], "create");
|
||||
assert!(create_args.contains(&context.test_run_label()));
|
||||
assert!(create_args.contains(&context.test_case_label()));
|
||||
}
|
||||
|
||||
struct EnvGuard {
|
||||
key: &'static str,
|
||||
original: Option<String>,
|
||||
}
|
||||
|
||||
impl EnvGuard {
|
||||
fn set(key: &'static str, value: Option<&str>) -> Self {
|
||||
let original = std::env::var(key).ok();
|
||||
match value {
|
||||
Some(value) => std::env::set_var(key, value),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
Self { key, original }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for EnvGuard {
|
||||
fn drop(&mut self) {
|
||||
match &self.original {
|
||||
Some(value) => std::env::set_var(self.key, value),
|
||||
None => std::env::remove_var(self.key),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,18 +195,21 @@ fn scan_orphan_runs(base: &Path) -> Result<Vec<RunInfo>> {
|
|||
}
|
||||
|
||||
pub async fn scan_runs_combined(store: &SlateStore, base: &Path) -> Result<Vec<RunInfo>> {
|
||||
let mut runs_by_id: HashMap<RunId, RunInfo> = HashMap::new();
|
||||
|
||||
if let Ok(store_runs) = store
|
||||
let store_runs = store
|
||||
.list_runs(&fabro_store::ListRunsQuery::default())
|
||||
.await
|
||||
{
|
||||
for summary in store_runs {
|
||||
let Some(run_info) = run_info_from_summary(&summary, base) else {
|
||||
continue;
|
||||
};
|
||||
runs_by_id.insert(run_info.run_id(), run_info);
|
||||
}
|
||||
.unwrap_or_default();
|
||||
Ok(scan_runs_with_summaries(&store_runs, base)?)
|
||||
}
|
||||
|
||||
pub fn scan_runs_with_summaries(summaries: &[RunSummary], base: &Path) -> Result<Vec<RunInfo>> {
|
||||
let mut runs_by_id: HashMap<RunId, RunInfo> = HashMap::new();
|
||||
|
||||
for summary in summaries {
|
||||
let Some(run_info) = run_info_from_summary(summary, base) else {
|
||||
continue;
|
||||
};
|
||||
runs_by_id.insert(run_info.run_id(), run_info);
|
||||
}
|
||||
|
||||
let store_run_ids = runs_by_id
|
||||
|
|
@ -309,7 +312,19 @@ pub async fn resolve_run_combined(
|
|||
let runs = scan_runs_combined(store, base)
|
||||
.await
|
||||
.context("Failed to scan runs")?;
|
||||
resolve_run_from_infos(&runs, identifier)
|
||||
}
|
||||
|
||||
pub fn resolve_run_from_summaries(
|
||||
summaries: &[RunSummary],
|
||||
base: &Path,
|
||||
identifier: &str,
|
||||
) -> Result<RunInfo> {
|
||||
let runs = scan_runs_with_summaries(summaries, base).context("Failed to scan runs")?;
|
||||
resolve_run_from_infos(&runs, identifier)
|
||||
}
|
||||
|
||||
fn resolve_run_from_infos(runs: &[RunInfo], identifier: &str) -> Result<RunInfo> {
|
||||
let id_matches: Vec<_> = runs
|
||||
.iter()
|
||||
.filter(|run| run_id_matches(run.run_id(), identifier))
|
||||
|
|
|
|||
|
|
@ -157,8 +157,10 @@ models/smoothness-rating.ts
|
|||
models/stage-retro.ts
|
||||
models/stage-status.ts
|
||||
models/stage-turn.ts
|
||||
models/start-run-request.ts
|
||||
models/status-reason.ts
|
||||
models/steer-request.ts
|
||||
models/store-run-summary.ts
|
||||
models/submit-answer-request.ts
|
||||
models/system-stage-turn.ts
|
||||
models/tls-settings.ts
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ import type { ErrorResponse } from '../models';
|
|||
import type { PaginatedRunList } from '../models';
|
||||
// @ts-ignore
|
||||
import type { RunStatusResponse } from '../models';
|
||||
// @ts-ignore
|
||||
import type { StartRunRequest } from '../models';
|
||||
// @ts-ignore
|
||||
import type { StoreRunSummary } from '../models';
|
||||
/**
|
||||
* RunsApi - axios parameter creator
|
||||
*/
|
||||
|
|
@ -76,7 +80,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
|
|||
};
|
||||
},
|
||||
/**
|
||||
* Creates a new workflow run from a Graphviz graph source. The run is created in `submitted` status. Use `POST /api/v1/runs/{id}/start` to begin execution.
|
||||
* Creates a new workflow run in `submitted` status. Callers may either provide `dot_source` directly or provide `workflow_path`, `cwd`, and `settings_json` so the server can load a local workflow path for trusted CLI execution.
|
||||
* @summary Create Run
|
||||
* @param {CreateRunRequest} createRunRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -118,15 +122,56 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
|
|||
};
|
||||
},
|
||||
/**
|
||||
* Returns a paginated list of runs for the board view, ordered by recency.
|
||||
* @summary List Runs
|
||||
* Deletes durable store state for a run. This does not remove any local run directory.
|
||||
* @summary Delete Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
deleteRun: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('deleteRun', 'id', id)
|
||||
const localVarPath = `/api/v1/runs/{id}`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication mTLS required
|
||||
await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration)
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Temporary board-view list of managed runs. This endpoint is UI-oriented and may change as the app evolves.
|
||||
* @summary List Board Runs
|
||||
* @param {number} [pageLimit] Maximum number of items to return per page.
|
||||
* @param {number} [pageOffset] Number of items to skip before returning results.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listRuns: async (pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/api/v1/runs`;
|
||||
listBoardRuns: async (pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/api/v1/boards/runs`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
|
|
@ -164,6 +209,43 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
|
|||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Returns durable run summaries from the backing store, including runs persisted before the current server boot.
|
||||
* @summary List Runs
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listRuns: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/api/v1/runs`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication mTLS required
|
||||
await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration)
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Pauses a running run. Returns 409 if the run is not running.
|
||||
* @summary Pause Run
|
||||
|
|
@ -206,7 +288,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
|
|||
};
|
||||
},
|
||||
/**
|
||||
* Returns the current status of a run, including error details and queue position if applicable.
|
||||
* Returns the durable run summary for a run.
|
||||
* @summary Retrieve Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -288,13 +370,14 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
|
|||
};
|
||||
},
|
||||
/**
|
||||
* Starts a submitted run, queuing it for execution. Returns 409 if the run is not in `submitted` status.
|
||||
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
|
||||
* @summary Start Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {StartRunRequest} [startRunRequest]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
startRun: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
startRun: async (id: string, startRunRequest?: StartRunRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('startRun', 'id', id)
|
||||
const localVarPath = `/api/v1/runs/{id}/start`
|
||||
|
|
@ -317,11 +400,13 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
|
|||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
localVarHeaderParameter['Content-Type'] = 'application/json';
|
||||
localVarHeaderParameter['Accept'] = 'application/json';
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
localVarRequestOptions.data = serializeDataIfNeeded(startRunRequest, localVarRequestOptions, configuration)
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
|
|
@ -392,7 +477,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
|
|||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Creates a new workflow run from a Graphviz graph source. The run is created in `submitted` status. Use `POST /api/v1/runs/{id}/start` to begin execution.
|
||||
* Creates a new workflow run in `submitted` status. Callers may either provide `dot_source` directly or provide `workflow_path`, `cwd`, and `settings_json` so the server can load a local workflow path for trusted CLI execution.
|
||||
* @summary Create Run
|
||||
* @param {CreateRunRequest} createRunRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -405,15 +490,40 @@ export const RunsApiFp = function(configuration?: Configuration) {
|
|||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Returns a paginated list of runs for the board view, ordered by recency.
|
||||
* @summary List Runs
|
||||
* Deletes durable store state for a run. This does not remove any local run directory.
|
||||
* @summary Delete Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async deleteRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteRun(id, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['RunsApi.deleteRun']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Temporary board-view list of managed runs. This endpoint is UI-oriented and may change as the app evolves.
|
||||
* @summary List Board Runs
|
||||
* @param {number} [pageLimit] Maximum number of items to return per page.
|
||||
* @param {number} [pageOffset] Number of items to skip before returning results.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedRunList>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(pageLimit, pageOffset, options);
|
||||
async listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedRunList>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.listBoardRuns(pageLimit, pageOffset, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['RunsApi.listBoardRuns']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Returns durable run summaries from the backing store, including runs persisted before the current server boot.
|
||||
* @summary List Runs
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async listRuns(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<StoreRunSummary>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['RunsApi.listRuns']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
|
|
@ -432,13 +542,13 @@ export const RunsApiFp = function(configuration?: Configuration) {
|
|||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Returns the current status of a run, including error details and queue position if applicable.
|
||||
* Returns the durable run summary for a run.
|
||||
* @summary Retrieve Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async retrieveRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunStatusResponse>> {
|
||||
async retrieveRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<StoreRunSummary>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveRun(id, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['RunsApi.retrieveRun']?.[localVarOperationServerIndex]?.url;
|
||||
|
|
@ -458,14 +568,15 @@ export const RunsApiFp = function(configuration?: Configuration) {
|
|||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Starts a submitted run, queuing it for execution. Returns 409 if the run is not in `submitted` status.
|
||||
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
|
||||
* @summary Start Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {StartRunRequest} [startRunRequest]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async startRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunStatusResponse>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.startRun(id, options);
|
||||
async startRun(id: string, startRunRequest?: StartRunRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunStatusResponse>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.startRun(id, startRunRequest, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['RunsApi.startRun']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
|
|
@ -503,7 +614,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
|
|||
return localVarFp.cancelRun(id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Creates a new workflow run from a Graphviz graph source. The run is created in `submitted` status. Use `POST /api/v1/runs/{id}/start` to begin execution.
|
||||
* Creates a new workflow run in `submitted` status. Callers may either provide `dot_source` directly or provide `workflow_path`, `cwd`, and `settings_json` so the server can load a local workflow path for trusted CLI execution.
|
||||
* @summary Create Run
|
||||
* @param {CreateRunRequest} createRunRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -513,15 +624,34 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
|
|||
return localVarFp.createRun(createRunRequest, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Returns a paginated list of runs for the board view, ordered by recency.
|
||||
* @summary List Runs
|
||||
* Deletes durable store state for a run. This does not remove any local run directory.
|
||||
* @summary Delete Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
deleteRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<void> {
|
||||
return localVarFp.deleteRun(id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Temporary board-view list of managed runs. This endpoint is UI-oriented and may change as the app evolves.
|
||||
* @summary List Board Runs
|
||||
* @param {number} [pageLimit] Maximum number of items to return per page.
|
||||
* @param {number} [pageOffset] Number of items to skip before returning results.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedRunList> {
|
||||
return localVarFp.listRuns(pageLimit, pageOffset, options).then((request) => request(axios, basePath));
|
||||
listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedRunList> {
|
||||
return localVarFp.listBoardRuns(pageLimit, pageOffset, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Returns durable run summaries from the backing store, including runs persisted before the current server boot.
|
||||
* @summary List Runs
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
listRuns(options?: RawAxiosRequestConfig): AxiosPromise<Array<StoreRunSummary>> {
|
||||
return localVarFp.listRuns(options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Pauses a running run. Returns 409 if the run is not running.
|
||||
|
|
@ -534,13 +664,13 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
|
|||
return localVarFp.pauseRun(id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Returns the current status of a run, including error details and queue position if applicable.
|
||||
* Returns the durable run summary for a run.
|
||||
* @summary Retrieve Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
retrieveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
|
||||
retrieveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<StoreRunSummary> {
|
||||
return localVarFp.retrieveRun(id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
|
|
@ -554,14 +684,15 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
|
|||
return localVarFp.retrieveRunGraph(id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Starts a submitted run, queuing it for execution. Returns 409 if the run is not in `submitted` status.
|
||||
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
|
||||
* @summary Start Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {StartRunRequest} [startRunRequest]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
startRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
|
||||
return localVarFp.startRun(id, options).then((request) => request(axios, basePath));
|
||||
startRun(id: string, startRunRequest?: StartRunRequest, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
|
||||
return localVarFp.startRun(id, startRunRequest, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Resumes a paused run. Returns 409 if the run is not paused.
|
||||
|
|
@ -592,7 +723,7 @@ export class RunsApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Creates a new workflow run from a Graphviz graph source. The run is created in `submitted` status. Use `POST /api/v1/runs/{id}/start` to begin execution.
|
||||
* Creates a new workflow run in `submitted` status. Callers may either provide `dot_source` directly or provide `workflow_path`, `cwd`, and `settings_json` so the server can load a local workflow path for trusted CLI execution.
|
||||
* @summary Create Run
|
||||
* @param {CreateRunRequest} createRunRequest
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -603,15 +734,36 @@ export class RunsApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns a paginated list of runs for the board view, ordered by recency.
|
||||
* @summary List Runs
|
||||
* Deletes durable store state for a run. This does not remove any local run directory.
|
||||
* @summary Delete Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public deleteRun(id: string, options?: RawAxiosRequestConfig) {
|
||||
return RunsApiFp(this.configuration).deleteRun(id, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporary board-view list of managed runs. This endpoint is UI-oriented and may change as the app evolves.
|
||||
* @summary List Board Runs
|
||||
* @param {number} [pageLimit] Maximum number of items to return per page.
|
||||
* @param {number} [pageOffset] Number of items to skip before returning results.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public listRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig) {
|
||||
return RunsApiFp(this.configuration).listRuns(pageLimit, pageOffset, options).then((request) => request(this.axios, this.basePath));
|
||||
public listBoardRuns(pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig) {
|
||||
return RunsApiFp(this.configuration).listBoardRuns(pageLimit, pageOffset, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns durable run summaries from the backing store, including runs persisted before the current server boot.
|
||||
* @summary List Runs
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public listRuns(options?: RawAxiosRequestConfig) {
|
||||
return RunsApiFp(this.configuration).listRuns(options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -626,7 +778,7 @@ export class RunsApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the current status of a run, including error details and queue position if applicable.
|
||||
* Returns the durable run summary for a run.
|
||||
* @summary Retrieve Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {*} [options] Override http request option.
|
||||
|
|
@ -648,14 +800,15 @@ export class RunsApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Starts a submitted run, queuing it for execution. Returns 409 if the run is not in `submitted` status.
|
||||
* Starts a submitted run, queuing it for execution. Provide `resume=true` to resume an interrupted run from checkpoint. Returns 409 if the run is not startable.
|
||||
* @summary Start Run
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {StartRunRequest} [startRunRequest]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public startRun(id: string, options?: RawAxiosRequestConfig) {
|
||||
return RunsApiFp(this.configuration).startRun(id, options).then((request) => request(this.axios, this.basePath));
|
||||
public startRun(id: string, startRunRequest?: StartRunRequest, options?: RawAxiosRequestConfig) {
|
||||
return RunsApiFp(this.configuration).startRun(id, startRunRequest, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -15,12 +15,28 @@
|
|||
|
||||
|
||||
/**
|
||||
* Request body for creating a new run from a Graphviz graph source.
|
||||
* Request body for creating a new run, either from inline Graphviz source or from a local workflow path plus resolved settings.
|
||||
*/
|
||||
export interface CreateRunRequest {
|
||||
/**
|
||||
* Graphviz DOT language source defining the workflow graph.
|
||||
*/
|
||||
'dot_source': string;
|
||||
'dot_source'?: string;
|
||||
/**
|
||||
* Absolute or relative path to the workflow file to load on the local machine.
|
||||
*/
|
||||
'workflow_path'?: string;
|
||||
/**
|
||||
* Working directory used to resolve the workflow path.
|
||||
*/
|
||||
'cwd'?: string;
|
||||
/**
|
||||
* JSON-serialized `fabro_types::Settings` payload resolved by the CLI.
|
||||
*/
|
||||
'settings_json'?: string;
|
||||
/**
|
||||
* Optional pre-generated run ID to use instead of allocating a new ULID.
|
||||
*/
|
||||
'run_id'?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,8 +137,10 @@ export * from './smoothness-rating';
|
|||
export * from './stage-retro';
|
||||
export * from './stage-status';
|
||||
export * from './stage-turn';
|
||||
export * from './start-run-request';
|
||||
export * from './status-reason';
|
||||
export * from './steer-request';
|
||||
export * from './store-run-summary';
|
||||
export * from './submit-answer-request';
|
||||
export * from './system-stage-turn';
|
||||
export * from './tls-settings';
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@
|
|||
|
||||
|
||||
/**
|
||||
* Request body for starting a new run from a Graphviz graph source.
|
||||
* Request body for starting or resuming a run.
|
||||
*/
|
||||
export interface StartRunRequest {
|
||||
/**
|
||||
* Graphviz DOT language source defining the workflow graph.
|
||||
* Resume from checkpoint instead of starting from submitted state.
|
||||
*/
|
||||
'dot_source': string;
|
||||
'resume'?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
* https://openapi-generator.tech
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Durable run summary derived from the backing store.
|
||||
*/
|
||||
export interface StoreRunSummary {
|
||||
'run_id': string;
|
||||
'workflow_name'?: string;
|
||||
'workflow_slug'?: string;
|
||||
'goal'?: string;
|
||||
'labels': { [key: string]: string; };
|
||||
'host_repo_path'?: string;
|
||||
'start_time'?: string;
|
||||
'status'?: string;
|
||||
'status_reason'?: string;
|
||||
'duration_ms'?: number;
|
||||
'total_cost'?: number;
|
||||
}
|
||||
|
||||
Loading…
Add table
Reference in a new issue