diff --git a/AGENTS.md b/AGENTS.md index f845b0c..1b842ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ This file is the project's committed home for project-intrinsic agent knowledge: - Version tags matching `package.json` publish through `.github/workflows/release.yml` only after `npm run release:check`; keep the packed-install smoke in that gate. - Native packaging preparation is intentionally unsigned and non-publishing: run `npm run test:native-packaging` for CLT-safe validation, and treat `scripts/native-release/require-full-xcode.sh` plus final archive/sign/notarize/clean-install work as later release-host gates. - `scripts/native-check.sh` builds the landed SwiftPM graph, validates both native manifests, runs every executable smoke, and generates the Xcode project; under Command Line Tools it reports XCTest and Xcode-only gates as explicitly remaining. +- `swift run --package-path native ReadTheCode` is the CLT-safe local demo launch; raw SwiftPM processes intentionally disable system notifications, and native IPC uses the UID-scoped short socket documented in `docs/native-architecture.md`. - `native/Sources/RTCSettings` owns private, versioned app settings and must retain the loopback-only adapter validation and credential-lookup boundary. - Native exports are constructed through the `RTCExport` allowlist and diagnostic preview/confirmation boundary; keep its caps, redaction rules, skill-v2 contract, and evidence-scoped public docs aligned through `RTCExportTests` and `scripts/validate-native-skill.swift`. diff --git a/README.md b/README.md index b1c0b65..9050177 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,18 @@ File review checkmarks are browser-local convenience state. Submitted comments, ## Development +### Native macOS demo + +Command Line Tools can build and launch the current unsigned local app directly: + +```bash +swift run --package-path native ReadTheCode +``` + +Choose **Open Repository…** to review that repository's latest committed change (`HEAD^` → `HEAD`). The app materializes the exact committed diff, persists review state outside the repository, and opens the Diff/Tour workspace with comments and the revision-scoped worker rail. The raw SwiftPM launch intentionally disables system notifications because it has no macOS application-bundle identity; notifications remain enabled for a generated `.app` build. + +For the Xcode app target, run `./scripts/native-bootstrap.sh`, open `native/ReadTheCode.xcodeproj`, select the `ReadTheCode` scheme and **My Mac**, then click **Run**. Full-Xcode UI tests, signing, notarization, and final release qualification remain unrun. + ```bash npm install npm run fixture # creates .test-state/example-repository diff --git a/docs/native-architecture.md b/docs/native-architecture.md index caf1d93..0b142f0 100644 --- a/docs/native-architecture.md +++ b/docs/native-architecture.md @@ -1,6 +1,6 @@ # Native source architecture -The native tree is a SwiftPM/XcodeGen module graph targeting macOS 14. The current source graph compiles with Command Line Tools; it is not yet the final composed or packaged product. +The native tree is a SwiftPM/XcodeGen module graph targeting macOS 14. The current source graph and demo application compile with Command Line Tools; it is not yet a signed, notarized, or release-qualified product. ```text RTCContracts @@ -15,6 +15,8 @@ RTCContracts Diagnostic preparation adds typed per-field serialization and stages a directory bundle privately. `PendingDiagnosticExport` is an actor-isolated one-shot state machine. `DiagnosticExportIPCComposition` creates separate preparation and confirmation dispatchers with disjoint capabilities and a shared opaque pending registry. Only the confirmation handler owns the private short-lived, one-use approval authority. The staging root descriptor remains open from no-follow creation through leaf writes, atomic rename, publication, and cleanup; path replacement cannot redirect the operation. Publication uses a separately opened no-follow destination descriptor and exclusive rename. The module performs no upload. -`native/Package.swift` and `native/project.yml` register only the new `RTCExport` source and `RTCExportTests` executable in this slice. `scripts/validate-native-manifests.mjs` keeps both manifest dependency graphs synchronized. `scripts/validate-native-skill.swift` mechanically compares the portable skill with the native CLI parser surface. +`native/Package.swift` and `native/project.yml` keep the SwiftPM and XcodeGen app dependency graphs synchronized. `scripts/validate-native-manifests.mjs` validates both manifests, and `scripts/validate-native-skill.swift` mechanically compares the portable skill with the native CLI parser surface. -The `ReadTheCode` app target compiles `RTCExport`, and the two export IPC service dispatchers are implemented, but no socket listener, CLI execution path, confirmation UI, final feature composition, or signed packaging connects them yet. See the exact wire and export shapes in [protocol v2](protocol-v2.md) and current boundaries in [native security](native-security.md). +The `ReadTheCode` composition root starts the private ingest runtime and socket, renders the Inbox, and opens a stored exact revision through the existing diff, comment, deterministic tour, bounded-diagram, and durable conversation features. The local **Open Repository…** path resolves and reviews `HEAD^` → `HEAD`; submitted reviews use the same composition. Tour rendering resolves only from the immutable stored manifest, and every review mutation re-resolves the submitted refs and repository identity before it can append an event. The worker rail truthfully remains offline unless an external worker transport is connected. + +A raw `swift run --package-path native ReadTheCode` process has no application-bundle proxy, so the composition root does not instantiate `UNUserNotificationCenter` there. Generated `.app` builds retain the system notification presenter. The private capability, spool, and database stay under Application Support; only the ephemeral Unix socket uses a deterministic, UID-scoped `/tmp` name so it remains within Darwin's short `sockaddr_un` limit. Same-UID authentication, mode-`0600` socket access, and the operation allowlist still guard every request. Export confirmation UI, worker-chat IPC routing, complete native CLI operations, full-Xcode UI checks, signing, notarization, and final packaging remain unimplemented or unverified as documented in [protocol v2](protocol-v2.md) and [native security](native-security.md). diff --git a/native/App/ReadTheCodeApp/main.swift b/native/App/ReadTheCodeApp/main.swift index f591e06..f3734df 100644 --- a/native/App/ReadTheCodeApp/main.swift +++ b/native/App/ReadTheCodeApp/main.swift @@ -1,14 +1,20 @@ +import AppKit +import RTCAgentChat import RTCContracts import RTCDesign +import RTCDiffCanvas import RTCDomain -import RTCExport +import RTCGit import RTCInboxFeature import RTCIngest -import RTCIPC import RTCLifecycle import RTCReview +import RTCReviewWorkspace import RTCStore +import RTCTourIntegration +import RTCWorkspaceShell import SwiftUI +import TourWorkspace #if canImport(UserNotifications) import UserNotifications #endif @@ -19,34 +25,199 @@ struct ReadTheCodeApp: App { var body: some Scene { WindowGroup { - Group { - if let inbox = model.inbox { - switch model.route { - case let .review(id): - VStack(spacing: 16) { - Text("Review \(id.value)").font(.headline).textSelection(.enabled) - Text("The exact review is selected. The review workspace lands in RTC-202.") - .foregroundStyle(.secondary) - Button("Back to Inbox") { model.route = .inbox } - } - .frame(minWidth: 520, minHeight: 360) - case .inbox: - VStack(spacing: 0) { - if model.notificationAuthorization == .notDetermined { - Button("Enable Ready Notifications") { Task { await model.enableNotifications() } } - .padding(8) - .accessibilityHint("Requests macOS notification permission") - } - InboxView(model: inbox) - } + NativeApplicationView(model: model) + .frame( + minWidth: WorkspaceSizing.minimumWindow.width, + minHeight: WorkspaceSizing.minimumWindow.height) + .task { await model.start() } + } + .defaultSize( + width: WorkspaceSizing.minimumWindowWithAgent.width, + height: WorkspaceSizing.minimumWindowWithAgent.height) + .commands { + CommandGroup(replacing: .newItem) { + Button("Open Repository…") { model.chooseRepository() } + .keyboardShortcut("o", modifiers: [.command]) + } + } + } +} + +private struct NativeApplicationView: View { + @ObservedObject var model: NativeApplicationModel + + var body: some View { + Group { + if let inbox = model.inbox { + switch model.route { + case let .review(id): review(id) + case .inbox: inboxView(inbox) + } + } else if let error = model.errorMessage { + RTCErrorState(title: "Inbox unavailable", message: error) + } else { + ProgressView("Opening private review state…") + } + } + .background(RTCDesign.color(.canvas)) + } + + @ViewBuilder + private func review(_ id: ReviewID) -> some View { + if let session = model.reviewSession, session.id == id { + NativeReviewView(session: session) { model.showInbox() } + } else if let error = model.reviewErrorMessage { + VStack(spacing: 16) { + RTCErrorState( + title: "Review unavailable", message: error, + retry: { Task { await model.openReview(id) } }) + Button("Back to Inbox") { model.showInbox() } + .buttonStyle(RTCButtonStyle()) + } + .padding(32) + } else { + ProgressView("Opening exact committed revision…") + .task(id: id.value) { await model.openReview(id) } + } + } + + private func inboxView(_ inbox: InboxModel) -> some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text("Review inbox").font(.headline) + Text("Exact committed comparisons, stored outside repositories") + .font(.caption) + .foregroundStyle(RTCDesign.color(.textSecondary)) + } + Spacer() + if model.notificationAuthorization == .notDetermined { + Button("Enable Ready Notifications") { + Task { await model.enableNotifications() } } - } else if let error = model.errorMessage { - ContentUnavailableView("Inbox unavailable", systemImage: "exclamationmark.triangle", description: Text(error)) - } else { - ProgressView("Opening Inbox…") + .buttonStyle(RTCButtonStyle()) + .accessibilityHint("Requests macOS notification permission") + } + Button { model.chooseRepository() } label: { + Label( + model.isOpeningRepository ? "Opening…" : "Open Repository…", + systemImage: "folder") + } + .buttonStyle(RTCButtonStyle(prominent: true)) + .disabled(model.isOpeningRepository) + .help("Review HEAD^ → HEAD without reading working-tree changes") + } + .padding(12) + Divider() + if let error = model.openRepositoryError { + HStack { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) + Text(error).font(.caption) + Spacer() + Button("Dismiss") { model.openRepositoryError = nil }.buttonStyle(.plain) + } + .padding(8) + .background(RTCDesign.color(.surface)) + } + InboxView(model: inbox) + } + } +} + +@MainActor +private final class ReviewNavigationState: ObservableObject { + @Published var mode: WorkspaceMode = .tour + @Published var agentRailOpen = false +} + +@MainActor +private final class NativeReviewSession: ObservableObject { + let id: ReviewID + let title: String + let repositoryName: String + let revision: RevisionIdentity + let review: ReviewWorkspaceModel + let tour: TourWorkspaceModel + let conversation: AgentConversationRailModel + let navigation: ReviewNavigationState + + init( + id: ReviewID, title: String, repositoryName: String, + revision: RevisionIdentity, review: ReviewWorkspaceModel, + tour: TourWorkspaceModel, conversation: AgentConversationRailModel, + navigation: ReviewNavigationState + ) { + self.id = id + self.title = title + self.repositoryName = repositoryName + self.revision = revision + self.review = review + self.tour = tour + self.conversation = conversation + self.navigation = navigation + } +} + +private struct NativeReviewView: View { + @ObservedObject var session: NativeReviewSession + @ObservedObject private var navigation: ReviewNavigationState + let showInbox: () -> Void + + init(session: NativeReviewSession, showInbox: @escaping () -> Void) { + self.session = session + _navigation = ObservedObject(wrappedValue: session.navigation) + self.showInbox = showInbox + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 10) { + Button(action: showInbox) { Label("Inbox", systemImage: "tray") } + .buttonStyle(RTCButtonStyle()) + VStack(alignment: .leading, spacing: 2) { + Text(session.title).font(.headline).lineLimit(1) + HStack(spacing: 6) { + Text(session.repositoryName) + Text("·") + Text(session.revision.baseSHA.prefix(8) + " → " + session.revision.headSHA.prefix(8)) + .font(.system(.caption, design: .monospaced)) + } + .font(.caption) + .foregroundStyle(RTCDesign.color(.textSecondary)) + } + .help("Exact committed revision: \(session.revision.baseSHA) → \(session.revision.headSHA)") + Spacer() + Picker("Review mode", selection: $navigation.mode) { + Text("Diff").tag(WorkspaceMode.diff) + Text("Tour").tag(WorkspaceMode.tour) + } + .pickerStyle(.segmented) + .frame(width: 150) + Button { navigation.agentRailOpen.toggle() } label: { + Label("Agent", systemImage: "bubble.left.and.bubble.right") + } + .buttonStyle(RTCButtonStyle()) + .keyboardShortcut("i", modifiers: [.command]) + .help("Show the revision-scoped worker conversation") + } + .padding(10) + .background(RTCDesign.color(.surface)) + Divider() + HStack(spacing: 0) { + Group { + switch navigation.mode { + case .diff: RTCReviewWorkspaceView(model: session.review) + case .tour: TourWorkspaceView(model: session.tour) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + if navigation.agentRailOpen { + Divider() + AgentConversationRail(model: session.conversation) + .frame(minWidth: 340, idealWidth: 380, maxWidth: 480) + .background(RTCDesign.color(.surface)) } } - .task { await model.start() } } } } @@ -55,40 +226,58 @@ struct ReadTheCodeApp: App { final class NativeApplicationModel: ObservableObject { @Published var inbox: InboxModel? @Published var route: ActivationRoute = .inbox + @Published fileprivate var reviewSession: NativeReviewSession? @Published var errorMessage: String? + @Published var reviewErrorMessage: String? + @Published var openRepositoryError: String? + @Published var isOpeningRepository = false @Published var notificationAuthorization: NotificationAuthorization = .notDetermined private var runtime: RTCIngestRuntime? private var lifecycle: LifecycleCoordinator? -#if canImport(UserNotifications) + #if canImport(UserNotifications) private var notificationDelegate: NotificationResponseDelegate? -#endif + #endif func start() async { guard runtime == nil else { return } do { - let lifecycle = LifecycleCoordinator(registrar: InMemoryLaunchAtLoginRegistrar()) { [weak self] event in - Task { @MainActor in self?.route = event.route } + let lifecycle = LifecycleCoordinator( + registrar: InMemoryLaunchAtLoginRegistrar() + ) { [weak self] event in + Task { @MainActor in + self?.route = event.route + if case let .review(id) = event.route { await self?.openReview(id) } + } } -#if canImport(UserNotifications) - let presenter: any NotificationPresenter = SystemNotificationPresenter() - let delegate = NotificationResponseDelegate(coordinator: lifecycle) - UNUserNotificationCenter.current().delegate = delegate - notificationDelegate = delegate -#else - let presenter: any NotificationPresenter = DisabledNotificationPresenter() -#endif + let presenter: any NotificationPresenter + #if canImport(UserNotifications) + if NotificationRuntimeSupport.canUseSystemCenter() { + let systemPresenter = SystemNotificationPresenter() + let delegate = NotificationResponseDelegate(coordinator: lifecycle) + UNUserNotificationCenter.current().delegate = delegate + notificationDelegate = delegate + presenter = systemPresenter + } else { + presenter = DisabledNotificationPresenter() + } + #else + presenter = DisabledNotificationPresenter() + #endif let runtime = try await RTCIngestRuntime( paths: RTCInstallationPaths.applicationSupport(), - notificationPresenter: presenter - ) - inbox = InboxModel(records: runtime.records, coordinator: runtime.coordinator) { id in - await lifecycle.activate(reviewID: id) + notificationPresenter: presenter) + inbox = InboxModel( + records: runtime.records, coordinator: runtime.coordinator + ) { [weak self] id in + await self?.activate(id) } self.lifecycle = lifecycle self.runtime = runtime try await runtime.start() - if ProcessInfo.processInfo.arguments.contains("--uitest-inbox-fixture") { try await seedInboxFixture(runtime) } + if ProcessInfo.processInfo.arguments.contains("--uitest-inbox-fixture") { + try await seedInboxFixture(runtime) + } notificationAuthorization = await runtime.notificationService.authorization() await inbox?.refresh() } catch { @@ -96,31 +285,188 @@ final class NativeApplicationModel: ObservableObject { } } + func chooseRepository() { + let panel = NSOpenPanel() + panel.title = "Choose a repository" + panel.message = "Review the latest committed change (HEAD^ → HEAD)." + panel.prompt = "Review Latest Commit" + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.begin { [weak self] response in + guard response == .OK, let url = panel.url else { return } + Task { @MainActor in await self?.openLatestCommit(in: url) } + } + } + + func showInbox() { + reviewErrorMessage = nil + route = .inbox + } + + func openLatestCommit(in selectedURL: URL) async { + guard let runtime, !isOpeningRepository else { return } + isOpeningRepository = true + openRepositoryError = nil + defer { isOpeningRepository = false } + do { + let resolved = try await ExactGitEngine().resolveSubmission( + repositoryPath: selectedURL.path, base: "HEAD^", head: "HEAD") + let submission = ReviewSubmission( + repositoryPath: resolved.revision.repositoryPath, + repositoryIdentity: resolved.repositoryIdentity, + base: SubmittedRef(label: "HEAD^", expectedSHA: resolved.revision.baseSHA), + head: SubmittedRef(label: "HEAD", expectedSHA: resolved.revision.headSHA), + title: "Latest commit in \(selectedURL.lastPathComponent)", notify: false) + let receipt = try await runtime.coordinator.submit(submission) + await runtime.coordinator.runUntilIdle() + await activate(receipt.reviewID) + } catch { + openRepositoryError = "The latest committed change could not be opened. Choose a Git repository with at least two commits." + } + } + + func openReview(_ id: ReviewID) async { + guard let runtime else { return } + if reviewSession?.id == id { return } + reviewErrorMessage = nil + reviewSession = nil + route = .review(id) + do { + guard let record = try await runtime.records.review(id), + let manifest = try await runtime.reviewRepository.review(id: id) + else { throw NativeCompositionError.reviewUnavailable } + guard ![.accepted, .materializing, .failed].contains(record.status) else { + throw NativeCompositionError.reviewNotReady + } + + let anchors = ManifestTourArtifactSource(manifest: manifest) + let reviewHandler = try await ReviewCommandHandler.open( + manifest: manifest, + repository: SQLiteEventRepository(store: runtime.store), + anchors: anchors, + mutationPreflight: SubmittedRefMutationPreflight(record: record)) + let review = ReviewWorkspaceModel( + revision: manifest.revision, + files: manifest.files.map { CanvasFile(artifact: $0) }, + handler: reviewHandler) + let navigation = ReviewNavigationState() + let artifacts = ManifestTourArtifactResolver(manifest: manifest) + let tourJobs = TourGenerationJobHandler( + persistence: SQLiteTourPersistence(store: runtime.store), + jobs: JobQueue(store: runtime.store), artifacts: artifacts, + reviewStateSource: StoredReviewStateSource(manifest: manifest)) + let tour = TourWorkspaceModel( + reviewID: id, revision: manifest.revision, + jobs: tourJobs, artifacts: artifacts + ) { anchor in + navigation.mode = .diff + if let side = anchor.side, let start = anchor.startLine, let end = anchor.endLine { + review.navigate(to: CanvasSelection( + path: anchor.path, side: side, startLine: start, endLine: end)) + } else { + review.selectFile(anchor.path) + } + } + + let conversationID = Self.conversationID(for: id) + let conversationRepository = SQLiteConversationEventRepository(store: runtime.store) + let coordinator = AgentChatCoordinator( + reviewID: id, conversationID: conversationID, + repository: conversationRepository, wakeSink: UnavailableWorkerWake()) + let conversation = AgentConversationRailModel( + queue: { requestID, body in + _ = requestID + return try await coordinator.queueMessage(body) + }, + replay: { cursor in try await coordinator.replay(after: cursor) }) + + reviewSession = NativeReviewSession( + id: id, title: record.title, + repositoryName: URL(fileURLWithPath: manifest.revision.repositoryPath).lastPathComponent, + revision: manifest.revision, review: review, tour: tour, + conversation: conversation, navigation: navigation) + } catch NativeCompositionError.reviewNotReady { + reviewErrorMessage = "The exact committed diff is still being prepared. Return to the Inbox and try again when it is Ready." + } catch { + reviewErrorMessage = "The stored exact revision could not be opened." + } + } + func enableNotifications() async { guard let runtime else { return } - do { notificationAuthorization = try await runtime.notificationService.requestPermissionIfNeeded() } - catch { errorMessage = "Notification permission could not be requested." } + do { + notificationAuthorization = try await runtime.notificationService.requestPermissionIfNeeded() + } catch { + errorMessage = "Notification permission could not be requested." + } + } + + private func activate(_ id: ReviewID) async { + if let lifecycle { await lifecycle.activate(reviewID: id) } + else { await openReview(id) } + } + + private static func conversationID(for reviewID: ReviewID) -> UUID { + let hex = SHA256Digest(data: Data("conversation\0\(reviewID.value)".utf8)).hex + let value = "\(hex.prefix(8))-\(hex.dropFirst(8).prefix(4))-4\(hex.dropFirst(13).prefix(3))-a\(hex.dropFirst(17).prefix(3))-\(hex.dropFirst(20).prefix(12))" + return UUID(uuidString: value)! } private func seedInboxFixture(_ runtime: RTCIngestRuntime) async throws { - let base = String(repeating: "a", count: 40), head = String(repeating: "b", count: 40) - let revision = try RevisionIdentity(repositoryPath: "/tmp/rtc-ui-fixture", baseSHA: base, headSHA: head) + let base = String(repeating: "a", count: 40) + let head = String(repeating: "b", count: 40) + let revision = try RevisionIdentity( + repositoryPath: "/tmp/rtc-ui-fixture", baseSHA: base, headSHA: head) let submission = ReviewSubmission( idempotencyKey: UUID(uuidString: "00000000-0000-0000-0000-000000000201")!, repositoryPath: revision.repositoryPath, repositoryIdentity: SHA256Digest(data: Data("rtc-ui-fixture".utf8)), - base: SubmittedRef(label: "base", expectedSHA: base), head: SubmittedRef(label: "head", expectedSHA: head), - title: "Exact revision fixture", notify: false - ) + base: SubmittedRef(label: "base", expectedSHA: base), + head: SubmittedRef(label: "head", expectedSHA: head), + title: "Exact revision fixture", notify: false) _ = try await runtime.records.accept(submission, revision: revision) } } -#if !canImport(UserNotifications) +private enum NativeCompositionError: Error { + case reviewUnavailable + case reviewNotReady +} + +private struct SubmittedRefMutationPreflight: ReviewMutationPreflight { + let record: IngestReviewRecord + + func currentHead(for revision: RevisionIdentity) async throws -> String { + guard revision == record.revision else { throw NativeCompositionError.reviewUnavailable } + let resolved = try await ExactGitEngine().resolveSubmission( + repositoryPath: revision.repositoryPath, + base: record.baseRef, head: record.headRef) + guard resolved.repositoryIdentity == record.repositoryIdentity else { + throw NativeCompositionError.reviewUnavailable + } + return resolved.revision.headSHA + } +} + +private struct StoredReviewStateSource: TourReviewStateSource { + let manifest: ReviewManifest + + func state(for revision: RevisionIdentity) async throws -> TourReviewState { + guard revision == manifest.revision else { throw NativeCompositionError.reviewUnavailable } + return TourReviewState(manifest: manifest) + } +} + +private struct UnavailableWorkerWake: WakeSink { + func wake(reviewID: ReviewID, conversationID: UUID, highestSequence: Int) async throws { + throw AgentChatError.workerUnavailable + } +} + private struct DisabledNotificationPresenter: NotificationPresenter { func authorization() async -> NotificationAuthorization { .denied } func requestAuthorization() async throws -> NotificationAuthorization { .denied } func present(_ request: NotificationRequestData) async throws {} func setBadge(_ value: Int) async {} } -#endif diff --git a/native/Package.swift b/native/Package.swift index 9ffce94..b464d87 100644 --- a/native/Package.swift +++ b/native/Package.swift @@ -129,17 +129,24 @@ let package = Package( .executableTarget( name: "ReadTheCode", dependencies: [ + "RTCAgentChat", "RTCContracts", "RTCDomain", + "RTCDiffCanvas", + "RTCGit", "RTCStore", "RTCIPC", "RTCReview", + "RTCReviewWorkspace", "RTCDesign", "RTCExport", "RTCInboxFeature", "RTCIngest", "RTCLifecycle", "RTCSettings", + "RTCTourIntegration", + "RTCWorkspaceShell", + "TourWorkspace", ], path: "App/ReadTheCodeApp" ), diff --git a/native/Sources/RTCIngest/IngestRuntime.swift b/native/Sources/RTCIngest/IngestRuntime.swift index c32d5de..036801a 100644 --- a/native/Sources/RTCIngest/IngestRuntime.swift +++ b/native/Sources/RTCIngest/IngestRuntime.swift @@ -19,11 +19,16 @@ public struct RTCInstallationPaths: Sendable { public let capability: URL public init(root: URL) { - self.root = root - store = root.appendingPathComponent("State", isDirectory: true) - spool = root.appendingPathComponent("Spool", isDirectory: true) - socket = root.appendingPathComponent("reviewd.sock") - capability = root.appendingPathComponent("install-capability") + let canonicalRoot = root.standardizedFileURL + self.root = canonicalRoot + store = canonicalRoot.appendingPathComponent("State", isDirectory: true) + spool = canonicalRoot.appendingPathComponent("Spool", isDirectory: true) + // Darwin's sockaddr_un path is only 104 bytes. A deterministic endpoint + // under the sticky system temp directory keeps legitimate long worktree + // test roots usable; peer-UID and capability checks remain authoritative. + let digest = SHA256Digest(data: Data(canonicalRoot.path.utf8)).hex.prefix(24) + socket = URL(fileURLWithPath: "/tmp/rtc-\(geteuid())-\(digest).sock") + capability = canonicalRoot.appendingPathComponent("install-capability") } public static func applicationSupport() throws -> RTCInstallationPaths { @@ -101,6 +106,9 @@ public final class RTCIngestRuntime: @unchecked Sendable { private static let allowedOperations: Set = ["submitReview", "status", "pollReviewEvents", "closeReview", "retryReview"] public let records: SQLiteIngestRepository public let reviewRepository: SQLiteReviewRepository + /// Shared private store used by the app composition root to open the review, + /// tour, and conversation projections for one exact revision. + public let store: SQLiteStore public let coordinator: SubmissionCoordinator public let handler: SubmissionOperationHandler public let notificationService: DeduplicatingNotificationService @@ -128,6 +136,7 @@ public final class RTCIngestRuntime: @unchecked Sendable { let handler = SubmissionOperationHandler(coordinator: coordinator) self.records = records self.reviewRepository = reviewRepository + self.store = store self.coordinator = coordinator self.handler = handler notificationService = notifications diff --git a/native/Sources/RTCLifecycle/Lifecycle.swift b/native/Sources/RTCLifecycle/Lifecycle.swift index 5a5a042..eff4581 100644 --- a/native/Sources/RTCLifecycle/Lifecycle.swift +++ b/native/Sources/RTCLifecycle/Lifecycle.swift @@ -28,6 +28,20 @@ public enum NotificationAuthorization: String, Codable, Sendable { case notDetermined, denied, provisional, authorized } +/// `UNUserNotificationCenter.current()` aborts when called by a raw SwiftPM +/// executable because that process has no application-bundle proxy. Keep the +/// CLT launch path notification-free while retaining the system center for the +/// generated `.app` target. +public enum NotificationRuntimeSupport { + public static func canUseSystemCenter( + bundleURL: URL = Bundle.main.bundleURL, + bundleIdentifier: String? = Bundle.main.bundleIdentifier + ) -> Bool { + bundleURL.pathExtension.lowercased() == "app" + && !(bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + } +} + public struct LaunchAtLoginState: Equatable, Sendable { public let enabled: Bool public let registrationError: String? diff --git a/native/Sources/RTCReviewWorkspace/ReviewWorkspace.swift b/native/Sources/RTCReviewWorkspace/ReviewWorkspace.swift index 96ba508..f7bfe63 100644 --- a/native/Sources/RTCReviewWorkspace/ReviewWorkspace.swift +++ b/native/Sources/RTCReviewWorkspace/ReviewWorkspace.swift @@ -110,6 +110,10 @@ public final class ReviewWorkspaceModel: ObservableObject { } public func select(_ next: CanvasSelection) { selection=next; selectedFile=next.path } + public func navigate(to next: CanvasSelection, focus: Bool = true) { + selection=next; selectedFile=next.path + navigationRequest=NavigationRequest(selection: next, file: nil, focus: focus) + } public func selectFile(_ path: String, focus: Bool = true) { selectedFile=path; navigationRequest=NavigationRequest(selection: nil, file: path, focus: focus) } public func selectThread(_ id: UUID, focus: Bool = true) { guard let thread=threads.first(where: { $0.id == id }), let side=thread.anchor.side, let start=thread.anchor.startLine, let end=thread.anchor.endLine else { return } diff --git a/native/Sources/RTCTourIntegration/TourArtifacts.swift b/native/Sources/RTCTourIntegration/TourArtifacts.swift index 2c385c8..ad19172 100644 --- a/native/Sources/RTCTourIntegration/TourArtifacts.swift +++ b/native/Sources/RTCTourIntegration/TourArtifacts.swift @@ -122,6 +122,51 @@ extension TourArtifactResolving { } } +/// Resolves tour blocks exclusively from the immutable manifest already stored +/// for a review. Rendering therefore remains available when the repository is +/// missing or its symbolic refs have moved, and never consults the working tree. +public struct ManifestTourArtifactResolver: TourArtifactResolving, Sendable { + public let manifest: ReviewManifest + private let syntax: any SyntaxHighlighter + + public init(manifest: ReviewManifest, syntax: any SyntaxHighlighter = RTCSyntaxHighlighter()) { + self.manifest = manifest + self.syntax = syntax + } + + public func manifest(for revision: RevisionIdentity) async throws -> ReviewManifest { + guard manifest.revision == revision, manifest.id == revision.reviewID else { + throw TourIntegrationError.revisionMismatch + } + return manifest + } + + public func resolve( + _ reference: DiffSliceReference, revision: RevisionIdentity + ) async throws -> ResolvedDiffSlice { + _ = try await manifest(for: revision) + let source = ManifestTourArtifactSource(manifest: manifest) + let anchor = try ReviewAnchor( + revision: revision, path: reference.path, scope: .hunk, + side: reference.side, startLine: reference.startLine, endLine: reference.endLine, + startContextHash: reference.startContextHash, + endContextHash: reference.endContextHash, hunkIndex: reference.hunkIndex) + guard try await source.validate(anchor), let lines = source.exactLines(for: reference) else { + throw TourIntegrationError.invalidPayload + } + let sourceText = lines.map(\.text).joined(separator: "\n") + let digest = SHA256Digest(data: Data(sourceText.utf8)) + let spans = (try? await syntax.highlight( + path: reference.path, fileDigest: digest, source: sourceText, + language: nil, lines: nil)) ?? [] + return ResolvedDiffSlice(reference: reference, lines: lines, syntaxSpans: spans) + } + + public func layout(_ diagram: DiagramDocument) throws -> DiagramLayout { + try DiagramLayoutEngine.layout(DiagramValidator.validate(diagram)) + } +} + public struct ExactTourArtifactResolver: TourArtifactResolving, ExactArtifactSource, Sendable { private let git: any ExactGitService private let syntax: any SyntaxHighlighter diff --git a/native/Tests/RTCIngestTests/RTCIngestTests.swift b/native/Tests/RTCIngestTests/RTCIngestTests.swift index 85592e9..f223110 100644 --- a/native/Tests/RTCIngestTests/RTCIngestTests.swift +++ b/native/Tests/RTCIngestTests/RTCIngestTests.swift @@ -10,6 +10,15 @@ import RTCStore @main struct RTCIngestTests { static func main() async throws { + let longWorktreeRoot = URL(fileURLWithPath: + "/Users/example/.treehouse/read-the-code-demo-ef699f/1234567890/read-the-code-demo/" + + String(repeating: "nested-worktree/", count: 8)) + let longPaths = RTCInstallationPaths(root: longWorktreeRoot) + check(longPaths.socket.path.utf8.count + 1 <= 104, "IPC endpoint must fit Darwin sockaddr_un") + check(longPaths.socket.path.hasPrefix("/tmp/rtc-"), "long-root IPC endpoint must remain out of repository") + check(longPaths.store.path.hasPrefix(longWorktreeRoot.path), "durable store moved out of private state root") + check(longPaths.capability.path.hasPrefix(longWorktreeRoot.path), "capability moved out of private state root") + let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) .appendingPathComponent(".test-state/i-\(UUID().uuidString.prefix(8))", isDirectory: true) defer { try? FileManager.default.removeItem(at: root) } diff --git a/native/Tests/RTCLifecycleTests/LifecycleTests.swift b/native/Tests/RTCLifecycleTests/LifecycleTests.swift index d2083b3..411f3a5 100644 --- a/native/Tests/RTCLifecycleTests/LifecycleTests.swift +++ b/native/Tests/RTCLifecycleTests/LifecycleTests.swift @@ -14,6 +14,7 @@ final class RecordingPresenter: NotificationPresenter, @unchecked Sendable { @main struct LifecycleTests { static func check(_ value: @autoclosure () -> Bool, _ message: String) { precondition(value(), message) } static func main() async throws { + notificationRuntimeRequiresAnApplicationBundle() let review = try ReviewID("0123456789abcdef01234567") let presenter = RecordingPresenter() let deliveries = InMemoryNotificationDeliveryStore() @@ -50,6 +51,16 @@ final class RecordingPresenter: NotificationPresenter, @unchecked Sendable { print("RTC lifecycle checks passed") } + static func notificationRuntimeRequiresAnApplicationBundle() { + precondition(!NotificationRuntimeSupport.canUseSystemCenter( + bundleURL: URL(fileURLWithPath: "/tmp/ReadTheCode"), bundleIdentifier: nil)) + precondition(!NotificationRuntimeSupport.canUseSystemCenter( + bundleURL: URL(fileURLWithPath: "/tmp/ReadTheCode"), bundleIdentifier: "com.readthecode.app")) + precondition(NotificationRuntimeSupport.canUseSystemCenter( + bundleURL: URL(fileURLWithPath: "/Applications/ReadTheCode.app"), + bundleIdentifier: "com.readthecode.app")) + } + final class ReceivedEvents: @unchecked Sendable { let lock = NSLock() var events = [ActivationRouteEvent]() diff --git a/native/Tests/RTCReviewWorkspaceFeatureTests/ReviewWorkspaceTests.swift b/native/Tests/RTCReviewWorkspaceFeatureTests/ReviewWorkspaceTests.swift index 549c110..f2ffcf7 100644 --- a/native/Tests/RTCReviewWorkspaceFeatureTests/ReviewWorkspaceTests.swift +++ b/native/Tests/RTCReviewWorkspaceFeatureTests/ReviewWorkspaceTests.swift @@ -21,6 +21,9 @@ private struct Source: AnchorArtifactSource { func validate(_ anchor: ReviewAnch let handler = try await ReviewCommandHandler.open(manifest: manifest, repository: repository, anchors: Source(), mutationPreflight: FixedReviewMutationPreflight(headSHA: revision.headSHA)) let model = ReviewWorkspaceModel(revision: revision, files: [CanvasFile(artifact: artifact)], handler: handler) await model.refresh() + let tourSelection = CanvasSelection(path: artifact.path, side: .new, startLine: 8, endLine: 8) + model.navigate(to: tourSelection) + precondition(model.selection == tourSelection && model.navigationRequest?.selection == tourSelection) precondition(!model.isReadOnly, "workspace control enablement comes from reducer snapshot") let menu = ReviewWorkspaceCommandRouter(model: model).reviewMenu() precondition(menu.items.map(\.title).contains("Send Review") && menu.items.map(\.title).contains("Request Changes") && menu.items.map(\.title).contains("Close Review"), "all review actions remain discoverable as menu commands") diff --git a/native/Tests/TourWorkspaceFeatureTests/TourWorkspaceFeatureTests.swift b/native/Tests/TourWorkspaceFeatureTests/TourWorkspaceFeatureTests.swift index 3411658..8c06b73 100644 --- a/native/Tests/TourWorkspaceFeatureTests/TourWorkspaceFeatureTests.swift +++ b/native/Tests/TourWorkspaceFeatureTests/TourWorkspaceFeatureTests.swift @@ -63,7 +63,8 @@ struct TourWorkspaceFeatureTests { } private static func diffSlicesResolveThroughExactArtifacts(_ fixture: Fixture) async throws { - let source = ManifestTourArtifactSource(manifest: fixture.manifest()) + let manifest = fixture.manifest() + let source = ManifestTourArtifactSource(manifest: manifest) let hash = fixture.file.hunks[0].lines[0].contextHash let valid = DiffSliceReference( path: fixture.file.path, hunkIndex: 0, startLine: 10, endLine: 10, @@ -87,6 +88,11 @@ struct TourWorkspaceFeatureTests { expectedInputDigest: fixture.contextDigest, anchors: source) try expectFailure(rejected, code: .unresolvedAnchor) + let storedResolver = ManifestTourArtifactResolver(manifest: manifest) + let resolved = try await storedResolver.resolve(valid, revision: fixture.revision) + try expect( + resolved.lines.map(\.text) == [fixture.file.hunks[0].lines[0].text], + "stored tour rendering did not use the immutable manifest") } private static func sideSpecificDiffSlicesAreExact(_ fixture: Fixture) async throws { diff --git a/native/project.yml b/native/project.yml index aa3c63c..703e04f 100644 --- a/native/project.yml +++ b/native/project.yml @@ -31,17 +31,24 @@ targets: sources: - App/ReadTheCodeApp dependencies: + - target: RTCAgentChat - target: RTCContracts - target: RTCIPC - target: RTCDomain + - target: RTCDiffCanvas + - target: RTCGit - target: RTCStore - target: RTCReview + - target: RTCReviewWorkspace - target: RTCDesign - target: RTCExport - target: RTCInboxFeature - target: RTCIngest - target: RTCLifecycle - target: RTCSettings + - target: RTCTourIntegration + - target: RTCWorkspaceShell + - target: TourWorkspace settings: PRODUCT_BUNDLE_IDENTIFIER: com.readthecode.app GENERATE_INFOPLIST_FILE: NO diff --git a/scripts/validate-native-manifests.mjs b/scripts/validate-native-manifests.mjs index bb5eb01..d41738c 100644 --- a/scripts/validate-native-manifests.mjs +++ b/scripts/validate-native-manifests.mjs @@ -6,17 +6,24 @@ import { fileURLToPath } from 'node:url'; const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const expectedDependencies = { ReadTheCode: [ + 'RTCAgentChat', 'RTCContracts', 'RTCDesign', + 'RTCDiffCanvas', 'RTCExport', + 'RTCGit', 'RTCInboxFeature', 'RTCIngest', 'RTCDomain', 'RTCIPC', 'RTCLifecycle', 'RTCReview', + 'RTCReviewWorkspace', 'RTCSettings', 'RTCStore', + 'RTCTourIntegration', + 'RTCWorkspaceShell', + 'TourWorkspace', ], rtc: ['RTCCLI'], GitWorker: ['RTCContracts', 'RTCGit'],