mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Merge pull request #747 from fabro-sh/codex/blob-hash-vocabulary
Some checks failed
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
Some checks failed
Rust / Format (push) Waiting to run
Rust / Clippy (push) Waiting to run
Rust / Generated Docs (push) Waiting to run
Rust / Test (Linux) (push) Waiting to run
Rust / Test (macOS) (push) Waiting to run
TypeScript / Typecheck (push) Has been cancelled
TypeScript / Test (push) Has been cancelled
TypeScript / Build (push) Has been cancelled
Unify blob hash vocabulary
This commit is contained in:
commit
2575ab85fc
583 changed files with 1020 additions and 814 deletions
|
|
@ -77,7 +77,7 @@ Emitted when the run record is created.
|
|||
| `source_directory` | string? | Submitter-side source directory |
|
||||
| `workflow_slug` | string? | Workflow slug |
|
||||
| `provenance` | object | Actor and request provenance |
|
||||
| `manifest_blob` | string? | Blob id for the submitted manifest |
|
||||
| `manifest_blob` | string? | Blob hash for the submitted manifest |
|
||||
| `git` | object? | Git provenance observed before the run: normalized `origin_url`, `branch`, optional `sha`, and `dirty` status |
|
||||
| `fork_source_ref` | object? | Source run/checkpoint reference when this run was forked |
|
||||
| `in_place` | boolean | Whether the run was created with `--in-place` (no git checkpoints) |
|
||||
|
|
|
|||
|
|
@ -219,10 +219,10 @@ When Fabro builds a [preamble](/execution/context#preamble-construction) for a d
|
|||
- **plan**: success
|
||||
- Model: claude-sonnet-4-5, 12.4k tokens in / 3.2k out
|
||||
- Files: src/main.rs, tests/api_test.rs
|
||||
- Response: See: /path/to/runtime/blobs/<blob_id>.json
|
||||
- Response: See: /path/to/runtime/blobs/<blob_hash>.json
|
||||
- **test**: success
|
||||
- Script: `cargo test 2>&1 || true`
|
||||
- Stdout: See: /path/to/runtime/blobs/<blob_id>.json
|
||||
- Stdout: See: /path/to/runtime/blobs/<blob_hash>.json
|
||||
```
|
||||
|
||||
This keeps preambles concise while still giving agents a path to read the full output if needed.
|
||||
|
|
@ -237,7 +237,7 @@ Captured stage artifacts such as screenshots, videos, reports, and traces still
|
|||
|
||||
For remote sandboxes (Docker, Daytona), execution-time file access happens inside the sandbox filesystem.
|
||||
|
||||
- Blob refs are materialized into `{working_directory}/.fabro/blobs/{blob_id}.json`
|
||||
- Blob refs are materialized into `{working_directory}/.fabro/blobs/{blob_hash}.json`
|
||||
- Explicit non-blob `file://` refs keep the existing copy-on-demand behavior and are copied into `{working_directory}/.fabro/artifacts/{filename}` when needed
|
||||
|
||||
In both cases, downstream handlers and agents continue to consume ordinary `file://` pointers during execution.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
openapi: "3.1.0"
|
||||
info:
|
||||
title: Fabro Run API
|
||||
version: "0.1.0"
|
||||
version: "0.2.0"
|
||||
description: HTTP API for managing Fabro workflow run executions.
|
||||
|
||||
tags:
|
||||
|
|
@ -3092,7 +3092,7 @@ paths:
|
|||
operationId: writeRunBlob
|
||||
tags: [Run Internals]
|
||||
summary: Write Run Blob
|
||||
description: Writes an opaque binary blob and returns its content-addressed blob identifier.
|
||||
description: Writes an opaque binary blob and returns its content-addressed blob hash.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
requestBody:
|
||||
|
|
@ -3137,15 +3137,15 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/api/v1/runs/{id}/blobs/{blobId}:
|
||||
/api/v1/runs/{id}/blobs/{blobHash}:
|
||||
get:
|
||||
operationId: readRunBlob
|
||||
tags: [Run Internals]
|
||||
summary: Read Run Blob
|
||||
description: Reads a previously stored blob by identifier.
|
||||
description: Reads a previously stored blob by hash.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/RunId"
|
||||
- $ref: "#/components/parameters/BlobId"
|
||||
- $ref: "#/components/parameters/BlobHash"
|
||||
responses:
|
||||
"200":
|
||||
description: Blob contents
|
||||
|
|
@ -5974,14 +5974,13 @@ components:
|
|||
default: 65536
|
||||
example: 65536
|
||||
|
||||
BlobId:
|
||||
name: blobId
|
||||
BlobHash:
|
||||
name: blobHash
|
||||
in: path
|
||||
required: true
|
||||
description: Content-addressed blob identifier.
|
||||
description: Content-addressed blob hash.
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^[0-9a-f]{64}$'
|
||||
$ref: "#/components/schemas/BlobHash"
|
||||
example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
|
||||
|
||||
ArtifactFilename:
|
||||
|
|
@ -9152,9 +9151,11 @@ components:
|
|||
example: graphs/main.fabro
|
||||
|
||||
WorkflowVersionId:
|
||||
description: SHA-256 identity of validated canonical workflow-version bytes.
|
||||
description: >-
|
||||
SHA-256 identity of validated canonical workflow-version bytes. Hex input is
|
||||
case-insensitive; Fabro emits the canonical lowercase form.
|
||||
type: string
|
||||
pattern: "^[0-9a-f]{64}$"
|
||||
pattern: "^[0-9A-Fa-f]{64}$"
|
||||
example: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
|
||||
WorkflowVersion:
|
||||
|
|
@ -10283,16 +10284,22 @@ components:
|
|||
description: Assigned event sequence number.
|
||||
example: 42
|
||||
|
||||
BlobHash:
|
||||
description: >-
|
||||
Content-addressed SHA-256 hash of a stored blob. Hex input is case-insensitive;
|
||||
Fabro emits the canonical lowercase form.
|
||||
type: string
|
||||
pattern: "^[0-9A-Fa-f]{64}$"
|
||||
example: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
|
||||
|
||||
WriteBlobResponse:
|
||||
description: Content-addressed identifier for a stored blob.
|
||||
description: Content-addressed hash of a stored blob.
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- hash
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Blob identifier.
|
||||
example: 550e8400-e29b-41d4-a716-446655440000
|
||||
hash:
|
||||
$ref: "#/components/schemas/BlobHash"
|
||||
|
||||
CommandTermination:
|
||||
description: Terminal state for a command execution.
|
||||
|
|
@ -10401,7 +10408,7 @@ components:
|
|||
example: src/lib.rs
|
||||
sha256:
|
||||
type: ["string", "null"]
|
||||
description: Optional lowercase hex SHA-256 checksum for the file contents.
|
||||
description: Optional SHA-256 checksum for the file contents; hex input is case-insensitive.
|
||||
example: 3f785df4c5b7d3f1f4c1f0ecb0f55f1d9f6f6a3d9f0a8a98f7a74f29d1f81a2c
|
||||
expected_bytes:
|
||||
type: ["integer", "null"]
|
||||
|
|
|
|||
|
|
@ -243,8 +243,8 @@ Checkpoints and checkpoint-completed events persist these `blob://` refs, not ho
|
|||
|
||||
Before Fabro builds a preamble or starts the next stage, it resolves any blob refs into execution-local files so handlers and agents still see normal `file://` references:
|
||||
|
||||
- Local execution materializes blobs under `{run_dir}/runtime/blobs/{blob_id}.json`
|
||||
- Remote sandboxes materialize blobs under `{working_directory}/.fabro/blobs/{blob_id}.json`
|
||||
- Local execution materializes blobs under `{run_dir}/runtime/blobs/{blob_hash}.json`
|
||||
- Remote sandboxes materialize blobs under `{working_directory}/.fabro/blobs/{blob_hash}.json`
|
||||
|
||||
These materialized `file://` paths are runtime-only. They are not written back into durable context snapshots.
|
||||
|
||||
|
|
|
|||
|
|
@ -86,8 +86,8 @@ async fn write_run_dump(
|
|||
dump.add_file_bytes("run.log", log);
|
||||
}
|
||||
|
||||
dump.hydrate_referenced_blobs_with_reader(|blob_id| {
|
||||
Box::pin(async move { client.read_run_blob(run_id, &blob_id).await })
|
||||
dump.hydrate_referenced_blobs_with_reader(|blob_hash| {
|
||||
Box::pin(async move { client.read_run_blob(run_id, &blob_hash).await })
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ use anyhow::{Context as _, Result};
|
|||
use cli_table::format::{Border, Justify, Separator};
|
||||
use cli_table::{Cell, CellStruct, Style, Table};
|
||||
use fabro_api::types;
|
||||
use fabro_types::{BlobHash, PullRequestLink, RunId, StageId, parse_blob_ref};
|
||||
use fabro_types::{PullRequestLink, RunId, StageId, parse_blob_ref};
|
||||
use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSection, CheckStatus};
|
||||
use fabro_util::error::render_with_causes;
|
||||
use fabro_util::printer::Printer;
|
||||
|
|
@ -325,11 +325,11 @@ async fn resolve_response_string(
|
|||
run_id: &RunId,
|
||||
response: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(blob_id) = blob_id_from_response(response) else {
|
||||
let Some(blob_hash) = parse_blob_ref(response) else {
|
||||
return Ok(Some(response.to_string()));
|
||||
};
|
||||
|
||||
let Some(bytes) = client.read_run_blob(run_id, &blob_id).await? else {
|
||||
let Some(bytes) = client.read_run_blob(run_id, &blob_hash).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value: serde_json::Value =
|
||||
|
|
@ -341,10 +341,6 @@ async fn resolve_response_string(
|
|||
}))
|
||||
}
|
||||
|
||||
fn blob_id_from_response(response: &str) -> Option<BlobHash> {
|
||||
parse_blob_ref(response)
|
||||
}
|
||||
|
||||
async fn list_artifact_display_entries_with_client(
|
||||
client: &server_client::Client,
|
||||
run_id: &RunId,
|
||||
|
|
|
|||
|
|
@ -1018,12 +1018,12 @@ impl RunStoreBackend for HttpRunStore {
|
|||
.await
|
||||
}
|
||||
|
||||
async fn read_blob(&self, id: &BlobHash) -> Result<Option<bytes::Bytes>> {
|
||||
async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<bytes::Bytes>> {
|
||||
self.with_retries("read run blob", || {
|
||||
let client = self.client.clone_for_reuse();
|
||||
let run_id = self.run_id;
|
||||
let blob_id = *id;
|
||||
async move { client.read_run_blob(&run_id, &blob_id).await }
|
||||
let blob_hash = *blob_hash;
|
||||
async move { client.read_run_blob(&run_id, &blob_hash).await }
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,20 +66,8 @@ fn format_output_snapshot(output: &Output, filters: &[(String, String)]) -> Stri
|
|||
}
|
||||
|
||||
fn normalize_attach_json_progress_event(mut event: Value) -> Value {
|
||||
if let Some(properties) = event.get_mut("properties").and_then(Value::as_object_mut) {
|
||||
if properties.contains_key("manifest_blob") {
|
||||
properties.insert(
|
||||
"manifest_blob".to_string(),
|
||||
Value::String("[BLOB_ID]".to_string()),
|
||||
);
|
||||
}
|
||||
if properties.contains_key("definition_blob") {
|
||||
properties.insert(
|
||||
"definition_blob".to_string(),
|
||||
Value::String("[BLOB_ID]".to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// manifest_blob/definition_blob hashes are already rewritten to
|
||||
// [BLOB_HASH] by the shared json_snapshot_filters regexes.
|
||||
// Strip v2-shape server/version fields that the bridge emits,
|
||||
// since the test fixture's socket path is randomised per run.
|
||||
if let Some(settings) = event
|
||||
|
|
@ -896,7 +884,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
}
|
||||
}
|
||||
},
|
||||
"manifest_blob": "[BLOB_ID]",
|
||||
"manifest_blob": "[BLOB_HASH]",
|
||||
"provenance": {
|
||||
"client": {
|
||||
"name": "fabro-cli",
|
||||
|
|
@ -1036,7 +1024,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
|
|||
"event": "run.submitted",
|
||||
"id": "[EVENT_ID]",
|
||||
"properties": {
|
||||
"definition_blob": "[BLOB_ID]"
|
||||
"definition_blob": "[BLOB_HASH]"
|
||||
},
|
||||
"run_id": "[ULID]",
|
||||
"ts": "[TIMESTAMP]"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use strum::IntoStaticStr;
|
|||
use crate::auth::{AuthErrorCode, JwtError, REFRESH_TOKEN_PREFIX};
|
||||
use crate::error::ApiError;
|
||||
use crate::jwt_auth::{self, AuthMode, ConfiguredAuth};
|
||||
use crate::server::{AppState, parse_blob_id_path, parse_run_id_path, parse_stage_id_path};
|
||||
use crate::server::{AppState, parse_blob_hash_path, parse_run_id_path, parse_stage_id_path};
|
||||
use crate::worker_token::{self, WORKER_TOKEN_KID, WorkerScopeSet};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -295,14 +295,14 @@ impl FromRequestParts<Arc<AppState>> for RequireRunBlob {
|
|||
parts: &mut Parts,
|
||||
state: &Arc<AppState>,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let Path((id, blob_id)): Path<(String, String)> = Path::from_request_parts(parts, state)
|
||||
let Path((id, blob_hash)): Path<(String, String)> = Path::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
let run_id = parse_run_id_path(&id)?;
|
||||
let blob_id = parse_blob_id_path(&blob_id)?;
|
||||
let blob_hash = parse_blob_hash_path(&blob_hash)?;
|
||||
require_worker_or_user_for_run(&auth_slot_from_parts(parts), &run_id)
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
Ok(Self(run_id, blob_id))
|
||||
Ok(Self(run_id, blob_hash))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2889,11 +2889,11 @@ pub(crate) fn parse_stage_id_path(stage_id: &str) -> Result<StageId, Response> {
|
|||
|
||||
#[allow(
|
||||
clippy::result_large_err,
|
||||
reason = "Blob ID parsing returns HTTP 400 responses directly."
|
||||
reason = "Blob hash parsing returns HTTP 400 responses directly."
|
||||
)]
|
||||
pub(crate) fn parse_blob_id_path(blob_id: &str) -> Result<BlobHash, Response> {
|
||||
BlobHash::from_str(blob_id)
|
||||
.map_err(|_| ApiError::bad_request("Invalid blob ID.").into_response())
|
||||
pub(crate) fn parse_blob_hash_path(blob_hash: &str) -> Result<BlobHash, Response> {
|
||||
BlobHash::from_str(blob_hash)
|
||||
.map_err(|_| ApiError::bad_request("Invalid blob hash.").into_response())
|
||||
}
|
||||
|
||||
#[allow(
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
|
|||
Router::new()
|
||||
.route("/runs/{id}/checkpoint", get(get_checkpoint))
|
||||
.route("/runs/{id}/blobs", post(write_run_blob))
|
||||
.route("/runs/{id}/blobs/{blobId}", get(read_run_blob))
|
||||
.route("/runs/{id}/blobs/{blobHash}", get(read_run_blob))
|
||||
.route("/runs/{id}/artifacts", get(list_run_artifacts))
|
||||
.route("/runs/{id}/artifacts/download", get(download_run_artifacts))
|
||||
.route(
|
||||
|
|
@ -105,10 +105,7 @@ async fn write_run_blob(
|
|||
}
|
||||
match state.stores.runs.open_run(&id).await {
|
||||
Ok(run_store) => match run_store.write_blob(&body).await {
|
||||
Ok(blob_id) => Json(WriteBlobResponse {
|
||||
id: blob_id.to_string(),
|
||||
})
|
||||
.into_response(),
|
||||
Ok(blob_hash) => Json(WriteBlobResponse { hash: blob_hash }).into_response(),
|
||||
Err(err) => {
|
||||
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
||||
}
|
||||
|
|
@ -118,11 +115,11 @@ async fn write_run_blob(
|
|||
}
|
||||
|
||||
async fn read_run_blob(
|
||||
RequireRunBlob(id, blob_id): RequireRunBlob,
|
||||
RequireRunBlob(id, blob_hash): RequireRunBlob,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Response {
|
||||
match state.stores.runs.open_run_reader(&id).await {
|
||||
Ok(run_store) => match run_store.read_blob(&blob_id).await {
|
||||
Ok(run_store) => match run_store.read_blob(&blob_hash).await {
|
||||
Ok(Some(bytes)) => octet_stream_response(bytes),
|
||||
Ok(None) => ApiError::not_found("Blob not found.").into_response(),
|
||||
Err(err) => {
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ pub(super) fn demo_routes() -> Router<Arc<AppState>> {
|
|||
)
|
||||
.route("/runs/{id}/attach", get(demo::run_events_stub))
|
||||
.route("/runs/{id}/blobs", post(not_implemented))
|
||||
.route("/runs/{id}/blobs/{blobId}", get(not_implemented))
|
||||
.route("/runs/{id}/blobs/{blobHash}", get(not_implemented))
|
||||
.route(
|
||||
"/runs/{id}/stages/{stageId}/logs/output",
|
||||
get(not_implemented),
|
||||
|
|
|
|||
|
|
@ -11034,7 +11034,7 @@ async fn get_checkpoint_returns_null_initially() {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_and_read_run_blob_round_trip() {
|
||||
async fn write_and_read_run_blob_accepts_uppercase_hash() {
|
||||
let state = test_app_state();
|
||||
let app = crate::test_support::build_test_router(Arc::clone(&state));
|
||||
|
||||
|
|
@ -11057,11 +11057,14 @@ async fn write_and_read_run_blob_round_trip() {
|
|||
.unwrap();
|
||||
let response = app.clone().oneshot(req).await.unwrap();
|
||||
let body = response_json!(response, StatusCode::OK).await;
|
||||
let blob_id = body["id"].as_str().unwrap();
|
||||
let blob_hash = body["hash"].as_str().unwrap();
|
||||
|
||||
let req = Request::builder()
|
||||
.method("GET")
|
||||
.uri(api(&format!("/runs/{run_id}/blobs/{blob_id}")))
|
||||
.uri(api(&format!(
|
||||
"/runs/{run_id}/blobs/{}",
|
||||
blob_hash.to_uppercase()
|
||||
)))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = app.oneshot(req).await.unwrap();
|
||||
|
|
@ -11459,7 +11462,7 @@ async fn worker_token_accepts_run_scoped_routes_and_falls_back_to_user_jwt() {
|
|||
let worker_token = issue_test_worker_token(&run_id);
|
||||
let other_run_id = create_run_with_bearer(&app, &user_jwt).await;
|
||||
let other_worker_token = issue_test_worker_token(&other_run_id);
|
||||
let blob_id = state
|
||||
let blob_hash = state
|
||||
.stores
|
||||
.runs
|
||||
.open_run(&run_id)
|
||||
|
|
@ -11553,7 +11556,7 @@ async fn worker_token_accepts_run_scoped_routes_and_falls_back_to_user_jwt() {
|
|||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
&format!("/runs/{run_id}/blobs/{blob_id}"),
|
||||
&format!("/runs/{run_id}/blobs/{blob_hash}"),
|
||||
&worker_token,
|
||||
Body::empty(),
|
||||
))
|
||||
|
|
@ -12058,7 +12061,7 @@ async fn worker_token_is_rejected_on_user_only_routes() {
|
|||
let user_jwt = issue_test_user_jwt();
|
||||
let run_id = create_run_with_bearer(&app, &user_jwt).await;
|
||||
let worker_token = issue_test_worker_token(&run_id);
|
||||
let blob_id = BlobHash::new(b"blob");
|
||||
let blob_hash = BlobHash::new(b"blob");
|
||||
let user_only_routes = vec![
|
||||
(Method::GET, "/runs".to_string()),
|
||||
(Method::POST, "/runs".to_string()),
|
||||
|
|
@ -12121,7 +12124,7 @@ async fn worker_token_is_rejected_on_user_only_routes() {
|
|||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
&format!("/runs/{run_id}/blobs/{blob_id}"),
|
||||
&format!("/runs/{run_id}/blobs/{blob_hash}"),
|
||||
&worker_token,
|
||||
Body::empty(),
|
||||
))
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
#[expect(
|
||||
clippy::disallowed_types,
|
||||
reason = "in-memory Vec<u8>::write_all for jsonl serialization; no filesystem or network I/O"
|
||||
|
|
@ -214,31 +215,42 @@ impl RunDump {
|
|||
for entry in &mut self.entries {
|
||||
match &mut entry.contents {
|
||||
RunDumpContents::Json(value) => {
|
||||
let mut blob_ids = Vec::new();
|
||||
collect_blob_refs_in_value(value, &mut blob_ids);
|
||||
for blob_id in blob_ids {
|
||||
if cache.contains_key(&blob_id) {
|
||||
let mut blob_hashes = Vec::new();
|
||||
collect_blob_refs_in_value(value, &mut blob_hashes);
|
||||
for blob_hash in blob_hashes {
|
||||
if cache.contains_key(&blob_hash) {
|
||||
continue;
|
||||
}
|
||||
let blob = read_blob(blob_id).await?.with_context(|| {
|
||||
format!("blob {blob_id:?} is missing from the store")
|
||||
let blob = read_blob(blob_hash).await?.with_context(|| {
|
||||
format!("blob {blob_hash:?} is missing from the store")
|
||||
})?;
|
||||
let hydrated: serde_json::Value = serde_json::from_slice(&blob)
|
||||
.with_context(|| format!("blob {blob_id:?} is not valid JSON"))?;
|
||||
cache.insert(blob_id, hydrated);
|
||||
.with_context(|| format!("blob {blob_hash:?} is not valid JSON"))?;
|
||||
cache.insert(blob_hash, hydrated);
|
||||
}
|
||||
replace_blob_refs_in_value(value, &cache)?;
|
||||
}
|
||||
RunDumpContents::Text(text) => {
|
||||
let Some(blob_id) = parse_blob_ref(text) else {
|
||||
let Some(blob_hash) = parse_blob_ref(text) else {
|
||||
continue;
|
||||
};
|
||||
let blob = read_blob(blob_id)
|
||||
.await?
|
||||
.with_context(|| format!("blob {blob_id:?} is missing from the store"))?;
|
||||
*text = serde_json::from_slice::<String>(&blob).with_context(|| {
|
||||
format!("blob {blob_id:?} is not a JSON string text log")
|
||||
})?;
|
||||
let hydrated = match cache.entry(blob_hash) {
|
||||
Entry::Occupied(entry) => entry.into_mut(),
|
||||
Entry::Vacant(entry) => {
|
||||
let blob = read_blob(blob_hash).await?.with_context(|| {
|
||||
format!("blob {blob_hash:?} is missing from the store")
|
||||
})?;
|
||||
let hydrated: serde_json::Value = serde_json::from_slice(&blob)
|
||||
.with_context(|| format!("blob {blob_hash:?} is not valid JSON"))?;
|
||||
entry.insert(hydrated)
|
||||
}
|
||||
};
|
||||
*text = hydrated
|
||||
.as_str()
|
||||
.with_context(|| {
|
||||
format!("blob {blob_hash:?} is not a JSON string text log")
|
||||
})?
|
||||
.to_string();
|
||||
}
|
||||
RunDumpContents::Bytes(_) => {}
|
||||
}
|
||||
|
|
@ -386,21 +398,21 @@ fn validate_relative_path(kind: &str, value: &str) -> Result<PathBuf> {
|
|||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn collect_blob_refs_in_value(value: &serde_json::Value, blob_ids: &mut Vec<BlobHash>) {
|
||||
fn collect_blob_refs_in_value(value: &serde_json::Value, blob_hashes: &mut Vec<BlobHash>) {
|
||||
match value {
|
||||
serde_json::Value::String(current) => {
|
||||
if let Some(blob_id) = parse_blob_ref(current) {
|
||||
blob_ids.push(blob_id);
|
||||
if let Some(blob_hash) = parse_blob_ref(current) {
|
||||
blob_hashes.push(blob_hash);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
for item in items {
|
||||
collect_blob_refs_in_value(item, blob_ids);
|
||||
collect_blob_refs_in_value(item, blob_hashes);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Object(map) => {
|
||||
for item in map.values() {
|
||||
collect_blob_refs_in_value(item, blob_ids);
|
||||
collect_blob_refs_in_value(item, blob_hashes);
|
||||
}
|
||||
}
|
||||
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
|
||||
|
|
@ -413,13 +425,12 @@ fn replace_blob_refs_in_value(
|
|||
) -> Result<()> {
|
||||
match value {
|
||||
serde_json::Value::String(current) => {
|
||||
let Some(blob_id) = parse_blob_ref(current) else {
|
||||
let Some(blob_hash) = parse_blob_ref(current) else {
|
||||
return Ok(());
|
||||
};
|
||||
let hydrated = cache
|
||||
.get(&blob_id)
|
||||
.cloned()
|
||||
.with_context(|| format!("blob {blob_id:?} is missing from the hydration cache"))?;
|
||||
let hydrated = cache.get(&blob_hash).cloned().with_context(|| {
|
||||
format!("blob {blob_hash:?} is missing from the hydration cache")
|
||||
})?;
|
||||
*value = hydrated;
|
||||
}
|
||||
serde_json::Value::Array(items) => {
|
||||
|
|
@ -724,8 +735,8 @@ mod tests {
|
|||
#[test]
|
||||
fn hydrate_referenced_blobs_ignores_legacy_artifact_file_refs() {
|
||||
let blob = serde_json::to_vec("hydrated legacy text").unwrap();
|
||||
let blob_id = fabro_types::BlobHash::new(&blob);
|
||||
let legacy_ref = format!("file:///sandbox/.fabro/artifacts/{blob_id}.json");
|
||||
let blob_hash = fabro_types::BlobHash::new(&blob);
|
||||
let legacy_ref = format!("file:///sandbox/.fabro/artifacts/{blob_hash}.json");
|
||||
let mut dump = RunDump {
|
||||
entries: vec![RunDumpEntry::json(
|
||||
"run.json",
|
||||
|
|
@ -736,10 +747,10 @@ mod tests {
|
|||
};
|
||||
|
||||
executor::block_on(async {
|
||||
dump.hydrate_referenced_blobs_with_reader(|read_blob_id| {
|
||||
dump.hydrate_referenced_blobs_with_reader(|read_blob_hash| {
|
||||
let blob = blob.clone();
|
||||
Box::pin(async move {
|
||||
assert_eq!(read_blob_id, blob_id);
|
||||
assert_eq!(read_blob_hash, blob_hash);
|
||||
Ok(Some(bytes::Bytes::from(blob)))
|
||||
})
|
||||
})
|
||||
|
|
@ -752,4 +763,43 @@ mod tests {
|
|||
};
|
||||
assert_eq!(value["stdout"], legacy_ref);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hydrate_referenced_blobs_fetches_shared_blobs_once() {
|
||||
let blob = serde_json::to_vec("offloaded response text").unwrap();
|
||||
let blob_hash = fabro_types::BlobHash::new(&blob);
|
||||
let blob_ref = fabro_types::format_blob_ref(&blob_hash);
|
||||
let mut dump = RunDump {
|
||||
entries: vec![
|
||||
RunDumpEntry::json("run.json", serde_json::json!({ "response": blob_ref })),
|
||||
RunDumpEntry::text("stages/001-demo@1/response.md", blob_ref.clone()),
|
||||
],
|
||||
stage_ranks: HashMap::new(),
|
||||
dump_log_index: None,
|
||||
};
|
||||
|
||||
let reads = std::cell::Cell::new(0);
|
||||
executor::block_on(async {
|
||||
dump.hydrate_referenced_blobs_with_reader(|read_blob_hash| {
|
||||
reads.set(reads.get() + 1);
|
||||
let blob = blob.clone();
|
||||
Box::pin(async move {
|
||||
assert_eq!(read_blob_hash, blob_hash);
|
||||
Ok(Some(bytes::Bytes::from(blob)))
|
||||
})
|
||||
})
|
||||
.await
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(reads.get(), 1, "shared blob should be fetched once");
|
||||
let RunDumpContents::Json(value) = &dump.entries[0].contents else {
|
||||
panic!("entry should be JSON");
|
||||
};
|
||||
assert_eq!(value["response"], "offloaded response text");
|
||||
let RunDumpContents::Text(text) = &dump.entries[1].contents else {
|
||||
panic!("entry should be text");
|
||||
};
|
||||
assert_eq!(text, "offloaded response text");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,12 +56,12 @@ impl BlobStore {
|
|||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn read(&self, id: &BlobHash) -> Result<Option<Bytes>> {
|
||||
Ok(self.repo.get(id).await?.map(|blob| blob.0))
|
||||
pub async fn read(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
Ok(self.repo.get(blob_hash).await?.map(|blob| blob.0))
|
||||
}
|
||||
|
||||
pub async fn exists(&self, id: &BlobHash) -> Result<bool> {
|
||||
self.repo.exists(id).await
|
||||
pub async fn exists(&self, blob_hash: &BlobHash) -> Result<bool> {
|
||||
self.repo.exists(blob_hash).await
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -836,12 +836,12 @@ mod tests {
|
|||
append_created(&run_2, "run-2", dt("2026-03-27T12:00:10Z")).await;
|
||||
|
||||
let shared_blob = br#"{"summary":"shared"}"#;
|
||||
let shared_blob_id = run_1.write_blob(shared_blob).await.unwrap();
|
||||
let shared_blob_hash = run_1.write_blob(shared_blob).await.unwrap();
|
||||
|
||||
store.delete_run(&test_run_id("run-1")).await.unwrap();
|
||||
|
||||
let reopened = store.open_run(&test_run_id("run-2")).await.unwrap();
|
||||
let read = reopened.read_blob(&shared_blob_id).await.unwrap();
|
||||
let read = reopened.read_blob(&shared_blob_hash).await.unwrap();
|
||||
assert_eq!(read.as_deref(), Some(shared_blob.as_slice()));
|
||||
}
|
||||
|
||||
|
|
@ -851,7 +851,7 @@ mod tests {
|
|||
let run = store.create_run(&test_run_id("run-1")).await.unwrap();
|
||||
append_created(&run, "run-1", dt("2026-03-27T12:00:00Z")).await;
|
||||
let blob = br#"{"summary":"readable"}"#;
|
||||
let blob_id = run.write_blob(blob).await.unwrap();
|
||||
let blob_hash = run.write_blob(blob).await.unwrap();
|
||||
|
||||
// Evict the cached writer so the reader is built through the real
|
||||
// `open_run_reader` construction path, not a clone of the writer.
|
||||
|
|
@ -859,7 +859,7 @@ mod tests {
|
|||
|
||||
let reader = store.open_run_reader(&test_run_id("run-1")).await.unwrap();
|
||||
assert_eq!(
|
||||
reader.read_blob(&blob_id).await.unwrap().as_deref(),
|
||||
reader.read_blob(&blob_hash).await.unwrap().as_deref(),
|
||||
Some(blob.as_slice())
|
||||
);
|
||||
let err = reader.write_blob(b"blocked").await.unwrap_err();
|
||||
|
|
|
|||
|
|
@ -561,8 +561,8 @@ impl RunDatabase {
|
|||
self.inner.blob_store.write(data).await
|
||||
}
|
||||
|
||||
pub async fn read_blob(&self, id: &BlobHash) -> Result<Option<Bytes>> {
|
||||
self.inner.blob_store.read(id).await
|
||||
pub async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
self.inner.blob_store.read(blob_hash).await
|
||||
}
|
||||
|
||||
pub async fn state(&self) -> Result<RunProjection> {
|
||||
|
|
|
|||
|
|
@ -148,10 +148,10 @@ impl WorkflowVersionStore {
|
|||
&self,
|
||||
id: &WorkflowVersionId,
|
||||
) -> Result<Option<ValidatedWorkflowVersion>, WorkflowVersionStoreError> {
|
||||
let blob_id = (*id).into();
|
||||
let blob_hash = (*id).into();
|
||||
let Some(bytes) = self
|
||||
.blobs
|
||||
.read(&blob_id)
|
||||
.read(&blob_hash)
|
||||
.await
|
||||
.map_err(|source| WorkflowVersionStoreError::Storage { source })?
|
||||
else {
|
||||
|
|
@ -273,8 +273,11 @@ mod tests {
|
|||
|
||||
let id = store.put(&version).await.unwrap();
|
||||
assert_eq!(id, expected_id);
|
||||
let blob_id = id.into();
|
||||
assert_eq!(blobs.read(&blob_id).await.unwrap().unwrap(), expected_bytes);
|
||||
let blob_hash = id.into();
|
||||
assert_eq!(
|
||||
blobs.read(&blob_hash).await.unwrap().unwrap(),
|
||||
expected_bytes
|
||||
);
|
||||
assert_eq!(store.get(&id).await.unwrap(), Some(version));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const ARTIFACT_POINTER_PREFIX: &str = "file://";
|
|||
///
|
||||
/// For each entry in `updates` whose serialized JSON exceeds
|
||||
/// `BLOB_OFFLOAD_THRESHOLD`, the value is persisted as a blob in `run_store`
|
||||
/// and replaced with a `"blob://sha256/{blob_id}"` reference.
|
||||
/// and replaced with a `"blob://sha256/{blob_hash}"` reference.
|
||||
/// Small values are left untouched.
|
||||
///
|
||||
/// `parallel.results` is offloaded at each branch context-update boundary
|
||||
|
|
@ -102,11 +102,11 @@ async fn offload_value(value: &mut Value, run_store: &RunStoreHandle) -> Result<
|
|||
.map_err(|e| Error::engine_with_source("artifact serialize failed", e))?;
|
||||
|
||||
if bytes.len() > BLOB_OFFLOAD_THRESHOLD {
|
||||
let blob_id = run_store
|
||||
let blob_hash = run_store
|
||||
.write_blob(&bytes)
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_anyhow("artifact blob write failed", e))?;
|
||||
*value = Value::String(format_blob_ref(&blob_id));
|
||||
*value = Value::String(format_blob_ref(&blob_hash));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -204,8 +204,16 @@ pub async fn resolve_outcomes_for_execution(
|
|||
run_dir: &Path,
|
||||
) -> Result<HashMap<String, Outcome>> {
|
||||
let mut resolved = node_outcomes.clone();
|
||||
let mut locality = SandboxLocality::default();
|
||||
for outcome in resolved.values_mut() {
|
||||
resolve_execution_values(&mut outcome.context_updates, run_store, env, run_dir).await?;
|
||||
resolve_execution_values(
|
||||
&mut outcome.context_updates,
|
||||
run_store,
|
||||
env,
|
||||
run_dir,
|
||||
&mut locality,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
|
@ -217,7 +225,8 @@ pub async fn resolved_context_snapshot(
|
|||
run_dir: &Path,
|
||||
) -> Result<HashMap<String, Value>> {
|
||||
let mut values = context.snapshot();
|
||||
resolve_execution_values(&mut values, run_store, env, run_dir).await?;
|
||||
let mut locality = SandboxLocality::default();
|
||||
resolve_execution_values(&mut values, run_store, env, run_dir, &mut locality).await?;
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
|
|
@ -232,17 +241,17 @@ pub async fn resolve_text_or_blob_ref(value: &Value, run_store: &RunStoreHandle)
|
|||
/// blob reference.
|
||||
///
|
||||
/// Managed `file://` references are normalized through their content-addressed
|
||||
/// blob id instead of reading an execution-local path. Ordinary strings and
|
||||
/// blob hash instead of reading an execution-local path. Ordinary strings and
|
||||
/// ordinary file references remain unchanged for the caller to validate.
|
||||
pub(crate) async fn resolve_json_value(value: Value, run_store: &RunStoreHandle) -> Result<Value> {
|
||||
let blob_id = value.as_str().and_then(|reference| {
|
||||
let blob_hash = value.as_str().and_then(|reference| {
|
||||
parse_blob_ref(reference).or_else(|| parse_managed_blob_file_ref(reference))
|
||||
});
|
||||
let Some(blob_id) = blob_id else {
|
||||
let Some(blob_hash) = blob_hash else {
|
||||
return Ok(value);
|
||||
};
|
||||
|
||||
let bytes = read_required_blob(&blob_id, run_store).await?;
|
||||
let bytes = read_required_blob(&blob_hash, run_store).await?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|err| Error::engine_with_source("artifact blob was not valid JSON", err))
|
||||
}
|
||||
|
|
@ -267,14 +276,14 @@ pub async fn resolve_text_or_blob_ref_str(
|
|||
current: &str,
|
||||
run_store: &RunStoreHandle,
|
||||
) -> Result<String> {
|
||||
let Some(blob_id) = parse_blob_ref(current) else {
|
||||
let Some(blob_hash) = parse_blob_ref(current) else {
|
||||
return Ok(current.to_string());
|
||||
};
|
||||
let bytes = run_store
|
||||
.read_blob(&blob_id)
|
||||
.read_blob(&blob_hash)
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_anyhow("text blob read failed", e))?
|
||||
.ok_or_else(|| Error::engine(format!("text blob missing: {blob_id}")))?;
|
||||
.ok_or_else(|| Error::engine(format!("text blob missing: {blob_hash}")))?;
|
||||
serde_json::from_slice::<String>(&bytes)
|
||||
.map_err(|e| Error::engine_with_source("text blob was not a JSON string", e))
|
||||
}
|
||||
|
|
@ -334,8 +343,8 @@ pub async fn sync_artifacts_to_env(
|
|||
fn normalize_durable_value(value: &mut Value) {
|
||||
match value {
|
||||
Value::String(current) => {
|
||||
if let Some(blob_id) = parse_managed_blob_file_ref(current) {
|
||||
*current = format_blob_ref(&blob_id);
|
||||
if let Some(blob_hash) = parse_managed_blob_file_ref(current) {
|
||||
*current = format_blob_ref(&blob_hash);
|
||||
}
|
||||
}
|
||||
Value::Array(items) => {
|
||||
|
|
@ -357,10 +366,12 @@ fn resolve_execution_values<'a>(
|
|||
run_store: &'a RunStoreHandle,
|
||||
env: &'a dyn Sandbox,
|
||||
run_dir: &'a Path,
|
||||
locality: &'a mut SandboxLocality,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
for (key, value) in values.iter_mut() {
|
||||
resolve_execution_value(Some(key.as_str()), value, run_store, env, run_dir).await?;
|
||||
resolve_execution_value(Some(key.as_str()), value, run_store, env, run_dir, locality)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
|
@ -376,14 +387,16 @@ fn resolve_execution_value<'a>(
|
|||
run_store: &'a RunStoreHandle,
|
||||
env: &'a dyn Sandbox,
|
||||
run_dir: &'a Path,
|
||||
locality: &'a mut SandboxLocality,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
match value {
|
||||
Value::String(current) => {
|
||||
if key.is_some_and(is_text_context_key) {
|
||||
*current = resolve_text_or_blob_ref_str(current, run_store).await?;
|
||||
} else if let Some(blob_id) = parse_blob_ref(current) {
|
||||
*current = materialize_blob_ref(&blob_id, run_store, env, run_dir).await?;
|
||||
} else if let Some(blob_hash) = parse_blob_ref(current) {
|
||||
*current =
|
||||
materialize_blob_ref(&blob_hash, run_store, env, run_dir, locality).await?;
|
||||
} else if current.starts_with(ARTIFACT_POINTER_PREFIX)
|
||||
&& parse_managed_blob_file_ref(current).is_none()
|
||||
{
|
||||
|
|
@ -392,7 +405,7 @@ fn resolve_execution_value<'a>(
|
|||
}
|
||||
Value::Array(items) => {
|
||||
for item in items {
|
||||
resolve_execution_value(key, item, run_store, env, run_dir).await?;
|
||||
resolve_execution_value(key, item, run_store, env, run_dir, locality).await?;
|
||||
}
|
||||
}
|
||||
Value::Object(map) => {
|
||||
|
|
@ -402,8 +415,15 @@ fn resolve_execution_value<'a>(
|
|||
} else {
|
||||
Some(child_key.as_str())
|
||||
};
|
||||
resolve_execution_value(child_context_key, item, run_store, env, run_dir)
|
||||
.await?;
|
||||
resolve_execution_value(
|
||||
child_context_key,
|
||||
item,
|
||||
run_store,
|
||||
env,
|
||||
run_dir,
|
||||
locality,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) => {}
|
||||
|
|
@ -413,17 +433,18 @@ fn resolve_execution_value<'a>(
|
|||
}
|
||||
|
||||
async fn materialize_blob_ref(
|
||||
blob_id: &BlobHash,
|
||||
blob_hash: &BlobHash,
|
||||
run_store: &RunStoreHandle,
|
||||
env: &dyn Sandbox,
|
||||
run_dir: &Path,
|
||||
locality: &mut SandboxLocality,
|
||||
) -> Result<String> {
|
||||
// Blobs are content-addressed, so an existing materialized file is always
|
||||
// current — check before paying for the store read.
|
||||
if is_local_execution(env, run_dir).await? {
|
||||
let path = local_materialized_blob_path(run_dir, blob_id);
|
||||
if locality.is_local(env, run_dir).await? {
|
||||
let path = local_materialized_blob_path(run_dir, blob_hash);
|
||||
if !path.exists() {
|
||||
let bytes = read_required_blob(blob_id, run_store).await?;
|
||||
let bytes = read_required_blob(blob_hash, run_store).await?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await.map_err(|err| {
|
||||
Error::Io(format!(
|
||||
|
|
@ -439,13 +460,13 @@ async fn materialize_blob_ref(
|
|||
return Ok(format!("{ARTIFACT_POINTER_PREFIX}{}", path.display()));
|
||||
}
|
||||
|
||||
let remote_path = format!("{}/.fabro/blobs/{blob_id}.json", env.working_directory());
|
||||
let remote_path = format!("{}/.fabro/blobs/{blob_hash}.json", env.working_directory());
|
||||
if !env
|
||||
.file_exists(&remote_path)
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_source("failed to check blob existence", e))?
|
||||
{
|
||||
let bytes = read_required_blob(blob_id, run_store).await?;
|
||||
let bytes = read_required_blob(blob_hash, run_store).await?;
|
||||
let content = String::from_utf8(bytes.to_vec())
|
||||
.map_err(|e| Error::engine_with_source("artifact blob was not valid UTF-8 JSON", e))?;
|
||||
env.write_file(&remote_path, &content).await.map_err(|e| {
|
||||
|
|
@ -457,14 +478,14 @@ async fn materialize_blob_ref(
|
|||
}
|
||||
|
||||
async fn read_required_blob(
|
||||
blob_id: &BlobHash,
|
||||
blob_hash: &BlobHash,
|
||||
run_store: &RunStoreHandle,
|
||||
) -> Result<bytes::Bytes> {
|
||||
run_store
|
||||
.read_blob(blob_id)
|
||||
.read_blob(blob_hash)
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_anyhow("artifact blob read failed", e))?
|
||||
.ok_or_else(|| Error::engine(format!("artifact blob missing: {blob_id}")))
|
||||
.ok_or_else(|| Error::engine(format!("artifact blob missing: {blob_hash}")))
|
||||
}
|
||||
|
||||
async fn resolve_explicit_file_ref(value: &str, env: &dyn Sandbox) -> Result<String> {
|
||||
|
|
@ -502,17 +523,33 @@ async fn resolve_explicit_file_ref(value: &str, env: &dyn Sandbox) -> Result<Str
|
|||
Ok(format!("{ARTIFACT_POINTER_PREFIX}{remote_path}"))
|
||||
}
|
||||
|
||||
async fn is_local_execution(env: &dyn Sandbox, run_dir: &Path) -> Result<bool> {
|
||||
env.file_exists(&run_dir.to_string_lossy())
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_source("failed to inspect sandbox locality", e))
|
||||
/// Memoized sandbox locality for one resolution pass. The sandbox and run
|
||||
/// directory are invariant across a pass, so the (possibly remote) probe is
|
||||
/// paid at most once instead of once per blob reference.
|
||||
#[derive(Default)]
|
||||
struct SandboxLocality {
|
||||
cached: Option<bool>,
|
||||
}
|
||||
|
||||
fn local_materialized_blob_path(run_dir: &Path, blob_id: &BlobHash) -> PathBuf {
|
||||
impl SandboxLocality {
|
||||
async fn is_local(&mut self, env: &dyn Sandbox, run_dir: &Path) -> Result<bool> {
|
||||
if let Some(local) = self.cached {
|
||||
return Ok(local);
|
||||
}
|
||||
let local = env
|
||||
.file_exists(&run_dir.to_string_lossy())
|
||||
.await
|
||||
.map_err(|e| Error::engine_with_source("failed to inspect sandbox locality", e))?;
|
||||
self.cached = Some(local);
|
||||
Ok(local)
|
||||
}
|
||||
}
|
||||
|
||||
fn local_materialized_blob_path(run_dir: &Path, blob_hash: &BlobHash) -> PathBuf {
|
||||
RunScratch::new(run_dir)
|
||||
.runtime_dir()
|
||||
.join("blobs")
|
||||
.join(format!("{blob_id}.json"))
|
||||
.join(format!("{blob_hash}.json"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -549,7 +586,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::BlobHash::new(&serialized);
|
||||
let expected_blob_hash = fabro_types::BlobHash::new(&serialized);
|
||||
|
||||
let mut updates = HashMap::new();
|
||||
updates.insert("response.plan".to_string(), serde_json::json!(large_string));
|
||||
|
|
@ -561,11 +598,11 @@ mod tests {
|
|||
let pointer = updates.get("response.plan").unwrap();
|
||||
assert_eq!(
|
||||
pointer,
|
||||
&serde_json::json!(fabro_types::format_blob_ref(&expected_blob_id))
|
||||
&serde_json::json!(fabro_types::format_blob_ref(&expected_blob_hash))
|
||||
);
|
||||
|
||||
let blob = run_store
|
||||
.read_blob(&expected_blob_id)
|
||||
.read_blob(&expected_blob_hash)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("blob should exist");
|
||||
|
|
@ -591,21 +628,21 @@ mod tests {
|
|||
async fn resolve_json_value_hydrates_blob_and_managed_file_references() {
|
||||
let run_store = make_run_store("structured-json-resolution").await;
|
||||
let value = serde_json::json!([{"name": "api"}, {"name": "web"}]);
|
||||
let blob_id = run_store
|
||||
let blob_hash = run_store
|
||||
.write_blob(&serde_json::to_vec(&value).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let handle = run_store.clone().into();
|
||||
|
||||
assert_eq!(
|
||||
resolve_json_value(serde_json::json!(format_blob_ref(&blob_id)), &handle)
|
||||
resolve_json_value(serde_json::json!(format_blob_ref(&blob_hash)), &handle)
|
||||
.await
|
||||
.unwrap(),
|
||||
value
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_json_value(
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_id}.json")),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_hash}.json")),
|
||||
&handle,
|
||||
)
|
||||
.await
|
||||
|
|
@ -787,15 +824,43 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_context_probes_sandbox_locality_once_per_pass() {
|
||||
let run_store = make_run_store("locality-probe-memoization").await;
|
||||
let first_blob = run_store
|
||||
.write_blob(&serde_json::to_vec(&serde_json::json!({"a": 1})).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let second_blob = run_store
|
||||
.write_blob(&serde_json::to_vec(&serde_json::json!({"b": 2})).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let context = Context::new();
|
||||
context.set("first", fabro_types::format_blob_ref(&first_blob).into());
|
||||
context.set("second", fabro_types::format_blob_ref(&second_blob).into());
|
||||
let env = TestSyncEnv::new(true, "/workspace");
|
||||
let run_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
resolved_context_snapshot(&context, &run_store.clone().into(), &env, run_dir.path())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
*env.exists_calls.lock().unwrap(),
|
||||
1,
|
||||
"sandbox locality should be probed once per resolution pass"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_durable_updates_rewrites_managed_blob_file_refs_recursively() {
|
||||
let blob_id = fabro_types::BlobHash::new(b"hello");
|
||||
let blob_hash = fabro_types::BlobHash::new(b"hello");
|
||||
let mut updates = HashMap::from([(
|
||||
"nested".to_string(),
|
||||
serde_json::json!({
|
||||
"items": [
|
||||
format!("file:///tmp/run/runtime/blobs/{blob_id}.json"),
|
||||
format!("file:///sandbox/.fabro/blobs/{blob_id}.json"),
|
||||
format!("file:///tmp/run/runtime/blobs/{blob_hash}.json"),
|
||||
format!("file:///sandbox/.fabro/blobs/{blob_hash}.json"),
|
||||
"file:///tmp/report.json",
|
||||
]
|
||||
}),
|
||||
|
|
@ -807,8 +872,8 @@ mod tests {
|
|||
updates["nested"],
|
||||
serde_json::json!({
|
||||
"items": [
|
||||
fabro_types::format_blob_ref(&blob_id),
|
||||
fabro_types::format_blob_ref(&blob_id),
|
||||
fabro_types::format_blob_ref(&blob_hash),
|
||||
fabro_types::format_blob_ref(&blob_hash),
|
||||
"file:///tmp/report.json",
|
||||
]
|
||||
})
|
||||
|
|
@ -870,7 +935,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn normalize_checkpoint_for_resume_converts_managed_blob_file_refs_and_drops_preamble() {
|
||||
let blob_id = fabro_types::BlobHash::new(b"managed");
|
||||
let blob_hash = fabro_types::BlobHash::new(b"managed");
|
||||
let mut checkpoint = crate::records::Checkpoint {
|
||||
timestamp: chrono::Utc::now(),
|
||||
current_node: "work".to_string(),
|
||||
|
|
@ -883,7 +948,7 @@ mod tests {
|
|||
),
|
||||
(
|
||||
"response.work".to_string(),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_id}.json")),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_hash}.json")),
|
||||
),
|
||||
]),
|
||||
node_outcomes: HashMap::from([(
|
||||
|
|
@ -891,7 +956,7 @@ mod tests {
|
|||
crate::outcome::Outcome {
|
||||
context_updates: HashMap::from([(
|
||||
"response.work".to_string(),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_id}.json")),
|
||||
serde_json::json!(format!("file:///sandbox/.fabro/blobs/{blob_hash}.json")),
|
||||
)]),
|
||||
..crate::outcome::Outcome::success()
|
||||
},
|
||||
|
|
@ -912,14 +977,14 @@ mod tests {
|
|||
);
|
||||
assert_eq!(
|
||||
checkpoint.context_values.get("response.work"),
|
||||
Some(&serde_json::json!(fabro_types::format_blob_ref(&blob_id)))
|
||||
Some(&serde_json::json!(fabro_types::format_blob_ref(&blob_hash)))
|
||||
);
|
||||
assert_eq!(
|
||||
checkpoint
|
||||
.node_outcomes
|
||||
.get("work")
|
||||
.and_then(|outcome| outcome.context_updates.get("response.work")),
|
||||
Some(&serde_json::json!(fabro_types::format_blob_ref(&blob_id)))
|
||||
Some(&serde_json::json!(fabro_types::format_blob_ref(&blob_hash)))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -928,9 +993,10 @@ mod tests {
|
|||
use std::sync::Mutex;
|
||||
|
||||
struct TestSyncEnv {
|
||||
accessible: bool,
|
||||
written: Mutex<Vec<(String, String)>>,
|
||||
working_dir: String,
|
||||
accessible: bool,
|
||||
written: Mutex<Vec<(String, String)>>,
|
||||
working_dir: String,
|
||||
exists_calls: Mutex<usize>,
|
||||
}
|
||||
|
||||
impl TestSyncEnv {
|
||||
|
|
@ -939,6 +1005,7 @@ mod tests {
|
|||
accessible,
|
||||
written: Mutex::new(Vec::new()),
|
||||
working_dir: working_dir.to_string(),
|
||||
exists_calls: Mutex::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -962,6 +1029,7 @@ mod tests {
|
|||
}
|
||||
|
||||
async fn file_exists(&self, _path: &str) -> fabro_sandbox::Result<bool> {
|
||||
*self.exists_calls.lock().unwrap() += 1;
|
||||
Ok(self.accessible)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -109,14 +109,14 @@ pub async fn read_json_string_blob(
|
|||
run_store: &RunStoreHandle,
|
||||
blob_ref: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(blob_id) = fabro_types::parse_blob_ref(blob_ref) else {
|
||||
let Some(blob_hash) = fabro_types::parse_blob_ref(blob_ref) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = run_store
|
||||
.read_blob(&blob_id)
|
||||
.read_blob(&blob_hash)
|
||||
.await
|
||||
.map_err(|err| Error::engine_with_anyhow("command log blob read failed", err))?
|
||||
.ok_or_else(|| Error::engine(format!("command log blob missing: {blob_id}")))?;
|
||||
.ok_or_else(|| Error::engine(format!("command log blob missing: {blob_hash}")))?;
|
||||
let text = serde_json::from_slice::<String>(&bytes)
|
||||
.map_err(|err| Error::engine_with_source("command log blob was not a JSON string", err))?;
|
||||
Ok(Some(text))
|
||||
|
|
@ -155,9 +155,9 @@ async fn write_json_string_blob(run_store: &RunStoreHandle, text: &str) -> Resul
|
|||
let value = Value::String(text.to_string());
|
||||
let bytes = serde_json::to_vec(&value)
|
||||
.map_err(|err| Error::engine_with_source("command log JSON serialization failed", err))?;
|
||||
let blob_id = run_store
|
||||
let blob_hash = run_store
|
||||
.write_blob(&bytes)
|
||||
.await
|
||||
.map_err(|err| Error::engine_with_anyhow("command log blob write failed", err))?;
|
||||
Ok(format_blob_ref(&blob_id))
|
||||
Ok(format_blob_ref(&blob_hash))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -390,16 +390,19 @@ mod tests {
|
|||
}
|
||||
|
||||
async fn write_blob(&self, data: &[u8]) -> anyhow::Result<fabro_types::BlobHash> {
|
||||
let blob_id = fabro_types::BlobHash::new(data);
|
||||
let blob_hash = fabro_types::BlobHash::new(data);
|
||||
self.blobs
|
||||
.lock()
|
||||
.await
|
||||
.insert(blob_id, Bytes::copy_from_slice(data));
|
||||
Ok(blob_id)
|
||||
.insert(blob_hash, Bytes::copy_from_slice(data));
|
||||
Ok(blob_hash)
|
||||
}
|
||||
|
||||
async fn read_blob(&self, id: &fabro_types::BlobHash) -> anyhow::Result<Option<Bytes>> {
|
||||
Ok(self.blobs.lock().await.get(id).cloned())
|
||||
async fn read_blob(
|
||||
&self,
|
||||
blob_hash: &fabro_types::BlobHash,
|
||||
) -> anyhow::Result<Option<Bytes>> {
|
||||
Ok(self.blobs.lock().await.get(blob_hash).cloned())
|
||||
}
|
||||
|
||||
async fn read_run_log(&self) -> anyhow::Result<Option<Vec<u8>>> {
|
||||
|
|
|
|||
|
|
@ -1917,7 +1917,7 @@ mod tests {
|
|||
"name": "large-item",
|
||||
"body": "x".repeat(101 * 1024)
|
||||
}]);
|
||||
let blob_id = run_store
|
||||
let blob_hash = run_store
|
||||
.write_blob(&serde_json::to_vec(&items).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
|
|
@ -1933,7 +1933,7 @@ mod tests {
|
|||
)));
|
||||
let (node, graph) = for_each_graph("items", 1);
|
||||
let context = test_context();
|
||||
context.set("items", serde_json::json!(format_blob_ref(&blob_id)));
|
||||
context.set("items", serde_json::json!(format_blob_ref(&blob_hash)));
|
||||
|
||||
let outcome = ParallelHandler
|
||||
.execute(&node, &context, &graph, sandbox_dir.path(), &services)
|
||||
|
|
|
|||
|
|
@ -1328,7 +1328,7 @@ mod tests {
|
|||
Ok(BlobHash::new(data))
|
||||
}
|
||||
|
||||
async fn read_blob(&self, _id: &BlobHash) -> Result<Option<Bytes>> {
|
||||
async fn read_blob(&self, _blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -359,8 +359,8 @@ impl RunSession {
|
|||
let git = git_checkpoint_options_from_start(settings, &record.run_id, state.start);
|
||||
let definition_blob = state.spec.definition_blob;
|
||||
let accepted_definition = match definition_blob {
|
||||
Some(blob_id) => {
|
||||
Some(load_accepted_run_definition(&services.run_store, blob_id).await?)
|
||||
Some(blob_hash) => {
|
||||
Some(load_accepted_run_definition(&services.run_store, blob_hash).await?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
|
@ -570,15 +570,15 @@ fn vault_token_lookup(vault: &Vault, name: &str) -> Option<String> {
|
|||
|
||||
async fn load_accepted_run_definition(
|
||||
run_store: &RunStoreHandle,
|
||||
blob_id: fabro_types::BlobHash,
|
||||
blob_hash: fabro_types::BlobHash,
|
||||
) -> Result<RunDefinition, Error> {
|
||||
let bytes = run_store
|
||||
.read_blob(&blob_id)
|
||||
.read_blob(&blob_hash)
|
||||
.await
|
||||
.map_err(|err| Error::engine(err.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
Error::engine(format!(
|
||||
"run definition blob is missing from the run store: {blob_id}"
|
||||
"run definition blob is missing from the run store: {blob_hash}"
|
||||
))
|
||||
})?;
|
||||
serde_json::from_slice(&bytes).map_err(|err| Error::Parse(err.to_string()))
|
||||
|
|
|
|||
|
|
@ -751,11 +751,11 @@ impl HandlerTrait for BlobCommandOutputHandler {
|
|||
services: &crate::handler::EngineServices,
|
||||
) -> std::result::Result<Outcome, Error> {
|
||||
let blob = serde_json::to_vec("routed-ok").unwrap();
|
||||
let blob_id = services.run.run_store.write_blob(&blob).await.unwrap();
|
||||
let blob_hash = services.run.run_store.write_blob(&blob).await.unwrap();
|
||||
let mut outcome = Outcome::success();
|
||||
outcome.context_updates.insert(
|
||||
context::keys::COMMAND_OUTPUT.to_string(),
|
||||
serde_json::json!(format_blob_ref(&blob_id)),
|
||||
serde_json::json!(format_blob_ref(&blob_hash)),
|
||||
);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1823,7 +1823,7 @@ mod tests {
|
|||
Ok(BlobHash::new(data))
|
||||
}
|
||||
|
||||
async fn read_blob(&self, _id: &BlobHash) -> Result<Option<Bytes>> {
|
||||
async fn read_blob(&self, _blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ pub trait RunStoreBackend: Send + Sync {
|
|||
async fn list_events(&self) -> Result<Vec<EventEnvelope>>;
|
||||
async fn append_run_event(&self, event: &RunEvent) -> Result<()>;
|
||||
async fn write_blob(&self, data: &[u8]) -> Result<BlobHash>;
|
||||
async fn read_blob(&self, id: &BlobHash) -> Result<Option<Bytes>>;
|
||||
async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>>;
|
||||
async fn read_run_log(&self) -> Result<Option<Vec<u8>>>;
|
||||
}
|
||||
|
||||
|
|
@ -50,8 +50,8 @@ impl RunStoreHandle {
|
|||
self.backend.write_blob(data).await
|
||||
}
|
||||
|
||||
pub async fn read_blob(&self, id: &BlobHash) -> Result<Option<Bytes>> {
|
||||
self.backend.read_blob(id).await
|
||||
pub async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
self.backend.read_blob(blob_hash).await
|
||||
}
|
||||
|
||||
pub async fn read_run_log(&self) -> Result<Option<Vec<u8>>> {
|
||||
|
|
@ -98,9 +98,9 @@ impl RunStoreBackend for LocalRunStoreBackend {
|
|||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
||||
async fn read_blob(&self, id: &BlobHash) -> Result<Option<Bytes>> {
|
||||
async fn read_blob(&self, blob_hash: &BlobHash) -> Result<Option<Bytes>> {
|
||||
self.run_store
|
||||
.read_blob(id)
|
||||
.read_blob(blob_hash)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)
|
||||
}
|
||||
|
|
@ -217,8 +217,8 @@ mod tests {
|
|||
};
|
||||
handle.append_run_event(&event).await.unwrap();
|
||||
|
||||
let blob_id = handle.write_blob(br#"{"ok":true}"#).await.unwrap();
|
||||
let blob = handle.read_blob(&blob_id).await.unwrap().unwrap();
|
||||
let blob_hash = handle.write_blob(br#"{"ok":true}"#).await.unwrap();
|
||||
let blob = handle.read_blob(&blob_hash).await.unwrap().unwrap();
|
||||
let events = handle.list_events().await.unwrap();
|
||||
|
||||
assert_eq!(events.len(), 2);
|
||||
|
|
|
|||
|
|
@ -544,13 +544,13 @@ async fn daytona_pipeline_artifact_offload_and_sync() {
|
|||
.get("response.big_output")
|
||||
.expect("context should have response.big_output");
|
||||
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
|
||||
let expected_blob_id = fabro_types::BlobHash::new(
|
||||
let expected_blob_hash = fabro_types::BlobHash::new(
|
||||
&serde_json::to_vec(&serde_json::json!("x".repeat(150 * 1024)))
|
||||
.expect("large value should serialize"),
|
||||
);
|
||||
assert_eq!(
|
||||
pointer_str,
|
||||
fabro_types::format_blob_ref(&expected_blob_id),
|
||||
fabro_types::format_blob_ref(&expected_blob_hash),
|
||||
"checkpoint should persist a blob ref"
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ fn resolve_checkpoint_text(
|
|||
let Some(current) = value.as_str() else {
|
||||
return Ok(value.to_string());
|
||||
};
|
||||
let Some(blob_id) = parse_blob_ref(current) else {
|
||||
let Some(blob_hash) = parse_blob_ref(current) else {
|
||||
return Ok(current.to_string());
|
||||
};
|
||||
|
||||
|
|
@ -272,7 +272,7 @@ fn resolve_checkpoint_text(
|
|||
};
|
||||
let run = runtime.block_on(store.open_run_reader(&run_id))?;
|
||||
let bytes = runtime
|
||||
.block_on(run.read_blob(&blob_id))?
|
||||
.block_on(run.read_blob(&blob_hash))?
|
||||
.ok_or("checkpoint blob should exist")?;
|
||||
Ok(serde_json::from_slice::<String>(&bytes)?)
|
||||
},
|
||||
|
|
@ -10059,13 +10059,13 @@ async fn large_context_values_are_offloaded_to_artifact_store() {
|
|||
.expect("context should have response.big_output");
|
||||
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
|
||||
|
||||
let expected_blob_id = fabro_types::BlobHash::new(
|
||||
let expected_blob_hash = fabro_types::BlobHash::new(
|
||||
&serde_json::to_vec(&serde_json::json!("x".repeat(150 * 1024)))
|
||||
.expect("large value should serialize"),
|
||||
);
|
||||
assert_eq!(
|
||||
pointer_str,
|
||||
fabro_types::format_blob_ref(&expected_blob_id),
|
||||
fabro_types::format_blob_ref(&expected_blob_hash),
|
||||
"value should be a durable blob ref"
|
||||
);
|
||||
|
||||
|
|
@ -10258,13 +10258,13 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() {
|
|||
.get("response.big_output")
|
||||
.expect("context should have response.big_output");
|
||||
let pointer_str = pointer_value.as_str().expect("pointer should be a string");
|
||||
let expected_blob_id = fabro_types::BlobHash::new(
|
||||
let expected_blob_hash = fabro_types::BlobHash::new(
|
||||
&serde_json::to_vec(&serde_json::json!("x".repeat(150 * 1024)))
|
||||
.expect("large value should serialize"),
|
||||
);
|
||||
assert_eq!(
|
||||
pointer_str,
|
||||
fabro_types::format_blob_ref(&expected_blob_id),
|
||||
fabro_types::format_blob_ref(&expected_blob_hash),
|
||||
"checkpoint should persist a blob ref"
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -725,6 +725,7 @@ fn main() {
|
|||
("WorkflowVersion", "fabro_types::WorkflowVersion", &[]),
|
||||
("WorkflowPath", "fabro_types::WorkflowPath", &[]),
|
||||
("WorkflowVersionId", "fabro_types::WorkflowVersionId", &[]),
|
||||
("BlobHash", "fabro_types::BlobHash", &[]),
|
||||
("CostSource", "fabro_model::CostSource", &[]),
|
||||
];
|
||||
for (name, path, impls) in replacements {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ pub mod types {
|
|||
pub use fabro_types::{
|
||||
ActivatedSkill, AgentControlState, AgentMcpToolSummary, AgentSkillActivationSource,
|
||||
AgentSkillSummary, AgentToolCategory, AgentToolSource, AgentToolSummary,
|
||||
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts,
|
||||
AgentToolsAvailableProps, AskFabro, AuthMethod, AutomationRef, BilledTokenCounts, BlobHash,
|
||||
CommandTermination, Conclusion, ContentPart, CreateVariableRequest, DiffStats, DiffSummary,
|
||||
DirtyStatus, EventEnvelope, ExecOutputTail, FailureCategory, FailureDetail,
|
||||
FailureSignature, GitContext, IdpIdentity, IntegrationConnectionKind,
|
||||
|
|
|
|||
61
lib/foundation/fabro-api/tests/blob_hash_round_trip.rs
Normal file
61
lib/foundation/fabro-api/tests/blob_hash_round_trip.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
use std::any::{TypeId, type_name};
|
||||
|
||||
use fabro_api::types::{BlobHash as ApiBlobHash, WriteBlobResponse};
|
||||
use fabro_types::BlobHash;
|
||||
use serde_json::json;
|
||||
|
||||
const BLOB_HASH: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||||
|
||||
#[test]
|
||||
fn blob_hash_schema_reuses_domain_type() {
|
||||
assert_same_type::<ApiBlobHash, BlobHash>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_blob_response_round_trips_exact_wire_shape() {
|
||||
let value = json!({ "hash": BLOB_HASH });
|
||||
|
||||
let response: WriteBlobResponse = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&response).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_hash_accepts_any_case_and_emits_lowercase() {
|
||||
for input in [
|
||||
BLOB_HASH.to_string(),
|
||||
BLOB_HASH.to_uppercase(),
|
||||
alternating_hex_case(BLOB_HASH),
|
||||
] {
|
||||
let hash: ApiBlobHash = serde_json::from_value(json!(input)).unwrap();
|
||||
assert_eq!(serde_json::to_value(hash).unwrap(), json!(BLOB_HASH));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_hash_rejects_non_hex_values() {
|
||||
assert!(serde_json::from_value::<ApiBlobHash>(json!("not-a-blob-hash")).is_err());
|
||||
}
|
||||
|
||||
fn assert_same_type<Api: 'static, Domain: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<Api>(),
|
||||
TypeId::of::<Domain>(),
|
||||
"{} must be the domain type {}",
|
||||
type_name::<Api>(),
|
||||
type_name::<Domain>()
|
||||
);
|
||||
}
|
||||
|
||||
fn alternating_hex_case(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.enumerate()
|
||||
.map(|(index, character)| {
|
||||
if index % 2 == 0 {
|
||||
character.to_ascii_uppercase()
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
|
@ -40,9 +40,7 @@ fn create_workflow_version_response_round_trips_exact_wire_shape() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_version_id_emits_the_documented_lowercase_pattern() {
|
||||
// Input is accepted case-insensitively, but serialization must match the
|
||||
// OpenAPI schema pattern `^[0-9a-f]{64}$`.
|
||||
fn workflow_version_id_accepts_any_case_and_emits_lowercase() {
|
||||
let id = serde_json::from_value::<ApiWorkflowVersionId>(json!(DEPENDENCY_ID.to_uppercase()))
|
||||
.unwrap();
|
||||
let emitted = serde_json::to_value(id).unwrap();
|
||||
|
|
|
|||
|
|
@ -1839,20 +1839,20 @@ impl Client {
|
|||
.await
|
||||
})
|
||||
.await?;
|
||||
response
|
||||
.into_inner()
|
||||
.id
|
||||
.parse()
|
||||
.context("write_run_blob returned invalid blob id")
|
||||
Ok(response.into_inner().hash)
|
||||
}
|
||||
|
||||
pub async fn read_run_blob(&self, run_id: &RunId, blob_id: &BlobHash) -> Result<Option<Bytes>> {
|
||||
pub async fn read_run_blob(
|
||||
&self,
|
||||
run_id: &RunId,
|
||||
blob_hash: &BlobHash,
|
||||
) -> Result<Option<Bytes>> {
|
||||
let response = self
|
||||
.current_state()
|
||||
.client
|
||||
.read_run_blob()
|
||||
.id(run_id.to_string())
|
||||
.blob_id(blob_id.to_string())
|
||||
.blob_hash(*blob_hash)
|
||||
.send()
|
||||
.await;
|
||||
match response {
|
||||
|
|
|
|||
|
|
@ -1955,14 +1955,12 @@ pub fn json_snapshot_filters(mut filters: Vec<(String, String)>) -> Vec<(String,
|
|||
r#""id": "[EVENT_ID]""#.to_string(),
|
||||
));
|
||||
filters = json_elapsed_ms_snapshot_filters(filters);
|
||||
filters.push((
|
||||
r#""manifest_blob":\s*"[0-9a-f]{64}""#.to_string(),
|
||||
r#""manifest_blob": "[BLOB_ID]""#.to_string(),
|
||||
));
|
||||
filters.push((
|
||||
r#""definition_blob":\s*"[0-9a-f]{64}""#.to_string(),
|
||||
r#""definition_blob": "[BLOB_ID]""#.to_string(),
|
||||
));
|
||||
for field in ["manifest_blob", "definition_blob"] {
|
||||
filters.push((
|
||||
format!(r#""{field}":\s*"[0-9a-f]{{64}}""#),
|
||||
format!(r#""{field}": "[BLOB_HASH]""#),
|
||||
));
|
||||
}
|
||||
filters.push((
|
||||
r#""run_dir":\s*"\[STORAGE_DIR\]/scratch/\d{8}-\[ULID\]""#.to_string(),
|
||||
r#""run_dir": "[RUN_DIR]""#.to_string(),
|
||||
|
|
@ -2562,8 +2560,8 @@ mod tests {
|
|||
"inference_time_ms": "[INFERENCE_TIME_MS]",
|
||||
"tool_time_ms": "[TOOL_TIME_MS]",
|
||||
"active_time_ms": "[ACTIVE_TIME_MS]",
|
||||
"manifest_blob": "[BLOB_ID]",
|
||||
"definition_blob": "[BLOB_ID]",
|
||||
"manifest_blob": "[BLOB_HASH]",
|
||||
"definition_blob": "[BLOB_HASH]",
|
||||
"run_dir": "[RUN_DIR]",
|
||||
"message": "[CUSTOM]"
|
||||
}"#
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ use serde::de::Error as _;
|
|||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// SHA-256 content identity.
|
||||
///
|
||||
/// Parsing accepts exactly 64 hexadecimal digits case-insensitively. Display
|
||||
/// and serialization emit the canonical lowercase form.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct BlobHash([u8; 32]);
|
||||
|
||||
|
|
@ -76,10 +80,17 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn display_and_parse_round_trip() {
|
||||
fn parse_accepts_any_case_and_display_normalizes_to_lowercase() {
|
||||
let blob_hash = BlobHash::new(b"hello");
|
||||
let parsed: BlobHash = blob_hash.to_string().parse().unwrap();
|
||||
assert_eq!(parsed, blob_hash);
|
||||
let lowercase = blob_hash.to_string();
|
||||
let uppercase = lowercase.to_uppercase();
|
||||
let mixed_case = alternating_hex_case(&lowercase);
|
||||
|
||||
for value in [&lowercase, &uppercase, &mixed_case] {
|
||||
let parsed: BlobHash = value.parse().unwrap();
|
||||
assert_eq!(parsed, blob_hash);
|
||||
assert_eq!(parsed.to_string(), lowercase);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -91,8 +102,30 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_non_hex_blob_hashes() {
|
||||
let parsed = "not-a-blob-hash".parse::<BlobHash>();
|
||||
assert!(parsed.is_err());
|
||||
fn parse_rejects_invalid_shapes() {
|
||||
for value in [
|
||||
String::new(),
|
||||
"0".repeat(63),
|
||||
"0".repeat(65),
|
||||
"g".repeat(64),
|
||||
format!("0x{}", "0".repeat(64)),
|
||||
format!(" {}", "0".repeat(64)),
|
||||
] {
|
||||
assert!(value.parse::<BlobHash>().is_err(), "accepted {value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn alternating_hex_case(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.enumerate()
|
||||
.map(|(index, character)| {
|
||||
if index % 2 == 0 {
|
||||
character.to_ascii_uppercase()
|
||||
} else {
|
||||
character
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,10 +63,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn conversion_preserves_digest_and_display() {
|
||||
let blob_id = BlobHash::new(b"workflow");
|
||||
let version_id = WorkflowVersionId::from(blob_id);
|
||||
assert_eq!(version_id.to_string(), blob_id.to_string());
|
||||
assert_eq!(BlobHash::from(version_id), blob_id);
|
||||
let blob_hash = BlobHash::new(b"workflow");
|
||||
let version_id = WorkflowVersionId::from(blob_hash);
|
||||
assert_eq!(version_id.to_string(), blob_hash.to_string());
|
||||
assert_eq!(BlobHash::from(version_id), blob_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
2
lib/packages/fabro-api-client/src/api.ts
generated
2
lib/packages/fabro-api-client/src/api.ts
generated
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
@ -781,21 +781,21 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config
|
|||
};
|
||||
},
|
||||
/**
|
||||
* Reads a previously stored blob by identifier.
|
||||
* Reads a previously stored blob by hash.
|
||||
* @summary Read Run Blob
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {string} blobId Content-addressed blob identifier.
|
||||
* @param {string} blobHash Content-addressed blob hash.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
readRunBlob: async (id: string, blobId: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
readRunBlob: async (id: string, blobHash: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'id' is not null or undefined
|
||||
assertParamExists('readRunBlob', 'id', id)
|
||||
// verify required parameter 'blobId' is not null or undefined
|
||||
assertParamExists('readRunBlob', 'blobId', blobId)
|
||||
const localVarPath = `/api/v1/runs/{id}/blobs/{blobId}`
|
||||
// verify required parameter 'blobHash' is not null or undefined
|
||||
assertParamExists('readRunBlob', 'blobHash', blobHash)
|
||||
const localVarPath = `/api/v1/runs/{id}/blobs/{blobHash}`
|
||||
.replace(`{${"id"}}`, encodeURIComponent(String(id)))
|
||||
.replace(`{${"blobId"}}`, encodeURIComponent(String(blobId)));
|
||||
.replace(`{${"blobHash"}}`, encodeURIComponent(String(blobHash)));
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
|
|
@ -905,7 +905,7 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config
|
|||
};
|
||||
},
|
||||
/**
|
||||
* Writes an opaque binary blob and returns its content-addressed blob identifier.
|
||||
* Writes an opaque binary blob and returns its content-addressed blob hash.
|
||||
* @summary Write Run Blob
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {File} body
|
||||
|
|
@ -1179,15 +1179,15 @@ export const RunInternalsApiFp = function(configuration?: Configuration) {
|
|||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Reads a previously stored blob by identifier.
|
||||
* Reads a previously stored blob by hash.
|
||||
* @summary Read Run Blob
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {string} blobId Content-addressed blob identifier.
|
||||
* @param {string} blobHash Content-addressed blob hash.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async readRunBlob(id: string, blobId: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.readRunBlob(id, blobId, options);
|
||||
async readRunBlob(id: string, blobHash: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.readRunBlob(id, blobHash, options);
|
||||
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
|
||||
const localVarOperationServerBasePath = operationServerMap['RunInternalsApi.readRunBlob']?.[localVarOperationServerIndex]?.url;
|
||||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
|
|
@ -1219,7 +1219,7 @@ export const RunInternalsApiFp = function(configuration?: Configuration) {
|
|||
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
|
||||
},
|
||||
/**
|
||||
* Writes an opaque binary blob and returns its content-addressed blob identifier.
|
||||
* Writes an opaque binary blob and returns its content-addressed blob hash.
|
||||
* @summary Write Run Blob
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {File} body
|
||||
|
|
@ -1417,15 +1417,15 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b
|
|||
return localVarFp.putStageArtifact(id, stageId, retry, body, filename, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Reads a previously stored blob by identifier.
|
||||
* Reads a previously stored blob by hash.
|
||||
* @summary Read Run Blob
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {string} blobId Content-addressed blob identifier.
|
||||
* @param {string} blobHash Content-addressed blob hash.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
readRunBlob(id: string, blobId: string, options?: RawAxiosRequestConfig): AxiosPromise<File> {
|
||||
return localVarFp.readRunBlob(id, blobId, options).then((request) => request(axios, basePath));
|
||||
readRunBlob(id: string, blobHash: string, options?: RawAxiosRequestConfig): AxiosPromise<File> {
|
||||
return localVarFp.readRunBlob(id, blobHash, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Returns the latest checkpoint data for a run, or null if no checkpoint has been recorded yet.
|
||||
|
|
@ -1448,7 +1448,7 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b
|
|||
return localVarFp.retrieveRunSettings(id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Writes an opaque binary blob and returns its content-addressed blob identifier.
|
||||
* Writes an opaque binary blob and returns its content-addressed blob hash.
|
||||
* @summary Write Run Blob
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {File} body
|
||||
|
|
@ -1656,15 +1656,15 @@ export class RunInternalsApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Reads a previously stored blob by identifier.
|
||||
* Reads a previously stored blob by hash.
|
||||
* @summary Read Run Blob
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {string} blobId Content-addressed blob identifier.
|
||||
* @param {string} blobHash Content-addressed blob hash.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
public readRunBlob(id: string, blobId: string, options?: RawAxiosRequestConfig) {
|
||||
return RunInternalsApiFp(this.configuration).readRunBlob(id, blobId, options).then((request) => request(this.axios, this.basePath));
|
||||
public readRunBlob(id: string, blobHash: string, options?: RawAxiosRequestConfig) {
|
||||
return RunInternalsApiFp(this.configuration).readRunBlob(id, blobHash, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1690,7 +1690,7 @@ export class RunInternalsApi extends BaseAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* Writes an opaque binary blob and returns its content-addressed blob identifier.
|
||||
* Writes an opaque binary blob and returns its content-addressed blob hash.
|
||||
* @summary Write Run Blob
|
||||
* @param {string} id Unique run identifier (ULID).
|
||||
* @param {File} body
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
2
lib/packages/fabro-api-client/src/base.ts
generated
2
lib/packages/fabro-api-client/src/base.ts
generated
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
2
lib/packages/fabro-api-client/src/common.ts
generated
2
lib/packages/fabro-api-client/src/common.ts
generated
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
2
lib/packages/fabro-api-client/src/index.ts
generated
2
lib/packages/fabro-api-client/src/index.ts
generated
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
@ -27,7 +27,7 @@ export interface ArtifactBatchUploadEntry {
|
|||
*/
|
||||
'path': string;
|
||||
/**
|
||||
* Optional lowercase hex SHA-256 checksum for the file contents.
|
||||
* Optional SHA-256 checksum for the file contents; hex input is case-insensitive.
|
||||
*/
|
||||
'sha256'?: string | null;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Fabro Run API
|
||||
* HTTP API for managing Fabro workflow run executions.
|
||||
*
|
||||
* The version of the OpenAPI document: 0.1.0
|
||||
* The version of the OpenAPI document: 0.2.0
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue