macOS: add Cron settings tab

This commit is contained in:
Peter Steinberger
2025-12-13 02:34:18 +00:00
parent f9409cbe43
commit 5118ba3dd2
4 changed files with 1236 additions and 1 deletions

View File

@@ -0,0 +1,209 @@
import ClawdisProtocol
import Foundation
import OSLog
@MainActor
final class CronJobsStore: ObservableObject {
static let shared = CronJobsStore()
@Published var jobs: [CronJob] = []
@Published var selectedJobId: String?
@Published var runEntries: [CronRunLogEntry] = []
@Published var isLoadingJobs = false
@Published var isLoadingRuns = false
@Published var lastError: String?
@Published var statusMessage: String?
private let logger = Logger(subsystem: "com.steipete.clawdis", category: "cron.ui")
private var refreshTask: Task<Void, Never>?
private var runsTask: Task<Void, Never>?
private var eventTask: Task<Void, Never>?
private var pollTask: Task<Void, Never>?
private let interval: TimeInterval = 30
private let isPreview: Bool
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview
}
func start() {
guard !self.isPreview else { return }
guard self.eventTask == nil else { return }
self.startGatewaySubscription()
self.pollTask = Task.detached { [weak self] in
guard let self else { return }
await self.refreshJobs()
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: UInt64(self.interval * 1_000_000_000))
await self.refreshJobs()
}
}
}
func stop() {
self.refreshTask?.cancel()
self.refreshTask = nil
self.runsTask?.cancel()
self.runsTask = nil
self.eventTask?.cancel()
self.eventTask = nil
self.pollTask?.cancel()
self.pollTask = nil
}
func refreshJobs() async {
guard !self.isLoadingJobs else { return }
self.isLoadingJobs = true
self.lastError = nil
self.statusMessage = nil
defer { self.isLoadingJobs = false }
do {
let data = try await self.request(
method: "cron.list",
params: ["includeDisabled": true])
let res = try JSONDecoder().decode(CronListResponse.self, from: data)
self.jobs = res.jobs
if self.jobs.isEmpty {
self.statusMessage = "No cron jobs yet."
}
} catch {
self.logger.error("cron.list failed \(error.localizedDescription, privacy: .public)")
self.lastError = error.localizedDescription
}
}
func refreshRuns(jobId: String, limit: Int = 200) async {
guard !self.isLoadingRuns else { return }
self.isLoadingRuns = true
defer { self.isLoadingRuns = false }
do {
let data = try await self.request(
method: "cron.runs",
params: ["id": jobId, "limit": limit])
let res = try JSONDecoder().decode(CronRunsResponse.self, from: data)
self.runEntries = res.entries
} catch {
self.logger.error("cron.runs failed \(error.localizedDescription, privacy: .public)")
self.lastError = error.localizedDescription
}
}
func runJob(id: String, force: Bool = true) async {
do {
_ = try await self.request(
method: "cron.run",
params: ["id": id, "mode": force ? "force" : "due"],
timeoutMs: 20_000)
} catch {
self.lastError = error.localizedDescription
}
}
func removeJob(id: String) async {
do {
_ = try await self.request(method: "cron.remove", params: ["id": id])
await self.refreshJobs()
if self.selectedJobId == id {
self.selectedJobId = nil
self.runEntries = []
}
} catch {
self.lastError = error.localizedDescription
}
}
func setJobEnabled(id: String, enabled: Bool) async {
do {
_ = try await self.request(
method: "cron.update",
params: ["id": id, "patch": ["enabled": enabled]])
await self.refreshJobs()
} catch {
self.lastError = error.localizedDescription
}
}
func upsertJob(
id: String?,
payload: [String: Any]) async throws
{
if let id {
_ = try await self.request(method: "cron.update", params: ["id": id, "patch": payload])
} else {
_ = try await self.request(method: "cron.add", params: payload)
}
await self.refreshJobs()
}
// MARK: - Gateway events
private func startGatewaySubscription() {
self.eventTask?.cancel()
self.eventTask = Task { [weak self] in
guard let self else { return }
let stream = await GatewayConnection.shared.subscribe()
for await push in stream {
if Task.isCancelled { return }
await MainActor.run { [weak self] in
self?.handle(push: push)
}
}
}
}
private func handle(push: GatewayPush) {
switch push {
case let .event(evt) where evt.event == "cron":
guard let payload = evt.payload else { return }
if let cronEvt = try? GatewayPayloadDecoding.decode(payload, as: CronEvent.self) {
self.handle(cronEvent: cronEvt)
}
case .seqGap:
self.scheduleRefresh()
default:
break
}
}
private func handle(cronEvent evt: CronEvent) {
// Keep UI in sync with the gateway scheduler.
self.scheduleRefresh(delayMs: 250)
if evt.action == "finished", let selected = self.selectedJobId, selected == evt.jobId {
self.scheduleRunsRefresh(jobId: selected, delayMs: 200)
}
}
private func scheduleRefresh(delayMs: Int = 250) {
self.refreshTask?.cancel()
self.refreshTask = Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
await self.refreshJobs()
}
}
private func scheduleRunsRefresh(jobId: String, delayMs: Int = 200) {
self.runsTask?.cancel()
self.runsTask = Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(delayMs) * 1_000_000)
await self.refreshRuns(jobId: jobId)
}
}
// MARK: - RPC
private func request(
method: String,
params: [String: Any]?,
timeoutMs: Double? = nil) async throws -> Data
{
let rawParams = params?.reduce(into: [String: AnyCodable]()) { $0[$1.key] = AnyCodable($1.value) }
return try await GatewayConnection.shared.request(method: method, params: rawParams, timeoutMs: timeoutMs)
}
}

