feat(projects): per-project Sessions tab + sidecar attribution

Third and final v2.3 commit. Adds the Sessions tab alongside
Dashboard and Site, and introduces the attribution sidecar that
makes per-project session filtering possible without any upstream
Hermes change.

## Sidecar

Hermes's state.db has no cwd column on sessions — the cwd passed
to `hermes acp` at session create is ephemeral from its side.
Scarf now records session_id → project_path in
~/.hermes/scarf/session_project_map.json, owned end-to-end by
Scarf. Written atomically on session creation; read by the per-
project Sessions tab. Missing file = empty map; corrupt file =
empty map (logged warning, no crash). Forward-only attribution:
only sessions Scarf starts with a project context get mapped; CLI-
started sessions still surface in the global Sessions sidebar
unchanged.

New pieces:
- Core/Models/SessionProjectMap.swift — Codable sidecar shape
  (mappings dict + updatedAt timestamp).
- Core/Services/SessionAttributionService.swift — load /
  attribute / forget / reverse-lookup, all idempotent, all going
  through atomic write.
- HermesPathSet.sessionProjectMap — canonical path resolution.

## Chat plumbing

ChatViewModel.startNewSession and the private startACPSession gain
an optional projectPath parameter. When non-nil it overrides the
default cwd = context.resolvedUserHome() and, on successful session
creation, SessionAttributionService.attribute is called.
Default-nil call sites keep v2.2 behavior exactly — terminal-mode
chats and the global "New Chat" button are unaffected.

## Coordinator handoff

AppCoordinator gains pendingProjectChat: String?. The per-project
Sessions tab sets it + switches selectedSection = .chat. ChatView
observes it (.task cold-launch + .onChange live), consumes the
path by calling startNewSession(projectPath:), and clears the
field. Clean separation: the Projects feature never reaches into
ChatViewModel directly.

## UI

- New DashboardTab.sessions case in ProjectsView. Tab bar now
  always renders when a dashboard is loaded (was gated on
  siteWidget before); .site still filters out when there's no
  webview widget.
- ProjectSessionsView — per-project session list with a "New Chat"
  button. Empty-state hint distinguishes "no attributions yet" from
  "stale sidecar entries". Reuses HermesDataService.fetchSessions
  and filters by the attribution map.
- ProjectSessionRow — local row view independent of the global
  sessions sidebar so the two can evolve separately.

## Tests

SessionAttributionServiceTests (7 tests):
- Missing file → empty map
- attribute writes + persists via fresh service instance
- attribute is idempotent (same pair twice doesn't bump timestamp)
- re-attribute changes mapping (session moves between projects)
- reverse lookup returns all + distinguishes by project
- forget removes mapping, is idempotent on missing sessions
- Corrupted JSON → empty map, no crash

80/80 Swift tests pass (was 73; 7 new). 24/24 Python tests still
pass. Both prep + feature commits stand independently; commit 3
depends on commit 1 (folder/archive fields) and commit 2 (sidebar
UI) only for the full flow to work end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-23 23:14:33 +02:00
parent 585d035fe8
commit 799cdb19e1
10 changed files with 679 additions and 11 deletions
@@ -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
@@ -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())
}
}
@@ -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<String> {
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)")
}
}
}
@@ -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()
@@ -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
@@ -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
}
}
}
@@ -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"
}
}
}
@@ -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)
@@ -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?
}
@@ -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)
}
}