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)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// iOS Cron screen. Read-only list of scheduled jobs pulled from
|
||||
/// `~/.hermes/cron/jobs.json`. Editing is deferred to a later phase —
|
||||
/// see `IOSCronViewModel`'s header for the scope rationale.
|
||||
/// iOS Cron screen. M6 gained: toggle-enabled, swipe-to-delete,
|
||||
/// "+" toolbar → editor sheet, and row-tap → edit existing job.
|
||||
struct CronListView: View {
|
||||
let config: IOSServerConfig
|
||||
|
||||
@State private var vm: IOSCronViewModel
|
||||
@State private var editingJob: HermesCronJob?
|
||||
@State private var showingNewJob = false
|
||||
|
||||
private static let sharedContextID: ServerID = ServerID(
|
||||
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
||||
@@ -33,7 +34,7 @@ struct CronListView: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("No cron jobs yet.")
|
||||
.font(.headline)
|
||||
Text("Create cron jobs from the Mac app or by editing `~/.hermes/cron/jobs.json` directly. iOS will display them here.")
|
||||
Text("Tap \(Image(systemName: "plus.circle.fill")) to create one, or manage them from the Mac app.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -42,13 +43,34 @@ struct CronListView: View {
|
||||
} else {
|
||||
Section {
|
||||
ForEach(vm.jobs) { job in
|
||||
CronRow(job: job)
|
||||
CronRow(job: job) {
|
||||
Task { await vm.toggleEnabled(id: job.id) }
|
||||
} onTap: {
|
||||
editingJob = job
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||||
Button(role: .destructive) {
|
||||
Task { await vm.delete(id: job.id) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Cron jobs")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showingNewJob = true
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
}
|
||||
.disabled(vm.isSaving)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if vm.isLoading && vm.jobs.isEmpty {
|
||||
ProgressView("Loading jobs…")
|
||||
@@ -59,29 +81,42 @@ struct CronListView: View {
|
||||
}
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.sheet(item: $editingJob) { job in
|
||||
CronEditorView(initial: job, title: "Edit cron job") { edited in
|
||||
Task { await vm.upsert(edited) }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingNewJob) {
|
||||
CronEditorView(initial: nil, title: "New cron job") { created in
|
||||
Task { await vm.upsert(created) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CronRow: View {
|
||||
let job: HermesCronJob
|
||||
let onToggle: () -> Void
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationLink {
|
||||
CronDetailView(job: job)
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack {
|
||||
Image(systemName: job.stateIcon)
|
||||
.foregroundStyle(stateColor)
|
||||
.font(.body)
|
||||
Button(action: onToggle) {
|
||||
Image(systemName: job.enabled
|
||||
? "checkmark.circle.fill"
|
||||
: "circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(job.enabled ? Color.accentColor : Color.secondary)
|
||||
}
|
||||
.frame(width: 22)
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: onTap) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack {
|
||||
Text(job.name)
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
if !job.enabled {
|
||||
Text("DISABLED")
|
||||
.font(.caption2)
|
||||
@@ -108,86 +143,171 @@ private struct CronRow: View {
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private var stateColor: Color {
|
||||
switch job.state {
|
||||
case "running": return .blue
|
||||
case "completed": return .green
|
||||
case "failed": return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CronDetailView: View {
|
||||
let job: HermesCronJob
|
||||
// MARK: - Editor
|
||||
|
||||
/// Sheet for creating or editing a single `HermesCronJob`. Scoped
|
||||
/// to the fields a user typically sets; runtime state fields
|
||||
/// (delivery_failures, last_run_at, etc.) pass through untouched
|
||||
/// when editing an existing job.
|
||||
struct CronEditorView: View {
|
||||
let title: String
|
||||
let onSave: (HermesCronJob) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// Form-backing state.
|
||||
@State private var id: String
|
||||
@State private var name: String
|
||||
@State private var prompt: String
|
||||
@State private var model: String
|
||||
@State private var skills: String // comma-separated
|
||||
@State private var deliver: String
|
||||
@State private var enabled: Bool
|
||||
|
||||
@State private var scheduleKind: String
|
||||
@State private var scheduleDisplay: String
|
||||
@State private var scheduleRunAt: String
|
||||
@State private var scheduleExpression: String
|
||||
|
||||
private let existing: HermesCronJob?
|
||||
|
||||
init(
|
||||
initial: HermesCronJob?,
|
||||
title: String,
|
||||
onSave: @escaping (HermesCronJob) -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.onSave = onSave
|
||||
self.existing = initial
|
||||
_id = State(initialValue: initial?.id ?? "job_\(UUID().uuidString.prefix(8))")
|
||||
_name = State(initialValue: initial?.name ?? "")
|
||||
_prompt = State(initialValue: initial?.prompt ?? "")
|
||||
_model = State(initialValue: initial?.model ?? "")
|
||||
_skills = State(initialValue: (initial?.skills ?? []).joined(separator: ", "))
|
||||
_deliver = State(initialValue: initial?.deliver ?? "")
|
||||
_enabled = State(initialValue: initial?.enabled ?? true)
|
||||
_scheduleKind = State(initialValue: initial?.schedule.kind ?? "cron")
|
||||
_scheduleDisplay = State(initialValue: initial?.schedule.display ?? "")
|
||||
_scheduleRunAt = State(initialValue: initial?.schedule.runAt ?? "")
|
||||
_scheduleExpression = State(initialValue: initial?.schedule.expression ?? "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Job") {
|
||||
TextField("Name", text: $name)
|
||||
.autocorrectionDisabled()
|
||||
Toggle("Enabled", isOn: $enabled)
|
||||
}
|
||||
|
||||
Section("Prompt") {
|
||||
Text(job.prompt)
|
||||
TextEditor(text: $prompt)
|
||||
.frame(minHeight: 120)
|
||||
.font(.body)
|
||||
.textSelection(.enabled)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
|
||||
Section("Schedule") {
|
||||
LabeledContent("Kind", value: job.schedule.kind)
|
||||
if let display = job.schedule.display {
|
||||
LabeledContent("When", value: display)
|
||||
Picker("Kind", selection: $scheduleKind) {
|
||||
Text("cron").tag("cron")
|
||||
Text("interval").tag("interval")
|
||||
Text("once").tag("once")
|
||||
}
|
||||
if let expr = job.schedule.expression {
|
||||
LabeledContent("Expression", value: expr)
|
||||
TextField("Display (e.g. \"9am weekdays\")", text: $scheduleDisplay)
|
||||
.autocorrectionDisabled()
|
||||
if scheduleKind == "cron" {
|
||||
TextField("Expression (e.g. \"0 9 * * 1-5\")", text: $scheduleExpression)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
if scheduleKind == "once" {
|
||||
TextField("Run at (ISO8601)", text: $scheduleRunAt)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
}
|
||||
|
||||
Section("State") {
|
||||
LabeledContent("Enabled", value: job.enabled ? "yes" : "no")
|
||||
LabeledContent("State", value: job.state)
|
||||
if let last = job.lastRunAt {
|
||||
LabeledContent("Last run", value: last)
|
||||
}
|
||||
if let next = job.nextRunAt {
|
||||
LabeledContent("Next run", value: next)
|
||||
}
|
||||
if let err = job.lastError {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Last error")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(err)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.red)
|
||||
.textSelection(.enabled)
|
||||
Section("Optional") {
|
||||
TextField("Model (leave blank to use default)", text: $model)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
TextField("Skills (comma-separated)", text: $skills)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
TextField("Deliver (e.g. discord:channel)", text: $deliver)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let delivery = job.deliveryDisplay {
|
||||
Section("Delivery") {
|
||||
LabeledContent("Route", value: delivery)
|
||||
}
|
||||
}
|
||||
|
||||
if let skills = job.skills, !skills.isEmpty {
|
||||
Section("Skills") {
|
||||
ForEach(skills, id: \.self) { s in
|
||||
Text(s)
|
||||
.font(.caption.monospaced())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let model = job.model {
|
||||
Section("Model") {
|
||||
Text(model).font(.caption.monospaced())
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(job.name)
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Save") {
|
||||
onSave(buildJob())
|
||||
dismiss()
|
||||
}
|
||||
.disabled(!isValid)
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
let n = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let p = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return !n.isEmpty && !p.isEmpty
|
||||
}
|
||||
|
||||
private func buildJob() -> HermesCronJob {
|
||||
let skillList = skills
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
let emptyToNil: (String) -> String? = { s in
|
||||
let t = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return t.isEmpty ? nil : t
|
||||
}
|
||||
let schedule = CronSchedule(
|
||||
kind: scheduleKind,
|
||||
runAt: emptyToNil(scheduleRunAt),
|
||||
display: emptyToNil(scheduleDisplay),
|
||||
expression: emptyToNil(scheduleExpression)
|
||||
)
|
||||
return HermesCronJob(
|
||||
id: id,
|
||||
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
prompt: prompt.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
skills: skillList.isEmpty ? nil : skillList,
|
||||
model: emptyToNil(model),
|
||||
schedule: schedule,
|
||||
enabled: enabled,
|
||||
state: existing?.state ?? "scheduled",
|
||||
deliver: emptyToNil(deliver),
|
||||
// Preserve runtime state fields from the existing job so
|
||||
// an edit doesn't reset last_run_at, failure counts, etc.
|
||||
nextRunAt: existing?.nextRunAt,
|
||||
lastRunAt: existing?.lastRunAt,
|
||||
lastError: existing?.lastError,
|
||||
preRunScript: existing?.preRunScript,
|
||||
deliveryFailures: existing?.deliveryFailures,
|
||||
lastDeliveryError: existing?.lastDeliveryError,
|
||||
timeoutType: existing?.timeoutType,
|
||||
timeoutSeconds: existing?.timeoutSeconds,
|
||||
silent: existing?.silent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,11 @@ struct DashboardView: View {
|
||||
} label: {
|
||||
Label("Skills", systemImage: "sparkles")
|
||||
}
|
||||
NavigationLink {
|
||||
SettingsView(config: config)
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape.fill")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Connected to") {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// iOS Settings screen. Read-only browser of `~/.hermes/config.yaml`
|
||||
/// as it currently stands on the remote, grouped into sections that
|
||||
/// mirror the Mac app's tabs. Source-of-truth toggle at the bottom
|
||||
/// reveals the raw YAML for users who want to see what the parser
|
||||
/// consumed.
|
||||
struct SettingsView: View {
|
||||
let config: IOSServerConfig
|
||||
|
||||
@State private var vm: IOSSettingsViewModel
|
||||
@State private var showRawYAML = false
|
||||
|
||||
private static let sharedContextID: ServerID = ServerID(
|
||||
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
||||
)!
|
||||
|
||||
init(config: IOSServerConfig) {
|
||||
self.config = config
|
||||
let ctx = config.toServerContext(id: Self.sharedContextID)
|
||||
_vm = State(initialValue: IOSSettingsViewModel(context: ctx))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
if let err = vm.lastError {
|
||||
Section {
|
||||
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
if !vm.isLoading || vm.config.model != "unknown" {
|
||||
modelSection
|
||||
agentSection
|
||||
displaySection
|
||||
terminalSection
|
||||
memorySection
|
||||
voiceSection
|
||||
securitySection
|
||||
compressionSection
|
||||
loggingSection
|
||||
platformsSection
|
||||
rawYAMLToggleSection
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.refreshable { await vm.load() }
|
||||
.task { await vm.load() }
|
||||
.overlay {
|
||||
if vm.isLoading && vm.config.model == "unknown" {
|
||||
ProgressView("Loading config.yaml…")
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
@ViewBuilder
|
||||
private var modelSection: some View {
|
||||
Section("Model") {
|
||||
LabeledContent("Default", value: vm.config.model)
|
||||
if !vm.config.provider.isEmpty, vm.config.provider != "unknown" {
|
||||
LabeledContent("Provider", value: vm.config.provider)
|
||||
}
|
||||
LabeledContent("Reasoning effort", value: vm.config.reasoningEffort)
|
||||
if !vm.config.timezone.isEmpty {
|
||||
LabeledContent("Timezone", value: vm.config.timezone)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var agentSection: some View {
|
||||
Section("Agent") {
|
||||
LabeledContent("Approval mode", value: vm.config.approvalMode)
|
||||
LabeledContent("Max turns", value: "\(vm.config.maxTurns)")
|
||||
LabeledContent("Service tier", value: vm.config.serviceTier)
|
||||
yesNoRow("Verbose logging", vm.config.verbose)
|
||||
LabeledContent("Tool use enforcement", value: vm.config.toolUseEnforcement)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var displaySection: some View {
|
||||
Section("Display") {
|
||||
yesNoRow("Streaming", vm.config.streaming)
|
||||
yesNoRow("Show reasoning", vm.config.showReasoning)
|
||||
yesNoRow("Show cost", vm.config.showCost)
|
||||
LabeledContent("Skin", value: vm.config.display.skin)
|
||||
yesNoRow("Compact", vm.config.display.compact)
|
||||
yesNoRow("Inline diffs", vm.config.display.inlineDiffs)
|
||||
LabeledContent("Personality", value: vm.config.personality)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var terminalSection: some View {
|
||||
Section("Terminal") {
|
||||
LabeledContent("Backend", value: vm.config.terminalBackend)
|
||||
LabeledContent("Cwd", value: vm.config.terminal.cwd)
|
||||
LabeledContent("Timeout", value: "\(vm.config.terminal.timeout)s")
|
||||
yesNoRow("Persistent shell", vm.config.terminal.persistentShell)
|
||||
if !vm.config.terminal.dockerImage.isEmpty {
|
||||
LabeledContent("Docker image", value: vm.config.terminal.dockerImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var memorySection: some View {
|
||||
Section("Memory") {
|
||||
yesNoRow("Memory enabled", vm.config.memoryEnabled)
|
||||
yesNoRow("User profile enabled", vm.config.userProfileEnabled)
|
||||
if vm.config.memoryCharLimit > 0 {
|
||||
LabeledContent("Char limit", value: "\(vm.config.memoryCharLimit)")
|
||||
}
|
||||
if !vm.config.memoryProfile.isEmpty {
|
||||
LabeledContent("Profile", value: vm.config.memoryProfile)
|
||||
}
|
||||
if !vm.config.memoryProvider.isEmpty {
|
||||
LabeledContent("Provider", value: vm.config.memoryProvider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var voiceSection: some View {
|
||||
Section("Voice") {
|
||||
yesNoRow("Auto TTS", vm.config.autoTTS)
|
||||
LabeledContent("TTS provider", value: vm.config.voice.ttsProvider)
|
||||
yesNoRow("STT enabled", vm.config.voice.sttEnabled)
|
||||
LabeledContent("STT provider", value: vm.config.voice.sttProvider)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var securitySection: some View {
|
||||
Section("Security") {
|
||||
yesNoRow("Redact secrets", vm.config.security.redactSecrets)
|
||||
yesNoRow("Redact PII", vm.config.security.redactPII)
|
||||
yesNoRow("Tirith enabled", vm.config.security.tirithEnabled)
|
||||
yesNoRow("Website blocklist", vm.config.security.blocklistEnabled)
|
||||
if !vm.config.security.blocklistDomains.isEmpty {
|
||||
ForEach(vm.config.security.blocklistDomains.prefix(5), id: \.self) { domain in
|
||||
Text(domain)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if vm.config.security.blocklistDomains.count > 5 {
|
||||
Text("+ \(vm.config.security.blocklistDomains.count - 5) more")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var compressionSection: some View {
|
||||
Section("Compression") {
|
||||
yesNoRow("Enabled", vm.config.compression.enabled)
|
||||
LabeledContent("Threshold", value: String(format: "%.2f", vm.config.compression.threshold))
|
||||
LabeledContent("Target ratio", value: String(format: "%.2f", vm.config.compression.targetRatio))
|
||||
LabeledContent("Protect last N", value: "\(vm.config.compression.protectLastN)")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var loggingSection: some View {
|
||||
Section("Logging") {
|
||||
LabeledContent("Level", value: vm.config.logging.level)
|
||||
LabeledContent("Max size", value: "\(vm.config.logging.maxSizeMB) MB")
|
||||
LabeledContent("Backup count", value: "\(vm.config.logging.backupCount)")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var platformsSection: some View {
|
||||
Section("Platforms") {
|
||||
yesNoRow("Discord: require mention", vm.config.discord.requireMention)
|
||||
yesNoRow("Discord: auto-thread", vm.config.discord.autoThread)
|
||||
yesNoRow("Telegram: require mention", vm.config.telegram.requireMention)
|
||||
LabeledContent("Slack: reply mode", value: vm.config.slack.replyToMode)
|
||||
yesNoRow("Matrix: require mention", vm.config.matrix.requireMention)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var rawYAMLToggleSection: some View {
|
||||
Section {
|
||||
DisclosureGroup("View source (config.yaml)", isExpanded: $showRawYAML) {
|
||||
if vm.rawYAML.isEmpty {
|
||||
Text("(empty)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
} else {
|
||||
Text(vm.rawYAML)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("M6 is read-only. Edit config.yaml on the Mac app or via a shell; iOS reflects the current remote state.")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@ViewBuilder
|
||||
private func yesNoRow(_ label: String, _ value: Bool) -> some View {
|
||||
LabeledContent(label) {
|
||||
Text(value ? "yes" : "no")
|
||||
.foregroundStyle(value ? .primary : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,4 +696,57 @@ Total **98 → 108 tests passing on Linux** via `docker run --rm -v $PWD/Package
|
||||
- **Editing Cron, adding Skills** — both are deferred. Cron editing needs atomic JSON rewrites (doable). Skills install needs git-clone + schema validation (larger).
|
||||
- **Settings tab on iOS is still missing** — requires a YAML parser in ScarfCore or porting `HermesFileService.loadConfig`. Next phase's job.
|
||||
|
||||
### M6 — pending
|
||||
### M6 — shipped (on `claude/ios-m6-settings-polish` branch, separate PR, stacked on M5)
|
||||
|
||||
Ports the Mac app's YAML parser into ScarfCore (unblocking iOS Settings), adds Settings browsing + Cron editing, consolidates the test-serialization story. App Store submission is deferred to a later task after real-device testing.
|
||||
|
||||
**Shipped — ScarfCore:**
|
||||
|
||||
- `Parsing/HermesYAML.swift` — `HermesYAML.parseNestedYAML(_:)` + `stripYAMLQuotes(_:)`. Lifted verbatim from `HermesFileService.parseNestedYAML` / `stripYAMLQuotes` but hoisted into a standalone enum for reuse. Scope unchanged: indent-based block nesting, scalar values, bullet lists, nested maps. Not full YAML-spec compliance; matches exactly what Hermes's `config.yaml` actually uses.
|
||||
- `Parsing/HermesConfig+YAML.swift` — `HermesConfig.init(yaml:)`. Lifted from `HermesFileService.parseConfig` one-for-one. Every default, every key, every legacy fallback (e.g., `platforms.slack.*` vs `slack.*`) tracked to the Mac implementation. Forgiving: malformed YAML produces a partial-state `HermesConfig` rather than throwing.
|
||||
- `ViewModels/IOSSettingsViewModel.swift` — `@Observable @MainActor` VM. Reads `~/.hermes/config.yaml` via transport, parses with the new loader, surfaces both the parsed `HermesConfig` and the raw text (so Settings view can offer a "View source" disclosure). M6 Settings is READ-ONLY — edit-path deferred until a round-trip-preserving YAML writer lands (commits, key order, whitespace would need preservation for a clean edit UX).
|
||||
- `ViewModels/IOSCronViewModel.swift` — added write paths: `toggleEnabled(id:)`, `delete(id:)`, `upsert(_:)`. All funnel through `saveJobs(_:)` which re-encodes the full `CronJobsFile` (`.prettyPrinted + .sortedKeys`) and writes atomically via the transport (Data.write-atomic semantics from M5). Creates the `cron/` directory on fresh installs.
|
||||
- Both `HermesCronJob` and `CronJobsFile` gained real memberwise inits (previously only hand-written `init(from:)` — Swift's synthesis was suppressed). Also `HermesCronJob.withEnabled(_:)` — clean field-passthrough instead of the JSON-roundtrip hack my first draft used.
|
||||
|
||||
**Shipped — iOS app:**
|
||||
|
||||
- `Scarf iOS/Settings/SettingsView.swift` — read-only browser grouped into sections that mirror the Mac app's tabs: Model, Agent, Display, Terminal, Memory, Voice, Security, Compression, Logging, Platforms. `DisclosureGroup` at the bottom reveals the raw YAML source for diagnostics.
|
||||
- `Scarf iOS/Cron/CronListView.swift` rewritten: toggle-enabled circle (tap to flip), swipe-to-delete, "+" toolbar for new-job, row-tap opens the editor sheet. New `CronEditorView` form handles name / prompt / enabled / schedule (kind + display + expression + run_at) / optional model / comma-separated skills / delivery route. Preserves runtime state fields (nextRunAt, lastRunAt, deliveryFailures, etc.) when editing — no resetting the cron's observed history on a field edit.
|
||||
- Dashboard's Surfaces section gets a 5th row: Settings.
|
||||
|
||||
**Test-suite reorganization:**
|
||||
|
||||
Discovered (and fixed) a cross-suite race: swift-testing's `.serialized` trait scopes to one @Suite, not globally. M5's serialized suite installed `ServerContext.sshTransportFactory`, M6's serialized suite did the same, and the M0b non-serialized `serverContextMakeTransportDispatches` test asserted the DEFAULT factory (nil) returned `SSHTransport` — all three raced on the shared static.
|
||||
|
||||
Fix: keep the YAML-parse + memberwise tests in a plain (non-serialized) `M6ConfigCronTests` suite since they're pure. **Move every factory-touching test into the single `.serialized` `M5FeatureVMTests`** — including M6's Cron write-path tests, Settings-load tests, AND the M0b default-factory test (with explicit `factory = nil` reset for race-freedom). Single serialization domain eliminates the race.
|
||||
|
||||
**Test counts:** 108 → **134 passing on Linux**.
|
||||
|
||||
| Suite | New in M6 | Total |
|
||||
|---|--:|--:|
|
||||
| `ScarfCoreSmokeTests` | 0 | 1 |
|
||||
| `M0aPublicInitTests` | 0 | 15 |
|
||||
| `M0bTransportTests` | 0 (1 split out + moved) | 18 |
|
||||
| `M0cServicesTests` | 0 | 8 |
|
||||
| `M0dViewModelsTests` | 0 | 9 |
|
||||
| `M1ACPTests` | 0 | 10 |
|
||||
| `M2OnboardingTests` | 0 | 26 |
|
||||
| `M3TransportTests` | 0 | 5 |
|
||||
| `M4ACPIOSTests` | 0 | 2 |
|
||||
| `M5FeatureVMTests` | **+7** (cron write paths + settings load + default-factory guard) | 21 |
|
||||
| `M6ConfigCronTests` | **+19** (YAML parsing + HermesConfig decode + memberwise inits) | 19 |
|
||||
|
||||
**Manual validation needed on Mac:**
|
||||
1. Xcode compile clean.
|
||||
2. Settings → confirm every section populates from your real `config.yaml`. Tap the "View source" disclosure to verify the raw text matches what's on the remote.
|
||||
3. Cron: toggle a job's enabled flag, verify it survives a full refresh + relaunch. Swipe-to-delete a job. Tap "+" to create a new job; verify prompt + schedule + skills round-trip. Tap an existing job to edit; verify runtime fields (lastRunAt, deliveryFailures) aren't reset.
|
||||
4. Skills: unchanged from M5, still browse-only.
|
||||
|
||||
**Rules next phases can rely on:**
|
||||
- **Any test that touches `ServerContext.sshTransportFactory` or any other global mutable state MUST live in `M5FeatureVMTests`** (the single `.serialized` suite) — or introduce a new cross-suite synchronization primitive. Swift-testing's `.serialized` does NOT serialize across suites.
|
||||
- **YAML parser in ScarfCore is a hard ceiling** — it handles the Hermes config subset, not arbitrary YAML. If a future Hermes version adds constructs the parser doesn't cover (flow-style `[...]`, anchors, `&` references, multi-line `|` blocks), port them on both sides simultaneously.
|
||||
- **Settings writes stay deferred** until a round-trip-preserving YAML writer ships. Options: (a) hand-write one, (b) adopt a YAML lib (adds dependency), (c) delegate to `hermes config set` via ACP.
|
||||
- **Cron editing on iOS is atomic per-save** — full jobs.json rewrites on every change. Fine for current cron sizes (dozens of jobs). If that grows into the thousands, consider partial updates via `hermes cron add/rm/toggle` over ACP.
|
||||
- **Skills install (git-clone + validation over SSH)** remains deferred — it's its own project. The iOS Skills list is read-only; users install from the Mac app or by cloning directly to the remote.
|
||||
|
||||
### M7 — pending (post-testing App Store submission + any polish that surfaces)
|
||||
|
||||
Reference in New Issue
Block a user