From 697dc1294fbc55944b177218e1cbef6022786b3e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sat, 9 May 2026 11:18:12 -0400 Subject: [PATCH] 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. --- apps/fabro-web/app/lib/board-events.test.tsx | 4 +- apps/fabro-web/app/lib/board-events.ts | 9 +- apps/fabro-web/app/lib/query-keys.test.ts | 3 + apps/fabro-web/app/lib/run-events.ts | 1 + apps/fabro-web/app/routes/runs.test.tsx | 1 + docs/public/api-reference/fabro-api.yaml | 67 +++++- .../2026-05-09-explicit-run-title-design.md | 31 ++- .../fabro-api/tests/run_summary_round_trip.rs | 3 +- lib/crates/fabro-cli/src/manifest_builder.rs | 1 + lib/crates/fabro-cli/tests/it/cmd/archive.rs | 1 + lib/crates/fabro-cli/tests/it/cmd/attach.rs | 1 + lib/crates/fabro-cli/tests/it/cmd/create.rs | 1 + lib/crates/fabro-cli/tests/it/cmd/run.rs | 1 + .../fabro-cli/tests/it/cmd/unarchive.rs | 1 + lib/crates/fabro-server/src/demo/mod.rs | 1 + lib/crates/fabro-server/src/run_manifest.rs | 9 + lib/crates/fabro-server/src/server.rs | 11 + .../fabro-server/src/server/handler/events.rs | 1 + .../src/server/handler/lifecycle.rs | 35 +++- .../fabro-server/src/server/handler/runs.rs | 71 ++++++- lib/crates/fabro-server/src/server/tests.rs | 197 ++++++++++++++++++ lib/crates/fabro-store/src/run_state.rs | 104 +++++++++ lib/crates/fabro-types/src/lib.rs | 2 + lib/crates/fabro-types/src/run_event/mod.rs | 3 + lib/crates/fabro-types/src/run_event/run.rs | 7 + lib/crates/fabro-types/src/run_projection.rs | 2 + lib/crates/fabro-types/src/run_summary.rs | 21 +- lib/crates/fabro-types/src/run_title.rs | 120 +++++++++++ .../fabro-types/tests/run_event_serde.rs | 2 + .../fabro-workflow/src/event/convert.rs | 8 + lib/crates/fabro-workflow/src/event/events.rs | 9 + lib/crates/fabro-workflow/src/event/names.rs | 1 + .../fabro-workflow/src/event/stored_fields.rs | 1 + .../fabro-workflow/src/lifecycle/git.rs | 1 + .../fabro-workflow/src/operations/create.rs | 13 ++ .../fabro-workflow/src/operations/fork.rs | 2 + .../fabro-workflow/src/operations/start.rs | 2 + .../fabro-workflow/src/pipeline/finalize.rs | 1 + .../fabro-workflow/src/pipeline/persist.rs | 1 + .../src/pipeline/pull_request.rs | 5 + lib/crates/fabro-workflow/src/run_lookup.rs | 1 + .../fabro-workflow/src/runtime_store.rs | 1 + lib/crates/fabro-workflow/src/test_support.rs | 1 + .../src/.openapi-generator/FILES | 1 + .../fabro-api-client/src/api/runs-api.ts | 84 ++++++++ .../fabro-api-client/src/models/index.ts | 1 + .../src/models/run-list-item.ts | 2 +- .../src/models/run-manifest.ts | 4 + .../src/models/run-projection.ts | 4 + .../src/models/run-status-response.ts | 4 + .../src/models/run-summary.ts | 3 + .../src/models/update-run-request.ts | 26 +++ 52 files changed, 848 insertions(+), 39 deletions(-) create mode 100644 lib/crates/fabro-types/src/run_title.rs create mode 100644 lib/packages/fabro-api-client/src/models/update-run-request.ts diff --git a/apps/fabro-web/app/lib/board-events.test.tsx b/apps/fabro-web/app/lib/board-events.test.tsx index 73074f248..9b82eb674 100644 --- a/apps/fabro-web/app/lib/board-events.test.tsx +++ b/apps/fabro-web/app/lib/board-events.test.tsx @@ -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); diff --git a/apps/fabro-web/app/lib/board-events.ts b/apps/fabro-web/app/lib/board-events.ts index d61b3067e..97c12bfaa 100644 --- a/apps/fabro-web/app/lib/board-events.ts +++ b/apps/fabro-web/app/lib/board-events.ts @@ -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({ @@ -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(); diff --git a/apps/fabro-web/app/lib/query-keys.test.ts b/apps/fabro-web/app/lib/query-keys.test.ts index 6dbe958ac..1bf1c96ec 100644 --- a/apps/fabro-web/app/lib/query-keys.test.ts +++ b/apps/fabro-web/app/lib/query-keys.test.ts @@ -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", () => { diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts index 7f27013bc..8ce0e4ac4 100644 --- a/apps/fabro-web/app/lib/run-events.ts +++ b/apps/fabro-web/app/lib/run-events.ts @@ -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", diff --git a/apps/fabro-web/app/routes/runs.test.tsx b/apps/fabro-web/app/routes/runs.test.tsx index 140f9820e..9df090370 100644 --- a/apps/fabro-web/app/routes/runs.test.tsx +++ b/apps/fabro-web/app/routes/runs.test.tsx @@ -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); diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index f5dd4f27e..2127799e3 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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" diff --git a/docs/superpowers/specs/2026-05-09-explicit-run-title-design.md b/docs/superpowers/specs/2026-05-09-explicit-run-title-design.md index 0b31616f1..c15a85083 100644 --- a/docs/superpowers/specs/2026-05-09-explicit-run-title-design.md +++ b/docs/superpowers/specs/2026-05-09-explicit-run-title-design.md @@ -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. 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 5465c72dc..f510ba1a0 100644 --- a/lib/crates/fabro-api/tests/run_summary_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_summary_round_trip.rs @@ -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" }, diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 05e412028..8c91b1fbd 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -160,6 +160,7 @@ pub fn build_run_manifest(input: ManifestBuildInput) -> Result { 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, diff --git a/lib/crates/fabro-cli/tests/it/cmd/archive.rs b/lib/crates/fabro-cli/tests/it/cmd/archive.rs index 86a43a61c..c392677ce 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/archive.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/archive.rs @@ -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(), diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 1752cd394..54a4c36a1 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -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" diff --git a/lib/crates/fabro-cli/tests/it/cmd/create.rs b/lib/crates/fabro-cli/tests/it/cmd/create.rs index 0c6c94416..e33358b10 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/create.rs @@ -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" }) } diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index eeaa6b537..a4210a461 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -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" }) } diff --git a/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs b/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs index 8d933e3ba..a53f72138 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/unarchive.rs @@ -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(), diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 72f073e41..d14307c2a 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -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, diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 163bcb8a3..839c2fd8f 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -51,6 +51,7 @@ pub(crate) struct PreparedManifest { pub git: Option, pub root_source: String, pub run_id: Option, + pub title: Option, 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::) .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(), diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 15ca01ebe..3b3efec16 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -3455,6 +3455,17 @@ async fn reject_if_archived(state: &AppState, run_id: &RunId) -> Option Result { + 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, diff --git a/lib/crates/fabro-server/src/server/handler/events.rs b/lib/crates/fabro-server/src/server/handler/events.rs index 519e41f43..f6a54ea10 100644 --- a/lib/crates/fabro-server/src/server/handler/events.rs +++ b/lib/crates/fabro-server/src/server/handler/events.rs @@ -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"), diff --git a/lib/crates/fabro-server/src/server/handler/lifecycle.rs b/lib/crates/fabro-server/src/server/handler/lifecycle.rs index 50d556c66..36696f3b0 100644 --- a/lib/crates/fabro-server/src/server/handler/lifecycle.rs +++ b/lib/crates/fabro-server/src/server/handler/lifecycle.rs @@ -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> { @@ -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, diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs index 432710ccf..3be6bce4a 100644 --- a/lib/crates/fabro-server/src/server/handler/runs.rs +++ b/lib/crates/fabro-server/src/server/handler/runs.rs @@ -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> { .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>, + Path(id): Path, + 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::(&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>, @@ -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, diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index c4fa08db9..dc0dadfc4 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -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::() + .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, diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 83b1a85a7..1baa7ada6 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -51,6 +51,10 @@ impl RunProjectionReducer for RunProjection { match &stored.body { EventBody::RunCreated(props) => { let labels = props.labels.clone().into_iter().collect::>(); + 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(); diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 224ca7042..3b8194a1d 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -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; diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index 50749ed5a..d9887e016 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -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", diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs index aeef18879..03db57594 100644 --- a/lib/crates/fabro-types/src/run_event/run.rs +++ b/lib/crates/fabro-types/src/run_event/run.rs @@ -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, 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." diff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs index 667f8a831..a7794d893 100644 --- a/lib/crates/fabro-types/src/run_projection.rs +++ b/lib/crates/fabro-types/src/run_projection.rs @@ -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, pub graph_source: Option, pub start: Option, diff --git a/lib/crates/fabro-types/src/run_summary.rs b/lib/crates/fabro-types/src/run_summary.rs index fc783b5f5..5e99e141b 100644 --- a/lib/crates/fabro-types/src/run_summary.rs +++ b/lib/crates/fabro-types/src/run_summary.rs @@ -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, workflow_slug: Option, goal: String, + title: String, labels: HashMap, source_directory: Option, in_place: bool, @@ -71,7 +71,6 @@ impl RunSummary { diff_summary: Option, pull_request: Option, ) -> 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, diff --git a/lib/crates/fabro-types/src/run_title.rs b/lib/crates/fabro-types/src/run_title.rs new file mode 100644 index 000000000..9468e448b --- /dev/null +++ b/lib/crates/fabro-types/src/run_title.rs @@ -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 { + 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"); + } +} diff --git a/lib/crates/fabro-types/tests/run_event_serde.rs b/lib/crates/fabro-types/tests/run_event_serde.rs index 94b68a4f7..336713c63 100644 --- a/lib/crates/fabro-types/tests/run_event_serde.rs +++ b/lib/crates/fabro-types/tests/run_event_serde.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index 3462e9dd8..966956f88 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs index ab03bb16c..f270c82df 100644 --- a/lib/crates/fabro-workflow/src/event/events.rs +++ b/lib/crates/fabro-workflow/src/event/events.rs @@ -20,6 +20,7 @@ use crate::outcome::{BilledModelUsage, FailureDetail, Outcome}; pub enum Event { RunCreated { run_id: RunId, + title: Option, 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, }, + RunTitleUpdated { + title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, 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, diff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs index e6ce82fc5..a4a630b8f 100644 --- a/lib/crates/fabro-workflow/src/event/names.rs +++ b/lib/crates/fabro-workflow/src/event/names.rs @@ -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", diff --git a/lib/crates/fabro-workflow/src/event/stored_fields.rs b/lib/crates/fabro-workflow/src/event/stored_fields.rs index 59a68248b..80c610b53 100644 --- a/lib/crates/fabro-workflow/src/event/stored_fields.rs +++ b/lib/crates/fabro-workflow/src/event/stored_fields.rs @@ -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(), diff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs index 0c74c5f27..ae0740aaa 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/git.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 8872b792e..cb8dca7c0 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -42,6 +42,7 @@ pub struct CreateRunInput { pub workflow_bundle: Option, pub submitted_manifest_bytes: Option>, pub run_id: Option, + pub title: Option, pub git: Option, pub fork_source_ref: Option, 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, submitted_manifest_bytes: Option<&[u8]>, accepted_definition: Option<&RunDefinition>, + explicit_title: Option, web_url: Option, ) -> 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, diff --git a/lib/crates/fabro-workflow/src/operations/fork.rs b/lib/crates/fabro-workflow/src/operations/fork.rs index 6fc175b04..9be3b703b 100644 --- a/lib/crates/fabro-workflow/src/operations/fork.rs +++ b/lib/crates/fabro-workflow/src/operations/fork.rs @@ -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()), diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index f01e61adb..7973d6018 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index c0e4e265c..c1ebd48a5 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/pipeline/persist.rs b/lib/crates/fabro-workflow/src/pipeline/persist.rs index 14d8237a9..30a7ac66b 100644 --- a/lib/crates/fabro-workflow/src/pipeline/persist.rs +++ b/lib/crates/fabro-workflow/src/pipeline/persist.rs @@ -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), diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index fdfa32e2a..e828f2910 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/crates/fabro-workflow/src/run_lookup.rs index 16b8d02a5..94a154e6a 100644 --- a/lib/crates/fabro-workflow/src/run_lookup.rs +++ b/lib/crates/fabro-workflow/src/run_lookup.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/crates/fabro-workflow/src/runtime_store.rs index c3d60a7be..05245a64a 100644 --- a/lib/crates/fabro-workflow/src/runtime_store.rs +++ b/lib/crates/fabro-workflow/src/runtime_store.rs @@ -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()), diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index 487e395e9..c85dab7ca 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -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"), diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index ebf9b9f6f..8661092f5 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -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 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 02116a0be..6580b7ad8 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 { 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 => { + // 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> { + 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 { 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 { + 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 diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index af3ab1e12..d9b318057 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/models/run-list-item.ts b/lib/packages/fabro-api-client/src/models/run-list-item.ts index 1a19f8d02..ecb7b89bd 100644 --- a/lib/packages/fabro-api-client/src/models/run-list-item.ts +++ b/lib/packages/fabro-api-client/src/models/run-list-item.ts @@ -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; 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 182b97ffc..7b78402bf 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 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. */ 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 1ea871fb7..fef9d2693 100644 --- a/lib/packages/fabro-api-client/src/models/run-projection.ts +++ b/lib/packages/fabro-api-client/src/models/run-projection.ts @@ -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; diff --git a/lib/packages/fabro-api-client/src/models/run-status-response.ts b/lib/packages/fabro-api-client/src/models/run-status-response.ts index a9268fd42..ee6ff3884 100644 --- a/lib/packages/fabro-api-client/src/models/run-status-response.ts +++ b/lib/packages/fabro-api-client/src/models/run-status-response.ts @@ -31,6 +31,10 @@ export interface RunStatusResponse { * Unique run identifier (ULID). */ 'id': string; + /** + * Non-blank resolved run title. + */ + 'title': string; 'status': RunStatus; 'error'?: RunError; /** diff --git a/lib/packages/fabro-api-client/src/models/run-summary.ts b/lib/packages/fabro-api-client/src/models/run-summary.ts index 7c4af77e7..eab9c7990 100644 --- a/lib/packages/fabro-api-client/src/models/run-summary.ts +++ b/lib/packages/fabro-api-client/src/models/run-summary.ts @@ -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; diff --git a/lib/packages/fabro-api-client/src/models/update-run-request.ts b/lib/packages/fabro-api-client/src/models/update-run-request.ts new file mode 100644 index 000000000..b8209a91c --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/update-run-request.ts @@ -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; +} +