Files
openclaw/apps/ios/Sources/Design/AgentAutomationDetailScreen.swift
Peter Steinberger 334e7f3f21 feat(apps): add native mobile Automations parity (#106355)
* 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
2026-07-13 15:35:32 -07:00

1109 lines
44 KiB
Swift

import OpenClawKit
import OpenClawProtocol
import SwiftUI
private enum AgentAutomationDetailSection: String, CaseIterable, Identifiable {
case settings
case history
var id: String {
self.rawValue
}
var title: String {
switch self {
case .settings: String(localized: "Settings")
case .history: String(localized: "History")
}
}
}
private struct AgentAutomationNotice {
enum Tone: Equatable {
case success
case warning
case error
var color: Color {
switch self {
case .success: OpenClawBrand.accent
case .warning: .orange
case .error: .red
}
}
}
let tone: Tone
let title: String
let message: String
}
struct AgentAutomationDetailScreen: View {
@Environment(NodeAppModel.self) private var appModel
@Environment(\.dismiss) private var dismiss
@State private var job: CronJob
@State private var draft: AgentAutomationDraft?
@State private var runs: [CronRunLogEntry] = []
@State private var selectedSection: AgentAutomationDetailSection = .settings
@State private var loading = false
@State private var actionName: String?
@State private var notice: AgentAutomationNotice?
@State private var confirmDelete = false
private let sourceGatewayID: String
private let pendingRunRegistry: AgentAutomationPendingRunRegistry
private let onRunQueued: (String, String?) -> Void
private let onChanged: () -> Void
init(
initialJob: CronJob,
sourceGatewayID: String,
pendingRunRegistry: AgentAutomationPendingRunRegistry,
onRunQueued: @escaping (String, String?) -> Void,
onChanged: @escaping () -> Void)
{
self._job = State(initialValue: initialJob)
self._draft = State(initialValue: AgentAutomationDraft(job: initialJob))
self.sourceGatewayID = sourceGatewayID
self.pendingRunRegistry = pendingRunRegistry
self.onRunQueued = onRunQueued
self.onChanged = onChanged
}
var body: some View {
NavigationStack {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
self.summaryCard
self.sectionPicker
if let notice {
self.noticeCard(notice)
}
if self.selectedSection == .settings {
self.settingsContent
} else {
self.historyContent
}
}
.padding(.vertical, 12)
}
.scrollDismissesKeyboard(.interactively)
if self.loading, self.draft == nil {
ProgressView()
.controlSize(.large)
}
}
.navigationTitle("Automation")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
self.dismiss()
} label: {
Text("Close")
.font(OpenClawType.subheadSemiBold)
}
}
ToolbarItem(placement: .primaryAction) {
Button {
Task { await self.reload() }
} label: {
Image(systemName: "arrow.clockwise")
}
.disabled(self.loading || self.isBusy)
.accessibilityLabel("Refresh automation")
}
}
}
.font(OpenClawType.body)
.presentationDetents([.large])
.interactiveDismissDisabled(self.isBusy)
.task { await self.initialLoad() }
.confirmationDialog(
"Delete this automation?",
isPresented: self.$confirmDelete,
titleVisibility: .visible)
{
Button(role: .destructive) {
Task { await self.deleteAutomation() }
} label: {
Text("Delete Automation")
.font(OpenClawType.subheadSemiBold)
}
Button(role: .cancel) {} label: {
Text("Cancel")
.font(OpenClawType.subheadSemiBold)
}
} message: {
Text("This permanently removes the automation and its schedule from the Gateway.")
.font(OpenClawType.caption)
}
}
private var isBusy: Bool {
self.actionName != nil
}
private var pendingRunID: String? {
self.pendingRunRegistry.runID(for: self.job.id)
}
private var canAdmin: Bool {
self.appModel.hasOperatorAdminScope
}
private var hasUnsavedChanges: Bool {
guard let draft else { return false }
return agentAutomationHasSemanticChanges(job: self.job, draft: draft)
}
private var summaryCard: some View {
ProCard(radius: OpenClawProMetric.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(
systemName: self.job.enabled ? "clock.arrow.circlepath" : "pause.circle.fill",
color: self.job.enabled ? OpenClawBrand.accent : .secondary)
VStack(alignment: .leading, spacing: 3) {
Text(self.job.name)
.font(OpenClawType.headline)
.lineLimit(2)
Text(self.scheduleSummary)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 8)
ProValuePill(
value: self.job.enabled ? String(localized: "active") : String(localized: "paused"),
color: self.job.enabled ? OpenClawBrand.accent : .secondary)
}
HStack(spacing: 8) {
self.summaryMetric(
title: String(localized: "Next run"),
value: Self.relativeTime(self.job.nextrunatms ?? self.stateMilliseconds("nextRunAtMs")))
self.summaryMetric(
title: String(localized: "Last run"),
value: Self.relativeTime(self.job.lastrunatms ?? self.stateMilliseconds("lastRunAtMs")))
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func summaryMetric(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(OpenClawType.caption2SemiBold)
.foregroundStyle(.secondary)
Text(value)
.font(OpenClawType.subheadSemiBold)
.lineLimit(1)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
private var sectionPicker: some View {
Picker("Automation detail", selection: self.$selectedSection) {
ForEach(AgentAutomationDetailSection.allCases) { section in
Text(section.title)
.font(OpenClawType.captionSemiBold)
.tag(section)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@ViewBuilder
private var settingsContent: some View {
if let draftBinding = Binding(self.$draft) {
Group {
self.identityCard(draftBinding)
self.scheduleCard(draftBinding)
self.actionCard(draftBinding)
self.executionCard(draftBinding)
}
.disabled(self.loading || self.isBusy)
self.managementCard(draftBinding)
.disabled(self.loading || self.isBusy)
} else {
self.messageCard(
icon: "exclamationmark.triangle.fill",
color: .orange,
title: String(localized: "Editing unavailable"),
message: AgentAutomationEditError.invalidModel.localizedDescription)
}
}
private func identityCard(_ draft: Binding<AgentAutomationDraft>) -> some View {
self.cardSection(title: String(localized: "Details"), icon: "text.alignleft") {
self.labeledField(
String(localized: "Name"),
text: draft.name,
prompt: String(localized: "Automation name"))
self.labeledField(
String(localized: "Description"),
text: draft.description,
prompt: String(localized: "Optional context"),
axis: .vertical)
}
}
private func scheduleCard(_ draft: Binding<AgentAutomationDraft>) -> some View {
self.cardSection(title: String(localized: "Schedule"), icon: "calendar.badge.clock") {
Text(draft.wrappedValue.schedule.kindLabel)
.font(OpenClawType.captionSemiBold)
.foregroundStyle(OpenClawBrand.accent)
self.scheduleFields(draft)
if case .at = draft.wrappedValue.schedule {
Toggle(isOn: draft.deleteAfterRun) {
VStack(alignment: .leading, spacing: 2) {
Text("Delete after successful run")
.font(OpenClawType.subheadSemiBold)
Text("Useful for one-time automations.")
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
}
.tint(OpenClawBrand.accent)
}
}
}
@ViewBuilder
private func scheduleFields(_ draft: Binding<AgentAutomationDraft>) -> some View {
switch draft.wrappedValue.schedule {
case .at:
self.labeledField(
String(localized: "ISO date and time"),
text: self.scheduleBinding(draft, keyPath: \.at),
prompt: "2030-01-15T09:00:00Z")
case .every:
self.labeledField(
String(localized: "Interval (milliseconds)"),
text: self.scheduleBinding(draft, keyPath: \.everyMs),
prompt: "86400000",
keyboard: .numberPad)
self.labeledField(
String(localized: "Anchor (milliseconds)"),
text: self.scheduleBinding(draft, keyPath: \.anchorMs),
prompt: String(localized: "Optional"),
keyboard: .numberPad)
case .cron:
self.labeledField(
String(localized: "Cron expression"),
text: self.scheduleBinding(draft, keyPath: \.expression),
prompt: "0 9 * * 1-5")
self.labeledField(
String(localized: "Time zone"),
text: self.scheduleBinding(draft, keyPath: \.timezone),
prompt: "America/Los_Angeles")
self.labeledField(
String(localized: "Stagger (milliseconds)"),
text: self.scheduleBinding(draft, keyPath: \.staggerMs),
prompt: String(localized: "Optional"),
keyboard: .numberPad)
case .onExit:
self.labeledField(
String(localized: "Command to watch"),
text: self.scheduleBinding(draft, keyPath: \.command),
prompt: "pnpm build")
self.labeledField(
String(localized: "Working directory"),
text: self.scheduleBinding(draft, keyPath: \.cwd),
prompt: String(localized: "Optional"))
}
}
private func actionCard(_ draft: Binding<AgentAutomationDraft>) -> some View {
self.cardSection(title: String(localized: "Action"), icon: "bolt.fill") {
Text(draft.wrappedValue.payload.kindLabel)
.font(OpenClawType.captionSemiBold)
.foregroundStyle(OpenClawBrand.accent)
self.payloadFields(draft)
}
}
@ViewBuilder
private func payloadFields(_ draft: Binding<AgentAutomationDraft>) -> some View {
switch draft.wrappedValue.payload {
case .systemEvent:
self.labeledField(
String(localized: "Event text"),
text: self.payloadBinding(draft, keyPath: \.text),
prompt: String(localized: "What should the agent know?"),
axis: .vertical)
case .agentTurn:
self.labeledField(
String(localized: "Message"),
text: self.payloadBinding(draft, keyPath: \.message),
prompt: String(localized: "What should the agent do?"),
axis: .vertical)
self.labeledField(
String(localized: "Model"),
text: self.payloadBinding(draft, keyPath: \.model),
prompt: String(localized: "Gateway default"))
self.labeledField(
String(localized: "Thinking"),
text: self.payloadBinding(draft, keyPath: \.thinking),
prompt: String(localized: "Gateway default"))
case .command:
self.labeledField(
String(localized: "Arguments (JSON array)"),
text: self.payloadBinding(draft, keyPath: \.argvJSON),
prompt: #"["openclaw","status"]"#,
axis: .vertical)
self.labeledField(
String(localized: "Working directory"),
text: self.payloadBinding(draft, keyPath: \.cwd),
prompt: String(localized: "Optional"))
}
}
private func executionCard(_ draft: Binding<AgentAutomationDraft>) -> some View {
self.cardSection(title: String(localized: "Execution"), icon: "scope") {
self.labeledField(
String(localized: "Session target"),
text: draft.sessionTarget,
prompt: "main, isolated, current, or session:<id>")
self.labeledField(
String(localized: "Wake mode"),
text: draft.wakeMode,
prompt: "now or next-heartbeat")
if self.job.delivery != nil || self.job.failurealert != nil {
Text("Delivery and failure routing remain visible and editable in the Control UI.")
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
}
}
private func managementCard(_ draft: Binding<AgentAutomationDraft>) -> some View {
self.cardSection(title: String(localized: "Manage"), icon: "slider.horizontal.3") {
if !self.canAdmin {
Text("Admin scope is required to change, run, or delete automations.")
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
self.actionButton(
title: String(localized: "Save"),
icon: "checkmark.circle.fill",
prominent: true,
enabled: self.canAdmin && self.hasUnsavedChanges && !self.isBusy)
{
Task { await self.save(draft.wrappedValue) }
}
self.actionButton(
title: self.pendingRunID == nil ? String(localized: "Run Now") : String(localized: "Running"),
icon: self.pendingRunID == nil ? "play.fill" : "hourglass",
enabled: self.canAdmin && !self.hasUnsavedChanges && !self.isBusy && self.pendingRunID == nil)
{
Task { await self.runNow() }
}
}
HStack(spacing: 8) {
self.actionButton(
title: self.job.enabled ? String(localized: "Pause") : String(localized: "Enable"),
icon: self.job.enabled ? "pause.fill" : "checkmark",
enabled: self.canAdmin && !self.hasUnsavedChanges && !self.isBusy)
{
Task { await self.setEnabled(!self.job.enabled) }
}
self.actionButton(
title: String(localized: "Delete"),
icon: "trash",
role: .destructive,
enabled: self.canAdmin && !self.isBusy)
{
self.confirmDelete = true
}
}
if let actionName {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text(actionName)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
}
if self.hasUnsavedChanges {
Text("Save or discard edits before running, pausing, or enabling this automation.")
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private var historyContent: some View {
ProSectionHeader(
title: "Recent runs",
actionTitle: self.loading ? "Loading" : "Refresh",
action: self.loading ? nil : { Task { await self.loadHistory() } })
if self.runs.isEmpty {
self.messageCard(
icon: "clock.badge.questionmark",
color: .secondary,
title: String(localized: "No runs yet"),
message: String(localized: "Completed runs and delivery outcomes will appear here."))
} else {
ProCard(padding: 0, radius: OpenClawProMetric.cardRadius) {
VStack(spacing: 0) {
ForEach(Array(self.runs.enumerated()), id: \.offset) { index, run in
self.historyRow(run)
if index < self.runs.count - 1 {
Divider().padding(.leading, 58)
}
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private func cardSection(
title: String,
icon: String,
@ViewBuilder content: () -> some View) -> some View
{
VStack(alignment: .leading, spacing: 8) {
ProSectionHeader(title: .verbatim(title))
ProCard(radius: OpenClawProMetric.cardRadius) {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 9) {
Image(systemName: icon)
.foregroundStyle(OpenClawBrand.accent)
Text(title)
.font(OpenClawType.subheadSemiBold)
}
content()
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
}
private func labeledField(
_ title: String,
text: Binding<String>,
prompt: String,
axis: Axis = .horizontal,
keyboard: UIKeyboardType = .default) -> some View
{
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(OpenClawType.captionSemiBold)
.foregroundStyle(.secondary)
TextField(prompt, text: text, axis: axis)
.font(OpenClawType.body)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(keyboard)
.lineLimit(axis == .vertical ? 2...5 : 1...1)
.padding(.horizontal, 11)
.padding(.vertical, 10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
}
@ViewBuilder
private func actionButton(
title: String,
icon: String,
prominent: Bool = false,
role: ButtonRole? = nil,
enabled: Bool,
action: @escaping () -> Void) -> some View
{
if prominent {
Button(role: role, action: action) {
Label(title, systemImage: icon)
.font(OpenClawType.captionSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(OpenClawBrand.accent)
.disabled(!enabled)
} else {
Button(role: role, action: action) {
Label(title, systemImage: icon)
.font(OpenClawType.captionSemiBold)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(!enabled)
}
}
private func messageCard(icon: String, color: Color, title: String, message: String) -> some View {
ProCard(radius: OpenClawProMetric.cardRadius) {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: icon, color: color)
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(OpenClawType.subheadSemiBold)
Text(message)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
private func noticeCard(_ notice: AgentAutomationNotice) -> some View {
self.messageCard(
icon: notice.tone == .success ? "checkmark.circle.fill" : "exclamationmark.triangle.fill",
color: notice.tone.color,
title: notice.title,
message: notice.message)
}
private var scheduleSummary: String {
guard let schedule = AgentAutomationValue.object(self.job.schedule),
let kind = AgentAutomationValue.string(schedule["kind"])
else { return String(localized: "Schedule configured") }
switch kind {
case "at": return AgentAutomationValue.string(schedule["at"]) ?? String(localized: "One time")
case "every":
return AgentAutomationValue.int(schedule["everyMs"])
.map { String(format: String(localized: "Every %@"), Self.duration($0)) }
?? String(localized: "Interval")
case "cron": return AgentAutomationValue.string(schedule["expr"]).map { "Cron \($0)" } ?? "Cron"
case "on-exit": return String(localized: "After command exits")
default: return kind
}
}
private func stateMilliseconds(_ key: String) -> Int? {
AgentAutomationValue.int(self.job.state[key])
}
private func scheduleBinding(
_ draft: Binding<AgentAutomationDraft>,
keyPath: WritableKeyPath<AgentAutomationScheduleDraft.Fields, String>) -> Binding<String>
{
Binding(
get: { draft.wrappedValue.schedule.fields[keyPath: keyPath] },
set: { value in
var fields = draft.wrappedValue.schedule.fields
fields[keyPath: keyPath] = value
draft.wrappedValue.schedule = fields.schedule(matching: draft.wrappedValue.schedule)
})
}
private func payloadBinding(
_ draft: Binding<AgentAutomationDraft>,
keyPath: WritableKeyPath<AgentAutomationPayloadDraft.Fields, String>) -> Binding<String>
{
Binding(
get: { draft.wrappedValue.payload.fields[keyPath: keyPath] },
set: { value in
var fields = draft.wrappedValue.payload.fields
fields[keyPath: keyPath] = value
draft.wrappedValue.payload = fields.payload(matching: draft.wrappedValue.payload)
})
}
@MainActor
private func reload() async {
guard !self.hasUnsavedChanges || self.draft == nil else {
self.notice = AgentAutomationNotice(
tone: .warning,
title: String(localized: "Unsaved changes"),
message: String(localized: "Save or close this automation before refreshing."))
return
}
let draftAtStart = self.draft
self.loading = true
defer { self.loading = false }
do {
let route = try await self.gatewayRoute()
let data = try await self.request(
method: "cron.get",
paramsJSON: Self.params(["id": self.job.id]),
timeoutSeconds: 20,
route: route)
let updated = try JSONDecoder().decode(CronJob.self, from: data)
guard let updatedDraft = AgentAutomationDraft(job: updated) else {
throw AgentAutomationEditError.invalidModel
}
guard self.draft == draftAtStart else {
self.notice = AgentAutomationNotice(
tone: .warning,
title: String(localized: "Edits preserved"),
message: String(
localized: "Gateway data changed while you edited. Save will validate the current revision."))
await self.loadHistory(route: route)
return
}
self.job = updated
self.draft = updatedDraft
await self.loadHistory(route: route)
} catch {
self.showError(title: String(localized: "Could not load automation"), error: error)
}
}
@MainActor
private func loadHistory() async {
self.loading = true
defer { self.loading = false }
do {
let route = try await self.gatewayRoute()
await self.loadHistory(route: route)
} catch {
self.showError(title: String(localized: "Could not load run history"), error: error)
}
}
@MainActor
private func loadHistory(route: GatewayNodeSessionRoute) async {
do {
let data = try await self.request(
method: "cron.runs",
paramsJSON: Self.params([
"id": self.job.id,
"limit": 25,
"sortDir": "desc",
]),
timeoutSeconds: 20,
route: route)
self.runs = try JSONDecoder().decode(AgentAutomationRunsResponse.self, from: data).entries
} catch {
self.showError(title: String(localized: "Could not load run history"), error: error)
}
}
@MainActor
private func save(_ draft: AgentAutomationDraft) async {
await self.performAction(String(localized: "Saving automation")) {
let route = try await self.gatewayRoute()
let paramsJSON = try buildAgentAutomationUpdateParams(job: self.job, draft: draft)
let data = try await self.request(
method: "cron.update",
paramsJSON: paramsJSON,
timeoutSeconds: 20,
route: route)
try self.applyUpdatedJob(JSONDecoder().decode(CronJob.self, from: data))
self.notice = AgentAutomationNotice(
tone: .success,
title: String(localized: "Automation saved"),
message: String(localized: "The Gateway accepted the latest revision."))
self.onChanged()
}
}
@MainActor
private func setEnabled(_ enabled: Bool) async {
await self
.performAction(enabled ? String(localized: "Enabling automation") :
String(localized: "Pausing automation"))
{
let route = try await self.gatewayRoute()
let paramsJSON = try buildAgentAutomationEnabledParams(job: self.job, enabled: enabled)
let data = try await self.request(
method: "cron.update",
paramsJSON: paramsJSON,
timeoutSeconds: 20,
route: route)
try self.applyUpdatedJob(JSONDecoder().decode(CronJob.self, from: data))
self.notice = AgentAutomationNotice(
tone: .success,
title: enabled ? String(localized: "Automation enabled") :
String(localized: "Automation paused"),
message: enabled
? String(localized: "Future runs are active.")
: String(localized: "Future scheduled runs are paused."))
self.onChanged()
}
}
@MainActor
private func runNow() async {
guard self.pendingRunID == nil else { return }
await self.performAction(String(localized: "Queueing run")) {
let route = try await self.gatewayRoute()
let systemInfoData = try await self.request(
method: "system.info",
paramsJSON: Self.params([:]),
timeoutSeconds: 20,
route: route)
let processInstanceID = try JSONDecoder()
.decode(SystemInfoResult.self, from: systemInfoData)
.processinstanceid
var runParams: [String: Any] = ["id": self.job.id, "mode": "force"]
if let processInstanceID {
runParams["expectedProcessInstanceId"] = processInstanceID
}
let data = try await self.request(
method: "cron.run",
paramsJSON: Self.params(runParams),
timeoutSeconds: 20,
route: route)
let result = try JSONDecoder().decode(AgentAutomationRunResult.self, from: data)
guard result.ok else {
throw AgentAutomationEditError.invalidResponse
}
if result.ran == false, result.enqueued != true {
self.notice = AgentAutomationNotice(
tone: .warning,
title: String(localized: "Run skipped"),
message: Self.runSkipMessage(result.reason))
if agentAutomationRunSkipShouldRefresh(reason: result.reason) {
// Invalid-spec preflight persists diagnostics before returning.
// Refresh every projection so the actionable Gateway error is visible.
await self.reloadAfterRun(route: route)
await self.loadHistory(route: route)
}
return
}
guard result.ran == true || result.enqueued == true else {
throw AgentAutomationEditError.invalidResponse
}
if result.enqueued == true, result.runId == nil {
throw AgentAutomationEditError.invalidResponse
}
self.notice = AgentAutomationNotice(
tone: .success,
title: String(localized: "Run queued"),
message: result.runId == nil
? String(localized: "Refresh History to follow the result.")
: String(localized: "This run stays tracked until its exact result appears."))
self.selectedSection = .history
self.onChanged()
if let runID = result.runId {
self.onRunQueued(runID, result.processInstanceId ?? processInstanceID)
Task { await self.trackRun(runID, route: route) }
} else {
await self.loadHistory(route: route)
}
}
}
@MainActor
private func trackRun(_ runID: String, route: GatewayNodeSessionRoute) async {
for _ in 0..<120 {
guard self.pendingRunID == runID else { return }
do {
let data = try await self.request(
method: "cron.runs",
paramsJSON: Self.params([
"id": self.job.id,
"runId": runID,
"limit": 1,
"sortDir": "desc",
]),
timeoutSeconds: 20,
route: route)
let entries = try JSONDecoder().decode(AgentAutomationRunsResponse.self, from: data).entries
guard self.pendingRunID == runID else { return }
if let entry = entries.first(where: { $0.runid == runID }) {
self.pendingRunRegistry.release(jobID: self.job.id, runID: runID)
await self.loadHistory(route: route)
let outcome = agentAutomationRunOutcome(
status: AgentAutomationValue.string(entry.status),
error: entry.error)
if agentAutomationDeletesAfterSuccessfulRun(job: self.job, outcome: outcome) {
self.onChanged()
self.dismiss()
return
}
self.notice = Self.runNotice(entry)
await self.reloadAfterRun(route: route)
return
}
} catch {
guard await self.appModel.operatorSession.currentRoute() == route else {
return
}
}
try? await Task.sleep(for: .seconds(1))
}
guard self.pendingRunID == runID else { return }
self.notice = AgentAutomationNotice(
tone: .warning,
title: String(localized: "Run still pending"),
message: String(localized: "The Gateway has not reported a result yet. Refresh History later."))
}
private static func runNotice(_ entry: CronRunLogEntry) -> AgentAutomationNotice {
let status = AgentAutomationValue.string(entry.status)
let detail = [entry.error, entry.summary].compactMap { value -> String? in
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}.first
switch agentAutomationRunOutcome(status: status, error: entry.error) {
case .success:
return AgentAutomationNotice(
tone: .success,
title: String(localized: "Run finished"),
message: String(localized: "History now shows the completed result."))
case .skipped:
return AgentAutomationNotice(
tone: .warning,
title: String(localized: "Run skipped"),
message: detail ?? String(localized: "The Gateway skipped this run. History shows the result."))
case .failure:
return AgentAutomationNotice(
tone: .error,
title: String(localized: "Run failed"),
message: detail ?? String(localized: "History shows the failure details."))
case .unknown:
return AgentAutomationNotice(
tone: .warning,
title: String(localized: "Run result available"),
message: detail ?? String(localized: "History shows the Gateway result."))
}
}
@MainActor
private func reloadAfterRun(route: GatewayNodeSessionRoute) async {
defer { self.onChanged() }
// A queued run can finish after the user starts editing. Keep that draft intact;
// the revision-safe save path will report any real configuration conflict.
guard !self.hasUnsavedChanges else { return }
do {
let data = try await self.request(
method: "cron.get",
paramsJSON: Self.params(["id": self.job.id]),
timeoutSeconds: 20,
route: route)
try self.applyUpdatedJob(JSONDecoder().decode(CronJob.self, from: data))
} catch {
// History already contains the authoritative terminal result.
}
}
@MainActor
private func deleteAutomation() async {
await self.performAction(String(localized: "Deleting automation")) {
let route = try await self.gatewayRoute()
_ = try await self.request(
method: "cron.remove",
paramsJSON: Self.params(["id": self.job.id]),
timeoutSeconds: 20,
route: route)
self.onChanged()
self.dismiss()
}
}
@MainActor
private func performAction(_ name: String, action: () async throws -> Void) async {
guard self.canAdmin, self.actionName == nil else { return }
self.actionName = name
self.notice = nil
defer { self.actionName = nil }
do {
try await action()
} catch {
self.showError(title: String(localized: "Automation action failed"), error: error)
}
}
@MainActor
private func applyUpdatedJob(_ updated: CronJob) throws {
guard let updatedDraft = AgentAutomationDraft(job: updated) else {
throw AgentAutomationEditError.invalidModel
}
self.job = updated
self.draft = updatedDraft
}
@MainActor
private func gatewayRoute() async throws -> GatewayNodeSessionRoute {
guard let route = await self.appModel.operatorSession.currentRoute(ifGatewayID: self.sourceGatewayID),
self.appModel.connectedGatewayID == self.sourceGatewayID
else {
throw AgentAutomationEditError.gatewayChanged
}
return route
}
private func request(
method: String,
paramsJSON: String,
timeoutSeconds: Int,
route: GatewayNodeSessionRoute) async throws -> Data
{
let data = try await self.appModel.operatorSession.request(
method: method,
paramsJSON: paramsJSON,
timeoutSeconds: timeoutSeconds,
ifCurrentRoute: route,
distinguishPreDispatchRouteChange: true)
guard await self.appModel.operatorSession.currentRoute() == route else {
throw AgentAutomationEditError.gatewayChangedAfterDispatch
}
return data
}
private static func params(_ 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.invalidResponse
}
return text
}
private static func runSkipMessage(_ reason: String?) -> String {
switch reason {
case "not-due": String(localized: "This automation is not due yet.")
case "already-running": String(localized: "This automation is already running.")
case "restart-recovery-pending": String(localized: "Gateway restart recovery is still in progress.")
case "invalid-spec": String(localized: "This automation has an invalid configuration.")
case "stopped": String(localized: "The automation scheduler is stopped.")
default: String(localized: "The Gateway did not start this automation.")
}
}
private static func relativeTime(_ milliseconds: Int?) -> String {
guard let milliseconds else { return String(localized: "not scheduled") }
return Date(timeIntervalSince1970: Double(milliseconds) / 1000)
.formatted(.relative(presentation: .named, unitsStyle: .abbreviated))
}
private static func duration(_ milliseconds: Int) -> String {
let seconds = max(0, milliseconds / 1000)
if seconds < 60 { return "\(seconds)s" }
let minutes = seconds / 60
if minutes < 60 { return "\(minutes)m" }
let hours = minutes / 60
if hours < 24 { return "\(hours)h" }
return "\(hours / 24)d"
}
}
extension AgentAutomationDetailScreen {
private func historyRow(_ run: CronRunLogEntry) -> some View {
let status = AgentAutomationValue.string(run.status) ?? run.action
let appearance: (icon: String, color: Color, messageColor: Color) = switch agentAutomationRunOutcome(
status: status,
error: run.error)
{
case .success: ("checkmark.circle.fill", OpenClawBrand.accent, .secondary)
case .skipped: ("forward.end.circle.fill", .orange, .orange)
case .failure: ("exclamationmark.triangle.fill", .red, .red)
case .unknown: ("questionmark.circle.fill", .orange, .orange)
}
return HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: appearance.icon, color: appearance.color)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(status.capitalized)
.font(OpenClawType.subheadSemiBold)
Spacer()
Text(Self.relativeTime(run.runatms ?? run.ts))
.font(OpenClawType.caption2)
.foregroundStyle(.secondary)
}
if let message = run.error ?? run.summary {
Text(message)
.font(OpenClawType.caption)
.foregroundStyle(appearance.messageColor)
.lineLimit(3)
}
let details = [
run.durationms.map { Self.duration($0) },
run.model,
AgentAutomationValue.string(run.deliverystatus).map { "Delivery \($0)" },
].compactMap(\.self)
if !details.isEmpty {
Text(details.joined(separator: " · "))
.font(OpenClawType.caption2)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
}
.padding(14)
}
@MainActor
private func initialLoad() async {
await self.reload()
guard let runID = self.pendingRunID else { return }
do {
let route = try await self.gatewayRoute()
await self.trackRun(runID, route: route)
} catch {
self.showError(title: String(localized: "Could not track queued run"), error: error)
}
}
@MainActor
private func showError(title: String, error: Error) {
self.notice = AgentAutomationNotice(
tone: .error,
title: title,
message: error.localizedDescription)
}
}
extension AgentAutomationScheduleDraft {
fileprivate struct Fields {
var at = ""
var everyMs = ""
var anchorMs = ""
var expression = ""
var timezone = ""
var staggerMs = ""
var command = ""
var cwd = ""
func schedule(matching schedule: AgentAutomationScheduleDraft) -> AgentAutomationScheduleDraft {
switch schedule {
case .at: .at(at: self.at)
case .every: .every(everyMs: self.everyMs, anchorMs: self.anchorMs)
case .cron: .cron(expression: self.expression, timezone: self.timezone, staggerMs: self.staggerMs)
case .onExit: .onExit(command: self.command, cwd: self.cwd)
}
}
}
fileprivate var fields: Fields {
switch self {
case let .at(at): Fields(at: at)
case let .every(everyMs, anchorMs): Fields(everyMs: everyMs, anchorMs: anchorMs)
case let .cron(expression, timezone, staggerMs):
Fields(expression: expression, timezone: timezone, staggerMs: staggerMs)
case let .onExit(command, cwd): Fields(command: command, cwd: cwd)
}
}
}
extension AgentAutomationPayloadDraft {
fileprivate struct Fields {
var text = ""
var message = ""
var model = ""
var thinking = ""
var argvJSON = ""
var cwd = ""
func payload(matching payload: AgentAutomationPayloadDraft) -> AgentAutomationPayloadDraft {
switch payload {
case .systemEvent: .systemEvent(text: self.text)
case .agentTurn: .agentTurn(message: self.message, model: self.model, thinking: self.thinking)
case .command: .command(argvJSON: self.argvJSON, cwd: self.cwd)
}
}
}
fileprivate var fields: Fields {
switch self {
case let .systemEvent(text): Fields(text: text)
case let .agentTurn(message, model, thinking):
Fields(message: message, model: model, thinking: thinking)
case let .command(argvJSON, cwd): Fields(argvJSON: argvJSON, cwd: cwd)
}
}
}