Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ ReviewMonitor composes `ReviewUI` and `CodexReviewMCPServer` over one
`CodexReviewHost` to `CodexReviewAppServer`; dependencies do not point back
toward the app or UI.

### Codex Updates

`CodexReviewStore.updateCodex(when:install:)` owns the update operation, its
observable progress, review dispatch suspension, and runtime replacement. It
retains MCP sessions and accepted jobs, joins existing execution and cleanup,
then resumes dispatch after runtime publication. Repeated update requests join
the same operation. Runtime recovery confirms the owned process has closed
without treating a recorded close error as permanent evidence that it is live.

The app's `ReviewMonitorCodexUpdater` owns update checks and their results. The
sidebar receives its availability projection, and Settings uses the same
instance. A continuous clock anchors automatic checks to launch and eight-hour
boundaries; manual checks share in-flight work without moving that anchor.
Checks due during installation are covered by one post-update check.

Application termination stops read-only checking, shuts down the Store, and
joins startup before replying to AppKit. The Store can cancel a deferred update
or wait for an installation already in progress. Codex updates do not use an
application relaunch helper.

## CodexReview

`CodexReviewStore` is the single source of truth for review, runtime, auth,
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ visible.
- `codex app-server` runs behind CodexReviewMonitor as the live review backend.
- `~/.codex_review` is the dedicated Codex home used by CodexReviewMonitor.

## Codex Updates

ReviewMonitor checks the selected Codex installation at launch and every eight
hours. Use **Settings → Updates → Check for Updates** for a manual check and its
last-check time. Manual checks do not change the automatic schedule and do not
install an update. Unsupported installations and failed checks are reported
separately from **Up to Date**. Automatic installation currently supports the
stable Homebrew Codex cask.

When an update is available, choose **Update** in the sidebar toolbar. During a
review, **Update After Reviews** lets current reviews finish and queues new
requests inside ReviewMonitor. **Stop Reviews and Update** cancels current
reviews and updates immediately. ReviewMonitor and its MCP sessions stay open;
queued requests resume after Codex restarts, without being resubmitted.

If updating fails but Codex can restart, queued reviews resume and the error
remains visible in Settings. If Codex cannot restart, the queue is retained and
**Retry** in the sidebar attempts runtime recovery without reinstalling. Explicitly
quitting the app cancels queued reviews; an installation already in progress is
allowed to finish before the app exits.

## Timeout Setup

Long reviews can exceed the default MCP client timeout. `codex mcp add` does
Expand Down
82 changes: 82 additions & 0 deletions Sources/CodexReview/Store/PreviewCodexReviewUpdateBackend.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import Foundation

@MainActor
package final class PreviewCodexReviewUpdateBackend: PreviewCodexReviewStoreBackend {
package let started = PreviewCodexUpdateGate()
package var completesReviews = false
package var failsNextPreparation = false
private var mailboxes: [String: BackendReviewEventMailbox] = [:]

package init() {
super.init(seed: .init(initialAccounts: [CodexAccount(email: "reviewer@example.com")]))
}

package override func prepareRuntime(generation: ReviewRuntimeGeneration, purpose: ReviewRuntimeTransitionPurpose) async throws -> PreparedRuntime {
if failsNextPreparation {
failsNextPreparation = false
throw CodexReviewAPI.Error.io("Codex could not restart after the update. Try again.")
}
let account = CodexReviewBackendModel.Account.Snapshot(id: .init("reviewer@example.com"), label: "reviewer@example.com", isActive: true)
return PreparedRuntime(
snapshot: .init(authentication: .init(accounts: [account], activeAccountID: account.id), settings: currentSettingsSnapshot),
handle: UpdatePreviewRuntime(
onActivate: { [weak self] in self?.isActive = true },
onClose: { [weak self] in self?.isActive = false }
)
)
}

package override func startReview(_ request: CodexReviewBackendModel.Review.Start, admission: ReviewStartAdmission) async throws -> BackendReviewAttempt {
let run = CodexReviewBackendModel.Review.Run(threadID: request.jobID, turnID: request.jobID)
try await admission.admitThreadStartDispatch()
try await admission.recordPreparedThread(run)
try await admission.admitReviewStartDispatch(for: run)
try await admission.recordActiveRun(run)
let mailbox = BackendReviewEventMailbox()
mailboxes[run.threadID] = mailbox
await started.open()
if completesReviews { await mailbox.append(.completed(summary: "Review completed.", result: "No findings.")) }
return .init(run: run, events: mailbox)
}

package override func interruptReview(_ admission: ReviewInterruptRequestAdmission, reason: CodexReviewBackendModel.CancellationReason) async throws {
await mailboxes[admission.run.threadID]?.append(.cancelled(reason.message))
}

package override func cleanupReview(_ run: CodexReviewBackendModel.Review.Run) async {
await mailboxes.removeValue(forKey: run.threadID)?.finish()
}
}

