refactor: split cron settings

This commit is contained in:
Peter Steinberger
2025-12-24 19:36:10 +01:00
parent 204bd7d2c4
commit 8c4c5e524b
10 changed files with 1200 additions and 1180 deletions

View File

@@ -0,0 +1,214 @@
import Foundation
import SwiftUI
extension CronJobEditor {
func gridLabel(_ text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
.frame(width: self.labelColumnWidth, alignment: .leading)
}
func hydrateFromJob() {
guard let job else { return }
self.name = job.name
self.description = job.description ?? ""
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 = GatewayAgentChannel(raw: channel)
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron"
}
func save() {
do {
self.error = nil
let payload = try self.buildPayload()
self.onSave(payload)
} catch {
self.error = error.localizedDescription
}
}
func buildPayload() throws -> [String: AnyCodable] {
let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
if name.isEmpty {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
}
let description = self.description.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 self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [
NSLocalizedDescriptionKey:
"Main session jobs require systemEvent payloads (switch Session target to isolated).",
])
}
if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" {
throw NSError(
domain: "Cron",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
}
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] = [
"name": name,
"enabled": self.enabled,
"schedule": schedule,
"sessionTarget": self.sessionTarget.rawValue,
"wakeMode": self.wakeMode.rawValue,
"payload": payload,
]
if !description.isEmpty { root["description"] = description }
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
root["isolation"] = [
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
]
}
return root.mapValues { AnyCodable($0) }
}
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.rawValue
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver
}
return payload
}
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": 60000
case "h": 3_600_000
default: 86_400_000
}
return Int(floor(n * factor))
}
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"
}
}

View File

@@ -0,0 +1,28 @@
#if DEBUG
extension CronJobEditor {
mutating func exerciseForTesting() {
self.name = "Test job"
self.description = "Test description"
self.enabled = true
self.sessionTarget = .isolated
self.wakeMode = .now
self.scheduleKind = .every
self.everyText = "15m"
self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic"
self.deliver = true
self.channel = .last
self.to = "+15551230000"
self.thinking = "low"
self.timeoutSeconds = "90"
self.bestEffortDeliver = true
self.postPrefix = "Cron"
_ = self.buildAgentTurnPayload()
_ = try? self.buildPayload()
_ = self.formatDuration(ms: 45000)
}
}
#endif

View File

