mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
44d2d6d6c6
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
314 lines
12 KiB
Swift
314 lines
12 KiB
Swift
import SwiftUI
|
|
import ScarfCore
|
|
|
|
/// 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"
|
|
)!
|
|
|
|
init(config: IOSServerConfig) {
|
|
self.config = config
|
|
let ctx = config.toServerContext(id: Self.sharedContextID)
|
|
_vm = State(initialValue: IOSCronViewModel(context: ctx))
|
|
}
|
|
|
|
var body: some View {
|
|
List {
|
|
if let err = vm.lastError {
|
|
Section {
|
|
Label(err, systemImage: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
|
|
if vm.jobs.isEmpty, !vm.isLoading {
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("No cron jobs yet.")
|
|
.font(.headline)
|
|
Text("Tap \(Image(systemName: "plus.circle.fill")) to create one, or manage them from the Mac app.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
} else {
|
|
Section {
|
|
ForEach(vm.jobs) { job in
|
|
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…")
|
|
.padding()
|
|
.background(.regularMaterial)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
}
|
|
.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 {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Button(action: onToggle) {
|
|
Image(systemName: job.enabled
|
|
? "checkmark.circle.fill"
|
|
: "circle")
|
|
.font(.title3)
|
|
.foregroundStyle(job.enabled ? Color.accentColor : Color.secondary)
|
|
}
|
|
.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)
|
|
.fontWeight(.bold)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 1)
|
|
.background(Color(.secondarySystemFill))
|
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
|
}
|
|
}
|
|
if let schedule = job.schedule.display, !schedule.isEmpty {
|
|
Text(schedule)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else if !job.schedule.kind.isEmpty {
|
|
Text(job.schedule.kind)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let nextRun = job.nextRunAt {
|
|
Text("Next: \(nextRun)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.vertical, 2)
|
|
}
|
|
}
|
|
|
|
// 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") {
|
|
TextEditor(text: $prompt)
|
|
.frame(minHeight: 120)
|
|
.font(.body)
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
}
|
|
|
|
Section("Schedule") {
|
|
Picker("Kind", selection: $scheduleKind) {
|
|
Text("cron").tag("cron")
|
|
Text("interval").tag("interval")
|
|
Text("once").tag("once")
|
|
}
|
|
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("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)
|
|
}
|
|
}
|
|
.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
|
|
)
|
|
}
|
|
}
|