View File

@@ -0,0 +1,212 @@
import Foundation
enum CronSessionTarget: String, CaseIterable, Identifiable, Codable {
case main
case isolated
var id: String { self.rawValue }
}
enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
case now
case nextHeartbeat = "next-heartbeat"
var id: String { self.rawValue }
}
enum CronSchedule: Codable, Equatable {
case at(atMs: Int)
case every(everyMs: Int, anchorMs: Int?)
case cron(expr: String, tz: String?)
enum CodingKeys: String, CodingKey { case kind, atMs, everyMs, anchorMs, expr, tz }
var kind: String {
switch self {
case .at: "at"
case .every: "every"
case .cron: "cron"
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let kind = try container.decode(String.self, forKey: .kind)
switch kind {
case "at":
self = .at(atMs: try container.decode(Int.self, forKey: .atMs))
case "every":
self = .every(
everyMs: try container.decode(Int.self, forKey: .everyMs),
anchorMs: try container.decodeIfPresent(Int.self, forKey: .anchorMs))
case "cron":
self = .cron(
expr: try container.decode(String.self, forKey: .expr),
tz: try container.decodeIfPresent(String.self, forKey: .tz))
default:
throw DecodingError.dataCorruptedError(
forKey: .kind,
in: container,
debugDescription: "Unknown schedule kind: \(kind)")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.kind, forKey: .kind)
switch self {
case let .at(atMs):
try container.encode(atMs, forKey: .atMs)
case let .every(everyMs, anchorMs):
try container.encode(everyMs, forKey: .everyMs)
try container.encodeIfPresent(anchorMs, forKey: .anchorMs)
case let .cron(expr, tz):
try container.encode(expr, forKey: .expr)
try container.encodeIfPresent(tz, forKey: .tz)
}
}
}
enum CronPayload: Codable, Equatable {
case systemEvent(text: String)
case agentTurn(
message: String,
thinking: String?,
timeoutSeconds: Int?,
deliver: Bool?,
channel: String?,
to: String?,
bestEffortDeliver: Bool?)
enum CodingKeys: String, CodingKey {
case kind, text, message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver
}
var kind: String {
switch self {
case .systemEvent: "systemEvent"
case .agentTurn: "agentTurn"
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let kind = try container.decode(String.self, forKey: .kind)
switch kind {
case "systemEvent":
self = .systemEvent(text: try container.decode(String.self, forKey: .text))
case "agentTurn":
self = .agentTurn(
message: try container.decode(String.self, forKey: .message),
thinking: try container.decodeIfPresent(String.self, forKey: .thinking),
timeoutSeconds: try container.decodeIfPresent(Int.self, forKey: .timeoutSeconds),
deliver: try container.decodeIfPresent(Bool.self, forKey: .deliver),
channel: try container.decodeIfPresent(String.self, forKey: .channel),
to: try container.decodeIfPresent(String.self, forKey: .to),
bestEffortDeliver: try container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
default:
throw DecodingError.dataCorruptedError(
forKey: .kind,
in: container,
debugDescription: "Unknown payload kind: \(kind)")
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.kind, forKey: .kind)
switch self {
case let .systemEvent(text):
try container.encode(text, forKey: .text)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
try container.encode(message, forKey: .message)
try container.encodeIfPresent(thinking, forKey: .thinking)
try container.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try container.encodeIfPresent(deliver, forKey: .deliver)
try container.encodeIfPresent(channel, forKey: .channel)
try container.encodeIfPresent(to, forKey: .to)
try container.encodeIfPresent(bestEffortDeliver, forKey: .bestEffortDeliver)
}
}
}
struct CronIsolation: Codable, Equatable {
var postToMain: Bool?
var postToMainPrefix: String?
}
struct CronJobState: Codable, Equatable {
var nextRunAtMs: Int?
var runningAtMs: Int?
var lastRunAtMs: Int?
var lastStatus: String?
var lastError: String?
var lastDurationMs: Int?
}
struct CronJob: Identifiable, Codable, Equatable {
let id: String
var name: String?
var enabled: Bool
let createdAtMs: Int
let updatedAtMs: Int
let schedule: CronSchedule
let sessionTarget: CronSessionTarget
let wakeMode: CronWakeMode
let payload: CronPayload
let isolation: CronIsolation?
let state: CronJobState
var displayName: String {
let trimmed = (self.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Untitled job" : trimmed
}
var nextRunDate: Date? {
guard let ms = self.state.nextRunAtMs else { return nil }
return Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
}
var lastRunDate: Date? {
guard let ms = self.state.lastRunAtMs else { return nil }
return Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
}
}
struct CronEvent: Codable, Sendable {
let jobId: String
let action: String
let runAtMs: Int?
let durationMs: Int?
let status: String?
let error: String?
let nextRunAtMs: Int?
}
struct CronRunLogEntry: Codable, Identifiable, Sendable {
var id: String { "\(self.jobId)-\(self.ts)" }
let ts: Int
let jobId: String
let action: String
let status: String?
let error: String?
let runAtMs: Int?
let durationMs: Int?
let nextRunAtMs: Int?
var date: Date { Date(timeIntervalSince1970: TimeInterval(self.ts) / 1000) }
var runDate: Date? {
guard let runAtMs else { return nil }
return Date(timeIntervalSince1970: TimeInterval(runAtMs) / 1000)
}
}
struct CronListResponse: Codable {
let jobs: [CronJob]
}
struct CronRunsResponse: Codable {
let entries: [CronRunLogEntry]
}

View File

@@ -0,0 +1,809 @@
import SwiftUI
struct CronSettings: View {
@ObservedObject var store: CronJobsStore
@State private var showEditor = false
@State private var editingJob: CronJob?
@State private var editorError: String?
@State private var isSaving = false
@State private var confirmDelete: CronJob?
init(store: CronJobsStore = .shared) {
self.store = store
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
self.header
self.content
Spacer(minLength: 0)
}
.onAppear { self.store.start() }
.onDisappear { self.store.stop() }
.sheet(isPresented: self.$showEditor) {
CronJobEditor(
job: self.editingJob,
isSaving: self.$isSaving,
error: self.$editorError,
onCancel: {
self.showEditor = false
self.editingJob = nil
},
onSave: { payload in
Task {
await self.save(payload: payload)
}
})
}
.alert("Delete cron job?", isPresented: Binding(
get: { self.confirmDelete != nil },
set: { if !$0 { self.confirmDelete = nil } }))
{
Button("Cancel", role: .cancel) { self.confirmDelete = nil }
Button("Delete", role: .destructive) {
if let job = self.confirmDelete {
Task { await self.store.removeJob(id: job.id) }
}
self.confirmDelete = nil
}
} message: {
if let job = self.confirmDelete {
Text(job.displayName)
}
}
.onChange(of: self.store.selectedJobId) { _, newValue in
guard let newValue else { return }
Task { await self.store.refreshRuns(jobId: newValue) }
}
}
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Cron")
.font(.headline)
Text("Manage Gateway cron jobs (main session vs isolated runs) and inspect run history.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
HStack(spacing: 8) {
Button {
Task { await self.store.refreshJobs() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.disabled(self.store.isLoadingJobs)
Button {
self.editorError = nil
self.editingJob = nil
self.showEditor = true
} label: {
Label("New Job", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
}
}
}
private var content: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 8) {
if let err = self.store.lastError {
Text("Error: \(err)")
.font(.footnote)
.foregroundStyle(.red)
} else if let msg = self.store.statusMessage {
Text(msg)
.font(.footnote)
.foregroundStyle(.secondary)
}
List(selection: self.$store.selectedJobId) {
ForEach(self.store.jobs) { job in
self.jobRow(job)
.tag(job.id)
.contextMenu { self.jobContextMenu(job) }
}
}
.listStyle(.inset)
}
.frame(width: 250)
Divider()
self.detail
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
@ViewBuilder
private var detail: some View {
if let selected = self.selectedJob {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 12) {
self.detailHeader(selected)
self.detailCard(selected)
self.runHistoryCard(selected)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 2)
}
} else {
VStack(alignment: .leading, spacing: 8) {
Text("Select a job to inspect details and run history.")
.font(.callout)
.foregroundStyle(.secondary)
Text("Tip: use New Job to add one, or enable cron in your gateway config.")
.font(.caption)
.foregroundStyle(.tertiary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(.top, 8)
}
}
private var selectedJob: CronJob? {
guard let id = self.store.selectedJobId else { return nil }
return self.store.jobs.first(where: { $0.id == id })
}
private func jobRow(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Text(job.displayName)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.truncationMode(.middle)
Spacer()
if !job.enabled {
StatusPill(text: "disabled", tint: .secondary)
} else if let next = job.nextRunDate {
StatusPill(text: self.nextRunLabel(next), tint: .secondary)
} else {
StatusPill(text: "no next run", tint: .secondary)
}
}
HStack(spacing: 6) {
StatusPill(text: job.sessionTarget.rawValue, tint: .secondary)
StatusPill(text: job.wakeMode.rawValue, tint: .secondary)
if let status = job.state.lastStatus {
StatusPill(text: status, tint: status == "ok" ? .green : .orange)
}
}
}
.padding(.vertical, 6)
}
@ViewBuilder
private func jobContextMenu(_ job: CronJob) -> some View {
Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } }
Divider()
Button(job.enabled ? "Disable" : "Enable") {
Task { await self.store.setJobEnabled(id: job.id, enabled: !job.enabled) }
}
Button("Edit…") {
self.editingJob = job
self.editorError = nil
self.showEditor = true
}
Divider()
Button("Delete…", role: .destructive) {
self.confirmDelete = job
}
}
private func detailHeader(_ job: CronJob) -> some View {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text(job.displayName)
.font(.title3.weight(.semibold))
Text(job.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
HStack(spacing: 8) {
Toggle("Enabled", isOn: Binding(
get: { job.enabled },
set: { enabled in Task { await self.store.setJobEnabled(id: job.id, enabled: enabled) } }))
.toggleStyle(.switch)
.labelsHidden()
Button("Run") { Task { await self.store.runJob(id: job.id, force: true) } }
.buttonStyle(.borderedProminent)
Button("Edit") {
self.editingJob = job
self.editorError = nil
self.showEditor = true
}
.buttonStyle(.bordered)
}
}
}
private func detailCard(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
LabeledContent("Session") { Text(job.sessionTarget.rawValue) }
LabeledContent("Wake") { Text(job.wakeMode.rawValue) }
LabeledContent("Next run") {
if let date = job.nextRunDate {
Text(date.formatted(date: .abbreviated, time: .standard))
} else {
Text("").foregroundStyle(.secondary)
}
}
LabeledContent("Last run") {
if let date = job.lastRunDate {
Text("\(date.formatted(date: .abbreviated, time: .standard)) · \(relativeAge(from: date))")
} else {
Text("").foregroundStyle(.secondary)
}
}
if let status = job.state.lastStatus {
LabeledContent("Last status") { Text(status) }
}
if let err = job.state.lastError, !err.isEmpty {
Text(err)
.font(.footnote)
.foregroundStyle(.orange)
.textSelection(.enabled)
}
self.payloadSummary(job.payload)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.secondary.opacity(0.06))
.cornerRadius(8)
}
private func runHistoryCard(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Run history")
.font(.headline)
Spacer()
Button {
Task { await self.store.refreshRuns(jobId: job.id) }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.disabled(self.store.isLoadingRuns)
}
if self.store.isLoadingRuns {
ProgressView().controlSize(.small)
}
if self.store.runEntries.isEmpty {
Text("No run log entries yet.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.store.runEntries) { entry in
self.runRow(entry)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.secondary.opacity(0.06))
.cornerRadius(8)
}
private func runRow(_ entry: CronRunLogEntry) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
StatusPill(text: entry.status ?? "unknown", tint: self.statusTint(entry.status))
Text(entry.date.formatted(date: .abbreviated, time: .standard))
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
if let ms = entry.durationMs {
Text("\(ms)ms")
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
}
}
if let error = entry.error, !error.isEmpty {
Text(error)
.font(.caption)
.foregroundStyle(.orange)
.textSelection(.enabled)
.lineLimit(2)
}
}
.padding(.vertical, 4)
}
private func statusTint(_ status: String?) -> Color {
switch (status ?? "").lowercased() {
case "ok": .green
case "error": .red
case "skipped": .orange
default: .secondary
}
}
private func scheduleSummary(_ schedule: CronSchedule) -> String {
switch schedule {
case let .at(atMs):
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
return "at \(date.formatted(date: .abbreviated, time: .standard))"
case let .every(everyMs, _):
return "every \(self.formatDuration(ms: everyMs))"
case let .cron(expr, tz):
if let tz, !tz.isEmpty { return "cron \(expr) (\(tz))" }
return "cron \(expr)"
}
}
private func payloadSummary(_ payload: CronPayload) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("Payload")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
switch payload {
case let .systemEvent(text):
Text(text)
.font(.callout)
.textSelection(.enabled)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, _):
VStack(alignment: .leading, spacing: 4) {
Text(message)
.font(.callout)
.textSelection(.enabled)
HStack(spacing: 8) {
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
if (deliver ?? false) {
StatusPill(text: "deliver", tint: .secondary)
if let channel, !channel.isEmpty { StatusPill(text: channel, tint: .secondary) }
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
}
}
}
}
}
}
private func formatDuration(ms: Int) -> String {
if ms < 1000 { return "\(ms)ms" }
let s = Double(ms) / 1000.0
if s < 60 { return "\(Int(round(s)))s" }
let m = s / 60.0
if m < 60 { return "\(Int(round(m)))m" }
let h = m / 60.0
if h < 48 { return "\(Int(round(h)))h" }
let d = h / 24.0
return "\(Int(round(d)))d"
}
private func nextRunLabel(_ date: Date, now: Date = .init()) -> String {
let delta = date.timeIntervalSince(now)
if delta <= 0 { return "due" }
if delta < 60 { return "in <1m" }
let minutes = Int(round(delta / 60))
if minutes < 60 { return "in \(minutes)m" }
let hours = Int(round(Double(minutes) / 60))
if hours < 48 { return "in \(hours)h" }
let days = Int(round(Double(hours) / 24))
return "in \(days)d"
}
private func save(payload: [String: Any]) async {
guard !self.isSaving else { return }
self.isSaving = true
self.editorError = nil
do {
try await self.store.upsertJob(id: self.editingJob?.id, payload: payload)
await MainActor.run {
self.isSaving = false
self.showEditor = false
self.editingJob = nil
}
} catch {
await MainActor.run {
self.isSaving = false
self.editorError = error.localizedDescription
}
}
}
}
private struct StatusPill: View {
let text: String
let tint: Color
var body: some View {
Text(self.text)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 7)
.padding(.vertical, 3)
.foregroundStyle(self.tint == .secondary ? .secondary : self.tint)
.background((self.tint == .secondary ? Color.secondary : self.tint).opacity(0.12))
.clipShape(Capsule())
}
}
private struct CronJobEditor: View {
let job: CronJob?
@Binding var isSaving: Bool
@Binding var error: String?
let onCancel: () -> Void
let onSave: ([String: Any]) -> Void
@State private var name: String = ""
@State private var enabled: Bool = true
@State private var sessionTarget: CronSessionTarget = .main
@State private var wakeMode: CronWakeMode = .nextHeartbeat
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
@State private var scheduleKind: ScheduleKind = .every
@State private var atDate: Date = Date().addingTimeInterval(60 * 5)
@State private var everyText: String = "1h"
@State private var cronExpr: String = "0 9 * * 3"
@State private var cronTz: String = ""
enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String { rawValue } }
@State private var payloadKind: PayloadKind = .systemEvent
@State private var systemEventText: String = ""
@State private var agentMessage: String = ""
@State private var deliver: Bool = false
@State private var channel: String = "last"
@State private var to: String = ""
@State private var thinking: String = ""
@State private var timeoutSeconds: String = ""
@State private var bestEffortDeliver: Bool = false
@State private var postToMain: Bool = false
@State private var postPrefix: String = "Cron"
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(self.job == nil ? "New cron job" : "Edit cron job")
.font(.title3.weight(.semibold))
Spacer()
}
Form {
Section("Basics") {
TextField("Name (optional)", text: self.$name)
Toggle("Enabled", isOn: self.$enabled)
Picker("Session target", selection: self.$sessionTarget) {
Text("main").tag(CronSessionTarget.main)
Text("isolated").tag(CronSessionTarget.isolated)
}
Picker("Wake mode", selection: self.$wakeMode) {
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
Text("now").tag(CronWakeMode.now)
}
}
Section("Schedule") {
Picker("Kind", selection: self.$scheduleKind) {
Text("at").tag(ScheduleKind.at)
Text("every").tag(ScheduleKind.every)
Text("cron").tag(ScheduleKind.cron)
}
.pickerStyle(.segmented)
switch self.scheduleKind {
case .at:
DatePicker("At", selection: self.$atDate, displayedComponents: [.date, .hourAndMinute])
case .every:
TextField("Every (e.g. 10m, 1h, 1d)", text: self.$everyText)
.textFieldStyle(.roundedBorder)
case .cron:
TextField("Cron expr (5-field)", text: self.$cronExpr)
.textFieldStyle(.roundedBorder)
TextField("Timezone (optional)", text: self.$cronTz)
.textFieldStyle(.roundedBorder)
}
}
Section("Payload") {
if self.sessionTarget == .isolated {
Text("Isolated jobs always run an agent turn.")
.font(.caption)
.foregroundStyle(.secondary)
self.agentTurnEditor
} else {
Picker("Kind", selection: self.$payloadKind) {
Text("systemEvent").tag(PayloadKind.systemEvent)
Text("agentTurn").tag(PayloadKind.agentTurn)
}
.pickerStyle(.segmented)
switch self.payloadKind {
case .systemEvent:
TextField("System event text", text: self.$systemEventText, axis: .vertical)
.lineLimit(3...6)
case .agentTurn:
self.agentTurnEditor
}
}
}
if self.payloadKind == .agentTurn {
Section("Post summary to main (optional)") {
Toggle("Post to main", isOn: self.$postToMain)
if self.postToMain {
TextField("Prefix", text: self.$postPrefix)
}
}
}
}
.frame(minWidth: 560, minHeight: 520)
if let error, !error.isEmpty {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
}
HStack {
Button("Cancel") { self.onCancel() }
.buttonStyle(.bordered)
Spacer()
Button {
self.save()
} label: {
if self.isSaving {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.isSaving)
}
}
.padding(18)
.onAppear { self.hydrateFromJob() }
.onChange(of: self.sessionTarget) { _, newValue in
if newValue == .isolated {
self.payloadKind = .agentTurn
}
}
}
private var agentTurnEditor: some View {
VStack(alignment: .leading, spacing: 8) {
TextField("Agent message", text: self.$agentMessage, axis: .vertical)
.lineLimit(3...6)
TextField("Thinking (optional)", text: self.$thinking)
TextField("Timeout seconds (optional)", text: self.$timeoutSeconds)
.textFieldStyle(.roundedBorder)
Toggle("Deliver result", isOn: self.$deliver)
if self.deliver {
Picker("Channel", selection: self.$channel) {
Text("last").tag("last")
Text("whatsapp").tag("whatsapp")
Text("telegram").tag("telegram")
}
TextField("To (optional)", text: self.$to)
Toggle("Best-effort deliver", isOn: self.$bestEffortDeliver)
}
}
}
private func hydrateFromJob() {
guard let job else { return }
self.name = job.name ?? ""
self.enabled = job.enabled
self.sessionTarget = job.sessionTarget
self.wakeMode = job.wakeMode
switch job.schedule {
case let .at(atMs):
self.scheduleKind = .at
self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
case let .every(everyMs, _):
self.scheduleKind = .every
self.everyText = self.formatDuration(ms: everyMs)
case let .cron(expr, tz):
self.scheduleKind = .cron
self.cronExpr = expr
self.cronTz = tz ?? ""
}
switch job.payload {
case let .systemEvent(text):
self.payloadKind = .systemEvent
self.systemEventText = text
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
self.payloadKind = .agentTurn
self.agentMessage = message
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false
self.channel = channel ?? "last"
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
self.postToMain = job.isolation?.postToMain ?? false
self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron"
}
private func save() {
do {
self.error = nil
let payload = try self.buildPayload()
self.onSave(payload)
} catch {
self.error = error.localizedDescription
}
}
private func buildPayload() throws -> [String: Any] {
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
let schedule: [String: Any]
switch self.scheduleKind {
case .at:
schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
case .every:
guard let ms = Self.parseDurationMs(self.everyText) else {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
}
schedule = ["kind": "every", "everyMs": ms]
case .cron:
let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines)
if expr.isEmpty {
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
}
let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines)
if tz.isEmpty {
schedule = ["kind": "cron", "expr": expr]
} else {
schedule = ["kind": "cron", "expr": expr, "tz": tz]
}
}
let payload: [String: Any] = {
if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() }
switch self.payloadKind {
case .systemEvent:
let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines)
return ["kind": "systemEvent", "text": text]
case .agentTurn:
return self.buildAgentTurnPayload()
}
}()
if payload["kind"] as? String == "systemEvent" {
if (payload["text"] as? String ?? "").isEmpty {
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "System event text is required."])
}
} else if payload["kind"] as? String == "agentTurn" {
if (payload["message"] as? String ?? "").isEmpty {
throw NSError(domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
}
}
var root: [String: Any] = [
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
if !name.isEmpty { root["name"] = name }
if self.payloadKind == .agentTurn || self.sessionTarget == .isolated {
if self.postToMain {
root["isolation"] = [
"postToMain": true,
"postToMainPrefix": self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Cron" : self.postPrefix,
]
} else if self.job != nil {
// Allow clearing isolation on edit.
root["isolation"] = ["postToMain": false]
}
}
return root
}
private func buildAgentTurnPayload() -> [String: Any] {
let msg = self.agentMessage.trimmingCharacters(in: .whitespacesAndNewlines)
var payload: [String: Any] = ["kind": "agentTurn", "message": msg]
let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines)
if !thinking.isEmpty { payload["thinking"] = thinking }
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver
if self.deliver {
payload["channel"] = self.channel
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver
}
return payload
}
private static func parseDurationMs(_ input: String) -> Int? {
let raw = input.trimmingCharacters(in: .whitespacesAndNewlines)
if raw.isEmpty { return nil }
let rx = try? NSRegularExpression(pattern: #"^(\d+(?:\.\d+)?)(ms|s|m|h|d)$"#, options: [.caseInsensitive])
guard let match = rx?.firstMatch(in: raw, range: NSRange(location: 0, length: raw.utf16.count)) else {
return nil
}
func group(_ idx: Int) -> String {
let range = match.range(at: idx)
guard let r = Range(range, in: raw) else { return "" }
return String(raw[r])
}
let n = Double(group(1)) ?? 0
if !n.isFinite || n <= 0 { return nil }
let unit = group(2).lowercased()
let factor: Double = switch unit {
case "ms": 1
case "s": 1000
case "m": 60_000
case "h": 3_600_000
default: 86_400_000
}
return Int(floor(n * factor))
}
private func formatDuration(ms: Int) -> String {
if ms < 1000 { return "\(ms)ms" }
let s = Double(ms) / 1000.0
if s < 60 { return "\(Int(round(s)))s" }
let m = s / 60.0
if m < 60 { return "\(Int(round(m)))m" }
let h = m / 60.0
if h < 48 { return "\(Int(round(h)))h" }
let d = h / 24.0
return "\(Int(round(d)))d"
}
}
#if DEBUG
struct CronSettings_Previews: PreviewProvider {
static var previews: some View {
let store = CronJobsStore(isPreview: true)
store.jobs = [
CronJob(
id: "job-1",
name: "Daily summary",
enabled: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
sessionTarget: .isolated,
wakeMode: .now,
payload: .agentTurn(
message: "Summarize inbox",
thinking: "low",
timeoutSeconds: 600,
deliver: true,
channel: "last",
to: nil,
bestEffortDeliver: true),
isolation: CronIsolation(postToMain: true, postToMainPrefix: "Cron"),
state: CronJobState(nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000), runningAtMs: nil, lastRunAtMs: nil, lastStatus: nil, lastError: nil, lastDurationMs: nil)),
]
store.selectedJobId = "job-1"
store.runEntries = [
CronRunLogEntry(ts: Int(Date().timeIntervalSince1970 * 1000), jobId: "job-1", action: "finished", status: "ok", error: nil, runAtMs: nil, durationMs: 1234, nextRunAtMs: nil),
]
return CronSettings(store: store)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
#endif

View File

@@ -36,6 +36,10 @@ struct SettingsRootView: View {
.tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") }
.tag(SettingsTab.sessions)
CronSettings()
.tabItem { Label("Cron", systemImage: "calendar") }
.tag(SettingsTab.cron)
ToolsSettings()
.tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") }
.tag(SettingsTab.tools)
@@ -120,7 +124,7 @@ struct SettingsRootView: View {
}
enum SettingsTab: CaseIterable {
case general, tools, sessions, config, instances, voiceWake, permissions, debug, about
case general, tools, sessions, cron, config, instances, voiceWake, permissions, debug, about
static let windowWidth: CGFloat = 598 // +15%
static let windowHeight: CGFloat = 718 // +15%
var title: String {
@@ -128,6 +132,7 @@ enum SettingsTab: CaseIterable {
case .general: "General"
case .tools: "Tools"
case .sessions: "Sessions"
case .cron: "Cron"
case .config: "Config"
case .instances: "Instances"
case .voiceWake: "Voice Wake"