From ed5e3f1792d0c9a6bff16db3af9f478c961a0334 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 15:34:39 -0400 Subject: [PATCH 01/37] feat(types): add archived run status with split terminal/immutable guards Adds `RunStatus::Archived` variant and splits the overloaded `is_terminal()` into `is_terminal()` (reached terminal outcome) and `is_immutable()` (cannot transition outbound). `can_transition_to()` now allows Succeeded|Failed|Dead to and from Archived, preserving the `* -> Dead` escape hatch. Downstream exhaustive matches in the CLI and server are updated with conservative Archived arms; the server's public-enum mapping and board-column placement carry TODOs for the OpenAPI update in a later unit. --- lib/crates/fabro-cli/src/commands/run/wait.rs | 3 +- .../fabro-cli/src/commands/runs/list.rs | 2 +- lib/crates/fabro-server/src/server.rs | 9 +- lib/crates/fabro-types/src/status.rs | 87 ++++++++++++++++++- 4 files changed, 94 insertions(+), 7 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/run/wait.rs b/lib/crates/fabro-cli/src/commands/run/wait.rs index afdd89c77..1a3f97fe8 100644 --- a/lib/crates/fabro-cli/src/commands/run/wait.rs +++ b/lib/crates/fabro-cli/src/commands/run/wait.rs @@ -108,7 +108,8 @@ fn print_human_output( RunStatus::Succeeded => (&styles.bold_green, "Succeeded"), RunStatus::Failed => (&styles.bold_red, "Failed"), RunStatus::Dead => (&styles.bold_red, "Dead"), - // Poll loop only breaks on is_terminal() which is Succeeded | Failed | Dead + RunStatus::Archived => (&styles.dim, "Archived"), + // Poll loop only breaks on is_terminal() which is the four arms above. _ => unreachable!(), }; let status_display = style.apply_to(label); diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index 86796cfb0..cb0373381 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -149,7 +149,7 @@ fn status_cell(status: RunStatus, use_color: bool) -> CellStruct { RunStatus::Succeeded => Some(Color::Green), RunStatus::Failed => Some(Color::Red), RunStatus::Running | RunStatus::Starting | RunStatus::Submitted => Some(Color::Cyan), - RunStatus::Queued | RunStatus::Dead => Some(Color::Ansi256(8)), + RunStatus::Queued | RunStatus::Dead | RunStatus::Archived => Some(Color::Ansi256(8)), RunStatus::Blocked | RunStatus::Removing => Some(Color::Yellow), RunStatus::Paused => Some(Color::Magenta), }; diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 81fe5bebe..ff9511afb 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -2593,7 +2593,9 @@ fn board_column(status: WorkflowRunStatus) -> Option<&'static str> { WorkflowRunStatus::Blocked => Some("blocked"), WorkflowRunStatus::Succeeded => Some("succeeded"), WorkflowRunStatus::Failed | WorkflowRunStatus::Dead => Some("failed"), - WorkflowRunStatus::Removing => None, + // TODO(unit-5): revisit once `archived` is a board-column first-class + // citizen; for now archived runs have no column. + WorkflowRunStatus::Removing | WorkflowRunStatus::Archived => None, } } @@ -3346,7 +3348,10 @@ fn api_status_from_workflow(status: WorkflowRunStatus) -> RunStatus { WorkflowRunStatus::Blocked => RunStatus::Blocked, WorkflowRunStatus::Paused => RunStatus::Paused, WorkflowRunStatus::Removing => RunStatus::Removing, - WorkflowRunStatus::Succeeded => RunStatus::Succeeded, + // TODO(unit-5): add `Archived` variant to the public `InternalRunStatus` + // enum in the OpenAPI spec and map to it here. Temporary: mirror + // `Succeeded` so the wire shape stays valid. + WorkflowRunStatus::Succeeded | WorkflowRunStatus::Archived => RunStatus::Succeeded, WorkflowRunStatus::Failed => RunStatus::Failed, WorkflowRunStatus::Dead => RunStatus::Dead, } diff --git a/lib/crates/fabro-types/src/status.rs b/lib/crates/fabro-types/src/status.rs index 5fddcabe2..00f78b163 100644 --- a/lib/crates/fabro-types/src/status.rs +++ b/lib/crates/fabro-types/src/status.rs @@ -17,10 +17,25 @@ pub enum RunStatus { Succeeded, Failed, Dead, + Archived, } impl RunStatus { + /// Whether the run has reached a terminal outcome and stops poll loops, + /// finalization, and similar "done" handling. `Archived` is terminal + /// because it is only reachable from another terminal status. pub fn is_terminal(self) -> bool { + matches!( + self, + Self::Succeeded | Self::Failed | Self::Dead | Self::Archived + ) + } + + /// Whether the run's status is frozen and cannot transition outbound + /// (except via the `* -> Dead` escape hatch). `Archived` is intentionally + /// NOT immutable — it can transition back to its prior terminal status + /// via `unarchive`. + pub fn is_immutable(self) -> bool { matches!(self, Self::Succeeded | Self::Failed | Self::Dead) } @@ -41,8 +56,13 @@ impl RunStatus { if to == Self::Dead { return true; } - if self.is_terminal() { - return false; + if self.is_immutable() { + // Allow immutable terminal statuses to archive. + return matches!(to, Self::Archived); + } + if self == Self::Archived { + // Unarchive: restore to any prior terminal status. + return matches!(to, Self::Succeeded | Self::Failed); } matches!( (self, to), @@ -84,6 +104,7 @@ impl fmt::Display for RunStatus { Self::Succeeded => "succeeded", Self::Failed => "failed", Self::Dead => "dead", + Self::Archived => "archived", }; f.write_str(s) } @@ -104,6 +125,7 @@ impl FromStr for RunStatus { "succeeded" => Ok(Self::Succeeded), "failed" => Ok(Self::Failed), "dead" => Ok(Self::Dead), + "archived" => Ok(Self::Archived), _ => Err(ParseRunStatusError(s.to_string())), } } @@ -189,7 +211,7 @@ impl RunStatusRecord { mod tests { use std::str::FromStr; - use super::RunStatus; + use super::{InvalidTransition, RunStatus}; #[test] fn queued_and_blocked_parse_and_format() { @@ -219,4 +241,63 @@ mod tests { assert!(blocked.can_transition_to(paused)); assert!(blocked.can_transition_to(RunStatus::from_str("failed").unwrap())); } + + #[test] + fn archived_parses_and_round_trips() { + let parsed = RunStatus::from_str("archived").expect("archived should parse"); + assert_eq!(parsed, RunStatus::Archived); + assert_eq!(parsed.to_string(), "archived"); + } + + #[test] + fn terminal_statuses_can_transition_to_archived() { + assert!(RunStatus::Succeeded.can_transition_to(RunStatus::Archived)); + assert!(RunStatus::Failed.can_transition_to(RunStatus::Archived)); + assert!(RunStatus::Dead.can_transition_to(RunStatus::Archived)); + } + + #[test] + fn archived_can_transition_back_to_terminal() { + assert!(RunStatus::Archived.can_transition_to(RunStatus::Succeeded)); + assert!(RunStatus::Archived.can_transition_to(RunStatus::Failed)); + // Dead is always reachable via the escape hatch. + assert!(RunStatus::Archived.can_transition_to(RunStatus::Dead)); + } + + #[test] + fn running_cannot_transition_to_archived() { + assert!(!RunStatus::Running.can_transition_to(RunStatus::Archived)); + assert!(!RunStatus::Queued.can_transition_to(RunStatus::Archived)); + assert!(!RunStatus::Submitted.can_transition_to(RunStatus::Archived)); + assert!(!RunStatus::Paused.can_transition_to(RunStatus::Archived)); + } + + #[test] + fn archived_to_archived_is_rejected() { + // Idempotency of archive is handled at the operation layer, not the guard. + assert!(!RunStatus::Archived.can_transition_to(RunStatus::Archived)); + } + + #[test] + fn archived_is_terminal_but_not_immutable() { + assert!(RunStatus::Archived.is_terminal()); + assert!(!RunStatus::Archived.is_immutable()); + assert!(!RunStatus::Archived.is_active()); + } + + #[test] + fn immutable_terminal_statuses_are_also_terminal() { + for status in [RunStatus::Succeeded, RunStatus::Failed, RunStatus::Dead] { + assert!(status.is_terminal(), "{status} should be terminal"); + assert!(status.is_immutable(), "{status} should be immutable"); + } + } + + #[test] + fn invalid_transition_carries_from_and_to() { + let from = RunStatus::Running; + let to = RunStatus::Archived; + let err = from.transition_to(to).expect_err("should reject"); + assert_eq!(err, InvalidTransition { from, to }); + } } From beda6b00d8a3f949675c7518521be991cfa0b838 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 15:42:39 -0400 Subject: [PATCH 02/37] feat(events): add run.archived and run.unarchived event variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `RunArchived` and `RunUnarchived` events end-to-end through the engine. Internal `Event` carries `actor` (and `restored_status` on unarchive); wire `EventBody` serializes as `run.archived`/`run.unarchived` with typed props. Projection gains `prior_status: Option` — `RunArchived` captures the current status before switching to Archived; `RunUnarchived` applies the event's `restored_status` payload (authoritative) and clears `prior_status`. --- lib/crates/fabro-store/src/run_state.rs | 149 ++++++++++++++++++++ lib/crates/fabro-types/src/run_event/mod.rs | 78 ++++++++++ lib/crates/fabro-types/src/run_event/run.rs | 17 ++- lib/crates/fabro-workflow/src/event.rs | 91 +++++++++++- 4 files changed, 331 insertions(+), 4 deletions(-) diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index c31454b45..da05d3ac3 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -24,6 +24,8 @@ pub struct RunProjection { pub graph_source: Option, pub start: Option, pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prior_status: Option, pub pending_control: Option, pub checkpoint: Option, pub checkpoints: Vec<(u32, Checkpoint)>, @@ -212,6 +214,14 @@ impl RunProjection { EventBody::RunRewound(_) => { self.reset_for_rewind(); } + EventBody::RunArchived(_props) => { + self.prior_status = self.status.as_ref().map(|record| record.status); + self.status = Some(run_status_record(RunStatus::Archived, None, ts)); + } + EventBody::RunUnarchived(props) => { + self.status = Some(run_status_record(props.restored_status, None, ts)); + self.prior_status = None; + } EventBody::CheckpointCompleted(props) => { let checkpoint = checkpoint_from_props(props, ts); if let Some(node_id) = stored.node_id.as_deref() { @@ -1097,4 +1107,143 @@ mod tests { events[1].payload.as_value()["properties"]["definition_blob"] ); } + + #[test] + fn run_archived_captures_prior_status_and_sets_archived() { + use fabro_types::RunStatus; + use fabro_types::run_event::{RunArchivedProps, RunCompletedProps}; + + let mut state = RunProjection::default(); + state + .apply_event(&test_event( + 1, + EventBody::RunCompleted(RunCompletedProps { + duration_ms: 10, + artifact_count: 0, + status: "success".to_string(), + reason: None, + total_usd_micros: None, + final_git_commit_sha: None, + final_patch: None, + billing: None, + }), + None, + )) + .unwrap(); + assert_eq!( + state.status.as_ref().map(|record| record.status), + Some(RunStatus::Succeeded) + ); + assert_eq!(state.prior_status, None); + + state + .apply_event(&test_event( + 2, + EventBody::RunArchived(RunArchivedProps { actor: None }), + None, + )) + .unwrap(); + + assert_eq!( + state.status.as_ref().map(|record| record.status), + Some(RunStatus::Archived) + ); + assert_eq!(state.prior_status, Some(RunStatus::Succeeded)); + } + + #[test] + fn run_unarchived_restores_status_and_clears_prior_status() { + use fabro_types::RunStatus; + use fabro_types::run_event::{RunArchivedProps, RunCompletedProps, RunUnarchivedProps}; + + let mut state = RunProjection::default(); + state + .apply_event(&test_event( + 1, + EventBody::RunCompleted(RunCompletedProps { + duration_ms: 10, + artifact_count: 0, + status: "success".to_string(), + reason: None, + total_usd_micros: None, + final_git_commit_sha: None, + final_patch: None, + billing: None, + }), + None, + )) + .unwrap(); + state + .apply_event(&test_event( + 2, + EventBody::RunArchived(RunArchivedProps { actor: None }), + None, + )) + .unwrap(); + state + .apply_event(&test_event( + 3, + EventBody::RunUnarchived(RunUnarchivedProps { + actor: None, + restored_status: RunStatus::Succeeded, + }), + None, + )) + .unwrap(); + + assert_eq!( + state.status.as_ref().map(|record| record.status), + Some(RunStatus::Succeeded) + ); + assert_eq!(state.prior_status, None); + } + + #[test] + fn run_unarchived_uses_event_payload_even_when_prior_status_differs() { + // The event payload is authoritative: the unarchive apply arm sets status + // from `restored_status`, ignoring whatever `prior_status` was captured. + use fabro_types::RunStatus; + use fabro_types::run_event::{RunArchivedProps, RunCompletedProps, RunUnarchivedProps}; + + let mut state = RunProjection::default(); + state + .apply_event(&test_event( + 1, + EventBody::RunCompleted(RunCompletedProps { + duration_ms: 10, + artifact_count: 0, + status: "success".to_string(), + reason: None, + total_usd_micros: None, + final_git_commit_sha: None, + final_patch: None, + billing: None, + }), + None, + )) + .unwrap(); + state + .apply_event(&test_event( + 2, + EventBody::RunArchived(RunArchivedProps { actor: None }), + None, + )) + .unwrap(); + state + .apply_event(&test_event( + 3, + EventBody::RunUnarchived(RunUnarchivedProps { + actor: None, + restored_status: RunStatus::Failed, + }), + None, + )) + .unwrap(); + + assert_eq!( + state.status.as_ref().map(|record| record.status), + Some(RunStatus::Failed) + ); + assert_eq!(state.prior_status, None); + } } diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index ecc6a37a3..b973e3457 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -114,6 +114,10 @@ pub enum EventBody { RunUnpaused(RunControlEffectProps), #[serde(rename = "run.rewound")] RunRewound(RunRewoundProps), + #[serde(rename = "run.archived")] + RunArchived(RunArchivedProps), + #[serde(rename = "run.unarchived")] + RunUnarchived(RunUnarchivedProps), #[serde(rename = "run.completed")] RunCompleted(RunCompletedProps), #[serde(rename = "run.failed")] @@ -375,6 +379,8 @@ impl EventBody { Self::RunPaused(_) => "run.paused", Self::RunUnpaused(_) => "run.unpaused", Self::RunRewound(_) => "run.rewound", + Self::RunArchived(_) => "run.archived", + Self::RunUnarchived(_) => "run.unarchived", Self::RunCompleted(_) => "run.completed", Self::RunFailed(_) => "run.failed", Self::RunNotice(_) => "run.notice", @@ -504,6 +510,8 @@ fn is_known_event_name(event: &str) -> bool { | "run.unblocked" | "run.removing" | "run.rewound" + | "run.archived" + | "run.unarchived" | "run.completed" | "run.failed" | "run.notice" @@ -1106,6 +1114,76 @@ mod tests { ); } + #[test] + fn run_archived_serializes_with_dotted_event_name_and_actor_property() { + let body = EventBody::RunArchived(RunArchivedProps { + actor: Some(ActorRef::user("alice".to_string())), + }); + let value = serde_json::to_value(&body).unwrap(); + assert_eq!(value["event"], "run.archived"); + assert_eq!(value["properties"]["actor"]["kind"], "user"); + assert_eq!(value["properties"]["actor"]["id"], "alice"); + } + + #[test] + fn run_unarchived_serializes_with_restored_status() { + let body = EventBody::RunUnarchived(RunUnarchivedProps { + actor: None, + restored_status: crate::RunStatus::Failed, + }); + let value = serde_json::to_value(&body).unwrap(); + assert_eq!(value["event"], "run.unarchived"); + assert_eq!(value["properties"]["restored_status"], "failed"); + } + + #[test] + fn run_archived_round_trips_through_from_value() { + let value = json!({ + "id": "evt_archived", + "ts": "2026-04-19T12:00:00.000Z", + "run_id": fixtures::RUN_1, + "event": "run.archived", + "properties": { + "actor": { + "kind": "user", + "id": "alice", + "display": "alice" + } + } + }); + + let parsed = RunEvent::from_value(value.clone()).unwrap(); + assert!(matches!(parsed.body, EventBody::RunArchived(_))); + let serialized = parsed.to_value().unwrap(); + assert_eq!(serialized["event"], "run.archived"); + assert_eq!( + serialized["properties"]["actor"], + value["properties"]["actor"] + ); + } + + #[test] + fn run_unarchived_round_trips_through_from_value() { + let value = json!({ + "id": "evt_unarchived", + "ts": "2026-04-19T12:00:00.000Z", + "run_id": fixtures::RUN_1, + "event": "run.unarchived", + "properties": { + "restored_status": "succeeded" + } + }); + + let parsed = RunEvent::from_value(value.clone()).unwrap(); + match &parsed.body { + EventBody::RunUnarchived(props) => { + assert_eq!(props.restored_status, crate::RunStatus::Succeeded); + assert!(props.actor.is_none()); + } + other => panic!("expected RunUnarchived body, got {other:?}"), + } + } + #[test] fn run_queued_and_unblocked_round_trip_as_typed_events() { for value in [ diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs index e7948284f..2bb51302b 100644 --- a/lib/crates/fabro-types/src/run_event/run.rs +++ b/lib/crates/fabro-types/src/run_event/run.rs @@ -2,9 +2,9 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use super::{BilledTokenCounts, RunNoticeLevel}; +use super::{ActorRef, BilledTokenCounts, RunNoticeLevel}; use crate::settings::SettingsLayer; -use crate::status::BlockedReason; +use crate::status::{BlockedReason, RunStatus}; use crate::{Graph, RunBlobId, RunControlAction, RunProvenance, StatusReason}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -93,6 +93,19 @@ pub struct RunRewoundProps { pub run_commit_sha: Option, } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunArchivedProps { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actor: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RunUnarchivedProps { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actor: Option, + pub restored_status: RunStatus, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct RunCompletedProps { pub duration_ms: u64, diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 7db5a75e5..ebdac9976 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -6,7 +6,8 @@ use std::sync::atomic::{AtomicI64, Ordering}; use ::fabro_types::{ ActorRef, BilledTokenCounts, BlockedReason, ParallelBranchId, RunBlobId, RunControlAction, - RunEvent, RunId, RunProvenance, StageId, StageStatus, StatusReason, run_event as fabro_types, + RunEvent, RunId, RunProvenance, RunStatus, StageId, StageStatus, StatusReason, + run_event as fabro_types, }; use anyhow::{Context, Result}; use chrono::Utc; @@ -118,6 +119,15 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] run_commit_sha: Option, }, + RunArchived { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, + RunUnarchived { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + restored_status: RunStatus, + }, WorkflowRunCompleted { duration_ms: u64, artifact_count: usize, @@ -628,6 +638,15 @@ impl Event { "Run rewound" ); } + Self::RunArchived { actor } => { + info!(?actor, "Run archived"); + } + Self::RunUnarchived { + actor, + restored_status, + } => { + info!(?actor, ?restored_status, "Run unarchived"); + } Self::WorkflowRunCompleted { duration_ms, artifact_count, @@ -1172,6 +1191,8 @@ pub fn event_name(event: &Event) -> &'static str { Event::RunPaused => "run.paused", Event::RunUnpaused => "run.unpaused", Event::RunRewound { .. } => "run.rewound", + Event::RunArchived { .. } => "run.archived", + Event::RunUnarchived { .. } => "run.unarchived", Event::WorkflowRunCompleted { .. } => "run.completed", Event::WorkflowRunFailed { .. } => "run.failed", Event::RunNotice { .. } => "run.notice", @@ -1357,7 +1378,9 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields { }, Event::RunCancelRequested { actor } | Event::RunPauseRequested { actor } - | Event::RunUnpauseRequested { actor } => StoredEventFields { + | Event::RunUnpauseRequested { actor } + | Event::RunArchived { actor } + | Event::RunUnarchived { actor, .. } => StoredEventFields { actor: actor.clone(), ..StoredEventFields::default() }, @@ -1584,6 +1607,16 @@ fn event_body_from_event(event: &Event) -> EventBody { previous_status: previous_status.clone(), run_commit_sha: run_commit_sha.clone(), }), + Event::RunArchived { actor } => EventBody::RunArchived(fabro_types::RunArchivedProps { + actor: actor.clone(), + }), + Event::RunUnarchived { + actor, + restored_status, + } => EventBody::RunUnarchived(fabro_types::RunUnarchivedProps { + actor: actor.clone(), + restored_status: *restored_status, + }), Event::WorkflowRunCompleted { duration_ms, artifact_count, @@ -3395,6 +3428,60 @@ mod tests { assert!(unpause.actor.is_none()); } + #[test] + fn run_archived_event_name_matches_dot_notation() { + assert_eq!( + event_name(&Event::RunArchived { actor: None }), + "run.archived" + ); + assert_eq!( + event_name(&Event::RunUnarchived { + actor: None, + restored_status: RunStatus::Succeeded, + }), + "run.unarchived" + ); + } + + #[test] + fn run_archived_round_trips_actor_in_envelope() { + let actor = ActorRef { + kind: ActorKind::User, + id: Some("alice".to_string()), + display: Some("alice".to_string()), + }; + + let archived = to_run_event(&fixtures::RUN_1, &Event::RunArchived { + actor: Some(actor.clone()), + }); + assert_eq!(archived.event_name(), "run.archived"); + assert_eq!(archived.actor.as_ref().expect("actor set"), &actor); + assert!(matches!(archived.body, EventBody::RunArchived(_))); + } + + #[test] + fn run_unarchived_round_trips_actor_and_restored_status() { + let actor = ActorRef { + kind: ActorKind::User, + id: Some("bob".to_string()), + display: Some("bob".to_string()), + }; + + let unarchived = to_run_event(&fixtures::RUN_1, &Event::RunUnarchived { + actor: Some(actor.clone()), + restored_status: RunStatus::Failed, + }); + assert_eq!(unarchived.event_name(), "run.unarchived"); + assert_eq!(unarchived.actor.as_ref().expect("actor set"), &actor); + match &unarchived.body { + EventBody::RunUnarchived(props) => { + assert_eq!(props.restored_status, RunStatus::Failed); + assert_eq!(props.actor.as_ref().expect("actor set"), &actor); + } + other => panic!("expected RunUnarchived body, got {other:?}"), + } + } + #[test] fn agent_assistant_message_populates_agent_actor() { let stored = to_run_event(&fixtures::RUN_1, &Event::Agent { From 54ddaa2cee00212b00fb3d972017a8d5b5e4d784 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 16:11:02 -0400 Subject: [PATCH 03/37] feat(install): redesign web wizard and scope dev token to PAT installs Redesign the install wizard for clarity: - swap the sidebar layout for a centered column and a horizontal stepper - make completed/current stepper entries clickable links - reorder steps so Server URL precedes LLMs - use env-var placeholders (ANTHROPIC_API_KEY, etc.) with per-provider "Where do I get this?" disclosures - replace the readonly "Validated username" input with a success pill - drop the GitHub App name field (GitHub confirms the name anyway) - re-label the GitHub App option and split review rows by strategy - add a copy action to the Server URL on the review screen Scope the dev token to PAT installs: - only generate the dev token, write its files, and set FABRO_DEV_TOKEN inside the GithubInstallState::Token arm - mark dev_token optional on InstallFinishResponse in the OpenAPI spec - hide the Development token card on /install/finishing when absent - add app_install_finish_omits_dev_token_and_does_not_write_it test Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/fabro-web/app/install-app.tsx | 1174 ++++++---- apps/fabro-web/app/install-config.ts | 18 +- docs/api-reference/fabro-api.yaml | 5 +- lib/crates/fabro-server/src/install.rs | 57 +- .../fabro-server/tests/it/api/install.rs | 144 ++ lib/crates/fabro-spa/assets/assets/app.css | 2 +- .../fabro-spa/assets/assets/entry-70cq40fr.js | 1969 ----------------- .../fabro-spa/assets/assets/entry-rzgjexka.js | 1969 +++++++++++++++++ lib/crates/fabro-spa/assets/index.html | 2 +- .../src/models/install-finish-response.ts | 5 +- 10 files changed, 2947 insertions(+), 2398 deletions(-) delete mode 100644 lib/crates/fabro-spa/assets/assets/entry-70cq40fr.js create mode 100644 lib/crates/fabro-spa/assets/assets/entry-rzgjexka.js diff --git a/apps/fabro-web/app/install-app.tsx b/apps/fabro-web/app/install-app.tsx index 6c390ede2..1b625ceaa 100644 --- a/apps/fabro-web/app/install-app.tsx +++ b/apps/fabro-web/app/install-app.tsx @@ -1,6 +1,18 @@ import { startTransition, useEffect, useMemo, useState } from "react"; -import type { ReactNode } from "react"; +import type { FormEvent, ReactNode } from "react"; import { Link, Navigate, useLocation, useNavigate } from "react-router"; +import { + ArrowLeftIcon, + ArrowRightIcon, + ArrowTopRightOnSquareIcon, + CheckCircleIcon, + CheckIcon, + ChevronDownIcon, + ClipboardDocumentCheckIcon, + ClipboardIcon, + EyeIcon, + EyeSlashIcon, +} from "@heroicons/react/16/solid"; import { type InstallFinishResponse, @@ -18,7 +30,6 @@ import { testInstallGithubToken, testInstallLlm, } from "./install-api"; -import { AuthLayout } from "./components/auth-layout"; import { INSTALL_PROVIDERS } from "./install-config"; import { shouldRedirectAfterHealthPoll } from "./install-flow"; import { @@ -29,12 +40,14 @@ import { const INSTALL_STEPS = [ { id: "welcome", label: "Welcome", href: "/install/welcome" }, - { id: "llm", label: "LLM", href: "/install/llm" }, { id: "server", label: "Server", href: "/install/server" }, + { id: "llm", label: "LLMs", href: "/install/llm" }, { id: "github", label: "GitHub", href: "/install/github" }, { id: "review", label: "Review", href: "/install/review" }, ] as const; +const STEPPER_STEPS = INSTALL_STEPS.slice(1); + type StepId = (typeof INSTALL_STEPS)[number]["id"]; type FinishState = InstallFinishResponse | null; type GithubStrategy = "token" | "app"; @@ -54,12 +67,7 @@ type AppForm = { allowedUsername: string; }; -type ProviderSelection = Record< - string, - { - apiKey: string; - } ->; +type ProviderSelection = Record; export default function InstallApp() { const navigate = useNavigate(); @@ -246,9 +254,9 @@ export default function InstallApp() { if (sessionState.status === "loading") { return ( - + Reading the current install state from the server. - + ); } @@ -278,11 +286,11 @@ export default function InstallApp() { ) : location.pathname === "/install/llm" ? ( { const providers = INSTALL_PROVIDERS.map(({ id }) => { const current = llmSelection[id] ?? { apiKey: "" }; @@ -306,7 +314,7 @@ export default function InstallApp() { await putInstallLlm(installToken, providers); const nextSession = await getInstallSession(installToken); setSessionState({ status: "ready", data: nextSession }); - navigate("/install/server"); + navigate("/install/github"); } catch (error) { setSaveError( error instanceof Error ? error.message : "Failed to save LLM settings.", @@ -320,11 +328,11 @@ export default function InstallApp() { ) : location.pathname === "/install/server" ? ( { if (!canonicalUrl.trim()) { setSaveError("Enter the canonical server URL before continuing."); @@ -336,7 +344,7 @@ export default function InstallApp() { await putInstallServer(installToken, canonicalUrl.trim()); const nextSession = await getInstallSession(installToken); setSessionState({ status: "ready", data: nextSession }); - navigate("/install/github"); + navigate("/install/llm"); } catch (error) { setSaveError( error instanceof Error ? error.message : "Failed to save server settings.", @@ -348,13 +356,17 @@ export default function InstallApp() { > setCanonicalUrl(event.target.value)} className={INPUT_CLASS} placeholder="https://fabro.example.com" + autoComplete="url" + spellCheck={false} /> @@ -362,12 +374,12 @@ export default function InstallApp() { ) : location.pathname === "/install/github" ? ( { setSubmitting(true); setSaveError(null); @@ -421,29 +433,43 @@ export default function InstallApp() { > {githubStrategy === "token" ? ( - <> - -