macOS: add Cron settings tab
This commit is contained in:
209
apps/macos/Sources/Clawdis/CronJobsStore.swift
Normal file
209
apps/macos/Sources/Clawdis/CronJobsStore.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
212
apps/macos/Sources/Clawdis/CronModels.swift
Normal file
212
apps/macos/Sources/Clawdis/CronModels.swift
Normal 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]
|
||||||
|
}
|
||||||
|
|
||||||
809
apps/macos/Sources/Clawdis/CronSettings.swift
Normal file
809
apps/macos/Sources/Clawdis/CronSettings.swift
Normal 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
|
||||||
@@ -36,6 +36,10 @@ struct SettingsRootView: View {
|
|||||||
.tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") }
|
.tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") }
|
||||||
.tag(SettingsTab.sessions)
|
.tag(SettingsTab.sessions)
|
||||||
|
|
||||||
|
CronSettings()
|
||||||
|
.tabItem { Label("Cron", systemImage: "calendar") }
|
||||||
|
.tag(SettingsTab.cron)
|
||||||
|
|
||||||
ToolsSettings()
|
ToolsSettings()
|
||||||
.tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") }
|
.tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") }
|
||||||
.tag(SettingsTab.tools)
|
.tag(SettingsTab.tools)
|
||||||
@@ -120,7 +124,7 @@ struct SettingsRootView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum SettingsTab: CaseIterable {
|
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 windowWidth: CGFloat = 598 // +15%
|
||||||
static let windowHeight: CGFloat = 718 // +15%
|
static let windowHeight: CGFloat = 718 // +15%
|
||||||
var title: String {
|
var title: String {
|
||||||
@@ -128,6 +132,7 @@ enum SettingsTab: CaseIterable {
|
|||||||
case .general: "General"
|
case .general: "General"
|
||||||
case .tools: "Tools"
|
case .tools: "Tools"
|
||||||
case .sessions: "Sessions"
|
case .sessions: "Sessions"
|
||||||
|
case .cron: "Cron"
|
||||||
case .config: "Config"
|
case .config: "Config"
|
||||||
case .instances: "Instances"
|
case .instances: "Instances"
|
||||||
case .voiceWake: "Voice Wake"
|
case .voiceWake: "Voice Wake"
|
||||||
|
|||||||
Reference in New Issue
Block a user