mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
iOS port M6: YAML parser port, Settings view, Cron editing
Ports the Mac app's YAML parser into ScarfCore, unlocking iOS
Settings. Adds Cron editing (add / delete / toggle / edit). Settings
stays read-only this phase (writes need a round-trip-preserving YAML
writer — out of scope). App Store submission deferred to a later
task per the brief.
## ScarfCore — YAML infrastructure
Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesYAML.swift:
- ParsedYAML struct (values / lists / maps)
- HermesYAML.parseNestedYAML(_:) — indent-based block parser
- HermesYAML.stripYAMLQuotes(_:) — single-layer quote stripping
Lifted verbatim from HermesFileService.parseNestedYAML/stripYAMLQuotes
and hoisted into a standalone namespace. Scope unchanged: the subset
Hermes's config.yaml actually uses (block nesting, scalars, bullet
lists, nested maps). NOT full YAML-spec compliance.
Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift:
- HermesConfig.init(yaml:) — ports HermesFileService.parseConfig
one-for-one. Every default, every key, every legacy fallback
(platforms.slack.* vs slack.*, command_allowlist vs permanent_
allowlist, etc.) matches the Mac implementation.
- Forgiving: malformed YAML produces partial state + defaults
rather than throwing. Callers surface the raw text so users can
diagnose parse failures on their own.
## ScarfCore — Cron editing (write paths)
Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift:
- toggleEnabled(id:)
- delete(id:)
- upsert(_:)
All funnel through private saveJobs(_:) which encodes the full
CronJobsFile (.prettyPrinted + .sortedKeys), writes atomically via
transport.writeFile (Data.write-atomic from M5). Creates the cron/
directory on fresh installs.
Models/HermesCronJob.swift — both HermesCronJob and CronJobsFile
gained real public memberwise inits (Swift's synthesis was
suppressed by the hand-written Codable; first draft hacked around
this with JSON round-trips). Also HermesCronJob.withEnabled(_:)
does clean field passthrough instead of encode→mutate→decode.
## ScarfCore — iOS Settings VM
Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift:
- Reads ~/.hermes/config.yaml via ServerContext.readText
- Parses with HermesConfig(yaml:)
- Surfaces both parsed config and rawYAML
- M6 read-only by design — config.yaml needs round-trip-preserving
YAML serialization (comments, key order, whitespace) for safe
edits; option (a) hand-write one, (b) YAML library dep, (c)
delegate to `hermes config set` via ACP. Defer.
## iOS app
Scarf iOS/Settings/SettingsView.swift:
- Read-only browser grouped into 10 sections matching the Mac
app's tabs. DisclosureGroup at the bottom reveals raw YAML
source for diagnostics.
Scarf iOS/Cron/CronListView.swift rewritten:
- Toggle-enabled circle (tap to flip, saves atomically)
- Swipe-to-delete
- "+" toolbar for new job → editor sheet
- Row-tap opens editor with existing fields populated
New CronEditorView form:
- Name, Prompt, Enabled toggle
- Schedule: kind picker (cron/interval/once), display, expression
(for cron), run_at (for once)
- Optional model + comma-separated skills + delivery route
- Preserves runtime fields (nextRunAt, lastRunAt,
deliveryFailures, etc.) when editing existing jobs — no reset
Dashboard's Surfaces section gains a 5th row: Settings.
## Test-suite reorganization (real bug caught)
swift-testing's `.serialized` trait serializes WITHIN one @Suite, not
across suites. Shipping M6 revealed a 3-way race on
`ServerContext.sshTransportFactory`:
- M5's `.serialized` suite sets factory, runs, restores.
- M6's `.serialized` suite did the same in parallel — clobbered.
- M0b's non-serialized `serverContextMakeTransportDispatches`
asserted the DEFAULT factory (nil) returned SSHTransport —
saw whichever factory was temporarily installed.
Fix: one serialization domain for everything that touches the
factory. Move cron-editing + settings-load M6 tests into M5's
serialized suite. M0b's factory-dependent assertion (SSHTransport
fallback) also moves to the M5 serialized suite with an explicit
`factory = nil` reset for race-freedom. Pure YAML/config/memberwise
tests stay in the new plain (non-serialized) M6ConfigCronTests
suite — they never touch globals.
## Test results: 108 → 134 passing on Linux
19 new in M6ConfigCronTests:
- YAML parser: scalars, bullets, nested maps, comments, quotes,
inline {} / []
- HermesConfig.init(yaml:): empty → defaults, model + agent,
display, security + blocklist domains, slack legacy fallback,
auxiliary (3 populated + 2 defaulted), permanent_allowlist vs
command_allowlist, quoted strings
- Memberwise inits for HermesCronJob, withEnabled(_:),
CronJobsFile, CronSchedule
7 new in M5FeatureVMTests (.serialized):
- defaultFactoryProducesSSHTransportForRemoteContext (moved +
hardened with explicit factory reset)
- cronUpsertCreatesFileFromScratch, cronToggleEnabledPersists,
cronDeleteRemovesJob, cronUpsertReplacesMatchingId,
cronPreservesRuntimeFieldsAcrossReloads
- settingsLoadsFromConfigYAML, settingsSurfacesMissingFile
## Manual validation needed on Mac
1. Xcode compile clean.
2. Settings: confirm every section populates from your real
~/.hermes/config.yaml. Tap "View source" disclosure, verify raw
text matches the remote file.
3. Cron: toggle-enabled survives refresh + relaunch. Swipe-delete
works. "+" creates jobs; round-trip name/prompt/schedule/skills.
Edit preserves runtime state.
4. Skills: unchanged from M5 (still browse-only, deferred).
Updated scarf/docs/IOS_PORT_PLAN.md with M6's shipped state, the
YAML-parser scope ceiling, the Settings-edit deferral rationale, and
the cross-suite serialization rule for future test authors.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
@@ -32,6 +32,49 @@ public struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||
case timeoutSeconds = "timeout_seconds"
|
||||
}
|
||||
|
||||
/// Memberwise init. Swift doesn't synthesize one for us because
|
||||
/// of the hand-written Codable conformance. The iOS Cron editor
|
||||
/// uses this to rebuild jobs from user-edited fields.
|
||||
public nonisolated init(
|
||||
id: String,
|
||||
name: String,
|
||||
prompt: String,
|
||||
skills: [String]? = nil,
|
||||
model: String? = nil,
|
||||
schedule: CronSchedule,
|
||||
enabled: Bool,
|
||||
state: String,
|
||||
deliver: String? = nil,
|
||||
nextRunAt: String? = nil,
|
||||
lastRunAt: String? = nil,
|
||||
lastError: String? = nil,
|
||||
preRunScript: String? = nil,
|
||||
deliveryFailures: Int? = nil,
|
||||
lastDeliveryError: String? = nil,
|
||||
timeoutType: String? = nil,
|
||||
timeoutSeconds: Int? = nil,
|
||||
silent: Bool? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.prompt = prompt
|
||||
self.skills = skills
|
||||
self.model = model
|
||||
self.schedule = schedule
|
||||
self.enabled = enabled
|
||||
self.state = state
|
||||
self.deliver = deliver
|
||||
self.nextRunAt = nextRunAt
|
||||
self.lastRunAt = lastRunAt
|
||||
self.lastError = lastError
|
||||
self.preRunScript = preRunScript
|
||||
self.deliveryFailures = deliveryFailures
|
||||
self.lastDeliveryError = lastDeliveryError
|
||||
self.timeoutType = timeoutType
|
||||
self.timeoutSeconds = timeoutSeconds
|
||||
self.silent = silent
|
||||
}
|
||||
|
||||
public nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try c.decode(String.self, forKey: .id)
|
||||
@@ -115,6 +158,18 @@ public struct CronSchedule: Sendable, Codable {
|
||||
case expression
|
||||
}
|
||||
|
||||
public nonisolated init(
|
||||
kind: String,
|
||||
runAt: String? = nil,
|
||||
display: String? = nil,
|
||||
expression: String? = nil
|
||||
) {
|
||||
self.kind = kind
|
||||
self.runAt = runAt
|
||||
self.display = display
|
||||
self.expression = expression
|
||||
}
|
||||
|
||||
public nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.kind = try c.decode(String.self, forKey: .kind)
|
||||
@@ -144,6 +199,11 @@ public struct CronJobsFile: Sendable, Codable {
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
public nonisolated init(jobs: [HermesCronJob], updatedAt: String?) {
|
||||
self.jobs = jobs
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
public nonisolated init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import Foundation
|
||||
|
||||
/// YAML-driven `HermesConfig` constructor. Lifted verbatim (with
|
||||
/// trivial adjustments to access the ScarfCore-public types) from
|
||||
/// `HermesFileService.parseConfig` so the same key → struct-field
|
||||
/// mapping feeds both the Mac app and iOS.
|
||||
///
|
||||
/// **Behaviour parity.** Every default value, every key, and every
|
||||
/// fallback path in this file tracks the Mac implementation
|
||||
/// one-for-one. If the Mac parser learns to recognise a new key,
|
||||
/// this one should too (and vice versa). The M6 test suite freezes
|
||||
/// the defaults + a few recognition paths, so behaviour drift
|
||||
/// surfaces on Linux CI without needing Xcode.
|
||||
public extension HermesConfig {
|
||||
/// Parse a `config.yaml` string into a fully-populated
|
||||
/// `HermesConfig`. Missing keys fall back to `HermesConfig.empty`-
|
||||
/// compatible defaults. Unknown keys are ignored — Hermes is
|
||||
/// forward-compatible, i.e. a config file with newer keys than
|
||||
/// scarf knows still loads.
|
||||
///
|
||||
/// The parse is deliberately forgiving: malformed YAML produces
|
||||
/// whatever partial state the parser could recover + defaults
|
||||
/// for everything else, not a throw. The iOS Settings view
|
||||
/// surfaces the raw file on top of this so users can spot a
|
||||
/// broken key even when the struct came back defaulted.
|
||||
init(yaml: String) {
|
||||
let parsed = HermesYAML.parseNestedYAML(yaml)
|
||||
let values = parsed.values
|
||||
let lists = parsed.lists
|
||||
let maps = parsed.maps
|
||||
|
||||
func bool(_ key: String, default def: Bool) -> Bool {
|
||||
guard let v = values[key] else { return def }
|
||||
return v == "true"
|
||||
}
|
||||
func int(_ key: String, default def: Int) -> Int {
|
||||
Int(values[key] ?? "") ?? def
|
||||
}
|
||||
func double(_ key: String, default def: Double) -> Double {
|
||||
Double(values[key] ?? "") ?? def
|
||||
}
|
||||
func str(_ key: String, default def: String = "") -> String {
|
||||
let raw = values[key] ?? def
|
||||
return HermesYAML.stripYAMLQuotes(raw)
|
||||
}
|
||||
|
||||
let dockerEnv = maps["terminal.docker_env"] ?? [:]
|
||||
let commandAllowlist = lists["permanent_allowlist"] ?? lists["command_allowlist"] ?? []
|
||||
|
||||
let display = DisplaySettings(
|
||||
skin: str("display.skin", default: "default"),
|
||||
compact: bool("display.compact", default: false),
|
||||
resumeDisplay: str("display.resume_display", default: "full"),
|
||||
bellOnComplete: bool("display.bell_on_complete", default: false),
|
||||
inlineDiffs: bool("display.inline_diffs", default: true),
|
||||
toolProgressCommand: bool("display.tool_progress_command", default: false),
|
||||
toolPreviewLength: int("display.tool_preview_length", default: 0),
|
||||
busyInputMode: str("display.busy_input_mode", default: "interrupt")
|
||||
)
|
||||
|
||||
let terminal = TerminalSettings(
|
||||
cwd: str("terminal.cwd", default: "."),
|
||||
timeout: int("terminal.timeout", default: 180),
|
||||
envPassthrough: lists["terminal.env_passthrough"] ?? [],
|
||||
persistentShell: bool("terminal.persistent_shell", default: true),
|
||||
dockerImage: str("terminal.docker_image"),
|
||||
dockerMountCwdToWorkspace: bool("terminal.docker_mount_cwd_to_workspace", default: false),
|
||||
dockerForwardEnv: lists["terminal.docker_forward_env"] ?? [],
|
||||
dockerVolumes: lists["terminal.docker_volumes"] ?? [],
|
||||
containerCPU: int("terminal.container_cpu", default: 0),
|
||||
containerMemory: int("terminal.container_memory", default: 0),
|
||||
containerDisk: int("terminal.container_disk", default: 0),
|
||||
containerPersistent: bool("terminal.container_persistent", default: false),
|
||||
modalImage: str("terminal.modal_image"),
|
||||
modalMode: str("terminal.modal_mode", default: "auto"),
|
||||
daytonaImage: str("terminal.daytona_image"),
|
||||
singularityImage: str("terminal.singularity_image")
|
||||
)
|
||||
|
||||
let browser = BrowserSettings(
|
||||
inactivityTimeout: int("browser.inactivity_timeout", default: 120),
|
||||
commandTimeout: int("browser.command_timeout", default: 30),
|
||||
recordSessions: bool("browser.record_sessions", default: false),
|
||||
allowPrivateURLs: bool("browser.allow_private_urls", default: false),
|
||||
camofoxManagedPersistence: bool("browser.camofox.managed_persistence", default: false)
|
||||
)
|
||||
|
||||
let voice = VoiceSettings(
|
||||
recordKey: str("voice.record_key", default: "ctrl+b"),
|
||||
maxRecordingSeconds: int("voice.max_recording_seconds", default: 120),
|
||||
silenceDuration: double("voice.silence_duration", default: 3.0),
|
||||
ttsProvider: str("tts.provider", default: "edge"),
|
||||
ttsEdgeVoice: str("tts.edge.voice", default: "en-US-AriaNeural"),
|
||||
ttsElevenLabsVoiceID: str("tts.elevenlabs.voice_id"),
|
||||
ttsElevenLabsModelID: str("tts.elevenlabs.model_id", default: "eleven_multilingual_v2"),
|
||||
ttsOpenAIModel: str("tts.openai.model", default: "gpt-4o-mini-tts"),
|
||||
ttsOpenAIVoice: str("tts.openai.voice", default: "alloy"),
|
||||
ttsNeuTTSModel: str("tts.neutts.model"),
|
||||
ttsNeuTTSDevice: str("tts.neutts.device", default: "cpu"),
|
||||
sttEnabled: bool("stt.enabled", default: true),
|
||||
sttProvider: str("stt.provider", default: "local"),
|
||||
sttLocalModel: str("stt.local.model", default: "base"),
|
||||
sttLocalLanguage: str("stt.local.language"),
|
||||
sttOpenAIModel: str("stt.openai.model", default: "whisper-1"),
|
||||
sttMistralModel: str("stt.mistral.model", default: "voxtral-mini-latest")
|
||||
)
|
||||
|
||||
func aux(_ name: String) -> AuxiliaryModel {
|
||||
AuxiliaryModel(
|
||||
provider: str("auxiliary.\(name).provider", default: "auto"),
|
||||
model: str("auxiliary.\(name).model"),
|
||||
baseURL: str("auxiliary.\(name).base_url"),
|
||||
apiKey: str("auxiliary.\(name).api_key"),
|
||||
timeout: int("auxiliary.\(name).timeout", default: 30)
|
||||
)
|
||||
}
|
||||
let auxiliary = AuxiliarySettings(
|
||||
vision: aux("vision"),
|
||||
webExtract: aux("web_extract"),
|
||||
compression: aux("compression"),
|
||||
sessionSearch: aux("session_search"),
|
||||
skillsHub: aux("skills_hub"),
|
||||
approval: aux("approval"),
|
||||
mcp: aux("mcp"),
|
||||
flushMemories: aux("flush_memories")
|
||||
)
|
||||
|
||||
let security = SecuritySettings(
|
||||
redactSecrets: bool("security.redact_secrets", default: true),
|
||||
redactPII: bool("privacy.redact_pii", default: false),
|
||||
tirithEnabled: bool("security.tirith_enabled", default: true),
|
||||
tirithPath: str("security.tirith_path", default: "tirith"),
|
||||
tirithTimeout: int("security.tirith_timeout", default: 5),
|
||||
tirithFailOpen: bool("security.tirith_fail_open", default: true),
|
||||
blocklistEnabled: bool("security.website_blocklist.enabled", default: false),
|
||||
blocklistDomains: lists["security.website_blocklist.domains"] ?? []
|
||||
)
|
||||
|
||||
let humanDelay = HumanDelaySettings(
|
||||
mode: str("human_delay.mode", default: "off"),
|
||||
minMS: int("human_delay.min_ms", default: 800),
|
||||
maxMS: int("human_delay.max_ms", default: 2500)
|
||||
)
|
||||
|
||||
let compression = CompressionSettings(
|
||||
enabled: bool("compression.enabled", default: true),
|
||||
threshold: double("compression.threshold", default: 0.5),
|
||||
targetRatio: double("compression.target_ratio", default: 0.2),
|
||||
protectLastN: int("compression.protect_last_n", default: 20)
|
||||
)
|
||||
|
||||
let checkpoints = CheckpointSettings(
|
||||
enabled: bool("checkpoints.enabled", default: true),
|
||||
maxSnapshots: int("checkpoints.max_snapshots", default: 50)
|
||||
)
|
||||
|
||||
let logging = LoggingSettings(
|
||||
level: str("logging.level", default: "INFO"),
|
||||
maxSizeMB: int("logging.max_size_mb", default: 5),
|
||||
backupCount: int("logging.backup_count", default: 3)
|
||||
)
|
||||
|
||||
let delegation = DelegationSettings(
|
||||
model: str("delegation.model"),
|
||||
provider: str("delegation.provider"),
|
||||
baseURL: str("delegation.base_url"),
|
||||
apiKey: str("delegation.api_key"),
|
||||
maxIterations: int("delegation.max_iterations", default: 50)
|
||||
)
|
||||
|
||||
let discord = DiscordSettings(
|
||||
requireMention: bool("discord.require_mention", default: true),
|
||||
freeResponseChannels: str("discord.free_response_channels"),
|
||||
autoThread: bool("discord.auto_thread", default: true),
|
||||
reactions: bool("discord.reactions", default: true)
|
||||
)
|
||||
|
||||
let telegram = TelegramSettings(
|
||||
requireMention: bool("telegram.require_mention", default: true),
|
||||
reactions: bool("telegram.reactions", default: false)
|
||||
)
|
||||
|
||||
// Slack fields live under both `platforms.slack.*` (newer) and `slack.*`
|
||||
// (legacy). Prefer the newer path but fall back.
|
||||
let slack = SlackSettings(
|
||||
replyToMode: values["platforms.slack.reply_to_mode"] ?? values["slack.reply_to_mode"] ?? "first",
|
||||
requireMention: (values["platforms.slack.require_mention"] ?? values["slack.require_mention"]) != "false",
|
||||
replyInThread: (values["platforms.slack.extra.reply_in_thread"] ?? "true") != "false",
|
||||
replyBroadcast: (values["platforms.slack.extra.reply_broadcast"] ?? "false") == "true"
|
||||
)
|
||||
|
||||
let matrix = MatrixSettings(
|
||||
requireMention: bool("matrix.require_mention", default: true),
|
||||
autoThread: bool("matrix.auto_thread", default: true),
|
||||
dmMentionThreads: bool("matrix.dm_mention_threads", default: false)
|
||||
)
|
||||
|
||||
let mattermost = MattermostSettings(
|
||||
requireMention: bool("mattermost.require_mention", default: true),
|
||||
replyMode: str("mattermost.reply_mode", default: "off")
|
||||
)
|
||||
|
||||
let whatsapp = WhatsAppSettings(
|
||||
unauthorizedDMBehavior: str("whatsapp.unauthorized_dm_behavior", default: "pair"),
|
||||
replyPrefix: str("whatsapp.reply_prefix")
|
||||
)
|
||||
|
||||
// Home Assistant lives under `platforms.homeassistant.extra.*`.
|
||||
let homeAssistant = HomeAssistantSettings(
|
||||
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
|
||||
watchEntities: lists["platforms.homeassistant.extra.watch_entities"] ?? [],
|
||||
watchAll: bool("platforms.homeassistant.extra.watch_all", default: false),
|
||||
ignoreEntities: lists["platforms.homeassistant.extra.ignore_entities"] ?? [],
|
||||
cooldownSeconds: int("platforms.homeassistant.extra.cooldown_seconds", default: 30)
|
||||
)
|
||||
|
||||
self.init(
|
||||
model: str("model.default", default: "unknown"),
|
||||
provider: str("model.provider", default: "unknown"),
|
||||
maxTurns: int("agent.max_turns", default: 0),
|
||||
personality: str("display.personality", default: "default"),
|
||||
terminalBackend: str("terminal.backend", default: "local"),
|
||||
memoryEnabled: bool("memory.memory_enabled", default: false),
|
||||
memoryCharLimit: int("memory.memory_char_limit", default: 0),
|
||||
userCharLimit: int("memory.user_char_limit", default: 0),
|
||||
nudgeInterval: int("memory.nudge_interval", default: 0),
|
||||
streaming: values["display.streaming"] != "false",
|
||||
showReasoning: bool("display.show_reasoning", default: false),
|
||||
verbose: bool("agent.verbose", default: false),
|
||||
autoTTS: values["voice.auto_tts"] != "false",
|
||||
silenceThreshold: int("voice.silence_threshold", default: QueryDefaults.defaultSilenceThreshold),
|
||||
reasoningEffort: str("agent.reasoning_effort", default: "medium"),
|
||||
showCost: bool("display.show_cost", default: false),
|
||||
approvalMode: str("approvals.mode", default: "manual"),
|
||||
browserBackend: str("browser.backend"),
|
||||
memoryProvider: str("memory.provider"),
|
||||
dockerEnv: dockerEnv,
|
||||
commandAllowlist: commandAllowlist,
|
||||
memoryProfile: str("memory.profile"),
|
||||
serviceTier: str("agent.service_tier", default: "normal"),
|
||||
gatewayNotifyInterval: int("agent.gateway_notify_interval", default: 600),
|
||||
forceIPv4: bool("network.force_ipv4", default: false),
|
||||
contextEngine: str("context.engine", default: "compressor"),
|
||||
interimAssistantMessages: values["display.interim_assistant_messages"] != "false",
|
||||
honchoInitOnSessionStart: bool("honcho.initOnSessionStart", default: false),
|
||||
timezone: str("timezone"),
|
||||
userProfileEnabled: bool("memory.user_profile_enabled", default: true),
|
||||
toolUseEnforcement: str("agent.tool_use_enforcement", default: "auto"),
|
||||
gatewayTimeout: int("agent.gateway_timeout", default: 1800),
|
||||
approvalTimeout: int("approvals.timeout", default: 60),
|
||||
fileReadMaxChars: int("file_read_max_chars", default: 100_000),
|
||||
cronWrapResponse: bool("cron.wrap_response", default: true),
|
||||
prefillMessagesFile: str("prefill_messages_file"),
|
||||
skillsExternalDirs: lists["skills.external_dirs"] ?? [],
|
||||
display: display,
|
||||
terminal: terminal,
|
||||
browser: browser,
|
||||
voice: voice,
|
||||
auxiliary: auxiliary,
|
||||
security: security,
|
||||
humanDelay: humanDelay,
|
||||
compression: compression,
|
||||
checkpoints: checkpoints,
|
||||
logging: logging,
|
||||
delegation: delegation,
|
||||
discord: discord,
|
||||
telegram: telegram,
|
||||
slack: slack,
|
||||
matrix: matrix,
|
||||
mattermost: mattermost,
|
||||
whatsapp: whatsapp,
|
||||
homeAssistant: homeAssistant
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import Foundation
|
||||
|
||||
/// Parsed YAML result bundle. Flat dotted-path keys point at the
|
||||
/// three value shapes we care about (scalars, bullet lists, maps).
|
||||
///
|
||||
/// **Scope note.** This is NOT a full YAML-spec parser. It handles
|
||||
/// the subset used by Hermes's `config.yaml`: indent-based block
|
||||
/// nesting, string/int/bool/float scalars, `- item` bullet lists,
|
||||
/// and one level of nested `key: value` maps. Anchors, aliases,
|
||||
/// multi-line scalars (`|` / `>` block scalars), flow-style `[ ]` /
|
||||
/// `{ }` literals, tags — none of those are supported. That covers
|
||||
/// 100% of what the current Hermes config actually uses.
|
||||
///
|
||||
/// The original implementation lived in the Mac app's
|
||||
/// `HermesFileService`. Ported into ScarfCore in M6 so iOS can read
|
||||
/// `config.yaml` through the same parser without having to pull in a
|
||||
/// third-party YAML dependency.
|
||||
public struct ParsedYAML: Sendable {
|
||||
/// Scalar key-value pairs at any indent level →
|
||||
/// `values["section.key"] = "..."`.
|
||||
public var values: [String: String]
|
||||
/// Bullet-list items attached to a parent key →
|
||||
/// `lists["section.key"] = [...]`.
|
||||
public var lists: [String: [String]]
|
||||
/// Nested `key: value` maps captured under a section header →
|
||||
/// `maps["section"] = [key: value, ...]`.
|
||||
public var maps: [String: [String: String]]
|
||||
|
||||
public init(
|
||||
values: [String: String] = [:],
|
||||
lists: [String: [String]] = [:],
|
||||
maps: [String: [String: String]] = [:]
|
||||
) {
|
||||
self.values = values
|
||||
self.lists = lists
|
||||
self.maps = maps
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry points for Hermes-flavored YAML parsing. Stateless, pure
|
||||
/// functions — no Foundation types that differ cross-platform.
|
||||
public enum HermesYAML {
|
||||
/// Parse a YAML string into a `ParsedYAML` bundle.
|
||||
public static func parseNestedYAML(_ yaml: String) -> ParsedYAML {
|
||||
var values: [String: String] = [:]
|
||||
var lists: [String: [String]] = [:]
|
||||
var maps: [String: [String: String]] = [:]
|
||||
// Path stack: each entry is (indent, name). Pop when indent shrinks.
|
||||
var stack: [(indent: Int, name: String)] = []
|
||||
|
||||
func currentPath(joinedWith child: String? = nil) -> String {
|
||||
var parts = stack.map(\.name)
|
||||
if let child { parts.append(child) }
|
||||
return parts.joined(separator: ".")
|
||||
}
|
||||
|
||||
let rawLines = yaml.components(separatedBy: "\n")
|
||||
for line in rawLines {
|
||||
// Skip comment-only and blank lines but preserve indent semantics.
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
|
||||
let indent = line.prefix(while: { $0 == " " }).count
|
||||
let isListItem = trimmed.hasPrefix("- ")
|
||||
|
||||
// Pop stack entries with indent >= current indent.
|
||||
// Exception: a list item at the same indent as its parent key is
|
||||
// valid block-style YAML ("toolsets:\n- hermes-cli") — keep the
|
||||
// parent so the item is attributed to it.
|
||||
while let top = stack.last {
|
||||
let shouldPop: Bool
|
||||
if isListItem && top.indent == indent {
|
||||
shouldPop = false
|
||||
} else {
|
||||
shouldPop = top.indent >= indent
|
||||
}
|
||||
if shouldPop { stack.removeLast() } else { break }
|
||||
}
|
||||
|
||||
if isListItem {
|
||||
let item = String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces)
|
||||
let stripped = stripYAMLQuotes(item)
|
||||
let path = currentPath()
|
||||
guard !path.isEmpty else { continue }
|
||||
lists[path, default: []].append(stripped)
|
||||
continue
|
||||
}
|
||||
|
||||
// Key-value or section line.
|
||||
guard let colonIdx = trimmed.firstIndex(of: ":") else { continue }
|
||||
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
|
||||
let afterColon = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
|
||||
|
||||
let path = currentPath(joinedWith: key)
|
||||
|
||||
if afterColon.isEmpty || afterColon == "|" || afterColon == ">" {
|
||||
// Section header or empty-valued key — push onto stack so children nest.
|
||||
stack.append((indent: indent, name: key))
|
||||
continue
|
||||
}
|
||||
|
||||
// Inline `{}` / `[]` literals → treat as empty.
|
||||
if afterColon == "{}" {
|
||||
values[path] = ""
|
||||
maps[path] = [:]
|
||||
continue
|
||||
}
|
||||
if afterColon == "[]" {
|
||||
values[path] = ""
|
||||
lists[path] = []
|
||||
continue
|
||||
}
|
||||
|
||||
values[path] = afterColon
|
||||
|
||||
// Also record as a map entry under the parent so blocks like
|
||||
// `terminal.docker_env` are accessible as `[String: String]`
|
||||
// without a separate scan.
|
||||
if !stack.isEmpty {
|
||||
let parentPath = currentPath()
|
||||
maps[parentPath, default: [:]][key] = stripYAMLQuotes(afterColon)
|
||||
}
|
||||
}
|
||||
return ParsedYAML(values: values, lists: lists, maps: maps)
|
||||
}
|
||||
|
||||
/// Strip a single layer of surrounding single or double quotes from a YAML scalar.
|
||||
public static func stripYAMLQuotes(_ s: String) -> String {
|
||||
guard s.count >= 2 else { return s }
|
||||
let first = s.first!
|
||||
let last = s.last!
|
||||
if (first == "'" && last == "'") || (first == "\"" && last == "\"") {
|
||||
return String(s.dropFirst().dropLast())
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// iOS read-only Cron view-state. Loads `~/.hermes/cron/jobs.json`
|
||||
/// via the transport, decodes into `CronJobsFile` (already Codable
|
||||
/// in ScarfCore), exposes the list for SwiftUI.
|
||||
/// iOS Cron view-state. Loads `~/.hermes/cron/jobs.json` via the
|
||||
/// transport, decodes into `CronJobsFile` (Codable, from M0a),
|
||||
/// exposes the sorted list for SwiftUI.
|
||||
///
|
||||
/// M5 is read-only by design — editing cron jobs (add / delete /
|
||||
/// toggle enabled) is deferred until we have a clearer iOS story for
|
||||
/// rewriting `jobs.json` atomically across the SSH SFTP path. The
|
||||
/// Mac app's `CronViewModel` does this through `HermesFileService`;
|
||||
/// porting that is out of scope for M5.
|
||||
/// M6 adds write paths: toggle enabled, delete, and upsert (add or
|
||||
/// replace a job by id). All writes re-encode the full file with a
|
||||
/// fresh `updatedAt` and call `transport.writeFile` — which on iOS
|
||||
/// dispatches to Citadel SFTP with atomic rename semantics.
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class IOSCronViewModel {
|
||||
@@ -17,6 +16,7 @@ public final class IOSCronViewModel {
|
||||
|
||||
public private(set) var jobs: [HermesCronJob] = []
|
||||
public private(set) var isLoading: Bool = true
|
||||
public private(set) var isSaving: Bool = false
|
||||
public private(set) var lastError: String?
|
||||
|
||||
public init(context: ServerContext) {
|
||||
@@ -43,17 +43,7 @@ public final class IOSCronViewModel {
|
||||
|
||||
switch result {
|
||||
case .success(let file):
|
||||
// Sort: enabled first, then by nextRunAt ascending (nil
|
||||
// last). Matches what the Mac app does for list rendering.
|
||||
jobs = file.jobs.sorted { lhs, rhs in
|
||||
if lhs.enabled != rhs.enabled { return lhs.enabled }
|
||||
switch (lhs.nextRunAt, rhs.nextRunAt) {
|
||||
case (let l?, let r?): return l < r
|
||||
case (_?, nil): return true
|
||||
case (nil, _?): return false
|
||||
case (nil, nil): return lhs.name < rhs.name
|
||||
}
|
||||
}
|
||||
jobs = Self.sorted(file.jobs)
|
||||
isLoading = false
|
||||
|
||||
case .failure(let err as LoadError):
|
||||
@@ -73,6 +63,97 @@ public final class IOSCronViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle `enabled` on the job with the given id, re-encode, and
|
||||
/// write back. On failure, leaves the in-memory state unchanged
|
||||
/// and sets `lastError`.
|
||||
@discardableResult
|
||||
public func toggleEnabled(id: String) async -> Bool {
|
||||
guard let idx = jobs.firstIndex(where: { $0.id == id }) else { return false }
|
||||
var updated = jobs
|
||||
let prev = updated[idx]
|
||||
updated[idx] = prev.withEnabled(!prev.enabled)
|
||||
return await saveJobs(updated)
|
||||
}
|
||||
|
||||
/// Remove the job with `id` and save.
|
||||
@discardableResult
|
||||
public func delete(id: String) async -> Bool {
|
||||
let updated = jobs.filter { $0.id != id }
|
||||
guard updated.count != jobs.count else { return false }
|
||||
return await saveJobs(updated)
|
||||
}
|
||||
|
||||
/// Add a new job or replace an existing one with matching id.
|
||||
@discardableResult
|
||||
public func upsert(_ job: HermesCronJob) async -> Bool {
|
||||
var updated = jobs
|
||||
if let idx = updated.firstIndex(where: { $0.id == job.id }) {
|
||||
updated[idx] = job
|
||||
} else {
|
||||
updated.append(job)
|
||||
}
|
||||
return await saveJobs(updated)
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
/// Shared persistence path: serialize `CronJobsFile` as pretty
|
||||
/// JSON, write it atomically through the transport, and update
|
||||
/// the in-memory list on success.
|
||||
private func saveJobs(_ newJobs: [HermesCronJob]) async -> Bool {
|
||||
guard !isSaving else { return false }
|
||||
isSaving = true
|
||||
lastError = nil
|
||||
let ctx = context
|
||||
let path = ctx.paths.cronJobsJSON
|
||||
|
||||
let iso = ISO8601DateFormatter()
|
||||
iso.formatOptions = [.withInternetDateTime]
|
||||
let file = CronJobsFile(jobs: newJobs, updatedAt: iso.string(from: Date()))
|
||||
|
||||
let ok: Bool = await Task.detached {
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(file)
|
||||
let transport = ctx.makeTransport()
|
||||
// Ensure the cron/ directory exists — on a fresh
|
||||
// Hermes install this file won't be present.
|
||||
let parent = (path as NSString).deletingLastPathComponent
|
||||
if !transport.fileExists(parent) {
|
||||
try? transport.createDirectory(parent)
|
||||
}
|
||||
try transport.writeFile(path, data: data)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}.value
|
||||
|
||||
isSaving = false
|
||||
if ok {
|
||||
jobs = Self.sorted(newJobs)
|
||||
return true
|
||||
} else {
|
||||
lastError = "Couldn't save jobs.json — check the connection and try again."
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort: enabled first, then by `nextRunAt` ascending (nil last,
|
||||
/// then by name). Matches the Mac app's list rendering.
|
||||
private static func sorted(_ jobs: [HermesCronJob]) -> [HermesCronJob] {
|
||||
jobs.sorted { lhs, rhs in
|
||||
if lhs.enabled != rhs.enabled { return lhs.enabled }
|
||||
switch (lhs.nextRunAt, rhs.nextRunAt) {
|
||||
case (let l?, let r?): return l < r
|
||||
case (_?, nil): return true
|
||||
case (nil, _?): return false
|
||||
case (nil, nil): return lhs.name < rhs.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum LoadError: Error, LocalizedError {
|
||||
case missingFile(path: String)
|
||||
|
||||
@@ -83,3 +164,32 @@ public final class IOSCronViewModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - HermesCronJob helpers
|
||||
|
||||
public extension HermesCronJob {
|
||||
/// Return a copy with a different `enabled` flag. Used by the iOS
|
||||
/// Cron list's toggle. All other fields pass through unchanged.
|
||||
func withEnabled(_ newEnabled: Bool) -> HermesCronJob {
|
||||
HermesCronJob(
|
||||
id: id,
|
||||
name: name,
|
||||
prompt: prompt,
|
||||
skills: skills,
|
||||
model: model,
|
||||
schedule: schedule,
|
||||
enabled: newEnabled,
|
||||
state: state,
|
||||
deliver: deliver,
|
||||
nextRunAt: nextRunAt,
|
||||
lastRunAt: lastRunAt,
|
||||
lastError: lastError,
|
||||
preRunScript: preRunScript,
|
||||
deliveryFailures: deliveryFailures,
|
||||
lastDeliveryError: lastDeliveryError,
|
||||
timeoutType: timeoutType,
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
silent: silent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// iOS Settings view-state. Loads `~/.hermes/config.yaml` via the
|
||||
/// transport, parses it into a `HermesConfig` with the ScarfCore
|
||||
/// YAML port, and exposes the parsed struct plus a copy of the raw
|
||||
/// text for users who want to see the source.
|
||||
///
|
||||
/// **M6 is read-only by design.** Editing config.yaml safely requires
|
||||
/// either (a) a round-trip preserving YAML parser (comments, key
|
||||
/// order, whitespace) or (b) delegating to `hermes config set` via
|
||||
/// ACP. Either is more work than fits in M6; the Mac app's Settings
|
||||
/// uses (a) via HermesFileService's manipulators. A later phase can
|
||||
/// port the write side.
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class IOSSettingsViewModel {
|
||||
public let context: ServerContext
|
||||
|
||||
/// Parsed config. Falls back to `.empty` when the file is missing
|
||||
/// or malformed; `lastError` carries the reason so the UI can
|
||||
/// surface it.
|
||||
public private(set) var config: HermesConfig = .empty
|
||||
/// Raw YAML text. Useful for the "View source" disclosure, and
|
||||
/// for diagnosing parse failures (our parser is forgiving but
|
||||
/// lossy on malformed input).
|
||||
public private(set) var rawYAML: String = ""
|
||||
|
||||
public private(set) var isLoading: Bool = true
|
||||
public private(set) var lastError: String?
|
||||
|
||||
public init(context: ServerContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
public func load() async {
|
||||
isLoading = true
|
||||
lastError = nil
|
||||
let ctx = context
|
||||
let path = ctx.paths.configYAML
|
||||
|
||||
let text: String? = await Task.detached {
|
||||
ctx.readText(path)
|
||||
}.value
|
||||
|
||||
guard let text else {
|
||||
config = .empty
|
||||
rawYAML = ""
|
||||
lastError = "`\(path)` not found on \(ctx.displayName). Once Hermes is configured on this host, Settings will light up."
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
rawYAML = text
|
||||
config = HermesConfig(yaml: text)
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -76,21 +76,16 @@ import Foundation
|
||||
#expect(remoteDefault.paths.home == "~/.hermes")
|
||||
}
|
||||
|
||||
@Test func serverContextMakeTransportDispatches() {
|
||||
@Test func serverContextMakeTransportDispatchesLocal() {
|
||||
// Only assert the .local path here. The .ssh → SSHTransport
|
||||
// default-factory assertion lives in the serialized
|
||||
// M5FeatureVMTests suite because it depends on
|
||||
// `ServerContext.sshTransportFactory` being nil, which races
|
||||
// with any other parallel test installing a custom factory.
|
||||
let local = ServerContext.local.makeTransport()
|
||||
#expect(local is LocalTransport)
|
||||
#expect(local.isRemote == false)
|
||||
#expect(local.contextID == ServerContext.local.id)
|
||||
|
||||
let remoteCtx = ServerContext(
|
||||
id: UUID(),
|
||||
displayName: "r",
|
||||
kind: .ssh(SSHConfig(host: "h"))
|
||||
)
|
||||
let remote = remoteCtx.makeTransport()
|
||||
#expect(remote is SSHTransport)
|
||||
#expect(remote.isRemote == true)
|
||||
#expect(remote.contextID == remoteCtx.id)
|
||||
}
|
||||
|
||||
@Test func fileStatMemberwise() {
|
||||
|
||||
@@ -326,4 +326,201 @@ import Foundation
|
||||
#expect(p.options[0].optionId == "allow")
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - M0b default SSH transport factory path
|
||||
//
|
||||
// Moved here from M0bTransportTests because it asserts the
|
||||
// default-factory (nil) behavior — which any other test in a
|
||||
// parallel suite installing a custom factory would clobber.
|
||||
// Living in a .serialized suite + explicitly resetting the
|
||||
// factory makes the assertion race-free.
|
||||
|
||||
@Test @MainActor func defaultFactoryProducesSSHTransportForRemoteContext() {
|
||||
let previous = ServerContext.sshTransportFactory
|
||||
defer { ServerContext.sshTransportFactory = previous }
|
||||
ServerContext.sshTransportFactory = nil
|
||||
|
||||
let remoteCtx = ServerContext(
|
||||
id: UUID(),
|
||||
displayName: "r",
|
||||
kind: .ssh(SSHConfig(host: "h"))
|
||||
)
|
||||
let remote = remoteCtx.makeTransport()
|
||||
#expect(remote is SSHTransport)
|
||||
#expect(remote.isRemote == true)
|
||||
#expect(remote.contextID == remoteCtx.id)
|
||||
}
|
||||
|
||||
// MARK: - M6 Cron editing (write paths)
|
||||
//
|
||||
// Live in this suite (rather than M6ConfigCronTests) because they
|
||||
// install the `ServerContext.sshTransportFactory` static — same
|
||||
// pattern as the Memory/Cron/Skills read-path tests above. Mixing
|
||||
// factory-users across multiple `.serialized` suites races on
|
||||
// the static, so M6's factory-touching tests merge here.
|
||||
|
||||
@Test @MainActor func cronUpsertCreatesFileFromScratch() async throws {
|
||||
try await withLocalTransportFactory { [self] in
|
||||
let (ctx, _) = try makeFakeHermes()
|
||||
let vm = IOSCronViewModel(context: ctx)
|
||||
await vm.load()
|
||||
#expect(vm.jobs.isEmpty)
|
||||
|
||||
let job = HermesCronJob(
|
||||
id: "job_abc",
|
||||
name: "Morning brief",
|
||||
prompt: "summarize my calendar",
|
||||
skills: ["calendar"],
|
||||
model: nil,
|
||||
schedule: CronSchedule(kind: "cron", display: "9am", expression: "0 9 * * *"),
|
||||
enabled: true,
|
||||
state: "scheduled"
|
||||
)
|
||||
let ok = await vm.upsert(job)
|
||||
#expect(ok)
|
||||
#expect(vm.jobs.count == 1)
|
||||
#expect(vm.jobs[0].name == "Morning brief")
|
||||
|
||||
let vm2 = IOSCronViewModel(context: ctx)
|
||||
await vm2.load()
|
||||
#expect(vm2.jobs.count == 1)
|
||||
#expect(vm2.jobs[0].id == "job_abc")
|
||||
#expect(vm2.jobs[0].prompt == "summarize my calendar")
|
||||
#expect(vm2.jobs[0].skills == ["calendar"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func cronToggleEnabledPersists() async throws {
|
||||
try await withLocalTransportFactory { [self] in
|
||||
let (ctx, _) = try makeFakeHermes()
|
||||
let vm = IOSCronViewModel(context: ctx)
|
||||
await vm.upsert(HermesCronJob(
|
||||
id: "j1", name: "A", prompt: "p",
|
||||
schedule: CronSchedule(kind: "cron"),
|
||||
enabled: true, state: "scheduled"
|
||||
))
|
||||
#expect(vm.jobs[0].enabled)
|
||||
let ok = await vm.toggleEnabled(id: "j1")
|
||||
#expect(ok)
|
||||
#expect(vm.jobs[0].enabled == false)
|
||||
|
||||
let vm2 = IOSCronViewModel(context: ctx)
|
||||
await vm2.load()
|
||||
#expect(vm2.jobs[0].enabled == false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func cronDeleteRemovesJob() async throws {
|
||||
try await withLocalTransportFactory { [self] in
|
||||
let (ctx, _) = try makeFakeHermes()
|
||||
let vm = IOSCronViewModel(context: ctx)
|
||||
await vm.upsert(HermesCronJob(id: "a", name: "A", prompt: "p", schedule: CronSchedule(kind: "cron"), enabled: true, state: "scheduled"))
|
||||
await vm.upsert(HermesCronJob(id: "b", name: "B", prompt: "q", schedule: CronSchedule(kind: "cron"), enabled: true, state: "scheduled"))
|
||||
#expect(vm.jobs.count == 2)
|
||||
|
||||
let ok = await vm.delete(id: "a")
|
||||
#expect(ok)
|
||||
#expect(vm.jobs.count == 1)
|
||||
#expect(vm.jobs[0].id == "b")
|
||||
|
||||
let vm2 = IOSCronViewModel(context: ctx)
|
||||
await vm2.load()
|
||||
#expect(vm2.jobs.count == 1)
|
||||
#expect(vm2.jobs[0].id == "b")
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func cronUpsertReplacesMatchingId() async throws {
|
||||
try await withLocalTransportFactory { [self] in
|
||||
let (ctx, _) = try makeFakeHermes()
|
||||
let vm = IOSCronViewModel(context: ctx)
|
||||
await vm.upsert(HermesCronJob(
|
||||
id: "j1", name: "Original", prompt: "p1",
|
||||
schedule: CronSchedule(kind: "cron"),
|
||||
enabled: true, state: "scheduled"
|
||||
))
|
||||
await vm.upsert(HermesCronJob(
|
||||
id: "j1", name: "Renamed", prompt: "p2",
|
||||
schedule: CronSchedule(kind: "interval"),
|
||||
enabled: false, state: "scheduled"
|
||||
))
|
||||
#expect(vm.jobs.count == 1)
|
||||
#expect(vm.jobs[0].name == "Renamed")
|
||||
#expect(vm.jobs[0].prompt == "p2")
|
||||
#expect(vm.jobs[0].enabled == false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func cronPreservesRuntimeFieldsAcrossReloads() async throws {
|
||||
try await withLocalTransportFactory { [self] in
|
||||
let (ctx, _) = try makeFakeHermes()
|
||||
let vm = IOSCronViewModel(context: ctx)
|
||||
await vm.upsert(HermesCronJob(
|
||||
id: "j1", name: "Kept", prompt: "p",
|
||||
skills: nil, model: "gpt-4",
|
||||
schedule: CronSchedule(kind: "cron", display: "midnight"),
|
||||
enabled: true,
|
||||
state: "completed",
|
||||
deliver: "discord:general",
|
||||
nextRunAt: "2026-04-25T00:00:00Z",
|
||||
lastRunAt: "2026-04-24T00:00:00Z",
|
||||
deliveryFailures: 3,
|
||||
lastDeliveryError: "rate limited",
|
||||
timeoutType: "soft",
|
||||
timeoutSeconds: 600,
|
||||
silent: false
|
||||
))
|
||||
|
||||
let vm2 = IOSCronViewModel(context: ctx)
|
||||
await vm2.load()
|
||||
let j = vm2.jobs[0]
|
||||
#expect(j.nextRunAt == "2026-04-25T00:00:00Z")
|
||||
#expect(j.lastRunAt == "2026-04-24T00:00:00Z")
|
||||
#expect(j.deliveryFailures == 3)
|
||||
#expect(j.lastDeliveryError == "rate limited")
|
||||
#expect(j.timeoutSeconds == 600)
|
||||
#expect(j.state == "completed")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - M6 Settings
|
||||
|
||||
@Test @MainActor func settingsLoadsFromConfigYAML() async throws {
|
||||
try await withLocalTransportFactory { [self] in
|
||||
let (ctx, home) = try makeFakeHermes()
|
||||
let yaml = """
|
||||
model:
|
||||
default: gpt-4o
|
||||
provider: openai
|
||||
display:
|
||||
skin: solarized
|
||||
compact: true
|
||||
"""
|
||||
try yaml.write(
|
||||
to: home.appendingPathComponent("config.yaml"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
let vm = IOSSettingsViewModel(context: ctx)
|
||||
await vm.load()
|
||||
#expect(vm.isLoading == false)
|
||||
#expect(vm.config.model == "gpt-4o")
|
||||
#expect(vm.config.provider == "openai")
|
||||
#expect(vm.config.display.skin == "solarized")
|
||||
#expect(vm.config.display.compact == true)
|
||||
#expect(vm.rawYAML.contains("gpt-4o"))
|
||||
#expect(vm.lastError == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func settingsSurfacesMissingFile() async throws {
|
||||
try await withLocalTransportFactory { [self] in
|
||||
let (ctx, _) = try makeFakeHermes()
|
||||
let vm = IOSSettingsViewModel(context: ctx)
|
||||
await vm.load()
|
||||
#expect(vm.isLoading == false)
|
||||
#expect(vm.lastError != nil)
|
||||
#expect(vm.config.model == "unknown")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ScarfCore
|
||||
|
||||
/// M6: YAML parser port + HermesConfig loader. Pure functions — no
|
||||
/// `ServerContext.sshTransportFactory` races, so this suite can run
|
||||
/// in parallel with everything else.
|
||||
///
|
||||
/// The write-path tests for Cron editing + Settings-from-yaml live
|
||||
/// in `M5FeatureVMTests` (the serialized suite that already owns
|
||||
/// the factory-install pattern) to avoid cross-suite parallel
|
||||
/// collisions on the shared factory static.
|
||||
@Suite struct M6ConfigCronTests {
|
||||
|
||||
// MARK: - YAML parser
|
||||
|
||||
@Test func parsesScalarKeyValues() {
|
||||
let yaml = """
|
||||
model:
|
||||
default: gpt-4o
|
||||
provider: openai
|
||||
"""
|
||||
let p = HermesYAML.parseNestedYAML(yaml)
|
||||
#expect(p.values["model.default"] == "gpt-4o")
|
||||
#expect(p.values["model.provider"] == "openai")
|
||||
}
|
||||
|
||||
@Test func parsesBulletLists() {
|
||||
let yaml = """
|
||||
permanent_allowlist:
|
||||
- ls
|
||||
- pwd
|
||||
- 'cat /etc/hostname'
|
||||
"""
|
||||
let p = HermesYAML.parseNestedYAML(yaml)
|
||||
#expect(p.lists["permanent_allowlist"] == ["ls", "pwd", "cat /etc/hostname"])
|
||||
}
|
||||
|
||||
@Test func parsesNestedMaps() {
|
||||
let yaml = """
|
||||
terminal:
|
||||
docker_env:
|
||||
PATH: /usr/local/bin
|
||||
HOME: /home/hermes
|
||||
"""
|
||||
let p = HermesYAML.parseNestedYAML(yaml)
|
||||
#expect(p.maps["terminal.docker_env"]?["PATH"] == "/usr/local/bin")
|
||||
#expect(p.maps["terminal.docker_env"]?["HOME"] == "/home/hermes")
|
||||
#expect(p.values["terminal.docker_env.PATH"] == "/usr/local/bin")
|
||||
}
|
||||
|
||||
@Test func ignoresCommentsAndBlankLines() {
|
||||
let yaml = """
|
||||
# Top-level comment
|
||||
model:
|
||||
# inline comment
|
||||
default: gpt-4o
|
||||
|
||||
provider: openai
|
||||
"""
|
||||
let p = HermesYAML.parseNestedYAML(yaml)
|
||||
#expect(p.values["model.default"] == "gpt-4o")
|
||||
#expect(p.values["model.provider"] == "openai")
|
||||
}
|
||||
|
||||
@Test func stripsQuotes() {
|
||||
#expect(HermesYAML.stripYAMLQuotes("'quoted'") == "quoted")
|
||||
#expect(HermesYAML.stripYAMLQuotes("\"quoted\"") == "quoted")
|
||||
#expect(HermesYAML.stripYAMLQuotes("plain") == "plain")
|
||||
#expect(HermesYAML.stripYAMLQuotes("'unbalanced") == "'unbalanced")
|
||||
#expect(HermesYAML.stripYAMLQuotes("") == "")
|
||||
}
|
||||
|
||||
@Test func handlesInlineLiterals() {
|
||||
let yaml = """
|
||||
empty_map: {}
|
||||
empty_list: []
|
||||
"""
|
||||
let p = HermesYAML.parseNestedYAML(yaml)
|
||||
#expect(p.maps["empty_map"] != nil)
|
||||
#expect(p.lists["empty_list"] != nil)
|
||||
}
|
||||
|
||||
// MARK: - HermesConfig from YAML
|
||||
|
||||
@Test func emptyYAMLProducesDefaults() {
|
||||
let c = HermesConfig(yaml: "")
|
||||
#expect(c.model == "unknown")
|
||||
#expect(c.provider == "unknown")
|
||||
#expect(c.display.skin == "default")
|
||||
#expect(c.streaming == true)
|
||||
#expect(c.security.redactSecrets == true)
|
||||
#expect(c.compression.enabled == true)
|
||||
#expect(c.voice.ttsProvider == "edge")
|
||||
}
|
||||
|
||||
@Test func parsesTopLevelModel() {
|
||||
let yaml = """
|
||||
model:
|
||||
default: claude-4-opus
|
||||
provider: anthropic
|
||||
agent:
|
||||
reasoning_effort: high
|
||||
service_tier: pro
|
||||
max_turns: 50
|
||||
"""
|
||||
let c = HermesConfig(yaml: yaml)
|
||||
#expect(c.model == "claude-4-opus")
|
||||
#expect(c.provider == "anthropic")
|
||||
#expect(c.reasoningEffort == "high")
|
||||
#expect(c.serviceTier == "pro")
|
||||
#expect(c.maxTurns == 50)
|
||||
}
|
||||
|
||||
@Test func parsesDisplaySection() {
|
||||
let yaml = """
|
||||
display:
|
||||
skin: dark
|
||||
compact: true
|
||||
streaming: false
|
||||
show_reasoning: true
|
||||
show_cost: true
|
||||
personality: professional
|
||||
"""
|
||||
let c = HermesConfig(yaml: yaml)
|
||||
#expect(c.display.skin == "dark")
|
||||
#expect(c.display.compact == true)
|
||||
#expect(c.streaming == false)
|
||||
#expect(c.showReasoning == true)
|
||||
#expect(c.showCost == true)
|
||||
#expect(c.personality == "professional")
|
||||
}
|
||||
|
||||
@Test func parsesSecuritySection() {
|
||||
let yaml = """
|
||||
security:
|
||||
redact_secrets: false
|
||||
tirith_enabled: false
|
||||
tirith_timeout: 15
|
||||
website_blocklist:
|
||||
enabled: true
|
||||
domains:
|
||||
- example.com
|
||||
- evil.org
|
||||
"""
|
||||
let c = HermesConfig(yaml: yaml)
|
||||
#expect(c.security.redactSecrets == false)
|
||||
#expect(c.security.tirithEnabled == false)
|
||||
#expect(c.security.tirithTimeout == 15)
|
||||
#expect(c.security.blocklistEnabled == true)
|
||||
#expect(c.security.blocklistDomains == ["example.com", "evil.org"])
|
||||
}
|
||||
|
||||
@Test func parsesSlackWithLegacyAndNewerPaths() {
|
||||
// Newer path wins when both present.
|
||||
let newerWins = HermesConfig(yaml: """
|
||||
platforms:
|
||||
slack:
|
||||
reply_to_mode: all
|
||||
slack:
|
||||
reply_to_mode: first
|
||||
""")
|
||||
#expect(newerWins.slack.replyToMode == "all")
|
||||
|
||||
// Legacy-only path used when newer is absent.
|
||||
let legacyFallback = HermesConfig(yaml: """
|
||||
slack:
|
||||
reply_to_mode: first
|
||||
""")
|
||||
#expect(legacyFallback.slack.replyToMode == "first")
|
||||
|
||||
// Default when neither present.
|
||||
let defaulted = HermesConfig(yaml: "")
|
||||
#expect(defaulted.slack.replyToMode == "first")
|
||||
}
|
||||
|
||||
@Test func parsesAuxiliarySection() {
|
||||
let yaml = """
|
||||
auxiliary:
|
||||
vision:
|
||||
provider: openai
|
||||
model: gpt-4-vision
|
||||
timeout: 60
|
||||
compression:
|
||||
provider: anthropic
|
||||
model: claude-3-haiku
|
||||
"""
|
||||
let c = HermesConfig(yaml: yaml)
|
||||
#expect(c.auxiliary.vision.provider == "openai")
|
||||
#expect(c.auxiliary.vision.model == "gpt-4-vision")
|
||||
#expect(c.auxiliary.vision.timeout == 60)
|
||||
#expect(c.auxiliary.compression.provider == "anthropic")
|
||||
// Not-configured aux blocks default to "auto" / empty.
|
||||
#expect(c.auxiliary.sessionSearch.provider == "auto")
|
||||
#expect(c.auxiliary.mcp.provider == "auto")
|
||||
}
|
||||
|
||||
@Test func parsesPermanentAllowlist() {
|
||||
let yaml = """
|
||||
permanent_allowlist:
|
||||
- ls
|
||||
- pwd
|
||||
- stat
|
||||
"""
|
||||
let c = HermesConfig(yaml: yaml)
|
||||
#expect(c.commandAllowlist == ["ls", "pwd", "stat"])
|
||||
}
|
||||
|
||||
@Test func parsesCommandAllowlistLegacyName() {
|
||||
// Fall back to `command_allowlist` when `permanent_allowlist` absent.
|
||||
let yaml = """
|
||||
command_allowlist:
|
||||
- whoami
|
||||
- id
|
||||
"""
|
||||
let c = HermesConfig(yaml: yaml)
|
||||
#expect(c.commandAllowlist == ["whoami", "id"])
|
||||
}
|
||||
|
||||
@Test func preservesQuotedStrings() {
|
||||
let yaml = """
|
||||
model:
|
||||
default: "gpt-4o with spaces"
|
||||
timezone: 'America/New_York'
|
||||
"""
|
||||
let c = HermesConfig(yaml: yaml)
|
||||
#expect(c.model == "gpt-4o with spaces")
|
||||
#expect(c.timezone == "America/New_York")
|
||||
}
|
||||
|
||||
@Test func cronScheduleMemberwise() {
|
||||
let s = CronSchedule(
|
||||
kind: "cron",
|
||||
runAt: nil,
|
||||
display: "9am weekdays",
|
||||
expression: "0 9 * * 1-5"
|
||||
)
|
||||
#expect(s.kind == "cron")
|
||||
#expect(s.display == "9am weekdays")
|
||||
}
|
||||
|
||||
@Test func hermesCronJobMemberwiseAndWithEnabled() {
|
||||
let job = HermesCronJob(
|
||||
id: "j1",
|
||||
name: "Brief",
|
||||
prompt: "summarize",
|
||||
skills: ["cal"],
|
||||
schedule: CronSchedule(kind: "cron"),
|
||||
enabled: true,
|
||||
state: "scheduled",
|
||||
deliver: "discord:general"
|
||||
)
|
||||
#expect(job.enabled)
|
||||
let toggled = job.withEnabled(false)
|
||||
#expect(toggled.enabled == false)
|
||||
// Every other field round-trips.
|
||||
#expect(toggled.id == job.id)
|
||||
#expect(toggled.name == job.name)
|
||||
#expect(toggled.prompt == job.prompt)
|
||||
#expect(toggled.skills == job.skills)
|
||||
#expect(toggled.deliver == job.deliver)
|
||||
}
|
||||
|
||||
@Test func cronJobsFileMemberwise() {
|
||||
let jobs = [
|
||||
HermesCronJob(
|
||||
id: "a", name: "A", prompt: "p",
|
||||
schedule: CronSchedule(kind: "cron"),
|
||||
enabled: true, state: "scheduled"
|
||||
)
|
||||
]
|
||||
let file = CronJobsFile(jobs: jobs, updatedAt: "2026-04-23T00:00:00Z")
|
||||
#expect(file.jobs.count == 1)
|
||||
#expect(file.updatedAt == "2026-04-23T00:00:00Z")
|
||||
// Codable round-trip should survive.
|
||||
let data = try! JSONEncoder().encode(file)
|
||||
let decoded = try! JSONDecoder().decode(CronJobsFile.self, from: data)
|
||||
#expect(decoded.jobs.count == 1)
|
||||
#expect(decoded.jobs[0].name == "A")
|
||||
#expect(decoded.updatedAt == file.updatedAt)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user