mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
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:
parent
53c03a121f
commit
697dc1294f
52 changed files with 848 additions and 39 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
120
lib/crates/fabro-types/src/run_title.rs
Normal file
120
lib/crates/fabro-types/src/run_title.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ export interface RunStatusResponse {
|
|||
* Unique run identifier (ULID).
|
||||
*/
|
||||
'id': string;
|
||||
/**
|
||||
* Non-blank resolved run title.
|
||||
*/
|
||||
'title': string;
|
||||
'status': RunStatus;
|
||||
'error'?: RunError;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Add table
Reference in a new issue