diff --git a/app/Sources/BrokerDashboardStore.swift b/app/Sources/BrokerDashboardStore.swift index db5d7b3..97a1046 100644 --- a/app/Sources/BrokerDashboardStore.swift +++ b/app/Sources/BrokerDashboardStore.swift @@ -311,13 +311,26 @@ final class BrokerDashboardStore { && serviceStatusUnverified == false } - var canOfferReadOnlyFinishSetup: Bool { + var canOfferServiceRecoveryAction: Bool { (startupState == .readOnlySnapshot || serviceRequiresRestart) && canStartBrokerService && isApplyingAction == false && serviceStatusUnverified == false } + var guidedSetupActionTitle: String? { + switch startupState { + case .missingCLI, .serviceStatusUnverified, .ready: + return nil + case .needsHostBootstrap: + return "Complete first-time setup" + case .needsServiceStart, .readOnlySnapshot: + return serviceRequiresRestart ? "Finish setup" : "Start service" + case .needsSnapshotRefresh: + return "Finish setup" + } + } + var serviceAvailabilityMessage: String { if serviceStatusUnverified { if snapshot != nil { diff --git a/app/Sources/RootView.swift b/app/Sources/RootView.swift index dbae975..8aa3ef1 100644 --- a/app/Sources/RootView.swift +++ b/app/Sources/RootView.swift @@ -206,12 +206,12 @@ struct RootView: View { || store.serviceRequiresRestart { VStack(alignment: .leading, spacing: 10) { - if store.canOfferReadOnlyFinishSetup { + if store.canOfferServiceRecoveryAction, let actionTitle = store.guidedSetupActionTitle { StatusMessageCard( color: .orange, message: store.serviceAvailabilityMessage, symbolName: "bolt.slash.fill", - actionTitle: "Finish setup", + actionTitle: actionTitle, onAction: resumeGuidedSetup, onDismiss: nil ) diff --git a/app/Sources/SharedViews.swift b/app/Sources/SharedViews.swift index 3b5c8b7..4a4be04 100644 --- a/app/Sources/SharedViews.swift +++ b/app/Sources/SharedViews.swift @@ -350,7 +350,7 @@ struct BrokerSetupView: View { } HStack(spacing: 12) { - if let primaryActionTitle = primaryActionTitle { + if let primaryActionTitle = store.guidedSetupActionTitle { Button(primaryActionTitle, action: performPrimaryAction) .buttonStyle(.borderedProminent) .disabled(store.isApplyingAction || primaryActionEnabled == false) @@ -554,19 +554,6 @@ struct BrokerSetupView: View { } } - private var primaryActionTitle: String? { - switch store.startupState { - case .missingCLI, .serviceStatusUnverified, .ready: - return nil - case .needsHostBootstrap: - return "Complete first-time setup" - case .needsServiceStart, .readOnlySnapshot: - return "Finish setup" - case .needsSnapshotRefresh: - return "Finish setup" - } - } - private func performPrimaryAction() { store.requestGuidedSetup() } diff --git a/app/Tests/BrokerDashboardStoreTests.swift b/app/Tests/BrokerDashboardStoreTests.swift index 5807f0a..852e43c 100644 --- a/app/Tests/BrokerDashboardStoreTests.swift +++ b/app/Tests/BrokerDashboardStoreTests.swift @@ -310,7 +310,7 @@ final class BrokerDashboardStoreTests: XCTestCase { XCTAssertFalse(store.canSendCommands) XCTAssertEqual(store.startupState, .serviceStatusUnverified) XCTAssertTrue(store.serviceStatusUnverified) - XCTAssertFalse(store.canOfferReadOnlyFinishSetup) + XCTAssertFalse(store.canOfferServiceRecoveryAction) XCTAssertEqual(store.serviceStatusText, "status unverified") } @@ -347,7 +347,7 @@ final class BrokerDashboardStoreTests: XCTestCase { XCTAssertEqual(store.startupState, .serviceStatusUnverified) XCTAssertEqual(store.lastErrorMessage, "Snapshot refresh failed once") XCTAssertTrue(store.serviceStatusUnverified) - XCTAssertFalse(store.canOfferReadOnlyFinishSetup) + XCTAssertFalse(store.canOfferServiceRecoveryAction) XCTAssertEqual(store.serviceStatusText, "status unverified") XCTAssertEqual( store.serviceAvailabilityMessage, @@ -403,7 +403,7 @@ final class BrokerDashboardStoreTests: XCTestCase { XCTAssertEqual(store.commandStatusText, "Status unverified") XCTAssertEqual(store.serviceStatusText, "status unverified") XCTAssertFalse(store.canStartBrokerService) - XCTAssertFalse(store.canOfferReadOnlyFinishSetup) + XCTAssertFalse(store.canOfferServiceRecoveryAction) XCTAssertEqual( store.serviceAvailabilityMessage, "Broker commands are disabled because current brokerd status could not be verified. Refresh before starting setup or sending commands." @@ -597,7 +597,7 @@ final class BrokerDashboardStoreTests: XCTestCase { XCTAssertFalse(store.serviceStatusUnverified) XCTAssertEqual(store.startupState, .readOnlySnapshot) XCTAssertTrue(store.canStartBrokerService) - XCTAssertTrue(store.canOfferReadOnlyFinishSetup) + XCTAssertTrue(store.canOfferServiceRecoveryAction) } func testUnreadableSnapshotWithValidatedLiveServiceRemainsUnverified() async throws { @@ -655,7 +655,8 @@ final class BrokerDashboardStoreTests: XCTestCase { XCTAssertFalse(store.serviceStatusUnverified) XCTAssertFalse(store.canSendCommands) XCTAssertTrue(store.canStartBrokerService) - XCTAssertTrue(store.canOfferReadOnlyFinishSetup) + XCTAssertTrue(store.canOfferServiceRecoveryAction) + XCTAssertEqual(store.guidedSetupActionTitle, "Finish setup") XCTAssertEqual(store.startupState, .needsServiceStart) XCTAssertEqual(store.commandStatusText, "Restart required") XCTAssertEqual(store.serviceStatusText, "brokerd restart required") @@ -781,7 +782,7 @@ final class BrokerDashboardStoreTests: XCTestCase { XCTAssertEqual(store.startupState, .serviceStatusUnverified) XCTAssertFalse(store.canSendCommands) XCTAssertFalse(store.canStartBrokerService) - XCTAssertFalse(store.canOfferReadOnlyFinishSetup) + XCTAssertFalse(store.canOfferServiceRecoveryAction) } func testRefreshFailureDismissesGuidedSetupConfirmationAndBlocksLocalApply() async throws { @@ -1327,10 +1328,24 @@ final class BrokerDashboardStoreTests: XCTestCase { "Broker commands are disabled because brokerd is not running. Start the service to enable pinning, release, and lifecycle actions." ) XCTAssertTrue(store.canStartBrokerService) - XCTAssertTrue(store.canOfferReadOnlyFinishSetup) + XCTAssertTrue(store.canOfferServiceRecoveryAction) + XCTAssertEqual(store.guidedSetupActionTitle, "Start service") store.isApplyingAction = true - XCTAssertFalse(store.canOfferReadOnlyFinishSetup) + XCTAssertFalse(store.canOfferServiceRecoveryAction) + } + + func testConfiguredStoppedServiceActionStartsService() { + let loadedState = makeLoadedState(snapshot: nil, service: nil) + let store = BrokerDashboardStore( + loader: StubSnapshotLoader(state: loadedState), + commandClient: RecordingCommandClient(), + runtimePaths: loadedState.paths + ) + store.loadedState = loadedState + + XCTAssertEqual(store.startupState, .needsServiceStart) + XCTAssertEqual(store.guidedSetupActionTitle, "Start service") } func testStartupStatePrioritizesMissingHostBootstrapWhenServiceExists() { @@ -1364,6 +1379,7 @@ final class BrokerDashboardStoreTests: XCTestCase { store.loadedState = loadedState XCTAssertEqual(store.startupState, .needsHostBootstrap) + XCTAssertEqual(store.guidedSetupActionTitle, "Complete first-time setup") } func testGuidedSetupPreviewsThenAppliesTheExactPlan() async throws { diff --git a/spec/tasks/guided-simbroker-setup.md b/spec/tasks/guided-simbroker-setup.md index 84f7e58..f21b325 100644 --- a/spec/tasks/guided-simbroker-setup.md +++ b/spec/tasks/guided-simbroker-setup.md @@ -1,8 +1,8 @@ # Guided `simbroker setup` > **Document ID:** `GSB-SETUP-001` -> **Version:** `1.0.33` -> **Last Updated:** `2026-08-30` +> **Version:** `1.0.34` +> **Last Updated:** `2026-09-01` > **Status:** `Active` > **Owner:** `spec-steward`, `ios-dev` > **Target:** Shipped on `v0.1.0-alpha.3` @@ -420,8 +420,12 @@ is already `ready` refreshes the current snapshot before reporting that success, matching confirmed apply. Failure of a confirmable plan refreshes first, preserves the sheet, and shows completed stages and recovery. Automatic finishing failure stays off the sheet and surfaces the recovery -error on the dashboard. The read-only snapshot `Finish setup` action is hidden -while setup is applying so an in-progress run can only be stopped through Stop. +error on the dashboard. A configured host with stopped `brokerd` labels its +recovery action `Start service`; an incompatible live runtime keeps the +`Finish setup` action, and missing host configuration keeps `Complete +first-time setup`. The +read-only recovery action is hidden while setup is applying so an in-progress +run can only be stopped through Stop. Displayed setup failures include the CLI `logPath`, `doctorIssues` including per-issue `remediationCommands` when present, and incomplete rollback count when those fields are present. @@ -625,6 +629,7 @@ long-running plan/handoff/evaluation, and a passing `agent:complete`. | Version | Date | Author | Changes | |---|---|---|---| +| 1.0.34 | 2026-09-01 | `spec-steward`, `ios-dev` | Give configured stopped-service recovery an explicit `Start service` action while preserving `Finish setup` for runtime restart and first-time setup copy for missing configuration | | 1.0.33 | 2026-08-30 | `spec-steward`, `ios-dev` | Make app live-service reads and mutations require the exact generated broker runtime version | | 1.0.32 | 2026-08-30 | `spec-steward`, `ios-dev` | Treat a live unhealthy `brokerd` as setup finishing work with service action `start`, and honor first SIGINT/SIGTERM while replacing that daemon | | 1.0.31 | 2026-08-29 | `spec-steward`, `ios-dev` | Record Target as shipped on `v0.1.0-alpha.3` so the spec matches the tagged Homebrew, npm, and notarized-app artifacts that include guided setup |