mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +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:
+45
-31
@@ -1,7 +1,19 @@
|
|||||||
|
// 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 Foundation
|
||||||
import ScarfCore
|
|
||||||
import SQLite3
|
import SQLite3
|
||||||
|
#if canImport(os)
|
||||||
import os
|
import os
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Dedupes concurrent `snapshotSQLite` calls for the same server. When the
|
/// Dedupes concurrent `snapshotSQLite` calls for the same server. When the
|
||||||
/// file watcher ticks, Dashboard + Sessions + Activity (+ Chat's loadHistory)
|
/// file watcher ticks, Dashboard + Sessions + Activity (+ Chat's loadHistory)
|
||||||
@@ -9,11 +21,11 @@ import os
|
|||||||
/// coordination they each spawn their own `ssh host sqlite3 .backup; scp`
|
/// coordination they each spawn their own `ssh host sqlite3 .backup; scp`
|
||||||
/// round-trip, three parallel backups of the same DB. Callers in flight for
|
/// 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.
|
/// the same `ServerID` await the first caller's Task and share its result.
|
||||||
actor SnapshotCoordinator {
|
public actor SnapshotCoordinator {
|
||||||
static let shared = SnapshotCoordinator()
|
public static let shared = SnapshotCoordinator()
|
||||||
private var inFlight: [ServerID: Task<URL, Error>] = [:]
|
private var inFlight: [ServerID: Task<URL, Error>] = [:]
|
||||||
|
|
||||||
func snapshot(
|
public func snapshot(
|
||||||
remotePath: String,
|
remotePath: String,
|
||||||
contextID: ServerID,
|
contextID: ServerID,
|
||||||
transport: any ServerTransport
|
transport: any ServerTransport
|
||||||
@@ -30,7 +42,7 @@ actor SnapshotCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actor HermesDataService {
|
public actor HermesDataService {
|
||||||
private static let logger = Logger(subsystem: "com.scarf", category: "HermesDataService")
|
private static let logger = Logger(subsystem: "com.scarf", category: "HermesDataService")
|
||||||
|
|
||||||
private var db: OpaquePointer?
|
private var db: OpaquePointer?
|
||||||
@@ -44,15 +56,15 @@ actor HermesDataService {
|
|||||||
/// instead of an empty Dashboard with no explanation.
|
/// instead of an empty Dashboard with no explanation.
|
||||||
private(set) var lastOpenError: String?
|
private(set) var lastOpenError: String?
|
||||||
|
|
||||||
let context: ServerContext
|
public let context: ServerContext
|
||||||
private let transport: any ServerTransport
|
private let transport: any ServerTransport
|
||||||
|
|
||||||
init(context: ServerContext = .local) {
|
public init(context: ServerContext = .local) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.transport = context.makeTransport()
|
self.transport = context.makeTransport()
|
||||||
}
|
}
|
||||||
|
|
||||||
func open() async -> Bool {
|
public func open() async -> Bool {
|
||||||
if db != nil { return true }
|
if db != nil { return true }
|
||||||
let localPath: String
|
let localPath: String
|
||||||
if context.isRemote {
|
if context.isRemote {
|
||||||
@@ -142,12 +154,12 @@ actor HermesDataService {
|
|||||||
/// written. Local contexts pay essentially nothing: close+reopen on a
|
/// written. Local contexts pay essentially nothing: close+reopen on a
|
||||||
/// live DB is a no-op.
|
/// live DB is a no-op.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func refresh() async -> Bool {
|
public func refresh() async -> Bool {
|
||||||
close()
|
close()
|
||||||
return await open()
|
return await open()
|
||||||
}
|
}
|
||||||
|
|
||||||
func close() {
|
public func close() {
|
||||||
if let db {
|
if let db {
|
||||||
sqlite3_close(db)
|
sqlite3_close(db)
|
||||||
}
|
}
|
||||||
@@ -184,7 +196,7 @@ actor HermesDataService {
|
|||||||
return cols
|
return cols
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
|
public func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT ?"
|
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT ?"
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
@@ -199,7 +211,7 @@ actor HermesDataService {
|
|||||||
return sessions
|
return sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
public func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
|
||||||
guard let db else { return [] }
|
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"
|
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? ORDER BY started_at DESC"
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
@@ -214,7 +226,7 @@ actor HermesDataService {
|
|||||||
return sessions
|
return sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSubagentSessions(parentId: String) -> [HermesSession] {
|
public func fetchSubagentSessions(parentId: String) -> [HermesSession] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id = ? ORDER BY started_at ASC"
|
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id = ? ORDER BY started_at ASC"
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
@@ -242,7 +254,7 @@ actor HermesDataService {
|
|||||||
return cols
|
return cols
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchMessages(sessionId: String) -> [HermesMessage] {
|
public func fetchMessages(sessionId: String) -> [HermesMessage] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC"
|
let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC"
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
@@ -257,7 +269,7 @@ actor HermesDataService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] {
|
public func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sanitized = sanitizeFTSQuery(query)
|
let sanitized = sanitizeFTSQuery(query)
|
||||||
guard !sanitized.isEmpty else { return [] }
|
guard !sanitized.isEmpty else { return [] }
|
||||||
@@ -285,7 +297,7 @@ actor HermesDataService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchToolResult(callId: String) -> String? {
|
public func fetchToolResult(callId: String) -> String? {
|
||||||
guard let db else { return nil }
|
guard let db else { return nil }
|
||||||
let sql = "SELECT content FROM messages WHERE role = 'tool' AND tool_call_id = ? LIMIT 1"
|
let sql = "SELECT content FROM messages WHERE role = 'tool' AND tool_call_id = ? LIMIT 1"
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
@@ -296,7 +308,7 @@ actor HermesDataService {
|
|||||||
return columnText(stmt!, 0)
|
return columnText(stmt!, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] {
|
public func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT \(messageColumns)
|
SELECT \(messageColumns)
|
||||||
@@ -317,7 +329,7 @@ actor HermesDataService {
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSessionPreviews(limit: Int = QueryDefaults.sessionPreviewLimit) -> [String: String] {
|
public func fetchSessionPreviews(limit: Int = QueryDefaults.sessionPreviewLimit) -> [String: String] {
|
||||||
guard let db else { return [:] }
|
guard let db else { return [:] }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT m.session_id, substr(m.content, 1, \(QueryDefaults.previewContentLength))
|
SELECT m.session_id, substr(m.content, 1, \(QueryDefaults.previewContentLength))
|
||||||
@@ -347,7 +359,7 @@ actor HermesDataService {
|
|||||||
|
|
||||||
// MARK: - Single-Row Queries
|
// MARK: - Single-Row Queries
|
||||||
|
|
||||||
struct MessageFingerprint: Equatable, Sendable {
|
public struct MessageFingerprint: Equatable, Sendable {
|
||||||
let count: Int
|
let count: Int
|
||||||
let maxId: Int
|
let maxId: Int
|
||||||
let maxTimestamp: Double
|
let maxTimestamp: Double
|
||||||
@@ -355,7 +367,7 @@ actor HermesDataService {
|
|||||||
static let empty = MessageFingerprint(count: 0, maxId: 0, maxTimestamp: 0)
|
static let empty = MessageFingerprint(count: 0, maxId: 0, maxTimestamp: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchMessageFingerprint(sessionId: String) -> MessageFingerprint {
|
public func fetchMessageFingerprint(sessionId: String) -> MessageFingerprint {
|
||||||
guard let db else { return .empty }
|
guard let db else { return .empty }
|
||||||
let sql = "SELECT COUNT(*), COALESCE(MAX(id), 0), COALESCE(MAX(timestamp), 0) FROM messages WHERE session_id = ?"
|
let sql = "SELECT COUNT(*), COALESCE(MAX(id), 0), COALESCE(MAX(timestamp), 0) FROM messages WHERE session_id = ?"
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
@@ -370,7 +382,7 @@ actor HermesDataService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchMessageCount(sessionId: String) -> Int {
|
public func fetchMessageCount(sessionId: String) -> Int {
|
||||||
guard let db else { return 0 }
|
guard let db else { return 0 }
|
||||||
let sql = "SELECT COUNT(*) FROM messages WHERE session_id = ?"
|
let sql = "SELECT COUNT(*) FROM messages WHERE session_id = ?"
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
@@ -381,7 +393,7 @@ actor HermesDataService {
|
|||||||
return Int(sqlite3_column_int(stmt, 0))
|
return Int(sqlite3_column_int(stmt, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSession(id: String) -> HermesSession? {
|
public func fetchSession(id: String) -> HermesSession? {
|
||||||
guard let db else { return nil }
|
guard let db else { return nil }
|
||||||
let sql = "SELECT \(sessionColumns) FROM sessions WHERE id = ? LIMIT 1"
|
let sql = "SELECT \(sessionColumns) FROM sessions WHERE id = ? LIMIT 1"
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
@@ -392,7 +404,7 @@ actor HermesDataService {
|
|||||||
return sessionFromRow(stmt!)
|
return sessionFromRow(stmt!)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchMostRecentlyActiveSessionId() -> String? {
|
public func fetchMostRecentlyActiveSessionId() -> String? {
|
||||||
guard let db else { return nil }
|
guard let db else { return nil }
|
||||||
let sql = "SELECT session_id FROM messages ORDER BY timestamp DESC LIMIT 1"
|
let sql = "SELECT session_id FROM messages ORDER BY timestamp DESC LIMIT 1"
|
||||||
var stmt: OpaquePointer?
|
var stmt: OpaquePointer?
|
||||||
@@ -402,7 +414,7 @@ actor HermesDataService {
|
|||||||
return columnText(stmt!, 0)
|
return columnText(stmt!, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchMostRecentlyStartedSessionId(after: Date? = nil) -> String? {
|
public func fetchMostRecentlyStartedSessionId(after: Date? = nil) -> String? {
|
||||||
guard let db else { return nil }
|
guard let db else { return nil }
|
||||||
let sql: String
|
let sql: String
|
||||||
if after != nil {
|
if after != nil {
|
||||||
@@ -422,7 +434,7 @@ actor HermesDataService {
|
|||||||
|
|
||||||
// MARK: - Stats
|
// MARK: - Stats
|
||||||
|
|
||||||
struct SessionStats: Sendable {
|
public struct SessionStats: Sendable {
|
||||||
let totalSessions: Int
|
let totalSessions: Int
|
||||||
let totalMessages: Int
|
let totalMessages: Int
|
||||||
let totalToolCalls: Int
|
let totalToolCalls: Int
|
||||||
@@ -439,7 +451,7 @@ actor HermesDataService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchStats() -> SessionStats {
|
public func fetchStats() -> SessionStats {
|
||||||
guard let db else { return .empty }
|
guard let db else { return .empty }
|
||||||
let sql: String
|
let sql: String
|
||||||
if hasV07Schema {
|
if hasV07Schema {
|
||||||
@@ -476,7 +488,7 @@ actor HermesDataService {
|
|||||||
|
|
||||||
// MARK: - Insights Queries
|
// MARK: - Insights Queries
|
||||||
|
|
||||||
func fetchUserMessageCount(since: Date) -> Int {
|
public func fetchUserMessageCount(since: Date) -> Int {
|
||||||
guard let db else { return 0 }
|
guard let db else { return 0 }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT COUNT(*) FROM messages m
|
SELECT COUNT(*) FROM messages m
|
||||||
@@ -491,7 +503,7 @@ actor HermesDataService {
|
|||||||
return Int(sqlite3_column_int(stmt, 0))
|
return Int(sqlite3_column_int(stmt, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchToolUsage(since: Date) -> [(name: String, count: Int)] {
|
public func fetchToolUsage(since: Date) -> [(name: String, count: Int)] {
|
||||||
guard let db else { return [] }
|
guard let db else { return [] }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT m.tool_name, COUNT(*) as cnt
|
SELECT m.tool_name, COUNT(*) as cnt
|
||||||
@@ -515,7 +527,7 @@ actor HermesDataService {
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSessionStartHours(since: Date) -> [Int: Int] {
|
public func fetchSessionStartHours(since: Date) -> [Int: Int] {
|
||||||
guard let db else { return [:] }
|
guard let db else { return [:] }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
|
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
|
||||||
@@ -536,7 +548,7 @@ actor HermesDataService {
|
|||||||
return hours
|
return hours
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] {
|
public func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] {
|
||||||
guard let db else { return [:] }
|
guard let db else { return [:] }
|
||||||
let sql = """
|
let sql = """
|
||||||
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
|
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
|
||||||
@@ -557,7 +569,7 @@ actor HermesDataService {
|
|||||||
return days
|
return days
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateDBModificationDate() -> Date? {
|
public func stateDBModificationDate() -> Date? {
|
||||||
// For remote contexts we stat the remote paths. For local it's the
|
// For remote contexts we stat the remote paths. For local it's the
|
||||||
// same FileManager lookup as before, just via the transport.
|
// same FileManager lookup as before, just via the transport.
|
||||||
let walDate = transport.stat(context.paths.stateDB + "-wal")?.mtime
|
let walDate = transport.stat(context.paths.stateDB + "-wal")?.mtime
|
||||||
@@ -656,3 +668,5 @@ actor HermesDataService {
|
|||||||
.joined(separator: " ")
|
.joined(separator: " ")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endif // canImport(SQLite3)
|
||||||
+35
-18
@@ -1,16 +1,33 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ScarfCore
|
|
||||||
|
|
||||||
struct LogEntry: Identifiable, Sendable {
|
public struct LogEntry: Identifiable, Sendable {
|
||||||
let id: Int
|
public let id: Int
|
||||||
let timestamp: String
|
public let timestamp: String
|
||||||
let level: LogLevel
|
public let level: LogLevel
|
||||||
let sessionId: String?
|
public let sessionId: String?
|
||||||
let logger: String
|
public let logger: String
|
||||||
let message: String
|
public let message: String
|
||||||
let raw: String
|
public let raw: String
|
||||||
|
|
||||||
enum LogLevel: String, Sendable, CaseIterable {
|
|
||||||
|
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 debug = "DEBUG"
|
||||||
case info = "INFO"
|
case info = "INFO"
|
||||||
case warning = "WARNING"
|
case warning = "WARNING"
|
||||||
@@ -29,7 +46,7 @@ struct LogEntry: Identifiable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actor HermesLogService {
|
public actor HermesLogService {
|
||||||
private var fileHandle: FileHandle?
|
private var fileHandle: FileHandle?
|
||||||
private var currentPath: String?
|
private var currentPath: String?
|
||||||
private var entryCounter = 0
|
private var entryCounter = 0
|
||||||
@@ -40,15 +57,15 @@ actor HermesLogService {
|
|||||||
private var remoteTailProcess: Process?
|
private var remoteTailProcess: Process?
|
||||||
private var remoteTailBuffer: String = ""
|
private var remoteTailBuffer: String = ""
|
||||||
|
|
||||||
let context: ServerContext
|
public let context: ServerContext
|
||||||
private let transport: any ServerTransport
|
private let transport: any ServerTransport
|
||||||
|
|
||||||
init(context: ServerContext = .local) {
|
public init(context: ServerContext = .local) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.transport = context.makeTransport()
|
self.transport = context.makeTransport()
|
||||||
}
|
}
|
||||||
|
|
||||||
func openLog(path: String) {
|
public func openLog(path: String) {
|
||||||
closeLog()
|
closeLog()
|
||||||
currentPath = path
|
currentPath = path
|
||||||
if context.isRemote {
|
if context.isRemote {
|
||||||
@@ -76,7 +93,7 @@ actor HermesLogService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func closeLog() {
|
public func closeLog() {
|
||||||
do {
|
do {
|
||||||
try fileHandle?.close()
|
try fileHandle?.close()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -91,7 +108,7 @@ actor HermesLogService {
|
|||||||
remoteTailBuffer = ""
|
remoteTailBuffer = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
|
public func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
|
||||||
guard let path = currentPath else { return [] }
|
guard let path = currentPath else { return [] }
|
||||||
if context.isRemote {
|
if context.isRemote {
|
||||||
// For the initial load we bypass the streaming tail and run a
|
// For the initial load we bypass the streaming tail and run a
|
||||||
@@ -113,7 +130,7 @@ actor HermesLogService {
|
|||||||
return lastLines.map { parseLine($0) }
|
return lastLines.map { parseLine($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func readNewLines() -> [LogEntry] {
|
public func readNewLines() -> [LogEntry] {
|
||||||
guard let handle = fileHandle else { return [] }
|
guard let handle = fileHandle else { return [] }
|
||||||
let data = handle.availableData
|
let data = handle.availableData
|
||||||
guard !data.isEmpty else { return [] }
|
guard !data.isEmpty else { return [] }
|
||||||
@@ -134,7 +151,7 @@ actor HermesLogService {
|
|||||||
return lines.map { parseLine($0) }
|
return lines.map { parseLine($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func seekToEnd() {
|
public func seekToEnd() {
|
||||||
// Only meaningful for local FileHandles — remote tail starts at the
|
// Only meaningful for local FileHandles — remote tail starts at the
|
||||||
// end implicitly after `readLastLines` drained the initial load.
|
// end implicitly after `readLastLines` drained the initial load.
|
||||||
if !context.isRemote {
|
if !context.isRemote {
|
||||||
+77
-32
@@ -1,32 +1,59 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ScarfCore
|
#if canImport(os)
|
||||||
import os
|
import os
|
||||||
|
#endif
|
||||||
|
|
||||||
/// A single model from the models.dev catalog shipped with hermes.
|
/// A single model from the models.dev catalog shipped with hermes.
|
||||||
struct HermesModelInfo: Sendable, Identifiable, Hashable {
|
public struct HermesModelInfo: Sendable, Identifiable, Hashable {
|
||||||
var id: String { providerID + ":" + modelID }
|
public var id: String { providerID + ":" + modelID }
|
||||||
|
|
||||||
let providerID: String
|
public let providerID: String
|
||||||
let providerName: String
|
public let providerName: String
|
||||||
let modelID: String
|
public let modelID: String
|
||||||
let modelName: String
|
public let modelName: String
|
||||||
let contextWindow: Int?
|
public let contextWindow: Int?
|
||||||
let maxOutput: Int?
|
public let maxOutput: Int?
|
||||||
let costInput: Double? // USD per 1M input tokens
|
public let costInput: Double? // USD per 1M input tokens
|
||||||
let costOutput: Double? // USD per 1M output tokens
|
public let costOutput: Double? // USD per 1M output tokens
|
||||||
let reasoning: Bool
|
public let reasoning: Bool
|
||||||
let toolCall: Bool
|
public let toolCall: Bool
|
||||||
let releaseDate: String?
|
public let releaseDate: String?
|
||||||
|
|
||||||
/// Display-friendly cost string, or nil if cost is unknown.
|
/// Display-friendly cost string, or nil if cost is unknown.
|
||||||
var costDisplay: String? {
|
|
||||||
|
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 }
|
guard let input = costInput, let output = costOutput else { return nil }
|
||||||
let currency = FloatingPointFormatStyle<Double>.Currency.currency(code: "USD").precision(.fractionLength(2))
|
let currency = FloatingPointFormatStyle<Double>.Currency.currency(code: "USD").precision(.fractionLength(2))
|
||||||
return "\(input.formatted(currency)) / \(output.formatted(currency))"
|
return "\(input.formatted(currency)) / \(output.formatted(currency))"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display-friendly context window ("200K", "1M", etc.).
|
/// Display-friendly context window ("200K", "1M", etc.).
|
||||||
var contextDisplay: String? {
|
public var contextDisplay: String? {
|
||||||
guard let ctx = contextWindow else { return nil }
|
guard let ctx = contextWindow else { return nil }
|
||||||
if ctx >= 1_000_000 { return "\(ctx / 1_000_000)M" }
|
if ctx >= 1_000_000 { return "\(ctx / 1_000_000)M" }
|
||||||
if ctx >= 1_000 { return "\(ctx / 1_000)K" }
|
if ctx >= 1_000 { return "\(ctx / 1_000)K" }
|
||||||
@@ -35,14 +62,28 @@ struct HermesModelInfo: Sendable, Identifiable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Provider summary — one row in the left column of the picker.
|
/// Provider summary — one row in the left column of the picker.
|
||||||
struct HermesProviderInfo: Sendable, Identifiable, Hashable {
|
public struct HermesProviderInfo: Sendable, Identifiable, Hashable {
|
||||||
var id: String { providerID }
|
public var id: String { providerID }
|
||||||
|
|
||||||
let providerID: String
|
public let providerID: String
|
||||||
let providerName: String
|
public let providerName: String
|
||||||
let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
|
public let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
|
||||||
let docURL: String?
|
public let docURL: String?
|
||||||
let modelCount: Int
|
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
|
/// Reads the models.dev catalog that hermes caches at
|
||||||
@@ -52,24 +93,26 @@ struct HermesProviderInfo: Sendable, Identifiable, Hashable {
|
|||||||
/// We decode a trimmed subset so unknown fields don't break loading. Every
|
/// 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
|
/// field we care about is optional on disk — providers may omit cost, context
|
||||||
/// limits, etc.
|
/// limits, etc.
|
||||||
struct ModelCatalogService: Sendable {
|
public struct ModelCatalogService: Sendable {
|
||||||
|
#if canImport(os)
|
||||||
private let logger = Logger(subsystem: "com.scarf", category: "ModelCatalogService")
|
private let logger = Logger(subsystem: "com.scarf", category: "ModelCatalogService")
|
||||||
let path: String
|
#endif
|
||||||
let transport: any ServerTransport
|
public let path: String
|
||||||
|
public let transport: any ServerTransport
|
||||||
|
|
||||||
nonisolated init(context: ServerContext = .local) {
|
public nonisolated init(context: ServerContext = .local) {
|
||||||
self.path = context.paths.home + "/models_dev_cache.json"
|
self.path = context.paths.home + "/models_dev_cache.json"
|
||||||
self.transport = context.makeTransport()
|
self.transport = context.makeTransport()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Escape hatch for tests.
|
/// Escape hatch for tests.
|
||||||
init(path: String) {
|
public init(path: String) {
|
||||||
self.path = path
|
self.path = path
|
||||||
self.transport = LocalTransport()
|
self.transport = LocalTransport()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All providers, sorted by display name.
|
/// All providers, sorted by display name.
|
||||||
func loadProviders() -> [HermesProviderInfo] {
|
public func loadProviders() -> [HermesProviderInfo] {
|
||||||
guard let catalog = loadCatalog() else { return [] }
|
guard let catalog = loadCatalog() else { return [] }
|
||||||
return catalog
|
return catalog
|
||||||
.map { (id, p) in
|
.map { (id, p) in
|
||||||
@@ -85,7 +128,7 @@ struct ModelCatalogService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Models for one provider, sorted by release date (newest first), then name.
|
/// Models for one provider, sorted by release date (newest first), then name.
|
||||||
func loadModels(for providerID: String) -> [HermesModelInfo] {
|
public func loadModels(for providerID: String) -> [HermesModelInfo] {
|
||||||
guard let catalog = loadCatalog(), let provider = catalog[providerID] else { return [] }
|
guard let catalog = loadCatalog(), let provider = catalog[providerID] else { return [] }
|
||||||
let providerName = provider.name ?? providerID
|
let providerName = provider.name ?? providerID
|
||||||
let models = (provider.models ?? [:]).map { (id, m) in
|
let models = (provider.models ?? [:]).map { (id, m) in
|
||||||
@@ -115,7 +158,7 @@ struct ModelCatalogService: Sendable {
|
|||||||
|
|
||||||
/// Find the provider that ships a given model ID. Useful for auto-syncing
|
/// 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.
|
/// provider when the user picks a model from a flat list or types one in.
|
||||||
func provider(for modelID: String) -> HermesProviderInfo? {
|
public func provider(for modelID: String) -> HermesProviderInfo? {
|
||||||
guard let catalog = loadCatalog() else { return nil }
|
guard let catalog = loadCatalog() else { return nil }
|
||||||
for (providerID, p) in catalog {
|
for (providerID, p) in catalog {
|
||||||
if p.models?[modelID] != nil {
|
if p.models?[modelID] != nil {
|
||||||
@@ -147,7 +190,7 @@ struct ModelCatalogService: Sendable {
|
|||||||
|
|
||||||
/// Look up a specific model by provider + ID. Returns nil if not in the
|
/// Look up a specific model by provider + ID. Returns nil if not in the
|
||||||
/// catalog (e.g., free-typed custom model).
|
/// catalog (e.g., free-typed custom model).
|
||||||
func model(providerID: String, modelID: String) -> HermesModelInfo? {
|
public func model(providerID: String, modelID: String) -> HermesModelInfo? {
|
||||||
guard let catalog = loadCatalog(),
|
guard let catalog = loadCatalog(),
|
||||||
let provider = catalog[providerID],
|
let provider = catalog[providerID],
|
||||||
let raw = provider.models?[modelID] else { return nil }
|
let raw = provider.models?[modelID] else { return nil }
|
||||||
@@ -175,7 +218,9 @@ struct ModelCatalogService: Sendable {
|
|||||||
do {
|
do {
|
||||||
return try JSONDecoder().decode([String: ProviderEntry].self, from: data)
|
return try JSONDecoder().decode([String: ProviderEntry].self, from: data)
|
||||||
} catch {
|
} catch {
|
||||||
|
#if canImport(os)
|
||||||
logger.error("Failed to decode models_dev_cache.json: \(error.localizedDescription)")
|
logger.error("Failed to decode models_dev_cache.json: \(error.localizedDescription)")
|
||||||
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+9
-10
@@ -1,21 +1,20 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ScarfCore
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
struct ProjectDashboardService: Sendable {
|
public struct ProjectDashboardService: Sendable {
|
||||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectDashboardService")
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectDashboardService")
|
||||||
|
|
||||||
let context: ServerContext
|
public let context: ServerContext
|
||||||
let transport: any ServerTransport
|
public let transport: any ServerTransport
|
||||||
|
|
||||||
nonisolated init(context: ServerContext = .local) {
|
public nonisolated init(context: ServerContext = .local) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.transport = context.makeTransport()
|
self.transport = context.makeTransport()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Registry
|
// MARK: - Registry
|
||||||
|
|
||||||
func loadRegistry() -> ProjectRegistry {
|
public func loadRegistry() -> ProjectRegistry {
|
||||||
guard let data = try? transport.readFile(context.paths.projectsRegistry) else {
|
guard let data = try? transport.readFile(context.paths.projectsRegistry) else {
|
||||||
return ProjectRegistry(projects: [])
|
return ProjectRegistry(projects: [])
|
||||||
}
|
}
|
||||||
@@ -37,7 +36,7 @@ struct ProjectDashboardService: Sendable {
|
|||||||
/// success screen, then be invisible in the sidebar). Callers that
|
/// success screen, then be invisible in the sidebar). Callers that
|
||||||
/// want fire-and-forget behaviour can still use `try?`, but the
|
/// want fire-and-forget behaviour can still use `try?`, but the
|
||||||
/// choice is now theirs.
|
/// choice is now theirs.
|
||||||
func saveRegistry(_ registry: ProjectRegistry) throws {
|
public func saveRegistry(_ registry: ProjectRegistry) throws {
|
||||||
let dir = context.paths.scarfDir
|
let dir = context.paths.scarfDir
|
||||||
if !transport.fileExists(dir) {
|
if !transport.fileExists(dir) {
|
||||||
try transport.createDirectory(dir)
|
try transport.createDirectory(dir)
|
||||||
@@ -56,7 +55,7 @@ struct ProjectDashboardService: Sendable {
|
|||||||
|
|
||||||
// MARK: - Dashboard
|
// MARK: - Dashboard
|
||||||
|
|
||||||
func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? {
|
public func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? {
|
||||||
guard let data = try? transport.readFile(project.dashboardPath) else {
|
guard let data = try? transport.readFile(project.dashboardPath) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -68,11 +67,11 @@ struct ProjectDashboardService: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func dashboardExists(for project: ProjectEntry) -> Bool {
|
public func dashboardExists(for project: ProjectEntry) -> Bool {
|
||||||
transport.fileExists(project.dashboardPath)
|
transport.fileExists(project.dashboardPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dashboardModificationDate(for project: ProjectEntry) -> Date? {
|
public func dashboardModificationDate(for project: ProjectEntry) -> Date? {
|
||||||
transport.stat(project.dashboardPath)?.mtime
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -303,7 +303,72 @@ stderr patterns, and round-trip an actual local file through
|
|||||||
iOS's Citadel-based transport lands (M4), it will provide its own env
|
iOS's Citadel-based transport lands (M4), it will provide its own env
|
||||||
story — the existing macOS helper stays untouched.
|
story — the existing macOS helper stays untouched.
|
||||||
|
|
||||||
### M0c — pending
|
### M0c — shipped
|
||||||
|
|
||||||
|
**Shipped:**
|
||||||
|
|
||||||
|
- 4 portable Services moved to `Packages/ScarfCore/Sources/ScarfCore/Services/`:
|
||||||
|
- `HermesDataService.swift` (658 lines, SQLite3-backed session/message/activity reader + `SnapshotCoordinator` actor)
|
||||||
|
- `HermesLogService.swift` (log tailing + parsing, `LogEntry` + `LogLevel`)
|
||||||
|
- `ModelCatalogService.swift` (models.dev cache reader, `HermesModelInfo` + `HermesProviderInfo`)
|
||||||
|
- `ProjectDashboardService.swift` (per-project dashboard JSON I/O)
|
||||||
|
- `HermesFileService.swift`, `HermesEnvService.swift`, `HermesFileWatcher.swift`,
|
||||||
|
`ACPClient.swift`, and `UpdaterService.swift` stay in the Mac target.
|
||||||
|
`HermesFileService` holds the big shell-enrichment logic and is the only
|
||||||
|
non-portable heavyweight — a later phase can port it once iOS has a
|
||||||
|
clearer story for shell-env-less ACP spawning. `ACPClient` is M1's job
|
||||||
|
(the `ACPChannel` refactor). `UpdaterService` wraps Sparkle and stays
|
||||||
|
Mac-only forever.
|
||||||
|
- The one remaining external consumer that wasn't already importing
|
||||||
|
ScarfCore (`Features/Settings/Views/Components/ModelPickerSheet.swift`)
|
||||||
|
now has `import ScarfCore` added.
|
||||||
|
|
||||||
|
**Platform guards:**
|
||||||
|
|
||||||
|
- **`HermesDataService.swift` is wrapped in `#if canImport(SQLite3)` /
|
||||||
|
`#endif`** — the whole file. SQLite3 isn't a system module on Linux
|
||||||
|
swift-corelibs-foundation, and the service is unusable without it.
|
||||||
|
Apple platforms (the real runtime targets) compile it unchanged. Linux
|
||||||
|
builds just skip it. Nothing in ScarfCore references
|
||||||
|
`HermesDataService` from outside that file, so there's no downstream
|
||||||
|
fallout.
|
||||||
|
- `ModelCatalogService.swift` — `import os` / logger definition / logger
|
||||||
|
call sites all guarded with `#if canImport(os)`. Linux gets silent
|
||||||
|
logging.
|
||||||
|
|
||||||
|
**Test coverage (`M0cServicesTests`):** 8 new tests.
|
||||||
|
|
||||||
|
- `HermesLogService.parseLine` exercised via `readLastLines` against a
|
||||||
|
real local log file with three lines (v0.9.0+ format with session tag,
|
||||||
|
older format without, and a garbage fallback line). Verifies the
|
||||||
|
optional session tag handling called out in CLAUDE.md.
|
||||||
|
- `LogEntry.LogLevel` colour strings pinned (SwiftUI views depend on
|
||||||
|
them matching colour names).
|
||||||
|
- `HermesModelInfo.contextDisplay` tested across `1M`, `200K`, `500`,
|
||||||
|
and `nil` cases; `costDisplay` tested with and without costs.
|
||||||
|
- `ModelCatalogService` load path exercised end-to-end against a
|
||||||
|
synthetic `models_dev_cache.json` lookalike — providers sorted
|
||||||
|
alphabetically, models filtered by provider, `provider(for:)` finds
|
||||||
|
models both by full scan AND via `provider/model` slash-prefix
|
||||||
|
fallback.
|
||||||
|
- Malformed + missing file paths return empty results, no crash.
|
||||||
|
- `ProjectDashboardService` round-trips a `ProjectRegistry` to disk and
|
||||||
|
reads back a synthetic `.scarf/dashboard.json`.
|
||||||
|
|
||||||
|
**Rules next phases can rely on:**
|
||||||
|
|
||||||
|
- The `#if canImport(SQLite3)` gate pattern is established — any future
|
||||||
|
ScarfCore code that touches SQLite3 directly should use the same
|
||||||
|
whole-file or whole-block guard rather than trying to abstract SQLite
|
||||||
|
behind a protocol (overkill; SQLite is reliably available on every
|
||||||
|
target that can run Hermes client code).
|
||||||
|
- Services take `ServerContext` in their init and construct their own
|
||||||
|
transport via `context.makeTransport()`. M0d ViewModels should follow
|
||||||
|
the same convention when they move to ScarfCore.
|
||||||
|
- `LocalTransport()` (no-arg init) is the fast path for tests — uses
|
||||||
|
`ServerContext.local.id`. Test helpers in ScarfCoreTests lean on this
|
||||||
|
heavily.
|
||||||
|
|
||||||
### M0d — pending
|
### M0d — pending
|
||||||
### M1 — pending
|
### M1 — pending
|
||||||
### M2 — pending
|
### M2 — pending
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import ScarfCore
|
||||||
|
|
||||||
/// Two-column model browser sheet. Left column lists providers, right column
|
/// Two-column model browser sheet. Left column lists providers, right column
|
||||||
/// lists models for the selected provider. Supports filtering and a "Custom…"
|
/// lists models for the selected provider. Supports filtering and a "Custom…"
|
||||||
|
|||||||
Reference in New Issue
Block a user