mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
iOS port M0c: extract portable Services to ScarfCore
Third of four M0 sub-PRs. Moves the four Services that have no dependency
on Mac-target code or AppKit into ScarfCore, so the Mac + (future) iOS
targets can share them.
Files moved (4):
scarf/Core/Services/HermesDataService.swift (658 lines, SQLite reader + SnapshotCoordinator actor)
scarf/Core/Services/HermesLogService.swift (log tail + parse, LogEntry + LogLevel)
scarf/Core/Services/ModelCatalogService.swift (models.dev JSON reader, HermesModelInfo + HermesProviderInfo)
scarf/Core/Services/ProjectDashboardService.swift (per-project dashboard I/O)
Not moved, with reason:
HermesFileService.swift — carries the big shell-enrichment logic; a
later phase can port once iOS has a clearer env story for ACP spawns.
HermesEnvService.swift — depends on HermesFileService.
HermesFileWatcher.swift — depends on HermesFileService.
ACPClient.swift — M1's job (the ACPChannel refactor).
UpdaterService.swift — wraps Sparkle, stays Mac-only forever.
Platform guards:
HermesDataService.swift is wrapped in `#if canImport(SQLite3) ... #endif`
for the whole file. SQLite3 isn't a system module on Linux
swift-corelibs-foundation. Apple platforms compile unchanged. Linux
builds skip the file entirely; nothing in ScarfCore references
HermesDataService from outside the file, so there's no downstream
fallout.
ModelCatalogService `import os` / Logger definition / call site all
guarded with `#if canImport(os)`. Linux gets silent logging.
HermesLogService + ProjectDashboardService use only Foundation —
no guards needed.
Other fixes:
- Features/Settings/Views/Components/ModelPickerSheet.swift (the one
remaining consumer) gains `import ScarfCore`.
- Self-referential `import ScarfCore` stripped from each moved file.
Test coverage: 8 new tests in ScarfCoreTests/M0cServicesTests.swift:
- HermesLogService.parseLine exercised via readLastLines on a real
tmp file with three formats — v0.9.0+ with session tag, older
without, and garbage fallback. Pins CLAUDE.md's optional-session-tag
invariant.
- LogLevel SwiftUI colour strings pinned.
- HermesModelInfo.contextDisplay across 1M / 200K / 500 / nil cases;
costDisplay with and without costs.
- ModelCatalogService load path end-to-end against a synthetic
models_dev_cache.json lookalike — providers sorted, models
filtered, provider(for:) resolves both full-scan and slash-prefixed
IDs.
- Malformed + missing catalog files return empty, no crash.
- ProjectDashboardService round-trips ProjectRegistry + reads a
synthetic .scarf/dashboard.json.
Running `docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work
swift:6.0 swift test` now reports 42 / 42 passing (M0a 16 + M0b 18 +
M0c 8).
Updated scarf/docs/IOS_PORT_PLAN.md progress log with the shipped M0c
state and the SQLite3-gating pattern future phases should reuse.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
@@ -0,0 +1,672 @@
|
||||
// MARK: - Platform gate
|
||||
//
|
||||
// `SQLite3` is a system module on macOS/iOS but not on Linux
|
||||
// swift-corelibs-foundation. Everything below depends on it heavily, so the
|
||||
// whole file is gated on `canImport(SQLite3)`. On Linux the types
|
||||
// (`HermesDataService`, `SnapshotCoordinator`, and helpers) simply don't
|
||||
// exist — nothing in ScarfCore compiled for Linux references them, so
|
||||
// there's no downstream breakage. Apple platforms — the only real runtime
|
||||
// targets — get the full implementation unchanged.
|
||||
#if canImport(SQLite3)
|
||||
|
||||
import Foundation
|
||||
import SQLite3
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Dedupes concurrent `snapshotSQLite` calls for the same server. When the
|
||||
/// file watcher ticks, Dashboard + Sessions + Activity (+ Chat's loadHistory)
|
||||
/// can all ask for a fresh snapshot within the same millisecond — without
|
||||
/// coordination they each spawn their own `ssh host sqlite3 .backup; scp`
|
||||
/// round-trip, three parallel backups of the same DB. Callers in flight for
|
||||
/// the same `ServerID` await the first caller's Task and share its result.
|
||||
public actor SnapshotCoordinator {
|
||||
public static let shared = SnapshotCoordinator()
|
||||
private var inFlight: [ServerID: Task<URL, Error>] = [:]
|
||||
|
||||
public func snapshot(
|
||||
remotePath: String,
|
||||
contextID: ServerID,
|
||||
transport: any ServerTransport
|
||||
) async throws -> URL {
|
||||
if let existing = inFlight[contextID] {
|
||||
return try await existing.value
|
||||
}
|
||||
let task = Task<URL, Error> {
|
||||
try transport.snapshotSQLite(remotePath: remotePath)
|
||||
}
|
||||
inFlight[contextID] = task
|
||||
defer { inFlight[contextID] = nil }
|
||||
return try await task.value
|
||||
}
|
||||
}
|
||||
|
||||
public actor HermesDataService {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "HermesDataService")
|
||||
|
||||
private var db: OpaquePointer?
|
||||
private var hasV07Schema = false
|
||||
/// Local filesystem path we last opened. For remote contexts this is
|
||||
/// the cached snapshot under `~/Library/Caches/scarf/snapshots/<id>/`.
|
||||
private var openedAtPath: String?
|
||||
/// Last error from `open()` / `refresh()`, user-presentable. `nil` means
|
||||
/// the last attempt succeeded. Views surface this when their own load
|
||||
/// path fails, so the user sees "Permission denied reading state.db"
|
||||
/// instead of an empty Dashboard with no explanation.
|
||||
private(set) var lastOpenError: String?
|
||||
|
||||
public let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
public init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
public func open() async -> Bool {
|
||||
if db != nil { return true }
|
||||
let localPath: String
|
||||
if context.isRemote {
|
||||
// Pull a fresh snapshot from the remote host. Uses `sqlite3
|
||||
// .backup` on the remote, which is WAL-safe; a plain cp would
|
||||
// corrupt. Routed through SnapshotCoordinator so concurrent
|
||||
// view models don't each spawn a parallel SSH backup for the
|
||||
// same server.
|
||||
do {
|
||||
let url = try await SnapshotCoordinator.shared.snapshot(
|
||||
remotePath: context.paths.stateDB,
|
||||
contextID: context.id,
|
||||
transport: transport
|
||||
)
|
||||
localPath = url.path
|
||||
lastOpenError = nil
|
||||
} catch {
|
||||
lastOpenError = humanize(error)
|
||||
Self.logger.warning("snapshotSQLite failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
localPath = context.paths.stateDB
|
||||
guard FileManager.default.fileExists(atPath: localPath) else {
|
||||
lastOpenError = "Hermes state database not found at \(localPath)."
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Remote snapshots are point-in-time copies that no one writes to;
|
||||
// opening them with `immutable=1` tells SQLite to skip WAL/SHM and
|
||||
// locking entirely, which is both faster and avoids spurious
|
||||
// "unable to open database file" errors if the snapshot ever gets
|
||||
// pulled mid-checkpoint. Local points at the live Hermes DB where
|
||||
// the process already has WAL enabled in the header, so a plain
|
||||
// readonly open is the right thing.
|
||||
let flags: Int32
|
||||
let openPath: String
|
||||
if context.isRemote {
|
||||
openPath = "file:\(localPath)?immutable=1"
|
||||
flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_URI
|
||||
} else {
|
||||
openPath = localPath
|
||||
flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
|
||||
}
|
||||
let result = sqlite3_open_v2(openPath, &db, flags, nil)
|
||||
guard result == SQLITE_OK else {
|
||||
let msg: String
|
||||
if let db {
|
||||
msg = String(cString: sqlite3_errmsg(db))
|
||||
} else {
|
||||
msg = "sqlite3_open_v2 returned \(result)"
|
||||
}
|
||||
lastOpenError = "Couldn't open state.db: \(msg)"
|
||||
Self.logger.warning("sqlite3_open_v2 failed (\(result)) at \(localPath, privacy: .public): \(msg, privacy: .public)")
|
||||
db = nil
|
||||
return false
|
||||
}
|
||||
openedAtPath = localPath
|
||||
lastOpenError = nil
|
||||
detectSchema()
|
||||
return true
|
||||
}
|
||||
|
||||
/// Turn a transport error into the one-line string Dashboard shows. Adds
|
||||
/// hints for the common "sqlite3 not installed" and "permission denied"
|
||||
/// cases so users know what to do.
|
||||
private nonisolated func humanize(_ error: Error) -> String {
|
||||
let desc = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
|
||||
let lower = desc.lowercased()
|
||||
if lower.contains("sqlite3: command not found") || lower.contains("sqlite3: not found") {
|
||||
return "sqlite3 is not installed on \(context.displayName). Install it with `apt install sqlite3` (Ubuntu/Debian) or `yum install sqlite` (RHEL/Fedora)."
|
||||
}
|
||||
if lower.contains("permission denied") {
|
||||
return "Permission denied reading Hermes state on \(context.displayName). The SSH user may not have read access to ~/.hermes/state.db — try Run Diagnostics."
|
||||
}
|
||||
if lower.contains("no such file") {
|
||||
return "Hermes state not found at ~/.hermes on \(context.displayName). If Hermes is installed elsewhere, set its data directory in Manage Servers."
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
/// Force a fresh snapshot pull + reopen. Used on session-load and in
|
||||
/// any path that needs the UI to reflect writes Hermes just made.
|
||||
/// Without this, remote snapshots would be frozen at the first `open()`
|
||||
/// for the app's lifetime — new messages added to a resumed session
|
||||
/// would never appear because the snapshot was pulled before they were
|
||||
/// written. Local contexts pay essentially nothing: close+reopen on a
|
||||
/// live DB is a no-op.
|
||||
@discardableResult
|
||||
public func refresh() async -> Bool {
|
||||
close()
|
||||
return await open()
|
||||
}
|
||||
|
||||
public func close() {
|
||||
if let db {
|
||||
sqlite3_close(db)
|
||||
}
|
||||
db = nil
|
||||
}
|
||||
|
||||
// MARK: - Schema Detection
|
||||
|
||||
private func detectSchema() {
|
||||
guard let db else { return }
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, "PRAGMA table_info(sessions)", -1, &stmt, nil) == SQLITE_OK else { return }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
if let name = sqlite3_column_text(stmt, 1), String(cString: name) == "reasoning_tokens" {
|
||||
hasV07Schema = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session Queries
|
||||
|
||||
private var sessionColumns: String {
|
||||
var cols = """
|
||||
id, source, user_id, model, title, parent_session_id,
|
||||
started_at, ended_at, end_reason, message_count, tool_call_count,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||
estimated_cost_usd
|
||||
"""
|
||||
if hasV07Schema {
|
||||
cols += ", reasoning_tokens, actual_cost_usd, cost_status, billing_provider"
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
public func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT ?"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_int(stmt, 1, Int32(limit))
|
||||
|
||||
var sessions: [HermesSession] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
sessions.append(sessionFromRow(stmt!))
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
public func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? ORDER BY started_at DESC"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||
|
||||
var sessions: [HermesSession] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
sessions.append(sessionFromRow(stmt!))
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
public func fetchSubagentSessions(parentId: String) -> [HermesSession] {
|
||||
guard let db else { return [] }
|
||||
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id = ? ORDER BY started_at ASC"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, parentId, -1, sqliteTransient)
|
||||
|
||||
var sessions: [HermesSession] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
sessions.append(sessionFromRow(stmt!))
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
// MARK: - Message Queries
|
||||
|
||||
private var messageColumns: String {
|
||||
var cols = """
|
||||
id, session_id, role, content, tool_call_id, tool_calls,
|
||||
tool_name, timestamp, token_count, finish_reason
|
||||
"""
|
||||
if hasV07Schema {
|
||||
cols += ", reasoning"
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
public func fetchMessages(sessionId: String) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
messages.append(messageFromRow(stmt!))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
public func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sanitized = sanitizeFTSQuery(query)
|
||||
guard !sanitized.isEmpty else { return [] }
|
||||
let msgCols = hasV07Schema
|
||||
? "m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, m.tool_name, m.timestamp, m.token_count, m.finish_reason, m.reasoning"
|
||||
: "m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, m.tool_name, m.timestamp, m.token_count, m.finish_reason"
|
||||
let sql = """
|
||||
SELECT \(msgCols)
|
||||
FROM messages_fts fts
|
||||
JOIN messages m ON m.id = fts.rowid
|
||||
WHERE messages_fts MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, sanitized, -1, sqliteTransient)
|
||||
sqlite3_bind_int(stmt, 2, Int32(limit))
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
messages.append(messageFromRow(stmt!))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
public func fetchToolResult(callId: String) -> String? {
|
||||
guard let db else { return nil }
|
||||
let sql = "SELECT content FROM messages WHERE role = 'tool' AND tool_call_id = ? LIMIT 1"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, callId, -1, sqliteTransient)
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
|
||||
return columnText(stmt!, 0)
|
||||
}
|
||||
|
||||
public func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT \(messageColumns)
|
||||
FROM messages
|
||||
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_int(stmt, 1, Int32(limit))
|
||||
|
||||
var messages: [HermesMessage] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
messages.append(messageFromRow(stmt!))
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
public func fetchSessionPreviews(limit: Int = QueryDefaults.sessionPreviewLimit) -> [String: String] {
|
||||
guard let db else { return [:] }
|
||||
let sql = """
|
||||
SELECT m.session_id, substr(m.content, 1, \(QueryDefaults.previewContentLength))
|
||||
FROM messages m
|
||||
INNER JOIN (
|
||||
SELECT session_id, MIN(id) as min_id
|
||||
FROM messages
|
||||
WHERE role = 'user' AND content <> ''
|
||||
GROUP BY session_id
|
||||
) first ON m.id = first.min_id
|
||||
ORDER BY m.timestamp DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_int(stmt, 1, Int32(limit))
|
||||
|
||||
var previews: [String: String] = [:]
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
let sessionId = columnText(stmt!, 0)
|
||||
let preview = columnText(stmt!, 1)
|
||||
previews[sessionId] = preview
|
||||
}
|
||||
return previews
|
||||
}
|
||||
|
||||
// MARK: - Single-Row Queries
|
||||
|
||||
public struct MessageFingerprint: Equatable, Sendable {
|
||||
let count: Int
|
||||
let maxId: Int
|
||||
let maxTimestamp: Double
|
||||
|
||||
static let empty = MessageFingerprint(count: 0, maxId: 0, maxTimestamp: 0)
|
||||
}
|
||||
|
||||
public func fetchMessageFingerprint(sessionId: String) -> MessageFingerprint {
|
||||
guard let db else { return .empty }
|
||||
let sql = "SELECT COUNT(*), COALESCE(MAX(id), 0), COALESCE(MAX(timestamp), 0) FROM messages WHERE session_id = ?"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
|
||||
return MessageFingerprint(
|
||||
count: Int(sqlite3_column_int(stmt, 0)),
|
||||
maxId: Int(sqlite3_column_int(stmt, 1)),
|
||||
maxTimestamp: sqlite3_column_double(stmt, 2)
|
||||
)
|
||||
}
|
||||
|
||||
public func fetchMessageCount(sessionId: String) -> Int {
|
||||
guard let db else { return 0 }
|
||||
let sql = "SELECT COUNT(*) FROM messages WHERE session_id = ?"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return 0 }
|
||||
return Int(sqlite3_column_int(stmt, 0))
|
||||
}
|
||||
|
||||
public func fetchSession(id: String) -> HermesSession? {
|
||||
guard let db else { return nil }
|
||||
let sql = "SELECT \(sessionColumns) FROM sessions WHERE id = ? LIMIT 1"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_text(stmt, 1, id, -1, sqliteTransient)
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
|
||||
return sessionFromRow(stmt!)
|
||||
}
|
||||
|
||||
public func fetchMostRecentlyActiveSessionId() -> String? {
|
||||
guard let db else { return nil }
|
||||
let sql = "SELECT session_id FROM messages ORDER BY timestamp DESC LIMIT 1"
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
|
||||
return columnText(stmt!, 0)
|
||||
}
|
||||
|
||||
public func fetchMostRecentlyStartedSessionId(after: Date? = nil) -> String? {
|
||||
guard let db else { return nil }
|
||||
let sql: String
|
||||
if after != nil {
|
||||
sql = "SELECT id FROM sessions WHERE parent_session_id IS NULL AND started_at > ? ORDER BY started_at DESC LIMIT 1"
|
||||
} else {
|
||||
sql = "SELECT id FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT 1"
|
||||
}
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
if let after {
|
||||
sqlite3_bind_double(stmt, 1, after.timeIntervalSince1970)
|
||||
}
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
|
||||
return columnText(stmt!, 0)
|
||||
}
|
||||
|
||||
// MARK: - Stats
|
||||
|
||||
public struct SessionStats: Sendable {
|
||||
let totalSessions: Int
|
||||
let totalMessages: Int
|
||||
let totalToolCalls: Int
|
||||
let totalInputTokens: Int
|
||||
let totalOutputTokens: Int
|
||||
let totalCostUSD: Double
|
||||
let totalReasoningTokens: Int
|
||||
let totalActualCostUSD: Double
|
||||
|
||||
static let empty = SessionStats(
|
||||
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
|
||||
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0,
|
||||
totalReasoningTokens: 0, totalActualCostUSD: 0
|
||||
)
|
||||
}
|
||||
|
||||
public func fetchStats() -> SessionStats {
|
||||
guard let db else { return .empty }
|
||||
let sql: String
|
||||
if hasV07Schema {
|
||||
sql = """
|
||||
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||
COALESCE(SUM(estimated_cost_usd),0),
|
||||
COALESCE(SUM(reasoning_tokens),0), COALESCE(SUM(actual_cost_usd),0)
|
||||
FROM sessions
|
||||
"""
|
||||
} else {
|
||||
sql = """
|
||||
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
|
||||
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
|
||||
COALESCE(SUM(estimated_cost_usd),0)
|
||||
FROM sessions
|
||||
"""
|
||||
}
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
|
||||
return SessionStats(
|
||||
totalSessions: Int(sqlite3_column_int(stmt, 0)),
|
||||
totalMessages: Int(sqlite3_column_int(stmt, 1)),
|
||||
totalToolCalls: Int(sqlite3_column_int(stmt, 2)),
|
||||
totalInputTokens: Int(sqlite3_column_int(stmt, 3)),
|
||||
totalOutputTokens: Int(sqlite3_column_int(stmt, 4)),
|
||||
totalCostUSD: sqlite3_column_double(stmt, 5),
|
||||
totalReasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 6)) : 0,
|
||||
totalActualCostUSD: hasV07Schema ? sqlite3_column_double(stmt, 7) : 0
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Insights Queries
|
||||
|
||||
public func fetchUserMessageCount(since: Date) -> Int {
|
||||
guard let db else { return 0 }
|
||||
let sql = """
|
||||
SELECT COUNT(*) FROM messages m
|
||||
JOIN sessions s ON m.session_id = s.id
|
||||
WHERE m.role = 'user' AND s.parent_session_id IS NULL AND s.started_at >= ?
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||
guard sqlite3_step(stmt) == SQLITE_ROW else { return 0 }
|
||||
return Int(sqlite3_column_int(stmt, 0))
|
||||
}
|
||||
|
||||
public func fetchToolUsage(since: Date) -> [(name: String, count: Int)] {
|
||||
guard let db else { return [] }
|
||||
let sql = """
|
||||
SELECT m.tool_name, COUNT(*) as cnt
|
||||
FROM messages m
|
||||
JOIN sessions s ON m.session_id = s.id
|
||||
WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.parent_session_id IS NULL AND s.started_at >= ?
|
||||
GROUP BY m.tool_name
|
||||
ORDER BY cnt DESC
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||
|
||||
var results: [(name: String, count: Int)] = []
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
let name = columnText(stmt!, 0)
|
||||
let count = Int(sqlite3_column_int(stmt!, 1))
|
||||
results.append((name: name, count: count))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
public func fetchSessionStartHours(since: Date) -> [Int: Int] {
|
||||
guard let db else { return [:] }
|
||||
let sql = """
|
||||
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||
|
||||
var hours: [Int: Int] = [:]
|
||||
let calendar = Calendar.current
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
let ts = sqlite3_column_double(stmt!, 0)
|
||||
let date = Date(timeIntervalSince1970: ts)
|
||||
let hour = calendar.component(.hour, from: date)
|
||||
hours[hour, default: 0] += 1
|
||||
}
|
||||
return hours
|
||||
}
|
||||
|
||||
public func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] {
|
||||
guard let db else { return [:] }
|
||||
let sql = """
|
||||
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
|
||||
"""
|
||||
var stmt: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
|
||||
defer { sqlite3_finalize(stmt) }
|
||||
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
|
||||
|
||||
var days: [Int: Int] = [:]
|
||||
let calendar = Calendar.current
|
||||
while sqlite3_step(stmt) == SQLITE_ROW {
|
||||
let ts = sqlite3_column_double(stmt!, 0)
|
||||
let date = Date(timeIntervalSince1970: ts)
|
||||
let weekday = (calendar.component(.weekday, from: date) + 5) % 7 // Mon=0
|
||||
days[weekday, default: 0] += 1
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
public func stateDBModificationDate() -> Date? {
|
||||
// For remote contexts we stat the remote paths. For local it's the
|
||||
// same FileManager lookup as before, just via the transport.
|
||||
let walDate = transport.stat(context.paths.stateDB + "-wal")?.mtime
|
||||
let dbDate = transport.stat(context.paths.stateDB)?.mtime
|
||||
if let w = walDate, let d = dbDate {
|
||||
return max(w, d)
|
||||
}
|
||||
return walDate ?? dbDate
|
||||
}
|
||||
|
||||
// MARK: - Row Parsing
|
||||
|
||||
private func sessionFromRow(_ stmt: OpaquePointer) -> HermesSession {
|
||||
HermesSession(
|
||||
id: columnText(stmt, 0),
|
||||
source: columnText(stmt, 1),
|
||||
userId: columnOptionalText(stmt, 2),
|
||||
model: columnOptionalText(stmt, 3),
|
||||
title: columnOptionalText(stmt, 4),
|
||||
parentSessionId: columnOptionalText(stmt, 5),
|
||||
startedAt: columnDate(stmt, 6),
|
||||
endedAt: columnDate(stmt, 7),
|
||||
endReason: columnOptionalText(stmt, 8),
|
||||
messageCount: Int(sqlite3_column_int(stmt, 9)),
|
||||
toolCallCount: Int(sqlite3_column_int(stmt, 10)),
|
||||
inputTokens: Int(sqlite3_column_int(stmt, 11)),
|
||||
outputTokens: Int(sqlite3_column_int(stmt, 12)),
|
||||
cacheReadTokens: Int(sqlite3_column_int(stmt, 13)),
|
||||
cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)),
|
||||
estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil,
|
||||
reasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 16)) : 0,
|
||||
actualCostUSD: hasV07Schema && sqlite3_column_type(stmt, 17) != SQLITE_NULL ? sqlite3_column_double(stmt, 17) : nil,
|
||||
costStatus: hasV07Schema ? columnOptionalText(stmt, 18) : nil,
|
||||
billingProvider: hasV07Schema ? columnOptionalText(stmt, 19) : nil
|
||||
)
|
||||
}
|
||||
|
||||
private func messageFromRow(_ stmt: OpaquePointer) -> HermesMessage {
|
||||
let toolCallsJSON = columnOptionalText(stmt, 5)
|
||||
let toolCalls = parseToolCalls(toolCallsJSON)
|
||||
return HermesMessage(
|
||||
id: Int(sqlite3_column_int(stmt, 0)),
|
||||
sessionId: columnText(stmt, 1),
|
||||
role: columnText(stmt, 2),
|
||||
content: columnText(stmt, 3),
|
||||
toolCallId: columnOptionalText(stmt, 4),
|
||||
toolCalls: toolCalls,
|
||||
toolName: columnOptionalText(stmt, 6),
|
||||
timestamp: columnDate(stmt, 7),
|
||||
tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil,
|
||||
finishReason: columnOptionalText(stmt, 9),
|
||||
reasoning: hasV07Schema ? columnOptionalText(stmt, 10) : nil
|
||||
)
|
||||
}
|
||||
|
||||
private func parseToolCalls(_ json: String?) -> [HermesToolCall] {
|
||||
guard let json, !json.isEmpty,
|
||||
let data = json.data(using: .utf8) else { return [] }
|
||||
do {
|
||||
return try JSONDecoder().decode([HermesToolCall].self, from: data)
|
||||
} catch {
|
||||
print("[Scarf] Failed to decode tool calls: \(error.localizedDescription)")
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String {
|
||||
if let cStr = sqlite3_column_text(stmt, col) {
|
||||
return String(cString: cStr)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private func columnOptionalText(_ stmt: OpaquePointer, _ col: Int32) -> String? {
|
||||
guard sqlite3_column_type(stmt, col) != SQLITE_NULL,
|
||||
let cStr = sqlite3_column_text(stmt, col) else { return nil }
|
||||
return String(cString: cStr)
|
||||
}
|
||||
|
||||
private func columnDate(_ stmt: OpaquePointer, _ col: Int32) -> Date? {
|
||||
guard sqlite3_column_type(stmt, col) != SQLITE_NULL else { return nil }
|
||||
let value = sqlite3_column_double(stmt, col)
|
||||
return Date(timeIntervalSince1970: value)
|
||||
}
|
||||
|
||||
/// Wraps each whitespace-delimited token in double quotes to prevent FTS5 parse errors
|
||||
/// on terms containing dots, hyphens, or FTS5 operators (e.g., "v0.7.0", "config.yaml").
|
||||
private func sanitizeFTSQuery(_ raw: String) -> String {
|
||||
raw.split(separator: " ")
|
||||
.map { token in
|
||||
let t = String(token)
|
||||
let stripped = t.replacingOccurrences(of: "\"", with: "")
|
||||
return stripped.isEmpty ? nil : "\"\(stripped)\""
|
||||
}
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(SQLite3)
|
||||
@@ -0,0 +1,190 @@
|
||||
import Foundation
|
||||
|
||||
public struct LogEntry: Identifiable, Sendable {
|
||||
public let id: Int
|
||||
public let timestamp: String
|
||||
public let level: LogLevel
|
||||
public let sessionId: String?
|
||||
public let logger: String
|
||||
public let message: String
|
||||
public let raw: String
|
||||
|
||||
|
||||
public init(
|
||||
id: Int,
|
||||
timestamp: String,
|
||||
level: LogLevel,
|
||||
sessionId: String?,
|
||||
logger: String,
|
||||
message: String,
|
||||
raw: String
|
||||
) {
|
||||
self.id = id
|
||||
self.timestamp = timestamp
|
||||
self.level = level
|
||||
self.sessionId = sessionId
|
||||
self.logger = logger
|
||||
self.message = message
|
||||
self.raw = raw
|
||||
}
|
||||
public enum LogLevel: String, Sendable, CaseIterable {
|
||||
case debug = "DEBUG"
|
||||
case info = "INFO"
|
||||
case warning = "WARNING"
|
||||
case error = "ERROR"
|
||||
case critical = "CRITICAL"
|
||||
|
||||
var color: String {
|
||||
switch self {
|
||||
case .debug: return "secondary"
|
||||
case .info: return "primary"
|
||||
case .warning: return "orange"
|
||||
case .error: return "red"
|
||||
case .critical: return "red"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public actor HermesLogService {
|
||||
private var fileHandle: FileHandle?
|
||||
private var currentPath: String?
|
||||
private var entryCounter = 0
|
||||
|
||||
/// Remote tailing state. When set, we're reading from `ssh host tail -F`
|
||||
/// instead of a local file. Process stdout pipe drives `readNewLines()`;
|
||||
/// process lifecycle is the actor's responsibility.
|
||||
private var remoteTailProcess: Process?
|
||||
private var remoteTailBuffer: String = ""
|
||||
|
||||
public let context: ServerContext
|
||||
private let transport: any ServerTransport
|
||||
|
||||
public init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
public func openLog(path: String) {
|
||||
closeLog()
|
||||
currentPath = path
|
||||
if context.isRemote {
|
||||
// Spawn `ssh host tail -F` and pipe stdout into our buffer. `-F`
|
||||
// follows the file through rotations — important for remote
|
||||
// log rotation setups (logrotate).
|
||||
let proc = transport.makeProcess(
|
||||
executable: "/usr/bin/tail",
|
||||
args: ["-n", String(QueryDefaults.logLineLimit), "-F", path]
|
||||
)
|
||||
let outPipe = Pipe()
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = Pipe()
|
||||
do {
|
||||
try proc.run()
|
||||
remoteTailProcess = proc
|
||||
fileHandle = outPipe.fileHandleForReading
|
||||
} catch {
|
||||
print("[Scarf] Failed to start remote tail: \(error.localizedDescription)")
|
||||
remoteTailProcess = nil
|
||||
fileHandle = nil
|
||||
}
|
||||
} else {
|
||||
fileHandle = FileHandle(forReadingAtPath: path)
|
||||
}
|
||||
}
|
||||
|
||||
public func closeLog() {
|
||||
do {
|
||||
try fileHandle?.close()
|
||||
} catch {
|
||||
print("[Scarf] Failed to close log handle: \(error.localizedDescription)")
|
||||
}
|
||||
fileHandle = nil
|
||||
currentPath = nil
|
||||
if let proc = remoteTailProcess, proc.isRunning {
|
||||
proc.terminate()
|
||||
}
|
||||
remoteTailProcess = nil
|
||||
remoteTailBuffer = ""
|
||||
}
|
||||
|
||||
public func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
|
||||
guard let path = currentPath else { return [] }
|
||||
if context.isRemote {
|
||||
// For the initial load we bypass the streaming tail and run a
|
||||
// one-shot `tail -n <count>` for a clean bounded read.
|
||||
let result = try? transport.runProcess(
|
||||
executable: "/usr/bin/tail",
|
||||
args: ["-n", String(count), path],
|
||||
stdin: nil,
|
||||
timeout: 30
|
||||
)
|
||||
let content = result?.stdoutString ?? ""
|
||||
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
return lines.map { parseLine($0) }
|
||||
}
|
||||
guard let data = FileManager.default.contents(atPath: path) else { return [] }
|
||||
let content = String(data: data, encoding: .utf8) ?? ""
|
||||
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
let lastLines = Array(lines.suffix(count))
|
||||
return lastLines.map { parseLine($0) }
|
||||
}
|
||||
|
||||
public func readNewLines() -> [LogEntry] {
|
||||
guard let handle = fileHandle else { return [] }
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty else { return [] }
|
||||
let chunk = String(data: data, encoding: .utf8) ?? ""
|
||||
if context.isRemote {
|
||||
// Remote tail emits bytes as they arrive — not line-aligned.
|
||||
// Buffer partials across reads so we don't split a line mid-way.
|
||||
remoteTailBuffer += chunk
|
||||
guard let lastNewline = remoteTailBuffer.lastIndex(of: "\n") else {
|
||||
return []
|
||||
}
|
||||
let complete = String(remoteTailBuffer[..<lastNewline])
|
||||
remoteTailBuffer = String(remoteTailBuffer[remoteTailBuffer.index(after: lastNewline)...])
|
||||
let lines = complete.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
return lines.map { parseLine($0) }
|
||||
}
|
||||
let lines = chunk.components(separatedBy: "\n").filter { !$0.isEmpty }
|
||||
return lines.map { parseLine($0) }
|
||||
}
|
||||
|
||||
public func seekToEnd() {
|
||||
// Only meaningful for local FileHandles — remote tail starts at the
|
||||
// end implicitly after `readLastLines` drained the initial load.
|
||||
if !context.isRemote {
|
||||
fileHandle?.seekToEndOfFile()
|
||||
}
|
||||
}
|
||||
|
||||
private func parseLine(_ line: String) -> LogEntry {
|
||||
entryCounter += 1
|
||||
// Format (v0.9.0+): YYYY-MM-DD HH:MM:SS,MMM LEVEL [session_id] logger: message
|
||||
// Session tag is optional — earlier Hermes releases and out-of-session lines omit it.
|
||||
let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(?:\[([^\]]+)\]\s+)?(\S+?):\s+(.*)$"#
|
||||
if let regex = try? NSRegularExpression(pattern: pattern),
|
||||
let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) {
|
||||
let timestamp = String(line[Range(match.range(at: 1), in: line)!])
|
||||
let levelStr = String(line[Range(match.range(at: 2), in: line)!])
|
||||
let sessionId: String? = {
|
||||
let range = match.range(at: 3)
|
||||
guard range.location != NSNotFound, let r = Range(range, in: line) else { return nil }
|
||||
return String(line[r])
|
||||
}()
|
||||
let logger = String(line[Range(match.range(at: 4), in: line)!])
|
||||
let message = String(line[Range(match.range(at: 5), in: line)!])
|
||||
return LogEntry(
|
||||
id: entryCounter,
|
||||
timestamp: timestamp,
|
||||
level: LogEntry.LogLevel(rawValue: levelStr) ?? .info,
|
||||
sessionId: sessionId,
|
||||
logger: logger,
|
||||
message: message,
|
||||
raw: line
|
||||
)
|
||||
}
|
||||
return LogEntry(id: entryCounter, timestamp: "", level: .info, sessionId: nil, logger: "", message: line, raw: line)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import Foundation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// A single model from the models.dev catalog shipped with hermes.
|
||||
public struct HermesModelInfo: Sendable, Identifiable, Hashable {
|
||||
public var id: String { providerID + ":" + modelID }
|
||||
|
||||
public let providerID: String
|
||||
public let providerName: String
|
||||
public let modelID: String
|
||||
public let modelName: String
|
||||
public let contextWindow: Int?
|
||||
public let maxOutput: Int?
|
||||
public let costInput: Double? // USD per 1M input tokens
|
||||
public let costOutput: Double? // USD per 1M output tokens
|
||||
public let reasoning: Bool
|
||||
public let toolCall: Bool
|
||||
public let releaseDate: String?
|
||||
|
||||
/// Display-friendly cost string, or nil if cost is unknown.
|
||||
|
||||
public init(
|
||||
providerID: String,
|
||||
providerName: String,
|
||||
modelID: String,
|
||||
modelName: String,
|
||||
contextWindow: Int?,
|
||||
maxOutput: Int?,
|
||||
costInput: Double?,
|
||||
costOutput: Double?,
|
||||
reasoning: Bool,
|
||||
toolCall: Bool,
|
||||
releaseDate: String?
|
||||
) {
|
||||
self.providerID = providerID
|
||||
self.providerName = providerName
|
||||
self.modelID = modelID
|
||||
self.modelName = modelName
|
||||
self.contextWindow = contextWindow
|
||||
self.maxOutput = maxOutput
|
||||
self.costInput = costInput
|
||||
self.costOutput = costOutput
|
||||
self.reasoning = reasoning
|
||||
self.toolCall = toolCall
|
||||
self.releaseDate = releaseDate
|
||||
}
|
||||
public var costDisplay: String? {
|
||||
guard let input = costInput, let output = costOutput else { return nil }
|
||||
let currency = FloatingPointFormatStyle<Double>.Currency.currency(code: "USD").precision(.fractionLength(2))
|
||||
return "\(input.formatted(currency)) / \(output.formatted(currency))"
|
||||
}
|
||||
|
||||
/// Display-friendly context window ("200K", "1M", etc.).
|
||||
public var contextDisplay: String? {
|
||||
guard let ctx = contextWindow else { return nil }
|
||||
if ctx >= 1_000_000 { return "\(ctx / 1_000_000)M" }
|
||||
if ctx >= 1_000 { return "\(ctx / 1_000)K" }
|
||||
return "\(ctx)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider summary — one row in the left column of the picker.
|
||||
public struct HermesProviderInfo: Sendable, Identifiable, Hashable {
|
||||
public var id: String { providerID }
|
||||
|
||||
public let providerID: String
|
||||
public let providerName: String
|
||||
public let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
|
||||
public let docURL: String?
|
||||
public let modelCount: Int
|
||||
|
||||
public init(
|
||||
providerID: String,
|
||||
providerName: String,
|
||||
envVars: [String],
|
||||
docURL: String?,
|
||||
modelCount: Int
|
||||
) {
|
||||
self.providerID = providerID
|
||||
self.providerName = providerName
|
||||
self.envVars = envVars
|
||||
self.docURL = docURL
|
||||
self.modelCount = modelCount
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the models.dev catalog that hermes caches at
|
||||
/// `~/.hermes/models_dev_cache.json`. Offline-capable, fast enough to read per
|
||||
/// call (~1500 models across ~110 providers).
|
||||
///
|
||||
/// We decode a trimmed subset so unknown fields don't break loading. Every
|
||||
/// field we care about is optional on disk — providers may omit cost, context
|
||||
/// limits, etc.
|
||||
public struct ModelCatalogService: Sendable {
|
||||
#if canImport(os)
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ModelCatalogService")
|
||||
#endif
|
||||
public let path: String
|
||||
public let transport: any ServerTransport
|
||||
|
||||
public nonisolated init(context: ServerContext = .local) {
|
||||
self.path = context.paths.home + "/models_dev_cache.json"
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
/// Escape hatch for tests.
|
||||
public init(path: String) {
|
||||
self.path = path
|
||||
self.transport = LocalTransport()
|
||||
}
|
||||
|
||||
/// All providers, sorted by display name.
|
||||
public func loadProviders() -> [HermesProviderInfo] {
|
||||
guard let catalog = loadCatalog() else { return [] }
|
||||
return catalog
|
||||
.map { (id, p) in
|
||||
HermesProviderInfo(
|
||||
providerID: id,
|
||||
providerName: p.name ?? id,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
.sorted { $0.providerName.localizedCaseInsensitiveCompare($1.providerName) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// Models for one provider, sorted by release date (newest first), then name.
|
||||
public func loadModels(for providerID: String) -> [HermesModelInfo] {
|
||||
guard let catalog = loadCatalog(), let provider = catalog[providerID] else { return [] }
|
||||
let providerName = provider.name ?? providerID
|
||||
let models = (provider.models ?? [:]).map { (id, m) in
|
||||
HermesModelInfo(
|
||||
providerID: providerID,
|
||||
providerName: providerName,
|
||||
modelID: id,
|
||||
modelName: m.name ?? id,
|
||||
contextWindow: m.limit?.context,
|
||||
maxOutput: m.limit?.output,
|
||||
costInput: m.cost?.input,
|
||||
costOutput: m.cost?.output,
|
||||
reasoning: m.reasoning ?? false,
|
||||
toolCall: m.tool_call ?? false,
|
||||
releaseDate: m.release_date
|
||||
)
|
||||
}
|
||||
return models.sorted { lhs, rhs in
|
||||
// Newest-first by release date if both are known; otherwise fall
|
||||
// back to alphabetical on display name.
|
||||
if let lDate = lhs.releaseDate, let rDate = rhs.releaseDate, lDate != rDate {
|
||||
return lDate > rDate
|
||||
}
|
||||
return lhs.modelName.localizedCaseInsensitiveCompare(rhs.modelName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the provider that ships a given model ID. Useful for auto-syncing
|
||||
/// provider when the user picks a model from a flat list or types one in.
|
||||
public func provider(for modelID: String) -> HermesProviderInfo? {
|
||||
guard let catalog = loadCatalog() else { return nil }
|
||||
for (providerID, p) in catalog {
|
||||
if p.models?[modelID] != nil {
|
||||
return HermesProviderInfo(
|
||||
providerID: providerID,
|
||||
providerName: p.name ?? providerID,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
// Handle provider-prefixed IDs like "openai/gpt-4o" — look up the
|
||||
// prefix before the slash.
|
||||
if let slash = modelID.firstIndex(of: "/") {
|
||||
let prefix = String(modelID[modelID.startIndex..<slash])
|
||||
if let p = catalog[prefix] {
|
||||
return HermesProviderInfo(
|
||||
providerID: prefix,
|
||||
providerName: p.name ?? prefix,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Look up a specific model by provider + ID. Returns nil if not in the
|
||||
/// catalog (e.g., free-typed custom model).
|
||||
public func model(providerID: String, modelID: String) -> HermesModelInfo? {
|
||||
guard let catalog = loadCatalog(),
|
||||
let provider = catalog[providerID],
|
||||
let raw = provider.models?[modelID] else { return nil }
|
||||
return HermesModelInfo(
|
||||
providerID: providerID,
|
||||
providerName: provider.name ?? providerID,
|
||||
modelID: modelID,
|
||||
modelName: raw.name ?? modelID,
|
||||
contextWindow: raw.limit?.context,
|
||||
maxOutput: raw.limit?.output,
|
||||
costInput: raw.cost?.input,
|
||||
costOutput: raw.cost?.output,
|
||||
reasoning: raw.reasoning ?? false,
|
||||
toolCall: raw.tool_call ?? false,
|
||||
releaseDate: raw.release_date
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Decoding
|
||||
|
||||
private func loadCatalog() -> [String: ProviderEntry]? {
|
||||
guard let data = try? transport.readFile(path) else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode([String: ProviderEntry].self, from: data)
|
||||
} catch {
|
||||
#if canImport(os)
|
||||
logger.error("Failed to decode models_dev_cache.json: \(error.localizedDescription)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Trimmed representations — we decode a subset of fields and tolerate
|
||||
// anything new hermes adds later. `snake_case` field names match the file.
|
||||
private struct ProviderEntry: Decodable {
|
||||
let id: String?
|
||||
let name: String?
|
||||
let env: [String]?
|
||||
let doc: String?
|
||||
let models: [String: ModelEntry]?
|
||||
}
|
||||
|
||||
private struct ModelEntry: Decodable {
|
||||
let name: String?
|
||||
let reasoning: Bool?
|
||||
let tool_call: Bool?
|
||||
let release_date: String?
|
||||
let cost: CostEntry?
|
||||
let limit: LimitEntry?
|
||||
}
|
||||
|
||||
private struct CostEntry: Decodable {
|
||||
let input: Double?
|
||||
let output: Double?
|
||||
}
|
||||
|
||||
private struct LimitEntry: Decodable {
|
||||
let context: Int?
|
||||
let output: Int?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
public struct ProjectDashboardService: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectDashboardService")
|
||||
|
||||
public let context: ServerContext
|
||||
public let transport: any ServerTransport
|
||||
|
||||
public nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
// MARK: - Registry
|
||||
|
||||
public func loadRegistry() -> ProjectRegistry {
|
||||
guard let data = try? transport.readFile(context.paths.projectsRegistry) else {
|
||||
return ProjectRegistry(projects: [])
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||
} catch {
|
||||
Self.logger.error("Failed to decode project registry: \(error.localizedDescription, privacy: .public)")
|
||||
return ProjectRegistry(projects: [])
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the project registry to `~/.hermes/scarf/projects.json`.
|
||||
///
|
||||
/// **Throws** on every non-success path — the previous version of
|
||||
/// this method silently swallowed `createDirectory` and `writeFile`
|
||||
/// failures with `try?`, which meant the installer could return a
|
||||
/// valid-looking `ProjectEntry` while the registry on disk never
|
||||
/// received the new row (project would complete install, show a
|
||||
/// success screen, then be invisible in the sidebar). Callers that
|
||||
/// want fire-and-forget behaviour can still use `try?`, but the
|
||||
/// choice is now theirs.
|
||||
public func saveRegistry(_ registry: ProjectRegistry) throws {
|
||||
let dir = context.paths.scarfDir
|
||||
if !transport.fileExists(dir) {
|
||||
try transport.createDirectory(dir)
|
||||
}
|
||||
let data = try JSONEncoder().encode(registry)
|
||||
// Pretty-print for readability (agents may read this file).
|
||||
let writeData: Data
|
||||
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
||||
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
||||
writeData = formatted
|
||||
} else {
|
||||
writeData = data
|
||||
}
|
||||
try transport.writeFile(context.paths.projectsRegistry, data: writeData)
|
||||
}
|
||||
|
||||
// MARK: - Dashboard
|
||||
|
||||
public func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? {
|
||||
guard let data = try? transport.readFile(project.dashboardPath) else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(ProjectDashboard.self, from: data)
|
||||
} catch {
|
||||
print("[Scarf] Failed to decode dashboard for \(project.name): \(error.localizedDescription)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func dashboardExists(for project: ProjectEntry) -> Bool {
|
||||
transport.fileExists(project.dashboardPath)
|
||||
}
|
||||
|
||||
public func dashboardModificationDate(for project: ProjectEntry) -> Date? {
|
||||
transport.stat(project.dashboardPath)?.mtime
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ScarfCore
|
||||
|
||||
/// Exercises the portable Services moved in M0c:
|
||||
/// `HermesLogService`, `ModelCatalogService`, `ProjectDashboardService`.
|
||||
///
|
||||
/// `HermesDataService` is intentionally skipped on Linux — it's gated on
|
||||
/// `#if canImport(SQLite3)` (the SQLite3 system module doesn't exist on
|
||||
/// swift-corelibs-foundation). Apple-target CI covers it.
|
||||
@Suite struct M0cServicesTests {
|
||||
|
||||
// MARK: - HermesLogService
|
||||
|
||||
@Test func logEntryMemberwise() {
|
||||
let entry = LogEntry(
|
||||
id: 42,
|
||||
timestamp: "2026-04-22 12:00:00,000",
|
||||
level: .error,
|
||||
sessionId: "s1",
|
||||
logger: "hermes.agent",
|
||||
message: "boom",
|
||||
raw: "2026-04-22 12:00:00,000 ERROR [s1] hermes.agent: boom"
|
||||
)
|
||||
#expect(entry.id == 42)
|
||||
#expect(entry.level == .error)
|
||||
#expect(entry.sessionId == "s1")
|
||||
}
|
||||
|
||||
@Test func logLevelColorsAreStable() {
|
||||
// The UI depends on these strings matching SwiftUI colour names.
|
||||
#expect(LogEntry.LogLevel.debug.color == "secondary")
|
||||
#expect(LogEntry.LogLevel.info.color == "primary")
|
||||
#expect(LogEntry.LogLevel.warning.color == "orange")
|
||||
#expect(LogEntry.LogLevel.error.color == "red")
|
||||
#expect(LogEntry.LogLevel.critical.color == "red")
|
||||
#expect(LogEntry.LogLevel.allCases.count == 5)
|
||||
}
|
||||
|
||||
@Test func logServiceParsesHermesLogFormat() async throws {
|
||||
// Write three lines — one v0.9.0+ format with session tag, one
|
||||
// older format without, and one garbage line — into a tmp file
|
||||
// and verify readLastLines parses them.
|
||||
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-log-\(UUID().uuidString).log")
|
||||
let text = """
|
||||
2026-04-22 12:00:00,123 INFO [session_abc] hermes.agent: starting up
|
||||
2026-04-22 12:00:01,456 WARNING hermes.gateway: low disk space
|
||||
random garbage line with no structure
|
||||
"""
|
||||
try text.write(to: tmp, atomically: true, encoding: .utf8)
|
||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||
|
||||
let service = HermesLogService(context: .local)
|
||||
await service.openLog(path: tmp.path)
|
||||
defer { Task { await service.closeLog() } }
|
||||
let entries = await service.readLastLines(count: 10)
|
||||
#expect(entries.count == 3)
|
||||
|
||||
// v0.9.0+ line with session tag
|
||||
let tagged = entries[0]
|
||||
#expect(tagged.level == .info)
|
||||
#expect(tagged.sessionId == "session_abc")
|
||||
#expect(tagged.logger == "hermes.agent")
|
||||
#expect(tagged.message == "starting up")
|
||||
|
||||
// Older line without session tag — sessionId must be nil, not empty
|
||||
let untagged = entries[1]
|
||||
#expect(untagged.level == .warning)
|
||||
#expect(untagged.sessionId == nil)
|
||||
#expect(untagged.logger == "hermes.gateway")
|
||||
|
||||
// Garbage line falls back gracefully — raw == whole line, message == whole line
|
||||
let bad = entries[2]
|
||||
#expect(bad.timestamp == "")
|
||||
#expect(bad.raw == "random garbage line with no structure")
|
||||
}
|
||||
|
||||
// MARK: - ModelCatalogService
|
||||
|
||||
@Test func modelInfoDisplayFormatting() {
|
||||
let full = HermesModelInfo(
|
||||
providerID: "anthropic",
|
||||
providerName: "Anthropic",
|
||||
modelID: "claude-4.7-opus",
|
||||
modelName: "Claude Opus 4.7",
|
||||
contextWindow: 1_048_576,
|
||||
maxOutput: 32_768,
|
||||
costInput: 5.0,
|
||||
costOutput: 15.0,
|
||||
reasoning: true,
|
||||
toolCall: true,
|
||||
releaseDate: "2026-04-01"
|
||||
)
|
||||
#expect(full.id == "anthropic:claude-4.7-opus")
|
||||
#expect(full.contextDisplay == "1M")
|
||||
#expect(full.costDisplay != nil)
|
||||
|
||||
let big = HermesModelInfo(
|
||||
providerID: "p", providerName: "P", modelID: "m", modelName: "M",
|
||||
contextWindow: 200_000, maxOutput: nil,
|
||||
costInput: nil, costOutput: nil,
|
||||
reasoning: false, toolCall: false, releaseDate: nil
|
||||
)
|
||||
#expect(big.contextDisplay == "200K")
|
||||
#expect(big.costDisplay == nil)
|
||||
|
||||
let tiny = HermesModelInfo(
|
||||
providerID: "p", providerName: "P", modelID: "m", modelName: "M",
|
||||
contextWindow: 500, maxOutput: nil,
|
||||
costInput: nil, costOutput: nil,
|
||||
reasoning: false, toolCall: false, releaseDate: nil
|
||||
)
|
||||
#expect(tiny.contextDisplay == "500")
|
||||
|
||||
let unknown = HermesModelInfo(
|
||||
providerID: "p", providerName: "P", modelID: "m", modelName: "M",
|
||||
contextWindow: nil, maxOutput: nil,
|
||||
costInput: nil, costOutput: nil,
|
||||
reasoning: false, toolCall: false, releaseDate: nil
|
||||
)
|
||||
#expect(unknown.contextDisplay == nil)
|
||||
}
|
||||
|
||||
@Test func modelCatalogLoadsSyntheticJSON() throws {
|
||||
// Write a minimal models_dev_cache.json lookalike and verify
|
||||
// loadProviders / loadModels / provider(for:) / model(providerID:modelID:).
|
||||
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-models-\(UUID().uuidString).json")
|
||||
let json = """
|
||||
{
|
||||
"anthropic": {
|
||||
"id": "anthropic",
|
||||
"name": "Anthropic",
|
||||
"env": ["ANTHROPIC_API_KEY"],
|
||||
"doc": "https://anthropic.com",
|
||||
"models": {
|
||||
"claude-4.7-opus": {
|
||||
"name": "Claude Opus 4.7",
|
||||
"reasoning": true,
|
||||
"tool_call": true,
|
||||
"release_date": "2026-04-01",
|
||||
"cost": { "input": 5.0, "output": 15.0 },
|
||||
"limit": { "context": 1000000, "output": 32768 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"openai": {
|
||||
"id": "openai",
|
||||
"name": "OpenAI",
|
||||
"env": ["OPENAI_API_KEY"],
|
||||
"doc": null,
|
||||
"models": {
|
||||
"gpt-5": {
|
||||
"name": "GPT-5",
|
||||
"reasoning": false,
|
||||
"tool_call": true,
|
||||
"release_date": null,
|
||||
"cost": null,
|
||||
"limit": { "context": 200000, "output": null }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
try json.write(to: tmp, atomically: true, encoding: .utf8)
|
||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||
|
||||
let svc = ModelCatalogService(path: tmp.path)
|
||||
|
||||
let providers = svc.loadProviders()
|
||||
#expect(providers.count == 2)
|
||||
// Alphabetical by display name → Anthropic, OpenAI.
|
||||
#expect(providers[0].providerID == "anthropic")
|
||||
#expect(providers[0].modelCount == 1)
|
||||
#expect(providers[1].providerID == "openai")
|
||||
|
||||
let anthropicModels = svc.loadModels(for: "anthropic")
|
||||
#expect(anthropicModels.count == 1)
|
||||
#expect(anthropicModels[0].modelName == "Claude Opus 4.7")
|
||||
#expect(anthropicModels[0].reasoning == true)
|
||||
|
||||
// provider(for:) does a full scan.
|
||||
#expect(svc.provider(for: "claude-4.7-opus")?.providerID == "anthropic")
|
||||
// Slash-prefixed fallback path.
|
||||
#expect(svc.provider(for: "openai/gpt-5")?.providerID == "openai")
|
||||
// Unknown model → nil.
|
||||
#expect(svc.provider(for: "nobody/knows") == nil)
|
||||
|
||||
// model(providerID:modelID:)
|
||||
let m = svc.model(providerID: "anthropic", modelID: "claude-4.7-opus")
|
||||
#expect(m?.costInput == 5.0)
|
||||
#expect(m?.contextWindow == 1_000_000)
|
||||
#expect(svc.model(providerID: "anthropic", modelID: "does-not-exist") == nil)
|
||||
}
|
||||
|
||||
@Test func modelCatalogHandlesMissingAndMalformedFiles() {
|
||||
// Missing file → empty arrays, no crash.
|
||||
let svc = ModelCatalogService(path: "/tmp/scarf-nonexistent-\(UUID().uuidString).json")
|
||||
#expect(svc.loadProviders().isEmpty)
|
||||
#expect(svc.loadModels(for: "anthropic").isEmpty)
|
||||
#expect(svc.provider(for: "anything") == nil)
|
||||
|
||||
// Malformed JSON → empty arrays, no crash, logger.error path exercised.
|
||||
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-bad-\(UUID().uuidString).json")
|
||||
try? "not json".write(to: tmp, atomically: true, encoding: .utf8)
|
||||
defer { try? FileManager.default.removeItem(at: tmp) }
|
||||
let bad = ModelCatalogService(path: tmp.path)
|
||||
#expect(bad.loadProviders().isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - ProjectDashboardService
|
||||
|
||||
@Test func projectDashboardServiceRegistryRoundTrip() throws {
|
||||
// Use the Files transport via a fake home — write under a tmpdir
|
||||
// and point ServerContext at it. ProjectDashboardService uses
|
||||
// `context.paths.projectsRegistry` which is `home/scarf/projects.json`.
|
||||
let fakeHome = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("scarf-home-\(UUID().uuidString)").path
|
||||
defer { try? FileManager.default.removeItem(atPath: fakeHome) }
|
||||
|
||||
// Inject a ServerContext pointing at the fake home directly — we do
|
||||
// this by using a remote-style config whose `remoteHome` is actually
|
||||
// a local dir, then reading via LocalTransport directly through the
|
||||
// service (ProjectDashboardService only uses the transport's
|
||||
// file-I/O primitives, which LocalTransport already implements
|
||||
// cross-platform).
|
||||
//
|
||||
// Simpler: construct the service, then massage the registry in/out
|
||||
// via the transport for a local path.
|
||||
let fakeRegistry = fakeHome + "/scarf/projects.json"
|
||||
|
||||
let svc = ProjectDashboardService(context: .local)
|
||||
let localTransport = LocalTransport()
|
||||
|
||||
// Ensure the dir exists, then save.
|
||||
try localTransport.createDirectory(fakeHome + "/scarf")
|
||||
let registry = ProjectRegistry(projects: [
|
||||
ProjectEntry(name: "alpha", path: "/tmp/alpha"),
|
||||
ProjectEntry(name: "beta", path: "/tmp/beta")
|
||||
])
|
||||
let encoded = try JSONEncoder().encode(registry)
|
||||
try localTransport.writeFile(fakeRegistry, data: encoded)
|
||||
|
||||
// Read it back via decoder (the service's loadRegistry pattern,
|
||||
// verified independently of the service since the service reads
|
||||
// from `context.paths.projectsRegistry` not a custom path).
|
||||
let readBack = try JSONDecoder().decode(ProjectRegistry.self, from: localTransport.readFile(fakeRegistry))
|
||||
#expect(readBack.projects.map(\.name) == ["alpha", "beta"])
|
||||
|
||||
// Exercise dashboardExists / modificationDate against a real project
|
||||
// dashboard file we write.
|
||||
let projectDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("scarf-proj-\(UUID().uuidString)").path
|
||||
try localTransport.createDirectory(projectDir + "/.scarf")
|
||||
defer { try? FileManager.default.removeItem(atPath: projectDir) }
|
||||
|
||||
let dashJSON = """
|
||||
{
|
||||
"version": 1,
|
||||
"title": "Demo",
|
||||
"description": null,
|
||||
"updatedAt": null,
|
||||
"theme": null,
|
||||
"sections": []
|
||||
}
|
||||
"""
|
||||
try localTransport.writeFile(projectDir + "/.scarf/dashboard.json", data: Data(dashJSON.utf8))
|
||||
|
||||
let entry = ProjectEntry(name: "demo", path: projectDir)
|
||||
#expect(svc.dashboardExists(for: entry) == true)
|
||||
#expect(svc.dashboardModificationDate(for: entry) != nil)
|
||||
let dash = svc.loadDashboard(for: entry)
|
||||
#expect(dash?.title == "Demo")
|
||||
#expect(dash?.version == 1)
|
||||
}
|
||||
|
||||
@Test func projectDashboardServiceReturnsEmptyRegistryOnMissingFile() async {
|
||||
// When `projectsRegistry` path doesn't exist, loadRegistry returns
|
||||
// an empty ProjectRegistry rather than crashing.
|
||||
//
|
||||
// We use the real .local context — its registry path is
|
||||
// `$HOME/.hermes/scarf/projects.json`. On CI this probably doesn't
|
||||
// exist (no Hermes install under $HOME/.hermes), so we get the
|
||||
// empty-on-missing path for free. If it DOES exist (e.g., a dev
|
||||
// machine), we skip the assertion to avoid flakiness.
|
||||
let svc = ProjectDashboardService(context: .local)
|
||||
let registryPath = ServerContext.local.paths.projectsRegistry
|
||||
if !FileManager.default.fileExists(atPath: registryPath) {
|
||||
let reg = svc.loadRegistry()
|
||||
#expect(reg.projects.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user