Engineering plans / Proposed

GarageKit: the app’s service layer, apart from the UI

Written from main on 2026-10-04. Nothing here is built yet; file and line references are to the tree at that date.

The rule the plan builds toward: GarageKit is everything about the Garage service and the corpus state that needs neither SwiftUI nor AppKit. It owns the client of GarageService, the lifecycle of the backend that serves it, and the observable state the pages show. The app keeps the views, their presentation values, and the platform glue that needs AppKit, the Keychain, StoreKit or Sparkle.

Why

GarageGRPCService (macapp/Sources/GarageApp/Services/GarageGRPCService.swift, with GarageGRPCService+Operations.swift and GarageGRPCService+Facts.swift) is the app’s whole relationship with the Python GarageService. It starts the server through GarageXPCService, it owns the grpc-swift channel, and it exposes about thirty RPCs. AppState (1,930 lines) holds the state those RPCs produce, the pipeline that runs them, and the app’s platform glue, in one @MainActor ObservableObject. The consequences:

A GarageKit module gives the views (and later the launchers, the UI tests’ readiness probe, or an extension) one typed, observable, UI-free service layer, and gives the service layer tests that need no Postgres, no XPC and no Python.

Three ways to cut it

A. Move the classes as they are

Lift GarageGRPCService, GarageGRPCAuth, the item structs and the service half of AppState into a new module and mark them public.

B. Three layers in the kit: client, backend lifecycle, observable state

The kit holds the endpoint, the token, the connection and a typed client behind protocols; a GarageBackend that starts, probes, stops and reports on the server; and a GarageStore that holds the corpus state and runs the operations and the pipeline that change it. The app provides what only it can (the Postgres cluster, the bundle’s paths, the Keychain, folder grants) through small protocols, and its views read the kit’s observable objects.

C. Thin kit: a connection factory and the generated client

Export GarageEndpoint, the token and a makeClient() -> Garage_GarageServiceAsyncClient, and let the app keep using protos and keep the lifecycle and state where they are.

Recommendation: B. The rest of this plan is B.

The design

Module

macapp/Sources/GarageKit, a swift_library named GarageKit, declared like IngestClient/BUILD.bazel (srcs = glob(["**/*.swift"]), module_name, public visibility). A static library, not a framework: it has no resources, unlike PythonXPCService.framework.

Dependencies, and only these:

