Files
scarf/scarf/scarf/Core/Services/HermesFileWatcher.swift
T
Alan Wizemann c7bcfd8655 feat(dashboards): v2.7 widget catalog — file-reading widgets, sparkline, typed status, project-wide watch
Major project-dashboard release. Five new widget types (markdown_file, log_tail,
cron_status, image, status_grid), inline sparkline on stat, typed status enum
shared by list + status_grid, structured WidgetErrorCard, and a project-wide
.scarf/ directory watch that picks up files cron jobs write next to dashboard.json.

- ProjectDashboard: extend DashboardWidget with path/lines/jobId/cells/gridColumns/sparkline; add StatusGridCell + ListItemStatus (lenient parse with synonyms)
- HermesFileWatcher: watch each project's .scarf/ dir alongside dashboard.json (local FSEvents + remote SSH mtime poll); updateProjectWatches signature now takes dashboardPaths + scarfDirs
- New widget views: CronStatus, Image, LogTail, MarkdownFile, StatusGrid, plus WidgetErrorCard for structured failure messaging; legacy "Unknown" placeholder replaced everywhere
- WidgetPathResolver: project-root-anchored path resolution that rejects absolute paths + ".." escapes pre and post canonicalization
- Stat widget gains optional inline sparkline (pure SwiftUI Path, no Charts dep); list widget rows route through typed status with semantic icons + ScarfColor tints
- iOS list widget + unsupported card adopt typed status + warning-toned error card (parity with Mac error styling); new widget types remain Mac-only
- Site mirror: widgets.js renders all five new types (file-reading widgets show annotated catalog placeholders), sparkline SVG, status-grid grid; styles.css adds typed-status palette + error-card + sparkline + grid styles
- Catalog validator: tools/widget-schema.json is the single source of truth; build-catalog.py loads it and enforces per-type required fields. 8 new test cases in test_build_catalog.py covering schema load, v2.7 additions, and missing-required rejection
- Template-author skill (SKILL.md) gains v2.7 Widget Catalog section + canonical status guidance; CONTRIBUTING.md points authors at widget-schema.json; template-author bundle rebuilt
- Localizable.xcstrings picks up auto-extracted strings for the previously-shipped OAuth keepalive feature
- Release notes drafted at releases/v2.7.0/RELEASE_NOTES.md

Backwards compatible — existing dashboard.json renders byte-identically, status synonyms (ok/up/down/active/etc.) keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:16:29 +02:00

162 lines
5.9 KiB
Swift

import Foundation
import ScarfCore
@Observable
final class HermesFileWatcher {
private(set) var lastChangeDate = Date()
private var coreSources: [DispatchSourceFileSystemObject] = []
private var projectSources: [DispatchSourceFileSystemObject] = []
private var timer: Timer?
/// Remote polling task. Non-nil only when `context.isRemote`. Cancelled
/// on `stopWatching()`.
private var remotePollTask: Task<Void, Never>?
/// Project directory paths fed to the SSH poller alongside `watchedCorePaths`.
/// Updated by `updateProjectWatches` so the remote stream restarts whenever
/// the project list changes.
private var remoteProjectPaths: [String] = []
let context: ServerContext
private let transport: any ServerTransport
nonisolated init(context: ServerContext = .local) {
self.context = context
self.transport = context.makeTransport()
}
/// Canonical list of paths we observe. Used for both FSEvents (local)
/// and mtime polling (remote).
private var watchedCorePaths: [String] {
let paths = context.paths
return [
paths.stateDB,
paths.stateDB + "-wal",
paths.configYAML,
paths.home + "/.env",
paths.memoryMD,
paths.userMD,
paths.cronJobsJSON,
paths.gatewayStateJSON,
paths.agentLog,
paths.errorsLog,
paths.gatewayLog,
paths.projectsRegistry,
// v2.3: sidecar attributing Hermes session IDs to Scarf project
// paths. Written by SessionAttributionService when a chat
// starts with a project context; read by
// ProjectSessionsViewModel to filter the session list. Without
// watching this file, the per-project Sessions tab would only
// pick up new sessions when the user re-entered the tab
// (triggering .task(id:) re-fire) switching directly back
// to the project's Sessions tab after a chat left the tab
// stale.
paths.sessionProjectMap,
paths.mcpTokensDir
]
}
func startWatching() {
if context.isRemote {
startRemotePoller()
return
}
for path in watchedCorePaths {
if let source = makeSource(for: path) {
coreSources.append(source)
}
}
// No heartbeat timer: every observing view runs its `.onChange`
// refresh whenever `lastChangeDate` ticks, so a 5s unconditional
// tick was triggering wasted reloads across many subscribers
// (Dashboard, Memory, Cron, Gateway, Platforms, Projects, Chat).
// FSEvents reliably fires on real changes; menu-bar Start/Stop
// touches `gateway_state.json` which the watcher catches.
}
/// (Re)start the SSH polling stream over the union of `watchedCorePaths`
/// and the current `remoteProjectPaths`. Called on initial start and
/// whenever `updateProjectWatches` changes the project set.
private func startRemotePoller() {
remotePollTask?.cancel()
let stream = transport.watchPaths(watchedCorePaths + remoteProjectPaths)
remotePollTask = Task { [weak self] in
for await _ in stream {
await MainActor.run { [weak self] in
self?.lastChangeDate = Date()
}
}
}
}
func stopWatching() {
for source in coreSources + projectSources {
source.cancel()
}
coreSources.removeAll()
projectSources.removeAll()
timer?.invalidate()
timer = nil
remotePollTask?.cancel()
remotePollTask = nil
}
/// Watch each project's `dashboard.json` AND its enclosing `.scarf/`
/// directory. Watching both is what lets file-reading widgets
/// (markdown_file, log_tail, image) refresh when a cron job rewrites
/// a sidecar file: dir-level FSEvents fire on add/remove/rename inside
/// `.scarf/`, file-level FSEvents fire on dashboard.json content
/// changes. In-place writes to an existing sidecar file (e.g., `>>` log
/// append) are NOT detected by convention the cron job should write
/// atomically (write-then-rename) or `touch dashboard.json` after each
/// run.
func updateProjectWatches(dashboardPaths: [String], scarfDirs: [String]) {
if context.isRemote {
// Restart the SSH poller with the union of core + project dir
// paths. `stat -c %Y` on a directory tracks mtime, which ticks
// on add/remove/rename inside the dir same coverage as the
// local FSEvents directory watch below.
let union = Array(Set(dashboardPaths + scarfDirs))
remoteProjectPaths = union.sorted()
startRemotePoller()
return
}
for source in projectSources {
source.cancel()
}
projectSources.removeAll()
for path in dashboardPaths {
if let source = makeSource(for: path) {
projectSources.append(source)
}
}
for dir in scarfDirs {
if let source = makeSource(for: dir) {
projectSources.append(source)
}
}
}
private func makeSource(for path: String) -> DispatchSourceFileSystemObject? {
let fd = Darwin.open(path, O_EVTONLY)
guard fd >= 0 else { return nil }
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .extend, .rename],
queue: .main
)
source.setEventHandler { [weak self] in
self?.lastChangeDate = Date()
}
source.setCancelHandler {
Darwin.close(fd)
}
source.resume()
return source
}
deinit {
stopWatching()
}
}