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") }
|
||||
.tag(SettingsTab.sessions)
|
||||
|
||||
CronSettings()
|
||||
.tabItem { Label("Cron", systemImage: "calendar") }
|
||||
.tag(SettingsTab.cron)
|
||||
|
||||
ToolsSettings()
|
||||
.tabItem { Label("Tools", systemImage: "wrench.and.screwdriver") }
|
||||
.tag(SettingsTab.tools)
|
||||
@@ -120,7 +124,7 @@ struct SettingsRootView: View {
|
||||
}
|
||||
|
||||
enum SettingsTab: CaseIterable {
|
||||
case general, tools, sessions, config, instances, voiceWake, permissions, debug, about
|
||||
case general, tools, sessions, cron, config, instances, voiceWake, permissions, debug, about
|
||||
static let windowWidth: CGFloat = 598 // +15%
|
||||
static let windowHeight: CGFloat = 718 // +15%
|
||||
var title: String {
|
||||
@@ -128,6 +132,7 @@ enum SettingsTab: CaseIterable {
|
||||
case .general: "General"
|
||||
case .tools: "Tools"
|
||||
case .sessions: "Sessions"
|
||||
case .cron: "Cron"
|
||||
case .config: "Config"
|
||||
case .instances: "Instances"
|
||||
case .voiceWake: "Voice Wake"
|
||||
|
||||
Reference in New Issue
Block a user