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

Unify blob hash vocabulary
This commit is contained in:
Scott Werner 2026-08-18 15:55:43 -04:00 committed by GitHub
commit 2575ab85fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
583 changed files with 1020 additions and 814 deletions

View file

@ -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) |

View file

@ -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.

View file

@ -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"]

View file

@ -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.

View file

@ -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?;

View file

@ -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,

View file

@ -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
}

View file

@ -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]"

View file

@ -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))
}
}

View file

@ -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(

View file

@ -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) => {

View file

@ -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),

View file

@ -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(),
))

View file

@ -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");
}
}

View file

@ -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
}
}

View file

@ -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();

View file

@ -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> {

View file

@ -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));
}

View file

@ -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)
}

View file

@ -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))
}

View file

@ -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>>> {

View file

@ -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)

View file

@ -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)
}

View file

@ -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()))

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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);

View file

@ -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"
);

View file

@ -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"
);

View file

@ -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 {

View file

@ -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,

View 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()
}

View file

@ -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();

View file

@ -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 {

View file

@ -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]"
}"#

View file

@ -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()
}
}

View file

@ -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]

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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;
/**

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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).

View file

@ -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