Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
303 lines
11 KiB
Swift
303 lines
11 KiB
Swift
import Foundation
|
|
import OpenClawProtocol
|
|
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.agentId = job.agentId ?? ""
|
|
self.enabled = job.enabled
|
|
self.deleteAfterRun = job.deleteAfterRun ?? false
|
|
switch job.parsedSessionTarget {
|
|
case let .predefined(target):
|
|
self.sessionTarget = target
|
|
self.preservedSessionTargetRaw = nil
|
|
case let .session(id):
|
|
self.sessionTarget = .isolated
|
|
self.preservedSessionTargetRaw = "session:\(id)"
|
|
}
|
|
self.wakeMode = job.wakeMode
|
|
|
|
switch job.schedule {
|
|
case let .at(at):
|
|
self.scheduleKind = .at
|
|
if let date = CronSchedule.parseAtDate(at) {
|
|
self.atDate = date
|
|
}
|
|
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 ?? ""
|
|
case .onExit:
|
|
// on-exit jobs are CLI-managed and have no editor form yet; fall back to
|
|
// the cron form so the editor still OPENS instead of failing to compile.
|
|
// Saving an on-exit job from the editor should be gated to avoid rewriting
|
|
// it as cron — tracked as a macOS read-only-editor follow-up.
|
|
self.scheduleKind = .cron
|
|
}
|
|
|
|
switch job.payload {
|
|
case let .systemEvent(text):
|
|
self.payloadKind = .systemEvent
|
|
self.systemEventText = text
|
|
case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _):
|
|
self.payloadKind = .agentTurn
|
|
self.agentMessage = message
|
|
self.thinking = thinking ?? ""
|
|
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
|
|
}
|
|
|
|
if let delivery = job.delivery {
|
|
self.deliveryMode = delivery.mode == .announce ? .announce : .none
|
|
let trimmed = (delivery.channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
self.channel = trimmed.isEmpty ? "last" : trimmed
|
|
self.to = delivery.to ?? ""
|
|
self.bestEffortDeliver = delivery.bestEffort ?? false
|
|
} else if self.isIsolatedLikeSessionTarget {
|
|
self.deliveryMode = .announce
|
|
}
|
|
}
|
|
|
|
func save() {
|
|
do {
|
|
self.error = nil
|
|
let payload = try self.buildPayload()
|
|
self.onSave(payload)
|
|
} catch {
|
|
self.error = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func buildPayload() throws -> [String: AnyCodable] {
|
|
// Gate on-exit saves: the editor has no on-exit schedule form (it falls back to
|
|
// the cron form), so saving would rewrite the on-exit schedule as cron and
|
|
// corrupt the job. Block it until a read-only/native on-exit editor exists.
|
|
if let job, case .onExit = job.schedule {
|
|
throw NSError(
|
|
domain: "Cron",
|
|
code: 0,
|
|
userInfo: [
|
|
NSLocalizedDescriptionKey:
|
|
"on-exit cron jobs can't be edited in the macOS app yet; manage them with the CLI.",
|
|
])
|
|
}
|
|
let name = try self.requireName()
|
|
let description = self.trimmed(self.description)
|
|
let agentId = self.trimmed(self.agentId)
|
|
let schedule = try self.buildSchedule()
|
|
let payload = try self.buildSelectedPayload()
|
|
|
|
try self.validateSessionTarget(payload)
|
|
try self.validatePayloadRequiredFields(payload)
|
|
|
|
var root: [String: Any] = [
|
|
"name": name,
|
|
"enabled": self.enabled,
|
|
"schedule": schedule,
|
|
"sessionTarget": self.effectiveSessionTargetRaw,
|
|
"wakeMode": self.wakeMode.rawValue,
|
|
"payload": payload,
|
|
]
|
|
self.applyDeleteAfterRun(to: &root)
|
|
if !description.isEmpty { root["description"] = description }
|
|
if !agentId.isEmpty {
|
|
root["agentId"] = agentId
|
|
} else if self.job?.agentId != nil {
|
|
root["agentId"] = NSNull()
|
|
}
|
|
|
|
if self.isIsolatedLikeSessionTarget {
|
|
root["delivery"] = self.buildDelivery()
|
|
}
|
|
|
|
return root.mapValues { AnyCodable($0) }
|
|
}
|
|
|
|
func buildDelivery() -> [String: Any] {
|
|
let mode = self.deliveryMode == .announce ? "announce" : "none"
|
|
var delivery: [String: Any] = ["mode": mode]
|
|
if self.deliveryMode == .announce {
|
|
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
delivery["channel"] = trimmed.isEmpty ? "last" : trimmed
|
|
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !to.isEmpty { delivery["to"] = to }
|
|
if self.bestEffortDeliver {
|
|
delivery["bestEffort"] = true
|
|
} else if self.job?.delivery?.bestEffort == true {
|
|
delivery["bestEffort"] = false
|
|
}
|
|
}
|
|
return delivery
|
|
}
|
|
|
|
func trimmed(_ value: String) -> String {
|
|
value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
func requireName() throws -> String {
|
|
let name = self.trimmed(self.name)
|
|
if name.isEmpty {
|
|
throw NSError(
|
|
domain: "Cron",
|
|
code: 0,
|
|
userInfo: [NSLocalizedDescriptionKey: "Name is required."])
|
|
}
|
|
return name
|
|
}
|
|
|
|
func buildSchedule() throws -> [String: Any] {
|
|
switch self.scheduleKind {
|
|
case .at:
|
|
return ["kind": "at", "at": CronSchedule.formatIsoDate(self.atDate)]
|
|
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)."])
|
|
}
|
|
return ["kind": "every", "everyMs": ms]
|
|
case .cron:
|
|
let expr = self.trimmed(self.cronExpr)
|
|
if expr.isEmpty {
|
|
throw NSError(
|
|
domain: "Cron",
|
|
code: 0,
|
|
userInfo: [NSLocalizedDescriptionKey: "Cron expression is required."])
|
|
}
|
|
let tz = self.trimmed(self.cronTz)
|
|
if tz.isEmpty {
|
|
return ["kind": "cron", "expr": expr]
|
|
}
|
|
return ["kind": "cron", "expr": expr, "tz": tz]
|
|
}
|
|
}
|
|
|
|
func buildSelectedPayload() throws -> [String: Any] {
|
|
if self.isIsolatedLikeSessionTarget { return self.buildAgentTurnPayload() }
|
|
switch self.payloadKind {
|
|
case .systemEvent:
|
|
let text = self.trimmed(self.systemEventText)
|
|
return ["kind": "systemEvent", "text": text]
|
|
case .agentTurn:
|
|
return self.buildAgentTurnPayload()
|
|
}
|
|
}
|
|
|
|
func validateSessionTarget(_ payload: [String: Any]) throws {
|
|
if self.effectiveSessionTargetRaw == "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.effectiveSessionTargetRaw != "main", payload["kind"] as? String == "systemEvent" {
|
|
throw NSError(
|
|
domain: "Cron",
|
|
code: 0,
|
|
userInfo: [NSLocalizedDescriptionKey: "Isolated jobs require agentTurn payloads."])
|
|
}
|
|
}
|
|
|
|
func validatePayloadRequiredFields(_ payload: [String: Any]) throws {
|
|
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."])
|
|
}
|
|
}
|
|
if payload["kind"] as? String == "agentTurn" {
|
|
if (payload["message"] as? String ?? "").isEmpty {
|
|
throw NSError(
|
|
domain: "Cron",
|
|
code: 0,
|
|
userInfo: [NSLocalizedDescriptionKey: "Agent message is required."])
|
|
}
|
|
}
|
|
}
|
|
|
|
func applyDeleteAfterRun(
|
|
to root: inout [String: Any],
|
|
scheduleKind: ScheduleKind? = nil,
|
|
deleteAfterRun: Bool? = nil)
|
|
{
|
|
let resolvedSchedule = scheduleKind ?? self.scheduleKind
|
|
let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun
|
|
if resolvedSchedule == .at {
|
|
root["deleteAfterRun"] = resolvedDelete
|
|
} else if self.job?.deleteAfterRun != nil {
|
|
root["deleteAfterRun"] = false
|
|
}
|
|
}
|
|
|
|
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 }
|
|
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))
|
|
}
|
|
|
|
var effectiveSessionTargetRaw: String {
|
|
if self.sessionTarget == .isolated,
|
|
let preserved = self.preservedSessionTargetRaw?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!preserved.isEmpty
|
|
{
|
|
return preserved
|
|
}
|
|
return self.sessionTarget.rawValue
|
|
}
|
|
|
|
var isIsolatedLikeSessionTarget: Bool {
|
|
self.effectiveSessionTargetRaw != "main"
|
|
}
|
|
|
|
func formatDuration(ms: Int) -> String {
|
|
DurationFormattingSupport.conciseDuration(ms: ms)
|
|
}
|
|
}
|