diff --git a/AGENTS.md b/AGENTS.md index acdcb897..97249077 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ make bump-version # Bump version (date-based YYYY.M.DD) and creat ``` Run a single test class or method: + ```bash xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platform=macOS" \ -only-testing:supacodeTests/TerminalTabManagerTests \ @@ -28,6 +29,7 @@ xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platf ``` **Swift Testing vs XCTest `-only-testing` format**: Swift Testing (`@Test`) requires trailing `()` in the test identifier. Without it, `xcodebuild` silently matches nothing and reports `TEST SUCCEEDED` with zero tests run. + ```bash # XCTest (func testFoo) -only-testing:supacodeTests/FooTests/testBar @@ -37,6 +39,8 @@ xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platf Requires [mise](https://mise.jdx.dev/) for zig, swiftlint, and xcsift tooling. +`make log-stream` shows no `TCA` action lines by default: per-action logging — the action label plus a full app-state snapshot and diff — is gated off because it runs on every action and shows up as steady main-thread cost. Launch with `PROWL_LOG_TCA_ACTIONS=1` (scheme env var, or exported before `open`) to trace the action stream through the unified log. + ## Architecture Prowl is a macOS orchestrator for running multiple coding agents in parallel, using GhosttyKit as the underlying terminal. diff --git a/supacode/Support/DebugCaseOutput.swift b/supacode/Support/DebugCaseOutput.swift index e5044ddb..294616c0 100644 --- a/supacode/Support/DebugCaseOutput.swift +++ b/supacode/Support/DebugCaseOutput.swift @@ -10,6 +10,18 @@ extension Reducer where State: Equatable { } } +/// When set, `LogActionsReducer` labels every action and logs it to the unified +/// log, plus prints a `CustomDump` state diff. Off by default: the label +/// reflection (`debugCaseOutput`) together with a full app-state snapshot and a +/// deep `==` compare run on *every* action, which stack sampling measured as a +/// steady main-thread cost under heavy action throughput. Enable per launch with +/// `PROWL_LOG_TCA_ACTIONS=1` (Xcode scheme env var, or `open` with the variable +/// exported) to trace the stream via `make log-stream`. +#if DEBUG + private let tcaActionLoggingEnabled = + ProcessInfo.processInfo.environment["PROWL_LOG_TCA_ACTIONS"] == "1" +#endif + struct LogActionsReducer: Reducer where Base.State: Equatable { let base: Base @@ -17,8 +29,14 @@ struct LogActionsReducer: Reducer where Base.State: Equatable { func reduce(into state: inout Base.State, action: Base.Action) -> Effect { #if DEBUG + guard tcaActionLoggingEnabled else { + return base.reduce(into: &state, action: action) + } let actionLabel = debugCaseOutput(action) - logger.debug("Action: \(actionLabel)") + // `notice`, not `debug`: in DEBUG `SupaLogger.debug` prints to a stdout + // that a Finder/launchd-launched app discards, so `make log-stream` would + // never see it. `notice` routes to the unified log in all configs. + logger.notice("Action: \(actionLabel)") let previousState = state let effects = base.reduce(into: &state, action: action) if previousState != state, let diff = CustomDump.diff(previousState, state) { diff --git a/supacode/Support/SupaLogger.swift b/supacode/Support/SupaLogger.swift index 9bc007df..e3ae252b 100644 --- a/supacode/Support/SupaLogger.swift +++ b/supacode/Support/SupaLogger.swift @@ -2,9 +2,7 @@ import OSLog nonisolated struct SupaLogger: Sendable { private let category: String - #if !DEBUG - private let logger: Logger - #endif + private let logger: Logger /// Signposter for emitting `os_signpost` intervals/events visible in /// Instruments. Signposts are essentially zero-cost when no Instruments /// session is attached (a single TLS read), so they are always live — @@ -23,9 +21,7 @@ nonisolated struct SupaLogger: Sendable { init(_ category: String) { self.category = category let subsystem = Bundle.main.bundleIdentifier ?? "com.onevcat.prowl" - #if !DEBUG - self.logger = Logger(subsystem: subsystem, category: category) - #endif + self.logger = Logger(subsystem: subsystem, category: category) self.signposter = OSSignposter(subsystem: subsystem, category: "PointsOfInterest") } @@ -53,6 +49,16 @@ nonisolated struct SupaLogger: Sendable { #endif } + /// Emits to the unified log (`log stream`, Console, `make log-stream`) in + /// every build configuration. Unlike `debug`/`info`, which `print` in DEBUG + /// so they surface in the Xcode console, `notice` is for opt-in diagnostics + /// that must be greppable from the unified log even during local development + /// — e.g. the gated TCA action stream, which a `print` to a discarded stdout + /// would never reach. + func notice(_ message: String) { + logger.notice("\(message, privacy: .public)") + } + /// Wraps `body` in an `os_signpost` interval named `name`. The /// interval renders as a labeled bar on the Instruments timeline, /// making it trivial to correlate hotspots with hangs/hitches without diff --git a/supacodeTests/LogActionsReducerTests.swift b/supacodeTests/LogActionsReducerTests.swift new file mode 100644 index 00000000..4fa04e36 --- /dev/null +++ b/supacodeTests/LogActionsReducerTests.swift @@ -0,0 +1,55 @@ +import ComposableArchitecture +import Testing + +@testable import supacode + +private struct Counter: Reducer { + struct State: Equatable { + var count = 0 + var label = "" + } + + enum Action: Equatable { + case increment + case setLabel(String) + } + + func reduce(into state: inout State, action: Action) -> Effect { + switch action { + case .increment: + state.count += 1 + return .none + case .setLabel(let value): + state.label = value + return .none + } + } +} + +@MainActor +struct LogActionsReducerTests { + /// With action logging off (the default), the wrapper must reduce exactly like + /// its base — same state mutation, no diverging behavior from the gated path. + @Test func passesActionsThroughToBaseWhenLoggingDisabled() { + let reducer = LogActionsReducer(base: Counter()) + var state = Counter.State() + + _ = reducer.reduce(into: &state, action: .increment) + _ = reducer.reduce(into: &state, action: .setLabel("repo")) + + #expect(state.count == 1) + #expect(state.label == "repo") + } + + /// A no-op action leaves state untouched, so the diff branch has nothing to + /// print; the reducer must still return the base's effect and state. + @Test func leavesStateUnchangedForActionsThatDoNotMutate() { + let reducer = LogActionsReducer(base: Counter()) + var state = Counter.State(count: 5, label: "keep") + + _ = reducer.reduce(into: &state, action: .setLabel("keep")) + + #expect(state.count == 5) + #expect(state.label == "keep") + } +}