feat(runs): support explicit run titles

Persist resolved run titles on creation, expose title update events, and add the run title PATCH API. Regenerate API clients and refresh web/server invalidation so title changes are reflected across run detail and board views.
This commit is contained in:
Bryan Helmkamp 2026-05-09 11:18:12 -04:00
parent 53c03a121f
commit 697dc1294f
No known key found for this signature in database
52 changed files with 848 additions and 39 deletions

View file

@ -70,7 +70,7 @@ describe("subscribeToBoardEvents", () => {
source.emit({ event: "run.running" });
expect(created).toEqual(["/api/v1/attach"]);
expect(keys).toEqual([queryKeys.boards.runs()]);
expect(keys).toEqual([queryKeys.boards.runs(false), queryKeys.boards.runs(true)]);
firstCleanup();
expect(source.closed).toBe(false);
@ -100,7 +100,7 @@ describe("subscribeToBoardEvents", () => {
source.emit({ event: "run.running" });
expect(created).toEqual(["/api/v1/attach"]);
expect(keys).toEqual([queryKeys.boards.runs()]);
expect(keys).toEqual([queryKeys.boards.runs(false), queryKeys.boards.runs(true)]);
firstCleanup();
expect(source.closed).toBe(false);

View file

@ -34,6 +34,7 @@ const BOARD_STATUS_EVENTS = new Set([
"run.failed",
"run.archived",
"run.unarchived",
"run.title.updated",
"interview.started",
"interview.completed",
"interview.timeout",
@ -57,7 +58,7 @@ export function subscribeToBoardEvents(
subscriptionKey: BOARD_SUBSCRIPTION_KEY,
mutate,
debounceMs,
resyncKeys: () => [queryKeys.boards.runs()],
resyncKeys: () => boardRunKeys(),
resolveInvalidation: boardInvalidation,
fallbackSubscribe: () =>
subscribeToSharedEventSource<EventPayload>({
@ -75,11 +76,15 @@ export function subscribeToBoardEvents(
function boardInvalidation(payload: EventPayload) {
return {
keys: payload.event && shouldRefreshBoardForEvent(payload.event)
? [queryKeys.boards.runs()]
? boardRunKeys()
: [],
};
}
function boardRunKeys() {
return [queryKeys.boards.runs(false), queryKeys.boards.runs(true)];
}
export function useBoardEvents() {
const { mutate } = useSWRConfig();

View file

@ -39,6 +39,9 @@ describe("queryKeys", () => {
queryKeys.runs.detail("run-1"),
queryKeys.runs.stageEvents("run-1", "stage-1"),
]);
expect(queryKeysForRunEvent("run-1", "run.title.updated")).toEqual([
queryKeys.runs.detail("run-1"),
]);
});
test("agent activity events invalidate the per-stage events key", () => {

View file

@ -46,6 +46,7 @@ const RUN_SUMMARY_EVENTS = new Set([
"run.unblocked",
"run.archived",
"run.unarchived",
"run.title.updated",
]);
const STAGE_EVENTS = new Set([
"stage.started",

View file

@ -102,6 +102,7 @@ describe("runs route board mapping", () => {
expect(shouldRefreshBoardForEvent("run.unblocked")).toBe(true);
expect(shouldRefreshBoardForEvent("run.archived")).toBe(true);
expect(shouldRefreshBoardForEvent("run.unarchived")).toBe(true);
expect(shouldRefreshBoardForEvent("run.title.updated")).toBe(true);
expect(shouldRefreshBoardForEvent("interview.started")).toBe(true);
expect(shouldRefreshBoardForEvent("interview.completed")).toBe(true);
expect(shouldRefreshBoardForEvent("run.created")).toBe(false);

View file

@ -918,6 +918,44 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
patch:
operationId: updateRun
tags: [Runs]
summary: Update Run
description: Updates mutable run metadata. Title updates are allowed for all run states, including archived runs.
parameters:
- $ref: "#/components/parameters/RunId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateRunRequest"
responses:
"200":
description: Updated durable run summary
content:
application/json:
schema:
$ref: "#/components/schemas/RunSummary"
"400":
description: Invalid title
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Run not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
delete:
operationId: deleteRun
tags: [Runs]
@ -4607,6 +4645,11 @@ components:
type: ["string", "null"]
description: Optional pre-generated run ID to use instead of allocating a new ULID.
example: "01HV6D7S5YF4Z4B2M7K4N0Q6T9"
title:
type: ["string", "null"]
maxLength: 100
description: 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.
example: "Add rate limiting to auth endpoints"
cwd:
type: string
description: CLI working directory at invocation time.
@ -5076,11 +5119,24 @@ components:
description: Resume from checkpoint instead of starting from submitted state.
default: false
UpdateRunRequest:
description: Request body for updating mutable run metadata.
type: object
required:
- title
properties:
title:
type: string
maxLength: 100
description: New run title. The server trims leading/trailing whitespace, rejects blank values, rejects control characters and newline characters, and requires at most 100 characters.
example: "Add rate limiting to auth endpoints"
RunStatusResponse:
description: Current status of a run with optional error and queue position.
type: object
required:
- id
- title
- status
- created_at
properties:
@ -5088,6 +5144,10 @@ components:
type: string
description: Unique run identifier (ULID).
example: 01JNQVR7M0EJ5GKAT2SC4ERS1Z
title:
type: string
description: Non-blank resolved run title.
example: Add rate limiting to auth endpoints
status:
$ref: "#/components/schemas/RunStatus"
error:
@ -6055,6 +6115,9 @@ components:
required:
- stages
properties:
title:
type: string
description: Resolved run title from the event log.
spec:
oneOf:
- $ref: "#/components/schemas/RunSpec"
@ -6146,6 +6209,8 @@ components:
type: string
title:
type: string
description: Non-blank resolved run title.
example: Add rate limiting to auth endpoints
labels:
type: object
additionalProperties:
@ -6827,7 +6892,7 @@ components:
$ref: "#/components/schemas/RepositoryReference"
title:
type: string
description: Human-readable title describing the run's goal.
description: Non-blank resolved run title.
example: Add rate limiting to auth endpoints
status:
$ref: "#/components/schemas/RunStatus"

View file

@ -6,23 +6,27 @@ Add an explicit run `title` that is always a non-blank string in API responses.
## Key Changes
- Add optional `title` to `RunManifest`; validate provided titles by trimming, rejecting blank/whitespace-only values, and rejecting values over 100 chars.
- Add optional `title` to `RunManifest`; validate provided titles by trimming, rejecting blank/whitespace-only values, rejecting titles containing newline/control characters, and rejecting values over 100 chars.
- Add shared title helpers:
- explicit title normalization: trim, require non-blank, max 100, reject invalid input.
- inferred title: current first-line goal cleanup, truncate to 100, fallback to `"Untitled run"` if inference is blank.
- explicit title normalization: trim, require non-blank, require a single logical line, max 100 chars, reject invalid input.
- inferred title: current first-line goal cleanup, truncate to 100 chars using the existing `97 chars + "..."` behavior, fallback to `"Untitled run"` if inference is blank.
- Store resolved title on new `run.created` events. Keep legacy event replay compatible by inferring title from goal only when replaying old `run.created` events that lack a title.
- Add `run.title.updated` with `{ "title": "..." }`; update `RunProjection.title`, and make `RunSummary.title` read from projection title instead of deriving from `goal`.
- Add `RunProjection.title`; populate it from `run.created`, update it from `run.title.updated`, and make `RunSummary.title` read from projection title instead of deriving from `goal`.
- Change summary construction so `build_summary` passes the projected title into `RunSummary` instead of letting `RunSummary::new` derive it from `goal`.
- Add `run.title.updated` with `{ "title": "..." }`; event creation is trusted and events are expected to already contain normalized, valid titles. PATCH is the public validation boundary.
- Add `PATCH /api/v1/runs/{id}` with body `{ "title": "..." }`; return updated `RunSummary`.
- Allow title PATCH for all run states, including archived runs, as a metadata-only exception to archived read-only behavior.
- Allow title PATCH for all run states, including archived runs, as a metadata-only exception to archived read-only behavior. This endpoint deliberately bypasses the existing archived-run mutation guard; all other archived-run mutation guards remain unchanged.
- If PATCH normalizes to the existing title, return the current summary without appending a no-op event.
- No web edit UI in this slice. Do update frontend/SSE invalidation so `run.title.updated` refreshes run detail and board data when another client changes a title.
- Add `title` to `RunStatusResponse` so create/start/lifecycle acknowledgements include the current resolved title.
- No web edit UI in this slice. Do update frontend/SSE invalidation so `run.title.updated` refreshes run detail and both board data keys (`include_archived=false` and `include_archived=true`) when another client changes a title.
## Public Interfaces
- OpenAPI:
- `RunManifest.title?: string | null`
- new `UpdateRunRequest` with required `title: string`
- `RunManifest.title?: string | null` with `maxLength: 100` and description documenting trim, non-blank, and single-line validation.
- new `UpdateRunRequest` with required `title: string`, `maxLength: 100`, and the same validation description.
- `PATCH /api/v1/runs/{id}` returns `RunSummary`
- add required `RunStatusResponse.title: string`; create/start/pause/unpause/archive/unarchive responses include the current projected title.
- document `RunSummary.title` and `RunListItem.title` as non-blank strings
- Generated clients:
- rebuild Rust API types and TypeScript API client after OpenAPI changes.
@ -37,20 +41,27 @@ Add an explicit run `title` that is always a non-blank string in API responses.
- new `run.created` with title populates projection and summary title.
- old `run.created` without title still replays and yields inferred/fallback title.
- `run.title.updated` changes projection and summary.
- `RunSummary` construction uses the projected title rather than deriving from `goal`.
- Server API tests:
- create run with explicit title returns that title.
- create run without title returns inferred title.
- create run with explicit title returns that title in `RunStatusResponse`.
- create run without title returns inferred title in `RunStatusResponse`.
- blank/whitespace create title returns 400.
- create and PATCH reject titles containing newline/control characters with 400.
- start/pause/unpause/archive/unarchive responses include the current projected title.
- PATCH updates active, terminal, and archived runs.
- archived title PATCH succeeds while the existing archived-run mutation guards continue rejecting other write operations.
- PATCH rejects blank and over-100 titles with 400.
- same-title PATCH is idempotent and does not append an update event.
- Frontend tests:
- board and run-detail invalidation include `run.title.updated`.
- board invalidation refreshes both archived and non-archived board keys for `run.title.updated`.
- existing rendering continues to display server `title`.
## Assumptions
- Maximum explicit title length is 100 characters.
- Explicit title length and inferred title truncation are counted in Rust chars, matching the current title derivation behavior.
- Explicit titles are rejected when invalid; only inferred titles are truncated.
- Explicit titles must be single-line strings without control characters.
- `"Untitled run"` is the canonical server fallback when inference cannot produce a non-blank title.
- Existing legacy runs may still have their title inferred during replay because old event streams did not record a resolved title.

View file

@ -26,6 +26,7 @@ fn run_summary_json_matches_openapi_shape() {
Some("workflow".to_string()),
Some("workflow".to_string()),
String::new(),
"API title".to_string(),
HashMap::from([("team".to_string(), "core".to_string())]),
Some("/tmp/fabro".to_string()),
false,
@ -64,7 +65,7 @@ fn run_summary_json_matches_openapi_shape() {
"workflow_name": "workflow",
"workflow_slug": "workflow",
"goal": "",
"title": "",
"title": "API title",
"labels": {
"team": "core"
},

View file

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

View file

@ -255,6 +255,7 @@ fn archive_resolves_selector_via_server_endpoint() {
"error": null,
"queue_position": null,
"pending_control": null,
"title": "Nightly run",
"created_at": "2026-04-05T12:00:00Z"
})
.to_string(),

View file

@ -1001,6 +1001,7 @@ fn attach_json_errors_without_prompting_for_human_input() {
}
},
"source_directory": "[TEMP_DIR]",
"title": "Wait for approval",
"web_url": "http://localhost:3000/runs/[ULID]",
"workflow_slug": "human-gate",
"workflow_source": "digraph HumanGate {/n graph [goal=\"Wait for approval\"]/n start [shape=Mdiamond, label=\"Start\"]/n exit [shape=Msquare, label=\"Exit\"]/n approve [shape=hexagon, label=\"Approve?\"]/n ship [shape=parallelogram, script=\"echo shipped\"]/n revise [shape=parallelogram, script=\"echo revised\"]/n start -> approve/n approve -> ship [label=\"[A] Approve\"]/n approve -> revise [label=\"[R] Revise\"]/n ship -> exit/n revise -> exit/n}/n"

View file

@ -18,6 +18,7 @@ fn run_status_response(run_id: &str, status: &str) -> serde_json::Value {
serde_json::json!({
"id": run_id,
"status": status,
"title": "Test run",
"created_at": "2026-04-05T12:00:00Z"
})
}

View file

@ -23,6 +23,7 @@ fn run_status_response(run_id: &str, status: &str) -> serde_json::Value {
serde_json::json!({
"id": run_id,
"status": status,
"title": "Test run",
"created_at": "2026-04-05T12:00:00Z"
})
}

View file

@ -260,6 +260,7 @@ fn unarchive_resolves_selector_via_server_endpoint() {
"error": null,
"queue_position": null,
"pending_control": null,
"title": "Nightly run",
"created_at": "2026-04-05T12:00:00Z"
})
.to_string(),

View file

@ -931,6 +931,7 @@ mod runs {
Some(workflow_name.into()),
Some(workflow_slug.into()),
goal.into(),
fabro_types::infer_run_title(goal),
labels(entries),
Some(format!("/demo/{repo_name}")),
false,

View file

@ -51,6 +51,7 @@ pub(crate) struct PreparedManifest {
pub git: Option<types::GitContext>,
pub root_source: String,
pub run_id: Option<RunId>,
pub title: Option<String>,
pub settings: WorkflowSettings,
pub target_path: ManifestPath,
pub workflow_bundle: WorkflowBundle,
@ -129,6 +130,11 @@ pub(crate) fn prepare_manifest(
if let Some(goal) = manifest.goal.as_ref() {
settings.run.goal = Some(RunGoal::Inline(InterpString::parse(&goal.text)));
}
let title = manifest
.title
.as_ref()
.map(|title| fabro_types::normalize_explicit_run_title(title.as_str()))
.transpose()?;
let in_place = settings.run.sandbox.provider == "local"
&& settings.run.sandbox.local.worktree_mode == WorktreeMode::Never;
@ -143,6 +149,7 @@ pub(crate) fn prepare_manifest(
.map(str::parse::<RunId>)
.transpose()
.context("invalid run ID")?,
title,
settings: settings.clone(),
target_path,
workflow_bundle,
@ -177,6 +184,7 @@ pub(crate) fn create_run_input(
workflow_bundle: Some(prepared.workflow_bundle),
submitted_manifest_bytes: None,
run_id: prepared.run_id,
title: prepared.title,
git: prepared.git,
fork_source_ref: None,
in_place: prepared.in_place,
@ -1352,6 +1360,7 @@ mod tests {
git: None,
goal: None,
run_id: None,
title: None,
target: types::ManifestTarget {
identifier: "workflow.fabro".to_string(),
path: "workflow.fabro".to_string(),

View file

@ -3455,6 +3455,17 @@ async fn reject_if_archived(state: &AppState, run_id: &RunId) -> Option<Response
})
}
async fn load_run_title(state: &AppState, run_id: &RunId) -> Result<String, ApiError> {
match state.store.get_cached_summary(run_id).await {
Ok(Some(summary)) => Ok(summary.title),
Ok(None) => Err(ApiError::not_found("Run not found.")),
Err(err) => Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
err.to_string(),
)),
}
}
#[cfg(test)]
#[expect(
clippy::disallowed_methods,

View file

@ -386,6 +386,7 @@ fn denied_lifecycle_event_name(body: &EventBody) -> Option<&'static str> {
match body {
EventBody::RunArchived(_) => Some("run.archived"),
EventBody::RunUnarchived(_) => Some("run.unarchived"),
EventBody::RunTitleUpdated(_) => Some("run.title.updated"),
EventBody::RunCancelRequested(_) => Some("run.cancel.requested"),
EventBody::RunPauseRequested(_) => Some("run.pause.requested"),
EventBody::RunUnpauseRequested(_) => Some("run.unpause.requested"),

View file

@ -5,9 +5,9 @@ use super::super::{
Principal, RequiredUser, Response, RewindRequest, RewindResponse, Router, RunAnswerTransport,
RunControlAction, RunExecutionMode, RunId, RunStatus, RunStatusResponse, StartRunRequest,
State, StatusCode, Storage, TimelineEntryResponse, WORKER_CANCEL_GRACE, WorkflowError,
append_control_request, get, load_pending_control, managed_run, operations, parse_run_id_path,
persist_cancelled_run_status, post, reject_if_archived, sleep, update_live_run_from_event,
workflow_event,
append_control_request, get, load_pending_control, load_run_title, managed_run, operations,
parse_run_id_path, persist_cancelled_run_status, post, reject_if_archived, sleep,
update_live_run_from_event, workflow_event,
};
pub(super) fn routes() -> Router<Arc<AppState>> {
@ -130,12 +130,17 @@ async fn start_run(
);
}
let title = match load_run_title(state.as_ref(), &id).await {
Ok(title) => title,
Err(err) => return err.into_response(),
};
let web_url = state.run_web_url(&id);
state.scheduler_notify.notify_one();
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.to_string(),
title,
status: RunStatus::Queued,
error: None,
queue_position: None,
@ -282,12 +287,17 @@ async fn cancel_run(
.into_response();
}
};
let title = match load_run_title(state.as_ref(), &id).await {
Ok(title) => title,
Err(err) => return err.into_response(),
};
let web_url = state.run_web_url(&id);
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.to_string(),
title,
status: response_status,
error: None,
queue_position: None,
@ -406,12 +416,17 @@ async fn pause_run(
.into_response();
}
};
let title = match load_run_title(state.as_ref(), &id).await {
Ok(title) => title,
Err(err) => return err.into_response(),
};
let web_url = state.run_web_url(&id);
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.to_string(),
title,
status: response_status,
error: None,
queue_position: None,
@ -513,12 +528,17 @@ async fn unpause_run(
.into_response();
}
};
let title = match load_run_title(state.as_ref(), &id).await {
Ok(title) => title,
Err(err) => return err.into_response(),
};
let web_url = state.run_web_url(&id);
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.to_string(),
title,
status: response_status,
error: None,
queue_position: None,
@ -760,11 +780,20 @@ async fn archive_status_response(state: &AppState, id: RunId) -> Response {
)
.into_response();
};
let title = if projection.title.is_empty() {
projection.spec.as_ref().map_or_else(
|| fabro_types::infer_run_title(""),
|spec| fabro_types::infer_run_title(spec.graph.goal()),
)
} else {
projection.title
};
let web_url = state.run_web_url(&id);
(
StatusCode::OK,
Json(RunStatusResponse {
id: id.to_string(),
title,
status,
error: None,
queue_position: None,

View file

@ -12,6 +12,7 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use bytes::Bytes;
use fabro_api::types::{
BoardColumn, BoardColumnDefinition, RunManifest, RunStatusResponse, SubmitAnswerRequest,
UpdateRunRequest,
};
use fabro_config::Storage;
use fabro_interview::AnswerSubmission;
@ -29,8 +30,8 @@ use tracing::info;
use super::super::{
AppState, ListResponse, MAX_PAGE_OFFSET, PaginationParams, RunExecutionMode,
answer_from_request, api_question_from_pending_interview, default_page_limit,
delete_run_internal, load_pending_interview, managed_run, parse_run_id_path,
reject_if_archived, resolve_interp_string, submit_pending_interview_answer,
delete_run_internal, load_pending_interview, load_run_title, managed_run, parse_run_id_path,
reject_if_archived, resolve_interp_string, submit_pending_interview_answer, workflow_event,
};
use crate::error::ApiError;
use crate::principal_middleware::{
@ -51,7 +52,10 @@ pub(super) fn routes() -> Router<Arc<AppState>> {
.route("/runs", get(list_runs).post(create_run))
.route("/runs/resolve", get(resolve_run))
.route("/boards/runs", get(list_board_runs))
.route("/runs/{id}", get(get_run_status).delete(delete_run))
.route(
"/runs/{id}",
get(get_run_status).patch(update_run).delete(delete_run),
)
.route("/runs/{id}/questions", get(get_questions))
.route("/runs/{id}/questions/{qid}/answer", post(submit_answer))
.route("/runs/{id}/state", get(get_run_state))
@ -400,6 +404,62 @@ async fn delete_run(
}
}
async fn update_run(
subject: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
body: Bytes,
) -> Response {
let id = match parse_run_id_path(&id) {
Ok(id) => id,
Err(response) => return response,
};
let request = match serde_json::from_slice::<UpdateRunRequest>(&body) {
Ok(request) => request,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let title = match fabro_types::normalize_explicit_run_title(request.title.as_str()) {
Ok(title) => title,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let current = match state.store.get_cached_summary(&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 current.title == title {
return (StatusCode::OK, Json(current)).into_response();
}
let run_store = match state.store.open_run(&id).await {
Ok(run_store) => run_store,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
if let Err(err) =
workflow_event::append_event(&run_store, &id, &workflow_event::Event::RunTitleUpdated {
title,
actor: Some(Principal::User(subject.0)),
})
.await
{
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response();
}
match state.store.get_cached_summary(&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 create_run(
RequestAuth(auth_slot): RequestAuth,
State(state): State<Arc<AppState>>,
@ -460,6 +520,10 @@ async fn create_run(
}
};
let created_at = created.run_id.created_at();
let title = match load_run_title(state.as_ref(), &created.run_id).await {
Ok(title) => title,
Err(err) => return err.into_response(),
};
{
let mut runs = state.runs.lock().expect("runs lock poisoned");
@ -479,6 +543,7 @@ async fn create_run(
StatusCode::CREATED,
Json(RunStatusResponse {
id: run_id.to_string(),
title,
status: RunStatus::Submitted,
error: None,
queue_position: None,

View file

@ -2465,6 +2465,7 @@ async fn list_run_stages_distinguishes_visits() {
create_durable_run_with_events(&state, run_id, &[
workflow_event::Event::RunCreated {
run_id,
title: None,
settings: serde_json::to_value(fabro_types::WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(&graph).unwrap(),
workflow_source: None,
@ -3394,6 +3395,7 @@ async fn create_completed_run_ready_for_pull_request(
create_durable_run_with_events(state, run_id, &[
workflow_event::Event::RunCreated {
run_id,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,
@ -6369,6 +6371,62 @@ async fn create_run_returns_submitted() {
let response = app.oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::CREATED).await;
assert_eq!(body["status"]["kind"], "submitted");
assert_eq!(body["title"], "Test");
}
#[tokio::test]
async fn create_run_accepts_explicit_title() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let mut manifest = minimal_manifest_json(MINIMAL_DOT);
manifest["title"] = json!(" Explicit server title ");
let req = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&manifest).unwrap()))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::CREATED).await;
assert_eq!(body["title"], "Explicit server title");
let run_id = body["id"].as_str().unwrap();
let detail_response = app
.oneshot(
Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let detail = response_json!(detail_response, StatusCode::OK).await;
assert_eq!(detail["title"], "Explicit server title");
}
#[tokio::test]
async fn create_run_rejects_invalid_titles() {
let app = test_app_with();
for title in [
" ".to_string(),
"First\nSecond".to_string(),
"x".repeat(101),
] {
let mut manifest = minimal_manifest_json(MINIMAL_DOT);
manifest["title"] = json!(title);
let req = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&manifest).unwrap()))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
assert_status!(response, StatusCode::BAD_REQUEST).await;
}
}
#[tokio::test]
@ -6396,6 +6454,7 @@ async fn start_run_transitions_to_queued() {
let response = app.oneshot(req).await.unwrap();
let body = response_json!(response, StatusCode::OK).await;
assert_eq!(body["status"]["kind"], "queued");
assert_eq!(body["title"], "Test");
let status = state
.store
@ -6410,6 +6469,142 @@ async fn start_run_transitions_to_queued() {
assert_eq!(status, RunStatus::Queued);
}
#[tokio::test]
async fn patch_run_title_updates_active_and_archived_runs() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = create_run(&app, MINIMAL_DOT)
.await
.parse::<RunId>()
.unwrap();
let patch_response = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(api(&format!("/runs/{run_id}")))
.header("content-type", "application/json")
.body(Body::from(
json!({ "title": " Active title " }).to_string(),
))
.unwrap(),
)
.await
.unwrap();
let patch_body = response_json!(patch_response, StatusCode::OK).await;
assert_eq!(patch_body["title"], "Active title");
let run_store = state.store.open_run_reader(&run_id).await.unwrap();
let event_count = run_store.list_events().await.unwrap().len();
let same_title_response = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(api(&format!("/runs/{run_id}")))
.header("content-type", "application/json")
.body(Body::from(json!({ "title": "Active title" }).to_string()))
.unwrap(),
)
.await
.unwrap();
let same_title_body = response_json!(same_title_response, StatusCode::OK).await;
assert_eq!(same_title_body["title"], "Active title");
assert_eq!(
state
.store
.open_run_reader(&run_id)
.await
.unwrap()
.list_events()
.await
.unwrap()
.len(),
event_count,
"same-title PATCH should not append an event"
);
let run_store = state.store.open_run(&run_id).await.unwrap();
for event in [
workflow_event::Event::RunQueued,
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
] {
workflow_event::append_event(&run_store, &run_id, &event)
.await
.unwrap();
}
workflow_event::append_event(
&run_store,
&run_id,
&workflow_event::Event::WorkflowRunCompleted {
duration_ms: 1,
artifact_count: 0,
status: "succeeded".to_string(),
reason: SuccessReason::Completed,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
diff_summary: None,
billing: None,
},
)
.await
.unwrap();
response_json!(
app.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(api(&format!("/runs/{run_id}/archive")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap(),
StatusCode::OK
)
.await;
let archived_patch_response = app
.oneshot(
Request::builder()
.method("PATCH")
.uri(api(&format!("/runs/{run_id}")))
.header("content-type", "application/json")
.body(Body::from(json!({ "title": "Archived title" }).to_string()))
.unwrap(),
)
.await
.unwrap();
let archived_patch_body = response_json!(archived_patch_response, StatusCode::OK).await;
assert_eq!(archived_patch_body["title"], "Archived title");
assert_eq!(archived_patch_body["status"]["kind"], "archived");
}
#[tokio::test]
async fn patch_run_title_rejects_invalid_titles() {
let app = test_app_with();
let run_id = create_run(&app, MINIMAL_DOT).await;
for title in [String::new(), "Bad\rTitle".to_string(), "x".repeat(101)] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("PATCH")
.uri(api(&format!("/runs/{run_id}")))
.header("content-type", "application/json")
.body(Body::from(json!({ "title": title }).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_status!(response, StatusCode::BAD_REQUEST).await;
}
}
#[tokio::test]
async fn start_run_conflict_when_not_submitted() {
let state = test_app_state();
@ -7255,6 +7450,7 @@ async fn delete_run_with_preserved_sandbox_returns_handoff() {
create_durable_run_with_events(&state, run_id, &[
workflow_event::Event::RunCreated {
run_id,
title: None,
settings: serde_json::to_value(settings).unwrap(),
graph: serde_json::to_value(graph).unwrap(),
workflow_source: None,
@ -7319,6 +7515,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {
create_durable_run_with_events(&state, run_id, &[
workflow_event::Event::RunCreated {
run_id,
title: None,
settings: serde_json::to_value(fabro_types::WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(graph).unwrap(),
workflow_source: None,

View file

@ -51,6 +51,10 @@ impl RunProjectionReducer for RunProjection {
match &stored.body {
EventBody::RunCreated(props) => {
let labels = props.labels.clone().into_iter().collect::<HashMap<_, _>>();
self.title = props
.title
.clone()
.unwrap_or_else(|| fabro_types::infer_run_title(props.graph.goal()));
self.spec = Some(RunSpec {
run_id,
settings: props.settings.clone(),
@ -196,6 +200,9 @@ impl RunProjectionReducer for RunProjection {
self.try_apply_status(prior.into(), ts)?;
}
}
EventBody::RunTitleUpdated(props) => {
self.title.clone_from(&props.title);
}
EventBody::CheckpointCompleted(props) => {
let checkpoint = checkpoint_from_props(props, ts);
self.diff_summary = props.diff_summary.or(self.diff_summary);
@ -513,6 +520,11 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary
.as_ref()
.map(|spec| spec.graph.goal().to_string())
.unwrap_or_default();
let title = if state.title.is_empty() {
fabro_types::infer_run_title(&goal)
} else {
state.title.clone()
};
RunSummary::new(
*run_id,
workflow_name,
@ -521,6 +533,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> RunSummary
.as_ref()
.and_then(|spec| spec.workflow_slug.clone()),
goal,
title,
state
.spec
.as_ref()
@ -1674,6 +1687,97 @@ mod tests {
assert_eq!(summary_json["status"], json!({ "kind": "submitted" }));
}
#[test]
fn run_created_title_populates_projection_and_summary() {
let event = test_raw_event(
1,
"run.created",
&json!({
"title": "Explicit title",
"settings": WorkflowSettings::default(),
"graph": {
"name": "test",
"nodes": {},
"edges": [],
"attrs": { "goal": { "String": "Goal title" } }
},
"labels": {},
"run_dir": "/tmp/run"
}),
None,
);
let state = RunProjection::apply_events(&[event]).unwrap();
assert_eq!(state.title, "Explicit title");
assert_eq!(
build_summary(&state, &fixtures::RUN_1).title,
"Explicit title"
);
}
#[test]
fn legacy_run_created_without_title_infers_projection_title() {
let event = test_raw_event(
1,
"run.created",
&json!({
"settings": WorkflowSettings::default(),
"graph": {
"name": "test",
"nodes": {},
"edges": [],
"attrs": { "goal": { "String": "## Plan: Legacy title\n\nDetails" } }
},
"labels": {},
"run_dir": "/tmp/run"
}),
None,
);
let state = RunProjection::apply_events(&[event]).unwrap();
assert_eq!(state.title, "Legacy title");
assert_eq!(
build_summary(&state, &fixtures::RUN_1).title,
"Legacy title"
);
}
#[test]
fn run_title_updated_changes_projection_and_summary() {
let events = vec![
test_raw_event(
1,
"run.created",
&json!({
"title": "Original title",
"settings": WorkflowSettings::default(),
"graph": {
"name": "test",
"nodes": {},
"edges": [],
"attrs": { "goal": { "String": "Goal title" } }
},
"labels": {},
"run_dir": "/tmp/run"
}),
None,
),
test_raw_event(
2,
"run.title.updated",
&json!({ "title": "Renamed title" }),
None,
),
];
let state = RunProjection::apply_events(&events).unwrap();
assert_eq!(state.title, "Renamed title");
assert_eq!(
build_summary(&state, &fixtures::RUN_1).title,
"Renamed title"
);
}
#[test]
fn projection_serialization_includes_manifest_and_definition_blob_refs() {
let manifest_blob = RunBlobId::new(br#"{"version":1}"#).to_string();

View file

@ -23,6 +23,7 @@ pub mod run_event;
pub mod run_id;
pub mod run_projection;
pub mod run_summary;
pub mod run_title;
pub mod sandbox_record;
pub mod secret;
pub mod settings;
@ -73,6 +74,7 @@ pub use run_event::{
pub use run_id::{RunId, fixtures};
pub use run_projection::{PendingInterviewRecord, RunProjection, StageProjection, first_event_seq};
pub use run_summary::RunSummary;
pub use run_title::{RunTitleError, infer_run_title, normalize_explicit_run_title};
pub use sandbox_record::SandboxRecord;
pub use secret::{SecretMetadata, SecretType};
pub use stage_completion::StageCompletion;

View file

@ -88,6 +88,8 @@ pub enum EventBody {
RunArchived(RunArchivedProps),
#[serde(rename = "run.unarchived")]
RunUnarchived(RunUnarchivedProps),
#[serde(rename = "run.title.updated")]
RunTitleUpdated(RunTitleUpdatedProps),
#[serde(rename = "run.completed")]
RunCompleted(RunCompletedProps),
#[serde(rename = "run.failed")]
@ -381,6 +383,7 @@ impl EventBody {
Self::RunSupersededBy(_) => "run.superseded_by",
Self::RunArchived(_) => "run.archived",
Self::RunUnarchived(_) => "run.unarchived",
Self::RunTitleUpdated(_) => "run.title.updated",
Self::RunCompleted(_) => "run.completed",
Self::RunFailed(_) => "run.failed",
Self::RunNotice(_) => "run.notice",

View file

@ -11,6 +11,8 @@ use crate::{
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunCreatedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
pub settings: WorkflowSettings,
pub graph: Graph,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -112,6 +114,11 @@ pub struct RunSupersededByProps {
pub target_visit: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunTitleUpdatedProps {
pub title: String,
}
#[allow(
clippy::empty_structs_with_brackets,
reason = "This type must serialize as {} rather than null."

View file

@ -12,6 +12,8 @@ use crate::{
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct RunProjection {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub title: String,
pub spec: Option<RunSpec>,
pub graph_source: Option<String>,
pub start: Option<StartRecord>,

View file

@ -1,7 +1,6 @@
use std::collections::HashMap;
use chrono::{DateTime, Utc};
use fabro_util::text::strip_goal_decoration;
use serde::{Deserialize, Serialize};
use crate::{
@ -57,6 +56,7 @@ impl RunSummary {
workflow_name: Option<String>,
workflow_slug: Option<String>,
goal: String,
title: String,
labels: HashMap<String, String>,
source_directory: Option<String>,
in_place: bool,
@ -71,7 +71,6 @@ impl RunSummary {
diff_summary: Option<DiffSummary>,
pull_request: Option<PullRequestRecord>,
) -> Self {
let title = truncate_goal(&goal);
let repository = RepositoryReference {
name: repository_name(repo_origin_url.as_deref(), source_directory.as_deref()),
};
@ -104,19 +103,6 @@ impl RunSummary {
}
}
fn truncate_goal(goal: &str) -> String {
const MAX_LEN: usize = 100;
let stripped = strip_goal_decoration(goal);
let char_count = stripped.chars().count();
if char_count <= MAX_LEN {
return stripped.to_string();
}
let truncated: String = stripped.chars().take(MAX_LEN - 3).collect();
format!("{truncated}...")
}
fn repository_name(repo_origin_url: Option<&str>, source_directory: Option<&str>) -> String {
repo_origin_url
.and_then(repository_name_from_origin)
@ -183,6 +169,7 @@ mod tests {
Some("workflow".to_string()),
Some("workflow".to_string()),
"ship it".to_string(),
"Production title".to_string(),
HashMap::from([("team".to_string(), "core".to_string())]),
Some("/Users/client/local-checkout".to_string()),
false,
@ -200,7 +187,7 @@ mod tests {
None,
);
assert_eq!(summary.title, "ship it");
assert_eq!(summary.title, "Production title");
assert_eq!(summary.repository, RepositoryReference {
name: "fabro-sh/fabro".to_string(),
});
@ -300,6 +287,7 @@ mod tests {
None,
None,
"ship it".to_string(),
"ship it".to_string(),
HashMap::new(),
Some("/Users/client/local-checkout".to_string()),
false,
@ -322,6 +310,7 @@ mod tests {
None,
None,
"ship it".to_string(),
"ship it".to_string(),
HashMap::new(),
None,
false,

View file

@ -0,0 +1,120 @@
use std::error::Error;
use std::fmt;
use fabro_util::text::strip_goal_decoration;
const MAX_RUN_TITLE_CHARS: usize = 100;
const TRUNCATED_RUN_TITLE_CHARS: usize = MAX_RUN_TITLE_CHARS - 3;
const UNTITLED_RUN: &str = "Untitled run";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RunTitleError {
Blank,
TooLong { max_chars: usize },
NotSingleLine,
}
impl fmt::Display for RunTitleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Blank => write!(f, "run title must not be blank"),
Self::TooLong { max_chars } => {
write!(f, "run title must be at most {max_chars} characters")
}
Self::NotSingleLine => write!(f, "run title must be a single line"),
}
}
}
impl Error for RunTitleError {}
#[must_use]
pub fn infer_run_title(goal: &str) -> String {
let stripped = strip_goal_decoration(goal).trim();
if stripped.is_empty() {
return UNTITLED_RUN.to_string();
}
let char_count = stripped.chars().count();
if char_count <= MAX_RUN_TITLE_CHARS {
return stripped.to_string();
}
let truncated: String = stripped.chars().take(TRUNCATED_RUN_TITLE_CHARS).collect();
format!("{truncated}...")
}
pub fn normalize_explicit_run_title(title: &str) -> Result<String, RunTitleError> {
let trimmed = title.trim();
if trimmed.is_empty() {
return Err(RunTitleError::Blank);
}
if trimmed.chars().any(char::is_control) {
return Err(RunTitleError::NotSingleLine);
}
if trimmed.chars().count() > MAX_RUN_TITLE_CHARS {
return Err(RunTitleError::TooLong {
max_chars: MAX_RUN_TITLE_CHARS,
});
}
Ok(trimmed.to_string())
}
#[cfg(test)]
mod tests {
use super::{RunTitleError, infer_run_title, normalize_explicit_run_title};
#[test]
fn explicit_title_normalization_trims_valid_titles() {
assert_eq!(
normalize_explicit_run_title(" Ship dashboard polish ").unwrap(),
"Ship dashboard polish"
);
}
#[test]
fn explicit_title_normalization_rejects_blank_titles() {
assert_eq!(
normalize_explicit_run_title(" \t ").unwrap_err(),
RunTitleError::Blank
);
}
#[test]
fn explicit_title_normalization_rejects_control_characters() {
assert_eq!(
normalize_explicit_run_title("First\nSecond").unwrap_err(),
RunTitleError::NotSingleLine
);
assert_eq!(
normalize_explicit_run_title("First\tSecond").unwrap_err(),
RunTitleError::NotSingleLine
);
}
#[test]
fn explicit_title_normalization_rejects_titles_over_100_chars() {
let title = "x".repeat(101);
assert_eq!(
normalize_explicit_run_title(&title).unwrap_err(),
RunTitleError::TooLong { max_chars: 100 }
);
}
#[test]
fn inferred_title_strips_goal_decoration() {
assert_eq!(infer_run_title("## Plan: migrate DB"), "migrate DB");
}
#[test]
fn inferred_title_truncates_to_100_chars_with_ellipsis() {
let title = infer_run_title(&"a".repeat(101));
assert_eq!(title.chars().count(), 100);
assert!(title.ends_with("..."));
}
#[test]
fn inferred_title_falls_back_for_blank_goals() {
assert_eq!(infer_run_title(" \nmore detail"), "Untitled run");
}
}

View file

@ -16,6 +16,7 @@ fn templated_settings() -> WorkflowSettings {
#[test]
fn run_created_props_round_trip_templated_settings() {
let props = RunCreatedProps {
title: Some("Ship task".to_string()),
settings: templated_settings(),
graph: Graph::new("ship"),
workflow_source: Some("digraph Ship { start -> exit }".to_string()),
@ -75,6 +76,7 @@ fn run_created_props_round_trip_templated_settings() {
#[test]
fn run_created_props_omits_web_url_when_absent() {
let props = RunCreatedProps {
title: None,
settings: WorkflowSettings::default(),
graph: Graph::new("ship"),
workflow_source: None,

View file

@ -38,6 +38,7 @@ fn stage_status_from_string(status: &str) -> StageOutcome {
fn event_body_from_event(event: &Event) -> EventBody {
match event {
Event::RunCreated {
title,
settings,
graph,
workflow_source,
@ -55,6 +56,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
web_url,
..
} => EventBody::RunCreated(fabro_types::RunCreatedProps {
title: title.clone(),
settings: serde_json::from_value(settings.clone())
.expect("run.created settings"),
graph: serde_json::from_value(graph.clone()).expect("run.created graph"),
@ -151,6 +153,11 @@ fn event_body_from_event(event: &Event) -> EventBody {
Event::RunUnarchived { .. } => {
EventBody::RunUnarchived(fabro_types::RunUnarchivedProps::default())
}
Event::RunTitleUpdated { title, .. } => {
EventBody::RunTitleUpdated(fabro_types::RunTitleUpdatedProps {
title: title.clone(),
})
}
Event::WorkflowRunCompleted {
duration_ms,
artifact_count,
@ -2030,6 +2037,7 @@ mod tests {
let stored = to_run_event(&fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(Graph::new("test")).unwrap(),
workflow_source: None,

View file

@ -20,6 +20,7 @@ use crate::outcome::{BilledModelUsage, FailureDetail, Outcome};
pub enum Event {
RunCreated {
run_id: RunId,
title: Option<String>,
settings: serde_json::Value,
graph: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -110,6 +111,11 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
},
RunTitleUpdated {
title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
actor: Option<Principal>,
},
WorkflowRunCompleted {
duration_ms: u64,
artifact_count: usize,
@ -754,6 +760,9 @@ impl Event {
Self::RunUnarchived { actor } => {
info!(?actor, "Run unarchived");
}
Self::RunTitleUpdated { title, actor } => {
info!(title, ?actor, "Run title updated");
}
Self::WorkflowRunCompleted {
duration_ms,
artifact_count,

View file

@ -24,6 +24,7 @@ pub fn event_name(event: &Event) -> &'static str {
Event::RunSupersededBy { .. } => "run.superseded_by",
Event::RunArchived { .. } => "run.archived",
Event::RunUnarchived { .. } => "run.unarchived",
Event::RunTitleUpdated { .. } => "run.title.updated",
Event::WorkflowRunCompleted { .. } => "run.completed",
Event::WorkflowRunFailed { .. } => "run.failed",
Event::RunNotice { .. } => "run.notice",

View file

@ -67,6 +67,7 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields {
| Event::RunSteer { actor, .. }
| Event::RunArchived { actor }
| Event::RunUnarchived { actor, .. }
| Event::RunTitleUpdated { actor, .. }
| Event::InterviewCompleted { actor, .. }
| Event::AgentSteerBuffered { actor, .. } => StoredEventFields {
actor: actor.clone(),

View file

@ -708,6 +708,7 @@ mod tests {
let run_store = store.create_run(&run_id).await.unwrap();
append_event(&run_store, &run_id, &Event::RunCreated {
run_id,
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(fabro_types::Graph::new("metadata")).unwrap(),
workflow_source: None,

View file

@ -42,6 +42,7 @@ pub struct CreateRunInput {
pub workflow_bundle: Option<WorkflowBundle>,
pub submitted_manifest_bytes: Option<Vec<u8>>,
pub run_id: Option<RunId>,
pub title: Option<String>,
pub git: Option<GitContext>,
pub fork_source_ref: Option<ForkSourceRef>,
pub in_place: bool,
@ -102,6 +103,7 @@ pub async fn create(
workflow_bundle,
submitted_manifest_bytes,
run_id,
title,
git,
fork_source_ref,
in_place,
@ -164,6 +166,7 @@ pub async fn create(
workflow_config,
submitted_manifest_bytes.as_deref(),
accepted_definition.as_ref(),
title,
web_url,
)
.await?;
@ -183,6 +186,7 @@ async fn persist_created_run(
workflow_config: Option<String>,
submitted_manifest_bytes: Option<&[u8]>,
accepted_definition: Option<&RunDefinition>,
explicit_title: Option<String>,
web_url: Option<String>,
) -> Result<(), Error> {
let record = persisted.run_spec();
@ -207,10 +211,12 @@ async fn persist_created_run(
None => None,
};
let title = explicit_title.unwrap_or_else(|| fabro_types::infer_run_title(record.graph.goal()));
let stored = to_run_event_at(
&record.run_id,
&Event::RunCreated {
run_id: record.run_id,
title: Some(title),
settings: normalize_json_value(
serde_json::to_value(&record.settings)
.map_err(|err| Error::engine(err.to_string()))?,
@ -713,6 +719,7 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: None,
title: None,
git: None,
fork_source_ref: None,
in_place: false,
@ -757,6 +764,7 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: None,
title: None,
git: None,
fork_source_ref: None,
in_place: false,
@ -817,6 +825,7 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_1),
title: None,
git: Some(fabro_types::GitContext {
origin_url: String::new(),
branch: "main".to_string(),
@ -932,6 +941,7 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_2),
title: None,
git: None,
fork_source_ref: None,
in_place: false,
@ -969,6 +979,7 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_2),
title: None,
git: Some(fabro_types::GitContext {
origin_url: "https://github.com/acme/widgets".to_string(),
branch: String::new(),
@ -1040,6 +1051,7 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_3),
title: None,
git: None,
fork_source_ref: None,
in_place: false,
@ -1084,6 +1096,7 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_64),
title: None,
git: None,
fork_source_ref: None,
in_place: false,

View file

@ -168,6 +168,7 @@ async fn persist_forked_run(
event::append_event(&run_store, &spec.run_id, &Event::RunCreated {
run_id: spec.run_id,
title: None,
settings: serde_json::to_value(&spec.settings)
.map_err(|err| Error::engine(err.to_string()))?,
graph: serde_json::to_value(&spec.graph)
@ -344,6 +345,7 @@ mod tests {
event::append_event(&source, &source_run_id, &Event::RunCreated {
run_id: source_run_id,
title: None,
settings: serde_json::to_value(&settings).unwrap(),
graph: serde_json::to_value(&graph).unwrap(),
workflow_source: Some("digraph fork_source {}".to_string()),

View file

@ -1075,6 +1075,7 @@ mod tests {
workflow_bundle: None,
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_1),
title: None,
git: None,
fork_source_ref: None,
in_place: false,
@ -1264,6 +1265,7 @@ mod tests {
workflow_bundle: Some(workflow_bundle),
submitted_manifest_bytes: None,
run_id: Some(fixtures::RUN_1),
title: None,
git: None,
fork_source_ref: None,
in_place: false,

View file

@ -725,6 +725,7 @@ mod tests {
let run_store = test_store().create_run(&test_run_id()).await.unwrap();
append_event(&run_store, &test_run_id(), &Event::RunCreated {
run_id: test_run_id(),
title: None,
settings: serde_json::to_value(WorkflowSettings::default()).unwrap(),
graph: serde_json::to_value(fabro_types::Graph::new("metadata")).unwrap(),
workflow_source: None,

View file

@ -161,6 +161,7 @@ mod tests {
let run_store = store.create_run(&record.run_id).await.unwrap();
append_event(&run_store, &record.run_id, &Event::RunCreated {
run_id: record.run_id,
title: None,
settings: serde_json::to_value(&record.settings).unwrap(),
graph: serde_json::to_value(&record.graph).unwrap(),
workflow_source: source.map(ToOwned::to_owned),

View file

@ -1044,6 +1044,7 @@ mod tests {
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
@ -1110,6 +1111,7 @@ mod tests {
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
@ -1459,6 +1461,7 @@ mod tests {
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,
@ -1570,6 +1573,7 @@ mod tests {
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: Some("digraph test { plan -> code }".to_string()),
@ -1739,6 +1743,7 @@ mod tests {
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,

View file

@ -466,6 +466,7 @@ mod tests {
let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&run_spec.settings).unwrap(),
graph: serde_json::to_value(&run_spec.graph).unwrap(),
workflow_source: None,

View file

@ -160,6 +160,7 @@ mod tests {
let record = test_run_spec();
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
title: None,
settings: serde_json::to_value(&record.settings).unwrap(),
graph: serde_json::to_value(&record.graph).unwrap(),
workflow_source: Some("digraph test {}".to_string()),

View file

@ -107,6 +107,7 @@ async fn initialized(
let run_store = inner_store;
append_event(&run_store, &run_options.run_id, &Event::RunCreated {
run_id: run_options.run_id,
title: None,
settings: serde_json::to_value(&run_options.settings)
.expect("failed to serialize settings"),
graph: serde_json::to_value(graph).expect("failed to serialize graph"),

View file

@ -332,6 +332,7 @@ models/teams-integration-settings.ts
models/terminal-status.ts
models/timeline-entry-response.ts
models/tls-mode.ts
models/update-run-request.ts
models/user-response.ts
models/validate-response.ts
models/webhook-strategy.ts

View file

@ -64,6 +64,8 @@ import type { StartRunRequest } from '../models';
// @ts-ignore
import type { TimelineEntryResponse } from '../models';
// @ts-ignore
import type { UpdateRunRequest } from '../models';
// @ts-ignore
import type { ValidateResponse } from '../models';
/**
* RunsApi - axios parameter creator
@ -1047,6 +1049,51 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
* Updates mutable run metadata. Title updates are allowed for all run states, including archived runs.
* @summary Update Run
* @param {string} id Unique run identifier (ULID).
* @param {UpdateRunRequest} updateRunRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateRun: async (id: string, updateRunRequest: UpdateRunRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('updateRun', 'id', id)
// verify required parameter 'updateRunRequest' is not null or undefined
assertParamExists('updateRun', 'updateRunRequest', updateRunRequest)
const localVarPath = `/api/v1/runs/{id}`
.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: 'PATCH', ...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(updateRunRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Validates workflow structure and diagnostics without runtime readiness checks.
* @summary Validate Workflow Manifest
@ -1407,6 +1454,20 @@ export const RunsApiFp = function(configuration?: Configuration) {
const localVarOperationServerBasePath = operationServerMap['RunsApi.unpauseRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Updates mutable run metadata. Title updates are allowed for all run states, including archived runs.
* @summary Update Run
* @param {string} id Unique run identifier (ULID).
* @param {UpdateRunRequest} updateRunRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updateRun(id: string, updateRunRequest: UpdateRunRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunSummary>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updateRun(id, updateRunRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunsApi.updateRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Validates workflow structure and diagnostics without runtime readiness checks.
* @summary Validate Workflow Manifest
@ -1670,6 +1731,17 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
unpauseRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunStatusResponse> {
return localVarFp.unpauseRun(id, options).then((request) => request(axios, basePath));
},
/**
* Updates mutable run metadata. Title updates are allowed for all run states, including archived runs.
* @summary Update Run
* @param {string} id Unique run identifier (ULID).
* @param {UpdateRunRequest} updateRunRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updateRun(id: string, updateRunRequest: UpdateRunRequest, options?: RawAxiosRequestConfig): AxiosPromise<RunSummary> {
return localVarFp.updateRun(id, updateRunRequest, options).then((request) => request(axios, basePath));
},
/**
* Validates workflow structure and diagnostics without runtime readiness checks.
* @summary Validate Workflow Manifest
@ -1951,6 +2023,18 @@ export class RunsApi extends BaseAPI {
return RunsApiFp(this.configuration).unpauseRun(id, options).then((request) => request(this.axios, this.basePath));
}
/**
* Updates mutable run metadata. Title updates are allowed for all run states, including archived runs.
* @summary Update Run
* @param {string} id Unique run identifier (ULID).
* @param {UpdateRunRequest} updateRunRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public updateRun(id: string, updateRunRequest: UpdateRunRequest, options?: RawAxiosRequestConfig) {
return RunsApiFp(this.configuration).updateRun(id, updateRunRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Validates workflow structure and diagnostics without runtime readiness checks.
* @summary Validate Workflow Manifest

View file

@ -309,6 +309,7 @@ export * from './teams-integration-settings';
export * from './terminal-status';
export * from './timeline-entry-response';
export * from './tls-mode';
export * from './update-run-request';
export * from './user-response';
export * from './validate-response';
export * from './webhook-strategy';

View file

@ -48,7 +48,7 @@ export interface RunListItem {
'goal': string;
'repository': RepositoryReference;
/**
* Human-readable title describing the run\'s goal.
* Non-blank resolved run title.
*/
'title': string;
'status': RunStatus;

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 explicit run title. The server trims leading/trailing whitespace, rejects blank values, rejects control characters and newline characters, and requires at most 100 characters.
*/
'title'?: string | null;
/**
* CLI working directory at invocation time.
*/

View file

@ -42,6 +42,10 @@ import type { StageProjection } from './stage-projection';
* Raw internal run projection derived from the event log.
*/
export interface RunProjection {
/**
* Resolved run title from the event log.
*/
'title'?: string;
'spec'?: RunSpec | null;
'graph_source'?: string | null;
'start'?: { [key: string]: any; } | null;

View file

@ -31,6 +31,10 @@ export interface RunStatusResponse {
* Unique run identifier (ULID).
*/
'id': string;
/**
* Non-blank resolved run title.
*/
'title': string;
'status': RunStatus;
'error'?: RunError;
/**

View file

@ -37,6 +37,9 @@ export interface RunSummary {
'workflow_name'?: string | null;
'workflow_slug'?: string | null;
'goal': string;
/**
* Non-blank resolved run title.
*/
'title': string;
'labels': { [key: string]: string; };
'source_directory'?: string | null;

View file

@ -0,0 +1,26 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Request body for updating mutable run metadata.
*/
export interface UpdateRunRequest {
/**
* New run title. The server trims leading/trailing whitespace, rejects blank values, rejects control characters and newline characters, and requires at most 100 characters.
*/
'title': string;
}