Imports allowed in the kit: Foundation, Observation, OSLog, Combine only if a consumer needs a publisher, the three above. Never SwiftUI or AppKit. Only Client/GarageClient*.swift and Client/Mapping/*.swift import proto_garage_proto_swift or GRPC. A test holds both lines (see Tests).

Because IngestClient depends on PythonXPCService_swift, which links the CPython embedding shim, the kit’s test bundle links Python.framework the way LlamaClientTests/BUILD.bazel does.

macapp/Sources/GarageKit/
  BUILD.bazel
  Client/
    GarageEndpoint.swift            where the server listens: .unixSocket(path) | .tcp(host, port)
    GarageServiceToken.swift        the per-launch x-garage-token value and its metadata key
    GarageConnection.swift          actor owning the EventLoopGroup and ClientConnection
    GarageClientError.swift         typed errors, mapped from GRPCStatus
    GarageServing.swift             the protocols the store and the views depend on
    GarageClient.swift              the grpc-swift implementation: ping, status, corpus reads
    GarageClient+Operations.swift   sources, models, settings, schema, MCP registration
    GarageClient+Streams.swift      scan, backfill, enrichFacts as AsyncThrowingStream
    Mapping/*.swift                 internal init(proto:) and request builders, one per family
  Models/
    Search.swift                    SearchQuery, SearchResultItem
    Documents.swift                 DocumentFilter, DocumentPage, DocumentListItem,
                                    DocumentDetailItem, DocumentChunkItem, DocumentAuthorItem,
                                    DocumentFactItem
    Facts.swift                     FactFilter, FactListPage, FactListItem, FactClassCount,
                                    FactAttribute, FactPromptItem, FactPromptList,
                                    EnrichFactsRequest, EnrichFactsEvent
    Sources.swift                   SourceSpec, SourceRecord, RegisteredSource, ScanEvent,
                                    ScanSummary, SyncOutcome, ReconcileOutcome
    Models.swift                    ModelRecord, ModelRegistration, BackfillEvent
    Service.swift                   ServiceStatus, ServiceVersion, CorpusStatistics, SettingValue,
                                    SettingChange, SchemaOutcome, OperationOutcome
    MCP.swift                       McpInstallScope, McpTransport, McpInstallOutcome,
                                    McpRegistrationStatus
    LogLine.swift                   LogLine, LogLevel (from Services/ProcessRunner.swift)
  Backend/
    GarageBackend.swift             @Observable lifecycle: start, probe, stop, status, logs
    GarageBackendConfiguration.swift  Configuration, DatabaseProviding, BackendProcessControlling
    GarageBackendError.swift        databaseNotOnline, startupTimeout, launchFailed
    GarageBackend+Diagnostics.swift the Status page's "Test": five RPCs, timed, summarized
  State/
    GarageStore.swift               @Observable corpus state and the operations that change it
    GarageStore+Pipeline.swift      scan → ingest → embed → glean, queues, cancellation
    GarageStore+Maintenance.swift   the scheduled run and its three defaults
    OperationRunner.swift           from Services/OperationRunner.swift, unchanged
    IngestService.swift             from Services/IngestService.swift, with an injected database
    GarageConfigReader.swift        from Services/GarageConfigLoader.swift

Observation

Both observable objects are @Observable (the Observation framework; the app’s minimum_os_version is 14.0) and @MainActor. Observation imports nothing from SwiftUI, and a SwiftUI body that reads appState.backend.status or appState.store.registeredSources is tracked property by property, however it reached the object. So AppState can stay an ObservableObject (the entry point is @StateObject private var appState = AppState()) and drops the objectWillChange forwarding sinks it keeps for grpc and the runners today. No view listens on appState.objectWillChange (the onReceives in the views are notification-center publishers and refresh timers), so nothing depends on the forwarding.

For consumers without SwiftUI (the launchers, tests, a future CLI probe) the backend also exposes statusChanges: AsyncStream<GarageBackendStatus>, fed where status is set.

Client

Endpoint and token

public enum GarageEndpoint: Hashable, Sendable {
    case unixSocket(path: String)
    case tcp(host: String, port: Int)

    /// The app's own server: `s/grpc` in the App Group container (GarageSockets), or loopback
    /// when that path would overflow sun_path.
    public static func appDefault(host: String = "127.0.0.1", port: Int = 50051) -> GarageEndpoint
    /// `unix:<path>` or `host:port`, as grpc names it and the Status page shows it.
    public var address: String { get }
    /// The keys the Python helpers read (GarageXPCServiceBase.grpcTarget): the socket when there
    /// is one, else host and port. Replaces GarageGRPCService.addressOptions.
    public var helperConfiguration: [String: String] { get }
}

public struct GarageServiceToken: Hashable, Sendable {
    public static let metadataKey = "x-garage-token"
    public let value: String
    public init(value: String)
    /// 32 random bytes, hex encoded. The backend makes one per launch.
    public static func random() -> GarageServiceToken
}

GarageGRPCAuth goes away: the backend owns the launch’s token and hands it to the client, to the server’s environment and, through helperConfiguration, to the ingest and embed helpers (IngestService reads GarageGRPCAuth.token today).

Connection

actor GarageConnection {
    init(endpoint: GarageEndpoint)
    /// A lazily made ClientConnection on one event loop, with the 256 MiB receive limit that
    /// matches MAX_REQUEST_BYTES in service/server.py.
    func channel() -> GRPCChannel
    /// Close and forget the channel, as cleanupChannel does after a failed ping.
    func reset() async
    func shutdown() async
}

Internal to the kit. An actor, so GarageClient is Sendable and not bound to the main actor.

Errors

public enum GarageClientError: Error, Equatable, LocalizedError {
    case unavailable(String)       // UNAVAILABLE: nothing listening, socket gone
    case unauthenticated           // UNAUTHENTICATED: token missing or wrong
    case permissionDenied(String)  // PERMISSION_DENIED: a config change over TCP (auth.py)
    case invalidArgument(String)   // INVALID_ARGUMENT: ValueError on the server
    case notFound(String)          // NOT_FOUND: LookupError
    case alreadyExists(String)     // ALREADY_EXISTS: FileExistsError
    case deadlineExceeded
    case server(code: Int, message: String)
}

Mapped from GRPCStatus in one function. _grpc_errors in garage_rag/service/server.py maps LookupError, ValueError, FileExistsError and PermissionError onto these codes, so the Swift side gets the vocabulary the CLI has. errorDescription is the server’s message, which is what GarageGRPCService.describe recovers today. A cancelled call is rethrown as CancellationError, which OperationRunner already catches.

Protocols

Split by what a page needs, composed into one:

public protocol CorpusReading: Sendable {
    func ping() async throws -> Bool
    func status() async throws -> ServiceStatus
    func version() async throws -> ServiceVersion
    func statistics() async throws -> CorpusStatistics
    func search(_ query: SearchQuery) async throws -> [SearchResultItem]
    func documents(_ filter: DocumentFilter) async throws -> DocumentPage
    func document(id: Int64) async throws -> DocumentDetailItem
    func facts(_ filter: FactFilter) async throws -> FactListPage
}

public protocol SourceAdministering: Sendable {
    func sources() async throws -> [SourceRecord]
    func addSource(_ spec: SourceSpec) async throws -> OperationOutcome
    func removeSource(slug: String) async throws -> OperationOutcome
    func scan(source: String, includeCode: Bool) -> AsyncThrowingStream<ScanEvent, Error>
    func syncSources(dryRun: Bool) async throws -> SyncOutcome
    func importSourcesToConfig() async throws -> OperationOutcome
    func reconcile(source: String, apply: Bool) async throws -> ReconcileOutcome
}

public protocol ModelAdministering: Sendable {
    func models() async throws -> [ModelRecord]
    func registerModel(_ registration: ModelRegistration) async throws -> OperationOutcome
    func setDefaultModel(slug: String) async throws -> OperationOutcome
    func dropModel(slug: String) async throws -> OperationOutcome
    func backfill(model: String?) -> AsyncThrowingStream<BackfillEvent, Error>
}

public protocol FactDistilling: Sendable {
    func enrichFacts(_ request: EnrichFactsRequest) -> AsyncThrowingStream<EnrichFactsEvent, Error>
    func factPrompts() async throws -> FactPromptList
}

public protocol SettingsAdministering: Sendable {
    func initializeSchema(schemaDirectory: String) async throws -> SchemaOutcome
    func setting(named name: String) async throws -> SettingValue
    func setSetting(_ name: String, to value: String) async throws -> SettingChange
}

public protocol MCPRegistering: Sendable {
    func mcpInstall(scope: McpInstallScope, transport: McpTransport, force: Bool)
        async throws -> McpInstallOutcome
    func mcpUninstall(target: String) async throws -> OperationOutcome
    func mcpStatus() async throws -> McpRegistrationStatus
}

public typealias GarageServing = CorpusReading & SourceAdministering & ModelAdministering
    & FactDistilling & SettingsAdministering & MCPRegistering

GarageClient: GarageServing is the real one. The store holds any GarageServing; a view that still calls the API directly asks for the slice it uses; the test double implements the typealias once.

Timeouts are not on the protocols. GarageClient keeps today’s per-RPC defaults (30 s for reads, 120 s for operations, 30 min for removeSource, 10 min for scan and reconcile, none for streams) in a GarageClient.Timeouts value its initializer takes, so a test can shorten them.

Requests with many fields are structs with defaults (SearchQuery replaces the nine-parameter search, ModelRegistration the seven-parameter registerModel), the shape the Python GarageClient in service/client.py already has.

Streaming

scan, backfill and enrichFacts take @MainActor onStatus: callbacks today and return the last status, leaving the caller to read phase strings. Each becomes an AsyncThrowingStream of a typed event:

public struct BackfillEvent: Sendable, Equatable {
    public enum Phase: Sendable, Equatable {
        case started, progress, complete, skipped, finished
        case other(String)   // a phase this build does not know: a newer server must not break it
                             // (the string is kept for the log)
    }
    public let phase: Phase
    public let model: String
    public let embedded: Int
    public let total: Int
    public let message: String
    public let error: String?
    /// complete | skipped | finished: the per-model outcomes backfill() collects today.
    public var isOutcome: Bool { get }
}

ScanEvent (running with the counts, finished with a ScanSummary) and EnrichFactsEvent (started, document, finished) follow the same shape. The phase vocabulary is then written down once, in the kit, with one test per string; the proto fields stay strings. (Follow-on: enums in garage.proto would make the contract exact on both sides.)

Cancelling the consuming task cancels the call, as now: grpc-swift reports CANCELLED and the stream finishes with CancellationError, so the server stops at its next progress step.

Models

The item files already in the app are the start. SearchResultItem, DocumentListItem, DocumentChunkItem, DocumentAuthorItem, DocumentFactItem, DocumentDetailItem, FactListItem, FactAttribute, FactClassCount and FactListPage are public structs with proto initializers (Services/SearchResultItem.swift, DocumentListItem.swift, FactListItem.swift). They move as they are (SearchResultItem loses an unused import SwiftUI). Their init(hit:), init(summary:), init(response:) become internal initializers in Mapping/, and each gets a public memberwise initializer so the app’s tests and the mock can make one without a proto (SearchResultItem has one; the others need it). FactPromptItem (the record) moves too; FactPromptConfig and FactPromptConfigError (the editor’s JSON round trip and validation of what the user types) stay in the app.

RegisteredModel and CorpusStats (Services/PostgresService.swift) and RegisteredSource (Services/GarageConfigLoader.swift) are the shapes the pages show today, filled from psql. RegisteredModel is Garage_ModelInfo field for field and becomes ModelRecord. RegisteredSource is Garage_SourceInfo plus two config-only fields (includeCode, origin) and moves as it is, filled by the store’s merge (below). CorpusStats carries more than Garage_StatsResponse does: documents by state, embedded chunks, per-model embedding counts, files seen and indexed, expected elements and fact counts. CorpusStatistics takes its shape, and GetStats grows to fill it (step 4).

New value types, each a plain mapping of one proto message: SourceSpec (from Services/SourcePresets.swift, same fields), SourceRecord, ServiceStatus, ServiceVersion, SettingValue, SettingChange, SchemaOutcome, SyncOutcome (with its undeclared sources), ReconcileOutcome, McpInstallOutcome (with per-target outcomes), McpRegistrationStatus, and OperationOutcome (the success + message pair most operation responses are). Int32/Int64 become Int in the mapping, once. The summary display text moves from the proto extensions onto these types.

Backend

GarageBackend is GarageGRPCService minus the API methods and the app types, made observable:

@Observable @MainActor
public final class GarageBackend {
    public struct Configuration: Sendable {
        public var endpoint: GarageEndpoint              // .appDefault() in the app
        public var workingDirectory: URL                 // GARAGE_WORKING_DIRECTORY
        public var mcpExecutable: URL?                   // GARAGE_MCP_EXECUTABLE, when bundled
        public var modelManifest: URL?                   // GARAGE_MODEL_MANIFEST
        public var modelsDirectory: URL?                 // a --data-directory launch's models folder
        public var lmStudioToken: @Sendable () async throws -> String?   // GARAGE_LMSTUDIO_API_TOKEN
    }

    public private(set) var status: GarageBackendStatus  // stopped | starting | running | stopping | failed(String)
    public private(set) var logs: [LogLine]              // the helper's stdout/stderr, polled as today
    public let endpoint: GarageEndpoint
    public let token: GarageServiceToken                 // one per launch
    public let client: GarageClient
    public var api: any GarageServing { client }
    public var statusChanges: AsyncStream<GarageBackendStatus>

    public init(configuration: Configuration,
                database: any DatabaseProviding,
                process: any BackendProcessControlling = GarageXPCClient())

    public func start(maxAttempts: Int = 3, readyTimeout: TimeInterval = 10) async throws
    public func ensureRunning() async throws            // the auto-start every API method does today
    public func stop() async
    public func terminateImmediately()
    public func refreshStatus() async
    public func helperConfiguration() throws -> [String: String]
    public func diagnostics() async -> BackendDiagnostics   // today's testServiceQuery
}

/// What the backend needs from the database: PostgresService conforms in the app.
public protocol DatabaseProviding: Sendable {
    @MainActor var isRunning: Bool { get }
    func connectionURL() throws -> String
}

/// The helper that hosts the server. GarageXPCClient conforms by an extension in the kit (its
/// startServer, stopServer, isServerRunning, fetchBufferedOutput and clearLogs already have these
/// shapes); a fake conforms in the tests.
public protocol BackendProcessControlling: Sendable {
    func startServer(host: String, port: Int, options: [String: String]) async throws -> (success: Bool, message: String?)
    func stopServer() async throws -> (success: Bool, message: String?)
    func isServerRunning() async throws -> Bool
    func fetchBufferedOutput(clearBuffer: Bool) async throws -> (stdout: String?, stderr: String?)
    func clearLogs() async throws -> Bool
}

What moves in unchanged: the readiness loop (waitUntilReady, XPC verdict first, then a ping), the retry on another port when the endpoint is TCP, the log polling, refreshStatus, the environment the server is started with (GARAGE_DATABASE_URL from database.connectionURL(), the token as GARAGE_GRPC_TOKEN, and the configuration’s paths and LM Studio token), and helperConfiguration (environment plus endpoint.helperConfiguration). GarageBackendError keeps databaseNotOnline, startupTimeout and launchFailed; cliNotFound and serverNotRunning are thrown nowhere and go; searchFailed and rpcFailed are replaced by GarageClientError.

The app supplies the configuration from Paths, GarageAppGroup.dataDirectoryOverride and LMStudioTokenStore, and PostgresService as the database. AppState.startBackend becomes try await backend.start() followed by xpcServices.configureHelpers(backend.helperConfiguration()).

The kit never starts Postgres. The cluster (PostgresService: initdb, start and stop, the Keychain password, backups, migrations) stays in the app behind DatabaseProviding.

Store

GarageStore is the service half of AppState: the state the pages show and the operations and pipeline that change it.

@Observable @MainActor
public final class GarageStore {
    public init(backend: GarageBackend,
                ingest: IngestService,
                config: GarageConfigReader = GarageConfigReader(),
                defaults: UserDefaults = .standard,
                factsModelResidency: any FactsModelResidency = NoFactsModelResidency())

    // Corpus state, filled over the API (today: psql and garage.json).
    public private(set) var registeredSources: [RegisteredSource]
    public private(set) var registeredModels: [ModelRecord]
    public private(set) var corpusStats: CorpusStatistics
    public private(set) var factsModel: String, factsProvider: String
    public private(set) var inferenceModel: String?, inferenceProvider: String?
    public private(set) var factPrompts: [FactPromptItem], factPromptsConfiguredJSON: String, factPromptsError: String?
    public private(set) var isFetchingSources, isFetchingModels, isFetchingStats: Bool

    // The last operation and the long jobs.
    public private(set) var lastCommandOutput: String, lastCommandSucceeded: Bool?, commandInProgress: Bool
    public let garage, scanner, backfill, enrichFacts: OperationRunner
    public private(set) var scanProgress: ScanProgress?, backfillProgress: BackfillProgress?, enrichFactsProgress: EnrichFactsProgress?

    // The pipeline (GarageStore+Pipeline.swift).
    public private(set) var ingestQueue: [String], sourcesAwaitingScan: [String], scanningSlugs: Set<String>
    public private(set) var isIngestingAll, isCancellingAll, isUpdatingEverything: Bool
    public private(set) var sourcesBeingRemoved: Set<String>, lastIngestAllFailure: String?

    // Maintenance (GarageStore+Maintenance.swift), persisted in `defaults` under today's keys.
    public var scheduledMaintenanceEnabled: Bool, scheduledMaintenanceInterval: TimeInterval, maintenanceRunsAtLaunch: Bool

    public func fetchRegisteredSources() async
    public func fetchRegisteredModels() async
    public func fetchCorpusStats() async
    public func fetchFactsSettings(), fetchFactPrompts() async
    public func runOperation(triggersMaintenance: Bool = false,
                             _ operation: @escaping @MainActor (any GarageServing) async throws -> String) async -> Bool
    public func scanSources(source:includeCode:followedByIngest:) async -> Bool
    public func scanAndIngestSource(slug:options:) async -> Bool
    public func ingestSource(slug:options:mode:) async -> Bool
    public func ingestAllSources(options:mode:) async -> Bool
    public func runBackfill(model:) async -> Bool
    public func runEnrichFacts(source:documentID:prompts:staleOnly:) async -> Bool
    public func removeSource(slug:) async -> Bool
    public func updateEverything() async
    public func cancelAll(), cancel(source:) async, cancelScan(), cancelIngest() async
    public func isBusy(source:), isPending(source:), isQueued(source:) -> Bool
    public func search(_:) async throws -> [SearchResultItem]
    public func listDocuments(_:) async throws -> DocumentPage
    public func getDocument(id:) async throws -> DocumentDetailItem
    public func listFacts(_:) async throws -> FactListPage
    public func setInferenceModel(_:provider:), setFactsModel(_:provider:), saveFactPrompts(configuredJSON:) async -> Bool
    public func clearLogs(for:)
    public func runMaintenanceAtLaunchIfEnabled(), triggerMaintenanceIfEnabled() async, resumeMaintenanceAfterFirstRun()
}

/// The facts model's residency around a distillation run. AppState+LlamaModels conforms, loading
/// the model inside the enrich-facts runner (so a run refused as "already running" never loads)
/// and unloading it afterwards unless it is also the search model. The default does nothing.
public protocol FactsModelResidency: Sendable {
    @MainActor func prepareForDistilling(log: OperationRunner) async
    @MainActor func releaseAfterDistilling() async
}

The bodies are today’s AppState methods, moved. Three change in substance:

IngestService moves with the store. It imports only Foundation, IngestClient and OSLog; its one use of PostgresService (a fallback database URL) becomes the backend’s DatabaseProviding. VolumeAccessService still hands it the folder-granted IngestClient as today. GarageConfigLoader moves as GarageConfigReader (Foundation and PythonXPCService only: the sources, facts and inference settings and the default embedding model in garage.json, and the model presets in the bundled catalog); ModelCatalog, which refreshes that catalog from the website, stays in the app.

What stays in the app

Threading

GarageClient is Sendable and not @MainActor; a call runs where it is awaited. GarageBackend and GarageStore are @MainActor, so the store’s for try await event in api.backfill(model:) loop runs on the main actor and sets backfillProgress directly, as the callbacks do today, without the @MainActor closure parameter the call helper in GarageGRPCService+Operations.swift needs now.

Steps

Each step leaves aspect build //:macapp and aspect test //macapp/... green.

  1. Kit skeleton, no callers. Client/, Models/ (the five item files move; LogLine and LogLevel leave ProcessRunner.swift), OperationRunner moves unchanged, and GarageClient has every RPC the app uses today. GarageKitTests with the mapping, error and endpoint tests. Add //macapp/Sources/GarageKit to GarageApp_lib’s deps. The app’s imports change, nothing else.
  2. Callers move to the typed client. GarageGRPCService makes a GarageClient(endpoint: token:) and exposes api; its readiness probe uses client.ping(); getOrCreateChannel, cleanupChannel and pingOverChannel go. Then page by page, deleting each old method as its last caller moves: the reads (AppState.search, listDocuments, getDocument, AppState+Facts.listFacts), the operations (runOperation’s closure type becomes (any GarageServing), then SourcesView, ModelsView, ModelsView+AddModel, FirstRunCoordinator, GarageMCPService, AppState.setInferenceModel/setFactsModel/ fetchFactPrompts/saveFactPrompts), the streams (scanSources, runBackfill, runEnrichFacts on the event streams). Ends with GarageGRPCService+Operations.swift, GarageGRPCService+Facts.swift, the proto summary extensions and GarageGRPCAuth deleted, //proto:garage_proto_swift dropped from GarageApp_lib, and the import-boundary test added.
  3. GarageBackend. The lifecycle moves into the kit as written above, behind Configuration, DatabaseProviding and BackendProcessControlling; PostgresService conforms; AppState.grpc becomes backend and loses its forwarding sink; IngestService takes the token from the backend; GarageGRPCService.swift is deleted. Lifecycle tests with a fake process controller and a fake database (below).
  4. GetStats grows. The SQL in PostgresService.fetchCorpusStats moves into the Python ops function behind GetStats, StatsResponse gains the fields (additive), CorpusStatistics fills from them, and test_postgres.py judges the query against a real server. A Python and proto change with no Swift in it; it can land before step 3.
  5. GarageStore, part one: state and operations. The corpus state, the operation runners, runOperation, the facts settings and prompts, the four reads, removeSource, runBackfill and runEnrichFacts (with FactsModelResidency), and GarageConfigReader. The three psql reads become api.models(), api.sources() and api.statistics(). AppState keeps the scan and ingest pipeline one more step and reads everything else from store. MockGarageService in GarageKitTestSupport, store tests on it, and the setXForTesting methods deleted in favour of state the mock produces.
  6. GarageStore, part two: the pipeline. IngestService moves; scanSources, the ingest methods, the queues and cancellation, updateEverything/runPipeline, and the scheduled maintenance (with UserDefaults injected) follow. AppState is the composition root and the platform glue listed above.
  7. Docs and project. CLAUDE.md’s macOS app section (the module list; the OperationRunner bullet names GarageGRPCService+Operations.swift), macapp/README.md “App architecture” (the GarageGRPCService bullet becomes GarageKit: backend, store, client), and //macapp/Tests/GarageKitTests in the xcodeproj target list.

Tests

Build and CI

Out of scope, worth noting

Risks and open questions