@MainActor
private final class UpdatePreviewRuntime: RuntimeLifecycleHandle {
private var closed = false
private let onActivate: @MainActor () -> Void
private let onClose: @MainActor () -> Void
init(onActivate: @escaping @MainActor () -> Void, onClose: @escaping @MainActor () -> Void) {
self.onActivate = onActivate
self.onClose = onClose
}
func activate() async throws { onActivate() }
func closeAdmission() {}
func close(purpose: ReviewRuntimeTransitionPurpose) async throws { closed = true; onClose() }
func waitUntilClosed() async throws {
if closed == false { throw CancellationError() }
}
}

package actor PreviewCodexUpdateGate {
private var opened = false
private var waiters: [CheckedContinuation<Void, Never>] = []
package init() {}
package func wait() async {
if opened { return }
await withCheckedContinuation { waiters.append($0) }
}
package func open() {
opened = true
let pending = waiters
waiters = []
for waiter in pending { waiter.resume() }
}
}
29 changes: 29 additions & 0 deletions Sources/ReviewUI/ReviewMonitorContentPreview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,32 @@ private struct ReviewMonitorContentPreviewHost: NSViewControllerRepresentable {
}
}
#endif

#if DEBUG
#Preview("Update Waiting") { ReviewMonitorUpdateScenarioPreview(scenario: .waiting) }
#Preview("Updating Codex") { ReviewMonitorUpdateScenarioPreview(scenario: .installing) }
#Preview("Update Recovery") { ReviewMonitorUpdateScenarioPreview(scenario: .failed) }
#Preview("Reviews Resumed") { ReviewMonitorUpdateScenarioPreview(scenario: .resumed) }

@MainActor
private struct ReviewMonitorUpdateScenarioPreview: View {
let scenario: ReviewMonitorUpdatePreview.Scenario
@State private var preview = ReviewMonitorUpdatePreview()

var body: some View {
UpdateController(store: preview.store, available: scenario != .resumed)
.frame(width: 860, height: 560)
.task { await preview.run(scenario) }
.onDisappear { Task { await preview.stop() } }
}

private struct UpdateController: NSViewControllerRepresentable {
let store: CodexReviewStore
let available: Bool
func makeNSViewController(context: Context) -> ReviewMonitorRootViewController {
makeReviewMonitorPreviewContentViewControllerForPreview(previewStore: store, isCodexUpdateAvailable: available)
}
func updateNSViewController(_ controller: ReviewMonitorRootViewController, context: Context) {}
}
}
#endif
51 changes: 44 additions & 7 deletions Sources/ReviewUI/ReviewMonitorSplitViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import AppKit
import Combine
import Foundation
import ObservationBridge
import CodexReview
@_spi(ApplicationHostSupport) import CodexReview

@MainActor
final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDelegate {
Expand Down Expand Up @@ -168,7 +168,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
toolbarMembershipObservation = withPortableContinuousObservation { [weak self, uiState] _ in
let sidebarSelection = uiState.sidebarSelection
let isAuthenticating = uiState.auth.isAuthenticating
let isCodexUpdateAvailable = uiState.isCodexUpdateAvailable
let isCodexUpdateAvailable = self?.codexUpdatePresentation != nil
guard let self else {
return
}
Expand All @@ -179,6 +179,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
isCodexUpdateAvailable: isCodexUpdateAvailable
)
self.applyToolbarItemIdentifiers(identifiers)
self.synchronizeCodexUpdatePresentation()
}

