diff --git a/scarf/scarf/Core/Models/HermesPathSet.swift b/scarf/scarf/Core/Models/HermesPathSet.swift index dfe91cc..59cdda7 100644 --- a/scarf/scarf/Core/Models/HermesPathSet.swift +++ b/scarf/scarf/Core/Models/HermesPathSet.swift @@ -55,6 +55,18 @@ struct HermesPathSet: Sendable, Hashable { nonisolated var gatewayLog: String { home + "/logs/gateway.log" } nonisolated var scarfDir: String { home + "/scarf" } nonisolated var projectsRegistry: String { scarfDir + "/projects.json" } + + /// Maps Hermes session IDs to the Scarf project path a chat was + /// started for. Written by `SessionAttributionService` when + /// Scarf spawns `hermes acp` with a project-scoped cwd; read by + /// the per-project Sessions tab (v2.3) to filter the session list + /// to just those attributed to a given project. + /// + /// Scarf-owned — Hermes never touches this file. Forward-only: + /// we only attribute sessions Scarf creates in a project context; + /// older / CLI-started sessions stay unattributed and surface in + /// the global Sessions sidebar unchanged. + nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" } nonisolated var mcpTokensDir: String { home + "/mcp-tokens" } // MARK: - Binary resolution diff --git a/scarf/scarf/Core/Models/SessionProjectMap.swift b/scarf/scarf/Core/Models/SessionProjectMap.swift new file mode 100644 index 0000000..55442d7 --- /dev/null +++ b/scarf/scarf/Core/Models/SessionProjectMap.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Scarf-owned sidecar mapping Hermes session IDs to the Scarf +/// project path a chat was started for. Written on session create +/// when Scarf spawns `hermes acp` with a project-scoped cwd; read +/// by the per-project Sessions tab. +/// +/// Hermes's own `state.db` has no `cwd` column on the sessions +/// table — the cwd is passed at runtime via ACP but not persisted +/// on its side. This sidecar is how we recover the attribution +/// without requiring an upstream schema change. +/// +/// Stored at `~/.hermes/scarf/session_project_map.json`. Forward- +/// compatible: if Hermes ever gains a canonical `cwd` column, Scarf +/// can prefer that and fall back to this file for pre-upgrade +/// sessions. Missing file → empty map (nothing attributed yet). +struct SessionProjectMap: Codable, Sendable { + /// session-id → absolute-project-path. Both strings are opaque + /// from this file's perspective; the service validates project + /// paths against the live registry when building the reverse + /// lookup used by the Sessions tab, so stale entries for + /// removed projects are ignored at read time without needing a + /// write-side cleanup. + var mappings: [String: String] + + /// ISO-8601 timestamp of the most recent write. Informational + /// only — not used for any decision logic. Useful when debugging + /// a stale sidecar ("when was this last updated?"). + var updatedAt: String? + + init(mappings: [String: String] = [:], updatedAt: String? = nil) { + self.mappings = mappings + self.updatedAt = updatedAt + } + + /// Current time in ISO-8601 format, suitable for the + /// `updatedAt` field. Matches the format used elsewhere in + /// Scarf (e.g. `TemplateLock.installedAt`) so tooling that + /// greps across .json files sees consistent timestamps. + static func nowISO8601() -> String { + ISO8601DateFormatter().string(from: Date()) + } +} diff --git a/scarf/scarf/Core/Services/SessionAttributionService.swift b/scarf/scarf/Core/Services/SessionAttributionService.swift new file mode 100644 index 0000000..2bc4b0f --- /dev/null +++ b/scarf/scarf/Core/Services/SessionAttributionService.swift @@ -0,0 +1,115 @@ +import Foundation +import os + +/// Owns the sidecar that attributes Hermes session IDs to Scarf +/// project paths. The `cwd` passed to `hermes acp` at session +/// creation is ephemeral from Hermes's perspective (not written to +/// `state.db`), so Scarf keeps this Scarf-owned record parallel to +/// Hermes's session store. +/// +/// File: `~/.hermes/scarf/session_project_map.json` (resolved via +/// `HermesPathSet.sessionProjectMap`). +/// +/// Thread safety: all public methods are `nonisolated` and each +/// performs a single read-modify-write cycle that's atomic on +/// disk. Concurrent writers (two Scarf windows on the same +/// `~/.hermes`) are safe at the file level — last write wins — +/// but the in-memory read in one window may lag until that window +/// reloads. Acceptable for v2.3's scale; revisit if multi-window +/// cross-talk becomes a problem. +struct SessionAttributionService: Sendable { + private static let logger = Logger(subsystem: "com.scarf", category: "SessionAttributionService") + + let context: ServerContext + + nonisolated init(context: ServerContext = .local) { + self.context = context + } + + // MARK: - Read + + /// Load the current sidecar contents. Missing file or unparseable + /// JSON returns an empty map — the sidecar is a convenience + /// index, not a source of truth for anything load-bearing. + nonisolated func load() -> SessionProjectMap { + let path = context.paths.sessionProjectMap + let transport = context.makeTransport() + guard transport.fileExists(path) else { + return SessionProjectMap() + } + do { + let data = try transport.readFile(path) + return try JSONDecoder().decode(SessionProjectMap.self, from: data) + } catch { + Self.logger.warning("session-project-map parse failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty map") + return SessionProjectMap() + } + } + + /// Look up the project path a given session was attributed to. + /// Returns nil for unattributed sessions (CLI-started, or + /// started before v2.3) — those surface in the global Sessions + /// sidebar unchanged and don't appear in any project's Sessions + /// tab. + nonisolated func projectPath(for sessionID: String) -> String? { + load().mappings[sessionID] + } + + /// Reverse lookup: every session ID attributed to the given + /// project path. Used by the per-project Sessions tab to filter + /// the global session list. Comparison is exact-string; the + /// registry stores absolute paths and we write absolute paths, + /// so no normalisation is needed in practice. + nonisolated func sessionIDs(forProject projectPath: String) -> Set { + let map = load() + return Set(map.mappings.filter { $0.value == projectPath }.keys) + } + + // MARK: - Write + + /// Record that `sessionID` was created under the given project + /// path. Idempotent — repeated calls for the same pair are no- + /// ops. Replacing an existing mapping (session moved to a + /// different project) is legal but expected to be rare; the + /// caller decides when that's correct. + nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) { + var map = load() + if map.mappings[sessionID] == projectPath { + return + } + map.mappings[sessionID] = projectPath + map.updatedAt = SessionProjectMap.nowISO8601() + persist(map) + } + + /// Remove a mapping. Called in v2.3's Sessions-tab code path is + /// minimal — we don't currently prune on session delete because + /// Hermes owns session lifecycle and we don't observe deletes. + /// Exposed for future roadmap items (e.g. explicit "detach + /// from project" action) and tests. + nonisolated func forget(sessionID: String) { + var map = load() + guard map.mappings.removeValue(forKey: sessionID) != nil else { return } + map.updatedAt = SessionProjectMap.nowISO8601() + persist(map) + } + + // MARK: - Private + + private func persist(_ map: SessionProjectMap) { + let path = context.paths.sessionProjectMap + let transport = context.makeTransport() + let dir = context.paths.scarfDir + do { + if !transport.fileExists(dir) { + try transport.createDirectory(dir) + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(map) + try transport.writeFile(path, data: data) + } catch { + Self.logger.error("failed to persist session-project-map at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 2f37ada..84a071d 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -118,15 +118,20 @@ final class ChatViewModel { // MARK: - Session Lifecycle - func startNewSession() { + func startNewSession(projectPath: String? = nil) { voiceEnabled = false ttsEnabled = false isRecording = false richChatViewModel.reset() if displayMode == .richChat { - startACPSession(resume: nil) + startACPSession(resume: nil, projectPath: projectPath) } else { + // Terminal mode doesn't surface project attribution today — + // `hermes chat` uses the shell's cwd, so starting a terminal + // chat from a project button would require changing the + // shell's cwd too. Out of scope for v2.3 — Rich Chat is + // the primary surface for project-scoped sessions. launchTerminal(arguments: ["chat"]) } } @@ -289,13 +294,14 @@ final class ChatViewModel { // MARK: - ACP Session Management - private func startACPSession(resume sessionId: String?) { + private func startACPSession(resume sessionId: String?, projectPath: String? = nil) { stopACP() clearACPErrorState() acpStatus = "Starting..." let client = ACPClient(context: context) self.acpClient = client + let attribution = SessionAttributionService(context: context) Task { @MainActor in do { @@ -305,7 +311,19 @@ final class ChatViewModel { startACPEventLoop(client: client) startHealthMonitor(client: client) - let cwd = await context.resolvedUserHome() + // Project-scoped chats pass the project's absolute path + // as cwd so Hermes tool calls and subsequent ACP ops + // resolve relative paths against the project's files. + // Falls back to the user's home (existing v2.2 behavior) + // when the caller didn't request a project scope. + // `??` can't wrap an async autoclosure, so we + // materialize the fallback with an if-let. + let cwd: String + if let projectPath { + cwd = projectPath + } else { + cwd = await context.resolvedUserHome() + } // Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true // and doesn't wipe messages with a DB refresh @@ -334,6 +352,17 @@ final class ChatViewModel { richChatViewModel.setSessionId(resolvedSessionId) acpStatus = "Connected (\(resolvedSessionId.prefix(12)))" + // Attribute this session to the project it was started + // under, so the per-project Sessions tab can surface it + // without a user action. No-op when projectPath is nil. + // Idempotent: re-attribution of the same pair is free. + if let projectPath { + attribution.attribute( + sessionID: resolvedSessionId, + toProjectPath: projectPath + ) + } + // Refresh session list so the new ACP session appears in the Resume menu await loadRecentSessions() diff --git a/scarf/scarf/Features/Chat/Views/ChatView.swift b/scarf/scarf/Features/Chat/Views/ChatView.swift index e9e7318..470ec1b 100644 --- a/scarf/scarf/Features/Chat/Views/ChatView.swift +++ b/scarf/scarf/Features/Chat/Views/ChatView.swift @@ -3,10 +3,12 @@ import SwiftUI struct ChatView: View { @Environment(ChatViewModel.self) private var viewModel @Environment(HermesFileWatcher.self) private var fileWatcher + @Environment(AppCoordinator.self) private var coordinator @State private var showErrorDetails = false var body: some View { @Bindable var vm = viewModel + @Bindable var coord = coordinator VStack(spacing: 0) { toolbar Divider() @@ -17,11 +19,30 @@ struct ChatView: View { .task { await viewModel.loadRecentSessions() viewModel.refreshCredentialPreflight() + // Cold-launch handoff: if the user clicked "New Chat" on + // a project before ChatView had a chance to render, the + // coordinator was already populated. Consume the request + // here. The onChange below handles the live case. + if let pending = coordinator.pendingProjectChat { + coordinator.pendingProjectChat = nil + viewModel.startNewSession(projectPath: pending) + } } .onChange(of: fileWatcher.lastChangeDate) { Task { await viewModel.loadRecentSessions() } viewModel.refreshCredentialPreflight() } + // Live handoff from the per-project Sessions tab: the tab + // sets `pendingProjectChat` + flips `selectedSection` to + // `.chat`; this view consumes the path and starts a fresh + // session with cwd=projectPath. Attribution happens inside + // ChatViewModel on successful session creation. + .onChange(of: coord.pendingProjectChat) { _, new in + if let projectPath = new { + coordinator.pendingProjectChat = nil + viewModel.startNewSession(projectPath: projectPath) + } + } } /// Banner rendered between the toolbar and the chat area when either diff --git a/scarf/scarf/Features/Projects/ViewModels/ProjectSessionsViewModel.swift b/scarf/scarf/Features/Projects/ViewModels/ProjectSessionsViewModel.swift new file mode 100644 index 0000000..8d6a56c --- /dev/null +++ b/scarf/scarf/Features/Projects/ViewModels/ProjectSessionsViewModel.swift @@ -0,0 +1,76 @@ +import Foundation +import os + +/// Drives the per-project Sessions tab introduced in v2.3. Pulls the +/// global session list from `HermesDataService`, filters by the +/// attribution sidecar, and exposes a minimal surface for the view: +/// the filtered sessions array, loading state, and a refresh entry +/// point that the view can call on appearance + on file-watcher +/// change. +@Observable +@MainActor +final class ProjectSessionsViewModel { + private static let logger = Logger(subsystem: "com.scarf", category: "ProjectSessionsViewModel") + + private let dataService: HermesDataService + private let attribution: SessionAttributionService + private let project: ProjectEntry + + init(context: ServerContext, project: ProjectEntry) { + self.dataService = HermesDataService(context: context) + self.attribution = SessionAttributionService(context: context) + self.project = project + } + + /// Sessions attributed to the owning project, in the order + /// `HermesDataService.fetchSessions` returns them (newest first). + var sessions: [HermesSession] = [] + + /// True from `load()` start to its completion. The view renders + /// a ProgressView during the first fetch; afterwards, re-fetches + /// triggered by file-watcher changes happen silently. + var isLoading: Bool = false + + /// Short diagnostic string for an empty list — nil when sessions + /// are loaded and populated, otherwise explains the empty state + /// (no sessions ever created in this project, vs. no sessions + /// matched the project's attribution map). + var emptyStateHint: String? + + /// Refresh the session list. Safe to call repeatedly; the data + /// service reconnects to state.db on demand and the attribution + /// service reads the sidecar afresh each call. + func load() async { + isLoading = true + defer { isLoading = false } + + let attributed = attribution.sessionIDs(forProject: project.path) + if attributed.isEmpty { + sessions = [] + emptyStateHint = "No chats have been started in this project yet. Click New Chat to begin." + return + } + + // Fetch a generous page; we filter client-side by attribution + // map membership. The 200 ceiling matches other feature VMs + // (ActivityViewModel, InsightsViewModel). HermesDataService + // is an actor so this crosses the isolation boundary — the + // SQLite read happens off the MainActor. If a single project + // accumulates more than 200 attributed sessions, we'll need + // a paged query; roadmap item, not a v2.3 problem. + let all = await dataService.fetchSessions(limit: 200) + let filtered = all.filter { attributed.contains($0.id) } + sessions = filtered + + if filtered.isEmpty { + // Attribution map has entries but none appear in the + // recent session fetch — likely stale sidecar entries + // for sessions Hermes has since deleted. The view shows + // an informational empty state; pruning stale entries + // is a roadmap follow-up, not a blocker. + emptyStateHint = "This project has \(attributed.count) attributed session\(attributed.count == 1 ? "" : "s"), but none are in the recent history. They may have been deleted from Hermes." + } else { + emptyStateHint = nil + } + } +} diff --git a/scarf/scarf/Features/Projects/Views/ProjectSessionsView.swift b/scarf/scarf/Features/Projects/Views/ProjectSessionsView.swift new file mode 100644 index 0000000..3bb02ec --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/ProjectSessionsView.swift @@ -0,0 +1,181 @@ +import SwiftUI + +/// Per-project Sessions tab (v2.3). Lives beside the Dashboard and +/// Site tabs in the project view; populated from the session +/// attribution sidecar maintained by ChatViewModel. A "New Chat" +/// button spawns a fresh ACP session at cwd = project.path and +/// routes the user into the Chat feature via AppCoordinator. +struct ProjectSessionsView: View { + let project: ProjectEntry + + @Environment(AppCoordinator.self) private var coordinator + @Environment(HermesFileWatcher.self) private var fileWatcher + @Environment(\.serverContext) private var serverContext + + @State private var viewModel: ProjectSessionsViewModel? + + var body: some View { + VStack(spacing: 0) { + header + Divider() + content + } + .task(id: project.id) { + // Rebuild the VM when the project changes so stale state + // from a previously-selected project doesn't bleed + // through. + viewModel = ProjectSessionsViewModel( + context: serverContext, + project: project + ) + await viewModel?.load() + } + .onChange(of: fileWatcher.lastChangeDate) { + Task { await viewModel?.load() } + } + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Sessions in this project") + .font(.headline) + Text("Chats you start here get attributed automatically. Older CLI-started sessions live in the global Sessions sidebar.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + Button { + // Route into the Chat feature with a cwd override. + // ChatView observes this via its onChange and starts + // a fresh session with projectPath = our project. + coordinator.pendingProjectChat = project.path + coordinator.selectedSection = .chat + } label: { + Label("New Chat", systemImage: "message.badge.filled.fill") + } + .buttonStyle(.borderedProminent) + } + .padding() + } + + // MARK: - Content + + @ViewBuilder + private var content: some View { + if let vm = viewModel { + if vm.isLoading && vm.sessions.isEmpty { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if vm.sessions.isEmpty { + emptyState(hint: vm.emptyStateHint) + } else { + sessionList(vm.sessions) + } + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func emptyState(hint: String?) -> some View { + VStack(spacing: 10) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 36)) + .foregroundStyle(.tertiary) + Text(hint ?? "No sessions yet.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func sessionList(_ sessions: [HermesSession]) -> some View { + List(sessions) { session in + ProjectSessionRow(session: session) + .contentShape(Rectangle()) + .onTapGesture { + // Route into the Chat feature with this session + // as a resume target. Existing ChatView logic + // handles ACP reconnect. + coordinator.selectedSessionId = session.id + coordinator.selectedSection = .chat + } + } + .listStyle(.plain) + } +} + +/// Single row in the per-project Sessions list. Intentionally small +/// and self-contained so it can evolve independently of the global +/// Sessions sidebar's row UI — if the two visualisations diverge +/// (e.g. the project tab wants to hide the `source` badge that's +/// useful in the global list), they don't pull each other along. +private struct ProjectSessionRow: View { + let session: HermesSession + + var body: some View { + HStack(spacing: 10) { + Image(systemName: iconForSource(session.source)) + .foregroundStyle(.secondary) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(displayTitle) + .font(.callout) + .lineLimit(1) + HStack(spacing: 6) { + Text(session.id.prefix(12)) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + if let started = formattedStart { + Text("·") + .foregroundStyle(.tertiary) + Text(started) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + Spacer(minLength: 12) + VStack(alignment: .trailing, spacing: 2) { + Text("\(session.messageCount)") + .font(.caption.monospaced()) + Text("msgs") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + private var displayTitle: String { + if let t = session.title, !t.isEmpty { return t } + return "Untitled session" + } + + private var formattedStart: String? { + // `startedAt` is `Date?` — the DB column can be null for + // sessions in unusual states. Locale-aware short form keeps + // us consistent with Insights + Activity. + guard let date = session.startedAt else { return nil } + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter.string(from: date) + } + + private func iconForSource(_ source: String) -> String { + switch source.lowercased() { + case "cli", "acp": return "terminal" + case "telegram": return "paperplane" + case "discord": return "bubble.left.and.bubble.right" + default: return "message" + } + } +} diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index 7c5292c..37325e6 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -4,11 +4,21 @@ import UniformTypeIdentifiers private enum DashboardTab: String, CaseIterable { case dashboard = "Dashboard" case site = "Site" + case sessions = "Sessions" var displayName: LocalizedStringResource { switch self { case .dashboard: return "Dashboard" case .site: return "Site" + case .sessions: return "Sessions" + } + } + + var systemImage: String { + switch self { + case .dashboard: return "square.grid.2x2" + case .site: return "globe" + case .sessions: return "bubble.left.and.bubble.right" } } } @@ -333,11 +343,13 @@ struct ProjectsView: View { .padding(.horizontal) .padding(.top) .padding(.bottom, 8) - if siteWidget != nil { - tabBar - .padding(.horizontal) - .padding(.bottom, 8) - } + // Sessions tab is always present in v2.3, so the tab + // bar always renders when a dashboard is loaded. + // Site tab filters out when there's no webview widget + // (existing v2.2 behavior preserved). + tabBar + .padding(.horizontal) + .padding(.bottom, 8) switch selectedTab { case .dashboard: widgetsTab(dashboard) @@ -347,6 +359,12 @@ struct ProjectsView: View { } else { widgetsTab(dashboard) } + case .sessions: + if let project = viewModel.selectedProject { + ProjectSessionsView(project: project) + } else { + ContentUnavailableView("No project selected", systemImage: "bubble.left.and.bubble.right") + } } } } else if let error = viewModel.dashboardError { @@ -372,14 +390,23 @@ struct ProjectsView: View { } } + /// Tabs that should appear for the current project. `.site` is + /// gated on the dashboard actually containing a webview widget, + /// per v2.2 behavior — the Site tab is meaningless without one. + private var visibleTabs: [DashboardTab] { + DashboardTab.allCases.filter { tab in + tab != .site || siteWidget != nil + } + } + private var tabBar: some View { HStack(spacing: 0) { - ForEach(DashboardTab.allCases, id: \.self) { tab in + ForEach(visibleTabs, id: \.self) { tab in Button { selectedTab = tab } label: { HStack(spacing: 4) { - Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe") + Image(systemName: tab.systemImage) .font(.caption) Text(tab.displayName) .font(.subheadline) diff --git a/scarf/scarf/Navigation/AppCoordinator.swift b/scarf/scarf/Navigation/AppCoordinator.swift index 1259802..d553b56 100644 --- a/scarf/scarf/Navigation/AppCoordinator.swift +++ b/scarf/scarf/Navigation/AppCoordinator.swift @@ -91,4 +91,15 @@ final class AppCoordinator { var selectedSection: SidebarSection = .dashboard var selectedSessionId: String? var selectedProjectName: String? + + /// When non-nil, ChatView should start a fresh ACP session with + /// this absolute project path as cwd and then clear the value. + /// Wired from the per-project Sessions tab's "New Chat" button + /// (v2.3): the tab sets this, switches `selectedSection` to + /// `.chat`, and ChatView reacts on its next render. + /// + /// Separate from `selectedSessionId` (which resumes an existing + /// session) — a new session needs a cwd override Scarf doesn't + /// yet have an id for. + var pendingProjectChat: String? } diff --git a/scarf/scarfTests/SessionAttributionServiceTests.swift b/scarf/scarfTests/SessionAttributionServiceTests.swift new file mode 100644 index 0000000..dcb8bfa --- /dev/null +++ b/scarf/scarfTests/SessionAttributionServiceTests.swift @@ -0,0 +1,153 @@ +import Testing +import Foundation +@testable import scarf + +/// Exercises the v2.3 sidecar at `~/.hermes/scarf/session_project_map.json` +/// via the real `ServerContext.local`. Each test snapshots + restores +/// the file through `TestRegistryLock` (reused — the sidecar lives +/// in the same scarf/ dir as projects.json, so serialising on one +/// lock prevents both cross-suite races). +/// +/// We scope the shared lock to this file's registry helper so tests +/// here don't step on the real registry either. +@Suite(.serialized) struct SessionAttributionServiceTests { + + @Test func loadOnMissingFileReturnsEmptyMap() throws { + let snapshot = Self.snapshot() + defer { Self.restore(snapshot) } + Self.deleteSidecar() + + let svc = SessionAttributionService(context: .local) + let map = svc.load() + #expect(map.mappings.isEmpty) + #expect(svc.projectPath(for: "anything") == nil) + #expect(svc.sessionIDs(forProject: "/anything").isEmpty) + } + + @Test func attributeWritesMappingAndPersists() throws { + let snapshot = Self.snapshot() + defer { Self.restore(snapshot) } + Self.deleteSidecar() + + let svc = SessionAttributionService(context: .local) + svc.attribute(sessionID: "sess-1", toProjectPath: "/proj/a") + + // Read back via a fresh service instance — confirms the + // write actually landed on disk, not just the in-memory map. + let fresh = SessionAttributionService(context: .local) + #expect(fresh.projectPath(for: "sess-1") == "/proj/a") + + // updatedAt populated on write. + let map = fresh.load() + let ts = try #require(map.updatedAt) + #expect(!ts.isEmpty) + } + + @Test func attributeIsIdempotent() throws { + let snapshot = Self.snapshot() + defer { Self.restore(snapshot) } + Self.deleteSidecar() + + let svc = SessionAttributionService(context: .local) + svc.attribute(sessionID: "s", toProjectPath: "/p") + let firstStamp = svc.load().updatedAt + // Call again with the same pair — should short-circuit, NOT + // bump updatedAt. We check that the timestamp didn't change + // even if the file would have been rewritten. + svc.attribute(sessionID: "s", toProjectPath: "/p") + let secondStamp = svc.load().updatedAt + #expect(firstStamp == secondStamp) + } + + @Test func reattributeChangesMapping() throws { + let snapshot = Self.snapshot() + defer { Self.restore(snapshot) } + Self.deleteSidecar() + + let svc = SessionAttributionService(context: .local) + svc.attribute(sessionID: "s", toProjectPath: "/a") + svc.attribute(sessionID: "s", toProjectPath: "/b") + #expect(svc.projectPath(for: "s") == "/b") + #expect(svc.sessionIDs(forProject: "/a").isEmpty) + #expect(svc.sessionIDs(forProject: "/b") == ["s"]) + } + + @Test func reverseLookupReturnsAllAttributedSessions() throws { + let snapshot = Self.snapshot() + defer { Self.restore(snapshot) } + Self.deleteSidecar() + + let svc = SessionAttributionService(context: .local) + svc.attribute(sessionID: "s1", toProjectPath: "/proj") + svc.attribute(sessionID: "s2", toProjectPath: "/proj") + svc.attribute(sessionID: "s3", toProjectPath: "/other") + + #expect(svc.sessionIDs(forProject: "/proj") == ["s1", "s2"]) + #expect(svc.sessionIDs(forProject: "/other") == ["s3"]) + #expect(svc.sessionIDs(forProject: "/nobody").isEmpty) + } + + @Test func forgetRemovesMapping() throws { + let snapshot = Self.snapshot() + defer { Self.restore(snapshot) } + Self.deleteSidecar() + + let svc = SessionAttributionService(context: .local) + svc.attribute(sessionID: "s", toProjectPath: "/p") + #expect(svc.projectPath(for: "s") == "/p") + + svc.forget(sessionID: "s") + #expect(svc.projectPath(for: "s") == nil) + // Forget on a missing session is a no-op, not an error. + svc.forget(sessionID: "s") + #expect(svc.projectPath(for: "s") == nil) + } + + @Test func corruptedFileReturnsEmptyMap() throws { + let snapshot = Self.snapshot() + defer { Self.restore(snapshot) } + // Write garbage to the sidecar path and confirm the service + // treats it as "no attributions" rather than crashing. Users + // hand-editing the JSON shouldn't soft-brick the Sessions tab. + let path = ServerContext.local.paths.sessionProjectMap + try FileManager.default.createDirectory( + atPath: (path as NSString).deletingLastPathComponent, + withIntermediateDirectories: true + ) + try "not json at all".data(using: .utf8)!.write(to: URL(fileURLWithPath: path)) + + let svc = SessionAttributionService(context: .local) + let map = svc.load() + #expect(map.mappings.isEmpty) + } + + // MARK: - Helpers + + /// Snapshot + restore the sidecar file (and delete if missing). + /// Uses the shared TestRegistryLock so this suite serialises + /// with any other registry-writing suite — both touch scarfDir. + static func snapshot() -> (lockToken: Any, data: Data?) { + // Re-use the ProjectTemplateTests lock implementation — + // same NSLock gates all scarfDir writes across suites. + let projectSnapshot = TestRegistryLock.acquireAndSnapshot() + let path = ServerContext.local.paths.sessionProjectMap + let sidecarData = try? Data(contentsOf: URL(fileURLWithPath: path)) + return (lockToken: projectSnapshot as Any, data: sidecarData) + } + + static func restore(_ snapshot: (lockToken: Any, data: Data?)) { + let path = ServerContext.local.paths.sessionProjectMap + if let data = snapshot.data { + try? data.write(to: URL(fileURLWithPath: path)) + } else { + try? FileManager.default.removeItem(atPath: path) + } + // Release the shared lock via the existing helper. + TestRegistryLock.restore(snapshot.lockToken as? Data) + } + + static func deleteSidecar() { + let path = ServerContext.local.paths.sessionProjectMap + try? FileManager.default.removeItem(atPath: path) + } +}