diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 72c89cb6b..3a0a27a88 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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: diff --git a/lib/crates/fabro-api/tests/run_summary_round_trip.rs b/lib/crates/fabro-api/tests/run_summary_round_trip.rs index 153bec293..8144d7d06 100644 --- a/lib/crates/fabro-api/tests/run_summary_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_summary_round_trip.rs @@ -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 { diff --git a/lib/crates/fabro-manifest/src/lib.rs b/lib/crates/fabro-manifest/src/lib.rs index 7455c61c6..95429bf50 100644 --- a/lib/crates/fabro-manifest/src/lib.rs +++ b/lib/crates/fabro-manifest/src/lib.rs @@ -245,6 +245,7 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result { 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 { diff --git a/lib/crates/fabro-mcp-server/src/run_tools/search.rs b/lib/crates/fabro-mcp-server/src/run_tools/search.rs index 0a5b2bb15..bc6ce7d37 100644 --- a/lib/crates/fabro-mcp-server/src/run_tools/search.rs +++ b/lib/crates/fabro-mcp-server/src/run_tools/search.rs @@ -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 { diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index dc91f0855..712f28882 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -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 { diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 590a54cee..a92211ecd 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -52,6 +52,7 @@ pub(crate) struct PreparedManifest { pub git: Option, pub root_source: String, pub run_id: Option, + pub parent_id: Option, pub title: Option, pub settings: WorkflowSettings, pub target_path: ManifestPath, @@ -155,6 +156,12 @@ pub(crate) fn prepare_manifest( .map(str::parse::) .transpose() .context("invalid run ID")?, + parent_id: manifest + .parent_id + .as_deref() + .map(str::parse::) + .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 { diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 7c64d1a16..ef3682953 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -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>, pub(super) server_secrets: ServerSecrets, @@ -1590,6 +1591,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result Router> { "/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, } 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>, + Path(id): Path, + Json(req): Json, +) -> 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::() { + 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>, + Path(id): Path, +) -> 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(¤t_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>, @@ -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); diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 142b88b50..740375ec0 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -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 { diff --git a/lib/crates/fabro-server/tests/it/api/run_files.rs b/lib/crates/fabro-server/tests/it/api/run_files.rs index df7307e82..b70688252 100644 --- a/lib/crates/fabro-server/tests/it/api/run_files.rs +++ b/lib/crates/fabro-server/tests/it/api/run_files.rs @@ -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 diff --git a/lib/crates/fabro-server/tests/it/api/runs.rs b/lib/crates/fabro-server/tests/it/api/runs.rs index c8fb2f609..9682a8c6e 100644 --- a/lib/crates/fabro-server/tests/it/api/runs.rs +++ b/lib/crates/fabro-server/tests/it/api/runs.rs @@ -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::>(); + 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; +} diff --git a/lib/crates/fabro-store/src/lib.rs b/lib/crates/fabro-store/src/lib.rs index 604893c91..51a0d4fbc 100644 --- a/lib/crates/fabro-store/src/lib.rs +++ b/lib/crates/fabro-store/src/lib.rs @@ -30,6 +30,7 @@ pub use types::EventPayload; #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct ListRunsQuery { - pub start: Option>, - pub end: Option>, + pub start: Option>, + pub end: Option>, + pub parent_id: Option, } diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index da3def19e..45c1647ad 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -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 { }; 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 { diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index e338384f5..18350ec6e 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -533,6 +533,32 @@ mod tests { .unwrap(); } + async fn append_created_with_parent( + run: &RunDatabase, + label: &str, + created_at: DateTime, + 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) { 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![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(); diff --git a/lib/crates/fabro-store/src/slate/projection_cache.rs b/lib/crates/fabro-store/src/slate/projection_cache.rs index e8d8486aa..9ee673488 100644 --- a/lib/crates/fabro-store/src/slate/projection_cache.rs +++ b/lib/crates/fabro-store/src/slate/projection_cache.rs @@ -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>, + state: Mutex, +} + +#[derive(Debug, Default)] +struct RunProjectionCacheState { + entries: HashMap, + children_by_parent: HashMap>, +} + +impl RunProjectionCacheState { + fn replace_all(&mut self, entries: Vec) { + 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) { - 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 { - 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::>(), + None => state.entries.values().cloned().collect::>(), + } + }; + 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::>(); entries.sort_by(|left, right| { right @@ -70,26 +128,26 @@ impl RunProjectionCache { } pub(crate) async fn get(&self, run_id: &RunId) -> Option { - 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 { - 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); } } diff --git a/lib/crates/fabro-store/src/slate/run_catalog_index.rs b/lib/crates/fabro-store/src/slate/run_catalog_index.rs index 332c09496..33efce158 100644 --- a/lib/crates/fabro-store/src/slate/run_catalog_index.rs +++ b/lib/crates/fabro-store/src/slate/run_catalog_index.rs @@ -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(), diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index a4509f005..8ee972961 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -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" diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs index 123d89c39..ae724224c 100644 --- a/lib/crates/fabro-types/src/run_event/run.rs +++ b/lib/crates/fabro-types/src/run_event/run.rs @@ -37,9 +37,23 @@ pub struct RunCreatedProps { #[serde(default, skip_serializing_if = "Option::is_none")] pub fork_source_ref: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub web_url: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunParentLinkedProps { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_parent_id: Option, + 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, diff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs index 9cba67103..27be839b4 100644 --- a/lib/crates/fabro-types/src/run_projection.rs +++ b/lib/crates/fabro-types/src/run_projection.rs @@ -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, pub spec: RunSpec, #[serde(default, skip_serializing_if = "Option::is_none")] pub web_url: Option, @@ -156,6 +158,7 @@ impl RunProjection { pub fn new(title: String, spec: RunSpec, created_at: DateTime) -> Self { Self { title, + parent_id: None, spec, web_url: None, start: None, diff --git a/lib/crates/fabro-types/src/run_summary.rs b/lib/crates/fabro-types/src/run_summary.rs index e4e64f30c..68c7ccb23 100644 --- a/lib/crates/fabro-types/src/run_summary.rs +++ b/lib/crates/fabro-types/src/run_summary.rs @@ -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, pub title: String, pub goal: String, pub workflow: WorkflowRef, diff --git a/lib/crates/fabro-types/tests/run_event_serde.rs b/lib/crates/fabro-types/tests/run_event_serde.rs index fccb59eae..8f96a0fc3 100644 --- a/lib/crates/fabro-types/tests/run_event_serde.rs +++ b/lib/crates/fabro-types/tests/run_event_serde.rs @@ -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"); } diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index 5538c01f7..0ce439079 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -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"); diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs index 88731cf9e..b9bc4d815 100644 --- a/lib/crates/fabro-workflow/src/event/events.rs +++ b/lib/crates/fabro-workflow/src/event/events.rs @@ -45,6 +45,8 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] fork_source_ref: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + parent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] web_url: Option, }, WorkflowRunStarted { @@ -115,6 +117,18 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] actor: Option, }, + RunParentLinked { + #[serde(default, skip_serializing_if = "Option::is_none")] + previous_parent_id: Option, + parent_id: RunId, + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, + RunParentUnlinked { + previous_parent_id: RunId, + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, 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, diff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs index 22d8b05cd..c5abfbc7c 100644 --- a/lib/crates/fabro-workflow/src/event/names.rs +++ b/lib/crates/fabro-workflow/src/event/names.rs @@ -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", diff --git a/lib/crates/fabro-workflow/src/event/sink.rs b/lib/crates/fabro-workflow/src/event/sink.rs index ba7902fcb..bf9ff0714 100644 --- a/lib/crates/fabro-workflow/src/event/sink.rs +++ b/lib/crates/fabro-workflow/src/event/sink.rs @@ -247,6 +247,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/event/stored_fields.rs b/lib/crates/fabro-workflow/src/event/stored_fields.rs index 20abd7a96..c640f0d39 100644 --- a/lib/crates/fabro-workflow/src/event/stored_fields.rs +++ b/lib/crates/fabro-workflow/src/event/stored_fields.rs @@ -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(), diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index 6734ebe1c..80220c4fa 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -472,6 +472,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs index 35d24d421..f0ac0ad07 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/crates/fabro-workflow/src/handler/agent.rs @@ -482,6 +482,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }, ) diff --git a/lib/crates/fabro-workflow/src/handler/command.rs b/lib/crates/fabro-workflow/src/handler/command.rs index c830bb8b6..ff0ba907d 100644 --- a/lib/crates/fabro-workflow/src/handler/command.rs +++ b/lib/crates/fabro-workflow/src/handler/command.rs @@ -353,6 +353,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }, ) diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index f67a8d37d..66e12dc7e 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -706,6 +706,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }, ) diff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs index 29a67b756..67bc37a79 100644 --- a/lib/crates/fabro-workflow/src/handler/prompt.rs +++ b/lib/crates/fabro-workflow/src/handler/prompt.rs @@ -274,6 +274,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }, ) diff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs index 7e8e6dca9..d34714631 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/git.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs @@ -729,6 +729,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/operations/archive.rs b/lib/crates/fabro-workflow/src/operations/archive.rs index 8a6044f16..a0fea188c 100644 --- a/lib/crates/fabro-workflow/src/operations/archive.rs +++ b/lib/crates/fabro-workflow/src/operations/archive.rs @@ -226,6 +226,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 23624c5bd..59c5f1e15 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -47,6 +47,7 @@ pub struct CreateRunInput { pub title: Option, pub git: Option, pub fork_source_ref: Option, + pub parent_id: Option, pub provenance: Option, pub configured_providers: Vec, /// 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, + parent_id: Option, web_url: Option, ) -> 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(), diff --git a/lib/crates/fabro-workflow/src/operations/fork.rs b/lib/crates/fabro-workflow/src/operations/fork.rs index 2341ddd5b..a42bcf5bb 100644 --- a/lib/crates/fabro-workflow/src/operations/fork.rs +++ b/lib/crates/fabro-workflow/src/operations/fork.rs @@ -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 diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 970c1a875..5517bf28c 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index c65c9e36f..dbb9dcadd 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -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 diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index 1d573207c..3a5f6ebb5 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -741,6 +741,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/pipeline/persist.rs b/lib/crates/fabro-workflow/src/pipeline/persist.rs index 5bebb4afe..b760bc571 100644 --- a/lib/crates/fabro-workflow/src/pipeline/persist.rs +++ b/lib/crates/fabro-workflow/src/pipeline/persist.rs @@ -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 diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 2335d9250..1df783161 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -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 diff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/crates/fabro-workflow/src/run_lookup.rs index 39ff91909..ccb2e2339 100644 --- a/lib/crates/fabro-workflow/src/run_lookup.rs +++ b/lib/crates/fabro-workflow/src/run_lookup.rs @@ -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 diff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/crates/fabro-workflow/src/runtime_store.rs index acf4f95cb..b32ef1c03 100644 --- a/lib/crates/fabro-workflow/src/runtime_store.rs +++ b/lib/crates/fabro-workflow/src/runtime_store.rs @@ -172,6 +172,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + parent_id: None, web_url: None, }) .await diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index db4307139..5e184edc8 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -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 diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 4d30c7580..db947cc87 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -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 diff --git a/lib/packages/fabro-api-client/src/api/runs-api.ts b/lib/packages/fabro-api-client/src/api/runs-api.ts index 32677953b..e3150d58e 100644 --- a/lib/packages/fabro-api-client/src/api/runs-api.ts +++ b/lib/packages/fabro-api-client/src/api/runs-api.ts @@ -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 => { + // 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 `false`. + * @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 => { + listRuns: async (pageLimit?: number, pageOffset?: number, includeArchived?: boolean, parentId?: string, options: RawAxiosRequestConfig = {}): Promise => { 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 => { + // 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> { + 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 `false`. + * @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> { - 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> { + 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> { + 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> { 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 { + 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 `false`. + * @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 { - return localVarFp.listRuns(pageLimit, pageOffset, includeArchived, options).then((request) => request(axios, basePath)); + listRuns(pageLimit?: number, pageOffset?: number, includeArchived?: boolean, parentId?: string, options?: RawAxiosRequestConfig): AxiosPromise { + 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 { 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 { + 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 `false`. + * @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 diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index a5fa311a4..345fe00a8 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/models/run-manifest.ts b/lib/packages/fabro-api-client/src/models/run-manifest.ts index 7b78402bf..7d1d86c0e 100644 --- a/lib/packages/fabro-api-client/src/models/run-manifest.ts +++ b/lib/packages/fabro-api-client/src/models/run-manifest.ts @@ -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. */ diff --git a/lib/packages/fabro-api-client/src/models/run-projection.ts b/lib/packages/fabro-api-client/src/models/run-projection.ts index 2c93eb48e..e49984ebb 100644 --- a/lib/packages/fabro-api-client/src/models/run-projection.ts +++ b/lib/packages/fabro-api-client/src/models/run-projection.ts @@ -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. diff --git a/lib/packages/fabro-api-client/src/models/run.ts b/lib/packages/fabro-api-client/src/models/run.ts index 0849c7118..67d4aa233 100644 --- a/lib/packages/fabro-api-client/src/models/run.ts +++ b/lib/packages/fabro-api-client/src/models/run.ts @@ -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; diff --git a/lib/packages/fabro-api-client/src/models/update-run-parent-request.ts b/lib/packages/fabro-api-client/src/models/update-run-parent-request.ts new file mode 100644 index 000000000..5f3ae55e5 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/update-run-parent-request.ts @@ -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; +} +