windowTitleObservation = withPortableContinuousObservation { [weak self, uiState] _ in
Expand Down Expand Up @@ -248,7 +249,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
sidebarSelection: uiState.sidebarSelection,
isSidebarCollapsed: isSidebarCollapsed,
isAuthenticating: uiState.auth.isAuthenticating,
isCodexUpdateAvailable: uiState.isCodexUpdateAvailable
isCodexUpdateAvailable: codexUpdatePresentation != nil
)
}

Expand Down Expand Up @@ -332,9 +333,43 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
menuItem.target = self
menuItem.state = .on
item.menuFormRepresentation = menuItem
applyCodexUpdatePresentation(to: item)
return item
}

private var codexUpdatePresentation: (title: String, help: String, enabled: Bool)? {
switch store.codexUpdateState {
case .waitingForReviews:
return ("Waiting", "Current reviews will finish before Codex is updated. New requests are queued.", false)
case .stoppingRuntime, .installing:
return ("Updating", "Codex is being updated. New requests are queued.", false)
case .restarting:
return ("Restarting", "Preparing Codex to resume queued reviews.", false)
case .failed(let message):
if case .failed = store.serverState { return ("Retry", message, true) }
return uiState.isCodexUpdateAvailable ? ("Update", message, true) : nil
case .idle:
return uiState.isCodexUpdateAvailable ? ("Update", "A Codex update is available", true) : nil
}
}

private func synchronizeCodexUpdatePresentation() {
if let item = toolbar?.items.first(where: { $0.itemIdentifier == Self.sidebarUpdateToolbarItemIdentifier }) {
applyCodexUpdatePresentation(to: item)
}
}

private func applyCodexUpdatePresentation(to item: NSToolbarItem) {
guard let presentation = codexUpdatePresentation, let button = item.view as? NSButton else { return }
button.title = presentation.title
button.isEnabled = presentation.enabled
button.setAccessibilityLabel(store.codexUpdateState == .waitingForReviews ? "Waiting to Update Codex" : presentation.title + " Codex")
item.label = presentation.title
item.toolTip = presentation.help
item.menuFormRepresentation?.title = presentation.title + " Codex"
item.menuFormRepresentation?.isEnabled = presentation.enabled
}

private func handleSidebarPickerSelection(_ selection: SidebarPickerSelection) {
guard uiState.sidebarSelection != selection else {
toggleSidebar(nil)
Expand Down Expand Up @@ -439,7 +474,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
sidebarSelection: uiState.sidebarSelection,
isSidebarCollapsed: isSidebarCollapsed,
isAuthenticating: uiState.auth.isAuthenticating,
isCodexUpdateAvailable: uiState.isCodexUpdateAvailable
isCodexUpdateAvailable: codexUpdatePresentation != nil
))
}

Expand All @@ -457,9 +492,7 @@ final class ReviewMonitorSplitViewController: NSSplitViewController, NSToolbarDe
@objc
private func handleCodexUpdate(_ sender: Any?) {
(sender as? NSButton)?.state = .on
guard uiState.isCodexUpdateAvailable else {
return
}
guard codexUpdatePresentation?.enabled == true else { return }
uiState.isCodexUpdateAvailable = false
notificationCenter.post(name: ReviewMonitorCodexUpdateNotification.requested, object: nil)
}
Expand Down Expand Up @@ -563,6 +596,10 @@ extension ReviewMonitorSplitViewController {
(sidebarUpdateToolbarItemForTesting?.view as? NSButton)?.accessibilityLabel()
}

var sidebarUpdateToolbarIsEnabledForTesting: Bool {
(sidebarUpdateToolbarItemForTesting?.view as? NSButton)?.isEnabled == true
}