@@ -0,0 +1,345 @@
import SwiftUI
struct CronJobEditor: View {
let job: CronJob?
@Binding var isSaving: Bool
@Binding var error: String?
let onCancel: () -> Void
let onSave: ([String: AnyCodable]) -> Void
let labelColumnWidth: CGFloat = 160
static let introText =
"Create a schedule that wakes clawd via the Gateway. "
+ "Use an isolated session for agent turns so your main chat stays clean."
static let sessionTargetNote =
"Main jobs post a system event into the current main session. "
+ "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/etc)."
static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote =
"Isolated jobs always run an agent turn. The result can be delivered to a surface, "
+ "and a short summary is posted back to your main chat."
static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target."
static let mainSummaryNote =
"Controls the label used when posting the completion summary back to the main session."
@State var name: String = ""
@State var description: String = ""
@State var enabled: Bool = true
@State var sessionTarget: CronSessionTarget = .main
@State var wakeMode: CronWakeMode = .nextHeartbeat
enum ScheduleKind: String, CaseIterable, Identifiable { case at, every, cron; var id: String { rawValue } }
@State var scheduleKind: ScheduleKind = .every
@State var atDate: Date = .init().addingTimeInterval(60 * 5)
@State var everyText: String = "1h"
@State var cronExpr: String = "0 9 * * 3"
@State var cronTz: String = ""
enum PayloadKind: String, CaseIterable, Identifiable { case systemEvent, agentTurn; var id: String { rawValue } }
@State var payloadKind: PayloadKind = .systemEvent
@State var systemEventText: String = ""
@State var agentMessage: String = ""
@State var deliver: Bool = false
@State var channel: GatewayAgentChannel = .last
@State var to: String = ""
@State var thinking: String = ""
@State var timeoutSeconds: String = ""
@State var bestEffortDeliver: Bool = false
@State var postPrefix: String = "Cron"
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(self.job == nil ? "New cron job" : "Edit cron job")
.font(.title3.weight(.semibold))
Text(Self.introText)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 14) {
GroupBox("Basics") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Name")
TextField("Required (e.g. “Daily summary”)", text: self.$name)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Description")
TextField("Optional notes", text: self.$description)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Enabled")
Toggle("", isOn: self.$enabled)
.labelsHidden()
.toggleStyle(.switch)
}
GridRow {
self.gridLabel("Session target")
Picker("", selection: self.$sessionTarget) {
Text("main").tag(CronSessionTarget.main)
Text("isolated").tag(CronSessionTarget.isolated)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("Wake mode")
Picker("", selection: self.$wakeMode) {
Text("next-heartbeat").tag(CronWakeMode.nextHeartbeat)
Text("now").tag(CronWakeMode.now)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
Self.sessionTargetNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
GroupBox("Schedule") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Kind")
Picker("", selection: self.$scheduleKind) {
Text("at").tag(ScheduleKind.at)
Text("every").tag(ScheduleKind.every)
Text("cron").tag(ScheduleKind.cron)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
Self.scheduleKindNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
switch self.scheduleKind {
case .at:
GridRow {
self.gridLabel("At")
DatePicker(
"",
selection: self.$atDate,
displayedComponents: [.date, .hourAndMinute])
.labelsHidden()
.frame(maxWidth: .infinity, alignment: .leading)
}
case .every:
GridRow {
self.gridLabel("Every")
TextField("10m, 1h, 1d", text: self.$everyText)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
case .cron:
GridRow {
self.gridLabel("Expression")
TextField("e.g. 0 9 * * 3", text: self.$cronExpr)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Timezone")
TextField("Optional (e.g. America/Los_Angeles)", text: self.$cronTz)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
}
}
}
GroupBox("Payload") {
VStack(alignment: .leading, spacing: 10) {
if self.sessionTarget == .isolated {
Text(Self.isolatedPayloadNote)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
self.agentTurnEditor
} else {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Kind")
Picker("", selection: self.$payloadKind) {
Text("systemEvent").tag(PayloadKind.systemEvent)
Text("agentTurn").tag(PayloadKind.agentTurn)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
Self.mainPayloadNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
switch self.payloadKind {
case .systemEvent:
TextField("System event text", text: self.$systemEventText, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(3...7)
.frame(maxWidth: .infinity)
case .agentTurn:
self.agentTurnEditor
}
}
}
}
if self.sessionTarget == .isolated {
GroupBox("Main session summary") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Prefix")
TextField("Cron", text: self.$postPrefix)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
Self.mainSummaryNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 2)
}
if let error, !error.isEmpty {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Button("Cancel") { self.onCancel() }
.keyboardShortcut(.cancelAction)
.buttonStyle(.bordered)
Spacer()
Button {
self.save()
} label: {
if self.isSaving {
ProgressView().controlSize(.small)
} else {
Text("Save")
}
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(self.isSaving)
}
}
.padding(24)
.frame(minWidth: 720, minHeight: 640)
.onAppear { self.hydrateFromJob() }
.onChange(of: self.payloadKind) { _, newValue in
if newValue == .agentTurn, self.sessionTarget == .main {
self.sessionTarget = .isolated
}
}
.onChange(of: self.sessionTarget) { _, newValue in
if newValue == .isolated {
self.payloadKind = .agentTurn
} else if newValue == .main, self.payloadKind == .agentTurn {
self.payloadKind = .systemEvent
}
}
}
var agentTurnEditor: some View {
VStack(alignment: .leading, spacing: 10) {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Message")
TextField("What should clawd do?", text: self.$agentMessage, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(3...7)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Thinking")
TextField("Optional (e.g. low)", text: self.$thinking)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Timeout")
TextField("Seconds (optional)", text: self.$timeoutSeconds)
.textFieldStyle(.roundedBorder)
.frame(width: 180, alignment: .leading)
}
GridRow {
self.gridLabel("Deliver")
Toggle("Deliver result to a surface", isOn: self.$deliver)
.toggleStyle(.switch)
}
}
if self.deliver {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Channel")
Picker("", selection: self.$channel) {
Text("last").tag(GatewayAgentChannel.last)
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
Text("telegram").tag(GatewayAgentChannel.telegram)
}
.labelsHidden()
.pickerStyle(.segmented)
.frame(maxWidth: .infinity, alignment: .leading)
}
GridRow {
self.gridLabel("To")
TextField("Optional override (phone number / chat id)", text: self.$to)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
self.gridLabel("Best-effort")
Toggle("Do not fail the job if delivery fails", isOn: self.$bestEffortDeliver)
.toggleStyle(.switch)
}
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
import Foundation
extension CronSettings {
func save(payload: [String: AnyCodable]) 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
}
}
}
}

View File

@@ -0,0 +1,54 @@
import SwiftUI
extension CronSettings {
var selectedJob: CronJob? {
guard let id = self.store.selectedJobId else { return nil }
return self.store.jobs.first(where: { $0.id == id })
}
func statusTint(_ status: String?) -> Color {
switch (status ?? "").lowercased() {
case "ok": .green
case "error": .red
case "skipped": .orange
default: .secondary
}
}
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)"
}
}
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"
}
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"
}
}

