import ClawdbotProtocol import Foundation import SwiftUI extension CronJobEditor { func gridLabel(_ text: String) -> some View { Text(text) .foregroundStyle(.secondary) .frame(width: self.labelColumnWidth, alignment: .leading) } func hydrateFromJob() { guard let job else { return } self.name = job.name self.description = job.description ?? "" self.enabled = job.enabled self.sessionTarget = job.sessionTarget self.wakeMode = job.wakeMode switch job.schedule { case let .at(atMs): self.scheduleKind = .at self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000) case let .every(everyMs, _): self.scheduleKind = .every self.everyText = self.formatDuration(ms: everyMs) case let .cron(expr, tz): self.scheduleKind = .cron self.cronExpr = expr self.cronTz = tz ?? "" } switch job.payload { case let .systemEvent(text): self.payloadKind = .systemEvent self.systemEventText = text case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, bestEffortDeliver): self.payloadKind = .agentTurn self.agentMessage = message self.thinking = thinking ?? "" self.timeoutSeconds = timeoutSeconds.map(String.init) ?? "" self.deliver = deliver ?? false self.provider = GatewayAgentProvider(raw: provider) self.to = to ?? "" self.bestEffortDeliver = bestEffortDeliver ?? false } self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron" } func save() { do { self.error = nil let payload = try self.buildPayload() self.onSave(payload) } catch { self.error = error.localizedDescription } } func buildPayload() throws -> [String: AnyCodable] { let name = self.name.trimmingCharacters(in: .whitespacesAndNewlines) if name.isEmpty { throw NSError( domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Name is required."]) } let description = self.description.trimmingCharacters(in: .whitespacesAndNewlines) let schedule: [String: Any] switch self.scheduleKind { case .at: schedule = ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)] case .every: guard let ms = Self.parseDurationMs(self.everyText) else { throw NSError( domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."]) } schedule = ["kind": "every", "everyMs": ms] case .cron: let expr = self.cronExpr.trimmingCharacters(in: .whitespacesAndNewlines) if expr.isEmpty { throw NSError( domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."]) } let tz = self.cronTz.trimmingCharacters(in: .whitespacesAndNewlines) if tz.isEmpty { schedule = ["kind": "cron", "expr": expr] } else { schedule = ["kind": "cron", "expr": expr, "tz": tz] } } let payload: [String: Any] = { if self.sessionTarget == .isolated { return self.buildAgentTurnPayload() } switch self.payloadKind { case .systemEvent: let text = self.systemEventText.trimmingCharacters(in: .whitespacesAndNewlines) return ["kind": "systemEvent", "text": text] case .agentTurn: return self.buildAgentTurnPayload() } }() if self.sessionTarget == .main, payload["kind"] as? String == "agentTurn" { throw NSError( domain: "Cron", code: 0, userInfo: [ NSLocalizedDescriptionKey: "Main session jobs require systemEvent payloads (switch Session target to isolated).", ]) } if self.sessionTarget == .isolated, payload["kind"] as? String == "systemEvent" { throw NSError( domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."]) } if payload["kind"] as? String == "systemEvent" { if (payload["text"] as? String ?? "").isEmpty { throw NSError( domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "System event text is required."]) } } else if payload["kind"] as? String == "agentTurn" { if (payload["message"] as? String ?? "").isEmpty { throw NSError( domain: "Cron", code: 0, userInfo: [NSLocalizedDescriptionKey: "Agent message is required."]) } } var root: [String: Any] = [ "name": name, "enabled": self.enabled, "schedule": schedule, "sessionTarget": self.sessionTarget.rawValue, "wakeMode": self.wakeMode.rawValue, "payload": payload, ] if !description.isEmpty { root["description"] = description } if self.sessionTarget == .isolated { let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines) root["isolation"] = [ "postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed, ] } return root.mapValues { AnyCodable($0) } } func buildAgentTurnPayload() -> [String: Any] { let msg = self.agentMessage.trimmingCharacters(in: .whitespacesAndNewlines) var payload: [String: Any] = ["kind": "agentTurn", "message": msg] let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines) if !thinking.isEmpty { payload["thinking"] = thinking } if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n } payload["deliver"] = self.deliver if self.deliver { payload["provider"] = self.provider.rawValue let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines) if !to.isEmpty { payload["to"] = to } payload["bestEffortDeliver"] = self.bestEffortDeliver } return payload } static func parseDurationMs(_ input: String) -> Int? { let raw = input.trimmingCharacters(in: .whitespacesAndNewlines) if raw.isEmpty { return nil } let rx = try? NSRegularExpression(pattern: "^(\\d+(?:\\.\\d+)?)(ms|s|m|h|d)$", options: [.caseInsensitive]) guard let match = rx?.firstMatch(in: raw, range: NSRange(location: 0, length: raw.utf16.count)) else { return nil } func group(_ idx: Int) -> String { let range = match.range(at: idx) guard let r = Range(range, in: raw) else { return "" } return String(raw[r]) } let n = Double(group(1)) ?? 0 if !n.isFinite || n <= 0 { return nil } let unit = group(2).lowercased() let factor: Double = switch unit { case "ms": 1 case "s": 1000 case "m": 60000 case "h": 3_600_000 default: 86_400_000 } return Int(floor(n * factor)) } func formatDuration(ms: Int) -> String { if ms < 1000 { return "\(ms)ms" } let s = Double(ms) / 1000.0 if s < 60 { return "\(Int(round(s)))s" } let m = s / 60.0 if m < 60 { return "\(Int(round(m)))m" } let h = m / 60.0 if h < 48 { return "\(Int(round(h)))h" } let d = h / 24.0 return "\(Int(round(d)))d" } }