var sidebarUpdateToolbarShowsSelectedBackgroundForTesting: Bool {
(sidebarUpdateToolbarItemForTesting?.view as? NSButton)?.state == .on
}
Expand Down
48 changes: 48 additions & 0 deletions Sources/ReviewUI/ReviewMonitorUpdatePreview.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Foundation
@_spi(ApplicationHostSupport) import CodexReview

@_spi(PreviewSupport)
@MainActor
public final class ReviewMonitorUpdatePreview {
public enum Scenario: Sendable { case waiting, installing, failed, resumed }
public let store: CodexReviewStore
private let backend: PreviewCodexReviewUpdateBackend
private let installation = PreviewCodexUpdateGate()
private var reviews: [Task<CodexReviewAPI.Read.Result, any Error>] = []

public init() {
let backend = PreviewCodexReviewUpdateBackend()
self.backend = backend
store = CodexReviewStore.makeTestingStore(backend: backend)
}

public func run(_ scenario: Scenario) async {
await store.start()
let store = store
if scenario == .waiting {
reviews.append(Task { try await store.startReview(
sessionID: "preview", request: .init(cwd: "/Preview/Current Review", target: .baseBranch("main"))
) })
await backend.started.wait()
}
backend.completesReviews = scenario == .resumed
reviews.append(Task { try await store.startReview(
sessionID: "preview", request: .init(cwd: "/Preview/Queued Review", target: .uncommittedChanges)
) })
try? await store.updateCodex(when: scenario == .waiting ? .afterCurrentReviews : .immediately) { [self] in
if scenario == .installing { await installation.wait() }
if scenario == .failed { backend.failsNextPreparation = true }
}
if scenario == .resumed {
for review in reviews { _ = try? await review.value }
}
}

public func stop() async {
await installation.open()
await store.shutdown()
for review in reviews { _ = try? await review.value }
reviews = []
}
}

37 changes: 36 additions & 1 deletion Tests/ReviewUITests/ReviewUIUpdateTests.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,45 @@
import AppKit
import Testing
@_spi(Testing) @testable import CodexReview
@_spi(Testing) @_spi(ApplicationHostSupport) @testable import CodexReview
@_spi(PreviewSupport) @testable import ReviewUI

@MainActor
extension ReviewUITests {
@Test(arguments: [ReviewMonitorUpdatePreview.Scenario.waiting, .installing, .failed, .resumed])
func updatePreviewUsesStoreTransitionsInTheSidebar(scenario: ReviewMonitorUpdatePreview.Scenario) async throws {
let preview = ReviewMonitorUpdatePreview()
let harness = makeWindowHarness(store: preview.store)
defer { harness.window.close() }
harness.viewController.splitViewItems.first?.isCollapsed = false
let run = Task { await preview.run(scenario) }
do {
try await waitForCondition {
switch scenario {
case .waiting: return harness.viewController.sidebarUpdateToolbarTitleForTesting == "Waiting" && preview.store.jobs.contains { $0.core.lifecycle.status == .queued }
case .installing: return harness.viewController.sidebarUpdateToolbarTitleForTesting == "Updating" && preview.store.jobs.contains { $0.core.lifecycle.status == .queued }
case .failed: return harness.viewController.sidebarUpdateToolbarTitleForTesting == "Retry"
case .resumed:
return preview.store.jobs.contains { $0.isTerminal } && preview.store.codexUpdateState == .idle
}
}
switch scenario {
case .waiting, .installing:
#expect(harness.viewController.sidebarUpdateToolbarIsEnabledForTesting == false)
#expect(preview.store.jobs.contains { $0.core.lifecycle.status == .queued })
case .failed:
#expect(harness.viewController.sidebarUpdateToolbarIsEnabledForTesting)
case .resumed:
#expect(harness.viewController.sidebarUpdateToolbarItemIsHiddenForTesting)
}
} catch {
await preview.stop()
await run.value
throw error
}
await preview.stop()
await run.value
}

@Test func updateAvailabilityNotificationControlsToolbarPresentation() async throws {
let notificationCenter = NotificationCenter()
let harness = makeWindowHarness(
Expand Down
Loading