diff --git a/Cargo.lock b/Cargo.lock index 334bfad6e..a77430a7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1494,6 +1494,7 @@ dependencies = [ "prettyplease", "progenitor", "progenitor-client", + "regress", "reqwest 0.13.2", "serde", "serde_json", @@ -1599,6 +1600,7 @@ name = "fabro-config" version = "0.176.2" dependencies = [ "anyhow", + "chrono", "clap", "dirs", "fabro-types", @@ -1937,6 +1939,7 @@ dependencies = [ "fabro-types", "futures", "object_store", + "percent-encoding", "serde", "serde_json", "slatedb", @@ -2011,11 +2014,11 @@ dependencies = [ "clap", "dirs", "fabro-macros", + "hex", "serde", "serde_json", "sha2", "ulid", - "uuid", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 25b5ba9de..66c779105 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ exec = "0.3" slatedb = "0.11.2" object_store = "0.12.5" rust-embed = "8" +percent-encoding = "2" [workspace.lints.rust] unsafe_code = "deny" diff --git a/docs-internal/run-directory-keys.md b/docs-internal/run-directory-keys.md index 1f36edbdf..043bbbe7d 100644 --- a/docs-internal/run-directory-keys.md +++ b/docs-internal/run-directory-keys.md @@ -1,298 +1,81 @@ -# Run Directory Keys +# Run Scratch Files -All keys that may be written to the run store during a workflow execution, with event source mappings. +This document maps the files written under a run scratch directory to their event sources. -## 1. `_init.json` +Scope: +- Scratch root: `~/.fabro/scratch/YYYYMMDD-{run_id}/` +- This covers local run files only +- Persistent store keys live in `lib/crates/fabro-store/src/keys.rs` +- Artifact object-store keys live in `lib/crates/fabro-store/src/artifact_store.rs` -Store initialization metadata. Written when the store is created. +There is no `_init.json` anymore. Run existence in the database is determined by stored run events, and local scratch directories are managed separately under `scratch/`. -No event source — written directly at store creation time. +## Root-Level Files -| Field | Description | Event Source | -|-------|-------------|--------------| -| `run_id` | ULID string | — | -| `created_at` | RFC 3339 timestamp | — | -| `db_prefix` | SlateDB key prefix | — | -| `run_dir` | path to run directory (optional) | — | +| File | Purpose | Event source | +|---|---|---| +| `run.json` | Run metadata snapshot: run id, resolved settings, graph, workflow slug, working directory, repo info, labels | Primarily `run.created`, plus request-time inputs persisted during create | +| `start.json` | Start timestamp and git context | `run.started` | +| `workflow.fabro` | Original dot source when available | `run.created.properties.workflow_source` | +| `workflow.toml` | Original workflow TOML when available | `run.created.properties.workflow_config` | +| `progress.jsonl` | Append-only event log | Every emitted run event | +| `live.json` | Latest live state snapshot | Derived continuously from emitted events | +| `checkpoint.json` | Crash-recovery snapshot | Derived from accumulated stage/checkpoint events plus engine state | +| `conclusion.json` | Final outcome summary | `run.completed` and `run.failed` | +| `retro.json` | Post-run retro output | `retro.completed` | +| `final.patch` | Final git diff for checkpointed runs | Local git state at finalize time, not a direct event payload | +| `cli.log` | Per-run tracing log | Local tracing output, not event-derived | +| `run.pid` | Legacy detached-run pid file from older runs | Legacy only; current flows do not rely on it | -## 2. `run.json` +## Local-Only Directories -Run configuration snapshot. Written at run creation. +These paths are local runtime state, not canonical event projections. -No single event carries this data. The `run.started` event has a subset (`name`, `run_id`, `base_branch`, `base_sha`, `run_branch`, `goal`) but not `settings`, `graph`, or `labels`. +| Path | Purpose | +|---|---| +| `worktree/` | Git worktree used by checkpointed runs | +| `runtime/interview_request.json` | Detached-run interview request IPC file | +| `runtime/interview_response.json` | Detached-run interview response IPC file | +| `runtime/interview_request.claim` | Detached-run interview claim lock | +| `cache/artifacts/values/` | Large context values spilled to the filesystem | +| `cache/artifacts/files/` | Captured artifact files organized by node and retry | -| Field | Description | Event Source | -|-------|-------------|--------------| -| `run_id` | ULID string | `run.started` → `envelope.run_id` | -| `created_at` | RFC 3339 timestamp | — | -| `settings` | full Settings object | — | -| `graph` | parsed workflow graph | — | -| `workflow_slug` | workflow slug (optional) | — | -| `working_directory` | path string | — | -| `host_repo_path` | original host repo path (optional) | — | -| `base_branch` | base git branch (optional) | `run.started` → `properties.base_branch` | -| `labels` | string key-value map (optional) | — | +## Node Directories -## 3. `start.json` +Per-node outputs are written under: +- `nodes/{node_id}/` +- `nodes/{node_id}-visit_{N}/` for retries, where `N` starts at `2` -Start timestamp and git context. Written when execution begins. +### Agent and prompt nodes -| Field | Description | Event Source | -|-------|-------------|--------------| -| `run_id` | ULID string | `run.started` → `envelope.run_id` | -| `start_time` | RFC 3339 timestamp | `run.started` → `envelope.ts` | -| `run_branch` | git branch for the run (optional) | `run.started` → `properties.run_branch` | -| `base_sha` | base commit SHA (optional) | `run.started` → `properties.base_sha` | +| File | Purpose | Event source | +|---|---|---| +| `prompt.md` | Rendered prompt text | `stage.prompt.properties.text` is the closest event source | +| `response.md` | Final model response text | Reconstructable from message events, but written as a local convenience file | +| `status.json` | Final node status, notes, failure reason, timestamp | `stage.completed` | -## 4. `checkpoint.json` +### Command nodes -Latest execution state. Updated after each node completes. +| File | Purpose | Event source | +|---|---|---| +| `script_invocation.json` | Command metadata: command, language, timeout | Partly from node config; not fully represented by a single event | +| `stdout.log` | Captured stdout | Local process output | +| `stderr.log` | Captured stderr | Local process output | +| `script_timing.json` | Duration, exit code, timeout result | Partly `stage.completed.properties.duration_ms`, otherwise local process state | +| `status.json` | Final node status | `stage.completed` | -Checkpoint is an accumulated snapshot built from multiple events over time. Individual fields map to specific events, but the full object is never in a single event. +### Nodes with git checkpointing -| Field | Description | Event Source | -|-------|-------------|--------------| -| `timestamp` | RFC 3339 timestamp | — (written at checkpoint time) | -| `current_node` | node being executed | `stage.started` → `envelope.node_id` | -| `completed_nodes` | list of completed node ids | accumulated from `stage.completed` → `envelope.node_id` | -| `node_retries` | map of node id → retry count | accumulated from `stage.retrying` → `envelope.node_id` + `properties.attempt` | -| `context_values` | map of context key → JSON value | — (internal engine state) | -| `node_outcomes` | map of node id → outcome | see below | -| `next_node_id` | pre-selected next node (optional) | `edge.selected` → `properties.to_node` | -| `git_commit_sha` | current HEAD SHA (optional) | `checkpoint.completed` → `properties.git_commit_sha` | -| `loop_failure_signatures` | failure signature → count (optional) | — (internal engine state) | -| `restart_failure_signatures` | failure signature → count (optional) | — (internal engine state) | -| `node_visits` | node id → visit count (optional) | — (internal engine state) | +| File | Purpose | +|---|---| +| `diff.patch` | Per-node git diff captured at checkpoint time | -**`node_outcomes[node_id]`** — each outcome maps to `stage.completed`: +### Manager / child workflow nodes -| Field | Description | Event Source | -|-------|-------------|--------------| -| `status` | `"success"` / `"fail"` / `"skipped"` / `"partial_success"` / `"retry"` | `stage.completed` → `properties.status` | -| `preferred_label` | edge label hint (optional) | `stage.completed` → `properties.preferred_label` | -| `suggested_next_ids` | successor node ids (optional) | `stage.completed` → `properties.suggested_next_ids` | -| `context_updates` | context key → JSON value (optional) | — (not in event) | -| `jump_to_node` | non-edge jump target (optional) | — (not in event) | -| `notes` | free-text notes (optional) | `stage.completed` → `properties.notes` | -| `failure.message` | error description | `stage.completed` → `properties.error` (flattened) | -| `failure.failure_class` | failure category | `stage.completed` → `properties.failure_class` (flattened) | -| `failure.failure_signature` | dedup key (optional) | `stage.completed` → `properties.failure_signature` (flattened) | -| `usage.model` | model identifier | `stage.completed` → `properties.usage.model` | -| `usage.input_tokens` | input token count | `stage.completed` → `properties.usage.input_tokens` | -| `usage.output_tokens` | output token count | `stage.completed` → `properties.usage.output_tokens` | -| `usage.cache_read_tokens` | cache read tokens (optional) | `stage.completed` → `properties.usage.cache_read_tokens` | -| `usage.cache_write_tokens` | cache write tokens (optional) | `stage.completed` → `properties.usage.cache_write_tokens` | -| `usage.reasoning_tokens` | reasoning tokens (optional) | `stage.completed` → `properties.usage.reasoning_tokens` | -| `usage.speed` | speed tier (optional) | `stage.completed` → `properties.usage.speed` | -| `usage.cost` | estimated cost in USD (optional) | `stage.completed` → `properties.usage.cost` | -| `files_touched` | file paths modified (optional) | `stage.completed` → `properties.files_touched` | -| `duration_ms` | stage duration (optional) | `stage.completed` → `properties.duration_ms` | +Manager nodes may create a nested `child/` directory containing a full run scratch structure for the child workflow. -## 5. `conclusion.json` +## Notes -Final run summary. Written when the run finishes. - -| Field | Description | Event Source | -|-------|-------------|--------------| -| `timestamp` | RFC 3339 timestamp | — (written at conclusion time) | -| `status` | final status | `run.completed` → `properties.status` | -| `duration_ms` | total run duration | `run.completed` → `properties.duration_ms` | -| `failure_reason` | error message (optional) | `run.failed` → `properties.error` | -| `final_git_commit_sha` | final HEAD SHA (optional) | `run.completed` → `properties.final_git_commit_sha` | -| `stages` | list of stage summaries (optional) | — (aggregated, not in events) | -| `stages[].stage_id` | node id | `stage.completed` → `envelope.node_id` | -| `stages[].stage_label` | display label | `stage.completed` → `envelope.node_label` | -| `stages[].duration_ms` | stage duration | `stage.completed` → `properties.duration_ms` | -| `stages[].cost` | cost in USD (optional) | `stage.completed` → `properties.usage.cost` | -| `stages[].retries` | retry count | accumulated from `stage.retrying` events | -| `total_cost` | aggregate cost (optional) | `run.completed` → `properties.total_cost` | -| `total_retries` | aggregate retries | — (aggregated from stage events) | -| `total_input_tokens` | aggregate input tokens | `run.completed` → `properties.usage.input_tokens` | -| `total_output_tokens` | aggregate output tokens | `run.completed` → `properties.usage.output_tokens` | -| `total_cache_read_tokens` | aggregate cache read tokens | `run.completed` → `properties.usage.cache_read_tokens` | -| `total_cache_write_tokens` | aggregate cache write tokens | `run.completed` → `properties.usage.cache_write_tokens` | -| `total_reasoning_tokens` | aggregate reasoning tokens | `run.completed` → `properties.usage.reasoning_tokens` | -| `has_pricing` | whether cost data is available | — (derived from `total_cost`) | - -## 6. `retro.json` - -Retrospective analysis. Written after the retro agent completes. - -| Field | Description | Event Source | -|-------|-------------|--------------| -| `run_id` | ULID string | `retro.completed` → `envelope.run_id` | -| `workflow_name` | workflow name | `run.started` → `properties.name` | -| `goal` | workflow goal text | `run.started` → `properties.goal` | -| `timestamp` | RFC 3339 timestamp | `retro.completed` → `envelope.ts` | -| `smoothness` | rating (optional) | `retro.completed` → `properties.retro.smoothness` | -| `stages` | list of stage retro objects | `retro.completed` → `properties.retro.stages` | -| `stats` | aggregate stats object | `retro.completed` → `properties.retro.stats` | -| `intent` | what the run intended to do (optional) | `retro.completed` → `properties.retro.intent` | -| `outcome` | what actually happened (optional) | `retro.completed` → `properties.retro.outcome` | -| `learnings` | list of learnings (optional) | `retro.completed` → `properties.retro.learnings` | -| `friction_points` | list of friction points (optional) | `retro.completed` → `properties.retro.friction_points` | -| `open_items` | list of open items (optional) | `retro.completed` → `properties.retro.open_items` | - -## 7. `sandbox.json` - -Sandbox environment details. Written when the sandbox is ready. - -| Field | Description | Event Source | -|-------|-------------|--------------| -| `provider` | provider name | `sandbox.initialized` → `properties.provider` | -| `working_directory` | working directory in sandbox | `sandbox.initialized` → `properties.working_directory` | -| `identifier` | instance identifier (optional) | `sandbox.initialized` → `properties.identifier` | -| `host_working_directory` | host-side path (optional) | `sandbox.initialized` → `properties.host_working_directory` | -| `container_mount_point` | container mount point (optional) | `sandbox.initialized` → `properties.container_mount_point` | - -## 8. `workflow.fabro` - -Raw Graphviz dot source for the workflow graph. Plain text, not JSON. - -Event source: `run.created` → `properties.workflow_source` - -## 9. `workflow.toml` - -Workflow configuration in TOML format. Same schema as `settings` in `run.json`. - -Event source: `run.created` → `properties.workflow_config` - -## 10. `checkpoints/{seq:04}-{epoch_ms}.json` - -Checkpoint history snapshots. Same schema as `checkpoint.json` (#4). - -Each snapshot is written on `checkpoint.completed` events. - -## 11. `nodes/{node_id}/prompt.md` - -Prompt sent to the LLM for agent or prompt nodes. Plain text/markdown, not JSON. - -Partial event source: `stage.prompt` → `properties.text` carries the rendered prompt text. - -## 12. `nodes/{node_id}/response.md` - -Response received from the LLM. Plain text/markdown, not JSON. - -Reconstructable from `agent.message` → `properties.text` events (one per LLM turn), but the file contains only the final response. - -## 13. `nodes/{node_id}/status.json` - -Node execution status. Written when a node completes. - -| Field | Description | Event Source | -|-------|-------------|--------------| -| `status` | stage status | `stage.completed` → `properties.status` | -| `notes` | free-text notes (optional) | `stage.completed` → `properties.notes` | -| `failure_reason` | error message (optional) | `stage.completed` → `properties.error` (flattened from failure) | -| `timestamp` | RFC 3339 timestamp | `stage.completed` → `envelope.ts` | - -## 14. `nodes/{node_id}/stdout.log` - -Standard output from command nodes. Plain text, not JSON. - -No event source — captured from sandbox exec, not emitted as events. - -## 15. `nodes/{node_id}/stderr.log` - -Standard error from command nodes. Plain text, not JSON. - -No event source — captured from sandbox exec, not emitted as events. - -## 16. `nodes/{node_id}/cli_stdout.log` - -Standard output from CLI-backend LLM invocations. Plain text, not JSON. - -No event source — captured from CLI subprocess, not emitted as events. - -## 17. `nodes/{node_id}/cli_stderr.log` - -Standard error from CLI-backend LLM invocations. Plain text, not JSON. - -No event source — captured from CLI subprocess, not emitted as events. - -## 18. `nodes/{node_id}/diff.patch` - -Git diff of sandbox changes made by the node. Plain text unified diff, not JSON. - -No event source — generated from git at checkpoint time. - -## 19. `nodes/{node_id}/provider_used.json` - -LLM provider metadata. Written for agent, prompt, and CLI-backend nodes. - -No direct event. Closest: `agent.failover` carries `from_provider`/`to_provider`/`from_model`/`to_model`, but only on failover. The initial provider choice is not emitted as an event. - -| Field | Description | Event Source | -|-------|-------------|--------------| -| `mode` | `"agent"` / `"prompt"` / `"cli"` | — | -| `provider` | provider name | — | -| `model` | model identifier | `stage.completed` → `properties.usage.model` (indirect) | -| `command` | CLI command (only when mode=cli) | — | - -## 20. `nodes/{node_id}/script_invocation.json` - -Command node invocation details. Written before the command runs. - -| Field | Description | Event Source | -|-------|-------------|--------------| -| `command` | shell command or script body | `stage.started` → `properties.script` (when handler_type is command) | -| `language` | `"shell"` / `"python"` | — | -| `timeout_ms` | timeout in milliseconds (null if none) | — | - -## 21. `nodes/{node_id}/script_timing.json` - -Command node execution timing. Written after the command completes. - -| Field | Description | Event Source | -|-------|-------------|--------------| -| `duration_ms` | execution duration | `stage.completed` → `properties.duration_ms` | -| `exit_code` | process exit code (null if timed out) | — | -| `timed_out` | whether command was killed by timeout | — | - -## 22. `nodes/{node_id}/parallel_results.json` - -Results from parallel branch execution. Written by the parallel handler. - -Array of objects: - -| Field | Description | Event Source | -|-------|-------------|--------------| -| `id` | branch node id | `parallel.branch.completed` → `envelope.node_id` | -| `status` | status string | `parallel.branch.completed` → `properties.status` | -| `head_sha` | git HEAD SHA (optional) | `parallel.branch.completed` → `properties.head_sha` | - -## 23. `retro/prompt.md` - -Prompt sent to the retro agent. Plain text/markdown, not JSON. - -Event source: `retro.started` → `properties.prompt` - -## 24. `retro/response.md` - -Response received from the retro agent. Plain text/markdown, not JSON. - -Event source: `retro.completed` → `properties.response` - -## 25. `retro/status.json` - -Retro agent execution status. - -| Field | Description | Event Source | -|-------|-------------|--------------| -| `outcome` | `"success"` / `"failure"` | `retro.completed` or `retro.failed` (inferred from which event fires) | -| `failure_reason` | error message (null on success) | `retro.failed` → `properties.error` | -| `timestamp` | RFC 3339 timestamp | `retro.completed` → `envelope.ts` or `retro.failed` → `envelope.ts` | - -## 26. `retro/provider_used.json` - -Retro agent LLM provider metadata. - -| Field | Description | Event Source | -|-------|-------------|--------------| -| `mode` | execution mode | constant `"agent"` plus `retro.started` context | -| `provider` | LLM provider | `retro.started` → `properties.provider` | -| `model` | model identifier | `retro.started` → `properties.model` | - ---- - -**Node visit directories:** The first visit writes to `nodes/{node_id}/`. Subsequent visits write to `nodes/{node_id}-visit_{N}/` where N is the visit number. +- `progress.jsonl` is the event log; many other files are denormalized convenience snapshots derived from that stream plus local runtime state. +- `checkpoint.json` and `live.json` are projections, not single-event payloads. +- Artifact binaries are no longer stored in the SlateDB keyspace. They live in `ArtifactStore`; the run scratch tree only contains local cached copies when a workflow stage writes them to disk. diff --git a/docs/agents/outputs.mdx b/docs/agents/outputs.mdx index 6fed29718..6e9919e0b 100644 --- a/docs/agents/outputs.mdx +++ b/docs/agents/outputs.mdx @@ -150,7 +150,7 @@ Values under 100KB remain in the context as-is. Offloaded artifacts are written to the run's directory: ``` -~/.fabro/runs/{run_id}/ +~/.fabro/scratch/{run_id}/ cache/ artifacts/ values/ @@ -207,7 +207,7 @@ For each pointer in the context updates: ``` # Before sync (host path) -file:///home/user/.fabro/runs/01JK.../cache/artifacts/values/response.plan.json +file:///home/user/.fabro/scratch/01JK.../cache/artifacts/values/response.plan.json # After sync (sandbox path) file:///workspace/.fabro/artifacts/response.plan.json @@ -256,7 +256,7 @@ Tool caches and dependency directories (`node_modules`, `.cache/ms-playwright`, Collected assets are written to the run's directory, organized by node and retry attempt: ``` -~/.fabro/runs/{run_id}/ +~/.fabro/scratch/{run_id}/ cache/ artifacts/ assets/ diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 6cd1a7d4a..3fb0e90be 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -1919,14 +1919,14 @@ components: description: Content-addressed blob identifier. schema: type: string - format: uuid - example: 550e8400-e29b-41d4-a716-446655440000 + pattern: '^[0-9a-f]{64}$' + example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 ArtifactFilename: name: filename in: query required: true - description: Artifact filename. May contain path separators. + description: Relative artifact path. `/` is allowed as a path separator. Backslash, empty segments, and traversal segments (`.` and `..`) are invalid. schema: type: string example: src/lib.rs diff --git a/docs/execution/observability.mdx b/docs/execution/observability.mdx index 47148c782..309fc2bf1 100644 --- a/docs/execution/observability.mdx +++ b/docs/execution/observability.mdx @@ -71,15 +71,15 @@ Because event payload lives in `properties`, most shell queries should look ther ```bash # Count tool calls in a run jq -r 'select(.event == "agent.tool.started") | .properties.tool_name' \ - ~/.fabro/runs/01JKXYZ.../progress.jsonl | wc -l + ~/.fabro/scratch/01JKXYZ.../progress.jsonl | wc -l # Find stage failures jq 'select(.event == "stage.failed")' \ - ~/.fabro/runs/01JKXYZ.../progress.jsonl + ~/.fabro/scratch/01JKXYZ.../progress.jsonl # See which edges were taken jq '{from: .properties.from_node, to: .properties.to_node, label: .properties.label}' \ - ~/.fabro/runs/01JKXYZ.../progress.jsonl | head + ~/.fabro/scratch/01JKXYZ.../progress.jsonl | head ``` `live.json` is still a pretty-printed copy of the most recent event envelope. diff --git a/docs/reference/run-directory.mdx b/docs/reference/run-directory.mdx index 82d8b0394..2b2b8e30c 100644 --- a/docs/reference/run-directory.mdx +++ b/docs/reference/run-directory.mdx @@ -7,13 +7,13 @@ description: "Structure of Fabro's per-run directory" The run directory structure and file formats described here are internal implementation details and subject to change without notice. Do not build tooling that relies on them. -Each `fabro run` invocation creates a timestamped directory under `~/.fabro/runs/`: +Each `fabro run` invocation creates a timestamped directory under `~/.fabro/scratch/`: ``` -~/.fabro/runs/20260307-01JQXYZ123ABC456DEF789/ +~/.fabro/scratch/20260307-01JQXYZ123ABC456DEF789/ ``` -The naming format is `YYYYMMDD-{run_id}`, where `run_id` is the ULID assigned to the run. You can override the base storage directory with the global `--storage-dir` flag (the runs directory will be `/runs/`). +The naming format is `YYYYMMDD-{run_id}`, where `run_id` is the ULID assigned to the run. You can override the base storage directory with the global `--storage-dir` flag (the scratch directory will be `/scratch/`). ## Root-level files @@ -78,7 +78,7 @@ Manager nodes that run sub-workflows write a nested `child/` directory containin ## Browsing runs -Use `fabro ps` to scan the runs directory and display a table of all runs with their status, workflow name, and timestamps. Pass `--json` for machine-readable output. +Use `fabro ps` to scan the scratch directory and display a table of all runs with their status, workflow name, and timestamps. Pass `--json` for machine-readable output. ```bash fabro ps @@ -89,7 +89,7 @@ fabro ps --filter workflow=my-workflow ## Full directory tree ``` -~/.fabro/runs/ +~/.fabro/scratch/ ├── 20260307-01JQXYZ123ABC456DEF789/ # One directory per run │ ├── run.json │ ├── start.json @@ -112,7 +112,7 @@ fabro ps --filter workflow=my-workflow │ │ ├── values/ │ │ │ ├── response.plan.json │ │ │ └── command.output.json -│ │ └── assets/ +│ │ └── files/ │ │ └── test/ │ │ └── retry_1/ │ │ ├── test-results/ diff --git a/lib/crates/fabro-api/Cargo.toml b/lib/crates/fabro-api/Cargo.toml index 0e711afed..98ef6836d 100644 --- a/lib/crates/fabro-api/Cargo.toml +++ b/lib/crates/fabro-api/Cargo.toml @@ -16,6 +16,7 @@ wildcard_imports = "warn" [dependencies] chrono = { workspace = true, features = ["serde"] } progenitor-client = "0.13" +regress = "0.10" reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index c44d7aa31..39ec63321 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -382,9 +382,9 @@ fn restore_empty_run_properties(value: &mut serde_json::Value) { #[cfg(test)] fn infer_storage_dir(run_dir: &Path) -> Option { - let runs_dir = run_dir.parent()?; - let storage_dir = runs_dir.parent()?; - (runs_dir.file_name()? == "runs").then(|| storage_dir.to_path_buf()) + let scratch_dir = run_dir.parent()?; + let storage_dir = scratch_dir.parent()?; + (scratch_dir.file_name()? == "scratch").then(|| storage_dir.to_path_buf()) } #[cfg(test)] @@ -482,7 +482,7 @@ mod tests { let run_dir = dir .path() .join("storage") - .join("runs") + .join("scratch") .join("20260401-test"); std::fs::create_dir_all(&run_dir).unwrap(); @@ -496,7 +496,7 @@ mod tests { fn infer_run_id_reads_id_txt() { let dir = tempfile::tempdir().unwrap(); let storage_dir = dir.path().join("storage"); - let run_dir = storage_dir.join("runs").join("20260401-test"); + let run_dir = storage_dir.join("scratch").join("20260401-test"); std::fs::create_dir_all(&run_dir).unwrap(); std::fs::write( run_dir.join("id.txt"), diff --git a/lib/crates/fabro-cli/src/commands/run/create.rs b/lib/crates/fabro-cli/src/commands/run/create.rs index 8e433a0e3..842d2ba77 100644 --- a/lib/crates/fabro-cli/src/commands/run/create.rs +++ b/lib/crates/fabro-cli/src/commands/run/create.rs @@ -2,9 +2,9 @@ use std::path::PathBuf; use crate::args::RunArgs; use fabro_config::ConfigLayer; +use fabro_config::Storage; use fabro_types::{RunId, Settings}; use fabro_util::terminal::Styles; -use fabro_workflow::operations::make_run_dir; use super::output::{api_diagnostics_to_local, print_preflight_workflow_summary}; use crate::manifest_builder::{ManifestBuildInput, build_run_manifest, run_manifest_args}; @@ -66,9 +66,12 @@ pub(crate) async fn create_run( let created_run_id = client.create_run_from_manifest(built.manifest).await?; let local_run_dir = match &connection { - ServerConnection::Local { storage_dir } => { - Some(make_run_dir(&storage_dir.join("runs"), &created_run_id)) - } + ServerConnection::Local { storage_dir } => Some( + Storage::new(storage_dir) + .run_scratch(&created_run_id) + .root() + .to_path_buf(), + ), ServerConnection::Target(_) => None, }; diff --git a/lib/crates/fabro-cli/src/commands/run/output.rs b/lib/crates/fabro-cli/src/commands/run/output.rs index 50f24b632..a95ec211d 100644 --- a/lib/crates/fabro-cli/src/commands/run/output.rs +++ b/lib/crates/fabro-cli/src/commands/run/output.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::Result; use fabro_api::types; -use fabro_store::RuntimeState; +use fabro_config::RunScratch; use fabro_types::PullRequestRecord; use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus}; use fabro_util::terminal::Styles; @@ -264,8 +264,8 @@ pub(crate) fn print_final_output(checkpoint: Option<&fabro_types::Checkpoint>, s } pub(crate) fn print_assets(run_dir: &Path, styles: &Styles) { - let runtime_state = RuntimeState::new(run_dir); - let paths = collect_artifact_paths(&runtime_state.artifacts_dir()); + let run_scratch = RunScratch::new(run_dir); + let paths = collect_artifact_paths(&run_scratch.artifact_files_dir()); if paths.is_empty() { return; } diff --git a/lib/crates/fabro-cli/src/commands/server/record.rs b/lib/crates/fabro-cli/src/commands/server/record.rs index 2e197dd2c..0574b218b 100644 --- a/lib/crates/fabro-cli/src/commands/server/record.rs +++ b/lib/crates/fabro-cli/src/commands/server/record.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; +use fabro_config::Storage; use fabro_server::bind::Bind; use serde::{Deserialize, Serialize}; @@ -13,18 +14,6 @@ pub(crate) struct ServerRecord { pub started_at: DateTime, } -pub(crate) fn server_record_path(storage_dir: &Path) -> PathBuf { - storage_dir.join("server.json") -} - -pub(crate) fn server_lock_path(storage_dir: &Path) -> PathBuf { - storage_dir.join("server.lock") -} - -pub(crate) fn server_log_path(storage_dir: &Path) -> PathBuf { - storage_dir.join("server.log") -} - pub(crate) fn write_server_record(path: &Path, record: &ServerRecord) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; @@ -47,7 +36,7 @@ pub(crate) fn server_record_is_running(record: &ServerRecord) -> bool { } pub(crate) fn active_server_record(storage_dir: &Path) -> Option { - let path = server_record_path(storage_dir); + let path = Storage::new(storage_dir).server_state().record_path(); let record = read_server_record(&path)?; if server_record_is_running(&record) { Some(record) @@ -91,7 +80,7 @@ mod tests { #[test] fn write_and_read_round_trip() { let dir = tempfile::tempdir().unwrap(); - let path = server_record_path(dir.path()); + let path = Storage::new(dir.path()).server_state().record_path(); let record = test_record(Bind::Tcp("127.0.0.1:3000".parse().unwrap())); write_server_record(&path, &record).unwrap(); @@ -109,7 +98,7 @@ mod tests { #[test] fn active_server_record_cleans_stale_dead_pid() { let dir = tempfile::tempdir().unwrap(); - let path = server_record_path(dir.path()); + let path = Storage::new(dir.path()).server_state().record_path(); let mut record = test_record(Bind::Tcp("127.0.0.1:3000".parse().unwrap())); record.pid = u32::MAX; // definitely not alive write_server_record(&path, &record).unwrap(); diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs index ce0b19d3c..cb16008e9 100644 --- a/lib/crates/fabro-cli/src/commands/server/start.rs +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -4,6 +4,7 @@ use std::time::Duration; use anyhow::{Result, bail}; use chrono::Utc; +use fabro_config::Storage; use fabro_server::bind::Bind; use fabro_server::serve; use fabro_server::serve::ServeArgs; @@ -83,13 +84,14 @@ async fn execute_foreground( ); } - let record_path = record::server_record_path(&storage_dir); + let server_state = Storage::new(&storage_dir).server_state(); + let record_path = server_state.record_path(); record::write_server_record( &record_path, &record::ServerRecord { pid: std::process::id(), bind: bind.clone(), - log_path: record::server_log_path(&storage_dir), + log_path: server_state.log_path(), started_at: Utc::now(), }, )?; @@ -135,11 +137,12 @@ fn execute_daemon( } // Rotate logs - let log_path = record::server_log_path(storage_dir); + let server_state = Storage::new(storage_dir).server_state(); + let log_path = server_state.log_path(); let prev_path = log_path.with_extension("log.prev"); let _ = std::fs::rename(&log_path, &prev_path); - let record_path = record::server_record_path(storage_dir); + let record_path = server_state.record_path(); let log_file = std::fs::File::create(&log_path)?; let stdout_log = log_file.try_clone()?; let exe = std::env::current_exe()?; @@ -250,7 +253,7 @@ fn execute_daemon( // --------------------------------------------------------------------------- fn acquire_lock(storage_dir: &Path) -> Result { - let lock_path = record::server_lock_path(storage_dir); + let lock_path = Storage::new(storage_dir).server_state().lock_path(); if let Some(parent) = lock_path.parent() { std::fs::create_dir_all(parent)?; } diff --git a/lib/crates/fabro-cli/src/commands/server/stop.rs b/lib/crates/fabro-cli/src/commands/server/stop.rs index a26506b43..6f3334697 100644 --- a/lib/crates/fabro-cli/src/commands/server/stop.rs +++ b/lib/crates/fabro-cli/src/commands/server/stop.rs @@ -2,6 +2,7 @@ use std::path::Path; use std::thread; use std::time::Duration; +use fabro_config::Storage; use fabro_server::bind::Bind; use super::record; @@ -29,7 +30,7 @@ pub(crate) fn execute(storage_dir: &Path, timeout: Duration) { thread::sleep(Duration::from_millis(100)); } - let record_path = record::server_record_path(storage_dir); + let record_path = Storage::new(storage_dir).server_state().record_path(); record::remove_server_record(&record_path); if let Bind::Unix(ref path) = record.bind { diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index 07e99aa53..446a82cde 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -1,18 +1,21 @@ use anyhow::{Context, Result}; +use fabro_config::Storage; #[cfg(test)] use fabro_store::StageId; -use fabro_store::{RunProjection, SlateRunStore}; +use fabro_store::{ArtifactStore, RunDatabase, RunProjection}; use fabro_workflow::run_dump::RunDump; #[cfg(test)] use serde::de::DeserializeOwned; use std::io::ErrorKind; use std::path::Path; +use std::sync::Arc; use crate::args::{GlobalArgs, StoreDumpArgs}; use crate::commands::store::rebuild::rebuild_run_store; use crate::server_runs::ServerRunLookup; use crate::shared::{absolute_or_current, print_json_pretty}; use crate::user_config::load_settings_with_storage_dir; +use object_store::{ObjectStore, local::LocalFileSystem}; pub(crate) async fn dump_command(args: &StoreDumpArgs, globals: &GlobalArgs) -> Result<()> { let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; @@ -21,8 +24,12 @@ pub(crate) async fn dump_command(args: &StoreDumpArgs, globals: &GlobalArgs) -> let run_id = run.run_id(); let events = lookup.client().list_run_events(&run_id, None, None).await?; let run_store = rebuild_run_store(&run_id, &events).await?; + let artifact_object_store: Arc = Arc::new(LocalFileSystem::new_with_prefix( + Storage::new(cli_settings.storage_dir()).store_dir(), + )?); + let artifact_store = ArtifactStore::new(artifact_object_store, "artifacts"); - let file_count = export_run(&run_store, &args.output).await?; + let file_count = export_run(&run_store, &artifact_store, &args.output).await?; if globals.json { print_json_pretty(&serde_json::json!({ "run_id": run_id, @@ -39,7 +46,11 @@ pub(crate) async fn dump_command(args: &StoreDumpArgs, globals: &GlobalArgs) -> Ok(()) } -pub(crate) async fn export_run(run_store: &SlateRunStore, output_dir: &Path) -> Result { +pub(crate) async fn export_run( + run_store: &RunDatabase, + artifact_store: &ArtifactStore, + output_dir: &Path, +) -> Result { let state = run_store.state().await?; anyhow::ensure!(state.run.is_some(), "run has no data in the store"); @@ -59,7 +70,7 @@ pub(crate) async fn export_run(run_store: &SlateRunStore, output_dir: &Path) -> })?; let staging_path = staging_dir.path().to_path_buf(); - let file_count = export_run_to_dir(run_store, &state, &staging_path).await?; + let file_count = export_run_to_dir(run_store, artifact_store, &state, &staging_path).await?; if matches!(output_state, OutputDirState::ExistingEmpty) { std::fs::remove_dir(output_dir) @@ -78,11 +89,12 @@ pub(crate) async fn export_run(run_store: &SlateRunStore, output_dir: &Path) -> } async fn export_run_to_dir( - run_store: &SlateRunStore, + run_store: &RunDatabase, + artifact_store: &ArtifactStore, state: &RunProjection, output_dir: &Path, ) -> Result { - let dump = RunDump::store_export(run_store, state).await?; + let dump = RunDump::store_export(run_store, artifact_store, state).await?; dump.write_to_dir(output_dir) } @@ -132,11 +144,10 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; - use std::sync::Arc; use std::time::Duration; use chrono::{DateTime, Utc}; - use fabro_store::{EventEnvelope, EventPayload, SlateStore}; + use fabro_store::{Database, EventEnvelope, EventPayload}; use fabro_types::{ AggregateStats, AttrValue, Checkpoint, Conclusion, Graph, NodeStatusRecord, Retro, RunId, RunRecord, RunStatus, RunStatusRecord, SandboxRecord, Settings, StageStatus, StartRecord, @@ -155,12 +166,15 @@ mod tests { fixtures::RUN_1 } - fn test_store() -> Arc { - Arc::new(SlateStore::new( - Arc::new(InMemory::new()), + fn test_store_bundle() -> (Arc, ArtifactStore) { + let object_store: Arc = Arc::new(InMemory::new()); + let store = Arc::new(Database::new( + Arc::clone(&object_store), "", Duration::from_millis(1), - )) + )); + let artifact_store = ArtifactStore::new(object_store, "artifacts"); + (store, artifact_store) } fn sample_run_record(run_id: RunId, _created_at: DateTime) -> RunRecord { @@ -280,7 +294,7 @@ mod tests { #[tokio::test] async fn export_run_writes_expected_directory_tree() { - let store = test_store(); + let (store, artifact_store) = test_store_bundle(); let created_at = dt("2026-03-27T12:00:00Z"); let run_id = test_run_id(); let run = store.create_run(&run_id).await.unwrap(); @@ -537,17 +551,21 @@ mod tests { .unwrap(); let summary_blob = run.write_blob(br#"{"done":true}"#).await.unwrap(); let plan_blob = run.write_blob(br#"{"steps":3}"#).await.unwrap(); - run.put_artifact(&node, "src/lib.rs", b"fn main() {}") + artifact_store + .put(&run_id, &node, "src/lib.rs", b"fn main() {}") .await .unwrap(); let artifact_only_node = StageId::new("artifact-only", 7); - run.put_artifact(&artifact_only_node, "logs/output.txt", b"hello") + artifact_store + .put(&run_id, &artifact_only_node, "logs/output.txt", b"hello") .await .unwrap(); let output = tempfile::tempdir().unwrap(); - let file_count = export_run(&run, output.path()).await.unwrap(); + let file_count = export_run(&run, &artifact_store, output.path()) + .await + .unwrap(); assert_eq!(file_count, 22); let exported_run: RunRecord = read_json(&output.path().join("run.json")); @@ -639,45 +657,6 @@ mod tests { assert!(!output.path().join("nodes/artifact-only").exists()); } - #[tokio::test] - async fn export_run_rejects_path_traversal_and_leaves_no_partial_output() { - let store = test_store(); - let created_at = dt("2026-03-27T12:00:00Z"); - let run_id = test_run_id(); - let run = store.create_run(&run_id).await.unwrap(); - let run_record = sample_run_record(run_id, created_at); - append_event( - &run, - &run_id, - &Event::RunCreated { - run_id, - settings: serde_json::to_value(&run_record.settings).unwrap(), - graph: serde_json::to_value(&run_record.graph).unwrap(), - workflow_source: Some("digraph night_sky {}".to_string()), - workflow_config: None, - labels: run_record.labels.clone().into_iter().collect(), - run_dir: "/tmp/night-sky-run".to_string(), - working_directory: run_record.working_directory.display().to_string(), - host_repo_path: run_record.host_repo_path.clone(), - repo_origin_url: run_record.repo_origin_url.clone(), - base_branch: run_record.base_branch.clone(), - workflow_slug: run_record.workflow_slug.clone(), - db_prefix: None, - }, - ) - .await - .unwrap(); - run.put_artifact(&StageId::new("code", 1), "../escape.txt", b"boom") - .await - .unwrap(); - - let temp = tempfile::tempdir().unwrap(); - let output = temp.path().join("dump"); - let err = export_run(&run, &output).await.unwrap_err(); - assert!(err.to_string().contains("artifact filename")); - assert!(!output.exists()); - } - #[test] fn inspect_output_dir_rejects_non_empty_directory() { let dir = tempfile::tempdir().unwrap(); diff --git a/lib/crates/fabro-cli/src/commands/store/rebuild.rs b/lib/crates/fabro-cli/src/commands/store/rebuild.rs index f78264ef4..43fcebcdb 100644 --- a/lib/crates/fabro-cli/src/commands/store/rebuild.rs +++ b/lib/crates/fabro-cli/src/commands/store/rebuild.rs @@ -2,14 +2,14 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Result; -use fabro_store::{EventEnvelope, EventPayload, SlateRunStore, SlateStore}; +use fabro_store::{Database, EventEnvelope, EventPayload, RunDatabase}; use object_store::memory::InMemory; pub(crate) async fn rebuild_run_store( run_id: &fabro_types::RunId, events: &[EventEnvelope], -) -> Result { - let store = Arc::new(SlateStore::new( +) -> Result { + let store = Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), diff --git a/lib/crates/fabro-cli/src/commands/system/df.rs b/lib/crates/fabro-cli/src/commands/system/df.rs index faed0465c..9e3e3e070 100644 --- a/lib/crates/fabro-cli/src/commands/system/df.rs +++ b/lib/crates/fabro-cli/src/commands/system/df.rs @@ -6,7 +6,8 @@ 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_with_summaries}; +use fabro_config::Storage; +use fabro_workflow::run_lookup::{scan_runs_with_summaries, scratch_base}; use fabro_workflow::run_status::RunStatus; use crate::args::{DfArgs, GlobalArgs}; @@ -47,14 +48,14 @@ struct DfOutput { pub(super) async fn df_command(args: &DfArgs, globals: &GlobalArgs) -> Result<()> { let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; let data_dir = cli_settings.storage_dir(); - let runs_base_dir = runs_base(&data_dir); - let logs_base_dir = logs_base(&data_dir); + let scratch_base_dir = scratch_base(&data_dir); + let logs_base_dir = Storage::new(&data_dir).logs_dir(); let lookup = ServerRunLookup::connect(&data_dir).await?; df_from( args, lookup.summaries(), &data_dir, - &runs_base_dir, + &scratch_base_dir, &logs_base_dir, globals, ) @@ -65,7 +66,7 @@ fn df_from( args: &DfArgs, summaries: &[fabro_store::RunSummary], data_dir: &Path, - runs_base: &Path, + scratch_base: &Path, logs_base: &Path, globals: &GlobalArgs, ) -> Result<()> { @@ -78,7 +79,7 @@ fn df_from( size: u64, } - let runs = scan_runs_with_summaries(summaries, runs_base)?; + let runs = scan_runs_with_summaries(summaries, scratch_base)?; let mut active_count = 0u64; let mut total_run_size = 0u64; let mut reclaimable_run_size = 0u64; diff --git a/lib/crates/fabro-cli/src/commands/system/prune.rs b/lib/crates/fabro-cli/src/commands/system/prune.rs index 017b9ea5a..f129b93ef 100644 --- a/lib/crates/fabro-cli/src/commands/system/prune.rs +++ b/lib/crates/fabro-cli/src/commands/system/prune.rs @@ -5,7 +5,9 @@ use chrono::Utc; use serde::Serialize; use tracing::{debug, info}; -use fabro_workflow::run_lookup::{StatusFilter, filter_runs, runs_base, scan_runs_with_summaries}; +use fabro_workflow::run_lookup::{ + StatusFilter, filter_runs, scan_runs_with_summaries, scratch_base, +}; use crate::args::{GlobalArgs, RunsPruneArgs}; use crate::commands::runs::rm::remove_run_with_cleanup; @@ -24,7 +26,7 @@ struct PruneRunRow { pub(super) async fn prune_command(args: &RunsPruneArgs, globals: &GlobalArgs) -> Result<()> { let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let base = runs_base(&cli_settings.storage_dir()); + let base = scratch_base(&cli_settings.storage_dir()); let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?; prune_from(args, lookup.client(), lookup.summaries(), &base, globals).await } diff --git a/lib/crates/fabro-cli/src/server_runs.rs b/lib/crates/fabro-cli/src/server_runs.rs index bf95f5c5b..f819398c7 100644 --- a/lib/crates/fabro-cli/src/server_runs.rs +++ b/lib/crates/fabro-cli/src/server_runs.rs @@ -6,29 +6,29 @@ use anyhow::{Result, bail}; use chrono::{DateTime, Utc}; use fabro_store::RunSummary; use fabro_types::{RunId, RunStatus, StatusReason}; -use fabro_workflow::run_lookup::{RunInfo, resolve_run_from_summaries, runs_base}; +use fabro_workflow::run_lookup::{RunInfo, resolve_run_from_summaries, scratch_base}; use crate::args::ServerTargetArgs; use crate::server_client::{self, ServerStoreClient}; pub(crate) struct ServerRunLookup { client: ServerStoreClient, - runs_base: PathBuf, + scratch_base: PathBuf, summaries: Vec, } impl ServerRunLookup { pub(crate) async fn connect(storage_dir: &Path) -> Result { - Self::connect_from_runs_base(&runs_base(storage_dir)).await + Self::connect_from_scratch_base(&scratch_base(storage_dir)).await } - pub(crate) async fn connect_from_runs_base(runs_base: &Path) -> Result { - let storage_dir = runs_base.parent().unwrap_or(runs_base); + pub(crate) async fn connect_from_scratch_base(scratch_base: &Path) -> Result { + let storage_dir = scratch_base.parent().unwrap_or(scratch_base); let client = server_client::connect_server(storage_dir).await?; let summaries = client.list_store_runs().await?; Ok(Self { client, - runs_base: runs_base.to_path_buf(), + scratch_base: scratch_base.to_path_buf(), summaries, }) } @@ -42,7 +42,7 @@ impl ServerRunLookup { } pub(crate) fn resolve(&self, selector: &str) -> Result { - resolve_run_from_summaries(&self.summaries, &self.runs_base, selector) + resolve_run_from_summaries(&self.summaries, &self.scratch_base, selector) } } diff --git a/lib/crates/fabro-cli/tests/it/cmd/config.rs b/lib/crates/fabro-cli/tests/it/cmd/config.rs index 171e29803..1accf26fa 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/config.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/config.rs @@ -426,7 +426,7 @@ fn create_explicit_workflow_path_uses_project_config_relative_to_workflow() { .assert() .success(); - let runs_dir = storage_dir.join("runs"); + let runs_dir = storage_dir.join("scratch"); let run_dir = std::fs::read_dir(&runs_dir) .unwrap() .flatten() @@ -575,6 +575,42 @@ shared = "legacy" ); } +#[test] +fn settings_uses_fabro_home_for_home_config_resolution() { + let context = test_context!(); + let fabro_home = tempfile::tempdir().unwrap(); + + std::fs::write( + fabro_home.path().join("settings.toml"), + r#" +verbose = true + +[llm] +model = "from-fabro-home" +"#, + ) + .unwrap(); + + let output = context + .settings() + .arg("--json") + .env("FABRO_HOME", fabro_home.path()) + .env_remove("FABRO_STORAGE_DIR") + .output() + .expect("command should execute"); + + assert!( + output.status.success(), + "settings command failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + + let cfg: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(cfg["verbose"].as_bool(), Some(true)); + assert_eq!(cfg["llm"]["model"].as_str(), Some("from-fabro-home")); +} + #[test] fn settings_rejects_server_url_flag() { let context = test_context!(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/logs.rs b/lib/crates/fabro-cli/tests/it/cmd/logs.rs index a15c52cca..89fc713ca 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/logs.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/logs.rs @@ -124,7 +124,7 @@ fn logs_completed_run_reads_store_without_progress_jsonl() { r#""id":"[EVENT_ID]""#.to_string(), )); filters.push(( - r#""run_dir":"(?:\[DRY_RUN_DIR\]|\[STORAGE_DIR\]/runs/REDACTED)""#.to_string(), + r#""run_dir":"(?:\[DRY_RUN_DIR\]|\[STORAGE_DIR\]/scratch/REDACTED)""#.to_string(), r#""run_dir":"[RUN_DIR]""#.to_string(), )); @@ -159,7 +159,7 @@ fn logs_tail_limits_output() { r#""id":"[EVENT_ID]""#.to_string(), )); filters.push(( - r#""run_dir":"(?:\[DRY_RUN_DIR\]|\[STORAGE_DIR\]/runs/REDACTED)""#.to_string(), + r#""run_dir":"(?:\[DRY_RUN_DIR\]|\[STORAGE_DIR\]/scratch/REDACTED)""#.to_string(), r#""run_dir":"[RUN_DIR]""#.to_string(), )); let mut cmd = context.command(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 33159f32b..4995f3da7 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -528,7 +528,7 @@ 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 runs_dir = context.storage_dir.join("scratch"); assert_eq!( entries.len(), 1, @@ -599,7 +599,7 @@ pub(crate) fn resolve_run(context: &TestContext, run_id: &str) -> RunSetup { } pub(crate) fn find_run_dir(storage_dir: &Path, run_id: &str) -> Option { - let runs_dir = storage_dir.join("runs"); + let runs_dir = storage_dir.join("scratch"); let entries = std::fs::read_dir(&runs_dir).ok()?; entries .filter_map(Result::ok) diff --git a/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs b/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs index a4d5276d9..ec58d9496 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs @@ -7,7 +7,7 @@ use crate::cmd::support::{read_text, setup_artifact_run, text_tree}; fn artifact_filters(context: &fabro_test::TestContext) -> Vec<(String, String)> { let mut filters = context.filters(); filters.push(( - r"\[STORAGE_DIR\]/runs/\d{8}-\[ULID\]".to_string(), + r"\[STORAGE_DIR\]/scratch/\d{8}-\[ULID\]".to_string(), "[RUN_DIR]".to_string(), )); filters diff --git a/lib/crates/fabro-cli/tests/it/support/mod.rs b/lib/crates/fabro-cli/tests/it/support/mod.rs index 98d0e0e94..bb5bc9a7d 100644 --- a/lib/crates/fabro-cli/tests/it/support/mod.rs +++ b/lib/crates/fabro-cli/tests/it/support/mod.rs @@ -18,7 +18,7 @@ macro_rules! fabro_json_snapshot { r#""duration_ms": "[DURATION_MS]""#.to_string(), )); filters.push(( - r#""run_dir":\s*"\[STORAGE_DIR\]/runs/\d{8}-\[ULID\]""#.to_string(), + r#""run_dir":\s*"\[STORAGE_DIR\]/scratch/\d{8}-\[ULID\]""#.to_string(), r#""run_dir": "[RUN_DIR]""#.to_string(), )); let filters: Vec<(&str, &str)> = filters diff --git a/lib/crates/fabro-cli/tests/it/workflow/mod.rs b/lib/crates/fabro-cli/tests/it/workflow/mod.rs index 8d440875a..76442ed92 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/mod.rs @@ -72,7 +72,7 @@ pub(super) fn store_dump_export(context: &TestContext, run_id: &str) -> PathBuf /// 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 runs_base = context.storage_dir.join("scratch"); let runs: Vec = block_on(get_server_json_for_storage( &context.storage_dir, "/api/v1/runs", @@ -152,7 +152,7 @@ async fn get_server_json_for_storage( } fn find_run_dir_for_id(storage_dir: &Path, run_id: &str) -> Option { - let runs_dir = storage_dir.join("runs"); + let runs_dir = storage_dir.join("scratch"); let entries = std::fs::read_dir(&runs_dir).ok()?; entries .filter_map(Result::ok) diff --git a/lib/crates/fabro-config/Cargo.toml b/lib/crates/fabro-config/Cargo.toml index 3e972afa9..afd0d175e 100644 --- a/lib/crates/fabro-config/Cargo.toml +++ b/lib/crates/fabro-config/Cargo.toml @@ -19,6 +19,7 @@ workspace = true [dependencies] anyhow.workspace = true clap = { workspace = true, optional = true } +chrono.workspace = true fabro-types = { path = "../fabro-types" } fabro-util = { path = "../fabro-util" } dirs.workspace = true diff --git a/lib/crates/fabro-config/src/home.rs b/lib/crates/fabro-config/src/home.rs new file mode 100644 index 000000000..8d58640b4 --- /dev/null +++ b/lib/crates/fabro-config/src/home.rs @@ -0,0 +1,78 @@ +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Home { + root: PathBuf, +} + +impl Home { + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + #[must_use] + pub fn from_env() -> Self { + if let Some(root) = std::env::var_os("FABRO_HOME") { + return Self::new(root); + } + + let root = dirs::home_dir() + .map(|home| home.join(".fabro")) + .unwrap_or_else(|| PathBuf::from(".fabro")); + Self::new(root) + } + + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + #[must_use] + pub fn user_config(&self) -> PathBuf { + self.root.join("settings.toml") + } + + #[must_use] + pub fn server_config(&self) -> PathBuf { + self.root.join("settings.toml") + } + + #[must_use] + pub fn certs_dir(&self) -> PathBuf { + self.root.join("certs") + } + + #[must_use] + pub fn skills_dir(&self) -> PathBuf { + self.root.join("skills") + } +} + +#[cfg(test)] +mod tests { + use super::Home; + + #[test] + fn accessors_are_relative_to_root() { + let home = Home::new("/tmp/fabro-home"); + + assert_eq!(home.root(), std::path::Path::new("/tmp/fabro-home")); + assert_eq!( + home.user_config(), + std::path::Path::new("/tmp/fabro-home/settings.toml") + ); + assert_eq!( + home.server_config(), + std::path::Path::new("/tmp/fabro-home/settings.toml") + ); + assert_eq!( + home.certs_dir(), + std::path::Path::new("/tmp/fabro-home/certs") + ); + assert_eq!( + home.skills_dir(), + std::path::Path::new("/tmp/fabro-home/skills") + ); + } +} diff --git a/lib/crates/fabro-config/src/lib.rs b/lib/crates/fabro-config/src/lib.rs index 2744d816a..eea98626d 100644 --- a/lib/crates/fabro-config/src/lib.rs +++ b/lib/crates/fabro-config/src/lib.rs @@ -2,6 +2,7 @@ extern crate self as fabro_config; pub mod combine; pub mod config; +pub mod home; pub mod hook; pub mod legacy_env; pub mod mcp; @@ -10,11 +11,14 @@ pub mod run; pub mod sandbox; pub mod server; pub mod settings; +pub mod storage; pub mod user; pub use config::ConfigLayer; pub use fabro_types::Combine; pub use fabro_util::path::expand_tilde; +pub use home::Home; +pub use storage::{RunScratch, ServerState, Storage}; use std::path::Path; @@ -34,11 +38,7 @@ where return Ok(toml::from_str(&contents)?); } - let Some(home) = dirs::home_dir() else { - tracing::debug!("No home directory found, using default config"); - return Ok(T::default()); - }; - let default_path = home.join(".fabro").join(filename); + let default_path = Home::from_env().root().join(filename); tracing::debug!(path = %default_path.display(), "Loading config"); match std::fs::read_to_string(&default_path) { Ok(contents) => Ok(toml::from_str(&contents)?), diff --git a/lib/crates/fabro-config/src/storage.rs b/lib/crates/fabro-config/src/storage.rs new file mode 100644 index 000000000..fe6067792 --- /dev/null +++ b/lib/crates/fabro-config/src/storage.rs @@ -0,0 +1,291 @@ +use std::path::{Path, PathBuf}; + +use chrono::Local; +use fabro_types::RunId; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Storage { + root: PathBuf, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServerState { + root: PathBuf, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RunScratch { + root: PathBuf, +} + +impl Storage { + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + #[must_use] + pub fn logs_dir(&self) -> PathBuf { + self.root.join("logs") + } + + #[must_use] + pub fn secrets_path(&self) -> PathBuf { + self.root.join("secrets.json") + } + + #[must_use] + pub fn server_state(&self) -> ServerState { + ServerState::new(self.root.clone()) + } + + #[must_use] + pub fn run_scratch(&self, run_id: &RunId) -> RunScratch { + let local_dt = run_id.created_at().with_timezone(&Local); + RunScratch::new( + self.scratch_dir() + .join(format!("{}-{run_id}", local_dt.format("%Y%m%d"))), + ) + } + + #[must_use] + pub fn scratch_dir(&self) -> PathBuf { + self.root.join("scratch") + } + + #[must_use] + pub fn store_dir(&self) -> PathBuf { + self.root.join("store") + } +} + +impl ServerState { + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + #[must_use] + pub fn record_path(&self) -> PathBuf { + self.root.join("server.json") + } + + #[must_use] + pub fn lock_path(&self) -> PathBuf { + self.root.join("server.lock") + } + + #[must_use] + pub fn log_path(&self) -> PathBuf { + self.root.join("server.log") + } +} + +impl RunScratch { + #[must_use] + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + + #[must_use] + pub fn root(&self) -> &Path { + &self.root + } + + #[must_use] + pub fn worktree_dir(&self) -> PathBuf { + self.root.join("worktree") + } + + #[must_use] + pub fn runtime_dir(&self) -> PathBuf { + self.root.join("runtime") + } + + #[must_use] + pub fn artifact_cache_dir(&self) -> PathBuf { + self.root.join("cache").join("artifacts") + } + + #[must_use] + pub fn blob_cache_dir(&self) -> PathBuf { + self.artifact_cache_dir().join("values") + } + + #[must_use] + pub fn artifact_files_dir(&self) -> PathBuf { + self.artifact_cache_dir().join("files") + } + + #[must_use] + pub fn interview_request_path(&self) -> PathBuf { + self.runtime_dir().join("interview_request.json") + } + + #[must_use] + pub fn interview_response_path(&self) -> PathBuf { + self.runtime_dir().join("interview_response.json") + } + + #[must_use] + pub fn interview_claim_path(&self) -> PathBuf { + self.runtime_dir().join("interview_request.claim") + } + + #[must_use] + pub fn artifact_stage_dir(&self, node_slug: &str, attempt: u32) -> PathBuf { + self.artifact_files_dir() + .join(node_slug) + .join(format!("retry_{attempt}")) + } + + #[must_use] + pub fn final_patch(&self) -> PathBuf { + self.root.join("final.patch") + } + + pub fn create(&self) -> std::io::Result<()> { + std::fs::create_dir_all(self.worktree_dir())?; + std::fs::create_dir_all(self.runtime_dir())?; + std::fs::create_dir_all(self.blob_cache_dir())?; + std::fs::create_dir_all(self.artifact_files_dir())?; + Ok(()) + } + + pub fn remove(&self) -> std::io::Result<()> { + match std::fs::remove_dir_all(&self.root) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } + } +} + +#[cfg(test)] +mod tests { + use chrono::Local; + + use super::{RunScratch, Storage}; + use fabro_types::RunId; + + #[test] + fn storage_accessors_are_relative_to_root() { + let storage = Storage::new("/tmp/fabro-data"); + + assert_eq!(storage.root(), std::path::Path::new("/tmp/fabro-data")); + assert_eq!( + storage.logs_dir(), + std::path::Path::new("/tmp/fabro-data/logs") + ); + assert_eq!( + storage.secrets_path(), + std::path::Path::new("/tmp/fabro-data/secrets.json") + ); + assert_eq!( + storage.store_dir(), + std::path::Path::new("/tmp/fabro-data/store") + ); + assert_eq!( + storage.server_state().record_path(), + std::path::Path::new("/tmp/fabro-data/server.json") + ); + assert_eq!( + storage.server_state().lock_path(), + std::path::Path::new("/tmp/fabro-data/server.lock") + ); + assert_eq!( + storage.server_state().log_path(), + std::path::Path::new("/tmp/fabro-data/server.log") + ); + } + + #[test] + fn run_scratch_uses_run_id_local_date() { + let storage = Storage::new("/tmp/fabro-data"); + let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(); + + let expected_date = run_id + .created_at() + .with_timezone(&Local) + .format("%Y%m%d") + .to_string(); + + assert_eq!( + storage.run_scratch(&run_id).root(), + std::path::Path::new("/tmp/fabro-data/scratch") + .join(format!("{expected_date}-{run_id}")) + ); + } + + #[test] + fn run_scratch_accessors_and_lifecycle_are_relative_to_root() { + let dir = tempfile::tempdir().unwrap(); + let scratch = RunScratch::new(dir.path().join("20260327-01TEST")); + + assert_eq!(scratch.worktree_dir(), scratch.root().join("worktree")); + assert_eq!(scratch.runtime_dir(), scratch.root().join("runtime")); + assert_eq!( + scratch.artifact_cache_dir(), + scratch.root().join("cache").join("artifacts") + ); + assert_eq!( + scratch.blob_cache_dir(), + scratch + .root() + .join("cache") + .join("artifacts") + .join("values") + ); + assert_eq!( + scratch.artifact_files_dir(), + scratch.root().join("cache").join("artifacts").join("files") + ); + assert_eq!( + scratch.interview_request_path(), + scratch + .root() + .join("runtime") + .join("interview_request.json") + ); + assert_eq!( + scratch.interview_response_path(), + scratch + .root() + .join("runtime") + .join("interview_response.json") + ); + assert_eq!( + scratch.interview_claim_path(), + scratch + .root() + .join("runtime") + .join("interview_request.claim") + ); + assert_eq!( + scratch.artifact_stage_dir("plan", 2), + scratch + .root() + .join("cache") + .join("artifacts") + .join("files") + .join("plan") + .join("retry_2") + ); + assert_eq!(scratch.final_patch(), scratch.root().join("final.patch")); + + scratch.create().unwrap(); + assert!(scratch.root().exists()); + assert!(scratch.worktree_dir().exists()); + assert!(scratch.runtime_dir().exists()); + assert!(scratch.blob_cache_dir().exists()); + assert!(scratch.artifact_files_dir().exists()); + + scratch.remove().unwrap(); + assert!(!scratch.root().exists()); + } +} diff --git a/lib/crates/fabro-config/src/user.rs b/lib/crates/fabro-config/src/user.rs index c229691a6..5fdcdb3de 100644 --- a/lib/crates/fabro-config/src/user.rs +++ b/lib/crates/fabro-config/src/user.rs @@ -6,6 +6,7 @@ use anyhow::anyhow; use serde::{Deserialize, Serialize}; use crate::config::ConfigLayer; +use crate::home::Home; pub use fabro_types::settings::user::{ ClientTlsSettings, ExecSettings, OutputFormat, PermissionLevel, ServerSettings, @@ -80,19 +81,23 @@ impl From for ExecSettings { } pub fn default_settings_path() -> Option { - dirs::home_dir().map(|home| home.join(".fabro").join(SETTINGS_CONFIG_FILENAME)) + Some(Home::from_env().user_config()) } pub fn legacy_user_config_path() -> Option { - dirs::home_dir().map(|home| home.join(".fabro").join(LEGACY_USER_CONFIG_FILENAME)) + Some(Home::from_env().root().join(LEGACY_USER_CONFIG_FILENAME)) } pub fn legacy_old_user_config_path() -> Option { - dirs::home_dir().map(|home| home.join(".fabro").join(LEGACY_OLD_USER_CONFIG_FILENAME)) + Some( + Home::from_env() + .root() + .join(LEGACY_OLD_USER_CONFIG_FILENAME), + ) } pub fn legacy_server_config_path() -> Option { - dirs::home_dir().map(|home| home.join(".fabro").join(LEGACY_SERVER_CONFIG_FILENAME)) + Some(Home::from_env().root().join(LEGACY_SERVER_CONFIG_FILENAME)) } fn warned_legacy_user_configs() -> &'static Mutex> { diff --git a/lib/crates/fabro-retro/src/retro_agent.rs b/lib/crates/fabro-retro/src/retro_agent.rs index 90a384de0..2dee2d27d 100644 --- a/lib/crates/fabro-retro/src/retro_agent.rs +++ b/lib/crates/fabro-retro/src/retro_agent.rs @@ -10,7 +10,7 @@ use fabro_agent::{ use fabro_llm::client::Client; use fabro_llm::provider::Provider; use fabro_llm::types::ToolDefinition; -use fabro_store::SlateRunStore; +use fabro_store::RunDatabase; use tokio::task::JoinHandle; use crate::retro::{RetroNarrative, SmoothnessRating}; @@ -135,7 +135,7 @@ pub fn build_retro_prompt(retro_data_dir: &str) -> String { /// files via tool access, then calls `submit_retro` with its analysis. pub async fn run_retro_agent( sandbox: &Arc, - run_store: &SlateRunStore, + run_store: &RunDatabase, run_dir: &Path, llm_client: &Client, provider: Provider, @@ -292,7 +292,7 @@ fn build_profile(provider: Provider, model: &str) -> Box { async fn upload_data_files( sandbox: &Arc, - run_store: &SlateRunStore, + run_store: &RunDatabase, _run_dir: &Path, target_dir: &str, ) -> anyhow::Result<()> { diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index 9edbf86f4..802fb91e4 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -748,7 +748,7 @@ impl Sandbox for DaytonaSandbox { key: &str, ) -> String { format!( - "{}/.fabro/runs/{}/parallel/{}/{}", + "{}/.fabro/scratch/{}/parallel/{}/{}", self.working_directory(), run_id, node_id, diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index c1273295d..481dc6d02 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -2,9 +2,11 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; use std::time::Duration; +use fabro_config::Storage; use fabro_config::server::resolve_storage_dir; use fabro_config::user::{default_settings_path, load_settings_config}; use fabro_util::terminal::Styles; +use object_store::ObjectStore; use object_store::local::LocalFileSystem; use tokio::net::{TcpListener, UnixListener}; use tokio::time::interval; @@ -107,7 +109,8 @@ pub async fn serve_command( let disk_settings = load_settings(config_path.as_deref())?; let active_config_path = resolved_config_path(config_path.as_deref()); let data_dir = storage_dir_override.unwrap_or_else(|| resolve_storage_dir(&disk_settings)); - let secret_store_path = data_dir.join("secrets.json"); + let storage = Storage::new(&data_dir); + let secret_store_path = storage.secrets_path(); let secret_store = SecretStore::load(secret_store_path.clone())?; let secret_snapshot = secret_store.snapshot(); @@ -167,19 +170,22 @@ pub async fn serve_command( (auth_mode, client_auth, max_concurrent_runs) }; - let store_path = data_dir.join("store"); + let store_path = storage.store_dir(); std::fs::create_dir_all(&store_path)?; - let object_store = Arc::new(LocalFileSystem::new_with_prefix(&store_path)?); - let store = Arc::new(fabro_store::SlateStore::new( - object_store, + let object_store: Arc = + Arc::new(LocalFileSystem::new_with_prefix(&store_path)?); + let store = Arc::new(fabro_store::Database::new( + Arc::clone(&object_store), "", Duration::from_millis(1), )); + let artifact_store = fabro_store::ArtifactStore::new(object_store, "artifacts"); let state = build_app_state_with_path( Arc::clone(&shared_settings), None, max_concurrent_runs, store, + artifact_store, secret_store_path, active_config_path, matches!(&auth_mode, AuthMode::Disabled), diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 14e5844d8..79820b0f9 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -18,6 +18,7 @@ use axum_extra::extract::cookie::Key; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use bytes::Bytes; +use fabro_config::Storage; use fabro_llm::client::Client as LlmClient; use fabro_llm::generate::{GenerateParams, generate_object}; use fabro_llm::model_test::{ModelTestMode, run_model_test_with_client}; @@ -25,7 +26,7 @@ use fabro_llm::types::{ ContentPart, FinishReason, Message as LlmMessage, Request as LlmRequest, Response as LlmResponse, Role, StreamEvent, ToolChoice, ToolDefinition, Usage, }; -use fabro_store::{EventEnvelope, EventPayload, StageId, StoreHandle}; +use fabro_store::{ArtifactStore, Database, EventEnvelope, EventPayload, StageId}; use fabro_types::{RunBlobId, RunEvent, RunId, Settings}; use fabro_util::redact::redact_jsonl_line; use fabro_util::version::FABRO_VERSION; @@ -223,7 +224,8 @@ type RegistryFactoryOverride = dyn Fn(Arc) -> HandlerRegistry + pub struct AppState { runs: Mutex>, aggregate_usage: Mutex, - store: StoreHandle, + store: Arc, + artifact_store: ArtifactStore, max_concurrent_runs: usize, scheduler_notify: Notify, pub sessions: SessionStore, @@ -880,11 +882,13 @@ pub fn create_app_state_with_settings_and_registry_factory( settings: Settings, registry_factory_override: impl Fn(Arc) -> HandlerRegistry + Send + Sync + 'static, ) -> Arc { + let (store, artifact_store) = test_store_bundle(); build_app_state_with_path( Arc::new(RwLock::new(settings)), Some(Box::new(registry_factory_override)), 5, - test_store(), + store, + artifact_store, test_secret_store_path(), test_config_path(), false, @@ -897,31 +901,38 @@ pub fn create_app_state_with_options( settings: Settings, max_concurrent_runs: usize, ) -> Arc { + let (store, artifact_store) = test_store_bundle(); create_app_state_with_store( Arc::new(RwLock::new(settings)), max_concurrent_runs, - test_store(), + store, + artifact_store, ) } -fn test_store() -> StoreHandle { - Arc::new(fabro_store::SlateStore::new( - Arc::new(MemoryObjectStore::new()), +fn test_store_bundle() -> (Arc, ArtifactStore) { + let object_store: Arc = Arc::new(MemoryObjectStore::new()); + let store = Arc::new(fabro_store::Database::new( + Arc::clone(&object_store), "", Duration::from_millis(1), - )) + )); + let artifact_store = ArtifactStore::new(object_store, "artifacts"); + (store, artifact_store) } pub fn create_app_state_with_store( settings: Arc>, max_concurrent_runs: usize, - store: StoreHandle, + store: Arc, + artifact_store: ArtifactStore, ) -> Arc { build_app_state_with_path( settings, None, max_concurrent_runs, store, + artifact_store, test_secret_store_path(), test_config_path(), false, @@ -933,7 +944,8 @@ pub(crate) fn build_app_state_with_path( settings: Arc>, registry_factory_override: Option>, max_concurrent_runs: usize, - store: StoreHandle, + store: Arc, + artifact_store: ArtifactStore, secret_store_path: PathBuf, config_path: PathBuf, local_daemon_mode: bool, @@ -943,6 +955,7 @@ pub(crate) fn build_app_state_with_path( runs: Mutex::new(HashMap::new()), aggregate_usage: Mutex::new(UsageAccumulator::default()), store, + artifact_store, max_concurrent_runs, scheduler_notify: Notify::new(), sessions: new_session_store(), @@ -1042,8 +1055,8 @@ async fn delete_run( } } } else { - let storage_dir = state.settings.read().unwrap().storage_dir(); - let run_dir = operations::make_run_dir(&storage_dir.join("runs"), &id); + let storage = Storage::new(state.settings.read().unwrap().storage_dir()); + let run_dir = storage.run_scratch(&id).root().to_path_buf(); if let Err(err) = remove_run_dir(&run_dir) { return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) .into_response(); @@ -1051,7 +1064,12 @@ async fn delete_run( } match state.store.delete_run(&id).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), + Ok(()) => match state.artifact_store.delete_for_run(&id).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() + } + }, Err(err) => { ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() } @@ -1128,8 +1146,9 @@ fn validate_relative_artifact_path(kind: &str, value: &str) -> Result PathBuf { - operations::make_run_dir(&run.settings.storage_dir().join("runs"), run_id) - .join("cache/artifacts/files") + Storage::new(run.settings.storage_dir()) + .run_scratch(run_id) + .artifact_files_dir() } #[allow(clippy::result_large_err)] @@ -1416,7 +1435,10 @@ async fn start_run( ) .into_response(); }; - let run_dir = operations::make_run_dir(&run_record.settings.storage_dir().join("runs"), &id); + let run_dir = Storage::new(run_record.settings.storage_dir()) + .run_scratch(&id) + .root() + .to_path_buf(); let dot_source = run_state.graph_source.unwrap_or_default(); { @@ -2172,7 +2194,7 @@ async fn list_stage_artifacts( ) .into_response(); }; - match run_store.list_artifacts_for_stage(&stage_id).await { + match state.artifact_store.list_for_node(&id, &stage_id).await { Ok(filenames) if !filenames.is_empty() => Json(ArtifactListResponse { data: filenames .into_iter() @@ -2228,8 +2250,12 @@ async fn put_stage_artifact( Ok(filename) => filename, Err(response) => return response, }; - match state.store.open_run(&id).await { - Ok(run_store) => match run_store.put_artifact(&stage_id, &filename, &body).await { + match state.store.open_run_reader(&id).await { + Ok(_) => match state + .artifact_store + .put(&id, &stage_id, &filename, &body) + .await + { Ok(()) => StatusCode::NO_CONTENT.into_response(), Err(err) => { ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() @@ -2267,7 +2293,7 @@ async fn get_stage_artifact( ) .into_response(); }; - match run_store.get_artifact(&stage_id, &filename).await { + match state.artifact_store.get(&id, &stage_id, &filename).await { Ok(Some(bytes)) => octet_stream_response(bytes), Ok(None) => { let relative_path = diff --git a/lib/crates/fabro-store/Cargo.toml b/lib/crates/fabro-store/Cargo.toml index f9f49d032..40055cff6 100644 --- a/lib/crates/fabro-store/Cargo.toml +++ b/lib/crates/fabro-store/Cargo.toml @@ -15,6 +15,7 @@ workspace = true fabro-types = { path = "../fabro-types" } slatedb.workspace = true object_store.workspace = true +percent-encoding.workspace = true async-trait.workspace = true tokio = { workspace = true, features = ["full"] } tokio-stream.workspace = true diff --git a/lib/crates/fabro-store/src/artifact_store.rs b/lib/crates/fabro-store/src/artifact_store.rs new file mode 100644 index 000000000..59dfd8ed9 --- /dev/null +++ b/lib/crates/fabro-store/src/artifact_store.rs @@ -0,0 +1,313 @@ +use std::sync::Arc; + +use bytes::Bytes; +use futures::StreamExt; +use object_store::{ObjectStore, path::Path as ObjectPath}; +use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode}; + +use crate::{Result, StageId, StoreError}; +use fabro_types::RunId; + +const ARTIFACT_SEGMENT_ENCODE_SET: &AsciiSet = + &NON_ALPHANUMERIC.remove(b'.').remove(b'_').remove(b'-'); + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct NodeArtifact { + pub node: StageId, + pub filename: String, +} + +#[derive(Clone)] +pub struct ArtifactStore { + object_store: Arc, + prefix: ObjectPath, +} + +impl std::fmt::Debug for ArtifactStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ArtifactStore") + .field("prefix", &self.prefix) + .finish_non_exhaustive() + } +} + +impl ArtifactStore { + #[must_use] + pub fn new(object_store: Arc, prefix: impl AsRef) -> Self { + Self { + object_store, + prefix: ObjectPath::from(prefix.as_ref()), + } + } + + pub async fn put( + &self, + run_id: &RunId, + node: &StageId, + filename: &str, + data: &[u8], + ) -> Result<()> { + let path = self.artifact_path(run_id, node, filename)?; + self.object_store + .put(&path, Bytes::copy_from_slice(data).into()) + .await?; + Ok(()) + } + + pub async fn get( + &self, + run_id: &RunId, + node: &StageId, + filename: &str, + ) -> Result> { + let path = self.artifact_path(run_id, node, filename)?; + match self.object_store.get(&path).await { + Ok(result) => Ok(Some(result.bytes().await?)), + Err(object_store::Error::NotFound { .. }) => Ok(None), + Err(err) => Err(err.into()), + } + } + + pub async fn list_for_run(&self, run_id: &RunId) -> Result> { + let prefix = self.run_prefix(run_id)?; + let mut stream = self.object_store.list(Some(&prefix)); + let mut artifacts = Vec::new(); + while let Some(meta) = stream.next().await.transpose()? { + artifacts.push(decode_artifact_location(&prefix, &meta.location)?); + } + artifacts.sort(); + Ok(artifacts) + } + + pub async fn list_for_node(&self, run_id: &RunId, node: &StageId) -> Result> { + let prefix = self.node_prefix(run_id, node)?; + let mut stream = self.object_store.list(Some(&prefix)); + let mut filenames = Vec::new(); + while let Some(meta) = stream.next().await.transpose()? { + filenames.push(decode_filename(&prefix, &meta.location)?); + } + filenames.sort(); + Ok(filenames) + } + + pub async fn delete_for_run(&self, run_id: &RunId) -> Result<()> { + let prefix = self.run_prefix(run_id)?; + let mut stream = self.object_store.list(Some(&prefix)); + let mut locations = Vec::new(); + while let Some(meta) = stream.next().await.transpose()? { + locations.push(meta.location); + } + for location in locations { + self.object_store.delete(&location).await?; + } + Ok(()) + } + + fn run_prefix(&self, run_id: &RunId) -> Result { + parse_object_path(self.prefixed_raw(&run_id.to_string())) + } + + fn node_prefix(&self, run_id: &RunId, node: &StageId) -> Result { + let encoded_node = encode_path_segment(node.node_id()); + parse_object_path( + self.prefixed_raw(&format!("{run_id}/{encoded_node}@{:04}", node.visit())), + ) + } + + fn artifact_path(&self, run_id: &RunId, node: &StageId, filename: &str) -> Result { + let mut raw = self.node_prefix(run_id, node)?.to_string(); + for segment in validate_filename_segments(filename)? { + raw.push('/'); + raw.push_str(&encode_path_segment(segment)); + } + parse_object_path(raw) + } + + fn prefixed_raw(&self, suffix: &str) -> String { + if self.prefix.as_ref().is_empty() { + suffix.to_string() + } else { + format!("{}/{suffix}", self.prefix) + } + } +} + +fn validate_filename_segments(filename: &str) -> Result> { + if filename.contains('\\') { + return Err(StoreError::Other( + "artifact filename must not contain backslashes".to_string(), + )); + } + let segments = filename.split('/').collect::>(); + if segments.is_empty() || segments.iter().any(|segment| segment.is_empty()) { + return Err(StoreError::Other( + "artifact filename must be a non-empty relative path".to_string(), + )); + } + if segments + .iter() + .any(|segment| matches!(*segment, "." | "..")) + { + return Err(StoreError::Other( + "artifact filename must not contain '.' or '..' segments".to_string(), + )); + } + Ok(segments) +} + +fn encode_path_segment(segment: &str) -> String { + utf8_percent_encode(segment, ARTIFACT_SEGMENT_ENCODE_SET).to_string() +} + +fn decode_path_segment(kind: &str, value: &str) -> Result { + percent_decode_str(value) + .decode_utf8() + .map(|decoded| decoded.into_owned()) + .map_err(|err| StoreError::Other(format!("invalid {kind}: {err}"))) +} + +fn decode_artifact_location(prefix: &ObjectPath, location: &ObjectPath) -> Result { + let mut parts = location.prefix_match(prefix).ok_or_else(|| { + StoreError::Other(format!( + "artifact location {location} does not match expected prefix {prefix}" + )) + })?; + let stage_part = parts.next().ok_or_else(|| { + StoreError::Other(format!( + "artifact location {location} is missing a stage segment" + )) + })?; + let (encoded_node_id, visit) = stage_part.as_ref().rsplit_once('@').ok_or_else(|| { + StoreError::Other(format!( + "artifact location {location} has an invalid stage segment" + )) + })?; + let node_id = decode_path_segment("artifact node id", encoded_node_id)?; + let visit = visit.parse::().map_err(|err| { + StoreError::Other(format!( + "artifact location {location} has an invalid visit number: {err}" + )) + })?; + let filename_segments = parts + .map(|part| decode_path_segment("artifact filename segment", part.as_ref())) + .collect::>>()?; + if filename_segments.is_empty() { + return Err(StoreError::Other(format!( + "artifact location {location} is missing a filename" + ))); + } + Ok(NodeArtifact { + node: StageId::new(node_id, visit), + filename: filename_segments.join("/"), + }) +} + +fn decode_filename(prefix: &ObjectPath, location: &ObjectPath) -> Result { + let mut parts = location.prefix_match(prefix).ok_or_else(|| { + StoreError::Other(format!( + "artifact location {location} does not match expected prefix {prefix}" + )) + })?; + let filename_segments = parts + .by_ref() + .map(|part| decode_path_segment("artifact filename segment", part.as_ref())) + .collect::>>()?; + if filename_segments.is_empty() { + return Err(StoreError::Other(format!( + "artifact location {location} is missing a filename" + ))); + } + Ok(filename_segments.join("/")) +} + +fn parse_object_path(raw: String) -> Result { + ObjectPath::parse(&raw) + .map_err(|err| StoreError::Other(format!("invalid artifact object path {raw:?}: {err}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + use object_store::memory::InMemory; + + use fabro_types::fixtures; + + fn test_store() -> ArtifactStore { + let object_store: Arc = Arc::new(InMemory::new()); + ArtifactStore::new(object_store, "artifacts") + } + + #[tokio::test] + async fn round_trips_unicode_nodes_and_nested_filenames() { + let store = test_store(); + let run_id = fixtures::RUN_1; + let node = StageId::new("build/naive @ alpha/π", 12); + let filename = "logs/unicode/naive file ☃.txt"; + + store.put(&run_id, &node, filename, b"hello").await.unwrap(); + + assert_eq!( + store.get(&run_id, &node, filename).await.unwrap(), + Some(Bytes::from_static(b"hello")) + ); + assert_eq!( + store.list_for_node(&run_id, &node).await.unwrap(), + vec![filename.to_string()] + ); + assert_eq!( + store.list_for_run(&run_id).await.unwrap(), + vec![NodeArtifact { + node, + filename: filename.to_string(), + }] + ); + } + + #[tokio::test] + async fn rejects_invalid_relative_filenames() { + let store = test_store(); + let run_id = fixtures::RUN_1; + let node = StageId::new("build", 1); + + for filename in [ + "", + "../escape.txt", + "logs//output.txt", + "logs/./output.txt", + r"logs\output.txt", + ] { + let err = store + .put(&run_id, &node, filename, b"boom") + .await + .unwrap_err(); + assert!(err.to_string().contains("artifact filename")); + } + } + + #[tokio::test] + async fn delete_for_run_only_removes_selected_run() { + let store = test_store(); + let run_id = fixtures::RUN_1; + let other_run_id = fixtures::RUN_2; + let node = StageId::new("build", 1); + + store.put(&run_id, &node, "a.txt", b"a").await.unwrap(); + store + .put(&run_id, &node, "nested/b.txt", b"b") + .await + .unwrap(); + store + .put(&other_run_id, &node, "keep.txt", b"keep") + .await + .unwrap(); + + store.delete_for_run(&run_id).await.unwrap(); + + assert!(store.list_for_run(&run_id).await.unwrap().is_empty()); + assert_eq!( + store.list_for_node(&other_run_id, &node).await.unwrap(), + vec!["keep.txt".to_string()] + ); + } +} diff --git a/lib/crates/fabro-store/src/keys.rs b/lib/crates/fabro-store/src/keys.rs index eab5bd275..05c8977ea 100644 --- a/lib/crates/fabro-store/src/keys.rs +++ b/lib/crates/fabro-store/src/keys.rs @@ -1,177 +1,123 @@ -use crate::StageId; use fabro_types::{RunBlobId, RunId}; -pub(crate) const RUNS_PREFIX: &str = "runs/"; -pub(crate) const CATALOG_BY_ID_PREFIX: &str = "_catalog/by-id/"; -pub(crate) const CATALOG_BY_START_PREFIX: &str = "_catalog/by-start/"; -pub(crate) const INIT_KEY: &str = "_init.json"; -pub(crate) const EVENTS_PREFIX: &str = "events#"; -pub(crate) const BLOBS_PREFIX: &str = "blobs#"; -pub(crate) const ARTIFACT_NODES_PREFIX: &str = "artifacts#nodes#"; +const RUNS_PREFIX: &str = "runs#"; +const RUNS_INDEX_BY_START_PREFIX: &str = "runs#_index#by-start#"; +const BLOBS_PREFIX: &str = "blobs#"; -pub(crate) fn run_prefix(run_id: &RunId) -> String { - format!("{RUNS_PREFIX}{run_id}/") +pub(crate) fn runs_index_by_start_prefix() -> &'static str { + RUNS_INDEX_BY_START_PREFIX } -pub(crate) fn init_key(run_id: &RunId) -> String { - format!("{}{INIT_KEY}", run_prefix(run_id)) +pub(crate) fn runs_index_by_start_key(run_id: &RunId) -> String { + format!( + "{RUNS_INDEX_BY_START_PREFIX}{}#{run_id}", + run_id.created_at().format("%Y-%m-%d") + ) } -pub(crate) fn events_prefix(run_id: &RunId) -> String { - format!("{}{EVENTS_PREFIX}", run_prefix(run_id)) +pub(crate) fn run_data_prefix(run_id: &RunId) -> String { + format!("{RUNS_PREFIX}{run_id}#") } -pub(crate) fn event_key(run_id: &RunId, seq: u32, epoch_ms: i64) -> String { - format!("{}{seq:06}-{epoch_ms}.json", events_prefix(run_id)) +pub(crate) fn run_events_prefix(run_id: &RunId) -> String { + format!("{}events#", run_data_prefix(run_id)) +} + +pub(crate) fn run_event_key(run_id: &RunId, seq: u32, epoch_ms: i64) -> String { + format!("{}{seq:06}-{epoch_ms}", run_events_prefix(run_id)) } pub(crate) fn blobs_prefix(run_id: &RunId) -> String { - format!("{}{BLOBS_PREFIX}", run_prefix(run_id)) + format!("{BLOBS_PREFIX}{run_id}#") } pub(crate) fn blob_key(run_id: &RunId, id: &RunBlobId) -> String { format!("{}{id}", blobs_prefix(run_id)) } -pub(crate) fn node_artifact_prefix(run_id: &RunId, node: &StageId) -> String { - format!( - "{}{ARTIFACT_NODES_PREFIX}{}#visit-{}", - run_prefix(run_id), - node.node_id(), - node.visit() - ) -} - -pub(crate) fn node_artifact(run_id: &RunId, node: &StageId, filename: &str) -> String { - format!("{}#{filename}", node_artifact_prefix(run_id, node)) -} - -pub(crate) fn catalog_by_id_key(run_id: &RunId) -> String { - format!("{CATALOG_BY_ID_PREFIX}{run_id}.json") -} - -pub(crate) fn catalog_by_start_prefix() -> &'static str { - CATALOG_BY_START_PREFIX -} - -pub(crate) fn catalog_by_start_key(run_id: &RunId) -> String { - format!( - "{CATALOG_BY_START_PREFIX}{}/{run_id}.json", - run_id.created_at().format("%Y-%m-%d-%H-%M") - ) -} - pub(crate) fn parse_event_seq(key: &str) -> Option { - parse_seq(key.rsplit('/').next()?, EVENTS_PREFIX) -} - -pub(crate) fn parse_blob_id(key: &str) -> Option { - key.rsplit('/') - .next()? - .strip_prefix(BLOBS_PREFIX)? + key.rsplit_once("#events#")? + .1 + .split_once('-')? + .0 .parse() .ok() } -pub(crate) fn parse_node_artifact_key(key: &str) -> Option<(StageId, String)> { - let artifact_start = key.find(ARTIFACT_NODES_PREFIX)?; - parse_visit_scoped_key(&key[artifact_start..], ARTIFACT_NODES_PREFIX) +pub(crate) fn parse_blob_id(key: &str) -> Option { + let rest = key.strip_prefix(BLOBS_PREFIX)?; + let (_, blob_id) = rest.split_once('#')?; + blob_id.parse().ok() } -pub(crate) fn parse_run_id_from_catalog_key(key: &str) -> Option { - let filename = key.rsplit('/').next()?; - let run_id = filename.strip_suffix(".json").unwrap_or(filename); +pub(crate) fn parse_run_id_from_index_key(key: &str) -> Option { + let rest = key.strip_prefix(RUNS_INDEX_BY_START_PREFIX)?; + let (_, run_id) = rest.split_once('#')?; run_id.parse().ok() } -fn parse_seq(key: &str, prefix: &str) -> Option { - key.strip_prefix(prefix)?.split_once('-')?.0.parse().ok() -} - -fn parse_visit_scoped_key(key: &str, prefix: &str) -> Option<(StageId, String)> { - let rest = key.strip_prefix(prefix)?; - let (node_id, rest) = rest.split_once("#visit-")?; - let (visit, file) = rest.split_once('#')?; - Some((StageId::new(node_id, visit.parse().ok()?), file.to_string())) -} - #[cfg(test)] mod tests { use super::*; + use fabro_types::RunId; #[test] fn top_level_keys_match_spec() { - let run_id = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(); - assert_eq!(INIT_KEY, "_init.json"); + let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(); assert_eq!( - event_key(&run_id, 7, 123), - "runs/01JT56VE4Z5NZ814GZN2JZD65A/events#000007-123.json" + run_event_key(&run_id, 7, 123), + "runs#01JT56VE4Z5NZ814GZN2JZD65A#events#000007-123" + ); + assert_eq!( + runs_index_by_start_key(&run_id), + format!( + "runs#_index#by-start#{}#{run_id}", + run_id.created_at().format("%Y-%m-%d") + ) ); } #[test] fn sequence_keys_are_zero_padded() { - let run_id = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(); + let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(); assert_eq!( - event_key(&run_id, 7, 123), - "runs/01JT56VE4Z5NZ814GZN2JZD65A/events#000007-123.json" + run_event_key(&run_id, 7, 123), + "runs#01JT56VE4Z5NZ814GZN2JZD65A#events#000007-123" ); } #[test] - fn artifact_keys_match_spec() { - let node = StageId::new("code", 2); - let run_id = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(); - let blob_id = RunBlobId::new(&run_id, b"summary"); + fn blob_keys_match_spec() { + let run_id: RunId = "01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(); + let blob_id = RunBlobId::new(b"summary"); assert_eq!( blob_key(&run_id, &blob_id), - format!("runs/{run_id}/blobs#{blob_id}") - ); - assert_eq!( - node_artifact(&run_id, &node, "src/main.rs"), - "runs/01JT56VE4Z5NZ814GZN2JZD65A/artifacts#nodes#code#visit-2#src/main.rs" + format!("blobs#{run_id}#{blob_id}") ); } #[test] - fn parse_helpers_extract_sequences_and_node_visits() { + fn parse_helpers_extract_sequences_and_blob_ids() { assert_eq!( - parse_event_seq("runs/01JT56VE4Z5NZ814GZN2JZD65A/events#000007-123.json"), + parse_event_seq("runs#01JT56VE4Z5NZ814GZN2JZD65A#events#000007-123"), Some(7) ); - let blob_id = RunBlobId::new(&"01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap(), b"summary"); + let blob_id = RunBlobId::new(b"summary"); assert_eq!( - parse_blob_id(&format!("runs/01JT56VE4Z5NZ814GZN2JZD65A/blobs#{blob_id}")), + parse_blob_id(&format!("blobs#01JT56VE4Z5NZ814GZN2JZD65A#{blob_id}")), Some(blob_id) ); assert_eq!( - parse_node_artifact_key( - "runs/01JT56VE4Z5NZ814GZN2JZD65A/artifacts#nodes#code#visit-2#src/main.rs" + parse_run_id_from_index_key( + "runs#_index#by-start#2026-03-27#01JT56VE4Z5NZ814GZN2JZD65A" ), - Some((StageId::new("code", 2), "src/main.rs".to_string())) + Some("01JT56VE4Z5NZ814GZN2JZD65A".parse().unwrap()) ); } #[test] fn parse_helpers_reject_invalid_keys() { - assert_eq!(parse_event_seq("events#not-a-seq.json"), None); + assert_eq!(parse_event_seq("runs#not-a-run#events#not-a-seq"), None); assert_eq!(parse_blob_id("blobs#not-a-uuid"), None); - assert_eq!( - parse_node_artifact_key("artifacts#nodes#code#status.json"), - None - ); - } - - #[test] - fn asset_filename_with_slashes_parses_correctly() { - assert_eq!( - parse_node_artifact_key( - "runs/01JT56VE4Z5NZ814GZN2JZD65A/artifacts#nodes#build#visit-1#deep/nested/path/file.rs" - ), - Some(( - StageId::new("build", 1), - "deep/nested/path/file.rs".to_string() - )) - ); } } diff --git a/lib/crates/fabro-store/src/lib.rs b/lib/crates/fabro-store/src/lib.rs index 9be4541f4..ff63fe0d4 100644 --- a/lib/crates/fabro-store/src/lib.rs +++ b/lib/crates/fabro-store/src/lib.rs @@ -1,23 +1,19 @@ -use std::sync::Arc; - use chrono::{DateTime, Utc}; +mod artifact_store; mod error; mod keys; mod run_state; -mod runtime; mod slate; mod types; +pub use artifact_store::{ArtifactStore, NodeArtifact}; pub use error::{Result, StoreError}; pub use fabro_types::{RunBlobId, StageId}; pub use run_state::{NodeState, RunProjection}; -pub use runtime::RuntimeState; -pub use slate::{NodeArtifact, SlateRunStore, SlateStore}; +pub use slate::{Database, RunDatabase, Runs}; pub use types::{EventEnvelope, EventPayload, RunSummary}; -pub type StoreHandle = Arc; - #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct ListRunsQuery { pub start: Option>, diff --git a/lib/crates/fabro-store/src/runtime.rs b/lib/crates/fabro-store/src/runtime.rs deleted file mode 100644 index 5fe2e41df..000000000 --- a/lib/crates/fabro-store/src/runtime.rs +++ /dev/null @@ -1,112 +0,0 @@ -use std::path::{Path, PathBuf}; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeState { - root: PathBuf, -} - -impl RuntimeState { - #[must_use] - pub fn new(run_dir: impl AsRef) -> Self { - Self { - root: run_dir.as_ref().to_path_buf(), - } - } - - #[must_use] - pub fn runtime_dir(&self) -> PathBuf { - self.root.join("runtime") - } - - #[must_use] - pub fn interview_request_path(&self) -> PathBuf { - self.runtime_dir().join("interview_request.json") - } - - #[must_use] - pub fn interview_response_path(&self) -> PathBuf { - self.runtime_dir().join("interview_response.json") - } - - #[must_use] - pub fn interview_claim_path(&self) -> PathBuf { - self.runtime_dir().join("interview_request.claim") - } - - #[must_use] - pub fn blob_cache_dir(&self) -> PathBuf { - self.root.join("cache").join("artifacts").join("values") - } - - #[must_use] - pub fn artifact_values_dir(&self) -> PathBuf { - self.blob_cache_dir() - } - - #[must_use] - pub fn artifact_value_path(&self, artifact_id: &str) -> PathBuf { - self.blob_cache_dir().join(format!("{artifact_id}.json")) - } - - #[must_use] - pub fn artifacts_dir(&self) -> PathBuf { - self.root.join("cache").join("artifacts").join("files") - } - - #[must_use] - pub fn artifact_stage_dir(&self, node_slug: &str, attempt: u32) -> PathBuf { - self.artifacts_dir() - .join(node_slug) - .join(format!("retry_{attempt}")) - } -} - -#[cfg(test)] -mod tests { - use super::RuntimeState; - - #[test] - fn computes_runtime_and_cache_paths() { - let dir = tempfile::tempdir().unwrap(); - let state = RuntimeState::new(dir.path()); - - assert_eq!(state.runtime_dir(), dir.path().join("runtime")); - assert_eq!( - state.interview_request_path(), - dir.path().join("runtime").join("interview_request.json") - ); - assert_eq!( - state.interview_response_path(), - dir.path().join("runtime").join("interview_response.json") - ); - assert_eq!( - state.interview_claim_path(), - dir.path().join("runtime").join("interview_request.claim") - ); - assert_eq!( - state.blob_cache_dir(), - dir.path().join("cache").join("artifacts").join("values") - ); - assert_eq!( - state.artifact_value_path("response.plan"), - dir.path() - .join("cache") - .join("artifacts") - .join("values") - .join("response.plan.json") - ); - assert_eq!( - state.artifacts_dir(), - dir.path().join("cache").join("artifacts").join("files") - ); - assert_eq!( - state.artifact_stage_dir("plan", 2), - dir.path() - .join("cache") - .join("artifacts") - .join("files") - .join("plan") - .join("retry_2") - ); - } -} diff --git a/lib/crates/fabro-store/src/slate/catalog.rs b/lib/crates/fabro-store/src/slate/catalog.rs index e8f484400..91c97485d 100644 --- a/lib/crates/fabro-store/src/slate/catalog.rs +++ b/lib/crates/fabro-store/src/slate/catalog.rs @@ -5,30 +5,24 @@ use crate::keys; use crate::{ListRunsQuery, Result}; use fabro_types::RunId; -pub(crate) async fn write_catalog(db: &Db, run_id: &RunId) -> Result<()> { - db.put(keys::catalog_by_id_key(run_id), []).await?; - db.put(keys::catalog_by_start_key(run_id), []).await?; +pub(crate) async fn write_index(db: &Db, run_id: &RunId) -> Result<()> { + db.put(keys::runs_index_by_start_key(run_id), []).await?; Ok(()) } -pub(crate) async fn read_locator(db: &Db, run_id: &RunId) -> Result { - Ok(db.get(keys::catalog_by_id_key(run_id)).await?.is_some()) -} - -pub(crate) async fn delete_catalog(db: &Db, run_id: &RunId) -> Result<()> { - db.delete(keys::catalog_by_id_key(run_id)).await?; - db.delete(keys::catalog_by_start_key(run_id)).await?; +pub(crate) async fn delete_index(db: &Db, run_id: &RunId) -> Result<()> { + db.delete(keys::runs_index_by_start_key(run_id)).await?; Ok(()) } pub(crate) async fn list_run_ids(db: &Db, query: &ListRunsQuery) -> Result> { - let mut iter = db.scan_prefix(keys::catalog_by_start_prefix()).await?; + let mut iter = db.scan_prefix(keys::runs_index_by_start_prefix()).await?; let mut run_ids = Vec::new(); while let Some(entry) = iter.next().await? { let key = String::from_utf8(entry.key.to_vec()).map_err(|err| { crate::StoreError::Other(format!("stored key is not valid UTF-8: {err}")) })?; - let Some(run_id) = keys::parse_run_id_from_catalog_key(&key) else { + let Some(run_id) = keys::parse_run_id_from_index_key(&key) else { continue; }; let created_at = run_id.created_at(); diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index e15ef1e04..a1f12fb59 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -12,28 +12,28 @@ use tokio::sync::{Mutex, OnceCell}; use crate::keys; use crate::{ListRunsQuery, Result, RunSummary, StoreError}; use fabro_types::RunId; -use run_store::SlateRunStoreInner; -pub use run_store::{NodeArtifact, SlateRunStore}; +pub use run_store::RunDatabase; +use run_store::RunDatabaseInner; #[derive(Clone)] -pub struct SlateStore { +pub struct Database { object_store: Arc, base_prefix: String, flush_interval: Duration, db: Arc>, - active_runs: Arc>>>, + active_runs: Arc>>>, } -impl std::fmt::Debug for SlateStore { +impl std::fmt::Debug for Database { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SlateStore") + f.debug_struct("Database") .field("base_prefix", &self.base_prefix) .field("flush_interval", &self.flush_interval) .finish_non_exhaustive() } } -impl SlateStore { +impl Database { pub fn new( object_store: Arc, base_prefix: impl Into, @@ -68,55 +68,52 @@ impl SlateStore { Ok(db.clone()) } - async fn get_active_run(&self, run_id: &RunId) -> Option { + async fn get_active_run(&self, run_id: &RunId) -> Option { let active_runs = self.active_runs.lock().await; active_runs .get(run_id) .cloned() - .map(SlateRunStore::from_inner) + .map(RunDatabase::from_inner) } - async fn cache_active_run(&self, run_store: &SlateRunStore) { + async fn cache_active_run(&self, run_store: &RunDatabase) { self.active_runs .lock() .await .insert(run_store.run_id(), run_store.inner_arc()); } - async fn remove_active_run(&self, run_id: &RunId) -> Option { + async fn remove_active_run(&self, run_id: &RunId) -> Option { self.active_runs .lock() .await .remove(run_id) - .map(SlateRunStore::from_inner) + .map(RunDatabase::from_inner) } - pub async fn create_run(&self, run_id: &RunId) -> Result { + pub async fn create_run(&self, run_id: &RunId) -> Result { let db = self.open_db().await?; - let locator_exists = catalog::read_locator(&db, run_id).await?; + let run_exists = RunDatabase::has_any_events(&db, run_id).await?; if let Some(active) = self.get_active_run(run_id).await { - if locator_exists && !active.matches_run(run_id) { + if run_exists && !active.matches_run(run_id) { return Err(StoreError::RunAlreadyExists(run_id.to_string())); } - catalog::write_catalog(&db, run_id).await?; + catalog::write_index(&db, run_id).await?; return Ok(active); } - if locator_exists { + if run_exists { return Err(StoreError::RunAlreadyExists(run_id.to_string())); } - SlateRunStore::validate_init(&db, run_id).await?; - db.put(keys::init_key(run_id), serde_json::to_vec(run_id)?) - .await?; - catalog::write_catalog(&db, run_id).await?; - let run_store = SlateRunStore::open_writer(*run_id, db).await?; + catalog::write_index(&db, run_id).await?; + let run_store = RunDatabase::open_writer(*run_id, db).await?; self.cache_active_run(&run_store).await; Ok(run_store) } - pub async fn open_run(&self, run_id: &RunId) -> Result { + pub async fn open_run(&self, run_id: &RunId) -> Result { let db = self.open_db().await?; if let Some(active) = self.get_active_run(run_id).await { if !active.matches_run(run_id) { @@ -126,18 +123,15 @@ impl SlateStore { } return Ok(active); } - if !catalog::read_locator(&db, run_id).await? { + if !RunDatabase::has_any_events(&db, run_id).await? { return Err(StoreError::RunNotFound(run_id.to_string())); } - if !SlateRunStore::validate_init(&db, run_id).await? { - return Err(StoreError::RunNotFound(run_id.to_string())); - } - let run_store = SlateRunStore::open_writer(*run_id, db).await?; + let run_store = RunDatabase::open_writer(*run_id, db).await?; self.cache_active_run(&run_store).await; Ok(run_store) } - pub async fn open_run_reader(&self, run_id: &RunId) -> Result { + pub async fn open_run_reader(&self, run_id: &RunId) -> Result { let db = self.open_db().await?; if let Some(active) = self.get_active_run(run_id).await { if !active.matches_run(run_id) { @@ -147,13 +141,10 @@ impl SlateStore { } return Ok(active.read_only_clone()); } - if !catalog::read_locator(&db, run_id).await? { + if !RunDatabase::has_any_events(&db, run_id).await? { return Err(StoreError::RunNotFound(run_id.to_string())); } - if !SlateRunStore::validate_init(&db, run_id).await? { - return Err(StoreError::RunNotFound(run_id.to_string())); - } - SlateRunStore::open_reader(*run_id, db).await + RunDatabase::open_reader(*run_id, db).await } pub async fn list_runs(&self, query: &ListRunsQuery) -> Result> { @@ -165,10 +156,10 @@ impl SlateStore { summaries.push(active.state().await?.build_summary(&run_id)); continue; } - if !SlateRunStore::validate_init(&db, &run_id).await? { + if !RunDatabase::has_any_events(&db, &run_id).await? { continue; } - summaries.push(SlateRunStore::build_summary(&db, &run_id).await?); + summaries.push(RunDatabase::build_summary(&db, &run_id).await?); } summaries.sort_by(|a, b| b.run_id.created_at().cmp(&a.run_id.created_at())); Ok(summaries) @@ -181,20 +172,49 @@ impl SlateStore { } let db = self.open_db().await?; - let prefix = keys::run_prefix(run_id); - let mut iter = db.scan_prefix(prefix.as_bytes()).await?; let mut keys_to_delete = Vec::new(); - while let Some(entry) = iter.next().await? { - keys_to_delete.push(String::from_utf8(entry.key.to_vec()).map_err(|err| { - StoreError::Other(format!("stored key is not valid UTF-8: {err}")) - })?); + for prefix in [keys::run_data_prefix(run_id), keys::blobs_prefix(run_id)] { + let mut iter = db.scan_prefix(prefix.as_bytes()).await?; + while let Some(entry) = iter.next().await? { + keys_to_delete.push(String::from_utf8(entry.key.to_vec()).map_err(|err| { + StoreError::Other(format!("stored key is not valid UTF-8: {err}")) + })?); + } } for key in keys_to_delete { db.delete(key).await?; } - catalog::delete_catalog(&db, run_id).await?; + catalog::delete_index(&db, run_id).await?; Ok(()) } + + #[must_use] + pub fn runs(&self) -> Runs { + Runs { db: self.clone() } + } +} + +#[derive(Clone, Debug)] +pub struct Runs { + db: Database, +} + +impl Runs { + pub async fn get(&self, run_id: &RunId) -> Result { + self.db.open_run(run_id).await + } + + pub async fn find(&self, run_id: &RunId) -> Result> { + match self.db.open_run_reader(run_id).await { + Ok(run_db) => Ok(Some(run_db.state().await?.build_summary(run_id))), + Err(StoreError::RunNotFound(_)) => Ok(None), + Err(err) => Err(err), + } + } + + pub async fn list(&self, query: &ListRunsQuery) -> Result> { + self.db.list_runs(query).await + } } pub(crate) fn normalize_base_prefix(prefix: String) -> String { @@ -244,9 +264,9 @@ mod tests { RunId::from(ulid::Ulid::from_parts(timestamp_ms, random)) } - fn make_store() -> (Arc, SlateStore) { + fn make_store() -> (Arc, Database) { let object_store: Arc = Arc::new(InMemory::new()); - let store = SlateStore::new(object_store.clone(), "runs/", Duration::from_millis(1)); + let store = Database::new(object_store.clone(), "runs/", Duration::from_millis(1)); (object_store, store) } @@ -288,7 +308,7 @@ mod tests { .unwrap() } - async fn append_created(run: &SlateRunStore, label: &str, created_at: DateTime) { + async fn append_created(run: &RunDatabase, label: &str, created_at: DateTime) { let run_record = sample_run_record(label); run.append_event(&event_payload( label, @@ -309,7 +329,7 @@ mod tests { .unwrap(); } - async fn append_completed(run: &SlateRunStore, label: &str, created_at: DateTime) { + async fn append_completed(run: &RunDatabase, label: &str, created_at: DateTime) { append_created(run, label, created_at).await; run.append_event(&event_payload( label, @@ -422,7 +442,7 @@ mod tests { let run = store.create_run(&test_run_id("run-1")).await.unwrap(); append_completed(&run, "run-1", dt("2026-03-27T12:00:00Z")).await; - let reopened = SlateStore::new(object_store, "runs", Duration::from_millis(1)); + let reopened = Database::new(object_store, "runs", Duration::from_millis(1)); let summary = reopened.list_runs(&ListRunsQuery::default()).await.unwrap(); assert_eq!(summary.len(), 1); assert_eq!(summary[0].run_id, test_run_id("run-1")); diff --git a/lib/crates/fabro-store/src/slate/run_store.rs b/lib/crates/fabro-store/src/slate/run_store.rs index 1a2f614e8..2381c8f75 100644 --- a/lib/crates/fabro-store/src/slate/run_store.rs +++ b/lib/crates/fabro-store/src/slate/run_store.rs @@ -5,40 +5,33 @@ use std::sync::atomic::{AtomicU32, Ordering}; use bytes::Bytes; use chrono::Utc; use futures::Stream; -use serde::de::DeserializeOwned; use slatedb::{Db, DbRead}; use tokio::sync::{Mutex, broadcast, mpsc}; use tokio_stream::wrappers::UnboundedReceiverStream; use crate::keys; use crate::run_state::EventProjectionCache; -use crate::{EventEnvelope, EventPayload, Result, RunProjection, RunSummary, StageId, StoreError}; +use crate::{EventEnvelope, EventPayload, Result, RunProjection, RunSummary, StoreError}; use fabro_types::{RunBlobId, RunId}; const DEFAULT_EVENT_TAIL_LIMIT: usize = 1024; -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct NodeArtifact { - pub node: StageId, - pub filename: String, -} - #[derive(Clone)] -pub struct SlateRunStore { - inner: Arc, +pub struct RunDatabase { + inner: Arc, read_only: bool, } -impl std::fmt::Debug for SlateRunStore { +impl std::fmt::Debug for RunDatabase { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SlateRunStore") + f.debug_struct("RunDatabase") .field("run_id", &self.inner.run_id) .field("read_only", &self.read_only) .finish_non_exhaustive() } } -pub(crate) struct SlateRunStoreInner { +pub(crate) struct RunDatabaseInner { run_id: RunId, db: Db, event_seq: AtomicU32, @@ -50,13 +43,17 @@ pub(crate) struct SlateRunStoreInner { event_tx: broadcast::Sender, } -impl SlateRunStore { +impl RunDatabase { pub(crate) async fn open_writer(run_id: RunId, db: Db) -> Result { - let event_seq = - recover_next_seq(&db, &keys::events_prefix(&run_id), keys::parse_event_seq).await?; + let event_seq = recover_next_seq( + &db, + &keys::run_events_prefix(&run_id), + keys::parse_event_seq, + ) + .await?; let (event_tx, _) = broadcast::channel(DEFAULT_EVENT_TAIL_LIMIT.max(16)); Ok(Self { - inner: Arc::new(SlateRunStoreInner { + inner: Arc::new(RunDatabaseInner { run_id, db, event_seq: AtomicU32::new(event_seq), @@ -72,11 +69,15 @@ impl SlateRunStore { } pub(crate) async fn open_reader(run_id: RunId, db: Db) -> Result { - let event_seq = - recover_next_seq(&db, &keys::events_prefix(&run_id), keys::parse_event_seq).await?; + let event_seq = recover_next_seq( + &db, + &keys::run_events_prefix(&run_id), + keys::parse_event_seq, + ) + .await?; let (event_tx, _) = broadcast::channel(DEFAULT_EVENT_TAIL_LIMIT.max(16)); Ok(Self { - inner: Arc::new(SlateRunStoreInner { + inner: Arc::new(RunDatabaseInner { run_id, db, event_seq: AtomicU32::new(event_seq), @@ -91,7 +92,7 @@ impl SlateRunStore { }) } - pub(crate) fn from_inner(inner: Arc) -> Self { + pub(crate) fn from_inner(inner: Arc) -> Self { Self { inner, read_only: false, @@ -105,7 +106,7 @@ impl SlateRunStore { } } - pub(crate) fn inner_arc(&self) -> Arc { + pub(crate) fn inner_arc(&self) -> Arc { Arc::clone(&self.inner) } @@ -122,17 +123,14 @@ impl SlateRunStore { Ok(()) } - pub(crate) async fn validate_init(db: &R, run_id: &RunId) -> Result + pub(crate) async fn has_any_events(db: &R, run_id: &RunId) -> Result where R: DbRead + Sync, { - match get_json::(db, &keys::init_key(run_id)).await? { - Some(existing) if existing == *run_id => Ok(true), - Some(existing) => Err(StoreError::Other(format!( - "existing init record {existing:?} does not match requested run_id {run_id:?}" - ))), - None => Ok(false), - } + let mut iter = db + .scan_prefix(keys::run_events_prefix(run_id).as_bytes()) + .await?; + Ok(iter.next().await?.is_some()) } pub(crate) async fn build_summary(db: &R, run_id: &RunId) -> Result @@ -193,7 +191,7 @@ impl SlateRunStore { } } -impl SlateRunStore { +impl RunDatabase { pub async fn append_event(&self, payload: &EventPayload) -> Result { if self.read_only { return Err(StoreError::ReadOnly); @@ -208,7 +206,7 @@ impl SlateRunStore { self.inner .db .put( - keys::event_key(&self.inner.run_id, seq, Utc::now().timestamp_millis()), + keys::run_event_key(&self.inner.run_id, seq, Utc::now().timestamp_millis()), serde_json::to_vec(payload)?, ) .await?; @@ -272,7 +270,7 @@ impl SlateRunStore { if self.read_only { return Err(StoreError::ReadOnly); } - let id = RunBlobId::new(&self.inner.run_id, data); + let id = RunBlobId::new(data); self.inner .db .put(keys::blob_key(&self.inner.run_id, &id), data) @@ -292,53 +290,11 @@ impl SlateRunStore { list_blobs(&self.inner.db, &self.inner.run_id).await } - pub async fn put_artifact(&self, node: &StageId, filename: &str, data: &[u8]) -> Result<()> { - if self.read_only { - return Err(StoreError::ReadOnly); - } - self.inner - .db - .put( - keys::node_artifact(&self.inner.run_id, node, filename), - data, - ) - .await?; - Ok(()) - } - - pub async fn get_artifact(&self, node: &StageId, filename: &str) -> Result> { - Ok(self - .inner - .db - .get(keys::node_artifact(&self.inner.run_id, node, filename)) - .await?) - } - - pub async fn list_all_artifacts(&self) -> Result> { - list_all_artifacts(&self.inner.db, &self.inner.run_id).await - } - - pub async fn list_artifacts_for_stage(&self, stage_id: &StageId) -> Result> { - list_artifacts_for_stage(&self.inner.db, &self.inner.run_id, stage_id).await - } - pub async fn state(&self) -> Result { self.projected_state().await } } -async fn get_json(db: &R, key: &str) -> Result> -where - R: DbRead + Sync, - T: DeserializeOwned, -{ - db.get(key) - .await? - .map(|value| serde_json::from_slice(&value)) - .transpose() - .map_err(Into::into) -} - async fn recover_next_seq(db: &R, prefix: &str, parse: fn(&str) -> Option) -> Result where R: DbRead + Sync, @@ -359,7 +315,7 @@ where R: DbRead + Sync, { let mut iter = db - .scan_prefix(keys::events_prefix(run_id).as_bytes()) + .scan_prefix(keys::run_events_prefix(run_id).as_bytes()) .await?; let mut events = Vec::new(); while let Some(entry) = iter.next().await? { @@ -412,47 +368,6 @@ where Ok(blob_ids) } -async fn list_all_artifacts(db: &R, run_id: &RunId) -> Result> -where - R: DbRead + Sync, -{ - let mut iter = db.scan_prefix(keys::run_prefix(run_id).as_bytes()).await?; - let mut assets = Vec::new(); - while let Some(entry) = iter.next().await? { - let key = key_to_string(&entry.key)?; - let Some((node, filename)) = keys::parse_node_artifact_key(&key) else { - continue; - }; - assets.push(NodeArtifact { node, filename }); - } - assets.sort(); - Ok(assets) -} - -async fn list_artifacts_for_stage( - db: &R, - run_id: &RunId, - stage_id: &StageId, -) -> Result> -where - R: DbRead + Sync, -{ - let prefix = keys::node_artifact_prefix(run_id, stage_id); - let mut iter = db.scan_prefix(prefix.as_bytes()).await?; - let mut filenames = Vec::new(); - while let Some(entry) = iter.next().await? { - let key = key_to_string(&entry.key)?; - let Some((node, filename)) = keys::parse_node_artifact_key(&key) else { - continue; - }; - if &node == stage_id { - filenames.push(filename); - } - } - filenames.sort(); - Ok(filenames) -} - fn key_to_string(key: &Bytes) -> Result { String::from_utf8(key.to_vec()) .map_err(|err| StoreError::Other(format!("stored key is not valid UTF-8: {err}"))) diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs index e2c83ea84..1e2042351 100644 --- a/lib/crates/fabro-test/src/lib.rs +++ b/lib/crates/fabro-test/src/lib.rs @@ -29,10 +29,10 @@ static INSTA_FILTERS: &[(&str, &str)] = &[ (r"\b[0-9A-HJKMNP-TV-Z]{26}\b", "[ULID]"), (r"in \d+(\.\d+)?(ms|s)", "in [TIME]"), ( - r"\[STORAGE_DIR\]/runs/\d{8}-dry-run-\[ULID\]", + r"\[STORAGE_DIR\]/scratch/\d{8}-dry-run-\[ULID\]", "[DRY_RUN_DIR]", ), - (r"\[STORAGE_DIR\]/runs/\d{8}-\[ULID\]", "[RUN_DIR]"), + (r"\[STORAGE_DIR\]/scratch/\d{8}-\[ULID\]", "[RUN_DIR]"), ( r"Duration:\s+\d+\s+(seconds?|minutes?|hours?)", "Duration: [DURATION]", @@ -706,9 +706,9 @@ impl TestContext { /// Find a run directory whose name ends with `run_id_suffix`. pub fn find_run_dir(&self, run_id_suffix: &str) -> PathBuf { - let runs_dir = self.storage_dir.join("runs"); - std::fs::read_dir(&runs_dir) - .expect("runs directory should exist") + let scratch_dir = self.storage_dir.join("scratch"); + std::fs::read_dir(&scratch_dir) + .expect("scratch directory should exist") .flatten() .map(|entry| entry.path()) .find(|path| { @@ -720,16 +720,16 @@ impl TestContext { .unwrap_or_else(|| { panic!( "expected run directory for {run_id_suffix} under {}", - runs_dir.display() + scratch_dir.display() ) }) } /// Return the only run directory currently present under storage. pub fn single_run_dir(&self) -> PathBuf { - let runs_dir = self.storage_dir.join("runs"); - let entries: Vec<_> = std::fs::read_dir(&runs_dir) - .expect("runs directory should exist") + let scratch_dir = self.storage_dir.join("scratch"); + let entries: Vec<_> = std::fs::read_dir(&scratch_dir) + .expect("scratch directory should exist") .flatten() .map(|entry| entry.path()) .filter(|path| path.is_dir()) @@ -755,7 +755,7 @@ impl TestContext { 1, "expected exactly one run directory for fabro_test_case={} under {}", self.test_case_id(), - runs_dir.display() + scratch_dir.display() ); entries.into_iter().next().unwrap() } diff --git a/lib/crates/fabro-types/Cargo.toml b/lib/crates/fabro-types/Cargo.toml index af2baa954..182737508 100644 --- a/lib/crates/fabro-types/Cargo.toml +++ b/lib/crates/fabro-types/Cargo.toml @@ -21,8 +21,8 @@ chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, optional = true } dirs.workspace = true fabro-macros = { path = "../fabro-macros" } +hex.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true ulid.workspace = true -uuid.workspace = true diff --git a/lib/crates/fabro-types/src/run_blob_id.rs b/lib/crates/fabro-types/src/run_blob_id.rs index 732110b99..b7c43d708 100644 --- a/lib/crates/fabro-types/src/run_blob_id.rs +++ b/lib/crates/fabro-types/src/run_blob_id.rs @@ -1,44 +1,36 @@ use std::fmt; use std::str::FromStr; +use hex::FromHexError; use serde::de::Error as _; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use sha2::{Digest, Sha256}; -use ulid::Ulid; -use uuid::Uuid; - -use crate::RunId; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct RunBlobId(Uuid); +pub struct RunBlobId([u8; 32]); impl RunBlobId { - pub fn new(run_id: &RunId, content: &[u8]) -> Self { - let ulid: Ulid = (*run_id).into(); - let ulid_bytes = ulid.to_bytes(); + pub fn new(content: &[u8]) -> Self { let hash = Sha256::digest(content); - let mut buf = [0_u8; 16]; - buf[..8].copy_from_slice(&ulid_bytes[..8]); - buf[8..].copy_from_slice(&hash[..8]); - Self(Uuid::new_v8(buf)) - } - - pub fn uuid(&self) -> &Uuid { - &self.0 + let mut bytes = [0_u8; 32]; + bytes.copy_from_slice(&hash); + Self(bytes) } } impl fmt::Display for RunBlobId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.hyphenated().fmt(f) + f.write_str(&hex::encode(self.0)) } } impl FromStr for RunBlobId { - type Err = uuid::Error; + type Err = FromHexError; fn from_str(s: &str) -> Result { - Ok(Self(Uuid::parse_str(s)?)) + let mut bytes = [0_u8; 32]; + hex::decode_to_slice(s, &mut bytes)?; + Ok(Self(bytes)) } } @@ -63,46 +55,44 @@ impl<'de> Deserialize<'de> for RunBlobId { #[cfg(test)] mod tests { - use crate::{RunBlobId, RunId}; + use crate::RunBlobId; #[test] - fn same_content_and_run_id_produce_same_blob_id() { - let run_id = RunId::new(); - assert_eq!( - RunBlobId::new(&run_id, b"hello"), - RunBlobId::new(&run_id, b"hello") - ); + fn same_content_produces_same_blob_id() { + assert_eq!(RunBlobId::new(b"hello"), RunBlobId::new(b"hello")); } #[test] - fn different_run_ids_produce_different_blob_ids() { - assert_ne!( - RunBlobId::new(&RunId::new(), b"hello"), - RunBlobId::new(&RunId::new(), b"hello") + fn display_is_lowercase_sha256_hex() { + assert_eq!( + RunBlobId::new(b"hello").to_string(), + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" ); } #[test] fn different_content_produces_different_blob_ids() { - let run_id = RunId::new(); - assert_ne!( - RunBlobId::new(&run_id, b"hello"), - RunBlobId::new(&run_id, b"world") - ); + assert_ne!(RunBlobId::new(b"hello"), RunBlobId::new(b"world")); } #[test] fn display_and_parse_round_trip() { - let blob_id = RunBlobId::new(&RunId::new(), b"hello"); + let blob_id = RunBlobId::new(b"hello"); let parsed: RunBlobId = blob_id.to_string().parse().unwrap(); assert_eq!(parsed, blob_id); } #[test] fn serde_round_trip() { - let blob_id = RunBlobId::new(&RunId::new(), b"hello"); + let blob_id = RunBlobId::new(b"hello"); let value = serde_json::to_value(blob_id).unwrap(); let parsed: RunBlobId = serde_json::from_value(value).unwrap(); assert_eq!(parsed, blob_id); } + + #[test] + fn parse_rejects_non_hex_blob_ids() { + let parsed = "not-a-blob-id".parse::(); + assert!(parsed.is_err()); + } } diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 25a79738d..f31d0348b 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -182,9 +182,10 @@ impl Settings { pub fn storage_dir(&self) -> PathBuf { self.storage_dir.clone().unwrap_or_else(|| { - dirs::home_dir() - .expect("could not determine home directory") - .join(".fabro") + std::env::var_os("FABRO_HOME") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".fabro"))) + .unwrap_or_else(|| PathBuf::from(".fabro")) }) } } diff --git a/lib/crates/fabro-workflow/src/artifact.rs b/lib/crates/fabro-workflow/src/artifact.rs index a55a1cfa5..938dfff14 100644 --- a/lib/crates/fabro-workflow/src/artifact.rs +++ b/lib/crates/fabro-workflow/src/artifact.rs @@ -4,7 +4,7 @@ use std::path::Path; use serde_json::Value; use fabro_agent::Sandbox; -use fabro_store::SlateRunStore; +use fabro_store::RunDatabase; use crate::error::{FabroError, Result}; @@ -26,7 +26,7 @@ const ARTIFACT_POINTER_PREFIX: &str = "file://"; /// Returns an error if blob persistence or cache materialization fails. pub async fn offload_large_values( updates: &mut HashMap, - run_store: &SlateRunStore, + run_store: &RunDatabase, cache_dir: &Path, ) -> Result<()> { std::fs::create_dir_all(cache_dir)?; @@ -133,7 +133,7 @@ mod tests { use std::time::Duration; use super::*; - use fabro_store::SlateStore; + use fabro_store::Database; use object_store::memory::InMemory; use ulid::Ulid; @@ -143,9 +143,9 @@ mod tests { fabro_types::RunId::from(Ulid(u128::from(hasher.finish()))) } - async fn make_run_store(label: &str) -> fabro_store::SlateRunStore { + async fn make_run_store(label: &str) -> fabro_store::RunDatabase { let object_store = Arc::new(InMemory::new()); - let store = SlateStore::new(object_store, "runs/", Duration::from_millis(1)); + let store = Database::new(object_store, "runs/", Duration::from_millis(1)); store.create_run(&test_run_id(label)).await.unwrap() } @@ -156,8 +156,7 @@ mod tests { let large_string = "x".repeat(BLOB_OFFLOAD_THRESHOLD + 1); let serialized = serde_json::to_vec(&serde_json::json!(large_string.clone())).unwrap(); - let expected_blob_id = - fabro_types::RunBlobId::new(&test_run_id("artifact-offload"), &serialized); + let expected_blob_id = fabro_types::RunBlobId::new(&serialized); let mut updates = HashMap::new(); updates.insert("response.plan".to_string(), serde_json::json!(large_string)); diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 2828c71dd..9972d972f 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -5,7 +5,7 @@ use ::fabro_types::run_event as fabro_types; use ::fabro_types::{RunEvent, RunId, StageStatus, StatusReason}; use anyhow::{Context, Result}; use chrono::Utc; -use fabro_store::{EventPayload, SlateRunStore}; +use fabro_store::{EventPayload, RunDatabase}; use fabro_util::json::normalize_json_value; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -2301,7 +2301,7 @@ pub fn event_payload_from_redacted_json(line: &str, run_id: &RunId) -> Result Result<()> { +pub async fn append_event(run_store: &RunDatabase, run_id: &RunId, event: &Event) -> Result<()> { let stored = to_run_event(run_id, event); let payload = build_redacted_event_payload(&stored, run_id)?; run_store @@ -2323,7 +2323,7 @@ pub struct StoreProgressLogger { impl StoreProgressLogger { #[must_use] - pub fn new(run_store: SlateRunStore) -> Self { + pub fn new(run_store: RunDatabase) -> Self { let (tx, mut rx) = mpsc::unbounded_channel(); tokio::spawn(async move { @@ -2697,7 +2697,7 @@ mod tests { #[tokio::test] async fn append_event_writes_store_event_shape() { - let store = fabro_store::SlateStore::new( + let store = fabro_store::Database::new( std::sync::Arc::new(object_store::memory::InMemory::new()), "", std::time::Duration::from_millis(1), diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index e14d1c8ca..60c8efeb4 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -310,7 +310,7 @@ pub fn sanitize_ref_component(s: &str) -> String { mod tests { use super::*; use crate::run_dump::RunDump; - use fabro_store::SlateStore; + use fabro_store::Database; use fabro_types::fixtures; use object_store::memory::InMemory; use std::fs; @@ -340,8 +340,8 @@ mod tests { .unwrap(); } - fn test_store() -> Arc { - Arc::new(SlateStore::new( + fn test_store() -> Arc { + Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs index 4a01b78ed..442abb83c 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/crates/fabro-workflow/src/handler/agent.rs @@ -395,7 +395,7 @@ mod tests { use super::*; use crate::event::Emitter; use fabro_graphviz::graph::AttrValue; - use fabro_store::{SlateRunStore, SlateStore, StageId}; + use fabro_store::{Database, RunDatabase, StageId}; use fabro_types::fixtures; use object_store::memory::InMemory; use std::sync::Arc; @@ -406,8 +406,8 @@ mod tests { EngineServices::test_default() } - fn test_store() -> Arc { - Arc::new(SlateStore::new( + fn test_store() -> Arc { + Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), @@ -416,7 +416,7 @@ mod tests { async fn make_services_with_run_store() -> ( EngineServices, - SlateRunStore, + RunDatabase, crate::event::StoreProgressLogger, ) { let store = test_store(); diff --git a/lib/crates/fabro-workflow/src/handler/command.rs b/lib/crates/fabro-workflow/src/handler/command.rs index b03438daa..74bc3066d 100644 --- a/lib/crates/fabro-workflow/src/handler/command.rs +++ b/lib/crates/fabro-workflow/src/handler/command.rs @@ -169,7 +169,7 @@ mod tests { use super::*; use crate::outcome::StageStatus; use fabro_graphviz::graph::AttrValue; - use fabro_store::{SlateRunStore, SlateStore, StageId}; + use fabro_store::{Database, RunDatabase, StageId}; use fabro_types::fixtures; use object_store::memory::InMemory; use std::sync::Arc; @@ -179,8 +179,8 @@ mod tests { EngineServices::test_default() } - fn test_store() -> Arc { - Arc::new(SlateStore::new( + fn test_store() -> Arc { + Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), @@ -189,7 +189,7 @@ mod tests { async fn make_services_with_run_store() -> ( EngineServices, - SlateRunStore, + RunDatabase, crate::event::StoreProgressLogger, ) { let store = test_store(); diff --git a/lib/crates/fabro-workflow/src/handler/manager_loop.rs b/lib/crates/fabro-workflow/src/handler/manager_loop.rs index 8df8cab83..8538d3fcd 100644 --- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs +++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs @@ -16,7 +16,7 @@ use crate::run_dir::visit_from_context; use crate::run_options::RunOptions; use async_trait::async_trait; use fabro_graphviz::graph::{AttrValue, Graph, Node}; -use fabro_store::SlateStore; +use fabro_store::Database; use fabro_types::Settings; use object_store::memory::InMemory; use tokio::time::{sleep, timeout}; @@ -233,7 +233,7 @@ impl Handler for SubWorkflowHandler { let env = services.env.clone(); let dry_run = services.dry_run; let workflow_bundle = services.workflow_bundle.clone(); - let store = Arc::new(SlateStore::new( + let store = Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), diff --git a/lib/crates/fabro-workflow/src/handler/mod.rs b/lib/crates/fabro-workflow/src/handler/mod.rs index 15661a928..2f21df16a 100644 --- a/lib/crates/fabro-workflow/src/handler/mod.rs +++ b/lib/crates/fabro-workflow/src/handler/mod.rs @@ -20,9 +20,9 @@ use std::time::Duration; use async_trait::async_trait; use fabro_agent::Sandbox; -use fabro_store::SlateRunStore; #[cfg(test)] -use fabro_store::SlateStore; +use fabro_store::Database; +use fabro_store::RunDatabase; #[cfg(test)] use object_store::memory::InMemory; @@ -41,7 +41,7 @@ pub struct EngineServices { pub registry: Arc, pub emitter: Arc, pub sandbox: Arc, - pub run_store: SlateRunStore, + pub run_store: RunDatabase, /// Git state for the current run. Set via `set_git_state` at the start of /// `run_via_core` and read by parallel/fan-in handlers. pub(crate) git_state: std::sync::RwLock>>, @@ -80,7 +80,7 @@ impl EngineServices { /// Test-only default: empty registry, no hooks, local sandbox at cwd. #[cfg(test)] pub fn test_default() -> Self { - let store = Arc::new(SlateStore::new( + let store = Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index 67b474391..92341ea9b 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -584,7 +584,7 @@ fn find_join_node(results: &[BranchResult], graph: &Graph) -> Option { mod tests { use super::*; use fabro_graphviz::graph::{AttrValue, Edge}; - use fabro_store::{SlateStore, StageId}; + use fabro_store::{Database, StageId}; use fabro_types::fixtures; use object_store::memory::InMemory; use std::sync::Arc; @@ -594,8 +594,8 @@ mod tests { EngineServices::test_default() } - fn test_store() -> Arc { - Arc::new(SlateStore::new( + fn test_store() -> Arc { + Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), diff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs index c8af00352..d6682da4d 100644 --- a/lib/crates/fabro-workflow/src/handler/prompt.rs +++ b/lib/crates/fabro-workflow/src/handler/prompt.rs @@ -175,7 +175,7 @@ impl Handler for PromptHandler { mod tests { use super::*; use fabro_graphviz::graph::AttrValue; - use fabro_store::{SlateRunStore, SlateStore, StageId}; + use fabro_store::{Database, RunDatabase, StageId}; use fabro_types::fixtures; use object_store::memory::InMemory; use std::sync::Arc; @@ -186,8 +186,8 @@ mod tests { EngineServices::test_default() } - fn test_store() -> Arc { - Arc::new(SlateStore::new( + fn test_store() -> Arc { + Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), @@ -196,7 +196,7 @@ mod tests { async fn make_services_with_run_store() -> ( EngineServices, - SlateRunStore, + RunDatabase, crate::event::StoreProgressLogger, ) { let store = test_store(); diff --git a/lib/crates/fabro-workflow/src/lifecycle/artifact.rs b/lib/crates/fabro-workflow/src/lifecycle/artifact.rs index 4661a5a4e..df55cda08 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/artifact.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/artifact.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use async_trait::async_trait; -use fabro_store::SlateRunStore; +use fabro_store::RunDatabase; use fabro_core::graph::NodeSpec; use fabro_core::lifecycle::{AttemptContext, AttemptResultContext, RunLifecycle}; @@ -26,7 +26,7 @@ type WfNodeDecision = NodeDecision>; /// Sub-lifecycle responsible for artifact collection, offloading, and syncing. pub(crate) struct ArtifactLifecycle { pub sandbox: Arc, - pub run_store: SlateRunStore, + pub run_store: RunDatabase, pub blob_cache_dir: PathBuf, pub emitter: Arc, pub artifacts_dir: PathBuf, @@ -40,7 +40,7 @@ impl ArtifactLifecycle { #[allow(clippy::too_many_arguments)] pub(crate) fn new( sandbox: Arc, - run_store: SlateRunStore, + run_store: RunDatabase, blob_cache_dir: PathBuf, emitter: Arc, artifacts_dir: PathBuf, diff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs index a92d6ea84..aecdbe28c 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/git.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use async_trait::async_trait; -use fabro_store::SlateRunStore; +use fabro_store::RunDatabase; use fabro_types::RunId; use tokio::fs; @@ -66,7 +66,7 @@ pub(crate) struct GitLifecycle { pub emitter: Arc, pub run_dir: PathBuf, pub run_id: RunId, - pub run_store: SlateRunStore, + pub run_store: RunDatabase, pub run_options: Arc, pub start_node_id: Option, // Cross-lifecycle data (shared with EventLifecycle) diff --git a/lib/crates/fabro-workflow/src/lifecycle/mod.rs b/lib/crates/fabro-workflow/src/lifecycle/mod.rs index bead23a7d..c9e9209fb 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/mod.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/mod.rs @@ -13,8 +13,8 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; use async_trait::async_trait; -use fabro_store::RuntimeState; -use fabro_store::SlateRunStore; +use fabro_config::RunScratch; +use fabro_store::RunDatabase; use fabro_types::RunId; use fabro_core::error::Result as CoreResult; @@ -81,12 +81,12 @@ impl WorkflowLifecycle { sandbox: &Arc, graph: Arc, run_dir: &PathBuf, - run_store: &SlateRunStore, + run_store: &RunDatabase, run_options: &Arc, is_resume: bool, on_node: crate::OnNodeCallback, ) -> Self { - let runtime_state = RuntimeState::new(run_dir); + let run_scratch = RunScratch::new(run_dir); let restarted_from: Arc>> = Arc::new(Mutex::new(None)); let loop_restart_signature_limit = graph.loop_restart_signature_limit(); let checkpoint_git_result: Arc>> = @@ -155,9 +155,9 @@ impl WorkflowLifecycle { let artifact = ArtifactLifecycle::new( Arc::clone(sandbox), run_store.clone(), - runtime_state.blob_cache_dir(), + run_scratch.blob_cache_dir(), Arc::clone(emitter), - runtime_state.artifacts_dir(), + run_scratch.artifact_files_dir(), run_options.artifact_globs().to_vec(), captured_artifact_count, ); diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 2d7da126c..6744a807d 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -1,8 +1,9 @@ use chrono::Local; +use fabro_config::Storage; use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_model::{Catalog, Provider}; use fabro_sandbox::SandboxProvider; -use fabro_store::SlateStore; +use fabro_store::Database; use fabro_types::{RunId, Settings}; use std::collections::BTreeMap; use std::collections::HashMap; @@ -14,7 +15,7 @@ use crate::file_resolver::FileResolver; use crate::pipeline::types::PersistOptions; use crate::pipeline::{self, Persisted, TransformOptions, Validated}; use crate::records::RunRecord; -use crate::run_lookup::default_runs_base; +use crate::run_lookup::default_scratch_base; use crate::transforms::{Transform, expand_vars}; use crate::workflow_bundle::{StoredWorkflowBundle, WorkflowBundle}; use fabro_sandbox::daytona::detect_repo_info; @@ -58,7 +59,7 @@ struct PersistCreateOptions { } /// Resolve workflow inputs, normalize settings, and persist a run directory. -pub async fn create(store: &SlateStore, request: CreateRunInput) -> Result { +pub async fn create(store: &Database, request: CreateRunInput) -> Result { let resolved = resolve_workflow(ResolveWorkflowInput { workflow: request.workflow, settings: request.settings, @@ -85,8 +86,8 @@ pub async fn create(store: &SlateStore, request: CreateRunInput) -> Result Result, @@ -388,12 +389,12 @@ pub(crate) fn resolve_run_settings(mut settings: Settings, graph: &Graph) -> Set } pub(crate) fn default_run_dir(run_id: &RunId) -> PathBuf { - make_run_dir(&default_runs_base(), run_id) + make_run_dir(&default_scratch_base(), run_id) } -pub fn make_run_dir(runs_base: &Path, run_id: &RunId) -> PathBuf { +pub fn make_run_dir(scratch_base: &Path, run_id: &RunId) -> PathBuf { let local_dt = run_id.created_at().with_timezone(&Local); - runs_base.join(format!("{}-{run_id}", local_dt.format("%Y%m%d"))) + scratch_base.join(format!("{}-{run_id}", local_dt.format("%Y%m%d"))) } #[cfg(test)] @@ -401,7 +402,7 @@ mod tests { use super::*; use chrono::{TimeZone, Utc}; use fabro_graphviz::graph::AttrValue; - use fabro_store::{SlateStore, StoreHandle}; + use fabro_store::Database; use fabro_types::fixtures; use object_store::local::LocalFileSystem; use object_store::memory::InMemory; @@ -410,8 +411,8 @@ mod tests { use crate::operations::{ValidateInput, validate}; use crate::workflow_bundle::BundledWorkflow; - fn memory_store() -> StoreHandle { - Arc::new(SlateStore::new( + fn memory_store() -> Arc { + Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), @@ -470,7 +471,7 @@ mod tests { #[test] fn make_run_dir_uses_run_id_timestamp_in_local_time() { - let runs_base = Path::new("/tmp/runs"); + let scratch_base = Path::new("/tmp/scratch"); let run_id = RunId::from(ulid::Ulid::from_datetime( Utc.with_ymd_and_hms(2026, 3, 27, 12, 0, 0).unwrap().into(), )); @@ -481,8 +482,8 @@ mod tests { .to_string(); assert_eq!( - make_run_dir(runs_base, &run_id), - runs_base.join(format!("{expected_date}-{run_id}")) + make_run_dir(scratch_base, &run_id), + scratch_base.join(format!("{expected_date}-{run_id}")) ); } @@ -874,11 +875,7 @@ mod tests { std::fs::create_dir_all(storage_dir.join("store")).unwrap(); let object_store = Arc::new(LocalFileSystem::new_with_prefix(storage_dir.join("store")).unwrap()); - let store = StoreHandle::from(Arc::new(SlateStore::new( - object_store, - "", - Duration::from_millis(1), - ))); + let store = Arc::new(Database::new(object_store, "", Duration::from_millis(1))); let created = create( store.as_ref(), CreateRunInput { diff --git a/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs b/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs index 0acc714e3..f0e3261b1 100644 --- a/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs +++ b/lib/crates/fabro-workflow/src/operations/rebuild_meta.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use anyhow::{Context, Result, bail}; use fabro_checkpoint::branch::BranchStore; use fabro_checkpoint::git::Store as GitStore; -use fabro_store::{SlateRunStore as DurableRunStore, SlateStore as DurableStore}; +use fabro_store::{Database as DurableStore, RunDatabase as DurableRunStore}; use fabro_types::{RunId, StageId}; use git2::{Repository, Signature}; use ulid::Ulid; @@ -334,7 +334,7 @@ fn resolve_prefix_matches(prefix: &str, matches: Vec) -> Result { mod tests { use chrono::{TimeZone, Utc}; use fabro_graphviz::graph::Graph; - use fabro_store::{SlateStore, StageId, StoreHandle}; + use fabro_store::{Database, StageId}; use fabro_types::{RunId, RunRecord, SandboxRecord, Settings, StartRecord, fixtures}; use object_store::memory::InMemory; use std::collections::HashMap; @@ -359,8 +359,8 @@ mod tests { fixtures::RUN_1 } - fn memory_store() -> StoreHandle { - Arc::new(SlateStore::new( + fn memory_store() -> Arc { + Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), @@ -428,7 +428,7 @@ mod tests { } async fn create_run_store( - store: &SlateStore, + store: &Database, run_id: RunId, host_repo_path: Option<&str>, ) -> DurableRunStore { diff --git a/lib/crates/fabro-workflow/src/operations/resume.rs b/lib/crates/fabro-workflow/src/operations/resume.rs index 99ffdf30b..586c773ba 100644 --- a/lib/crates/fabro-workflow/src/operations/resume.rs +++ b/lib/crates/fabro-workflow/src/operations/resume.rs @@ -1,6 +1,6 @@ use std::path::Path; -use fabro_store::RuntimeState; +use fabro_config::RunScratch; use crate::error::FabroError; use crate::event::{Event, append_event}; @@ -52,11 +52,11 @@ pub async fn resume(run_dir: &Path, services: StartServices) -> Result, seed_context: Option, - run_store: SlateRunStore, + run_store: RunDatabase, git: Option, github_app: Option, worktree_mode: Option, @@ -68,7 +68,7 @@ pub struct StartServices { pub cancel_token: Option>, pub emitter: Arc, pub interviewer: Arc, - pub run_store: SlateRunStore, + pub run_store: RunDatabase, pub github_app: Option, pub on_node: crate::OnNodeCallback, pub registry_override: Option>, @@ -209,7 +209,7 @@ pub(super) async fn execute_persisted_run( async fn persist_terminal_engine_failure( run_id: RunId, - run_store: &SlateRunStore, + run_store: &RunDatabase, _run_dir: &Path, error: &FabroError, duration: Duration, @@ -590,7 +590,7 @@ impl RunSession { struct DetachedRunBootstrapGuard { run_id: RunId, - run_store: SlateRunStore, + run_store: RunDatabase, cancel_token: Option>, active: bool, } @@ -599,7 +599,7 @@ impl DetachedRunBootstrapGuard { fn arm( run_id: RunId, _run_dir: &Path, - run_store: SlateRunStore, + run_store: RunDatabase, cancel_token: Option>, ) -> Self { Self { @@ -652,14 +652,14 @@ const POSTRUN_ABORTED_MESSAGE: &str = "Run aborted before post-run finalization const POSTRUN_CANCELLED_MESSAGE: &str = "Run cancelled before post-run finalization completed."; struct DetachedRunCompletionGuard { - run_store: SlateRunStore, + run_store: RunDatabase, run_id: RunId, cancel_token: Option>, active: bool, } impl DetachedRunCompletionGuard { - fn arm(run_id: RunId, run_store: SlateRunStore, cancel_token: Option>) -> Self { + fn arm(run_id: RunId, run_store: RunDatabase, cancel_token: Option>) -> Self { Self { run_store, run_id, @@ -766,7 +766,7 @@ impl Drop for DetachedRunCompletionGuard { async fn persist_detached_failure( run_id: RunId, - run_store: &SlateRunStore, + run_store: &RunDatabase, _run_dir: &Path, phase: &'static str, reason: StatusReason, @@ -818,7 +818,7 @@ mod tests { use std::time::Duration; use chrono::Utc; - use fabro_store::{SlateStore, StoreHandle}; + use fabro_store::Database; use fabro_types::{Settings, fixtures}; use object_store::memory::InMemory; @@ -838,15 +838,15 @@ mod tests { start -> exit }"#; - fn memory_store() -> StoreHandle { - Arc::new(SlateStore::new( + fn memory_store() -> Arc { + Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), )) } - async fn persisted_workflow(dot: &str, run_dir: &Path) -> (Persisted, StoreHandle) { + async fn persisted_workflow(dot: &str, run_dir: &Path) -> (Persisted, Arc) { let store = memory_store(); let created = crate::operations::create( &store, @@ -885,7 +885,7 @@ mod tests { } async fn test_start_services( - store: &SlateStore, + store: &Database, _run_dir: &Path, emitter: Arc, registry: Arc, diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index 8c4916698..d44bed1a6 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -12,7 +12,7 @@ use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_hooks::HookSettings; use fabro_interview::AutoApproveInterviewer; use fabro_sandbox::SandboxSpec; -use fabro_store::{SlateStore, StoreHandle}; +use fabro_store::Database; use fabro_types::{RunId, Settings, fixtures}; use object_store::memory::InMemory; @@ -156,8 +156,8 @@ fn test_lifecycle(setup_commands: Vec) -> LifecycleOptions { } } -async fn test_run_store(run_id: &RunId) -> fabro_store::SlateRunStore { - let store: StoreHandle = Arc::new(SlateStore::new( +async fn test_run_store(run_id: &RunId) -> fabro_store::RunDatabase { + let store: Arc = Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index 64ec14e96..b5dd9b26c 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -10,7 +10,7 @@ use crate::run_options::RunOptions; use crate::run_status::{RunStatus, StatusReason}; use crate::sandbox_git::git_push_host; use fabro_hooks::{HookContext, HookEvent, HookRunner}; -use fabro_store::SlateRunStore; +use fabro_store::RunDatabase; use super::types::{Concluded, FinalizeOptions, Retroed}; @@ -63,7 +63,7 @@ pub fn classify_engine_result( } pub(crate) async fn build_conclusion_from_store( - run_store: &SlateRunStore, + run_store: &RunDatabase, status: StageStatus, failure_reason: Option, run_duration_ms: u64, @@ -169,7 +169,7 @@ fn build_conclusion_from_parts( /// /// This captures the last diff.patch (written after the final checkpoint) and retro.json. /// Best-effort: errors are logged as warnings. -pub async fn write_finalize_commit(run_options: &RunOptions, run_store: &SlateRunStore) { +pub async fn write_finalize_commit(run_options: &RunOptions, run_store: &RunDatabase) { let (Some(meta_branch), Some(repo_path)) = ( run_options .git @@ -321,7 +321,7 @@ mod tests { use std::time::Duration; use fabro_graphviz::graph::Graph; - use fabro_store::SlateStore; + use fabro_store::Database; use fabro_types::{RunId, Settings, fixtures}; use object_store::memory::InMemory; @@ -350,8 +350,8 @@ mod tests { } } - fn test_store() -> Arc { - Arc::new(SlateStore::new( + fn test_store() -> Arc { + Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index 68b485433..9ee43ffcf 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -668,7 +668,7 @@ mod tests { use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_interview::AutoApproveInterviewer; use fabro_sandbox::SandboxSpec; - use fabro_store::{SlateStore, StoreHandle}; + use fabro_store::Database; use fabro_types::{RunId, Settings, fixtures}; use object_store::memory::InMemory; @@ -682,8 +682,8 @@ mod tests { fixtures::RUN_1 } - fn memory_store() -> StoreHandle { - Arc::new(SlateStore::new( + fn memory_store() -> Arc { + Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), diff --git a/lib/crates/fabro-workflow/src/pipeline/persist.rs b/lib/crates/fabro-workflow/src/pipeline/persist.rs index ac742a9d6..75acce0eb 100644 --- a/lib/crates/fabro-workflow/src/pipeline/persist.rs +++ b/lib/crates/fabro-workflow/src/pipeline/persist.rs @@ -1,6 +1,6 @@ use std::path::Path; -use fabro_store::SlateRunStore; +use fabro_store::RunDatabase; use crate::error::FabroError; @@ -26,7 +26,7 @@ pub(crate) fn persist( } pub(crate) async fn load_from_store( - run_store: &SlateRunStore, + run_store: &RunDatabase, run_dir: &Path, ) -> Result { let state = run_store @@ -54,7 +54,7 @@ mod tests { use std::path::PathBuf; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; - use fabro_store::{SlateRunStore, SlateStore, StoreHandle}; + use fabro_store::{Database, RunDatabase}; use fabro_types::{Settings, fixtures}; use object_store::memory::InMemory; use std::sync::Arc; @@ -64,8 +64,8 @@ mod tests { use crate::event::{Event, append_event}; use crate::records::RunRecord; - fn memory_store() -> StoreHandle { - Arc::new(SlateStore::new( + fn memory_store() -> Arc { + Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), @@ -137,11 +137,7 @@ mod tests { } } - async fn seeded_store( - run_dir: &Path, - record: &RunRecord, - source: Option<&str>, - ) -> SlateRunStore { + async fn seeded_store(run_dir: &Path, record: &RunRecord, source: Option<&str>) -> RunDatabase { let store = memory_store(); let run_store = store.create_run(&record.run_id).await.unwrap(); append_event( diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 7dd05c3e9..4af096399 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -1,5 +1,5 @@ use fabro_config::run::MergeStrategy; -use fabro_store::{RunProjection, SlateRunStore}; +use fabro_store::{RunDatabase, RunProjection}; use fabro_types::PullRequestRecord; use tracing::{debug, info}; @@ -280,7 +280,7 @@ fn emit_run_notice( }); } -async fn load_pull_request_diff(run_store: &SlateRunStore) -> String { +async fn load_pull_request_diff(run_store: &RunDatabase) -> String { run_store .state() .await @@ -298,7 +298,7 @@ pub async fn build_pr_body( diff: &str, goal: &str, model: &str, - run_store: &SlateRunStore, + run_store: &RunDatabase, conclusion: Option<&Conclusion>, ) -> Result { debug!("Building PR body"); @@ -406,7 +406,7 @@ pub async fn maybe_open_pull_request( model: &str, draft: bool, auto_merge: Option, - run_store: &SlateRunStore, + run_store: &RunDatabase, conclusion: Option<&Conclusion>, ) -> Result, String> { if diff.is_empty() { @@ -591,7 +591,7 @@ mod tests { use fabro_retro::retro::{ AggregateStats, FrictionKind, FrictionPoint, OpenItem, OpenItemKind, StageRetro, }; - use fabro_store::SlateStore; + use fabro_store::Database; use fabro_types::{RunRecord, Settings, fixtures}; use futures::stream; use object_store::memory::InMemory; @@ -668,8 +668,8 @@ mod tests { } } - fn test_store() -> Arc { - Arc::new(SlateStore::new( + fn test_store() -> Arc { + Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), diff --git a/lib/crates/fabro-workflow/src/pipeline/retro.rs b/lib/crates/fabro-workflow/src/pipeline/retro.rs index 8a6de88cf..1c5a3c81b 100644 --- a/lib/crates/fabro-workflow/src/pipeline/retro.rs +++ b/lib/crates/fabro-workflow/src/pipeline/retro.rs @@ -169,7 +169,7 @@ mod tests { use std::time::Duration; use fabro_graphviz::graph::Graph; - use fabro_store::SlateStore; + use fabro_store::Database; use fabro_types::{RunId, Settings, fixtures}; use object_store::memory::InMemory; @@ -203,8 +203,8 @@ mod tests { ) } - fn test_store() -> Arc { - Arc::new(SlateStore::new( + fn test_store() -> Arc { + Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), @@ -214,7 +214,7 @@ mod tests { async fn test_run_store( run_dir: &std::path::Path, checkpoint: &Checkpoint, - ) -> fabro_store::SlateRunStore { + ) -> fabro_store::RunDatabase { let inner = test_store().create_run(&test_run_id()).await.unwrap(); let run_store = inner; let run_record = RunRecord { diff --git a/lib/crates/fabro-workflow/src/pipeline/types.rs b/lib/crates/fabro-workflow/src/pipeline/types.rs index 518af8401..65d0d4d06 100644 --- a/lib/crates/fabro-workflow/src/pipeline/types.rs +++ b/lib/crates/fabro-workflow/src/pipeline/types.rs @@ -11,7 +11,7 @@ use fabro_llm::Provider; use fabro_mcp::config::McpServerSettings; use fabro_model::FallbackTarget; use fabro_sandbox::SandboxSpec; -use fabro_store::SlateRunStore; +use fabro_store::RunDatabase; use fabro_types::RunId; use fabro_validate::Diagnostic; @@ -197,7 +197,7 @@ impl Persisted { } pub async fn load_from_store( - run_store: &SlateRunStore, + run_store: &RunDatabase, run_dir: &Path, ) -> Result { super::persist::load_from_store(run_store, run_dir).await @@ -229,7 +229,7 @@ pub struct DevcontainerSpec { pub struct InitOptions { pub run_id: RunId, - pub run_store: SlateRunStore, + pub run_store: RunDatabase, pub dry_run: bool, pub emitter: Arc, pub sandbox: SandboxSpec, @@ -257,7 +257,7 @@ pub struct Initialized { pub run_options: RunOptions, pub workflow_path: Option, pub workflow_bundle: Option>, - pub run_store: SlateRunStore, + pub run_store: RunDatabase, pub(crate) checkpoint: Option, pub(crate) seed_context: Option, pub emitter: Arc, @@ -278,7 +278,7 @@ pub struct Executed { pub graph: Graph, pub outcome: Result, pub run_options: RunOptions, - pub run_store: SlateRunStore, + pub run_store: RunDatabase, pub hook_runner: Option>, pub emitter: Arc, pub sandbox: Arc, @@ -295,7 +295,7 @@ pub struct Retroed { pub graph: Graph, pub outcome: Result, pub run_options: RunOptions, - pub run_store: SlateRunStore, + pub run_store: RunDatabase, pub hook_runner: Option>, pub emitter: Arc, pub sandbox: Arc, @@ -335,7 +335,7 @@ pub struct TransformOptions { /// Options for the RETRO phase. pub struct RetroOptions { pub run_id: RunId, - pub run_store: SlateRunStore, + pub run_store: RunDatabase, pub workflow_name: String, pub goal: String, pub run_dir: PathBuf, @@ -353,7 +353,7 @@ pub struct RetroOptions { pub struct FinalizeOptions { pub run_dir: PathBuf, pub run_id: RunId, - pub run_store: SlateRunStore, + pub run_store: RunDatabase, pub workflow_name: String, pub hook_runner: Option>, pub preserve_sandbox: bool, @@ -363,7 +363,7 @@ pub struct FinalizeOptions { /// Options for the PULL_REQUEST phase. pub struct PullRequestOptions { pub run_dir: PathBuf, - pub run_store: SlateRunStore, + pub run_store: RunDatabase, pub pr_config: Option, pub github_app: Option, pub origin_url: Option, diff --git a/lib/crates/fabro-workflow/src/run_dump.rs b/lib/crates/fabro-workflow/src/run_dump.rs index 154b2f52c..80d953869 100644 --- a/lib/crates/fabro-workflow/src/run_dump.rs +++ b/lib/crates/fabro-workflow/src/run_dump.rs @@ -2,7 +2,7 @@ use std::io::Write; use std::path::{Component, Path, PathBuf}; use anyhow::{Context, Result, bail}; -use fabro_store::{RunProjection, SlateRunStore}; +use fabro_store::{ArtifactStore, RunDatabase, RunProjection}; use crate::git::MetadataStore; @@ -116,10 +116,15 @@ impl RunDump { dump } - pub async fn store_export(run_store: &SlateRunStore, state: &RunProjection) -> Result { + pub async fn store_export( + run_store: &RunDatabase, + artifact_store: &ArtifactStore, + state: &RunProjection, + ) -> Result { let mut entries = Vec::new(); - if let Some(record) = state.run.as_ref() { + let run_record = state.run.as_ref(); + if let Some(record) = run_record { push_json_entry(&mut entries, "run.json", record); } if let Some(record) = state.start.as_ref() { @@ -218,28 +223,31 @@ impl RunDump { )); } - for asset in run_store.list_all_artifacts().await? { - let node_id_segment = validate_single_path_segment("node id", asset.node.node_id())?; - let filename_path = validate_relative_path("artifact filename", &asset.filename)?; - let data = run_store - .get_artifact(&asset.node, &asset.filename) - .await? - .with_context(|| { - format!( - "asset {:?} for node {:?} visit {} is missing from the store", - asset.filename, - asset.node.node_id(), - asset.node.visit() - ) - })?; - entries.push(RunDumpEntry::bytes_path( - &PathBuf::from("artifacts") - .join("nodes") - .join(node_id_segment) - .join(format!("visit-{}", asset.node.visit())) - .join(filename_path), - data.to_vec(), - )); + if let Some(run_record) = run_record { + for asset in artifact_store.list_for_run(&run_record.run_id).await? { + let node_id_segment = + validate_single_path_segment("node id", asset.node.node_id())?; + let filename_path = validate_relative_path("artifact filename", &asset.filename)?; + let data = artifact_store + .get(&run_record.run_id, &asset.node, &asset.filename) + .await? + .with_context(|| { + format!( + "asset {:?} for node {:?} visit {} is missing from the store", + asset.filename, + asset.node.node_id(), + asset.node.visit() + ) + })?; + entries.push(RunDumpEntry::bytes_path( + &PathBuf::from("artifacts") + .join("nodes") + .join(node_id_segment) + .join(format!("visit-{}", asset.node.visit())) + .join(filename_path), + data.to_vec(), + )); + } } Ok(Self { entries }) diff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/crates/fabro-workflow/src/run_lookup.rs index df167eb52..295746c75 100644 --- a/lib/crates/fabro-workflow/src/run_lookup.rs +++ b/lib/crates/fabro-workflow/src/run_lookup.rs @@ -3,7 +3,8 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; use chrono::{DateTime, Utc}; -use fabro_store::{RunSummary, SlateStore}; +use fabro_config::{Home, Storage}; +use fabro_store::{Database, RunSummary}; use fabro_types::RunId; use serde::Serialize; @@ -126,22 +127,12 @@ fn empty_labels() -> &'static HashMap { EMPTY.get_or_init(HashMap::new) } -pub fn default_storage_dir() -> PathBuf { - dirs::home_dir() - .expect("could not determine home directory") - .join(".fabro") +pub fn scratch_base(storage_dir: &Path) -> PathBuf { + Storage::new(storage_dir).scratch_dir() } -pub fn logs_base(storage_dir: &Path) -> PathBuf { - storage_dir.join("logs") -} - -pub fn runs_base(storage_dir: &Path) -> PathBuf { - storage_dir.join("runs") -} - -pub fn default_runs_base() -> PathBuf { - runs_base(&default_storage_dir()) +pub fn default_scratch_base() -> PathBuf { + scratch_base(Home::from_env().root()) } fn scan_orphan_runs(base: &Path) -> Result> { @@ -194,7 +185,7 @@ fn scan_orphan_runs(base: &Path) -> Result> { Ok(runs) } -pub async fn scan_runs_combined(store: &SlateStore, base: &Path) -> Result> { +pub async fn scan_runs_combined(store: &Database, base: &Path) -> Result> { let store_runs = store .list_runs(&fabro_store::ListRunsQuery::default()) .await @@ -232,8 +223,8 @@ pub fn scan_runs_with_summaries(summaries: &[RunSummary], base: &Path) -> Result Ok(runs) } -fn run_info_from_summary(summary: &RunSummary, runs_base: &Path) -> Option { - let path = make_run_dir(runs_base, &summary.run_id); +fn run_info_from_summary(summary: &RunSummary, scratch_base: &Path) -> Option { + let path = make_run_dir(scratch_base, &summary.run_id); if !path.exists() { return None; } @@ -305,7 +296,7 @@ pub fn filter_runs( } pub async fn resolve_run_combined( - store: &SlateStore, + store: &Database, base: &Path, identifier: &str, ) -> Result { @@ -399,7 +390,7 @@ mod tests { use std::time::Duration; use fabro_graphviz::graph::Graph; - use fabro_store::{SlateStore, StoreHandle}; + use fabro_store::Database; use fabro_types::{RunStatus, Settings, fixtures}; use object_store::memory::InMemory; @@ -408,8 +399,8 @@ mod tests { use crate::operations::make_run_dir; use crate::records::RunRecord; - fn memory_store() -> StoreHandle { - Arc::new(SlateStore::new( + fn memory_store() -> Arc { + Arc::new(Database::new( Arc::new(InMemory::new()), "", Duration::from_millis(1), diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index d751c60c4..90a77f042 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -5,7 +5,7 @@ use std::time::Duration; use fabro_agent::Sandbox; use fabro_graphviz::graph::Graph as GvGraph; -use fabro_store::{RunProjection, SlateStore}; +use fabro_store::{Database, RunProjection}; use object_store::local::LocalFileSystem; use crate::error::{FabroError, Result}; @@ -51,7 +51,7 @@ async fn initialized( run_options.run_id.to_string(), ) .expect("failed to write run id marker"); - let store = Arc::new(SlateStore::new( + let store = Arc::new(Database::new( Arc::new( LocalFileSystem::new_with_prefix(run_options.run_dir.join("store")) .expect("failed to create local test run store"), diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs index 935df26d5..fabb7af19 100644 --- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs @@ -18,10 +18,11 @@ use std::path::Path; use std::sync::Arc; use fabro_agent::Sandbox; +use fabro_config::RunScratch; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_llm::provider::Provider; use fabro_sandbox::daytona::{DaytonaConfig, DaytonaSandbox, DaytonaSnapshotConfig}; -use fabro_store::{RuntimeState, SlateStore}; +use fabro_store::Database; use fabro_types::{RunId, Settings}; use fabro_workflow::artifact::sync_artifacts_to_env; use fabro_workflow::context::Context; @@ -70,7 +71,7 @@ fn load_checkpoint(path: &Path) -> Result (storage_dir.join("store"), run_id) }; let object_store = Arc::new(LocalFileSystem::new_with_prefix(store_dir)?); - let store = Arc::new(SlateStore::new( + let store = Arc::new(Database::new( object_store, "", std::time::Duration::from_millis(1), @@ -1346,7 +1347,7 @@ async fn daytona_asset_collection() { .expect("pipeline should succeed"); assert_eq!(outcome.status, StageStatus::Success); - let artifacts_dir = RuntimeState::new(dir.path()).artifact_stage_dir("create_assets", 1); + let artifacts_dir = RunScratch::new(dir.path()).artifact_stage_dir("create_assets", 1); let report_path = artifacts_dir.join("test-results/report.xml"); assert!( diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs index ef9313bd4..9cabfac40 100644 --- a/lib/crates/fabro-workflow/tests/it/integration.rs +++ b/lib/crates/fabro-workflow/tests/it/integration.rs @@ -17,6 +17,7 @@ use std::path::Path; use std::sync::Arc; use std::time::Duration; +use fabro_config::RunScratch; use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; use fabro_graphviz::parser::parse; use fabro_interview::{ @@ -24,7 +25,7 @@ use fabro_interview::{ QueueInterviewer, RecordingInterviewer, }; use fabro_llm::provider::Provider; -use fabro_store::{RuntimeState, SlateStore}; +use fabro_store::Database; use fabro_types::{RunEvent, RunId, Settings}; use fabro_validate::{Severity, validate, validate_or_raise}; use fabro_workflow::context::Context; @@ -90,7 +91,7 @@ fn load_checkpoint(path: &Path) -> Result (storage_dir.join("store"), run_id) }; let object_store = Arc::new(LocalFileSystem::new_with_prefix(store_dir)?); - let store = Arc::new(SlateStore::new(object_store, "", Duration::from_millis(1))); + let store = Arc::new(Database::new(object_store, "", Duration::from_millis(1))); let state = if tokio::runtime::Handle::try_current().is_ok() { std::thread::spawn( move || -> Result<_, Box> { @@ -8599,14 +8600,13 @@ async fn large_context_values_are_offloaded_to_artifact_store() { ); let expected_blob_id = fabro_types::RunBlobId::new( - &run_options.run_id, &serde_json::to_vec(&serde_json::json!("x".repeat(150 * 1024))) .expect("large value should serialize"), ); // The artifact file should exist on disk - let artifact_file = RuntimeState::new(dir.path()) - .artifact_values_dir() + let artifact_file = RunScratch::new(dir.path()) + .blob_cache_dir() .join(format!("{expected_blob_id}.json")); assert!( artifact_file.exists(), @@ -12453,7 +12453,7 @@ async fn asset_collection_local_sandbox_success() { assert_eq!(outcome.status, StageStatus::Success); // Check that artifact files were collected into the stage directory - let artifacts_dir = RuntimeState::new(run_dir.path()).artifact_stage_dir("create_assets", 1); + let artifacts_dir = RunScratch::new(run_dir.path()).artifact_stage_dir("create_assets", 1); let report_path = artifacts_dir.join("test-results/report.xml"); assert!( @@ -12573,7 +12573,7 @@ async fn asset_collection_local_sandbox_on_failure() { // Assets should still be collected regardless of intermediate node failures. assert_eq!(outcome.status, StageStatus::Success); - let artifacts_dir = RuntimeState::new(run_dir.path()).artifact_stage_dir("create_assets", 1); + let artifacts_dir = RunScratch::new(run_dir.path()).artifact_stage_dir("create_assets", 1); let report_path = artifacts_dir.join("test-results/report.xml"); assert!( @@ -12662,7 +12662,7 @@ async fn asset_collection_docker_sandbox() { .expect("pipeline should succeed"); assert_eq!(outcome.status, StageStatus::Success); - let artifacts_dir = RuntimeState::new(run_dir.path()).artifact_stage_dir("create_assets", 1); + let artifacts_dir = RunScratch::new(run_dir.path()).artifact_stage_dir("create_assets", 1); let report_path = artifacts_dir.join("test-results/report.xml"); assert!( diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 94ac7ef7d..231f47fbc 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -146,6 +146,8 @@ models/retro-list-item.ts models/retro-stats.ts models/root-response-urls.ts models/root-response.ts +models/run-artifact-entry.ts +models/run-artifact-list-response.ts models/run-checkpoint.ts models/run-error.ts models/run-event.ts @@ -166,6 +168,8 @@ models/run-timings.ts models/run-usage.ts models/run-verification-control.ts models/run-verification.ts +models/sandbox-file-entry.ts +models/sandbox-file-list-response.ts models/sandbox-resources.ts models/sandbox-settings.ts models/save-query-request.ts @@ -184,6 +188,8 @@ models/sibling-control.ts models/signoff-status.ts models/signoff.ts models/smoothness-rating.ts +models/ssh-access-request.ts +models/ssh-access-response.ts models/stage-retro.ts models/stage-status.ts models/stage-turn.ts diff --git a/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts b/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts index ef82a1570..ee83b0459 100644 --- a/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts +++ b/lib/packages/fabro-api-client/src/api/human-in-the-loop-api.ts @@ -30,6 +30,12 @@ import type { PreviewUrlRequest } from '../models'; // @ts-ignore import type { PreviewUrlResponse } from '../models'; // @ts-ignore +import type { SandboxFileListResponse } from '../models'; +// @ts-ignore +import type { SshAccessRequest } from '../models'; +// @ts-ignore +import type { SshAccessResponse } from '../models'; +// @ts-ignore import type { SteerRequest } from '../models'; // @ts-ignore import type { SubmitAnswerRequest } from '../models'; @@ -39,7 +45,53 @@ import type { SubmitAnswerRequest } from '../models'; export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Configuration) { return { /** - * Generates a time-limited preview URL for a port exposed by the run\'s sandbox environment. + * Creates a time-limited SSH command for the run\'s sandbox environment. + * @summary SSH Access + * @param {string} id Unique run identifier (ULID). + * @param {SshAccessRequest} sshAccessRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createRunSshAccess: async (id: string, sshAccessRequest: SshAccessRequest, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('createRunSshAccess', 'id', id) + // verify required parameter 'sshAccessRequest' is not null or undefined + assertParamExists('createRunSshAccess', 'sshAccessRequest', sshAccessRequest) + const localVarPath = `/api/v1/runs/{id}/ssh` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication mTLS required + await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration) + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + localVarHeaderParameter['Content-Type'] = 'application/json'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(sshAccessRequest, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Generates a preview URL for a port exposed by the run\'s sandbox environment. * @summary Preview URL * @param {string} id Unique run identifier (ULID). * @param {PreviewUrlRequest} previewUrlRequest @@ -84,6 +136,54 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf options: localVarRequestOptions, }; }, + /** + * Downloads a file from the run\'s sandbox environment. + * @summary Download Sandbox File + * @param {string} id Unique run identifier (ULID). + * @param {string} path + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSandboxFile: async (id: string, path: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getSandboxFile', 'id', id) + // verify required parameter 'path' is not null or undefined + assertParamExists('getSandboxFile', 'path', path) + const localVarPath = `/api/v1/runs/{id}/sandbox/file` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication mTLS required + await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration) + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (path !== undefined) { + localVarQueryParameter['path'] = path; + } + + localVarHeaderParameter['Accept'] = 'application/octet-stream,application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Returns pending human-in-the-loop questions for a run. Questions are generated when the workflow needs user input to proceed. * @summary List Run Questions @@ -135,6 +235,112 @@ export const HumanInTheLoopApiAxiosParamCreator = function (configuration?: Conf options: localVarRequestOptions, }; }, + /** + * Lists directory entries from the run\'s sandbox environment. + * @summary List Sandbox Files + * @param {string} id Unique run identifier (ULID). + * @param {string} path + * @param {number} [depth] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listSandboxFiles: async (id: string, path: string, depth?: number, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('listSandboxFiles', 'id', id) + // verify required parameter 'path' is not null or undefined + assertParamExists('listSandboxFiles', 'path', path) + const localVarPath = `/api/v1/runs/{id}/sandbox/files` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication mTLS required + await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration) + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (path !== undefined) { + localVarQueryParameter['path'] = path; + } + + if (depth !== undefined) { + localVarQueryParameter['depth'] = depth; + } + + 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, + }; + }, + /** + * Uploads a file into the run\'s sandbox environment. + * @summary Upload Sandbox File + * @param {string} id Unique run identifier (ULID). + * @param {string} path + * @param {File} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + putSandboxFile: async (id: string, path: string, body: File, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('putSandboxFile', 'id', id) + // verify required parameter 'path' is not null or undefined + assertParamExists('putSandboxFile', 'path', path) + // verify required parameter 'body' is not null or undefined + assertParamExists('putSandboxFile', 'body', body) + const localVarPath = `/api/v1/runs/{id}/sandbox/file` + .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: 'PUT', ...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) + + if (path !== undefined) { + localVarQueryParameter['path'] = path; + } + + localVarHeaderParameter['Content-Type'] = 'application/octet-stream'; + localVarHeaderParameter['Accept'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * Sends inline guidance to a running agent, targeting a specific file and line. The guidance is delivered asynchronously. * @summary Steer Run @@ -241,7 +447,21 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = HumanInTheLoopApiAxiosParamCreator(configuration) return { /** - * Generates a time-limited preview URL for a port exposed by the run\'s sandbox environment. + * Creates a time-limited SSH command for the run\'s sandbox environment. + * @summary SSH Access + * @param {string} id Unique run identifier (ULID). + * @param {SshAccessRequest} sshAccessRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createRunSshAccess(id: string, sshAccessRequest: SshAccessRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createRunSshAccess(id, sshAccessRequest, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.createRunSshAccess']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Generates a preview URL for a port exposed by the run\'s sandbox environment. * @summary Preview URL * @param {string} id Unique run identifier (ULID). * @param {PreviewUrlRequest} previewUrlRequest @@ -254,6 +474,20 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.generatePreviewUrl']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Downloads a file from the run\'s sandbox environment. + * @summary Download Sandbox File + * @param {string} id Unique run identifier (ULID). + * @param {string} path + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getSandboxFile(id: string, path: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getSandboxFile(id, path, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.getSandboxFile']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Returns pending human-in-the-loop questions for a run. Questions are generated when the workflow needs user input to proceed. * @summary List Run Questions @@ -269,6 +503,36 @@ export const HumanInTheLoopApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.listRunQuestions']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Lists directory entries from the run\'s sandbox environment. + * @summary List Sandbox Files + * @param {string} id Unique run identifier (ULID). + * @param {string} path + * @param {number} [depth] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listSandboxFiles(id: string, path: string, depth?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listSandboxFiles(id, path, depth, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.listSandboxFiles']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + /** + * Uploads a file into the run\'s sandbox environment. + * @summary Upload Sandbox File + * @param {string} id Unique run identifier (ULID). + * @param {string} path + * @param {File} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async putSandboxFile(id: string, path: string, body: File, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.putSandboxFile(id, path, body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['HumanInTheLoopApi.putSandboxFile']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Sends inline guidance to a running agent, targeting a specific file and line. The guidance is delivered asynchronously. * @summary Steer Run @@ -308,7 +572,18 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, const localVarFp = HumanInTheLoopApiFp(configuration) return { /** - * Generates a time-limited preview URL for a port exposed by the run\'s sandbox environment. + * Creates a time-limited SSH command for the run\'s sandbox environment. + * @summary SSH Access + * @param {string} id Unique run identifier (ULID). + * @param {SshAccessRequest} sshAccessRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createRunSshAccess(id: string, sshAccessRequest: SshAccessRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.createRunSshAccess(id, sshAccessRequest, options).then((request) => request(axios, basePath)); + }, + /** + * Generates a preview URL for a port exposed by the run\'s sandbox environment. * @summary Preview URL * @param {string} id Unique run identifier (ULID). * @param {PreviewUrlRequest} previewUrlRequest @@ -318,6 +593,17 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, generatePreviewUrl(id: string, previewUrlRequest: PreviewUrlRequest, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.generatePreviewUrl(id, previewUrlRequest, options).then((request) => request(axios, basePath)); }, + /** + * Downloads a file from the run\'s sandbox environment. + * @summary Download Sandbox File + * @param {string} id Unique run identifier (ULID). + * @param {string} path + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSandboxFile(id: string, path: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.getSandboxFile(id, path, options).then((request) => request(axios, basePath)); + }, /** * Returns pending human-in-the-loop questions for a run. Questions are generated when the workflow needs user input to proceed. * @summary List Run Questions @@ -330,6 +616,30 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, listRunQuestions(id: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.listRunQuestions(id, pageLimit, pageOffset, options).then((request) => request(axios, basePath)); }, + /** + * Lists directory entries from the run\'s sandbox environment. + * @summary List Sandbox Files + * @param {string} id Unique run identifier (ULID). + * @param {string} path + * @param {number} [depth] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listSandboxFiles(id: string, path: string, depth?: number, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listSandboxFiles(id, path, depth, options).then((request) => request(axios, basePath)); + }, + /** + * Uploads a file into the run\'s sandbox environment. + * @summary Upload Sandbox File + * @param {string} id Unique run identifier (ULID). + * @param {string} path + * @param {File} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + putSandboxFile(id: string, path: string, body: File, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.putSandboxFile(id, path, body, options).then((request) => request(axios, basePath)); + }, /** * Sends inline guidance to a running agent, targeting a specific file and line. The guidance is delivered asynchronously. * @summary Steer Run @@ -361,7 +671,19 @@ export const HumanInTheLoopApiFactory = function (configuration?: Configuration, */ export class HumanInTheLoopApi extends BaseAPI { /** - * Generates a time-limited preview URL for a port exposed by the run\'s sandbox environment. + * Creates a time-limited SSH command for the run\'s sandbox environment. + * @summary SSH Access + * @param {string} id Unique run identifier (ULID). + * @param {SshAccessRequest} sshAccessRequest + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public createRunSshAccess(id: string, sshAccessRequest: SshAccessRequest, options?: RawAxiosRequestConfig) { + return HumanInTheLoopApiFp(this.configuration).createRunSshAccess(id, sshAccessRequest, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Generates a preview URL for a port exposed by the run\'s sandbox environment. * @summary Preview URL * @param {string} id Unique run identifier (ULID). * @param {PreviewUrlRequest} previewUrlRequest @@ -372,6 +694,18 @@ export class HumanInTheLoopApi extends BaseAPI { return HumanInTheLoopApiFp(this.configuration).generatePreviewUrl(id, previewUrlRequest, options).then((request) => request(this.axios, this.basePath)); } + /** + * Downloads a file from the run\'s sandbox environment. + * @summary Download Sandbox File + * @param {string} id Unique run identifier (ULID). + * @param {string} path + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public getSandboxFile(id: string, path: string, options?: RawAxiosRequestConfig) { + return HumanInTheLoopApiFp(this.configuration).getSandboxFile(id, path, options).then((request) => request(this.axios, this.basePath)); + } + /** * Returns pending human-in-the-loop questions for a run. Questions are generated when the workflow needs user input to proceed. * @summary List Run Questions @@ -385,6 +719,32 @@ export class HumanInTheLoopApi extends BaseAPI { return HumanInTheLoopApiFp(this.configuration).listRunQuestions(id, pageLimit, pageOffset, options).then((request) => request(this.axios, this.basePath)); } + /** + * Lists directory entries from the run\'s sandbox environment. + * @summary List Sandbox Files + * @param {string} id Unique run identifier (ULID). + * @param {string} path + * @param {number} [depth] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public listSandboxFiles(id: string, path: string, depth?: number, options?: RawAxiosRequestConfig) { + return HumanInTheLoopApiFp(this.configuration).listSandboxFiles(id, path, depth, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * Uploads a file into the run\'s sandbox environment. + * @summary Upload Sandbox File + * @param {string} id Unique run identifier (ULID). + * @param {string} path + * @param {File} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public putSandboxFile(id: string, path: string, body: File, options?: RawAxiosRequestConfig) { + return HumanInTheLoopApiFp(this.configuration).putSandboxFile(id, path, body, options).then((request) => request(this.axios, this.basePath)); + } + /** * Sends inline guidance to a running agent, targeting a specific file and line. The guidance is delivered asynchronously. * @summary Steer Run diff --git a/lib/packages/fabro-api-client/src/api/run-internals-api.ts b/lib/packages/fabro-api-client/src/api/run-internals-api.ts index 535b5ea76..4dd7e8c46 100644 --- a/lib/packages/fabro-api-client/src/api/run-internals-api.ts +++ b/lib/packages/fabro-api-client/src/api/run-internals-api.ts @@ -34,6 +34,8 @@ import type { PaginatedRunStageList } from '../models'; // @ts-ignore import type { PaginatedStageTurnList } from '../models'; // @ts-ignore +import type { RunArtifactListResponse } from '../models'; +// @ts-ignore import type { RunCheckpoint } from '../models'; // @ts-ignore import type { RunEvent } from '../models'; @@ -186,7 +188,7 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config * @summary Get Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. - * @param {string} filename Artifact filename. May contain path separators. + * @param {string} filename Relative artifact path. `/` is allowed as a path separator. Backslash, empty segments, and traversal segments (`.` and `..`) are invalid. * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -233,6 +235,47 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config options: localVarRequestOptions, }; }, + /** + * Lists captured artifact files for a run. + * @summary List Run Artifacts + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listRunArtifacts: async (id: string, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('listRunArtifacts', 'id', id) + const localVarPath = `/api/v1/runs/{id}/artifacts` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication 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, + }; + }, /** * Returns a paginated JSON list of stored run events. * @summary List Run Events @@ -440,7 +483,7 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. - * @param {string} filename Artifact filename. May contain path separators. + * @param {string} filename Relative artifact path. `/` is allowed as a path separator. Backslash, empty segments, and traversal segments (`.` and `..`) are invalid. * @param {File} body * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -720,7 +763,7 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { * @summary Get Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. - * @param {string} filename Artifact filename. May contain path separators. + * @param {string} filename Relative artifact path. `/` is allowed as a path separator. Backslash, empty segments, and traversal segments (`.` and `..`) are invalid. * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -730,6 +773,19 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.getStageArtifact']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, + /** + * Lists captured artifact files for a run. + * @summary List Run Artifacts + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listRunArtifacts(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.listRunArtifacts(id, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.listRunArtifacts']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, /** * Returns a paginated JSON list of stored run events. * @summary List Run Events @@ -795,7 +851,7 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. - * @param {string} filename Artifact filename. May contain path separators. + * @param {string} filename Relative artifact path. `/` is allowed as a path separator. Backslash, empty segments, and traversal segments (`.` and `..`) are invalid. * @param {File} body * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -906,13 +962,23 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b * @summary Get Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. - * @param {string} filename Artifact filename. May contain path separators. + * @param {string} filename Relative artifact path. `/` is allowed as a path separator. Backslash, empty segments, and traversal segments (`.` and `..`) are invalid. * @param {*} [options] Override http request option. * @throws {RequiredError} */ getStageArtifact(id: string, stageId: string, filename: string, options?: RawAxiosRequestConfig): AxiosPromise { return localVarFp.getStageArtifact(id, stageId, filename, options).then((request) => request(axios, basePath)); }, + /** + * Lists captured artifact files for a run. + * @summary List Run Artifacts + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listRunArtifacts(id: string, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.listRunArtifacts(id, options).then((request) => request(axios, basePath)); + }, /** * Returns a paginated JSON list of stored run events. * @summary List Run Events @@ -966,7 +1032,7 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. - * @param {string} filename Artifact filename. May contain path separators. + * @param {string} filename Relative artifact path. `/` is allowed as a path separator. Backslash, empty segments, and traversal segments (`.` and `..`) are invalid. * @param {File} body * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -1063,7 +1129,7 @@ export class RunInternalsApi extends BaseAPI { * @summary Get Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. - * @param {string} filename Artifact filename. May contain path separators. + * @param {string} filename Relative artifact path. `/` is allowed as a path separator. Backslash, empty segments, and traversal segments (`.` and `..`) are invalid. * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -1071,6 +1137,17 @@ export class RunInternalsApi extends BaseAPI { return RunInternalsApiFp(this.configuration).getStageArtifact(id, stageId, filename, options).then((request) => request(this.axios, this.basePath)); } + /** + * Lists captured artifact files for a run. + * @summary List Run Artifacts + * @param {string} id Unique run identifier (ULID). + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public listRunArtifacts(id: string, options?: RawAxiosRequestConfig) { + return RunInternalsApiFp(this.configuration).listRunArtifacts(id, options).then((request) => request(this.axios, this.basePath)); + } + /** * Returns a paginated JSON list of stored run events. * @summary List Run Events @@ -1128,7 +1205,7 @@ export class RunInternalsApi extends BaseAPI { * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. - * @param {string} filename Artifact filename. May contain path separators. + * @param {string} filename Relative artifact path. `/` is allowed as a path separator. Backslash, empty segments, and traversal segments (`.` and `..`) are invalid. * @param {File} body * @param {*} [options] Override http request option. * @throws {RequiredError} diff --git a/lib/packages/fabro-api-client/src/api/run-outputs-api.ts b/lib/packages/fabro-api-client/src/api/run-outputs-api.ts index 3008fc778..5718a8484 100644 --- a/lib/packages/fabro-api-client/src/api/run-outputs-api.ts +++ b/lib/packages/fabro-api-client/src/api/run-outputs-api.ts @@ -24,8 +24,6 @@ import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError // @ts-ignore import type { ErrorResponse } from '../models'; // @ts-ignore -import type { PaginatedRunFileList } from '../models'; -// @ts-ignore import type { PaginatedRunVerificationList } from '../models'; // @ts-ignore import type { RunUsage } from '../models'; @@ -34,62 +32,6 @@ import type { RunUsage } from '../models'; */ export const RunOutputsApiAxiosParamCreator = function (configuration?: Configuration) { return { - /** - * Returns a paginated list of file-level diffs produced by the run, optionally filtered to a specific checkpoint. - * @summary Retrieve Run Files - * @param {string} id Unique run identifier (ULID). - * @param {string} [checkpoint] Filter to a specific checkpoint ID. Omit to include all changes. - * @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} - */ - retrieveRunFiles: async (id: string, checkpoint?: string, pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('retrieveRunFiles', 'id', id) - const localVarPath = `/api/v1/runs/{id}/files` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication mTLS required - await setApiKeyToObject(localVarHeaderParameter, "X-mTLS-Client-CN", configuration) - - // authentication BearerAuth required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - if (checkpoint !== undefined) { - localVarQueryParameter['checkpoint'] = checkpoint; - } - - if (pageLimit !== undefined) { - localVarQueryParameter['page[limit]'] = pageLimit; - } - - if (pageOffset !== undefined) { - localVarQueryParameter['page[offset]'] = pageOffset; - } - - 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, - }; - }, /** * Returns token and cost usage broken down by stage and model for a specific run. * @summary Retrieve Run Usage @@ -191,22 +133,6 @@ export const RunOutputsApiAxiosParamCreator = function (configuration?: Configur export const RunOutputsApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = RunOutputsApiAxiosParamCreator(configuration) return { - /** - * Returns a paginated list of file-level diffs produced by the run, optionally filtered to a specific checkpoint. - * @summary Retrieve Run Files - * @param {string} id Unique run identifier (ULID). - * @param {string} [checkpoint] Filter to a specific checkpoint ID. Omit to include all changes. - * @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 retrieveRunFiles(id: string, checkpoint?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveRunFiles(id, checkpoint, pageLimit, pageOffset, options); - const localVarOperationServerIndex = configuration?.serverIndex ?? 0; - const localVarOperationServerBasePath = operationServerMap['RunOutputsApi.retrieveRunFiles']?.[localVarOperationServerIndex]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); - }, /** * Returns token and cost usage broken down by stage and model for a specific run. * @summary Retrieve Run Usage @@ -244,19 +170,6 @@ export const RunOutputsApiFp = function(configuration?: Configuration) { export const RunOutputsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = RunOutputsApiFp(configuration) return { - /** - * Returns a paginated list of file-level diffs produced by the run, optionally filtered to a specific checkpoint. - * @summary Retrieve Run Files - * @param {string} id Unique run identifier (ULID). - * @param {string} [checkpoint] Filter to a specific checkpoint ID. Omit to include all changes. - * @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} - */ - retrieveRunFiles(id: string, checkpoint?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.retrieveRunFiles(id, checkpoint, pageLimit, pageOffset, options).then((request) => request(axios, basePath)); - }, /** * Returns token and cost usage broken down by stage and model for a specific run. * @summary Retrieve Run Usage @@ -286,20 +199,6 @@ export const RunOutputsApiFactory = function (configuration?: Configuration, bas * RunOutputsApi - object-oriented interface */ export class RunOutputsApi extends BaseAPI { - /** - * Returns a paginated list of file-level diffs produced by the run, optionally filtered to a specific checkpoint. - * @summary Retrieve Run Files - * @param {string} id Unique run identifier (ULID). - * @param {string} [checkpoint] Filter to a specific checkpoint ID. Omit to include all changes. - * @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 retrieveRunFiles(id: string, checkpoint?: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig) { - return RunOutputsApiFp(this.configuration).retrieveRunFiles(id, checkpoint, pageLimit, pageOffset, options).then((request) => request(this.axios, this.basePath)); - } - /** * Returns token and cost usage broken down by stage and model for a specific run. * @summary Retrieve Run Usage diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index 1b8d472f3..1324f1932 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -124,6 +124,8 @@ export * from './retro-list-item'; export * from './retro-stats'; export * from './root-response'; export * from './root-response-urls'; +export * from './run-artifact-entry'; +export * from './run-artifact-list-response'; export * from './run-checkpoint'; export * from './run-error'; export * from './run-event'; @@ -144,6 +146,8 @@ export * from './run-timings'; export * from './run-usage'; export * from './run-verification'; export * from './run-verification-control'; +export * from './sandbox-file-entry'; +export * from './sandbox-file-list-response'; export * from './sandbox-resources'; export * from './sandbox-settings'; export * from './save-query-request'; @@ -162,6 +166,8 @@ export * from './sibling-control'; export * from './signoff'; export * from './signoff-status'; export * from './smoothness-rating'; +export * from './ssh-access-request'; +export * from './ssh-access-response'; export * from './stage-retro'; export * from './stage-status'; export * from './stage-turn'; diff --git a/lib/packages/fabro-api-client/src/models/preview-url-request.ts b/lib/packages/fabro-api-client/src/models/preview-url-request.ts index 70345515c..02ee448ff 100644 --- a/lib/packages/fabro-api-client/src/models/preview-url-request.ts +++ b/lib/packages/fabro-api-client/src/models/preview-url-request.ts @@ -26,5 +26,9 @@ export interface PreviewUrlRequest { * Time-to-live for the preview URL in seconds. */ 'expires_in_secs': number; + /** + * When true, return a signed URL that does not require a preview token header. + */ + 'signed'?: boolean; } diff --git a/lib/packages/fabro-api-client/src/models/preview-url-response.ts b/lib/packages/fabro-api-client/src/models/preview-url-response.ts index 32cdd984c..1529c5f46 100644 --- a/lib/packages/fabro-api-client/src/models/preview-url-response.ts +++ b/lib/packages/fabro-api-client/src/models/preview-url-response.ts @@ -19,8 +19,12 @@ */ export interface PreviewUrlResponse { /** - * Time-limited preview URL. + * Preview URL. */ 'url': string; + /** + * Preview token header value for unsigned preview URLs. + */ + 'token'?: string; } diff --git a/lib/packages/fabro-api-client/src/models/run-artifact-entry.ts b/lib/packages/fabro-api-client/src/models/run-artifact-entry.ts new file mode 100644 index 000000000..8530d6da8 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-artifact-entry.ts @@ -0,0 +1,42 @@ +/* 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. + */ + + + +/** + * A captured artifact file for a run. + */ +export interface RunArtifactEntry { + /** + * Stage ID in `node@visit` form. + */ + 'stage_id': string; + /** + * Node slug that produced the artifact. + */ + 'node_slug': string; + /** + * Retry attempt number. + */ + 'retry': number; + /** + * Artifact path relative to the stage artifact capture directory. + */ + 'relative_path': string; + /** + * Artifact size in bytes. + */ + 'size': number; +} + diff --git a/lib/packages/fabro-api-client/src/models/run-artifact-list-response.ts b/lib/packages/fabro-api-client/src/models/run-artifact-list-response.ts new file mode 100644 index 000000000..f07fb2188 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-artifact-list-response.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { RunArtifactEntry } from './run-artifact-entry'; + +/** + * List of captured artifact files for a run. + */ +export interface RunArtifactListResponse { + 'data': Array; +} + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-file-entry.ts b/lib/packages/fabro-api-client/src/models/sandbox-file-entry.ts new file mode 100644 index 000000000..ddc3db662 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-file-entry.ts @@ -0,0 +1,34 @@ +/* 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. + */ + + + +/** + * A directory entry in a run sandbox. + */ +export interface SandboxFileEntry { + /** + * Basename of the entry. + */ + 'name': string; + /** + * Whether the entry is a directory. + */ + 'is_dir': boolean; + /** + * File size in bytes when known. + */ + 'size'?: number; +} + diff --git a/lib/packages/fabro-api-client/src/models/sandbox-file-list-response.ts b/lib/packages/fabro-api-client/src/models/sandbox-file-list-response.ts new file mode 100644 index 000000000..5ab9b62f3 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/sandbox-file-list-response.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { SandboxFileEntry } from './sandbox-file-entry'; + +/** + * Non-paginated list of sandbox directory entries. + */ +export interface SandboxFileListResponse { + 'data': Array; +} + diff --git a/lib/packages/fabro-api-client/src/models/ssh-access-request.ts b/lib/packages/fabro-api-client/src/models/ssh-access-request.ts new file mode 100644 index 000000000..db98a2d05 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/ssh-access-request.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Request body for creating SSH access for a sandbox-backed run. + */ +export interface SshAccessRequest { + /** + * Time-to-live for the SSH command in minutes. + */ + 'ttl_minutes': number; +} + diff --git a/lib/packages/fabro-api-client/src/models/ssh-access-response.ts b/lib/packages/fabro-api-client/src/models/ssh-access-response.ts new file mode 100644 index 000000000..7e75bbe18 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/ssh-access-response.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Response containing an SSH command for the sandbox. + */ +export interface SshAccessResponse { + /** + * SSH command to connect to the sandbox. + */ + 'command': string; +} +