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"
|
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 {
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.id = try c.decode(String.self, forKey: .id)
|
self.id = try c.decode(String.self, forKey: .id)
|
||||||
@@ -115,6 +158,18 @@ public struct CronSchedule: Sendable, Codable {
|
|||||||
case expression
|
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 {
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.kind = try c.decode(String.self, forKey: .kind)
|
self.kind = try c.decode(String.self, forKey: .kind)
|
||||||
@@ -144,6 +199,11 @@ public struct CronJobsFile: Sendable, Codable {
|
|||||||
case updatedAt = "updated_at"
|
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 {
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
|
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 Foundation
|
||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
/// iOS read-only Cron view-state. Loads `~/.hermes/cron/jobs.json`
|
/// iOS Cron view-state. Loads `~/.hermes/cron/jobs.json` via the
|
||||||
/// via the transport, decodes into `CronJobsFile` (already Codable
|
/// transport, decodes into `CronJobsFile` (Codable, from M0a),
|
||||||
/// in ScarfCore), exposes the list for SwiftUI.
|
/// exposes the sorted list for SwiftUI.
|
||||||
///
|
///
|
||||||
/// M5 is read-only by design — editing cron jobs (add / delete /
|
/// M6 adds write paths: toggle enabled, delete, and upsert (add or
|
||||||
/// toggle enabled) is deferred until we have a clearer iOS story for
|
/// replace a job by id). All writes re-encode the full file with a
|
||||||
/// rewriting `jobs.json` atomically across the SSH SFTP path. The
|
/// fresh `updatedAt` and call `transport.writeFile` — which on iOS
|
||||||
/// Mac app's `CronViewModel` does this through `HermesFileService`;
|
/// dispatches to Citadel SFTP with atomic rename semantics.
|
||||||
/// porting that is out of scope for M5.
|
|
||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class IOSCronViewModel {
|
public final class IOSCronViewModel {
|
||||||
@@ -17,6 +16,7 @@ public final class IOSCronViewModel {
|
|||||||
|
|
||||||
public private(set) var jobs: [HermesCronJob] = []
|
public private(set) var jobs: [HermesCronJob] = []
|
||||||
public private(set) var isLoading: Bool = true
|
public private(set) var isLoading: Bool = true
|
||||||
|
public private(set) var isSaving: Bool = false
|
||||||
public private(set) var lastError: String?
|
public private(set) var lastError: String?
|
||||||
|
|
||||||
public init(context: ServerContext) {
|
public init(context: ServerContext) {
|
||||||
@@ -43,17 +43,7 @@ public final class IOSCronViewModel {
|
|||||||
|
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let file):
|
case .success(let file):
|
||||||
// Sort: enabled first, then by nextRunAt ascending (nil
|
jobs = Self.sorted(file.jobs)
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|
||||||
case .failure(let err as LoadError):
|
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 {
|
public enum LoadError: Error, LocalizedError {
|
||||||
case missingFile(path: String)
|
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")
|
#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()
|
let local = ServerContext.local.makeTransport()
|
||||||
#expect(local is LocalTransport)
|
#expect(local is LocalTransport)
|
||||||
#expect(local.isRemote == false)
|
#expect(local.isRemote == false)
|
||||||
#expect(local.contextID == ServerContext.local.id)
|
#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() {
|
@Test func fileStatMemberwise() {
|
||||||
|
|||||||
@@ -326,4 +326,201 @@ import Foundation
|
|||||||
#expect(p.options[0].optionId == "allow")
|
#expect(p.options[0].optionId == "allow")
|
||||||
}
|
}
|
||||||
#endif
|
#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 SwiftUI
|
||||||
import ScarfCore
|
import ScarfCore
|
||||||
|
|
||||||
/// iOS Cron screen. Read-only list of scheduled jobs pulled from
|
/// iOS Cron screen. M6 gained: toggle-enabled, swipe-to-delete,
|
||||||
/// `~/.hermes/cron/jobs.json`. Editing is deferred to a later phase —
|
/// "+" toolbar → editor sheet, and row-tap → edit existing job.
|
||||||
/// see `IOSCronViewModel`'s header for the scope rationale.
|
|
||||||
struct CronListView: View {
|
struct CronListView: View {
|
||||||
let config: IOSServerConfig
|
let config: IOSServerConfig
|
||||||
|
|
||||||
@State private var vm: IOSCronViewModel
|
@State private var vm: IOSCronViewModel
|
||||||
|
@State private var editingJob: HermesCronJob?
|
||||||
|
@State private var showingNewJob = false
|
||||||
|
|
||||||
private static let sharedContextID: ServerID = ServerID(
|
private static let sharedContextID: ServerID = ServerID(
|
||||||
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
||||||
@@ -33,7 +34,7 @@ struct CronListView: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("No cron jobs yet.")
|
Text("No cron jobs yet.")
|
||||||
.font(.headline)
|
.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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -42,13 +43,34 @@ struct CronListView: View {
|
|||||||
} else {
|
} else {
|
||||||
Section {
|
Section {
|
||||||
ForEach(vm.jobs) { job in
|
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")
|
.navigationTitle("Cron jobs")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showingNewJob = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
.disabled(vm.isSaving)
|
||||||
|
}
|
||||||
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
if vm.isLoading && vm.jobs.isEmpty {
|
if vm.isLoading && vm.jobs.isEmpty {
|
||||||
ProgressView("Loading jobs…")
|
ProgressView("Loading jobs…")
|
||||||
@@ -59,29 +81,42 @@ struct CronListView: View {
|
|||||||
}
|
}
|
||||||
.refreshable { await vm.load() }
|
.refreshable { await vm.load() }
|
||||||
.task { 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 {
|
private struct CronRow: View {
|
||||||
let job: HermesCronJob
|
let job: HermesCronJob
|
||||||
|
let onToggle: () -> Void
|
||||||
|
let onTap: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationLink {
|
|
||||||
CronDetailView(job: job)
|
|
||||||
} label: {
|
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
VStack {
|
Button(action: onToggle) {
|
||||||
Image(systemName: job.stateIcon)
|
Image(systemName: job.enabled
|
||||||
.foregroundStyle(stateColor)
|
? "checkmark.circle.fill"
|
||||||
.font(.body)
|
: "circle")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(job.enabled ? Color.accentColor : Color.secondary)
|
||||||
}
|
}
|
||||||
.frame(width: 22)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Button(action: onTap) {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(job.name)
|
Text(job.name)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
if !job.enabled {
|
if !job.enabled {
|
||||||
Text("DISABLED")
|
Text("DISABLED")
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
@@ -108,86 +143,171 @@ private struct CronRow: View {
|
|||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var stateColor: Color {
|
// MARK: - Editor
|
||||||
switch job.state {
|
|
||||||
case "running": return .blue
|
|
||||||
case "completed": return .green
|
|
||||||
case "failed": return .red
|
|
||||||
default: return .secondary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct CronDetailView: View {
|
/// Sheet for creating or editing a single `HermesCronJob`. Scoped
|
||||||
let job: HermesCronJob
|
/// 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 {
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
|
Section("Job") {
|
||||||
|
TextField("Name", text: $name)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
Toggle("Enabled", isOn: $enabled)
|
||||||
|
}
|
||||||
|
|
||||||
Section("Prompt") {
|
Section("Prompt") {
|
||||||
Text(job.prompt)
|
TextEditor(text: $prompt)
|
||||||
|
.frame(minHeight: 120)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.textSelection(.enabled)
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Schedule") {
|
Section("Schedule") {
|
||||||
LabeledContent("Kind", value: job.schedule.kind)
|
Picker("Kind", selection: $scheduleKind) {
|
||||||
if let display = job.schedule.display {
|
Text("cron").tag("cron")
|
||||||
LabeledContent("When", value: display)
|
Text("interval").tag("interval")
|
||||||
|
Text("once").tag("once")
|
||||||
}
|
}
|
||||||
if let expr = job.schedule.expression {
|
TextField("Display (e.g. \"9am weekdays\")", text: $scheduleDisplay)
|
||||||
LabeledContent("Expression", value: expr)
|
.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") {
|
Section("Optional") {
|
||||||
LabeledContent("Enabled", value: job.enabled ? "yes" : "no")
|
TextField("Model (leave blank to use default)", text: $model)
|
||||||
LabeledContent("State", value: job.state)
|
.autocorrectionDisabled()
|
||||||
if let last = job.lastRunAt {
|
.textInputAutocapitalization(.never)
|
||||||
LabeledContent("Last run", value: last)
|
TextField("Skills (comma-separated)", text: $skills)
|
||||||
}
|
.autocorrectionDisabled()
|
||||||
if let next = job.nextRunAt {
|
.textInputAutocapitalization(.never)
|
||||||
LabeledContent("Next run", value: next)
|
TextField("Deliver (e.g. discord:channel)", text: $deliver)
|
||||||
}
|
.autocorrectionDisabled()
|
||||||
if let err = job.lastError {
|
.textInputAutocapitalization(.never)
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
Text("Last error")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(err)
|
|
||||||
.font(.caption.monospaced())
|
|
||||||
.foregroundStyle(.red)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.navigationTitle(title)
|
||||||
|
|
||||||
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)
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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: {
|
||||||
Label("Skills", systemImage: "sparkles")
|
Label("Skills", systemImage: "sparkles")
|
||||||
}
|
}
|
||||||
|
NavigationLink {
|
||||||
|
SettingsView(config: config)
|
||||||
|
} label: {
|
||||||
|
Label("Settings", systemImage: "gearshape.fill")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Connected to") {
|
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).
|
- **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.
|
- **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