From c0189679b68ddf0b92be4aff2a7bbcd193ebe62b Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 1 Sep 2026 15:02:55 +0800 Subject: [PATCH] RR-24: clarify stopped-service recovery Why: - A configured host with brokerd stopped instructed people to start the service while its action misleadingly said Finish setup. Changed: - Centralize guided setup action labels by startup state. - Show Start service for configured stopped-service recovery while preserving restart-required and first-time setup wording. - Cover no-snapshot, read-only snapshot, incompatible-runtime, and missing-host boundaries and update the guided setup contract. Verification: - npm run test:app:focus -- SimulatorBrokerAppTests/BrokerDashboardStoreTests (92 tests). - npm run agent:verify -- --profile implementation (full app 172 tests plus routed suites). - npm run agent:verify -- --profile spec-only (51 public-front-door tests plus contract checks). - Current-source public-release scan and staged semantic diff review. Affected: - macOS dashboard and setup recovery copy only; guided setup and service-control semantics are unchanged. Refs: - RR-24 Session: - task-sessions/stopped-service-cta-alpha5.oTJqYc --- app/Sources/BrokerDashboardStore.swift | 15 ++++++++++- app/Sources/RootView.swift | 4 +-- app/Sources/SharedViews.swift | 15 +---------- app/Tests/BrokerDashboardStoreTests.swift | 32 +++++++++++++++++------ spec/tasks/guided-simbroker-setup.md | 13 ++++++--- 5 files changed, 50 insertions(+), 29 deletions(-) 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 |