feat(runs): add parent run links (#271)

## Summary

Adds orchestration-only parent links between runs without merging them
into fork or rewind lineage. Runs can now be created under a parent,
linked to a different parent, or unlinked through event-sourced
mutations that rebuild summaries and projections from the run event
stream.

## Changes

- Adds optional `parent_id` to run manifests, public run summaries, run
projections, `run.created`, OpenAPI, and the generated TypeScript API
client.
- Adds `PUT /api/v1/runs/{id}/parent` and `DELETE
/api/v1/runs/{id}/parent` for mutable parent links across any run state,
including terminal or archived runs.
- Records `run.parent.linked` and `run.parent.unlinked` events with
actor metadata and previous/current parent IDs.
- Validates parent changes in the API path: parent must exist for new
links, self-parenting is rejected, cycles are rejected, and same-parent
or already-root operations are idempotent no-ops.
- Adds `parent_id` filtering to run listing while preserving dangling
historical parent references after parent deletion.

## Validation

- `cargo +nightly-2026-04-14 fmt --check --all`
- `cargo build -p fabro-api`
- `cargo check -p fabro-workflow -p fabro-store -p fabro-server`
- `cargo nextest run -p fabro-types -p fabro-store`
- `cargo nextest run -p fabro-server --features test-support
create_run_can_set_parent_and_list_children
link_relink_and_unlink_parent_are_idempotent
parent_link_validation_rejects_missing_self_and_cycles
deleting_parent_leaves_child_parent_id_as_historical_reference`
- `cargo nextest run -p fabro-api
run_summary_json_matches_openapi_shape`
- `cd lib/packages/fabro-api-client && bun run typecheck`

Known unrelated broad-suite blocker: `cargo nextest run -p fabro-server
--features test-support get_graph_returns_svg` currently returns 500
because the render subprocess emits test-harness output instead of SVG.

---

[![Compound
Engineering](https://img.shields.io/badge/Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
🤖 Generated with GPT-5 (unknown context, medium reasoning) via
[Codex](https://openai.com/codex)
This commit is contained in:
Bryan Helmkamp 2026-05-16 10:57:33 -07:00 • committed by GitHub
parent ae55bded81
commit 2296ba6ea8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 1161 additions and 44 deletions

View file

@ -823,6 +823,7 @@ paths:
- $ref: "#/components/parameters/PageLimit"
- $ref: "#/components/parameters/PageOffset"
- $ref: "#/components/parameters/IncludeArchived"
- $ref: "#/components/parameters/ParentRunId"
responses:
"200":
description: Paginated durable run summaries
@ -1078,6 +1079,69 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/parent:
put:
operationId: linkRunParent
tags: [Runs]
summary: Link Run Parent
description: Links a run under an orchestration parent. Parent links are mutable for all run states, including archived and terminal runs.
parameters:
- $ref: "#/components/parameters/RunId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateRunParentRequest"
responses:
"200":
description: Updated durable run summary
content:
application/json:
schema:
$ref: "#/components/schemas/Run"
"400":
description: Self-parent or cycle rejected
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Child or parent run not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
delete:
operationId: unlinkRunParent
tags: [Runs]
summary: Unlink Run Parent
description: Removes a run's orchestration parent. Already-root runs are returned unchanged.
parameters:
- $ref: "#/components/parameters/RunId"
responses:
"200":
description: Updated durable run summary
content:
application/json:
schema:
$ref: "#/components/schemas/Run"
"404":
description: Run not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/cancel:
post:
operationId: cancelRun
@ -3771,6 +3835,15 @@ components:
default: false
example: false
ParentRunId:
name: parent_id
in: query
required: false
description: Return only runs currently linked to this orchestration parent.
schema:
type: string
example: 01JNQVR7M0EJ5GKAT2SC4ERS1Z
ForceRunDelete:
name: force
in: query
@ -5062,6 +5135,10 @@ components:
type: ["string", "null"]
description: Optional pre-generated run ID to use instead of allocating a new ULID.
example: "01HV6D7S5YF4Z4B2M7K4N0Q6T9"
parent_id:
type: ["string", "null"]
description: Optional orchestration parent run ID. Fork and rewind lineage use separate fields and should not set this value.
example: "01HV6D7S5YF4Z4B2M7K4N0Q6T8"
title:
type: ["string", "null"]
maxLength: 100
@ -6484,6 +6561,15 @@ components:
- $ref: "#/components/schemas/ForkSourceRef"
- type: "null"
UpdateRunParentRequest:
type: object
required:
- parent_id
properties:
parent_id:
type: string
description: Existing orchestration parent run ID.
StartRecord:
description: Metadata captured when execution starts.
type: object
@ -6595,6 +6681,9 @@ components:
title:
type: string
description: Resolved run title from the event log.
parent_id:
type: ["string", "null"]
description: Current orchestration parent run ID, if linked.
spec:
$ref: "#/components/schemas/RunSpec"
web_url:
@ -6675,6 +6764,9 @@ components:
properties:
id:
type: string
parent_id:
type: ["string", "null"]
description: Current orchestration parent run ID, if linked.
title:
type: string
goal:

View file

@ -24,6 +24,7 @@ fn run_summary_json_matches_openapi_shape() {
let archived_at = Utc.with_ymd_and_hms(2026, 4, 20, 12, 1, 0).unwrap();
let summary = RunSummary {
id: run_id,
parent_id: None,
title: "API title".to_string(),
goal: String::new(),
workflow: WorkflowRef {

View file

@ -245,6 +245,7 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result<BuiltManifest> {
cwd: input.cwd.display().to_string(),
git,
goal,
parent_id: None,
run_id: input.run_id.map(|run_id| run_id.to_string()),
title: None,
target: types::ManifestTarget {

View file

@ -337,6 +337,7 @@ mod tests {
let created_at = Utc.with_ymd_and_hms(2026, 5, 11, 12, 0, seconds).unwrap();
Run {
id: id.parse().expect("test run id should parse"),
parent_id: None,
title: "test".to_string(),
goal: "test".to_string(),
workflow: WorkflowRef {

View file

@ -1056,6 +1056,7 @@ mod runs {
let duration_ms = elapsed_secs.and_then(duration_ms_from_secs);
RunSummary {
id: run_id,
parent_id: None,
title: fabro_types::infer_run_title(goal),
goal: goal.into(),
workflow: WorkflowRef {

View file

@ -52,6 +52,7 @@ pub(crate) struct PreparedManifest {
pub git: Option<types::GitContext>,
pub root_source: String,
pub run_id: Option<RunId>,
pub parent_id: Option<RunId>,
pub title: Option<String>,
pub settings: WorkflowSettings,
pub target_path: ManifestPath,
@ -155,6 +156,12 @@ pub(crate) fn prepare_manifest(
.map(str::parse::<RunId>)
.transpose()
.context("invalid run ID")?,
parent_id: manifest
.parent_id
.as_deref()
.map(str::parse::<RunId>)
.transpose()
.context("invalid parent run ID")?,
title,
settings: settings.clone(),
target_path,
@ -196,6 +203,7 @@ pub(crate) fn create_run_input(
title: prepared.title,
git: prepared.git,
fork_source_ref: None,
parent_id: prepared.parent_id,
provenance: None,
configured_providers,
web_url,
@ -1325,6 +1333,7 @@ mod tests {
cwd: "/tmp/project".to_string(),
git: None,
goal: None,
parent_id: None,
run_id: None,
title: None,
target: types::ManifestTarget {

View file

@ -525,6 +525,7 @@ pub struct AppState {
/// proceed in parallel. See `crate::run_files` for semantics.
pub(crate) files_in_flight: FilesInFlight,
pull_request_create_locks: PullRequestCreateLocks,
parent_link_lock: AsyncMutex<()>,
pub(crate) vault: Arc<AsyncRwLock<Vault>>,
pub(super) server_secrets: ServerSecrets,
@ -1590,6 +1591,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
global_event_tx,
files_in_flight: new_files_in_flight(),
pull_request_create_locks: Arc::new(Mutex::new(HashMap::new())),
parent_link_lock: AsyncMutex::new(()),
vault,
server_secrets,
llm_source,

View file

@ -441,6 +441,7 @@ mod stage_events_tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await

View file

@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::io::ErrorKind;
use std::path::PathBuf;
use std::sync::Arc;
@ -5,13 +6,14 @@ use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::routing::{get, post, put};
use axum::{Json, Router};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use bytes::Bytes;
use fabro_api::types::{
BoardColumn, BoardColumnDefinition, RunManifest, SubmitAnswerRequest, UpdateRunRequest,
BoardColumn, BoardColumnDefinition, RunManifest, SubmitAnswerRequest, UpdateRunParentRequest,
UpdateRunRequest,
};
use fabro_config::Storage;
use fabro_interview::AnswerSubmission;
@ -56,6 +58,10 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
"/runs/{id}",
get(get_run_status).patch(update_run).delete(delete_run),
)
.route(
"/runs/{id}/parent",
put(link_run_parent).delete(unlink_run_parent),
)
.route("/runs/{id}/questions", get(get_questions))
.route("/runs/{id}/questions/{qid}/answer", post(submit_answer))
.route("/runs/{id}/state", get(get_run_state))
@ -78,6 +84,8 @@ struct ListRunsParams {
offset: u32,
#[serde(default)]
include_archived: bool,
#[serde(default)]
parent_id: Option<RunId>,
}
impl ListRunsParams {
@ -156,7 +164,10 @@ async fn list_board_runs(
) -> Response {
let entries = match state
.store
.list_cached_runs(&fabro_store::ListRunsQuery::default())
.list_cached_runs(&fabro_store::ListRunsQuery {
parent_id: params.parent_id,
..fabro_store::ListRunsQuery::default()
})
.await
{
Ok(runs) => runs,
@ -195,6 +206,140 @@ async fn list_board_runs(
.into_response()
}
async fn link_run_parent(
subject: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Json(req): Json<UpdateRunParentRequest>,
) -> Response {
let child_id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(err) => return err.into_response(),
};
let parent_id = match req.parent_id.parse::<RunId>() {
Ok(parent_id) => parent_id,
Err(err) => {
return ApiError::bad_request(format!("invalid parent run ID: {err}")).into_response();
}
};
let _parent_link_guard = state.parent_link_lock.lock().await;
let child = match state.store.get_cached_summary(&child_id).await {
Ok(Some(summary)) => summary,
Ok(None) => return ApiError::not_found("Run not found.").into_response(),
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
if parent_id == child_id {
return ApiError::bad_request("A run cannot be its own parent.").into_response();
}
if let Err(err) = validate_parent_link(&state, child_id, parent_id).await {
return err.into_response();
}
if child.parent_id == Some(parent_id) {
return (StatusCode::OK, Json(child)).into_response();
}
let Ok(run_store) = state.store.open_run(&child_id).await else {
return ApiError::not_found("Run not found.").into_response();
};
if let Err(err) = workflow_event::append_event(
&run_store,
&child_id,
&workflow_event::Event::RunParentLinked {
previous_parent_id: child.parent_id,
parent_id,
actor: Some(Principal::User(subject.0)),
},
)
.await
{
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
}
updated_run_response(&state, &child_id).await
}
async fn unlink_run_parent(
subject: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let child_id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(err) => return err.into_response(),
};
let _parent_link_guard = state.parent_link_lock.lock().await;
let child = match state.store.get_cached_summary(&child_id).await {
Ok(Some(summary)) => summary,
Ok(None) => return ApiError::not_found("Run not found.").into_response(),
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let Some(previous_parent_id) = child.parent_id else {
return (StatusCode::OK, Json(child)).into_response();
};
let Ok(run_store) = state.store.open_run(&child_id).await else {
return ApiError::not_found("Run not found.").into_response();
};
if let Err(err) = workflow_event::append_event(
&run_store,
&child_id,
&workflow_event::Event::RunParentUnlinked {
previous_parent_id,
actor: Some(Principal::User(subject.0)),
},
)
.await
{
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
}
updated_run_response(&state, &child_id).await
}
async fn validate_parent_link(
state: &AppState,
child_id: RunId,
parent_id: RunId,
) -> Result<(), ApiError> {
let mut cursor = Some(parent_id);
let mut visited = HashSet::new();
while let Some(current_id) = cursor {
if current_id == child_id {
return Err(ApiError::bad_request("Parent link would create a cycle."));
}
if !visited.insert(current_id) {
return Err(ApiError::bad_request("Parent link would create a cycle."));
}
let summary = state
.store
.get_cached_summary(&current_id)
.await
.map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
let Some(summary) = summary else {
if current_id == parent_id {
return Err(ApiError::not_found("Parent run not found."));
}
return Ok(());
};
cursor = summary.parent_id;
}
Ok(())
}
async fn updated_run_response(state: &AppState, run_id: &RunId) -> Response {
match state.store.get_cached_summary(run_id).await {
Ok(Some(summary)) => (StatusCode::OK, Json(summary)).into_response(),
Ok(None) => ApiError::not_found("Run not found.").into_response(),
Err(err) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
}
}
}
async fn list_runs(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
@ -202,7 +347,10 @@ async fn list_runs(
) -> Response {
match state
.store
.list_cached_runs(&fabro_store::ListRunsQuery::default())
.list_cached_runs(&fabro_store::ListRunsQuery {
parent_id: params.parent_id,
..fabro_store::ListRunsQuery::default()
})
.await
{
Ok(entries) => {
@ -399,6 +547,14 @@ async fn create_run(
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let run_id = prepared.run_id.unwrap_or_else(RunId::new);
if let Some(parent_id) = prepared.parent_id {
if parent_id == run_id {
return ApiError::bad_request("A run cannot be its own parent.").into_response();
}
if let Err(err) = validate_parent_link(&state, run_id, parent_id).await {
return err.into_response();
}
}
info!(run_id = %run_id, "Run created");
let web_url = state.run_web_url(&run_id);

View file

@ -2454,6 +2454,7 @@ async fn append_default_run_created(run_store: &fabro_store::RunDatabase, run_id
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await
@ -2801,6 +2802,7 @@ async fn list_run_stages_distinguishes_visits() {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
},
workflow_event::Event::RunStarting,
@ -3781,6 +3783,7 @@ async fn create_completed_run_ready_for_pull_request(
manifest_blob: None,
git,
fork_source_ref: None,
parent_id: None,
web_url: None,
},
workflow_event::Event::WorkflowRunStarted {
@ -8262,6 +8265,7 @@ async fn delete_run_with_preserved_sandbox_returns_handoff() {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
},
workflow_event::Event::RunSubmitted {
@ -8328,6 +8332,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
},
workflow_event::Event::RunSubmitted {

View file

@ -72,6 +72,7 @@ async fn append_completed_run_with_final_patch(
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await

View file

@ -3,10 +3,55 @@ use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use crate::helpers::{
MINIMAL_DOT, api, body_json, minimal_manifest_json, response_json, settings_from_toml,
test_app_state_with_options,
MINIMAL_DOT, api, body_json, minimal_manifest_json, response_json, response_status,
settings_from_toml, test_app_state_with_options,
};
async fn create_run(app: &axum::Router, mut manifest: serde_json::Value) -> serde_json::Value {
manifest
.as_object_mut()
.expect("manifest should be an object")
.entry("configs")
.or_insert_with(|| serde_json::json!([]));
let request = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&manifest).expect("manifest should serialize"),
))
.expect("create run request should build");
response_json(
app.clone().oneshot(request).await.unwrap(),
StatusCode::CREATED,
"POST /api/v1/runs",
)
.await
}
async fn request_json(
app: &axum::Router,
method: &str,
path: String,
body: serde_json::Value,
expected: StatusCode,
) -> serde_json::Value {
let request = Request::builder()
.method(method)
.uri(api(&path))
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&body).expect("request body should serialize"),
))
.expect("JSON request should build");
response_json(
app.clone().oneshot(request).await.unwrap(),
expected,
format!("{method} /api/v1{path}"),
)
.await
}
#[tokio::test]
async fn retrieve_run_settings_returns_dense_snapshot() {
let storage_dir = tempfile::tempdir().unwrap();
@ -94,3 +139,221 @@ session_sandboxes = true
assert!(body.pointer("/features").is_none());
assert!(body.pointer("/server").is_none());
}
#[tokio::test]
async fn create_run_can_set_parent_and_list_children() {
let app = fabro_server::test_support::build_test_router(crate::helpers::test_app_state());
let parent = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await;
let parent_id = parent["id"].as_str().unwrap();
let mut child_manifest = minimal_manifest_json(MINIMAL_DOT);
child_manifest["parent_id"] = serde_json::json!(parent_id);
let child = create_run(&app, child_manifest).await;
let child_id = child["id"].as_str().unwrap();
assert_eq!(child["parent_id"], parent_id);
let list_request = Request::builder()
.method("GET")
.uri(api(&format!("/runs?parent_id={parent_id}")))
.body(Body::empty())
.unwrap();
let list = response_json(
app.clone().oneshot(list_request).await.unwrap(),
StatusCode::OK,
"GET /api/v1/runs?parent_id",
)
.await;
assert_eq!(list["data"].as_array().unwrap().len(), 1);
assert_eq!(list["data"][0]["id"], child_id);
}
#[tokio::test]
async fn link_relink_and_unlink_parent_are_idempotent() {
let app = fabro_server::test_support::build_test_router(crate::helpers::test_app_state());
let parent_1 = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await;
let parent_2 = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await;
let child = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await;
let parent_1_id = parent_1["id"].as_str().unwrap();
let parent_2_id = parent_2["id"].as_str().unwrap();
let child_id = child["id"].as_str().unwrap();
let linked = request_json(
&app,
"PUT",
format!("/runs/{child_id}/parent"),
serde_json::json!({ "parent_id": parent_1_id }),
StatusCode::OK,
)
.await;
assert_eq!(linked["parent_id"], parent_1_id);
let linked_again = request_json(
&app,
"PUT",
format!("/runs/{child_id}/parent"),
serde_json::json!({ "parent_id": parent_1_id }),
StatusCode::OK,
)
.await;
assert_eq!(linked_again["parent_id"], parent_1_id);
let relinked = request_json(
&app,
"PUT",
format!("/runs/{child_id}/parent"),
serde_json::json!({ "parent_id": parent_2_id }),
StatusCode::OK,
)
.await;
assert_eq!(relinked["parent_id"], parent_2_id);
let events_request = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{child_id}/events")))
.body(Body::empty())
.unwrap();
let events = response_json(
app.clone().oneshot(events_request).await.unwrap(),
StatusCode::OK,
format!("GET /api/v1/runs/{child_id}/events"),
)
.await;
let event_names = events["data"]
.as_array()
.unwrap()
.iter()
.map(|event| event["event"].as_str().unwrap())
.collect::<Vec<_>>();
assert_eq!(event_names, vec![
"run.created",
"run.submitted",
"run.parent.linked",
"run.parent.linked"
]);
let unlink_request = Request::builder()
.method("DELETE")
.uri(api(&format!("/runs/{child_id}/parent")))
.body(Body::empty())
.unwrap();
let unlinked = response_json(
app.clone().oneshot(unlink_request).await.unwrap(),
StatusCode::OK,
format!("DELETE /api/v1/runs/{child_id}/parent"),
)
.await;
assert!(unlinked["parent_id"].is_null());
let unlink_again = Request::builder()
.method("DELETE")
.uri(api(&format!("/runs/{child_id}/parent")))
.body(Body::empty())
.unwrap();
let unlinked_again = response_json(
app.clone().oneshot(unlink_again).await.unwrap(),
StatusCode::OK,
format!("DELETE /api/v1/runs/{child_id}/parent"),
)
.await;
assert!(unlinked_again["parent_id"].is_null());
}
#[tokio::test]
async fn deleting_parent_leaves_child_parent_id_as_historical_reference() {
let app = fabro_server::test_support::build_test_router(crate::helpers::test_app_state());
let parent = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await;
let parent_id = parent["id"].as_str().unwrap();
let mut child_manifest = minimal_manifest_json(MINIMAL_DOT);
child_manifest["parent_id"] = serde_json::json!(parent_id);
let child = create_run(&app, child_manifest).await;
let child_id = child["id"].as_str().unwrap();
let delete_request = Request::builder()
.method("DELETE")
.uri(api(&format!("/runs/{parent_id}?force=true")))
.body(Body::empty())
.unwrap();
crate::helpers::checked_response_in(
app.clone().oneshot(delete_request).await.unwrap(),
&[StatusCode::OK, StatusCode::NO_CONTENT],
format!("DELETE /api/v1/runs/{parent_id}?force=true"),
)
.await;
let get_child = Request::builder()
.method("GET")
.uri(api(&format!("/runs/{child_id}")))
.body(Body::empty())
.unwrap();
let child_after_delete = response_json(
app.clone().oneshot(get_child).await.unwrap(),
StatusCode::OK,
format!("GET /api/v1/runs/{child_id}"),
)
.await;
assert_eq!(child_after_delete["parent_id"], parent_id);
request_json(
&app,
"PUT",
format!("/runs/{child_id}/parent"),
serde_json::json!({ "parent_id": parent_id }),
StatusCode::NOT_FOUND,
)
.await;
}
#[tokio::test]
async fn parent_link_validation_rejects_missing_self_and_cycles() {
let app = fabro_server::test_support::build_test_router(crate::helpers::test_app_state());
let parent = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await;
let child = create_run(&app, minimal_manifest_json(MINIMAL_DOT)).await;
let parent_id = parent["id"].as_str().unwrap();
let child_id = child["id"].as_str().unwrap();
request_json(
&app,
"PUT",
format!("/runs/{child_id}/parent"),
serde_json::json!({ "parent_id": parent_id }),
StatusCode::OK,
)
.await;
request_json(
&app,
"PUT",
format!("/runs/{parent_id}/parent"),
serde_json::json!({ "parent_id": child_id }),
StatusCode::BAD_REQUEST,
)
.await;
request_json(
&app,
"PUT",
format!("/runs/{child_id}/parent"),
serde_json::json!({ "parent_id": child_id }),
StatusCode::BAD_REQUEST,
)
.await;
request_json(
&app,
"PUT",
format!("/runs/{child_id}/parent"),
serde_json::json!({ "parent_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV" }),
StatusCode::NOT_FOUND,
)
.await;
let missing_child = Request::builder()
.method("DELETE")
.uri(api("/runs/01ARZ3NDEKTSV4RRFFQ69G5FAV/parent"))
.body(Body::empty())
.unwrap();
response_status(
app.oneshot(missing_child).await.unwrap(),
StatusCode::NOT_FOUND,
"DELETE /api/v1/runs/{missing}/parent",
)
.await;
}

View file

@ -30,6 +30,7 @@ pub use types::EventPayload;
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ListRunsQuery {
pub start: Option<DateTime<Utc>>,
pub end: Option<DateTime<Utc>>,
pub start: Option<DateTime<Utc>>,
pub end: Option<DateTime<Utc>>,
pub parent_id: Option<fabro_types::RunId>,
}

View file

@ -160,6 +160,12 @@ impl RunProjectionReducer for RunProjection {
EventBody::RunSupersededBy(props) => {
self.superseded_by = Some(props.new_run_id);
}
EventBody::RunParentLinked(props) => {
self.parent_id = Some(props.parent_id);
}
EventBody::RunParentUnlinked(_props) => {
self.parent_id = None;
}
EventBody::RunArchived(_props) => {
if self.archived_at.is_some() {
return Ok(());
@ -524,6 +530,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result<RunProjection> {
};
let mut projection = RunProjection::new(title, spec, stored.ts);
projection.parent_id = props.parent_id;
projection.web_url.clone_from(&props.web_url);
projection.sandbox = Some(planned_sandbox(&projection.spec.settings.run.sandbox));
Ok(projection)
@ -671,6 +678,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary
RunSummary {
id: *run_id,
parent_id: state.parent_id,
title: state.title().into_owned(),
goal,
workflow: WorkflowRef {

View file

@ -533,6 +533,32 @@ mod tests {
.unwrap();
}
async fn append_created_with_parent(
run: &RunDatabase,
label: &str,
created_at: DateTime<Utc>,
parent_id: RunId,
) {
let run_spec = sample_run_spec(label);
run.append_event(&event_payload(
label,
&created_at.to_rfc3339(),
"run.created",
&serde_json::json!({
"settings": run_spec.settings,
"graph": run_spec.graph,
"workflow_slug": run_spec.workflow_slug,
"source_directory": run_spec.source_directory,
"run_dir": format!("/tmp/{label}"),
"git": run_spec.git,
"labels": run_spec.labels,
"parent_id": parent_id,
}),
))
.await
.unwrap();
}
async fn append_completed(run: &RunDatabase, label: &str, created_at: DateTime<Utc>) {
append_running(run, label, created_at).await;
run.append_event(&event_payload(
@ -673,6 +699,143 @@ mod tests {
);
}
#[tokio::test]
async fn parent_id_is_projected_from_created_and_parent_events() {
let (_object_store, store) = make_store();
let parent_1 = store.create_run(&test_run_id("run-1")).await.unwrap();
let parent_2 = store.create_run(&test_run_id("run-2")).await.unwrap();
let child = store.create_run(&test_run_id("run-3")).await.unwrap();
append_created(&parent_1, "run-1", dt("2026-03-27T12:00:00Z")).await;
append_created(&parent_2, "run-2", dt("2026-03-27T12:00:10Z")).await;
append_created_with_parent(
&child,
"run-3",
dt("2026-03-27T12:00:20Z"),
test_run_id("run-1"),
)
.await;
let initial = store.open_run(&test_run_id("run-3")).await.unwrap();
assert_eq!(
initial.state().await.unwrap().parent_id,
Some(test_run_id("run-1"))
);
assert_eq!(
store
.get_cached_summary(&test_run_id("run-3"))
.await
.unwrap()
.unwrap()
.parent_id,
Some(test_run_id("run-1"))
);
child
.append_event(&event_payload(
"run-3",
"2026-03-27T12:00:21Z",
"run.parent.linked",
&serde_json::json!({
"previous_parent_id": test_run_id("run-1"),
"parent_id": test_run_id("run-2"),
}),
))
.await
.unwrap();
assert_eq!(
store
.get_cached_summary(&test_run_id("run-3"))
.await
.unwrap()
.unwrap()
.parent_id,
Some(test_run_id("run-2"))
);
assert!(
store
.list_runs(&ListRunsQuery {
parent_id: Some(test_run_id("run-1")),
..ListRunsQuery::default()
})
.await
.unwrap()
.is_empty()
);
assert_eq!(
store
.list_runs(&ListRunsQuery {
parent_id: Some(test_run_id("run-2")),
..ListRunsQuery::default()
})
.await
.unwrap()
.into_iter()
.map(|summary| summary.id)
.collect::<Vec<_>>(),
vec![test_run_id("run-3")]
);
child
.append_event(&event_payload(
"run-3",
"2026-03-27T12:00:22Z",
"run.parent.unlinked",
&serde_json::json!({
"previous_parent_id": test_run_id("run-2"),
}),
))
.await
.unwrap();
assert_eq!(
store
.get_cached_summary(&test_run_id("run-3"))
.await
.unwrap()
.unwrap()
.parent_id,
None
);
assert!(
store
.list_runs(&ListRunsQuery {
parent_id: Some(test_run_id("run-2")),
..ListRunsQuery::default()
})
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
async fn list_runs_filters_by_parent_id() {
let (_object_store, store) = make_store();
let parent = store.create_run(&test_run_id("run-1")).await.unwrap();
let child = store.create_run(&test_run_id("run-2")).await.unwrap();
let unrelated = store.create_run(&test_run_id("run-3")).await.unwrap();
append_created(&parent, "run-1", dt("2026-03-27T12:00:00Z")).await;
append_created_with_parent(
&child,
"run-2",
dt("2026-03-27T12:00:10Z"),
test_run_id("run-1"),
)
.await;
append_created(&unrelated, "run-3", dt("2026-03-27T12:00:20Z")).await;
let summaries = store
.list_runs(&ListRunsQuery {
parent_id: Some(test_run_id("run-1")),
..ListRunsQuery::default()
})
.await
.unwrap();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].id, test_run_id("run-2"));
assert_eq!(summaries[0].parent_id, Some(test_run_id("run-1")));
}
#[tokio::test]
async fn control_effect_events_clear_pending_control_and_update_status() {
let (_object_store, store) = make_store();
@ -831,8 +994,9 @@ mod tests {
let filtered = reopened
.list_cached_runs(&ListRunsQuery {
start: Some(test_run_id("run-2").created_at()),
end: Some(test_run_id("run-2").created_at() + chrono::Duration::seconds(1)),
start: Some(test_run_id("run-2").created_at()),
end: Some(test_run_id("run-2").created_at() + chrono::Duration::seconds(1)),
parent_id: None,
})
.await
.unwrap();

View file

@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{BTreeSet, HashMap};
use std::sync::Arc;
use fabro_types::{RunId, RunProjection, RunSummary};
@ -29,24 +29,83 @@ impl CachedRunProjection {
#[derive(Debug, Default)]
pub(crate) struct RunProjectionCache {
entries: Mutex<HashMap<RunId, CachedRunProjection>>,
state: Mutex<RunProjectionCacheState>,
}
#[derive(Debug, Default)]
struct RunProjectionCacheState {
entries: HashMap<RunId, CachedRunProjection>,
children_by_parent: HashMap<RunId, BTreeSet<RunId>>,
}
impl RunProjectionCacheState {
fn replace_all(&mut self, entries: Vec<CachedRunProjection>) {
self.entries.clear();
self.children_by_parent.clear();
for entry in entries {
self.insert(entry);
}
}
fn insert(&mut self, entry: CachedRunProjection) {
let run_id = entry.run_id;
let parent_id = entry.summary.parent_id;
if let Some(previous) = self.entries.insert(run_id, entry) {
self.remove_parent_index(&previous);
}
if let Some(parent_id) = parent_id {
self.children_by_parent
.entry(parent_id)
.or_default()
.insert(run_id);
}
}
fn remove(&mut self, run_id: &RunId) {
if let Some(entry) = self.entries.remove(run_id) {
self.remove_parent_index(&entry);
}
}
fn remove_parent_index(&mut self, entry: &CachedRunProjection) {
let Some(parent_id) = entry.summary.parent_id else {
return;
};
let Some(children) = self.children_by_parent.get_mut(&parent_id) else {
return;
};
children.remove(&entry.run_id);
if children.is_empty() {
self.children_by_parent.remove(&parent_id);
}
}
}
impl RunProjectionCache {
pub(crate) async fn replace_all(&self, entries: Vec<CachedRunProjection>) {
let mut cache = self.entries.lock().await;
cache.clear();
cache.extend(entries.into_iter().map(|entry| (entry.run_id, entry)));
self.state.lock().await.replace_all(entries);
}
pub(crate) async fn replace(&self, entry: CachedRunProjection) {
self.entries.lock().await.insert(entry.run_id, entry);
self.state.lock().await.insert(entry);
}
pub(crate) async fn list(&self, query: &ListRunsQuery) -> Vec<CachedRunProjection> {
let cache = self.entries.lock().await;
let mut entries = cache
.values()
let entries = {
let state = self.state.lock().await;
match query.parent_id {
Some(parent_id) => state
.children_by_parent
.get(&parent_id)
.into_iter()
.flat_map(|children| children.iter())
.filter_map(|run_id| state.entries.get(run_id).cloned())
.collect::<Vec<_>>(),
None => state.entries.values().cloned().collect::<Vec<_>>(),
}
};
let mut entries = entries
.into_iter()
.filter(|entry| {
let created_at = entry.run_id.created_at();
if query.start.is_some_and(|start| created_at < start) {
@ -57,7 +116,6 @@ impl RunProjectionCache {
}
true
})
.cloned()
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
@ -70,26 +128,26 @@ impl RunProjectionCache {
}
pub(crate) async fn get(&self, run_id: &RunId) -> Option<CachedRunProjection> {
self.entries.lock().await.get(run_id).cloned()
self.state.lock().await.entries.get(run_id).cloned()
}
pub(crate) async fn get_summary(&self, run_id: &RunId) -> Option<RunSummary> {
self.entries
self.state
.lock()
.await
.entries
.get(run_id)
.map(|entry| entry.summary.clone())
}
pub(crate) async fn apply_event(&self, run_id: &RunId, event: &EventEnvelope) -> Result<()> {
let mut cache = self.entries.lock().await;
let Some(entry) = cache.get(run_id) else {
let mut state = self.state.lock().await;
let Some(entry) = state.entries.get(run_id).cloned() else {
if event.seq == 1 {
let projection = RunProjection::apply_events(std::slice::from_ref(event))?;
cache.insert(
*run_id,
CachedRunProjection::from_projection(*run_id, projection, event.seq),
);
state.insert(CachedRunProjection::from_projection(
*run_id, projection, event.seq,
));
} else {
return Err(Error::InvalidEvent(format!(
"projection cache cannot initialize run {run_id} from event seq {}",
@ -111,14 +169,13 @@ impl RunProjectionCache {
let mut projection = (*entry.projection).clone();
projection.apply_event(event)?;
cache.insert(
*run_id,
CachedRunProjection::from_projection(*run_id, projection, event.seq),
);
state.insert(CachedRunProjection::from_projection(
*run_id, projection, event.seq,
));
Ok(())
}
pub(crate) async fn remove(&self, run_id: &RunId) {
self.entries.lock().await.remove(run_id);
self.state.lock().await.remove(run_id);
}
}

View file

@ -139,8 +139,9 @@ mod tests {
assert_eq!(
index
.list(&ListRunsQuery {
start: Some(second.created_at()),
end: Some(second.created_at() + ChronoDuration::seconds(1)),
start: Some(second.created_at()),
end: Some(second.created_at() + ChronoDuration::seconds(1)),
parent_id: None,
})
.await
.unwrap(),

View file

@ -90,6 +90,10 @@ pub enum EventBody {
RunUnarchived(RunUnarchivedProps),
#[serde(rename = "run.title.updated")]
RunTitleUpdated(RunTitleUpdatedProps),
#[serde(rename = "run.parent.linked")]
RunParentLinked(RunParentLinkedProps),
#[serde(rename = "run.parent.unlinked")]
RunParentUnlinked(RunParentUnlinkedProps),
#[serde(rename = "run.completed")]
RunCompleted(RunCompletedProps),
#[serde(rename = "run.failed")]
@ -396,6 +400,8 @@ impl EventBody {
Self::RunArchived(_) => "run.archived",
Self::RunUnarchived(_) => "run.unarchived",
Self::RunTitleUpdated(_) => "run.title.updated",
Self::RunParentLinked(_) => "run.parent.linked",
Self::RunParentUnlinked(_) => "run.parent.unlinked",
Self::RunCompleted(_) => "run.completed",
Self::RunFailed(_) => "run.failed",
Self::RunNotice(_) => "run.notice",
@ -549,6 +555,8 @@ fn is_known_event_name(event: &str) -> bool {
| "run.superseded_by"
| "run.archived"
| "run.unarchived"
| "run.parent.linked"
| "run.parent.unlinked"
| "run.completed"
| "run.failed"
| "run.notice"

View file

@ -37,9 +37,23 @@ pub struct RunCreatedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fork_source_ref: Option<ForkSourceRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<RunId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunParentLinkedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_parent_id: Option<RunId>,
pub parent_id: RunId,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunParentUnlinkedProps {
pub previous_parent_id: RunId,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunStartedProps {
pub name: String,

View file

@ -14,6 +14,8 @@ use crate::{
pub struct RunProjection {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<RunId>,
pub spec: RunSpec,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web_url: Option<String>,
@ -156,6 +158,7 @@ impl RunProjection {
pub fn new(title: String, spec: RunSpec, created_at: DateTime<Utc>) -> Self {
Self {
title,
parent_id: None,
spec,
web_url: None,
start: None,

View file

@ -11,6 +11,8 @@ use crate::{
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Run {
pub id: RunId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<RunId>,
pub title: String,
pub goal: String,
pub workflow: WorkflowRef,

View file

@ -2,10 +2,10 @@ use std::collections::BTreeMap;
use fabro_types::graph::Graph;
use fabro_types::run::{DirtyStatus, ForkSourceRef, GitContext, PreRunPushOutcome};
use fabro_types::run_event::run::RunCreatedProps;
use fabro_types::run_event::run::{RunCreatedProps, RunParentLinkedProps, RunParentUnlinkedProps};
use fabro_types::settings::InterpString;
use fabro_types::settings::run::RunGoal;
use fabro_types::{WorkflowSettings, fixtures};
use fabro_types::{EventBody, WorkflowSettings, fixtures};
fn templated_settings() -> WorkflowSettings {
let mut settings = WorkflowSettings::default();
@ -39,6 +39,7 @@ fn run_created_props_round_trip_templated_settings() {
source_run_id: fixtures::RUN_2,
checkpoint_sha: "def456".to_string(),
}),
parent_id: Some(fixtures::RUN_2),
web_url: Some("http://localhost:3000/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z".to_string()),
};
@ -57,6 +58,7 @@ fn run_created_props_round_trip_templated_settings() {
json["web_url"],
"http://localhost:3000/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z"
);
assert_eq!(json["parent_id"], fixtures::RUN_2.to_string());
let round_trip: RunCreatedProps =
serde_json::from_value(json.clone()).expect("props should deserialize");
@ -88,6 +90,7 @@ fn run_created_props_omits_web_url_when_absent() {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
};
@ -96,8 +99,46 @@ fn run_created_props_omits_web_url_when_absent() {
json.get("web_url").is_none(),
"web_url must be omitted when None, got {json}"
);
assert!(
json.get("parent_id").is_none(),
"parent_id must be omitted when None, got {json}"
);
let round_trip: RunCreatedProps =
serde_json::from_value(json.clone()).expect("props should deserialize");
assert_eq!(round_trip.web_url, None);
assert_eq!(round_trip.parent_id, None);
}
#[test]
fn run_parent_events_round_trip_parent_ids() {
let linked = EventBody::RunParentLinked(RunParentLinkedProps {
previous_parent_id: None,
parent_id: fixtures::RUN_2,
});
let linked_json = serde_json::to_value(&linked).expect("linked event should serialize");
assert_eq!(linked_json["event"], "run.parent.linked");
assert_eq!(
linked_json["properties"]["parent_id"],
fixtures::RUN_2.to_string()
);
let linked_round_trip: EventBody =
serde_json::from_value(linked_json).expect("linked event should deserialize");
assert_eq!(linked_round_trip.event_name(), "run.parent.linked");
let unlinked = EventBody::RunParentUnlinked(RunParentUnlinkedProps {
previous_parent_id: fixtures::RUN_2,
});
let unlinked_json = serde_json::to_value(&unlinked).expect("unlinked event should serialize");
assert_eq!(unlinked_json["event"], "run.parent.unlinked");
assert_eq!(
unlinked_json["properties"]["previous_parent_id"],
fixtures::RUN_2.to_string()
);
assert!(unlinked_json["properties"].get("parent_id").is_none());
let unlinked_round_trip: EventBody =
serde_json::from_value(unlinked_json).expect("unlinked event should deserialize");
assert_eq!(unlinked_round_trip.event_name(), "run.parent.unlinked");
}

View file

@ -39,6 +39,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
manifest_blob,
git,
fork_source_ref,
parent_id,
web_url,
..
} => EventBody::RunCreated(fabro_types::RunCreatedProps {
@ -57,6 +58,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
manifest_blob: *manifest_blob,
git: git.clone(),
fork_source_ref: fork_source_ref.clone(),
parent_id: *parent_id,
web_url: web_url.clone(),
}),
Event::WorkflowRunStarted {
@ -143,6 +145,19 @@ fn event_body_from_event(event: &Event) -> EventBody {
title: title.clone(),
})
}
Event::RunParentLinked {
previous_parent_id,
parent_id,
..
} => EventBody::RunParentLinked(fabro_types::RunParentLinkedProps {
previous_parent_id: *previous_parent_id,
parent_id: *parent_id,
}),
Event::RunParentUnlinked {
previous_parent_id, ..
} => EventBody::RunParentUnlinked(fabro_types::RunParentUnlinkedProps {
previous_parent_id: *previous_parent_id,
}),
Event::WorkflowRunCompleted {
duration_ms,
artifact_count,
@ -2251,6 +2266,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
});
let actor = stored.actor.as_ref().expect("actor set");

View file

@ -45,6 +45,8 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
fork_source_ref: Option<ForkSourceRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_id: Option<RunId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
web_url: Option<String>,
},
WorkflowRunStarted {
@ -115,6 +117,18 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
},
RunParentLinked {
#[serde(default, skip_serializing_if = "Option::is_none")]
previous_parent_id: Option<RunId>,
parent_id: RunId,
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
},
RunParentUnlinked {
previous_parent_id: RunId,
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
},
WorkflowRunCompleted {
duration_ms: u64,
artifact_count: usize,
@ -810,6 +824,19 @@ impl Event {
Self::RunTitleUpdated { title, actor } => {
info!(title, ?actor, "Run title updated");
}
Self::RunParentLinked {
previous_parent_id,
parent_id,
actor,
} => {
info!(?previous_parent_id, %parent_id, ?actor, "Run parent linked");
}
Self::RunParentUnlinked {
previous_parent_id,
actor,
} => {
info!(%previous_parent_id, ?actor, "Run parent unlinked");
}
Self::WorkflowRunCompleted {
duration_ms,
artifact_count,

View file

@ -25,6 +25,8 @@ pub fn event_name(event: &Event) -> &'static str {
Event::RunArchived { .. } => "run.archived",
Event::RunUnarchived { .. } => "run.unarchived",
Event::RunTitleUpdated { .. } => "run.title.updated",
Event::RunParentLinked { .. } => "run.parent.linked",
Event::RunParentUnlinked { .. } => "run.parent.unlinked",
Event::WorkflowRunCompleted { .. } => "run.completed",
Event::WorkflowRunFailed { .. } => "run.failed",
Event::RunNotice { .. } => "run.notice",

View file

@ -247,6 +247,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await

View file

@ -68,6 +68,8 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields {
| Event::RunArchived { actor }
| Event::RunUnarchived { actor, .. }
| Event::RunTitleUpdated { actor, .. }
| Event::RunParentLinked { actor, .. }
| Event::RunParentUnlinked { actor, .. }
| Event::InterviewCompleted { actor, .. }
| Event::AgentSteerBuffered { actor, .. } => StoredEventFields {
actor: actor.clone(),

View file

@ -472,6 +472,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await

View file

@ -482,6 +482,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
},
)

View file

@ -353,6 +353,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
},
)

View file

@ -706,6 +706,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
},
)

View file

@ -274,6 +274,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
},
)

View file

@ -729,6 +729,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await

View file

@ -226,6 +226,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await

View file

@ -47,6 +47,7 @@ pub struct CreateRunInput {
pub title: Option<String>,
pub git: Option<GitContext>,
pub fork_source_ref: Option<ForkSourceRef>,
pub parent_id: Option<RunId>,
pub provenance: Option<RunProvenance>,
pub configured_providers: Vec<ProviderId>,
/// Public URL where this run can be viewed in the web UI, when the server
@ -109,6 +110,7 @@ pub async fn create(
title,
git,
fork_source_ref,
parent_id,
provenance,
configured_providers,
web_url,
@ -169,6 +171,7 @@ pub async fn create(
submitted_manifest_bytes.as_deref(),
accepted_definition.as_ref(),
title,
parent_id,
web_url,
)
.await?;
@ -189,6 +192,7 @@ async fn persist_created_run(
submitted_manifest_bytes: Option<&[u8]>,
accepted_definition: Option<&RunDefinition>,
explicit_title: Option<String>,
parent_id: Option<RunId>,
web_url: Option<String>,
) -> Result<(), Error> {
let record = persisted.run_spec();
@ -242,6 +246,7 @@ async fn persist_created_run(
manifest_blob,
git: record.git.clone(),
fork_source_ref: record.fork_source_ref.clone(),
parent_id,
web_url,
},
record.run_id.created_at(),
@ -871,6 +876,7 @@ mod tests {
title: None,
git: None,
fork_source_ref: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
web_url: None,
@ -916,6 +922,7 @@ mod tests {
title: None,
git: None,
fork_source_ref: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
web_url: None,
@ -983,6 +990,7 @@ mod tests {
push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,
}),
fork_source_ref: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
web_url: None,
@ -1093,6 +1101,7 @@ mod tests {
title: None,
git: None,
fork_source_ref: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
web_url: None,
@ -1137,6 +1146,7 @@ mod tests {
push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,
}),
fork_source_ref: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
web_url: None,
@ -1203,6 +1213,7 @@ mod tests {
title: None,
git: None,
fork_source_ref: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
web_url: None,
@ -1248,6 +1259,7 @@ mod tests {
title: None,
git: None,
fork_source_ref: None,
parent_id: None,
provenance: Some(fabro_types::RunProvenance {
server: Some(fabro_types::RunServerProvenance {
version: "0.9.0".to_string(),

View file

@ -166,6 +166,7 @@ async fn persist_forked_run(
manifest_blob: spec.manifest_blob,
git: spec.git.clone(),
fork_source_ref: spec.fork_source_ref.clone(),
parent_id: None,
web_url: None,
})
.await
@ -384,6 +385,7 @@ mod tests {
push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,
}),
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await

View file

@ -1306,6 +1306,7 @@ mod tests {
title: None,
git: None,
fork_source_ref: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
web_url: None,
@ -1497,6 +1498,7 @@ mod tests {
title: None,
git: None,
fork_source_ref: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
web_url: None,

View file

@ -211,6 +211,7 @@ async fn seed_created_and_starting(
manifest_blob: None,
git: run_options.pre_run_git.clone(),
fork_source_ref: run_options.fork_source_ref.clone(),
parent_id: None,
web_url: None,
})
.await

View file

@ -741,6 +741,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await

View file

@ -173,6 +173,7 @@ mod tests {
manifest_blob: None,
git: record.git.clone(),
fork_source_ref: record.fork_source_ref.clone(),
parent_id: None,
web_url: None,
})
.await

View file

@ -1164,6 +1164,7 @@ mod tests {
manifest_blob: None,
git: run_spec.git.clone(),
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await
@ -1231,6 +1232,7 @@ mod tests {
manifest_blob: None,
git: run_spec.git.clone(),
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await
@ -1587,6 +1589,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await
@ -1706,6 +1709,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await
@ -1876,6 +1880,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await

View file

@ -485,6 +485,7 @@ mod tests {
manifest_blob: None,
git: run_spec.git.clone(),
fork_source_ref: run_spec.fork_source_ref.clone(),
parent_id: None,
web_url: None,
})
.await

View file

@ -172,6 +172,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
parent_id: None,
web_url: None,
})
.await

View file

@ -127,6 +127,7 @@ async fn initialized(
manifest_blob: None,
git: run_options.pre_run_git.clone(),
fork_source_ref: run_options.fork_source_ref.clone(),
parent_id: None,
web_url: None,
})
.await

View file

@ -370,6 +370,7 @@ models/system-repair-runs-response.ts
models/system-run-counts.ts
models/timeline-entry-response.ts
models/tls-mode.ts
models/update-run-parent-request.ts
models/update-run-request.ts
models/user-response.ts
models/validate-response.ts

View file

@ -64,6 +64,8 @@ import type { StartRunRequest } from '../models';
// @ts-ignore
import type { TimelineEntryResponse } from '../models';
// @ts-ignore
import type { UpdateRunParentRequest } from '../models';
// @ts-ignore
import type { UpdateRunRequest } from '../models';
// @ts-ignore
import type { ValidateResponse } from '../models';
@ -446,6 +448,51 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
* Links a run under an orchestration parent. Parent links are mutable for all run states, including archived and terminal runs.
* @summary Link Run Parent
* @param {string} id Unique run identifier (ULID).
* @param {UpdateRunParentRequest} updateRunParentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
linkRunParent: async (id: string, updateRunParentRequest: UpdateRunParentRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('linkRunParent', 'id', id)
// verify required parameter 'updateRunParentRequest' is not null or undefined
assertParamExists('linkRunParent', 'updateRunParentRequest', updateRunParentRequest)
const localVarPath = `/api/v1/runs/{id}/parent`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(updateRunParentRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Links or replaces the GitHub pull request association for a run without modifying the remote pull request.
* @summary Link Run Pull Request
@ -548,10 +595,11 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {boolean} [includeArchived] Whether to include archived runs in the response. Defaults to &#x60;false&#x60;.
* @param {string} [parentId] Return only runs currently linked to this orchestration parent.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listRuns: async (pageLimit?: number, pageOffset?: number, includeArchived?: boolean, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
listRuns: async (pageLimit?: number, pageOffset?: number, includeArchived?: boolean, parentId?: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/v1/runs`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -582,6 +630,10 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
localVarQueryParameter['include_archived'] = includeArchived;
}
if (parentId !== undefined) {
localVarQueryParameter['parent_id'] = parentId;
}
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -1054,6 +1106,46 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
* Removes a run\'s orchestration parent. Already-root runs are returned unchanged.
* @summary Unlink Run Parent
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
unlinkRunParent: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('unlinkRunParent', 'id', id)
const localVarPath = `/api/v1/runs/{id}/parent`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Removes Fabro\'s stored pull request association for a run without modifying the remote pull request.
* @summary Unlink Run Pull Request
@ -1349,6 +1441,20 @@ export const RunsApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['RunsApi.getRunTimeline']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Links a run under an orchestration parent. Parent links are mutable for all run states, including archived and terminal runs.
* @summary Link Run Parent
* @param {string} id Unique run identifier (ULID).
* @param {UpdateRunParentRequest} updateRunParentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async linkRunParent(id: string, updateRunParentRequest: UpdateRunParentRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.linkRunParent(id, updateRunParentRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.linkRunParent']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Links or replaces the GitHub pull request association for a run without modifying the remote pull request.
* @summary Link Run Pull Request
@ -1384,11 +1490,12 @@ export const RunsApiFp = function(configuration?: Configuration) {
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {boolean} [includeArchived] Whether to include archived runs in the response. Defaults to &#x60;false&#x60;.
* @param {string} [parentId] Return only runs currently linked to this orchestration parent.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedRunList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(pageLimit, pageOffset, includeArchived, options);
async listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, parentId?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedRunList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listRuns(pageLimit, pageOffset, includeArchived, parentId, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.listRuns']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
@ -1540,6 +1647,19 @@ export const RunsApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['RunsApi.unarchiveRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Removes a run\'s orchestration parent. Already-root runs are returned unchanged.
* @summary Unlink Run Parent
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async unlinkRunParent(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.unlinkRunParent(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.unlinkRunParent']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Removes Fabro\'s stored pull request association for a run without modifying the remote pull request.
* @summary Unlink Run Pull Request
@ -1695,6 +1815,17 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
getRunTimeline(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Array<TimelineEntryResponse>> {
return localVarFp.getRunTimeline(id, options).then((request) => request(axios, basePath));
},
/**
* Links a run under an orchestration parent. Parent links are mutable for all run states, including archived and terminal runs.
* @summary Link Run Parent
* @param {string} id Unique run identifier (ULID).
* @param {UpdateRunParentRequest} updateRunParentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
linkRunParent(id: string, updateRunParentRequest: UpdateRunParentRequest, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.linkRunParent(id, updateRunParentRequest, options).then((request) => request(axios, basePath));
},
/**
* Links or replaces the GitHub pull request association for a run without modifying the remote pull request.
* @summary Link Run Pull Request
@ -1724,11 +1855,12 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {boolean} [includeArchived] Whether to include archived runs in the response. Defaults to &#x60;false&#x60;.
* @param {string} [parentId] Return only runs currently linked to this orchestration parent.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedRunList> {
return localVarFp.listRuns(pageLimit, pageOffset, includeArchived, options).then((request) => request(axios, basePath));
listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, parentId?: string, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedRunList> {
return localVarFp.listRuns(pageLimit, pageOffset, includeArchived, parentId, options).then((request) => request(axios, basePath));
},
/**
* Merges the stored pull request for a run on GitHub.
@ -1844,6 +1976,16 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
unarchiveRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.unarchiveRun(id, options).then((request) => request(axios, basePath));
},
/**
* Removes a run\'s orchestration parent. Already-root runs are returned unchanged.
* @summary Unlink Run Parent
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
unlinkRunParent(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.unlinkRunParent(id, options).then((request) => request(axios, basePath));
},
/**
* Removes Fabro\'s stored pull request association for a run without modifying the remote pull request.
* @summary Unlink Run Pull Request
@ -1994,6 +2136,18 @@ export class RunsApi extends BaseAPI {
return RunsApiFp(this.configuration).getRunTimeline(id, options).then((request) => request(this.axios, this.basePath));
}
/**
* Links a run under an orchestration parent. Parent links are mutable for all run states, including archived and terminal runs.
* @summary Link Run Parent
* @param {string} id Unique run identifier (ULID).
* @param {UpdateRunParentRequest} updateRunParentRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public linkRunParent(id: string, updateRunParentRequest: UpdateRunParentRequest, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).linkRunParent(id, updateRunParentRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Links or replaces the GitHub pull request association for a run without modifying the remote pull request.
* @summary Link Run Pull Request
@ -2025,11 +2179,12 @@ export class RunsApi extends BaseAPI {
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {boolean} [includeArchived] Whether to include archived runs in the response. Defaults to &#x60;false&#x60;.
* @param {string} [parentId] Return only runs currently linked to this orchestration parent.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).listRuns(pageLimit, pageOffset, includeArchived, options).then((request) => request(this.axios, this.basePath));
public listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, parentId?: string, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).listRuns(pageLimit, pageOffset, includeArchived, parentId, options).then((request) => request(this.axios, this.basePath));
}
/**
@ -2157,6 +2312,17 @@ export class RunsApi extends BaseAPI {
return RunsApiFp(this.configuration).unarchiveRun(id, options).then((request) => request(this.axios, this.basePath));
}
/**
* Removes a run\'s orchestration parent. Already-root runs are returned unchanged.
* @summary Unlink Run Parent
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public unlinkRunParent(id: string, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).unlinkRunParent(id, options).then((request) => request(this.axios, this.basePath));
}
/**
* Removes Fabro\'s stored pull request association for a run without modifying the remote pull request.
* @summary Unlink Run Pull Request

View file

@ -347,6 +347,7 @@ export * from './system-repair-runs-response';
export * from './system-run-counts';
export * from './timeline-entry-response';
export * from './tls-mode';
export * from './update-run-parent-request';
export * from './update-run-request';
export * from './user-response';
export * from './validate-response';

View file

@ -44,6 +44,10 @@ export interface RunManifest {
* Optional pre-generated run ID to use instead of allocating a new ULID.
*/
'run_id'?: string | null;
/**
* Optional orchestration parent run ID. Fork and rewind lineage use separate fields and should not set this value.
*/
'parent_id'?: string | null;
/**
* Optional explicit run title. The server trims leading/trailing whitespace, rejects blank values, rejects control characters and newline characters, and requires at most 100 characters.
*/

View file

@ -52,6 +52,10 @@ export interface RunProjection {
* Resolved run title from the event log.
*/
'title'?: string;
/**
* Current orchestration parent run ID, if linked.
*/
'parent_id'?: string | null;
'spec': RunSpec;
/**
* Absolute web UI URL for this run when server web settings are configured.

View file

@ -61,6 +61,10 @@ import type { WorkflowRef } from './workflow-ref';
*/
export interface Run {
'id': string;
/**
* Current orchestration parent run ID, if linked.
*/
'parent_id'?: string | null;
'title': string;
'goal': string;
'workflow': WorkflowRef;

View file

@ -0,0 +1,23 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface UpdateRunParentRequest {
/**
* Existing orchestration parent run ID.
*/
'parent_id': string;
}