View File

@@ -0,0 +1,172 @@
import SwiftUI
extension CronSettings {
var body: some View {
VStack(alignment: .leading, spacing: 12) {
self.header
self.schedulerBanner
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) }
}
}
var schedulerBanner: some View {
Group {
if self.store.schedulerEnabled == false {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text("Cron scheduler is disabled")
.font(.headline)
Spacer()
}
Text(
"Jobs are saved, but they will not run automatically until `cron.enabled` is set to `true` " +
"and the Gateway restarts.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let storePath = self.store.schedulerStorePath, !storePath.isEmpty {
Text(storePath)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.middle)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(Color.orange.opacity(0.10))
.cornerRadius(8)
}
}
}
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)
}
}
}
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
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)
}
}
}

View File

@@ -0,0 +1,227 @@
import SwiftUI
extension CronSettings {
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
func jobContextMenu(_ job: CronJob) -> some View {
Button("Run now") { Task { await self.store.runJob(id: job.id, force: true) } }
if job.sessionTarget == .isolated {
Button("Open transcript") {
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
}
}
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
}
}
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)
if job.sessionTarget == .isolated {
Button("Transcript") {
WebChatManager.shared.show(sessionKey: "cron:\(job.id)")
}
.buttonStyle(.bordered)
}
Button("Edit") {
self.editingJob = job
self.editorError = nil
self.showEditor = true
}
.buttonStyle(.bordered)
}
}
}
func detailCard(_ job: CronJob) -> some View {
VStack(alignment: .leading, spacing: 10) {
LabeledContent("Schedule") { Text(self.scheduleSummary(job.schedule)).font(.callout) }
if let desc = job.description, !desc.isEmpty {
LabeledContent("Description") { Text(desc).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)
}
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)
}
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 summary = entry.summary, !summary.isEmpty {
Text(summary)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.lineLimit(2)
}
if let error = entry.error, !error.isEmpty {
Text(error)
.font(.caption)
.foregroundStyle(.orange)
.textSelection(.enabled)
.lineLimit(2)
}
}
.padding(.vertical, 4)
}
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) }
}
}
}
}
}
}
}

View File

@@ -0,0 +1,117 @@
import SwiftUI
#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",
description: nil,
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(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,
summary: "All good.",
runAtMs: nil,
durationMs: 1234,
nextRunAtMs: nil),
]
return CronSettings(store: store)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
@MainActor
extension CronSettings {
static func exerciseForTesting() {
let store = CronJobsStore(isPreview: true)
store.schedulerEnabled = false
store.schedulerStorePath = "/tmp/clawdis-cron-store.json"
let job = CronJob(
id: "job-1",
name: "Daily summary",
description: "Summary job",
enabled: true,
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_100_000,
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
sessionTarget: .isolated,
wakeMode: .nextHeartbeat,
payload: .agentTurn(
message: "Summarize",
thinking: "low",
timeoutSeconds: 120,
deliver: true,
channel: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),
state: CronJobState(
nextRunAtMs: 1_700_000_200_000,
runningAtMs: nil,
lastRunAtMs: 1_700_000_050_000,
lastStatus: "ok",
lastError: nil,
lastDurationMs: 1200))
let run = CronRunLogEntry(
ts: 1_700_000_050_000,
jobId: job.id,
action: "finished",
status: "ok",
error: nil,
summary: "done",
runAtMs: 1_700_000_050_000,
durationMs: 1200,
nextRunAtMs: 1_700_000_200_000)
store.jobs = [job]
store.selectedJobId = job.id
store.runEntries = [run]
var view = CronSettings(store: store)
_ = view.body
_ = view.jobRow(job)
_ = view.jobContextMenu(job)
_ = view.detailHeader(job)
_ = view.detailCard(job)
_ = view.runHistoryCard(job)
_ = view.runRow(run)
_ = view.payloadSummary(job.payload)
_ = view.scheduleSummary(job.schedule)
_ = view.statusTint(job.state.lastStatus)
_ = view.nextRunLabel(Date())
_ = view.formatDuration(ms: 1234)
}
}
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
import SwiftUI
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())
}
}