1078 lines
44 KiB
Swift
1078 lines
44 KiB
Swift
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.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) }
|
||
}
|
||
}
|
||
|
||
private 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
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) } }
|
||
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
|
||
}
|
||
}
|
||
|
||
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)
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
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 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)
|
||
}
|
||
|
||
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
|
||
|
||
private let labelColumnWidth: CGFloat = 160
|
||
private 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."
|
||
private 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)."
|
||
private static let scheduleKindNote =
|
||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||
private 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."
|
||
private static let mainPayloadNote =
|
||
"System events are injected into the current main session. Agent turns require an isolated session target."
|
||
private static let mainSummaryNote =
|
||
"Controls the label used when posting the completion summary back to the main session."
|
||
|
||
@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 = .init().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 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("Optional label (e.g. “Daily summary”)", text: self.$name)
|
||
.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
|
||
}
|
||
}
|
||
}
|
||
|
||
private 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("last")
|
||
Text("whatsapp").tag("whatsapp")
|
||
Text("telegram").tag("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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func gridLabel(_ text: String) -> some View {
|
||
Text(text)
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: self.labelColumnWidth, alignment: .leading)
|
||
}
|
||
|
||
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.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 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] = [
|
||
"enabled": self.enabled,
|
||
"schedule": schedule,
|
||
"sessionTarget": self.sessionTarget.rawValue,
|
||
"wakeMode": self.wakeMode.rawValue,
|
||
"payload": payload,
|
||
]
|
||
if !name.isEmpty { root["name"] = name }
|
||
|
||
if self.sessionTarget == .isolated {
|
||
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
root["isolation"] = [
|
||
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
|
||
]
|
||
}
|
||
|
||
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": 60000
|
||
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(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)
|
||
}
|
||
}
|
||
#endif
|