mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 22:51:15 +00:00
* fix(apps): address native Skills review feedback * feat(apps): add native Automations parity * fix(ios): preserve automation editor selection * fix(android): page automation discovery * chore(apps): refresh native source inventory * fix(ios): refresh invalid automation diagnostics * chore(apps): sync native localization * fix(cron): stabilize paginated snapshots * refactor(cron): split pagination helpers * fix(apps): address final native review feedback * chore(apps): refresh native source inventory * fix(ios): dedupe automation list actions * chore: keep release note in PR context * fix(ci): repair current main architecture gates * fix(ios): default new skill requirement fields * fix(ios): retain queued automation reservations
436 lines
18 KiB
Swift
436 lines
18 KiB
Swift
import Foundation
|
|
import Observation
|
|
import OpenClawProtocol
|
|
|
|
enum AgentAutomationScheduleDraft: Equatable {
|
|
case at(at: String)
|
|
case every(everyMs: String, anchorMs: String)
|
|
case cron(expression: String, timezone: String, staggerMs: String)
|
|
case onExit(command: String, cwd: String)
|
|
|
|
var kindLabel: String {
|
|
switch self {
|
|
case .at: String(localized: "One time")
|
|
case .every: String(localized: "Interval")
|
|
case .cron: String(localized: "Cron expression")
|
|
case .onExit: String(localized: "On command exit")
|
|
}
|
|
}
|
|
}
|
|
|
|
enum AgentAutomationPayloadDraft: Equatable {
|
|
case systemEvent(text: String)
|
|
case agentTurn(message: String, model: String, thinking: String)
|
|
case command(argvJSON: String, cwd: String)
|
|
|
|
var kindLabel: String {
|
|
switch self {
|
|
case .systemEvent: String(localized: "System event")
|
|
case .agentTurn: String(localized: "Agent turn")
|
|
case .command: String(localized: "Command")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AgentAutomationDraft: Equatable {
|
|
var name: String
|
|
var description: String
|
|
var enabled: Bool
|
|
var deleteAfterRun: Bool
|
|
var schedule: AgentAutomationScheduleDraft
|
|
var sessionTarget: String
|
|
var wakeMode: String
|
|
var payload: AgentAutomationPayloadDraft
|
|
|
|
init?(job: CronJob) {
|
|
guard let schedule = Self.schedule(from: job.schedule),
|
|
let payload = Self.payload(from: job.payload),
|
|
let sessionTarget = AgentAutomationValue.string(job.sessiontarget),
|
|
let wakeMode = AgentAutomationValue.string(job.wakemode)
|
|
else { return nil }
|
|
self.name = job.name
|
|
self.description = job.description ?? ""
|
|
self.enabled = job.enabled
|
|
if case .at = schedule {
|
|
self.deleteAfterRun = job.deleteafterrun == true
|
|
} else {
|
|
self.deleteAfterRun = false
|
|
}
|
|
self.schedule = schedule
|
|
self.sessionTarget = sessionTarget
|
|
self.wakeMode = wakeMode
|
|
self.payload = payload
|
|
}
|
|
|
|
private static func schedule(from value: AnyCodable) -> AgentAutomationScheduleDraft? {
|
|
guard let object = AgentAutomationValue.object(value),
|
|
let kind = AgentAutomationValue.string(object["kind"])
|
|
else { return nil }
|
|
switch kind {
|
|
case "at":
|
|
guard let at = AgentAutomationValue.string(object["at"]) else { return nil }
|
|
return .at(at: at)
|
|
case "every":
|
|
guard let everyMs = AgentAutomationValue.int(object["everyMs"]) else { return nil }
|
|
return .every(
|
|
everyMs: String(everyMs),
|
|
anchorMs: AgentAutomationValue.int(object["anchorMs"]).map(String.init) ?? "")
|
|
case "cron":
|
|
guard let expression = AgentAutomationValue.string(object["expr"]) else { return nil }
|
|
return .cron(
|
|
expression: expression,
|
|
timezone: AgentAutomationValue.string(object["tz"]) ?? "",
|
|
staggerMs: AgentAutomationValue.int(object["staggerMs"]).map(String.init) ?? "")
|
|
case "on-exit":
|
|
guard let command = AgentAutomationValue.string(object["command"]) else { return nil }
|
|
return .onExit(command: command, cwd: AgentAutomationValue.string(object["cwd"]) ?? "")
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func payload(from value: AnyCodable) -> AgentAutomationPayloadDraft? {
|
|
guard let object = AgentAutomationValue.object(value),
|
|
let kind = AgentAutomationValue.string(object["kind"])
|
|
else { return nil }
|
|
switch kind {
|
|
case "systemEvent":
|
|
guard let text = AgentAutomationValue.string(object["text"]) else { return nil }
|
|
return .systemEvent(text: text)
|
|
case "agentTurn":
|
|
guard let message = AgentAutomationValue.string(object["message"]) else { return nil }
|
|
return .agentTurn(
|
|
message: message,
|
|
model: AgentAutomationValue.string(object["model"]) ?? "",
|
|
thinking: AgentAutomationValue.string(object["thinking"]) ?? "")
|
|
case "command":
|
|
guard let argv = AgentAutomationValue.strings(object["argv"]), !argv.isEmpty else { return nil }
|
|
let data = try? JSONSerialization.data(withJSONObject: argv, options: [.sortedKeys])
|
|
let argvJSON = data.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
|
return .command(argvJSON: argvJSON, cwd: AgentAutomationValue.string(object["cwd"]) ?? "")
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
struct AgentAutomationRunsResponse: Decodable {
|
|
let entries: [CronRunLogEntry]
|
|
}
|
|
|
|
struct AgentAutomationRunResult: Decodable {
|
|
let ok: Bool
|
|
let ran: Bool?
|
|
let enqueued: Bool?
|
|
let runId: String?
|
|
let processInstanceId: String?
|
|
let reason: String?
|
|
}
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class AgentAutomationPendingRunRegistry {
|
|
private var runIDsByJobID: [String: String] = [:]
|
|
|
|
func runID(for jobID: String) -> String? {
|
|
self.runIDsByJobID[jobID]
|
|
}
|
|
|
|
@discardableResult
|
|
func reserve(jobID: String, runID: String) -> Bool {
|
|
guard self.runIDsByJobID[jobID] == nil else { return false }
|
|
self.runIDsByJobID[jobID] = runID
|
|
return true
|
|
}
|
|
|
|
func release(jobID: String, runID: String) {
|
|
// Only the owning tracker may clear a reservation; a stale completion
|
|
// must not reopen Run while a newer exact run is still pending.
|
|
guard self.runIDsByJobID[jobID] == runID else { return }
|
|
self.runIDsByJobID.removeValue(forKey: jobID)
|
|
}
|
|
}
|
|
|
|
func agentAutomationRunSkipShouldRefresh(reason: String?) -> Bool {
|
|
reason == "invalid-spec"
|
|
}
|
|
|
|
enum AgentAutomationRunOutcome: Equatable {
|
|
case success
|
|
case skipped
|
|
case failure
|
|
case unknown
|
|
}
|
|
|
|
func agentAutomationRunOutcome(status: String?, error: String?) -> AgentAutomationRunOutcome {
|
|
let normalizedStatus = status?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if error?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false || normalizedStatus == "error" {
|
|
return .failure
|
|
}
|
|
switch normalizedStatus {
|
|
case "ok": return .success
|
|
case "skipped": return .skipped
|
|
default: return .unknown
|
|
}
|
|
}
|
|
|
|
func agentAutomationDeletesAfterSuccessfulRun(job: CronJob, outcome: AgentAutomationRunOutcome) -> Bool {
|
|
guard outcome == .success,
|
|
job.deleteafterrun == true,
|
|
let draft = AgentAutomationDraft(job: job)
|
|
else { return false }
|
|
if case .at = draft.schedule { return true }
|
|
return false
|
|
}
|
|
|
|
enum AgentAutomationEditError: LocalizedError {
|
|
case invalidModel
|
|
case missingRevision
|
|
case noChanges
|
|
case invalidName
|
|
case invalidSessionTarget
|
|
case invalidWakeMode
|
|
case invalidSchedule(String)
|
|
case invalidPayload(String)
|
|
case gatewayChanged
|
|
case gatewayChangedAfterDispatch
|
|
case invalidResponse
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidModel: String(localized: "This automation uses fields this app cannot edit safely.")
|
|
case .missingRevision: String(localized: "Update the Gateway before saving automation changes from iOS.")
|
|
case .noChanges: String(localized: "No automation changes to save.")
|
|
case .invalidName: String(localized: "Automation name is required.")
|
|
case .invalidSessionTarget:
|
|
String(localized: "Session target must be main, isolated, current, or session:<id>.")
|
|
case .invalidWakeMode: String(localized: "Wake mode must be now or next-heartbeat.")
|
|
case let .invalidSchedule(message), let .invalidPayload(message): message
|
|
case .gatewayChanged: String(localized: "The connected Gateway changed. Refresh Automations and try again.")
|
|
case .gatewayChangedAfterDispatch:
|
|
String(localized: "The Gateway changed after the request was sent. Check current state before retrying.")
|
|
case .invalidResponse: String(localized: "The Gateway returned an unexpected automation response.")
|
|
}
|
|
}
|
|
}
|
|
|
|
enum AgentAutomationValue {
|
|
static func object(_ value: AnyCodable?) -> [String: AnyCodable]? {
|
|
value?.value as? [String: AnyCodable]
|
|
}
|
|
|
|
static func string(_ value: AnyCodable?) -> String? {
|
|
(value?.value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
static func int(_ value: AnyCodable?) -> Int? {
|
|
switch value?.value {
|
|
case let value as Int: value
|
|
case let value as Double where value.isFinite: Int(value)
|
|
case let value as String: Int(value)
|
|
default: nil
|
|
}
|
|
}
|
|
|
|
static func strings(_ value: AnyCodable?) -> [String]? {
|
|
if let values = value?.value as? [String] { return values }
|
|
return (value?.value as? [AnyCodable])?.compactMap { $0.value as? String }
|
|
}
|
|
}
|
|
|
|
func buildAgentAutomationUpdateParams(job: CronJob, draft: AgentAutomationDraft) throws -> String {
|
|
guard let baseline = AgentAutomationDraft(job: job) else { throw AgentAutomationEditError.invalidModel }
|
|
guard let revision = job.configrevision, !revision.isEmpty else { throw AgentAutomationEditError.missingRevision }
|
|
let name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !name.isEmpty else { throw AgentAutomationEditError.invalidName }
|
|
let description = draft.description.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let sessionTarget = draft.sessionTarget.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard validAgentAutomationSessionTarget(sessionTarget) else {
|
|
throw AgentAutomationEditError.invalidSessionTarget
|
|
}
|
|
let wakeMode = draft.wakeMode.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard wakeMode == "now" || wakeMode == "next-heartbeat" else {
|
|
throw AgentAutomationEditError.invalidWakeMode
|
|
}
|
|
|
|
var patch: [String: Any] = [:]
|
|
if name != baseline.name { patch["name"] = name }
|
|
if description != baseline.description { patch["description"] = description }
|
|
if draft.enabled != baseline.enabled { patch["enabled"] = draft.enabled }
|
|
if draft.deleteAfterRun != baseline.deleteAfterRun { patch["deleteAfterRun"] = draft.deleteAfterRun }
|
|
if let schedule = try agentAutomationScheduleJSON(draft.schedule, baseline: baseline.schedule) {
|
|
patch["schedule"] = schedule
|
|
}
|
|
if sessionTarget != baseline.sessionTarget { patch["sessionTarget"] = sessionTarget }
|
|
if wakeMode != baseline.wakeMode { patch["wakeMode"] = wakeMode }
|
|
if let payload = try agentAutomationPayloadJSON(draft.payload, baseline: baseline.payload) {
|
|
patch["payload"] = payload
|
|
}
|
|
guard !patch.isEmpty else { throw AgentAutomationEditError.noChanges }
|
|
return try encodeAgentAutomationParams([
|
|
"id": job.id,
|
|
"expectedConfigRevision": revision,
|
|
"patch": patch,
|
|
])
|
|
}
|
|
|
|
func agentAutomationHasSemanticChanges(job: CronJob, draft: AgentAutomationDraft) -> Bool {
|
|
do {
|
|
_ = try buildAgentAutomationUpdateParams(job: job, draft: draft)
|
|
return true
|
|
} catch AgentAutomationEditError.noChanges {
|
|
return false
|
|
} catch {
|
|
return draft != AgentAutomationDraft(job: job)
|
|
}
|
|
}
|
|
|
|
func buildAgentAutomationEnabledParams(job: CronJob, enabled: Bool) throws -> String {
|
|
guard let revision = job.configrevision, !revision.isEmpty else { throw AgentAutomationEditError.missingRevision }
|
|
return try encodeAgentAutomationParams([
|
|
"id": job.id,
|
|
"expectedConfigRevision": revision,
|
|
"patch": ["enabled": enabled],
|
|
])
|
|
}
|
|
|
|
private func validAgentAutomationSessionTarget(_ value: String) -> Bool {
|
|
value == "main" || value == "isolated" || value == "current"
|
|
|| (value.hasPrefix("session:") && !value.dropFirst("session:".count).isEmpty)
|
|
}
|
|
|
|
private func agentAutomationScheduleJSON(
|
|
_ schedule: AgentAutomationScheduleDraft,
|
|
baseline: AgentAutomationScheduleDraft) throws -> [String: Any]?
|
|
{
|
|
switch (schedule, baseline) {
|
|
case let (.at(at), .at(oldAt)):
|
|
let value = at.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !value.isEmpty else {
|
|
throw AgentAutomationEditError.invalidSchedule(String(localized: "One-time automations need an ISO time."))
|
|
}
|
|
guard value != oldAt else { return nil }
|
|
return ["kind": "at", "at": value]
|
|
case let (.every(everyMs, anchorMs), .every(oldEveryMs, oldAnchorMs)):
|
|
guard let interval = Int(everyMs), interval > 0 else {
|
|
throw AgentAutomationEditError.invalidSchedule(
|
|
String(localized: "Interval must be a positive number of milliseconds."))
|
|
}
|
|
let anchor = try optionalNonNegativeAgentAutomationInt(
|
|
anchorMs,
|
|
error: String(localized: "Anchor must be a non-negative number of milliseconds."))
|
|
guard interval != Int(oldEveryMs) || anchor != Int(oldAnchorMs) else { return nil }
|
|
var result: [String: Any] = ["kind": "every", "everyMs": interval]
|
|
if let anchor { result["anchorMs"] = anchor }
|
|
return result
|
|
case let (.cron(expression, timezone, staggerMs), .cron(oldExpression, oldTimezone, oldStaggerMs)):
|
|
let expr = expression.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !expr.isEmpty else {
|
|
throw AgentAutomationEditError.invalidSchedule(String(localized: "Cron expression is required."))
|
|
}
|
|
let timezone = timezone.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let requestedStagger = try optionalNonNegativeAgentAutomationInt(
|
|
staggerMs,
|
|
error: String(localized: "Stagger must be a non-negative number of milliseconds."))
|
|
let stagger = requestedStagger ?? (oldStaggerMs.isEmpty ? nil : 0)
|
|
guard expr != oldExpression
|
|
|| timezone != oldTimezone
|
|
|| stagger != Int(oldStaggerMs)
|
|
else { return nil }
|
|
var result: [String: Any] = ["kind": "cron", "expr": expr]
|
|
if !timezone.isEmpty { result["tz"] = timezone }
|
|
if let stagger { result["staggerMs"] = stagger }
|
|
return result
|
|
case let (.onExit(command, cwd), .onExit(oldCommand, oldCwd)):
|
|
let command = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !command.isEmpty else {
|
|
throw AgentAutomationEditError.invalidSchedule(
|
|
String(localized: "On-exit automations need a command."))
|
|
}
|
|
let cwd = cwd.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard command != oldCommand || cwd != oldCwd else { return nil }
|
|
var result: [String: Any] = ["kind": "on-exit", "command": command]
|
|
if !cwd.isEmpty { result["cwd"] = cwd }
|
|
return result
|
|
default:
|
|
throw AgentAutomationEditError.invalidSchedule(
|
|
String(localized: "Changing schedule type is available in the Control UI."))
|
|
}
|
|
}
|
|
|
|
private func optionalNonNegativeAgentAutomationInt(_ raw: String, error message: String) throws -> Int? {
|
|
let value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !value.isEmpty else { return nil }
|
|
guard let parsed = Int(value), parsed >= 0 else {
|
|
throw AgentAutomationEditError.invalidSchedule(message)
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
private func agentAutomationPayloadJSON(
|
|
_ payload: AgentAutomationPayloadDraft,
|
|
baseline: AgentAutomationPayloadDraft) throws -> [String: Any]?
|
|
{
|
|
switch (payload, baseline) {
|
|
case let (.systemEvent(text), .systemEvent(oldText)):
|
|
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !text.isEmpty else {
|
|
throw AgentAutomationEditError.invalidPayload(String(localized: "System event text is required."))
|
|
}
|
|
guard text != oldText else { return nil }
|
|
return ["kind": "systemEvent", "text": text]
|
|
case let (.agentTurn(message, model, thinking), .agentTurn(oldMessage, oldModel, oldThinking)):
|
|
let message = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !message.isEmpty else {
|
|
throw AgentAutomationEditError.invalidPayload(String(localized: "Agent message is required."))
|
|
}
|
|
let model = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let thinking = thinking.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard message != oldMessage || model != oldModel || thinking != oldThinking else { return nil }
|
|
var result: [String: Any] = ["kind": "agentTurn"]
|
|
if message != oldMessage { result["message"] = message }
|
|
if model != oldModel { result["model"] = model.isEmpty ? NSNull() : model }
|
|
if thinking != oldThinking { result["thinking"] = thinking.isEmpty ? NSNull() : thinking }
|
|
return result
|
|
case let (.command(argvJSON, cwd), .command(oldArgvJSON, oldCwd)):
|
|
guard let data = argvJSON.data(using: .utf8),
|
|
let object = try? JSONSerialization.jsonObject(with: data),
|
|
let argv = object as? [String],
|
|
!argv.isEmpty,
|
|
argv.allSatisfy({ !$0.isEmpty })
|
|
else {
|
|
throw AgentAutomationEditError.invalidPayload(
|
|
String(localized: "Command arguments must be a JSON array of non-empty strings."))
|
|
}
|
|
let cwd = cwd.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !oldCwd.isEmpty, cwd.isEmpty {
|
|
throw AgentAutomationEditError.invalidPayload(
|
|
String(localized: "The Gateway can change this command path but cannot clear it."))
|
|
}
|
|
let oldArgv: [String] = if let oldData = oldArgvJSON.data(using: .utf8),
|
|
let oldObject = try? JSONSerialization.jsonObject(with: oldData),
|
|
let parsed = oldObject as? [String]
|
|
{
|
|
parsed
|
|
} else {
|
|
[]
|
|
}
|
|
guard argv != oldArgv || cwd != oldCwd else { return nil }
|
|
var result: [String: Any] = ["kind": "command"]
|
|
if argv != oldArgv { result["argv"] = argv }
|
|
if cwd != oldCwd { result["cwd"] = cwd }
|
|
return result
|
|
default:
|
|
throw AgentAutomationEditError.invalidPayload(
|
|
String(localized: "Changing action type is available in the Control UI."))
|
|
}
|
|
}
|
|
|
|
private func encodeAgentAutomationParams(_ value: [String: Any]) throws -> String {
|
|
let data = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
|
|
guard let text = String(data: data, encoding: .utf8) else {
|
|
throw AgentAutomationEditError.invalidPayload(String(localized: "Could not encode automation changes."))
|
|
}
|
|
return text
|
|
}
|